Skip to content

Commit 172bed5

Browse files
committed
Fix bhyve service calls on valve entities
The switch→valve conversion registered services like enable_rain_delay, disable_rain_delay, set_manual_preset_runtime and start_program in the valve platform but never ported the corresponding methods onto BHyveZoneValve, so calling them with a valve entity errored out with "Service not implemented". The service handler also only looked up entities in the valve domain, breaking start_program on program switches. - Move websocket payload construction for rain delay and manual preset runtime onto BHyveClient, where the wire protocol belongs. - Give BHyveZoneValve thin service wrappers that delegate to the client. - Route the valve service handler through both VALVE_DOMAIN and SWITCH_DOMAIN so program switches can handle start_program, and surface unsupported (entity, service) pairs instead of aborting the whole loop. - Collapse BHyveRainDelaySwitch turn on/off onto the shared client methods.
1 parent 441c296 commit 172bed5

5 files changed

Lines changed: 156 additions & 48 deletions

File tree

custom_components/bhyve/pybhyve/client.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -276,3 +276,23 @@ async def send_message(self, payload: Any) -> None:
276276
"""Send a message via the websocket."""
277277
if self._websocket is not None:
278278
await self._websocket.send(payload)
279+
280+
async def set_rain_delay(self, device_id: str, hours: int) -> None:
281+
"""Set rain delay hours for a device. Use hours=0 to disable."""
282+
payload = {
283+
"event": "rain_delay",
284+
"device_id": device_id,
285+
"delay": hours,
286+
}
287+
_LOGGER.info("Setting rain delay: %s", payload)
288+
await self.send_message(payload)
289+
290+
async def set_manual_preset_runtime(self, device_id: str, minutes: int) -> None:
291+
"""Set the default watering runtime for a device."""
292+
payload = {
293+
"event": "set_manual_preset_runtime",
294+
"device_id": device_id,
295+
"seconds": minutes * 60,
296+
}
297+
_LOGGER.info("Setting manual preset runtime: %s", payload)
298+
await self.send_message(payload)

custom_components/bhyve/switch.py

Lines changed: 2 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -505,32 +505,8 @@ def extra_state_attributes(self) -> dict[str, Any]:
505505

506506
async def async_turn_on(self, **_kwargs: Any) -> None:
507507
"""Turn the switch on."""
508-
await self._enable_rain_delay()
508+
await self.coordinator.client.set_rain_delay(self._device_id, 24)
509509

510510
async def async_turn_off(self, **_kwargs: Any) -> None:
511511
"""Turn the switch off."""
512-
await self._disable_rain_delay()
513-
514-
async def _enable_rain_delay(self, hours: int = 24) -> None:
515-
"""Enable rain delay."""
516-
await self._set_rain_delay(hours)
517-
518-
async def _disable_rain_delay(self) -> None:
519-
"""Disable rain delay."""
520-
await self._set_rain_delay(0)
521-
522-
async def _set_rain_delay(self, hours: int) -> None:
523-
"""Set rain delay hours."""
524-
try:
525-
payload = {
526-
"event": "rain_delay",
527-
"device_id": self._device_id,
528-
"delay": hours,
529-
}
530-
_LOGGER.info("Setting rain delay: %s", payload)
531-
await self.coordinator.client.send_message(payload)
532-
# Coordinator updates via WebSocket event
533-
534-
except BHyveError as err:
535-
_LOGGER.warning("Failed to send to BHyve websocket message %s", err)
536-
raise
512+
await self.coordinator.client.set_rain_delay(self._device_id, 0)

custom_components/bhyve/valve.py

Lines changed: 34 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from typing import TYPE_CHECKING, Any
1010

1111
import voluptuous as vol
12+
from homeassistant.components.switch.const import DOMAIN as SWITCH_DOMAIN
1213
from homeassistant.components.valve import (
1314
ValveDeviceClass,
1415
ValveEntity,
@@ -207,20 +208,29 @@ async def async_service_handler(service: Any) -> None:
207208
params = {
208209
key: value for key, value in service.data.items() if key != ATTR_ENTITY_ID
209210
}
210-
entity_ids = service.data.get(ATTR_ENTITY_ID)
211-
component = hass.data.get(VALVE_DOMAIN)
212-
if entity_ids and component is not None:
213-
target_vales = [component.get_entity(entity) for entity in entity_ids]
214-
else:
215-
return
216-
211+
entity_ids = service.data.get(ATTR_ENTITY_ID) or []
217212
method_name = method["method"]
218213
_LOGGER.debug("Service handler: %s %s", method_name, params)
219214

220-
for entity in target_vales:
215+
valve_component = hass.data.get(VALVE_DOMAIN)
216+
switch_component = hass.data.get(SWITCH_DOMAIN)
217+
218+
for entity_id in entity_ids:
219+
entity = None
220+
if valve_component is not None:
221+
entity = valve_component.get_entity(entity_id)
222+
if entity is None and switch_component is not None:
223+
entity = switch_component.get_entity(entity_id)
224+
if entity is None:
225+
_LOGGER.error("Entity not found: %s", entity_id)
226+
continue
221227
if not hasattr(entity, method_name):
222-
_LOGGER.error("Service not implemented: %s", method_name)
223-
return
228+
_LOGGER.error(
229+
"Service %s is not supported by entity %s",
230+
method_name,
231+
entity_id,
232+
)
233+
continue
224234
await getattr(entity, method_name)(**params)
225235

226236
for service, details in SERVICE_TO_METHOD.items():
@@ -525,6 +535,20 @@ async def stop_watering(self) -> None:
525535
self._attr_is_closed = True
526536
await self._send_station_message(station_payload)
527537

538+
async def enable_rain_delay(self, hours: int = 24) -> None:
539+
"""Enable rain delay for the device."""
540+
await self.coordinator.client.set_rain_delay(self._device_id, hours)
541+
542+
async def disable_rain_delay(self) -> None:
543+
"""Disable rain delay for the device."""
544+
await self.coordinator.client.set_rain_delay(self._device_id, 0)
545+
546+
async def set_manual_preset_runtime(self, minutes: int) -> None:
547+
"""Set the default watering runtime for the device."""
548+
await self.coordinator.client.set_manual_preset_runtime(
549+
self._device_id, minutes
550+
)
551+
528552
async def async_open_valve(self) -> None:
529553
"""Open the valve."""
530554
run_time = self._manual_preset_runtime / 60

tests/test_switch.py

Lines changed: 8 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,8 @@ def create_mock_coordinator(devices: dict, programs: dict) -> MagicMock:
4040
coordinator.client.send_message = AsyncMock()
4141
coordinator.client.update_program = AsyncMock()
4242
coordinator.client.update_device = AsyncMock()
43+
coordinator.client.set_rain_delay = AsyncMock()
44+
coordinator.client.set_manual_preset_runtime = AsyncMock()
4345
return coordinator
4446

4547

@@ -811,12 +813,10 @@ async def test_rain_delay_switch_turn_on(
811813
# Turn on the switch
812814
await switch.async_turn_on()
813815

814-
# Verify send_message was called with delay=24
815-
mock_coordinator.client.send_message.assert_called_once()
816-
payload = mock_coordinator.client.send_message.call_args[0][0]
817-
assert payload["event"] == "rain_delay"
818-
assert payload["device_id"] == TEST_DEVICE_ID
819-
assert payload["delay"] == 24 # Default 24 hours
816+
# Verify set_rain_delay was called with the default 24 hours
817+
mock_coordinator.client.set_rain_delay.assert_called_once_with(
818+
TEST_DEVICE_ID, 24
819+
)
820820

821821
async def test_rain_delay_switch_turn_off(
822822
self,
@@ -846,12 +846,8 @@ async def test_rain_delay_switch_turn_off(
846846
# Turn off the switch
847847
await switch.async_turn_off()
848848

849-
# Verify send_message was called with delay=0
850-
coordinator.client.send_message.assert_called_once()
851-
payload = coordinator.client.send_message.call_args[0][0]
852-
assert payload["event"] == "rain_delay"
853-
assert payload["device_id"] == TEST_DEVICE_ID
854-
assert payload["delay"] == 0
849+
# Verify set_rain_delay was called with 0 hours to disable
850+
coordinator.client.set_rain_delay.assert_called_once_with(TEST_DEVICE_ID, 0)
855851

856852
async def test_rain_delay_switch_websocket_event(
857853
self,

tests/test_valve.py

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ def create_mock_coordinator(devices: dict, programs: dict | None = None) -> Magi
2626
coordinator.client.send_message = AsyncMock()
2727
coordinator.client.get_landscape = AsyncMock()
2828
coordinator.client.update_landscape = AsyncMock()
29+
coordinator.client.set_rain_delay = AsyncMock()
30+
coordinator.client.set_manual_preset_runtime = AsyncMock()
2931
return coordinator
3032

3133

@@ -352,3 +354,93 @@ async def test_valve_with_landscape_data(
352354
attrs = valve.extra_state_attributes
353355
assert attrs.get("landscape_image") == "https://example.com/landscape.jpg"
354356
assert attrs.get("sprinkler_type") == "spray"
357+
358+
359+
async def test_valve_enable_rain_delay(
360+
mock_sprinkler_device: BHyveDevice,
361+
mock_zone_data: BHyveZone,
362+
) -> None:
363+
"""Test enabling rain delay via the valve service method."""
364+
coordinator = create_mock_coordinator(
365+
{
366+
"test-device-123": {
367+
"device": mock_sprinkler_device,
368+
"history": [],
369+
"landscapes": {},
370+
}
371+
}
372+
)
373+
374+
valve = BHyveZoneValve(
375+
coordinator=coordinator,
376+
device=mock_sprinkler_device,
377+
zone=mock_zone_data,
378+
zone_name="Front Yard",
379+
device_programs=[],
380+
)
381+
382+
await valve.enable_rain_delay(hours=5)
383+
384+
coordinator.client.set_rain_delay.assert_called_once_with(
385+
mock_sprinkler_device["id"], 5
386+
)
387+
388+
389+
async def test_valve_disable_rain_delay(
390+
mock_sprinkler_device: BHyveDevice,
391+
mock_zone_data: BHyveZone,
392+
) -> None:
393+
"""Test disabling rain delay via the valve service method."""
394+
coordinator = create_mock_coordinator(
395+
{
396+
"test-device-123": {
397+
"device": mock_sprinkler_device,
398+
"history": [],
399+
"landscapes": {},
400+
}
401+
}
402+
)
403+
404+
valve = BHyveZoneValve(
405+
coordinator=coordinator,
406+
device=mock_sprinkler_device,
407+
zone=mock_zone_data,
408+
zone_name="Front Yard",
409+
device_programs=[],
410+
)
411+
412+
await valve.disable_rain_delay()
413+
414+
coordinator.client.set_rain_delay.assert_called_once_with(
415+
mock_sprinkler_device["id"], 0
416+
)
417+
418+
419+
async def test_valve_set_manual_preset_runtime(
420+
mock_sprinkler_device: BHyveDevice,
421+
mock_zone_data: BHyveZone,
422+
) -> None:
423+
"""Test setting manual preset runtime via the valve service method."""
424+
coordinator = create_mock_coordinator(
425+
{
426+
"test-device-123": {
427+
"device": mock_sprinkler_device,
428+
"history": [],
429+
"landscapes": {},
430+
}
431+
}
432+
)
433+
434+
valve = BHyveZoneValve(
435+
coordinator=coordinator,
436+
device=mock_sprinkler_device,
437+
zone=mock_zone_data,
438+
zone_name="Front Yard",
439+
device_programs=[],
440+
)
441+
442+
await valve.set_manual_preset_runtime(minutes=8)
443+
444+
coordinator.client.set_manual_preset_runtime.assert_called_once_with(
445+
mock_sprinkler_device["id"], 8
446+
)

0 commit comments

Comments
 (0)