async def async_run_logs(config, address): conf = config["api"] port: int = int(conf[CONF_PORT]) password: str = conf[CONF_PASSWORD] noise_psk: Optional[str] = None if CONF_ENCRYPTION in conf: noise_psk = conf[CONF_ENCRYPTION][CONF_KEY] _LOGGER.info("Starting log output from %s using esphome API", address) zc = zeroconf.Zeroconf() cli = APIClient( address, port, password, client_info=f"ESPHome Logs {__version__}", noise_psk=noise_psk, ) first_connect = True def on_log(msg): time_ = datetime.now().time().strftime("[%H:%M:%S]") text = msg.message.decode("utf8", "backslashreplace") safe_print(time_ + text) async def on_connect(): nonlocal first_connect try: await cli.subscribe_logs( on_log, log_level=LogLevel.LOG_LEVEL_VERY_VERBOSE, dump_config=first_connect, ) first_connect = False except APIConnectionError: cli.disconnect() async def on_disconnect(): _LOGGER.warning("Disconnected from API") zc = zeroconf.Zeroconf() reconnect = ReconnectLogic( client=cli, on_connect=on_connect, on_disconnect=on_disconnect, zeroconf_instance=zc, ) await reconnect.start() try: while True: await asyncio.sleep(60) except KeyboardInterrupt: await reconnect.stop() zc.close()
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up the esphome component.""" host = entry.data[CONF_HOST] port = entry.data[CONF_PORT] password = entry.data[CONF_PASSWORD] noise_psk = entry.data.get(CONF_NOISE_PSK) device_id = None zeroconf_instance = await zeroconf.async_get_instance(hass) cli = APIClient( host, port, password, client_info=f"Home Assistant {const.__version__}", zeroconf_instance=zeroconf_instance, noise_psk=noise_psk, ) domain_data = DomainData.get(hass) entry_data = RuntimeEntryData( client=cli, entry_id=entry.entry_id, store=domain_data.get_or_create_store(hass, entry), ) domain_data.set_entry_data(entry, entry_data) async def on_stop(event: Event) -> None: """Cleanup the socket client on HA stop.""" await _cleanup_instance(hass, entry) # Use async_listen instead of async_listen_once so that we don't deregister # the callback twice when shutting down Home Assistant. # "Unable to remove unknown listener <function EventBus.async_listen_once.<locals>.onetime_listener>" entry_data.cleanup_callbacks.append( hass.bus.async_listen(EVENT_HOMEASSISTANT_STOP, on_stop)) @callback def async_on_state(state: EntityState) -> None: """Send dispatcher updates when a new state is received.""" entry_data.async_update_state(hass, state) @callback def async_on_service_call(service: HomeassistantServiceCall) -> None: """Call service when user automation in ESPHome config is triggered.""" domain, service_name = service.service.split(".", 1) service_data = service.data if service.data_template: try: data_template = { key: Template(value) # type: ignore[no-untyped-call] for key, value in service.data_template.items() } template.attach(hass, data_template) service_data.update( template.render_complex(data_template, service.variables)) except TemplateError as ex: _LOGGER.error("Error rendering data template for %s: %s", host, ex) return if service.is_event: # ESPHome uses servicecall packet for both events and service calls # Ensure the user can only send events of form 'esphome.xyz' if domain != "esphome": _LOGGER.error( "Can only generate events under esphome domain! (%s)", host) return # Call native tag scan if service_name == "tag_scanned": tag_id = service_data["tag_id"] hass.async_create_task( hass.components.tag.async_scan_tag(tag_id, device_id)) return hass.bus.async_fire(service.service, service_data) else: hass.async_create_task( hass.services.async_call(domain, service_name, service_data, blocking=True)) async def _send_home_assistant_state(entity_id: str, attribute: str | None, state: State | None) -> None: """Forward Home Assistant states to ESPHome.""" if state is None or (attribute and attribute not in state.attributes): return send_state = state.state if attribute: attr_val = state.attributes[attribute] # ESPHome only handles "on"/"off" for boolean values if isinstance(attr_val, bool): send_state = "on" if attr_val else "off" else: send_state = attr_val await cli.send_home_assistant_state(entity_id, attribute, str(send_state)) @callback def async_on_state_subscription(entity_id: str, attribute: str | None = None) -> None: """Subscribe and forward states for requested entities.""" async def send_home_assistant_state_event(event: Event) -> None: """Forward Home Assistant states updates to ESPHome.""" # Only communicate changes to the state or attribute tracked if (event.data.get("old_state") is not None and "new_state" in event.data and ((not attribute and event.data["old_state"].state == event.data["new_state"].state) or (attribute and attribute in event.data["old_state"].attributes and attribute in event.data["new_state"].attributes and event.data["old_state"].attributes[attribute] == event.data["new_state"].attributes[attribute]))): return await _send_home_assistant_state(event.data["entity_id"], attribute, event.data.get("new_state")) unsub = async_track_state_change_event( hass, [entity_id], send_home_assistant_state_event) entry_data.disconnect_callbacks.append(unsub) # Send initial state hass.async_create_task( _send_home_assistant_state(entity_id, attribute, hass.states.get(entity_id))) async def on_connect() -> None: """Subscribe to states and list entities on successful API login.""" nonlocal device_id try: entry_data.device_info = await cli.device_info() assert cli.api_version is not None entry_data.api_version = cli.api_version entry_data.available = True device_id = _async_setup_device_registry(hass, entry, entry_data.device_info) entry_data.async_update_device_state(hass) entity_infos, services = await cli.list_entities_services() await entry_data.async_update_static_infos(hass, entry, entity_infos) await _setup_services(hass, entry_data, services) await cli.subscribe_states(async_on_state) await cli.subscribe_service_calls(async_on_service_call) await cli.subscribe_home_assistant_states( async_on_state_subscription) hass.async_create_task(entry_data.async_save_to_store()) except APIConnectionError as err: _LOGGER.warning("Error getting initial data for %s: %s", host, err) # Re-connection logic will trigger after this await cli.disconnect() async def on_disconnect() -> None: """Run disconnect callbacks on API disconnect.""" for disconnect_cb in entry_data.disconnect_callbacks: disconnect_cb() entry_data.disconnect_callbacks = [] entry_data.available = False entry_data.async_update_device_state(hass) async def on_connect_error(err: Exception) -> None: """Start reauth flow if appropriate connect error type.""" if isinstance( err, (RequiresEncryptionAPIError, InvalidEncryptionKeyAPIError)): entry.async_start_reauth(hass) reconnect_logic = ReconnectLogic( client=cli, on_connect=on_connect, on_disconnect=on_disconnect, zeroconf_instance=zeroconf_instance, name=host, on_connect_error=on_connect_error, ) async def complete_setup() -> None: """Complete the config entry setup.""" infos, services = await entry_data.async_load_from_store() await entry_data.async_update_static_infos(hass, entry, infos) await _setup_services(hass, entry_data, services) await reconnect_logic.start() entry_data.cleanup_callbacks.append(reconnect_logic.stop_callback) hass.async_create_task(complete_setup()) return True