diff --git a/custom_components/eyeonwater/binary_sensor.py b/custom_components/eyeonwater/binary_sensor.py index da5fdf9..3463333 100644 --- a/custom_components/eyeonwater/binary_sensor.py +++ b/custom_components/eyeonwater/binary_sensor.py @@ -29,6 +29,7 @@ class Description: key: str device_class: BinarySensorDeviceClass translation_key: str | None = None + enabled_by_default: bool = True FLAG_SENSORS = [ @@ -37,6 +38,12 @@ class Description: translation_key="leak", device_class=BinarySensorDeviceClass.MOISTURE, ), + Description( + key="encoder_leak", + translation_key="encoderleak", + device_class=BinarySensorDeviceClass.MOISTURE, + enabled_by_default=False, + ), Description( key="empty_pipe", translation_key="emptypipe", @@ -65,6 +72,18 @@ class Description: key="battery_charging", device_class=BinarySensorDeviceClass.BATTERY_CHARGING, ), + Description( + key="endpoint_reading_missed", + translation_key="endpointreadingmissed", + device_class=BinarySensorDeviceClass.PROBLEM, + enabled_by_default=False, + ), + Description( + key="device_alert", + translation_key="devicealert", + device_class=BinarySensorDeviceClass.PROBLEM, + enabled_by_default=False, + ), ] @@ -104,6 +123,7 @@ def __init__( key=description.key, device_class=description.device_class, translation_key=description.translation_key, + entity_registry_enabled_default=description.enabled_by_default, ) self.meter = meter self._uuid = normalize_id(meter.meter_uuid) @@ -124,7 +144,11 @@ def __init__( def get_flag(self) -> bool: """Get flag value.""" return bool( - self.meter.meter_info.reading.flags.__dict__[self.entity_description.key], + getattr( + self.meter.meter_info.reading.flags, + self.entity_description.key, + False, + ), ) @callback diff --git a/custom_components/eyeonwater/manifest.json b/custom_components/eyeonwater/manifest.json index 68263ef..bf84564 100644 --- a/custom_components/eyeonwater/manifest.json +++ b/custom_components/eyeonwater/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "issue_tracker": "https://github.com/kdeyev/eyeonwater/issues", "requirements": ["pyonwater==0.3.32"], -"version": "2.7.9-beta.1" +"version": "2.7.10-beta.1" } diff --git a/custom_components/eyeonwater/strings.json b/custom_components/eyeonwater/strings.json index 4467998..c14a2b4 100644 --- a/custom_components/eyeonwater/strings.json +++ b/custom_components/eyeonwater/strings.json @@ -39,6 +39,9 @@ "leak": { "name": "Leak Sensor" }, + "encoderleak": { + "name": "Encoder Leak" + }, "emptypipe": { "name": "Empty Pipe" }, @@ -50,6 +53,12 @@ }, "reverseflow": { "name": "Reverse Waterflow" + }, + "endpointreadingmissed": { + "name": "Endpoint Reading Missed" + }, + "devicealert": { + "name": "Device Alert" } } } diff --git a/custom_components/eyeonwater/translations/en.json b/custom_components/eyeonwater/translations/en.json index 2053c9a..a4dddb2 100644 --- a/custom_components/eyeonwater/translations/en.json +++ b/custom_components/eyeonwater/translations/en.json @@ -41,6 +41,9 @@ "leak": { "name": "Leak Sensor" }, + "encoderleak": { + "name": "Encoder Leak" + }, "emptypipe": { "name": "Empty Pipe" }, @@ -52,6 +55,12 @@ }, "reverseflow": { "name": "Reverse Waterflow" + }, + "endpointreadingmissed": { + "name": "Endpoint Reading Missed" + }, + "devicealert": { + "name": "Device Alert" } } }, diff --git a/tests/conftest.py b/tests/conftest.py index 6f494c7..363648a 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -32,12 +32,15 @@ class FakeFlags: """Fake meter flag data for testing.""" leak: bool = False + encoder_leak: bool = False empty_pipe: bool = False tamper: bool = False cover_removed: bool = False reverse_flow: bool = False low_battery: bool = False battery_charging: bool = False + endpoint_reading_missed: bool = False + device_alert: bool = False @dataclass diff --git a/tests/test_binary_sensor.py b/tests/test_binary_sensor.py new file mode 100644 index 0000000..6d51097 --- /dev/null +++ b/tests/test_binary_sensor.py @@ -0,0 +1,153 @@ +"""Tests for EyeOnWater binary sensor entities.""" + +from unittest.mock import MagicMock + +import pytest +from homeassistant.components.binary_sensor import BinarySensorDeviceClass +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from custom_components.eyeonwater.binary_sensor import ( + FLAG_SENSORS, + Description, + EyeOnWaterBinarySensor, +) +from custom_components.eyeonwater.statistic_helper import normalize_id + +from .conftest import MOCK_METER_UUID, _make_meter + + +@pytest.fixture +def coordinator() -> MagicMock: + """Provide a mock coordinator.""" + coord = MagicMock(spec=DataUpdateCoordinator) + coord.last_update_success = True + coord.async_add_listener = MagicMock(return_value=lambda: None) + return coord + + +def _make_sensor(key: str, coordinator: MagicMock) -> EyeOnWaterBinarySensor: + """Return a sensor for the given flag key.""" + desc = next(d for d in FLAG_SENSORS if d.key == key) + return EyeOnWaterBinarySensor(_make_meter(), coordinator, desc) + + +# ---------- FLAG_SENSORS catalog ---------- + + +class TestFlagSensorsCatalog: + """Verify the FLAG_SENSORS list contains expected entries.""" + + def test_encoder_leak_present(self) -> None: + """encoder_leak sensor must exist in FLAG_SENSORS.""" + keys = [d.key for d in FLAG_SENSORS] + assert "encoder_leak" in keys + + def test_endpoint_reading_missed_present(self) -> None: + """endpoint_reading_missed sensor must exist in FLAG_SENSORS.""" + keys = [d.key for d in FLAG_SENSORS] + assert "endpoint_reading_missed" in keys + + def test_device_alert_present(self) -> None: + """device_alert sensor must exist in FLAG_SENSORS.""" + keys = [d.key for d in FLAG_SENSORS] + assert "device_alert" in keys + + def test_encoder_leak_disabled_by_default(self) -> None: + """encoder_leak must be disabled by default.""" + desc = next(d for d in FLAG_SENSORS if d.key == "encoder_leak") + assert desc.enabled_by_default is False + + def test_endpoint_reading_missed_disabled_by_default(self) -> None: + """endpoint_reading_missed must be disabled by default.""" + desc = next(d for d in FLAG_SENSORS if d.key == "endpoint_reading_missed") + assert desc.enabled_by_default is False + + def test_device_alert_disabled_by_default(self) -> None: + """device_alert must be disabled by default.""" + desc = next(d for d in FLAG_SENSORS if d.key == "device_alert") + assert desc.enabled_by_default is False + + def test_leak_enabled_by_default(self) -> None: + """Primary leak sensor must remain enabled by default.""" + desc = next(d for d in FLAG_SENSORS if d.key == "leak") + assert desc.enabled_by_default is True + + def test_encoder_leak_is_moisture(self) -> None: + """encoder_leak must have MOISTURE device class.""" + desc = next(d for d in FLAG_SENSORS if d.key == "encoder_leak") + assert desc.device_class == BinarySensorDeviceClass.MOISTURE + + def test_endpoint_reading_missed_is_problem(self) -> None: + """endpoint_reading_missed must have PROBLEM device class.""" + desc = next(d for d in FLAG_SENSORS if d.key == "endpoint_reading_missed") + assert desc.device_class == BinarySensorDeviceClass.PROBLEM + + def test_device_alert_is_problem(self) -> None: + """device_alert must have PROBLEM device class.""" + desc = next(d for d in FLAG_SENSORS if d.key == "device_alert") + assert desc.device_class == BinarySensorDeviceClass.PROBLEM + + +# ---------- EyeOnWaterBinarySensor ---------- + + +class TestEyeOnWaterBinarySensor: + """Tests for EyeOnWaterBinarySensor entity behaviour.""" + + def test_unique_id_encoder_leak(self, coordinator: MagicMock) -> None: + """Unique ID must include sensor key and normalized UUID.""" + sensor = _make_sensor("encoder_leak", coordinator) + assert sensor._attr_unique_id == f"encoder_leak_{normalize_id(MOCK_METER_UUID)}" + + def test_entity_registry_disabled_encoder_leak( + self, + coordinator: MagicMock, + ) -> None: + """encoder_leak entity must be disabled in the entity registry by default.""" + sensor = _make_sensor("encoder_leak", coordinator) + assert sensor.entity_description.entity_registry_enabled_default is False + + def test_entity_registry_disabled_endpoint_reading_missed( + self, + coordinator: MagicMock, + ) -> None: + """endpoint_reading_missed entity must be disabled in the entity registry by default.""" + sensor = _make_sensor("endpoint_reading_missed", coordinator) + assert sensor.entity_description.entity_registry_enabled_default is False + + def test_entity_registry_disabled_device_alert( + self, + coordinator: MagicMock, + ) -> None: + """device_alert entity must be disabled in the entity registry by default.""" + sensor = _make_sensor("device_alert", coordinator) + assert sensor.entity_description.entity_registry_enabled_default is False + + def test_entity_registry_enabled_leak(self, coordinator: MagicMock) -> None: + """Primary leak sensor must be enabled in the entity registry by default.""" + sensor = _make_sensor("leak", coordinator) + assert sensor.entity_description.entity_registry_enabled_default is True + + def test_get_flag_true(self, coordinator: MagicMock) -> None: + """get_flag returns True when the flag field is set on the meter.""" + meter = _make_meter() + meter.meter_info.reading.flags.encoder_leak = True + desc = next(d for d in FLAG_SENSORS if d.key == "encoder_leak") + sensor = EyeOnWaterBinarySensor(meter, coordinator, desc) + assert sensor.get_flag() is True + + def test_get_flag_false(self, coordinator: MagicMock) -> None: + """get_flag returns False when the flag field is unset.""" + sensor = _make_sensor("encoder_leak", coordinator) + assert sensor.get_flag() is False + + def test_get_flag_missing_field_returns_false(self, coordinator: MagicMock) -> None: + """get_flag returns False gracefully for flags not present on the meter.""" + meter = _make_meter() + # FakeFlags does not define 'nonexistent_flag', so getattr returns the default False + desc = Description( + key="nonexistent_flag", + device_class=BinarySensorDeviceClass.PROBLEM, + ) + sensor = EyeOnWaterBinarySensor(meter, coordinator, desc) + assert sensor.get_flag() is False