diff --git a/custom_components/grocy/__init__.py b/custom_components/grocy/__init__.py index 014eb48..d934882 100644 --- a/custom_components/grocy/__init__.py +++ b/custom_components/grocy/__init__.py @@ -11,10 +11,12 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import async_get_platforms from .const import ( ATTR_BATTERIES, ATTR_CHORES, + ATTR_EQUIPMENT, ATTR_EXPIRED_PRODUCTS, ATTR_EXPIRING_PRODUCTS, ATTR_MEAL_PLAN, @@ -33,6 +35,7 @@ from .coordinator import GrocyDataUpdateCoordinator from .grocy_data import GrocyData, async_setup_endpoint_for_image_proxy from .services import async_setup_services, async_unload_services +from .equipment_sensor import async_setup_equipment_custom_fields _LOGGER = logging.getLogger(__name__) @@ -53,6 +56,20 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry): await async_setup_services(hass, config_entry) await async_setup_endpoint_for_image_proxy(hass, config_entry.data) + # Setup equipment custom field sensors + if ATTR_EQUIPMENT in coordinator.available_entities: + # Force an initial equipment data fetch to avoid "Update failed" errors + try: + equipment_data = await coordinator.grocy_data.async_update_equipment() + coordinator.data[ATTR_EQUIPMENT] = equipment_data + except Exception as error: + _LOGGER.error("Error pre-fetching equipment data: %s", error) + + # Now setup custom field sensors once data is available + for platform in async_get_platforms(hass, config_entry.domain): + if platform.domain == "sensor": + await async_setup_equipment_custom_fields(hass, config_entry, platform.async_add_entities) + return True @@ -97,6 +114,9 @@ async def _async_get_available_entities(grocy_data: GrocyData) -> List[str]: available_entities.append(ATTR_BATTERIES) available_entities.append(ATTR_OVERDUE_BATTERIES) + if "FEATURE_FLAG_EQUIPMENT" in grocy_config.enabled_features: + available_entities.append(ATTR_EQUIPMENT) + _LOGGER.debug("Available entities: %s", available_entities) return available_entities diff --git a/custom_components/grocy/const.py b/custom_components/grocy/const.py index 24824e8..844857f 100644 --- a/custom_components/grocy/const.py +++ b/custom_components/grocy/const.py @@ -33,6 +33,7 @@ PRODUCTS: Final = "Product(s)" TASKS: Final = "Task(s)" ITEMS: Final = "Item(s)" +EQUIPMENT: Final = "Equipment Item(s)" ATTR_BATTERIES: Final = "batteries" ATTR_CHORES: Final = "chores" @@ -47,3 +48,4 @@ ATTR_SHOPPING_LIST: Final = "shopping_list" ATTR_STOCK: Final = "stock" ATTR_TASKS: Final = "tasks" +ATTR_EQUIPMENT: Final = "equipment" diff --git a/custom_components/grocy/equipment_sensor.py b/custom_components/grocy/equipment_sensor.py new file mode 100644 index 0000000..53c650b --- /dev/null +++ b/custom_components/grocy/equipment_sensor.py @@ -0,0 +1,151 @@ +"""Custom field sensor for Grocy equipment.""" +from __future__ import annotations + +import logging +from dataclasses import dataclass +from typing import Any, Callable, Mapping + +from homeassistant.components.sensor import SensorEntity, SensorEntityDescription +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import ATTR_EQUIPMENT, DOMAIN +from .coordinator import GrocyDataUpdateCoordinator +from .entity import GrocyEntity + +_LOGGER = logging.getLogger(__name__) + + +@dataclass +class EquipmentCustomFieldSensorDescription(SensorEntityDescription): + """Sensor entity description for equipment custom fields.""" + + equipment_id: int | None = None + field_name: str | None = None + field_value_fn: Callable[[Any], Any] | None = None + native_unit_of_measurement: str | None = None + icon: str | None = "mdi:tools" + + +async def async_setup_equipment_custom_fields( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +): + """Set up equipment custom field sensors.""" + coordinator: GrocyDataUpdateCoordinator = hass.data[DOMAIN] + + if ATTR_EQUIPMENT not in coordinator.available_entities: + return + + # Get equipment data to discover custom fields + equipment_data = coordinator.data.get(ATTR_EQUIPMENT, []) + if not equipment_data: + try: + # Force an update to get equipment data if not available + _LOGGER.debug("Equipment data not available, fetching directly") + equipment_data = await hass.async_add_executor_job( + coordinator.grocy_data.api.equipment, None, True + ) + + # Store the data in coordinator for future use + coordinator.data[ATTR_EQUIPMENT] = equipment_data + except Exception as error: + _LOGGER.error("Error fetching equipment data: %s", error) + return + + entities = [] + + for equipment_item in equipment_data: + if not hasattr(equipment_item, "userfields") or not equipment_item.userfields: + continue + + for field_name, field_value in equipment_item.userfields.items(): + # Skip empty fields + if field_value is None or field_value == "": + continue + + # Determine field type and icon + icon = "mdi:tools" + native_unit = None + + # Try to infer field type and unit + if isinstance(field_value, bool): + icon = "mdi:checkbox-marked" if field_value else "mdi:checkbox-blank-outline" + elif isinstance(field_value, (int, float)): + icon = "mdi:numeric" + elif isinstance(field_value, str): + if "temperature" in field_name.lower(): + icon = "mdi:thermometer" + native_unit = "°C" + elif "voltage" in field_name.lower() or "volt" in field_name.lower(): + icon = "mdi:flash" + native_unit = "V" + elif "power" in field_name.lower() or "watt" in field_name.lower(): + icon = "mdi:power-plug" + native_unit = "W" + elif "weight" in field_name.lower(): + icon = "mdi:weight" + native_unit = "kg" + elif "length" in field_name.lower() or "width" in field_name.lower() or "height" in field_name.lower(): + icon = "mdi:ruler" + native_unit = "m" + elif "date" in field_name.lower(): + icon = "mdi:calendar" + + description = EquipmentCustomFieldSensorDescription( + key=f"equipment_{equipment_item.id}_{field_name}", + name=f"{equipment_item.name} {field_name}", + equipment_id=equipment_item.id, + field_name=field_name, + field_value_fn=lambda data, e_id=equipment_item.id, f_name=field_name: _get_field_value(data, e_id, f_name), + native_unit_of_measurement=native_unit, + icon=icon, + ) + + entity = EquipmentCustomFieldSensor(coordinator, description, config_entry) + coordinator.entities.append(entity) + entities.append(entity) + + await async_add_entities(entities, True) + + +def _get_field_value(data, equipment_id, field_name): + """Get the value of a specific field for an equipment item.""" + for equipment in data: + if equipment.id == equipment_id: + if hasattr(equipment, "userfields") and equipment.userfields: + return equipment.userfields.get(field_name) + return None + + +class EquipmentCustomFieldSensor(GrocyEntity, SensorEntity): + """Sensor for equipment custom fields.""" + + entity_description: EquipmentCustomFieldSensorDescription + + @property + def native_value(self): + """Return the value of the custom field.""" + entity_data = self.coordinator.data.get(ATTR_EQUIPMENT, []) + if entity_data and self.entity_description.field_value_fn: + return self.entity_description.field_value_fn(entity_data) + return None + + @property + def extra_state_attributes(self) -> Mapping[str, Any] | None: + """Return additional attributes for the sensor.""" + entity_data = self.coordinator.data.get(ATTR_EQUIPMENT, []) + if not entity_data: + return None + + for equipment in entity_data: + if equipment.id == self.entity_description.equipment_id: + return { + "equipment_name": equipment.name, + "equipment_id": equipment.id, + "field_name": self.entity_description.field_name, + } + + return None diff --git a/custom_components/grocy/grocy_data.py b/custom_components/grocy/grocy_data.py index cd987a9..2ab44fb 100644 --- a/custom_components/grocy/grocy_data.py +++ b/custom_components/grocy/grocy_data.py @@ -15,6 +15,7 @@ from .const import ( ATTR_BATTERIES, ATTR_CHORES, + ATTR_EQUIPMENT, ATTR_EXPIRED_PRODUCTS, ATTR_EXPIRING_PRODUCTS, ATTR_MEAL_PLAN, @@ -56,6 +57,7 @@ def __init__(self, hass, api): ATTR_OVERDUE_TASKS: self.async_update_overdue_tasks, ATTR_BATTERIES: self.async_update_batteries, ATTR_OVERDUE_BATTERIES: self.async_update_overdue_batteries, + ATTR_EQUIPMENT: self.async_update_equipment, } async def async_update_data(self, entity_key): @@ -183,6 +185,14 @@ def wrapper(): return await self.hass.async_add_executor_job(wrapper) + async def async_update_equipment(self): + """Update equipment data.""" + + def wrapper(): + return self.api.equipment(get_details=True) + + return await self.hass.async_add_executor_job(wrapper) + async def async_setup_endpoint_for_image_proxy( hass: HomeAssistant, config_entry: ConfigEntry diff --git a/custom_components/grocy/sensor.py b/custom_components/grocy/sensor.py index de041fb..5a54444 100644 --- a/custom_components/grocy/sensor.py +++ b/custom_components/grocy/sensor.py @@ -19,12 +19,14 @@ from .const import ( ATTR_BATTERIES, ATTR_CHORES, + ATTR_EQUIPMENT, ATTR_MEAL_PLAN, ATTR_SHOPPING_LIST, ATTR_STOCK, ATTR_TASKS, CHORES, DOMAIN, + EQUIPMENT, ITEMS, MEAL_PLANS, PRODUCTS, @@ -140,6 +142,18 @@ class GrocySensorEntityDescription(SensorEntityDescription): "count": len(data), }, ), + GrocySensorEntityDescription( + key=ATTR_EQUIPMENT, + name="Grocy equipment", + native_unit_of_measurement=EQUIPMENT, + state_class=SensorStateClass.MEASUREMENT, + icon="mdi:tools", + exists_fn=lambda entities: ATTR_EQUIPMENT in entities, + attributes_fn=lambda data: { + "equipment": [x.as_dict() for x in data], + "count": len(data), + }, + ), )