Skip to content

Commit c53a360

Browse files
authored
Add device mode select entity for sprinkler timers (#384)
Closes #263
1 parent b059505 commit c53a360

5 files changed

Lines changed: 285 additions & 0 deletions

File tree

custom_components/bhyve/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@
4141

4242
PLATFORMS: list[Platform] = [
4343
Platform.BINARY_SENSOR,
44+
Platform.SELECT,
4445
Platform.SENSOR,
4546
Platform.SWITCH,
4647
Platform.VALVE,

custom_components/bhyve/select.py

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
"""Support for Orbit BHyve select entities."""
2+
3+
from __future__ import annotations
4+
5+
from dataclasses import dataclass
6+
from typing import TYPE_CHECKING
7+
8+
from homeassistant.components.select import SelectEntity, SelectEntityDescription
9+
from homeassistant.const import EntityCategory
10+
11+
from . import BHyveCoordinatorEntity
12+
from .const import DEVICE_SPRINKLER, DOMAIN
13+
14+
if TYPE_CHECKING:
15+
from homeassistant.config_entries import ConfigEntry
16+
from homeassistant.core import HomeAssistant
17+
from homeassistant.helpers.entity_platform import AddEntitiesCallback
18+
19+
from .coordinator import BHyveDataUpdateCoordinator
20+
from .pybhyve.typings import BHyveDevice
21+
22+
23+
@dataclass(frozen=True, kw_only=True)
24+
class BHyveSelectEntityDescription(SelectEntityDescription):
25+
"""Describes BHyve select entity."""
26+
27+
unique_id_suffix: str
28+
name: str = ""
29+
device_types: tuple[str, ...] = ()
30+
31+
32+
SELECT_TYPES: tuple[BHyveSelectEntityDescription, ...] = (
33+
BHyveSelectEntityDescription(
34+
key="device_mode",
35+
translation_key="device_mode",
36+
name="Device mode",
37+
icon="mdi:auto-mode",
38+
unique_id_suffix="device_mode",
39+
entity_category=EntityCategory.CONFIG,
40+
device_types=(DEVICE_SPRINKLER,),
41+
options=["auto", "off"],
42+
),
43+
)
44+
45+
46+
async def async_setup_entry(
47+
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
48+
) -> None:
49+
"""Set up the BHyve select platform from a config entry."""
50+
coordinator = hass.data[DOMAIN][entry.entry_id]["coordinator"]
51+
devices = hass.data[DOMAIN][entry.entry_id]["devices"]
52+
53+
entities = [
54+
BHyveSelectEntity(coordinator, device, description)
55+
for device in devices
56+
for description in SELECT_TYPES
57+
if device.get("type") in description.device_types
58+
]
59+
60+
async_add_entities(entities)
61+
62+
63+
class BHyveSelectEntity(BHyveCoordinatorEntity, SelectEntity):
64+
"""Define a BHyve select entity."""
65+
66+
entity_description: BHyveSelectEntityDescription
67+
_attr_has_entity_name = True
68+
69+
def __init__(
70+
self,
71+
coordinator: BHyveDataUpdateCoordinator,
72+
device: BHyveDevice,
73+
description: BHyveSelectEntityDescription,
74+
) -> None:
75+
"""Initialize the select entity."""
76+
self.entity_description = description
77+
self._attr_name = description.name
78+
super().__init__(coordinator, device)
79+
self._attr_unique_id = (
80+
f"{self._mac_address}:{self._device_id}:{description.unique_id_suffix}"
81+
)
82+
83+
@property
84+
def current_option(self) -> str | None:
85+
"""Return the current selected option."""
86+
status = self.device_data.get("status", {})
87+
return status.get("mode", status.get("run_mode"))
88+
89+
async def async_select_option(self, option: str) -> None:
90+
"""Change the selected option."""
91+
await self.coordinator.client.send_message(
92+
{
93+
"event": "change_mode",
94+
"device_id": self._device_id,
95+
"mode": option,
96+
}
97+
)

custom_components/bhyve/strings.json

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,15 @@
136136
}
137137
},
138138
"entity": {
139+
"select": {
140+
"device_mode": {
141+
"name": "Device mode",
142+
"state": {
143+
"auto": "Auto",
144+
"off": "Off"
145+
}
146+
}
147+
},
139148
"binary_sensor": {
140149
"flood": {
141150
"name": "Flood sensor",

custom_components/bhyve/translations/en.json

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,15 @@
136136
}
137137
},
138138
"entity": {
139+
"select": {
140+
"device_mode": {
141+
"name": "Device mode",
142+
"state": {
143+
"auto": "Auto",
144+
"off": "Off"
145+
}
146+
}
147+
},
139148
"binary_sensor": {
140149
"flood": {
141150
"name": "Flood sensor",

tests/test_select.py

Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
"""Test BHyve select entities."""
2+
3+
from unittest.mock import AsyncMock, MagicMock
4+
5+
from homeassistant.helpers.entity import EntityCategory
6+
7+
from custom_components.bhyve.coordinator import BHyveDataUpdateCoordinator
8+
from custom_components.bhyve.pybhyve.typings import BHyveDevice
9+
from custom_components.bhyve.select import SELECT_TYPES, BHyveSelectEntity
10+
11+
12+
def create_mock_coordinator(devices: dict, programs: dict | None = None) -> MagicMock:
13+
"""Create a mock coordinator with the given devices."""
14+
coordinator = MagicMock(spec=BHyveDataUpdateCoordinator)
15+
coordinator.data = {
16+
"devices": devices,
17+
"programs": programs or {},
18+
}
19+
coordinator.last_update_success = True
20+
coordinator.async_set_updated_data = MagicMock()
21+
coordinator.client = MagicMock()
22+
coordinator.client.send_message = AsyncMock()
23+
return coordinator
24+
25+
26+
async def test_select_initialization(
27+
mock_sprinkler_device: BHyveDevice,
28+
) -> None:
29+
"""Test select entity initialization."""
30+
coordinator = create_mock_coordinator(
31+
{
32+
"test-device-123": {
33+
"device": mock_sprinkler_device,
34+
"history": [],
35+
"landscapes": {},
36+
}
37+
}
38+
)
39+
40+
description = SELECT_TYPES[0]
41+
entity = BHyveSelectEntity(coordinator, mock_sprinkler_device, description)
42+
43+
assert entity._attr_name == "Device mode"
44+
assert entity.unique_id == (
45+
f"{mock_sprinkler_device['mac_address']}:"
46+
f"{mock_sprinkler_device['id']}:device_mode"
47+
)
48+
assert entity.entity_description.entity_category == EntityCategory.CONFIG
49+
assert entity.entity_description.options == ["auto", "off"]
50+
51+
52+
async def test_select_current_option(
53+
mock_sprinkler_device: BHyveDevice,
54+
) -> None:
55+
"""Test current_option returns run_mode from device status."""
56+
coordinator = create_mock_coordinator(
57+
{
58+
"test-device-123": {
59+
"device": mock_sprinkler_device,
60+
"history": [],
61+
"landscapes": {},
62+
}
63+
}
64+
)
65+
66+
entity = BHyveSelectEntity(coordinator, mock_sprinkler_device, SELECT_TYPES[0])
67+
68+
assert entity.current_option == "auto"
69+
70+
71+
async def test_select_option_off(
72+
mock_sprinkler_device: BHyveDevice,
73+
) -> None:
74+
"""Test selecting 'off' sends correct websocket message."""
75+
coordinator = create_mock_coordinator(
76+
{
77+
"test-device-123": {
78+
"device": mock_sprinkler_device,
79+
"history": [],
80+
"landscapes": {},
81+
}
82+
}
83+
)
84+
85+
entity = BHyveSelectEntity(coordinator, mock_sprinkler_device, SELECT_TYPES[0])
86+
87+
await entity.async_select_option("off")
88+
89+
coordinator.client.send_message.assert_called_once_with(
90+
{
91+
"event": "change_mode",
92+
"device_id": "test-device-123",
93+
"mode": "off",
94+
}
95+
)
96+
97+
98+
async def test_select_option_auto(
99+
mock_sprinkler_device: BHyveDevice,
100+
) -> None:
101+
"""Test selecting 'auto' sends correct websocket message."""
102+
coordinator = create_mock_coordinator(
103+
{
104+
"test-device-123": {
105+
"device": mock_sprinkler_device,
106+
"history": [],
107+
"landscapes": {},
108+
}
109+
}
110+
)
111+
112+
entity = BHyveSelectEntity(coordinator, mock_sprinkler_device, SELECT_TYPES[0])
113+
114+
await entity.async_select_option("auto")
115+
116+
coordinator.client.send_message.assert_called_once_with(
117+
{
118+
"event": "change_mode",
119+
"device_id": "test-device-123",
120+
"mode": "auto",
121+
}
122+
)
123+
124+
125+
async def test_select_current_option_from_websocket_event(
126+
mock_sprinkler_device: BHyveDevice,
127+
) -> None:
128+
"""Test current_option reads 'mode' field set by websocket events."""
129+
device_data = dict(mock_sprinkler_device)
130+
device_data["status"] = {"run_mode": "auto", "mode": "off"}
131+
device = BHyveDevice(device_data)
132+
133+
coordinator = create_mock_coordinator(
134+
{
135+
"test-device-123": {
136+
"device": device,
137+
"history": [],
138+
"landscapes": {},
139+
}
140+
}
141+
)
142+
143+
entity = BHyveSelectEntity(coordinator, device, SELECT_TYPES[0])
144+
145+
# 'mode' (from websocket event) takes precedence over 'run_mode'
146+
assert entity.current_option == "off"
147+
148+
149+
async def test_select_current_option_missing_status(
150+
mock_sprinkler_device: BHyveDevice,
151+
) -> None:
152+
"""Test current_option returns None when status is missing."""
153+
device_data = dict(mock_sprinkler_device)
154+
del device_data["status"]
155+
device_no_status = BHyveDevice(device_data)
156+
157+
coordinator = create_mock_coordinator(
158+
{
159+
"test-device-123": {
160+
"device": device_no_status,
161+
"history": [],
162+
"landscapes": {},
163+
}
164+
}
165+
)
166+
167+
entity = BHyveSelectEntity(coordinator, device_no_status, SELECT_TYPES[0])
168+
169+
assert entity.current_option is None

0 commit comments

Comments
 (0)