diff --git a/.devcontainer/configuration.yaml b/.devcontainer/configuration.yaml index 64308fe..1ed0e5b 100644 --- a/.devcontainer/configuration.yaml +++ b/.devcontainer/configuration.yaml @@ -4,4 +4,4 @@ logger: default: info logs: custom_components.grocy: debug - + pygrocy.grocy_api_client: debug diff --git a/custom_components/grocy/__init__.py b/custom_components/grocy/__init__.py index 2f16a70..2438559 100644 --- a/custom_components/grocy/__init__.py +++ b/custom_components/grocy/__init__.py @@ -4,151 +4,104 @@ For more details about this integration, please refer to https://github.com/custom-components/grocy """ +from __future__ import annotations + import logging -from datetime import timedelta from typing import Any, List from homeassistant.config_entries import ConfigEntry -from homeassistant.core import Config, HomeAssistant -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from pygrocy import Grocy - -from .helpers import extract_base_url_and_path +from homeassistant.core import HomeAssistant from .const import ( - CONF_API_KEY, - CONF_PORT, - CONF_URL, - CONF_VERIFY_SSL, + ATTR_CHORES, + ATTR_EXPIRED_PRODUCTS, + ATTR_EXPIRING_PRODUCTS, + ATTR_MEAL_PLAN, + ATTR_MISSING_PRODUCTS, + ATTR_OVERDUE_CHORES, + ATTR_OVERDUE_TASKS, + ATTR_SHOPPING_LIST, + ATTR_STOCK, + ATTR_TASKS, DOMAIN, - GrocyEntityType, PLATFORMS, STARTUP_MESSAGE, ) -from .grocy_data import GrocyData, async_setup_image_api +from .coordinator import GrocyDataUpdateCoordinator +from .grocy_data import GrocyData, async_setup_endpoint_for_image_proxy from .services import async_setup_services, async_unload_services -SCAN_INTERVAL = timedelta(seconds=30) - _LOGGER = logging.getLogger(__name__) async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry): """Set up this integration using UI.""" - hass.data.setdefault(DOMAIN, {}) _LOGGER.info(STARTUP_MESSAGE) - coordinator = GrocyDataUpdateCoordinator( - hass, - config_entry.data[CONF_URL], - config_entry.data[CONF_API_KEY], - config_entry.data[CONF_PORT], - config_entry.data[CONF_VERIFY_SSL], + coordinator: GrocyDataUpdateCoordinator = GrocyDataUpdateCoordinator(hass) + coordinator.available_entities = await _async_get_available_entities( + coordinator.grocy_data ) - await coordinator.async_config_entry_first_refresh() - + hass.data.setdefault(DOMAIN, {}) hass.data[DOMAIN] = coordinator - for platform in PLATFORMS: - hass.async_add_job( - hass.config_entries.async_forward_entry_setup(config_entry, platform) - ) - + hass.config_entries.async_setup_platforms(config_entry, PLATFORMS) await async_setup_services(hass, config_entry) - - # Setup http endpoint for proxying images from grocy - await async_setup_image_api(hass, config_entry.data) + await async_setup_endpoint_for_image_proxy(hass, config_entry.data) return True -class GrocyDataUpdateCoordinator(DataUpdateCoordinator): - """Class to manage fetching data from the API.""" - - def __init__(self, hass, url, api_key, port_number, verify_ssl): - """Initialize.""" - super().__init__(hass, _LOGGER, name=DOMAIN, update_interval=SCAN_INTERVAL) - (base_url, path) = extract_base_url_and_path(url) - self.api = Grocy( - base_url, api_key, path=path, port=port_number, verify_ssl=verify_ssl - ) - self.entities = [] - self.data = {} - - async def _async_update_data(self): - """Update data via library.""" - grocy_data = GrocyData(self.hass, self.api) - data = {} - features = await async_supported_features(grocy_data) - if not features: - raise UpdateFailed("No features enabled") - - for entity in self.entities: - if not entity.enabled: - continue - if not entity.entity_type in features: - _LOGGER.debug( - "You have enabled the entity for '%s', but this feature is not enabled in Grocy", - entity.name, - ) - continue - - try: - data[entity.entity_type] = await grocy_data.async_update_data( - entity.entity_type - ) - except Exception as exception: # pylint: disable=broad-except - _LOGGER.error( - "Update of %s failed with %s", - entity.entity_type, - exception, - ) - return data - - -async def async_supported_features(grocy_data: GrocyData) -> List[str]: - """Return a list of supported features.""" - features = [] - config = await grocy_data.async_get_config() - if config: - if is_enabled_grocy_feature(config, "FEATURE_FLAG_STOCK"): - features.append(GrocyEntityType.STOCK) - features.append(GrocyEntityType.PRODUCTS) - features.append(GrocyEntityType.MISSING_PRODUCTS) - features.append(GrocyEntityType.EXPIRED_PRODUCTS) - features.append(GrocyEntityType.EXPIRING_PRODUCTS) - - if is_enabled_grocy_feature(config, "FEATURE_FLAG_SHOPPINGLIST"): - features.append(GrocyEntityType.SHOPPING_LIST) - - if is_enabled_grocy_feature(config, "FEATURE_FLAG_TASKS"): - features.append(GrocyEntityType.TASKS) - features.append(GrocyEntityType.OVERDUE_TASKS) - - if is_enabled_grocy_feature(config, "FEATURE_FLAG_CHORES"): - features.append(GrocyEntityType.CHORES) - features.append(GrocyEntityType.OVERDUE_CHORES) - - if is_enabled_grocy_feature(config, "FEATURE_FLAG_RECIPES"): - features.append(GrocyEntityType.MEAL_PLAN) - - return features - - -def is_enabled_grocy_feature(grocy_config: Any, feature_setting_key: str) -> bool: +async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: + """Unload a config entry.""" + await async_unload_services(hass) + if unloaded := await hass.config_entries.async_unload_platforms( + config_entry, PLATFORMS + ): + del hass.data[DOMAIN] + + return unloaded + + +async def _async_get_available_entities(grocy_data: GrocyData) -> List[str]: + """Return a list of available entities based on enabled Grocy features.""" + available_entities = [] + grocy_config = await grocy_data.async_get_config() + if grocy_config: + if _is_enabled_grocy_feature(grocy_config, "FEATURE_FLAG_STOCK"): + available_entities.append(ATTR_STOCK) + available_entities.append(ATTR_MISSING_PRODUCTS) + available_entities.append(ATTR_EXPIRED_PRODUCTS) + available_entities.append(ATTR_EXPIRING_PRODUCTS) + + if _is_enabled_grocy_feature(grocy_config, "FEATURE_FLAG_SHOPPINGLIST"): + available_entities.append(ATTR_SHOPPING_LIST) + + if _is_enabled_grocy_feature(grocy_config, "FEATURE_FLAG_TASKS"): + available_entities.append(ATTR_TASKS) + available_entities.append(ATTR_OVERDUE_TASKS) + + if _is_enabled_grocy_feature(grocy_config, "FEATURE_FLAG_CHORES"): + available_entities.append(ATTR_CHORES) + available_entities.append(ATTR_OVERDUE_CHORES) + + if _is_enabled_grocy_feature(grocy_config, "FEATURE_FLAG_RECIPES"): + available_entities.append(ATTR_MEAL_PLAN) + + _LOGGER.debug("Available entities: %s", available_entities) + + return available_entities + + +def _is_enabled_grocy_feature(grocy_config: Any, feature_setting_key: str) -> bool: """ Return whether the Grocy feature is enabled or not, default is enabled. Setting value received from Grocy can be a str or bool. """ feature_setting_value = grocy_config[feature_setting_key] - return feature_setting_value not in (False, "0") - - -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: - """Unload a config entry.""" - await async_unload_services(hass) - if unloaded := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - del hass.data[DOMAIN] + _LOGGER.debug( + "Grocy feature '%s' has value '%s'.", feature_setting_key, feature_setting_value + ) - return unloaded + return feature_setting_value not in (False, "0") diff --git a/custom_components/grocy/binary_sensor.py b/custom_components/grocy/binary_sensor.py index 46527f8..74f8b5b 100644 --- a/custom_components/grocy/binary_sensor.py +++ b/custom_components/grocy/binary_sensor.py @@ -1,45 +1,124 @@ """Binary sensor platform for Grocy.""" +from __future__ import annotations + import logging -from homeassistant.components.binary_sensor import BinarySensorEntity +from collections.abc import Callable, Mapping +from dataclasses import dataclass +from typing import Any, List + +from homeassistant.components.binary_sensor import ( + BinarySensorEntity, + BinarySensorEntityDescription, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback -# pylint: disable=relative-beyond-top-level from .const import ( + ATTR_EXPIRED_PRODUCTS, + ATTR_EXPIRING_PRODUCTS, + ATTR_MISSING_PRODUCTS, + ATTR_OVERDUE_CHORES, + ATTR_OVERDUE_TASKS, DOMAIN, - GrocyEntityType, ) +from .coordinator import GrocyDataUpdateCoordinator from .entity import GrocyEntity _LOGGER = logging.getLogger(__name__) -BINARY_SENSOR_TYPES = [ - GrocyEntityType.EXPIRED_PRODUCTS, - GrocyEntityType.EXPIRING_PRODUCTS, - GrocyEntityType.MISSING_PRODUCTS, - GrocyEntityType.OVERDUE_CHORES, - GrocyEntityType.OVERDUE_TASKS, -] -async def async_setup_entry(hass, entry, async_add_entities): - """Setup binary_sensor platform.""" - coordinator = hass.data[DOMAIN] - +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +): + """Setup binary sensor platform.""" + coordinator: GrocyDataUpdateCoordinator = hass.data[DOMAIN] entities = [] - for binary_sensor in BINARY_SENSOR_TYPES: - _LOGGER.debug("Adding %s binary sensor", binary_sensor) - entity = GrocyBinarySensor(coordinator, entry, binary_sensor) - coordinator.entities.append(entity) - entities.append(entity) + for description in BINARY_SENSORS: + if description.exists_fn(coordinator.available_entities): + entity = GrocyBinarySensorEntity(coordinator, description, config_entry) + coordinator.entities.append(entity) + entities.append(entity) + else: + _LOGGER.debug( + "Entity description '%s' is not available.", + description.key, + ) async_add_entities(entities, True) -class GrocyBinarySensor(GrocyEntity, BinarySensorEntity): - """Grocy binary_sensor class.""" +@dataclass +class GrocyBinarySensorEntityDescription(BinarySensorEntityDescription): + """Grocy binary sensor entity description.""" + + attributes_fn: Callable[[List[Any]], Mapping[str, Any] | None] = lambda _: None + exists_fn: Callable[[List[str]], bool] = lambda _: True + entity_registry_enabled_default: bool = False + + +BINARY_SENSORS: tuple[GrocyBinarySensorEntityDescription, ...] = ( + GrocyBinarySensorEntityDescription( + key=ATTR_EXPIRED_PRODUCTS, + name="Grocy expired products", + icon="mdi:delete-alert-outline", + exists_fn=lambda entities: ATTR_EXPIRED_PRODUCTS in entities, + attributes_fn=lambda data: { + "expired_products": [x.as_dict() for x in data], + "count": len(data), + }, + ), + GrocyBinarySensorEntityDescription( + key=ATTR_EXPIRING_PRODUCTS, + name="Grocy expiring products", + icon="mdi:clock-fast", + exists_fn=lambda entities: ATTR_EXPIRING_PRODUCTS in entities, + attributes_fn=lambda data: { + "expiring_products": [x.as_dict() for x in data], + "count": len(data), + }, + ), + GrocyBinarySensorEntityDescription( + key=ATTR_MISSING_PRODUCTS, + name="Grocy missing products", + icon="mdi:flask-round-bottom-empty-outline", + exists_fn=lambda entities: ATTR_MISSING_PRODUCTS in entities, + attributes_fn=lambda data: { + "missing_products": [x.as_dict() for x in data], + "count": len(data), + }, + ), + GrocyBinarySensorEntityDescription( + key=ATTR_OVERDUE_CHORES, + name="Grocy overdue chores", + icon="mdi:alert-circle-check-outline", + exists_fn=lambda entities: ATTR_OVERDUE_CHORES in entities, + attributes_fn=lambda data: { + "overdue_chores": [x.as_dict() for x in data], + "count": len(data), + }, + ), + GrocyBinarySensorEntityDescription( + key=ATTR_OVERDUE_TASKS, + name="Grocy overdue tasks", + icon="mdi:alert-circle-check-outline", + exists_fn=lambda entities: ATTR_OVERDUE_TASKS in entities, + attributes_fn=lambda data: { + "overdue_tasks": [x.as_dict() for x in data], + "count": len(data), + }, + ), +) + + +class GrocyBinarySensorEntity(GrocyEntity, BinarySensorEntity): + """Grocy binary sensor entity definition.""" @property - def is_on(self): - """Return true if the binary_sensor is on.""" - if not self.entity_data: - return + def is_on(self) -> bool | None: + """Return true if the binary sensor is on.""" + entity_data = self.coordinator.data.get(self.entity_description.key, None) - return len(self.entity_data) > 0 + return len(entity_data) > 0 if entity_data else False diff --git a/custom_components/grocy/breaking_changes b/custom_components/grocy/breaking_changes deleted file mode 100644 index 7b6d274..0000000 --- a/custom_components/grocy/breaking_changes +++ /dev/null @@ -1,5 +0,0 @@ -services: - add_project -> add_product_to_stock - price -> str (was int) - - consume_product -> consume_product_from_stock \ No newline at end of file diff --git a/custom_components/grocy/config_flow.py b/custom_components/grocy/config_flow.py index f0ad799..b266393 100644 --- a/custom_components/grocy/config_flow.py +++ b/custom_components/grocy/config_flow.py @@ -6,17 +6,16 @@ from homeassistant import config_entries from pygrocy import Grocy -from .helpers import extract_base_url_and_path - from .const import ( CONF_API_KEY, - CONF_PORT, # pylint: disable=unused-import + CONF_PORT, CONF_URL, CONF_VERIFY_SSL, DEFAULT_PORT, DOMAIN, NAME, ) +from .helpers import extract_base_url_and_path _LOGGER = logging.getLogger(__name__) @@ -36,7 +35,6 @@ async def async_step_user(self, user_input=None): self._errors = {} _LOGGER.debug("Step user") - # Uncomment the next 2 lines if only a single instance of the integration is allowed: if self._async_current_entries(): return self.async_abort(reason="single_instance_allowed") @@ -51,8 +49,8 @@ async def async_step_user(self, user_input=None): _LOGGER.debug(valid) if valid: return self.async_create_entry(title=NAME, data=user_input) - else: - self._errors["base"] = "auth" + + self._errors["base"] = "auth" return await self._show_config_form(user_input) return await self._show_config_form(user_input) @@ -89,11 +87,12 @@ async def _test_credentials(self, url, api_key, port, verify_ssl): def system_info(): """Get system information from Grocy.""" - client._api_client._do_get_request("system/info") + client._api_client._do_get_request( + "system/info" + ) # TODO Make endpoint available in pygrocy await self.hass.async_add_executor_job(system_info) return True - except Exception as e: # pylint: disable=broad-except - _LOGGER.error(e) - pass + except Exception as error: # pylint: disable=broad-except + _LOGGER.error(error) return False diff --git a/custom_components/grocy/const.py b/custom_components/grocy/const.py index 5c50b00..77c1276 100644 --- a/custom_components/grocy/const.py +++ b/custom_components/grocy/const.py @@ -1,27 +1,24 @@ """Constants for Grocy.""" -from enum import Enum +from datetime import timedelta +from typing import Final -# Base component constants -NAME = "Grocy" -DOMAIN = "grocy" +NAME: Final = "Grocy" +DOMAIN: Final = "grocy" VERSION = "0.0.0" -ISSUE_URL = "https://github.com/custom-components/grocy/issues" +ISSUE_URL: Final = "https://github.com/custom-components/grocy/issues" +PLATFORMS: Final = ["binary_sensor", "sensor"] -# Platforms -PLATFORMS = ["binary_sensor", "sensor"] +SCAN_INTERVAL = timedelta(seconds=30) -# Configuration and options -CONF_NAME = "name" +DEFAULT_PORT: Final = 9192 +CONF_URL: Final = "url" +CONF_PORT: Final = "port" +CONF_API_KEY: Final = "api_key" +CONF_VERIFY_SSL: Final = "verify_ssl" -DEFAULT_PORT = 9192 -CONF_URL = "url" -CONF_PORT = "port" -CONF_API_KEY = "api_key" -CONF_VERIFY_SSL = "verify_ssl" - -STARTUP_MESSAGE = f""" +STARTUP_MESSAGE: Final = f""" ------------------------------------------------------------------- {NAME} Version: {VERSION} @@ -31,45 +28,18 @@ ------------------------------------------------------------------- """ - -class GrocyEntityType(str, Enum): - """Entity type for Grocy entities.""" - - CHORES = "Chores" - EXPIRED_PRODUCTS = "Expired_products" - EXPIRING_PRODUCTS = "Expiring_products" - MEAL_PLAN = "Meal_plan" - MISSING_PRODUCTS = "Missing_products" - OVERDUE_CHORES = "Overdue_chores" - OVERDUE_TASKS = "Overdue_tasks" - PRODUCTS = "Products" - SHOPPING_LIST = "Shopping_list" - STOCK = "Stock" - TASKS = "Tasks" - - -class GrocyEntityUnit(str, Enum): - """Unit of measurement for Grocy entities.""" - - CHORES = "Chore(s)" - MEAL_PLAN = "Meal(s)" - PRODUCTS = "Product(s)" - TASKS = "Task(s)" - - -class GrocyEntityIcon(str, Enum): - """Icon for a Grocy entity.""" - - DEFAULT = "mdi:format-quote-close" - - CHORES = "mdi:broom" - EXPIRED_PRODUCTS = "mdi:delete-alert-outline" - EXPIRING_PRODUCTS = "mdi:clock-fast" - MEAL_PLAN = "mdi:silverware-variant" - MISSING_PRODUCTS = "mdi:flask-round-bottom-empty-outline" - OVERDUE_CHORES = "mdi:alert-circle-check-outline" - OVERDUE_TASKS = "mdi:alert-circle-check-outline" - PRODUCTS = "mdi:food-fork-drink" - SHOPPING_LIST = "mdi:cart-outline" - STOCK = "mdi:fridge-outline" - TASKS = "mdi:checkbox-marked-circle-outline" +CHORES: Final = "Chore(s)" +MEAL_PLANS: Final = "Meal(s)" +PRODUCTS: Final = "Product(s)" +TASKS: Final = "Task(s)" + +ATTR_CHORES: Final = "chores" +ATTR_EXPIRED_PRODUCTS: Final = "expired_products" +ATTR_EXPIRING_PRODUCTS: Final = "expiring_products" +ATTR_MEAL_PLAN: Final = "meal_plan" +ATTR_MISSING_PRODUCTS: Final = "missing_products" +ATTR_OVERDUE_CHORES: Final = "overdue_chores" +ATTR_OVERDUE_TASKS: Final = "overdue_tasks" +ATTR_SHOPPING_LIST: Final = "shopping_list" +ATTR_STOCK: Final = "stock" +ATTR_TASKS: Final = "tasks" diff --git a/custom_components/grocy/coordinator.py b/custom_components/grocy/coordinator.py new file mode 100644 index 0000000..4aac982 --- /dev/null +++ b/custom_components/grocy/coordinator.py @@ -0,0 +1,74 @@ +"""Data update coordinator for Grocy.""" +from __future__ import annotations + +import logging +from typing import Any, Dict, List + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +from pygrocy import Grocy + +from .const import ( + CONF_API_KEY, + CONF_PORT, + CONF_URL, + CONF_VERIFY_SSL, + DOMAIN, + SCAN_INTERVAL, +) +from .grocy_data import GrocyData +from .helpers import extract_base_url_and_path + +_LOGGER = logging.getLogger(__name__) + + +class GrocyDataUpdateCoordinator(DataUpdateCoordinator[Dict[str, Any]]): + """Grocy data update coordinator.""" + + def __init__( + self, + hass: HomeAssistant, + ) -> None: + """Initialize Grocy data update coordinator.""" + super().__init__( + hass, + _LOGGER, + name=DOMAIN, + update_interval=SCAN_INTERVAL, + ) + + url = self.config_entry.data[CONF_URL] + api_key = self.config_entry.data[CONF_API_KEY] + port = self.config_entry.data[CONF_PORT] + verify_ssl = self.config_entry.data[CONF_VERIFY_SSL] + + (base_url, path) = extract_base_url_and_path(url) + + self.grocy_api = Grocy( + base_url, api_key, path=path, port=port, verify_ssl=verify_ssl + ) + self.grocy_data = GrocyData(hass, self.grocy_api) + + self.available_entities: List[str] = [] + self.entities: List[Entity] = [] + + async def _async_update_data(self) -> dict[str, Any]: + """Fetch data.""" + data: dict[str, Any] = {} + + for entity in self.entities: + if not entity.enabled: + _LOGGER.debug("Entity %s is disabled.", entity.entity_id) + continue + + try: + data[ + entity.entity_description.key + ] = await self.grocy_data.async_update_data( + entity.entity_description.key + ) + except Exception as error: # pylint: disable=broad-except + raise UpdateFailed(f"Update failed: {error}") from error + + return data diff --git a/custom_components/grocy/entity.py b/custom_components/grocy/entity.py index f5c0fe0..0eed44e 100644 --- a/custom_components/grocy/entity.py +++ b/custom_components/grocy/entity.py @@ -1,108 +1,56 @@ -"""GrocyEntity class""" +"""Entity for Grocy.""" +from __future__ import annotations + import json +from collections.abc import Mapping +from typing import Any +from homeassistant.config_entries import ConfigEntry from homeassistant.helpers.device_registry import DeviceEntryType +from homeassistant.helpers.entity import DeviceInfo, EntityDescription from homeassistant.helpers.update_coordinator import CoordinatorEntity -# pylint: disable=relative-beyond-top-level -from .const import ( - DOMAIN, - GrocyEntityIcon, - GrocyEntityType, - GrocyEntityUnit, - NAME, - VERSION, -) -from .json_encode import GrocyJSONEncoder +from .const import DOMAIN, NAME, VERSION +from .coordinator import GrocyDataUpdateCoordinator +from .json_encoder import CustomJSONEncoder -class GrocyEntity(CoordinatorEntity): - """Base class for Grocy entities.""" +class GrocyEntity(CoordinatorEntity[GrocyDataUpdateCoordinator]): + """Grocy base entity definition.""" - def __init__(self, coordinator, config_entry, entity_type): - """Initialize generic Grocy entity.""" + def __init__( + self, + coordinator: GrocyDataUpdateCoordinator, + description: EntityDescription, + config_entry: ConfigEntry, + ) -> None: + """Initialize entity.""" super().__init__(coordinator) - self.config_entry = config_entry - self.entity_type = entity_type - - @property - def unique_id(self): - """Return a unique ID to use for this entity.""" - return f"{self.config_entry.entry_id}{self.entity_type.lower()}" - - @property - def name(self): - """Return the name of the binary_sensor.""" - return f"{NAME} {self.entity_type.lower().replace('_', ' ')}" - - @property - def entity_registry_enabled_default(self) -> bool: - """Return if the entity should be enabled when first added to the entity registry.""" - return False - - @property - def entity_data(self): - """Return the entity_data of the entity.""" - if self.coordinator.data is None: - return None - return self.coordinator.data.get(self.entity_type) - - @property - def unit_of_measurement(self): - """Return the unit the value is expressed in.""" - if GrocyEntityType(self.entity_type).name in [x.name for x in GrocyEntityUnit]: - return GrocyEntityUnit[GrocyEntityType(self.entity_type).name] - - @property - def icon(self): - """Return the icon of the entity.""" - if GrocyEntityType(self.entity_type).name in [x.name for x in GrocyEntityIcon]: - return GrocyEntityIcon[GrocyEntityType(self.entity_type).name] - - return GrocyEntityIcon.DEFAULT - - @property - def device_info(self): - return { - "identifiers": {(DOMAIN, self.config_entry.entry_id)}, - "name": NAME, - "model": VERSION, - "manufacturer": NAME, - "entry_type": DeviceEntryType.SERVICE, - } - - @property - def extra_state_attributes(self): - """Return the state attributes.""" - if not self.entity_data: - return - - data = {} - - if self.entity_type == GrocyEntityType.CHORES: - data = {"chores": [x.as_dict() for x in self.entity_data]} - elif self.entity_type == GrocyEntityType.EXPIRED_PRODUCTS: - data = {"expired": [x.as_dict() for x in self.entity_data]} - elif self.entity_type == GrocyEntityType.EXPIRING_PRODUCTS: - data = {"expiring": [x.as_dict() for x in self.entity_data]} - elif self.entity_type == GrocyEntityType.MEAL_PLAN: - data = {"meals": [x.as_dict() for x in self.entity_data]} - elif self.entity_type == GrocyEntityType.MISSING_PRODUCTS: - data = {"missing": [x.as_dict() for x in self.entity_data]} - elif self.entity_type == GrocyEntityType.OVERDUE_CHORES: - data = {"chores": [x.as_dict() for x in self.entity_data]} - elif self.entity_type == GrocyEntityType.OVERDUE_TASKS: - data = {"tasks": [x.as_dict() for x in self.entity_data]} - elif self.entity_type == GrocyEntityType.PRODUCTS: - data = {"products": [x.as_dict() for x in self.entity_data]} - elif self.entity_type == GrocyEntityType.SHOPPING_LIST: - data = {"products": [x.as_dict() for x in self.entity_data]} - elif self.entity_type == GrocyEntityType.STOCK: - data = {"products": [x.as_dict() for x in self.entity_data]} - elif self.entity_type == GrocyEntityType.TASKS: - data = {"tasks": [x.as_dict() for x in self.entity_data]} - - if data: - data["count"] = sum(len(entry) for entry in data.values()) - - return json.loads(json.dumps(data, cls=GrocyJSONEncoder)) + self._attr_name = description.name + self._attr_unique_id = f"{config_entry.entry_id}{description.key.lower()}" + self.entity_description = description + + @property + def device_info(self) -> DeviceInfo: + """Grocy device information.""" + return DeviceInfo( + identifiers={(DOMAIN, self.coordinator.config_entry.entry_id)}, + name=NAME, + manufacturer=NAME, + software_version=VERSION, + entry_type=DeviceEntryType.SERVICE, + ) + + @property + def extra_state_attributes(self) -> Mapping[str, Any] | None: + """Return the extra state attributes.""" + data = self.coordinator.data.get(self.entity_description.key) + if data and hasattr(self.entity_description, "attributes_fn"): + return json.loads( + json.dumps( + self.entity_description.attributes_fn(data), + cls=CustomJSONEncoder, + ) + ) + + return None diff --git a/custom_components/grocy/grocy_data.py b/custom_components/grocy/grocy_data.py index de274a6..64c3292 100644 --- a/custom_components/grocy/grocy_data.py +++ b/custom_components/grocy/grocy_data.py @@ -1,15 +1,27 @@ -from aiohttp import hdrs, web -from datetime import datetime +"""Communication with Grocy API.""" import logging +from datetime import datetime +from aiohttp import hdrs, web from homeassistant.components.http import HomeAssistantView +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import ( + ATTR_CHORES, + ATTR_EXPIRED_PRODUCTS, + ATTR_EXPIRING_PRODUCTS, + ATTR_MEAL_PLAN, + ATTR_MISSING_PRODUCTS, + ATTR_OVERDUE_CHORES, + ATTR_OVERDUE_TASKS, + ATTR_SHOPPING_LIST, + ATTR_STOCK, + ATTR_TASKS, CONF_API_KEY, - CONF_URL, CONF_PORT, - GrocyEntityType, + CONF_URL, ) from .helpers import MealPlanItem, extract_base_url_and_path @@ -17,67 +29,47 @@ class GrocyData: - """This class handle communication and stores the data.""" + """Handles communication and gets the data.""" - def __init__(self, hass, client): - """Initialize the class.""" + def __init__(self, hass, api): + """Initialize Grocy data.""" self.hass = hass - self.client = client - self.sensor_types_dict = { - GrocyEntityType.STOCK: self.async_update_stock, - GrocyEntityType.CHORES: self.async_update_chores, - GrocyEntityType.TASKS: self.async_update_tasks, - GrocyEntityType.SHOPPING_LIST: self.async_update_shopping_list, - GrocyEntityType.EXPIRING_PRODUCTS: self.async_update_expiring_products, - GrocyEntityType.EXPIRED_PRODUCTS: self.async_update_expired_products, - GrocyEntityType.MISSING_PRODUCTS: self.async_update_missing_products, - GrocyEntityType.MEAL_PLAN: self.async_update_meal_plan, - GrocyEntityType.OVERDUE_CHORES: self.async_update_overdue_chores, - GrocyEntityType.OVERDUE_TASKS: self.async_update_overdue_tasks, - } - self.sensor_update_dict = { - GrocyEntityType.STOCK: None, - GrocyEntityType.CHORES: None, - GrocyEntityType.TASKS: None, - GrocyEntityType.SHOPPING_LIST: None, - GrocyEntityType.EXPIRING_PRODUCTS: None, - GrocyEntityType.EXPIRED_PRODUCTS: None, - GrocyEntityType.MISSING_PRODUCTS: None, - GrocyEntityType.MEAL_PLAN: None, - GrocyEntityType.OVERDUE_CHORES: None, - GrocyEntityType.OVERDUE_TASKS: None, + self.api = api + self.entity_update_method = { + ATTR_STOCK: self.async_update_stock, + ATTR_CHORES: self.async_update_chores, + ATTR_TASKS: self.async_update_tasks, + ATTR_SHOPPING_LIST: self.async_update_shopping_list, + ATTR_EXPIRING_PRODUCTS: self.async_update_expiring_products, + ATTR_EXPIRED_PRODUCTS: self.async_update_expired_products, + ATTR_MISSING_PRODUCTS: self.async_update_missing_products, + ATTR_MEAL_PLAN: self.async_update_meal_plan, + ATTR_OVERDUE_CHORES: self.async_update_overdue_chores, + ATTR_OVERDUE_TASKS: self.async_update_overdue_tasks, } - async def async_update_data(self, sensor_type): + async def async_update_data(self, entity_key): """Update data.""" - sensor_update = self.sensor_update_dict[sensor_type] - db_changed = await self.hass.async_add_executor_job( - self.client.get_last_db_changed - ) - if db_changed != sensor_update: - self.sensor_update_dict[sensor_type] = db_changed - if sensor_type in self.sensor_types_dict: - # This is where the main logic to update platform data goes. - return await self.sensor_types_dict[sensor_type]() + if entity_key in self.entity_update_method: + return await self.entity_update_method[entity_key]() async def async_update_stock(self): - """Update data.""" - # This is where the main logic to update platform data goes. - return await self.hass.async_add_executor_job(self.client.stock) + """Update stock data.""" + return await self.hass.async_add_executor_job(self.api.stock) async def async_update_chores(self): - """Update data.""" - # This is where the main logic to update platform data goes. + """Update chores data.""" + def wrapper(): - return self.client.chores(True) + return self.api.chores(True) return await self.hass.async_add_executor_job(wrapper) async def async_update_overdue_chores(self): - """Update data.""" - # This is where the main logic to update platform data goes. + """Update overdue chores data.""" + def wrapper(): - return self.client.chores(True) + return self.api.chores(True) chores = await self.hass.async_add_executor_job(wrapper) overdue_chores = [] @@ -93,19 +85,19 @@ async def async_get_config(self): """Get the configuration from Grocy.""" def wrapper(): - return self.client._api_client._do_get_request("system/config") + return self.api._api_client._do_get_request( + "system/config" + ) # TODO Make endpoint available in pygrocy return await self.hass.async_add_executor_job(wrapper) async def async_update_tasks(self): - """Update data.""" - # This is where the main logic to update platform data goes. - return await self.hass.async_add_executor_job(self.client.tasks) + """Update tasks data.""" + return await self.hass.async_add_executor_job(self.api.tasks) async def async_update_overdue_tasks(self): - """Update data.""" - # This is where the main logic to update platform data goes. - tasks = await self.hass.async_add_executor_job(self.client.tasks) + """Update overdue tasks data.""" + tasks = await self.hass.async_add_executor_job(self.api.tasks) overdue_tasks = [] for task in tasks: @@ -117,42 +109,42 @@ async def async_update_overdue_tasks(self): return overdue_tasks async def async_update_shopping_list(self): - """Update data.""" - # This is where the main logic to update platform data goes. + """Update shopping list data.""" + def wrapper(): - return self.client.shopping_list(True) + return self.api.shopping_list(True) return await self.hass.async_add_executor_job(wrapper) async def async_update_expiring_products(self): - """Update data.""" - # This is where the main logic to update platform data goes. + """Update expiring products data.""" + def wrapper(): - return self.client.due_products(True) + return self.api.due_products(True) return await self.hass.async_add_executor_job(wrapper) async def async_update_expired_products(self): - """Update data.""" - # This is where the main logic to update platform data goes. + """Update expired products data.""" + def wrapper(): - return self.client.expired_products(True) + return self.api.expired_products(True) return await self.hass.async_add_executor_job(wrapper) async def async_update_missing_products(self): - """Update data.""" - # This is where the main logic to update platform data goes. + """Update missing products data.""" + def wrapper(): - return self.client.missing_products(True) + return self.api.missing_products(True) return await self.hass.async_add_executor_job(wrapper) async def async_update_meal_plan(self): - """Update data.""" - # This is where the main logic to update platform data goes. + """Update meal plan data.""" + def wrapper(): - meal_plan = self.client.meal_plan(True) + meal_plan = self.api.meal_plan(True) today = datetime.today().date() plan = [MealPlanItem(item) for item in meal_plan if item.day >= today] return sorted(plan, key=lambda item: item.day) @@ -160,14 +152,16 @@ def wrapper(): return await self.hass.async_add_executor_job(wrapper) -async def async_setup_image_api(hass, config): +async def async_setup_endpoint_for_image_proxy( + hass: HomeAssistant, config_entry: ConfigEntry +): """Setup and register the image api for grocy images with HA.""" session = async_get_clientsession(hass) - url = config.get(CONF_URL) + url = config_entry.get(CONF_URL) (grocy_base_url, grocy_path) = extract_base_url_and_path(url) - api_key = config.get(CONF_API_KEY) - port_number = config.get(CONF_PORT) + api_key = config_entry.get(CONF_API_KEY) + port_number = config_entry.get(CONF_PORT) if grocy_path: grocy_full_url = f"{grocy_base_url}:{port_number}/{grocy_path}" else: @@ -190,6 +184,7 @@ def __init__(self, session, base_url, api_key): self._api_key = api_key async def get(self, request, picture_type: str, filename: str) -> web.Response: + """GET request for the image.""" width = request.query.get("width", 400) url = f"{self._base_url}/api/files/{picture_type}/{filename}" url = f"{url}?force_serve_as=picture&best_fit_width={int(width)}" diff --git a/custom_components/grocy/helpers.py b/custom_components/grocy/helpers.py index 100c98e..07253ca 100644 --- a/custom_components/grocy/helpers.py +++ b/custom_components/grocy/helpers.py @@ -1,15 +1,20 @@ +"""Helpers for Grocy.""" + import base64 +from typing import Any, Dict, Tuple from urllib.parse import urlparse -from typing import Tuple def extract_base_url_and_path(url: str) -> Tuple[str, str]: """Extract the base url and path from a given URL.""" parsed_url = urlparse(url) + return (f"{parsed_url.scheme}://{parsed_url.netloc}", parsed_url.path.strip("/")) class MealPlanItem(object): + """Grocy meal plan item definition.""" + def __init__(self, data): self.day = data.day self.note = data.note @@ -22,5 +27,6 @@ def __init__(self, data): else: self.picture_url = None - def as_dict(self): + def as_dict(self) -> Dict[str, Any]: + """Return dictionary for object.""" return vars(self) diff --git a/custom_components/grocy/json_encode.py b/custom_components/grocy/json_encode.py deleted file mode 100644 index a318df0..0000000 --- a/custom_components/grocy/json_encode.py +++ /dev/null @@ -1,30 +0,0 @@ -import json -import datetime -from pathlib import Path -from typing import Any - -from pygrocy.data_models.product import ProductBarcode -from pygrocy.grocy_api_client import ProductBarcodeData - - -class GrocyJSONEncoder(json.JSONEncoder): - """Custom JSONEncoder for Grocy""" - - def default(self, o: Any) -> Any: - """Convert special objects.""" - - if isinstance(o, (ProductBarcode, ProductBarcodeData)): - return o.barcode - if isinstance(o, (datetime.datetime, datetime.date, datetime.time)): - return o.isoformat() - if isinstance(o, set): - return list(o) - if isinstance(o, Path): - return str(o) - if hasattr(o, "as_dict"): - return o.as_dict() - - try: - return json.JSONEncoder.default(self, o) - except TypeError: - return {"__type": str(type(o)), "repr": repr(o), "str": str(o)} diff --git a/custom_components/grocy/json_encoder.py b/custom_components/grocy/json_encoder.py new file mode 100644 index 0000000..b17bcd9 --- /dev/null +++ b/custom_components/grocy/json_encoder.py @@ -0,0 +1,18 @@ +"""JSON encoder for Grocy.""" + +import datetime +from typing import Any + +from homeassistant.helpers.json import ExtendedJSONEncoder + + +class CustomJSONEncoder(ExtendedJSONEncoder): + """JSONEncoder for compatibility, falls back to the Home Assistant Core ExtendedJSONEncoder.""" + + def default(self, o: Any) -> Any: + """Convert certain objects.""" + + if isinstance(o, (datetime.date, datetime.time)): + return o.isoformat() + + return super().default(o) diff --git a/custom_components/grocy/sensor.py b/custom_components/grocy/sensor.py index 1d63f11..4de863b 100644 --- a/custom_components/grocy/sensor.py +++ b/custom_components/grocy/sensor.py @@ -1,43 +1,140 @@ """Sensor platform for Grocy.""" +from __future__ import annotations import logging +from collections.abc import Callable, Mapping +from dataclasses import dataclass +from typing import Any, List -from .const import DOMAIN, GrocyEntityType +from homeassistant.components.sensor import ( + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType + +from .const import ( + ATTR_CHORES, + ATTR_MEAL_PLAN, + ATTR_SHOPPING_LIST, + ATTR_STOCK, + ATTR_TASKS, + CHORES, + DOMAIN, + MEAL_PLANS, + PRODUCTS, + TASKS, +) +from .coordinator import GrocyDataUpdateCoordinator from .entity import GrocyEntity _LOGGER = logging.getLogger(__name__) -SENSOR_TYPES = [ - GrocyEntityType.CHORES, - GrocyEntityType.MEAL_PLAN, - GrocyEntityType.SHOPPING_LIST, - GrocyEntityType.STOCK, - GrocyEntityType.TASKS, -] -async def async_setup_entry(hass, entry, async_add_entities): +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +): """Setup sensor platform.""" - coordinator = hass.data[DOMAIN] - + coordinator: GrocyDataUpdateCoordinator = hass.data[DOMAIN] entities = [] - for sensor in SENSOR_TYPES: - _LOGGER.debug("Adding %s sensor", sensor) - entity = GrocySensor(coordinator, entry, sensor) - coordinator.entities.append(entity) - entities.append(entity) + for description in SENSORS: + if description.exists_fn(coordinator.available_entities): + entity = GrocySensorEntity(coordinator, description, config_entry) + coordinator.entities.append(entity) + entities.append(entity) + else: + _LOGGER.debug( + "Entity description '%s' is not available.", + description.key, + ) async_add_entities(entities, True) -class GrocySensor(GrocyEntity): - """Grocy Sensor class.""" +@dataclass +class GrocySensorEntityDescription(SensorEntityDescription): + """Grocy sensor entity description.""" + + attributes_fn: Callable[[List[Any]], Mapping[str, Any] | None] = lambda _: None + exists_fn: Callable[[List[str]], bool] = lambda _: True + entity_registry_enabled_default: bool = False + + +SENSORS: tuple[GrocySensorEntityDescription, ...] = ( + GrocySensorEntityDescription( + key=ATTR_CHORES, + name="Grocy chores", + native_unit_of_measurement=CHORES, + state_class=SensorStateClass.MEASUREMENT, + icon="mdi:broom", + exists_fn=lambda entities: ATTR_CHORES in entities, + attributes_fn=lambda data: { + "chores": [x.as_dict() for x in data], + "count": len(data), + }, + ), + GrocySensorEntityDescription( + key=ATTR_MEAL_PLAN, + name="Grocy meal plan", + native_unit_of_measurement=MEAL_PLANS, + state_class=SensorStateClass.MEASUREMENT, + icon="mdi:silverware-variant", + exists_fn=lambda entities: ATTR_MEAL_PLAN in entities, + attributes_fn=lambda data: { + "meals": [x.as_dict() for x in data], + "count": len(data), + }, + ), + GrocySensorEntityDescription( + key=ATTR_SHOPPING_LIST, + name="Grocy shopping list", + native_unit_of_measurement=PRODUCTS, + state_class=SensorStateClass.MEASUREMENT, + icon="mdi:cart-outline", + exists_fn=lambda entities: ATTR_SHOPPING_LIST in entities, + attributes_fn=lambda data: { + "products": [x.as_dict() for x in data], + "count": len(data), + }, + ), + GrocySensorEntityDescription( + key=ATTR_STOCK, + name="Grocy stock", + native_unit_of_measurement=PRODUCTS, + state_class=SensorStateClass.MEASUREMENT, + icon="mdi:fridge-outline", + exists_fn=lambda entities: ATTR_STOCK in entities, + attributes_fn=lambda data: { + "products": [x.as_dict() for x in data], + "count": len(data), + }, + ), + GrocySensorEntityDescription( + key=ATTR_TASKS, + name="Grocy tasks", + native_unit_of_measurement=TASKS, + state_class=SensorStateClass.MEASUREMENT, + icon="mdi:checkbox-marked-circle-outline", + exists_fn=lambda entities: ATTR_TASKS in entities, + attributes_fn=lambda data: { + "tasks": [x.as_dict() for x in data], + "count": len(data), + }, + ), +) + + +class GrocySensorEntity(GrocyEntity, SensorEntity): + """Grocy sensor entity definition.""" @property - def state(self): - """Return the state of the sensor.""" - _LOGGER.debug("Data for {}: {}".format(self.entity_id, self.entity_data)) - if self.entity_data is None: - return - if not self.entity_data: - return 0 - return len(self.entity_data) + def native_value(self) -> StateType: + """Return the value reported by the sensor.""" + entity_data = self.coordinator.data.get(self.entity_description.key, None) + + return len(entity_data) if entity_data else 0 diff --git a/custom_components/grocy/services.py b/custom_components/grocy/services.py index de18f0d..c624430 100644 --- a/custom_components/grocy/services.py +++ b/custom_components/grocy/services.py @@ -1,16 +1,13 @@ """Grocy services.""" from __future__ import annotations -import asyncio import voluptuous as vol from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, ServiceCall -from homeassistant.helpers import entity_component +from pygrocy import EntityType, TransactionType -from pygrocy import TransactionType, EntityType - -# pylint: disable=relative-beyond-top-level -from .const import DOMAIN +from .const import ATTR_CHORES, ATTR_TASKS, DOMAIN +from .coordinator import GrocyDataUpdateCoordinator SERVICE_PRODUCT_ID = "product_id" SERVICE_AMOUNT = "amount" @@ -87,9 +84,11 @@ ] -async def async_setup_services(hass: HomeAssistant, entry: ConfigEntry) -> None: +async def async_setup_services( + hass: HomeAssistant, config_entry: ConfigEntry # pylint: disable=unused-argument +) -> None: """Set up services for Grocy integration.""" - coordinator = hass.data[DOMAIN] + coordinator: GrocyDataUpdateCoordinator = hass.data[DOMAIN] if hass.services.async_services().get(DOMAIN): return @@ -133,7 +132,7 @@ async def async_add_product_service(hass, coordinator, data): price = data.get(SERVICE_PRICE, "") def wrapper(): - coordinator.api.add_product(product_id, amount, price) + coordinator.grocy_api.add_product(product_id, amount, price) await hass.async_add_executor_job(wrapper) @@ -152,7 +151,7 @@ async def async_consume_product_service(hass, coordinator, data): transaction_type = TransactionType[transaction_type_raw] def wrapper(): - coordinator.api.consume_product( + coordinator.grocy_api.consume_product( product_id, amount, spoiled=spoiled, @@ -169,13 +168,10 @@ async def async_execute_chore_service(hass, coordinator, data): done_by = data.get(SERVICE_DONE_BY, "") def wrapper(): - coordinator.api.execute_chore(chore_id, done_by) + coordinator.grocy_api.execute_chore(chore_id, done_by) await hass.async_add_executor_job(wrapper) - - asyncio.run_coroutine_threadsafe( - entity_component.async_update_entity(hass, "sensor.grocy_chores"), hass.loop - ) + await _async_force_update_entity(coordinator, ATTR_CHORES) async def async_complete_task_service(hass, coordinator, data): @@ -183,13 +179,10 @@ async def async_complete_task_service(hass, coordinator, data): task_id = data[SERVICE_TASK_ID] def wrapper(): - coordinator.api.complete_task(task_id) + coordinator.grocy_api.complete_task(task_id) await hass.async_add_executor_job(wrapper) - - asyncio.run_coroutine_threadsafe( - entity_component.async_update_entity(hass, "sensor.grocy_tasks"), hass.loop - ) + await _async_force_update_entity(coordinator, ATTR_TASKS) async def async_add_generic_service(hass, coordinator, data): @@ -203,6 +196,22 @@ async def async_add_generic_service(hass, coordinator, data): data = data[SERVICE_DATA] def wrapper(): - coordinator.api.add_generic(entity_type, data) + coordinator.grocy_api.add_generic(entity_type, data) await hass.async_add_executor_job(wrapper) + + +async def _async_force_update_entity( + coordinator: GrocyDataUpdateCoordinator, entity_key: str +) -> None: + """Force entity update for given entity key.""" + entity = next( + ( + entity + for entity in coordinator.entities + if entity.entity_description.key == entity_key + ), + None, + ) + if entity: + await entity.async_update_ha_state(force_refresh=True) diff --git a/custom_components/grocy/translations/en.json b/custom_components/grocy/translations/en.json index 0efc2e4..e3d4f41 100644 --- a/custom_components/grocy/translations/en.json +++ b/custom_components/grocy/translations/en.json @@ -1,9 +1,8 @@ { "config": { - "title": "Grocy", "step": { "user": { - "description": "If you need help with the configuration have a look here: https://github.com/custom-components/grocy", + "title": "Connect to your Grocy instance", "data": { "url": "Grocy API URL (e.g. \"http://yourgrocyurl.com\")", "api_key": "Grocy API Key", @@ -18,21 +17,5 @@ "abort": { "single_instance_allowed": "Only a single configuration of Grocy is allowed." } - }, - "options": { - "step": { - "user": { - "data": { - "binary_sensor": "Binary sensor enabled", - "sensor": "Sensor enabled", - "switch": "Switch enabled", - "allow_chores": "Chores enabled", - "allow_meal_plan": "Meal plan enabled", - "allow_shopping_list": "Shopping list enabled", - "allow_stock": "Stock enabled", - "allow_tasks": "Tasks enabled" - } - } - } } -} +} \ No newline at end of file