ha-inlite

Home Assistant integration for in-lite
git clone https://git.stephank.nl/ha-inlite
Log | Files | Refs | README | LICENSE | ZIP

__init__.py (5857B)


      1 """The in-lite integration."""
      2 
      3 from __future__ import annotations
      4 
      5 import logging
      6 import sys
      7 from pathlib import Path
      8 
      9 import voluptuous as vol
     10 
     11 # Make the bundled inlite_ble package importable as a top-level module.
     12 # This allows `from inlite_ble.hub import ...` to work without pip-installing.
     13 _LIB_DIR = str(Path(__file__).parent / "lib")
     14 if _LIB_DIR not in sys.path:
     15     sys.path.insert(0, _LIB_DIR)
     16 
     17 from homeassistant.components import bluetooth
     18 from homeassistant.components.bluetooth import BluetoothScanningMode
     19 from homeassistant.components.bluetooth.match import BluetoothCallbackMatcher
     20 from homeassistant.config_entries import ConfigEntry
     21 from homeassistant.const import ATTR_DEVICE_ID, Platform
     22 from homeassistant.core import HomeAssistant, ServiceCall, callback
     23 from homeassistant.exceptions import ConfigEntryNotReady, ServiceValidationError
     24 from homeassistant.helpers import config_validation as cv
     25 from homeassistant.helpers.event import async_track_time_change
     26 from homeassistant.helpers.update_coordinator import UpdateFailed
     27 
     28 from .const import CONF_CONFIRM, CONF_GARDEN_ID, DOMAIN
     29 from .coordinator import InliteCoordinator
     30 # Preload the config flow with the integration package. Home Assistant imports
     31 # package-level modules in its import executor; loading this later for options
     32 # or setup would otherwise perform disk I/O from the event loop.
     33 from . import config_flow as _config_flow  # noqa: F401
     34 
     35 _LOGGER = logging.getLogger(__name__)
     36 
     37 PLATFORMS = [Platform.LIGHT]
     38 
     39 SERVICE_START_TEACH_IN = "start_teach_in"
     40 EVENT_TEACH_IN_PROGRESS = f"{DOMAIN}_teach_in_progress"
     41 DATA_COORDINATORS = "coordinators"
     42 
     43 type InliteConfigEntry = ConfigEntry[InliteCoordinator]
     44 
     45 
     46 async def async_setup_entry(hass: HomeAssistant, entry: InliteConfigEntry) -> bool:
     47     """Set up in-lite from a config entry."""
     48     coordinator = InliteCoordinator(hass, entry)
     49 
     50     @callback
     51     def _async_update_ble(
     52         service_info: bluetooth.BluetoothServiceInfoBleak,
     53         change: bluetooth.BluetoothChange,
     54     ) -> None:
     55         """Update the cached BLE device from advertisement data."""
     56         coordinator.update_ble_service_info(service_info)
     57 
     58     entry.async_on_unload(
     59         bluetooth.async_register_callback(
     60             hass,
     61             _async_update_ble,
     62             BluetoothCallbackMatcher(address=entry.data[CONF_GARDEN_ID]),
     63             BluetoothScanningMode.ACTIVE,
     64         )
     65     )
     66 
     67     try:
     68         await coordinator.async_config_entry_first_refresh()
     69     except UpdateFailed as err:
     70         raise ConfigEntryNotReady("Hub not reachable") from err
     71 
     72     entry.runtime_data = coordinator
     73 
     74     coordinators: dict[str, InliteCoordinator] = hass.data.setdefault(
     75         DOMAIN, {}
     76     ).setdefault(DATA_COORDINATORS, {})
     77     coordinators[entry.entry_id] = coordinator
     78     _async_register_services(hass)
     79 
     80     @callback
     81     def _async_sync_clock_daily(_: datetime) -> None:
     82         """Start the daily local clock synchronization without blocking HA."""
     83         hass.async_create_task(coordinator.async_sync_clock())
     84 
     85     # Synchronize immediately after a successful local setup and then at
     86     # 03:00 in Home Assistant's configured local timezone every day.
     87     hass.async_create_task(coordinator.async_sync_clock())
     88     entry.async_on_unload(
     89         async_track_time_change(hass, _async_sync_clock_daily, hour=3, minute=0, second=0)
     90     )
     91 
     92     @callback
     93     def _async_remove_coordinator() -> None:
     94         """Remove this entry's coordinator and its last shared service."""
     95         coordinators.pop(entry.entry_id, None)
     96         if not coordinators:
     97             hass.services.async_remove(DOMAIN, SERVICE_START_TEACH_IN)
     98 
     99     entry.async_on_unload(_async_remove_coordinator)
    100 
    101     # Disconnect hubs when the config entry is unloaded
    102     entry.async_on_unload(coordinator.async_shutdown)
    103 
    104     # Reload integration when options change (scan interval, idle disconnect)
    105     entry.async_on_unload(entry.add_update_listener(_async_options_updated))
    106 
    107     await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
    108     return True
    109 
    110 
    111 async def _async_options_updated(
    112     hass: HomeAssistant, entry: InliteConfigEntry
    113 ) -> None:
    114     """Reload when options are updated."""
    115     await hass.config_entries.async_reload(entry.entry_id)
    116 
    117 
    118 async def async_unload_entry(hass: HomeAssistant, entry: InliteConfigEntry) -> bool:
    119     """Unload a config entry."""
    120     return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
    121 
    122 
    123 @callback
    124 def _async_register_services(hass: HomeAssistant) -> None:
    125     """Register the explicit, confirmation-gated teach-in service once."""
    126     if hass.services.has_service(DOMAIN, SERVICE_START_TEACH_IN):
    127         return
    128 
    129     schema = vol.Schema(
    130         {
    131             vol.Required(ATTR_DEVICE_ID): vol.Coerce(int),
    132             vol.Required(CONF_CONFIRM): cv.boolean,
    133         }
    134     )
    135 
    136     async def _async_start_teach_in(call: ServiceCall) -> None:
    137         if not call.data[CONF_CONFIRM]:
    138             raise ServiceValidationError(
    139                 "Set confirm: true to start teach-in; it changes mesh group assignments."
    140             )
    141 
    142         device_id = call.data[ATTR_DEVICE_ID]
    143         coordinators: dict[str, InliteCoordinator] = hass.data[DOMAIN][
    144             DATA_COORDINATORS
    145         ]
    146         matches = [
    147             coordinator
    148             for coordinator in coordinators.values()
    149             if device_id in coordinator.hubs
    150         ]
    151         if len(matches) != 1:
    152             raise ServiceValidationError(
    153                 f"Expected exactly one configured in-lite hub with device_id {device_id}."
    154             )
    155         if not await matches[0].async_start_teach_in(device_id):
    156             raise ServiceValidationError("The hub did not acknowledge the teach-in request.")
    157 
    158     hass.services.async_register(
    159         DOMAIN, SERVICE_START_TEACH_IN, _async_start_teach_in, schema=schema
    160     )