Skip to content

Commit 1ae755f

Browse files
authored
Merge branch 'sebr:main' into main
2 parents bb134b6 + e7903a5 commit 1ae755f

20 files changed

Lines changed: 1661 additions & 206 deletions

.devcontainer.json

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,8 @@
1818
"github.vscode-pull-request-github",
1919
"ms-python.python",
2020
"ms-python.vscode-pylance",
21-
"ryanluker.vscode-coverage-gutters"
21+
"ryanluker.vscode-coverage-gutters",
22+
"Anthropic.claude-code"
2223
],
2324
"settings": {
2425
"files.eol": "\n",
@@ -38,15 +39,14 @@
3839
},
3940
"remoteUser": "vscode",
4041
"features": {
41-
"ghcr.io/devcontainers-extra/features/apt-packages:1": {
42-
"packages": "ffmpeg,libturbojpeg0,libpcap-dev"
43-
},
42+
"ghcr.io/devcontainers/features/github-cli:1": {},
4443
"ghcr.io/devcontainers/features/git:1": {}
4544
},
4645
"remoteEnv": {
4746
"PATH": "${containerEnv:PATH}:/home/vscode/.local/bin"
4847
},
4948
"mounts": [
50-
"source=claude-config,target=/home/vscode/.config/claude,type=volume"
49+
"source=${localEnv:HOME}/.claude,target=/home/vscode/.claude,type=bind,consistency=cached",
50+
"source=${localEnv:HOME}/.gitconfig,target=/home/vscode/.gitconfig,type=bind,consistency=cached"
5151
]
5252
}

custom_components/bhyve/__init__.py

Lines changed: 39 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -18,14 +18,15 @@
1818
)
1919
from homeassistant.helpers import device_registry as dr
2020
from homeassistant.helpers.aiohttp_client import async_get_clientsession
21-
from homeassistant.helpers.device_registry import DeviceInfo
21+
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
2222
from homeassistant.helpers.typing import ConfigType
2323
from homeassistant.helpers.update_coordinator import CoordinatorEntity
2424

2525
from custom_components.bhyve.pybhyve.typings import BHyveDevice
2626

2727
from .const import (
2828
CONF_DEVICES,
29+
DEVICE_BRIDGE,
2930
DOMAIN,
3031
EVENT_PROGRAM_CHANGED,
3132
LOGGER,
@@ -103,6 +104,17 @@ async def async_update_callback(data: dict) -> None:
103104
programs = await client.timer_programs
104105
devices = filter_configured_devices(entry, all_devices)
105106

107+
# Build a mapping from device_gateway_topic to bridge device ID
108+
# so child devices can reference their bridge via via_device.
109+
# Bridges are always included by filter_configured_devices.
110+
gateway_to_bridge: dict[str, str] = {}
111+
for device in devices:
112+
if device.get("type") == DEVICE_BRIDGE:
113+
gateway_topic = device.get("device_gateway_topic")
114+
if gateway_topic:
115+
gateway_to_bridge[gateway_topic] = device.get("id", "")
116+
coordinator.gateway_to_bridge = gateway_to_bridge
117+
106118
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = {
107119
"client": client,
108120
"coordinator": coordinator,
@@ -146,9 +158,12 @@ async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None:
146158
# Get currently configured device IDs
147159
configured_ids = set(entry.options.get(CONF_DEVICES, []))
148160

149-
# Find devices that were removed from configuration
150-
all_device_ids = {str(d["id"]) for d in all_devices}
151-
removed_device_ids = all_device_ids - configured_ids
161+
# Find leaf devices that were removed from configuration
162+
# (bridges are always included and not user-selectable)
163+
leaf_device_ids = {
164+
str(d["id"]) for d in all_devices if d.get("type") != DEVICE_BRIDGE
165+
}
166+
removed_device_ids = leaf_device_ids - configured_ids
152167

153168
if removed_device_ids:
154169
# Remove devices from Home Assistant
@@ -183,8 +198,16 @@ def __init__(
183198
self._mac_address = device.get("mac_address")
184199

185200
# Device info for grouping
186-
self._attr_device_info = DeviceInfo(
201+
connections: set[tuple[str, str]] = set()
202+
if self._mac_address:
203+
# Format raw MAC (e.g. "4467552a366e") with colons
204+
raw = self._mac_address.replace(":", "").replace("-", "").lower()
205+
formatted_mac = ":".join(raw[i : i + 2] for i in range(0, len(raw), 2))
206+
connections.add((CONNECTION_NETWORK_MAC, formatted_mac))
207+
208+
device_info = DeviceInfo(
187209
identifiers={(DOMAIN, self._device_id)},
210+
connections=connections,
188211
manufacturer=MANUFACTURER,
189212
configuration_url=f"https://techsupport.orbitbhyve.com/dashboard/support/device/{self._device_id}",
190213
name=self._device_name,
@@ -193,11 +216,21 @@ def __init__(
193216
sw_version=device.get("firmware_version"),
194217
)
195218

219+
# Link non-bridge devices to their bridge via device_gateway_topic
220+
if self._device_type != DEVICE_BRIDGE:
221+
gateway_topic = device.get("device_gateway_topic")
222+
gateway_to_bridge = getattr(coordinator, "gateway_to_bridge", {})
223+
bridge_id = gateway_to_bridge.get(gateway_topic) if gateway_topic else None
224+
if bridge_id:
225+
device_info["via_device"] = (DOMAIN, bridge_id)
226+
227+
self._attr_device_info = device_info
228+
196229
LOGGER.debug(
197230
"Creating %s: %s - %s",
198231
self.__class__.__name__,
199232
self._device_name,
200-
self._device_type,
233+
getattr(self, "_attr_name", None) or self._device_name,
201234
)
202235

203236
@property

custom_components/bhyve/binary_sensor.py

Lines changed: 40 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,7 @@
1-
"""Support for Orbit BHyve sensors."""
1+
"""Support for Orbit BHyve binary sensors."""
22

33
from __future__ import annotations
44

5-
import logging
65
from dataclasses import dataclass
76
from typing import TYPE_CHECKING, Any
87

@@ -13,7 +12,7 @@
1312
)
1413

1514
from . import BHyveCoordinatorEntity
16-
from .const import DEVICE_FLOOD, DOMAIN
15+
from .const import DEVICE_BRIDGE, DEVICE_FLOOD, DEVICE_SPRINKLER, DOMAIN
1716

1817
if TYPE_CHECKING:
1918
from homeassistant.config_entries import ConfigEntry
@@ -23,14 +22,15 @@
2322
from .coordinator import BHyveDataUpdateCoordinator
2423
from .pybhyve.typings import BHyveDevice
2524

26-
_LOGGER = logging.getLogger(__name__)
27-
2825

2926
@dataclass(frozen=True, kw_only=True)
3027
class BHyveBinarySensorEntityDescription(BinarySensorEntityDescription):
3128
"""Describes BHyve binary sensor entity."""
3229

3330
unique_id_suffix: str
31+
name: str = ""
32+
# Device types this sensor applies to (e.g., DEVICE_FLOOD, DEVICE_SPRINKLER)
33+
device_types: tuple[str, ...] = ()
3434
# Callable that takes device_data and returns bool
3535
value_fn: Any = None
3636
# Callable that takes device_data and returns attributes
@@ -41,28 +41,54 @@ class BHyveBinarySensorEntityDescription(BinarySensorEntityDescription):
4141
BHyveBinarySensorEntityDescription(
4242
key="flood",
4343
translation_key="flood",
44-
name="flood sensor",
44+
name="Flood sensor",
4545
device_class=BinarySensorDeviceClass.MOISTURE,
4646
unique_id_suffix="water",
47+
device_types=(DEVICE_FLOOD,),
4748
value_fn=lambda data: (
4849
data.get("status", {}).get("flood_alarm_status") == "alarm"
4950
),
5051
attributes_fn=lambda data: {
5152
"location": data.get("location_name"),
52-
"shutoff": data.get("auto_shutoff"),
53-
"rssi": data.get("status", {}).get("rssi"),
53+
"auto_shutoff": data.get("auto_shutoff"),
5454
},
5555
),
5656
BHyveBinarySensorEntityDescription(
5757
key="temperature_alert",
5858
translation_key="temperature_alert",
59-
name="temperature alert",
60-
device_class=BinarySensorDeviceClass.HEAT,
59+
name="Temperature alert",
60+
device_class=BinarySensorDeviceClass.PROBLEM,
6161
unique_id_suffix="tempalert",
62+
device_types=(DEVICE_FLOOD,),
6263
value_fn=lambda data: (
6364
"alarm" in data.get("status", {}).get("temp_alarm_status", "")
6465
),
65-
attributes_fn=lambda data: data.get("temp_alarm_thresholds", {}),
66+
attributes_fn=lambda data: (
67+
thresh
68+
if isinstance(thresh := data.get("temp_alarm_thresholds"), dict)
69+
else {}
70+
),
71+
),
72+
BHyveBinarySensorEntityDescription(
73+
key="fault",
74+
translation_key="fault",
75+
name="Fault",
76+
device_class=BinarySensorDeviceClass.PROBLEM,
77+
unique_id_suffix="fault",
78+
device_types=(DEVICE_SPRINKLER,),
79+
value_fn=lambda data: bool(data.get("status", {}).get("station_faults")),
80+
attributes_fn=lambda data: {
81+
"station_faults": data.get("status", {}).get("station_faults", []),
82+
},
83+
),
84+
BHyveBinarySensorEntityDescription(
85+
key="connectivity",
86+
translation_key="connectivity",
87+
name="Connected",
88+
device_class=BinarySensorDeviceClass.CONNECTIVITY,
89+
unique_id_suffix="connectivity",
90+
device_types=(DEVICE_BRIDGE,),
91+
value_fn=lambda data: data.get("is_connected", False),
6692
),
6793
)
6894

@@ -77,8 +103,8 @@ async def async_setup_entry(
77103
entities = [
78104
BHyveBinarySensor(coordinator, device, description)
79105
for device in devices
80-
if device.get("type") == DEVICE_FLOOD
81106
for description in BINARY_SENSOR_TYPES
107+
if device.get("type") in description.device_types
82108
]
83109

84110
async_add_entities(entities)
@@ -88,6 +114,7 @@ class BHyveBinarySensor(BHyveCoordinatorEntity, BinarySensorEntity):
88114
"""Define a BHyve binary sensor."""
89115

90116
entity_description: BHyveBinarySensorEntityDescription
117+
_attr_has_entity_name = True
91118

92119
def __init__(
93120
self,
@@ -97,6 +124,7 @@ def __init__(
97124
) -> None:
98125
"""Initialize the sensor."""
99126
self.entity_description = description
127+
self._attr_name = description.name
100128
super().__init__(coordinator, device)
101129
self._attr_unique_id = (
102130
f"{self._mac_address}:{self._device_id}:{description.unique_id_suffix}"

custom_components/bhyve/const.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
DEVICE_FLOOD = "flood_sensor"
2121

2222
EVENT_BATTERY_STATUS = "battery_status"
23+
EVENT_FAULT = "fault"
2324
EVENT_CHANGE_MODE = "change_mode"
2425
EVENT_DEVICE_IDLE = "device_idle"
2526
EVENT_FS_ALARM = "fs_status_update"

custom_components/bhyve/coordinator.py

Lines changed: 89 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
EVENT_BATTERY_STATUS,
1616
EVENT_CHANGE_MODE,
1717
EVENT_DEVICE_IDLE,
18+
EVENT_FAULT,
1819
EVENT_FS_ALARM,
1920
EVENT_RAIN_DELAY,
2021
EVENT_SET_MANUAL_PRESET_TIME,
@@ -51,6 +52,7 @@ def __init__(
5152
)
5253
self.client = client
5354
self.entry = entry
55+
self.gateway_to_bridge: dict[str, str] = {}
5456

5557
async def _async_update_data(self) -> dict[str, Any]:
5658
"""Fetch data from API (periodic polling)."""
@@ -260,11 +262,30 @@ async def async_handle_device_event(self, event_data: dict[str, Any]) -> None:
260262
device_data["status"] = {}
261263
device_data["status"]["rain_delay"] = event_data.get("delay", 0)
262264

265+
elif event == EVENT_FAULT:
266+
# Update station fault information
267+
if "status" not in device_data:
268+
device_data["status"] = {}
269+
device_data["status"]["station_faults"] = event_data.get(
270+
"station_faults", []
271+
)
272+
263273
elif event == EVENT_FS_ALARM:
264-
# Update flood sensor status
274+
# Update flood sensor status with only meaningful flood sensor fields.
275+
# Avoid a blind merge that could overwrite unrelated device-level keys.
265276
if "status" not in device_data:
266277
device_data["status"] = {}
267-
device_data["status"].update(event_data)
278+
for key in (
279+
"flood_alarm_status",
280+
"temp_alarm_status",
281+
"temp_f",
282+
"rssi",
283+
"last_flood_alarm_at",
284+
"last_temp_alarm_at",
285+
"status_updated_at",
286+
):
287+
if key in event_data:
288+
device_data["status"][key] = event_data[key]
268289

269290
elif event == EVENT_SET_MANUAL_PRESET_TIME:
270291
# Update manual preset runtime
@@ -275,20 +296,81 @@ async def async_handle_device_event(self, event_data: dict[str, Any]) -> None:
275296
# Notify all listening entities
276297
self.async_set_updated_data(self.data)
277298

299+
def _set_zones_smart_watering(
300+
self, device_id: str | None, *, enabled: bool
301+
) -> None:
302+
"""Update smart_watering_enabled on all zones of a device."""
303+
if not device_id:
304+
return
305+
device_data = self.data.get("devices", {}).get(device_id, {})
306+
device = device_data.get("device", {})
307+
for zone in device.get("zones", []):
308+
zone["smart_watering_enabled"] = enabled
309+
310+
@staticmethod
311+
def _get_program_id(event_data: dict[str, Any]) -> str | None:
312+
"""Extract program ID from event data."""
313+
program_id = event_data.get("program_id")
314+
if not program_id:
315+
program = event_data.get("program", {})
316+
program_id = program.get("id") if isinstance(program, dict) else None
317+
return program_id
318+
278319
async def async_handle_program_event(self, event_data: dict[str, Any]) -> None:
279320
"""Handle WebSocket program events."""
280321
if not self.data:
281322
_LOGGER.debug("Coordinator data not initialized, ignoring event")
282323
return
283324

284-
program_id = event_data.get("program_id")
325+
lifecycle_phase = event_data.get("lifecycle_phase")
326+
program_id = self._get_program_id(event_data)
285327

286328
if not program_id:
287-
# Some program events have program in a different structure
288-
program = event_data.get("program", {})
289-
program_id = program.get("id") if isinstance(program, dict) else None
329+
_LOGGER.debug("No program_id found in event, ignoring")
330+
return
290331

291-
if not program_id or program_id not in self.data["programs"]:
332+
# Handle program creation
333+
if lifecycle_phase == "create":
334+
program_data = event_data.get("program")
335+
if isinstance(program_data, dict):
336+
_LOGGER.debug("Adding new program %s to coordinator data", program_id)
337+
self.data["programs"][program_id] = program_data
338+
if program_data.get("is_smart_program"):
339+
# Smart program created means smart watering was enabled
340+
device_id = event_data.get("device_id")
341+
self._set_zones_smart_watering(device_id, enabled=True)
342+
else:
343+
self.hass.bus.async_fire(
344+
"bhyve_program_created",
345+
{"program_id": program_id, "program": program_data},
346+
)
347+
self.async_set_updated_data(self.data)
348+
return
349+
350+
# Handle program deletion (smart programs use "destroy" but should
351+
# be kept as entities since they represent a toggle, not a removal)
352+
if lifecycle_phase in ("delete", "destroy"):
353+
is_smart = (
354+
self.data["programs"].get(program_id, {}).get("is_smart_program", False)
355+
)
356+
if not is_smart and program_id in self.data["programs"]:
357+
_LOGGER.debug("Removing program %s from coordinator data", program_id)
358+
del self.data["programs"][program_id]
359+
self.hass.bus.async_fire(
360+
"bhyve_program_deleted",
361+
{"program_id": program_id},
362+
)
363+
self.async_set_updated_data(self.data)
364+
return
365+
if is_smart:
366+
# Smart program destroy means smart watering was disabled.
367+
# Update zone data so smart watering switches reflect the change.
368+
device_id = event_data.get("device_id")
369+
self._set_zones_smart_watering(device_id, enabled=False)
370+
# Fall through to update the program data
371+
372+
# For update events, check if program exists
373+
if program_id not in self.data["programs"]:
292374
_LOGGER.debug(
293375
"Program %s not found in coordinator data, ignoring event",
294376
program_id,

custom_components/bhyve/manifest.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,5 +7,5 @@
77
"integration_type": "hub",
88
"iot_class": "cloud_push",
99
"issue_tracker": "https://github.com/sebr/bhyve-home-assistant/issues",
10-
"version": "4.0.0-beta0"
10+
"version": "4.0.0-beta1"
1111
}

0 commit comments

Comments
 (0)