From 8aaaa0b296b3ef2a992ca3bdc9fd74edb5cc41c3 Mon Sep 17 00:00:00 2001 From: Bearded Tinker Date: Sat, 30 Dec 2023 10:29:48 +0100 Subject: [PATCH] HACS update to multiple intagrations #16 --- custom_components/battery_notes/__init__.py | 86 ++++++- custom_components/battery_notes/button.py | 229 ++++++++++++++++++ .../battery_notes/config_flow.py | 59 ++++- custom_components/battery_notes/const.py | 20 ++ .../battery_notes/coordinator.py | 46 ++++ custom_components/battery_notes/discovery.py | 3 + custom_components/battery_notes/library.py | 13 +- custom_components/battery_notes/manifest.json | 2 +- custom_components/battery_notes/sensor.py | 128 +++++++++- custom_components/battery_notes/services.yaml | 13 + custom_components/battery_notes/store.py | 150 ++++++++++++ custom_components/powercalc/__init__.py | 4 +- custom_components/powercalc/const.py | 3 +- custom_components/powercalc/discovery.py | 110 +++++---- .../powercalc/group_include/include.py | 14 +- custom_components/powercalc/manifest.json | 2 +- custom_components/powercalc/sensor.py | 7 +- custom_components/powercalc/sensors/energy.py | 3 +- custom_components/powercalc/sensors/group.py | 24 +- python_scripts/shellies_discovery_gen2.py | 7 +- 20 files changed, 832 insertions(+), 91 deletions(-) create mode 100644 custom_components/battery_notes/button.py create mode 100644 custom_components/battery_notes/coordinator.py create mode 100644 custom_components/battery_notes/services.yaml create mode 100644 custom_components/battery_notes/store.py diff --git a/custom_components/battery_notes/__init__.py b/custom_components/battery_notes/__init__.py index ec006c59..382f7903 100644 --- a/custom_components/battery_notes/__init__.py +++ b/custom_components/battery_notes/__init__.py @@ -6,6 +6,7 @@ from __future__ import annotations import logging +from datetime import datetime import homeassistant.helpers.config_validation as cv import voluptuous as vol @@ -15,14 +16,18 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.const import __version__ as HA_VERSION # noqa: N812 from homeassistant.helpers.aiohttp_client import async_get_clientsession - from homeassistant.helpers.typing import ConfigType +from homeassistant.helpers import device_registry as dr from .discovery import DiscoveryManager from .library_coordinator import BatteryNotesLibraryUpdateCoordinator from .library_updater import ( LibraryUpdaterClient, ) +from .coordinator import BatteryNotesCoordinator +from .store import ( + async_get_registry, +) from .const import ( DOMAIN, @@ -31,6 +36,11 @@ CONF_ENABLE_AUTODISCOVERY, CONF_LIBRARY, DATA_UPDATE_COORDINATOR, + CONF_SHOW_ALL_DEVICES, + SERVICE_BATTERY_REPLACED, + SERVICE_BATTERY_REPLACED_SCHEMA, + DATA_COORDINATOR, + ATTR_REMOVE, ) MIN_HA_VERSION = "2023.7" @@ -44,6 +54,7 @@ { vol.Optional(CONF_ENABLE_AUTODISCOVERY, default=True): cv.boolean, vol.Optional(CONF_LIBRARY, default="library.json"): cv.string, + vol.Optional(CONF_SHOW_ALL_DEVICES, default=False): cv.boolean, }, ), ), @@ -51,6 +62,7 @@ extra=vol.ALLOW_EXTRA, ) +ATTR_SERVICE_DEVICE_ID = "device_id" async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Integration setup.""" @@ -66,18 +78,24 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: domain_config: ConfigType = config.get(DOMAIN) or { CONF_ENABLE_AUTODISCOVERY: True, + CONF_SHOW_ALL_DEVICES: False, } hass.data[DOMAIN] = { DOMAIN_CONFIG: domain_config, } - coordinator = BatteryNotesLibraryUpdateCoordinator( + store = await async_get_registry(hass) + + coordinator = BatteryNotesCoordinator(hass, store) + hass.data[DOMAIN][DATA_COORDINATOR] = coordinator + + library_coordinator = BatteryNotesLibraryUpdateCoordinator( hass=hass, client=LibraryUpdaterClient(session=async_get_clientsession(hass)), ) - hass.data[DOMAIN][DATA_UPDATE_COORDINATOR] = coordinator + hass.data[DOMAIN][DATA_UPDATE_COORDINATOR] = library_coordinator await coordinator.async_refresh() @@ -97,9 +115,28 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: entry.async_on_unload(entry.add_update_listener(async_update_options)) + # Register custom services + register_services(hass) + return True +async def async_remove_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> None: + """Device removed, tidy up store.""" + + if "device_id" not in config_entry.data: + return + + device_id = config_entry.data["device_id"] + + coordinator = hass.data[DOMAIN][DATA_COORDINATOR] + data = {ATTR_REMOVE: True} + + coordinator.async_update_device_config(device_id=device_id, data=data) + + _LOGGER.debug("Removed Device %s", device_id) + + @callback async def async_update_options(hass: HomeAssistant, entry: ConfigEntry) -> None: """Update options.""" @@ -111,6 +148,43 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) -async def config_entry_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: - """Update listener, called when the config entry options are changed.""" - await hass.config_entries.async_reload(entry.entry_id) +@callback +def register_services(hass): + """Register services used by battery notes component.""" + + async def handle_battery_replaced(call): + """Handle the service call.""" + device_id = call.data.get(ATTR_SERVICE_DEVICE_ID, "") + + device_registry = dr.async_get(hass) + + device_entry = device_registry.async_get(device_id) + if not device_entry: + return + + for entry_id in device_entry.config_entries: + if ( + entry := hass.config_entries.async_get_entry(entry_id) + ) and entry.domain == DOMAIN: + date_replaced = datetime.utcnow() + + coordinator = hass.data[DOMAIN][DATA_COORDINATOR] + device_entry = {"battery_last_replaced": date_replaced} + + coordinator.async_update_device_config( + device_id=device_id, data=device_entry + ) + + await coordinator._async_update_data() + await coordinator.async_request_refresh() + + _LOGGER.debug( + "Device %s battery replaced on %s", device_id, str(date_replaced) + ) + + hass.services.async_register( + DOMAIN, + SERVICE_BATTERY_REPLACED, + handle_battery_replaced, + schema=SERVICE_BATTERY_REPLACED_SCHEMA, + ) diff --git a/custom_components/battery_notes/button.py b/custom_components/battery_notes/button.py new file mode 100644 index 00000000..01860019 --- /dev/null +++ b/custom_components/battery_notes/button.py @@ -0,0 +1,229 @@ +"""Button platform for battery_notes.""" +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime + +import voluptuous as vol + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_ENTITY_ID +from homeassistant.core import HomeAssistant, callback, Event +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers import ( + config_validation as cv, + device_registry as dr, + entity_registry as er, +) +from homeassistant.components.button import ( + PLATFORM_SCHEMA, + ButtonEntity, + ButtonEntityDescription, +) +from homeassistant.helpers.entity import DeviceInfo, EntityCategory +from homeassistant.helpers.event import ( + async_track_entity_registry_updated_event, +) + +from homeassistant.helpers.reload import async_setup_reload_service +from homeassistant.helpers.typing import ( + ConfigType, +) + +from homeassistant.const import ( + CONF_NAME, + CONF_UNIQUE_ID, + CONF_DEVICE_ID, +) + +from . import PLATFORMS + +from .const import ( + DOMAIN, + DATA_COORDINATOR, +) + +from .entity import ( + BatteryNotesEntityDescription, +) + + +@dataclass +class BatteryNotesButtonEntityDescription( + BatteryNotesEntityDescription, + ButtonEntityDescription, +): + """Describes Battery Notes button entity.""" + + unique_id_suffix: str + + +ENTITY_DESCRIPTIONS: tuple[BatteryNotesButtonEntityDescription, ...] = ( + BatteryNotesButtonEntityDescription( + unique_id_suffix="_battery_replaced_button", + key="battery_replaced", + translation_key="battery_replaced", + icon="mdi:battery-sync", + entity_category=EntityCategory.DIAGNOSTIC, + ), +) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + {vol.Optional(CONF_NAME): cv.string, vol.Required(CONF_DEVICE_ID): cv.string} +) + + +@callback +def async_add_to_device(hass: HomeAssistant, entry: ConfigEntry) -> str | None: + """Add our config entry to the device.""" + device_registry = dr.async_get(hass) + + device_id = entry.data.get(CONF_DEVICE_ID) + device_registry.async_update_device(device_id, add_config_entry_id=entry.entry_id) + + return device_id + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Initialize Battery Type config entry.""" + entity_registry = er.async_get(hass) + device_registry = dr.async_get(hass) + + device_id = config_entry.data.get(CONF_DEVICE_ID) + + async def async_registry_updated(event: Event) -> None: + """Handle entity registry update.""" + data = event.data + if data["action"] == "remove": + await hass.config_entries.async_remove(config_entry.entry_id) + + if data["action"] != "update": + return + + if "entity_id" in data["changes"]: + # Entity_id changed, reload the config entry + await hass.config_entries.async_reload(config_entry.entry_id) + + if device_id and "device_id" in data["changes"]: + # If the tracked battery note is no longer in the device, remove our config entry + # from the device + if ( + not (entity_entry := entity_registry.async_get(data[CONF_ENTITY_ID])) + or not device_registry.async_get(device_id) + or entity_entry.device_id == device_id + ): + # No need to do any cleanup + return + + device_registry.async_update_device( + device_id, remove_config_entry_id=config_entry.entry_id + ) + + config_entry.async_on_unload( + async_track_entity_registry_updated_event( + hass, config_entry.entry_id, async_registry_updated + ) + ) + + device_id = async_add_to_device(hass, config_entry) + + async_add_entities( + BatteryNotesButton( + hass, + description, + f"{config_entry.entry_id}{description.unique_id_suffix}", + device_id, + ) + for description in ENTITY_DESCRIPTIONS + ) + + +async def async_setup_platform( + hass: HomeAssistant, + config: ConfigType, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the battery type button.""" + device_id: str = config[CONF_DEVICE_ID] + + await async_setup_reload_service(hass, DOMAIN, PLATFORMS) + + async_add_entities( + BatteryNotesButton( + hass, + description, + f"{config.get(CONF_UNIQUE_ID)}{description.unique_id_suffix}", + device_id, + ) + for description in ENTITY_DESCRIPTIONS + ) + + +class BatteryNotesButton(ButtonEntity): + """Represents a battery replaced button.""" + + _attr_should_poll = False + + entity_description: BatteryNotesButtonEntityDescription + + def __init__( + self, + hass: HomeAssistant, + description: BatteryNotesButtonEntityDescription, + unique_id: str, + device_id: str, + ) -> None: + """Create a battery replaced button.""" + device_registry = dr.async_get(hass) + + self.entity_description = description + self._attr_unique_id = unique_id + self._attr_has_entity_name = True + self._device_id = device_id + + self._device_id = device_id + if device_id and (device := device_registry.async_get(device_id)): + self._attr_device_info = DeviceInfo( + connections=device.connections, + identifiers=device.identifiers, + ) + + async def async_added_to_hass(self) -> None: + """Handle added to Hass.""" + # Update entity options + registry = er.async_get(self.hass) + if registry.async_get(self.entity_id) is not None: + registry.async_update_entity_options( + self.entity_id, + DOMAIN, + {"entity_id": self._attr_unique_id}, + ) + + async def update_battery_last_replaced(self): + """Handle sensor state changes.""" + + # device_id = self._device_id + + # device_entry = { + # "battery_last_replaced" : datetime.utcnow() + # } + + # coordinator = self.hass.data[DOMAIN][DATA_COORDINATOR] + # coordinator.async_update_device_config(device_id = device_id, data = device_entry) + + self.async_write_ha_state() + + async def async_press(self) -> None: + """Press the button.""" + device_id = self._device_id + + device_entry = {"battery_last_replaced": datetime.utcnow()} + + coordinator = self.hass.data[DOMAIN][DATA_COORDINATOR] + coordinator.async_update_device_config(device_id=device_id, data=device_entry) + await coordinator._async_update_data() + await coordinator.async_request_refresh() diff --git a/custom_components/battery_notes/config_flow.py b/custom_components/battery_notes/config_flow.py index e5f74ad9..e5535a93 100644 --- a/custom_components/battery_notes/config_flow.py +++ b/custom_components/battery_notes/config_flow.py @@ -13,7 +13,8 @@ from homeassistant.config_entries import ConfigEntry, OptionsFlow from homeassistant.helpers import selector from homeassistant.helpers.typing import DiscoveryInfoType - +from homeassistant.const import Platform +from homeassistant.components.sensor import SensorDeviceClass import homeassistant.helpers.device_registry as dr from homeassistant.const import ( @@ -30,14 +31,38 @@ CONF_MANUFACTURER, CONF_MODEL, DATA_UPDATE_COORDINATOR, + DOMAIN_CONFIG, + CONF_SHOW_ALL_DEVICES, ) _LOGGER = logging.getLogger(__name__) +DEVICE_SCHEMA_ALL = vol.Schema( + { + vol.Required(CONF_DEVICE_ID): selector.DeviceSelector( + config=selector.DeviceFilterSelectorConfig() + ), + vol.Optional(CONF_NAME): selector.TextSelector( + selector.TextSelectorConfig(type=selector.TextSelectorType.TEXT), + ), + } +) + DEVICE_SCHEMA = vol.Schema( { vol.Required(CONF_DEVICE_ID): selector.DeviceSelector( - # selector.DeviceSelectorConfig(model="otgw-nodo") + config=selector.DeviceSelectorConfig( + entity=[ + selector.EntityFilterSelectorConfig( + domain=Platform.SENSOR, + device_class=SensorDeviceClass.BATTERY, + ), + selector.EntityFilterSelectorConfig( + domain=Platform.BINARY_SENSOR, + device_class=SensorDeviceClass.BATTERY, + ), + ] + ) ), vol.Optional(CONF_NAME): selector.TextSelector( selector.TextSelectorConfig(type=selector.TextSelectorType.TEXT), @@ -85,9 +110,10 @@ async def async_step_user( device_id = user_input[CONF_DEVICE_ID] - coordinator = self.hass.data[DOMAIN][DATA_UPDATE_COORDINATOR] - - await coordinator.async_refresh() + if DOMAIN in self.hass.data: + if DATA_UPDATE_COORDINATOR in self.hass.data[DOMAIN]: + coordinator = self.hass.data[DOMAIN][DATA_UPDATE_COORDINATOR] + await coordinator.async_refresh() device_registry = dr.async_get(self.hass) device_entry = device_registry.async_get(device_id) @@ -103,18 +129,27 @@ async def async_step_user( ) if device_battery_details: - _LOGGER.debug( - "Found device %s %s", device_entry.manufacturer, device_entry.model - ) - self.data[ - CONF_BATTERY_TYPE - ] = device_battery_details.battery_type_and_quantity + if not device_battery_details.is_manual: + _LOGGER.debug( + "Found device %s %s", device_entry.manufacturer, device_entry.model + ) + self.data[ + CONF_BATTERY_TYPE + ] = device_battery_details.battery_type_and_quantity return await self.async_step_battery() + schema = DEVICE_SCHEMA + # If show_all_devices = is specified and true, don't filter + if DOMAIN in self.hass.data: + if DOMAIN_CONFIG in self.hass.data[DOMAIN]: + domain_config = self.hass.data[DOMAIN][DOMAIN_CONFIG] + if domain_config.get(CONF_SHOW_ALL_DEVICES, False): + schema = DEVICE_SCHEMA_ALL + return self.async_show_form( step_id="user", - data_schema=DEVICE_SCHEMA, + data_schema=schema, errors=_errors, last_step=False, ) diff --git a/custom_components/battery_notes/const.py b/custom_components/battery_notes/const.py index 518c37c3..4facd5ce 100644 --- a/custom_components/battery_notes/const.py +++ b/custom_components/battery_notes/const.py @@ -3,9 +3,12 @@ from logging import Logger, getLogger from pathlib import Path from typing import Final +import voluptuous as vol from homeassistant.const import Platform +from homeassistant.helpers import config_validation as cv + LOGGER: Logger = getLogger(__package__) manifestfile = Path(__file__).parent / "manifest.json" @@ -17,6 +20,7 @@ VERSION = manifest_data.get("version") ISSUEURL = manifest_data.get("issue_tracker") MANUFACTURER = "@Andrew-CodeChimp" +LAST_REPLACED = "battery_last_replaced" DOMAIN_CONFIG = "config" @@ -28,6 +32,7 @@ CONF_MANUFACTURER = "manufacturer" CONF_DEVICE_NAME = "device_name" CONF_LIBRARY_URL = "https://raw.githubusercontent.com/andrew-codechimp/HA-Battery-Notes/main/custom_components/battery_notes/data/library.json" # pylint: disable=line-too-long +CONF_SHOW_ALL_DEVICES = "show_all_devices" DATA_CONFIGURED_ENTITIES = "configured_entities" DATA_DISCOVERED_ENTITIES = "discovered_entities" @@ -35,7 +40,22 @@ DATA_LIBRARY = "library" DATA_UPDATE_COORDINATOR = "update_coordinator" DATA_LIBRARY_LAST_UPDATE = "library_last_update" +DATA_COORDINATOR = "coordinator" +DATA_STORE = "store" + +SERVICE_BATTERY_REPLACED = "set_battery_replaced" + +ATTR_DEVICE_ID = "device_id" +ATTR_DATE_TIME_REPLACED = "datetime_replaced" +ATTR_REMOVE = "remove" + +SERVICE_BATTERY_REPLACED_SCHEMA = vol.Schema( + { + vol.Required(ATTR_DEVICE_ID): cv.string, + } +) PLATFORMS: Final = [ + Platform.BUTTON, Platform.SENSOR, ] diff --git a/custom_components/battery_notes/coordinator.py b/custom_components/battery_notes/coordinator.py new file mode 100644 index 00000000..0664042f --- /dev/null +++ b/custom_components/battery_notes/coordinator.py @@ -0,0 +1,46 @@ +"""DataUpdateCoordinator for battery notes.""" +from __future__ import annotations + +import logging + +from homeassistant.helpers.update_coordinator import ( + DataUpdateCoordinator, +) + +from .const import ( + DOMAIN, + ATTR_REMOVE, +) + +_LOGGER = logging.getLogger(__name__) + + +class BatteryNotesCoordinator(DataUpdateCoordinator): + """Define an object to hold Battery Notes device.""" + + def __init__(self, hass, store): + """Initialize.""" + self.hass = hass + self.store = store + + super().__init__(hass, _LOGGER, name=DOMAIN) + + async def _async_update_data(self): + """Update data.""" + + _LOGGER.debug("Update coordinator") + + def async_update_device_config(self, device_id: str, data: dict): + """Conditional create, update or remove device from store.""" + + if ATTR_REMOVE in data: + self.store.async_delete_device(device_id) + elif self.store.async_get_device(device_id): + self.store.async_update_device(device_id, data) + else: + self.store.async_create_device(device_id, data) + + async def async_delete_config(self): + """Wipe battery notes storage.""" + + await self.store.async_delete() diff --git a/custom_components/battery_notes/discovery.py b/custom_components/battery_notes/discovery.py index c9870a76..d296a038 100644 --- a/custom_components/battery_notes/discovery.py +++ b/custom_components/battery_notes/discovery.py @@ -103,6 +103,9 @@ async def start_discovery(self) -> None: if not device_battery_details: continue + if device_battery_details.is_manual: + continue + self._init_entity_discovery(device_entry, device_battery_details) else: _LOGGER.error("Library not loaded") diff --git a/custom_components/battery_notes/library.py b/custom_components/battery_notes/library.py index 59ae1aa9..ed0905b0 100644 --- a/custom_components/battery_notes/library.py +++ b/custom_components/battery_notes/library.py @@ -42,10 +42,10 @@ def __init__(self, hass: HomeAssistant) -> None: _LOGGER.debug("Using library file at %s", json_path) try: - with open(json_path, encoding="utf-8") as file: - json_data = json.load(file) - + with open(json_path, encoding="utf-8") as myfile: + json_data = json.load(myfile) self._devices = json_data["devices"] + myfile.close() except FileNotFoundError: _LOGGER.error( @@ -105,6 +105,13 @@ class DeviceBatteryDetails(NamedTuple): battery_type: str battery_quantity: int + @property + def is_manual(self): + """Return whether the device should be discovered or battery type suggested.""" + if self.battery_type.casefold() == "manual".casefold(): + return True + return False + @property def battery_type_and_quantity(self): """Return battery type with quantity prefix.""" diff --git a/custom_components/battery_notes/manifest.json b/custom_components/battery_notes/manifest.json index 6dfbdb8e..dd25d131 100644 --- a/custom_components/battery_notes/manifest.json +++ b/custom_components/battery_notes/manifest.json @@ -9,5 +9,5 @@ "integration_type": "device", "iot_class": "calculated", "issue_tracker": "https://github.com/andrew-codechimp/ha-battery-notes/issues", - "version": "1.2.0" + "version": "1.3.3" } diff --git a/custom_components/battery_notes/sensor.py b/custom_components/battery_notes/sensor.py index b30352c2..9ef1602b 100644 --- a/custom_components/battery_notes/sensor.py +++ b/custom_components/battery_notes/sensor.py @@ -1,11 +1,14 @@ """Sensor platform for battery_notes.""" from __future__ import annotations +from datetime import datetime from dataclasses import dataclass import voluptuous as vol +import logging from homeassistant.components.sensor import ( PLATFORM_SCHEMA, + SensorDeviceClass, SensorEntity, SensorEntityDescription, RestoreSensor, @@ -40,14 +43,19 @@ PLATFORMS, CONF_BATTERY_TYPE, DATA_UPDATE_COORDINATOR, + DATA_COORDINATOR, + LAST_REPLACED, ) from .library_coordinator import BatteryNotesLibraryUpdateCoordinator +from .coordinator import BatteryNotesCoordinator from .entity import ( BatteryNotesEntityDescription, ) +_LOGGER = logging.getLogger(__name__) + @dataclass class BatteryNotesSensorEntityDescription( @@ -67,6 +75,15 @@ class BatteryNotesSensorEntityDescription( entity_category=EntityCategory.DIAGNOSTIC, ) +lastReplacedSensorEntityDescription = BatteryNotesSensorEntityDescription( + unique_id_suffix="_battery_last_replaced", + key="battery_last_replaced", + translation_key="battery_last_replaced", + icon="mdi:battery-clock", + entity_category=EntityCategory.DIAGNOSTIC, + device_class=SensorDeviceClass.TIMESTAMP, +) + PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { vol.Optional(CONF_NAME): cv.string, @@ -109,7 +126,7 @@ async def async_registry_updated(event: Event) -> None: return if "entity_id" in data["changes"]: - # Entity_id changed, reload the config entry + # Entity_id replaced, reload the config entry await hass.config_entries.async_reload(config_entry.entry_id) if device_id and "device_id" in data["changes"]: @@ -135,26 +152,37 @@ async def async_registry_updated(event: Event) -> None: device_id = async_add_to_device(hass, config_entry) - coordinator = hass.data[DOMAIN][DATA_UPDATE_COORDINATOR] + library_coordinator = hass.data[DOMAIN][DATA_UPDATE_COORDINATOR] + coordinator = hass.data[DOMAIN][DATA_COORDINATOR] entities = [ BatteryNotesTypeSensor( hass, - coordinator, + library_coordinator, typeSensorEntityDescription, device_id, f"{config_entry.entry_id}{typeSensorEntityDescription.unique_id_suffix}", battery_type, ), + BatteryNotesLastReplacedSensor( + hass, + coordinator, + lastReplacedSensorEntityDescription, + device_id, + f"{config_entry.entry_id}{lastReplacedSensorEntityDescription.unique_id_suffix}", + ), ] async_add_entities(entities) + await coordinator.async_config_entry_first_refresh() + async def async_setup_platform( hass: HomeAssistant, ) -> None: """Set up the battery note sensor.""" + await async_setup_reload_service(hass, DOMAIN, PLATFORMS) @@ -199,12 +227,12 @@ async def async_added_to_hass(self) -> None: async_track_state_change_event( self.hass, [self._attr_unique_id], - self._async_battery_note_state_changed_listener, + self._async_battery_note_state_replaced_listener, ) ) # Call once on adding - self._async_battery_note_state_changed_listener() + self._async_battery_note_state_replaced_listener() # Update entity options registry = er.async_get(self.hass) @@ -216,7 +244,7 @@ async def async_added_to_hass(self) -> None: ) @callback - def _async_battery_note_state_changed_listener(self) -> None: + def _async_battery_note_state_replaced_listener(self) -> None: """Handle the sensor state changes.""" self.async_write_ha_state() @@ -249,7 +277,93 @@ def native_value(self) -> str: return self._battery_type @callback - def _async_battery_type_state_changed_listener(self) -> None: + def _async_battery_type_state_replaced_listener(self) -> None: """Handle the sensor state changes.""" self.async_write_ha_state() self.async_schedule_update_ha_state(True) + + +class BatteryNotesLastReplacedSensor(SensorEntity, CoordinatorEntity): + """Represents a battery note sensor.""" + + _attr_should_poll = False + entity_description: BatteryNotesSensorEntityDescription + + def __init__( + self, + hass, + coordinator: BatteryNotesCoordinator, + description: BatteryNotesSensorEntityDescription, + device_id: str, + unique_id: str, + ) -> None: + """Initialize the sensor.""" + super().__init__(coordinator) + self._attr_device_class = description.device_class + self._attr_has_entity_name = True + self._attr_unique_id = unique_id + self._device_id = device_id + self.entity_description = description + self._native_value = None + + self._set_native_value(log_on_error=False) + + device_registry = dr.async_get(hass) + + if device_id and (device := device_registry.async_get(device_id)): + self._attr_device_info = DeviceInfo( + connections=device.connections, + identifiers=device.identifiers, + ) + + def _set_native_value(self, log_on_error=True): + device_entry = self.coordinator.store.async_get_device(self._device_id) + if device_entry: + if LAST_REPLACED in device_entry: + last_replaced_date = datetime.fromisoformat( + str(device_entry[LAST_REPLACED]) + "+00:00" + ) + self._native_value = last_replaced_date + + return True + return False + + # async def async_added_to_hass(self) -> None: + # """Handle added to Hass.""" + # await super().async_added_to_hass() + + # self.async_on_remove( + # async_track_state_change_event( + # self.hass, + # [self._attr_unique_id], + # self._async_battery_note_state_replaced_listener, + # ) + # ) + + # # Update entity options + # registry = er.async_get(self.hass) + # if registry.async_get(self.entity_id) is not None: + # registry.async_update_entity_options( + # self.entity_id, + # DOMAIN, + # {"entity_id": self._attr_unique_id}, + # ) + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + + device_entry = self.coordinator.store.async_get_device(self._device_id) + if device_entry: + if LAST_REPLACED in device_entry: + last_replaced_date = datetime.fromisoformat( + str(device_entry[LAST_REPLACED]) + "+00:00" + ) + self._native_value = last_replaced_date + + self.async_write_ha_state() + + @property + def native_value(self) -> datetime | None: + """Return the native value of the sensor.""" + return self._native_value diff --git a/custom_components/battery_notes/services.yaml b/custom_components/battery_notes/services.yaml new file mode 100644 index 00000000..d3f142da --- /dev/null +++ b/custom_components/battery_notes/services.yaml @@ -0,0 +1,13 @@ + +set_battery_replaced: + name: Set battery replaced + description: "Set the battery last replaced to now." + fields: + device_id: + name: Device + description: Device that has had it's battery replaced. + required: true + selector: + device: + filter: + - integration: battery_notes diff --git a/custom_components/battery_notes/store.py b/custom_components/battery_notes/store.py new file mode 100644 index 00000000..e9dee82d --- /dev/null +++ b/custom_components/battery_notes/store.py @@ -0,0 +1,150 @@ +"""Data store for battery_notes.""" +from __future__ import annotations + +import logging +import attr +from collections import OrderedDict +from collections.abc import MutableMapping +from typing import cast +from datetime import datetime + +from homeassistant.core import (callback, HomeAssistant) +from homeassistant.loader import bind_hass +from homeassistant.helpers.storage import Store + +from .const import ( + DOMAIN, +) + +_LOGGER = logging.getLogger(__name__) + +DATA_REGISTRY = f"{DOMAIN}_storage" +STORAGE_KEY = f"{DOMAIN}.storage" +STORAGE_VERSION_MAJOR = 1 +STORAGE_VERSION_MINOR = 0 +SAVE_DELAY = 10 + +@attr.s(slots=True, frozen=True) +class DeviceEntry: + """Battery Notes storage Entry.""" + + device_id = attr.ib(type=str, default=None) + battery_last_replaced = attr.ib(type=datetime, default=None) + +class MigratableStore(Store): + """Holds battery notes data.""" + + async def _async_migrate_func(self, old_major_version: int, old_minor_version: int, data: dict): + + # if old_major_version == 1: + # Do nothing for now + + return data + + +class BatteryNotesStorage: + """Class to hold battery notes data.""" + + def __init__(self, hass: HomeAssistant) -> None: + """Initialize the storage.""" + self.hass = hass + self.devices: MutableMapping[str, DeviceEntry] = {} + self._store = MigratableStore(hass, STORAGE_VERSION_MAJOR, STORAGE_KEY, minor_version=STORAGE_VERSION_MINOR) + + async def async_load(self) -> None: + """Load the registry of schedule entries.""" + data = await self._store.async_load() + devices: OrderedDict[str, DeviceEntry] = OrderedDict() + + if data is not None: + if "devices" in data: + for device in data["devices"]: + devices[device["device_id"]] = DeviceEntry(**device) + + self.devices = devices + + @callback + def async_schedule_save(self) -> None: + """Schedule saving the registry.""" + self._store.async_delay_save(self._data_to_save, SAVE_DELAY) + + async def async_save(self) -> None: + """Save the registry.""" + await self._store.async_save(self._data_to_save()) + + @callback + def _data_to_save(self) -> dict: + """Return data for the registry to store in a file.""" + store_data = {} + + store_data["devices"] = [ + attr.asdict(entry) for entry in self.devices.values() + ] + + return store_data + + async def async_delete(self): + """Delete data.""" + _LOGGER.warning("Removing battery notes data!") + await self._store.async_remove() + self.devices = {} + await self.async_factory_default() + + + @callback + def async_get_device(self, device_id) -> DeviceEntry: + """Get an existing DeviceEntry by id.""" + res = self.devices.get(device_id) + return attr.asdict(res) if res else None + + @callback + def async_get_devices(self): + """Get an existing DeviceEntry by id.""" + res = {} + for (key, val) in self.devices.items(): + res[key] = attr.asdict(val) + return res + + @callback + def async_create_device(self, device_id: str, data: dict) -> DeviceEntry: + """Create a new DeviceEntry.""" + if device_id in self.devices: + return False + new_device = DeviceEntry(**data, device_id=device_id) + self.devices[device_id] = new_device + self.async_schedule_save() + return new_device + + @callback + def async_delete_device(self, device_id: str) -> None: + """Delete DeviceEntry.""" + if device_id in self.devices: + del self.devices[device_id] + self.async_schedule_save() + return True + return False + + @callback + def async_update_device(self, device_id: str, changes: dict) -> DeviceEntry: + """Update existing DeviceEntry.""" + old = self.devices[device_id] + new = self.devices[device_id] = attr.evolve(old, **changes) + self.async_schedule_save() + return new + + +@bind_hass +async def async_get_registry(hass: HomeAssistant) -> BatteryNotesStorage: + """Return battery notes storage instance.""" + task = hass.data.get(DATA_REGISTRY) + + if task is None: + + async def _load_reg() -> BatteryNotesStorage: + registry = BatteryNotesStorage(hass) + await registry.async_load() + return registry + + task = hass.data[DATA_REGISTRY] = hass.async_create_task(_load_reg()) + + return cast(BatteryNotesStorage, await task) diff --git a/custom_components/powercalc/__init__.py b/custom_components/powercalc/__init__.py index 98d8254b..1a5cedc3 100644 --- a/custom_components/powercalc/__init__.py +++ b/custom_components/powercalc/__init__.py @@ -55,6 +55,7 @@ DATA_CALCULATOR_FACTORY, DATA_CONFIGURED_ENTITIES, DATA_DISCOVERED_ENTITIES, + DATA_DISCOVERY_MANAGER, DATA_DOMAIN_ENTITIES, DATA_STANDBY_POWER_SENSORS, DATA_USED_UNIQUE_IDS, @@ -209,8 +210,10 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: CONF_UTILITY_METER_TYPES: DEFAULT_UTILITY_METER_TYPES, } + discovery_manager = DiscoveryManager(hass, config) hass.data[DOMAIN] = { DATA_CALCULATOR_FACTORY: PowerCalculatorStrategyFactory(hass), + DATA_DISCOVERY_MANAGER: DiscoveryManager(hass, config), DOMAIN_CONFIG: domain_config, DATA_CONFIGURED_ENTITIES: {}, DATA_DOMAIN_ENTITIES: {}, @@ -222,7 +225,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: await hass.async_add_executor_job(register_services, hass) if domain_config.get(CONF_ENABLE_AUTODISCOVERY): - discovery_manager = DiscoveryManager(hass, config) await discovery_manager.start_discovery() await setup_yaml_sensors(hass, config, domain_config) diff --git a/custom_components/powercalc/const.py b/custom_components/powercalc/const.py index f279bb34..9e7402ab 100644 --- a/custom_components/powercalc/const.py +++ b/custom_components/powercalc/const.py @@ -19,13 +19,14 @@ STATE_UNAVAILABLE, ) -MIN_HA_VERSION = "2022.11" +MIN_HA_VERSION = "2023.1" DOMAIN = "powercalc" DOMAIN_CONFIG = "config" DATA_CALCULATOR_FACTORY = "calculator_factory" DATA_CONFIGURED_ENTITIES = "configured_entities" +DATA_DISCOVERY_MANAGER = "discovery_manager" DATA_DISCOVERED_ENTITIES = "discovered_entities" DATA_DOMAIN_ENTITIES = "domain_entities" DATA_USED_UNIQUE_IDS = "used_unique_ids" diff --git a/custom_components/powercalc/discovery.py b/custom_components/powercalc/discovery.py index 3933372b..8494613f 100644 --- a/custom_components/powercalc/discovery.py +++ b/custom_components/powercalc/discovery.py @@ -109,6 +109,7 @@ class DiscoveryManager: def __init__(self, hass: HomeAssistant, ha_config: ConfigType) -> None: self.hass = hass self.ha_config = ha_config + self.power_profiles: dict[str, PowerProfile | None] = {} self.manually_configured_entities: list[str] | None = None async def start_discovery(self) -> None: @@ -116,59 +117,86 @@ async def start_discovery(self) -> None: _LOGGER.debug("Start auto discovering entities") entity_registry = er.async_get(self.hass) for entity_entry in list(entity_registry.entities.values()): - if not self.should_process_entity(entity_entry): - continue - model_info = await autodiscover_model(self.hass, entity_entry) - if not model_info or not model_info.manufacturer or not model_info.model: + if not model_info: continue - + power_profile = await self.get_power_profile( + entity_entry.entity_id, model_info, + ) source_entity = await create_source_entity( entity_entry.entity_id, self.hass, ) - - if ( - model_info.manufacturer == MANUFACTURER_WLED - and entity_entry.domain == LIGHT_DOMAIN - and not re.search( - "master|segment", - str(entity_entry.original_name), - flags=re.IGNORECASE, - ) - ): - self._init_entity_discovery( - source_entity, - power_profile=None, - extra_discovery_data={ - CONF_MODE: CalculationStrategy.WLED, - CONF_MANUFACTURER: model_info.manufacturer, - CONF_MODEL: model_info.model, - }, - ) + if not await self.is_entity_supported(entity_entry): continue - try: - power_profile = await get_power_profile( - self.hass, - {}, - model_info=model_info, - ) - except ModelNotSupportedError: - _LOGGER.debug( - "%s: Model not found in library, skipping discovery", - entity_entry.entity_id, - ) - continue + self._init_entity_discovery(source_entity, power_profile, {}) + + _LOGGER.debug("Done auto discovering entities") + + async def get_power_profile( + self, entity_id: str, model_info: ModelInfo, + ) -> PowerProfile | None: + if entity_id in self.power_profiles: + return self.power_profiles[entity_id] + + try: + self.power_profiles[entity_id] = await get_power_profile( + self.hass, + {}, + model_info=model_info, + ) + return self.power_profiles[entity_id] + except ModelNotSupportedError: + _LOGGER.debug( + "%s: Model not found in library, skipping discovery", + entity_id, + ) + return None + + async def is_entity_supported(self, entity_entry: er.RegistryEntry) -> bool: + if not self.should_process_entity(entity_entry): + return False - if power_profile and not power_profile.is_entity_domain_supported( + model_info = await autodiscover_model(self.hass, entity_entry) + if not model_info or not model_info.manufacturer or not model_info.model: + return False + + source_entity = await create_source_entity( + entity_entry.entity_id, + self.hass, + ) + + if ( + model_info.manufacturer == MANUFACTURER_WLED + and entity_entry.domain == LIGHT_DOMAIN + and not re.search( + "master|segment", + str(entity_entry.original_name), + flags=re.IGNORECASE, + ) + ): + self._init_entity_discovery( source_entity, - ): - continue + power_profile=None, + extra_discovery_data={ + CONF_MODE: CalculationStrategy.WLED, + CONF_MANUFACTURER: model_info.manufacturer, + CONF_MODEL: model_info.model, + }, + ) + return False - self._init_entity_discovery(source_entity, power_profile, {}) + power_profile = await self.get_power_profile(entity_entry.entity_id, model_info) + if not power_profile: + return False - _LOGGER.debug("Done auto discovering entities") + if power_profile and not power_profile.is_entity_domain_supported( + source_entity, + ): + return False + + return True def should_process_entity(self, entity_entry: er.RegistryEntry) -> bool: """Do some validations on the registry entry to see if it qualifies for discovery.""" diff --git a/custom_components/powercalc/group_include/include.py b/custom_components/powercalc/group_include/include.py index 6a15d587..2d7c3546 100644 --- a/custom_components/powercalc/group_include/include.py +++ b/custom_components/powercalc/group_include/include.py @@ -7,9 +7,11 @@ from homeassistant.helpers import entity_registry from homeassistant.helpers.entity import Entity +from custom_components.powercalc import DiscoveryManager from custom_components.powercalc.const import ( CONF_FILTER, DATA_CONFIGURED_ENTITIES, + DATA_DISCOVERY_MANAGER, DOMAIN, ENTRY_DATA_ENERGY_ENTITY, ENTRY_DATA_POWER_ENTITY, @@ -26,11 +28,16 @@ _LOGGER = logging.getLogger(__name__) -def resolve_include_entities(hass: HomeAssistant, include_config: dict) -> list[Entity]: +async def resolve_include_entities( + hass: HomeAssistant, include_config: dict, +) -> tuple[list[Entity], list[str]]: """ " For a given include configuration fetch all power and energy sensors from the HA instance """ + discovery_manager: DiscoveryManager = hass.data[DOMAIN][DATA_DISCOVERY_MANAGER] + resolved_entities: list[Entity] = [] + discoverable_entities: list[str] = [] source_entities = resolve_include_source_entities(hass, include_config) if _LOGGER.isEnabledFor(logging.DEBUG): # pragma: no cover _LOGGER.debug( @@ -53,7 +60,10 @@ def resolve_include_entities(hass: HomeAssistant, include_config: dict) -> list[ elif device_class == SensorDeviceClass.ENERGY: resolved_entities.append(RealEnergySensor(source_entity.entity_id)) - return resolved_entities + if not resolved_entities and source_entity and await discovery_manager.is_entity_supported(source_entity): + discoverable_entities.append(source_entity.entity_id) + + return resolved_entities, discoverable_entities def find_powercalc_entities_by_source_entity( diff --git a/custom_components/powercalc/manifest.json b/custom_components/powercalc/manifest.json index 4728f90a..0d655ca7 100644 --- a/custom_components/powercalc/manifest.json +++ b/custom_components/powercalc/manifest.json @@ -22,5 +22,5 @@ "requirements": [ "numpy>=1.21.1" ], - "version": "v1.9.11" + "version": "v1.9.13" } \ No newline at end of file diff --git a/custom_components/powercalc/sensor.py b/custom_components/powercalc/sensor.py index 9cd99673..a85f7481 100644 --- a/custom_components/powercalc/sensor.py +++ b/custom_components/powercalc/sensor.py @@ -579,7 +579,7 @@ def convert_config_entry_to_sensor_config(config_entry: ConfigEntry) -> ConfigTy return sensor_config -async def create_sensors( +async def create_sensors( # noqa: C901 hass: HomeAssistant, config: ConfigType, discovery_info: DiscoveryInfoType | None = None, @@ -649,7 +649,10 @@ async def create_sensors( # Automatically add a bunch of entities by area or evaluating template if CONF_INCLUDE in config: - entities_to_add.existing.extend(resolve_include_entities(hass, config.get(CONF_INCLUDE))) # type: ignore + found_entities, discoverable_entities = await resolve_include_entities(hass, config.get(CONF_INCLUDE)) # type: ignore + entities_to_add.existing.extend(found_entities) + for entity_id in discoverable_entities: + sensor_configs.update({entity_id: {CONF_ENTITY_ID: entity_id}}) # Create sensors for each entity for sensor_config in sensor_configs.values(): diff --git a/custom_components/powercalc/sensors/energy.py b/custom_components/powercalc/sensors/energy.py index 404219f6..b65519cd 100644 --- a/custom_components/powercalc/sensors/energy.py +++ b/custom_components/powercalc/sensors/energy.py @@ -10,7 +10,6 @@ from homeassistant.const import ( ATTR_UNIT_OF_MEASUREMENT, CONF_NAME, - TIME_HOURS, UnitOfEnergy, UnitOfPower, UnitOfTime, @@ -130,7 +129,7 @@ async def create_energy_sensor( name=name, round_digits=sensor_config.get(CONF_ENERGY_SENSOR_PRECISION), # type: ignore unit_prefix=unit_prefix, - unit_time=TIME_HOURS, # type: ignore + unit_time=UnitOfTime.HOURS, integration_method=sensor_config.get(CONF_ENERGY_INTEGRATION_METHOD) or DEFAULT_ENERGY_INTEGRATION_METHOD, powercalc_source_entity=source_entity.entity_id, diff --git a/custom_components/powercalc/sensors/group.py b/custom_components/powercalc/sensors/group.py index 75ad351f..e86bd365 100644 --- a/custom_components/powercalc/sensors/group.py +++ b/custom_components/powercalc/sensors/group.py @@ -176,7 +176,7 @@ async def create_group_sensors_from_config_entry( sensor_config[CONF_UNIQUE_ID] = entry.entry_id power_sensor_ids: set[str] = set( - resolve_entity_ids_recursively(hass, entry, SensorDeviceClass.POWER), + await resolve_entity_ids_recursively(hass, entry, SensorDeviceClass.POWER), ) if power_sensor_ids: power_sensor = create_grouped_power_sensor( @@ -188,7 +188,7 @@ async def create_group_sensors_from_config_entry( group_sensors.append(power_sensor) energy_sensor_ids: set[str] = set( - resolve_entity_ids_recursively(hass, entry, SensorDeviceClass.ENERGY), + await resolve_entity_ids_recursively(hass, entry, SensorDeviceClass.ENERGY), ) if energy_sensor_ids: energy_sensor = create_grouped_energy_sensor( @@ -323,8 +323,7 @@ async def add_to_associated_group( return group_entry -@callback -def resolve_entity_ids_recursively( +async def resolve_entity_ids_recursively( hass: HomeAssistant, entry: ConfigEntry, device_class: SensorDeviceClass, @@ -360,12 +359,13 @@ def resolve_entity_ids_recursively( # Include entities from defined areas if CONF_AREA in entry.data: + resolved_area_entities, _ = await resolve_include_entities( + hass, + {CONF_AREA: entry.data[CONF_AREA]}, + ) area_entities = [ entity.entity_id - for entity in resolve_include_entities( - hass, - {CONF_AREA: entry.data[CONF_AREA]}, - ) + for entity in resolved_area_entities if isinstance( entity, PowerSensor @@ -385,7 +385,9 @@ def resolve_entity_ids_recursively( if subgroup_entry is None: _LOGGER.error("Subgroup config entry not found: %s", subgroup_entry_id) continue - resolve_entity_ids_recursively(hass, subgroup_entry, device_class, resolved_ids) + await resolve_entity_ids_recursively( + hass, subgroup_entry, device_class, resolved_ids, + ) return resolved_ids @@ -567,7 +569,9 @@ def on_state_change(self, _: Any) -> None: # noqa if state and state.state not in [STATE_UNKNOWN, STATE_UNAVAILABLE] ] if not available_states: - if self._sensor_config.get(CONF_IGNORE_UNAVAILABLE_STATE) and isinstance(self, GroupedPowerSensor): + if self._sensor_config.get(CONF_IGNORE_UNAVAILABLE_STATE) and isinstance( + self, GroupedPowerSensor, + ): self._attr_native_value = 0 self._attr_available = True else: diff --git a/python_scripts/shellies_discovery_gen2.py b/python_scripts/shellies_discovery_gen2.py index 8ecea393..c2a568d4 100644 --- a/python_scripts/shellies_discovery_gen2.py +++ b/python_scripts/shellies_discovery_gen2.py @@ -1,5 +1,5 @@ """This script adds MQTT discovery support for Shellies Gen2 devices.""" -VERSION = "2.24.0" +VERSION = "2.24.1" ATTR_BATTERY_POWERED = "battery_powered" ATTR_BINARY_SENSORS = "binary_sensors" @@ -37,6 +37,7 @@ ATTR_TEMPERATURE_STEP = "temperature_step" ATTR_THERMOSTATS = "thermostats" ATTR_UPDATES = "updates" +ATTR_WAKEUP_PERIOD = "wakeup_period" BUTTON_MUTE_ALARM = "mute_alarm" BUTTON_RESTART = "restart" @@ -1441,6 +1442,7 @@ SENSOR_WIFI_SIGNAL: DESCRIPTION_SLEEPING_SENSOR_WIFI_SIGNAL, }, ATTR_MIN_FIRMWARE_DATE: 20230803, + ATTR_WAKEUP_PERIOD: 7200, }, MODEL_PLUS_I4: { ATTR_NAME: "Shelly Plus I4", @@ -1652,6 +1654,7 @@ SENSOR_WIFI_SIGNAL: DESCRIPTION_SLEEPING_SENSOR_WIFI_SIGNAL, }, ATTR_MIN_FIRMWARE_DATE: 20230803, + ATTR_WAKEUP_PERIOD: 86400, }, MODEL_PLUS_WALL_DIMMER: { ATTR_NAME: "Shelly Plus Wall Dimmer", @@ -3002,7 +3005,6 @@ def remove_old_script_versions(): device_name = device_config["sys"]["device"].get(ATTR_NAME) device_url = f"http://{device_id}.local/" -wakeup_period = device_config["sys"].get("sleep", {}).get("wakeup_period", 0) if not device_name: device_name = SUPPORTED_MODELS[model][ATTR_NAME] @@ -3029,6 +3031,7 @@ def remove_old_script_versions(): KEY_SUPPORT_URL: "https://github.com/bieniu/ha-shellies-discovery-gen2", } +wakeup_period = SUPPORTED_MODELS[model].get(ATTR_WAKEUP_PERIOD, 0) if wakeup_period > 0: availability = None expire_after = wakeup_period * 1.2