From d3d2ac3ebedce79add8aef35ed898edf71792898 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Isabella=20Gross=20Alstr=C3=B6m?= Date: Mon, 17 Aug 2020 15:28:05 +0200 Subject: [PATCH 01/45] WIP: Add options to config flow --- custom_components/grocy/__init__.py | 44 +-------- custom_components/grocy/binary_sensor.py | 10 +- custom_components/grocy/config_flow.py | 99 +++++++++++++++++++- custom_components/grocy/const.py | 18 +++- custom_components/grocy/sensor.py | 4 +- custom_components/grocy/translations/en.json | 14 +++ 6 files changed, 138 insertions(+), 51 deletions(-) diff --git a/custom_components/grocy/__init__.py b/custom_components/grocy/__init__.py index fec82ca..6195480 100644 --- a/custom_components/grocy/__init__.py +++ b/custom_components/grocy/__init__.py @@ -19,11 +19,9 @@ from .const import ( CHORES_NAME, TASKS_NAME, - CONF_BINARY_SENSOR, CONF_ENABLED, CONF_NAME, - CONF_SENSOR, - DEFAULT_NAME, + DEFAULT_CONF_NAME, DEFAULT_PORT_NUMBER, DOMAIN, DOMAIN_DATA, @@ -46,38 +44,6 @@ _LOGGER = logging.getLogger(__name__) -SENSOR_SCHEMA = vol.Schema( - { - vol.Optional(CONF_ENABLED, default=True): cv.boolean, - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - } -) - -BINARY_SENSOR_SCHEMA = vol.Schema( - { - vol.Optional(CONF_ENABLED, default=True): cv.boolean, - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - } -) - -CONFIG_SCHEMA = vol.Schema( - { - DOMAIN: vol.Schema( - { - vol.Required(CONF_URL): cv.string, - vol.Required(CONF_API_KEY): cv.string, - vol.Optional(CONF_PORT, default=DEFAULT_PORT_NUMBER): cv.port, - vol.Optional(CONF_VERIFY_SSL, default=True): cv.boolean, - vol.Optional(CONF_SENSOR): vol.All(cv.ensure_list, [SENSOR_SCHEMA]), - vol.Optional(CONF_BINARY_SENSOR): vol.All( - cv.ensure_list, [BINARY_SENSOR_SCHEMA] - ), - } - ) - }, - extra=vol.ALLOW_EXTRA, -) - async def async_setup(hass, config): """Set up this component.""" @@ -123,11 +89,11 @@ async def async_setup_entry(hass, config_entry): hass.data[DOMAIN_DATA]["hash_key"] = hash_key hass.data[DOMAIN_DATA]["url"] = f"{url}:{port_number}" - # Add sensor + # Add sensors hass.async_add_job( hass.config_entries.async_forward_entry_setup(config_entry, "sensor") ) - # Add sensor + # Add binary sensors hass.async_add_job( hass.config_entries.async_forward_entry_setup(config_entry, "binary_sensor") ) @@ -216,7 +182,7 @@ def __init__(self, hass, client): EXPIRING_PRODUCTS_NAME: self.async_update_expiring_products, EXPIRED_PRODUCTS_NAME: self.async_update_expired_products, MISSING_PRODUCTS_NAME: self.async_update_missing_products, - MEAL_PLAN_NAME : self.async_update_meal_plan, + MEAL_PLAN_NAME: self.async_update_meal_plan, } self.sensor_update_dict = { STOCK_NAME: None, @@ -226,7 +192,7 @@ def __init__(self, hass, client): EXPIRING_PRODUCTS_NAME: None, EXPIRED_PRODUCTS_NAME: None, MISSING_PRODUCTS_NAME: None, - MEAL_PLAN_NAME : None, + MEAL_PLAN_NAME: None, } async def async_update_data(self, sensor_type): diff --git a/custom_components/grocy/binary_sensor.py b/custom_components/grocy/binary_sensor.py index 9daeff7..8e96d0e 100644 --- a/custom_components/grocy/binary_sensor.py +++ b/custom_components/grocy/binary_sensor.py @@ -2,7 +2,13 @@ import logging from homeassistant.components.binary_sensor import BinarySensorEntity -from .const import ATTRIBUTION, BINARY_SENSOR_TYPES, DEFAULT_NAME, DOMAIN, DOMAIN_DATA +from .const import ( + ATTRIBUTION, + BINARY_SENSOR_TYPES, + DEFAULT_CONF_NAME, + DOMAIN, + DOMAIN_DATA, +) _LOGGER = logging.getLogger(__name__) @@ -30,7 +36,7 @@ def __init__(self, hass, sensor_type): self._status = False self._hash_key = self.hass.data[DOMAIN_DATA]["hash_key"] self._unique_id = "{}-{}".format(self._hash_key, self.sensor_type) - self._name = "{}.{}".format(DEFAULT_NAME, self.sensor_type) + self._name = "{}.{}".format(DEFAULT_CONF_NAME, self.sensor_type) self._client = self.hass.data[DOMAIN_DATA]["client"] async def async_update(self): diff --git a/custom_components/grocy/config_flow.py b/custom_components/grocy/config_flow.py index 5d643c2..f8b758e 100644 --- a/custom_components/grocy/config_flow.py +++ b/custom_components/grocy/config_flow.py @@ -1,23 +1,42 @@ -"""Adds config flow for grocy.""" from collections import OrderedDict import logging import voluptuous as vol from homeassistant import config_entries +from homeassistant.core import callback from pygrocy import Grocy -from .const import DEFAULT_PORT_NUMBER, DOMAIN +from .const import ( + DEFAULT_PORT_NUMBER, + DOMAIN, + CONF_ALLOW_CHORES, + CONF_ALLOW_MEAL_PLAN, + CONF_ALLOW_PRODUCTS, + CONF_ALLOW_SHOPPING_LIST, + CONF_ALLOW_STOCK, + CONF_ALLOW_TASKS, + DEFAULT_CONF_ALLOW_CHORES, + DEFAULT_CONF_ALLOW_MEAL_PLAN, + DEFAULT_CONF_ALLOW_PRODUCTS, + DEFAULT_CONF_ALLOW_SHOPPING_LIST, + DEFAULT_CONF_ALLOW_STOCK, + DEFAULT_CONF_ALLOW_TASKS, +) _LOGGER = logging.getLogger(__name__) -@config_entries.HANDLERS.register(DOMAIN) -class GrocyFlowHandler(config_entries.ConfigFlow): +class GrocyFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): """Config flow for grocy.""" VERSION = 1 CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL + @staticmethod + @callback + def async_get_options_flow(config_entry): + return GrocyOptionsFlowHandler(config_entry) + def __init__(self): """Initialize.""" self._errors = {} @@ -50,7 +69,7 @@ async def async_step_user( return await self._show_config_form(user_input) async def _show_config_form(self, user_input): - """Show the configuration form to edit location data.""" + """Show the configuration form to edit the data.""" # Defaults url = "" @@ -97,3 +116,73 @@ async def _test_credentials(self, url, api_key, port, verify_ssl): _LOGGER.exception(e) pass return False + + +class GrocyOptionsFlowHandler(config_entries.OptionsFlow): + """Handle Grocy options.""" + + def __init__(self, config_entry): + """Initialize Grocy options flow.""" + self.config_entry = config_entry + self.options = dict(config_entry.options) + + async def async_step_init(self, user_input=None): + """Manage the Grocy options.""" + return await self.async_step_grocy_devices() + + async def async_step_grocy_devices(self, user_input=None): + """Manage the Grocy devices options.""" + if user_input is not None: + self.options[CONF_ALLOW_CHORES] = user_input[CONF_ALLOW_CHORES] + self.options[CONF_ALLOW_MEAL_PLAN] = user_input[CONF_ALLOW_MEAL_PLAN] + self.options[CONF_ALLOW_PRODUCTS] = user_input[CONF_ALLOW_PRODUCTS] + self.options[CONF_ALLOW_SHOPPING_LIST] = user_input[ + CONF_ALLOW_SHOPPING_LIST + ] + self.options[CONF_ALLOW_STOCK] = user_input[CONF_ALLOW_STOCK] + self.options[CONF_ALLOW_TASKS] = user_input[CONF_ALLOW_TASKS] + return self.async_create_entry(title="", data=self.options) + + return self.async_show_form( + step_id="grocy_devices", + data_schema=vol.Schema( + { + vol.Optional( + CONF_ALLOW_CHORES, + default=self.config_entry.options.get( + CONF_ALLOW_CHORES, DEFAULT_CONF_ALLOW_CHORES + ), + ): bool, + vol.Optional( + CONF_ALLOW_MEAL_PLAN, + default=self.config_entry.options.get( + CONF_ALLOW_MEAL_PLAN, DEFAULT_CONF_ALLOW_MEAL_PLAN + ), + ): bool, + vol.Optional( + CONF_ALLOW_PRODUCTS, + default=self.config_entry.options.get( + CONF_ALLOW_PRODUCTS, DEFAULT_CONF_ALLOW_PRODUCTS + ), + ): bool, + vol.Optional( + CONF_ALLOW_SHOPPING_LIST, + default=self.config_entry.options.get( + CONF_ALLOW_SHOPPING_LIST, DEFAULT_CONF_ALLOW_SHOPPING_LIST + ), + ): bool, + vol.Optional( + CONF_ALLOW_STOCK, + default=self.config_entry.options.get( + CONF_ALLOW_STOCK, DEFAULT_CONF_ALLOW_STOCK + ), + ): bool, + vol.Optional( + CONF_ALLOW_TASKS, + default=self.config_entry.options.get( + CONF_ALLOW_TASKS, DEFAULT_CONF_ALLOW_TASKS + ), + ): bool, + } + ), + ) diff --git a/custom_components/grocy/const.py b/custom_components/grocy/const.py index 8becc0f..424e55e 100644 --- a/custom_components/grocy/const.py +++ b/custom_components/grocy/const.py @@ -32,6 +32,7 @@ SENSOR_CHORES_UNIT_OF_MEASUREMENT = "Chore(s)" SENSOR_TASKS_UNIT_OF_MEASUREMENT = "Task(s)" SENSOR_MEALS_UNIT_OF_MEASUREMENT = "Meal(s)" + STOCK_NAME = "stock" CHORES_NAME = "chores" TASKS_NAME = "tasks" @@ -49,11 +50,22 @@ ] # Configuration -CONF_SENSOR = "sensor" -CONF_BINARY_SENSOR = "binary_sensor" CONF_ENABLED = "enabled" CONF_NAME = "name" +CONF_ALLOW_CHORES = "allow_chores" +CONF_ALLOW_MEAL_PLAN = "allow_meal_plan" +CONF_ALLOW_PRODUCTS = "allow_products" +CONF_ALLOW_SHOPPING_LIST = "allow_shopping_list" +CONF_ALLOW_STOCK = "allow_stock" +CONF_ALLOW_TASKS = "allow_tasks" + # Defaults -DEFAULT_NAME = DOMAIN +DEFAULT_CONF_NAME = DOMAIN DEFAULT_PORT_NUMBER = 9192 +DEFAULT_CONF_ALLOW_CHORES = False +DEFAULT_CONF_ALLOW_MEAL_PLAN = False +DEFAULT_CONF_ALLOW_PRODUCTS = True +DEFAULT_CONF_ALLOW_SHOPPING_LIST = False +DEFAULT_CONF_ALLOW_STOCK = True +DEFAULT_CONF_ALLOW_TASKS = False diff --git a/custom_components/grocy/sensor.py b/custom_components/grocy/sensor.py index 043d238..ea2f974 100644 --- a/custom_components/grocy/sensor.py +++ b/custom_components/grocy/sensor.py @@ -7,7 +7,7 @@ CHORES_NAME, TASKS_NAME, MEAL_PLAN_NAME, - DEFAULT_NAME, + DEFAULT_CONF_NAME, DOMAIN, DOMAIN_DATA, ICON, @@ -45,7 +45,7 @@ def __init__(self, hass, sensor_type): self._state = None self._hash_key = self.hass.data[DOMAIN_DATA]["hash_key"] self._unique_id = "{}-{}".format(self._hash_key, self.sensor_type) - self._name = "{}.{}".format(DEFAULT_NAME, self.sensor_type) + self._name = "{}.{}".format(DEFAULT_CONF_NAME, self.sensor_type) async def async_update(self): """Update the sensor.""" diff --git a/custom_components/grocy/translations/en.json b/custom_components/grocy/translations/en.json index 6a9f407..68ec0b7 100644 --- a/custom_components/grocy/translations/en.json +++ b/custom_components/grocy/translations/en.json @@ -18,5 +18,19 @@ "abort": { "single_instance_allowed": "Only a single configuration of Grocy is allowed." } + }, + "options": { + "step": { + "grocy_devices": { + "data": { + "allow_chores": "Chores enabled", + "allow_meal_plan": "Meal plan enabled", + "allow_products": "Products enabled", + "allow_shopping_list": "Shopping list enabled", + "allow_stock": "Stock enabled", + "allow_tasks": "Tasks enabled" + } + } + } } } \ No newline at end of file From d8f55d9607d2fd5f66ae7b35bff4463907364582 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Isabella=20Gross=20Alstr=C3=B6m?= Date: Mon, 17 Aug 2020 15:31:39 +0200 Subject: [PATCH 02/45] move imports to the top --- custom_components/grocy/__init__.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/custom_components/grocy/__init__.py b/custom_components/grocy/__init__.py index 6195480..dac814d 100644 --- a/custom_components/grocy/__init__.py +++ b/custom_components/grocy/__init__.py @@ -1,5 +1,5 @@ """ -The integration for grocy. +The integration for Grocy. """ import asyncio import hashlib @@ -15,6 +15,9 @@ from homeassistant.helpers import discovery, entity_component from homeassistant.util import Throttle from integrationhelper.const import CC_STARTUP_VERSION +from pygrocy import Grocy, TransactionType +from datetime import datetime +import iso8601 from .const import ( CHORES_NAME, @@ -46,15 +49,12 @@ async def async_setup(hass, config): - """Set up this component.""" + """Old setup way.""" return True async def async_setup_entry(hass, config_entry): """Set up this integration using UI.""" - from pygrocy import Grocy, TransactionType - from datetime import datetime - import iso8601 conf = hass.data.get(DOMAIN_DATA) if config_entry.source == config_entries.SOURCE_IMPORT: From 02a4063d2b2db873aaecd45fc9e0ac62955ad016 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Isabella=20Gross=20Alstr=C3=B6m?= Date: Mon, 17 Aug 2020 18:45:21 +0200 Subject: [PATCH 03/45] WIP: Move services to own file --- custom_components/grocy/services.py | 128 ++++++++++++++++++++++++++++ 1 file changed, 128 insertions(+) create mode 100644 custom_components/grocy/services.py diff --git a/custom_components/grocy/services.py b/custom_components/grocy/services.py new file mode 100644 index 0000000..7171485 --- /dev/null +++ b/custom_components/grocy/services.py @@ -0,0 +1,128 @@ +"""Grocy services.""" +# from pydeconz.utils import normalize_bridge_id +import voluptuous as vol + +from homeassistant.helpers import config_validation as cv + +# from .config_flow import get_instance +from pygrocy import TransactionType + +from .const import ( + # CONF_BRIDGE_ID, + DOMAIN, + # LOGGER, + # NEW_GROUP, + # NEW_LIGHT, + # NEW_SCENE, + # NEW_SENSOR, +) + +GROCY_SERVICES = "grocy_services" + +SERVICE_PRODUCT_ID = "product_id" +SERVICE_AMOUNT = "amount" +SERVICE_PRICE = "price" +SERVICE_SPOILED = "spoiled" +SERVICE_TRANSACTION_TYPE = "transaction_type" + +SERVICE_ADD_PRODUCT = "add_product" +SERVICE_CONSUME_PRODUCT = "consume_product" + +SERVICE_ADD_PRODUCT_SCHEMA = vol.All( + vol.Schema( + { + vol.Required(SERVICE_PRODUCT_ID): int, + vol.Required(SERVICE_AMOUNT): int, + vol.Optional(SERVICE_PRICE): str, + vol.Optional(SERVICE_PRICE): str, + } + ) +) + +SERVICE_CONSUME_PRODUCT_SCHEMA = vol.All( + vol.Schema( + { + vol.Required(SERVICE_PRODUCT_ID): int, + vol.Required(SERVICE_AMOUNT): int, + vol.Optional(SERVICE_SPOILED): bool, + vol.Optional(SERVICE_TRANSACTION_TYPE): TransactionType, + } + ) +) + + +async def async_setup_services(hass): + """Set up services for deCONZ integration.""" + if hass.data.get(GROCY_SERVICES, False): + return + + hass.data[GROCY_SERVICES] = True + + async def async_call_grocy_service(service_call): + """Call correct Grocy service.""" + service = service_call.service + service_data = service_call.data + + if service == SERVICE_ADD_PRODUCT: + await async_add_product_service(hass, service_data) + + elif service == SERVICE_CONSUME_PRODUCT: + await async_consume_product_service(hass, service_data) + + hass.services.async_register( + DOMAIN, + SERVICE_ADD_PRODUCT, + async_call_grocy_service, + schema=SERVICE_ADD_PRODUCT_SCHEMA, + ) + + hass.services.async_register( + DOMAIN, + SERVICE_CONSUME_PRODUCT, + async_call_grocy_service, + schema=SERVICE_CONSUME_PRODUCT_SCHEMA, + ) + + +async def async_unload_services(hass): + """Unload Grocy services.""" + if not hass.data.get(GROCY_SERVICES): + return + + hass.data[GROCY_SERVICES] = False + + hass.services.async_remove(DOMAIN, SERVICE_ADD_PRODUCT) + hass.services.async_remove(DOMAIN, SERVICE_CONSUME_PRODUCT) + + +async def async_add_product_service(hass, data): + """Add a product in Grocy.""" + # gateway = get_master_gateway(hass) + # if CONF_BRIDGE_ID in data: + # gateway = hass.data[DOMAIN][normalize_bridge_id(data[CONF_BRIDGE_ID])] + + product_id = data[SERVICE_PRODUCT_ID] + amount = data[SERVICE_AMOUNT] + price = data.get(SERVICE_PRICE, "") + + grocy.add_product(product_id, amount, price) + + +async def async_consume_product_service(hass, data): + """Consume a product in Grocy.""" + # gateway = get_master_gateway(hass) + # if CONF_BRIDGE_ID in data: + # gateway = hass.data[DOMAIN][normalize_bridge_id(data[CONF_BRIDGE_ID])] + + product_id = data[SERVICE_PRODUCT_ID] + amount = data[SERVICE_AMOUNT] + spoiled = data.get("spoiled", False) + + transaction_type_raw = data.get(SERVICE_TRANSACTION_TYPE, None) + transaction_type = TransactionType.CONSUME + + if transaction_type_raw is not None: + transaction_type = TransactionType[transaction_type_raw] + grocy.consume_product( + product_id, amount, spoiled=spoiled, transaction_type=transaction_type + ) From 18315122cd39932774fa7fbbd1c5f8baece5efc4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Isabella=20Gross=20Alstr=C3=B6m?= Date: Mon, 17 Aug 2020 18:56:03 +0200 Subject: [PATCH 04/45] Change logger to const --- custom_components/grocy/__init__.py | 18 ++++++++---------- custom_components/grocy/binary_sensor.py | 6 ++---- custom_components/grocy/config_flow.py | 8 +++----- custom_components/grocy/const.py | 4 ++++ custom_components/grocy/sensor.py | 5 +---- custom_components/grocy/services.py | 2 +- 6 files changed, 19 insertions(+), 24 deletions(-) diff --git a/custom_components/grocy/__init__.py b/custom_components/grocy/__init__.py index dac814d..b8eeb10 100644 --- a/custom_components/grocy/__init__.py +++ b/custom_components/grocy/__init__.py @@ -1,9 +1,8 @@ """ -The integration for Grocy. +The integration for grocy. """ import asyncio import hashlib -import logging import os from datetime import timedelta @@ -20,6 +19,7 @@ import iso8601 from .const import ( + LOGGER, CHORES_NAME, TASKS_NAME, CONF_ENABLED, @@ -45,8 +45,6 @@ MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=60) -_LOGGER = logging.getLogger(__name__) - async def async_setup(hass, config): """Old setup way.""" @@ -65,7 +63,7 @@ async def async_setup_entry(hass, config_entry): return False # Print startup message - _LOGGER.info( + LOGGER.info( CC_STARTUP_VERSION.format(name=DOMAIN, version=VERSION, issue_link=ISSUE_URL) ) @@ -301,7 +299,7 @@ def check_files(hass): missing.append(file) if missing: - _LOGGER.critical("The following files are missing: %s", str(missing)) + LOGGER.critical("The following files are missing: %s", str(missing)) returnvalue = False else: returnvalue = True @@ -313,15 +311,15 @@ async def async_remove_entry(hass, config_entry): """Handle removal of an entry.""" try: await hass.config_entries.async_forward_entry_unload(config_entry, "sensor") - _LOGGER.info("Successfully removed sensor from the grocy integration") + LOGGER.info("Successfully removed sensor from the grocy integration") except ValueError as error: - _LOGGER.exception(error) + LOGGER.exception(error) pass try: await hass.config_entries.async_forward_entry_unload( config_entry, "binary_sensor" ) - _LOGGER.info("Successfully removed sensor from the grocy integration") + LOGGER.info("Successfully removed sensor from the grocy integration") except ValueError as error: - _LOGGER.exception(error) + LOGGER.exception(error) pass diff --git a/custom_components/grocy/binary_sensor.py b/custom_components/grocy/binary_sensor.py index 8e96d0e..8949bc6 100644 --- a/custom_components/grocy/binary_sensor.py +++ b/custom_components/grocy/binary_sensor.py @@ -1,5 +1,4 @@ """Binary sensor platform for grocy.""" -import logging from homeassistant.components.binary_sensor import BinarySensorEntity from .const import ( @@ -8,10 +7,9 @@ DEFAULT_CONF_NAME, DOMAIN, DOMAIN_DATA, + LOGGER, ) -_LOGGER = logging.getLogger(__name__) - async def async_setup_platform( hass, config, async_add_entities, discovery_info=None @@ -48,7 +46,7 @@ async def async_update(self): x.as_dict() for x in self.hass.data[DOMAIN_DATA].get(self.sensor_type, []) ] self._status = len(self.attr["items"]) != 0 - _LOGGER.debug(self.attr) + LOGGER.debug(self.attr) @property def unique_id(self): diff --git a/custom_components/grocy/config_flow.py b/custom_components/grocy/config_flow.py index f8b758e..48f7935 100644 --- a/custom_components/grocy/config_flow.py +++ b/custom_components/grocy/config_flow.py @@ -1,6 +1,5 @@ from collections import OrderedDict -import logging import voluptuous as vol from homeassistant import config_entries from homeassistant.core import callback @@ -21,10 +20,9 @@ DEFAULT_CONF_ALLOW_SHOPPING_LIST, DEFAULT_CONF_ALLOW_STOCK, DEFAULT_CONF_ALLOW_TASKS, + LOGGER, ) -_LOGGER = logging.getLogger(__name__) - class GrocyFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): """Config flow for grocy.""" @@ -62,7 +60,7 @@ async def async_step_user( return self.async_create_entry(title="Grocy", data=user_input) else: self._errors["base"] = "auth" - _LOGGER.error(self._errors) + LOGGER.error(self._errors) return await self._show_config_form(user_input) @@ -113,7 +111,7 @@ async def _test_credentials(self, url, api_key, port, verify_ssl): await self.hass.async_add_executor_job(client.stock) return True except Exception as e: # pylint: disable=broad-except - _LOGGER.exception(e) + LOGGER.exception(e) pass return False diff --git a/custom_components/grocy/const.py b/custom_components/grocy/const.py index 424e55e..3c94339 100644 --- a/custom_components/grocy/const.py +++ b/custom_components/grocy/const.py @@ -1,4 +1,8 @@ """Constants for grocy.""" +import logging + +LOGGER = logging.getLogger(__package__) + # Base component constants DOMAIN = "grocy" DOMAIN_DATA = "{}_data".format(DOMAIN) diff --git a/custom_components/grocy/sensor.py b/custom_components/grocy/sensor.py index ea2f974..c86b1fb 100644 --- a/custom_components/grocy/sensor.py +++ b/custom_components/grocy/sensor.py @@ -1,5 +1,4 @@ """Sensor platform for grocy.""" -import logging from homeassistant.helpers.entity import Entity from .const import ( @@ -18,8 +17,6 @@ SENSOR_TYPES, ) -_LOGGER = logging.getLogger(__name__) - async def async_setup_platform( hass, config, async_add_entities, discovery_info=None @@ -56,7 +53,7 @@ async def async_update(self): x.as_dict() for x in self.hass.data[DOMAIN_DATA].get(self.sensor_type, []) ] self._state = len(self.attr["items"]) - _LOGGER.debug(self.attr) + LOGGER.debug(self.attr) @property def unique_id(self): diff --git a/custom_components/grocy/services.py b/custom_components/grocy/services.py index 7171485..36fb726 100644 --- a/custom_components/grocy/services.py +++ b/custom_components/grocy/services.py @@ -10,7 +10,7 @@ from .const import ( # CONF_BRIDGE_ID, DOMAIN, - # LOGGER, + LOGGER, # NEW_GROUP, # NEW_LIGHT, # NEW_SCENE, From 675c131ac79451c6bc70b634ec644f0c9c3e5ff1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Isabella=20Gross=20Alstr=C3=B6m?= Date: Mon, 17 Aug 2020 18:56:45 +0200 Subject: [PATCH 05/45] Add const logger to sensor --- custom_components/grocy/sensor.py | 1 + 1 file changed, 1 insertion(+) diff --git a/custom_components/grocy/sensor.py b/custom_components/grocy/sensor.py index c86b1fb..6fd1c71 100644 --- a/custom_components/grocy/sensor.py +++ b/custom_components/grocy/sensor.py @@ -8,6 +8,7 @@ MEAL_PLAN_NAME, DEFAULT_CONF_NAME, DOMAIN, + LOGGER, DOMAIN_DATA, ICON, SENSOR_CHORES_UNIT_OF_MEASUREMENT, From 578874e479a918c5ddfb17d5c311c396eeddfc2b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Isabella=20Gross=20Alstr=C3=B6m?= Date: Mon, 17 Aug 2020 19:09:19 +0200 Subject: [PATCH 06/45] Use DOMAIN instead of DOMAIN_DATA --- custom_components/grocy/__init__.py | 45 ++++++++++++------------ custom_components/grocy/binary_sensor.py | 7 ++-- custom_components/grocy/const.py | 1 - custom_components/grocy/sensor.py | 7 ++-- 4 files changed, 28 insertions(+), 32 deletions(-) diff --git a/custom_components/grocy/__init__.py b/custom_components/grocy/__init__.py index b8eeb10..07d0696 100644 --- a/custom_components/grocy/__init__.py +++ b/custom_components/grocy/__init__.py @@ -27,7 +27,6 @@ DEFAULT_CONF_NAME, DEFAULT_PORT_NUMBER, DOMAIN, - DOMAIN_DATA, EXPIRED_PRODUCTS_NAME, EXPIRING_PRODUCTS_NAME, ISSUE_URL, @@ -54,7 +53,7 @@ async def async_setup(hass, config): async def async_setup_entry(hass, config_entry): """Set up this integration using UI.""" - conf = hass.data.get(DOMAIN_DATA) + conf = hass.data.get(DOMAIN) if config_entry.source == config_entries.SOURCE_IMPORT: if conf is None: hass.async_create_task( @@ -72,7 +71,7 @@ async def async_setup_entry(hass, config_entry): return False # Create DATA dict - hass.data[DOMAIN_DATA] = {} + hass.data[DOMAIN] = {} # Get "global" configuration. url = config_entry.data.get(CONF_URL) @@ -83,9 +82,9 @@ async def async_setup_entry(hass, config_entry): # Configure the client. grocy = Grocy(url, api_key, port_number, verify_ssl) - hass.data[DOMAIN_DATA]["client"] = GrocyData(hass, grocy) - hass.data[DOMAIN_DATA]["hash_key"] = hash_key - hass.data[DOMAIN_DATA]["url"] = f"{url}:{port_number}" + hass.data[DOMAIN]["client"] = GrocyData(hass, grocy) + hass.data[DOMAIN]["hash_key"] = hash_key + hass.data[DOMAIN]["url"] = f"{url}:{port_number}" # Add sensors hass.async_add_job( @@ -209,9 +208,9 @@ async def async_update_data(self, sensor_type): async def async_update_stock(self): """Update data.""" # This is where the main logic to update platform data goes. - self.hass.data[DOMAIN_DATA][ - STOCK_NAME - ] = await self.hass.async_add_executor_job(self.client.stock) + self.hass.data[DOMAIN][STOCK_NAME] = await self.hass.async_add_executor_job( + self.client.stock + ) async def async_update_chores(self): """Update data.""" @@ -219,17 +218,17 @@ async def async_update_chores(self): def wrapper(): return self.client.chores(True) - self.hass.data[DOMAIN_DATA][ - CHORES_NAME - ] = await self.hass.async_add_executor_job(wrapper) + self.hass.data[DOMAIN][CHORES_NAME] = 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. - self.hass.data[DOMAIN_DATA][ - TASKS_NAME - ] = await self.hass.async_add_executor_job(self.client.tasks) + self.hass.data[DOMAIN][TASKS_NAME] = await self.hass.async_add_executor_job( + self.client.tasks + ) async def async_update_shopping_list(self): """Update data.""" @@ -237,7 +236,7 @@ async def async_update_shopping_list(self): def wrapper(): return self.client.shopping_list(True) - self.hass.data[DOMAIN_DATA][ + self.hass.data[DOMAIN][ SHOPPING_LIST_NAME ] = await self.hass.async_add_executor_job(wrapper) @@ -248,7 +247,7 @@ async def async_update_expiring_products(self): def wrapper(): return self.client.expiring_products(True) - self.hass.data[DOMAIN_DATA][ + self.hass.data[DOMAIN][ EXPIRING_PRODUCTS_NAME ] = await self.hass.async_add_executor_job(wrapper) @@ -259,7 +258,7 @@ async def async_update_expired_products(self): def wrapper(): return self.client.expired_products(True) - self.hass.data[DOMAIN_DATA][ + self.hass.data[DOMAIN][ EXPIRED_PRODUCTS_NAME ] = await self.hass.async_add_executor_job(wrapper) @@ -270,7 +269,7 @@ async def async_update_missing_products(self): def wrapper(): return self.client.missing_products(True) - self.hass.data[DOMAIN_DATA][ + self.hass.data[DOMAIN][ MISSING_PRODUCTS_NAME ] = await self.hass.async_add_executor_job(wrapper) @@ -280,12 +279,12 @@ async def async_update_meal_plan(self): # This is where the main logic to update platform data goes. def wrapper(): meal_plan = self.client.meal_plan(True) - base_url = self.hass.data[DOMAIN_DATA]["url"] + base_url = self.hass.data[DOMAIN]["url"] return [MealPlanItem(item, base_url) for item in meal_plan] - self.hass.data[DOMAIN_DATA][ - MEAL_PLAN_NAME - ] = await self.hass.async_add_executor_job(wrapper) + self.hass.data[DOMAIN][MEAL_PLAN_NAME] = await self.hass.async_add_executor_job( + wrapper + ) def check_files(hass): diff --git a/custom_components/grocy/binary_sensor.py b/custom_components/grocy/binary_sensor.py index 8949bc6..8222171 100644 --- a/custom_components/grocy/binary_sensor.py +++ b/custom_components/grocy/binary_sensor.py @@ -6,7 +6,6 @@ BINARY_SENSOR_TYPES, DEFAULT_CONF_NAME, DOMAIN, - DOMAIN_DATA, LOGGER, ) @@ -32,10 +31,10 @@ def __init__(self, hass, sensor_type): self.sensor_type = sensor_type self.attr = {} self._status = False - self._hash_key = self.hass.data[DOMAIN_DATA]["hash_key"] + self._hash_key = self.hass.data[DOMAIN]["hash_key"] self._unique_id = "{}-{}".format(self._hash_key, self.sensor_type) self._name = "{}.{}".format(DEFAULT_CONF_NAME, self.sensor_type) - self._client = self.hass.data[DOMAIN_DATA]["client"] + self._client = self.hass.data[DOMAIN]["client"] async def async_update(self): """Update the binary_sensor.""" @@ -43,7 +42,7 @@ async def async_update(self): await self._client.async_update_data(self.sensor_type) self.attr["items"] = [ - x.as_dict() for x in self.hass.data[DOMAIN_DATA].get(self.sensor_type, []) + x.as_dict() for x in self.hass.data[DOMAIN].get(self.sensor_type, []) ] self._status = len(self.attr["items"]) != 0 LOGGER.debug(self.attr) diff --git a/custom_components/grocy/const.py b/custom_components/grocy/const.py index 3c94339..61172cb 100644 --- a/custom_components/grocy/const.py +++ b/custom_components/grocy/const.py @@ -5,7 +5,6 @@ # Base component constants DOMAIN = "grocy" -DOMAIN_DATA = "{}_data".format(DOMAIN) VERSION = "0.4.0" PLATFORMS = ["sensor", "binary_sensor"] REQUIRED_FILES = [ diff --git a/custom_components/grocy/sensor.py b/custom_components/grocy/sensor.py index 6fd1c71..403f010 100644 --- a/custom_components/grocy/sensor.py +++ b/custom_components/grocy/sensor.py @@ -9,7 +9,6 @@ DEFAULT_CONF_NAME, DOMAIN, LOGGER, - DOMAIN_DATA, ICON, SENSOR_CHORES_UNIT_OF_MEASUREMENT, SENSOR_TASKS_UNIT_OF_MEASUREMENT, @@ -41,17 +40,17 @@ def __init__(self, hass, sensor_type): self.sensor_type = sensor_type self.attr = {} self._state = None - self._hash_key = self.hass.data[DOMAIN_DATA]["hash_key"] + self._hash_key = self.hass.data[DOMAIN]["hash_key"] self._unique_id = "{}-{}".format(self._hash_key, self.sensor_type) self._name = "{}.{}".format(DEFAULT_CONF_NAME, self.sensor_type) async def async_update(self): """Update the sensor.""" # Send update "signal" to the component - await self.hass.data[DOMAIN_DATA]["client"].async_update_data(self.sensor_type) + await self.hass.data[DOMAIN]["client"].async_update_data(self.sensor_type) self.attr["items"] = [ - x.as_dict() for x in self.hass.data[DOMAIN_DATA].get(self.sensor_type, []) + x.as_dict() for x in self.hass.data[DOMAIN].get(self.sensor_type, []) ] self._state = len(self.attr["items"]) LOGGER.debug(self.attr) From ea0ac98fc2e7b6a20301f87f5e0aa67ad137ea9b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Isabella=20Gross=20Alstr=C3=B6m?= Date: Mon, 17 Aug 2020 20:20:06 +0200 Subject: [PATCH 07/45] Refactor services to own file --- custom_components/grocy/__init__.py | 69 +------------ custom_components/grocy/const.py | 2 +- custom_components/grocy/services.py | 134 +++++++++++++++++++++++--- custom_components/grocy/services.yaml | 6 +- 4 files changed, 129 insertions(+), 82 deletions(-) diff --git a/custom_components/grocy/__init__.py b/custom_components/grocy/__init__.py index 07d0696..22001a0 100644 --- a/custom_components/grocy/__init__.py +++ b/custom_components/grocy/__init__.py @@ -41,6 +41,7 @@ ) from .helpers import MealPlanItem +from .services import async_setup_services MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=60) @@ -82,6 +83,7 @@ async def async_setup_entry(hass, config_entry): # Configure the client. grocy = Grocy(url, api_key, port_number, verify_ssl) + hass.data[DOMAIN]["instance"] = grocy hass.data[DOMAIN]["client"] = GrocyData(hass, grocy) hass.data[DOMAIN]["hash_key"] = hash_key hass.data[DOMAIN]["url"] = f"{url}:{port_number}" @@ -95,71 +97,8 @@ async def async_setup_entry(hass, config_entry): hass.config_entries.async_forward_entry_setup(config_entry, "binary_sensor") ) - @callback - def handle_add_product(call): - product_id = call.data["product_id"] - amount = call.data.get("amount", 0) - price = call.data.get("price", None) - grocy.add_product(product_id, amount, price) - - hass.services.async_register(DOMAIN, "add_product", handle_add_product) - - @callback - def handle_consume_product(call): - product_id = call.data["product_id"] - amount = call.data.get("amount", 0) - spoiled = call.data.get("spoiled", False) - - transaction_type_raw = call.data.get("transaction_type", None) - transaction_type = TransactionType.CONSUME - - if transaction_type_raw is not None: - transaction_type = TransactionType[transaction_type_raw] - grocy.consume_product( - product_id, amount, spoiled=spoiled, transaction_type=transaction_type - ) - - hass.services.async_register(DOMAIN, "consume_product", handle_consume_product) - - @callback - def handle_execute_chore(call): - chore_id = call.data["chore_id"] - done_by = call.data.get("done_by", None) - tracked_time_str = call.data.get("tracked_time", None) - - tracked_time = datetime.now() - if tracked_time_str is not None: - tracked_time = iso8601.parse_date(tracked_time_str) - grocy.execute_chore(chore_id, done_by, tracked_time) - asyncio.run_coroutine_threadsafe( - entity_component.async_update_entity(hass, "sensor.grocy_chores"), hass.loop - ) - - hass.services.async_register(DOMAIN, "execute_chore", handle_execute_chore) - - @callback - def handle_complete_task(call): - task_id = call.data["task_id"] - done_time_str = call.data.get("done_time", None) - - done_time = datetime.now() - if done_time_str is not None: - done_time = iso8601.parse_date(done_time_str) - grocy.complete_task(task_id, done_time) - asyncio.run_coroutine_threadsafe( - entity_component.async_update_entity(hass, "sensor.grocy_tasks"), hass.loop - ) - - hass.services.async_register(DOMAIN, "complete_task", handle_complete_task) - - @callback - def handle_add_generic(call): - entity_type = call.data["entity_type"] - data = call.data["data"] - - grocy.add_generic(entity_type, data) - - hass.services.async_register(DOMAIN, "add_generic", handle_add_generic) + # Setup services + await async_setup_services(hass) return True diff --git a/custom_components/grocy/const.py b/custom_components/grocy/const.py index 61172cb..5ad6277 100644 --- a/custom_components/grocy/const.py +++ b/custom_components/grocy/const.py @@ -5,7 +5,7 @@ # Base component constants DOMAIN = "grocy" -VERSION = "0.4.0" +VERSION = "1.2.0" PLATFORMS = ["sensor", "binary_sensor"] REQUIRED_FILES = [ "const.py", diff --git a/custom_components/grocy/services.py b/custom_components/grocy/services.py index 36fb726..0ffe1f8 100644 --- a/custom_components/grocy/services.py +++ b/custom_components/grocy/services.py @@ -1,11 +1,15 @@ """Grocy services.""" # from pydeconz.utils import normalize_bridge_id +import asyncio import voluptuous as vol +import iso8601 from homeassistant.helpers import config_validation as cv +from homeassistant.helpers import entity_component # from .config_flow import get_instance from pygrocy import TransactionType +from datetime import timedelta, datetime from .const import ( # CONF_BRIDGE_ID, @@ -24,9 +28,19 @@ SERVICE_PRICE = "price" SERVICE_SPOILED = "spoiled" SERVICE_TRANSACTION_TYPE = "transaction_type" - -SERVICE_ADD_PRODUCT = "add_product" -SERVICE_CONSUME_PRODUCT = "consume_product" +SERVICE_CHORE_ID = "chore_id" +SERVICE_TRACKED_TIME = "tracked_time" +SERVICE_DONE_BY = "done_by" +SERVICE_TASK_ID = "task_id" +SERVICE_DONE_TIME = "done_time" +SERVICE_ENTITY_TYPE = "entity_type" +SERVICE_DATA = "data" + +SERVICE_ADD_PRODUCT = "add_product_to_stock" +SERVICE_CONSUME_PRODUCT = "consume_product_from_stock" +SERVICE_EXECUTE_CHORE = "execute_chore" +SERVICE_COMPLETE_TASK = "complete_task" +SERVICE_ADD_GENERIC = "add_generic" SERVICE_ADD_PRODUCT_SCHEMA = vol.All( vol.Schema( @@ -34,7 +48,6 @@ vol.Required(SERVICE_PRODUCT_ID): int, vol.Required(SERVICE_AMOUNT): int, vol.Optional(SERVICE_PRICE): str, - vol.Optional(SERVICE_PRICE): str, } ) ) @@ -45,14 +58,36 @@ vol.Required(SERVICE_PRODUCT_ID): int, vol.Required(SERVICE_AMOUNT): int, vol.Optional(SERVICE_SPOILED): bool, - vol.Optional(SERVICE_TRANSACTION_TYPE): TransactionType, + vol.Optional(SERVICE_TRANSACTION_TYPE): str, } ) ) +SERVICE_EXECUTE_CHORE_SCHEMA = vol.All( + vol.Schema( + { + vol.Required(SERVICE_CHORE_ID): int, + vol.Optional(SERVICE_DONE_BY): int, + vol.Optional(SERVICE_TRACKED_TIME): str, + } + ) +) + +SERVICE_COMPLETE_TASK_SCHEMA = vol.All( + vol.Schema( + {vol.Required(SERVICE_TASK_ID): int, vol.Optional(SERVICE_DONE_TIME): str,} + ) +) + +SERVICE_ADD_GENERIC_SCHEMA = vol.All( + vol.Schema( + {vol.Required(SERVICE_ENTITY_TYPE): str, vol.Required(SERVICE_DATA): object,} + ) +) + async def async_setup_services(hass): - """Set up services for deCONZ integration.""" + """Set up services for Grocy integration.""" if hass.data.get(GROCY_SERVICES, False): return @@ -69,6 +104,15 @@ async def async_call_grocy_service(service_call): elif service == SERVICE_CONSUME_PRODUCT: await async_consume_product_service(hass, service_data) + elif service == SERVICE_EXECUTE_CHORE: + await async_execute_chore_service(hass, service_data) + + elif service == SERVICE_COMPLETE_TASK: + await async_complete_task_service(hass, service_data) + + elif service == SERVICE_ADD_GENERIC: + await async_add_generic_service(hass, service_data) + hass.services.async_register( DOMAIN, SERVICE_ADD_PRODUCT, @@ -83,6 +127,27 @@ async def async_call_grocy_service(service_call): schema=SERVICE_CONSUME_PRODUCT_SCHEMA, ) + hass.services.async_register( + DOMAIN, + SERVICE_EXECUTE_CHORE, + async_call_grocy_service, + schema=SERVICE_EXECUTE_CHORE_SCHEMA, + ) + + hass.services.async_register( + DOMAIN, + SERVICE_COMPLETE_TASK, + async_call_grocy_service, + schema=SERVICE_COMPLETE_TASK_SCHEMA, + ) + + hass.services.async_register( + DOMAIN, + SERVICE_ADD_GENERIC, + async_call_grocy_service, + schema=SERVICE_ADD_GENERIC_SCHEMA, + ) + async def async_unload_services(hass): """Unload Grocy services.""" @@ -93,13 +158,13 @@ async def async_unload_services(hass): hass.services.async_remove(DOMAIN, SERVICE_ADD_PRODUCT) hass.services.async_remove(DOMAIN, SERVICE_CONSUME_PRODUCT) + hass.services.async_remove(DOMAIN, SERVICE_EXECUTE_CHORE) + hass.services.async_remove(DOMAIN, SERVICE_COMPLETE_TASK) async def async_add_product_service(hass, data): """Add a product in Grocy.""" - # gateway = get_master_gateway(hass) - # if CONF_BRIDGE_ID in data: - # gateway = hass.data[DOMAIN][normalize_bridge_id(data[CONF_BRIDGE_ID])] + grocy = hass.data[DOMAIN]["instance"] product_id = data[SERVICE_PRODUCT_ID] amount = data[SERVICE_AMOUNT] @@ -110,13 +175,11 @@ async def async_add_product_service(hass, data): async def async_consume_product_service(hass, data): """Consume a product in Grocy.""" - # gateway = get_master_gateway(hass) - # if CONF_BRIDGE_ID in data: - # gateway = hass.data[DOMAIN][normalize_bridge_id(data[CONF_BRIDGE_ID])] + grocy = hass.data[DOMAIN]["instance"] product_id = data[SERVICE_PRODUCT_ID] amount = data[SERVICE_AMOUNT] - spoiled = data.get("spoiled", False) + spoiled = data.get(SERVICE_SPOILED, False) transaction_type_raw = data.get(SERVICE_TRANSACTION_TYPE, None) transaction_type = TransactionType.CONSUME @@ -126,3 +189,48 @@ async def async_consume_product_service(hass, data): grocy.consume_product( product_id, amount, spoiled=spoiled, transaction_type=transaction_type ) + + +async def async_execute_chore_service(hass, data): + """Execute a chore in Grocy.""" + grocy = hass.data[DOMAIN]["instance"] + + chore_id = data[SERVICE_CHORE_ID] + done_by = data.get(SERVICE_DONE_BY, "") + tracked_time_str = data.get(SERVICE_TRACKED_TIME, "") + + tracked_time = datetime.now() + if tracked_time_str is not None and tracked_time_str != "": + tracked_time = iso8601.parse_date(tracked_time_str) + + grocy.execute_chore(chore_id, done_by, tracked_time) + asyncio.run_coroutine_threadsafe( + entity_component.async_update_entity(hass, "sensor.grocy_chores"), hass.loop + ) + + +async def async_complete_task_service(hass, data): + """Complete a task in Grocy.""" + grocy = hass.data[DOMAIN]["instance"] + + task_id = data[SERVICE_TASK_ID] + done_time_str = data.get(SERVICE_DONE_TIME, None) + + done_time = datetime.now() + if done_time_str is not None and done_time_str != "": + done_time = iso8601.parse_date(done_time_str) + + grocy.complete_task(task_id, done_time) + asyncio.run_coroutine_threadsafe( + entity_component.async_update_entity(hass, "sensor.grocy_tasks"), hass.loop + ) + + +async def async_add_generic_service(hass, data): + """Add a generic entity in Grocy.""" + grocy = hass.data[DOMAIN]["instance"] + + entity_type = data[SERVICE_ENTITY_TYPE] + data = data[SERVICE_DATA] + + grocy.add_generic(entity_type, data) diff --git a/custom_components/grocy/services.yaml b/custom_components/grocy/services.yaml index 667941b..c3b406e 100644 --- a/custom_components/grocy/services.yaml +++ b/custom_components/grocy/services.yaml @@ -1,4 +1,4 @@ -add_product: +add_product_to_stock: description: Adds a given amount of a product to the stock fields: product_id: @@ -8,9 +8,9 @@ add_product: description: The amount to add to stock example: 3.0 price: - example: 1.99 + example: "1.99" description: The purchase price per purchase quantity unit of the added product -consume_product: +consume_product_from_stock: description: Consumes a given amount of a product to the stock fields: product_id: From 60ead8abc288586e4c6a9909af34e909797548a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Isabella=20Gross=20Alstr=C3=B6m?= Date: Mon, 17 Aug 2020 20:23:22 +0200 Subject: [PATCH 08/45] remove comments --- custom_components/grocy/services.py | 7 ------- 1 file changed, 7 deletions(-) diff --git a/custom_components/grocy/services.py b/custom_components/grocy/services.py index 0ffe1f8..6570758 100644 --- a/custom_components/grocy/services.py +++ b/custom_components/grocy/services.py @@ -1,5 +1,4 @@ """Grocy services.""" -# from pydeconz.utils import normalize_bridge_id import asyncio import voluptuous as vol import iso8601 @@ -7,18 +6,12 @@ from homeassistant.helpers import config_validation as cv from homeassistant.helpers import entity_component -# from .config_flow import get_instance from pygrocy import TransactionType from datetime import timedelta, datetime from .const import ( - # CONF_BRIDGE_ID, DOMAIN, LOGGER, - # NEW_GROUP, - # NEW_LIGHT, - # NEW_SCENE, - # NEW_SENSOR, ) GROCY_SERVICES = "grocy_services" From 8d1c0cfa4b1e369af0f0bafcbf51dfcb5f72f8d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Isabella=20Gross=20Alstr=C3=B6m?= Date: Tue, 18 Aug 2020 08:51:31 +0200 Subject: [PATCH 09/45] Keep track of breaking changes for the refactoring --- custom_components/grocy/breaking_changes | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 custom_components/grocy/breaking_changes diff --git a/custom_components/grocy/breaking_changes b/custom_components/grocy/breaking_changes new file mode 100644 index 0000000..7b6d274 --- /dev/null +++ b/custom_components/grocy/breaking_changes @@ -0,0 +1,5 @@ +services: + add_project -> add_product_to_stock + price -> str (was int) + + consume_product -> consume_product_from_stock \ No newline at end of file From 62c3e04a363c1d703f8cf6933693a211c1e03dec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Isabella=20Gross=20Alstr=C3=B6m?= Date: Tue, 18 Aug 2020 08:52:15 +0200 Subject: [PATCH 10/45] WIP: move to instance.py --- custom_components/grocy/instance.py | 305 ++++++++++++++++++++++++++++ 1 file changed, 305 insertions(+) create mode 100644 custom_components/grocy/instance.py diff --git a/custom_components/grocy/instance.py b/custom_components/grocy/instance.py new file mode 100644 index 0000000..abfb0ab --- /dev/null +++ b/custom_components/grocy/instance.py @@ -0,0 +1,305 @@ +""" Representation of a Grocy instance """ +import asyncio +import hashlib + +from datetime import timedelta +from pygrocy import Grocy, TransactionType + +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.const import CONF_API_KEY, CONF_PORT, CONF_URL, CONF_VERIFY_SSL +from homeassistant.util import Throttle + +from .helpers import MealPlanItem +from .const import ( + CONF_ALLOW_CHORES, + CONF_ALLOW_MEAL_PLAN, + CONF_ALLOW_PRODUCTS, + CONF_ALLOW_SHOPPING_LIST, + CONF_ALLOW_STOCK, + CONF_ALLOW_TASKS, + CONF_MASTER_INSTANCE, + DEFAULT_CONF_ALLOW_CHORES, + DEFAULT_CONF_ALLOW_MEAL_PLAN, + DEFAULT_CONF_ALLOW_PRODUCTS, + DEFAULT_CONF_ALLOW_SHOPPING_LIST, + DEFAULT_CONF_ALLOW_STOCK, + DEFAULT_CONF_ALLOW_TASKS, + CHORES_NAME, + TASKS_NAME, + CONF_ENABLED, + CONF_NAME, + DEFAULT_CONF_NAME, + DEFAULT_PORT_NUMBER, + DOMAIN, + EXPIRED_PRODUCTS_NAME, + EXPIRING_PRODUCTS_NAME, + ISSUE_URL, + MISSING_PRODUCTS_NAME, + MEAL_PLAN_NAME, + PLATFORMS, + REQUIRED_FILES, + SHOPPING_LIST_NAME, + STARTUP, + STOCK_NAME, + VERSION, + LOGGER, +) + +MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=60) + + +class GrocyInstance: + """ Manages a single Grocy instance """ + + def __init__(self, hass, config_entry) -> None: + """Initialize the system.""" + self.hass = hass + self.config_entry = config_entry + + self.available = True + self.api = None + + self._current_option_allow_chores = self.option_allow_chores + self._current_option_allow_meal_plan = self.option_allow_meal_plan + self._current_option_allow_products = self.option_allow_products + self._current_option_allow_shopping_list = self.option_allow_shopping_list + self._current_option_allow_stock = self.option_allow_stock + self._current_option_allow_tasks = self.option_allow_tasks + + @property + def client(self) -> str: + """Return the unique identifier of the instance.""" + return self.config_entry.unique_id + + @property + def instanceid(self) -> str: + """Return the unique identifier of the instance.""" + return self.config_entry.unique_id + + @property + def master(self) -> bool: + """Instance which is used with Grocy without defining id.""" + return self.config_entry.options[CONF_MASTER_INSTANCE] + + @property + def option_allow_chores(self) -> bool: + """Allow loading chores sensor from instance.""" + return self.config_entry.options.get( + CONF_ALLOW_CHORES, DEFAULT_CONF_ALLOW_CHORES + ) + + @property + def option_allow_meal_plan(self) -> bool: + """Allow loading meal plan sensor from instance.""" + return self.config_entry.options.get( + CONF_ALLOW_MEAL_PLAN, DEFAULT_CONF_ALLOW_MEAL_PLAN + ) + + @property + def option_allow_products(self) -> bool: + """Allow loading products sensor from instance.""" + return self.config_entry.options.get( + CONF_ALLOW_PRODUCTS, DEFAULT_CONF_ALLOW_PRODUCTS + ) + + @property + def option_allow_shopping_list(self) -> bool: + """Allow loading shopping list sensor from instance.""" + return self.config_entry.options.get( + CONF_ALLOW_SHOPPING_LIST, DEFAULT_CONF_ALLOW_SHOPPING_LIST + ) + + @property + def option_allow_stock(self) -> bool: + """Allow loading stock sensors from instance.""" + return self.config_entry.options.get(CONF_ALLOW_STOCK, DEFAULT_CONF_ALLOW_STOCK) + + @property + def option_allow_tasks(self) -> bool: + """Allow loading tasks sensor from instance.""" + return self.config_entry.options.get(CONF_ALLOW_TASKS, DEFAULT_CONF_ALLOW_TASKS) + + async def async_setup(self) -> bool: + """Set up a Grocy instance.""" + LOGGER.debug("Setting up") + try: + self.api = await get_instance( + self.hass, + self.config_entry.data, + # self.async_add_device_callback, + # self.async_connection_status_callback, + ) + + # except CannotConnect: + # raise ConfigEntryNotReady + + except Exception as err: # pylint: disable=broad-except + LOGGER.error("Error connecting with deCONZ gateway: %s", err) + return False + + # for component in SUPPORTED_PLATFORMS: + # self.hass.async_create_task( + # self.hass.config_entries.async_forward_entry_setup( + # self.config_entry, component + # ) + # ) + + # self.api.start() + + # self.config_entry.add_update_listener(self.async_config_entry_updated) + + return True + + +async def get_instance(hass, config) -> Grocy: + """Create a gateway object and verify configuration.""" + # session = aiohttp_client.async_get_clientsession(hass) + + url = config.get(CONF_URL) + api_key = config.get(CONF_API_KEY) + verify_ssl = config.get(CONF_VERIFY_SSL) + port_number = config.get(CONF_PORT) + hash_key = hashlib.md5(api_key.encode("utf-8") + url.encode("utf-8")).hexdigest() + + grocy = Grocy(url, api_key, port_number, verify_ssl) + hass.data[DOMAIN]["client"] = GrocyData(hass, grocy) + hass.data[DOMAIN]["hash_key"] = hash_key + hass.data[DOMAIN]["url"] = f"{url}:{port_number}" + + return grocy + # try: + # with async_timeout.timeout(10): + # await deconz.initialize() + # return deconz + + # except errors.Unauthorized: + # LOGGER.warning("Invalid key for deCONZ at %s", config[CONF_HOST]) + # raise AuthenticationRequired + + # except (asyncio.TimeoutError, errors.RequestError): + # LOGGER.error("Error connecting to deCONZ gateway at %s", config[CONF_HOST]) + # raise CannotConnect + + +class GrocyData: + """This class handle communication and stores the data.""" + + def __init__(self, hass, client): + """Initialize the class.""" + self.hass = hass + self.client = client + self.sensor_types_dict = { + STOCK_NAME: self.async_update_stock, + CHORES_NAME: self.async_update_chores, + TASKS_NAME: self.async_update_tasks, + SHOPPING_LIST_NAME: self.async_update_shopping_list, + EXPIRING_PRODUCTS_NAME: self.async_update_expiring_products, + EXPIRED_PRODUCTS_NAME: self.async_update_expired_products, + MISSING_PRODUCTS_NAME: self.async_update_missing_products, + MEAL_PLAN_NAME: self.async_update_meal_plan, + } + self.sensor_update_dict = { + STOCK_NAME: None, + CHORES_NAME: None, + TASKS_NAME: None, + SHOPPING_LIST_NAME: None, + EXPIRING_PRODUCTS_NAME: None, + EXPIRED_PRODUCTS_NAME: None, + MISSING_PRODUCTS_NAME: None, + MEAL_PLAN_NAME: None, + } + + async def async_update_data(self, sensor_type): + """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. + self.hass.async_create_task(self.sensor_types_dict[sensor_type]()) + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + async def async_update_stock(self): + """Update data.""" + # This is where the main logic to update platform data goes. + self.hass.data[DOMAIN][STOCK_NAME] = await self.hass.async_add_executor_job( + self.client.stock + ) + + async def async_update_chores(self): + """Update data.""" + # This is where the main logic to update platform data goes. + def wrapper(): + return self.client.chores(True) + + self.hass.data[DOMAIN][CHORES_NAME] = 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. + + self.hass.data[DOMAIN][TASKS_NAME] = await self.hass.async_add_executor_job( + self.client.tasks + ) + + async def async_update_shopping_list(self): + """Update data.""" + # This is where the main logic to update platform data goes. + def wrapper(): + return self.client.shopping_list(True) + + self.hass.data[DOMAIN][ + SHOPPING_LIST_NAME + ] = await self.hass.async_add_executor_job(wrapper) + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + async def async_update_expiring_products(self): + """Update data.""" + # This is where the main logic to update platform data goes. + def wrapper(): + return self.client.expiring_products(True) + + self.hass.data[DOMAIN][ + EXPIRING_PRODUCTS_NAME + ] = await self.hass.async_add_executor_job(wrapper) + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + async def async_update_expired_products(self): + """Update data.""" + # This is where the main logic to update platform data goes. + def wrapper(): + return self.client.expired_products(True) + + self.hass.data[DOMAIN][ + EXPIRED_PRODUCTS_NAME + ] = await self.hass.async_add_executor_job(wrapper) + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + async def async_update_missing_products(self): + """Update data.""" + # This is where the main logic to update platform data goes. + def wrapper(): + return self.client.missing_products(True) + + self.hass.data[DOMAIN][ + MISSING_PRODUCTS_NAME + ] = await self.hass.async_add_executor_job(wrapper) + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + async def async_update_meal_plan(self): + """Update data.""" + # This is where the main logic to update platform data goes. + def wrapper(): + meal_plan = self.client.meal_plan(True) + base_url = self.hass.data[DOMAIN]["url"] + return [MealPlanItem(item, base_url) for item in meal_plan] + + self.hass.data[DOMAIN][MEAL_PLAN_NAME] = await self.hass.async_add_executor_job( + wrapper + ) + From f414d4eaf2110d6d0eb0de2c35f4f2e4bfd1483d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Isabella=20Gross=20Alstr=C3=B6m?= Date: Tue, 18 Aug 2020 13:35:21 +0200 Subject: [PATCH 11/45] Refactor init to use instance.py --- custom_components/grocy/__init__.py | 142 +---------------------- custom_components/grocy/binary_sensor.py | 2 +- custom_components/grocy/instance.py | 20 +--- custom_components/grocy/sensor.py | 2 +- 4 files changed, 12 insertions(+), 154 deletions(-) diff --git a/custom_components/grocy/__init__.py b/custom_components/grocy/__init__.py index 22001a0..88b8ea9 100644 --- a/custom_components/grocy/__init__.py +++ b/custom_components/grocy/__init__.py @@ -42,6 +42,7 @@ from .helpers import MealPlanItem from .services import async_setup_services +from .instance import GrocyInstance MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=60) @@ -67,26 +68,16 @@ async def async_setup_entry(hass, config_entry): CC_STARTUP_VERSION.format(name=DOMAIN, version=VERSION, issue_link=ISSUE_URL) ) - # Check that all required files are present if not await hass.async_add_executor_job(check_files, hass): return False - # Create DATA dict hass.data[DOMAIN] = {} - # Get "global" configuration. - url = config_entry.data.get(CONF_URL) - api_key = config_entry.data.get(CONF_API_KEY) - verify_ssl = config_entry.data.get(CONF_VERIFY_SSL) - port_number = config_entry.data.get(CONF_PORT) - hash_key = hashlib.md5(api_key.encode("utf-8") + url.encode("utf-8")).hexdigest() + instance = GrocyInstance(hass, config_entry) + hass.data[DOMAIN]["instance"] = instance - # Configure the client. - grocy = Grocy(url, api_key, port_number, verify_ssl) - hass.data[DOMAIN]["instance"] = grocy - hass.data[DOMAIN]["client"] = GrocyData(hass, grocy) - hass.data[DOMAIN]["hash_key"] = hash_key - hass.data[DOMAIN]["url"] = f"{url}:{port_number}" + if not await instance.async_setup(): + return False # Add sensors hass.async_add_job( @@ -103,129 +94,6 @@ async def async_setup_entry(hass, config_entry): return True -class GrocyData: - """This class handle communication and stores the data.""" - - def __init__(self, hass, client): - """Initialize the class.""" - self.hass = hass - self.client = client - self.sensor_types_dict = { - STOCK_NAME: self.async_update_stock, - CHORES_NAME: self.async_update_chores, - TASKS_NAME: self.async_update_tasks, - SHOPPING_LIST_NAME: self.async_update_shopping_list, - EXPIRING_PRODUCTS_NAME: self.async_update_expiring_products, - EXPIRED_PRODUCTS_NAME: self.async_update_expired_products, - MISSING_PRODUCTS_NAME: self.async_update_missing_products, - MEAL_PLAN_NAME: self.async_update_meal_plan, - } - self.sensor_update_dict = { - STOCK_NAME: None, - CHORES_NAME: None, - TASKS_NAME: None, - SHOPPING_LIST_NAME: None, - EXPIRING_PRODUCTS_NAME: None, - EXPIRED_PRODUCTS_NAME: None, - MISSING_PRODUCTS_NAME: None, - MEAL_PLAN_NAME: None, - } - - async def async_update_data(self, sensor_type): - """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. - self.hass.async_create_task(self.sensor_types_dict[sensor_type]()) - - @Throttle(MIN_TIME_BETWEEN_UPDATES) - async def async_update_stock(self): - """Update data.""" - # This is where the main logic to update platform data goes. - self.hass.data[DOMAIN][STOCK_NAME] = await self.hass.async_add_executor_job( - self.client.stock - ) - - async def async_update_chores(self): - """Update data.""" - # This is where the main logic to update platform data goes. - def wrapper(): - return self.client.chores(True) - - self.hass.data[DOMAIN][CHORES_NAME] = 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. - - self.hass.data[DOMAIN][TASKS_NAME] = await self.hass.async_add_executor_job( - self.client.tasks - ) - - async def async_update_shopping_list(self): - """Update data.""" - # This is where the main logic to update platform data goes. - def wrapper(): - return self.client.shopping_list(True) - - self.hass.data[DOMAIN][ - SHOPPING_LIST_NAME - ] = await self.hass.async_add_executor_job(wrapper) - - @Throttle(MIN_TIME_BETWEEN_UPDATES) - async def async_update_expiring_products(self): - """Update data.""" - # This is where the main logic to update platform data goes. - def wrapper(): - return self.client.expiring_products(True) - - self.hass.data[DOMAIN][ - EXPIRING_PRODUCTS_NAME - ] = await self.hass.async_add_executor_job(wrapper) - - @Throttle(MIN_TIME_BETWEEN_UPDATES) - async def async_update_expired_products(self): - """Update data.""" - # This is where the main logic to update platform data goes. - def wrapper(): - return self.client.expired_products(True) - - self.hass.data[DOMAIN][ - EXPIRED_PRODUCTS_NAME - ] = await self.hass.async_add_executor_job(wrapper) - - @Throttle(MIN_TIME_BETWEEN_UPDATES) - async def async_update_missing_products(self): - """Update data.""" - # This is where the main logic to update platform data goes. - def wrapper(): - return self.client.missing_products(True) - - self.hass.data[DOMAIN][ - MISSING_PRODUCTS_NAME - ] = await self.hass.async_add_executor_job(wrapper) - - @Throttle(MIN_TIME_BETWEEN_UPDATES) - async def async_update_meal_plan(self): - """Update data.""" - # This is where the main logic to update platform data goes. - def wrapper(): - meal_plan = self.client.meal_plan(True) - base_url = self.hass.data[DOMAIN]["url"] - return [MealPlanItem(item, base_url) for item in meal_plan] - - self.hass.data[DOMAIN][MEAL_PLAN_NAME] = await self.hass.async_add_executor_job( - wrapper - ) - - def check_files(hass): """Return bool that indicates if all files are present.""" # Verify that the user downloaded all files. diff --git a/custom_components/grocy/binary_sensor.py b/custom_components/grocy/binary_sensor.py index 8222171..227397e 100644 --- a/custom_components/grocy/binary_sensor.py +++ b/custom_components/grocy/binary_sensor.py @@ -31,7 +31,7 @@ def __init__(self, hass, sensor_type): self.sensor_type = sensor_type self.attr = {} self._status = False - self._hash_key = self.hass.data[DOMAIN]["hash_key"] + self._hash_key = self.hass.data[DOMAIN].get("hash_key") self._unique_id = "{}-{}".format(self._hash_key, self.sensor_type) self._name = "{}.{}".format(DEFAULT_CONF_NAME, self.sensor_type) self._client = self.hass.data[DOMAIN]["client"] diff --git a/custom_components/grocy/instance.py b/custom_components/grocy/instance.py index abfb0ab..cf5e7a7 100644 --- a/custom_components/grocy/instance.py +++ b/custom_components/grocy/instance.py @@ -17,7 +17,6 @@ CONF_ALLOW_SHOPPING_LIST, CONF_ALLOW_STOCK, CONF_ALLOW_TASKS, - CONF_MASTER_INSTANCE, DEFAULT_CONF_ALLOW_CHORES, DEFAULT_CONF_ALLOW_MEAL_PLAN, DEFAULT_CONF_ALLOW_PRODUCTS, @@ -76,11 +75,6 @@ def instanceid(self) -> str: """Return the unique identifier of the instance.""" return self.config_entry.unique_id - @property - def master(self) -> bool: - """Instance which is used with Grocy without defining id.""" - return self.config_entry.options[CONF_MASTER_INSTANCE] - @property def option_allow_chores(self) -> bool: """Allow loading chores sensor from instance.""" @@ -123,18 +117,13 @@ async def async_setup(self) -> bool: """Set up a Grocy instance.""" LOGGER.debug("Setting up") try: - self.api = await get_instance( - self.hass, - self.config_entry.data, - # self.async_add_device_callback, - # self.async_connection_status_callback, - ) + self.api = await get_instance(self.hass, self.config_entry.data) # except CannotConnect: # raise ConfigEntryNotReady except Exception as err: # pylint: disable=broad-except - LOGGER.error("Error connecting with deCONZ gateway: %s", err) + LOGGER.error("Error connecting with Grocy instance: %s", err) return False # for component in SUPPORTED_PLATFORMS: @@ -154,7 +143,7 @@ async def async_setup(self) -> bool: async def get_instance(hass, config) -> Grocy: """Create a gateway object and verify configuration.""" # session = aiohttp_client.async_get_clientsession(hass) - + LOGGER.debug("Getting Grocy instance.") url = config.get(CONF_URL) api_key = config.get(CONF_API_KEY) verify_ssl = config.get(CONF_VERIFY_SSL) @@ -165,7 +154,8 @@ async def get_instance(hass, config) -> Grocy: hass.data[DOMAIN]["client"] = GrocyData(hass, grocy) hass.data[DOMAIN]["hash_key"] = hash_key hass.data[DOMAIN]["url"] = f"{url}:{port_number}" - + # LOGGER.debug(hash_key) + # LOGGER.debug(hass.data[DOMAIN]["hash_key"]) return grocy # try: # with async_timeout.timeout(10): diff --git a/custom_components/grocy/sensor.py b/custom_components/grocy/sensor.py index 403f010..7b4bac5 100644 --- a/custom_components/grocy/sensor.py +++ b/custom_components/grocy/sensor.py @@ -40,7 +40,7 @@ def __init__(self, hass, sensor_type): self.sensor_type = sensor_type self.attr = {} self._state = None - self._hash_key = self.hass.data[DOMAIN]["hash_key"] + self._hash_key = self.hass.data[DOMAIN].get("hash_key") self._unique_id = "{}-{}".format(self._hash_key, self.sensor_type) self._name = "{}.{}".format(DEFAULT_CONF_NAME, self.sensor_type) From d30e85434e25fb65f8a424d5053aaa9c3237a597 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Isabella=20Gross=20Alstr=C3=B6m?= Date: Tue, 18 Aug 2020 13:53:50 +0200 Subject: [PATCH 12/45] Add start of platforms to instance --- custom_components/grocy/__init__.py | 23 ----------------- custom_components/grocy/const.py | 3 ++- custom_components/grocy/instance.py | 39 ++++++----------------------- 3 files changed, 10 insertions(+), 55 deletions(-) diff --git a/custom_components/grocy/__init__.py b/custom_components/grocy/__init__.py index 88b8ea9..aa1f3f6 100644 --- a/custom_components/grocy/__init__.py +++ b/custom_components/grocy/__init__.py @@ -14,29 +14,15 @@ from homeassistant.helpers import discovery, entity_component from homeassistant.util import Throttle from integrationhelper.const import CC_STARTUP_VERSION -from pygrocy import Grocy, TransactionType from datetime import datetime import iso8601 from .const import ( LOGGER, - CHORES_NAME, - TASKS_NAME, - CONF_ENABLED, - CONF_NAME, - DEFAULT_CONF_NAME, - DEFAULT_PORT_NUMBER, DOMAIN, - EXPIRED_PRODUCTS_NAME, - EXPIRING_PRODUCTS_NAME, ISSUE_URL, - MISSING_PRODUCTS_NAME, - MEAL_PLAN_NAME, - PLATFORMS, REQUIRED_FILES, - SHOPPING_LIST_NAME, STARTUP, - STOCK_NAME, VERSION, ) @@ -79,15 +65,6 @@ async def async_setup_entry(hass, config_entry): if not await instance.async_setup(): return False - # Add sensors - hass.async_add_job( - hass.config_entries.async_forward_entry_setup(config_entry, "sensor") - ) - # Add binary sensors - hass.async_add_job( - hass.config_entries.async_forward_entry_setup(config_entry, "binary_sensor") - ) - # Setup services await async_setup_services(hass) diff --git a/custom_components/grocy/const.py b/custom_components/grocy/const.py index 5ad6277..72a763f 100644 --- a/custom_components/grocy/const.py +++ b/custom_components/grocy/const.py @@ -6,7 +6,6 @@ # Base component constants DOMAIN = "grocy" VERSION = "1.2.0" -PLATFORMS = ["sensor", "binary_sensor"] REQUIRED_FILES = [ "const.py", "manifest.json", @@ -45,6 +44,8 @@ MISSING_PRODUCTS_NAME = "missing_products" MEAL_PLAN_NAME = "meal_plan" + +SUPPORTED_PLATFORMS = ["binary_sensor", "sensor"] SENSOR_TYPES = [STOCK_NAME, CHORES_NAME, TASKS_NAME, SHOPPING_LIST_NAME, MEAL_PLAN_NAME] BINARY_SENSOR_TYPES = [ EXPIRING_PRODUCTS_NAME, diff --git a/custom_components/grocy/instance.py b/custom_components/grocy/instance.py index cf5e7a7..147d566 100644 --- a/custom_components/grocy/instance.py +++ b/custom_components/grocy/instance.py @@ -25,23 +25,19 @@ DEFAULT_CONF_ALLOW_TASKS, CHORES_NAME, TASKS_NAME, - CONF_ENABLED, - CONF_NAME, - DEFAULT_CONF_NAME, DEFAULT_PORT_NUMBER, DOMAIN, EXPIRED_PRODUCTS_NAME, EXPIRING_PRODUCTS_NAME, - ISSUE_URL, MISSING_PRODUCTS_NAME, MEAL_PLAN_NAME, - PLATFORMS, REQUIRED_FILES, SHOPPING_LIST_NAME, STARTUP, STOCK_NAME, VERSION, LOGGER, + SUPPORTED_PLATFORMS, ) MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=60) @@ -119,21 +115,16 @@ async def async_setup(self) -> bool: try: self.api = await get_instance(self.hass, self.config_entry.data) - # except CannotConnect: - # raise ConfigEntryNotReady - except Exception as err: # pylint: disable=broad-except LOGGER.error("Error connecting with Grocy instance: %s", err) return False - # for component in SUPPORTED_PLATFORMS: - # self.hass.async_create_task( - # self.hass.config_entries.async_forward_entry_setup( - # self.config_entry, component - # ) - # ) - - # self.api.start() + for component in SUPPORTED_PLATFORMS: + self.hass.async_create_task( + self.hass.config_entries.async_forward_entry_setup( + self.config_entry, component + ) + ) # self.config_entry.add_update_listener(self.async_config_entry_updated) @@ -142,7 +133,6 @@ async def async_setup(self) -> bool: async def get_instance(hass, config) -> Grocy: """Create a gateway object and verify configuration.""" - # session = aiohttp_client.async_get_clientsession(hass) LOGGER.debug("Getting Grocy instance.") url = config.get(CONF_URL) api_key = config.get(CONF_API_KEY) @@ -154,21 +144,8 @@ async def get_instance(hass, config) -> Grocy: hass.data[DOMAIN]["client"] = GrocyData(hass, grocy) hass.data[DOMAIN]["hash_key"] = hash_key hass.data[DOMAIN]["url"] = f"{url}:{port_number}" - # LOGGER.debug(hash_key) - # LOGGER.debug(hass.data[DOMAIN]["hash_key"]) + return grocy - # try: - # with async_timeout.timeout(10): - # await deconz.initialize() - # return deconz - - # except errors.Unauthorized: - # LOGGER.warning("Invalid key for deCONZ at %s", config[CONF_HOST]) - # raise AuthenticationRequired - - # except (asyncio.TimeoutError, errors.RequestError): - # LOGGER.error("Error connecting to deCONZ gateway at %s", config[CONF_HOST]) - # raise CannotConnect class GrocyData: From d8571d868d3e7ad1a374ec96e43edfd915e20a64 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Isabella=20Gross=20Alstr=C3=B6m?= Date: Tue, 18 Aug 2020 20:07:54 +0200 Subject: [PATCH 13/45] WIP: Adding new sensors via options (tries to add every sensor again) --- custom_components/grocy/__init__.py | 3 +- custom_components/grocy/binary_sensor.py | 17 ++++++- custom_components/grocy/const.py | 4 ++ custom_components/grocy/instance.py | 64 +++++++++++++++++++++++- custom_components/grocy/sensor.py | 45 +++++++++++++++-- 5 files changed, 126 insertions(+), 7 deletions(-) diff --git a/custom_components/grocy/__init__.py b/custom_components/grocy/__init__.py index aa1f3f6..938c46b 100644 --- a/custom_components/grocy/__init__.py +++ b/custom_components/grocy/__init__.py @@ -26,7 +26,6 @@ VERSION, ) -from .helpers import MealPlanItem from .services import async_setup_services from .instance import GrocyInstance @@ -90,7 +89,7 @@ def check_files(hass): return returnvalue -async def async_remove_entry(hass, config_entry): +async def async_unload_entry(hass, config_entry): """Handle removal of an entry.""" try: await hass.config_entries.async_forward_entry_unload(config_entry, "sensor") diff --git a/custom_components/grocy/binary_sensor.py b/custom_components/grocy/binary_sensor.py index 227397e..60ce8ea 100644 --- a/custom_components/grocy/binary_sensor.py +++ b/custom_components/grocy/binary_sensor.py @@ -4,6 +4,9 @@ from .const import ( ATTRIBUTION, BINARY_SENSOR_TYPES, + EXPIRING_PRODUCTS_NAME, + EXPIRED_PRODUCTS_NAME, + MISSING_PRODUCTS_NAME, DEFAULT_CONF_NAME, DOMAIN, LOGGER, @@ -19,8 +22,20 @@ async def async_setup_platform( async def async_setup_entry(hass, config_entry, async_add_devices): """Setup sensor platform.""" + instance = hass.data[DOMAIN]["instance"] for binary_sensor in BINARY_SENSOR_TYPES: - async_add_devices([GrocyBinarySensor(hass, binary_sensor)], True) + if instance.option_allow_products and binary_sensor.startswith( + EXPIRING_PRODUCTS_NAME + ): + async_add_devices([GrocyBinarySensor(hass, binary_sensor)], True) + elif instance.option_allow_products and binary_sensor.startswith( + EXPIRED_PRODUCTS_NAME + ): + async_add_devices([GrocyBinarySensor(hass, binary_sensor)], True) + elif instance.option_allow_products and binary_sensor.startswith( + MISSING_PRODUCTS_NAME + ): + async_add_devices([GrocyBinarySensor(hass, binary_sensor)], True) class GrocyBinarySensor(BinarySensorEntity): diff --git a/custom_components/grocy/const.py b/custom_components/grocy/const.py index 72a763f..c8f81af 100644 --- a/custom_components/grocy/const.py +++ b/custom_components/grocy/const.py @@ -39,6 +39,7 @@ CHORES_NAME = "chores" TASKS_NAME = "tasks" SHOPPING_LIST_NAME = "shopping_list" +PRODUCTS_NAME = "products" EXPIRING_PRODUCTS_NAME = "expiring_products" EXPIRED_PRODUCTS_NAME = "expired_products" MISSING_PRODUCTS_NAME = "missing_products" @@ -53,6 +54,9 @@ MISSING_PRODUCTS_NAME, ] +NEW_SENSOR = "sensors" +NEW_BINARY_SENSOR = "binary_sensors" + # Configuration CONF_ENABLED = "enabled" CONF_NAME = "name" diff --git a/custom_components/grocy/instance.py b/custom_components/grocy/instance.py index 147d566..e8246b4 100644 --- a/custom_components/grocy/instance.py +++ b/custom_components/grocy/instance.py @@ -5,6 +5,8 @@ from datetime import timedelta from pygrocy import Grocy, TransactionType +from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.core import callback from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.const import CONF_API_KEY, CONF_PORT, CONF_URL, CONF_VERIFY_SSL from homeassistant.util import Throttle @@ -38,6 +40,8 @@ VERSION, LOGGER, SUPPORTED_PLATFORMS, + NEW_BINARY_SENSOR, + NEW_SENSOR, ) MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=60) @@ -53,6 +57,7 @@ def __init__(self, hass, config_entry) -> None: self.available = True self.api = None + self.listeners = [] self._current_option_allow_chores = self.option_allow_chores self._current_option_allow_meal_plan = self.option_allow_meal_plan @@ -126,10 +131,67 @@ async def async_setup(self) -> bool: ) ) - # self.config_entry.add_update_listener(self.async_config_entry_updated) + self.config_entry.add_update_listener(self.async_config_entry_updated) return True + @staticmethod + async def async_config_entry_updated(hass, entry) -> None: + """Handle signals of config entry being updated.""" + instance = hass.data[DOMAIN]["instance"] + + if not instance: + return + + return await instance.options_updated() + + async def options_updated(self): + """Manage entities affected by config entry options.""" + if self._current_option_allow_chores != self.option_allow_chores: + self._current_option_allow_chores = self.option_allow_chores + + # New is true, add sensor + if self._current_option_allow_chores: + self.async_add_device_callback(NEW_SENSOR, CHORES_NAME) + if self._current_option_allow_tasks != self.option_allow_tasks: + self._current_option_allow_tasks = self.option_allow_tasks + + # New is true, add sensor + if self._current_option_allow_tasks: + self.async_add_device_callback(NEW_SENSOR, TASKS_NAME) + + @callback + def async_add_device_callback(self, device_type, device) -> None: + """Handle event of new device creation in deCONZ.""" + if not isinstance(device, list): + device = [device] + async_dispatcher_send( + self.hass, self.async_signal_new_device(device_type), device + ) + + @callback + def async_signal_new_device(self, device_type) -> str: + """Gateway specific event to signal new device.""" + new_device = {NEW_SENSOR: f"grocy_new_sensor"} + return new_device[device_type] + + +# @callback +# def add_entities(controller, async_add_entities, clients): +# """Add new sensor entities from the controller.""" +# sensors = [] + +# for mac in clients: +# for sensor_class in (UniFiRxBandwidthSensor, UniFiTxBandwidthSensor): +# if mac in controller.entities[DOMAIN][sensor_class.TYPE]: +# continue + +# client = controller.api.clients[mac] +# sensors.append(sensor_class(client, controller)) + +# if sensors: +# async_add_entities(sensors) + async def get_instance(hass, config) -> Grocy: """Create a gateway object and verify configuration.""" diff --git a/custom_components/grocy/sensor.py b/custom_components/grocy/sensor.py index 7b4bac5..444370b 100644 --- a/custom_components/grocy/sensor.py +++ b/custom_components/grocy/sensor.py @@ -1,11 +1,18 @@ """Sensor platform for grocy.""" from homeassistant.helpers.entity import Entity +from homeassistant.helpers.dispatcher import ( + async_dispatcher_connect, + async_dispatcher_send, +) +from homeassistant.core import callback from .const import ( ATTRIBUTION, CHORES_NAME, TASKS_NAME, MEAL_PLAN_NAME, + STOCK_NAME, + SHOPPING_LIST_NAME, DEFAULT_CONF_NAME, DOMAIN, LOGGER, @@ -15,6 +22,7 @@ SENSOR_PRODUCTS_UNIT_OF_MEASUREMENT, SENSOR_MEALS_UNIT_OF_MEASUREMENT, SENSOR_TYPES, + NEW_SENSOR, ) @@ -28,8 +36,39 @@ async def async_setup_platform( async def async_setup_entry(hass, config_entry, async_add_devices): """Setup sensor platform.""" - for sensor in SENSOR_TYPES: - async_add_devices([GrocySensor(hass, sensor)], True) + instance = hass.data[DOMAIN]["instance"] + + @callback + def async_add_sensor(new=True): + LOGGER.debug("Adding sensors") + entities = [] + + for sensor in SENSOR_TYPES: + if instance.option_allow_chores and sensor.startswith(CHORES_NAME): + LOGGER.debug("Adding chores sensor.") + async_add_devices([GrocySensor(hass, sensor)], True) + elif instance.option_allow_meal_plan and sensor.startswith(MEAL_PLAN_NAME): + LOGGER.debug("Adding meal plan sensor.") + async_add_devices([GrocySensor(hass, sensor)], True) + elif instance.option_allow_shopping_list and sensor.startswith( + SHOPPING_LIST_NAME + ): + LOGGER.debug("Adding shopping list sensor.") + async_add_devices([GrocySensor(hass, sensor)], True) + elif instance.option_allow_stock and sensor.startswith(STOCK_NAME): + LOGGER.debug("Adding stock sensor.") + async_add_devices([GrocySensor(hass, sensor)], True) + elif instance.option_allow_tasks and sensor.startswith(TASKS_NAME): + LOGGER.debug("Adding tasks sensor.") + async_add_devices([GrocySensor(hass, sensor)], True) + + instance.listeners.append( + async_dispatcher_connect( + hass, instance.async_signal_new_device(NEW_SENSOR), async_add_sensor + ) + ) + + async_add_sensor() class GrocySensor(Entity): @@ -53,7 +92,7 @@ async def async_update(self): x.as_dict() for x in self.hass.data[DOMAIN].get(self.sensor_type, []) ] self._state = len(self.attr["items"]) - LOGGER.debug(self.attr) + # LOGGER.debug(self.attr) @property def unique_id(self): From 753ec611fb7d4550233c4f119f03fb681c64ee52 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Isabella=20Gross=20Alstr=C3=B6m?= Date: Wed, 19 Aug 2020 15:46:13 +0200 Subject: [PATCH 14/45] Add entites to devices accordingly --- custom_components/grocy/binary_sensor.py | 37 ++++++++++++++-------- custom_components/grocy/instance.py | 26 ++++++++-------- custom_components/grocy/sensor.py | 39 +++++++++++++++--------- 3 files changed, 62 insertions(+), 40 deletions(-) diff --git a/custom_components/grocy/binary_sensor.py b/custom_components/grocy/binary_sensor.py index 60ce8ea..c2035dd 100644 --- a/custom_components/grocy/binary_sensor.py +++ b/custom_components/grocy/binary_sensor.py @@ -10,6 +10,7 @@ DEFAULT_CONF_NAME, DOMAIN, LOGGER, + STOCK_NAME, ) @@ -17,33 +18,43 @@ async def async_setup_platform( hass, config, async_add_entities, discovery_info=None ): # pylint: disable=unused-argument """Setup binary_sensor platform.""" - async_add_entities([GrocyBinarySensor(hass, discovery_info)], True) + # async_add_entities([GrocyBinarySensor(hass, discovery_info)], True) -async def async_setup_entry(hass, config_entry, async_add_devices): +async def async_setup_entry(hass, config_entry, async_add_entities): """Setup sensor platform.""" instance = hass.data[DOMAIN]["instance"] for binary_sensor in BINARY_SENSOR_TYPES: - if instance.option_allow_products and binary_sensor.startswith( + if instance.option_allow_stock and binary_sensor.startswith( EXPIRING_PRODUCTS_NAME ): - async_add_devices([GrocyBinarySensor(hass, binary_sensor)], True) - elif instance.option_allow_products and binary_sensor.startswith( + device_name = STOCK_NAME + async_add_entities( + [GrocyBinarySensor(hass, binary_sensor, device_name)], True + ) + elif instance.option_allow_stock and binary_sensor.startswith( EXPIRED_PRODUCTS_NAME ): - async_add_devices([GrocyBinarySensor(hass, binary_sensor)], True) - elif instance.option_allow_products and binary_sensor.startswith( + device_name = STOCK_NAME + async_add_entities( + [GrocyBinarySensor(hass, binary_sensor, device_name)], True + ) + elif instance.option_allow_stock and binary_sensor.startswith( MISSING_PRODUCTS_NAME ): - async_add_devices([GrocyBinarySensor(hass, binary_sensor)], True) + device_name = STOCK_NAME + async_add_entities( + [GrocyBinarySensor(hass, binary_sensor, device_name)], True + ) class GrocyBinarySensor(BinarySensorEntity): """grocy binary_sensor class.""" - def __init__(self, hass, sensor_type): + def __init__(self, hass, sensor_type, device_name): self.hass = hass self.sensor_type = sensor_type + self.device_name = device_name self.attr = {} self._status = False self._hash_key = self.hass.data[DOMAIN].get("hash_key") @@ -60,7 +71,8 @@ async def async_update(self): x.as_dict() for x in self.hass.data[DOMAIN].get(self.sensor_type, []) ] self._status = len(self.attr["items"]) != 0 - LOGGER.debug(self.attr) + # LOGGER.debug(self.attr) + LOGGER.debug(self.device_info) @property def unique_id(self): @@ -70,9 +82,10 @@ def unique_id(self): @property def device_info(self): return { - "identifiers": {(DOMAIN, self.unique_id)}, - "name": self._name, + "identifiers": {(DOMAIN, self.device_name)}, + "name": self.device_name, "manufacturer": "Grocy", + "entry_type": "service", } @property diff --git a/custom_components/grocy/instance.py b/custom_components/grocy/instance.py index e8246b4..a9453a2 100644 --- a/custom_components/grocy/instance.py +++ b/custom_components/grocy/instance.py @@ -149,31 +149,31 @@ async def options_updated(self): """Manage entities affected by config entry options.""" if self._current_option_allow_chores != self.option_allow_chores: self._current_option_allow_chores = self.option_allow_chores - # New is true, add sensor if self._current_option_allow_chores: - self.async_add_device_callback(NEW_SENSOR, CHORES_NAME) + self.async_add_entity_callback(NEW_SENSOR, CHORES_NAME) + if self._current_option_allow_tasks != self.option_allow_tasks: self._current_option_allow_tasks = self.option_allow_tasks - # New is true, add sensor if self._current_option_allow_tasks: - self.async_add_device_callback(NEW_SENSOR, TASKS_NAME) + self.async_add_entity_callback(NEW_SENSOR, TASKS_NAME) @callback - def async_add_device_callback(self, device_type, device) -> None: - """Handle event of new device creation in deCONZ.""" - if not isinstance(device, list): - device = [device] + def async_add_entity_callback(self, sensor_type, sensor) -> None: + """Handle event of new device creation in Grocy.""" + if not isinstance(sensor, list): + sensor = [sensor] async_dispatcher_send( - self.hass, self.async_signal_new_device(device_type), device + self.hass, self.async_signal_new_entity(sensor_type), sensor ) + ## testa lägga till specifikt sensor-namn här @callback - def async_signal_new_device(self, device_type) -> str: - """Gateway specific event to signal new device.""" - new_device = {NEW_SENSOR: f"grocy_new_sensor"} - return new_device[device_type] + def async_signal_new_entity(self, sensor_type) -> str: + """Event to signal new device.""" + new_sensor = {NEW_SENSOR: f"grocy_new_sensor"} + return new_sensor[sensor_type] # @callback diff --git a/custom_components/grocy/sensor.py b/custom_components/grocy/sensor.py index 444370b..3e37136 100644 --- a/custom_components/grocy/sensor.py +++ b/custom_components/grocy/sensor.py @@ -31,52 +31,59 @@ async def async_setup_platform( ): # pylint: disable=unused-argument """Setup sensor platform.""" - async_add_entities([GrocySensor(hass, discovery_info)], True) + # async_add_entities([GrocySensor(hass, discovery_info)], True) -async def async_setup_entry(hass, config_entry, async_add_devices): +async def async_setup_entry(hass, config_entry, async_add_entities): """Setup sensor platform.""" instance = hass.data[DOMAIN]["instance"] @callback - def async_add_sensor(new=True): + def async_add_sensor(sensors): LOGGER.debug("Adding sensors") entities = [] - for sensor in SENSOR_TYPES: + for sensor in sensors: if instance.option_allow_chores and sensor.startswith(CHORES_NAME): LOGGER.debug("Adding chores sensor.") - async_add_devices([GrocySensor(hass, sensor)], True) + device_name = CHORES_NAME + async_add_entities([GrocySensor(hass, sensor, device_name)], True) elif instance.option_allow_meal_plan and sensor.startswith(MEAL_PLAN_NAME): LOGGER.debug("Adding meal plan sensor.") - async_add_devices([GrocySensor(hass, sensor)], True) + device_name = MEAL_PLAN_NAME + async_add_entities([GrocySensor(hass, sensor, device_name)], True) elif instance.option_allow_shopping_list and sensor.startswith( SHOPPING_LIST_NAME ): LOGGER.debug("Adding shopping list sensor.") - async_add_devices([GrocySensor(hass, sensor)], True) + device_name = SHOPPING_LIST_NAME + async_add_entities([GrocySensor(hass, sensor, device_name)], True) elif instance.option_allow_stock and sensor.startswith(STOCK_NAME): LOGGER.debug("Adding stock sensor.") - async_add_devices([GrocySensor(hass, sensor)], True) + device_name = STOCK_NAME + async_add_entities([GrocySensor(hass, sensor, device_name)], True) elif instance.option_allow_tasks and sensor.startswith(TASKS_NAME): LOGGER.debug("Adding tasks sensor.") - async_add_devices([GrocySensor(hass, sensor)], True) + device_name = TASKS_NAME + async_add_entities([GrocySensor(hass, sensor, device_name)], True) + ## en listener per sensor? instance.listeners.append( async_dispatcher_connect( - hass, instance.async_signal_new_device(NEW_SENSOR), async_add_sensor + hass, instance.async_signal_new_entity(NEW_SENSOR), async_add_sensor ) ) - async_add_sensor() + async_add_sensor(SENSOR_TYPES) class GrocySensor(Entity): - """grocy Sensor class.""" + """Grocy sensor class.""" - def __init__(self, hass, sensor_type): + def __init__(self, hass, sensor_type, device_name): self.hass = hass self.sensor_type = sensor_type + self.device_name = device_name self.attr = {} self._state = None self._hash_key = self.hass.data[DOMAIN].get("hash_key") @@ -93,6 +100,7 @@ async def async_update(self): ] self._state = len(self.attr["items"]) # LOGGER.debug(self.attr) + LOGGER.debug(self.device_info) @property def unique_id(self): @@ -102,9 +110,10 @@ def unique_id(self): @property def device_info(self): return { - "identifiers": {(DOMAIN, self.unique_id)}, - "name": self._name, + "identifiers": {(DOMAIN, self.device_name)}, + "name": self.device_name, "manufacturer": "Grocy", + "entry_type": "service", } @property From 0e9c759b29c4997e704ce79f51ec46120aef76df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Isabella=20Gross=20Alstr=C3=B6m?= Date: Wed, 19 Aug 2020 18:08:59 +0200 Subject: [PATCH 15/45] Add all sorts of entities via config options --- custom_components/grocy/binary_sensor.py | 66 ++++++++++------ custom_components/grocy/config_flow.py | 9 --- custom_components/grocy/const.py | 5 +- custom_components/grocy/instance.py | 82 ++++++++++++-------- custom_components/grocy/sensor.py | 14 +++- custom_components/grocy/translations/en.json | 1 - 6 files changed, 108 insertions(+), 69 deletions(-) diff --git a/custom_components/grocy/binary_sensor.py b/custom_components/grocy/binary_sensor.py index c2035dd..7e4f0d0 100644 --- a/custom_components/grocy/binary_sensor.py +++ b/custom_components/grocy/binary_sensor.py @@ -1,5 +1,10 @@ """Binary sensor platform for grocy.""" from homeassistant.components.binary_sensor import BinarySensorEntity +from homeassistant.helpers.dispatcher import ( + async_dispatcher_connect, + async_dispatcher_send, +) +from homeassistant.core import callback from .const import ( ATTRIBUTION, @@ -11,6 +16,8 @@ DOMAIN, LOGGER, STOCK_NAME, + NEW_BINARY_SENSOR, + NEW_SENSOR, ) @@ -24,28 +31,43 @@ async def async_setup_platform( async def async_setup_entry(hass, config_entry, async_add_entities): """Setup sensor platform.""" instance = hass.data[DOMAIN]["instance"] - for binary_sensor in BINARY_SENSOR_TYPES: - if instance.option_allow_stock and binary_sensor.startswith( - EXPIRING_PRODUCTS_NAME - ): - device_name = STOCK_NAME - async_add_entities( - [GrocyBinarySensor(hass, binary_sensor, device_name)], True - ) - elif instance.option_allow_stock and binary_sensor.startswith( - EXPIRED_PRODUCTS_NAME - ): - device_name = STOCK_NAME - async_add_entities( - [GrocyBinarySensor(hass, binary_sensor, device_name)], True - ) - elif instance.option_allow_stock and binary_sensor.startswith( - MISSING_PRODUCTS_NAME - ): - device_name = STOCK_NAME - async_add_entities( - [GrocyBinarySensor(hass, binary_sensor, device_name)], True - ) + + @callback + def async_add_binary_sensor(binary_sensors): + LOGGER.debug("Adding binary sensors") + LOGGER.debug(binary_sensors) + for binary_sensor in binary_sensors: + if instance.option_allow_stock and binary_sensor.startswith( + EXPIRING_PRODUCTS_NAME + ): + device_name = STOCK_NAME + async_add_entities( + [GrocyBinarySensor(hass, binary_sensor, device_name)], True + ) + elif instance.option_allow_stock and binary_sensor.startswith( + EXPIRED_PRODUCTS_NAME + ): + device_name = STOCK_NAME + async_add_entities( + [GrocyBinarySensor(hass, binary_sensor, device_name)], True + ) + elif instance.option_allow_stock and binary_sensor.startswith( + MISSING_PRODUCTS_NAME + ): + device_name = STOCK_NAME + async_add_entities( + [GrocyBinarySensor(hass, binary_sensor, device_name)], True + ) + + instance.listeners.append( + async_dispatcher_connect( + hass, + instance.async_signal_new_entity(NEW_BINARY_SENSOR), + async_add_binary_sensor, + ) + ) + + async_add_binary_sensor(BINARY_SENSOR_TYPES) class GrocyBinarySensor(BinarySensorEntity): diff --git a/custom_components/grocy/config_flow.py b/custom_components/grocy/config_flow.py index 48f7935..c4314a7 100644 --- a/custom_components/grocy/config_flow.py +++ b/custom_components/grocy/config_flow.py @@ -10,13 +10,11 @@ DOMAIN, CONF_ALLOW_CHORES, CONF_ALLOW_MEAL_PLAN, - CONF_ALLOW_PRODUCTS, CONF_ALLOW_SHOPPING_LIST, CONF_ALLOW_STOCK, CONF_ALLOW_TASKS, DEFAULT_CONF_ALLOW_CHORES, DEFAULT_CONF_ALLOW_MEAL_PLAN, - DEFAULT_CONF_ALLOW_PRODUCTS, DEFAULT_CONF_ALLOW_SHOPPING_LIST, DEFAULT_CONF_ALLOW_STOCK, DEFAULT_CONF_ALLOW_TASKS, @@ -133,7 +131,6 @@ async def async_step_grocy_devices(self, user_input=None): if user_input is not None: self.options[CONF_ALLOW_CHORES] = user_input[CONF_ALLOW_CHORES] self.options[CONF_ALLOW_MEAL_PLAN] = user_input[CONF_ALLOW_MEAL_PLAN] - self.options[CONF_ALLOW_PRODUCTS] = user_input[CONF_ALLOW_PRODUCTS] self.options[CONF_ALLOW_SHOPPING_LIST] = user_input[ CONF_ALLOW_SHOPPING_LIST ] @@ -157,12 +154,6 @@ async def async_step_grocy_devices(self, user_input=None): CONF_ALLOW_MEAL_PLAN, DEFAULT_CONF_ALLOW_MEAL_PLAN ), ): bool, - vol.Optional( - CONF_ALLOW_PRODUCTS, - default=self.config_entry.options.get( - CONF_ALLOW_PRODUCTS, DEFAULT_CONF_ALLOW_PRODUCTS - ), - ): bool, vol.Optional( CONF_ALLOW_SHOPPING_LIST, default=self.config_entry.options.get( diff --git a/custom_components/grocy/const.py b/custom_components/grocy/const.py index c8f81af..dc5c1b4 100644 --- a/custom_components/grocy/const.py +++ b/custom_components/grocy/const.py @@ -63,7 +63,7 @@ CONF_ALLOW_CHORES = "allow_chores" CONF_ALLOW_MEAL_PLAN = "allow_meal_plan" -CONF_ALLOW_PRODUCTS = "allow_products" +CONF_ALLOW_ = "allow_products" CONF_ALLOW_SHOPPING_LIST = "allow_shopping_list" CONF_ALLOW_STOCK = "allow_stock" CONF_ALLOW_TASKS = "allow_tasks" @@ -73,7 +73,6 @@ DEFAULT_PORT_NUMBER = 9192 DEFAULT_CONF_ALLOW_CHORES = False DEFAULT_CONF_ALLOW_MEAL_PLAN = False -DEFAULT_CONF_ALLOW_PRODUCTS = True DEFAULT_CONF_ALLOW_SHOPPING_LIST = False -DEFAULT_CONF_ALLOW_STOCK = True +DEFAULT_CONF_ALLOW_STOCK = False DEFAULT_CONF_ALLOW_TASKS = False diff --git a/custom_components/grocy/instance.py b/custom_components/grocy/instance.py index a9453a2..bcfe56f 100644 --- a/custom_components/grocy/instance.py +++ b/custom_components/grocy/instance.py @@ -15,13 +15,11 @@ from .const import ( CONF_ALLOW_CHORES, CONF_ALLOW_MEAL_PLAN, - CONF_ALLOW_PRODUCTS, CONF_ALLOW_SHOPPING_LIST, CONF_ALLOW_STOCK, CONF_ALLOW_TASKS, DEFAULT_CONF_ALLOW_CHORES, DEFAULT_CONF_ALLOW_MEAL_PLAN, - DEFAULT_CONF_ALLOW_PRODUCTS, DEFAULT_CONF_ALLOW_SHOPPING_LIST, DEFAULT_CONF_ALLOW_STOCK, DEFAULT_CONF_ALLOW_TASKS, @@ -61,7 +59,6 @@ def __init__(self, hass, config_entry) -> None: self._current_option_allow_chores = self.option_allow_chores self._current_option_allow_meal_plan = self.option_allow_meal_plan - self._current_option_allow_products = self.option_allow_products self._current_option_allow_shopping_list = self.option_allow_shopping_list self._current_option_allow_stock = self.option_allow_stock self._current_option_allow_tasks = self.option_allow_tasks @@ -90,13 +87,6 @@ def option_allow_meal_plan(self) -> bool: CONF_ALLOW_MEAL_PLAN, DEFAULT_CONF_ALLOW_MEAL_PLAN ) - @property - def option_allow_products(self) -> bool: - """Allow loading products sensor from instance.""" - return self.config_entry.options.get( - CONF_ALLOW_PRODUCTS, DEFAULT_CONF_ALLOW_PRODUCTS - ) - @property def option_allow_shopping_list(self) -> bool: """Allow loading shopping list sensor from instance.""" @@ -147,52 +137,80 @@ async def async_config_entry_updated(hass, entry) -> None: async def options_updated(self): """Manage entities affected by config entry options.""" + if self._current_option_allow_chores != self.option_allow_chores: self._current_option_allow_chores = self.option_allow_chores # New is true, add sensor if self._current_option_allow_chores: + # todo: do same with binary self.async_add_entity_callback(NEW_SENSOR, CHORES_NAME) + else: + # remove sensor + self.async_remove_entity_callback(NEW_SENSOR, CHORES_NAME) + + if self._current_option_allow_meal_plan != self.option_allow_meal_plan: + self._current_option_allow_meal_plan = self.option_allow_meal_plan + if self._current_option_allow_meal_plan: + self.async_add_entity_callback(NEW_SENSOR, MEAL_PLAN_NAME) + + if self._current_option_allow_shopping_list != self.option_allow_shopping_list: + self._current_option_allow_shopping_list = self.option_allow_shopping_list + if self._current_option_allow_shopping_list: + self.async_add_entity_callback(NEW_SENSOR, SHOPPING_LIST_NAME) + + if self._current_option_allow_stock != self.option_allow_stock: + self._current_option_allow_stock = self.option_allow_stock + if self._current_option_allow_stock: + self.async_add_entity_callback( + NEW_SENSOR, [STOCK_NAME], + ) + self.async_add_entity_callback( + NEW_BINARY_SENSOR, + [ + EXPIRING_PRODUCTS_NAME, + EXPIRED_PRODUCTS_NAME, + MISSING_PRODUCTS_NAME, + ], + ) if self._current_option_allow_tasks != self.option_allow_tasks: self._current_option_allow_tasks = self.option_allow_tasks - # New is true, add sensor if self._current_option_allow_tasks: self.async_add_entity_callback(NEW_SENSOR, TASKS_NAME) + @callback + def async_remove_entity_callback(self, sensor_type, sensor) -> None: + """Handle event of removing an entity.""" + LOGGER.debug("remove entity callback") + if not isinstance(sensor, list): + sensor = [sensor] + async_dispatcher_send(self.hass, self.async_signal_remove(sensor_type), sensor) + + @callback + def async_signal_remove(self, sensor_type) -> str: + """Event to signal removal.""" + return f"grocy-remove-{self.instanceid}" + @callback def async_add_entity_callback(self, sensor_type, sensor) -> None: - """Handle event of new device creation in Grocy.""" + """Handle event of new entity creation.""" + LOGGER.debug("add entity callback") if not isinstance(sensor, list): sensor = [sensor] async_dispatcher_send( self.hass, self.async_signal_new_entity(sensor_type), sensor ) - ## testa lägga till specifikt sensor-namn här @callback def async_signal_new_entity(self, sensor_type) -> str: - """Event to signal new device.""" - new_sensor = {NEW_SENSOR: f"grocy_new_sensor"} + """Event to signal new entity.""" + new_sensor = { + NEW_SENSOR: f"grocy_new_sensor", + NEW_BINARY_SENSOR: f"grocy_new_binary_sensor", + } return new_sensor[sensor_type] -# @callback -# def add_entities(controller, async_add_entities, clients): -# """Add new sensor entities from the controller.""" -# sensors = [] - -# for mac in clients: -# for sensor_class in (UniFiRxBandwidthSensor, UniFiTxBandwidthSensor): -# if mac in controller.entities[DOMAIN][sensor_class.TYPE]: -# continue - -# client = controller.api.clients[mac] -# sensors.append(sensor_class(client, controller)) - -# if sensors: -# async_add_entities(sensors) - - async def get_instance(hass, config) -> Grocy: """Create a gateway object and verify configuration.""" LOGGER.debug("Getting Grocy instance.") diff --git a/custom_components/grocy/sensor.py b/custom_components/grocy/sensor.py index 3e37136..73c7b8c 100644 --- a/custom_components/grocy/sensor.py +++ b/custom_components/grocy/sensor.py @@ -41,7 +41,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities): @callback def async_add_sensor(sensors): LOGGER.debug("Adding sensors") - entities = [] + LOGGER.debug(sensors) for sensor in sensors: if instance.option_allow_chores and sensor.startswith(CHORES_NAME): @@ -67,7 +67,6 @@ def async_add_sensor(sensors): device_name = TASKS_NAME async_add_entities([GrocySensor(hass, sensor, device_name)], True) - ## en listener per sensor? instance.listeners.append( async_dispatcher_connect( hass, instance.async_signal_new_entity(NEW_SENSOR), async_add_sensor @@ -90,6 +89,17 @@ def __init__(self, hass, sensor_type, device_name): self._unique_id = "{}-{}".format(self._hash_key, self.sensor_type) self._name = "{}.{}".format(DEFAULT_CONF_NAME, self.sensor_type) + async def async_added_to_hass(self) -> None: + instance = self.hass.data[DOMAIN]["instance"] + self.async_on_remove( + async_dispatcher_connect( + self.hass, instance.async_signal_remove, self.remove_item + ) + ) + + async def remove_item(self, mac_addresses: set) -> None: + await self.async_remove() + async def async_update(self): """Update the sensor.""" # Send update "signal" to the component diff --git a/custom_components/grocy/translations/en.json b/custom_components/grocy/translations/en.json index 68ec0b7..0776135 100644 --- a/custom_components/grocy/translations/en.json +++ b/custom_components/grocy/translations/en.json @@ -25,7 +25,6 @@ "data": { "allow_chores": "Chores enabled", "allow_meal_plan": "Meal plan enabled", - "allow_products": "Products enabled", "allow_shopping_list": "Shopping list enabled", "allow_stock": "Stock enabled", "allow_tasks": "Tasks enabled" From cbd574ea70ab068f32679109e8c6f5480ffba5d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Isabella=20Gross=20Alstr=C3=B6m?= Date: Wed, 19 Aug 2020 18:11:43 +0200 Subject: [PATCH 16/45] Have done for binary too --- custom_components/grocy/instance.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/custom_components/grocy/instance.py b/custom_components/grocy/instance.py index bcfe56f..bea0ef5 100644 --- a/custom_components/grocy/instance.py +++ b/custom_components/grocy/instance.py @@ -140,9 +140,7 @@ async def options_updated(self): if self._current_option_allow_chores != self.option_allow_chores: self._current_option_allow_chores = self.option_allow_chores - # New is true, add sensor if self._current_option_allow_chores: - # todo: do same with binary self.async_add_entity_callback(NEW_SENSOR, CHORES_NAME) else: # remove sensor From a31e91381f4e088908b05fcf22062cc7e24f7a2b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Isabella=20Gross=20Alstr=C3=B6m?= Date: Wed, 19 Aug 2020 18:35:49 +0200 Subject: [PATCH 17/45] WIP: removing an entity --- custom_components/grocy/instance.py | 9 ++++++++- custom_components/grocy/sensor.py | 7 +++++-- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/custom_components/grocy/instance.py b/custom_components/grocy/instance.py index bea0ef5..3c5a154 100644 --- a/custom_components/grocy/instance.py +++ b/custom_components/grocy/instance.py @@ -138,13 +138,17 @@ async def async_config_entry_updated(hass, entry) -> None: async def options_updated(self): """Manage entities affected by config entry options.""" + hash_key = self.hass.data[DOMAIN].get("hash_key") + if self._current_option_allow_chores != self.option_allow_chores: self._current_option_allow_chores = self.option_allow_chores if self._current_option_allow_chores: self.async_add_entity_callback(NEW_SENSOR, CHORES_NAME) else: # remove sensor - self.async_remove_entity_callback(NEW_SENSOR, CHORES_NAME) + self.async_remove_entity_callback( + NEW_SENSOR, "{}-{}".format(hash_key, CHORES_NAME) + ) if self._current_option_allow_meal_plan != self.option_allow_meal_plan: self._current_option_allow_meal_plan = self.option_allow_meal_plan @@ -180,6 +184,7 @@ async def options_updated(self): def async_remove_entity_callback(self, sensor_type, sensor) -> None: """Handle event of removing an entity.""" LOGGER.debug("remove entity callback") + LOGGER.debug(sensor) if not isinstance(sensor, list): sensor = [sensor] async_dispatcher_send(self.hass, self.async_signal_remove(sensor_type), sensor) @@ -187,6 +192,8 @@ def async_remove_entity_callback(self, sensor_type, sensor) -> None: @callback def async_signal_remove(self, sensor_type) -> str: """Event to signal removal.""" + LOGGER.debug("signal remove") + return f"grocy-remove-{self.instanceid}" @callback diff --git a/custom_components/grocy/sensor.py b/custom_components/grocy/sensor.py index 73c7b8c..53d6023 100644 --- a/custom_components/grocy/sensor.py +++ b/custom_components/grocy/sensor.py @@ -97,8 +97,11 @@ async def async_added_to_hass(self) -> None: ) ) - async def remove_item(self, mac_addresses: set) -> None: - await self.async_remove() + async def remove_item(self, removal_ids) -> None: + LOGGER.debug(self._unique_id) + LOGGER.debug(removal_ids) + if self._unique_id in removal_ids: + await self.async_remove() async def async_update(self): """Update the sensor.""" From cc6a6fc7045a99a4eaefdf47e0e6790a258a922b Mon Sep 17 00:00:00 2001 From: Raymond Julin Date: Wed, 19 Aug 2020 09:32:03 +0200 Subject: [PATCH 18/45] Add api endpoint to serve proxied images from grocy --- custom_components/grocy/__init__.py | 6 +++- custom_components/grocy/helpers.py | 11 +++++-- custom_components/grocy/instance.py | 49 +++++++++++++++++++++++++++-- 3 files changed, 60 insertions(+), 6 deletions(-) diff --git a/custom_components/grocy/__init__.py b/custom_components/grocy/__init__.py index 938c46b..d95659f 100644 --- a/custom_components/grocy/__init__.py +++ b/custom_components/grocy/__init__.py @@ -27,7 +27,8 @@ ) from .services import async_setup_services -from .instance import GrocyInstance +from .instance import GrocyInstance, async_setup_api +from .helpers import MealPlanItem MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=60) @@ -67,6 +68,9 @@ async def async_setup_entry(hass, config_entry): # Setup services await async_setup_services(hass) + # Setup http endpoint for proxying images from grocy + await async_setup_api(hass, config_entry.data) + return True diff --git a/custom_components/grocy/helpers.py b/custom_components/grocy/helpers.py index 85c8907..c06643d 100644 --- a/custom_components/grocy/helpers.py +++ b/custom_components/grocy/helpers.py @@ -1,12 +1,17 @@ +import base64 + class MealPlanItem(object): - def __init__(self, data, base_url): + def __init__(self, data): self.day = data.day self.note = data.note self.recipe_name = data.recipe.name self.desired_servings = data.recipe.desired_servings - picture_path = data.recipe.get_picture_url_path(400) - self.picture_url = f"{base_url}/api/{picture_path}" + if data.recipe.picture_file_name is not None: + b64name = base64.b64encode(data.recipe.picture_file_name.encode("ascii")) + self.picture_url = f"/api/grocy/recipepictures/{str(b64name, 'utf-8')}" + else: + self.picture_url = None def as_dict(self): return vars(self) diff --git a/custom_components/grocy/instance.py b/custom_components/grocy/instance.py index 3c5a154..dda2768 100644 --- a/custom_components/grocy/instance.py +++ b/custom_components/grocy/instance.py @@ -1,7 +1,9 @@ """ Representation of a Grocy instance """ import asyncio import hashlib +import aiohttp +from aiohttp import hdrs, web from datetime import timedelta from pygrocy import Grocy, TransactionType @@ -10,6 +12,8 @@ from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.const import CONF_API_KEY, CONF_PORT, CONF_URL, CONF_VERIFY_SSL from homeassistant.util import Throttle +from homeassistant.components.http import HomeAssistantView +from homeassistant.helpers.aiohttp_client import async_get_clientsession from .helpers import MealPlanItem from .const import ( @@ -44,6 +48,40 @@ MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=60) +class GrocyPictureView(HomeAssistantView): + """View to render pictures from grocy without auth.""" + + requires_auth = False + url = '/api/grocy/{picture_type}/{filename}' + name = "api:grocy:picture" + + def __init__(self, session, base_url, api_key): + self._session = session + self._base_url = base_url + self._api_key = api_key + + async def get(self, request, picture_type: str, filename: str) -> web.Response: + 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)}" + headers = {'GROCY-API-KEY': self._api_key, 'accept': '*/*'} + + async with self._session.get(url, headers=headers) as resp: + resp.raise_for_status() + + response_headers = {} + for name, value in resp.headers.items(): + if name in ( + hdrs.CACHE_CONTROL, + hdrs.CONTENT_DISPOSITION, + hdrs.CONTENT_LENGTH, + hdrs.CONTENT_TYPE, + hdrs.CONTENT_ENCODING, + ): + response_headers[name] = value + + body = await resp.read() + return web.Response(body=body, headers=response_headers) class GrocyInstance: """ Manages a single Grocy instance """ @@ -348,10 +386,17 @@ async def async_update_meal_plan(self): # This is where the main logic to update platform data goes. def wrapper(): meal_plan = self.client.meal_plan(True) - base_url = self.hass.data[DOMAIN]["url"] - return [MealPlanItem(item, base_url) for item in meal_plan] + return [MealPlanItem(item) for item in meal_plan] self.hass.data[DOMAIN][MEAL_PLAN_NAME] = await self.hass.async_add_executor_job( wrapper ) +async def async_setup_api(hass, config): + session = async_get_clientsession(hass) + + url = config.get(CONF_URL) + api_key = config.get(CONF_API_KEY) + port_number = config.get(CONF_PORT) + base_url = f"{url}:{port_number}" + hass.http.register_view(GrocyPictureView(session, base_url, api_key)) From 7c0fac9d6f133a4471bfa78fb818689f509b78aa Mon Sep 17 00:00:00 2001 From: Raymond Julin Date: Wed, 19 Aug 2020 16:11:13 +0200 Subject: [PATCH 19/45] Add http dependency --- custom_components/grocy/manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/grocy/manifest.json b/custom_components/grocy/manifest.json index 952d29e..d259b86 100644 --- a/custom_components/grocy/manifest.json +++ b/custom_components/grocy/manifest.json @@ -2,7 +2,7 @@ "domain": "grocy", "name": "Grocy", "documentation": "https://github.com/custom-components/grocy", - "dependencies": [], + "dependencies": ["http"], "config_flow": true, "codeowners": [ "@SebRut", From c9fe568fde550dabae6e06311727d5a699fba510 Mon Sep 17 00:00:00 2001 From: Raymond Julin Date: Mon, 31 Aug 2020 11:04:46 +0200 Subject: [PATCH 20/45] Filter meal plan to only include today+future entries and sort it by date --- custom_components/grocy/instance.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/custom_components/grocy/instance.py b/custom_components/grocy/instance.py index dda2768..0640d59 100644 --- a/custom_components/grocy/instance.py +++ b/custom_components/grocy/instance.py @@ -4,7 +4,7 @@ import aiohttp from aiohttp import hdrs, web -from datetime import timedelta +from datetime import timedelta, datetime from pygrocy import Grocy, TransactionType from homeassistant.helpers.dispatcher import async_dispatcher_send @@ -386,7 +386,10 @@ async def async_update_meal_plan(self): # This is where the main logic to update platform data goes. def wrapper(): meal_plan = self.client.meal_plan(True) - return [MealPlanItem(item) for item in meal_plan] + today = datetime.today().date() + date_format = '%Y-%m-%d %H:%M:%S.%f' + plan = [MealPlanItem(item) for item in meal_plan if item.day.date() >= today] + return sorted(plan, key=lambda item: item.day) self.hass.data[DOMAIN][MEAL_PLAN_NAME] = await self.hass.async_add_executor_job( wrapper From 8e6cb14fc0b222f00a646ddab59756d6c3aa5f1f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Isabella=20Gross=20Alstr=C3=B6m?= Date: Sat, 5 Sep 2020 14:19:31 +0200 Subject: [PATCH 21/45] WIP: refactor according to blueprint --- custom_components/grocy/__init__.py | 396 +++++-------------- custom_components/grocy/binary_sensor.py | 106 +++-- custom_components/grocy/config_flow.py | 188 ++++++--- custom_components/grocy/const.py | 115 ++++-- custom_components/grocy/entity.py | 59 +++ custom_components/grocy/grocy_data.py | 207 ++++++++++ custom_components/grocy/helpers.py | 12 +- custom_components/grocy/manifest.json | 9 +- custom_components/grocy/sensor.py | 154 ++++---- custom_components/grocy/services.py | 220 +++++++++++ custom_components/grocy/translations/en.json | 55 ++- 11 files changed, 965 insertions(+), 556 deletions(-) create mode 100644 custom_components/grocy/entity.py create mode 100644 custom_components/grocy/grocy_data.py create mode 100644 custom_components/grocy/services.py diff --git a/custom_components/grocy/__init__.py b/custom_components/grocy/__init__.py index fec82ca..9496ece 100644 --- a/custom_components/grocy/__init__.py +++ b/custom_components/grocy/__init__.py @@ -1,332 +1,88 @@ """ -The integration for grocy. +Custom integration to integrate Grocy with Home Assistant. + +For more details about this integration, please refer to +https://github.com/custom-components/grocy """ import asyncio -import hashlib -import logging -import os from datetime import timedelta +import os +import logging +import hashlib -import homeassistant.helpers.config_validation as cv -import voluptuous as vol -from homeassistant import config_entries -from homeassistant.const import CONF_API_KEY, CONF_PORT, CONF_URL, CONF_VERIFY_SSL -from homeassistant.core import callback -from homeassistant.helpers import discovery, entity_component -from homeassistant.util import Throttle -from integrationhelper.const import CC_STARTUP_VERSION +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import Config, HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +from pygrocy import Grocy, TransactionType from .const import ( - CHORES_NAME, - TASKS_NAME, - CONF_BINARY_SENSOR, - CONF_ENABLED, - CONF_NAME, - CONF_SENSOR, - DEFAULT_NAME, - DEFAULT_PORT_NUMBER, DOMAIN, - DOMAIN_DATA, - EXPIRED_PRODUCTS_NAME, - EXPIRING_PRODUCTS_NAME, - ISSUE_URL, - MISSING_PRODUCTS_NAME, - MEAL_PLAN_NAME, PLATFORMS, + STARTUP_MESSAGE, + CONF_URL, + CONF_API_KEY, + CONF_PORT, + CONF_VERIFY_SSL, + ALL_ENTITY_TYPES, REQUIRED_FILES, - SHOPPING_LIST_NAME, - STARTUP, - STOCK_NAME, - VERSION, ) +from .grocy_data import GrocyData, async_setup_image_api +from .services import async_setup_services -from .helpers import MealPlanItem - -MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=60) +SCAN_INTERVAL = timedelta(seconds=30) _LOGGER = logging.getLogger(__name__) -SENSOR_SCHEMA = vol.Schema( - { - vol.Optional(CONF_ENABLED, default=True): cv.boolean, - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - } -) -BINARY_SENSOR_SCHEMA = vol.Schema( - { - vol.Optional(CONF_ENABLED, default=True): cv.boolean, - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - } -) - -CONFIG_SCHEMA = vol.Schema( - { - DOMAIN: vol.Schema( - { - vol.Required(CONF_URL): cv.string, - vol.Required(CONF_API_KEY): cv.string, - vol.Optional(CONF_PORT, default=DEFAULT_PORT_NUMBER): cv.port, - vol.Optional(CONF_VERIFY_SSL, default=True): cv.boolean, - vol.Optional(CONF_SENSOR): vol.All(cv.ensure_list, [SENSOR_SCHEMA]), - vol.Optional(CONF_BINARY_SENSOR): vol.All( - cv.ensure_list, [BINARY_SENSOR_SCHEMA] - ), - } - ) - }, - extra=vol.ALLOW_EXTRA, -) - - -async def async_setup(hass, config): - """Set up this component.""" +async def async_setup(hass: HomeAssistant, config: Config): + """Set up this integration using YAML is not supported.""" return True -async def async_setup_entry(hass, config_entry): +async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry): """Set up this integration using UI.""" - from pygrocy import Grocy, TransactionType - from datetime import datetime - import iso8601 + if hass.data.get(DOMAIN) is None: + hass.data.setdefault(DOMAIN, {}) + _LOGGER.info(STARTUP_MESSAGE) - conf = hass.data.get(DOMAIN_DATA) - if config_entry.source == config_entries.SOURCE_IMPORT: - if conf is None: - hass.async_create_task( - hass.config_entries.async_remove(config_entry.entry_id) - ) - return False - - # Print startup message - _LOGGER.info( - CC_STARTUP_VERSION.format(name=DOMAIN, version=VERSION, issue_link=ISSUE_URL) - ) - - # Check that all required files are present if not await hass.async_add_executor_job(check_files, hass): return False - # Create DATA dict - hass.data[DOMAIN_DATA] = {} - - # Get "global" configuration. url = config_entry.data.get(CONF_URL) api_key = config_entry.data.get(CONF_API_KEY) - verify_ssl = config_entry.data.get(CONF_VERIFY_SSL) port_number = config_entry.data.get(CONF_PORT) - hash_key = hashlib.md5(api_key.encode("utf-8") + url.encode("utf-8")).hexdigest() - - # Configure the client. - grocy = Grocy(url, api_key, port_number, verify_ssl) - hass.data[DOMAIN_DATA]["client"] = GrocyData(hass, grocy) - hass.data[DOMAIN_DATA]["hash_key"] = hash_key - hass.data[DOMAIN_DATA]["url"] = f"{url}:{port_number}" + verify_ssl = config_entry.data.get(CONF_VERIFY_SSL) - # Add sensor - hass.async_add_job( - hass.config_entries.async_forward_entry_setup(config_entry, "sensor") - ) - # Add sensor - hass.async_add_job( - hass.config_entries.async_forward_entry_setup(config_entry, "binary_sensor") + coordinator = GrocyDataUpdateCoordinator( + hass, url, api_key, port_number, verify_ssl ) + await coordinator.async_refresh() - @callback - def handle_add_product(call): - product_id = call.data["product_id"] - amount = call.data.get("amount", 0) - price = call.data.get("price", None) - grocy.add_product(product_id, amount, price) - - hass.services.async_register(DOMAIN, "add_product", handle_add_product) - - @callback - def handle_consume_product(call): - product_id = call.data["product_id"] - amount = call.data.get("amount", 0) - spoiled = call.data.get("spoiled", False) - - transaction_type_raw = call.data.get("transaction_type", None) - transaction_type = TransactionType.CONSUME - - if transaction_type_raw is not None: - transaction_type = TransactionType[transaction_type_raw] - grocy.consume_product( - product_id, amount, spoiled=spoiled, transaction_type=transaction_type - ) - - hass.services.async_register(DOMAIN, "consume_product", handle_consume_product) - - @callback - def handle_execute_chore(call): - chore_id = call.data["chore_id"] - done_by = call.data.get("done_by", None) - tracked_time_str = call.data.get("tracked_time", None) - - tracked_time = datetime.now() - if tracked_time_str is not None: - tracked_time = iso8601.parse_date(tracked_time_str) - grocy.execute_chore(chore_id, done_by, tracked_time) - asyncio.run_coroutine_threadsafe( - entity_component.async_update_entity(hass, "sensor.grocy_chores"), hass.loop - ) - - hass.services.async_register(DOMAIN, "execute_chore", handle_execute_chore) - - @callback - def handle_complete_task(call): - task_id = call.data["task_id"] - done_time_str = call.data.get("done_time", None) - - done_time = datetime.now() - if done_time_str is not None: - done_time = iso8601.parse_date(done_time_str) - grocy.complete_task(task_id, done_time) - asyncio.run_coroutine_threadsafe( - entity_component.async_update_entity(hass, "sensor.grocy_tasks"), hass.loop - ) + if not coordinator.last_update_success: + raise ConfigEntryNotReady - hass.services.async_register(DOMAIN, "complete_task", handle_complete_task) + hass.data[DOMAIN][config_entry.entry_id] = coordinator - @callback - def handle_add_generic(call): - entity_type = call.data["entity_type"] - data = call.data["data"] + for platform in PLATFORMS: + if config_entry.options.get(platform, True): + coordinator.platforms.append(platform) + hass.async_add_job( + hass.config_entries.async_forward_entry_setup(config_entry, platform) + ) - grocy.add_generic(entity_type, data) + await async_setup_services(hass, config_entry) - hass.services.async_register(DOMAIN, "add_generic", handle_add_generic) + # Setup http endpoint for proxying images from grocy + await async_setup_image_api(hass, config_entry.data) + config_entry.add_update_listener(async_reload_entry) return True -class GrocyData: - """This class handle communication and stores the data.""" - - def __init__(self, hass, client): - """Initialize the class.""" - self.hass = hass - self.client = client - self.sensor_types_dict = { - STOCK_NAME: self.async_update_stock, - CHORES_NAME: self.async_update_chores, - TASKS_NAME: self.async_update_tasks, - SHOPPING_LIST_NAME: self.async_update_shopping_list, - EXPIRING_PRODUCTS_NAME: self.async_update_expiring_products, - EXPIRED_PRODUCTS_NAME: self.async_update_expired_products, - MISSING_PRODUCTS_NAME: self.async_update_missing_products, - MEAL_PLAN_NAME : self.async_update_meal_plan, - } - self.sensor_update_dict = { - STOCK_NAME: None, - CHORES_NAME: None, - TASKS_NAME: None, - SHOPPING_LIST_NAME: None, - EXPIRING_PRODUCTS_NAME: None, - EXPIRED_PRODUCTS_NAME: None, - MISSING_PRODUCTS_NAME: None, - MEAL_PLAN_NAME : None, - } - - async def async_update_data(self, sensor_type): - """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. - self.hass.async_create_task(self.sensor_types_dict[sensor_type]()) - - @Throttle(MIN_TIME_BETWEEN_UPDATES) - async def async_update_stock(self): - """Update data.""" - # This is where the main logic to update platform data goes. - self.hass.data[DOMAIN_DATA][ - STOCK_NAME - ] = await self.hass.async_add_executor_job(self.client.stock) - - async def async_update_chores(self): - """Update data.""" - # This is where the main logic to update platform data goes. - def wrapper(): - return self.client.chores(True) - - self.hass.data[DOMAIN_DATA][ - CHORES_NAME - ] = 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. - - self.hass.data[DOMAIN_DATA][ - TASKS_NAME - ] = await self.hass.async_add_executor_job(self.client.tasks) - - async def async_update_shopping_list(self): - """Update data.""" - # This is where the main logic to update platform data goes. - def wrapper(): - return self.client.shopping_list(True) - - self.hass.data[DOMAIN_DATA][ - SHOPPING_LIST_NAME - ] = await self.hass.async_add_executor_job(wrapper) - - @Throttle(MIN_TIME_BETWEEN_UPDATES) - async def async_update_expiring_products(self): - """Update data.""" - # This is where the main logic to update platform data goes. - def wrapper(): - return self.client.expiring_products(True) - - self.hass.data[DOMAIN_DATA][ - EXPIRING_PRODUCTS_NAME - ] = await self.hass.async_add_executor_job(wrapper) - - @Throttle(MIN_TIME_BETWEEN_UPDATES) - async def async_update_expired_products(self): - """Update data.""" - # This is where the main logic to update platform data goes. - def wrapper(): - return self.client.expired_products(True) - - self.hass.data[DOMAIN_DATA][ - EXPIRED_PRODUCTS_NAME - ] = await self.hass.async_add_executor_job(wrapper) - - @Throttle(MIN_TIME_BETWEEN_UPDATES) - async def async_update_missing_products(self): - """Update data.""" - # This is where the main logic to update platform data goes. - def wrapper(): - return self.client.missing_products(True) - - self.hass.data[DOMAIN_DATA][ - MISSING_PRODUCTS_NAME - ] = await self.hass.async_add_executor_job(wrapper) - - @Throttle(MIN_TIME_BETWEEN_UPDATES) - async def async_update_meal_plan(self): - """Update data.""" - # This is where the main logic to update platform data goes. - def wrapper(): - meal_plan = self.client.meal_plan(True) - base_url = self.hass.data[DOMAIN_DATA]["url"] - return [MealPlanItem(item, base_url) for item in meal_plan] - - self.hass.data[DOMAIN_DATA][ - MEAL_PLAN_NAME - ] = await self.hass.async_add_executor_job(wrapper) - - def check_files(hass): - """Return bool that indicates if all files are present.""" - # Verify that the user downloaded all files. + """Verify that the user downloaded all files.""" + base = "{}/custom_components/{}/".format(hass.config.path(), DOMAIN) missing = [] for file in REQUIRED_FILES: @@ -343,19 +99,51 @@ def check_files(hass): return returnvalue -async def async_remove_entry(hass, config_entry): +class GrocyDataUpdateCoordinator(DataUpdateCoordinator): + """Class to manage fetching data from the API.""" + + def __init__(self, hass, url, api_key, port_number, verify_ssl): + """Initialize.""" + self.api = Grocy(url, api_key, port_number, verify_ssl) + self.platforms = [] + self.hass = hass + hash_key = hashlib.md5( + api_key.encode("utf-8") + url.encode("utf-8") + ).hexdigest() + self.hash_key = hash_key + super().__init__(hass, _LOGGER, name=DOMAIN, update_interval=SCAN_INTERVAL) + + async def _async_update_data(self): + """Update data via library.""" + try: + grocy_data = GrocyData(self.hass, self.api) + for platform in self.platforms: + data = await grocy_data.async_update_data(platform) + _LOGGER.debug(data) + return "" + except Exception as exception: + raise UpdateFailed(exception) + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): """Handle removal of an entry.""" - try: - await hass.config_entries.async_forward_entry_unload(config_entry, "sensor") - _LOGGER.info("Successfully removed sensor from the grocy integration") - except ValueError as error: - _LOGGER.exception(error) - pass - try: - await hass.config_entries.async_forward_entry_unload( - config_entry, "binary_sensor" + coordinator = hass.data[DOMAIN][entry.entry_id] + unloaded = all( + await asyncio.gather( + *[ + hass.config_entries.async_forward_entry_unload(entry, platform) + for platform in PLATFORMS + if platform in coordinator.platforms + ] ) - _LOGGER.info("Successfully removed sensor from the grocy integration") - except ValueError as error: - _LOGGER.exception(error) - pass + ) + if unloaded: + hass.data[DOMAIN].pop(entry.entry_id) + _LOGGER.debug("Successfully unloaded %s", unloaded) + return unloaded + + +async def async_reload_entry(hass: HomeAssistant, entry: ConfigEntry): + """Reload config entry.""" + await async_unload_entry(hass, entry) + await async_setup_entry(hass, entry) diff --git a/custom_components/grocy/binary_sensor.py b/custom_components/grocy/binary_sensor.py index 9daeff7..41982aa 100644 --- a/custom_components/grocy/binary_sensor.py +++ b/custom_components/grocy/binary_sensor.py @@ -1,66 +1,61 @@ -"""Binary sensor platform for grocy.""" +"""Binary sensor platform for blueprint.""" import logging -from homeassistant.components.binary_sensor import BinarySensorEntity - -from .const import ATTRIBUTION, BINARY_SENSOR_TYPES, DEFAULT_NAME, DOMAIN, DOMAIN_DATA +from homeassistant.components.binary_sensor import BinarySensorDevice + +# pylint: disable=relative-beyond-top-level +from .const import ( + BINARY_SENSOR, + DEFAULT_NAME, + DOMAIN, + BINARY_SENSOR_TYPES, + CONF_ALLOW_STOCK, + DEFAULT_CONF_ALLOW_STOCK, + EXPIRING_PRODUCTS_NAME, + EXPIRED_PRODUCTS_NAME, + MISSING_PRODUCTS_NAME, + STOCK_NAME, +) +from .entity import GrocyEntity _LOGGER = logging.getLogger(__name__) -async def async_setup_platform( - hass, config, async_add_entities, discovery_info=None -): # pylint: disable=unused-argument +async def async_setup_entry(hass, entry, async_add_entities): """Setup binary_sensor platform.""" - async_add_entities([GrocyBinarySensor(hass, discovery_info)], True) - + coordinator = hass.data[DOMAIN][entry.entry_id] -async def async_setup_entry(hass, config_entry, async_add_devices): - """Setup sensor platform.""" + options_allow_stock = entry.options.get(CONF_ALLOW_STOCK, DEFAULT_CONF_ALLOW_STOCK) for binary_sensor in BINARY_SENSOR_TYPES: - async_add_devices([GrocyBinarySensor(hass, binary_sensor)], True) - - -class GrocyBinarySensor(BinarySensorEntity): - """grocy binary_sensor class.""" - - def __init__(self, hass, sensor_type): - self.hass = hass - self.sensor_type = sensor_type - self.attr = {} - self._status = False - self._hash_key = self.hass.data[DOMAIN_DATA]["hash_key"] - self._unique_id = "{}-{}".format(self._hash_key, self.sensor_type) - self._name = "{}.{}".format(DEFAULT_NAME, self.sensor_type) - self._client = self.hass.data[DOMAIN_DATA]["client"] - - async def async_update(self): - """Update the binary_sensor.""" - # Send update "signal" to the component - await self._client.async_update_data(self.sensor_type) - - self.attr["items"] = [ - x.as_dict() for x in self.hass.data[DOMAIN_DATA].get(self.sensor_type, []) - ] - self._status = len(self.attr["items"]) != 0 - _LOGGER.debug(self.attr) - - @property - def unique_id(self): - """Return a unique ID to use for this binary_sensor.""" - return self._unique_id - - @property - def device_info(self): - return { - "identifiers": {(DOMAIN, self.unique_id)}, - "name": self._name, - "manufacturer": "Grocy", - } + if options_allow_stock and binary_sensor.startswith(EXPIRING_PRODUCTS_NAME): + _LOGGER.debug("Adding expiring products binary sensor") + device_name = STOCK_NAME + async_add_entities( + [GrocyBinarySensor(coordinator, entry, device_name, binary_sensor)], + True, + ) + elif options_allow_stock and binary_sensor.startswith(EXPIRED_PRODUCTS_NAME): + _LOGGER.debug("Adding expired products binary sensor") + device_name = STOCK_NAME + async_add_entities( + [GrocyBinarySensor(coordinator, entry, device_name, binary_sensor)], + True, + ) + elif options_allow_stock and binary_sensor.startswith(MISSING_PRODUCTS_NAME): + _LOGGER.debug("Adding missing products binary sensor") + device_name = STOCK_NAME + async_add_entities( + [GrocyBinarySensor(coordinator, entry, device_name, binary_sensor)], + True, + ) + + +class GrocyBinarySensor(GrocyEntity, BinarySensorDevice): + """Grocy binary_sensor class.""" @property def name(self): """Return the name of the binary_sensor.""" - return self._name + return f"{DEFAULT_NAME}_{BINARY_SENSOR}" @property def device_class(self): @@ -70,10 +65,5 @@ def device_class(self): @property def is_on(self): """Return true if the binary_sensor is on.""" - return self._status - - @property - def device_state_attributes(self): - """Return the state attributes.""" - return self.attr - + return True + # return self.coordinator.data.get("bool_on", False) diff --git a/custom_components/grocy/config_flow.py b/custom_components/grocy/config_flow.py index 5d643c2..ba4a339 100644 --- a/custom_components/grocy/config_flow.py +++ b/custom_components/grocy/config_flow.py @@ -1,19 +1,39 @@ -"""Adds config flow for grocy.""" -from collections import OrderedDict - -import logging -import voluptuous as vol +"""Adds config flow for Grocy.""" from homeassistant import config_entries +from homeassistant.core import callback from pygrocy import Grocy +import voluptuous as vol +from collections import OrderedDict +import logging -from .const import DEFAULT_PORT_NUMBER, DOMAIN +from .const import ( # pylint: disable=unused-import + NAME, + DOMAIN, + PLATFORMS, + DEFAULT_PORT, + CONF_URL, + CONF_PORT, + CONF_API_KEY, + CONF_VERIFY_SSL, + CONF_ALLOW_CHORES, + CONF_ALLOW_MEAL_PLAN, + CONF_ALLOW_PRODUCTS, + CONF_ALLOW_SHOPPING_LIST, + CONF_ALLOW_STOCK, + CONF_ALLOW_TASKS, + DEFAULT_CONF_PORT_NUMBER, + DEFAULT_CONF_ALLOW_CHORES, + DEFAULT_CONF_ALLOW_MEAL_PLAN, + DEFAULT_CONF_ALLOW_SHOPPING_LIST, + DEFAULT_CONF_ALLOW_STOCK, + DEFAULT_CONF_ALLOW_TASKS, +) _LOGGER = logging.getLogger(__name__) -@config_entries.HANDLERS.register(DOMAIN) -class GrocyFlowHandler(config_entries.ConfigFlow): - """Config flow for grocy.""" +class GrocyFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): + """Config flow for Blueprint.""" VERSION = 1 CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL @@ -23,77 +43,135 @@ def __init__(self): self._errors = {} async def async_step_user( - self, user_input={} - ): # pylint: disable=dangerous-default-value + self, user_input=None # pylint: disable=bad-continuation + ): """Handle a flow initialized by the user.""" 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") - if self.hass.data.get(DOMAIN): - return self.async_abort(reason="single_instance_allowed") if user_input is not None: valid = await self._test_credentials( - user_input["url"], - user_input["api_key"], - user_input["port"], - user_input["verify_ssl"], + user_input[CONF_URL], + user_input[CONF_API_KEY], + user_input[CONF_PORT], + user_input[CONF_VERIFY_SSL], ) + _LOGGER.debug("Testing of credentials returned: ") + _LOGGER.debug(valid) if valid: - return self.async_create_entry(title="Grocy", data=user_input) + return self.async_create_entry(title=NAME, data=user_input) else: self._errors["base"] = "auth" - _LOGGER.error(self._errors) - return await self._show_config_form(user_input) return await self._show_config_form(user_input) - async def _show_config_form(self, user_input): - """Show the configuration form to edit location data.""" - - # Defaults - url = "" - api_key = "" - port = DEFAULT_PORT_NUMBER - verify_ssl = True - - if user_input is not None: - if "url" in user_input: - url = user_input["url"] - if "api_key" in user_input: - api_key = user_input["api_key"] - if "port" in user_input: - port = user_input["port"] - if "verify_ssl" in user_input: - verify_ssl = user_input["verify_ssl"] + @staticmethod + @callback + def async_get_options_flow(config_entry): + return GrocyOptionsFlowHandler(config_entry) + async def _show_config_form(self, user_input): # pylint: disable=unused-argument + """Show the configuration form to edit the data.""" data_schema = OrderedDict() - data_schema[vol.Required("url", default=url)] = str - data_schema[vol.Required("api_key", default=api_key)] = str - data_schema[vol.Optional("port", default=port)] = int - data_schema[vol.Optional("verify_ssl", default=verify_ssl)] = bool + # TODO remove + data_schema[vol.Required(CONF_URL, default="http://192.168.1.78")] = str + # TODO remove + data_schema[ + vol.Required( + CONF_API_KEY, + default="uZlwmnzzCnF1hpvNHNXbcCG0tmFB06h12bMZC4ggLxGja5Yg9X", + ) + ] = str + data_schema[vol.Optional(CONF_PORT, default=DEFAULT_PORT)] = int + data_schema[vol.Optional(CONF_VERIFY_SSL, default=False)] = bool + _LOGGER.debug("config form") + return self.async_show_form( - step_id="user", data_schema=vol.Schema(data_schema), errors=self._errors + step_id="user", data_schema=vol.Schema(data_schema), errors=self._errors, ) - async def async_step_import(self, user_input): # pylint: disable=unused-argument - """Import a config entry. - Special type of import, we're not actually going to store any data. - Instead, we're going to rely on the values that are in config file. - """ - if self._async_current_entries(): - return self.async_abort(reason="single_instance_allowed") - - return self.async_create_entry(title="configuration.yaml", data={}) - async def _test_credentials(self, url, api_key, port, verify_ssl): """Return true if credentials is valid.""" try: client = Grocy(url, api_key, port, verify_ssl) - await self.hass.async_add_executor_job(client.stock) + + _LOGGER.debug("Testing credentials") + + def system_info(): + """Get system information from Grocy.""" + client._api_client._do_get_request("/api/system/info") + + await self.hass.async_add_executor_job(system_info) return True except Exception as e: # pylint: disable=broad-except - _LOGGER.exception(e) + _LOGGER.error(e) pass return False + + +class GrocyOptionsFlowHandler(config_entries.OptionsFlow): + """Grocy config flow options handler.""" + + def __init__(self, config_entry): + """Initialize Grocy options flow.""" + self.config_entry = config_entry + self.options = dict(config_entry.options) + + async def async_step_init(self, user_input=None): # pylint: disable=unused-argument + """Manage the options.""" + return await self.async_step_user() + + async def async_step_user(self, user_input=None): + """Handle a flow initialized by the user.""" + if user_input is not None: + self.options.update(user_input) + return await self._update_options() + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Optional( + CONF_ALLOW_CHORES, + default=self.config_entry.options.get( + CONF_ALLOW_CHORES, DEFAULT_CONF_ALLOW_CHORES + ), + ): bool, + vol.Optional( + CONF_ALLOW_MEAL_PLAN, + default=self.config_entry.options.get( + CONF_ALLOW_MEAL_PLAN, DEFAULT_CONF_ALLOW_MEAL_PLAN + ), + ): bool, + vol.Optional( + CONF_ALLOW_SHOPPING_LIST, + default=self.config_entry.options.get( + CONF_ALLOW_SHOPPING_LIST, DEFAULT_CONF_ALLOW_SHOPPING_LIST + ), + ): bool, + vol.Optional( + CONF_ALLOW_STOCK, + default=self.config_entry.options.get( + CONF_ALLOW_STOCK, DEFAULT_CONF_ALLOW_STOCK + ), + ): bool, + vol.Optional( + CONF_ALLOW_TASKS, + default=self.config_entry.options.get( + CONF_ALLOW_TASKS, DEFAULT_CONF_ALLOW_TASKS + ), + ): bool, + } + ), + ) + + async def _update_options(self): + """Update config entry options.""" + return self.async_create_entry( + title=self.config_entry.data.get(NAME), data=self.options + ) diff --git a/custom_components/grocy/const.py b/custom_components/grocy/const.py index 8becc0f..23c2507 100644 --- a/custom_components/grocy/const.py +++ b/custom_components/grocy/const.py @@ -1,45 +1,39 @@ -"""Constants for grocy.""" +"""Constants for Grocy.""" # Base component constants +NAME = "Grocy" DOMAIN = "grocy" -DOMAIN_DATA = "{}_data".format(DOMAIN) -VERSION = "0.4.0" -PLATFORMS = ["sensor", "binary_sensor"] -REQUIRED_FILES = [ - "const.py", - "manifest.json", - "sensor.py", - "binary_sensor.py", - "config_flow.py", - "translations/en.json", -] +DOMAIN_DATA = f"{DOMAIN}_data" +VERSION = "0.0.1" + ISSUE_URL = "https://github.com/custom-components/grocy/issues" -ATTRIBUTION = "Data from this is provided by grocy." -STARTUP = """ -------------------------------------------------------------------- -{name} -Version: {version} -This is a custom component -If you have any issues with this you need to open an issue here: -{issueurl} -------------------------------------------------------------------- -""" # Icons ICON = "mdi:format-quote-close" # Device classes +# BINARY_SENSOR_DEVICE_CLASS = "connectivity" + SENSOR_PRODUCTS_UNIT_OF_MEASUREMENT = "Product(s)" SENSOR_CHORES_UNIT_OF_MEASUREMENT = "Chore(s)" SENSOR_TASKS_UNIT_OF_MEASUREMENT = "Task(s)" SENSOR_MEALS_UNIT_OF_MEASUREMENT = "Meal(s)" -STOCK_NAME = "stock" -CHORES_NAME = "chores" -TASKS_NAME = "tasks" -SHOPPING_LIST_NAME = "shopping_list" -EXPIRING_PRODUCTS_NAME = "expiring_products" -EXPIRED_PRODUCTS_NAME = "expired_products" -MISSING_PRODUCTS_NAME = "missing_products" -MEAL_PLAN_NAME = "meal_plan" + +# Platforms +BINARY_SENSOR = "binary_sensor" +SENSOR = "sensor" +# PLATFORMS = [BINARY_SENSOR, SENSOR, SWITCH] +PLATFORMS = [BINARY_SENSOR, SENSOR] + +# Entities +STOCK_NAME = "Stock" +CHORES_NAME = "Chores" +TASKS_NAME = "Tasks" +SHOPPING_LIST_NAME = "Shopping_list" +PRODUCTS_NAME = "Products" +EXPIRING_PRODUCTS_NAME = "Expiring_products" +EXPIRED_PRODUCTS_NAME = "Expired_products" +MISSING_PRODUCTS_NAME = "Missing_products" +MEAL_PLAN_NAME = "Meal_plan" SENSOR_TYPES = [STOCK_NAME, CHORES_NAME, TASKS_NAME, SHOPPING_LIST_NAME, MEAL_PLAN_NAME] BINARY_SENSOR_TYPES = [ @@ -48,12 +42,65 @@ MISSING_PRODUCTS_NAME, ] -# Configuration -CONF_SENSOR = "sensor" -CONF_BINARY_SENSOR = "binary_sensor" +ALL_ENTITY_TYPES = [ + STOCK_NAME, + CHORES_NAME, + TASKS_NAME, + SHOPPING_LIST_NAME, + MEAL_PLAN_NAME, + EXPIRING_PRODUCTS_NAME, + EXPIRED_PRODUCTS_NAME, + MISSING_PRODUCTS_NAME, +] + + +# Configuration and options CONF_ENABLED = "enabled" CONF_NAME = "name" +DEFAULT_CONF_NAME = DOMAIN +DEFAULT_PORT = 9192 +CONF_URL = "url" +CONF_PORT = "port" +CONF_API_KEY = "api_key" +CONF_VERIFY_SSL = "verify_ssl" + +CONF_ALLOW_CHORES = "allow_chores" +CONF_ALLOW_MEAL_PLAN = "allow_meal_plan" +CONF_ALLOW_PRODUCTS = "allow_products" +CONF_ALLOW_SHOPPING_LIST = "allow_shopping_list" +CONF_ALLOW_STOCK = "allow_stock" +CONF_ALLOW_TASKS = "allow_tasks" + # Defaults DEFAULT_NAME = DOMAIN -DEFAULT_PORT_NUMBER = 9192 +# DEFAULT_CONF_NAME = DOMAIN +DEFAULT_CONF_PORT_NUMBER = 9192 +DEFAULT_CONF_ALLOW_CHORES = False +DEFAULT_CONF_ALLOW_MEAL_PLAN = False +DEFAULT_CONF_ALLOW_SHOPPING_LIST = False +DEFAULT_CONF_ALLOW_STOCK = False +DEFAULT_CONF_ALLOW_TASKS = False + +STARTUP_MESSAGE = f""" +------------------------------------------------------------------- +{NAME} +Version: {VERSION} +This is a custom integration! +If you have any issues with this you need to open an issue here: +{ISSUE_URL} +------------------------------------------------------------------- +""" + +REQUIRED_FILES = [ + "const.py", + "entity.py", + "grocy_data.py", + "manifest.json", + "helpers.py", + "sensor.py", + "binary_sensor.py", + "config_flow.py", + "services.py", + "translations/en.json", +] diff --git a/custom_components/grocy/entity.py b/custom_components/grocy/entity.py new file mode 100644 index 0000000..02dbfba --- /dev/null +++ b/custom_components/grocy/entity.py @@ -0,0 +1,59 @@ +"""GrocyEntity class""" +from homeassistant.helpers import entity + +# pylint: disable=relative-beyond-top-level +from .const import DOMAIN, NAME, VERSION + + +class GrocyEntity(entity.Entity): + def __init__(self, coordinator, config_entry, device_name, sensor_type): + self.coordinator = coordinator + self.config_entry = config_entry + self.device_name = device_name + self.sensor_type = sensor_type + + self._unique_id = "{}-{}".format(self.coordinator.hash_key, self.sensor_type) + + @property + def should_poll(self): + """No need to poll. Coordinator notifies entity of updates.""" + return False + + @property + def available(self): + """Return if entity is available.""" + return self.coordinator.last_update_success + + @property + def unique_id(self): + """Return a unique ID to use for this entity.""" + return self._unique_id + + @property + def device_info(self): + return { + # "identifiers": {(DOMAIN, self.unique_id)}, + "identifiers": {(DOMAIN, self.device_name)}, + "name": self.device_name, + "model": VERSION, + "manufacturer": NAME, + "entry_type": "service", + } + + # @property + # def device_state_attributes(self): + # """Return the state attributes.""" + # return { + # "time": str(self.coordinator.data.get("time")), + # "static": self.coordinator.data.get("static"), + # } + + async def async_added_to_hass(self): + """Connect to dispatcher listening for entity data notifications.""" + self.async_on_remove( + self.coordinator.async_add_listener(self.async_write_ha_state) + ) + + async def async_update(self): + """Update Grocy entity.""" + await self.coordinator.async_request_refresh() diff --git a/custom_components/grocy/grocy_data.py b/custom_components/grocy/grocy_data.py new file mode 100644 index 0000000..c53defe --- /dev/null +++ b/custom_components/grocy/grocy_data.py @@ -0,0 +1,207 @@ +from aiohttp import hdrs, web +from datetime import timedelta, datetime + +from homeassistant.util import Throttle +from homeassistant.components.http import HomeAssistantView +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import ( + CONF_ALLOW_CHORES, + CONF_ALLOW_MEAL_PLAN, + CONF_ALLOW_SHOPPING_LIST, + CONF_ALLOW_STOCK, + CONF_ALLOW_TASKS, + CONF_API_KEY, + CONF_URL, + CONF_PORT, + DEFAULT_CONF_ALLOW_CHORES, + DEFAULT_CONF_ALLOW_MEAL_PLAN, + DEFAULT_CONF_ALLOW_SHOPPING_LIST, + DEFAULT_CONF_ALLOW_STOCK, + DEFAULT_CONF_ALLOW_TASKS, + CHORES_NAME, + TASKS_NAME, + DOMAIN, + EXPIRED_PRODUCTS_NAME, + EXPIRING_PRODUCTS_NAME, + MISSING_PRODUCTS_NAME, + MEAL_PLAN_NAME, + SHOPPING_LIST_NAME, + STOCK_NAME, +) +from .helpers import MealPlanItem + +MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=60) + + +class GrocyData: + """This class handle communication and stores the data.""" + + def __init__(self, hass, client): + """Initialize the class.""" + self.hass = hass + self.client = client + self.sensor_types_dict = { + STOCK_NAME: self.async_update_stock, + CHORES_NAME: self.async_update_chores, + TASKS_NAME: self.async_update_tasks, + SHOPPING_LIST_NAME: self.async_update_shopping_list, + EXPIRING_PRODUCTS_NAME: self.async_update_expiring_products, + EXPIRED_PRODUCTS_NAME: self.async_update_expired_products, + MISSING_PRODUCTS_NAME: self.async_update_missing_products, + MEAL_PLAN_NAME: self.async_update_meal_plan, + } + self.sensor_update_dict = { + STOCK_NAME: None, + CHORES_NAME: None, + TASKS_NAME: None, + SHOPPING_LIST_NAME: None, + EXPIRING_PRODUCTS_NAME: None, + EXPIRED_PRODUCTS_NAME: None, + MISSING_PRODUCTS_NAME: None, + MEAL_PLAN_NAME: None, + } + + async def async_update_data(self, sensor_type): + """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. + self.hass.async_create_task(self.sensor_types_dict[sensor_type]()) + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + async def async_update_stock(self): + """Update data.""" + # This is where the main logic to update platform data goes. + self.hass.data[DOMAIN][STOCK_NAME] = await self.hass.async_add_executor_job( + self.client.stock + ) + + async def async_update_chores(self): + """Update data.""" + # This is where the main logic to update platform data goes. + def wrapper(): + return self.client.chores(True) + + self.hass.data[DOMAIN][CHORES_NAME] = 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. + + self.hass.data[DOMAIN][TASKS_NAME] = await self.hass.async_add_executor_job( + self.client.tasks + ) + + async def async_update_shopping_list(self): + """Update data.""" + # This is where the main logic to update platform data goes. + def wrapper(): + return self.client.shopping_list(True) + + self.hass.data[DOMAIN][ + SHOPPING_LIST_NAME + ] = await self.hass.async_add_executor_job(wrapper) + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + async def async_update_expiring_products(self): + """Update data.""" + # This is where the main logic to update platform data goes. + def wrapper(): + return self.client.expiring_products(True) + + self.hass.data[DOMAIN][ + EXPIRING_PRODUCTS_NAME + ] = await self.hass.async_add_executor_job(wrapper) + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + async def async_update_expired_products(self): + """Update data.""" + # This is where the main logic to update platform data goes. + def wrapper(): + return self.client.expired_products(True) + + self.hass.data[DOMAIN][ + EXPIRED_PRODUCTS_NAME + ] = await self.hass.async_add_executor_job(wrapper) + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + async def async_update_missing_products(self): + """Update data.""" + # This is where the main logic to update platform data goes. + def wrapper(): + return self.client.missing_products(True) + + self.hass.data[DOMAIN][ + MISSING_PRODUCTS_NAME + ] = await self.hass.async_add_executor_job(wrapper) + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + async def async_update_meal_plan(self): + """Update data.""" + # This is where the main logic to update platform data goes. + def wrapper(): + meal_plan = self.client.meal_plan(True) + today = datetime.today().date() + date_format = "%Y-%m-%d %H:%M:%S.%f" + plan = [ + MealPlanItem(item) for item in meal_plan if item.day.date() >= today + ] + return sorted(plan, key=lambda item: item.day) + + self.hass.data[DOMAIN][MEAL_PLAN_NAME] = await self.hass.async_add_executor_job( + wrapper + ) + + +async def async_setup_image_api(hass, config): + session = async_get_clientsession(hass) + + url = config.get(CONF_URL) + api_key = config.get(CONF_API_KEY) + port_number = config.get(CONF_PORT) + base_url = f"{url}:{port_number}" + hass.http.register_view(GrocyPictureView(session, base_url, api_key)) + + +class GrocyPictureView(HomeAssistantView): + """View to render pictures from grocy without auth.""" + + requires_auth = False + url = "/api/grocy/{picture_type}/{filename}" + name = "api:grocy:picture" + + def __init__(self, session, base_url, api_key): + self._session = session + self._base_url = base_url + self._api_key = api_key + + async def get(self, request, picture_type: str, filename: str) -> web.Response: + 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)}" + headers = {"GROCY-API-KEY": self._api_key, "accept": "*/*"} + + async with self._session.get(url, headers=headers) as resp: + resp.raise_for_status() + + response_headers = {} + for name, value in resp.headers.items(): + if name in ( + hdrs.CACHE_CONTROL, + hdrs.CONTENT_DISPOSITION, + hdrs.CONTENT_LENGTH, + hdrs.CONTENT_TYPE, + hdrs.CONTENT_ENCODING, + ): + response_headers[name] = value + + body = await resp.read() + return web.Response(body=body, headers=response_headers) diff --git a/custom_components/grocy/helpers.py b/custom_components/grocy/helpers.py index 85c8907..f09f72f 100644 --- a/custom_components/grocy/helpers.py +++ b/custom_components/grocy/helpers.py @@ -1,12 +1,18 @@ +import base64 + + class MealPlanItem(object): - def __init__(self, data, base_url): + def __init__(self, data): self.day = data.day self.note = data.note self.recipe_name = data.recipe.name self.desired_servings = data.recipe.desired_servings - picture_path = data.recipe.get_picture_url_path(400) - self.picture_url = f"{base_url}/api/{picture_path}" + if data.recipe.picture_file_name is not None: + b64name = base64.b64encode(data.recipe.picture_file_name.encode("ascii")) + self.picture_url = f"/api/grocy/recipepictures/{str(b64name, 'utf-8')}" + else: + self.picture_url = None def as_dict(self): return vars(self) diff --git a/custom_components/grocy/manifest.json b/custom_components/grocy/manifest.json index 952d29e..71cc08f 100644 --- a/custom_components/grocy/manifest.json +++ b/custom_components/grocy/manifest.json @@ -2,15 +2,18 @@ "domain": "grocy", "name": "Grocy", "documentation": "https://github.com/custom-components/grocy", - "dependencies": [], + "dependencies": [ + "http" + ], "config_flow": true, "codeowners": [ "@SebRut", "@isabellaalstrom" ], "requirements": [ - "pygrocy==0.20.0", + "sampleclient", + "pygrocy==0.21.0", "iso8601==0.1.12", "integrationhelper" ] -} +} \ No newline at end of file diff --git a/custom_components/grocy/sensor.py b/custom_components/grocy/sensor.py index 043d238..2400c68 100644 --- a/custom_components/grocy/sensor.py +++ b/custom_components/grocy/sensor.py @@ -1,104 +1,98 @@ -"""Sensor platform for grocy.""" +"""Sensor platform for Grocy.""" + import logging -from homeassistant.helpers.entity import Entity +# pylint: disable=relative-beyond-top-level from .const import ( - ATTRIBUTION, - CHORES_NAME, - TASKS_NAME, - MEAL_PLAN_NAME, DEFAULT_NAME, DOMAIN, - DOMAIN_DATA, ICON, - SENSOR_CHORES_UNIT_OF_MEASUREMENT, - SENSOR_TASKS_UNIT_OF_MEASUREMENT, - SENSOR_PRODUCTS_UNIT_OF_MEASUREMENT, - SENSOR_MEALS_UNIT_OF_MEASUREMENT, + SENSOR, SENSOR_TYPES, + CHORES_NAME, + MEAL_PLAN_NAME, + SHOPPING_LIST_NAME, + STOCK_NAME, + TASKS_NAME, + CONF_ALLOW_CHORES, + CONF_ALLOW_MEAL_PLAN, + CONF_ALLOW_SHOPPING_LIST, + CONF_ALLOW_STOCK, + CONF_ALLOW_TASKS, + DEFAULT_CONF_ALLOW_CHORES, + DEFAULT_CONF_ALLOW_MEAL_PLAN, + DEFAULT_CONF_ALLOW_SHOPPING_LIST, + DEFAULT_CONF_ALLOW_STOCK, + DEFAULT_CONF_ALLOW_TASKS, ) +from .entity import GrocyEntity _LOGGER = logging.getLogger(__name__) -async def async_setup_platform( - hass, config, async_add_entities, discovery_info=None -): # pylint: disable=unused-argument +async def async_setup_entry(hass, entry, async_add_entities): """Setup sensor platform.""" + coordinator = hass.data[DOMAIN][entry.entry_id] + + options_allow_chores = entry.options.get( + CONF_ALLOW_CHORES, DEFAULT_CONF_ALLOW_CHORES + ) + options_allow_meal_plan = entry.options.get( + CONF_ALLOW_MEAL_PLAN, DEFAULT_CONF_ALLOW_MEAL_PLAN + ) + options_allow_shopping_list = entry.options.get( + CONF_ALLOW_SHOPPING_LIST, DEFAULT_CONF_ALLOW_SHOPPING_LIST + ) + options_allow_stock = entry.options.get(CONF_ALLOW_STOCK, DEFAULT_CONF_ALLOW_STOCK) + options_allow_tasks = entry.options.get(CONF_ALLOW_TASKS, DEFAULT_CONF_ALLOW_TASKS) - async_add_entities([GrocySensor(hass, discovery_info)], True) - - -async def async_setup_entry(hass, config_entry, async_add_devices): - """Setup sensor platform.""" for sensor in SENSOR_TYPES: - async_add_devices([GrocySensor(hass, sensor)], True) - - -class GrocySensor(Entity): - """grocy Sensor class.""" - - def __init__(self, hass, sensor_type): - self.hass = hass - self.sensor_type = sensor_type - self.attr = {} - self._state = None - self._hash_key = self.hass.data[DOMAIN_DATA]["hash_key"] - self._unique_id = "{}-{}".format(self._hash_key, self.sensor_type) - self._name = "{}.{}".format(DEFAULT_NAME, self.sensor_type) - - async def async_update(self): - """Update the sensor.""" - # Send update "signal" to the component - await self.hass.data[DOMAIN_DATA]["client"].async_update_data(self.sensor_type) - - self.attr["items"] = [ - x.as_dict() for x in self.hass.data[DOMAIN_DATA].get(self.sensor_type, []) - ] - self._state = len(self.attr["items"]) - _LOGGER.debug(self.attr) - - @property - def unique_id(self): - """Return a unique ID to use for this sensor.""" - return self._unique_id - - @property - def device_info(self): - return { - "identifiers": {(DOMAIN, self.unique_id)}, - "name": self._name, - "manufacturer": "Grocy", - } - - @property - def state(self): - """Return the state of the sensor.""" - return self._state + if options_allow_chores and sensor.startswith(CHORES_NAME): + _LOGGER.debug("Adding chores sensor") + device_name = CHORES_NAME + async_add_entities( + [GrocySensor(coordinator, entry, device_name, sensor)], True + ) + elif options_allow_meal_plan and sensor.startswith(MEAL_PLAN_NAME): + _LOGGER.debug("Adding meal plan sensor") + device_name = MEAL_PLAN_NAME + async_add_entities( + [GrocySensor(coordinator, entry, device_name, sensor)], True + ) + elif options_allow_shopping_list and sensor.startswith(SHOPPING_LIST_NAME): + _LOGGER.debug("Adding shopping list sensor") + device_name = SHOPPING_LIST_NAME + async_add_entities( + [GrocySensor(coordinator, entry, device_name, sensor)], True + ) + elif options_allow_stock and sensor.startswith(STOCK_NAME): + _LOGGER.debug("Adding stock sensor") + device_name = STOCK_NAME + async_add_entities( + [GrocySensor(coordinator, entry, device_name, sensor)], True + ) + elif options_allow_tasks and sensor.startswith(TASKS_NAME): + _LOGGER.debug("Adding tasks sensor") + device_name = TASKS_NAME + async_add_entities( + [GrocySensor(coordinator, entry, device_name, sensor)], True + ) + + +class GrocySensor(GrocyEntity): + """Grocy Sensor class.""" @property def name(self): """Return the name of the sensor.""" - return self._name + return f"{DEFAULT_NAME}_{SENSOR}" + + # @property + # def state(self): + # """Return the state of the sensor.""" + # return self.coordinator.data.get("static") @property def icon(self): """Return the icon of the sensor.""" return ICON - - @property - def unit_of_measurement(self): - if self.sensor_type == CHORES_NAME: - return SENSOR_CHORES_UNIT_OF_MEASUREMENT - elif self.sensor_type == TASKS_NAME: - return SENSOR_TASKS_UNIT_OF_MEASUREMENT - elif self.sensor_type == MEAL_PLAN_NAME: - return SENSOR_MEALS_UNIT_OF_MEASUREMENT - else: - return SENSOR_PRODUCTS_UNIT_OF_MEASUREMENT - - @property - def device_state_attributes(self): - """Return the state attributes.""" - return self.attr - diff --git a/custom_components/grocy/services.py b/custom_components/grocy/services.py new file mode 100644 index 0000000..65554f0 --- /dev/null +++ b/custom_components/grocy/services.py @@ -0,0 +1,220 @@ +"""Grocy services.""" +import asyncio +import voluptuous as vol +import iso8601 +import logging + +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers import entity_component + +from pygrocy import TransactionType +from datetime import timedelta, datetime + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +GROCY_SERVICES = "grocy_services" + +SERVICE_PRODUCT_ID = "product_id" +SERVICE_AMOUNT = "amount" +SERVICE_PRICE = "price" +SERVICE_SPOILED = "spoiled" +SERVICE_TRANSACTION_TYPE = "transaction_type" +SERVICE_CHORE_ID = "chore_id" +SERVICE_TRACKED_TIME = "tracked_time" +SERVICE_DONE_BY = "done_by" +SERVICE_TASK_ID = "task_id" +SERVICE_DONE_TIME = "done_time" +SERVICE_ENTITY_TYPE = "entity_type" +SERVICE_DATA = "data" + +SERVICE_ADD_PRODUCT = "add_product_to_stock" +SERVICE_CONSUME_PRODUCT = "consume_product_from_stock" +SERVICE_EXECUTE_CHORE = "execute_chore" +SERVICE_COMPLETE_TASK = "complete_task" +SERVICE_ADD_GENERIC = "add_generic" + +SERVICE_ADD_PRODUCT_SCHEMA = vol.All( + vol.Schema( + { + vol.Required(SERVICE_PRODUCT_ID): int, + vol.Required(SERVICE_AMOUNT): int, + vol.Optional(SERVICE_PRICE): str, + } + ) +) + +SERVICE_CONSUME_PRODUCT_SCHEMA = vol.All( + vol.Schema( + { + vol.Required(SERVICE_PRODUCT_ID): int, + vol.Required(SERVICE_AMOUNT): int, + vol.Optional(SERVICE_SPOILED): bool, + vol.Optional(SERVICE_TRANSACTION_TYPE): str, + } + ) +) + +SERVICE_EXECUTE_CHORE_SCHEMA = vol.All( + vol.Schema( + { + vol.Required(SERVICE_CHORE_ID): int, + vol.Optional(SERVICE_DONE_BY): int, + vol.Optional(SERVICE_TRACKED_TIME): str, + } + ) +) + +SERVICE_COMPLETE_TASK_SCHEMA = vol.All( + vol.Schema( + {vol.Required(SERVICE_TASK_ID): int, vol.Optional(SERVICE_DONE_TIME): str,} + ) +) + +SERVICE_ADD_GENERIC_SCHEMA = vol.All( + vol.Schema( + {vol.Required(SERVICE_ENTITY_TYPE): str, vol.Required(SERVICE_DATA): object,} + ) +) + + +async def async_setup_services(hass, entry): + """Set up services for Grocy integration.""" + coordinator = hass.data[DOMAIN][entry.entry_id] + if hass.data.get(GROCY_SERVICES, False): + return + + hass.data[GROCY_SERVICES] = True + + async def async_call_grocy_service(service_call): + """Call correct Grocy service.""" + service = service_call.service + service_data = service_call.data + + if service == SERVICE_ADD_PRODUCT: + await async_add_product_service(hass, coordinator, service_data) + + elif service == SERVICE_CONSUME_PRODUCT: + await async_consume_product_service(hass, coordinator, service_data) + + elif service == SERVICE_EXECUTE_CHORE: + await async_execute_chore_service(hass, coordinator, service_data) + + elif service == SERVICE_COMPLETE_TASK: + await async_complete_task_service(hass, coordinator, service_data) + + elif service == SERVICE_ADD_GENERIC: + await async_add_generic_service(hass, coordinator, service_data) + + hass.services.async_register( + DOMAIN, + SERVICE_ADD_PRODUCT, + async_call_grocy_service, + schema=SERVICE_ADD_PRODUCT_SCHEMA, + ) + + hass.services.async_register( + DOMAIN, + SERVICE_CONSUME_PRODUCT, + async_call_grocy_service, + schema=SERVICE_CONSUME_PRODUCT_SCHEMA, + ) + + hass.services.async_register( + DOMAIN, + SERVICE_EXECUTE_CHORE, + async_call_grocy_service, + schema=SERVICE_EXECUTE_CHORE_SCHEMA, + ) + + hass.services.async_register( + DOMAIN, + SERVICE_COMPLETE_TASK, + async_call_grocy_service, + schema=SERVICE_COMPLETE_TASK_SCHEMA, + ) + + hass.services.async_register( + DOMAIN, + SERVICE_ADD_GENERIC, + async_call_grocy_service, + schema=SERVICE_ADD_GENERIC_SCHEMA, + ) + + +async def async_unload_services(hass): + """Unload Grocy services.""" + if not hass.data.get(GROCY_SERVICES): + return + + hass.data[GROCY_SERVICES] = False + + hass.services.async_remove(DOMAIN, SERVICE_ADD_PRODUCT) + hass.services.async_remove(DOMAIN, SERVICE_CONSUME_PRODUCT) + hass.services.async_remove(DOMAIN, SERVICE_EXECUTE_CHORE) + hass.services.async_remove(DOMAIN, SERVICE_COMPLETE_TASK) + + +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, "") + + coordinator.api.add_product(product_id, amount, price) + + +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) + + transaction_type_raw = data.get(SERVICE_TRANSACTION_TYPE, None) + transaction_type = TransactionType.CONSUME + + if transaction_type_raw is not None: + transaction_type = TransactionType[transaction_type_raw] + coordinator.api.consume_product( + product_id, amount, spoiled=spoiled, transaction_type=transaction_type + ) + + +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, "") + tracked_time_str = data.get(SERVICE_TRACKED_TIME, "") + + tracked_time = datetime.now() + if tracked_time_str is not None and tracked_time_str != "": + tracked_time = iso8601.parse_date(tracked_time_str) + + coordinator.api.execute_chore(chore_id, done_by, tracked_time) + asyncio.run_coroutine_threadsafe( + entity_component.async_update_entity(hass, "sensor.grocy_chores"), hass.loop + ) + + +async def async_complete_task_service(hass, coordinator, data): + """Complete a task in Grocy.""" + task_id = data[SERVICE_TASK_ID] + done_time_str = data.get(SERVICE_DONE_TIME, None) + + done_time = datetime.now() + if done_time_str is not None and done_time_str != "": + done_time = iso8601.parse_date(done_time_str) + + coordinator.api.complete_task(task_id, done_time) + asyncio.run_coroutine_threadsafe( + entity_component.async_update_entity(hass, "sensor.grocy_tasks"), hass.loop + ) + + +async def async_add_generic_service(hass, coordinator, data): + """Add a generic entity in Grocy.""" + entity_type = data[SERVICE_ENTITY_TYPE] + data = data[SERVICE_DATA] + + coordinator.api.add_generic(entity_type, data) diff --git a/custom_components/grocy/translations/en.json b/custom_components/grocy/translations/en.json index 6a9f407..af010d7 100644 --- a/custom_components/grocy/translations/en.json +++ b/custom_components/grocy/translations/en.json @@ -1,22 +1,39 @@ - { - "config": { - "step": { - "user": { - "title": "Grocy", - "description": "If you need help with the configuration have a look here: https://github.com/custom-components/grocy", - "data": { - "url": "Grocy API URL (e.g. \"http://yourgrocyurl.com\")", - "api_key": "Grocy API Key", - "port": "Port Number (9192)", - "verify_ssl": "Verify SSL Certificate" - } +{ + "config": { + "title": "Blueprint", + "step": { + "user": { + "title": "Grocy", + "description": "If you need help with the configuration have a look here: https://github.com/custom-components/grocy", + "data": { + "url": "Grocy API URL (e.g. \"http://yourgrocyurl.com\")", + "api_key": "Grocy API Key", + "port": "Port Number (9192)", + "verify_ssl": "Verify SSL Certificate" } - }, - "error": { - "auth": "Something went wrong." - }, - "abort": { - "single_instance_allowed": "Only a single configuration of Grocy is allowed." } + }, + "error": { + "auth": "Something went wrong." + }, + "abort": { + "single_instance_allowed": "Only a single configuration of Grocy is allowed." } - } \ No newline at end of file + }, + "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 From d975bda52fcb6b14dad2c48c01cf651dfa61f6e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Isabella=20Gross=20Alstr=C3=B6m?= Date: Sat, 5 Sep 2020 15:04:13 +0200 Subject: [PATCH 22/45] add check for loaded --- custom_components/grocy/__init__.py | 30 +++++++++++++++-------------- 1 file changed, 16 insertions(+), 14 deletions(-) diff --git a/custom_components/grocy/__init__.py b/custom_components/grocy/__init__.py index 9496ece..d738e33 100644 --- a/custom_components/grocy/__init__.py +++ b/custom_components/grocy/__init__.py @@ -127,20 +127,22 @@ async def _async_update_data(self): async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): """Handle removal of an entry.""" - coordinator = hass.data[DOMAIN][entry.entry_id] - unloaded = all( - await asyncio.gather( - *[ - hass.config_entries.async_forward_entry_unload(entry, platform) - for platform in PLATFORMS - if platform in coordinator.platforms - ] - ) - ) - if unloaded: - hass.data[DOMAIN].pop(entry.entry_id) - _LOGGER.debug("Successfully unloaded %s", unloaded) - return unloaded + _LOGGER.debug("Unloading with state %s", entry.state) + if entry.state == "loaded": + try: + unloaded = all( + await asyncio.gather( + *[ + hass.config_entries.async_forward_entry_unload(entry, platform) + for platform in PLATFORMS + ] + ) + ) + _LOGGER.debug("Unloaded? %s", unloaded) + return unloaded + + except ValueError: + pass async def async_reload_entry(hass: HomeAssistant, entry: ConfigEntry): From d29a21c4683189183946af078f2fb41ca7b09e23 Mon Sep 17 00:00:00 2001 From: Ludeeus Date: Sat, 5 Sep 2020 14:28:41 +0000 Subject: [PATCH 23/45] Fix things --- .devcontainer/devcontainer.json | 8 ++++---- custom_components/grocy/__init__.py | 11 ++++------- 2 files changed, 8 insertions(+), 11 deletions(-) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 141b0a8..1da4a47 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -1,15 +1,15 @@ -// See https://aka.ms/vscode-remote/devcontainer.json for format details. { "image": "ludeeus/container:integration", "context": "..", "appPort": [ "9123:8123" ], - "postCreateCommand": "container install", + "postCreateCommand": "apk add jq && for req in $(jq -c -r '.requirements | .[]' custom_components/grocy/manifest.json); do python -m pip install '$req'; done && container install", "extensions": [ "ms-python.python", "github.vscode-pull-request-github", - "tabnine.tabnine-vscode" + "tabnine.tabnine-vscode", + "ms-python.vscode-pylance" ], "settings": { "files.eol": "\n", @@ -24,4 +24,4 @@ "editor.formatOnType": true, "files.trimTrailingWhitespace": true } -} +} \ No newline at end of file diff --git a/custom_components/grocy/__init__.py b/custom_components/grocy/__init__.py index d738e33..2cb8d58 100644 --- a/custom_components/grocy/__init__.py +++ b/custom_components/grocy/__init__.py @@ -14,7 +14,7 @@ from homeassistant.core import Config, HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from pygrocy import Grocy, TransactionType +from pygrocy import Grocy from .const import ( DOMAIN, @@ -24,7 +24,6 @@ CONF_API_KEY, CONF_PORT, CONF_VERIFY_SSL, - ALL_ENTITY_TYPES, REQUIRED_FILES, ) from .grocy_data import GrocyData, async_setup_image_api @@ -65,11 +64,9 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry): hass.data[DOMAIN][config_entry.entry_id] = coordinator for platform in PLATFORMS: - if config_entry.options.get(platform, True): - coordinator.platforms.append(platform) - hass.async_add_job( - hass.config_entries.async_forward_entry_setup(config_entry, platform) - ) + hass.async_add_job( + hass.config_entries.async_forward_entry_setup(config_entry, platform) + ) await async_setup_services(hass, config_entry) From 827575783e53c96bab5b49af24fdf87489bf4416 Mon Sep 17 00:00:00 2001 From: Ludeeus Date: Sat, 5 Sep 2020 20:01:30 +0000 Subject: [PATCH 24/45] Just a few smal changes --- custom_components/grocy/__init__.py | 110 +++++++++------------- custom_components/grocy/binary_sensor.py | 63 ++++--------- custom_components/grocy/config_flow.py | 113 ++++------------------- custom_components/grocy/const.py | 112 +++++++--------------- custom_components/grocy/entity.py | 85 ++++++++++------- custom_components/grocy/grocy_data.py | 94 ++++++------------- custom_components/grocy/sensor.py | 100 +++++--------------- custom_components/grocy/services.py | 16 ++-- 8 files changed, 222 insertions(+), 471 deletions(-) diff --git a/custom_components/grocy/__init__.py b/custom_components/grocy/__init__.py index 2cb8d58..84bf260 100644 --- a/custom_components/grocy/__init__.py +++ b/custom_components/grocy/__init__.py @@ -5,10 +5,8 @@ https://github.com/custom-components/grocy """ import asyncio -from datetime import timedelta -import os import logging -import hashlib +from datetime import timedelta from homeassistant.config_entries import ConfigEntry from homeassistant.core import Config, HomeAssistant @@ -17,51 +15,46 @@ from pygrocy import Grocy from .const import ( - DOMAIN, - PLATFORMS, - STARTUP_MESSAGE, - CONF_URL, CONF_API_KEY, CONF_PORT, + CONF_URL, CONF_VERIFY_SSL, - REQUIRED_FILES, + DOMAIN, + PLATFORMS, + STARTUP_MESSAGE, ) from .grocy_data import GrocyData, async_setup_image_api -from .services import async_setup_services +from .services import async_setup_services, async_unload_services SCAN_INTERVAL = timedelta(seconds=30) _LOGGER = logging.getLogger(__name__) -async def async_setup(hass: HomeAssistant, config: Config): +async def async_setup(_hass: HomeAssistant, _config: Config): """Set up this integration using YAML is not supported.""" return True async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry): """Set up this integration using UI.""" - if hass.data.get(DOMAIN) is None: - hass.data.setdefault(DOMAIN, {}) - _LOGGER.info(STARTUP_MESSAGE) - - if not await hass.async_add_executor_job(check_files, hass): - return False - - url = config_entry.data.get(CONF_URL) - api_key = config_entry.data.get(CONF_API_KEY) - port_number = config_entry.data.get(CONF_PORT) - verify_ssl = config_entry.data.get(CONF_VERIFY_SSL) + hass.data.setdefault(DOMAIN, {}) + _LOGGER.info(STARTUP_MESSAGE) coordinator = GrocyDataUpdateCoordinator( - hass, url, api_key, port_number, verify_ssl + hass, + config_entry.data[CONF_URL], + config_entry.data[CONF_API_KEY], + config_entry.data[CONF_PORT], + config_entry.data[CONF_VERIFY_SSL], ) + await coordinator.async_refresh() if not coordinator.last_update_success: raise ConfigEntryNotReady - hass.data[DOMAIN][config_entry.entry_id] = coordinator + hass.data[DOMAIN] = coordinator for platform in PLATFORMS: hass.async_add_job( @@ -77,47 +70,27 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry): return True -def check_files(hass): - """Verify that the user downloaded all files.""" - - base = "{}/custom_components/{}/".format(hass.config.path(), DOMAIN) - missing = [] - for file in REQUIRED_FILES: - fullpath = "{}{}".format(base, file) - if not os.path.exists(fullpath): - missing.append(file) - - if missing: - _LOGGER.critical("The following files are missing: %s", str(missing)) - returnvalue = False - else: - returnvalue = True - - return returnvalue - - class GrocyDataUpdateCoordinator(DataUpdateCoordinator): """Class to manage fetching data from the API.""" def __init__(self, hass, url, api_key, port_number, verify_ssl): """Initialize.""" - self.api = Grocy(url, api_key, port_number, verify_ssl) - self.platforms = [] - self.hass = hass - hash_key = hashlib.md5( - api_key.encode("utf-8") + url.encode("utf-8") - ).hexdigest() - self.hash_key = hash_key super().__init__(hass, _LOGGER, name=DOMAIN, update_interval=SCAN_INTERVAL) + self.api = Grocy(url, api_key, port_number, verify_ssl) + self.entities = [] + self.data = {} async def _async_update_data(self): """Update data via library.""" + data = {} try: grocy_data = GrocyData(self.hass, self.api) - for platform in self.platforms: - data = await grocy_data.async_update_data(platform) - _LOGGER.debug(data) - return "" + for entity in self.entities: + if entity.enabled: + data[entity.entity_type] = await grocy_data.async_update_data( + entity.entity_type + ) + return data except Exception as exception: raise UpdateFailed(exception) @@ -126,23 +99,24 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): """Handle removal of an entry.""" _LOGGER.debug("Unloading with state %s", entry.state) if entry.state == "loaded": - try: - unloaded = all( - await asyncio.gather( - *[ - hass.config_entries.async_forward_entry_unload(entry, platform) - for platform in PLATFORMS - ] - ) + unloaded = all( + await asyncio.gather( + *[ + hass.config_entries.async_forward_entry_unload(entry, platform) + for platform in PLATFORMS + ] ) - _LOGGER.debug("Unloaded? %s", unloaded) - return unloaded - - except ValueError: - pass + ) + _LOGGER.debug("Unloaded? %s", unloaded) + del hass.data[DOMAIN] + return unloaded + return False async def async_reload_entry(hass: HomeAssistant, entry: ConfigEntry): """Reload config entry.""" - await async_unload_entry(hass, entry) - await async_setup_entry(hass, entry) + unloaded = await async_unload_entry(hass, entry) + _LOGGER.error("Unloaded successfully: %s", unloaded) + if unloaded: + await async_setup_entry(hass, entry) + await async_unload_services(hass) diff --git a/custom_components/grocy/binary_sensor.py b/custom_components/grocy/binary_sensor.py index 41982aa..b2be7cf 100644 --- a/custom_components/grocy/binary_sensor.py +++ b/custom_components/grocy/binary_sensor.py @@ -4,66 +4,41 @@ # pylint: disable=relative-beyond-top-level from .const import ( - BINARY_SENSOR, - DEFAULT_NAME, DOMAIN, - BINARY_SENSOR_TYPES, - CONF_ALLOW_STOCK, - DEFAULT_CONF_ALLOW_STOCK, - EXPIRING_PRODUCTS_NAME, - EXPIRED_PRODUCTS_NAME, - MISSING_PRODUCTS_NAME, - STOCK_NAME, + GrocyEntityType, ) from .entity import GrocyEntity _LOGGER = logging.getLogger(__name__) +BINARY_SENSOR_TYPES = [ + GrocyEntityType.EXPIRED_PRODUCTS, + GrocyEntityType.EXPIRING_PRODUCTS, + GrocyEntityType.MISSING_PRODUCTS, +] async def async_setup_entry(hass, entry, async_add_entities): """Setup binary_sensor platform.""" - coordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = hass.data[DOMAIN] - options_allow_stock = entry.options.get(CONF_ALLOW_STOCK, DEFAULT_CONF_ALLOW_STOCK) + entities = [] for binary_sensor in BINARY_SENSOR_TYPES: - if options_allow_stock and binary_sensor.startswith(EXPIRING_PRODUCTS_NAME): - _LOGGER.debug("Adding expiring products binary sensor") - device_name = STOCK_NAME - async_add_entities( - [GrocyBinarySensor(coordinator, entry, device_name, binary_sensor)], - True, - ) - elif options_allow_stock and binary_sensor.startswith(EXPIRED_PRODUCTS_NAME): - _LOGGER.debug("Adding expired products binary sensor") - device_name = STOCK_NAME - async_add_entities( - [GrocyBinarySensor(coordinator, entry, device_name, binary_sensor)], - True, - ) - elif options_allow_stock and binary_sensor.startswith(MISSING_PRODUCTS_NAME): - _LOGGER.debug("Adding missing products binary sensor") - device_name = STOCK_NAME - async_add_entities( - [GrocyBinarySensor(coordinator, entry, device_name, binary_sensor)], - True, - ) + _LOGGER.debug("Adding %s binary sensor", binary_sensor) + entity = GrocyBinarySensor(coordinator, entry, binary_sensor) + coordinator.entities.append(entity) + entities.append(entity) + + async_add_entities(entities, True) class GrocyBinarySensor(GrocyEntity, BinarySensorDevice): """Grocy binary_sensor class.""" - @property - def name(self): - """Return the name of the binary_sensor.""" - return f"{DEFAULT_NAME}_{BINARY_SENSOR}" - - @property - def device_class(self): - """Return the class of this binary_sensor.""" - return None - @property def is_on(self): """Return true if the binary_sensor is on.""" - return True - # return self.coordinator.data.get("bool_on", False) + if not self.enity_data: + return + + elif self.entity_type == GrocyEntityType.MISSING_PRODUCTS: + return len(self.enity_data) > 0 diff --git a/custom_components/grocy/config_flow.py b/custom_components/grocy/config_flow.py index ba4a339..46635a5 100644 --- a/custom_components/grocy/config_flow.py +++ b/custom_components/grocy/config_flow.py @@ -1,32 +1,19 @@ """Adds config flow for Grocy.""" +import logging +from collections import OrderedDict + +import voluptuous as vol from homeassistant import config_entries -from homeassistant.core import callback from pygrocy import Grocy -import voluptuous as vol -from collections import OrderedDict -import logging -from .const import ( # pylint: disable=unused-import - NAME, - DOMAIN, - PLATFORMS, - DEFAULT_PORT, - CONF_URL, - CONF_PORT, +from .const import ( CONF_API_KEY, + CONF_PORT, # pylint: disable=unused-import + CONF_URL, CONF_VERIFY_SSL, - CONF_ALLOW_CHORES, - CONF_ALLOW_MEAL_PLAN, - CONF_ALLOW_PRODUCTS, - CONF_ALLOW_SHOPPING_LIST, - CONF_ALLOW_STOCK, - CONF_ALLOW_TASKS, - DEFAULT_CONF_PORT_NUMBER, - DEFAULT_CONF_ALLOW_CHORES, - DEFAULT_CONF_ALLOW_MEAL_PLAN, - DEFAULT_CONF_ALLOW_SHOPPING_LIST, - DEFAULT_CONF_ALLOW_STOCK, - DEFAULT_CONF_ALLOW_TASKS, + DEFAULT_PORT, + DOMAIN, + NAME, ) _LOGGER = logging.getLogger(__name__) @@ -42,9 +29,7 @@ def __init__(self): """Initialize.""" self._errors = {} - async def async_step_user( - self, user_input=None # pylint: disable=bad-continuation - ): + async def async_step_user(self, user_input=None): """Handle a flow initialized by the user.""" self._errors = {} _LOGGER.debug("Step user") @@ -70,21 +55,16 @@ async def async_step_user( return await self._show_config_form(user_input) - @staticmethod - @callback - def async_get_options_flow(config_entry): - return GrocyOptionsFlowHandler(config_entry) - async def _show_config_form(self, user_input): # pylint: disable=unused-argument """Show the configuration form to edit the data.""" data_schema = OrderedDict() # TODO remove - data_schema[vol.Required(CONF_URL, default="http://192.168.1.78")] = str + data_schema[vol.Required(CONF_URL, default="http://192.168.2.145")] = str # TODO remove data_schema[ vol.Required( CONF_API_KEY, - default="uZlwmnzzCnF1hpvNHNXbcCG0tmFB06h12bMZC4ggLxGja5Yg9X", + default="EV4qJ2FwsxbW43H8eHbMCYHj68O28N0DXBqbOUyzSnSq8EHaI0", ) ] = str data_schema[vol.Optional(CONF_PORT, default=DEFAULT_PORT)] = int @@ -92,7 +72,9 @@ async def _show_config_form(self, user_input): # pylint: disable=unused-argumen _LOGGER.debug("config form") return self.async_show_form( - step_id="user", data_schema=vol.Schema(data_schema), errors=self._errors, + step_id="user", + data_schema=vol.Schema(data_schema), + errors=self._errors, ) async def _test_credentials(self, url, api_key, port, verify_ssl): @@ -112,66 +94,3 @@ def system_info(): _LOGGER.error(e) pass return False - - -class GrocyOptionsFlowHandler(config_entries.OptionsFlow): - """Grocy config flow options handler.""" - - def __init__(self, config_entry): - """Initialize Grocy options flow.""" - self.config_entry = config_entry - self.options = dict(config_entry.options) - - async def async_step_init(self, user_input=None): # pylint: disable=unused-argument - """Manage the options.""" - return await self.async_step_user() - - async def async_step_user(self, user_input=None): - """Handle a flow initialized by the user.""" - if user_input is not None: - self.options.update(user_input) - return await self._update_options() - - return self.async_show_form( - step_id="user", - data_schema=vol.Schema( - { - vol.Optional( - CONF_ALLOW_CHORES, - default=self.config_entry.options.get( - CONF_ALLOW_CHORES, DEFAULT_CONF_ALLOW_CHORES - ), - ): bool, - vol.Optional( - CONF_ALLOW_MEAL_PLAN, - default=self.config_entry.options.get( - CONF_ALLOW_MEAL_PLAN, DEFAULT_CONF_ALLOW_MEAL_PLAN - ), - ): bool, - vol.Optional( - CONF_ALLOW_SHOPPING_LIST, - default=self.config_entry.options.get( - CONF_ALLOW_SHOPPING_LIST, DEFAULT_CONF_ALLOW_SHOPPING_LIST - ), - ): bool, - vol.Optional( - CONF_ALLOW_STOCK, - default=self.config_entry.options.get( - CONF_ALLOW_STOCK, DEFAULT_CONF_ALLOW_STOCK - ), - ): bool, - vol.Optional( - CONF_ALLOW_TASKS, - default=self.config_entry.options.get( - CONF_ALLOW_TASKS, DEFAULT_CONF_ALLOW_TASKS - ), - ): bool, - } - ), - ) - - async def _update_options(self): - """Update config entry options.""" - return self.async_create_entry( - title=self.config_entry.data.get(NAME), data=self.options - ) diff --git a/custom_components/grocy/const.py b/custom_components/grocy/const.py index 23c2507..2c69056 100644 --- a/custom_components/grocy/const.py +++ b/custom_components/grocy/const.py @@ -1,87 +1,26 @@ """Constants for Grocy.""" +from enum import Enum + # Base component constants NAME = "Grocy" DOMAIN = "grocy" -DOMAIN_DATA = f"{DOMAIN}_data" -VERSION = "0.0.1" +VERSION = "1.0.0" ISSUE_URL = "https://github.com/custom-components/grocy/issues" -# Icons -ICON = "mdi:format-quote-close" - -# Device classes -# BINARY_SENSOR_DEVICE_CLASS = "connectivity" - -SENSOR_PRODUCTS_UNIT_OF_MEASUREMENT = "Product(s)" -SENSOR_CHORES_UNIT_OF_MEASUREMENT = "Chore(s)" -SENSOR_TASKS_UNIT_OF_MEASUREMENT = "Task(s)" -SENSOR_MEALS_UNIT_OF_MEASUREMENT = "Meal(s)" # Platforms -BINARY_SENSOR = "binary_sensor" -SENSOR = "sensor" -# PLATFORMS = [BINARY_SENSOR, SENSOR, SWITCH] -PLATFORMS = [BINARY_SENSOR, SENSOR] - -# Entities -STOCK_NAME = "Stock" -CHORES_NAME = "Chores" -TASKS_NAME = "Tasks" -SHOPPING_LIST_NAME = "Shopping_list" -PRODUCTS_NAME = "Products" -EXPIRING_PRODUCTS_NAME = "Expiring_products" -EXPIRED_PRODUCTS_NAME = "Expired_products" -MISSING_PRODUCTS_NAME = "Missing_products" -MEAL_PLAN_NAME = "Meal_plan" - -SENSOR_TYPES = [STOCK_NAME, CHORES_NAME, TASKS_NAME, SHOPPING_LIST_NAME, MEAL_PLAN_NAME] -BINARY_SENSOR_TYPES = [ - EXPIRING_PRODUCTS_NAME, - EXPIRED_PRODUCTS_NAME, - MISSING_PRODUCTS_NAME, -] - -ALL_ENTITY_TYPES = [ - STOCK_NAME, - CHORES_NAME, - TASKS_NAME, - SHOPPING_LIST_NAME, - MEAL_PLAN_NAME, - EXPIRING_PRODUCTS_NAME, - EXPIRED_PRODUCTS_NAME, - MISSING_PRODUCTS_NAME, -] - +PLATFORMS = ["binary_sensor", "sensor"] # Configuration and options -CONF_ENABLED = "enabled" CONF_NAME = "name" -DEFAULT_CONF_NAME = DOMAIN DEFAULT_PORT = 9192 CONF_URL = "url" CONF_PORT = "port" CONF_API_KEY = "api_key" CONF_VERIFY_SSL = "verify_ssl" -CONF_ALLOW_CHORES = "allow_chores" -CONF_ALLOW_MEAL_PLAN = "allow_meal_plan" -CONF_ALLOW_PRODUCTS = "allow_products" -CONF_ALLOW_SHOPPING_LIST = "allow_shopping_list" -CONF_ALLOW_STOCK = "allow_stock" -CONF_ALLOW_TASKS = "allow_tasks" - -# Defaults -DEFAULT_NAME = DOMAIN -# DEFAULT_CONF_NAME = DOMAIN -DEFAULT_CONF_PORT_NUMBER = 9192 -DEFAULT_CONF_ALLOW_CHORES = False -DEFAULT_CONF_ALLOW_MEAL_PLAN = False -DEFAULT_CONF_ALLOW_SHOPPING_LIST = False -DEFAULT_CONF_ALLOW_STOCK = False -DEFAULT_CONF_ALLOW_TASKS = False - STARTUP_MESSAGE = f""" ------------------------------------------------------------------- {NAME} @@ -92,15 +31,34 @@ ------------------------------------------------------------------- """ -REQUIRED_FILES = [ - "const.py", - "entity.py", - "grocy_data.py", - "manifest.json", - "helpers.py", - "sensor.py", - "binary_sensor.py", - "config_flow.py", - "services.py", - "translations/en.json", -] + +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" + PRODUCTS = "Products" + SHOPPING_LIST = "Shopping_list" + STOCK = "Stock" + TASKS = "Tasks" + + +class GrocyEntityUnit(str, Enum): + """Unit of measurement for Grocy entities.""" + + CHORES = "Chore(s)" + MEALS = "Meal(s)" + PRODUCTS = "Product(s)" + TASKS = "Task(s)" + + +class GrocyEntityIcon(str, Enum): + """Icon for a Grocy entity.""" + + DEFAULT = "mdi:format-quote-close" + + TASKS = "mdi:checkbox-marked-circle-outline" + MISSING_PRODUCTS = "mdi:flask-round-bottom-empty-outline" diff --git a/custom_components/grocy/entity.py b/custom_components/grocy/entity.py index 02dbfba..e5f8cdf 100644 --- a/custom_components/grocy/entity.py +++ b/custom_components/grocy/entity.py @@ -1,59 +1,78 @@ """GrocyEntity class""" from homeassistant.helpers import entity +from homeassistant.helpers.update_coordinator import CoordinatorEntity # pylint: disable=relative-beyond-top-level -from .const import DOMAIN, NAME, VERSION +from .const import ( + DOMAIN, + GrocyEntityIcon, + GrocyEntityType, + GrocyEntityUnit, + NAME, + VERSION, +) -class GrocyEntity(entity.Entity): - def __init__(self, coordinator, config_entry, device_name, sensor_type): +class GrocyEntity(CoordinatorEntity): + def __init__(self, coordinator, config_entry, entity_type): + super().__init__(coordinator) self.coordinator = coordinator self.config_entry = config_entry - self.device_name = device_name - self.sensor_type = sensor_type + self.entity_type = entity_type - self._unique_id = "{}-{}".format(self.coordinator.hash_key, self.sensor_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 should_poll(self): - """No need to poll. Coordinator notifies entity of updates.""" + 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 available(self): - """Return if entity is available.""" - return self.coordinator.last_update_success + def enity_data(self): + """Return the enity_data of the entity.""" + return self.coordinator.data.get(self.entity_type) @property - def unique_id(self): - """Return a unique ID to use for this entity.""" - return self._unique_id + 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.unique_id)}, - "identifiers": {(DOMAIN, self.device_name)}, - "name": self.device_name, + "identifiers": {(DOMAIN, self.config_entry.entry_id)}, + "name": NAME, "model": VERSION, "manufacturer": NAME, "entry_type": "service", } - # @property - # def device_state_attributes(self): - # """Return the state attributes.""" - # return { - # "time": str(self.coordinator.data.get("time")), - # "static": self.coordinator.data.get("static"), - # } - - async def async_added_to_hass(self): - """Connect to dispatcher listening for entity data notifications.""" - self.async_on_remove( - self.coordinator.async_add_listener(self.async_write_ha_state) - ) - - async def async_update(self): - """Update Grocy entity.""" - await self.coordinator.async_request_refresh() + @property + def device_state_attributes(self): + """Return the state attributes.""" + if not self.enity_data: + return + + elif self.entity_type == GrocyEntityType.TASKS: + return {"tasks": [x.as_dict() for x in self.enity_data]} + + elif self.entity_type == GrocyEntityType.MISSING_PRODUCTS: + return {"missing": [x.as_dict() for x in self.enity_data]} diff --git a/custom_components/grocy/grocy_data.py b/custom_components/grocy/grocy_data.py index c53defe..d56fa04 100644 --- a/custom_components/grocy/grocy_data.py +++ b/custom_components/grocy/grocy_data.py @@ -1,33 +1,14 @@ from aiohttp import hdrs, web from datetime import timedelta, datetime -from homeassistant.util import Throttle from homeassistant.components.http import HomeAssistantView from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import ( - CONF_ALLOW_CHORES, - CONF_ALLOW_MEAL_PLAN, - CONF_ALLOW_SHOPPING_LIST, - CONF_ALLOW_STOCK, - CONF_ALLOW_TASKS, CONF_API_KEY, CONF_URL, CONF_PORT, - DEFAULT_CONF_ALLOW_CHORES, - DEFAULT_CONF_ALLOW_MEAL_PLAN, - DEFAULT_CONF_ALLOW_SHOPPING_LIST, - DEFAULT_CONF_ALLOW_STOCK, - DEFAULT_CONF_ALLOW_TASKS, - CHORES_NAME, - TASKS_NAME, - DOMAIN, - EXPIRED_PRODUCTS_NAME, - EXPIRING_PRODUCTS_NAME, - MISSING_PRODUCTS_NAME, - MEAL_PLAN_NAME, - SHOPPING_LIST_NAME, - STOCK_NAME, + GrocyEntityType, ) from .helpers import MealPlanItem @@ -42,24 +23,24 @@ def __init__(self, hass, client): self.hass = hass self.client = client self.sensor_types_dict = { - STOCK_NAME: self.async_update_stock, - CHORES_NAME: self.async_update_chores, - TASKS_NAME: self.async_update_tasks, - SHOPPING_LIST_NAME: self.async_update_shopping_list, - EXPIRING_PRODUCTS_NAME: self.async_update_expiring_products, - EXPIRED_PRODUCTS_NAME: self.async_update_expired_products, - MISSING_PRODUCTS_NAME: self.async_update_missing_products, - MEAL_PLAN_NAME: self.async_update_meal_plan, + 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, } self.sensor_update_dict = { - STOCK_NAME: None, - CHORES_NAME: None, - TASKS_NAME: None, - SHOPPING_LIST_NAME: None, - EXPIRING_PRODUCTS_NAME: None, - EXPIRED_PRODUCTS_NAME: None, - MISSING_PRODUCTS_NAME: None, - MEAL_PLAN_NAME: None, + 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, } async def async_update_data(self, sensor_type): @@ -72,15 +53,12 @@ async def async_update_data(self, sensor_type): 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. - self.hass.async_create_task(self.sensor_types_dict[sensor_type]()) + return await self.sensor_types_dict[sensor_type]() - @Throttle(MIN_TIME_BETWEEN_UPDATES) async def async_update_stock(self): """Update data.""" # This is where the main logic to update platform data goes. - self.hass.data[DOMAIN][STOCK_NAME] = await self.hass.async_add_executor_job( - self.client.stock - ) + return await self.hass.async_add_executor_job(self.client.stock) async def async_update_chores(self): """Update data.""" @@ -88,17 +66,12 @@ async def async_update_chores(self): def wrapper(): return self.client.chores(True) - self.hass.data[DOMAIN][CHORES_NAME] = await self.hass.async_add_executor_job( - wrapper - ) + 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. - - self.hass.data[DOMAIN][TASKS_NAME] = await self.hass.async_add_executor_job( - self.client.tasks - ) + return await self.hass.async_add_executor_job(self.client.tasks) async def async_update_shopping_list(self): """Update data.""" @@ -106,59 +79,44 @@ async def async_update_shopping_list(self): def wrapper(): return self.client.shopping_list(True) - self.hass.data[DOMAIN][ - SHOPPING_LIST_NAME - ] = await self.hass.async_add_executor_job(wrapper) + return await self.hass.async_add_executor_job(wrapper) - @Throttle(MIN_TIME_BETWEEN_UPDATES) async def async_update_expiring_products(self): """Update data.""" # This is where the main logic to update platform data goes. def wrapper(): return self.client.expiring_products(True) - self.hass.data[DOMAIN][ - EXPIRING_PRODUCTS_NAME - ] = await self.hass.async_add_executor_job(wrapper) + return await self.hass.async_add_executor_job(wrapper) - @Throttle(MIN_TIME_BETWEEN_UPDATES) async def async_update_expired_products(self): """Update data.""" # This is where the main logic to update platform data goes. def wrapper(): return self.client.expired_products(True) - self.hass.data[DOMAIN][ - EXPIRED_PRODUCTS_NAME - ] = await self.hass.async_add_executor_job(wrapper) + return await self.hass.async_add_executor_job(wrapper) - @Throttle(MIN_TIME_BETWEEN_UPDATES) async def async_update_missing_products(self): """Update data.""" # This is where the main logic to update platform data goes. def wrapper(): return self.client.missing_products(True) - self.hass.data[DOMAIN][ - MISSING_PRODUCTS_NAME - ] = await self.hass.async_add_executor_job(wrapper) + return await self.hass.async_add_executor_job(wrapper) - @Throttle(MIN_TIME_BETWEEN_UPDATES) async def async_update_meal_plan(self): """Update data.""" # This is where the main logic to update platform data goes. def wrapper(): meal_plan = self.client.meal_plan(True) today = datetime.today().date() - date_format = "%Y-%m-%d %H:%M:%S.%f" plan = [ MealPlanItem(item) for item in meal_plan if item.day.date() >= today ] return sorted(plan, key=lambda item: item.day) - self.hass.data[DOMAIN][MEAL_PLAN_NAME] = await self.hass.async_add_executor_job( - wrapper - ) + return await self.hass.async_add_executor_job(wrapper) async def async_setup_image_api(hass, config): diff --git a/custom_components/grocy/sensor.py b/custom_components/grocy/sensor.py index 2400c68..5907384 100644 --- a/custom_components/grocy/sensor.py +++ b/custom_components/grocy/sensor.py @@ -2,97 +2,41 @@ import logging -# pylint: disable=relative-beyond-top-level -from .const import ( - DEFAULT_NAME, - DOMAIN, - ICON, - SENSOR, - SENSOR_TYPES, - CHORES_NAME, - MEAL_PLAN_NAME, - SHOPPING_LIST_NAME, - STOCK_NAME, - TASKS_NAME, - CONF_ALLOW_CHORES, - CONF_ALLOW_MEAL_PLAN, - CONF_ALLOW_SHOPPING_LIST, - CONF_ALLOW_STOCK, - CONF_ALLOW_TASKS, - DEFAULT_CONF_ALLOW_CHORES, - DEFAULT_CONF_ALLOW_MEAL_PLAN, - DEFAULT_CONF_ALLOW_SHOPPING_LIST, - DEFAULT_CONF_ALLOW_STOCK, - DEFAULT_CONF_ALLOW_TASKS, -) +from .const import DOMAIN, GrocyEntityType 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): """Setup sensor platform.""" - coordinator = hass.data[DOMAIN][entry.entry_id] - - options_allow_chores = entry.options.get( - CONF_ALLOW_CHORES, DEFAULT_CONF_ALLOW_CHORES - ) - options_allow_meal_plan = entry.options.get( - CONF_ALLOW_MEAL_PLAN, DEFAULT_CONF_ALLOW_MEAL_PLAN - ) - options_allow_shopping_list = entry.options.get( - CONF_ALLOW_SHOPPING_LIST, DEFAULT_CONF_ALLOW_SHOPPING_LIST - ) - options_allow_stock = entry.options.get(CONF_ALLOW_STOCK, DEFAULT_CONF_ALLOW_STOCK) - options_allow_tasks = entry.options.get(CONF_ALLOW_TASKS, DEFAULT_CONF_ALLOW_TASKS) + coordinator = hass.data[DOMAIN] + entities = [] for sensor in SENSOR_TYPES: - if options_allow_chores and sensor.startswith(CHORES_NAME): - _LOGGER.debug("Adding chores sensor") - device_name = CHORES_NAME - async_add_entities( - [GrocySensor(coordinator, entry, device_name, sensor)], True - ) - elif options_allow_meal_plan and sensor.startswith(MEAL_PLAN_NAME): - _LOGGER.debug("Adding meal plan sensor") - device_name = MEAL_PLAN_NAME - async_add_entities( - [GrocySensor(coordinator, entry, device_name, sensor)], True - ) - elif options_allow_shopping_list and sensor.startswith(SHOPPING_LIST_NAME): - _LOGGER.debug("Adding shopping list sensor") - device_name = SHOPPING_LIST_NAME - async_add_entities( - [GrocySensor(coordinator, entry, device_name, sensor)], True - ) - elif options_allow_stock and sensor.startswith(STOCK_NAME): - _LOGGER.debug("Adding stock sensor") - device_name = STOCK_NAME - async_add_entities( - [GrocySensor(coordinator, entry, device_name, sensor)], True - ) - elif options_allow_tasks and sensor.startswith(TASKS_NAME): - _LOGGER.debug("Adding tasks sensor") - device_name = TASKS_NAME - async_add_entities( - [GrocySensor(coordinator, entry, device_name, sensor)], True - ) + _LOGGER.debug("Adding %s sensor", sensor) + entity = GrocySensor(coordinator, entry, sensor) + coordinator.entities.append(entity) + entities.append(entity) + + async_add_entities(entities, True) class GrocySensor(GrocyEntity): """Grocy Sensor class.""" @property - def name(self): - """Return the name of the sensor.""" - return f"{DEFAULT_NAME}_{SENSOR}" + def state(self): + """Return the state of the sensor.""" + if not self.enity_data: + return - # @property - # def state(self): - # """Return the state of the sensor.""" - # return self.coordinator.data.get("static") - - @property - def icon(self): - """Return the icon of the sensor.""" - return ICON + elif self.entity_type == GrocyEntityType.TASKS: + return len(self.enity_data) diff --git a/custom_components/grocy/services.py b/custom_components/grocy/services.py index 65554f0..0737480 100644 --- a/custom_components/grocy/services.py +++ b/custom_components/grocy/services.py @@ -8,12 +8,10 @@ from homeassistant.helpers import entity_component from pygrocy import TransactionType -from datetime import timedelta, datetime +from datetime import datetime from .const import DOMAIN -_LOGGER = logging.getLogger(__name__) - GROCY_SERVICES = "grocy_services" SERVICE_PRODUCT_ID = "product_id" @@ -68,20 +66,26 @@ SERVICE_COMPLETE_TASK_SCHEMA = vol.All( vol.Schema( - {vol.Required(SERVICE_TASK_ID): int, vol.Optional(SERVICE_DONE_TIME): str,} + { + vol.Required(SERVICE_TASK_ID): int, + vol.Optional(SERVICE_DONE_TIME): str, + } ) ) SERVICE_ADD_GENERIC_SCHEMA = vol.All( vol.Schema( - {vol.Required(SERVICE_ENTITY_TYPE): str, vol.Required(SERVICE_DATA): object,} + { + vol.Required(SERVICE_ENTITY_TYPE): str, + vol.Required(SERVICE_DATA): object, + } ) ) async def async_setup_services(hass, entry): """Set up services for Grocy integration.""" - coordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = hass.data[DOMAIN] if hass.data.get(GROCY_SERVICES, False): return From cf86454c8a3153ac83f06f195f5210bc9fa93cd0 Mon Sep 17 00:00:00 2001 From: Ludeeus Date: Sat, 5 Sep 2020 20:13:25 +0000 Subject: [PATCH 25/45] Fix typo --- custom_components/grocy/binary_sensor.py | 4 ++-- custom_components/grocy/entity.py | 10 +++++----- custom_components/grocy/sensor.py | 4 ++-- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/custom_components/grocy/binary_sensor.py b/custom_components/grocy/binary_sensor.py index b2be7cf..0a67573 100644 --- a/custom_components/grocy/binary_sensor.py +++ b/custom_components/grocy/binary_sensor.py @@ -37,8 +37,8 @@ class GrocyBinarySensor(GrocyEntity, BinarySensorDevice): @property def is_on(self): """Return true if the binary_sensor is on.""" - if not self.enity_data: + if not self.entity_data: return elif self.entity_type == GrocyEntityType.MISSING_PRODUCTS: - return len(self.enity_data) > 0 + return len(self.entity_data) > 0 diff --git a/custom_components/grocy/entity.py b/custom_components/grocy/entity.py index e5f8cdf..e305334 100644 --- a/custom_components/grocy/entity.py +++ b/custom_components/grocy/entity.py @@ -36,8 +36,8 @@ def entity_registry_enabled_default(self) -> bool: return False @property - def enity_data(self): - """Return the enity_data of the entity.""" + def entity_data(self): + """Return the entity_data of the entity.""" return self.coordinator.data.get(self.entity_type) @property @@ -68,11 +68,11 @@ def device_info(self): @property def device_state_attributes(self): """Return the state attributes.""" - if not self.enity_data: + if not self.entity_data: return elif self.entity_type == GrocyEntityType.TASKS: - return {"tasks": [x.as_dict() for x in self.enity_data]} + return {"tasks": [x.as_dict() for x in self.entity_data]} elif self.entity_type == GrocyEntityType.MISSING_PRODUCTS: - return {"missing": [x.as_dict() for x in self.enity_data]} + return {"missing": [x.as_dict() for x in self.entity_data]} diff --git a/custom_components/grocy/sensor.py b/custom_components/grocy/sensor.py index 5907384..c315ef9 100644 --- a/custom_components/grocy/sensor.py +++ b/custom_components/grocy/sensor.py @@ -35,8 +35,8 @@ class GrocySensor(GrocyEntity): @property def state(self): """Return the state of the sensor.""" - if not self.enity_data: + if not self.entity_data: return elif self.entity_type == GrocyEntityType.TASKS: - return len(self.enity_data) + return len(self.entity_data) From 119809609251b4dee54fde80758bd5be6ca4bac5 Mon Sep 17 00:00:00 2001 From: Ludeeus Date: Sat, 5 Sep 2020 20:13:52 +0000 Subject: [PATCH 26/45] Remove unused entity --- custom_components/grocy/entity.py | 1 - 1 file changed, 1 deletion(-) diff --git a/custom_components/grocy/entity.py b/custom_components/grocy/entity.py index e305334..9e34037 100644 --- a/custom_components/grocy/entity.py +++ b/custom_components/grocy/entity.py @@ -1,5 +1,4 @@ """GrocyEntity class""" -from homeassistant.helpers import entity from homeassistant.helpers.update_coordinator import CoordinatorEntity # pylint: disable=relative-beyond-top-level From 08ce3a00a42bdbbe777dfaed0046d69c583ac2e5 Mon Sep 17 00:00:00 2001 From: isabellaalstrom Date: Sat, 5 Sep 2020 21:07:22 +0000 Subject: [PATCH 27/45] add for the rest of the sensors --- custom_components/grocy/binary_sensor.py | 4 ++++ custom_components/grocy/config_flow.py | 8 +++----- custom_components/grocy/const.py | 10 +++++++++- custom_components/grocy/entity.py | 20 ++++++++++++++++---- custom_components/grocy/sensor.py | 11 ++++++++++- 5 files changed, 42 insertions(+), 11 deletions(-) diff --git a/custom_components/grocy/binary_sensor.py b/custom_components/grocy/binary_sensor.py index 0a67573..d249365 100644 --- a/custom_components/grocy/binary_sensor.py +++ b/custom_components/grocy/binary_sensor.py @@ -42,3 +42,7 @@ def is_on(self): elif self.entity_type == GrocyEntityType.MISSING_PRODUCTS: return len(self.entity_data) > 0 + elif self.entity_type == GrocyEntityType.EXPIRING_PRODUCTS: + return len(self.entity_data) > 0 + elif self.entity_type == GrocyEntityType.EXPIRED_PRODUCTS: + return len(self.entity_data) > 0 diff --git a/custom_components/grocy/config_flow.py b/custom_components/grocy/config_flow.py index 46635a5..8908a4a 100644 --- a/custom_components/grocy/config_flow.py +++ b/custom_components/grocy/config_flow.py @@ -59,12 +59,12 @@ async def _show_config_form(self, user_input): # pylint: disable=unused-argumen """Show the configuration form to edit the data.""" data_schema = OrderedDict() # TODO remove - data_schema[vol.Required(CONF_URL, default="http://192.168.2.145")] = str + data_schema[vol.Required(CONF_URL, default="http://192.168.1.78")] = str # TODO remove data_schema[ vol.Required( CONF_API_KEY, - default="EV4qJ2FwsxbW43H8eHbMCYHj68O28N0DXBqbOUyzSnSq8EHaI0", + default="uZlwmnzzCnF1hpvNHNXbcCG0tmFB06h12bMZC4ggLxGja5Yg9X", ) ] = str data_schema[vol.Optional(CONF_PORT, default=DEFAULT_PORT)] = int @@ -72,9 +72,7 @@ async def _show_config_form(self, user_input): # pylint: disable=unused-argumen _LOGGER.debug("config form") return self.async_show_form( - step_id="user", - data_schema=vol.Schema(data_schema), - errors=self._errors, + step_id="user", data_schema=vol.Schema(data_schema), errors=self._errors, ) async def _test_credentials(self, url, api_key, port, verify_ssl): diff --git a/custom_components/grocy/const.py b/custom_components/grocy/const.py index 2c69056..489bd66 100644 --- a/custom_components/grocy/const.py +++ b/custom_components/grocy/const.py @@ -60,5 +60,13 @@ class GrocyEntityIcon(str, Enum): DEFAULT = "mdi:format-quote-close" - TASKS = "mdi:checkbox-marked-circle-outline" + CHORES = "mdi:broom" + EXPIRED_PRODUCTS = "mdi:clock-end" + EXPIRING_PRODUCTS = "mdi:clock-fast" + MEAL_PLAN = "mdi:silverware-variant" MISSING_PRODUCTS = "mdi:flask-round-bottom-empty-outline" + PRODUCTS = "mdi:food-fork-drink" + SHOPPING_LIST = "mdi:cart-outline" + STOCK = "mdi:fridge-outline" + TASKS = "mdi:checkbox-marked-circle-outline" + diff --git a/custom_components/grocy/entity.py b/custom_components/grocy/entity.py index 9e34037..f9ff2d1 100644 --- a/custom_components/grocy/entity.py +++ b/custom_components/grocy/entity.py @@ -69,9 +69,21 @@ def device_state_attributes(self): """Return the state attributes.""" if not self.entity_data: return - - elif self.entity_type == GrocyEntityType.TASKS: - return {"tasks": [x.as_dict() for x in self.entity_data]} - + elif self.entity_type == GrocyEntityType.CHORES: + return {"chores": [x.as_dict() for x in self.entity_data]} + elif self.entity_type == GrocyEntityType.EXPIRED_PRODUCTS: + return {"expired": [x.as_dict() for x in self.entity_data]} + elif self.entity_type == GrocyEntityType.EXPIRING_PRODUCTS: + return {"expiring": [x.as_dict() for x in self.entity_data]} + elif self.entity_type == GrocyEntityType.MEAL_PLAN: + return {"meals": [x.as_dict() for x in self.entity_data]} elif self.entity_type == GrocyEntityType.MISSING_PRODUCTS: return {"missing": [x.as_dict() for x in self.entity_data]} + elif self.entity_type == GrocyEntityType.PRODUCTS: + return {"products": [x.as_dict() for x in self.entity_data]} + elif self.entity_type == GrocyEntityType.SHOPPING_LIST: + return {"products": [x.as_dict() for x in self.entity_data]} + elif self.entity_type == GrocyEntityType.STOCK: + return {"products": [x.as_dict() for x in self.entity_data]} + elif self.entity_type == GrocyEntityType.TASKS: + return {"tasks": [x.as_dict() for x in self.entity_data]} diff --git a/custom_components/grocy/sensor.py b/custom_components/grocy/sensor.py index c315ef9..42492bb 100644 --- a/custom_components/grocy/sensor.py +++ b/custom_components/grocy/sensor.py @@ -37,6 +37,15 @@ def state(self): """Return the state of the sensor.""" if not self.entity_data: return - + elif self.entity_type == GrocyEntityType.CHORES: + return len(self.entity_data) + elif self.entity_type == GrocyEntityType.MEAL_PLAN: + return len(self.entity_data) + elif self.entity_type == GrocyEntityType.PRODUCTS: + return len(self.entity_data) + elif self.entity_type == GrocyEntityType.SHOPPING_LIST: + return len(self.entity_data) + elif self.entity_type == GrocyEntityType.STOCK: + return len(self.entity_data) elif self.entity_type == GrocyEntityType.TASKS: return len(self.entity_data) From 242ad8dc7b451bb1d2b3e8c40236ce9dba7431e6 Mon Sep 17 00:00:00 2001 From: isabellaalstrom Date: Sat, 5 Sep 2020 21:14:53 +0000 Subject: [PATCH 28/45] streamline sensor state and binary sensor is on --- custom_components/grocy/binary_sensor.py | 7 +------ custom_components/grocy/sensor.py | 13 +------------ 2 files changed, 2 insertions(+), 18 deletions(-) diff --git a/custom_components/grocy/binary_sensor.py b/custom_components/grocy/binary_sensor.py index d249365..005c70a 100644 --- a/custom_components/grocy/binary_sensor.py +++ b/custom_components/grocy/binary_sensor.py @@ -40,9 +40,4 @@ def is_on(self): if not self.entity_data: return - elif self.entity_type == GrocyEntityType.MISSING_PRODUCTS: - return len(self.entity_data) > 0 - elif self.entity_type == GrocyEntityType.EXPIRING_PRODUCTS: - return len(self.entity_data) > 0 - elif self.entity_type == GrocyEntityType.EXPIRED_PRODUCTS: - return len(self.entity_data) > 0 + return len(self.entity_data) > 0 diff --git a/custom_components/grocy/sensor.py b/custom_components/grocy/sensor.py index 42492bb..bef08dc 100644 --- a/custom_components/grocy/sensor.py +++ b/custom_components/grocy/sensor.py @@ -37,15 +37,4 @@ def state(self): """Return the state of the sensor.""" if not self.entity_data: return - elif self.entity_type == GrocyEntityType.CHORES: - return len(self.entity_data) - elif self.entity_type == GrocyEntityType.MEAL_PLAN: - return len(self.entity_data) - elif self.entity_type == GrocyEntityType.PRODUCTS: - return len(self.entity_data) - elif self.entity_type == GrocyEntityType.SHOPPING_LIST: - return len(self.entity_data) - elif self.entity_type == GrocyEntityType.STOCK: - return len(self.entity_data) - elif self.entity_type == GrocyEntityType.TASKS: - return len(self.entity_data) + return len(self.entity_data) From 228609d6f547db5b07196e1e7a8ee66dbbfa01d1 Mon Sep 17 00:00:00 2001 From: isabellaalstrom Date: Sat, 5 Sep 2020 21:42:35 +0000 Subject: [PATCH 29/45] change major version --- custom_components/grocy/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/grocy/const.py b/custom_components/grocy/const.py index 489bd66..450465c 100644 --- a/custom_components/grocy/const.py +++ b/custom_components/grocy/const.py @@ -4,7 +4,7 @@ # Base component constants NAME = "Grocy" DOMAIN = "grocy" -VERSION = "1.0.0" +VERSION = "2.0.0" ISSUE_URL = "https://github.com/custom-components/grocy/issues" From 471e02f2814c58fcc69ab15dd2e74e05a3bcb70d Mon Sep 17 00:00:00 2001 From: isabellaalstrom Date: Sat, 5 Sep 2020 21:45:14 +0000 Subject: [PATCH 30/45] reflect breaking change in service in the service docs --- custom_components/grocy/services.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/custom_components/grocy/services.yaml b/custom_components/grocy/services.yaml index 667941b..c8946f2 100644 --- a/custom_components/grocy/services.yaml +++ b/custom_components/grocy/services.yaml @@ -1,4 +1,4 @@ -add_product: +add_product_to_stock: description: Adds a given amount of a product to the stock fields: product_id: @@ -10,7 +10,7 @@ add_product: price: example: 1.99 description: The purchase price per purchase quantity unit of the added product -consume_product: +consume_product_from_stock: description: Consumes a given amount of a product to the stock fields: product_id: From 00393d604972a361ae4001ed1905ced533fee192 Mon Sep 17 00:00:00 2001 From: isabellaalstrom Date: Sat, 5 Sep 2020 21:53:19 +0000 Subject: [PATCH 31/45] changes to readme --- README.md | 26 ++++++++++++++------------ 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 62f0a19..e460a0f 100644 --- a/README.md +++ b/README.md @@ -1,28 +1,30 @@ [![hacs_badge](https://img.shields.io/badge/HACS-Default-orange.svg)](https://github.com/custom-components/hacs) -## Installation instructions (general): +## Installation instructions (external Grocy install): 1. Install HACS for Home Assistant -2. Go to Community-Store-Grocy -3. Install Grocy +2. Go to Community > Store > Grocy +3. Install the Grocy integration 4. Restart Home Assistant -5. Go to Grocy-Wrench icon-Manage API keys-Add -6. In the HA UI go to "Configuration" -> "Integrations" click "+" and search for "Grocy" -7. Look for the new Grocy sensor in States and use its info +5. Go to Grocy > Wrench icon > Manage API keys > Add +6. Copy resulting API key +7. In the HA UI go to "Configuration" -> "Integrations" click "+" and search for "Grocy" +8. You will now have a new integration for Grocy. Some or all of the entities might be disabled from the start. + +(This component will not currently work if you have an install where you don't use a port, due to [this](https://github.com/SebRut/pygrocy/issues/121).) ## Additional installation instructions for Hass.io users The configuration is slightly different for users that use Hass.io and the [official Grocy addon](https://github.com/hassio-addons/addon-grocy) from the Hass.io Add-on store. -1. If you haven't already done so, install Grocy from the Add-on store +1. If you haven't already done so, install Grocy from the add-on store 2. In the 'Network' section of the add-on config, input 9192 in the host field [screenshot](https://github.com/custom-components/grocy/raw/master/grocy-addon-config.png). Save your changes and restart the add-on. 3. Install HACS for Home Assistant -4. Go to Grocy > Wrench icon > Manage API keys > Add -5. Copy resulting API key 4. Go to Community > Store > Grocy -5. Install the Grocy integration component +5. Install the Grocy integration 6. Restart Home Assistant 7. Go to Grocy > Wrench icon > Manage API keys > Add -8. In the HA UI go to "Configuration" -> "Integrations" click "+" and search for "Grocy" -9. Look for the new Grocy sensor in States and use its info \ No newline at end of file +8. Copy resulting API key +9. In the HA UI go to "Configuration" -> "Integrations" click "+" and search for "Grocy" +10. You will now have a new integration for Grocy. Some or all of the entities might be disabled from the start. \ No newline at end of file From cef5533c8ae69f11f5315ff7c94cac15e8e8dce9 Mon Sep 17 00:00:00 2001 From: isabellaalstrom Date: Sat, 5 Sep 2020 21:56:22 +0000 Subject: [PATCH 32/45] remove dev things --- custom_components/grocy/config_flow.py | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/custom_components/grocy/config_flow.py b/custom_components/grocy/config_flow.py index 8908a4a..52b0021 100644 --- a/custom_components/grocy/config_flow.py +++ b/custom_components/grocy/config_flow.py @@ -58,15 +58,8 @@ async def async_step_user(self, user_input=None): async def _show_config_form(self, user_input): # pylint: disable=unused-argument """Show the configuration form to edit the data.""" data_schema = OrderedDict() - # TODO remove - data_schema[vol.Required(CONF_URL, default="http://192.168.1.78")] = str - # TODO remove - data_schema[ - vol.Required( - CONF_API_KEY, - default="uZlwmnzzCnF1hpvNHNXbcCG0tmFB06h12bMZC4ggLxGja5Yg9X", - ) - ] = str + data_schema[vol.Required(CONF_URL, default="")] = str + data_schema[vol.Required(CONF_API_KEY, default="",)] = str data_schema[vol.Optional(CONF_PORT, default=DEFAULT_PORT)] = int data_schema[vol.Optional(CONF_VERIFY_SSL, default=False)] = bool _LOGGER.debug("config form") From ff0c45ef6f9850d53b37188c2633e477e98d2f81 Mon Sep 17 00:00:00 2001 From: isabellaalstrom Date: Sun, 6 Sep 2020 10:26:04 +0200 Subject: [PATCH 33/45] Add info in the readme about what entities you get. --- README.md | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index e460a0f..1a26f39 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ 7. In the HA UI go to "Configuration" -> "Integrations" click "+" and search for "Grocy" 8. You will now have a new integration for Grocy. Some or all of the entities might be disabled from the start. -(This component will not currently work if you have an install where you don't use a port, due to [this](https://github.com/SebRut/pygrocy/issues/121).) +(This component will not currently work if you have an install where you don't use a port, due to [this issue](https://github.com/SebRut/pygrocy/issues/121).) ## Additional installation instructions for Hass.io users @@ -27,4 +27,10 @@ The configuration is slightly different for users that use Hass.io and the [offi 7. Go to Grocy > Wrench icon > Manage API keys > Add 8. Copy resulting API key 9. In the HA UI go to "Configuration" -> "Integrations" click "+" and search for "Grocy" -10. You will now have a new integration for Grocy. Some or all of the entities might be disabled from the start. \ No newline at end of file +10. You will now have a new integration for Grocy. Some or all of the entities might be disabled from the start. + + +# Entities +This component creates eight entities. Some or all of the entities might be disabled from the start. +You get a sensor each for chores, meal plan, shopping list, stock and tasks. +You get a binary sensor each for expired, expiring and missing products. \ No newline at end of file From 6c1505a4aa974d9c307135963353eafc5fa887ad Mon Sep 17 00:00:00 2001 From: Ludeeus Date: Sun, 6 Sep 2020 09:37:35 +0000 Subject: [PATCH 34/45] Clone CoordinatorEntity --- .devcontainer/devcontainer.json | 2 +- custom_components/grocy/entity.py | 48 +++++++++++++++++++++++++++++-- hacs.json | 9 ++++-- 3 files changed, 52 insertions(+), 7 deletions(-) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 1da4a47..9b24607 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -4,7 +4,7 @@ "appPort": [ "9123:8123" ], - "postCreateCommand": "apk add jq && for req in $(jq -c -r '.requirements | .[]' custom_components/grocy/manifest.json); do python -m pip install '$req'; done && container install", + "postCreateCommand": "apk add jq && for req in $(jq -c -r '.requirements | .[]' custom_components/grocy/manifest.json); do python -m pip install $req; done && container install", "extensions": [ "ms-python.python", "github.vscode-pull-request-github", diff --git a/custom_components/grocy/entity.py b/custom_components/grocy/entity.py index f9ff2d1..acaeb6a 100644 --- a/custom_components/grocy/entity.py +++ b/custom_components/grocy/entity.py @@ -1,5 +1,6 @@ """GrocyEntity class""" -from homeassistant.helpers.update_coordinator import CoordinatorEntity +from homeassistant.helpers import entity +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator # pylint: disable=relative-beyond-top-level from .const import ( @@ -12,10 +13,51 @@ ) -class GrocyEntity(CoordinatorEntity): +class GrocyCoordinatorEntity(entity.Entity): + """ + CoordinatorEntity was added to HA in 0.115, this is a copy of the + class CoordinatorEntity from homeassistant.helpers.update_coordinator + + Remove this class and use CoordinatorEntity instead when grocy require min version 0.115 + """ + + def __init__(self, coordinator: DataUpdateCoordinator) -> None: + """Create the entity with a DataUpdateCoordinator.""" + self.coordinator = coordinator + + @property + def should_poll(self) -> bool: + """No need to poll. Coordinator notifies entity of updates.""" + return False + + @property + def available(self) -> bool: + """Return if entity is available.""" + return self.coordinator.last_update_success + + async def async_added_to_hass(self) -> None: + """When entity is added to hass.""" + await super().async_added_to_hass() + self.async_on_remove( + self.coordinator.async_add_listener(self.async_write_ha_state) + ) + + async def async_update(self) -> None: + """Update the entity. + + Only used by the generic entity update service. + """ + + # Ignore manual update requests if the entity is disabled + if not self.enabled: + return + + await self.coordinator.async_request_refresh() + + +class GrocyEntity(GrocyCoordinatorEntity): def __init__(self, coordinator, config_entry, entity_type): super().__init__(coordinator) - self.coordinator = coordinator self.config_entry = config_entry self.entity_type = entity_type diff --git a/hacs.json b/hacs.json index bc2950b..6ec81dc 100644 --- a/hacs.json +++ b/hacs.json @@ -1,7 +1,10 @@ { "name": "Grocy custom component", - "domains": ["sensor", "binary_sensor"], + "domains": [ + "sensor", + "binary_sensor" + ], "render_readme": true, "iot_class": "Cloud Polling", - "homeassistant": "0.100.0" -} + "homeassistant": "0.109.0" +} \ No newline at end of file From f618af8f63787a72ec67c1946411defa5f33ee7d Mon Sep 17 00:00:00 2001 From: Ludeeus Date: Sun, 6 Sep 2020 09:53:16 +0000 Subject: [PATCH 35/45] Add supported features --- custom_components/grocy/__init__.py | 32 ++++++++++++++++++++++++++- custom_components/grocy/grocy_data.py | 8 +++++++ 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/custom_components/grocy/__init__.py b/custom_components/grocy/__init__.py index 84bf260..22aa389 100644 --- a/custom_components/grocy/__init__.py +++ b/custom_components/grocy/__init__.py @@ -7,6 +7,7 @@ import asyncio import logging from datetime import timedelta +from typing import List from homeassistant.config_entries import ConfigEntry from homeassistant.core import Config, HomeAssistant @@ -20,6 +21,7 @@ CONF_URL, CONF_VERIFY_SSL, DOMAIN, + GrocyEntityType, PLATFORMS, STARTUP_MESSAGE, ) @@ -85,8 +87,9 @@ async def _async_update_data(self): data = {} try: grocy_data = GrocyData(self.hass, self.api) + features = await async_supported_features(grocy_data) for entity in self.entities: - if entity.enabled: + if entity.enabled and entity.entity_type in features: data[entity.entity_type] = await grocy_data.async_update_data( entity.entity_type ) @@ -95,6 +98,33 @@ async def _async_update_data(self): raise UpdateFailed(exception) +async def async_supported_features(grocy_data) -> List[str]: + """Return a list of supported features.""" + features = [] + config = await grocy_data.async_get_config() + if config: + if 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 config["FEATURE_FLAG_SHOPPINGLIST"]: + features.append(GrocyEntityType.SHOPPING_LIST) + + if config["FEATURE_FLAG_TASKS"]: + features.append(GrocyEntityType.TASKS) + + if config["FEATURE_FLAG_CHORES"]: + features.append(GrocyEntityType.CHORES) + + if config["FEATURE_FLAG_RECIPES"]: + features.append(GrocyEntityType.MEAL_PLAN) + + return features + + async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): """Handle removal of an entry.""" _LOGGER.debug("Unloading with state %s", entry.state) diff --git a/custom_components/grocy/grocy_data.py b/custom_components/grocy/grocy_data.py index d56fa04..c6e8077 100644 --- a/custom_components/grocy/grocy_data.py +++ b/custom_components/grocy/grocy_data.py @@ -68,6 +68,14 @@ def wrapper(): return await self.hass.async_add_executor_job(wrapper) + async def async_get_config(self): + """Get the configuration from Grocy.""" + + def wrapper(): + return self.client._api_client._do_get_request("/api/system/config") + + 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. From 31ba2b7f9585c291a8c3cad596cf2987b6ee1f7b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Isabella=20Gross=20Alstr=C3=B6m?= Date: Sun, 6 Sep 2020 11:21:03 +0000 Subject: [PATCH 36/45] Add more info to readme --- README.md | 43 +++++++++++++++++++++++------------------ grocy-addon-config.png | Bin 116230 -> 48263 bytes 2 files changed, 24 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index 1a26f39..37a020e 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,24 @@ [![hacs_badge](https://img.shields.io/badge/HACS-Default-orange.svg)](https://github.com/custom-components/hacs) +# Installation instructions +## for Grocy add-on -## Installation instructions (external Grocy install): +The configuration is slightly different for those who use the [official Grocy addon](https://github.com/hassio-addons/addon-grocy) from the add-on store. -1. Install HACS for Home Assistant +1. If you haven't already done so, install Grocy from the add-on store +2. In the 'Configuration' section of the add-on config, input `9192` in the host field - see [screenshot](#screenshot). Save your changes and restart the add-on. +3. Install [HACS](https://hacs.xyz/) +4. Go to Community > Store > Grocy +5. Install the Grocy integration +6. Restart Home Assistant +7. Go to Grocy > Wrench icon > Manage API keys > Add +8. Copy resulting API key +9. In the HA UI go to "Configuration" -> "Integrations" click "+" and search for "Grocy" +10. You will now have a new integration for Grocy. Some or all of the entities might be disabled from the start. + + +## for existing external Grocy install + +1. Install [HACS](https://hacs.xyz/) 2. Go to Community > Store > Grocy 3. Install the Grocy integration 4. Restart Home Assistant @@ -14,23 +30,12 @@ (This component will not currently work if you have an install where you don't use a port, due to [this issue](https://github.com/SebRut/pygrocy/issues/121).) -## Additional installation instructions for Hass.io users - -The configuration is slightly different for users that use Hass.io and the [official Grocy addon](https://github.com/hassio-addons/addon-grocy) from the Hass.io Add-on store. - -1. If you haven't already done so, install Grocy from the add-on store -2. In the 'Network' section of the add-on config, input 9192 in the host field [screenshot](https://github.com/custom-components/grocy/raw/master/grocy-addon-config.png). Save your changes and restart the add-on. -3. Install HACS for Home Assistant -4. Go to Community > Store > Grocy -5. Install the Grocy integration -6. Restart Home Assistant -7. Go to Grocy > Wrench icon > Manage API keys > Add -8. Copy resulting API key -9. In the HA UI go to "Configuration" -> "Integrations" click "+" and search for "Grocy" -10. You will now have a new integration for Grocy. Some or all of the entities might be disabled from the start. - # Entities -This component creates eight entities. Some or all of the entities might be disabled from the start. +Some or all of the entities might be disabled from the start. You get a sensor each for chores, meal plan, shopping list, stock and tasks. -You get a binary sensor each for expired, expiring and missing products. \ No newline at end of file +You get a binary sensor each for expired, expiring and missing products. + + +# Add-on port configuration +![alt text](https://github.com/custom-components/grocy/raw/master/grocy-addon-config.png) \ No newline at end of file diff --git a/grocy-addon-config.png b/grocy-addon-config.png index 36e94578fd09ab2597c2e1b3f38c261e6e0a5832..35cdf7110187cf705c9d107f34d906262712d416 100644 GIT binary patch literal 48263 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 literal 116230 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

HJTPFF4fz%F{VkG>I#rm;8Cegr0)b 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

  • M5C3ehSObrJ$avpD=j*8=E*47cAMPmSd zc0luuv+wh2wE{Yo;Lm#rX2w@rrB_zem;0;b7aG`7?bf1>GdlUxAVg)7;dJ#nBS_d} zK}^)6bf3(L)!||vIVZDoDLv!%X|B&k(i%Ur7J9bjq#FW?QI)*D$NVk-jyGWqqC63w zs!U4*mvvBJ&hyKG@%YMue^TBb+$%T5R;HNjUPI=@w^g2dIojW=3UUQgge*Fa#Cmp~f zx(izx8ACr6X*4|$_=snDM;`6O8KoqfTh}pbmn|(De=#_oH#sp3Tx|VgU^h$3&ZkNJ4n`A|z%yEX2p2j% zXXd!-gz(SBmA`rY_EX1w9r2=vUoTU=2-C$BYF9*;&oZAq=3>aq>X8Ow63S$s_sNG+ zf+>?foMO{I_);Hink?^hCmd{J-N-TpB!cd2`&incETkh6Fw z9293E=|<@0k~jBA(6o6c>HXdcCKc$EC6cVowh!gSbZD~X8`tlX_=*kq;)37)aC^b> zINUNjx%0%Z`V-LdnEh`okGHZc)5f}pKUbv@b0>V;KRJD{cx6rbX^~WUAk&m;h32u} z0xhWmg)`DS4kG@F?$t(enHL@|WEfs1gAvHldc;Ui(+x;#)SMX}8-#fqy3STw|uGx2^vH zqxqoi&1dm9gkJ%#=63H#b@ZO&s9EPk%b6T#XrfY%*++#wH_g*mo)V;%c$#s%_L z>29pf!l^`;UwutLAi=m#3D6`d=sG$%SrUx%Jy)bs`|?m6Jnwz7j8a*LJ&2a0(X(ZP zo|{(dEBPp^Daqruq9+e|Q;DsKzZ9BcUk4LY>?{^TRm3=+(cAes%zhWh80i~H?6WRc zGHHpodNL2n1v<`YjTt@*_z}p1&g>Lr9zS&GN6qTFcF>+Qy^=~l-ZiLl8!X}6BUvVB z3uw1ve6pxZ6G!?$e|{7j+_zj`nAf( zX(nIah0r?`12u9iQX1%Oc@bd&Hi>&19bF?v5~_K$5WcglFbT#5vx0`Jmo1yGjU0)( zd{=hryfPN~sP{euLVQV-csvSNv3ft~gm8D z�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

    q(aky=@hPlJZh&g=JtRC-n$yT1GUumL& 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 Date: Sun, 6 Sep 2020 11:23:04 +0000 Subject: [PATCH 37/45] add image relative link --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 37a020e..af604fb 100644 --- a/README.md +++ b/README.md @@ -38,4 +38,4 @@ You get a binary sensor each for expired, expiring and missing products. # Add-on port configuration -![alt text](https://github.com/custom-components/grocy/raw/master/grocy-addon-config.png) \ No newline at end of file +![alt text](grocy-addon-config.png) \ No newline at end of file From d9962ed485de3429b339e33fd9c2f24a2787963e Mon Sep 17 00:00:00 2001 From: Ludeeus Date: Sun, 6 Sep 2020 11:43:36 +0000 Subject: [PATCH 38/45] Make update more verbose --- custom_components/grocy/__init__.py | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/custom_components/grocy/__init__.py b/custom_components/grocy/__init__.py index 22aa389..57b280e 100644 --- a/custom_components/grocy/__init__.py +++ b/custom_components/grocy/__init__.py @@ -84,18 +84,27 @@ def __init__(self, hass, url, api_key, port_number, verify_ssl): async def _async_update_data(self): """Update data via library.""" + grocy_data = GrocyData(self.hass, self.api) data = {} + features = [] try: - grocy_data = GrocyData(self.hass, self.api) features = await async_supported_features(grocy_data) - for entity in self.entities: - if entity.enabled and entity.entity_type in features: + if not features: + raise UpdateFailed("No features enabled") + except Exception as exception: + raise UpdateFailed(exception) + + for entity in self.entities: + if entity.enabled and entity.entity_type in features: + try: data[entity.entity_type] = await grocy_data.async_update_data( entity.entity_type ) - return data - except Exception as exception: - raise UpdateFailed(exception) + except Exception as exception: + raise UpdateFailed( + f"Update of {entity.entity_type} failed with {exception}" + ) + return data async def async_supported_features(grocy_data) -> List[str]: From 6c63b6fccf0e7adf81349feb437048c784380a9e Mon Sep 17 00:00:00 2001 From: Ludeeus Date: Sun, 6 Sep 2020 12:14:26 +0000 Subject: [PATCH 39/45] WTF.... --- custom_components/grocy/__init__.py | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/custom_components/grocy/__init__.py b/custom_components/grocy/__init__.py index 57b280e..f0c10e3 100644 --- a/custom_components/grocy/__init__.py +++ b/custom_components/grocy/__init__.py @@ -28,7 +28,7 @@ from .grocy_data import GrocyData, async_setup_image_api from .services import async_setup_services, async_unload_services -SCAN_INTERVAL = timedelta(seconds=30) +SCAN_INTERVAL = timedelta(seconds=10) _LOGGER = logging.getLogger(__name__) @@ -101,9 +101,13 @@ async def _async_update_data(self): entity.entity_type ) except Exception as exception: - raise UpdateFailed( + _LOGGER.error( f"Update of {entity.entity_type} failed with {exception}" ) + elif entity.entity_type not in features: + _LOGGER.warning( + f"You have enabled the entity for {entity.name}, but this feature is not enabled in Grocy", + ) return data @@ -112,23 +116,23 @@ async def async_supported_features(grocy_data) -> List[str]: features = [] config = await grocy_data.async_get_config() if config: - if config["FEATURE_FLAG_STOCK"]: + if config["FEATURE_FLAG_STOCK"] != "0": features.append(GrocyEntityType.STOCK) features.append(GrocyEntityType.PRODUCTS) features.append(GrocyEntityType.MISSING_PRODUCTS) features.append(GrocyEntityType.EXPIRED_PRODUCTS) features.append(GrocyEntityType.EXPIRING_PRODUCTS) - if config["FEATURE_FLAG_SHOPPINGLIST"]: + if config["FEATURE_FLAG_SHOPPINGLIST"] != "0": features.append(GrocyEntityType.SHOPPING_LIST) - if config["FEATURE_FLAG_TASKS"]: + if config["FEATURE_FLAG_TASKS"] != "0": features.append(GrocyEntityType.TASKS) - if config["FEATURE_FLAG_CHORES"]: + if config["FEATURE_FLAG_CHORES"] != "0": features.append(GrocyEntityType.CHORES) - if config["FEATURE_FLAG_RECIPES"]: + if config["FEATURE_FLAG_RECIPES"] != "0": features.append(GrocyEntityType.MEAL_PLAN) return features From cb080cc552d5b2af15b1d0da233db29c2fd684f7 Mon Sep 17 00:00:00 2001 From: Ludeeus Date: Sun, 6 Sep 2020 12:16:50 +0000 Subject: [PATCH 40/45] SCAN_INTERVAL=30 --- custom_components/grocy/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/grocy/__init__.py b/custom_components/grocy/__init__.py index f0c10e3..e1f8e75 100644 --- a/custom_components/grocy/__init__.py +++ b/custom_components/grocy/__init__.py @@ -28,7 +28,7 @@ from .grocy_data import GrocyData, async_setup_image_api from .services import async_setup_services, async_unload_services -SCAN_INTERVAL = timedelta(seconds=10) +SCAN_INTERVAL = timedelta(seconds=30) _LOGGER = logging.getLogger(__name__) From 81aef8fca4e81faec502872134cfe876544bc43d Mon Sep 17 00:00:00 2001 From: isabellaalstrom Date: Mon, 7 Sep 2020 12:29:16 +0000 Subject: [PATCH 41/45] Changes to readme --- .github/ISSUE_TEMPLATE/bug_report.md | 16 +++++++------- README.md | 33 +++++++++++++++++++++++++--- 2 files changed, 38 insertions(+), 11 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index 1c4bab0..8edd283 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -6,10 +6,14 @@ labels: '' assignees: '' --- +## Unless all relevant information is provided, I can't help you **Describe the bug** A clear and concise description of what the bug is. +**Expected behavior** +A clear and concise description of what you expected to happen. + **To Reproduce** Steps to reproduce the behavior: 1. Go to '...' @@ -17,16 +21,12 @@ Steps to reproduce the behavior: 3. Scroll down to '....' 4. See error -**Expected behavior** -A clear and concise description of what you expected to happen. -**Are you using HASSIO to run grocy?** -Yes/No +**What is your installed versions of Home Assistant, Grocy and this integration?** -**configuration.yaml entry** -```yaml -Your grocy component config entry -``` +**How do you have Grocy installed? Add-on or external?** + +**Have you added debugging to the log, and what does the log say?** **JSON service data (if related to using a service)** ```json diff --git a/README.md b/README.md index af604fb..198b509 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,16 @@ [![hacs_badge](https://img.shields.io/badge/HACS-Default-orange.svg)](https://github.com/custom-components/hacs) -# Installation instructions -## for Grocy add-on +# Installation + + +--- +**INFO** + +You have to have the Grocy software already installed, this integration only communicates with an existing installation of Grocy. + +--- + + +## for Grocy add-on The configuration is slightly different for those who use the [official Grocy addon](https://github.com/hassio-addons/addon-grocy) from the add-on store. @@ -16,7 +26,7 @@ The configuration is slightly different for those who use the [official Grocy ad 10. You will now have a new integration for Grocy. Some or all of the entities might be disabled from the start. -## for existing external Grocy install +## for existing external Grocy install 1. Install [HACS](https://hacs.xyz/) 2. Go to Community > Store > Grocy @@ -37,5 +47,22 @@ You get a sensor each for chores, meal plan, shopping list, stock and tasks. You get a binary sensor each for expired, expiring and missing products. +# Troubleshooting + +If you have problems with the integration you can add debug prints to the log. + +```yaml +logger: + default: info + logs: + pygrocy: debug +    custom_components.grocy: debug +``` + +If you are having issues and want to report a problem, always start with making sure that you're on the latest version of the integration, Grocy and Home Assistant. + +You can ask for help [in the forums](https://community.home-assistant.io/t/grocy-custom-component-and-card-s/218978), or [make an issue with all of the relevant information here](https://github.com/custom-components/grocy/issues/new?assignees=&labels=&template=bug_report.md&title=). + + # Add-on port configuration ![alt text](grocy-addon-config.png) \ No newline at end of file From 81d726d138aac310d8e78804c28696c2c5370ee1 Mon Sep 17 00:00:00 2001 From: isabellaalstrom Date: Mon, 7 Sep 2020 14:39:01 +0200 Subject: [PATCH 42/45] Make new binary sensors for overdue tasks and chores --- custom_components/grocy/__init__.py | 2 ++ custom_components/grocy/binary_sensor.py | 2 ++ custom_components/grocy/const.py | 6 +++- custom_components/grocy/entity.py | 4 +++ custom_components/grocy/grocy_data.py | 39 ++++++++++++++++++++++++ 5 files changed, 52 insertions(+), 1 deletion(-) diff --git a/custom_components/grocy/__init__.py b/custom_components/grocy/__init__.py index e1f8e75..7790f81 100644 --- a/custom_components/grocy/__init__.py +++ b/custom_components/grocy/__init__.py @@ -128,9 +128,11 @@ async def async_supported_features(grocy_data) -> List[str]: if config["FEATURE_FLAG_TASKS"] != "0": features.append(GrocyEntityType.TASKS) + features.append(GrocyEntityType.OVERDUE_TASKS) if config["FEATURE_FLAG_CHORES"] != "0": features.append(GrocyEntityType.CHORES) + features.append(GrocyEntityType.OVERDUE_CHORES) if config["FEATURE_FLAG_RECIPES"] != "0": features.append(GrocyEntityType.MEAL_PLAN) diff --git a/custom_components/grocy/binary_sensor.py b/custom_components/grocy/binary_sensor.py index 3d3a8eb..24a2f0b 100644 --- a/custom_components/grocy/binary_sensor.py +++ b/custom_components/grocy/binary_sensor.py @@ -14,6 +14,8 @@ GrocyEntityType.EXPIRED_PRODUCTS, GrocyEntityType.EXPIRING_PRODUCTS, GrocyEntityType.MISSING_PRODUCTS, + GrocyEntityType.OVERDUE_CHORES, + GrocyEntityType.OVERDUE_TASKS, ] diff --git a/custom_components/grocy/const.py b/custom_components/grocy/const.py index 450465c..c612f9e 100644 --- a/custom_components/grocy/const.py +++ b/custom_components/grocy/const.py @@ -40,6 +40,8 @@ class GrocyEntityType(str, Enum): 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" @@ -61,10 +63,12 @@ class GrocyEntityIcon(str, Enum): DEFAULT = "mdi:format-quote-close" CHORES = "mdi:broom" - EXPIRED_PRODUCTS = "mdi:clock-end" + 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" diff --git a/custom_components/grocy/entity.py b/custom_components/grocy/entity.py index acaeb6a..569805b 100644 --- a/custom_components/grocy/entity.py +++ b/custom_components/grocy/entity.py @@ -121,6 +121,10 @@ def device_state_attributes(self): return {"meals": [x.as_dict() for x in self.entity_data]} elif self.entity_type == GrocyEntityType.MISSING_PRODUCTS: return {"missing": [x.as_dict() for x in self.entity_data]} + elif self.entity_type == GrocyEntityType.OVERDUE_CHORES: + return {"chores": [x.as_dict() for x in self.entity_data]} + elif self.entity_type == GrocyEntityType.OVERDUE_TASKS: + return {"tasks": [x.as_dict() for x in self.entity_data]} elif self.entity_type == GrocyEntityType.PRODUCTS: return {"products": [x.as_dict() for x in self.entity_data]} elif self.entity_type == GrocyEntityType.SHOPPING_LIST: diff --git a/custom_components/grocy/grocy_data.py b/custom_components/grocy/grocy_data.py index c6e8077..5a15215 100644 --- a/custom_components/grocy/grocy_data.py +++ b/custom_components/grocy/grocy_data.py @@ -1,5 +1,7 @@ from aiohttp import hdrs, web from datetime import timedelta, datetime +import logging +import pytz from homeassistant.components.http import HomeAssistantView from homeassistant.helpers.aiohttp_client import async_get_clientsession @@ -13,6 +15,9 @@ from .helpers import MealPlanItem MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=60) +_LOGGER = logging.getLogger(__name__) + +utc = pytz.UTC class GrocyData: @@ -31,6 +36,8 @@ def __init__(self, hass, client): 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, @@ -41,6 +48,8 @@ def __init__(self, hass, client): GrocyEntityType.EXPIRED_PRODUCTS: None, GrocyEntityType.MISSING_PRODUCTS: None, GrocyEntityType.MEAL_PLAN: None, + GrocyEntityType.OVERDUE_CHORES: None, + GrocyEntityType.OVERDUE_TASKS: None, } async def async_update_data(self, sensor_type): @@ -68,6 +77,22 @@ def wrapper(): 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. + def wrapper(): + return self.client.chores(True) + + chores = await self.hass.async_add_executor_job(wrapper) + overdue_chores = [] + for chore in chores: + if chore.next_estimated_execution_time: + now = datetime.now().replace(tzinfo=utc) + due = chore.next_estimated_execution_time.replace(tzinfo=utc) + if due < now: + overdue_chores.append(chore) + return overdue_chores + async def async_get_config(self): """Get the configuration from Grocy.""" @@ -81,6 +106,20 @@ async def async_update_tasks(self): # This is where the main logic to update platform data goes. return await self.hass.async_add_executor_job(self.client.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) + + overdue_tasks = [] + for task in tasks: + if task.due_date: + now = datetime.now().replace(tzinfo=utc) + due = task.due_date.replace(tzinfo=utc) + if due < now: + overdue_tasks.append(task) + return overdue_tasks + async def async_update_shopping_list(self): """Update data.""" # This is where the main logic to update platform data goes. From e3676164223cf8e15e250de0a58f06eddd9094f8 Mon Sep 17 00:00:00 2001 From: isabellaalstrom Date: Tue, 8 Sep 2020 10:33:18 +0200 Subject: [PATCH 43/45] Update pygrocy to v 0.22.0 --- custom_components/grocy/manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/grocy/manifest.json b/custom_components/grocy/manifest.json index 71cc08f..3577ba4 100644 --- a/custom_components/grocy/manifest.json +++ b/custom_components/grocy/manifest.json @@ -12,7 +12,7 @@ ], "requirements": [ "sampleclient", - "pygrocy==0.21.0", + "pygrocy==0.22.0", "iso8601==0.1.12", "integrationhelper" ] From 62cc0343516ac50ccb92b00414b6498da84c2a41 Mon Sep 17 00:00:00 2001 From: isabellaalstrom Date: Tue, 8 Sep 2020 10:33:37 +0200 Subject: [PATCH 44/45] cleanup --- custom_components/grocy/services.py | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/custom_components/grocy/services.py b/custom_components/grocy/services.py index 0737480..522f6f1 100644 --- a/custom_components/grocy/services.py +++ b/custom_components/grocy/services.py @@ -2,14 +2,13 @@ import asyncio import voluptuous as vol import iso8601 -import logging - from homeassistant.helpers import config_validation as cv from homeassistant.helpers import entity_component from pygrocy import TransactionType from datetime import datetime +# pylint: disable=relative-beyond-top-level from .const import DOMAIN GROCY_SERVICES = "grocy_services" @@ -66,19 +65,13 @@ SERVICE_COMPLETE_TASK_SCHEMA = vol.All( vol.Schema( - { - vol.Required(SERVICE_TASK_ID): int, - vol.Optional(SERVICE_DONE_TIME): str, - } + {vol.Required(SERVICE_TASK_ID): int, vol.Optional(SERVICE_DONE_TIME): str,} ) ) SERVICE_ADD_GENERIC_SCHEMA = vol.All( vol.Schema( - { - vol.Required(SERVICE_ENTITY_TYPE): str, - vol.Required(SERVICE_DATA): object, - } + {vol.Required(SERVICE_ENTITY_TYPE): str, vol.Required(SERVICE_DATA): object,} ) ) From 6e539051db4697ba93246ad9884a0fbf3072f372 Mon Sep 17 00:00:00 2001 From: Sebastian Rutofski Date: Mon, 14 Sep 2020 10:02:54 +0200 Subject: [PATCH 45/45] Update manifest.json --- custom_components/grocy/manifest.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/custom_components/grocy/manifest.json b/custom_components/grocy/manifest.json index 3577ba4..3be9c18 100644 --- a/custom_components/grocy/manifest.json +++ b/custom_components/grocy/manifest.json @@ -12,8 +12,8 @@ ], "requirements": [ "sampleclient", - "pygrocy==0.22.0", + "pygrocy==0.23.0", "iso8601==0.1.12", "integrationhelper" ] -} \ No newline at end of file +}