diff --git a/README.md b/README.md index 7946b7a..b749c28 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ _Note_: The Wifi hub is required to provide the flood sensors with internet conn ## Supported Entities - `valve` for opening/closing individual zones on `sprinkler_timer` devices. -- `sensor` for battery levels, zone watering history, device run-mode state, flood-sensor temperature and flood-sensor signal strength. +- `sensor` for battery levels, zone watering history, device next watering time, device run-mode state, flood-sensor temperature and flood-sensor signal strength. - `switch` for enabling/disabling rain delays, toggling pre-configured programs and enabling/disabling per-zone smart watering. - `select` for the device run-mode (auto/off) on `sprinkler_timer` devices. - `binary_sensor` for flood detection, temperature alerts, sprinkler station faults and Wi-Fi bridge connectivity. @@ -94,6 +94,18 @@ The following attributes are set on zone history sensor entities: | `consumption_litres` | `number` | The amount of water consumed, in litres. | | `start_time` | `string` | The start time of the watering. | +### Device Next Watering sensor + +A **next watering** `sensor` entity is created for each `sprinkler_timer` device. This reports the next scheduled watering time as a timestamp, allowing relative-time rendering ("in 14 hours"), long-term statistics, and direct use in automations and dashboard cards. + +The existing `next_start_time` valve attribute is preserved for backward compatibility. + +The following attributes are set on next watering sensor entities: + +| Attribute | Type | Notes | +| ---------- | -------------- | -------------------------------------------------------------- | +| `programs` | `list[string]` | The programs scheduled to run at this time, if any. | + ### Temperature sensor A **temperature** `sensor` entity is created for each `flood_sensor` device, reporting the ambient temperature in degrees Fahrenheit. diff --git a/custom_components/bhyve/sensor.py b/custom_components/bhyve/sensor.py index 1273f13..96e65ea 100644 --- a/custom_components/bhyve/sensor.py +++ b/custom_components/bhyve/sensor.py @@ -44,6 +44,7 @@ ATTR_PROGRAM = "program" ATTR_PROGRAM_NAME = "program_name" ATTR_RUN_TIME = "run_time" +ATTR_NEXT_START_PROGRAMS = "programs" ATTR_START_TIME = "start_time" ATTR_STATUS = "status" @@ -103,6 +104,22 @@ class BHyveSensorEntityDescription(SensorEntityDescription): entity_category=EntityCategory.DIAGNOSTIC, value_fn=lambda data: data.get("status", {}).get("run_mode", "unavailable"), ), + BHyveSensorEntityDescription( + key="next_watering", + translation_key="next_watering", + name="Next watering", + unique_id_suffix="next_watering", + device_class=SensorDeviceClass.TIMESTAMP, + icon="mdi:sprinkler-variant", + value_fn=lambda data: orbit_time_to_local_time( + data.get("status", {}).get("next_start_time") + ), + attributes_fn=lambda data: ( + {ATTR_NEXT_START_PROGRAMS: programs} + if (programs := data.get("status", {}).get("next_start_programs")) + else {} + ), + ), ) SENSOR_TYPES_FLOOD: tuple[BHyveSensorEntityDescription, ...] = ( @@ -188,6 +205,7 @@ async def async_setup_entry( name=base_description.name, icon=base_description.icon, unique_id_suffix=base_description.unique_id_suffix, + device_class=base_description.device_class, entity_category=base_description.entity_category, value_fn=base_description.value_fn, attributes_fn=base_description.attributes_fn, @@ -305,7 +323,7 @@ def __init__( ) @property - def native_value(self) -> int | float | str | None: + def native_value(self) -> datetime | int | float | str | None: """Return the state of the entity.""" if self.entity_description.value_fn: return self.entity_description.value_fn(self.device_data) diff --git a/custom_components/bhyve/strings.json b/custom_components/bhyve/strings.json index a3519cb..f0b8422 100644 --- a/custom_components/bhyve/strings.json +++ b/custom_components/bhyve/strings.json @@ -200,6 +200,9 @@ "zone_history": { "name": "{zone_name} zone history" }, + "next_watering": { + "name": "Next watering" + }, "temperature": { "name": "Temperature" }, diff --git a/custom_components/bhyve/translations/en.json b/custom_components/bhyve/translations/en.json index 6ad02d1..a8bd88e 100644 --- a/custom_components/bhyve/translations/en.json +++ b/custom_components/bhyve/translations/en.json @@ -184,6 +184,9 @@ } }, "sensor": { + "next_watering": { + "name": "Next watering" + }, "temperature": { "name": "Temperature" }, diff --git a/tests/test_sensor.py b/tests/test_sensor.py index e2c5892..ee29c9b 100644 --- a/tests/test_sensor.py +++ b/tests/test_sensor.py @@ -134,6 +134,50 @@ def mock_zone_history_data() -> list: ] +@pytest.fixture +def mock_sprinkler_device_with_next_start_time() -> BHyveDevice: + """Mock BHyve sprinkler device with next_start_time in device status.""" + return BHyveDevice( + { + "id": "test-device-123", + "name": "Test Sprinkler", + "type": "sprinkler_timer", + "mac_address": "aa:bb:cc:dd:ee:ff", + "hardware_version": "v2.0", + "firmware_version": "1.2.3", + "is_connected": True, + "status": { + "run_mode": "auto", + "watering_status": None, + "next_start_time": "2026-05-01T03:30:00-07:00", + "next_start_programs": ["e"], + }, + "zones": [{"station": "1", "name": "Front Lawn"}], + } + ) + + +@pytest.fixture +def mock_sprinkler_device_no_schedule() -> BHyveDevice: + """Mock BHyve sprinkler device with no upcoming schedule.""" + return BHyveDevice( + { + "id": "test-device-123", + "name": "Test Sprinkler", + "type": "sprinkler_timer", + "mac_address": "aa:bb:cc:dd:ee:ff", + "hardware_version": "v2.0", + "firmware_version": "1.2.3", + "is_connected": True, + "status": { + "run_mode": "auto", + "watering_status": None, + }, + "zones": [{"station": "1", "name": "Front Lawn"}], + } + ) + + class TestBHyveBatterySensor: """Test BHyveBatterySensor entity.""" @@ -600,3 +644,98 @@ async def test_sensors_handle_device_connection_events( # Sensors should be unavailable assert battery_sensor.available is False assert state_sensor.available is False + + +class TestBHyveNextWateringSensor: + """Test next watering device-level sensor (SENSOR_TYPES_SPRINKLER[1]).""" + + async def test_next_watering_sensor_initialization( + self, + mock_sprinkler_device_with_next_start_time: BHyveDevice, + ) -> None: + """Test next watering sensor entity initialization.""" + coordinator = create_mock_coordinator( + { + "test-device-123": { + "device": mock_sprinkler_device_with_next_start_time, + "history": [], + "landscapes": {}, + } + } + ) + + description = create_sensor_description( + mock_sprinkler_device_with_next_start_time, SENSOR_TYPES_SPRINKLER[1] + ) + sensor = BHyveSensor( + coordinator=coordinator, + device=mock_sprinkler_device_with_next_start_time, + description=description, + ) + + assert sensor.name == "Next watering" + assert sensor.device_class == SensorDeviceClass.TIMESTAMP + assert sensor._attr_unique_id.endswith(":next_watering") + + async def test_next_watering_sensor_with_scheduled_time( + self, + mock_sprinkler_device_with_next_start_time: BHyveDevice, + ) -> None: + """Test next watering sensor returns correct timestamp and programs.""" + coordinator = create_mock_coordinator( + { + "test-device-123": { + "device": mock_sprinkler_device_with_next_start_time, + "history": [], + "landscapes": {}, + } + } + ) + + description = create_sensor_description( + mock_sprinkler_device_with_next_start_time, SENSOR_TYPES_SPRINKLER[1] + ) + sensor = BHyveSensor( + coordinator=coordinator, + device=mock_sprinkler_device_with_next_start_time, + description=description, + ) + + # Value should parse to a datetime + assert sensor.native_value is not None + assert sensor.native_value.year == 2026 + assert sensor.native_value.month == 5 + assert sensor.native_value.day == 1 + + # Programs attribute should be present when next_start_programs exists + attrs = sensor.extra_state_attributes + assert attrs["programs"] == ["e"] + + async def test_next_watering_sensor_no_schedule( + self, + mock_sprinkler_device_no_schedule: BHyveDevice, + ) -> None: + """Test next watering sensor returns None when next_start_time is absent.""" + coordinator = create_mock_coordinator( + { + "test-device-123": { + "device": mock_sprinkler_device_no_schedule, + "history": [], + "landscapes": {}, + } + } + ) + + description = create_sensor_description( + mock_sprinkler_device_no_schedule, SENSOR_TYPES_SPRINKLER[1] + ) + sensor = BHyveSensor( + coordinator=coordinator, + device=mock_sprinkler_device_no_schedule, + description=description, + ) + + # No next_start_time in status — should return None (HA renders as Unknown) + assert sensor.native_value is None + # No programs attribute when there is no schedule + assert sensor.extra_state_attributes == {}