coordinator.py (9803B)
1 """Disconnected polling coordinator for Hunter BTT valves.""" 2 3 import asyncio 4 import logging 5 from contextlib import suppress 6 from dataclasses import dataclass 7 from datetime import datetime, timedelta 8 from typing import override 9 10 from bleak import BleakClient 11 from bleak.exc import BleakError 12 from bleak_retry_connector import establish_connection 13 from homeassistant.components import bluetooth 14 from homeassistant.config_entries import ConfigEntry 15 from homeassistant.const import CONF_ADDRESS 16 from homeassistant.core import HomeAssistant 17 from homeassistant.helpers import device_registry as dr 18 from homeassistant.helpers.device_registry import DeviceInfo 19 from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed 20 from homeassistant.util import dt as dt_util 21 from pyhunterbtt import ( 22 HunterBTTError, 23 HunterBTTSession, 24 HunterBTTState, 25 ProtocolError, 26 RuntimeActivity, 27 ) 28 29 from .const import ( 30 ACTIVE_REFRESH_INTERVAL, 31 CONF_PIN, 32 CONNECTION_TIMEOUT, 33 DEFAULT_MANUAL_DURATION_MINUTES, 34 DOMAIN, 35 END_GUARD_INTERVAL, 36 END_REFRESH_DELAY, 37 IDLE_REFRESH_INTERVAL, 38 ) 39 from .runtime import command_end_time 40 41 _LOGGER = logging.getLogger(__name__) 42 43 type HunterBTTConfigEntry = ConfigEntry[HunterBTTCoordinator] 44 45 46 def _operation_failed(message: str, err: Exception) -> UpdateFailed: 47 """Log and retain the specific BLE or protocol error for HA.""" 48 _LOGGER.debug("%s", message, exc_info=err) 49 detail = str(err) or type(err).__name__ 50 return UpdateFailed(f"{message}: {detail}") 51 52 53 @dataclass(frozen=True, slots=True) 54 class HunterBTTData: 55 """One disconnected status poll and its HA-calculated end time.""" 56 57 state: HunterBTTState 58 refreshed_at: datetime 59 end_time: datetime | None 60 61 @property 62 def is_watering(self) -> bool: 63 """Return the current FF82 watering state for either physical channel.""" 64 return bool(self.state.zone1_is_watering or self.state.zone2_is_watering) 65 66 67 class HunterBTTCoordinator(DataUpdateCoordinator[HunterBTTData]): 68 """Read status using a short-lived HA-managed BLE connection.""" 69 70 config_entry: HunterBTTConfigEntry 71 72 def __init__(self, hass: HomeAssistant, entry: HunterBTTConfigEntry) -> None: 73 """Initialize the coordinator with the low-power idle cadence.""" 74 super().__init__( 75 hass, 76 _LOGGER, 77 config_entry=entry, 78 name=DOMAIN, 79 update_interval=IDLE_REFRESH_INTERVAL, 80 ) 81 self._poll_lock = asyncio.Lock() 82 self.manual_duration_minutes = DEFAULT_MANUAL_DURATION_MINUTES 83 address = entry.data[CONF_ADDRESS] 84 self.device_info = DeviceInfo( 85 identifiers={(DOMAIN, address)}, 86 connections={(dr.CONNECTION_BLUETOOTH, address)}, 87 manufacturer="Hunter", 88 model="BTT", 89 name=entry.title, 90 ) 91 92 @override 93 async def _async_update_data(self) -> HunterBTTData: 94 """Connect, authenticate, read status, and disconnect.""" 95 async with self._poll_lock: 96 try: 97 state = await self._async_read_status() 98 except ( 99 BleakError, 100 HunterBTTError, 101 OSError, 102 ProtocolError, 103 TimeoutError, 104 ) as err: 105 msg = "Unable to refresh Hunter BTT status" 106 raise _operation_failed(msg, err) from err 107 108 refreshed_at = dt_util.utcnow() 109 data = HunterBTTData( 110 state=state, 111 refreshed_at=refreshed_at, 112 end_time=self._end_time(state, refreshed_at), 113 ) 114 self._set_refresh_interval(data, refreshed_at) 115 self._update_device_info(data) 116 return data 117 118 async def _async_read_status(self) -> HunterBTTState: 119 """Use Home Assistant's BLE connection helper for one status read.""" 120 address = self.config_entry.data[CONF_ADDRESS] 121 ble_device = bluetooth.async_ble_device_from_address( 122 self.hass, address, connectable=True 123 ) 124 if ble_device is None: 125 msg = "Hunter BTT is not currently reachable" 126 raise OSError(msg) 127 128 client: BleakClient | None = None 129 try: 130 async with asyncio.timeout(CONNECTION_TIMEOUT): 131 client = await establish_connection(BleakClient, ble_device, address) 132 return await HunterBTTSession(client).async_authenticate( 133 self.config_entry.data[CONF_PIN] 134 ) 135 finally: 136 if client is not None and client.is_connected: 137 with suppress(BleakError, OSError): 138 await client.disconnect() 139 140 async def async_start_manual_zone1(self) -> None: 141 """Start zone 1 for the selected, device-enforced manual duration.""" 142 duration_seconds = self.manual_duration_minutes * 60 143 await self._async_run_manual_command(duration_seconds) 144 145 async def async_stop_manual_zone1(self) -> None: 146 """Stop zone 1 with the observed BTT-101 manual-stop transaction.""" 147 await self._async_run_manual_command(None) 148 149 async def _async_run_manual_command(self, duration_seconds: int | None) -> None: 150 """Run one authenticated manual command and publish its returned status.""" 151 async with self._poll_lock: 152 try: 153 state = await self._async_execute_manual_command(duration_seconds) 154 except ( 155 BleakError, 156 HunterBTTError, 157 OSError, 158 ProtocolError, 159 TimeoutError, 160 ) as err: 161 msg = "Unable to change Hunter BTT watering" 162 raise _operation_failed(msg, err) from err 163 164 refreshed_at = dt_util.utcnow() 165 data = HunterBTTData( 166 state=state, 167 refreshed_at=refreshed_at, 168 end_time=self._command_end_time(state, refreshed_at, duration_seconds), 169 ) 170 self._set_refresh_interval(data, refreshed_at) 171 self._update_device_info(data) 172 self.async_set_updated_data(data) 173 174 @staticmethod 175 def _command_end_time( 176 state: HunterBTTState, now: datetime, duration_seconds: int | None 177 ) -> datetime | None: 178 """Use HA's requested duration when a just-started run is confirmed. 179 180 FF8A can return the previous manual-duration value in the immediate 181 read after FF86/FF83 writes. The selected duration is authoritative for 182 this HA-initiated start once FF82 confirms zone 1 is watering. 183 """ 184 if duration_seconds is not None and state.zone1_is_watering: 185 return now + timedelta(seconds=duration_seconds) 186 return command_end_time( 187 state.zone1_is_watering, 188 now, 189 duration_seconds, 190 HunterBTTCoordinator._end_time(state, now), 191 ) 192 193 async def _async_execute_manual_command( 194 self, duration_seconds: int | None 195 ) -> HunterBTTState: 196 """Connect, authenticate, execute one manual command, and disconnect.""" 197 address = self.config_entry.data[CONF_ADDRESS] 198 ble_device = bluetooth.async_ble_device_from_address( 199 self.hass, address, connectable=True 200 ) 201 if ble_device is None: 202 msg = "Hunter BTT is not currently reachable" 203 raise OSError(msg) 204 205 client: BleakClient | None = None 206 try: 207 async with asyncio.timeout(CONNECTION_TIMEOUT): 208 client = await establish_connection(BleakClient, ble_device, address) 209 session = HunterBTTSession(client) 210 await session.async_authenticate(self.config_entry.data[CONF_PIN]) 211 if duration_seconds is None: 212 return await session.async_stop_manual_zone1() 213 return await session.async_start_manual_zone1(duration_seconds) 214 finally: 215 if client is not None and client.is_connected: 216 with suppress(BleakError, OSError): 217 await client.disconnect() 218 219 @staticmethod 220 def _end_time(state: HunterBTTState, now: datetime) -> datetime | None: 221 """Calculate an end time from runtime values whose meaning is established.""" 222 runtime = state.runtime 223 if runtime is None: 224 return None 225 if runtime.activity is RuntimeActivity.APPLICATION_MANUAL: 226 return now + timedelta(seconds=runtime.manual_seconds) 227 if runtime.activity is RuntimeActivity.EXTERNAL_MANUAL: 228 return now + timedelta(seconds=runtime.external_manual_seconds) 229 if runtime.activity is RuntimeActivity.TIMER: 230 return now + timedelta(seconds=runtime.timer_seconds) 231 return None 232 233 def _set_refresh_interval(self, data: HunterBTTData, now: datetime) -> None: 234 """Apply the agreed idle/active/end-verification polling policy.""" 235 if not data.is_watering: 236 self.update_interval = IDLE_REFRESH_INTERVAL 237 return 238 if data.end_time is None: 239 self.update_interval = ACTIVE_REFRESH_INTERVAL 240 return 241 until_end = data.end_time - now 242 if until_end <= END_GUARD_INTERVAL: 243 self.update_interval = max(timedelta(0), until_end) + END_REFRESH_DELAY 244 return 245 self.update_interval = min( 246 ACTIVE_REFRESH_INTERVAL, 247 until_end - END_GUARD_INTERVAL, 248 ) 249 250 def _update_device_info(self, data: HunterBTTData) -> None: 251 """Update static device metadata without creating diagnostic entities.""" 252 self.device_info = DeviceInfo( 253 { 254 **self.device_info, 255 "sw_version": data.state.firmware_revision, 256 } 257 )