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