Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions custom_components/grocy/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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__)

Expand All @@ -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


Expand Down Expand Up @@ -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
2 changes: 2 additions & 0 deletions custom_components/grocy/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -47,3 +48,4 @@
ATTR_SHOPPING_LIST: Final = "shopping_list"
ATTR_STOCK: Final = "stock"
ATTR_TASKS: Final = "tasks"
ATTR_EQUIPMENT: Final = "equipment"
151 changes: 151 additions & 0 deletions custom_components/grocy/equipment_sensor.py
Original file line number Diff line number Diff line change
@@ -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
10 changes: 10 additions & 0 deletions custom_components/grocy/grocy_data.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
from .const import (
ATTR_BATTERIES,
ATTR_CHORES,
ATTR_EQUIPMENT,
ATTR_EXPIRED_PRODUCTS,
ATTR_EXPIRING_PRODUCTS,
ATTR_MEAL_PLAN,
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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
Expand Down
14 changes: 14 additions & 0 deletions custom_components/grocy/sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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),
},
),
)


Expand Down