From 072615187a351c08c3a245491d347635d3d42e97 Mon Sep 17 00:00:00 2001 From: Andrew Jackson Date: Tue, 26 Dec 2023 13:21:00 +0000 Subject: [PATCH] Battery Replaced (#147) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * An initial button * Bump ruff from 0.0.287 to 0.0.288 Bumps [ruff](https://github.com/astral-sh/ruff) from 0.0.287 to 0.0.288. - [Release notes](https://github.com/astral-sh/ruff/releases) - [Changelog](https://github.com/astral-sh/ruff/blob/main/BREAKING_CHANGES.md) - [Commits](https://github.com/astral-sh/ruff/compare/v0.0.287...v0.0.288) --- updated-dependencies: - dependency-name: ruff dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] * Update README.md * Made the button appear * Start of multiple sensors * Remove unused const * Improving sensors * Add entity translations * Tidy up entity naming * Formatting * Update minimum requirements * Work on battery changed * Got a datetime for last changed * Work on last changed sensor * Trying things * Work on last changed time * Work on last changed * Manual merge from main refactor * WIP * WIP * Update library.json (#48) * Update library.json IKEA - TRADFRI motion sensor (E1525/E1745) - Edit to 2X CR2032 SONOFF - Temperature and humidity sensor (SNZB-02) - Edit to CR2450 IKEA - TRADFRI ON/OFF switch (E1743) - Added Configuration Saswell - Thermostatic radiator valve (SEA801-Zigbee/SEA802-Zigbee) - Added Configuration Siterwell - Radiator valve with thermostat (GS361A-H04) - Added Configuration * Update library.json Add comma --------- Co-authored-by: Andrew Jackson * Add 2 Devices (#50) * Add 2 Devices These are how two of my Aqara Zigbee devices showed up. * Update library.json Remove quantity where just 1 --------- Co-authored-by: Andrew Jackson * Update manifest.json Version bump * Add discovery screenshot * Update README.md * Update discovery screenshot * WIP * WIP * remove duplicate entries for LUMI devices (#121) * Update library.json (#122) Change HEIMAN smoke detectors, the batteries are replaceable. * Update library.json (#123) * Fix Philips Hue Dimmer switch v2 manufacturer name * Add Roborock S7 * Update library.json * Update library.json --------- Co-authored-by: Andrew Jackson * Create translation to Hungarian (#125) * Added device details (Xiaomi SRTS-A01, Sonoff TRVZB) (#127) * Added "SONOFF TRVZB" * Added "Xiaomi SRTS-A01" * Fixed formatting * Update library.json --------- Co-authored-by: Andrew Jackson * Update library.json (#128) Add Ecolink TILT-ZWAVE2.5-ECO tilt sensor * Add Drayton Wiser iTRV & RoomStat (#129) * Updated readme and additions to library * Update README.md * Add multiple devices to library (#130) * Add multiple devices to library * Update library.json --------- Co-authored-by: Andrew Jackson * Update README.md * Update validate.yml Ignore readme changes * WIP * WIP * Create battery_changed service * Change the service to use the last changed entity * WIP * WIP * WIP * Change back to device id * WIP * WIP * WIP * WIP * WIP * WIP * Button press update store * Docs * Sensor update on button & service * WIP * Tidy up storage on device remove * Check device exists on service call * Lint fixes * Update HA version to test service translation * Add placeholders for new translation requirements * Tidy up code * Add new fields to Danish translation * Remove date from service * Rename to battery replaced * Update service description * Update library * Fix lint issues & version bump --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: P-v-D <88889180+P-v-D@users.noreply.github.com> Co-authored-by: Patrick <4002194+askpatrickw@users.noreply.github.com> Co-authored-by: Wil T Co-authored-by: Edouard Kleinhans Co-authored-by: Jan Čermák Co-authored-by: bekesizl <41523450+bekesizl@users.noreply.github.com> Co-authored-by: Janek <6506725+jkrgr0@users.noreply.github.com> Co-authored-by: Christian Allred <13487734+cgallred@users.noreply.github.com> Co-authored-by: Thomas Dietrich --- .devcontainer.json | 2 - README.md | 9 +- custom_components/battery_notes/__init__.py | 83 ++++++- custom_components/battery_notes/button.py | 229 ++++++++++++++++++ custom_components/battery_notes/const.py | 19 ++ .../battery_notes/coordinator.py | 46 ++++ custom_components/battery_notes/library.py | 6 +- 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 ++++++++++++ .../battery_notes/translations/da.json | 22 +- .../battery_notes/translations/de.json | 22 +- .../battery_notes/translations/en.json | 20 ++ .../battery_notes/translations/hu.json | 22 +- .../battery_notes/translations/sk.json | 22 +- requirements.txt | 2 +- 17 files changed, 770 insertions(+), 27 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/.devcontainer.json b/.devcontainer.json index 18847cf7f..610036707 100644 --- a/.devcontainer.json +++ b/.devcontainer.json @@ -24,8 +24,6 @@ "editor.tabSize": 4, "python.pythonPath": "/usr/bin/python3", "python.analysis.autoSearchPaths": false, - "python.linting.pylintEnabled": true, - "python.linting.enabled": true, "python.formatting.provider": "black", "python.formatting.blackPath": "/usr/local/py-utils/bin/black", "editor.formatOnPaste": false, diff --git a/README.md b/README.md index 4fb859f5b..19c151c95 100644 --- a/README.md +++ b/README.md @@ -19,9 +19,12 @@ Integration to add battery notes to a device, with automatic discovery via a gro **This integration will set up the following platforms.** -Platform | Description --- | -- -`sensor` | Show battery type. +Platform | Name | Description +-- | -- | -- +`sensor` | Battery Type | Show battery type. +`sensor` | Battery last replaced | Date & Time the battery was last replaced. +`button` | Battery replaced | Update Battery last replaced to now. +`service` | Set battery replaced | Update Battery last replaced to now. ## Installation diff --git a/custom_components/battery_notes/__init__.py b/custom_components/battery_notes/__init__.py index 0252271a2..382f79030 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, @@ -32,6 +37,10 @@ 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" @@ -53,6 +62,7 @@ extra=vol.ALLOW_EXTRA, ) +ATTR_SERVICE_DEVICE_ID = "device_id" async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Integration setup.""" @@ -75,12 +85,17 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: 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() @@ -100,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.""" @@ -114,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 000000000..01860019e --- /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/const.py b/custom_components/battery_notes/const.py index ed29a9d21..4facd5ce5 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" @@ -36,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 000000000..0664042f8 --- /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/library.py b/custom_components/battery_notes/library.py index 59ae1aa93..48be8bdc9 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( diff --git a/custom_components/battery_notes/manifest.json b/custom_components/battery_notes/manifest.json index 235d761ba..f8840c8f2 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.0" } \ No newline at end of file diff --git a/custom_components/battery_notes/sensor.py b/custom_components/battery_notes/sensor.py index b30352c2f..9ef1602ba 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 000000000..d3f142da3 --- /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 000000000..e9dee82d5 --- /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/battery_notes/translations/da.json b/custom_components/battery_notes/translations/da.json index e55582c3c..53987e33f 100644 --- a/custom_components/battery_notes/translations/da.json +++ b/custom_components/battery_notes/translations/da.json @@ -45,10 +45,30 @@ } }, "entity": { + "button": { + "battery_replaced": { + "name": "Battery replaced" + } + }, "sensor": { "battery_type": { "name": "Batteri type" + }, + "battery_last_replaced": { + "name": "Battery last replaced" } } + }, + "services": { + "set_battery_replaced": { + "description": "Set the battery last replaced to now.", + "fields": { + "device_id": { + "description": "Device that has had it's battery replaced.", + "name": "Device" + } + }, + "name": "Set battery replaced" + } } -} +} \ No newline at end of file diff --git a/custom_components/battery_notes/translations/de.json b/custom_components/battery_notes/translations/de.json index 4fe8c06c0..26f27d559 100644 --- a/custom_components/battery_notes/translations/de.json +++ b/custom_components/battery_notes/translations/de.json @@ -45,10 +45,30 @@ } }, "entity": { + "button": { + "battery_replaced": { + "name": "Battery replaced" + } + }, "sensor": { "battery_type": { "name": "Batterieart" + }, + "battery_last_replaced": { + "name": "Battery last replaced" } } + }, + "services": { + "set_battery_replaced": { + "description": "Set the battery last replaced to now.", + "fields": { + "device_id": { + "description": "Device that has had it's battery replaced.", + "name": "Device" + } + }, + "name": "Set battery replaced" + } } -} +} \ No newline at end of file diff --git a/custom_components/battery_notes/translations/en.json b/custom_components/battery_notes/translations/en.json index 4492dfa14..095c6f561 100644 --- a/custom_components/battery_notes/translations/en.json +++ b/custom_components/battery_notes/translations/en.json @@ -45,10 +45,30 @@ } }, "entity": { + "button": { + "battery_replaced": { + "name": "Battery replaced" + } + }, "sensor": { "battery_type": { "name": "Battery type" + }, + "battery_last_replaced": { + "name": "Battery last replaced" } } + }, + "services": { + "set_battery_replaced": { + "description": "Set the battery last replaced to now.", + "fields": { + "device_id": { + "description": "Device that has had it's battery replaced.", + "name": "Device" + } + }, + "name": "Set battery replaced" + } } } \ No newline at end of file diff --git a/custom_components/battery_notes/translations/hu.json b/custom_components/battery_notes/translations/hu.json index 8a818f494..4100f7bd9 100644 --- a/custom_components/battery_notes/translations/hu.json +++ b/custom_components/battery_notes/translations/hu.json @@ -45,10 +45,30 @@ } }, "entity": { + "button": { + "battery_replaced": { + "name": "Battery replaced" + } + }, "sensor": { "battery_type": { "name": "Elem típus" + }, + "battery_last_replaced": { + "name": "Battery last replaced" } } + }, + "services": { + "set_battery_replaced": { + "description": "Set the battery last replaced to now.", + "fields": { + "device_id": { + "description": "Device that has had it's battery replaced.", + "name": "Device" + } + }, + "name": "Set battery replaced" + } } -} +} \ No newline at end of file diff --git a/custom_components/battery_notes/translations/sk.json b/custom_components/battery_notes/translations/sk.json index 12a9cc681..b8f0a9d40 100644 --- a/custom_components/battery_notes/translations/sk.json +++ b/custom_components/battery_notes/translations/sk.json @@ -45,10 +45,30 @@ } }, "entity": { + "button": { + "battery_replaced": { + "name": "Battery replaced" + } + }, "sensor": { "battery_type": { "name": "Typ batérie" + }, + "battery_last_replaced": { + "name": "Battery last replaced" } } + }, + "services": { + "set_battery_replaced": { + "description": "Set the battery last replaced to now.", + "fields": { + "device_id": { + "description": "Device that has had it's battery replaced.", + "name": "Device" + } + }, + "name": "Set battery replaced" + } } -} +} \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index c2816ac2b..69e428100 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ colorlog==6.8.0 -homeassistant==2023.7.0 +homeassistant==2023.8.0 pip>=21.0,<23.4 ruff==0.1.9