diff --git a/homeassistant/components/pglab/device_sensor.py b/homeassistant/components/pglab/device_sensor.py new file mode 100644 index 00000000000..d202d11d6e7 --- /dev/null +++ b/homeassistant/components/pglab/device_sensor.py @@ -0,0 +1,56 @@ +"""Device Sensor for PG LAB Electronics.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from pypglab.device import Device as PyPGLabDevice +from pypglab.sensor import Sensor as PyPGLabSensors + +from homeassistant.core import callback + +if TYPE_CHECKING: + from .entity import PGLabEntity + + +class PGLabDeviceSensor: + """Keeps PGLab device sensor update.""" + + def __init__(self, pglab_device: PyPGLabDevice) -> None: + """Initialize the device sensor.""" + + # get a reference of PG Lab device internal sensors state + self._sensors: PyPGLabSensors = pglab_device.sensors + + self._ha_sensors: list[PGLabEntity] = [] # list of HA entity sensors + + async def subscribe_topics(self): + """Subscribe to the device sensors topics.""" + self._sensors.set_on_state_callback(self.state_updated) + await self._sensors.subscribe_topics() + + def add_ha_sensor(self, entity: PGLabEntity) -> None: + """Add a new HA sensor to the list.""" + self._ha_sensors.append(entity) + + def remove_ha_sensor(self, entity: PGLabEntity) -> None: + """Remove a HA sensor from the list.""" + self._ha_sensors.remove(entity) + + @callback + def state_updated(self, payload: str) -> None: + """Handle state updates.""" + + # notify all HA sensors that PG LAB device sensor fields have been updated + for s in self._ha_sensors: + s.state_updated(payload) + + @property + def state(self) -> dict: + """Return the device sensors state.""" + return self._sensors.state + + @property + def sensors(self) -> PyPGLabSensors: + """Return the pypglab device sensors.""" + return self._sensors diff --git a/homeassistant/components/pglab/discovery.py b/homeassistant/components/pglab/discovery.py index af6bedc9bf4..fec6f5ce40d 100644 --- a/homeassistant/components/pglab/discovery.py +++ b/homeassistant/components/pglab/discovery.py @@ -28,17 +28,20 @@ from homeassistant.helpers.dispatcher import ( from homeassistant.helpers.entity import Entity from .const import DISCOVERY_TOPIC, DOMAIN, LOGGER +from .device_sensor import PGLabDeviceSensor if TYPE_CHECKING: from . import PGLABConfigEntry # Supported platforms. PLATFORMS = [ + Platform.SENSOR, Platform.SWITCH, ] # Used to create a new component entity. CREATE_NEW_ENTITY = { + Platform.SENSOR: "pglab_create_new_entity_sensor", Platform.SWITCH: "pglab_create_new_entity_switch", } @@ -74,6 +77,7 @@ class DiscoverDeviceInfo: # When the hash string changes the devices entities must be rebuilt. self._hash = pglab_device.hash self._entities: list[tuple[str, str]] = [] + self._sensors = PGLabDeviceSensor(pglab_device) def add_entity(self, entity: Entity) -> None: """Add an entity.""" @@ -93,6 +97,20 @@ class DiscoverDeviceInfo: """Return array of entities available.""" return self._entities + @property + def sensors(self) -> PGLabDeviceSensor: + """Return the PGLab device sensor.""" + return self._sensors + + +async def createDiscoverDeviceInfo(pglab_device: PyPGLabDevice) -> DiscoverDeviceInfo: + """Create a new DiscoverDeviceInfo instance.""" + discovery_info = DiscoverDeviceInfo(pglab_device) + + # Subscribe to sensor state changes. + await discovery_info.sensors.subscribe_topics() + return discovery_info + @dataclass class PGLabDiscovery: @@ -223,7 +241,7 @@ class PGLabDiscovery: self.__clean_discovered_device(hass, pglab_device.id) # Add a new device. - discovery_info = DiscoverDeviceInfo(pglab_device) + discovery_info = await createDiscoverDeviceInfo(pglab_device) self._discovered[pglab_device.id] = discovery_info # Create all new relay entities. @@ -233,6 +251,14 @@ class PGLabDiscovery: hass, CREATE_NEW_ENTITY[Platform.SWITCH], pglab_device, r ) + # Create all new sensor entities. + async_dispatcher_send( + hass, + CREATE_NEW_ENTITY[Platform.SENSOR], + pglab_device, + discovery_info.sensors, + ) + topics = { "discovery_topic": { "topic": f"{self._discovery_topic}/#", diff --git a/homeassistant/components/pglab/entity.py b/homeassistant/components/pglab/entity.py index 1b8975a3bbe..175b4c1eb0f 100644 --- a/homeassistant/components/pglab/entity.py +++ b/homeassistant/components/pglab/entity.py @@ -43,12 +43,20 @@ class PGLabEntity(Entity): connections={(CONNECTION_NETWORK_MAC, device.mac)}, ) - async def async_added_to_hass(self) -> None: - """Update the device discovery info.""" - + async def subscribe_to_update(self): + """Subscribe to the entity updates.""" self._entity.set_on_state_callback(self.state_updated) await self._entity.subscribe_topics() + async def unsubscribe_to_update(self): + """Unsubscribe to the entity updates.""" + await self._entity.unsubscribe_topics() + self._entity.set_on_state_callback(None) + + async def async_added_to_hass(self) -> None: + """Update the device discovery info.""" + + await self.subscribe_to_update() await super().async_added_to_hass() # Inform PGLab discovery instance that a new entity is available. @@ -60,9 +68,7 @@ class PGLabEntity(Entity): """Unsubscribe when removed.""" await super().async_will_remove_from_hass() - - await self._entity.unsubscribe_topics() - self._entity.set_on_state_callback(None) + await self.unsubscribe_to_update() @callback def state_updated(self, payload: str) -> None: diff --git a/homeassistant/components/pglab/sensor.py b/homeassistant/components/pglab/sensor.py new file mode 100644 index 00000000000..f868e7ae101 --- /dev/null +++ b/homeassistant/components/pglab/sensor.py @@ -0,0 +1,119 @@ +"""Sensor for PG LAB Electronics.""" + +from __future__ import annotations + +from datetime import timedelta + +from pypglab.const import SENSOR_REBOOT_TIME, SENSOR_TEMPERATURE, SENSOR_VOLTAGE +from pypglab.device import Device as PyPGLabDevice + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.const import Platform, UnitOfElectricPotential, UnitOfTemperature +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.util.dt import utcnow + +from . import PGLABConfigEntry +from .device_sensor import PGLabDeviceSensor +from .discovery import PGLabDiscovery +from .entity import PGLabEntity + +PARALLEL_UPDATES = 0 + +SENSOR_INFO: list[SensorEntityDescription] = [ + SensorEntityDescription( + key=SENSOR_TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key=SENSOR_VOLTAGE, + translation_key="mpu_voltage", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + key=SENSOR_REBOOT_TIME, + translation_key="runtime", + device_class=SensorDeviceClass.TIMESTAMP, + icon="mdi:progress-clock", + ), +] + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: PGLABConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up sensor for device.""" + + @callback + def async_discover( + pglab_device: PyPGLabDevice, + pglab_device_sensor: PGLabDeviceSensor, + ) -> None: + """Discover and add a PG LAB Sensor.""" + pglab_discovery = config_entry.runtime_data + for description in SENSOR_INFO: + pglab_sensor = PGLabSensor( + pglab_discovery, pglab_device, pglab_device_sensor, description + ) + async_add_entities([pglab_sensor]) + + # Register the callback to create the sensor entity when discovered. + pglab_discovery = config_entry.runtime_data + await pglab_discovery.register_platform(hass, Platform.SENSOR, async_discover) + + +class PGLabSensor(PGLabEntity, SensorEntity): + """A PGLab sensor.""" + + def __init__( + self, + pglab_discovery: PGLabDiscovery, + pglab_device: PyPGLabDevice, + pglab_device_sensor: PGLabDeviceSensor, + description: SensorEntityDescription, + ) -> None: + """Initialize the Sensor class.""" + + super().__init__( + discovery=pglab_discovery, + device=pglab_device, + entity=pglab_device_sensor.sensors, + ) + + self._type = description.key + self._pglab_device_sensor = pglab_device_sensor + self._attr_unique_id = f"{pglab_device.id}_{description.key}" + self.entity_description = description + + @callback + def state_updated(self, payload: str) -> None: + """Handle state updates.""" + + # get the sensor value from pglab multi fields sensor + value = self._pglab_device_sensor.state[self._type] + + if self.entity_description.device_class == SensorDeviceClass.TIMESTAMP: + self._attr_native_value = utcnow() - timedelta(seconds=value) + else: + self._attr_native_value = value + + super().state_updated(payload) + + async def subscribe_to_update(self): + """Register the HA sensor to be notify when the sensor status is changed.""" + self._pglab_device_sensor.add_ha_sensor(self) + + async def unsubscribe_to_update(self): + """Unregister the HA sensor from sensor tatus updates.""" + self._pglab_device_sensor.remove_ha_sensor(self) diff --git a/homeassistant/components/pglab/strings.json b/homeassistant/components/pglab/strings.json index 8f9021cdcca..4fad408ad98 100644 --- a/homeassistant/components/pglab/strings.json +++ b/homeassistant/components/pglab/strings.json @@ -19,6 +19,17 @@ "relay": { "name": "Relay {relay_id}" } + }, + "sensor": { + "temperature": { + "name": "Temperature" + }, + "runtime": { + "name": "Run time" + }, + "mpu_voltage": { + "name": "MPU voltage" + } } } } diff --git a/tests/components/pglab/snapshots/test_sensor.ambr b/tests/components/pglab/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..f25f459bb70 --- /dev/null +++ b/tests/components/pglab/snapshots/test_sensor.ambr @@ -0,0 +1,95 @@ +# serializer version: 1 +# name: test_sensors[mpu_voltage][initial_sensor_mpu_voltage] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'test MPU voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_mpu_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensors[mpu_voltage][updated_sensor_mpu_voltage] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'test MPU voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_mpu_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '3.31', + }) +# --- +# name: test_sensors[run_time][initial_sensor_run_time] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'test Run time', + 'icon': 'mdi:progress-clock', + }), + 'context': , + 'entity_id': 'sensor.test_run_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensors[run_time][updated_sensor_run_time] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'test Run time', + 'icon': 'mdi:progress-clock', + }), + 'context': , + 'entity_id': 'sensor.test_run_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2024-02-26T01:04:54+00:00', + }) +# --- +# name: test_sensors[temperature][initial_sensor_temperature] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'test Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_sensors[temperature][updated_sensor_temperature] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'test Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '33.4', + }) +# --- diff --git a/tests/components/pglab/test_sensor.py b/tests/components/pglab/test_sensor.py new file mode 100644 index 00000000000..ff20d1452a4 --- /dev/null +++ b/tests/components/pglab/test_sensor.py @@ -0,0 +1,71 @@ +"""The tests for the PG LAB Electronics sensor.""" + +import json + +from freezegun import freeze_time +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.core import HomeAssistant + +from tests.common import async_fire_mqtt_message +from tests.typing import MqttMockHAClient + + +async def send_discovery_message(hass: HomeAssistant) -> None: + """Send mqtt discovery message.""" + + topic = "pglab/discovery/E-Board-DD53AC85/config" + payload = { + "ip": "192.168.1.16", + "mac": "80:34:28:1B:18:5A", + "name": "test", + "hw": "1.0.7", + "fw": "1.0.0", + "type": "E-Board", + "id": "E-Board-DD53AC85", + "manufacturer": "PG LAB Electronics", + "params": {"shutters": 0, "boards": "00000000"}, + } + + async_fire_mqtt_message( + hass, + topic, + json.dumps(payload), + ) + await hass.async_block_till_done() + + +@freeze_time("2024-02-26 01:21:34") +@pytest.mark.parametrize( + "sensor_suffix", + [ + "temperature", + "mpu_voltage", + "run_time", + ], +) +async def test_sensors( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mqtt_mock: MqttMockHAClient, + setup_pglab, + sensor_suffix: str, +) -> None: + """Check if sensors are properly created and updated.""" + + # send the discovery message to make E-BOARD device discoverable + await send_discovery_message(hass) + + # check initial sensors state + state = hass.states.get(f"sensor.test_{sensor_suffix}") + assert state == snapshot(name=f"initial_sensor_{sensor_suffix}") + + # update sensors value via mqtt + update_payload = {"temp": 33.4, "volt": 3.31, "rtime": 1000} + async_fire_mqtt_message(hass, "pglab/test/sensor/value", json.dumps(update_payload)) + await hass.async_block_till_done() + + # check updated sensors state + state = hass.states.get(f"sensor.test_{sensor_suffix}") + assert state == snapshot(name=f"updated_sensor_{sensor_suffix}")