ha-hunterbtt

Home Assistant integration for Hunter BTT
git clone https://git.stephank.nl/ha-hunterbtt
Log | Files | Refs | README | LICENSE | ZIP

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         )