diff --git a/README.md b/README.md index a551cbd..91ea1f7 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ The configuration is slightly different for those who use the [official Grocy ad 4. Restart Home Assistant as instructed by HACS. 5. Install the [Grocy integration](https://my.home-assistant.io/redirect/config_flow_start/?domain=grocy). Fill out the information according to [this instruction](#integration-configuration). 6. Before integration version v4.3.3, now restart Home Assistant again (with later versions you can skip this step). -7. You will now have a new integration for Grocy. All entities are disabled from start, manually enable the entities you want to use. It can take up to 30 seconds before all entities are visible. +7. You will now have a new integration for Grocy. All entities are disabled from start, manually enable the entities you want to use. Future integration updates will appear automatically within Home Assistant via HACS. @@ -35,8 +35,8 @@ Future integration updates will appear automatically within Home Assistant via H # Entities **All entities are disabled from the start. You have to manually enable the entities you want to use in Home Assistant.** -You get a sensor each for chores, meal plan, shopping list, stock, tasks and batteries. The sensors refresh every 60 seconds. -You get a binary sensor each for overdue, expired, expiring and missing products and for overdue tasks, overdue chores and overdue batteries. The binary sensors refresh every 5 minutes. +You get a sensor each for chores, meal plan, shopping list, stock, tasks and batteries. +You get a binary sensor each for overdue, expired, expiring and missing products and for overdue tasks, overdue chores and overdue batteries. # Services diff --git a/custom_components/grocy/__init__.py b/custom_components/grocy/__init__.py index f6eb18d..fd73b78 100644 --- a/custom_components/grocy/__init__.py +++ b/custom_components/grocy/__init__.py @@ -11,7 +11,6 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from pygrocy import Grocy from .const import ( ATTR_BATTERIES, @@ -27,19 +26,12 @@ ATTR_SHOPPING_LIST, ATTR_STOCK, ATTR_TASKS, - CONF_API_KEY, - CONF_PORT, - CONF_URL, - CONF_VERIFY_SSL, DOMAIN, - GROCY_AVAILABLE_ENTITIES, - GROCY_CLIENT, PLATFORMS, - REGISTERED_ENTITIES, STARTUP_MESSAGE, ) +from .coordinator import GrocyDataUpdateCoordinator from .grocy_data import GrocyData, async_setup_endpoint_for_image_proxy -from .helpers import extract_base_url_and_path from .services import async_setup_services, async_unload_services _LOGGER = logging.getLogger(__name__) @@ -49,17 +41,13 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry): """Set up this integration using UI.""" _LOGGER.info(STARTUP_MESSAGE) - grocy_client = setup_grocy_client(hass, config_entry) - available_entities = await _async_get_available_entities(grocy_client) - - hass.data.setdefault( - DOMAIN, - { - GROCY_CLIENT: grocy_client, - GROCY_AVAILABLE_ENTITIES: available_entities, - REGISTERED_ENTITIES: [], - }, + 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 hass.config_entries.async_setup_platforms(config_entry, PLATFORMS) await async_setup_services(hass, config_entry) @@ -68,19 +56,6 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry): return True -def setup_grocy_client(hass: HomeAssistant, config_entry: ConfigEntry) -> GrocyData: - """Initialize Grocy""" - url = config_entry.data[CONF_URL] - api_key = config_entry.data[CONF_API_KEY] - port = config_entry.data[CONF_PORT] - verify_ssl = config_entry.data[CONF_VERIFY_SSL] - - (base_url, path) = extract_base_url_and_path(url) - - grocy_api = Grocy(base_url, api_key, path=path, port=port, verify_ssl=verify_ssl) - return GrocyData(hass, grocy_api) - - async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: """Unload a config entry.""" await async_unload_services(hass) @@ -92,10 +67,10 @@ async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> return unloaded -async def _async_get_available_entities(grocy_client: GrocyData) -> List[str]: +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_client.async_get_config() + grocy_config = await grocy_data.async_get_config() if grocy_config: if "FEATURE_FLAG_STOCK" in grocy_config.enabled_features: available_entities.append(ATTR_STOCK) diff --git a/custom_components/grocy/binary_sensor.py b/custom_components/grocy/binary_sensor.py index c6c252f..c0becbc 100644 --- a/custom_components/grocy/binary_sensor.py +++ b/custom_components/grocy/binary_sensor.py @@ -4,8 +4,7 @@ import logging from collections.abc import Callable, Mapping from dataclasses import dataclass -from datetime import timedelta -from typing import Any, Coroutine, List +from typing import Any, List from homeassistant.components.binary_sensor import ( BinarySensorEntity, @@ -24,16 +23,12 @@ ATTR_OVERDUE_PRODUCTS, ATTR_OVERDUE_TASKS, DOMAIN, - GROCY_AVAILABLE_ENTITIES, - REGISTERED_ENTITIES, ) +from .coordinator import GrocyDataUpdateCoordinator from .entity import GrocyEntity -from .grocy_data import GrocyData _LOGGER = logging.getLogger(__name__) -SCAN_INTERVAL = timedelta(seconds=300) - async def async_setup_entry( hass: HomeAssistant, @@ -41,14 +36,13 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ): """Setup binary sensor platform.""" - available_entities = hass.data[DOMAIN][GROCY_AVAILABLE_ENTITIES] - registered_entities = hass.data[DOMAIN][REGISTERED_ENTITIES] + coordinator: GrocyDataUpdateCoordinator = hass.data[DOMAIN] entities = [] for description in BINARY_SENSORS: - if description.exists_fn(available_entities): - entity = GrocyBinarySensorEntity(hass, description, config_entry) + if description.exists_fn(coordinator.available_entities): + entity = GrocyBinarySensorEntity(coordinator, description, config_entry) + coordinator.entities.append(entity) entities.append(entity) - registered_entities.append(entity) else: _LOGGER.debug( "Entity description '%s' is not available.", @@ -59,16 +53,7 @@ async def async_setup_entry( @dataclass -class GrocyEntityDescriptionMixin: - """Mixin for required keys.""" - - value_fn: Callable[[GrocyData], Coroutine[Any, Any, Any]] - - -@dataclass -class GrocyBinarySensorEntityDescription( - BinarySensorEntityDescription, GrocyEntityDescriptionMixin -): +class GrocyBinarySensorEntityDescription(BinarySensorEntityDescription): """Grocy binary sensor entity description.""" attributes_fn: Callable[[List[Any]], Mapping[str, Any] | None] = lambda _: None @@ -82,7 +67,6 @@ class GrocyBinarySensorEntityDescription( name="Grocy expired products", icon="mdi:delete-alert-outline", exists_fn=lambda entities: ATTR_EXPIRED_PRODUCTS in entities, - value_fn=lambda grocy: grocy.async_update_expired_products(), attributes_fn=lambda data: { "expired_products": [x.as_dict() for x in data], "count": len(data), @@ -93,7 +77,6 @@ class GrocyBinarySensorEntityDescription( name="Grocy expiring products", icon="mdi:clock-fast", exists_fn=lambda entities: ATTR_EXPIRING_PRODUCTS in entities, - value_fn=lambda grocy: grocy.async_update_expiring_products(), attributes_fn=lambda data: { "expiring_products": [x.as_dict() for x in data], "count": len(data), @@ -104,7 +87,6 @@ class GrocyBinarySensorEntityDescription( name="Grocy overdue products", icon="mdi:alert-circle-check-outline", exists_fn=lambda entities: ATTR_OVERDUE_PRODUCTS in entities, - value_fn=lambda grocy: grocy.async_update_overdue_products(), attributes_fn=lambda data: { "overdue_products": [x.as_dict() for x in data], "count": len(data), @@ -115,7 +97,6 @@ class GrocyBinarySensorEntityDescription( name="Grocy missing products", icon="mdi:flask-round-bottom-empty-outline", exists_fn=lambda entities: ATTR_MISSING_PRODUCTS in entities, - value_fn=lambda grocy: grocy.async_update_missing_products(), attributes_fn=lambda data: { "missing_products": [x.as_dict() for x in data], "count": len(data), @@ -126,7 +107,6 @@ class GrocyBinarySensorEntityDescription( name="Grocy overdue chores", icon="mdi:alert-circle-check-outline", exists_fn=lambda entities: ATTR_OVERDUE_CHORES in entities, - value_fn=lambda grocy: grocy.async_update_overdue_chores(), attributes_fn=lambda data: { "overdue_chores": [x.as_dict() for x in data], "count": len(data), @@ -137,7 +117,6 @@ class GrocyBinarySensorEntityDescription( name="Grocy overdue tasks", icon="mdi:alert-circle-check-outline", exists_fn=lambda entities: ATTR_OVERDUE_TASKS in entities, - value_fn=lambda grocy: grocy.async_update_overdue_tasks(), attributes_fn=lambda data: { "overdue_tasks": [x.as_dict() for x in data], "count": len(data), @@ -148,7 +127,6 @@ class GrocyBinarySensorEntityDescription( name="Grocy overdue batteries", icon="mdi:battery-charging-10", exists_fn=lambda entities: ATTR_OVERDUE_BATTERIES in entities, - value_fn=lambda grocy: grocy.async_update_batteries(), attributes_fn=lambda data: { "overdue_batteries": [x.as_dict() for x in data], "count": len(data), @@ -163,4 +141,6 @@ class GrocyBinarySensorEntity(GrocyEntity, BinarySensorEntity): @property def is_on(self) -> bool | None: """Return true if the binary sensor is on.""" - return len(self.data) > 0 if self.data else False + entity_data = self.coordinator.data.get(self.entity_description.key, None) + + return len(entity_data) > 0 if entity_data else False diff --git a/custom_components/grocy/const.py b/custom_components/grocy/const.py index 1cf0923..24824e8 100644 --- a/custom_components/grocy/const.py +++ b/custom_components/grocy/const.py @@ -1,4 +1,5 @@ """Constants for Grocy.""" +from datetime import timedelta from typing import Final NAME: Final = "Grocy" @@ -9,6 +10,8 @@ PLATFORMS: Final = ["binary_sensor", "sensor"] +SCAN_INTERVAL = timedelta(seconds=30) + DEFAULT_PORT: Final = 9192 CONF_URL: Final = "url" CONF_PORT: Final = "port" @@ -25,10 +28,6 @@ ------------------------------------------------------------------- """ -GROCY_CLIENT = "grocy_client" -GROCY_AVAILABLE_ENTITIES = "available_entities" -REGISTERED_ENTITIES = "registered_entities" - CHORES: Final = "Chore(s)" MEAL_PLANS: Final = "Meal(s)" PRODUCTS: Final = "Product(s)" @@ -48,27 +47,3 @@ ATTR_SHOPPING_LIST: Final = "shopping_list" ATTR_STOCK: Final = "stock" ATTR_TASKS: Final = "tasks" - -SERVICE_PRODUCT_ID = "product_id" -SERVICE_AMOUNT = "amount" -SERVICE_PRICE = "price" -SERVICE_SPOILED = "spoiled" -SERVICE_SUBPRODUCT_SUBSTITUTION = "allow_subproduct_substitution" -SERVICE_TRANSACTION_TYPE = "transaction_type" -SERVICE_CHORE_ID = "chore_id" -SERVICE_DONE_BY = "done_by" -SERVICE_SKIPPED = "skipped" -SERVICE_TASK_ID = "task_id" -SERVICE_ENTITY_TYPE = "entity_type" -SERVICE_DATA = "data" -SERVICE_RECIPE_ID = "recipe_id" -SERVICE_BATTERY_ID = "battery_id" - -SERVICE_ADD_PRODUCT = "add_product_to_stock" -SERVICE_CONSUME_PRODUCT = "consume_product_from_stock" -SERVICE_OPEN_PRODUCT = "open_product" -SERVICE_EXECUTE_CHORE = "execute_chore" -SERVICE_COMPLETE_TASK = "complete_task" -SERVICE_ADD_GENERIC = "add_generic" -SERVICE_CONSUME_RECIPE = "consume_recipe" -SERVICE_TRACK_BATTERY = "track_battery" 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 6f330e2..0eed44e 100644 --- a/custom_components/grocy/entity.py +++ b/custom_components/grocy/entity.py @@ -2,58 +2,39 @@ from __future__ import annotations import json -import logging from collections.abc import Mapping -from typing import Any, List +from typing import Any from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType -from homeassistant.helpers.entity import DeviceInfo, Entity, EntityDescription +from homeassistant.helpers.entity import DeviceInfo, EntityDescription +from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import DOMAIN, GROCY_CLIENT, NAME, VERSION -from .grocy_data import GrocyData +from .const import DOMAIN, NAME, VERSION +from .coordinator import GrocyDataUpdateCoordinator from .json_encoder import CustomJSONEncoder -_LOGGER = logging.getLogger(__name__) - -class GrocyEntity(Entity): +class GrocyEntity(CoordinatorEntity[GrocyDataUpdateCoordinator]): """Grocy base entity definition.""" def __init__( self, - hass: HomeAssistant, + coordinator: GrocyDataUpdateCoordinator, description: EntityDescription, config_entry: ConfigEntry, ) -> None: """Initialize entity.""" + super().__init__(coordinator) self._attr_name = description.name self._attr_unique_id = f"{config_entry.entry_id}{description.key.lower()}" - self.entity_description: EntityDescription = description - self.config_entry: ConfigEntry = config_entry - self.grocy_client: GrocyData = hass.data[DOMAIN][GROCY_CLIENT] - self.data: List[Any] = [] - - async def async_update(self) -> None: - """Update Grocy entity.""" - if not self.enabled: - return - - try: - self.data = await self.entity_description.value_fn(self.grocy_client) - self._attr_available = True - except Exception as error: # pylint: disable=broad-except - self.data = [] - if self._attr_available: - _LOGGER.error("An error occurred while updating sensor", exc_info=error) - self._attr_available = False + self.entity_description = description @property def device_info(self) -> DeviceInfo: """Grocy device information.""" return DeviceInfo( - identifiers={(DOMAIN, self.config_entry.entry_id)}, + identifiers={(DOMAIN, self.coordinator.config_entry.entry_id)}, name=NAME, manufacturer=NAME, software_version=VERSION, @@ -63,7 +44,7 @@ def device_info(self) -> DeviceInfo: @property def extra_state_attributes(self) -> Mapping[str, Any] | None: """Return the extra state attributes.""" - data = self.data + data = self.coordinator.data.get(self.entity_description.key) if data and hasattr(self.entity_description, "attributes_fn"): return json.loads( json.dumps( diff --git a/custom_components/grocy/grocy_data.py b/custom_components/grocy/grocy_data.py index 3c91fae..a4067cd 100644 --- a/custom_components/grocy/grocy_data.py +++ b/custom_components/grocy/grocy_data.py @@ -3,232 +3,185 @@ import logging from datetime import datetime, timedelta -from typing import Any, List +from typing import List 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 pygrocy import EntityType, Grocy from pygrocy.data_models.battery import Battery -from .const import CONF_API_KEY, CONF_PORT, CONF_URL +from .const import ( + ATTR_BATTERIES, + ATTR_CHORES, + ATTR_EXPIRED_PRODUCTS, + ATTR_EXPIRING_PRODUCTS, + ATTR_MEAL_PLAN, + ATTR_MISSING_PRODUCTS, + ATTR_OVERDUE_BATTERIES, + ATTR_OVERDUE_CHORES, + ATTR_OVERDUE_PRODUCTS, + ATTR_OVERDUE_TASKS, + ATTR_SHOPPING_LIST, + ATTR_STOCK, + ATTR_TASKS, + CONF_API_KEY, + CONF_PORT, + CONF_URL, +) from .helpers import MealPlanItemWrapper, extract_base_url_and_path _LOGGER = logging.getLogger(__name__) -class GrocyData: # pylint: disable=too-many-public-methods +class GrocyData: """Handles communication and gets the data.""" def __init__(self, hass, api): """Initialize Grocy data.""" - self.hass: HomeAssistant = hass - self._api: Grocy = api + self.hass = hass + 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_OVERDUE_PRODUCTS: self.async_update_overdue_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, + ATTR_BATTERIES: self.async_update_batteries, + ATTR_OVERDUE_BATTERIES: self.async_update_overdue_batteries, + } + + async def async_update_data(self, entity_key): + """Update data.""" + if entity_key in self.entity_update_method: + return await self.entity_update_method[entity_key]() async def async_update_stock(self): """Update stock data.""" - return await self.hass.async_add_executor_job(self._api.stock) + return await self.hass.async_add_executor_job(self.api.stock) async def async_update_chores(self): """Update chores data.""" - def action(): - return self._api.chores(get_details=True) + def wrapper(): + return self.api.chores(True) - return await self.hass.async_add_executor_job(action) + return await self.hass.async_add_executor_job(wrapper) async def async_update_overdue_chores(self): """Update overdue chores data.""" + query_filter = [f"next_estimated_execution_time<{datetime.now()}"] - def action(): - return self._api.chores(get_details=True, query_filters=query_filter) + def wrapper(): + return self.api.chores(get_details=True, query_filters=query_filter) - return await self.hass.async_add_executor_job(action) + return await self.hass.async_add_executor_job(wrapper) async def async_get_config(self): """Get the configuration from Grocy.""" - return await self.hass.async_add_executor_job(self._api.get_system_config) + + def wrapper(): + return self.api.get_system_config() + + return await self.hass.async_add_executor_job(wrapper) async def async_update_tasks(self): """Update tasks data.""" - return await self.hass.async_add_executor_job(self._api.tasks) + + return await self.hass.async_add_executor_job(self.api.tasks) async def async_update_overdue_tasks(self): """Update overdue tasks data.""" + and_query_filter = [ f"due_date<{datetime.now().date()}", # It's not possible to pass an empty value to Grocy, so use a regex that matches non-empty values to exclude empty str due_date. r"due_dateĀ§.*\S.*", ] - def action(): - return self._api.tasks(query_filters=and_query_filter) + def wrapper(): + return self.api.tasks(query_filters=and_query_filter) - return await self.hass.async_add_executor_job(action) + return await self.hass.async_add_executor_job(wrapper) async def async_update_shopping_list(self): """Update shopping list data.""" - def action(): - return self._api.shopping_list(get_details=True) + def wrapper(): + return self.api.shopping_list(True) - return await self.hass.async_add_executor_job(action) + return await self.hass.async_add_executor_job(wrapper) async def async_update_expiring_products(self): """Update expiring products data.""" - def action(): - return self._api.due_products(get_details=True) + def wrapper(): + return self.api.due_products(True) - return await self.hass.async_add_executor_job(action) + return await self.hass.async_add_executor_job(wrapper) async def async_update_expired_products(self): """Update expired products data.""" - def action(): - return self._api.expired_products(get_details=True) + def wrapper(): + return self.api.expired_products(True) - return await self.hass.async_add_executor_job(action) + return await self.hass.async_add_executor_job(wrapper) async def async_update_overdue_products(self): """Update overdue products data.""" - def action(): - return self._api.overdue_products(get_details=True) + def wrapper(): + return self.api.overdue_products(True) - return await self.hass.async_add_executor_job(action) + return await self.hass.async_add_executor_job(wrapper) async def async_update_missing_products(self): """Update missing products data.""" - def action(): - return self._api.missing_products(get_details=True) + def wrapper(): + return self.api.missing_products(True) - return await self.hass.async_add_executor_job(action) + return await self.hass.async_add_executor_job(wrapper) async def async_update_meal_plan(self): """Update meal plan data.""" + + # The >= condition is broken before Grocy 3.3.1. So use > to maintain backward compatibility. yesterday = datetime.now() - timedelta(1) - query_filter = [ - f"day>{yesterday.date()}" - ] # The >= condition is broken before Grocy 3.3.1. So use > to maintain backward compatibility. - - def action(): - meal_plan = self._api.meal_plan( - get_details=True, query_filters=query_filter - ) + query_filter = [f"day>{yesterday.date()}"] + + def wrapper(): + meal_plan = self.api.meal_plan(get_details=True, query_filters=query_filter) plan = [MealPlanItemWrapper(item) for item in meal_plan] return sorted(plan, key=lambda item: item.meal_plan.day) - return await self.hass.async_add_executor_job(action) + return await self.hass.async_add_executor_job(wrapper) async def async_update_batteries(self) -> List[Battery]: """Update batteries.""" - def action(): - return self._api.batteries(get_details=True) + def wrapper(): + return self.api.batteries(get_details=True) - return await self.hass.async_add_executor_job(action) + return await self.hass.async_add_executor_job(wrapper) async def async_update_overdue_batteries(self) -> List[Battery]: """Update overdue batteries.""" - query_filter = [f"next_estimated_charge_time<{datetime.now()}"] - - def action(): - return self._api.batteries(query_filters=query_filter, get_details=True) - - return await self.hass.async_add_executor_job(action) - - async def async_execute_chore( - self, chore_id: str, done_by: str, skipped: str = False - ) -> None: - """Execute a chore.""" - - def action(): - self._api.execute_chore(chore_id, done_by, skipped=skipped) - - return await self.hass.async_add_executor_job(action) - - async def async_complete_task(self, task_id: str) -> None: - """Complete a task.""" - - def action(): - self._api.complete_task(task_id) - - return await self.hass.async_add_executor_job(action) - - async def async_add_product(self, product_id: str, amount: str, price: str) -> None: - """Add a product.""" - - def action(): - self._api.add_product(product_id, amount, price) - - return await self.hass.async_add_executor_job(action) - - async def async_consume_product( # pylint: disable=too-many-arguments - self, - product_id: str, - amount: str, - spoiled: bool, - transaction_type: str, - allow_subproduct_substitution: bool = False, - ) -> None: - """Consume a product.""" - - def action(): - self._api.consume_product( - product_id, - amount, - spoiled=spoiled, - transaction_type=transaction_type, - allow_subproduct_substitution=allow_subproduct_substitution, - ) - - return await self.hass.async_add_executor_job(action) - - async def async_open_product( - self, - product_id: str, - amount: str, - allow_subproduct_substitution: bool = False, - ) -> None: - """Open a product.""" - - def action(): - self._api.open_product( - product_id, - amount, - allow_subproduct_substitution, - ) - - return await self.hass.async_add_executor_job(action) - - async def async_consume_recipe(self, recipe_id: str) -> None: - """Consume a recipe.""" - - def action(): - self._api.consume_recipe(recipe_id) - - return await self.hass.async_add_executor_job(action) - - async def async_track_battery(self, battery_id: str) -> None: - """Track a battery.""" - - def action(): - self._api.charge_battery(battery_id) - - return await self.hass.async_add_executor_job(action) - - async def async_add_generic(self, entity_type: EntityType, data: Any) -> None: - """Add generic object.""" - def action(): - self._api.add_generic(entity_type, data) + def wrapper(): + filter_query = [f"next_estimated_charge_time<{datetime.now()}"] + return self.api.batteries(filter_query, get_details=True) - return await self.hass.async_add_executor_job(action) + return await self.hass.async_add_executor_job(wrapper) async def async_setup_endpoint_for_image_proxy( diff --git a/custom_components/grocy/sensor.py b/custom_components/grocy/sensor.py index 68d7189..de041fb 100644 --- a/custom_components/grocy/sensor.py +++ b/custom_components/grocy/sensor.py @@ -4,8 +4,7 @@ import logging from collections.abc import Callable, Mapping from dataclasses import dataclass -from datetime import timedelta -from typing import Any, Coroutine, List +from typing import Any, List from homeassistant.components.sensor import ( SensorEntity, @@ -26,20 +25,16 @@ ATTR_TASKS, CHORES, DOMAIN, - GROCY_AVAILABLE_ENTITIES, ITEMS, MEAL_PLANS, PRODUCTS, - REGISTERED_ENTITIES, TASKS, ) +from .coordinator import GrocyDataUpdateCoordinator from .entity import GrocyEntity -from .grocy_data import GrocyData _LOGGER = logging.getLogger(__name__) -SCAN_INTERVAL = timedelta(seconds=60) - async def async_setup_entry( hass: HomeAssistant, @@ -47,14 +42,13 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ): """Setup sensor platform.""" - available_entities = hass.data[DOMAIN][GROCY_AVAILABLE_ENTITIES] - registered_entities = hass.data[DOMAIN][REGISTERED_ENTITIES] + coordinator: GrocyDataUpdateCoordinator = hass.data[DOMAIN] entities = [] for description in SENSORS: - if description.exists_fn(available_entities): - entity = GrocySensorEntity(hass, description, config_entry) + if description.exists_fn(coordinator.available_entities): + entity = GrocySensorEntity(coordinator, description, config_entry) + coordinator.entities.append(entity) entities.append(entity) - registered_entities.append(entity) else: _LOGGER.debug( "Entity description '%s' is not available.", @@ -65,16 +59,7 @@ async def async_setup_entry( @dataclass -class GrocyEntityDescriptionMixin: - """Mixin for required keys.""" - - value_fn: Callable[[GrocyData], Coroutine[Any, Any, Any]] - - -@dataclass -class GrocySensorEntityDescription( - SensorEntityDescription, GrocyEntityDescriptionMixin -): +class GrocySensorEntityDescription(SensorEntityDescription): """Grocy sensor entity description.""" attributes_fn: Callable[[List[Any]], Mapping[str, Any] | None] = lambda _: None @@ -90,7 +75,6 @@ class GrocySensorEntityDescription( state_class=SensorStateClass.MEASUREMENT, icon="mdi:broom", exists_fn=lambda entities: ATTR_CHORES in entities, - value_fn=lambda grocy: grocy.async_update_chores(), attributes_fn=lambda data: { "chores": [x.as_dict() for x in data], "count": len(data), @@ -103,7 +87,6 @@ class GrocySensorEntityDescription( state_class=SensorStateClass.MEASUREMENT, icon="mdi:silverware-variant", exists_fn=lambda entities: ATTR_MEAL_PLAN in entities, - value_fn=lambda grocy: grocy.async_update_meal_plan(), attributes_fn=lambda data: { "meals": [x.as_dict() for x in data], "count": len(data), @@ -116,7 +99,6 @@ class GrocySensorEntityDescription( state_class=SensorStateClass.MEASUREMENT, icon="mdi:cart-outline", exists_fn=lambda entities: ATTR_SHOPPING_LIST in entities, - value_fn=lambda grocy: grocy.async_update_shopping_list(), attributes_fn=lambda data: { "products": [x.as_dict() for x in data], "count": len(data), @@ -129,7 +111,6 @@ class GrocySensorEntityDescription( state_class=SensorStateClass.MEASUREMENT, icon="mdi:fridge-outline", exists_fn=lambda entities: ATTR_STOCK in entities, - value_fn=lambda grocy: grocy.async_update_stock(), attributes_fn=lambda data: { "products": [x.as_dict() for x in data], "count": len(data), @@ -142,7 +123,6 @@ class GrocySensorEntityDescription( state_class=SensorStateClass.MEASUREMENT, icon="mdi:checkbox-marked-circle-outline", exists_fn=lambda entities: ATTR_TASKS in entities, - value_fn=lambda grocy: grocy.async_update_tasks(), attributes_fn=lambda data: { "tasks": [x.as_dict() for x in data], "count": len(data), @@ -155,7 +135,6 @@ class GrocySensorEntityDescription( state_class=SensorStateClass.MEASUREMENT, icon="mdi:battery", exists_fn=lambda entities: ATTR_BATTERIES in entities, - value_fn=lambda grocy: grocy.async_update_batteries(), attributes_fn=lambda data: { "batteries": [x.as_dict() for x in data], "count": len(data), @@ -170,4 +149,6 @@ class GrocySensorEntity(GrocyEntity, SensorEntity): @property def native_value(self) -> StateType: """Return the value reported by the sensor.""" - return len(self.data) if self.data else 0 + 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 fd0f7a8..5069ac6 100644 --- a/custom_components/grocy/services.py +++ b/custom_components/grocy/services.py @@ -1,259 +1,296 @@ """Grocy services.""" from __future__ import annotations -from typing import List - import voluptuous as vol from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, ServiceCall -from homeassistant.helpers.entity import Entity from pygrocy import EntityType, TransactionType -from .const import ( - ATTR_CHORES, - ATTR_TASKS, - DOMAIN, - GROCY_CLIENT, - REGISTERED_ENTITIES, - SERVICE_ADD_GENERIC, - SERVICE_ADD_PRODUCT, - SERVICE_AMOUNT, - SERVICE_BATTERY_ID, - SERVICE_CHORE_ID, - SERVICE_COMPLETE_TASK, - SERVICE_CONSUME_PRODUCT, - SERVICE_CONSUME_RECIPE, - SERVICE_DATA, - SERVICE_DONE_BY, - SERVICE_ENTITY_TYPE, - SERVICE_EXECUTE_CHORE, - SERVICE_OPEN_PRODUCT, - SERVICE_PRICE, - SERVICE_PRODUCT_ID, - SERVICE_RECIPE_ID, - SERVICE_SKIPPED, - SERVICE_SPOILED, - SERVICE_SUBPRODUCT_SUBSTITUTION, - SERVICE_TASK_ID, - SERVICE_TRACK_BATTERY, - SERVICE_TRANSACTION_TYPE, +from .const import ATTR_CHORES, ATTR_TASKS, DOMAIN +from .coordinator import GrocyDataUpdateCoordinator + +SERVICE_PRODUCT_ID = "product_id" +SERVICE_AMOUNT = "amount" +SERVICE_PRICE = "price" +SERVICE_SPOILED = "spoiled" +SERVICE_SUBPRODUCT_SUBSTITUTION = "allow_subproduct_substitution" +SERVICE_TRANSACTION_TYPE = "transaction_type" +SERVICE_CHORE_ID = "chore_id" +SERVICE_DONE_BY = "done_by" +SERVICE_SKIPPED = "skipped" +SERVICE_TASK_ID = "task_id" +SERVICE_ENTITY_TYPE = "entity_type" +SERVICE_DATA = "data" +SERVICE_RECIPE_ID = "recipe_id" +SERVICE_BATTERY_ID = "battery_id" + +SERVICE_ADD_PRODUCT = "add_product_to_stock" +SERVICE_OPEN_PRODUCT = "open_product" +SERVICE_CONSUME_PRODUCT = "consume_product_from_stock" +SERVICE_EXECUTE_CHORE = "execute_chore" +SERVICE_COMPLETE_TASK = "complete_task" +SERVICE_ADD_GENERIC = "add_generic" +SERVICE_CONSUME_RECIPE = "consume_recipe" +SERVICE_TRACK_BATTERY = "track_battery" + +SERVICE_ADD_PRODUCT_SCHEMA = vol.All( + vol.Schema( + { + vol.Required(SERVICE_PRODUCT_ID): vol.Coerce(int), + vol.Required(SERVICE_AMOUNT): vol.Coerce(float), + vol.Optional(SERVICE_PRICE): str, + } + ) ) -from .grocy_data import GrocyData - -SERVICE_ADD_PRODUCT_SCHEMA = { - vol.Required(SERVICE_PRODUCT_ID): vol.Coerce(int), - vol.Required(SERVICE_AMOUNT): vol.Coerce(float), - vol.Optional(SERVICE_PRICE): str, -} - -SERVICE_CONSUME_PRODUCT_SCHEMA = { - vol.Required(SERVICE_PRODUCT_ID): vol.Coerce(int), - vol.Required(SERVICE_AMOUNT): vol.Coerce(float), - vol.Optional(SERVICE_SPOILED): bool, - vol.Optional(SERVICE_SUBPRODUCT_SUBSTITUTION): bool, - vol.Optional(SERVICE_TRANSACTION_TYPE): str, -} - -SERVICE_OPEN_PRODUCT_SCHEMA = { - vol.Required(SERVICE_PRODUCT_ID): vol.Coerce(int), - vol.Required(SERVICE_AMOUNT): vol.Coerce(float), - vol.Optional(SERVICE_SUBPRODUCT_SUBSTITUTION): bool, -} - -SERVICE_EXECUTE_CHORE_SCHEMA = { - vol.Required(SERVICE_CHORE_ID): vol.Coerce(int), - vol.Optional(SERVICE_DONE_BY): vol.Coerce(int), - vol.Optional(SERVICE_SKIPPED): bool, -} - -SERVICE_COMPLETE_TASK_SCHEMA = { - vol.Required(SERVICE_TASK_ID): vol.Coerce(int), -} - -SERVICE_ADD_GENERIC_SCHEMA = { - vol.Required(SERVICE_ENTITY_TYPE): str, - vol.Required(SERVICE_DATA): object, -} - -SERVICE_CONSUME_RECIPE_SCHEMA = { - vol.Required(SERVICE_RECIPE_ID): vol.Coerce(int), -} - -SERVICE_TRACK_BATTERY_SCHEMA = { - vol.Required(SERVICE_BATTERY_ID): vol.Coerce(int), -} - - -def make_service_schema(schema: dict): - """Create a service schema.""" - return vol.Schema( - vol.All( - vol.Schema( - { - **schema, - }, - ), - ) + +SERVICE_OPEN_PRODUCT_SCHEMA = vol.All( + vol.Schema( + { + vol.Required(SERVICE_PRODUCT_ID): vol.Coerce(int), + vol.Required(SERVICE_AMOUNT): vol.Coerce(float), + vol.Optional(SERVICE_SUBPRODUCT_SUBSTITUTION): bool, + } ) +) + +SERVICE_CONSUME_PRODUCT_SCHEMA = vol.All( + vol.Schema( + { + vol.Required(SERVICE_PRODUCT_ID): vol.Coerce(int), + vol.Required(SERVICE_AMOUNT): vol.Coerce(float), + vol.Optional(SERVICE_SPOILED): bool, + vol.Optional(SERVICE_SUBPRODUCT_SUBSTITUTION): bool, + vol.Optional(SERVICE_TRANSACTION_TYPE): str, + } + ) +) + +SERVICE_EXECUTE_CHORE_SCHEMA = vol.All( + vol.Schema( + { + vol.Required(SERVICE_CHORE_ID): vol.Coerce(int), + vol.Optional(SERVICE_DONE_BY): vol.Coerce(int), + vol.Optional(SERVICE_SKIPPED): bool, + } + ) +) + +SERVICE_COMPLETE_TASK_SCHEMA = vol.All( + vol.Schema( + { + vol.Required(SERVICE_TASK_ID): vol.Coerce(int), + } + ) +) + +SERVICE_ADD_GENERIC_SCHEMA = vol.All( + vol.Schema( + { + vol.Required(SERVICE_ENTITY_TYPE): str, + vol.Required(SERVICE_DATA): object, + } + ) +) + +SERVICE_CONSUME_RECIPE_SCHEMA = vol.All( + vol.Schema( + { + vol.Required(SERVICE_RECIPE_ID): vol.Coerce(int), + } + ) +) + +SERVICE_TRACK_BATTERY_SCHEMA = vol.All( + vol.Schema( + { + vol.Required(SERVICE_BATTERY_ID): vol.Coerce(int), + } + ) +) + +SERVICES_WITH_ACCOMPANYING_SCHEMA: list[tuple[str, vol.Schema]] = [ + (SERVICE_ADD_PRODUCT, SERVICE_ADD_PRODUCT_SCHEMA), + (SERVICE_OPEN_PRODUCT, SERVICE_OPEN_PRODUCT_SCHEMA), + (SERVICE_CONSUME_PRODUCT, SERVICE_CONSUME_PRODUCT_SCHEMA), + (SERVICE_EXECUTE_CHORE, SERVICE_EXECUTE_CHORE_SCHEMA), + (SERVICE_COMPLETE_TASK, SERVICE_COMPLETE_TASK_SCHEMA), + (SERVICE_ADD_GENERIC, SERVICE_ADD_GENERIC_SCHEMA), + (SERVICE_CONSUME_RECIPE, SERVICE_CONSUME_RECIPE_SCHEMA), + (SERVICE_TRACK_BATTERY, SERVICE_TRACK_BATTERY_SCHEMA), +] async def async_setup_services( hass: HomeAssistant, config_entry: ConfigEntry # pylint: disable=unused-argument ) -> None: """Set up services for Grocy integration.""" + coordinator: GrocyDataUpdateCoordinator = hass.data[DOMAIN] if hass.services.async_services().get(DOMAIN): return - grocy_client: GrocyData = hass.data[DOMAIN][GROCY_CLIENT] + async def async_call_grocy_service(service_call: ServiceCall) -> None: + """Call correct Grocy service.""" + service = service_call.service + service_data = service_call.data - async def _async_add_product(call: ServiceCall) -> None: - """Add a product service call handler.""" - product_id = call.data[SERVICE_PRODUCT_ID] - amount = call.data[SERVICE_AMOUNT] - price = call.data.get(SERVICE_PRICE, "") + if service == SERVICE_ADD_PRODUCT: + await async_add_product_service(hass, coordinator, service_data) - await grocy_client.async_add_product(product_id, amount, price) + elif service == SERVICE_OPEN_PRODUCT: + await async_open_product_service(hass, coordinator, service_data) - hass.services.async_register( - domain=DOMAIN, - service=SERVICE_ADD_PRODUCT, - schema=make_service_schema(SERVICE_ADD_PRODUCT_SCHEMA), - service_func=_async_add_product, - ) + elif service == SERVICE_CONSUME_PRODUCT: + await async_consume_product_service(hass, coordinator, service_data) - async def _async_consume_product(call: ServiceCall) -> None: - """Consume a product service call handler.""" - product_id = call.data[SERVICE_PRODUCT_ID] - amount = call.data[SERVICE_AMOUNT] - spoiled = call.data.get(SERVICE_SPOILED, False) - allow_subproduct_substitution = call.data.get( - SERVICE_SUBPRODUCT_SUBSTITUTION, False - ) + elif service == SERVICE_EXECUTE_CHORE: + await async_execute_chore_service(hass, coordinator, service_data) - transaction_type_raw = call.data.get(SERVICE_TRANSACTION_TYPE, None) - transaction_type = TransactionType.CONSUME + elif service == SERVICE_COMPLETE_TASK: + await async_complete_task_service(hass, coordinator, service_data) - if transaction_type_raw is not None: - transaction_type = TransactionType[transaction_type_raw] + elif service == SERVICE_ADD_GENERIC: + await async_add_generic_service(hass, coordinator, service_data) - await grocy_client.async_consume_product( - product_id, amount, spoiled, transaction_type, allow_subproduct_substitution - ) + elif service == SERVICE_CONSUME_RECIPE: + await async_consume_recipe_service(hass, coordinator, service_data) - hass.services.async_register( - domain=DOMAIN, - service=SERVICE_CONSUME_PRODUCT, - schema=make_service_schema(SERVICE_CONSUME_PRODUCT_SCHEMA), - service_func=_async_consume_product, - ) + elif service == SERVICE_TRACK_BATTERY: + await async_track_battery_service(hass, coordinator, service_data) - async def _async_open_product(call: ServiceCall) -> None: - """Open a product service call handler.""" - product_id = call.data[SERVICE_PRODUCT_ID] - amount = call.data[SERVICE_AMOUNT] - allow_subproduct_substitution = call.data.get( - SERVICE_SUBPRODUCT_SUBSTITUTION, False - ) + for service, schema in SERVICES_WITH_ACCOMPANYING_SCHEMA: + hass.services.async_register(DOMAIN, service, async_call_grocy_service, schema) + + +async def async_unload_services(hass: HomeAssistant) -> None: + """Unload Grocy services.""" + if not hass.services.async_services().get(DOMAIN): + return + + for service, _ in SERVICES_WITH_ACCOMPANYING_SCHEMA: + hass.services.async_remove(DOMAIN, service) + + +async def async_add_product_service(hass, coordinator, data): + """Add a product in Grocy.""" + product_id = data[SERVICE_PRODUCT_ID] + amount = data[SERVICE_AMOUNT] + price = data.get(SERVICE_PRICE, "") + + def wrapper(): + coordinator.grocy_api.add_product(product_id, amount, price) + + await hass.async_add_executor_job(wrapper) + + +async def async_open_product_service(hass, coordinator, data): + """Open a product in Grocy.""" + product_id = data[SERVICE_PRODUCT_ID] + amount = data[SERVICE_AMOUNT] + allow_subproduct_substitution = data.get(SERVICE_SUBPRODUCT_SUBSTITUTION, False) - await grocy_client.async_open_product( + def wrapper(): + coordinator.grocy_api.open_product( product_id, amount, allow_subproduct_substitution ) - hass.services.async_register( - domain=DOMAIN, - service=SERVICE_OPEN_PRODUCT, - schema=make_service_schema(SERVICE_OPEN_PRODUCT_SCHEMA), - service_func=_async_open_product, - ) + await hass.async_add_executor_job(wrapper) - async def _async_execute_chore(call: ServiceCall) -> None: - """Execute a chore service call handler.""" - chore_id = call.data[SERVICE_CHORE_ID] - done_by = call.data.get(SERVICE_DONE_BY, "") - skipped = call.data.get(SERVICE_SKIPPED, False) - await grocy_client.async_execute_chore(chore_id, done_by, skipped) - await _async_force_update_entity( - hass.data[DOMAIN][REGISTERED_ENTITIES], ATTR_CHORES - ) +async def async_consume_product_service(hass, coordinator, data): + """Consume a product in Grocy.""" + product_id = data[SERVICE_PRODUCT_ID] + amount = data[SERVICE_AMOUNT] + spoiled = data.get(SERVICE_SPOILED, False) + allow_subproduct_substitution = data.get(SERVICE_SUBPRODUCT_SUBSTITUTION, False) - hass.services.async_register( - domain=DOMAIN, - service=SERVICE_EXECUTE_CHORE, - schema=make_service_schema(SERVICE_EXECUTE_CHORE_SCHEMA), - service_func=_async_execute_chore, - ) + transaction_type_raw = data.get(SERVICE_TRANSACTION_TYPE, None) + transaction_type = TransactionType.CONSUME - async def _async_complete_task(call: ServiceCall) -> None: - """Complete a task service call handler.""" - task_id = call.data[SERVICE_TASK_ID] + if transaction_type_raw is not None: + transaction_type = TransactionType[transaction_type_raw] - await grocy_client.async_complete_task(task_id) - await _async_force_update_entity( - hass.data[DOMAIN][REGISTERED_ENTITIES], ATTR_TASKS + def wrapper(): + coordinator.grocy_api.consume_product( + product_id, + amount, + spoiled=spoiled, + transaction_type=transaction_type, + allow_subproduct_substitution=allow_subproduct_substitution, ) - hass.services.async_register( - domain=DOMAIN, - service=SERVICE_COMPLETE_TASK, - schema=make_service_schema(SERVICE_COMPLETE_TASK_SCHEMA), - service_func=_async_complete_task, - ) + await hass.async_add_executor_job(wrapper) - async def _async_consume_recipe(call: ServiceCall) -> None: - """Consume a recipe service call handler.""" - recipe_id = call.data[SERVICE_RECIPE_ID] - await grocy_client.async_consume_recipe(recipe_id) +async def async_execute_chore_service(hass, coordinator, data): + """Execute a chore in Grocy.""" + chore_id = data[SERVICE_CHORE_ID] + done_by = data.get(SERVICE_DONE_BY, "") + skipped = data.get(SERVICE_SKIPPED, False) - hass.services.async_register( - domain=DOMAIN, - service=SERVICE_CONSUME_RECIPE, - schema=make_service_schema(SERVICE_CONSUME_RECIPE_SCHEMA), - service_func=_async_consume_recipe, - ) + def wrapper(): + coordinator.grocy_api.execute_chore(chore_id, done_by, skipped=skipped) - async def _async_track_battery(call: ServiceCall) -> None: - """Track a battery service call handler.""" - battery_id = call.data[SERVICE_BATTERY_ID] + await hass.async_add_executor_job(wrapper) + await _async_force_update_entity(coordinator, ATTR_CHORES) - await grocy_client.async_track_battery(battery_id) - hass.services.async_register( - domain=DOMAIN, - service=SERVICE_TRACK_BATTERY, - schema=make_service_schema(SERVICE_TRACK_BATTERY_SCHEMA), - service_func=_async_track_battery, - ) +async def async_complete_task_service(hass, coordinator, data): + """Complete a task in Grocy.""" + task_id = data[SERVICE_TASK_ID] - async def _async_add_generic(call: ServiceCall) -> None: - """Add a generic entity service call handler.""" - entity_type_raw = call.data.get(SERVICE_ENTITY_TYPE, None) - entity_type = EntityType.TASKS - if entity_type_raw is not None: - entity_type = EntityType(entity_type_raw) - data = call.data[SERVICE_DATA] - - await grocy_client.async_add_generic(entity_type, data) - - hass.services.async_register( - domain=DOMAIN, - service=SERVICE_ADD_GENERIC, - schema=make_service_schema(SERVICE_ADD_GENERIC_SCHEMA), - service_func=_async_add_generic, - ) + def wrapper(): + coordinator.grocy_api.complete_task(task_id) + await hass.async_add_executor_job(wrapper) + await _async_force_update_entity(coordinator, ATTR_TASKS) + + +async def async_add_generic_service(hass, coordinator, data): + """Add a generic entity in Grocy.""" + entity_type_raw = data.get(SERVICE_ENTITY_TYPE, None) + entity_type = EntityType.TASKS + + if entity_type_raw is not None: + entity_type = EntityType(entity_type_raw) + + data = data[SERVICE_DATA] + + def wrapper(): + coordinator.grocy_api.add_generic(entity_type, data) + + await hass.async_add_executor_job(wrapper) -async def async_unload_services(hass: HomeAssistant) -> None: - """Unload Grocy services.""" - for service in hass.services.async_services().get(DOMAIN): - hass.services.async_remove(DOMAIN, service) +async def async_consume_recipe_service(hass, coordinator, data): + """Consume a recipe in Grocy.""" + recipe_id = data[SERVICE_RECIPE_ID] -async def _async_force_update_entity(entities: List[Entity], entity_key: str) -> None: + def wrapper(): + coordinator.grocy_api.consume_recipe(recipe_id) + + await hass.async_add_executor_job(wrapper) + + +async def async_track_battery_service(hass, coordinator, data): + """Track a battery in Grocy.""" + battery_id = data[SERVICE_BATTERY_ID] + + def wrapper(): + coordinator.grocy_api.charge_battery(battery_id) + + 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 entities if entity.entity_description.key == entity_key), + ( + entity + for entity in coordinator.entities + if entity.entity_description.key == entity_key + ), None, ) if entity: