Create greeneye_monitor entities when monitor connects (#66710)

pull/67132/head
Jonathan Keljo 2022-02-23 10:09:12 -08:00 committed by GitHub
parent 93fab1f996
commit a08165a8d7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 165 additions and 148 deletions

View File

@ -53,56 +53,71 @@ async def async_setup_platform(
if not discovery_info:
return
entities: list[GEMSensor] = []
for monitor_config in discovery_info[CONF_MONITORS]:
monitor_serial_number = monitor_config[CONF_SERIAL_NUMBER]
monitor_configs = discovery_info[CONF_MONITORS]
channel_configs = monitor_config[CONF_CHANNELS]
for sensor in channel_configs:
entities.append(
CurrentSensor(
monitor_serial_number,
sensor[CONF_NUMBER],
sensor[CONF_NAME],
sensor[CONF_NET_METERING],
def on_new_monitor(monitor: greeneye.monitor.Monitor) -> None:
monitor_config = next(
filter(
lambda monitor_config: monitor_config[CONF_SERIAL_NUMBER]
== monitor.serial_number,
monitor_configs,
),
None,
)
if monitor_config:
entities: list[GEMSensor] = []
channel_configs = monitor_config[CONF_CHANNELS]
for sensor in channel_configs:
entities.append(
CurrentSensor(
monitor,
sensor[CONF_NUMBER],
sensor[CONF_NAME],
sensor[CONF_NET_METERING],
)
)
)
pulse_counter_configs = monitor_config[CONF_PULSE_COUNTERS]
for sensor in pulse_counter_configs:
entities.append(
PulseCounter(
monitor_serial_number,
sensor[CONF_NUMBER],
sensor[CONF_NAME],
sensor[CONF_COUNTED_QUANTITY],
sensor[CONF_TIME_UNIT],
sensor[CONF_COUNTED_QUANTITY_PER_PULSE],
pulse_counter_configs = monitor_config[CONF_PULSE_COUNTERS]
for sensor in pulse_counter_configs:
entities.append(
PulseCounter(
monitor,
sensor[CONF_NUMBER],
sensor[CONF_NAME],
sensor[CONF_COUNTED_QUANTITY],
sensor[CONF_TIME_UNIT],
sensor[CONF_COUNTED_QUANTITY_PER_PULSE],
)
)
)
temperature_sensor_configs = monitor_config[CONF_TEMPERATURE_SENSORS]
for sensor in temperature_sensor_configs[CONF_SENSORS]:
entities.append(
TemperatureSensor(
monitor_serial_number,
sensor[CONF_NUMBER],
sensor[CONF_NAME],
temperature_sensor_configs[CONF_TEMPERATURE_UNIT],
temperature_sensor_configs = monitor_config[CONF_TEMPERATURE_SENSORS]
for sensor in temperature_sensor_configs[CONF_SENSORS]:
entities.append(
TemperatureSensor(
monitor,
sensor[CONF_NUMBER],
sensor[CONF_NAME],
temperature_sensor_configs[CONF_TEMPERATURE_UNIT],
)
)
)
voltage_sensor_configs = monitor_config[CONF_VOLTAGE_SENSORS]
for sensor in voltage_sensor_configs:
entities.append(
VoltageSensor(
monitor_serial_number,
sensor[CONF_NUMBER],
sensor[CONF_NAME],
voltage_sensor_configs = monitor_config[CONF_VOLTAGE_SENSORS]
for sensor in voltage_sensor_configs:
entities.append(
VoltageSensor(monitor, sensor[CONF_NUMBER], sensor[CONF_NAME])
)
)
async_add_entities(entities)
async_add_entities(entities)
monitor_configs.remove(monitor_config)
if len(monitor_configs) == 0:
monitors.remove_listener(on_new_monitor)
monitors: greeneye.Monitors = hass.data[DATA_GREENEYE_MONITOR]
monitors.add_listener(on_new_monitor)
for monitor in monitors.monitors.values():
on_new_monitor(monitor)
UnderlyingSensorType = Union[
@ -119,13 +134,19 @@ class GEMSensor(SensorEntity):
_attr_should_poll = False
def __init__(
self, monitor_serial_number: int, name: str, sensor_type: str, number: int
self,
monitor: greeneye.monitor.Monitor,
name: str,
sensor_type: str,
sensor: UnderlyingSensorType,
number: int,
) -> None:
"""Construct the entity."""
self._monitor_serial_number = monitor_serial_number
self._monitor = monitor
self._monitor_serial_number = self._monitor.serial_number
self._attr_name = name
self._monitor: greeneye.monitor.Monitor | None = None
self._sensor_type = sensor_type
self._sensor: UnderlyingSensorType = sensor
self._number = number
self._attr_unique_id = (
f"{self._monitor_serial_number}-{self._sensor_type}-{self._number}"
@ -133,37 +154,12 @@ class GEMSensor(SensorEntity):
async def async_added_to_hass(self) -> None:
"""Wait for and connect to the sensor."""
monitors = self.hass.data[DATA_GREENEYE_MONITOR]
if not self._try_connect_to_monitor(monitors):
monitors.add_listener(self._on_new_monitor)
def _on_new_monitor(self, monitor: greeneye.monitor.Monitor) -> None:
monitors = self.hass.data[DATA_GREENEYE_MONITOR]
if self._try_connect_to_monitor(monitors):
monitors.remove_listener(self._on_new_monitor)
self._sensor.add_listener(self.async_write_ha_state)
async def async_will_remove_from_hass(self) -> None:
"""Remove listener from the sensor."""
if self._sensor:
self._sensor.remove_listener(self.async_write_ha_state)
else:
monitors = self.hass.data[DATA_GREENEYE_MONITOR]
monitors.remove_listener(self._on_new_monitor)
def _try_connect_to_monitor(self, monitors: greeneye.Monitors) -> bool:
self._monitor = monitors.monitors.get(self._monitor_serial_number)
if not self._sensor:
return False
self._sensor.add_listener(self.async_write_ha_state)
self.async_write_ha_state()
return True
@property
def _sensor(self) -> UnderlyingSensorType | None:
raise NotImplementedError()
class CurrentSensor(GEMSensor):
@ -173,30 +169,25 @@ class CurrentSensor(GEMSensor):
_attr_device_class = SensorDeviceClass.POWER
def __init__(
self, monitor_serial_number: int, number: int, name: str, net_metering: bool
self,
monitor: greeneye.monitor.Monitor,
number: int,
name: str,
net_metering: bool,
) -> None:
"""Construct the entity."""
super().__init__(monitor_serial_number, name, "current", number)
super().__init__(monitor, name, "current", monitor.channels[number - 1], number)
self._sensor: greeneye.monitor.Channel = self._sensor
self._net_metering = net_metering
@property
def _sensor(self) -> greeneye.monitor.Channel | None:
return self._monitor.channels[self._number - 1] if self._monitor else None
@property
def native_value(self) -> float | None:
"""Return the current number of watts being used by the channel."""
if not self._sensor:
return None
return self._sensor.watts
@property
def extra_state_attributes(self) -> dict[str, Any] | None:
"""Return total wattseconds in the state dictionary."""
if not self._sensor:
return None
if self._net_metering:
watt_seconds = self._sensor.polarized_watt_seconds
else:
@ -212,7 +203,7 @@ class PulseCounter(GEMSensor):
def __init__(
self,
monitor_serial_number: int,
monitor: greeneye.monitor.Monitor,
number: int,
name: str,
counted_quantity: str,
@ -220,19 +211,18 @@ class PulseCounter(GEMSensor):
counted_quantity_per_pulse: float,
) -> None:
"""Construct the entity."""
super().__init__(monitor_serial_number, name, "pulse", number)
super().__init__(
monitor, name, "pulse", monitor.pulse_counters[number - 1], number
)
self._sensor: greeneye.monitor.PulseCounter = self._sensor
self._counted_quantity_per_pulse = counted_quantity_per_pulse
self._time_unit = time_unit
self._attr_native_unit_of_measurement = f"{counted_quantity}/{self._time_unit}"
@property
def _sensor(self) -> greeneye.monitor.PulseCounter | None:
return self._monitor.pulse_counters[self._number - 1] if self._monitor else None
@property
def native_value(self) -> float | None:
"""Return the current rate of change for the given pulse counter."""
if not self._sensor or self._sensor.pulses_per_second is None:
if self._sensor.pulses_per_second is None:
return None
result = (
@ -258,11 +248,8 @@ class PulseCounter(GEMSensor):
)
@property
def extra_state_attributes(self) -> dict[str, Any] | None:
def extra_state_attributes(self) -> dict[str, Any]:
"""Return total pulses in the data dictionary."""
if not self._sensor:
return None
return {DATA_PULSES: self._sensor.pulses}
@ -272,26 +259,18 @@ class TemperatureSensor(GEMSensor):
_attr_device_class = SensorDeviceClass.TEMPERATURE
def __init__(
self, monitor_serial_number: int, number: int, name: str, unit: str
self, monitor: greeneye.monitor.Monitor, number: int, name: str, unit: str
) -> None:
"""Construct the entity."""
super().__init__(monitor_serial_number, name, "temp", number)
self._attr_native_unit_of_measurement = unit
@property
def _sensor(self) -> greeneye.monitor.TemperatureSensor | None:
return (
self._monitor.temperature_sensors[self._number - 1]
if self._monitor
else None
super().__init__(
monitor, name, "temp", monitor.temperature_sensors[number - 1], number
)
self._sensor: greeneye.monitor.TemperatureSensor = self._sensor
self._attr_native_unit_of_measurement = unit
@property
def native_value(self) -> float | None:
"""Return the current temperature being reported by this sensor."""
if not self._sensor:
return None
return self._sensor.temperature
@ -301,19 +280,14 @@ class VoltageSensor(GEMSensor):
_attr_native_unit_of_measurement = ELECTRIC_POTENTIAL_VOLT
_attr_device_class = SensorDeviceClass.VOLTAGE
def __init__(self, monitor_serial_number: int, number: int, name: str) -> None:
def __init__(
self, monitor: greeneye.monitor.Monitor, number: int, name: str
) -> None:
"""Construct the entity."""
super().__init__(monitor_serial_number, name, "volts", number)
@property
def _sensor(self) -> greeneye.monitor.VoltageSensor | None:
"""Wire the updates to the monitor itself, since there is no voltage element in the API."""
return self._monitor.voltage_sensor if self._monitor else None
super().__init__(monitor, name, "volts", monitor.voltage_sensor, number)
self._sensor: greeneye.monitor.VoltageSensor = self._sensor
@property
def native_value(self) -> float | None:
"""Return the current voltage being reported by this sensor."""
if not self._sensor:
return None
return self._sensor.voltage

View File

@ -239,3 +239,13 @@ def mock_monitor(serial_number: int) -> MagicMock:
monitor.temperature_sensors = [mock_temperature_sensor() for i in range(0, 8)]
monitor.channels = [mock_channel() for i in range(0, 32)]
return monitor
async def connect_monitor(
hass: HomeAssistant, monitors: AsyncMock, serial_number: int
) -> MagicMock:
"""Simulate a monitor connecting to Home Assistant. Returns the mock monitor API object."""
monitor = mock_monitor(serial_number)
monitors.add_monitor(monitor)
await hass.async_block_till_done()
return monitor

View File

@ -18,6 +18,7 @@ from .common import (
SINGLE_MONITOR_CONFIG_TEMPERATURE_SENSORS,
SINGLE_MONITOR_CONFIG_VOLTAGE_SENSORS,
SINGLE_MONITOR_SERIAL_NUMBER,
connect_monitor,
setup_greeneye_monitor_component_with_config,
)
from .conftest import (
@ -53,7 +54,7 @@ async def test_setup_creates_temperature_entities(
assert await setup_greeneye_monitor_component_with_config(
hass, SINGLE_MONITOR_CONFIG_TEMPERATURE_SENSORS
)
await connect_monitor(hass, monitors, SINGLE_MONITOR_SERIAL_NUMBER)
assert_temperature_sensor_registered(
hass, SINGLE_MONITOR_SERIAL_NUMBER, 1, "temp_a"
)
@ -87,7 +88,7 @@ async def test_setup_creates_pulse_counter_entities(
assert await setup_greeneye_monitor_component_with_config(
hass, SINGLE_MONITOR_CONFIG_PULSE_COUNTERS
)
await connect_monitor(hass, monitors, SINGLE_MONITOR_SERIAL_NUMBER)
assert_pulse_counter_registered(
hass,
SINGLE_MONITOR_SERIAL_NUMBER,
@ -124,7 +125,7 @@ async def test_setup_creates_power_sensor_entities(
assert await setup_greeneye_monitor_component_with_config(
hass, SINGLE_MONITOR_CONFIG_POWER_SENSORS
)
await connect_monitor(hass, monitors, SINGLE_MONITOR_SERIAL_NUMBER)
assert_power_sensor_registered(hass, SINGLE_MONITOR_SERIAL_NUMBER, 1, "channel 1")
assert_power_sensor_registered(hass, SINGLE_MONITOR_SERIAL_NUMBER, 2, "channel two")
@ -136,7 +137,7 @@ async def test_setup_creates_voltage_sensor_entities(
assert await setup_greeneye_monitor_component_with_config(
hass, SINGLE_MONITOR_CONFIG_VOLTAGE_SENSORS
)
await connect_monitor(hass, monitors, SINGLE_MONITOR_SERIAL_NUMBER)
assert_voltage_sensor_registered(hass, SINGLE_MONITOR_SERIAL_NUMBER, 1, "voltage 1")
@ -147,6 +148,10 @@ async def test_multi_monitor_config(hass: HomeAssistant, monitors: AsyncMock) ->
MULTI_MONITOR_CONFIG,
)
await connect_monitor(hass, monitors, 1)
await connect_monitor(hass, monitors, 2)
await connect_monitor(hass, monitors, 3)
assert_temperature_sensor_registered(hass, 1, 1, "unit_1_temp_1")
assert_temperature_sensor_registered(hass, 2, 1, "unit_2_temp_1")
assert_temperature_sensor_registered(hass, 3, 1, "unit_3_temp_1")

View File

@ -1,5 +1,5 @@
"""Tests for greeneye_monitor sensors."""
from unittest.mock import AsyncMock, MagicMock
from unittest.mock import AsyncMock
from homeassistant.components.greeneye_monitor.sensor import (
DATA_PULSES,
@ -19,38 +19,50 @@ from .common import (
SINGLE_MONITOR_CONFIG_TEMPERATURE_SENSORS,
SINGLE_MONITOR_CONFIG_VOLTAGE_SENSORS,
SINGLE_MONITOR_SERIAL_NUMBER,
mock_monitor,
connect_monitor,
setup_greeneye_monitor_component_with_config,
)
from .conftest import assert_sensor_state
async def test_disable_sensor_before_monitor_connected(
async def test_sensor_does_not_exist_before_monitor_connected(
hass: HomeAssistant, monitors: AsyncMock
) -> None:
"""Test that a sensor disabled before its monitor connected stops listening for new monitors."""
"""Test that a sensor does not exist before its monitor is connected."""
# The sensor base class handles connecting the monitor, so we test this with a single voltage sensor for ease
await setup_greeneye_monitor_component_with_config(
hass, SINGLE_MONITOR_CONFIG_VOLTAGE_SENSORS
)
assert len(monitors.listeners) == 1
await disable_entity(hass, "sensor.voltage_1")
assert len(monitors.listeners) == 0 # Make sure we cleaned up the listener
entity_registry = get_entity_registry(hass)
assert entity_registry.async_get("sensor.voltage_1") is None
async def test_updates_state_when_monitor_connected(
async def test_sensors_created_when_monitor_connected(
hass: HomeAssistant, monitors: AsyncMock
) -> None:
"""Test that a sensor updates its state when its monitor first connects."""
"""Test that sensors get created when the monitor first connects."""
# The sensor base class handles updating the state on connection, so we test this with a single voltage sensor for ease
await setup_greeneye_monitor_component_with_config(
hass, SINGLE_MONITOR_CONFIG_VOLTAGE_SENSORS
)
assert_sensor_state(hass, "sensor.voltage_1", STATE_UNKNOWN)
assert len(monitors.listeners) == 1
connect_monitor(monitors, SINGLE_MONITOR_SERIAL_NUMBER)
await connect_monitor(hass, monitors, SINGLE_MONITOR_SERIAL_NUMBER)
assert len(monitors.listeners) == 0 # Make sure we cleaned up the listener
assert_sensor_state(hass, "sensor.voltage_1", "120.0")
async def test_sensors_created_during_setup_if_monitor_already_connected(
hass: HomeAssistant, monitors: AsyncMock
) -> None:
"""Test that sensors get created during setup if the monitor happens to connect really quickly."""
# The sensor base class handles updating the state on connection, so we test this with a single voltage sensor for ease
await connect_monitor(hass, monitors, SINGLE_MONITOR_SERIAL_NUMBER)
await setup_greeneye_monitor_component_with_config(
hass, SINGLE_MONITOR_CONFIG_VOLTAGE_SENSORS
)
assert len(monitors.listeners) == 0 # Make sure we cleaned up the listener
assert_sensor_state(hass, "sensor.voltage_1", "120.0")
@ -63,7 +75,7 @@ async def test_disable_sensor_after_monitor_connected(
await setup_greeneye_monitor_component_with_config(
hass, SINGLE_MONITOR_CONFIG_VOLTAGE_SENSORS
)
monitor = connect_monitor(monitors, SINGLE_MONITOR_SERIAL_NUMBER)
monitor = await connect_monitor(hass, monitors, SINGLE_MONITOR_SERIAL_NUMBER)
assert len(monitor.voltage_sensor.listeners) == 1
await disable_entity(hass, "sensor.voltage_1")
@ -78,7 +90,7 @@ async def test_updates_state_when_sensor_pushes(
await setup_greeneye_monitor_component_with_config(
hass, SINGLE_MONITOR_CONFIG_VOLTAGE_SENSORS
)
monitor = connect_monitor(monitors, SINGLE_MONITOR_SERIAL_NUMBER)
monitor = await connect_monitor(hass, monitors, SINGLE_MONITOR_SERIAL_NUMBER)
assert_sensor_state(hass, "sensor.voltage_1", "120.0")
monitor.voltage_sensor.voltage = 119.8
@ -93,7 +105,7 @@ async def test_power_sensor_initially_unknown(
await setup_greeneye_monitor_component_with_config(
hass, SINGLE_MONITOR_CONFIG_POWER_SENSORS
)
connect_monitor(monitors, SINGLE_MONITOR_SERIAL_NUMBER)
await connect_monitor(hass, monitors, SINGLE_MONITOR_SERIAL_NUMBER)
assert_sensor_state(
hass, "sensor.channel_1", STATE_UNKNOWN, {DATA_WATT_SECONDS: 1000}
)
@ -109,7 +121,7 @@ async def test_power_sensor(hass: HomeAssistant, monitors: AsyncMock) -> None:
await setup_greeneye_monitor_component_with_config(
hass, SINGLE_MONITOR_CONFIG_POWER_SENSORS
)
monitor = connect_monitor(monitors, SINGLE_MONITOR_SERIAL_NUMBER)
monitor = await connect_monitor(hass, monitors, SINGLE_MONITOR_SERIAL_NUMBER)
monitor.channels[0].watts = 120.0
monitor.channels[1].watts = 120.0
monitor.channels[0].notify_all_listeners()
@ -120,12 +132,35 @@ async def test_power_sensor(hass: HomeAssistant, monitors: AsyncMock) -> None:
assert_sensor_state(hass, "sensor.channel_two", "120.0", {DATA_WATT_SECONDS: -400})
async def test_pulse_counter_initially_unknown(
hass: HomeAssistant, monitors: AsyncMock
) -> None:
"""Test that the pulse counter sensor can handle its initial state being unknown (since the GEM API needs at least two packets to arrive before it can compute pulses per time)."""
await setup_greeneye_monitor_component_with_config(
hass, SINGLE_MONITOR_CONFIG_PULSE_COUNTERS
)
monitor = await connect_monitor(hass, monitors, SINGLE_MONITOR_SERIAL_NUMBER)
monitor.pulse_counters[0].pulses_per_second = None
monitor.pulse_counters[1].pulses_per_second = None
monitor.pulse_counters[2].pulses_per_second = None
monitor.pulse_counters[0].notify_all_listeners()
monitor.pulse_counters[1].notify_all_listeners()
monitor.pulse_counters[2].notify_all_listeners()
assert_sensor_state(hass, "sensor.pulse_a", STATE_UNKNOWN, {DATA_PULSES: 1000})
# This counter was configured with each pulse meaning 0.5 gallons and
# wanting to show gallons per minute, so 10 pulses per second -> 300 gal/min
assert_sensor_state(hass, "sensor.pulse_2", STATE_UNKNOWN, {DATA_PULSES: 1000})
# This counter was configured with each pulse meaning 0.5 gallons and
# wanting to show gallons per hour, so 10 pulses per second -> 18000 gal/hr
assert_sensor_state(hass, "sensor.pulse_3", STATE_UNKNOWN, {DATA_PULSES: 1000})
async def test_pulse_counter(hass: HomeAssistant, monitors: AsyncMock) -> None:
"""Test that a pulse counter sensor reports its values properly, including calculating different units."""
await setup_greeneye_monitor_component_with_config(
hass, SINGLE_MONITOR_CONFIG_PULSE_COUNTERS
)
connect_monitor(monitors, SINGLE_MONITOR_SERIAL_NUMBER)
await connect_monitor(hass, monitors, SINGLE_MONITOR_SERIAL_NUMBER)
assert_sensor_state(hass, "sensor.pulse_a", "10.0", {DATA_PULSES: 1000})
# This counter was configured with each pulse meaning 0.5 gallons and
# wanting to show gallons per minute, so 10 pulses per second -> 300 gal/min
@ -140,7 +175,7 @@ async def test_temperature_sensor(hass: HomeAssistant, monitors: AsyncMock) -> N
await setup_greeneye_monitor_component_with_config(
hass, SINGLE_MONITOR_CONFIG_TEMPERATURE_SENSORS
)
connect_monitor(monitors, SINGLE_MONITOR_SERIAL_NUMBER)
await connect_monitor(hass, monitors, SINGLE_MONITOR_SERIAL_NUMBER)
# The config says that the sensor is reporting in Fahrenheit; if we set that up
# properly, HA will have converted that to Celsius by default.
assert_sensor_state(hass, "sensor.temp_a", "0.0")
@ -151,28 +186,21 @@ async def test_voltage_sensor(hass: HomeAssistant, monitors: AsyncMock) -> None:
await setup_greeneye_monitor_component_with_config(
hass, SINGLE_MONITOR_CONFIG_VOLTAGE_SENSORS
)
connect_monitor(monitors, SINGLE_MONITOR_SERIAL_NUMBER)
await connect_monitor(hass, monitors, SINGLE_MONITOR_SERIAL_NUMBER)
assert_sensor_state(hass, "sensor.voltage_1", "120.0")
async def test_multi_monitor_sensors(hass: HomeAssistant, monitors: AsyncMock) -> None:
"""Test that sensors still work when multiple monitors are registered."""
await setup_greeneye_monitor_component_with_config(hass, MULTI_MONITOR_CONFIG)
connect_monitor(monitors, 1)
connect_monitor(monitors, 2)
connect_monitor(monitors, 3)
await connect_monitor(hass, monitors, 1)
await connect_monitor(hass, monitors, 2)
await connect_monitor(hass, monitors, 3)
assert_sensor_state(hass, "sensor.unit_1_temp_1", "32.0")
assert_sensor_state(hass, "sensor.unit_2_temp_1", "0.0")
assert_sensor_state(hass, "sensor.unit_3_temp_1", "32.0")
def connect_monitor(monitors: AsyncMock, serial_number: int) -> MagicMock:
"""Simulate a monitor connecting to Home Assistant. Returns the mock monitor API object."""
monitor = mock_monitor(serial_number)
monitors.add_monitor(monitor)
return monitor
async def disable_entity(hass: HomeAssistant, entity_id: str) -> None:
"""Disable the given entity."""
entity_registry = get_entity_registry(hass)