Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down
20 changes: 19 additions & 1 deletion custom_components/bhyve/sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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, ...] = (
Expand Down Expand Up @@ -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,
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good pickup

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That was human ~ Claude wanted to give you a string. Kept telling me it was correct... 🤦‍♂️😂

~DAB

entity_category=base_description.entity_category,
value_fn=base_description.value_fn,
attributes_fn=base_description.attributes_fn,
Expand Down Expand Up @@ -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)
Expand Down
3 changes: 3 additions & 0 deletions custom_components/bhyve/strings.json
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,9 @@
"zone_history": {
"name": "{zone_name} zone history"
},
"next_watering": {
"name": "Next watering"
},
"temperature": {
"name": "Temperature"
},
Expand Down
3 changes: 3 additions & 0 deletions custom_components/bhyve/translations/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,9 @@
}
},
"sensor": {
"next_watering": {
"name": "Next watering"
},
"temperature": {
"name": "Temperature"
},
Expand Down
139 changes: 139 additions & 0 deletions tests/test_sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""

Expand Down Expand Up @@ -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 == {}
Loading