Skip to content

Commit 35f4296

Browse files
authored
Add next_watering timestamp sensor per device (#414)
1 parent 8cd2e40 commit 35f4296

5 files changed

Lines changed: 177 additions & 2 deletions

File tree

README.md

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ _Note_: The Wifi hub is required to provide the flood sensors with internet conn
3232
## Supported Entities
3333

3434
- `valve` for opening/closing individual zones on `sprinkler_timer` devices.
35-
- `sensor` for battery levels, zone watering history, device run-mode state, flood-sensor temperature and flood-sensor signal strength.
35+
- `sensor` for battery levels, zone watering history, device next watering time, device run-mode state, flood-sensor temperature and flood-sensor signal strength.
3636
- `switch` for enabling/disabling rain delays, toggling pre-configured programs and enabling/disabling per-zone smart watering.
3737
- `select` for the device run-mode (auto/off) on `sprinkler_timer` devices.
3838
- `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:
9494
| `consumption_litres` | `number` | The amount of water consumed, in litres. |
9595
| `start_time` | `string` | The start time of the watering. |
9696

97+
### Device Next Watering sensor
98+
99+
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.
100+
101+
The existing `next_start_time` valve attribute is preserved for backward compatibility.
102+
103+
The following attributes are set on next watering sensor entities:
104+
105+
| Attribute | Type | Notes |
106+
| ---------- | -------------- | -------------------------------------------------------------- |
107+
| `programs` | `list[string]` | The programs scheduled to run at this time, if any. |
108+
97109
### Temperature sensor
98110

99111
A **temperature** `sensor` entity is created for each `flood_sensor` device, reporting the ambient temperature in degrees Fahrenheit.

custom_components/bhyve/sensor.py

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@
4444
ATTR_PROGRAM = "program"
4545
ATTR_PROGRAM_NAME = "program_name"
4646
ATTR_RUN_TIME = "run_time"
47+
ATTR_NEXT_START_PROGRAMS = "programs"
4748
ATTR_START_TIME = "start_time"
4849
ATTR_STATUS = "status"
4950

@@ -103,6 +104,22 @@ class BHyveSensorEntityDescription(SensorEntityDescription):
103104
entity_category=EntityCategory.DIAGNOSTIC,
104105
value_fn=lambda data: data.get("status", {}).get("run_mode", "unavailable"),
105106
),
107+
BHyveSensorEntityDescription(
108+
key="next_watering",
109+
translation_key="next_watering",
110+
name="Next watering",
111+
unique_id_suffix="next_watering",
112+
device_class=SensorDeviceClass.TIMESTAMP,
113+
icon="mdi:sprinkler-variant",
114+
value_fn=lambda data: orbit_time_to_local_time(
115+
data.get("status", {}).get("next_start_time")
116+
),
117+
attributes_fn=lambda data: (
118+
{ATTR_NEXT_START_PROGRAMS: programs}
119+
if (programs := data.get("status", {}).get("next_start_programs"))
120+
else {}
121+
),
122+
),
106123
)
107124

108125
SENSOR_TYPES_FLOOD: tuple[BHyveSensorEntityDescription, ...] = (
@@ -188,6 +205,7 @@ async def async_setup_entry(
188205
name=base_description.name,
189206
icon=base_description.icon,
190207
unique_id_suffix=base_description.unique_id_suffix,
208+
device_class=base_description.device_class,
191209
entity_category=base_description.entity_category,
192210
value_fn=base_description.value_fn,
193211
attributes_fn=base_description.attributes_fn,
@@ -305,7 +323,7 @@ def __init__(
305323
)
306324

307325
@property
308-
def native_value(self) -> int | float | str | None:
326+
def native_value(self) -> datetime | int | float | str | None:
309327
"""Return the state of the entity."""
310328
if self.entity_description.value_fn:
311329
return self.entity_description.value_fn(self.device_data)

custom_components/bhyve/strings.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,9 @@
200200
"zone_history": {
201201
"name": "{zone_name} zone history"
202202
},
203+
"next_watering": {
204+
"name": "Next watering"
205+
},
203206
"temperature": {
204207
"name": "Temperature"
205208
},

custom_components/bhyve/translations/en.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,9 @@
184184
}
185185
},
186186
"sensor": {
187+
"next_watering": {
188+
"name": "Next watering"
189+
},
187190
"temperature": {
188191
"name": "Temperature"
189192
},

tests/test_sensor.py

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,50 @@ def mock_zone_history_data() -> list:
134134
]
135135

136136

137+
@pytest.fixture
138+
def mock_sprinkler_device_with_next_start_time() -> BHyveDevice:
139+
"""Mock BHyve sprinkler device with next_start_time in device status."""
140+
return BHyveDevice(
141+
{
142+
"id": "test-device-123",
143+
"name": "Test Sprinkler",
144+
"type": "sprinkler_timer",
145+
"mac_address": "aa:bb:cc:dd:ee:ff",
146+
"hardware_version": "v2.0",
147+
"firmware_version": "1.2.3",
148+
"is_connected": True,
149+
"status": {
150+
"run_mode": "auto",
151+
"watering_status": None,
152+
"next_start_time": "2026-05-01T03:30:00-07:00",
153+
"next_start_programs": ["e"],
154+
},
155+
"zones": [{"station": "1", "name": "Front Lawn"}],
156+
}
157+
)
158+
159+
160+
@pytest.fixture
161+
def mock_sprinkler_device_no_schedule() -> BHyveDevice:
162+
"""Mock BHyve sprinkler device with no upcoming schedule."""
163+
return BHyveDevice(
164+
{
165+
"id": "test-device-123",
166+
"name": "Test Sprinkler",
167+
"type": "sprinkler_timer",
168+
"mac_address": "aa:bb:cc:dd:ee:ff",
169+
"hardware_version": "v2.0",
170+
"firmware_version": "1.2.3",
171+
"is_connected": True,
172+
"status": {
173+
"run_mode": "auto",
174+
"watering_status": None,
175+
},
176+
"zones": [{"station": "1", "name": "Front Lawn"}],
177+
}
178+
)
179+
180+
137181
class TestBHyveBatterySensor:
138182
"""Test BHyveBatterySensor entity."""
139183

@@ -600,3 +644,98 @@ async def test_sensors_handle_device_connection_events(
600644
# Sensors should be unavailable
601645
assert battery_sensor.available is False
602646
assert state_sensor.available is False
647+
648+
649+
class TestBHyveNextWateringSensor:
650+
"""Test next watering device-level sensor (SENSOR_TYPES_SPRINKLER[1])."""
651+
652+
async def test_next_watering_sensor_initialization(
653+
self,
654+
mock_sprinkler_device_with_next_start_time: BHyveDevice,
655+
) -> None:
656+
"""Test next watering sensor entity initialization."""
657+
coordinator = create_mock_coordinator(
658+
{
659+
"test-device-123": {
660+
"device": mock_sprinkler_device_with_next_start_time,
661+
"history": [],
662+
"landscapes": {},
663+
}
664+
}
665+
)
666+
667+
description = create_sensor_description(
668+
mock_sprinkler_device_with_next_start_time, SENSOR_TYPES_SPRINKLER[1]
669+
)
670+
sensor = BHyveSensor(
671+
coordinator=coordinator,
672+
device=mock_sprinkler_device_with_next_start_time,
673+
description=description,
674+
)
675+
676+
assert sensor.name == "Next watering"
677+
assert sensor.device_class == SensorDeviceClass.TIMESTAMP
678+
assert sensor._attr_unique_id.endswith(":next_watering")
679+
680+
async def test_next_watering_sensor_with_scheduled_time(
681+
self,
682+
mock_sprinkler_device_with_next_start_time: BHyveDevice,
683+
) -> None:
684+
"""Test next watering sensor returns correct timestamp and programs."""
685+
coordinator = create_mock_coordinator(
686+
{
687+
"test-device-123": {
688+
"device": mock_sprinkler_device_with_next_start_time,
689+
"history": [],
690+
"landscapes": {},
691+
}
692+
}
693+
)
694+
695+
description = create_sensor_description(
696+
mock_sprinkler_device_with_next_start_time, SENSOR_TYPES_SPRINKLER[1]
697+
)
698+
sensor = BHyveSensor(
699+
coordinator=coordinator,
700+
device=mock_sprinkler_device_with_next_start_time,
701+
description=description,
702+
)
703+
704+
# Value should parse to a datetime
705+
assert sensor.native_value is not None
706+
assert sensor.native_value.year == 2026
707+
assert sensor.native_value.month == 5
708+
assert sensor.native_value.day == 1
709+
710+
# Programs attribute should be present when next_start_programs exists
711+
attrs = sensor.extra_state_attributes
712+
assert attrs["programs"] == ["e"]
713+
714+
async def test_next_watering_sensor_no_schedule(
715+
self,
716+
mock_sprinkler_device_no_schedule: BHyveDevice,
717+
) -> None:
718+
"""Test next watering sensor returns None when next_start_time is absent."""
719+
coordinator = create_mock_coordinator(
720+
{
721+
"test-device-123": {
722+
"device": mock_sprinkler_device_no_schedule,
723+
"history": [],
724+
"landscapes": {},
725+
}
726+
}
727+
)
728+
729+
description = create_sensor_description(
730+
mock_sprinkler_device_no_schedule, SENSOR_TYPES_SPRINKLER[1]
731+
)
732+
sensor = BHyveSensor(
733+
coordinator=coordinator,
734+
device=mock_sprinkler_device_no_schedule,
735+
description=description,
736+
)
737+
738+
# No next_start_time in status — should return None (HA renders as Unknown)
739+
assert sensor.native_value is None
740+
# No programs attribute when there is no schedule
741+
assert sensor.extra_state_attributes == {}

0 commit comments

Comments
 (0)