diff --git a/.coveragerc b/.coveragerc index 28a6eff3073b277657c4aead678ad9dd41dcb686..53012078f331d0b24c33636df7a82728e15a478f 100644 --- a/.coveragerc +++ b/.coveragerc @@ -399,8 +399,6 @@ omit = homeassistant/components/google_travel_time/sensor.py homeassistant/components/gpmdp/media_player.py homeassistant/components/gpsd/sensor.py - homeassistant/components/greeneye_monitor/* - homeassistant/components/greeneye_monitor/sensor.py homeassistant/components/greenwave/light.py homeassistant/components/group/notify.py homeassistant/components/growatt_server/sensor.py diff --git a/homeassistant/components/greeneye_monitor/__init__.py b/homeassistant/components/greeneye_monitor/__init__.py index 3417d0c08dc4d338cfc42b271d5672f5ed952dce..d2b0e7c307ba64de78f2f8ff1c64d049649919bd 100644 --- a/homeassistant/components/greeneye_monitor/__init__.py +++ b/homeassistant/components/greeneye_monitor/__init__.py @@ -3,7 +3,7 @@ from __future__ import annotations import logging -from greeneye import Monitors +import greeneye import voluptuous as vol from homeassistant.const import ( @@ -123,7 +123,7 @@ CONFIG_SCHEMA = vol.Schema({DOMAIN: COMPONENT_SCHEMA}, extra=vol.ALLOW_EXTRA) async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the GreenEye Monitor component.""" - monitors = Monitors() + monitors = greeneye.Monitors() hass.data[DATA_GREENEYE_MONITOR] = monitors server_config = config[DOMAIN] diff --git a/homeassistant/components/greeneye_monitor/sensor.py b/homeassistant/components/greeneye_monitor/sensor.py index 2a4692674c9e9d3341562c1dc0dafc573bd29fd2..de71e3c27fab047ca9527eff35e471453858da46 100644 --- a/homeassistant/components/greeneye_monitor/sensor.py +++ b/homeassistant/components/greeneye_monitor/sensor.py @@ -4,7 +4,6 @@ from __future__ import annotations from typing import Any, Generic, Optional, TypeVar, cast import greeneye -from greeneye import Monitors from homeassistant.components.sensor import SensorEntity from homeassistant.const import ( @@ -145,7 +144,7 @@ class GEMSensor(Generic[T], SensorEntity): monitors = self.hass.data[DATA_GREENEYE_MONITOR] monitors.remove_listener(self._on_new_monitor) - def _try_connect_to_monitor(self, monitors: Monitors) -> bool: + def _try_connect_to_monitor(self, monitors: greeneye.Monitors) -> bool: monitor = monitors.monitors.get(self._monitor_serial_number) if not monitor: return False diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4e25d14b60f4fcddd202b5e8690e1ec3c23ba2de..77ab56dc4849ee966902f3e77c66c1ec26de33ff 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -463,6 +463,9 @@ googlemaps==2.5.1 # homeassistant.components.gree greeclimate==0.12.3 +# homeassistant.components.greeneye_monitor +greeneye_monitor==2.1 + # homeassistant.components.growatt_server growattServer==1.1.0 diff --git a/tests/components/greeneye_monitor/__init__.py b/tests/components/greeneye_monitor/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..db9bcaee1f45a0be83bf18a9bbc90f8aba9a9b2f --- /dev/null +++ b/tests/components/greeneye_monitor/__init__.py @@ -0,0 +1 @@ +"""Tests for the GreenEye Monitor integration.""" diff --git a/tests/components/greeneye_monitor/common.py b/tests/components/greeneye_monitor/common.py new file mode 100644 index 0000000000000000000000000000000000000000..ac00ccbfc0b27fc344a485180a26a6021adcf70c --- /dev/null +++ b/tests/components/greeneye_monitor/common.py @@ -0,0 +1,205 @@ +"""Common helpers for greeneye_monitor tests.""" +from __future__ import annotations + +from typing import Any +from unittest.mock import AsyncMock, MagicMock + +from homeassistant.components.greeneye_monitor import ( + CONF_CHANNELS, + CONF_COUNTED_QUANTITY, + CONF_COUNTED_QUANTITY_PER_PULSE, + CONF_MONITORS, + CONF_NET_METERING, + CONF_NUMBER, + CONF_PULSE_COUNTERS, + CONF_SERIAL_NUMBER, + CONF_TEMPERATURE_SENSORS, + CONF_TIME_UNIT, + CONF_VOLTAGE_SENSORS, + DOMAIN, +) +from homeassistant.const import ( + CONF_NAME, + CONF_PORT, + CONF_SENSORS, + CONF_TEMPERATURE_UNIT, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.typing import ConfigType +from homeassistant.setup import async_setup_component + +SINGLE_MONITOR_SERIAL_NUMBER = 110011 + + +def make_single_monitor_config_with_sensors(sensors: dict[str, Any]) -> dict[str, Any]: + """Wrap the given sensor config in the boilerplate for a single monitor with serial number SINGLE_MONITOR_SERIAL_NUMBER.""" + return { + DOMAIN: { + CONF_PORT: 7513, + CONF_MONITORS: [ + { + CONF_SERIAL_NUMBER: f"00{SINGLE_MONITOR_SERIAL_NUMBER}", + **sensors, + } + ], + } + } + + +SINGLE_MONITOR_CONFIG_NO_SENSORS = make_single_monitor_config_with_sensors({}) +SINGLE_MONITOR_CONFIG_PULSE_COUNTERS = make_single_monitor_config_with_sensors( + { + CONF_PULSE_COUNTERS: [ + { + CONF_NUMBER: 1, + CONF_NAME: "pulse_a", + CONF_COUNTED_QUANTITY: "pulses", + CONF_COUNTED_QUANTITY_PER_PULSE: 1.0, + CONF_TIME_UNIT: "s", + }, + { + CONF_NUMBER: 2, + CONF_NAME: "pulse_2", + CONF_COUNTED_QUANTITY: "gal", + CONF_COUNTED_QUANTITY_PER_PULSE: 0.5, + CONF_TIME_UNIT: "min", + }, + { + CONF_NUMBER: 3, + CONF_NAME: "pulse_3", + CONF_COUNTED_QUANTITY: "gal", + CONF_COUNTED_QUANTITY_PER_PULSE: 0.5, + CONF_TIME_UNIT: "h", + }, + { + CONF_NUMBER: 4, + CONF_NAME: "pulse_d", + CONF_COUNTED_QUANTITY: "pulses", + CONF_COUNTED_QUANTITY_PER_PULSE: 1.0, + CONF_TIME_UNIT: "s", + }, + ] + } +) + +SINGLE_MONITOR_CONFIG_POWER_SENSORS = make_single_monitor_config_with_sensors( + { + CONF_CHANNELS: [ + { + CONF_NUMBER: 1, + CONF_NAME: "channel 1", + }, + { + CONF_NUMBER: 2, + CONF_NAME: "channel two", + CONF_NET_METERING: True, + }, + ] + } +) + + +SINGLE_MONITOR_CONFIG_TEMPERATURE_SENSORS = make_single_monitor_config_with_sensors( + { + CONF_TEMPERATURE_SENSORS: { + CONF_TEMPERATURE_UNIT: "F", + CONF_SENSORS: [ + {CONF_NUMBER: 1, CONF_NAME: "temp_a"}, + {CONF_NUMBER: 2, CONF_NAME: "temp_2"}, + {CONF_NUMBER: 3, CONF_NAME: "temp_c"}, + {CONF_NUMBER: 4, CONF_NAME: "temp_d"}, + {CONF_NUMBER: 5, CONF_NAME: "temp_5"}, + {CONF_NUMBER: 6, CONF_NAME: "temp_f"}, + {CONF_NUMBER: 7, CONF_NAME: "temp_g"}, + {CONF_NUMBER: 8, CONF_NAME: "temp_h"}, + ], + } + } +) + +SINGLE_MONITOR_CONFIG_VOLTAGE_SENSORS = make_single_monitor_config_with_sensors( + { + CONF_VOLTAGE_SENSORS: [ + { + CONF_NUMBER: 1, + CONF_NAME: "voltage 1", + }, + ] + } +) + + +async def setup_greeneye_monitor_component_with_config( + hass: HomeAssistant, config: ConfigType +) -> bool: + """Set up the greeneye_monitor component with the given config. Return True if successful, False otherwise.""" + result = await async_setup_component( + hass, + DOMAIN, + config, + ) + await hass.async_block_till_done() + + return result + + +def mock_with_listeners() -> MagicMock: + """Create a MagicMock with methods that follow the same pattern for working with listeners in the greeneye_monitor API.""" + mock = MagicMock() + add_listeners(mock) + return mock + + +def async_mock_with_listeners() -> AsyncMock: + """Create an AsyncMock with methods that follow the same pattern for working with listeners in the greeneye_monitor API.""" + mock = AsyncMock() + add_listeners(mock) + return mock + + +def add_listeners(mock: MagicMock | AsyncMock) -> None: + """Add add_listener and remove_listener methods to the given mock that behave like their counterparts on objects from the greeneye_monitor API, plus a notify_all_listeners method that calls all registered listeners.""" + mock.listeners = [] + mock.add_listener = mock.listeners.append + mock.remove_listener = mock.listeners.remove + + def notify_all_listeners(*args): + for listener in list(mock.listeners): + listener(*args) + + mock.notify_all_listeners = notify_all_listeners + + +def mock_pulse_counter() -> MagicMock: + """Create a mock GreenEye Monitor pulse counter.""" + pulse_counter = mock_with_listeners() + pulse_counter.pulses = 1000 + pulse_counter.pulses_per_second = 10 + return pulse_counter + + +def mock_temperature_sensor() -> MagicMock: + """Create a mock GreenEye Monitor temperature sensor.""" + temperature_sensor = mock_with_listeners() + temperature_sensor.temperature = 32.0 + return temperature_sensor + + +def mock_channel() -> MagicMock: + """Create a mock GreenEye Monitor CT channel.""" + channel = mock_with_listeners() + channel.absolute_watt_seconds = 1000 + channel.polarized_watt_seconds = -400 + channel.watts = None + return channel + + +def mock_monitor(serial_number: int) -> MagicMock: + """Create a mock GreenEye Monitor.""" + monitor = mock_with_listeners() + monitor.serial_number = serial_number + monitor.voltage = 120.0 + monitor.pulse_counters = [mock_pulse_counter() for i in range(0, 4)] + monitor.temperature_sensors = [mock_temperature_sensor() for i in range(0, 8)] + monitor.channels = [mock_channel() for i in range(0, 32)] + return monitor diff --git a/tests/components/greeneye_monitor/conftest.py b/tests/components/greeneye_monitor/conftest.py new file mode 100644 index 0000000000000000000000000000000000000000..b6fa49032cc618e4e7af24dec0fadc5519ca8cb9 --- /dev/null +++ b/tests/components/greeneye_monitor/conftest.py @@ -0,0 +1,118 @@ +"""Common fixtures for testing greeneye_monitor.""" +from typing import Any, Dict +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from homeassistant.components.greeneye_monitor import DOMAIN +from homeassistant.const import ( + DEVICE_CLASS_POWER, + DEVICE_CLASS_TEMPERATURE, + DEVICE_CLASS_VOLTAGE, + ELECTRIC_POTENTIAL_VOLT, + POWER_WATT, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_registry import ( + RegistryEntry, + async_get as get_entity_registry, +) + +from .common import add_listeners + + +def assert_sensor_state( + hass: HomeAssistant, + entity_id: str, + expected_state: str, + attributes: Dict[str, Any] = {}, +) -> None: + """Assert that the given entity has the expected state and at least the provided attributes.""" + state = hass.states.get(entity_id) + assert state + actual_state = state.state + assert actual_state == expected_state + for (key, value) in attributes.items(): + assert key in state.attributes + assert state.attributes[key] == value + + +def assert_temperature_sensor_registered( + hass: HomeAssistant, + serial_number: int, + number: int, + name: str, +): + """Assert that a temperature sensor entity was registered properly.""" + sensor = assert_sensor_registered(hass, serial_number, "temp", number, name) + assert sensor.device_class == DEVICE_CLASS_TEMPERATURE + + +def assert_pulse_counter_registered( + hass: HomeAssistant, + serial_number: int, + number: int, + name: str, + quantity: str, + per_time: str, +): + """Assert that a pulse counter entity was registered properly.""" + sensor = assert_sensor_registered(hass, serial_number, "pulse", number, name) + assert sensor.unit_of_measurement == f"{quantity}/{per_time}" + + +def assert_power_sensor_registered( + hass: HomeAssistant, serial_number: int, number: int, name: str +) -> None: + """Assert that a power sensor entity was registered properly.""" + sensor = assert_sensor_registered(hass, serial_number, "current", number, name) + assert sensor.unit_of_measurement == POWER_WATT + assert sensor.device_class == DEVICE_CLASS_POWER + + +def assert_voltage_sensor_registered( + hass: HomeAssistant, serial_number: int, number: int, name: str +) -> None: + """Assert that a voltage sensor entity was registered properly.""" + sensor = assert_sensor_registered(hass, serial_number, "volts", number, name) + assert sensor.unit_of_measurement == ELECTRIC_POTENTIAL_VOLT + assert sensor.device_class == DEVICE_CLASS_VOLTAGE + + +def assert_sensor_registered( + hass: HomeAssistant, + serial_number: int, + sensor_type: str, + number: int, + name: str, +) -> RegistryEntry: + """Assert that a sensor entity of a given type was registered properly.""" + registry = get_entity_registry(hass) + unique_id = f"{serial_number}-{sensor_type}-{number}" + + entity_id = registry.async_get_entity_id("sensor", DOMAIN, unique_id) + assert entity_id is not None + + sensor = registry.async_get(entity_id) + assert sensor + assert sensor.unique_id == unique_id + assert sensor.original_name == name + + return sensor + + +@pytest.fixture +def monitors() -> AsyncMock: + """Provide a mock greeneye.Monitors object that has listeners and can add new monitors.""" + with patch("greeneye.Monitors", new=AsyncMock) as mock_monitors: + add_listeners(mock_monitors) + mock_monitors.monitors = {} + + def add_monitor(monitor: MagicMock) -> None: + """Add the given mock monitor as a monitor with the given serial number, notifying any listeners on the Monitors object.""" + serial_number = monitor.serial_number + mock_monitors.monitors[serial_number] = monitor + mock_monitors.notify_all_listeners(monitor) + + mock_monitors.add_monitor = add_monitor + yield mock_monitors diff --git a/tests/components/greeneye_monitor/test_init.py b/tests/components/greeneye_monitor/test_init.py new file mode 100644 index 0000000000000000000000000000000000000000..143fb14f28ce67e12b1e6be191924989f804bf73 --- /dev/null +++ b/tests/components/greeneye_monitor/test_init.py @@ -0,0 +1,199 @@ +"""Tests for greeneye_monitor component initialization.""" + +from __future__ import annotations + +from unittest.mock import AsyncMock + +import pytest + +from homeassistant.components.greeneye_monitor import ( + CONF_MONITORS, + CONF_NUMBER, + CONF_SERIAL_NUMBER, + CONF_TEMPERATURE_SENSORS, + DOMAIN, +) +from homeassistant.const import ( + CONF_NAME, + CONF_PORT, + CONF_SENSORS, + CONF_TEMPERATURE_UNIT, +) +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from .common import ( + SINGLE_MONITOR_CONFIG_NO_SENSORS, + SINGLE_MONITOR_CONFIG_POWER_SENSORS, + SINGLE_MONITOR_CONFIG_PULSE_COUNTERS, + SINGLE_MONITOR_CONFIG_TEMPERATURE_SENSORS, + SINGLE_MONITOR_CONFIG_VOLTAGE_SENSORS, + SINGLE_MONITOR_SERIAL_NUMBER, + setup_greeneye_monitor_component_with_config, +) +from .conftest import ( + assert_power_sensor_registered, + assert_pulse_counter_registered, + assert_temperature_sensor_registered, + assert_voltage_sensor_registered, +) + + +async def test_setup_fails_if_no_sensors_defined( + hass: HomeAssistant, monitors: AsyncMock +) -> None: + """Test that component setup fails if there are no sensors defined in the YAML.""" + success = await setup_greeneye_monitor_component_with_config( + hass, SINGLE_MONITOR_CONFIG_NO_SENSORS + ) + assert not success + + +@pytest.mark.xfail(reason="Currently failing. Will fix in subsequent PR.") +async def test_setup_succeeds_no_config( + hass: HomeAssistant, monitors: AsyncMock +) -> None: + """Test that component setup succeeds if there is no config present in the YAML.""" + assert await async_setup_component(hass, DOMAIN, {}) + + +async def test_setup_creates_temperature_entities( + hass: HomeAssistant, monitors: AsyncMock +) -> None: + """Test that component setup registers temperature sensors properly.""" + assert await setup_greeneye_monitor_component_with_config( + hass, SINGLE_MONITOR_CONFIG_TEMPERATURE_SENSORS + ) + + assert_temperature_sensor_registered( + hass, SINGLE_MONITOR_SERIAL_NUMBER, 1, "temp_a" + ) + assert_temperature_sensor_registered( + hass, SINGLE_MONITOR_SERIAL_NUMBER, 2, "temp_2" + ) + assert_temperature_sensor_registered( + hass, SINGLE_MONITOR_SERIAL_NUMBER, 3, "temp_c" + ) + assert_temperature_sensor_registered( + hass, SINGLE_MONITOR_SERIAL_NUMBER, 4, "temp_d" + ) + assert_temperature_sensor_registered( + hass, SINGLE_MONITOR_SERIAL_NUMBER, 5, "temp_5" + ) + assert_temperature_sensor_registered( + hass, SINGLE_MONITOR_SERIAL_NUMBER, 6, "temp_f" + ) + assert_temperature_sensor_registered( + hass, SINGLE_MONITOR_SERIAL_NUMBER, 7, "temp_g" + ) + assert_temperature_sensor_registered( + hass, SINGLE_MONITOR_SERIAL_NUMBER, 8, "temp_h" + ) + + +async def test_setup_creates_pulse_counter_entities( + hass: HomeAssistant, monitors: AsyncMock +) -> None: + """Test that component setup registers pulse counters properly.""" + assert await setup_greeneye_monitor_component_with_config( + hass, SINGLE_MONITOR_CONFIG_PULSE_COUNTERS + ) + + assert_pulse_counter_registered( + hass, + SINGLE_MONITOR_SERIAL_NUMBER, + 1, + "pulse_a", + "pulses", + "s", + ) + assert_pulse_counter_registered( + hass, SINGLE_MONITOR_SERIAL_NUMBER, 2, "pulse_2", "gal", "min" + ) + assert_pulse_counter_registered( + hass, + SINGLE_MONITOR_SERIAL_NUMBER, + 3, + "pulse_3", + "gal", + "h", + ) + assert_pulse_counter_registered( + hass, + SINGLE_MONITOR_SERIAL_NUMBER, + 4, + "pulse_d", + "pulses", + "s", + ) + + +async def test_setup_creates_power_sensor_entities( + hass: HomeAssistant, monitors: AsyncMock +) -> None: + """Test that component setup registers power sensors correctly.""" + assert await setup_greeneye_monitor_component_with_config( + hass, SINGLE_MONITOR_CONFIG_POWER_SENSORS + ) + + assert_power_sensor_registered(hass, SINGLE_MONITOR_SERIAL_NUMBER, 1, "channel 1") + assert_power_sensor_registered(hass, SINGLE_MONITOR_SERIAL_NUMBER, 2, "channel two") + + +async def test_setup_creates_voltage_sensor_entities( + hass: HomeAssistant, monitors: AsyncMock +) -> None: + """Test that component setup registers voltage sensors properly.""" + assert await setup_greeneye_monitor_component_with_config( + hass, SINGLE_MONITOR_CONFIG_VOLTAGE_SENSORS + ) + + assert_voltage_sensor_registered(hass, SINGLE_MONITOR_SERIAL_NUMBER, 1, "voltage 1") + + +async def test_multi_monitor_config(hass: HomeAssistant, monitors: AsyncMock) -> None: + """Test that component setup registers entities from multiple monitors correctly.""" + assert await setup_greeneye_monitor_component_with_config( + hass, + { + DOMAIN: { + CONF_PORT: 7513, + CONF_MONITORS: [ + { + CONF_SERIAL_NUMBER: "00000001", + CONF_TEMPERATURE_SENSORS: { + CONF_TEMPERATURE_UNIT: "C", + CONF_SENSORS: [ + {CONF_NUMBER: 1, CONF_NAME: "unit_1_temp_1"} + ], + }, + }, + { + CONF_SERIAL_NUMBER: "00000002", + CONF_TEMPERATURE_SENSORS: { + CONF_TEMPERATURE_UNIT: "F", + CONF_SENSORS: [ + {CONF_NUMBER: 1, CONF_NAME: "unit_2_temp_1"} + ], + }, + }, + ], + } + }, + ) + + assert_temperature_sensor_registered(hass, 1, 1, "unit_1_temp_1") + assert_temperature_sensor_registered(hass, 2, 1, "unit_2_temp_1") + + +async def test_setup_and_shutdown(hass: HomeAssistant, monitors: AsyncMock) -> None: + """Test that the component can set up and shut down cleanly, closing the underlying server on shutdown.""" + server = AsyncMock() + monitors.start_server = AsyncMock(return_value=server) + assert await setup_greeneye_monitor_component_with_config( + hass, SINGLE_MONITOR_CONFIG_POWER_SENSORS + ) + + await hass.async_stop() + + assert server.close.called diff --git a/tests/components/greeneye_monitor/test_sensor.py b/tests/components/greeneye_monitor/test_sensor.py new file mode 100644 index 0000000000000000000000000000000000000000..63ab8b64423b77b71e8e1bf580272f3bd91c454d --- /dev/null +++ b/tests/components/greeneye_monitor/test_sensor.py @@ -0,0 +1,165 @@ +"""Tests for greeneye_monitor sensors.""" +from unittest.mock import AsyncMock, MagicMock + +from homeassistant.components.greeneye_monitor.sensor import ( + DATA_PULSES, + DATA_WATT_SECONDS, +) +from homeassistant.const import STATE_UNKNOWN +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_registry import async_get as get_entity_registry + +from .common import ( + SINGLE_MONITOR_CONFIG_POWER_SENSORS, + SINGLE_MONITOR_CONFIG_PULSE_COUNTERS, + SINGLE_MONITOR_CONFIG_TEMPERATURE_SENSORS, + SINGLE_MONITOR_CONFIG_VOLTAGE_SENSORS, + SINGLE_MONITOR_SERIAL_NUMBER, + mock_monitor, + setup_greeneye_monitor_component_with_config, +) +from .conftest import assert_sensor_state + + +async def test_disable_sensor_before_monitor_connected( + hass: HomeAssistant, monitors: AsyncMock +) -> None: + """Test that a sensor disabled before its monitor connected stops listening for new monitors.""" + # 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 + + +async def test_updates_state_when_monitor_connected( + hass: HomeAssistant, monitors: AsyncMock +) -> None: + """Test that a sensor updates its state when its 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) + assert len(monitors.listeners) == 0 # Make sure we cleaned up the listener + assert_sensor_state(hass, "sensor.voltage_1", "120.0") + + +async def test_disable_sensor_after_monitor_connected( + hass: HomeAssistant, monitors: AsyncMock +) -> None: + """Test that a sensor disabled after its monitor connected stops listening for sensor changes.""" + # 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 + ) + monitor = connect_monitor(monitors, SINGLE_MONITOR_SERIAL_NUMBER) + + assert len(monitor.listeners) == 1 + await disable_entity(hass, "sensor.voltage_1") + assert len(monitor.listeners) == 0 + + +async def test_updates_state_when_sensor_pushes( + hass: HomeAssistant, monitors: AsyncMock +) -> None: + """Test that a sensor entity updates its state when the underlying sensor pushes an update.""" + # The sensor base class handles triggering state updates, so we test this with a single voltage sensor for ease + await setup_greeneye_monitor_component_with_config( + hass, SINGLE_MONITOR_CONFIG_VOLTAGE_SENSORS + ) + monitor = connect_monitor(monitors, SINGLE_MONITOR_SERIAL_NUMBER) + assert_sensor_state(hass, "sensor.voltage_1", "120.0") + + monitor.voltage = 119.8 + monitor.notify_all_listeners() + assert_sensor_state(hass, "sensor.voltage_1", "119.8") + + +async def test_power_sensor_initially_unknown( + hass: HomeAssistant, monitors: AsyncMock +) -> None: + """Test that the power sensor can handle its initial state being unknown (since the GEM API needs at least two packets to arrive before it can compute watts).""" + await setup_greeneye_monitor_component_with_config( + hass, SINGLE_MONITOR_CONFIG_POWER_SENSORS + ) + connect_monitor(monitors, SINGLE_MONITOR_SERIAL_NUMBER) + assert_sensor_state( + hass, "sensor.channel_1", STATE_UNKNOWN, {DATA_WATT_SECONDS: 1000} + ) + # This sensor was configured with net metering on, so we should be taking the + # polarized value + assert_sensor_state( + hass, "sensor.channel_two", STATE_UNKNOWN, {DATA_WATT_SECONDS: -400} + ) + + +async def test_power_sensor(hass: HomeAssistant, monitors: AsyncMock) -> None: + """Test that a power sensor reports its values correctly, including handling net metering.""" + await setup_greeneye_monitor_component_with_config( + hass, SINGLE_MONITOR_CONFIG_POWER_SENSORS + ) + monitor = connect_monitor(monitors, SINGLE_MONITOR_SERIAL_NUMBER) + monitor.channels[0].watts = 120.0 + monitor.channels[1].watts = 120.0 + monitor.channels[0].notify_all_listeners() + monitor.channels[1].notify_all_listeners() + assert_sensor_state(hass, "sensor.channel_1", "120.0", {DATA_WATT_SECONDS: 1000}) + # This sensor was configured with net metering on, so we should be taking the + # polarized value + assert_sensor_state(hass, "sensor.channel_two", "120.0", {DATA_WATT_SECONDS: -400}) + + +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) + 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 + assert_sensor_state(hass, "sensor.pulse_2", "300.0", {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", "18000.0", {DATA_PULSES: 1000}) + + +async def test_temperature_sensor(hass: HomeAssistant, monitors: AsyncMock) -> None: + """Test that a temperature sensor reports its values properly, including proper handling of when its native unit is different from that configured in hass.""" + await setup_greeneye_monitor_component_with_config( + hass, SINGLE_MONITOR_CONFIG_TEMPERATURE_SENSORS + ) + connect_monitor(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") + + +async def test_voltage_sensor(hass: HomeAssistant, monitors: AsyncMock) -> None: + """Test that a voltage sensor reports its values properly.""" + await setup_greeneye_monitor_component_with_config( + hass, SINGLE_MONITOR_CONFIG_VOLTAGE_SENSORS + ) + connect_monitor(monitors, SINGLE_MONITOR_SERIAL_NUMBER) + assert_sensor_state(hass, "sensor.voltage_1", "120.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) + entity_registry.async_update_entity(entity_id, disabled_by="user") + await hass.async_block_till_done()