__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 )