From 09729e8c46f60d3e9dc31dce2e9d3c1eef0aab46 Mon Sep 17 00:00:00 2001 From: Daniel Trnka Date: Sun, 24 Sep 2023 22:50:13 +0200 Subject: [PATCH] Add Mysensors battery sensor (#100749) * Move child related stuff to MySensorsChildEntity * Dispatch signal for newly discovered MySensors node * Create battery entity for each MySensors node * Removed ATTR_BATTERY_LEVEL attribute from each node sensor Attribute is redundant with newly introduced battery sensor entity * Apply suggestions from code review --------- Co-authored-by: Martin Hjelmare --- .../components/mysensors/__init__.py | 18 +- .../components/mysensors/binary_sensor.py | 2 +- homeassistant/components/mysensors/climate.py | 2 +- homeassistant/components/mysensors/const.py | 10 + homeassistant/components/mysensors/cover.py | 2 +- homeassistant/components/mysensors/device.py | 237 +++++++++--------- .../components/mysensors/device_tracker.py | 4 +- homeassistant/components/mysensors/gateway.py | 2 + homeassistant/components/mysensors/handler.py | 17 +- homeassistant/components/mysensors/helpers.py | 24 ++ homeassistant/components/mysensors/light.py | 6 +- homeassistant/components/mysensors/remote.py | 4 +- homeassistant/components/mysensors/sensor.py | 59 ++++- homeassistant/components/mysensors/switch.py | 4 +- homeassistant/components/mysensors/text.py | 4 +- tests/components/mysensors/conftest.py | 16 ++ .../fixtures/battery_sensor_state.json | 12 + tests/components/mysensors/test_sensor.py | 19 ++ 18 files changed, 303 insertions(+), 139 deletions(-) create mode 100644 tests/components/mysensors/fixtures/battery_sensor_state.json diff --git a/homeassistant/components/mysensors/__init__.py b/homeassistant/components/mysensors/__init__.py index 5b8154e17aa..a3f52cd28ab 100644 --- a/homeassistant/components/mysensors/__init__.py +++ b/homeassistant/components/mysensors/__init__.py @@ -15,6 +15,7 @@ from homeassistant.helpers.device_registry import DeviceEntry from .const import ( ATTR_DEVICES, DOMAIN, + MYSENSORS_DISCOVERED_NODES, MYSENSORS_GATEWAYS, MYSENSORS_ON_UNLOAD, PLATFORMS, @@ -22,7 +23,7 @@ from .const import ( DiscoveryInfo, SensorType, ) -from .device import MySensorsEntity, get_mysensors_devices +from .device import MySensorsChildEntity, get_mysensors_devices from .gateway import finish_setup, gw_stop, setup_gateway _LOGGER = logging.getLogger(__name__) @@ -72,6 +73,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data[DOMAIN].pop(key) del hass.data[DOMAIN][MYSENSORS_GATEWAYS][entry.entry_id] + hass.data[DOMAIN].pop(MYSENSORS_DISCOVERED_NODES.format(entry.entry_id), None) await gw_stop(hass, entry, gateway) return True @@ -91,6 +93,11 @@ async def async_remove_config_entry_device( gateway.sensors.pop(node_id, None) gateway.tasks.persistence.need_save = True + # remove node from discovered nodes + hass.data[DOMAIN].setdefault( + MYSENSORS_DISCOVERED_NODES.format(config_entry.entry_id), set() + ).remove(node_id) + return True @@ -99,12 +106,13 @@ def setup_mysensors_platform( hass: HomeAssistant, domain: Platform, # hass platform name discovery_info: DiscoveryInfo, - device_class: type[MySensorsEntity] | Mapping[SensorType, type[MySensorsEntity]], + device_class: type[MySensorsChildEntity] + | Mapping[SensorType, type[MySensorsChildEntity]], device_args: ( None | tuple ) = None, # extra arguments that will be given to the entity constructor async_add_entities: Callable | None = None, -) -> list[MySensorsEntity] | None: +) -> list[MySensorsChildEntity] | None: """Set up a MySensors platform. Sets up a bunch of instances of a single platform that is supported by this @@ -118,10 +126,10 @@ def setup_mysensors_platform( """ if device_args is None: device_args = () - new_devices: list[MySensorsEntity] = [] + new_devices: list[MySensorsChildEntity] = [] new_dev_ids: list[DevId] = discovery_info[ATTR_DEVICES] for dev_id in new_dev_ids: - devices: dict[DevId, MySensorsEntity] = get_mysensors_devices(hass, domain) + devices: dict[DevId, MySensorsChildEntity] = get_mysensors_devices(hass, domain) if dev_id in devices: _LOGGER.debug( "Skipping setup of %s for platform %s as it already exists", diff --git a/homeassistant/components/mysensors/binary_sensor.py b/homeassistant/components/mysensors/binary_sensor.py index d8f4ec07cb2..2b4edd99221 100644 --- a/homeassistant/components/mysensors/binary_sensor.py +++ b/homeassistant/components/mysensors/binary_sensor.py @@ -95,7 +95,7 @@ async def async_setup_entry( ) -class MySensorsBinarySensor(mysensors.device.MySensorsEntity, BinarySensorEntity): +class MySensorsBinarySensor(mysensors.device.MySensorsChildEntity, BinarySensorEntity): """Representation of a MySensors binary sensor child node.""" entity_description: MySensorsBinarySensorDescription diff --git a/homeassistant/components/mysensors/climate.py b/homeassistant/components/mysensors/climate.py index d207d7ff550..e9d4502242e 100644 --- a/homeassistant/components/mysensors/climate.py +++ b/homeassistant/components/mysensors/climate.py @@ -66,7 +66,7 @@ async def async_setup_entry( ) -class MySensorsHVAC(mysensors.device.MySensorsEntity, ClimateEntity): +class MySensorsHVAC(mysensors.device.MySensorsChildEntity, ClimateEntity): """Representation of a MySensors HVAC.""" _attr_hvac_modes = OPERATION_LIST diff --git a/homeassistant/components/mysensors/const.py b/homeassistant/components/mysensors/const.py index 7f9326091fe..a5c82c32b55 100644 --- a/homeassistant/components/mysensors/const.py +++ b/homeassistant/components/mysensors/const.py @@ -8,6 +8,7 @@ from homeassistant.const import Platform ATTR_DEVICES: Final = "devices" ATTR_GATEWAY_ID: Final = "gateway_id" +ATTR_NODE_ID: Final = "node_id" CONF_BAUD_RATE: Final = "baud_rate" CONF_DEVICE: Final = "device" @@ -26,11 +27,13 @@ CONF_GATEWAY_TYPE_MQTT: ConfGatewayType = "MQTT" DOMAIN: Final = "mysensors" MYSENSORS_GATEWAY_START_TASK: str = "mysensors_gateway_start_task_{}" MYSENSORS_GATEWAYS: Final = "mysensors_gateways" +MYSENSORS_DISCOVERED_NODES: Final = "mysensors_discovered_nodes_{}" PLATFORM: Final = "platform" SCHEMA: Final = "schema" CHILD_CALLBACK: str = "mysensors_child_callback_{}_{}_{}_{}" NODE_CALLBACK: str = "mysensors_node_callback_{}_{}" MYSENSORS_DISCOVERY: str = "mysensors_discovery_{}_{}" +MYSENSORS_NODE_DISCOVERY: str = "mysensors_node_discovery" MYSENSORS_ON_UNLOAD: str = "mysensors_on_unload_{}" TYPE: Final = "type" UPDATE_DELAY: float = 0.1 @@ -43,6 +46,13 @@ class DiscoveryInfo(TypedDict): gateway_id: GatewayId +class NodeDiscoveryInfo(TypedDict): + """Represent discovered mysensors node.""" + + gateway_id: GatewayId + node_id: int + + SERVICE_SEND_IR_CODE: Final = "send_ir_code" SensorType = str diff --git a/homeassistant/components/mysensors/cover.py b/homeassistant/components/mysensors/cover.py index a1b2cb303ed..8be5f1f8620 100644 --- a/homeassistant/components/mysensors/cover.py +++ b/homeassistant/components/mysensors/cover.py @@ -54,7 +54,7 @@ async def async_setup_entry( ) -class MySensorsCover(mysensors.device.MySensorsEntity, CoverEntity): +class MySensorsCover(mysensors.device.MySensorsChildEntity, CoverEntity): """Representation of the value of a MySensors Cover child node.""" def get_cover_state(self) -> CoverState: diff --git a/homeassistant/components/mysensors/device.py b/homeassistant/components/mysensors/device.py index a89de3abf69..9e1d91c7cce 100644 --- a/homeassistant/components/mysensors/device.py +++ b/homeassistant/components/mysensors/device.py @@ -1,14 +1,14 @@ """Handle MySensors devices.""" from __future__ import annotations -from abc import ABC, abstractmethod +from abc import abstractmethod import logging from typing import Any from mysensors import BaseAsyncGateway, Sensor from mysensors.sensor import ChildSensor -from homeassistant.const import ATTR_BATTERY_LEVEL, STATE_OFF, STATE_ON, Platform +from homeassistant.const import STATE_OFF, STATE_ON, Platform from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.debounce import Debouncer from homeassistant.helpers.device_registry import DeviceInfo @@ -36,56 +36,24 @@ ATTR_HEARTBEAT = "heartbeat" MYSENSORS_PLATFORM_DEVICES = "mysensors_devices_{}" -class MySensorsDevice(ABC): +class MySensorNodeEntity(Entity): """Representation of a MySensors device.""" hass: HomeAssistant def __init__( - self, - gateway_id: GatewayId, - gateway: BaseAsyncGateway, - node_id: int, - child_id: int, - value_type: int, + self, gateway_id: GatewayId, gateway: BaseAsyncGateway, node_id: int ) -> None: - """Set up the MySensors device.""" + """Set up the MySensors node entity.""" self.gateway_id: GatewayId = gateway_id self.gateway: BaseAsyncGateway = gateway self.node_id: int = node_id - self.child_id: int = child_id - # value_type as int. string variant can be looked up in gateway consts - self.value_type: int = value_type - self.child_type = self._child.type - self._values: dict[int, Any] = {} self._debouncer: Debouncer | None = None - @property - def dev_id(self) -> DevId: - """Return the DevId of this device. - - It is used to route incoming MySensors messages to the correct device/entity. - """ - return self.gateway_id, self.node_id, self.child_id, self.value_type - - async def async_will_remove_from_hass(self) -> None: - """Remove this entity from home assistant.""" - for platform in PLATFORM_TYPES: - platform_str = MYSENSORS_PLATFORM_DEVICES.format(platform) - if platform_str in self.hass.data[DOMAIN]: - platform_dict = self.hass.data[DOMAIN][platform_str] - if self.dev_id in platform_dict: - del platform_dict[self.dev_id] - _LOGGER.debug("Deleted %s from platform %s", self.dev_id, platform) - @property def _node(self) -> Sensor: return self.gateway.sensors[self.node_id] - @property - def _child(self) -> ChildSensor: - return self._node.children[self.child_id] - @property def sketch_name(self) -> str: """Return the name of the sketch running on the whole node. @@ -110,11 +78,6 @@ class MySensorsDevice(ABC): """ return f"{self.sketch_name} {self.node_id}" - @property - def unique_id(self) -> str: - """Return a unique ID for use in home assistant.""" - return f"{self.gateway_id}-{self.node_id}-{self.child_id}-{self.value_type}" - @property def device_info(self) -> DeviceInfo: """Return the device info.""" @@ -125,6 +88,96 @@ class MySensorsDevice(ABC): sw_version=self.sketch_version, ) + @property + def extra_state_attributes(self) -> dict[str, Any]: + """Return device specific attributes.""" + node = self.gateway.sensors[self.node_id] + + return { + ATTR_HEARTBEAT: node.heartbeat, + ATTR_NODE_ID: self.node_id, + } + + @callback + @abstractmethod + def _async_update_callback(self) -> None: + """Update the device.""" + + async def async_update_callback(self) -> None: + """Update the device after delay.""" + if not self._debouncer: + self._debouncer = Debouncer( + self.hass, + _LOGGER, + cooldown=UPDATE_DELAY, + immediate=False, + function=self._async_update_callback, + ) + + await self._debouncer.async_call() + + async def async_added_to_hass(self) -> None: + """Register update callback.""" + self.async_on_remove( + async_dispatcher_connect( + self.hass, + NODE_CALLBACK.format(self.gateway_id, self.node_id), + self.async_update_callback, + ) + ) + self._async_update_callback() + + +def get_mysensors_devices( + hass: HomeAssistant, domain: Platform +) -> dict[DevId, MySensorsChildEntity]: + """Return MySensors devices for a hass platform name.""" + if MYSENSORS_PLATFORM_DEVICES.format(domain) not in hass.data[DOMAIN]: + hass.data[DOMAIN][MYSENSORS_PLATFORM_DEVICES.format(domain)] = {} + devices: dict[DevId, MySensorsChildEntity] = hass.data[DOMAIN][ + MYSENSORS_PLATFORM_DEVICES.format(domain) + ] + return devices + + +class MySensorsChildEntity(MySensorNodeEntity): + """Representation of a MySensors entity.""" + + _attr_should_poll = False + + def __init__( + self, + gateway_id: GatewayId, + gateway: BaseAsyncGateway, + node_id: int, + child_id: int, + value_type: int, + ) -> None: + """Set up the MySensors child entity.""" + super().__init__(gateway_id, gateway, node_id) + self.child_id: int = child_id + # value_type as int. string variant can be looked up in gateway consts + self.value_type: int = value_type + self.child_type = self._child.type + self._values: dict[int, Any] = {} + + @property + def dev_id(self) -> DevId: + """Return the DevId of this device. + + It is used to route incoming MySensors messages to the correct device/entity. + """ + return self.gateway_id, self.node_id, self.child_id, self.value_type + + @property + def _child(self) -> ChildSensor: + return self._node.children[self.child_id] + + @property + def unique_id(self) -> str: + """Return a unique ID for use in home assistant.""" + return f"{self.gateway_id}-{self.node_id}-{self.child_id}-{self.value_type}" + @property def name(self) -> str: """Return the name of this entity.""" @@ -134,21 +187,33 @@ class MySensorsDevice(ABC): return str(child.description) return f"{self.node_name} {self.child_id}" + async def async_will_remove_from_hass(self) -> None: + """Remove this entity from home assistant.""" + for platform in PLATFORM_TYPES: + platform_str = MYSENSORS_PLATFORM_DEVICES.format(platform) + if platform_str in self.hass.data[DOMAIN]: + platform_dict = self.hass.data[DOMAIN][platform_str] + if self.dev_id in platform_dict: + del platform_dict[self.dev_id] + _LOGGER.debug("Deleted %s from platform %s", self.dev_id, platform) + @property - def _extra_attributes(self) -> dict[str, Any]: - """Return device specific attributes.""" - node = self.gateway.sensors[self.node_id] - child = node.children[self.child_id] - attr = { - ATTR_BATTERY_LEVEL: node.battery_level, - ATTR_HEARTBEAT: node.heartbeat, - ATTR_CHILD_ID: self.child_id, - ATTR_DESCRIPTION: child.description, - ATTR_NODE_ID: self.node_id, - } + def available(self) -> bool: + """Return true if entity is available.""" + return self.value_type in self._values + + @property + def extra_state_attributes(self) -> dict[str, Any]: + """Return entity and device specific state attributes.""" + attr = super().extra_state_attributes + + assert self.platform.config_entry + attr[ATTR_DEVICE] = self.platform.config_entry.data[CONF_DEVICE] + + attr[ATTR_CHILD_ID] = self.child_id + attr[ATTR_DESCRIPTION] = self._child.description set_req = self.gateway.const.SetReq - for value_type, value in self._values.items(): attr[set_req(value_type).name] = value @@ -157,10 +222,8 @@ class MySensorsDevice(ABC): @callback def _async_update(self) -> None: """Update the controller with the latest value from a sensor.""" - node = self.gateway.sensors[self.node_id] - child = node.children[self.child_id] set_req = self.gateway.const.SetReq - for value_type, value in child.values.items(): + for value_type, value in self._child.values.items(): _LOGGER.debug( "Entity update: %s: value_type %s, value = %s", self.name, @@ -182,57 +245,6 @@ class MySensorsDevice(ABC): else: self._values[value_type] = value - @callback - @abstractmethod - def _async_update_callback(self) -> None: - """Update the device.""" - - async def async_update_callback(self) -> None: - """Update the device after delay.""" - if not self._debouncer: - self._debouncer = Debouncer( - self.hass, - _LOGGER, - cooldown=UPDATE_DELAY, - immediate=False, - function=self._async_update_callback, - ) - - await self._debouncer.async_call() - - -def get_mysensors_devices( - hass: HomeAssistant, domain: Platform -) -> dict[DevId, MySensorsEntity]: - """Return MySensors devices for a hass platform name.""" - if MYSENSORS_PLATFORM_DEVICES.format(domain) not in hass.data[DOMAIN]: - hass.data[DOMAIN][MYSENSORS_PLATFORM_DEVICES.format(domain)] = {} - devices: dict[DevId, MySensorsEntity] = hass.data[DOMAIN][ - MYSENSORS_PLATFORM_DEVICES.format(domain) - ] - return devices - - -class MySensorsEntity(MySensorsDevice, Entity): - """Representation of a MySensors entity.""" - - _attr_should_poll = False - - @property - def available(self) -> bool: - """Return true if entity is available.""" - return self.value_type in self._values - - @property - def extra_state_attributes(self) -> dict[str, Any]: - """Return entity specific state attributes.""" - attr = self._extra_attributes - - assert self.platform.config_entry - attr[ATTR_DEVICE] = self.platform.config_entry.data[CONF_DEVICE] - - return attr - @callback def _async_update_callback(self) -> None: """Update the entity.""" @@ -241,6 +253,7 @@ class MySensorsEntity(MySensorsDevice, Entity): async def async_added_to_hass(self) -> None: """Register update callback.""" + await super().async_added_to_hass() self.async_on_remove( async_dispatcher_connect( self.hass, @@ -248,11 +261,3 @@ class MySensorsEntity(MySensorsDevice, Entity): self.async_update_callback, ) ) - self.async_on_remove( - async_dispatcher_connect( - self.hass, - NODE_CALLBACK.format(self.gateway_id, self.node_id), - self.async_update_callback, - ) - ) - self._async_update() diff --git a/homeassistant/components/mysensors/device_tracker.py b/homeassistant/components/mysensors/device_tracker.py index 920645a229a..d56e9874560 100644 --- a/homeassistant/components/mysensors/device_tracker.py +++ b/homeassistant/components/mysensors/device_tracker.py @@ -10,7 +10,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import setup_mysensors_platform from .const import MYSENSORS_DISCOVERY, DiscoveryInfo -from .device import MySensorsEntity +from .device import MySensorsChildEntity from .helpers import on_unload @@ -43,7 +43,7 @@ async def async_setup_entry( ) -class MySensorsDeviceTracker(MySensorsEntity, TrackerEntity): +class MySensorsDeviceTracker(MySensorsChildEntity, TrackerEntity): """Represent a MySensors device tracker.""" _latitude: float | None = None diff --git a/homeassistant/components/mysensors/gateway.py b/homeassistant/components/mysensors/gateway.py index ce602e6266d..590ad41d6a2 100644 --- a/homeassistant/components/mysensors/gateway.py +++ b/homeassistant/components/mysensors/gateway.py @@ -42,6 +42,7 @@ from .const import ( ) from .handler import HANDLERS from .helpers import ( + discover_mysensors_node, discover_mysensors_platform, on_unload, validate_child, @@ -244,6 +245,7 @@ async def _discover_persistent_devices( for node_id in gateway.sensors: if not validate_node(gateway, node_id): continue + discover_mysensors_node(hass, entry.entry_id, node_id) node: Sensor = gateway.sensors[node_id] for child in node.children.values(): # child is of type ChildSensor validated = validate_child(entry.entry_id, gateway, node_id, child) diff --git a/homeassistant/components/mysensors/handler.py b/homeassistant/components/mysensors/handler.py index 8a77d167f8b..aa8a235c7cb 100644 --- a/homeassistant/components/mysensors/handler.py +++ b/homeassistant/components/mysensors/handler.py @@ -4,6 +4,7 @@ from __future__ import annotations from collections.abc import Callable from mysensors import Message +from mysensors.const import SYSTEM_CHILD_ID from homeassistant.const import Platform from homeassistant.core import HomeAssistant, callback @@ -12,7 +13,11 @@ from homeassistant.util import decorator from .const import CHILD_CALLBACK, NODE_CALLBACK, DevId, GatewayId from .device import get_mysensors_devices -from .helpers import discover_mysensors_platform, validate_set_msg +from .helpers import ( + discover_mysensors_node, + discover_mysensors_platform, + validate_set_msg, +) HANDLERS: decorator.Registry[ str, Callable[[HomeAssistant, GatewayId, Message], None] @@ -71,6 +76,16 @@ def handle_sketch_version( _handle_node_update(hass, gateway_id, msg) +@HANDLERS.register("presentation") +@callback +def handle_presentation( + hass: HomeAssistant, gateway_id: GatewayId, msg: Message +) -> None: + """Handle an internal presentation message.""" + if msg.child_id == SYSTEM_CHILD_ID: + discover_mysensors_node(hass, gateway_id, msg.node_id) + + @callback def _handle_child_update( hass: HomeAssistant, gateway_id: GatewayId, validated: dict[Platform, list[DevId]] diff --git a/homeassistant/components/mysensors/helpers.py b/homeassistant/components/mysensors/helpers.py index a5f67111738..9985929eecd 100644 --- a/homeassistant/components/mysensors/helpers.py +++ b/homeassistant/components/mysensors/helpers.py @@ -19,9 +19,12 @@ from homeassistant.util.decorator import Registry from .const import ( ATTR_DEVICES, ATTR_GATEWAY_ID, + ATTR_NODE_ID, DOMAIN, FLAT_PLATFORM_TYPES, + MYSENSORS_DISCOVERED_NODES, MYSENSORS_DISCOVERY, + MYSENSORS_NODE_DISCOVERY, MYSENSORS_ON_UNLOAD, TYPE_TO_PLATFORMS, DevId, @@ -65,6 +68,27 @@ def discover_mysensors_platform( ) +@callback +def discover_mysensors_node( + hass: HomeAssistant, gateway_id: GatewayId, node_id: int +) -> None: + """Discover a MySensors node.""" + discovered_nodes = hass.data[DOMAIN].setdefault( + MYSENSORS_DISCOVERED_NODES.format(gateway_id), set() + ) + + if node_id not in discovered_nodes: + discovered_nodes.add(node_id) + async_dispatcher_send( + hass, + MYSENSORS_NODE_DISCOVERY, + { + ATTR_GATEWAY_ID: gateway_id, + ATTR_NODE_ID: node_id, + }, + ) + + def default_schema( gateway: BaseAsyncGateway, child: ChildSensor, value_type_name: ValueType ) -> vol.Schema: diff --git a/homeassistant/components/mysensors/light.py b/homeassistant/components/mysensors/light.py index 213e268696e..7aea1e906a6 100644 --- a/homeassistant/components/mysensors/light.py +++ b/homeassistant/components/mysensors/light.py @@ -19,7 +19,7 @@ from homeassistant.util.color import rgb_hex_to_rgb_list from .. import mysensors from .const import MYSENSORS_DISCOVERY, DiscoveryInfo, SensorType -from .device import MySensorsEntity +from .device import MySensorsChildEntity from .helpers import on_unload @@ -29,7 +29,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up this platform for a specific ConfigEntry(==Gateway).""" - device_class_map: dict[SensorType, type[MySensorsEntity]] = { + device_class_map: dict[SensorType, type[MySensorsChildEntity]] = { "S_DIMMER": MySensorsLightDimmer, "S_RGB_LIGHT": MySensorsLightRGB, "S_RGBW_LIGHT": MySensorsLightRGBW, @@ -56,7 +56,7 @@ async def async_setup_entry( ) -class MySensorsLight(mysensors.device.MySensorsEntity, LightEntity): +class MySensorsLight(mysensors.device.MySensorsChildEntity, LightEntity): """Representation of a MySensors Light child node.""" def __init__(self, *args: Any) -> None: diff --git a/homeassistant/components/mysensors/remote.py b/homeassistant/components/mysensors/remote.py index d72bbfa4235..8521e407ae1 100644 --- a/homeassistant/components/mysensors/remote.py +++ b/homeassistant/components/mysensors/remote.py @@ -17,7 +17,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import setup_mysensors_platform from .const import MYSENSORS_DISCOVERY, DiscoveryInfo -from .device import MySensorsEntity +from .device import MySensorsChildEntity from .helpers import on_unload @@ -50,7 +50,7 @@ async def async_setup_entry( ) -class MySensorsRemote(MySensorsEntity, RemoteEntity): +class MySensorsRemote(MySensorsChildEntity, RemoteEntity): """Representation of a MySensors IR transceiver.""" _current_command: str | None = None diff --git a/homeassistant/components/mysensors/sensor.py b/homeassistant/components/mysensors/sensor.py index 174b1f094b1..84ae1ed031f 100644 --- a/homeassistant/components/mysensors/sensor.py +++ b/homeassistant/components/mysensors/sensor.py @@ -4,6 +4,7 @@ from __future__ import annotations from typing import Any from awesomeversion import AwesomeVersion +from mysensors import BaseAsyncGateway from homeassistant.components.sensor import ( SensorDeviceClass, @@ -30,13 +31,22 @@ from homeassistant.const import ( UnitOfTemperature, UnitOfVolume, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.unit_system import METRIC_SYSTEM from .. import mysensors -from .const import MYSENSORS_DISCOVERY, DiscoveryInfo +from .const import ( + ATTR_GATEWAY_ID, + ATTR_NODE_ID, + DOMAIN, + MYSENSORS_DISCOVERY, + MYSENSORS_GATEWAYS, + MYSENSORS_NODE_DISCOVERY, + DiscoveryInfo, + NodeDiscoveryInfo, +) from .helpers import on_unload SENSORS: dict[str, SensorEntityDescription] = { @@ -211,6 +221,14 @@ async def async_setup_entry( async_add_entities=async_add_entities, ) + @callback + def async_node_discover(discovery_info: NodeDiscoveryInfo) -> None: + """Add battery sensor for each MySensors node.""" + gateway_id = discovery_info[ATTR_GATEWAY_ID] + node_id = discovery_info[ATTR_NODE_ID] + gateway: BaseAsyncGateway = hass.data[DOMAIN][MYSENSORS_GATEWAYS][gateway_id] + async_add_entities([MyBatterySensor(gateway_id, gateway, node_id)]) + on_unload( hass, config_entry.entry_id, @@ -221,8 +239,43 @@ async def async_setup_entry( ), ) + on_unload( + hass, + config_entry.entry_id, + async_dispatcher_connect( + hass, + MYSENSORS_NODE_DISCOVERY, + async_node_discover, + ), + ) -class MySensorsSensor(mysensors.device.MySensorsEntity, SensorEntity): + +class MyBatterySensor(mysensors.device.MySensorNodeEntity, SensorEntity): + """Battery sensor of MySensors node.""" + + _attr_device_class = SensorDeviceClass.BATTERY + _attr_state_class = SensorStateClass.MEASUREMENT + _attr_native_unit_of_measurement = PERCENTAGE + _attr_force_update = True + + @property + def unique_id(self) -> str: + """Return a unique ID for use in home assistant.""" + return f"{self.gateway_id}-{self.node_id}-battery" + + @property + def name(self) -> str: + """Return the name of this entity.""" + return f"{self.node_name} Battery" + + @callback + def _async_update_callback(self) -> None: + """Update the controller with the latest battery level.""" + self._attr_native_value = self._node.battery_level + self.async_write_ha_state() + + +class MySensorsSensor(mysensors.device.MySensorsChildEntity, SensorEntity): """Representation of a MySensors Sensor child node.""" _attr_force_update = True diff --git a/homeassistant/components/mysensors/switch.py b/homeassistant/components/mysensors/switch.py index 6067a98af08..b1ec1a420d2 100644 --- a/homeassistant/components/mysensors/switch.py +++ b/homeassistant/components/mysensors/switch.py @@ -12,7 +12,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import setup_mysensors_platform from .const import MYSENSORS_DISCOVERY, DiscoveryInfo, SensorType -from .device import MySensorsEntity +from .device import MySensorsChildEntity from .helpers import on_unload @@ -58,7 +58,7 @@ async def async_setup_entry( ) -class MySensorsSwitch(MySensorsEntity, SwitchEntity): +class MySensorsSwitch(MySensorsChildEntity, SwitchEntity): """Representation of the value of a MySensors Switch child node.""" @property diff --git a/homeassistant/components/mysensors/text.py b/homeassistant/components/mysensors/text.py index e7bb7add084..68fa2a434d5 100644 --- a/homeassistant/components/mysensors/text.py +++ b/homeassistant/components/mysensors/text.py @@ -10,7 +10,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .. import mysensors from .const import MYSENSORS_DISCOVERY, DiscoveryInfo -from .device import MySensorsEntity +from .device import MySensorsChildEntity from .helpers import on_unload @@ -43,7 +43,7 @@ async def async_setup_entry( ) -class MySensorsText(MySensorsEntity, TextEntity): +class MySensorsText(MySensorsChildEntity, TextEntity): """Representation of the value of a MySensors Text child node.""" _attr_native_max = 25 diff --git a/tests/components/mysensors/conftest.py b/tests/components/mysensors/conftest.py index e7c0a3c5a7b..883a94ea02e 100644 --- a/tests/components/mysensors/conftest.py +++ b/tests/components/mysensors/conftest.py @@ -470,3 +470,19 @@ def text_node(gateway_nodes: dict[int, Sensor], text_node_state: dict) -> Sensor nodes = update_gateway_nodes(gateway_nodes, text_node_state) node = nodes[1] return node + + +@pytest.fixture(name="battery_sensor_state", scope="session") +def battery_sensor_state_fixture() -> dict: + """Load the battery sensor state.""" + return load_nodes_state("battery_sensor_state.json") + + +@pytest.fixture +def battery_sensor( + gateway_nodes: dict[int, Sensor], battery_sensor_state: dict +) -> Sensor: + """Load the battery sensor.""" + nodes = update_gateway_nodes(gateway_nodes, deepcopy(battery_sensor_state)) + node = nodes[1] + return node diff --git a/tests/components/mysensors/fixtures/battery_sensor_state.json b/tests/components/mysensors/fixtures/battery_sensor_state.json new file mode 100644 index 00000000000..fc89237ed97 --- /dev/null +++ b/tests/components/mysensors/fixtures/battery_sensor_state.json @@ -0,0 +1,12 @@ +{ + "1": { + "sensor_id": 1, + "children": {}, + "type": 17, + "sketch_name": "Battery Sensor", + "sketch_version": "1.0", + "battery_level": 42, + "protocol_version": "2.3.2", + "heartbeat": 0 + } +} diff --git a/tests/components/mysensors/test_sensor.py b/tests/components/mysensors/test_sensor.py index 12a47896326..17301e4b212 100644 --- a/tests/components/mysensors/test_sensor.py +++ b/tests/components/mysensors/test_sensor.py @@ -77,6 +77,25 @@ async def test_ir_transceiver( assert state.state == "new_code" +async def test_battery_entity( + hass: HomeAssistant, + battery_sensor: Sensor, + receive_message: Callable[[str], None], +) -> None: + """Test sensor with battery level reporting.""" + battery_entity_id = "sensor.battery_sensor_1_battery" + state = hass.states.get(battery_entity_id) + assert state + assert state.state == "42" + + receive_message("1;255;3;0;0;84\n") + await hass.async_block_till_done() + + state = hass.states.get(battery_entity_id) + assert state + assert state.state == "84" + + async def test_power_sensor( hass: HomeAssistant, power_sensor: Sensor,