ha-inlite

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

__init__.py (6015B)


      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 BLE_LOCAL_NAME, CONF_CONFIRM, 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     # Register a BLE callback that:
     51     # 1. Suppresses future discovery notifications for this device
     52     # 2. Keeps the BLE device reference fresh (critical for ESPHome proxies)
     53     @callback
     54     def _async_update_ble(
     55         service_info: bluetooth.BluetoothServiceInfoBleak,
     56         change: bluetooth.BluetoothChange,
     57     ) -> None:
     58         """Update the cached BLE device from advertisement data."""
     59         coordinator.update_ble_service_info(service_info)
     60 
     61     entry.async_on_unload(
     62         bluetooth.async_register_callback(
     63             hass,
     64             _async_update_ble,
     65             BluetoothCallbackMatcher(local_name=BLE_LOCAL_NAME),
     66             BluetoothScanningMode.PASSIVE,
     67         )
     68     )
     69 
     70     try:
     71         await coordinator.async_config_entry_first_refresh()
     72     except UpdateFailed as err:
     73         raise ConfigEntryNotReady("Hub not reachable") from err
     74 
     75     entry.runtime_data = coordinator
     76 
     77     coordinators: dict[str, InliteCoordinator] = hass.data.setdefault(
     78         DOMAIN, {}
     79     ).setdefault(DATA_COORDINATORS, {})
     80     coordinators[entry.entry_id] = coordinator
     81     _async_register_services(hass)
     82 
     83     def _async_sync_clock_daily(_: datetime) -> None:
     84         """Start the daily local clock synchronization without blocking HA."""
     85         hass.async_create_task(coordinator.async_sync_clock())
     86 
     87     # Synchronize immediately after a successful local setup and then at
     88     # 03:00 in Home Assistant's configured local timezone every day.
     89     hass.async_create_task(coordinator.async_sync_clock())
     90     entry.async_on_unload(
     91         async_track_time_change(hass, _async_sync_clock_daily, hour=3, minute=0, second=0)
     92     )
     93 
     94     @callback
     95     def _async_remove_coordinator() -> None:
     96         """Remove this entry's coordinator and its last shared service."""
     97         coordinators.pop(entry.entry_id, None)
     98         if not coordinators:
     99             hass.services.async_remove(DOMAIN, SERVICE_START_TEACH_IN)
    100 
    101     entry.async_on_unload(_async_remove_coordinator)
    102 
    103     # Disconnect hubs when the config entry is unloaded
    104     entry.async_on_unload(coordinator.async_shutdown)
    105 
    106     # Reload integration when options change (scan interval, idle disconnect)
    107     entry.async_on_unload(entry.add_update_listener(_async_options_updated))
    108 
    109     await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
    110     return True
    111 
    112 
    113 async def _async_options_updated(
    114     hass: HomeAssistant, entry: InliteConfigEntry
    115 ) -> None:
    116     """Reload when options are updated."""
    117     await hass.config_entries.async_reload(entry.entry_id)
    118 
    119 
    120 async def async_unload_entry(hass: HomeAssistant, entry: InliteConfigEntry) -> bool:
    121     """Unload a config entry."""
    122     return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
    123 
    124 
    125 @callback
    126 def _async_register_services(hass: HomeAssistant) -> None:
    127     """Register the explicit, confirmation-gated teach-in service once."""
    128     if hass.services.has_service(DOMAIN, SERVICE_START_TEACH_IN):
    129         return
    130 
    131     schema = vol.Schema(
    132         {
    133             vol.Required(ATTR_DEVICE_ID): vol.Coerce(int),
    134             vol.Required(CONF_CONFIRM): cv.boolean,
    135         }
    136     )
    137 
    138     async def _async_start_teach_in(call: ServiceCall) -> None:
    139         if not call.data[CONF_CONFIRM]:
    140             raise ServiceValidationError(
    141                 "Set confirm: true to start teach-in; it changes mesh group assignments."
    142             )
    143 
    144         device_id = call.data[ATTR_DEVICE_ID]
    145         coordinators: dict[str, InliteCoordinator] = hass.data[DOMAIN][
    146             DATA_COORDINATORS
    147         ]
    148         matches = [
    149             coordinator
    150             for coordinator in coordinators.values()
    151             if device_id in coordinator.hubs
    152         ]
    153         if len(matches) != 1:
    154             raise ServiceValidationError(
    155                 f"Expected exactly one configured in-lite hub with device_id {device_id}."
    156             )
    157         if not await matches[0].async_start_teach_in(device_id):
    158             raise ServiceValidationError("The hub did not acknowledge the teach-in request.")
    159 
    160     hass.services.async_register(
    161         DOMAIN, SERVICE_START_TEACH_IN, _async_start_teach_in, schema=schema
    162     )