Skip to content

Commit 9020238

Browse files
committed
Pick latest zone history irrigation by start_time
The zone history sensor picked zone_irrigation[-1] from the first history item that had any entry for the zone. That relied on the upstream irrigation array being ordered by time, which isn't always true: when a later entry appears earlier in the array, the sensor surfaces the wrong run and its attributes (run_time, consumption, status). Select the entry with the greatest start_time across every history item instead. All statuses are considered so skipped runs still report consumption.
1 parent 25b845c commit 9020238

2 files changed

Lines changed: 131 additions & 47 deletions

File tree

custom_components/bhyve/sensor.py

Lines changed: 42 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -367,59 +367,56 @@ def __init__(
367367
@property
368368
def native_value(self) -> datetime | None:
369369
"""Return the state of the entity."""
370-
history = self._get_device_history()
371-
if not history:
370+
latest = self._get_latest_irrigation()
371+
if not latest:
372372
return None
373-
374-
for history_item in history:
375-
zone_irrigation = list(
376-
filter(
377-
lambda i: i.get("station") == self._zone_id,
378-
history_item.get(ATTR_IRRIGATION, []),
379-
)
380-
)
381-
if zone_irrigation:
382-
# This is a bit crude - assumes the list is ordered by time.
383-
latest_irrigation = zone_irrigation[-1]
384-
start_time = latest_irrigation.get("start_time")
385-
if start_time:
386-
local_time = orbit_time_to_local_time(start_time)
387-
if local_time:
388-
return local_time
373+
start_time = latest.get(ATTR_START_TIME)
374+
if start_time:
375+
return orbit_time_to_local_time(start_time)
389376
return None
390377

391378
@property
392379
def extra_state_attributes(self) -> dict[str, Any]:
393380
"""Return the device state attributes."""
394-
history = self._get_device_history()
395-
if not history:
381+
latest = self._get_latest_irrigation()
382+
if not latest:
396383
return {}
397384

398-
for history_item in history:
399-
zone_irrigation = list(
400-
filter(
401-
lambda i: i.get("station") == self._zone_id,
402-
history_item.get(ATTR_IRRIGATION, []),
403-
)
404-
)
405-
if zone_irrigation:
406-
# This is a bit crude - assumes the list is ordered by time.
407-
latest_irrigation = zone_irrigation[-1]
408-
409-
gallons = latest_irrigation.get("water_volume_gal")
410-
litres = round(gallons * 3.785, 2) if gallons else None
411-
412-
return {
413-
ATTR_BUDGET: latest_irrigation.get(ATTR_BUDGET),
414-
ATTR_PROGRAM: latest_irrigation.get(ATTR_PROGRAM),
415-
ATTR_PROGRAM_NAME: latest_irrigation.get(ATTR_PROGRAM_NAME),
416-
ATTR_RUN_TIME: latest_irrigation.get(ATTR_RUN_TIME),
417-
ATTR_STATUS: latest_irrigation.get(ATTR_STATUS),
418-
ATTR_CONSUMPTION_GALLONS: gallons,
419-
ATTR_CONSUMPTION_LITRES: litres,
420-
ATTR_START_TIME: latest_irrigation.get(ATTR_START_TIME),
421-
}
422-
return {}
385+
gallons = latest.get("water_volume_gal")
386+
litres = round(gallons * 3.785, 2) if gallons else None
387+
388+
return {
389+
ATTR_BUDGET: latest.get(ATTR_BUDGET),
390+
ATTR_PROGRAM: latest.get(ATTR_PROGRAM),
391+
ATTR_PROGRAM_NAME: latest.get(ATTR_PROGRAM_NAME),
392+
ATTR_RUN_TIME: latest.get(ATTR_RUN_TIME),
393+
ATTR_STATUS: latest.get(ATTR_STATUS),
394+
ATTR_CONSUMPTION_GALLONS: gallons,
395+
ATTR_CONSUMPTION_LITRES: litres,
396+
ATTR_START_TIME: latest.get(ATTR_START_TIME),
397+
}
398+
399+
def _get_latest_irrigation(self) -> dict | None:
400+
"""
401+
Return the irrigation entry with the greatest start_time for this zone.
402+
403+
All statuses are considered — skipped runs can still report flow
404+
consumption. Ordering is derived from start_time rather than array
405+
position so we don't depend on how the upstream API sorts entries.
406+
"""
407+
latest: dict | None = None
408+
latest_start: str | None = None
409+
for history_item in self._get_device_history():
410+
for irrigation in history_item.get(ATTR_IRRIGATION, []):
411+
if irrigation.get("station") != self._zone_id:
412+
continue
413+
start_time = irrigation.get(ATTR_START_TIME)
414+
if not start_time:
415+
continue
416+
if latest_start is None or start_time > latest_start:
417+
latest_start = start_time
418+
latest = irrigation
419+
return latest
423420

424421
def _get_device_history(self) -> list:
425422
"""Get device history from coordinator."""

tests/test_sensor.py

Lines changed: 89 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,7 @@ def mock_zone_history_data() -> list:
125125
"program": "a",
126126
"program_name": "Morning Schedule",
127127
"run_time": 15,
128-
"status": "completed",
128+
"status": "complete",
129129
"water_volume_gal": 25.5,
130130
"start_time": "2020-01-09T20:15:00.000Z",
131131
}
@@ -461,10 +461,97 @@ async def test_zone_history_sensor_attributes(
461461
assert attrs["program"] == "a"
462462
assert attrs["program_name"] == "Morning Schedule"
463463
assert attrs["run_time"] == 15
464-
assert attrs["status"] == "completed"
464+
assert attrs["status"] == "complete"
465465
assert attrs["consumption_gallons"] == 25.5
466466
assert attrs["consumption_litres"] == 96.52
467467

468+
async def test_zone_history_sensor_picks_greatest_start_time(
469+
self,
470+
mock_sprinkler_device_with_battery: BHyveDevice,
471+
) -> None:
472+
"""Selection is by greatest start_time across all irrigation entries.
473+
474+
Skipped runs are not filtered — they still report consumption. The
475+
latest entry by timestamp wins regardless of array position or which
476+
history item it lives in.
477+
"""
478+
history = [
479+
{
480+
"irrigation": [
481+
{
482+
"station": "1",
483+
"budget": 100,
484+
"program": "e",
485+
"program_name": "Smart Watering",
486+
"run_time": 24,
487+
"status": "complete",
488+
"water_volume_gal": None,
489+
"start_time": "2026-04-18T20:00:00.000Z",
490+
},
491+
{
492+
"station": "1",
493+
"budget": 100,
494+
"program": "manual",
495+
"program_name": "manual",
496+
"run_time": 0.97,
497+
"status": "skipped",
498+
"water_volume_gal": 2,
499+
"start_time": "2026-04-19T04:36:14.000Z",
500+
},
501+
]
502+
},
503+
{
504+
"irrigation": [
505+
{
506+
"station": "1",
507+
"budget": 100,
508+
"program": "e",
509+
"program_name": "Smart Watering",
510+
"run_time": 22,
511+
"status": "complete",
512+
"water_volume_gal": 69,
513+
"start_time": "2026-04-16T20:00:04.000Z",
514+
}
515+
]
516+
},
517+
]
518+
coordinator = create_mock_coordinator(
519+
{
520+
"test-device-123": {
521+
"device": mock_sprinkler_device_with_battery,
522+
"history": history,
523+
"landscapes": {},
524+
}
525+
}
526+
)
527+
528+
description = SensorEntityDescription(
529+
key="zone_history",
530+
translation_key="zone_history",
531+
icon="mdi:history",
532+
device_class=SensorDeviceClass.TIMESTAMP,
533+
entity_category=EntityCategory.DIAGNOSTIC,
534+
)
535+
sensor = BHyveZoneHistorySensor(
536+
coordinator=coordinator,
537+
device=mock_sprinkler_device_with_battery,
538+
zone={"station": "1", "name": "Front Lawn"},
539+
zone_name="Front Lawn",
540+
description=description,
541+
)
542+
543+
# Greatest start_time is the 2026-04-19 manual skipped run.
544+
assert sensor.native_value is not None
545+
assert sensor.native_value.day == 19
546+
assert sensor.native_value.hour == 4
547+
548+
attrs = sensor.extra_state_attributes
549+
assert attrs["program"] == "manual"
550+
assert attrs["status"] == "skipped"
551+
assert attrs["run_time"] == 0.97
552+
assert attrs["consumption_gallons"] == 2
553+
assert attrs["consumption_litres"] == 7.57
554+
468555

469556
class TestSensorWebsocketEvents:
470557
"""Test sensor response to websocket events."""

0 commit comments

Comments
 (0)