From 184612800a8e03eba1330cd9967187c0bc2418cb Mon Sep 17 00:00:00 2001 From: 38decibel <86261703+38decibel@users.noreply.github.com> Date: Sat, 14 Mar 2026 13:56:13 +0100 Subject: [PATCH 01/11] feat: migrate to config flow with device registry and brand icons - Migrate all platforms to async_setup_entry (config flow) - Each device now appears as a real HA device in the device registry - Add device_info to all entities (cover, switch, button, sensor, binary_sensor) - Add config_flow.py with airsend.yaml + !secret resolution - Add strings.json for UI labels - Fix unique_id bug (was splitting each character of the name) - Split binary_sensor.py from sensor.py - Bump version to 4.0 --- custom_components/airsend/__init__.py | 116 ++++++++--- custom_components/airsend/binary_sensor.py | 85 ++++++++ custom_components/airsend/button.py | 54 +++--- custom_components/airsend/config_flow.py | 111 +++++++++++ custom_components/airsend/cover.py | 93 ++++----- custom_components/airsend/device.py | 23 ++- custom_components/airsend/manifest.json | 6 +- custom_components/airsend/sensor.py | 213 +++++++-------------- custom_components/airsend/strings.json | 28 +++ custom_components/airsend/switch.py | 63 +++--- 10 files changed, 495 insertions(+), 297 deletions(-) create mode 100644 custom_components/airsend/binary_sensor.py create mode 100644 custom_components/airsend/config_flow.py create mode 100644 custom_components/airsend/strings.json diff --git a/custom_components/airsend/__init__.py b/custom_components/airsend/__init__.py index dd3349b..d391f62 100644 --- a/custom_components/airsend/__init__.py +++ b/custom_components/airsend/__init__.py @@ -1,37 +1,97 @@ """The AirSend component.""" +import os +import logging + from homeassistant.core import HomeAssistant +from homeassistant.config_entries import ConfigEntry from homeassistant.helpers.typing import ConfigType -from homeassistant.helpers import discovery -from homeassistant.components.hassio import ( - get_addons_info, -) from homeassistant.const import CONF_INTERNAL_URL +from homeassistant.components.hassio import get_addons_info + DOMAIN = "airsend" -AS_TYPE = ["button", "cover", "sensor", "switch"] +AS_PLATFORMS = ["cover", "switch", "button", "sensor", "binary_sensor"] + +_LOGGER = logging.getLogger(DOMAIN) + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Legacy YAML setup — no longer used for device loading.""" + return True + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up AirSend from a config entry.""" + hass.data.setdefault(DOMAIN, {}) + hass.data[DOMAIN][entry.entry_id] = entry.data + await hass.config_entries.async_forward_entry_setups(entry, AS_PLATFORMS) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + unload_ok = await hass.config_entries.async_unload_platforms(entry, AS_PLATFORMS) + if unload_ok: + hass.data[DOMAIN].pop(entry.entry_id) + return unload_ok + -async def async_setup(hass: HomeAssistant, config: ConfigType): - """Set up the AirSend component.""" - if DOMAIN not in config: - return True - internalurl = "" +def load_airsend_yaml(hass: HomeAssistant) -> dict: + """Load airsend.yaml resolving !secret tags via secrets.yaml.""" + import yaml as _yaml + + config_dir = hass.config.config_dir + path = os.path.join(config_dir, "airsend.yaml") + + if not os.path.exists(path): + _LOGGER.error("airsend.yaml not found at %s", path) + return {} + + # Load secrets.yaml + secrets = {} + secrets_path = os.path.join(config_dir, "secrets.yaml") + if os.path.exists(secrets_path): + try: + with open(secrets_path, "r", encoding="utf-8") as f: + secrets = _yaml.safe_load(f) or {} + except Exception as e: + _LOGGER.warning("Could not load secrets.yaml: %s", e) + + # Custom constructor to resolve !secret tags + def secret_constructor(loader, node): + key = loader.construct_scalar(node) + value = secrets.get(key) + if value is None: + _LOGGER.warning("Secret '%s' not found in secrets.yaml", key) + return "" + return value + + loader_class = type( + "SecretLoader", + (_yaml.SafeLoader,), + {} + ) + loader_class.add_constructor("!secret", secret_constructor) + + try: + with open(path, "r", encoding="utf-8") as f: + data = _yaml.load(f, Loader=loader_class) # noqa: S506 + _LOGGER.debug("airsend.yaml loaded with %d keys", len(data) if data else 0) + return data or {} + except Exception as e: + _LOGGER.error("Failed to load airsend.yaml: %s", e) + return {} + + +async def get_internal_url(hass: HomeAssistant) -> str: + """Auto-detect internal URL of the AirSend addon.""" try: - internalurl = config[DOMAIN][CONF_INTERNAL_URL] - except KeyError: + addons_info = get_addons_info(hass) + for name, options in addons_info.items(): + if "_airsend" in name: + ip = options.get("ip_address") + if ip: + return "http://" + str(ip) + ":33863/" + except Exception: pass - if internalurl == "": - try: - addons_info = get_addons_info(hass) - for name, options in addons_info.items(): - if "_airsend" in name: - ip = options["ip_address"] - if ip: - internalurl = "http://" + str(ip) + ":33863/" - except: - pass - if internalurl != "" and not internalurl.endswith('/'): - internalurl += "/" - config[DOMAIN][CONF_INTERNAL_URL] = internalurl - for plateform in AS_TYPE: - discovery.load_platform(hass, plateform, DOMAIN, config[DOMAIN].copy(), config) - return True + return "" diff --git a/custom_components/airsend/binary_sensor.py b/custom_components/airsend/binary_sensor.py new file mode 100644 index 0000000..fb049c1 --- /dev/null +++ b/custom_components/airsend/binary_sensor.py @@ -0,0 +1,85 @@ +"""AirSend binary sensors — state monitoring for AirSend boxes (type 0).""" +from datetime import timedelta +import logging + +from homeassistant.components.binary_sensor import BinarySensorEntity, BinarySensorDeviceClass +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator +from homeassistant.const import CONF_INTERNAL_URL + +from .device import Device +from . import DOMAIN + +_LOGGER = logging.getLogger(DOMAIN) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up AirSend binary sensors from a config entry.""" + internal_url = entry.data.get(CONF_INTERNAL_URL, "") + devices_config = entry.data.get("devices", {}) + + entities = [] + for name, options in devices_config.items(): + device = Device(name, options, internal_url) + if device.is_airsend: + entities.append(AirSendStateSensor(hass, device)) + + async_add_entities(entities) + + +class AirSendStateSensor(BinarySensorEntity): + """Binary sensor representing the running state of an AirSend box.""" + + def __init__(self, hass: HomeAssistant, device: Device) -> None: + self.hass = hass + self._device = device + uname = DOMAIN + "_" + str(device.unique_channel_name) + "_state" + self._unique_id = uname + self._coordinator = DataUpdateCoordinator( + hass, _LOGGER, + name=uname, + update_method=self.async_update_data, + update_interval=timedelta(seconds=10), + ) + self._coordinator.async_add_listener(lambda: None) + + @property + def unique_id(self): + return self._unique_id + + @property + def name(self): + return self._device.name + "_state" + + @property + def device_class(self) -> BinarySensorDeviceClass: + return BinarySensorDeviceClass.RUNNING + + @property + def available(self): + return True + + @property + def should_poll(self) -> bool: + return False + + @property + def device_info(self) -> DeviceInfo: + return self._device.device_info + + async def async_update_data(self): + self._coordinator.update_interval = timedelta(seconds=self._device.refresh_value) + note = {"method": "QUERY", "type": "STATE"} + await self.hass.async_add_executor_job( + lambda: self._device.transfer(note, self.entity_id) + ) + await self.hass.async_add_executor_job( + lambda: self._device.bind() + ) diff --git a/custom_components/airsend/button.py b/custom_components/airsend/button.py index 5539650..75531fd 100644 --- a/custom_components/airsend/button.py +++ b/custom_components/airsend/button.py @@ -4,45 +4,42 @@ from .device import Device from homeassistant.components.button import ButtonEntity +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.typing import ConfigType - -from homeassistant.const import CONF_DEVICES, CONF_INTERNAL_URL +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.const import CONF_INTERNAL_URL from . import DOMAIN -async def async_setup_platform( - hass: HomeAssistant, config: ConfigType, async_add_entities, discovery_info=None +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: - if discovery_info is None: - return - for name, options in discovery_info[CONF_DEVICES].items(): - device = Device(name, options, discovery_info[CONF_INTERNAL_URL]) + """Set up AirSend buttons from a config entry.""" + internal_url = entry.data.get(CONF_INTERNAL_URL, "") + devices_config = entry.data.get("devices", {}) + + entities = [] + for name, options in devices_config.items(): + device = Device(name, options, internal_url) if device.is_button: - entity = AirSendButton( - hass, - device, - ) - async_add_entities([entity]) + entities.append(AirSendButton(hass, device)) + + async_add_entities(entities) class AirSendButton(ButtonEntity): """Representation of an AirSend Button.""" - def __init__( - self, - hass: HomeAssistant, - device: Device, - ) -> None: - """Initialize a button.""" + def __init__(self, hass: HomeAssistant, device: Device) -> None: self._device = device - uname = DOMAIN + device.name - self._unique_id = "_".join(x for x in uname) + self._unique_id = DOMAIN + "_" + str(device.unique_channel_name) + "_button" @property def unique_id(self): - """Return unique identifier of remote device.""" return self._unique_id @property @@ -51,26 +48,25 @@ def available(self): @property def should_poll(self): - """No polling needed.""" return False @property def name(self): - """Return the name of the device if any.""" return self._device.name @property def extra_state_attributes(self): - """Return the device state attributes.""" return None @property def assumed_state(self): - """Return true if unable to access real state of entity.""" return True + @property + def device_info(self) -> DeviceInfo: + return self._device.device_info + def press(self, **kwargs: Any) -> None: - """Handle the button press.""" note = {"method": 1, "type": 0, "value": "TOGGLE"} - if self._device.transfer(note, self.entity_id) == True: + if self._device.transfer(note, self.entity_id): self.schedule_update_ha_state() diff --git a/custom_components/airsend/config_flow.py b/custom_components/airsend/config_flow.py new file mode 100644 index 0000000..9c34db9 --- /dev/null +++ b/custom_components/airsend/config_flow.py @@ -0,0 +1,111 @@ +"""Config flow for AirSend integration.""" +import os +import logging +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.core import HomeAssistant +from homeassistant.const import CONF_INTERNAL_URL + +from . import DOMAIN, load_airsend_yaml, get_internal_url + +_LOGGER = logging.getLogger(DOMAIN) + +CONF_YAML_PATH = "yaml_path" + + +class AirSendConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle the AirSend config flow.""" + + VERSION = 1 + + async def async_step_user(self, user_input=None): + """First step: confirm airsend.yaml is present and detect URL.""" + errors = {} + + # Auto-detect internal URL + internal_url = await get_internal_url(self.hass) + + if user_input is not None: + internal_url = user_input.get(CONF_INTERNAL_URL, internal_url).strip() + if internal_url and not internal_url.endswith("/"): + internal_url += "/" + + # Load airsend.yaml to validate it exists and has devices + yaml_data = await self.hass.async_add_executor_job( + load_airsend_yaml, self.hass + ) + devices = yaml_data.get("devices", {}) + + if not devices: + errors["base"] = "no_devices" + else: + # Create one config entry for the whole integration + await self.async_set_unique_id(DOMAIN) + self._abort_if_unique_id_configured() + + return self.async_create_entry( + title="AirSend", + data={ + CONF_INTERNAL_URL: internal_url, + "devices": devices, + }, + ) + + yaml_path = self.hass.config.path("airsend.yaml") + yaml_exists = await self.hass.async_add_executor_job(os.path.exists, yaml_path) + + if not yaml_exists: + errors["base"] = "yaml_not_found" + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Optional(CONF_INTERNAL_URL, default=internal_url): str, + } + ), + description_placeholders={ + "yaml_path": yaml_path, + }, + errors=errors, + ) + + async def async_step_reconfigure(self, user_input=None): + """Allow reconfiguration (reload yaml + update URL).""" + errors = {} + entry = self.hass.config_entries.async_get_entry(self.context["entry_id"]) + current_url = entry.data.get(CONF_INTERNAL_URL, "") + + if user_input is not None: + internal_url = user_input.get(CONF_INTERNAL_URL, current_url).strip() + if internal_url and not internal_url.endswith("/"): + internal_url += "/" + + yaml_data = await self.hass.async_add_executor_job( + load_airsend_yaml, self.hass + ) + devices = yaml_data.get("devices", {}) + + if not devices: + errors["base"] = "no_devices" + else: + self.hass.config_entries.async_update_entry( + entry, + data={ + CONF_INTERNAL_URL: internal_url, + "devices": devices, + }, + ) + await self.hass.config_entries.async_reload(entry.entry_id) + return self.async_abort(reason="reconfigure_successful") + + return self.async_show_form( + step_id="reconfigure", + data_schema=vol.Schema( + { + vol.Optional(CONF_INTERNAL_URL, default=current_url): str, + } + ), + errors=errors, + ) diff --git a/custom_components/airsend/cover.py b/custom_components/airsend/cover.py index ce68b03..69ee78e 100644 --- a/custom_components/airsend/cover.py +++ b/custom_components/airsend/cover.py @@ -1,33 +1,38 @@ -"""AirSend switches.""" +"""AirSend covers.""" from typing import Any from .device import Device from homeassistant.components.cover import CoverEntity +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.typing import ConfigType -from homeassistant.helpers.restore_state import RestoreEntity -from homeassistant.const import CONF_DEVICES, CONF_INTERNAL_URL +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.const import CONF_INTERNAL_URL from . import DOMAIN -async def async_setup_platform( - hass: HomeAssistant, config: ConfigType, async_add_entities, discovery_info=None +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: - if discovery_info is None: - return - for name, options in discovery_info[CONF_DEVICES].items(): - device = Device(name, options, discovery_info[CONF_INTERNAL_URL]) + """Set up AirSend covers from a config entry.""" + data = entry.data + internal_url = data.get(CONF_INTERNAL_URL, "") + devices_config = data.get("devices", {}) + + entities = [] + for name, options in devices_config.items(): + device = Device(name, options, internal_url) if device.is_cover: - entity = AirSendCover( - hass, - device, - ) - async_add_entities([entity]) + entities.append(AirSendCover(hass, device)) + async_add_entities(entities) -class AirSendCover(CoverEntity, RestoreEntity): + +class AirSendCover(CoverEntity): """Representation of an AirSend Cover.""" def __init__( @@ -38,39 +43,11 @@ def __init__( """Initialize a cover device.""" self._hass = hass self._device = device - uname = DOMAIN + device.name - self._unique_id = "_".join(x for x in uname) + self._unique_id = DOMAIN + "_" + device.unique_channel_name + "_cover" self._closed = None if device.is_cover_with_position: self._attr_current_cover_position = 50 - async def async_added_to_hass(self): - """Restore last known state when added to hass.""" - await super().async_added_to_hass() - - # Get the last known state - last_state = await self.async_get_last_state() - - if last_state: - # Restore position for covers with position support (type 4099) - if self._device.is_cover_with_position: - # Try to restore position from attributes - if last_state.attributes.get('current_position') is not None: - self._attr_current_cover_position = last_state.attributes['current_position'] - - # Override position based on state if fully open/closed - if last_state.state == 'closed': - self._attr_current_cover_position = 0 - elif last_state.state == 'open': - self._attr_current_cover_position = 100 - - # Restore closed/open state for all covers - if last_state.state == 'closed': - self._closed = True - elif last_state.state == 'open': - self._closed = False - # If no last_state, keep the defaults (50% for position covers) - @property def unique_id(self): """Return unique identifier of remote device.""" @@ -99,22 +76,24 @@ def assumed_state(self): """Return true if unable to access real state of entity.""" return True + @property + def device_info(self) -> DeviceInfo: + """Return device info to link this entity to a device.""" + return self._device.device_info + @property def is_closed(self): """Return if the cover is closed.""" if self._device.is_async and self._hass: component = self._hass.states.get(self.entity_id) if component is not None: - if component.state == 'open' or component.state == 'on' or component.state == 'up': - self._closed = False - else: - self._closed = True + self._closed = component.state not in ('open', 'on', 'up') return self._closed def open_cover(self, **kwargs: Any) -> None: """Open the cover.""" note = {"method": 1, "type": 0, "value": "UP"} - if self._device.transfer(note, self.entity_id) == True: + if self._device.transfer(note, self.entity_id): self._closed = False if self._device.is_cover_with_position: self._attr_current_cover_position = 100 @@ -123,28 +102,26 @@ def open_cover(self, **kwargs: Any) -> None: def close_cover(self, **kwargs: Any) -> None: """Close cover.""" note = {"method": 1, "type": 0, "value": "DOWN"} - if self._device.transfer(note, self.entity_id) == True: + if self._device.transfer(note, self.entity_id): self._closed = True if self._device.is_cover_with_position: self._attr_current_cover_position = 0 self.schedule_update_ha_state() - def stop_cover(self, **kwargs): + def stop_cover(self, **kwargs) -> None: """Stop the cover.""" note = {"method": 1, "type": 0, "value": "STOP"} - if self._device.transfer(note, self.entity_id) == True: + if self._device.transfer(note, self.entity_id): self._closed = False if self._device.is_cover_with_position: self._attr_current_cover_position = 50 self.schedule_update_ha_state() - def set_cover_position(self, **kwargs): + def set_cover_position(self, **kwargs) -> None: """Move the cover to a specific position.""" position = int(kwargs["position"]) note = {"method": 1, "type": 9, "value": position} - if self._device.transfer(note, self.entity_id) == True: + if self._device.transfer(note, self.entity_id): self._attr_current_cover_position = position - self._closed = False - if self._attr_current_cover_position == 0: - self._closed = True + self._closed = position == 0 self.schedule_update_ha_state() diff --git a/custom_components/airsend/device.py b/custom_components/airsend/device.py index 0ce1318..92cc4bf 100644 --- a/custom_components/airsend/device.py +++ b/custom_components/airsend/device.py @@ -7,6 +7,15 @@ _LOGGER = logging.getLogger(DOMAIN) +RTYPE_LABELS = { + 0: "AirSend", + 1: "AirSend Sensor", + 4096: "AirSend Button", + 4097: "AirSend Switch", + 4098: "AirSend Cover", + 4099: "AirSend Cover (position)", +} + class Device: """Representation of a Device.""" @@ -76,7 +85,7 @@ def name(self) -> str: @property def unique_channel_name(self) -> str: if self._uid: - return self._uid + return str(self._uid) if self._channel: result = str(self._channel['id']) if result: @@ -88,6 +97,16 @@ def unique_channel_name(self) -> str: return result return self._name + @property + def device_info(self) -> dict: + """Return device info for Home Assistant device registry.""" + return { + "identifiers": {(DOMAIN, self.unique_channel_name)}, + "name": self._name, + "manufacturer": "AirSend", + "model": RTYPE_LABELS.get(self._rtype, "AirSend"), + } + @property def extra_state_attributes(self): if self._channel: @@ -176,7 +195,7 @@ def bind(self) -> bool: pass return ret - def transfer(self, note, entity_id = None) -> bool: + def transfer(self, note, entity_id=None) -> bool: """Send a command.""" status_code = 404 ret = False diff --git a/custom_components/airsend/manifest.json b/custom_components/airsend/manifest.json index f5e0f65..cf94242 100644 --- a/custom_components/airsend/manifest.json +++ b/custom_components/airsend/manifest.json @@ -3,9 +3,9 @@ "name": "AirSend", "documentation": "https://github.com/devmel/hass_airsend", "dependencies": ["http"], - "config_flow": false, + "config_flow": true, "codeowners": ["@devmel"], "requirements": [], - "version": "3.4", - "iot_class": "cloud_polling" + "version": "4.0", + "iot_class": "local_push" } diff --git a/custom_components/airsend/sensor.py b/custom_components/airsend/sensor.py index 57aa085..36ef6f6 100644 --- a/custom_components/airsend/sensor.py +++ b/custom_components/airsend/sensor.py @@ -1,71 +1,70 @@ """AirSend sensors.""" -from typing import Any from datetime import timedelta +import logging -from homeassistant.components.binary_sensor import BinarySensorEntity, BinarySensorDeviceClass from homeassistant.components.sensor import SensorEntity, SensorDeviceClass +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity import generate_entity_id +from homeassistant.helpers.entity import DeviceInfo, generate_entity_id +from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import DataUpdateCoordinator -from homeassistant.helpers.typing import ConfigType -from homeassistant.const import CONF_DEVICES, CONF_INTERNAL_URL, UnitOfTemperature, LIGHT_LUX +from homeassistant.const import CONF_INTERNAL_URL, UnitOfTemperature, LIGHT_LUX from .device import Device - from . import DOMAIN -import logging + _LOGGER = logging.getLogger(DOMAIN) -async def async_setup_platform( - hass: HomeAssistant, config: ConfigType, async_add_entities, discovery_info=None + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: - if discovery_info is None: - return - for name, options in discovery_info[CONF_DEVICES].items(): - device = Device(name, options, discovery_info[CONF_INTERNAL_URL]) + """Set up AirSend sensors from a config entry.""" + internal_url = entry.data.get(CONF_INTERNAL_URL, "") + devices_config = entry.data.get("devices", {}) + + entities = [] + for name, options in devices_config.items(): + device = Device(name, options, internal_url) + + # Generic external sensor (type 1) + if device.is_sensor: + entities.append(AirSendAnySensor(hass, device)) + + # AirSend box sensors (type 0) — optional temp/illuminance if device.is_airsend: - entity = AirSendStateSensor(hass, device) - async_add_entities([entity]) sensors = False try: - sensors = eval(str(options["sensors"])) - except KeyError: + sensors = eval(str(options.get("sensors", False))) + except Exception: pass - if sensors == True: - entityTmp = AirSendTempSensor(hass, device) - entityIll = AirSendIllSensor(hass, device) - async_add_entities([entityTmp, entityIll]) - if device.is_sensor: - entity = AirSendAnySensor(hass, device) - async_add_entities([entity]) + if sensors: + entities.append(AirSendTempSensor(hass, device)) + entities.append(AirSendIllSensor(hass, device)) + + async_add_entities(entities) + class AirSendAnySensor(SensorEntity): - """Representation of an AirSend device temperature.""" - - def __init__( - self, - hass: HomeAssistant, - device: Device, - ) -> None: - """Initialize a sensor.""" + """Generic AirSend sensor (type 1).""" + + def __init__(self, hass: HomeAssistant, device: Device) -> None: self._device = device - uname = DOMAIN + device.name - self._unique_id = "_".join(x for x in uname) - self.entity_id = generate_entity_id("sensor.{}", self._device.unique_channel_name, hass=hass) + self._unique_id = DOMAIN + "_" + str(device.unique_channel_name) + "_sensor" + self.entity_id = generate_entity_id("sensor.{}", device.unique_channel_name, hass=hass) @property def unique_id(self): - """Return unique identifier of device.""" return self._unique_id @property def name(self): - """Return the name of the device if any.""" return self._device.name @property def extra_state_attributes(self): - """Return the device state attributes.""" return self._device.extra_state_attributes @property @@ -74,98 +73,34 @@ def available(self): @property def should_poll(self) -> bool: - """Return the polling state.""" return False - -class AirSendStateSensor(BinarySensorEntity): - """Representation of an AirSend device.""" - - def __init__( - self, - hass: HomeAssistant, - device: Device, - ) -> None: - """Initialize a sensor.""" - self.hass = hass - self._bind = None - self._device = device - uname = DOMAIN + device.name + "_state" - self._unique_id = "_".join(x for x in uname) - self._coordinator = DataUpdateCoordinator( - hass, - _LOGGER, - name=uname, - update_method=self.async_update_data, - update_interval=timedelta(seconds=10), - ) - def null_callback(): - return - self._coordinator.async_add_listener(null_callback) - @property - def unique_id(self): - """Return unique identifier of device.""" - return self._unique_id - - @property - def name(self): - """Return the name of the device if any.""" - return self._device.name + "_state" - - @property - def device_class(self) -> BinarySensorDeviceClass | None: - """Cette entité""" - return BinarySensorDeviceClass.RUNNING - - @property - def available(self): - return True - - @property - def should_poll(self) -> bool: - """Return the polling state.""" - return False - - async def async_update_data(self): - """Register update callback.""" - self._coordinator.update_interval = timedelta(seconds=self._device.refresh_value) - note = {"method": "QUERY", "type": "STATE"} - await self.hass.async_add_executor_job( lambda: self._device.transfer(note, self.entity_id) ) - await self.hass.async_add_executor_job( lambda: self._device.bind() ) + def device_info(self) -> DeviceInfo: + return self._device.device_info class AirSendTempSensor(SensorEntity): - """Representation of an AirSend device temperature.""" - - def __init__( - self, - hass: HomeAssistant, - device: Device, - ) -> None: - """Initialize a sensor.""" + """AirSend device temperature sensor.""" + + def __init__(self, hass: HomeAssistant, device: Device) -> None: self._device = device - uname = DOMAIN + device.name + "_temp" - self._unique_id = "_".join(x for x in uname) + uname = DOMAIN + "_" + str(device.unique_channel_name) + "_temp" + self._unique_id = uname self._coordinator = DataUpdateCoordinator( - hass, - _LOGGER, + hass, _LOGGER, name=uname, update_method=self.async_update_data, - update_interval=timedelta(seconds=12), + update_interval=timedelta(seconds=12), ) - def null_callback(): - return - self._coordinator.async_add_listener(null_callback) + self._coordinator.async_add_listener(lambda: None) @property def unique_id(self): - """Return unique identifier of device.""" return self._unique_id @property def name(self): - """Return the name of the device if any.""" return self._device.name + "_temp" @property @@ -173,58 +108,50 @@ def available(self): return True @property - def device_class(self) -> SensorDeviceClass | None: - """Cette entité""" + def device_class(self) -> SensorDeviceClass: return SensorDeviceClass.TEMPERATURE @property def native_unit_of_measurement(self): - """Return measurement unit.""" return UnitOfTemperature.CELSIUS @property def should_poll(self) -> bool: - """Return the polling state.""" return False + @property + def device_info(self) -> DeviceInfo: + return self._device.device_info + async def async_update_data(self): - """Register update callback.""" self._coordinator.update_interval = timedelta(seconds=self._device.refresh_value) note = {"method": "QUERY", "type": "TEMPERATURE"} - await self.hass.async_add_executor_job( lambda: self._device.transfer(note, self.entity_id) ) + await self._coordinator.hass.async_add_executor_job( + lambda: self._device.transfer(note, self.entity_id) + ) class AirSendIllSensor(SensorEntity): - """Representation of an AirSend device temperature.""" - - def __init__( - self, - hass: HomeAssistant, - device: Device, - ) -> None: - """Initialize a sensor.""" + """AirSend device illuminance sensor.""" + + def __init__(self, hass: HomeAssistant, device: Device) -> None: self._device = device - uname = DOMAIN + device.name + "_ill" - self._unique_id = "_".join(x for x in uname) + uname = DOMAIN + "_" + str(device.unique_channel_name) + "_ill" + self._unique_id = uname self._coordinator = DataUpdateCoordinator( - hass, - _LOGGER, + hass, _LOGGER, name=uname, update_method=self.async_update_data, - update_interval=timedelta(seconds=12), + update_interval=timedelta(seconds=12), ) - def null_callback(): - return - self._coordinator.async_add_listener(null_callback) + self._coordinator.async_add_listener(lambda: None) @property def unique_id(self): - """Return unique identifier of device.""" return self._unique_id @property def name(self): - """Return the name of the device if any.""" return self._device.name + "_ill" @property @@ -232,22 +159,24 @@ def available(self): return True @property - def device_class(self) -> SensorDeviceClass | None: - """Cette entité""" + def device_class(self) -> SensorDeviceClass: return SensorDeviceClass.ILLUMINANCE @property def native_unit_of_measurement(self): - """Return measurement unit.""" return LIGHT_LUX @property def should_poll(self) -> bool: - """Return the polling state.""" return False + @property + def device_info(self) -> DeviceInfo: + return self._device.device_info + async def async_update_data(self): - """Register update callback.""" self._coordinator.update_interval = timedelta(seconds=self._device.refresh_value) note = {"method": "QUERY", "type": "ILLUMINANCE"} - await self.hass.async_add_executor_job( lambda: self._device.transfer(note, self.entity_id) ) + await self._coordinator.hass.async_add_executor_job( + lambda: self._device.transfer(note, self.entity_id) + ) diff --git a/custom_components/airsend/strings.json b/custom_components/airsend/strings.json new file mode 100644 index 0000000..5cfee98 --- /dev/null +++ b/custom_components/airsend/strings.json @@ -0,0 +1,28 @@ +{ + "config": { + "step": { + "user": { + "title": "Configurer AirSend", + "description": "Le fichier `airsend.yaml` doit être présent dans le dossier `/config`.\nChaque appareil défini dans ce fichier sera créé comme un appareil Home Assistant.\n\nURL interne détectée automatiquement si l'addon AirSend est installé.", + "data": { + "internal_url": "URL interne (laisser vide pour auto-détection)" + } + }, + "reconfigure": { + "title": "Reconfigurer AirSend", + "description": "Rechargez la configuration depuis `airsend.yaml` et mettez à jour l'URL si nécessaire.", + "data": { + "internal_url": "URL interne" + } + } + }, + "error": { + "no_devices": "Aucun appareil trouvé dans airsend.yaml. Vérifiez la section `devices:`.", + "yaml_not_found": "Fichier airsend.yaml introuvable dans /config. Créez-le d'abord." + }, + "abort": { + "already_configured": "AirSend est déjà configuré.", + "reconfigure_successful": "Reconfiguration réussie." + } + } +} diff --git a/custom_components/airsend/switch.py b/custom_components/airsend/switch.py index bc94222..41502ef 100644 --- a/custom_components/airsend/switch.py +++ b/custom_components/airsend/switch.py @@ -4,46 +4,44 @@ from .device import Device from homeassistant.components.switch import SwitchEntity +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.typing import ConfigType - -from homeassistant.const import CONF_DEVICES, CONF_INTERNAL_URL +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.const import CONF_INTERNAL_URL from . import DOMAIN -async def async_setup_platform( - hass: HomeAssistant, config: ConfigType, async_add_entities, discovery_info=None + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: - if discovery_info is None: - return - for name, options in discovery_info[CONF_DEVICES].items(): - device = Device(name, options, discovery_info[CONF_INTERNAL_URL]) + """Set up AirSend switches from a config entry.""" + internal_url = entry.data.get(CONF_INTERNAL_URL, "") + devices_config = entry.data.get("devices", {}) + + entities = [] + for name, options in devices_config.items(): + device = Device(name, options, internal_url) if device.is_switch: - entity = AirSendSwitch( - hass, - device, - ) - async_add_entities([entity]) + entities.append(AirSendSwitch(hass, device)) + + async_add_entities(entities) class AirSendSwitch(SwitchEntity): """Representation of an AirSend Switch.""" - def __init__( - self, - hass: HomeAssistant, - device: Device, - ) -> None: - """Initialize a switch or light device.""" + def __init__(self, hass: HomeAssistant, device: Device) -> None: self._hass = hass self._device = device - uname = DOMAIN + device.name - self._unique_id = "_".join(x for x in uname) + self._unique_id = DOMAIN + "_" + str(device.unique_channel_name) + "_switch" self._state = None @property def unique_id(self): - """Return unique identifier of remote device.""" return self._unique_id @property @@ -52,45 +50,40 @@ def available(self): @property def should_poll(self): - """No polling needed.""" return False @property def name(self): - """Return the name of the device if any.""" return self._device.name @property def extra_state_attributes(self): - """Return the device state attributes.""" return self._device.extra_state_attributes @property def assumed_state(self): - """Return true if unable to access real state of entity.""" return True + @property + def device_info(self) -> DeviceInfo: + return self._device.device_info + @property def is_on(self): if self._device.is_async and self._hass: component = self._hass.states.get(self.entity_id) if component is not None: - if component.state == 'on': - self._state = True - else: - self._state = False + self._state = component.state == 'on' return self._state def turn_on(self, **kwargs: Any) -> None: - """Turn the device on.""" note = {"method": 1, "type": 0, "value": "ON"} - if self._device.transfer(note, self.entity_id) == True: + if self._device.transfer(note, self.entity_id): self._state = True self.schedule_update_ha_state() def turn_off(self, **kwargs: Any) -> None: - """Turn the device off.""" note = {"method": 1, "type": 0, "value": "OFF"} - if self._device.transfer(note, self.entity_id) == True: + if self._device.transfer(note, self.entity_id): self._state = False self.schedule_update_ha_state() From cfd6a41429cb0988dd82ca3dc4948752de2f1770 Mon Sep 17 00:00:00 2001 From: 38decibel <86261703+38decibel@users.noreply.github.com> Date: Sat, 14 Mar 2026 14:05:56 +0100 Subject: [PATCH 02/11] Add icon for local brand --- custom_components/airsend/brand/icon.png | Bin 0 -> 27237 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 custom_components/airsend/brand/icon.png diff --git a/custom_components/airsend/brand/icon.png b/custom_components/airsend/brand/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..a24c0c94628dd8e2c3fccffe0a7b52140a9deecc GIT binary patch literal 27237 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

vZ-xcmJ7^ABX6RouyWgYl?_C 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 Date: Sat, 14 Mar 2026 14:08:57 +0100 Subject: [PATCH 03/11] Revise installation steps for AirSend integration --- README.md | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 85d0b3e..492393e 100644 --- a/README.md +++ b/README.md @@ -28,19 +28,13 @@ Component for sending radio commands through the AirSend (RF433) or AirSend duo ``` - Replace `**************` with the AirSend Password, `fe80::xxxx:xxxx:xxxx:xxxx` with AirSend Local IP and `192.168.xxx.xxx` with the AirSend IPv4 address. -4. **Edit the `configuration.yaml` File**: - - Add the following line to the `configuration.yaml` file to include the `airsend.yaml` file: - ```yaml - airsend: !include airsend.yaml - ``` - -5. **Install the Custom Component**: +4. **Install the Custom Component**: - In the Home Assistant terminal, run the following command to install the component: ```bash wget -q -O - https://raw.githubusercontent.com/devmel/hass_airsend/master/install | bash - ``` -6. **Restart Home Assistant and the AirSend Addon**: +5. **Restart Home Assistant and the AirSend Addon**: - Restart Home Assistant. - Restart the AirSend addon. From ad2d7c635f96bb2b4582a2e6c05d55c7f4013da1 Mon Sep 17 00:00:00 2001 From: 38decibel <86261703+38decibel@users.noreply.github.com> Date: Sun, 15 Mar 2026 09:25:34 +0100 Subject: [PATCH 04/11] Correction du crash websocket MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit raise Exception remplacé par return False --- custom_components/airsend/device.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/airsend/device.py b/custom_components/airsend/device.py index 92cc4bf..554e60b 100644 --- a/custom_components/airsend/device.py +++ b/custom_components/airsend/device.py @@ -292,4 +292,4 @@ def transfer(self, note, entity_id=None) -> bool: if status_code == 200: return ret _LOGGER.error("Transfer error '%s' : '%s'", self.name, status_code) - raise Exception("Transfer error " + self.name + " : " + str(status_code)) + return False From 80b41985bd99447c428f25013d9b9e965935da9f Mon Sep 17 00:00:00 2001 From: 38decibel <86261703+38decibel@users.noreply.github.com> Date: Sun, 15 Mar 2026 09:28:30 +0100 Subject: [PATCH 05/11] Update README with AirSend configuration steps Added instructions for configuring AirSend in Home Assistant. --- README.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/README.md b/README.md index 492393e..13476f4 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,14 @@ Component for sending radio commands through the AirSend (RF433) or AirSend duo - Select your devices, for local connection, select `spurl` - Click `Export YAML` to save the airsend.yaml - In the `config` folder of Home Assistant, place the `airsend.yaml` file. + - Add these lines + devices: + ... + AirSend Box: + type: 0 + spurl: !secret spurl + sensors: true + 3. **Edit the `secrets.yaml` File**: - Add a line to the `secrets.yaml` file with the AirSend - Local IP - / - Password - (and IPv4 address). From e17e2ff0f50eedf4e8e1b0811e22a8cd44f97115 Mon Sep 17 00:00:00 2001 From: 38decibel <86261703+38decibel@users.noreply.github.com> Date: Sun, 15 Mar 2026 09:37:27 +0100 Subject: [PATCH 06/11] Update README with clearer instructions for YAML Clarified instructions for editing the airsend.yaml file. --- README.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 13476f4..ab943ee 100644 --- a/README.md +++ b/README.md @@ -15,14 +15,15 @@ Component for sending radio commands through the AirSend (RF433) or AirSend duo - Select your devices, for local connection, select `spurl` - Click `Export YAML` to save the airsend.yaml - In the `config` folder of Home Assistant, place the `airsend.yaml` file. - - Add these lines + - Edit the file and add these lines + ```yaml devices: ... AirSend Box: type: 0 spurl: !secret spurl sensors: true - + ``` 3. **Edit the `secrets.yaml` File**: - Add a line to the `secrets.yaml` file with the AirSend - Local IP - / - Password - (and IPv4 address). From 90b2372cf7cfdec481587fdd89b73a93cb52b0aa Mon Sep 17 00:00:00 2001 From: 38decibel <86261703+38decibel@users.noreply.github.com> Date: Sun, 15 Mar 2026 14:44:23 +0100 Subject: [PATCH 07/11] Add files via upload --- custom_components/airsend/__init__.py | 26 ++- custom_components/airsend/binary_sensor.py | 60 ++----- custom_components/airsend/button.py | 15 +- custom_components/airsend/config_flow.py | 108 +++++++----- custom_components/airsend/coordinator.py | 78 +++++++++ custom_components/airsend/cover.py | 62 +++---- custom_components/airsend/device.py | 194 +++++++++------------ custom_components/airsend/sensor.py | 104 +++++------ custom_components/airsend/strings.json | 23 +-- custom_components/airsend/switch.py | 28 +-- 10 files changed, 370 insertions(+), 328 deletions(-) create mode 100644 custom_components/airsend/coordinator.py diff --git a/custom_components/airsend/__init__.py b/custom_components/airsend/__init__.py index d391f62..b48e359 100644 --- a/custom_components/airsend/__init__.py +++ b/custom_components/airsend/__init__.py @@ -22,8 +22,26 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up AirSend from a config entry.""" + from .coordinator import AirSendCoordinator + from .device import Device + hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN][entry.entry_id] = entry.data + + # Create one coordinator per device and start them + internal_url = entry.data.get(CONF_INTERNAL_URL, "") + devices_config = entry.data.get("devices", {}) + + coordinators = {} + for name, options in devices_config.items(): + device = Device(name, options, internal_url) + coordinator = AirSendCoordinator(hass, device) + coordinators[name] = coordinator + + hass.data[DOMAIN][entry.entry_id] = { + "entry": entry.data, + "coordinators": coordinators, + } + await hass.config_entries.async_forward_entry_setups(entry, AS_PLATFORMS) return True @@ -66,11 +84,7 @@ def secret_constructor(loader, node): return "" return value - loader_class = type( - "SecretLoader", - (_yaml.SafeLoader,), - {} - ) + loader_class = type("SecretLoader", (_yaml.SafeLoader,), {}) loader_class.add_constructor("!secret", secret_constructor) try: diff --git a/custom_components/airsend/binary_sensor.py b/custom_components/airsend/binary_sensor.py index fb049c1..b619cb2 100644 --- a/custom_components/airsend/binary_sensor.py +++ b/custom_components/airsend/binary_sensor.py @@ -1,5 +1,4 @@ """AirSend binary sensors — state monitoring for AirSend boxes (type 0).""" -from datetime import timedelta import logging from homeassistant.components.binary_sensor import BinarySensorEntity, BinarySensorDeviceClass @@ -7,10 +6,9 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator -from homeassistant.const import CONF_INTERNAL_URL +from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .device import Device +from .coordinator import AirSendCoordinator from . import DOMAIN _LOGGER = logging.getLogger(DOMAIN) @@ -21,34 +19,22 @@ async def async_setup_entry( entry: ConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: - """Set up AirSend binary sensors from a config entry.""" - internal_url = entry.data.get(CONF_INTERNAL_URL, "") - devices_config = entry.data.get("devices", {}) - + coordinators: dict[str, AirSendCoordinator] = ( + hass.data[DOMAIN][entry.entry_id]["coordinators"] + ) entities = [] - for name, options in devices_config.items(): - device = Device(name, options, internal_url) - if device.is_airsend: - entities.append(AirSendStateSensor(hass, device)) - + for name, coordinator in coordinators.items(): + if coordinator.device.is_airsend: + entities.append(AirSendStateSensor(coordinator)) async_add_entities(entities) -class AirSendStateSensor(BinarySensorEntity): +class AirSendStateSensor(CoordinatorEntity, BinarySensorEntity): """Binary sensor representing the running state of an AirSend box.""" - def __init__(self, hass: HomeAssistant, device: Device) -> None: - self.hass = hass - self._device = device - uname = DOMAIN + "_" + str(device.unique_channel_name) + "_state" - self._unique_id = uname - self._coordinator = DataUpdateCoordinator( - hass, _LOGGER, - name=uname, - update_method=self.async_update_data, - update_interval=timedelta(seconds=10), - ) - self._coordinator.async_add_listener(lambda: None) + def __init__(self, coordinator: AirSendCoordinator) -> None: + super().__init__(coordinator) + self._unique_id = DOMAIN + "_" + str(coordinator.device.unique_channel_name) + "_state" @property def unique_id(self): @@ -56,30 +42,20 @@ def unique_id(self): @property def name(self): - return self._device.name + "_state" + return self.coordinator.device.name + "_state" @property def device_class(self) -> BinarySensorDeviceClass: return BinarySensorDeviceClass.RUNNING @property - def available(self): - return True + def available(self) -> bool: + return self.coordinator.data.get("available", True) @property - def should_poll(self) -> bool: - return False + def is_on(self) -> bool | None: + return self.coordinator.data.get("available", True) @property def device_info(self) -> DeviceInfo: - return self._device.device_info - - async def async_update_data(self): - self._coordinator.update_interval = timedelta(seconds=self._device.refresh_value) - note = {"method": "QUERY", "type": "STATE"} - await self.hass.async_add_executor_job( - lambda: self._device.transfer(note, self.entity_id) - ) - await self.hass.async_add_executor_job( - lambda: self._device.bind() - ) + return self.coordinator.device.device_info diff --git a/custom_components/airsend/button.py b/custom_components/airsend/button.py index 75531fd..df4ce90 100644 --- a/custom_components/airsend/button.py +++ b/custom_components/airsend/button.py @@ -18,16 +18,13 @@ async def async_setup_entry( entry: ConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: - """Set up AirSend buttons from a config entry.""" internal_url = entry.data.get(CONF_INTERNAL_URL, "") devices_config = entry.data.get("devices", {}) - entities = [] for name, options in devices_config.items(): device = Device(name, options, internal_url) if device.is_button: entities.append(AirSendButton(hass, device)) - async_add_entities(entities) @@ -37,6 +34,7 @@ class AirSendButton(ButtonEntity): def __init__(self, hass: HomeAssistant, device: Device) -> None: self._device = device self._unique_id = DOMAIN + "_" + str(device.unique_channel_name) + "_button" + self._available = True @property def unique_id(self): @@ -44,7 +42,7 @@ def unique_id(self): @property def available(self): - return True + return self._available @property def should_poll(self): @@ -66,7 +64,10 @@ def assumed_state(self): def device_info(self) -> DeviceInfo: return self._device.device_info - def press(self, **kwargs: Any) -> None: + async def async_press(self, **kwargs: Any) -> None: note = {"method": 1, "type": 0, "value": "TOGGLE"} - if self._device.transfer(note, self.entity_id): - self.schedule_update_ha_state() + result = await self._device.async_transfer(note, self.entity_id) + available = result is not False + if self._available != available: + self._available = available + self.async_write_ha_state() diff --git a/custom_components/airsend/config_flow.py b/custom_components/airsend/config_flow.py index 9c34db9..f8e0b4f 100644 --- a/custom_components/airsend/config_flow.py +++ b/custom_components/airsend/config_flow.py @@ -1,17 +1,37 @@ """Config flow for AirSend integration.""" import os import logging +import aiohttp import voluptuous as vol from homeassistant import config_entries -from homeassistant.core import HomeAssistant from homeassistant.const import CONF_INTERNAL_URL from . import DOMAIN, load_airsend_yaml, get_internal_url _LOGGER = logging.getLogger(DOMAIN) -CONF_YAML_PATH = "yaml_path" + +async def test_connection(url: str) -> str | None: + """Test connection to AirSend addon. Returns None if OK, error key otherwise.""" + if not url: + return "no_url" + try: + async with aiohttp.ClientSession() as session: + async with session.get( + url, + timeout=aiohttp.ClientTimeout(total=5), + ) as response: + # L'addon répond même avec 401/404, l'important est qu'il réponde + if response.status < 500: + return None + return "cannot_connect" + except aiohttp.ClientConnectorError: + return "cannot_connect" + except TimeoutError: + return "timeout" + except Exception: + return "cannot_connect" class AirSendConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): @@ -20,10 +40,9 @@ class AirSendConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 async def async_step_user(self, user_input=None): - """First step: confirm airsend.yaml is present and detect URL.""" + """First step: detect URL, test connection, load devices.""" errors = {} - # Auto-detect internal URL internal_url = await get_internal_url(self.hass) if user_input is not None: @@ -31,31 +50,33 @@ async def async_step_user(self, user_input=None): if internal_url and not internal_url.endswith("/"): internal_url += "/" - # Load airsend.yaml to validate it exists and has devices - yaml_data = await self.hass.async_add_executor_job( - load_airsend_yaml, self.hass - ) - devices = yaml_data.get("devices", {}) - - if not devices: - errors["base"] = "no_devices" + # Test de connexion + connection_error = await test_connection(internal_url) + if connection_error: + errors["base"] = connection_error else: - # Create one config entry for the whole integration - await self.async_set_unique_id(DOMAIN) - self._abort_if_unique_id_configured() - - return self.async_create_entry( - title="AirSend", - data={ - CONF_INTERNAL_URL: internal_url, - "devices": devices, - }, + # Chargement du yaml + yaml_data = await self.hass.async_add_executor_job( + load_airsend_yaml, self.hass ) + devices = yaml_data.get("devices", {}) + + if not devices: + errors["base"] = "no_devices" + else: + await self.async_set_unique_id(DOMAIN) + self._abort_if_unique_id_configured() + return self.async_create_entry( + title="AirSend", + data={ + CONF_INTERNAL_URL: internal_url, + "devices": devices, + }, + ) yaml_path = self.hass.config.path("airsend.yaml") yaml_exists = await self.hass.async_add_executor_job(os.path.exists, yaml_path) - - if not yaml_exists: + if not yaml_exists and not errors: errors["base"] = "yaml_not_found" return self.async_show_form( @@ -65,9 +86,7 @@ async def async_step_user(self, user_input=None): vol.Optional(CONF_INTERNAL_URL, default=internal_url): str, } ), - description_placeholders={ - "yaml_path": yaml_path, - }, + description_placeholders={"yaml_path": yaml_path}, errors=errors, ) @@ -82,23 +101,28 @@ async def async_step_reconfigure(self, user_input=None): if internal_url and not internal_url.endswith("/"): internal_url += "/" - yaml_data = await self.hass.async_add_executor_job( - load_airsend_yaml, self.hass - ) - devices = yaml_data.get("devices", {}) - - if not devices: - errors["base"] = "no_devices" + # Test de connexion + connection_error = await test_connection(internal_url) + if connection_error: + errors["base"] = connection_error else: - self.hass.config_entries.async_update_entry( - entry, - data={ - CONF_INTERNAL_URL: internal_url, - "devices": devices, - }, + yaml_data = await self.hass.async_add_executor_job( + load_airsend_yaml, self.hass ) - await self.hass.config_entries.async_reload(entry.entry_id) - return self.async_abort(reason="reconfigure_successful") + devices = yaml_data.get("devices", {}) + + if not devices: + errors["base"] = "no_devices" + else: + self.hass.config_entries.async_update_entry( + entry, + data={ + CONF_INTERNAL_URL: internal_url, + "devices": devices, + }, + ) + await self.hass.config_entries.async_reload(entry.entry_id) + return self.async_abort(reason="reconfigure_successful") return self.async_show_form( step_id="reconfigure", diff --git a/custom_components/airsend/coordinator.py b/custom_components/airsend/coordinator.py new file mode 100644 index 0000000..f35e59f --- /dev/null +++ b/custom_components/airsend/coordinator.py @@ -0,0 +1,78 @@ +"""AirSend DataUpdateCoordinator.""" +import logging +from datetime import timedelta + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .device import Device +from . import DOMAIN + +_LOGGER = logging.getLogger(DOMAIN) + + +class AirSendCoordinator(DataUpdateCoordinator): + """Coordinator for a single AirSend device — polls state, temp and illuminance.""" + + def __init__(self, hass: HomeAssistant, device: Device) -> None: + super().__init__( + hass, + _LOGGER, + name=f"{DOMAIN}_{device.unique_channel_name}", + update_interval=timedelta(seconds=device.refresh_value), + ) + self._device = device + # Data structure shared across all entities of this device + self.data = { + "state": None, + "temperature": None, + "illuminance": None, + "available": True, + } + + @property + def device(self) -> Device: + return self._device + + async def _async_update_data(self) -> dict: + """Fetch data from device — called automatically by the coordinator.""" + data = dict(self.data) # keep last known values + + # Query state + if self._device.is_airsend: + try: + await self._device.async_transfer( + {"method": "QUERY", "type": "STATE"}, + f"coordinator_{self._device.unique_channel_name}", + ) + await self._device.async_bind() + data["available"] = True + except Exception as err: + data["available"] = False + raise UpdateFailed(f"Error querying state for {self._device.name}: {err}") from err + + # Query temperature if sensors enabled + if self._device.is_airsend and self._has_sensors: + try: + await self._device.async_transfer( + {"method": "QUERY", "type": "TEMPERATURE"}, + f"coordinator_{self._device.unique_channel_name}_temp", + ) + await self._device.async_transfer( + {"method": "QUERY", "type": "ILLUMINANCE"}, + f"coordinator_{self._device.unique_channel_name}_ill", + ) + except Exception as err: + _LOGGER.warning("Sensor query failed for %s: %s", self._device.name, err) + + return data + + @property + def _has_sensors(self) -> bool: + return self.data.get("temperature") is not None or self.data.get("illuminance") is not None + + def set_has_sensors(self, value: bool) -> None: + """Called by sensor entities to indicate sensors are enabled.""" + if value: + self.data["temperature"] = self.data.get("temperature") + self.data["illuminance"] = self.data.get("illuminance") diff --git a/custom_components/airsend/cover.py b/custom_components/airsend/cover.py index 69ee78e..aa403f0 100644 --- a/custom_components/airsend/cover.py +++ b/custom_components/airsend/cover.py @@ -19,52 +19,42 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up AirSend covers from a config entry.""" - data = entry.data - internal_url = data.get(CONF_INTERNAL_URL, "") - devices_config = data.get("devices", {}) - + internal_url = entry.data.get(CONF_INTERNAL_URL, "") + devices_config = entry.data.get("devices", {}) entities = [] for name, options in devices_config.items(): device = Device(name, options, internal_url) if device.is_cover: entities.append(AirSendCover(hass, device)) - async_add_entities(entities) class AirSendCover(CoverEntity): """Representation of an AirSend Cover.""" - def __init__( - self, - hass: HomeAssistant, - device: Device, - ) -> None: - """Initialize a cover device.""" + def __init__(self, hass: HomeAssistant, device: Device) -> None: self._hass = hass self._device = device - self._unique_id = DOMAIN + "_" + device.unique_channel_name + "_cover" + self._unique_id = DOMAIN + "_" + str(device.unique_channel_name) + "_cover" self._closed = None + self._available = True if device.is_cover_with_position: self._attr_current_cover_position = 50 @property def unique_id(self): - """Return unique identifier of remote device.""" return self._unique_id @property def available(self): - return True + return self._available @property def should_poll(self): - """No polling needed.""" return False @property def name(self): - """Return the name of the device if any.""" return self._device.name @property @@ -73,55 +63,57 @@ def extra_state_attributes(self): @property def assumed_state(self): - """Return true if unable to access real state of entity.""" return True @property def device_info(self) -> DeviceInfo: - """Return device info to link this entity to a device.""" return self._device.device_info @property def is_closed(self): - """Return if the cover is closed.""" if self._device.is_async and self._hass: component = self._hass.states.get(self.entity_id) if component is not None: self._closed = component.state not in ('open', 'on', 'up') return self._closed - def open_cover(self, **kwargs: Any) -> None: - """Open the cover.""" + async def _send(self, note: dict) -> bool: + """Send a command and update availability accordingly.""" + result = await self._device.async_transfer(note, self.entity_id) + available = result is not False + if self._available != available: + self._available = available + self.async_write_ha_state() + return result is not False + + async def async_open_cover(self, **kwargs: Any) -> None: note = {"method": 1, "type": 0, "value": "UP"} - if self._device.transfer(note, self.entity_id): + if await self._send(note): self._closed = False if self._device.is_cover_with_position: self._attr_current_cover_position = 100 - self.schedule_update_ha_state() + self.async_write_ha_state() - def close_cover(self, **kwargs: Any) -> None: - """Close cover.""" + async def async_close_cover(self, **kwargs: Any) -> None: note = {"method": 1, "type": 0, "value": "DOWN"} - if self._device.transfer(note, self.entity_id): + if await self._send(note): self._closed = True if self._device.is_cover_with_position: self._attr_current_cover_position = 0 - self.schedule_update_ha_state() + self.async_write_ha_state() - def stop_cover(self, **kwargs) -> None: - """Stop the cover.""" + async def async_stop_cover(self, **kwargs: Any) -> None: note = {"method": 1, "type": 0, "value": "STOP"} - if self._device.transfer(note, self.entity_id): + if await self._send(note): self._closed = False if self._device.is_cover_with_position: self._attr_current_cover_position = 50 - self.schedule_update_ha_state() + self.async_write_ha_state() - def set_cover_position(self, **kwargs) -> None: - """Move the cover to a specific position.""" + async def async_set_cover_position(self, **kwargs: Any) -> None: position = int(kwargs["position"]) note = {"method": 1, "type": 9, "value": position} - if self._device.transfer(note, self.entity_id): + if await self._send(note): self._attr_current_cover_position = position self._closed = position == 0 - self.schedule_update_ha_state() + self.async_write_ha_state() diff --git a/custom_components/airsend/device.py b/custom_components/airsend/device.py index 554e60b..958981c 100644 --- a/custom_components/airsend/device.py +++ b/custom_components/airsend/device.py @@ -2,7 +2,7 @@ import logging import json import hashlib -from requests import get, post, exceptions +import aiohttp from . import DOMAIN _LOGGER = logging.getLogger(DOMAIN) @@ -110,117 +110,96 @@ def device_info(self) -> dict: @property def extra_state_attributes(self): if self._channel: - self._attrs = { - "channel": self._channel - } - return self._attrs + return {"channel": self._channel} return None @property def is_async(self) -> bool: """Return if asynchronous state.""" - if self._wait == False: - return True - return False + return self._wait == False @property def is_airsend(self) -> bool: - """Return if is an AirSend.""" - if self._rtype == 0: - return True - return False + return self._rtype == 0 @property def is_sensor(self) -> bool: - """Return if is a sensor to listen.""" - if self._rtype == 1: - return True - return False + return self._rtype == 1 @property def is_button(self) -> bool: - """Return if is a button.""" - if self._rtype == 4096: - return True - return False + return self._rtype == 4096 @property def is_cover(self) -> bool: - """Return if is a cover.""" - if self._rtype in (4098, 4099): - return True - return False + return self._rtype in (4098, 4099) @property def is_cover_with_position(self) -> bool: - """Return if is a cover with position.""" - if self._rtype == 4099: - return True - return False + return self._rtype == 4099 @property def is_switch(self) -> bool: - """Return if is a switch.""" - if self._rtype == 4097: - return True - return False + return self._rtype == 4097 @property def refresh_value(self) -> int: """Return refresh value in seconds.""" - if type(self._refresh) is int and self._refresh > 0: + if isinstance(self._refresh, int) and self._refresh > 0: return self._refresh - return (5 * 60) + return 5 * 60 - def bind(self) -> bool: - """Bind a channel to listen.""" - ret = False - if self._serviceurl and self._spurl and type(self._bind) is int and self._bind > 0: - payload = ('{"channel":{"id": '+str(self._bind)+'},\"duration\":0,\"callback\":\"http://127.0.0.1/\"}') - headers = { - "Authorization": "Bearer " + self._spurl, - "content-type": "application/json", - "User-Agent": "hass_airsend", - } - try: - response = post( + async def async_bind(self) -> bool: + """Bind a channel to listen (async).""" + if not (self._serviceurl and self._spurl and isinstance(self._bind, int) and self._bind > 0): + return False + payload = json.dumps({ + "channel": {"id": self._bind}, + "duration": 0, + "callback": "http://127.0.0.1/" + }) + headers = { + "Authorization": "Bearer " + self._spurl, + "content-type": "application/json", + "User-Agent": "hass_airsend", + } + try: + async with aiohttp.ClientSession() as session: + async with session.post( self._serviceurl + "airsend/bind", headers=headers, data=payload, - timeout=6, - ) - if response.status_code == 200: - ret = True - except exceptions.RequestException: - pass - return ret + timeout=aiohttp.ClientTimeout(total=6), + ) as response: + return response.status == 200 + except aiohttp.ClientError as err: + _LOGGER.debug("Bind error '%s': %s", self._name, err) + return False - def transfer(self, note, entity_id=None) -> bool: - """Send a command.""" + async def async_transfer(self, note, entity_id=None) -> bool: + """Send a command (async).""" status_code = 404 ret = False wait = 'false, "callback":"http://127.0.0.1/"' - if self._wait == True: + if self._wait: wait = 'true' + if self._serviceurl and self._spurl and entity_id is not None: uid = hashlib.sha256(entity_id.encode('utf-8')).hexdigest()[:12] jnote = json.dumps(note) if ( self._note is not None - and "method" in self._note - and "type" in self._note - and "value" in self._note - and "method" in note.keys() - and "type" in note.keys() - and "value" in note.keys() + and all(k in self._note for k in ("method", "type", "value")) + and all(k in note for k in ("method", "type", "value")) and note["method"] == 1 and note["type"] == 0 - and (note["value"] == "TOGGLE" or note["value"] == 6) + and note["value"] in ("TOGGLE", 6) ): jnote = json.dumps(self._note) + payload = ( - '{"wait": '+wait+', "channel":' + '{"wait": ' + wait + ', "channel":' + json.dumps(self._channel) - + ', "thingnotes":{"uid":"0x'+uid+'", "notes":[' + + ', "thingnotes":{"uid":"0x' + uid + '", "notes":[' + jnote + "]}}" ) @@ -230,53 +209,44 @@ def transfer(self, note, entity_id=None) -> bool: "User-Agent": "hass_airsend", } try: - response = post( - self._serviceurl + "airsend/transfer", - headers=headers, - data=payload, - timeout=6, - ) - if self._wait == True: - ret = True - status_code = 500 - jdata = json.loads(response.text) - if jdata["type"] < 0x100: - status_code = response.status_code - else: - ret = None - status_code = response.status_code - except exceptions.RequestException: - pass + async with aiohttp.ClientSession() as session: + async with session.post( + self._serviceurl + "airsend/transfer", + headers=headers, + data=payload, + timeout=aiohttp.ClientTimeout(total=6), + ) as response: + if self._wait: + ret = True + status_code = 500 + try: + jdata = await response.json(content_type=None) + if jdata.get("type", 0x100) < 0x100: + status_code = response.status + except Exception: + pass + else: + ret = None + status_code = response.status + except aiohttp.ClientError as err: + _LOGGER.debug("Transfer local error '%s': %s", self._name, err) + + # Fallback to cloud API if local failed if status_code != 200 and self._apikey: action = "command" value = 6 - if ( - "method" in note.keys() - and "type" in note.keys() - and "value" in note.keys() - ): + if all(k in note for k in ("method", "type", "value")): if note["method"] == 1 and note["type"] == 0: - if note["value"] == "OFF": - value = 0 - if note["value"] == "ON": - value = 1 - if note["value"] == "STOP": - value = 3 - if note["value"] == "DOWN": - value = 4 - if note["value"] == "UP": - value = 5 - if note["method"] == 1 and note["type"] == 9: + value = { + "OFF": 0, "ON": 1, "STOP": 3, "DOWN": 4, "UP": 5 + }.get(note["value"], 6) + elif note["method"] == 1 and note["type"] == 9: action = "level" value = int(note["value"]) + cloud_url = ( "https://airsend.cloud/device/" - + str(self._uid) - + "/" - + action - + "/" - + str(value) - + "/" + + str(self._uid) + "/" + action + "/" + str(value) + "/" ) headers = { "Authorization": "Bearer " + self._apikey, @@ -284,11 +254,17 @@ def transfer(self, note, entity_id=None) -> bool: "User-Agent": "hass_airsend", } try: - response = get(cloud_url, headers=headers, timeout=10) - status_code = response.status_code - ret = True - except exceptions.RequestException: - pass + async with aiohttp.ClientSession() as session: + async with session.get( + cloud_url, + headers=headers, + timeout=aiohttp.ClientTimeout(total=10), + ) as response: + status_code = response.status + ret = True + except aiohttp.ClientError as err: + _LOGGER.debug("Transfer cloud error '%s': %s", self._name, err) + if status_code == 200: return ret _LOGGER.error("Transfer error '%s' : '%s'", self.name, status_code) diff --git a/custom_components/airsend/sensor.py b/custom_components/airsend/sensor.py index 36ef6f6..a5b2f4b 100644 --- a/custom_components/airsend/sensor.py +++ b/custom_components/airsend/sensor.py @@ -1,5 +1,4 @@ """AirSend sensors.""" -from datetime import timedelta import logging from homeassistant.components.sensor import SensorEntity, SensorDeviceClass @@ -7,9 +6,10 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity import DeviceInfo, generate_entity_id from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator +from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.const import CONF_INTERNAL_URL, UnitOfTemperature, LIGHT_LUX +from .coordinator import AirSendCoordinator from .device import Device from . import DOMAIN @@ -21,19 +21,22 @@ async def async_setup_entry( entry: ConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: - """Set up AirSend sensors from a config entry.""" - internal_url = entry.data.get(CONF_INTERNAL_URL, "") + coordinators: dict[str, AirSendCoordinator] = ( + hass.data[DOMAIN][entry.entry_id]["coordinators"] + ) devices_config = entry.data.get("devices", {}) + internal_url = entry.data.get(CONF_INTERNAL_URL, "") entities = [] - for name, options in devices_config.items(): - device = Device(name, options, internal_url) + for name, coordinator in coordinators.items(): + device = coordinator.device + options = devices_config.get(name, {}) - # Generic external sensor (type 1) + # Generic external sensor (type 1) — no coordinator needed if device.is_sensor: - entities.append(AirSendAnySensor(hass, device)) + entities.append(AirSendAnySensor(hass, device, internal_url)) - # AirSend box sensors (type 0) — optional temp/illuminance + # AirSend box sensors (type 0) if device.is_airsend: sensors = False try: @@ -41,16 +44,17 @@ async def async_setup_entry( except Exception: pass if sensors: - entities.append(AirSendTempSensor(hass, device)) - entities.append(AirSendIllSensor(hass, device)) + coordinator.set_has_sensors(True) + entities.append(AirSendTempSensor(coordinator)) + entities.append(AirSendIllSensor(coordinator)) async_add_entities(entities) class AirSendAnySensor(SensorEntity): - """Generic AirSend sensor (type 1).""" + """Generic AirSend sensor (type 1) — no coordinator, push only.""" - def __init__(self, hass: HomeAssistant, device: Device) -> None: + def __init__(self, hass: HomeAssistant, device: Device, internal_url: str) -> None: self._device = device self._unique_id = DOMAIN + "_" + str(device.unique_channel_name) + "_sensor" self.entity_id = generate_entity_id("sensor.{}", device.unique_channel_name, hass=hass) @@ -80,20 +84,12 @@ def device_info(self) -> DeviceInfo: return self._device.device_info -class AirSendTempSensor(SensorEntity): - """AirSend device temperature sensor.""" +class AirSendTempSensor(CoordinatorEntity, SensorEntity): + """AirSend device temperature sensor — uses shared coordinator.""" - def __init__(self, hass: HomeAssistant, device: Device) -> None: - self._device = device - uname = DOMAIN + "_" + str(device.unique_channel_name) + "_temp" - self._unique_id = uname - self._coordinator = DataUpdateCoordinator( - hass, _LOGGER, - name=uname, - update_method=self.async_update_data, - update_interval=timedelta(seconds=12), - ) - self._coordinator.async_add_listener(lambda: None) + def __init__(self, coordinator: AirSendCoordinator) -> None: + super().__init__(coordinator) + self._unique_id = DOMAIN + "_" + str(coordinator.device.unique_channel_name) + "_temp" @property def unique_id(self): @@ -101,11 +97,11 @@ def unique_id(self): @property def name(self): - return self._device.name + "_temp" + return self.coordinator.device.name + "_temp" @property - def available(self): - return True + def available(self) -> bool: + return self.coordinator.data.get("available", True) @property def device_class(self) -> SensorDeviceClass: @@ -116,35 +112,20 @@ def native_unit_of_measurement(self): return UnitOfTemperature.CELSIUS @property - def should_poll(self) -> bool: - return False + def native_value(self): + return self.coordinator.data.get("temperature") @property def device_info(self) -> DeviceInfo: - return self._device.device_info + return self.coordinator.device.device_info - async def async_update_data(self): - self._coordinator.update_interval = timedelta(seconds=self._device.refresh_value) - note = {"method": "QUERY", "type": "TEMPERATURE"} - await self._coordinator.hass.async_add_executor_job( - lambda: self._device.transfer(note, self.entity_id) - ) +class AirSendIllSensor(CoordinatorEntity, SensorEntity): + """AirSend device illuminance sensor — uses shared coordinator.""" -class AirSendIllSensor(SensorEntity): - """AirSend device illuminance sensor.""" - - def __init__(self, hass: HomeAssistant, device: Device) -> None: - self._device = device - uname = DOMAIN + "_" + str(device.unique_channel_name) + "_ill" - self._unique_id = uname - self._coordinator = DataUpdateCoordinator( - hass, _LOGGER, - name=uname, - update_method=self.async_update_data, - update_interval=timedelta(seconds=12), - ) - self._coordinator.async_add_listener(lambda: None) + def __init__(self, coordinator: AirSendCoordinator) -> None: + super().__init__(coordinator) + self._unique_id = DOMAIN + "_" + str(coordinator.device.unique_channel_name) + "_ill" @property def unique_id(self): @@ -152,11 +133,11 @@ def unique_id(self): @property def name(self): - return self._device.name + "_ill" + return self.coordinator.device.name + "_ill" @property - def available(self): - return True + def available(self) -> bool: + return self.coordinator.data.get("available", True) @property def device_class(self) -> SensorDeviceClass: @@ -167,16 +148,9 @@ def native_unit_of_measurement(self): return LIGHT_LUX @property - def should_poll(self) -> bool: - return False + def native_value(self): + return self.coordinator.data.get("illuminance") @property def device_info(self) -> DeviceInfo: - return self._device.device_info - - async def async_update_data(self): - self._coordinator.update_interval = timedelta(seconds=self._device.refresh_value) - note = {"method": "QUERY", "type": "ILLUMINANCE"} - await self._coordinator.hass.async_add_executor_job( - lambda: self._device.transfer(note, self.entity_id) - ) + return self.coordinator.device.device_info diff --git a/custom_components/airsend/strings.json b/custom_components/airsend/strings.json index 5cfee98..15dbf65 100644 --- a/custom_components/airsend/strings.json +++ b/custom_components/airsend/strings.json @@ -2,27 +2,30 @@ "config": { "step": { "user": { - "title": "Configurer AirSend", - "description": "Le fichier `airsend.yaml` doit être présent dans le dossier `/config`.\nChaque appareil défini dans ce fichier sera créé comme un appareil Home Assistant.\n\nURL interne détectée automatiquement si l'addon AirSend est installé.", + "title": "Set up AirSend", + "description": "The `airsend.yaml` file must be present in `/config`.\nThe internal URL is auto-detected if the AirSend addon is running.", "data": { - "internal_url": "URL interne (laisser vide pour auto-détection)" + "internal_url": "Internal URL (e.g. http://172.30.33.4:33863/)" } }, "reconfigure": { - "title": "Reconfigurer AirSend", - "description": "Rechargez la configuration depuis `airsend.yaml` et mettez à jour l'URL si nécessaire.", + "title": "Reconfigure AirSend", + "description": "Reload configuration from `airsend.yaml` and update the URL if needed.", "data": { - "internal_url": "URL interne" + "internal_url": "Internal URL" } } }, "error": { - "no_devices": "Aucun appareil trouvé dans airsend.yaml. Vérifiez la section `devices:`.", - "yaml_not_found": "Fichier airsend.yaml introuvable dans /config. Créez-le d'abord." + "no_devices": "No devices found in airsend.yaml. Check the `devices:` section.", + "yaml_not_found": "File airsend.yaml not found in /config. Please create it first.", + "cannot_connect": "Unable to reach the AirSend addon. Check that the addon is running and the URL is correct.", + "timeout": "The AirSend addon is not responding (timeout). Check that the addon is running.", + "no_url": "Please enter the internal URL of the AirSend addon." }, "abort": { - "already_configured": "AirSend est déjà configuré.", - "reconfigure_successful": "Reconfiguration réussie." + "already_configured": "AirSend is already configured.", + "reconfigure_successful": "Reconfiguration successful." } } } diff --git a/custom_components/airsend/switch.py b/custom_components/airsend/switch.py index 41502ef..0c010c9 100644 --- a/custom_components/airsend/switch.py +++ b/custom_components/airsend/switch.py @@ -18,16 +18,13 @@ async def async_setup_entry( entry: ConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: - """Set up AirSend switches from a config entry.""" internal_url = entry.data.get(CONF_INTERNAL_URL, "") devices_config = entry.data.get("devices", {}) - entities = [] for name, options in devices_config.items(): device = Device(name, options, internal_url) if device.is_switch: entities.append(AirSendSwitch(hass, device)) - async_add_entities(entities) @@ -39,6 +36,7 @@ def __init__(self, hass: HomeAssistant, device: Device) -> None: self._device = device self._unique_id = DOMAIN + "_" + str(device.unique_channel_name) + "_switch" self._state = None + self._available = True @property def unique_id(self): @@ -46,7 +44,7 @@ def unique_id(self): @property def available(self): - return True + return self._available @property def should_poll(self): @@ -76,14 +74,20 @@ def is_on(self): self._state = component.state == 'on' return self._state - def turn_on(self, **kwargs: Any) -> None: - note = {"method": 1, "type": 0, "value": "ON"} - if self._device.transfer(note, self.entity_id): + async def _send(self, note: dict) -> bool: + result = await self._device.async_transfer(note, self.entity_id) + available = result is not False + if self._available != available: + self._available = available + self.async_write_ha_state() + return result is not False + + async def async_turn_on(self, **kwargs: Any) -> None: + if await self._send({"method": 1, "type": 0, "value": "ON"}): self._state = True - self.schedule_update_ha_state() + self.async_write_ha_state() - def turn_off(self, **kwargs: Any) -> None: - note = {"method": 1, "type": 0, "value": "OFF"} - if self._device.transfer(note, self.entity_id): + async def async_turn_off(self, **kwargs: Any) -> None: + if await self._send({"method": 1, "type": 0, "value": "OFF"}): self._state = False - self.schedule_update_ha_state() + self.async_write_ha_state() From 65259e1a34427377d7fd28552e5308dc4d40e71f Mon Sep 17 00:00:00 2001 From: 38decibel <86261703+38decibel@users.noreply.github.com> Date: Sun, 15 Mar 2026 14:46:11 +0100 Subject: [PATCH 08/11] Add English translation for AirSend configuration --- .../airsend/translations/en.json | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 custom_components/airsend/translations/en.json diff --git a/custom_components/airsend/translations/en.json b/custom_components/airsend/translations/en.json new file mode 100644 index 0000000..15dbf65 --- /dev/null +++ b/custom_components/airsend/translations/en.json @@ -0,0 +1,31 @@ +{ + "config": { + "step": { + "user": { + "title": "Set up AirSend", + "description": "The `airsend.yaml` file must be present in `/config`.\nThe internal URL is auto-detected if the AirSend addon is running.", + "data": { + "internal_url": "Internal URL (e.g. http://172.30.33.4:33863/)" + } + }, + "reconfigure": { + "title": "Reconfigure AirSend", + "description": "Reload configuration from `airsend.yaml` and update the URL if needed.", + "data": { + "internal_url": "Internal URL" + } + } + }, + "error": { + "no_devices": "No devices found in airsend.yaml. Check the `devices:` section.", + "yaml_not_found": "File airsend.yaml not found in /config. Please create it first.", + "cannot_connect": "Unable to reach the AirSend addon. Check that the addon is running and the URL is correct.", + "timeout": "The AirSend addon is not responding (timeout). Check that the addon is running.", + "no_url": "Please enter the internal URL of the AirSend addon." + }, + "abort": { + "already_configured": "AirSend is already configured.", + "reconfigure_successful": "Reconfiguration successful." + } + } +} From 4edf5d57336104db1215e1c737c00e97dff551c5 Mon Sep 17 00:00:00 2001 From: 38decibel <86261703+38decibel@users.noreply.github.com> Date: Sun, 15 Mar 2026 14:46:45 +0100 Subject: [PATCH 09/11] Add fr translations --- .../airsend/translations/fr.json | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 custom_components/airsend/translations/fr.json diff --git a/custom_components/airsend/translations/fr.json b/custom_components/airsend/translations/fr.json new file mode 100644 index 0000000..a3ad281 --- /dev/null +++ b/custom_components/airsend/translations/fr.json @@ -0,0 +1,31 @@ +{ + "config": { + "step": { + "user": { + "title": "Configurer AirSend", + "description": "Le fichier `airsend.yaml` doit être présent dans `/config`.\nL'URL interne est auto-détectée si l'addon AirSend est installé.", + "data": { + "internal_url": "URL interne (ex: http://172.30.33.4:33863/)" + } + }, + "reconfigure": { + "title": "Reconfigurer AirSend", + "description": "Rechargez la configuration depuis `airsend.yaml` et mettez à jour l'URL si nécessaire.", + "data": { + "internal_url": "URL interne" + } + } + }, + "error": { + "no_devices": "Aucun appareil trouvé dans airsend.yaml. Vérifiez la section `devices:`.", + "yaml_not_found": "Fichier airsend.yaml introuvable dans /config. Créez-le d'abord.", + "cannot_connect": "Impossible de joindre l'addon AirSend. Vérifiez que l'addon est démarré et que l'URL est correcte.", + "timeout": "L'addon AirSend ne répond pas (timeout). Vérifiez que l'addon est démarré.", + "no_url": "Veuillez saisir l'URL interne de l'addon AirSend." + }, + "abort": { + "already_configured": "AirSend est déjà configuré.", + "reconfigure_successful": "Reconfiguration réussie." + } + } +} From e1ca404ff72fc3c44821fce495ef245a7ead3433 Mon Sep 17 00:00:00 2001 From: 38decibel <86261703+38decibel@users.noreply.github.com> Date: Sun, 15 Mar 2026 16:51:06 +0100 Subject: [PATCH 10/11] Update screenshot following latest version --- img/screenshot.png | Bin 23480 -> 47115 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/img/screenshot.png b/img/screenshot.png index b02aace970a9f02be47db1ebc5fc4bf65e5fd45f..baba05e3a9b857f51300df8c95523767507863db 100644 GIT binary patch literal 47115 zcmd3Ng;yJ4*C!RIP)aEd1zIRj+}(=16bTN+f&hFW>f56VkoVhcZdHmY%-X}y&MHc5d*>emG3>?5AB~mL_azF ztcr`iJaAT*mB1()e6xkVd2A`JERKOu8HIggf{DI=_C-$D83W^G$Nl-B+p*9b{gBi} zTE|7h5$FOk`D%foW@7E&!m1*z`Id*3gO!6L^eNFG`la6%I`Tkyc}9$<=o?H7vIm$L z573kX`Xv7^jcyLchX;@TJ%5CzIUYR3z(Zg4(5H3KDL>HFg$y5xud-c%RhM^pd(ii4f~{@>{} zcK3mkoG%y{b@lR6;+pP8yNG8WH7DAi9B^Sjdc@U7$7%QD>5uQ^SwG1416LhF6M!kL zqbC-v-bvEJiep2}_2=oi$tUA*9_MERB7fdXVPc9)KgNs)e>Vx#ZFRO}Ey?^qjCqR^ z6&2c;cdjSBZN1(|cV-)EZ~t*$Jq89hS;j5fKkDPkTf=_@2H!8%f5bO(IpTlBADmq5 zf5cy`(ff(Q!1(fL>wZ?zBmdpyzSkHS^B)`kT`?T~KP|?ch=|CpvVr6EvpvD5Z{{Or zK}7s61UfZq$w@~S58bwo^T%vlEb>xryi(w{YkX|BoMlDT4l!S8n|@<%ayg%t;;E=- zH#(nnua!j2o1_{)*XS5x-F#=klzVZr-C9jOeZTNAgj?1VS>1)xqbr9yrd+0lylsWj z<>2q1Q)ckuQ&vw8myix~pmK6hB^EJ(3R%4kB^P^Mr?om110*%Ky4AQd9#*EZbrN!e zR9XHTckKX5-czV0@k#OTRsfxBct(w&KSTsC>XUN;CeG1mMQjQgIeB)5OcC>7L&8Nj zQlEy}G}Y2d{NGGiy&kYLj~kYo&T2BhQs1iadj=MM41`?Dz=Xg5>fRU|A4$u`lpcE- zMM)we?D@;PdQwJ(arn|v1#tq<~~wkx-UIoTw>wEHwPYt}*O`(7uD2-`%GRqu-e>GZv) zsjSbZV~kW(IQ4BlbQdk5UV1Gi`6jEj_!naHY0Xk%PRhp|~hA{8ebv)3g`-=W{jWCz3c);j@IU6Z2l6v?! z9gg~)z;^`eJ0U zb7?+C^CSOk?<2HGp(_zZ%-LRGsp@wdHI%zXzpt6G;DE3+z3u+TSZtBNU#vf=SG0r3 z&ndg+t3K0de+hI@?4h9+<-k9ppp{twzgkLoZH{n8@Is9%EWvN@Nt`qi{DgH$C2WyN>!qE#?9a1ROtkUUH* zLsm=w8`SS2NK2VRcNquKQ4n-FjP{CuSjPqbp=jl+XTmJuBFy1)^{)jKBkVdQLhy{= z7p9On{QjOby1BYGd;R>>-s3VtLhI}IBb!!EPyN&R}(3OuK^Wm`cwaPQfjuf%&h%ISA`F;tQJIhF3Rus zUCyUg)^+$m=xRojiXNZ@x54>KXD}`9UN$hJ#<(%^nMFU=S*wQSAQ{&M;kt=NAdy8I z*p@hQ_X2nuouXP^>{N)kk@(~%VGH#0Y#}Aw;Ong>zJ-&@>=shgxT-p`mnO2DMTeWI zB8+0MUo*bTy7IbRdo_F}(o*JrLcSmc3b50Aq&R(wdP-DRHuRo)NHMR@r97u|W2ajG zV9oNHqtS3Yfk#V*z0gMAk(5hG-5iq_`aYD~E2miZDffnuEyTzj-6)hOAlLMeL z0|_c4Db)1#oyZkhc^OgETrtGYxpVsK*e*|@V^sQZ%~^oLt3fmJhpA-yHJ;yK@jQ)l z7xW6Ei>G?K>D(^qlrE>e`CePgpfCAPSp4mCrX;03*)&TG%R)Tc;rVmc#F}ctJh(@DNxXt_%mNu3lD-B;chmpkwgDsce8&o^^X!X zQY&$*OWyAWIhgM};TwXS_Aq6uyI9Qymq>>(qC}k`aV(s0FXm~V56jg>B4pgp`+c`T zVl0JhdTOvUAJH3z8|6Ac>Uhqel{jfr_~nPvC;C}$3Ic;B2w6T@*INDKi+PW-t@fU2 zm+2FkL9Ya|Ls=n3Kg#vrh5D%ulctpw`~06to}V5Jb!=|aN%#`m9(E&UcS-lTC-bzJ za%6lIP97@Tp-3y?>*r`w#4=?@R;n|feMU9NEHy_KMkrQM?qv1lO zG@RIsNCk4M$<&xrla?a%Ph+x*47t3kabDA(AvJ2dCD`PFI+7pOwY|)0wV$i!}|5dwlWB4C`Cu!-a2n{==qKx^W4DKkGVKj&iDEYu0(|=ZfY@eiUZ!Md*3a_dY(}T z18;&`MPz@zDNWER_30iz=0_~s3Q5YG?~?Na;T5eSi=lF5zQkQ+x3+fH>m-ZYTti`O zj^MfYeJl9q#+)@=DuH{^@@1(ZA3G@0JGxgxm_D@Zvpu!@V*giVBa;V?{ zZy9}<7g%OQ%DoBPf(a#sO`g=I?pVygRztA#wf-J%T0f@{s?uVeQQg~bBPvQ;U4fD) zfD*a^FzsjDL?;&kl&i|=aFtz7!+YUK?+#TE%^ zOhG3g))T<3Q^R)R6^!C`+t2=-2GR0Hedn3V1TFF@=@>{#?&5IO%XQ%edmWMsw6k9f za-BQ>TI42~bun6`Y!_XN6t4~QErM7YM$Kr&i`ZQKuJx(7gHqZTN3|+6RWwF+1OKt^ zRJv_r1&CgiKO=RB1`JW+#jQY&0GNy2lFLV8B=S6cOc&x7xlkVaMhf2*@=PcnO?;{0 zG7Kmq`MOVuLvM?Jw24(BmXfd^xqou~;Oqn=-TfCq6x)|Cmi_Zv9L3JHJIJ(EGudfs zv)juZE$tf^_zB<4_3r+g%X8V-z|&npkNvBbjWNnlQWyn{8O7#BY^pW~2>g*=(;Y-? zTjV)TXyL#U%p&samcMceUJ?|xoNZTkZ|Y+xZ+qlpr%TV#lMzWby{zHG#;tIU4rSEN z(MHj(&H7jeS#2%SXd4p!*SOVwtBqUu^Kh*@9ndZSEg;=BGwN^nDO-SF z3D{upThq&9F>6@O;AI@&<3Gl683SKRK|{TQW;JGX$^ z3cMVLX$AMDN+9mKLv=*3AAO(X86)<`5i#|u=64>(S;Lz(cFy)SP2Xgtywj6^InogE zCq3K3Wf^9)&kfradYD4L2^?C~RuYN!*j9Ol0B3E<^5DEh%WGD?6yJVR!|T9W;Zi+F24=4d8No@$uA{^_&fG-1Shell7-)It{t zCO)wYn~10^K2*o{KTbp&gLMdt&H2WdkQSenpihRsHBL_$Oy@_=8R%>8-Pk|))(^HM zs8`wcOw4G2RFhKW$O%0JT4yNUHESCfbyHx^hMn!&*jHtSP0Wf`IbPTJjJ4F($A{!q z{(%QOGMfATr}Zbw$hw`Y9?@;niGf)uogv7J#vIxW0C(EkU!nku-h^%XL^mvq)pVlBUqn^Vu?croIQEhd6ISQe5P#T37rT8#;V)e^1U;PJYbEm=35Vf)Qq0|&Ht z`j)*w&I%zl5O8gcseIezDROAmC7xl@tI(4t5#U2$HhDui3x!Q{pzYQsfzH{H*01!X z%f}_;9MA8)@bt*pE=9zGzWt*3987$!Pr;JeH+$puV~dMmx_TR1-+XE54G{=D+5AI9iZ=Ad zQA)XB;^G!Sl+JbJx@ou?Y&)t|)acr0id!RT+t0>_q_4(DamGpQ+(o-0iBUiMw2asJ1y z*vc-}cHXHsqgy?XPrfS&!a;n7{1g`&HS{9@E93f8z7WaHA?>1)u^4G?J9DSowLCj0 zv=H(R|T>8&Jz04hhu9WkT1 zt6oagM-k%x?XmCOiT-E(R2gSg;x)b3=b7O~!((br)oQ0`GpQ-h#!VL!m=F-(yV1&< zP6U%7{QiyZL9Gfs+f;tP)|@&viZWj0c-*iNOzXE$AXdYEd}4@pA`y&$5r-OQi@r?q zLw3G=^4wGi$s?@*N(_+~9hykYkr_V(TZ!$N?AR7Zz1Jydkf9u`KSZ<>=k&hesvn{( zguA}oMXU`93`YtrBPOm*W03@!FTzGO5{MQXJbf4Ws%1{VH8(vS@0JD!Y0a)$3G22x z*T|R{4NM8qti4DoHC?!)0|H)@@dCl28AA|d!3w; zGz!ugX-CGj$W*wxQ*Leyd1JSBOnc&6?H{uiZ@NIF91`4hx4cWRJqM#m^MXb7ZhVMGez_6h->%UEQ zMep2&^XkqNSvK>0t^qQrT?hL%;k}T&iD+`^K2%d&lNjXUyM*Le4-F@Vp;WA-N^+n~ zaOVxDWl?lM_flyYtA$* zZ1rZI$8AqKaj?R5zvdhLsOXRQkhkMgu+a4fb>GphDE)ri&$K4L5V}AtXk#jx$=TLE z5TiUY%2fDFtqM}}$SvJW+7Fu!(0#%%!s6D8gD!UiG6k|0v2Wd`pM&(=8m-Kzd$euF z3AyMr$qqk(ysl>kN@M0D0a=m$qr5co1%+F1rl zaYa!{7+I4R{mHdg!$t+cg7@s=NZ*9RfLx=TbGmQ55O z`=*f+K)M<}E=Go_(cHt+uKC?rF1BS982G^ zCV{J@$(Ngfzn+t9`|V63z{U7ex4(*Y5P;g(k_kT%AftX;QXAG^yCuCw?A#wp>G>P- zJwk_lHg5u%e1ulqu#M-B;M-Veu1ow0u=!MTAhgaxG`xG*r=SnyQYU0d4ZQiCsCZW z`$oxv#-~cgzj4st9b~J!;(S(&*x2)Ro-G+^h1n4@{&v}qLpiW@{d^g#U&#{DIZ=N` zT@dE=d|>wHyt4ddeOSMB=WngjD~`M`hNhIaS(tix5iTPj`Ab{Y4PavN%*1_z{#q9q zHrGHPGkvj@0^E6t`-T~k`*vUx8)p1x_5nHQ1MXSbA?)D0k8l2#$sY)E*&&Y*4BcWbP$l+y39fn*vD?YlQ8x9j4l z|Bc0ct(k}8do44gu}5`hmXsV8t4P3J@zv1dL(s--#`5O#t1c(MzQN<=ycr@XvPN_ts2st5_$E|>Ze0|7{t z%N9M0ytEyc6aZX1;!CoLt=%BKRGx5j8t>-NLkaWlf?9I9P$GwxE1tbkXxN}(THM!+ z-*fKUd>?7Iv_V_rOTVJ>3Z7+H)mqP-uTv_B!OKd*waHv#6C4*w#qy3!!H7r`s8-WK z+OTb4!;uu$VLe~IKBN0D5epQg1O9v>5~5To!s^kdMLTAYSN8I%B^SZ+)*fycZspY* zIQGnXp}edLzly17Q|0)PEk&+J)5@JamO`>9=B0pQq|xG^H+6?;7*TAqVFQ>y2$qcQsGbB+#no*#erVfs)mdG2F^_9>5Z)ijPD?`ds`NJz1oy2 z12bO6G}W>8lt8mh1t}OJyg&9xv5fab^jjq~Z~GvkIq5aTgM5zdB&b-7H>c{_173O9 z73J6(Y}3?o$X8&%n%|rG*LO0mI@t>cT>Nr$UCnsr zqlO`TAZ?HIxv5Eu%@-l!M!ZIGJ`RR#Dix#fzRhSVm6r6grjjKK`G2vGgBMo%x|MnK zOUsMJc9$#@JbI=_WH`ifRnI51~AtuhN}nkwpEr%)dy zXCY^A#?v}*dp;WXJ3O>kTF%2OD2uv2b*KrQp4A!RIALMC^fdM^KrrkgV!AcpxCk_(QSV=Y!@LkKQ+^L8%B!emvXm@IB=Z2nN}oJ5Yc=1j zn%m;fw&nTI--fuPnv@wUFdM+1a>V#%FSeFxGvy~!PUCwPBg^WDg$=D-1h4$@)Y!N{ z`U9%SUcXzfL#8t_HZL5OU2o%k>jD#Ew3GD5+0t=ks(5?(f@Ym~6Xr`nevqs6U@?J3 z1dD6#d!eeqZ_zc)F4eWn92ov#vYgs4jasLUFQp1md&8fJuJ}IE=K<_3`-)C*q&`ci zR+Mh`7sGbAmXBYQ2%E718#65}m_IM%6JHe%gc{jj_bGKrtV<&f)GO^FhA4nMJSIEK zXe}_I+>h&1aqlnfz8q18*#kgG3nnEITF3n1KpX7|WOjidTjFfxMsT&r#DT^yO%(%k zx7e9A&!VeT!q?TLfyF{zOlD14dTn{>#?u)e@`)cwBu+BA5i3Z1WyGSG82UL?LBC|g zAfGcu0EDsb9unh2t_~i#y<4fIAhVOCbk6CJwt=Vjh)Bd?9_D_4#8H-2Di)bs3t;NK z_N$+)IqLQbMeduhsWg5zY963Zfc^009J>TVTB-h4_*8VDT`Jjz8oepmI-iP_AFPuu zpKQ%UBS@c;;YPb{&gA&VL26vo@evz2goh^5v0g>)6Ro>!euSXw($hK;Gf3p)bM@O( zk0oL=!u5P*Z)ZipYV)n7Y$x$I$iPK?1SsaL>*n^ONLqU+V3*N3n^sjhDmf+a#?|#D zlU^${7Zj0Ij)5_pdm-ImSJVd6BaJ?aX^D=9f>ak$%W3n08a8mn!D(k>Wu*b_i1TTp zzfKcF^9hAHj=)zjgvLt2a-8%P9d+X6QuXBtPm)kmw&Ol(L2UEAXPr0iah#l-pn_>z zZq8qV2K>QPr{*P1eRiIB$jw&z%#N(XGq_x_to&j^9-Yi6ZLHDA`S}9x@aeB7O-jg0 z3Y$^X-I#$*C8WOeY`EN6fW9v{1adntg-9MUQ3|s65dr>6D|ck-3qD+JGkl5ZGJWh> zFY;&(dFZ!{-BfU~PdT|%Ys&?ar7IT6vcN)Zf1iJ$|O~ z4n?TA0upA~%m({YES#~|^WiImySFEhay7QJ*sZl^hIYRpaUnr%^;PhZA3uzHc?%1t zTC(h&O!=W31!~o4_*y5X4^i3X@pPD0*eRA3|#2@Waz$uG9z*gu}5po{BFNrz38=` z$-vOhrq@16qPWjs#ZEF^37Zcq$2CS>mBrNXX6yp@<>TmCjj7`P)Ue$!3AKhJO&vvM zX5O=uZ=-PilGWoNgl@KuB6T?LKn{`oiLtrMUV?h64s5j_Sf>xBAS88WhU#Mg1Rx;< z4r|oKU1f;Tv~%`((G397<=sA_ZM+zVH}vjO`**t)F7+WqxQ*rYmE9Z+FAWgSb^TQ= z!5=0Yr}ajM+VYysy}W0E5-MrRMoGmVUIjlwly=WP9p&6-3CSN36_Da?ZY7#l570IQTu!bH?oLj&MsffmYv)#rxpkAqsqSbcA`uN|v#I_+-?ke+lt^)4XBx#1VCsg2Cj zigFISD0egNxz5+;spPzAe%>8^G{~pIFw`x0pA;_)mDt1TEn#$)qVY~T-q?jD!P(}< z-k3nA&g!w(lP`a?A*>cpU#xi!yKp4zVsi_1Pwspyzw3V0n~fVjEjX3pc|DM(p)-=E zl8u9}#E4pdgz@oMgZG0DT2s}Hx`Tz*&P%-)F}Ya|N2rt|k)N4VO9Jw+!7AMwhjz*V z6fEISrjv0h_-Z-vMsvlNET*Tf^G5@|+O5z}On6pYV=t%F1;&Fa-nTIJU!nH>OMRn$NqI4Ge)EZZS{& z#OXgDYpn4)*qv&cNy1Z)@m=>L$6ZjvUly~qfL0}4mx+iF_;K3GH>rZTCi~T;f*IMI z{Mjqhr-ypMx- zb*6Aeb0@Hfbm)u;7L55Wdr;sQikv?`P!o+~g<}oi?txI(!xVv!7rh4i+B_D6Dr@Oo zS96xr_jY_3H7y!9Ka4R`PIE6+XhpSt*I_YaPi&}zHLdn>q?5f{ zS`?+d98%bWcYM{ZE9n`I5L4oDwkZn3yuGHAyOD3C`O!VUd7`PROMRXlK`XOS9!qKH z4rK@TTT%exI-eW*#1~Ib8V4LTw=2k8CyddkCz5~>?=M+m+zKminWrjzC_|_StcvGAjqZsnc_15Aw|%2V6+R_&#FZOEL_o(^kF&C%v3~Ju>1r zpdz!Cm}HGr%1UZC{W)d^HWYrO_`;GUG$!s+mxQC+z=_(syT&oe%EuUa^98bIde35{+1i zmv7eysvDGv#2MWpt*TKAAg4gvzsqC#=GNQMvl!G!L(-?1~d2#*J~UNhd* zyO{Fqg877xsI^({+z*R;groQtig@}$>AnlB2+fNXBfn(#c#SUlGr=(R+|Cp4DoqY! z@)nKX8?AJ^n5F81mQ|!=wsCXn+;X~Im;_sy8U_z#0qPtL8R_klJz%dc;AjDP)~Tnq zmPO5Hx-W@ShtjiklVx7j@2_I1$yi36FUR;&PcN(DWfZ*)$Hfh8BCS1N$Vv~*0gaV7 zP;}~6#A<>yVV~SHyIRSA&i`UIE%)rVgla2f>jhw?l;8FCIH?~NV$2p(+(j*`D8vTi z!cnJ?ke;IESuybl?I%ZbDU;-UhY-;O)vb0R3Y*ps!#CF@vv*vC4y##v+9Nm@jUWE> z@SE02_t;q%=bE1hYDue&x*#DDde(PaPav$JeXxO28g~KLVHqFWyRBUSbR!-YC4|7s z+i7WZ&s4bCBM4KKq$O23nxWE$`+=C9n;8v>O+CL`1>NOL(+t`1jCGg((Gp!jDpB3( zbfw7`r}a!`j8=>Go~LMXJC&LY1c`A-+8WC8%X84n`IyCe$Ypl*qJfaLMdKelLZmelW;lH&Nm*(GO2Op^XYmOoAWK7WF3Oi`Xjy667=&K zw05`~$!Ne03SNtC60^7Ef^9 zD$=QajXN*NJv^REz&lOvTF8l!B_UULGq-rqbg09odK=V_&*`Wwm_#z@z)-sjaaA2l zOF(>%%w#GKjUA%hc(yk?Lz=>5o+0LYcGV$yV`r55w_Lb&Wt9dC6;BRiRn{jwE^M5AeizCv-`J)h z^$e6=Dk>lD=M=SdDd+2p#js#jw@ouvU3uc|&ZiRke4tOns~WuhDs$JGDf}|(u=Yjv zz^mQMN$#mSJN8)f4~@!)T{`L8fq#ce9%exrC$(h|k*|%zmy0+bck_i%B^5P}v7+uor;M<1V>ZoO8al7-LwGr>Fn=5?R6iSFeagj38Ddm6oD`i@Fdw7Ier`8O9!)A{ z#Ppj*o3M!6C7L5+s?C7AYak64ep@aufV5ki)f?CA=${*9qyIfL)H z+Hzl4M|*8)t;CEX*YFg{JA7VCLpI``&7ed>UDUq#EnT)m^SF(2zcl157Oh@3dgph? zJC=^Jk)Fe0@uSj{@$d9gNpiupztv7*|7tRl4^l`gO)}>?(%x$Z0D`HGng!HY?sIusq>YSTliqN;wL`FDh z&^F`sPs)}4O;;ETylfKJvX6aNyu!~DpT!7B0%Y6UTl4oEEs^8MrlV#mj*1CO22-er zZOu7K|5hUv>3p+^y2-!@MO1{9Mcw=Bx=c={{6+i&d!Cnp3Wy9H3faPex62Em0oOQB z{)9jAx~O0p_aXrrhQ8>AE`Rw1>NpfzZbqBCiZH)BSVHF7-)((c(ybq!9ksQZoiB8Y z!*K`IN1hk~sNCw#CWl(wvp3+Q=_gk~`Oz*Omq*yRbW*@(kl;>Ji3dobufJBXPv=j8 zJP<(NkC3G3c(l9jq2HJswi%?w;ds-~FNE(WJ6mb{WOQIoGAaF)zQ=?gYE_#`+bl`; zlZNN&?C`K3G78FeM#4kC8(YNvQ}~C@c+w7AC2CdNzFcO%wQF+6MDvs{+88gG|LaQY zU&xoR(O`@Owi7t5285T2_*%76Bgv zczF0#3@KTSRUUtay~A7Zh#6(7c6srTkwtUPY&2W3E%oVMe2m|KgX`azKk(YsmWTii zx(U3!9dCyzv*ZD0A2vB3n)VwFAToyB&Ka>S6uzqgDTx#truL3)t$lDWkt1M3EwOH2E?&tbpa@^n|8Bfm{A* zlOMJvFCh_DgYNr|7}{x3s(i=0f_-!T?`1Q1euiCpbalceO13HXU42?iHz;q7>}4Ig zsTR*~i}QqHx{O-EGt!=0=f`q$Zd$6J>ng3JkM^f{QnMfI2A(<-K5zMf!>1wB^m`zw|Bd9wU{N{USh59e&wFJG5xt(Q)(6VnY&J*3E2RDp|W5yGo~kgHl>{dN4QWAOQ|1$5l2=M4Z(vGqvPDfta4}wDU*64f<89L)RLS7R^34gNI52%B0-= z!!5(NAV;&KkSOKOx7}1v1Rggfr*f(O@$pp{>@1M?Iys|eI6U0_NYIrm_Ig64W0BNO zl`XGJ#ee-L>kH4#+l%MUQA;cSvLGOxN*Kb>{^xd83O;qVYB!T`ASS35?{!whqELXP}*ko!5(P{o@F4(784=wkjhzwi&e&)v>jnTXu`pQkhb+5A}0(6LCpS@@e3%le4G(8sk?V(xrJJ?QmHQp?Xb+;>d+b0Rt; z4cNB|HHL`Cucs7=UgRA*1;>df1dL`qoca-nYd&jOCn zy%moI|5UP7PK>k0A0W%ld6Y|-D`qgGfyH7w$U?`JEU!K!>w=~e(^KM~*Jb?5o zkU#d@P>zZ#ik_7G@Bx^Nkp)uFU~rs832NwiNd)BJu-Iqd^|;*>_W4O}B%}@Oype zQIK0>pcvIA^(W`aSeM>Sh-#|6I)`dinZ$s%L zg8W#(XJB1D75tkusOu>Im=cGd+2OCUpPgim7xmUoPcFNgjuHdob&UDuI)nl7s5z8; zENy7=&U4XguVluieUr~28S<*@MRdOE6&JDUTONmo+4x&jIa+4dTJH2>HiVLWKu5T7 zYvpu_LGu>P2u=R*R1A1v=hmq$)IQ*QUG}3zGMVRxe%FN)x(znVN8Yodqv|!F*oyUV z@)V4pb9bs=7efSu8a1Ybs|PYT>Vs;$y2yPwZTRZGY%Tr3?BJgAwW$q*#nZ<%saNd; z)}d~G(C(~K&d?Pxn%V(|TH3E(uF#K7h>@n^WClf}X1w#33uheRv2NsFjL`Td>B_WR zc$CfECP)g(P}d$nbkK?|8s$>Wd+GGc|JRU^pX%XYuaZ_*xx`j&%D18BK{SkFZW=Us z29zkmJN9+x@WHMH6sYYCS|r{j>3wPWsGAL3a5$W{UX0)L4d7OJnmo)2|HRo=t0iO| zv9!w_)QMobol9o+@bLHL>qlI({{6J?tN+{rD=}lC16IT>?seyNV z3d(D#4O?{9y?sf@$=TnD5O*~GB~SgDL`F*_PUZ!8E5pMgWklA&l4-nFaQxXhYdWbS z7ovTN0C}Pn`co>8E_H~^&qvDJ$KrwE_>$Ril#vE}m`%O7_uNvc^={yOdG0&ox3U*4 z{foZP&b=&DYP|$h7EzkHQA%vNkM;K$1t!_Q6)#qYJ_Hu(Y!Mh zHd6u2c8Q1bd9vddg!Q|t{JID(QNG-ig?7Tx(iMYPvG zT+{F7!q(Kw4FmR%MJ5|?6lzS6|A{i>xblYbZ|&z$y4=U;)5a87`mj`C%HbP57xV98 z7werbeijDReseKlT!oy~?>VxKY`Q3-8llIYcujwwJ@b%yzlu}A<<(K^`ouX}>cgvN zNtcVbPactxZ?+J(#V*R z6-39o>5*~68F3P86b*WkE`iC!-jvA;Bg-9SfIwn&5NUPS@NzMyN^t@%m)b4yFC)G% z*X>Zf{v0p|8m@C1uB#d)4VxjY8I}{IP%ZjPymmxo{v2sQyrA?df5H>>5#MHi>xWTY zYS@L?iyEZ4iF*D>3us+Mvo8D454lOSxoBkez|o?-PV!A&;3Y}&(b4ZWU576O5CIYc z^O2ce<>};B^{S2a&)jV7Nj~O{#Q1MXD-JGdPx1{k)BR`fm3w9FHwFkdmR$G+_bV%x z*&l%e8NSRZSXODTUZv+f6S^=enivr9I=qC4rt)Wu*}bpRxaPs(gWYsJ>YvRY|1#Un)NnUC$AS!7*B?v z*_Rxqt;=uU9x51M8QUatBf_O6yf5P$@xZYXv7jME{S%uk;v>EFB4s5$v?QreOf3V@ z5y8Ci#rNpZ$)2v%7 zOYC*0Iy-Dv)sajW@oTI8Rl9#({EK;NFedHmCW$2*U+mhy{PoT=lO68#>d2!E64Cv; z<~eS~^%_sXxltuLHSP=#kz5c+)Jsf;iX9{-M+pm@8rK65N5tG!$1M%Jmkk6=d?Sl$1LDSDM;3G<{ zNQDS?EU3@2X)3Z%J5gfa$vvl;k6oL9PLyG??Rk__lXA2wrjE?B>|RWpf|#a^B$c(kzJ)|Jrk^2s{Gi;Jb%SNf9SBvyL)VOuJ5PAqEwIN z!Xd;sFyu(jJ)~^eFW~5a2O~!nRvm4x8Wl^l=aLADY67?|H@RRP9R5Qz zBD0}SW)-cHS@X7*^}de|%wM!3xN$ zo$f%ESCVO`p2u_R4d;rN-(3o)SAN@gPlyAG3Adu(vtyauk^>a!X*l=9eNzqOmout0 zQH4f?e73uu91~#{)K5v;cGTE(fsa{ZYtEXH9--4=^vl;WnhMzG8Rc2kd^6&pJ6Hp3 zfwfctGb)ofU56+i;RGp&;<#fyP=>sYtOw~T9KhUmm~ zo7N*8pp#}ocIUx@!>#i3*?@ck$v~5R3+``qrMVZ^s(t0K3Rjh2FWcrvnj%6)cE?I$ zmw$roX$7~~m2v&FHdGjMN+5th(IJ8BQ#GjbZynF#)0B8~cv*X-w!Ys)p?@jT+ttW~ z*374W%b3YN5wbBz{Q0SpsLuhA@T z-zB3lKY&6^IUv6j3~Sp9*6?x?9qbK+Gd_BVgR2n1+EjDn$AF0+WuYXR&~P#2)y=68 z+F$Fk61X>(lk0cnE=D&quXPXEpGt5Pn5hT@_I(+;h+d~79`cXDX*D@RGRmWpb&-3~)xq+0M zps-djK+g;Eacxr1+<`e0++W*xYb{OBsmQt!zsAHv#u7`-|FK%y`qQTi>eGQRCGVwh z(xpUp-GVdZ;EdZIjRW?9S0eP*HzXCa<;}(#o&4Q#bVbsuf{JW`y6jk~nZ-~2^f%bUj9sl9V zX5kQQn%F%Mzrq|7{aQ6e?X3~v?{cSe2mII&=gUVc^T}=qtMJ;}^_Gvw70PnEt|(di zHR?(a^NxK2UX;g5bfS;<{_Z^juZ_#Dn-`!yp4S;xhYo5j+y}LE5%GrdHEhGXthY*n zA-qbjUX;)HM*eCXJhxW8&sfWpE?&7hP9EJFuP=++zXa$T!M0BMKJCb*t2-~N^oCxf z{WT*!zUv9?V{9q^OsU(yfmbB=*OrD?3cI+_py^Oy;ZTScmw=2XXe7J2)O@IZ`$Wn? zHR6>|O`)XEp4NC9>erk>;+^deX}MtC@3x2I=S-3CPZNR)SLF=r}U&m8kDXt_4}6{6R#G@CE>yw*X> zdl2!Or|BWR3%9;ip9(U&Kt}bKz4smbaggM|R4KuSP1j4>oPN^Iw(8{?uFfXvi%{={ zJ)V4yOTD25!tYs;ZbtBwNhUEa&tIx?t#8j7KW=L}bf2vo&zpIQ#$;rOdp>56N`#-i zS+!Pluh$M`!`CV>u`8H;uNU37IV+#PQL7B}iVQtso}tCM6>4IyHVfY~5F{rflMn)G z--Q}tItOl@7MnebsM$26y)IJ zC2)TJ;&Oi{z)#5MJW*fT<|U{EYMaGhnRBAXFTzt-srz9mUZ>E%13Q6g(AbIJFWj~k zX{_Zz_08|J@sE=Fr;;47Y<2J7^5@WTNAt+?z+kzq6WnyhA+_N?yAa((>REA=#&}Ed zkR){kTSB7pjMhopl!cFvMtxEW-~{#(`-wKKs(mO~rL)oBPy80xCd3Fr$>AblqY|FfCoY@vTjw+0{0Y!o z5YT+Q3cc!IJo4H$WiGzXWSXtbNnoeRnH&J}R=rE0FgmM1BfQo@eHnUORK?~`K}zh# zE%M%ydHW*O2V=;1mEDH`*j15FliTT*z?SjGOhO>z62ge4RVVxGjoLLifL^|YN`HmY zu&a2*n+jxuo+b>o1Yiv72e67(e&d&B_Z11P8+rR=gMx|elwDCQM46s!AGcsUVCqkS zm8adm+Y**&u^Yae zD#J0D(ifAcPjz=w2d6hU%WG?R4 zy|;{tv+2@ClaPcE0)&u2@Zcd>@ZbT0TX2Wq4#B0-;K7|ngS$f`jk~)w?(R-w=Z5E< zXTF*9&01$>-Z|?#Yn}eXg1fn^YS*q^dtY_!UB8uIO9^mXUo~G6PWWLM>jhPhR$J8% z=F9{YaBWTEBpxN0Cb{)B0DtNiKZUGr~N@5n5&4bhhsxeSSd5_B`*9(x>? zxIVN1f>^-28PwJXPO(`U|gf=1=n*)_S-;Y=oqZLK*f z=tC8b4K{xYJ`j~I1%Jcet$h=fxx8(xs+hPd*M?hfTTc>df0kY{RdTX>X`@a|km+op zYWc&$PT35<`$<+b>dQaU;uGzfX1tX}XgpE@Zp#EE$BHMXW19xHR5fu(vS}vi0i$)~ zFnzt{=scQUzfMIiqS}MqoRF8lV2)3;b4sj7{Y}$thC3xYZRnZn zb1sK7>ZHfMjSN!6@EhTR5)t+R*2r-*@hu0GGpk3#$JUB!(IXgbSPcxD`oTD^2n zMjm$)JV~(=Fe*V&brZB1+1t6uBcn({ z`W4O}S}CS0qoA)D^T99_CsR20Y>%%)_=;JoIN!?`hdWvC*=uG#j(wSyhouF5u!|`! z)(0JhvG-S|m;5EqQqKKz^{>{AwG+)UYRicuPNxqUVUI>Lz(MycWgtkyME<)>N7zI8 zf=7)gGtm1-kN%IX)A4^+@sX$6C9yOtLNa%-WVg>vm9|sWd6z1Xdrt#cyBYc}`+PG- zN<2aWO9KF0{Jl)#;lZF9^EL=;3sLdzi{+Y3^zQlNUm>T4L$8*o>`U`iHb*4&?^DscK zw%7B8XG*jNPBWpESn*Wdk7im$c-Jf){*cr0HAo90HT;BJXfcEIP7!NjcJMERervV=bnZWDQiD4SOS-wL>xnW_bqug7I{AO{n-f_=YQveXM zM~^-oP*YP+YBK^HuGI}x)-$(S`Z%*6W2Tj-ZU!o$zEPou?>61`eow|I^#*>6Tb2q> z+lQU}tbF@M0KQ3hj&%w$@j{fXcDcTCE5n*$shf9PXGRl4uTzi=nZqbaF6=IhcOY-+ zpYJK&JI4*4WCZDs|4^Ghw{!?}Rlj-e|B3Fh^L$=tESO8C=&+39yjbfj)}f(7k;Cl7 zXZ31i^A|g6RZUI*l+g*#Iy@p4MRQl|Jh{%QTnv6$)(W-YpqE}J+nYLQaIl!#=sqL! zvCYs(%L>|D{P1p=n-a0%jtE|V8%Y%+n&`r9dc$a^PAhHzZX%HR& z9Dn;9WBxIj7X!#h3ZY=ZjL;*7(114-%=l%RSk58u+ODkzz&n?J$z^Y!%pfNtf{duC8=yjI^!{hQ2sSc_mh!OeCP<2lWa zYFS?h`-0DM<@TQ4es4vC7q#Wqrec2lSUZhYk!7w=QYF*Xm`Xx>Le zOR6(p;fC*9*y$*gW3$Aw<#Mct5O5|L?Q3RRN zKk@r|D?h)$RyP^EAT`zvA`wFo<`jdycwN6H@rNCuSE3zR$@rL*m!;60mtpPP4x`Ulwv zu1_`*C-%4{(XJ|=K0}|XC{x5*Uya%bDI%pSQj*|&emNb^(a~am=epLLvLBL#u9aoZ zwIO}!F#TDm!UsZl=h)Yz>Qhj%cU^Bh*1m6lr);x7>QkgZJ@4@GvOkD1Eaup+voH}M zr!K(wghIXormi@~SLEv1JDIU5^lCQ@iR_!<{9cz7=Dn;`Vlf%^my~vuu?T|Az?Vq- z@0&aD+-?qN&logSZ(de?q!YT+q|u@c#U?m|c*V-+vri;fCpHxczj(L(sr&7TR7zNi zLf>jY*UOZS^e8Q4{b%P9bC<{B&iX;f=JDe)e0v$1I(bn9wvAGM5|Un|$u;3=u4*Y3 z+mv8oXy2*CYO(hEk=KXXZG9-qv6RC(Yk2B_royM0;c?VESWJA!b@mHN=Z)X><`esa@-$OZQJadreN+?Swa*Jh zg?&@@YmfDTXu|`Xh0!_rcojcPC;K5D1K7h6vn-n?xu#- ztze{TKWE%LpM$6iL2%KF?+Uuw+hv4bM(7l6b?_V#)?3P|TD8}0#h)5>%yd^pbU#?& z1-+5L5$|&KhWeXNZcbk&9M<{gz>Hm=hW9GG_X9U3EDkO%k?+{<02W73T}gR9mwlYF ze4ho@$+`V6_!;g=`?Yt#n$#{lTZ_Y3Upy+KW5JbxWks^2*t6r$p)AOzcxx~Xni!kC zZ!kk|!`R7T&8<##@}Aj%Y4#$%iR+WTE$fAQGM;s%my#XcXi)@8wFBIlJIv0_Qng*R;4YdLmVQ(JM5R6H?}Cxt`9JN zyRH*1FCU*cD3UE1>`$u|bMFu}_GDsbA;+8Z79j#P0(KM_$s!Z(TyJhyoJ)l6{`_g0 zZL=TPnZg=Sd&0A_SdH$M)Sk8`NaJja^YMkq6yH(`=KTsdL`r zqjvrLfeFLmdmfXa{6hl4&8&uyszy7Vs09BD%@h3L{=3sZu4`TX_OLY{!UGKGPfm7K zGnkRmcRCQ?@Oe>)U%y=CE;seYsq5}r|Eo$N23dvQA#QgK$LE)4Tt3fHtiI3ZaXeZP zFrN!k%I0ZSY&*mnHu_UcKNFi%a&PbCXQIvRl+GanT~`e^i=5~7y7y}K$J;PzANpud zgoZlc7)6$n^B4d8X6A2=GH17->A$MG1(CcCFbVwf3oD}YZdPjktdg`nI(W_#0R+RI z=c!~oVsHQaurzcE^msA;OngqZsyPDBx(1JK61yU zIrYB>vw(Dsng3oHI0ti!5k>}vaA8vI5+%|%NKcTkfKORL zf)ncIueFzAVq$SrkxnP1lmH=}_C9of^r-OAfz#vbhqo^Uh{H-s==FEiK$QT(>J|sK==ZeueIXT~myZZWQK7G=Tuln#hpv|;obBL6Gb$y*GN)+RLYR?(Uu(7X;aDrpo6joNOE)9Wmjc{LB0N74n#$zT)>VGfOZy z>HSDTLSkaFeSPix?V*;QyZ$eYgb^_trxZ!L30P*kqabjOTy7+*q0zs{^;g+FL(T$) zp5;Tsj~^*1wc>FWm1m%TL^?-DBO_WK4mlo~4n?ZdwBcc ztA|IDbW<=V63ze{Er*I{st?oYuJ5B!Xw9Irq@0c%COrCW|H(sX6Zdx6ys?wi!~&+~ zGGVMI9*Exbg3RS0BKRZ}nIN&83TKRV9;BdP;FDN*Xhj1SDn z$WWs-pnB;1_ZZ1Uc8P>9B|;$tCrg)2o`|N~WyE2^N5{vo?vduYe@q0?hYyXSA()ey z(a$%RkS-+9kidH#3UNvYHJ#bJM1LJ9{UQ5WlihI=M3I&XasH^2=j4ju`Ae{#JF-B2 zoz;GJuai+akTDGLw(LhX!e0T_9}l4Ms5BvDQ(3W=vf|<&0TS(FW6T^J@dZEA-R^Er z21c@^aB~ylulVJDg7`{<`{UnZc0k6cE~Y!H)qhL5HJA@{QA-#7shr1NPXhg1RP7;z zE|EMf3P>=q%{^aPHO0RlN2+!Mz@f?>X^J{*yUqU`!sQg0Mkkbh&TF13tLW}Ch*y7_2Oro7f> zsM2Ddh~LNX1Sl#3dp2_j-rO$~{!(Eoq?XxhBJ+A$CBU;%eQSJ7Y+|2isB#a(|7x(B ze^N_Md0u>f7UZl%E`e;qL%8p}o#nVp(XZ;31WX2sR@v{?9x0rgzLQNt3yHT?As~C& zk+skEFsSQrJSch~w*IG3O`_+wd5rshXkaF?+s5TRh<&7a*t?{F++sAhRM$D*a2LMUZQN2$6DL!!)|$4&L?UUc?o|OpS%A zvt^L~%q}!!qdUnleyNmU*^Zh!F5Bu1$51I9&cxi>f2xGYyhC60fTRJj-`QWAv=zLE?Qj&#f_({RAAS#03 z!A}b+VqACuH~J3VIxSF>QSj@9BD{MbNVEX*_|ecU1^DgK+noRa&!?BBLI- z{;$J5BDzM#G!^lh&&CZkp0zTba%hV42SS=V z1w!OmLMp58v72zPGwU8Nn^<|maehcy{j4=NF&S-IQWrJkC1qBrm0xuf7J=UVsyryI zaXQ_4&ZY7rUL_7Qg4Xr!=Da|)QlrMkzd(^6cQX}(LbCX@Jr-+$tUl^}SertQgeB!C z`#W$H_99UUXvsWx#<%FDOI|~WXRpD^uL_J>GVcj(yAE%mjBLCoe9=MH6b#8-J>xf3 zmSe(p8NPY~t?7m{zAlgtoh`Hb7J0wwKIh@yOHd^?nrWYyiz|_aYXv*~{;XvWfAqdE>FqwXD%ARX%Dc#Zlyv99Y7Q0W{Wz^g{eJYQMN@S4 ztjXP48fVGrHo^|OZKqnp1vGZ)Y?+V6LYY~{jtZl!=H5JO&_2j^Yx?BA@kbh;_Z#k9 z<5|oM7XjmScYEG|5;V};bH&2-@YvAjA7DwGH`W4J`aSkv+rq#6>_hthZnf6`3<}ir zw{?x8dD!E=TH7wSqd-o|lcZ}p`}_6(+6@M;C#R-ZxYd8?zI~A1|9B1CJ-{-Tk~x6? zd!Eq$QdhV9k?ROQU*=Uw6gg%sErT)Wg{qdfX~1tajushq`j3(P(<6 z@>>z7KXz_?A{|U9S~R<2#c7wj@7lbi;gQ`{OSkgA*i-)z>p~t3ixnxzF6=YxX1G zHP3#ZJ|LvD;#1SSE5VMyQoSmt#nOql2}#*h5VA6XjElg1W9|k>o2hJ;N(& zg`VeMG$BDRVFY^b3tC^PPF36OdSqv3n-S?IBtyR?RT&(HWGyquYTFgvUMv01u9FJA zJ@cOT#YMX=Y{)vi+LIrd1(V-A$_h#@oH8@ysnz4PaA71W?TQMTzV3y!tE6*5idjW+ z3xqi{X{HHsEP{T|)Kka8DgxO$BUIOLP<1x;DEy%iNV2!Q=DoEadO>rg9i99|Ya4O7!>#x<&hf5#p7xkTnL}YmWVEWG{$s4U zqXHqA)2^eLA8vNv@x}nu&D6^Sq@&1Ha)MdjrRISQC0j=qoM{bNh2Eo(=JRXJ2)vS6mP<@Z!bOGN}pTewrJQZw%rsPBSIhWP=_HvI7YY(kwQKggL2Qglwk4(qCT_M*mH9H+Hz9Vax?IUYX9rvnI5qYaj8FfyE zI@>)aoUaDf9-lfD8qw1rNR8z0vFEhwxfi5F`^boOS2!6j?`h!6$Av`dqvIty9c&Fsw&@wq1-4Wzpx9o#`3F$(`6rSWY_4mnQ7J?)E&0KCD0o9W;%L@L+psF zrM2qvz@_5zQ|0NzEG^%ODN$%oc(gfO?l;5*wu7^?q|aKvzJ543Jj7-)d_6rCXuCCP zR7gNmEJ1@ZEMP_=7+4H}=u-{!mp_u~D&Sv+F?}@?k*hc#ABZUQ-7lSGPEwCAJ>6lh zZtQn)7BzodV!W<+dGHb3lE&AalaHruzIr+-1XCdQU9?m9mh*{pMX*qf+PWyAgsWLp zbfua#Hg!IW@U*h}ops8jwf?pZhe8?@Meb4b>YLJY8-Q zto+0U8p>MCG7ehKPXep2UzeIDaD-ioYe4oog7O4Qsko@Ah{*+(xp&H*@e=I0x~aa~ zkw4EjPiAGMeZROj2O6I$(MavB!f!w?wH8u~jv5%%Yns(7-76dKKP&70JzWw3Cg(Q! zLw!CI>`i6YniIP@i&BMfPx}~nT(__{-@v2lSn`F9&C$uJy`$sqsE5uJgNO@srYkn6 z8%i7J{cLttM8A6oNA_*Akwd60w&EQ}&2qFa^?aD{^}I5~Zhfx;S#v*IWb$ye7QeUL zx2@-VHXKef`KAH{O%=Pqw}wa#Sk#%S8G&xR85U*Fuo#-n?O zh7S8!=b64C{~lPEiv8U56A@u(3HG(dkh&!h8Q~#r{^R}UvCjTn9i3p~pCN5vOnfP! z1(jbN^dGk3a#MeQf2JRX6;qh_pA!-j34<|D(p}-3S)-$Jkhz^jurwWd3ogy8N%DKG z#tINlchi#><4MFC{j*^oj20s4m8$x+q zKg=h*V(ZOezNe<%cF1 zpQ9ZD!K0HY|5K<@SHt^{2r-qH-G0w`wL7ZC7mbkJ<|7*$_?AQWVK*&TijqoqX-aK_ zm`#8Fu;s{G*H$f_U@uxC;f8xdJkVK*O~5Ja z2q6p!4(7I8dOAItDxSo7xo8NOx!`DAn(TX@@ZUzoeHxYKv*P078>}juCz~U{FI)2s z4Rv*OiHZ2T80hFJBbyt%wrdHQsnVv?*o}Gp-|@!q)^Jo)xE1h98GineS zk6RZ#F()5mef`W76?G>kc2%dB<6N9HW9goi_Nx=&E;fC`K?VVnI6YPikx1rkzT;lr z-Y(ULhL*mp=&$bM>bo*a*T`EndK%_Oz8%zz46LGe{Z!yugUw?7?MJ;B& zYZO<^Cd05>QCcvM_t^{gc#i}@mBN$@j=}HT? z$Ej`BLJg(u^VdzC*=sFC3#S?xZZ{{srPe=$Wa#BO>&3pt}j`v8B-OF|7Kz?UOVPwwq~DXWa^c3%RDtbZC|RGdkD3~eB6QN zviu(ZRVRZ^;k(x%tlaO7ZH#YN?>M5Sa!q&?tm2fF0T%tB!l_Pr=8g&@A?ziMlKFHziR1@24uL{ONkiE5L5a| zbz5%j7<98JQVIvvcc;HdmAG(JZ*?5jnd0H&xil2~L1BHPJ>9<-o?a$YCjZT4`>Rc6 z0YAv)cy|M#a`c;OsHG+?ePt#Js(J8Mo`Lp8v>Y3~BEv~yZh46oMBki(Q7+H&MFJrX z4#Qa4!U5fSZ2bEu)4EwwN`v4zc?|g}k63Y43RV|u-s{83Cp28K`OfvN%`)?6hI>io z&klX*FbC6uxWM1_*O$I}2C2}L*!_%rflOaVTZ+B>N`q^-&AGZOI&`n;2t`B0uD`Uy&ql1R$=#VeBw#cld)x(-V#Gh4X{=To(s+=CnDNxV;Iu3!};@D<{0dHse zb$mTj^XE+aG-W@*1TU9(skgQS9Cs0eQfm!E%FTB7p|HC?5Tbw&_Qt!?mvCHylbK6S z&sb-|9bz5N!y*tjiuNn?*!+Fb)T5!chvcMrf3m5yRF%l|aro6sk5XA@40gxsL~OFPP3JcB6F>T~^jQ7YC|d-yz(YY1%BAnj++cb|Uwf63j6 zRmZKrWiV~)XM$yi&Aw8<4@>_j^VJvdOdN!6Mu2@TP+mGMDRTqe; zyMG#HSS`2bn$$Ex`RV#G%yYSXU)iFg6PKRFf37Ghs6XdtC`!-eI6Y2-dE0^7!pvMq zD|F7obWT*);;-tLvg_j&xOg^Lzu07#g}zR)En>|)%QNWk0_SSUU0JAAw72GgA-6hE zhMewn{)JKcB)%nvqckb~lakDM1;zM{hv2yn12~L9o;Yqv57%T{I{7ZF**OH5%Rx{J_Jd zMs+oT+xfP>yHLy>rHn;ACkj-wmx(RTcBkT#-Lbvw`TTq{-Rb+FV5q!{f_-B5uxtPx zLVGX)_vb$`X`EXfyCuwX_F--`@|!F_nbc>Bpv<#}$>;K8XcP5f?m9-CG(E=>T+h)^ zw~U>32eu1f$HZee5#h4?m3#21$uRyWaGOCg0uHCN;)N;vv^{hWVwV3&V(>;eqn-PrBx&%2#o-F5y1| zZT#16*SWvmhlT}PQBm=)3gM6X#)5EJCx=Iz1UHHA0qFZt0=0c~01JL$4{#@`}I$|GH5`y^DO&sj9%pnkm*DWD5Ts<%@4>K9?cx4{A^dJS#q$oLCk}0 zbG{sY#r+ron&c+z6Nt*9m3-IVsvK&ygedJ6%$pj zQYmXCmTSIu&RT$Mo5B2}Y|Jyd*>-~7MH*zE4aLMN76#+?5SLVQ!OfI=zkO;eq9aP?xX};rjnfsk)UvT|vonHAXi{t+t*r@kv2`+J#ik_|VCVGcZtvgp5SupQaFfaB?!6(pK zw*98+=iGclFpFJb;0sr=n7*S3@^7CJ%T9f4@7T7ZPxY~|IhVvAOkV~N4UC)Ps}Mp+ zsdv7=B%)Nkt0KqZxC%7D>cTjuzNIH8yIeR*L$dPVT#RYspiOVZe<9XOtbodW^84<8 z2Wy0ej=#~7NpM~I@lu4X%BCZDerY3fo8lN;YntsVoJ3$B`4sUyOz);7jxBb@Gw%*J zhHP`AgFsxNH`jX+8q!XRh_Jo5JxbGJRb!5(yyt!34fjO4`=Ksg z*lQpYBtjnYMfP4n3@L#Kvm(-FiQ5R9L&GpB#ES*#m00eO+*O&Vd+%>Q4REGrMBEzL zcoCG5oK|eg?XnADnza!A9jB6v`ua7LkGY?BM+G`@+b0!Qv%dX~!!Rw%tMhrGUX1f} zay4BOL15{Z@$Wz2E4oAd6@zy23d6h*LDac!k~dTakj#lcjQ3lb17A{7l2|k~(#x0H z+k>s4w8q<$CCdqH)_ln?p-6jA>EM3Ybmhi#7jt!>qM(SfK9nfjclxnOW}o%2&8ABn zu2&rIZkXv6m$E!H2Cfn!39uxFGUF#S5MW4qE_DN*=$E5nrs z?c?m7o(460AwBVE?C3BU{KIQ%!Z@BIn^o;p-pEqHB#c!N^q#(XYAUXP)1qJDjpI^N zF@mLX`H^lzk5m`U2Zh~h=gCiDY@b?r2oUpt56uy3SkNO*Qk;#d?#XggJrMmFFgpbUUu;xd4@_gqrT#C&OWM(-v2f5$f zL?3ivU+iyA{B3#6)tJL)D$Y>fAD`tr^6QmbItX!ih{|^s9(+-Cs|%&t;&db_D+CSt zCf^;jL_bS}>AgF{ljyD3x;-ncymqcZqeQOs#GYZYG2#DE-lAWp+dxN!!oPO7Oo|`AbyKZ4n+~~- zvbTomzd7DqIzVn+w=l8aSL5ibyEw^g3rL$3cpk)dum27&|6jl^9I?A}QI(Z_9C3it zBDZa8``r<#0ibbr^05rD80j?NHmX`6x)l%-Dl5P%si<(FRdnB-s)%RM`yCdBPGeFr z1)v*b6cqNmQ>VZRP*(1JZJLdCk7rahDI{U;sdLu(rafC-S!s>d5M8dHtGd~~2cTL> zKA!ONfUi|vr2>l#I6l}M&d_wd6v!Gu!DeWkpHDV3>%zmwpLaf%;4lAk;e6|bgz?J$ z^J|(Ul=OghhTf{rKjJmCLG>^v(N)In=~?M`{wKW_yUG>Uiyb3?@&0Ei! zH?3_nN7kEGIcVq&sHXLD7;#>Pg>SuTu7<;7EGjo2~1w6^=` zRgH|b6F4W`OfD%Yal1LiqA8yN$>0Cy7$6I8L1cmup|mytSeP7-UKaR`IXaf7$hrX( ziya;Qpw1+`-k(^cR&8~0u*mO=3W$Hi&3EU1&vM7iJMu55Q!1Z?nBhME#ziNLB_*CU z@^JG8@Ju3}B~2(Kb7*M?Rz^dwQf@I{UvFr6ye0ve8wRiox`3V-x`v}}Di042US3|n z@B!$PHWm|GN~_H9FT%XDS|qZya5#Au+q3>V8`lM~8z(??4mn&72MZU6%R@s$6p{(R z0b*oV1O*Qsjor@VkX72V(MOiXOUj<5c$$A|kl9Od=Y{C5YdHH3LpCK&VnPSlmD=kCJ!?YNW!{T?LT z>~pBY(|Tr?qfVm7k6ko&2Xj9#GYhrRI`S1sqTmy=yKK^`j_I&)?*@hOBQoH!c1cz# z|Iv$K;tFfc=vinSA`!BaOP0vCltWqntbOZyt8ya(Lrvn!N|shrtI1VK^Eho3@w_ys zpN>C?OvbxUon1KFotChtp$!v`J9+uD(fzTh$iUBu)Wf1U*Lr6cm%iR!!<6!JCKi_Q z!HeF(B5EF<NtBtq}+yTrI=IGT%N{pG`Xaz{f$X8a&LrbU@;wP+HSjTcEyg4gz92j{*!xvUr-u2&g$Qj9`tASPHJUJP@GZ%2 z(y>LCh5x)SY01Kb52_vm`c3O)Rv_9whrxp+cT=lP$bsHnJ0A!uMW(WA$EbI#v>bT? z4vNv!_)>iQJ$%M#BLTK%P=C$lahB%G+IQW0w2RcLwQxe|f-f$td@QApn20&Wwx3@ttLem`H-Tm` ziMh>2!!)QoU;V-EIIgACC?!d;Ib0)WD7MRO;{)JKxtjnuu7uMwrOTHLL4sufiiq_= zh133o%H+C_a0Y6~jpt;;ASD)57b>$b-WOw*R~Y5>&FobgRtKd>4*4v(e+ul=Nw!_b=4EfdL4*O|48UDW9%B5`HMJvc^9<bZl@5rT1Js>#hjX5t9X4@pqp-RH8BxMy4Rg)E$D2Pi!4XPKm7wYWk*igd`ryivz#GCmngWcQ0;M(y%=e zdrt$*IG|$T{RDjq=%)n;LtqH%?rC3T8rqBuh8b_WOLc2P*PDNjt$Z3|{m_-dF?(@{ zVD!o9y$Iq`DZa)bGZ8qZD5lyGimtshJI8q{`h+McX%~5kWEr_Uec{^Qivb+q>AKsC zOtF`Hn(?wg*UXTK!H9(M+aI5w=QdP+JkBNVNn$%)UJM*|(?-%hY2BTv>l1$p#;il{ zE%Bfs;2e0XVo618i}Zd>tL@2gL`{V9SV25FcXuGn01ZQ1M>l#on=Jx5+#fu?#y4P@ z^4vqRHPviZv5!Ga1T5O5V4MejoZH!}+O-Nd$^$d(af$>I?;EQa>XjBc`WF--c%Q`NEs}5H}lmS3} zjW6z}0l19_fH=r&p|%X@PJv^ZNBxIoGX|&Os*Vgmm6+NTJ56U=(~0+EWBo*o zEKzCnG$hH)XA!HE=Ea2v-&mb{B z{rv7QGDO1mTw?>wSCr;h4QV|a}{t`+0P*1}{ z=QORrYuuFt#Icf2mMya89m%kdP67^!2L+#$^wqvmZMPYybxw4!kIJBLNsT2^Rb(`~ z5wc~>aw`y@d6E@$n!m4(Aw?7hJDV)*^#I$vNMLU-tCv5RqX-O#-sO5TN5o*20msw#j;6(nE^tN{Kd-Qa6NbD8fokI)R`oXv+Sb5f{!JJEF zB`2AN3ySC;QnXKm!iu)TNwIVOc$cM5x^2cIo9OspO+5#bB$h}xx%Z3}PNi+a6hXoe zV#@V%!W(vhNRlz(HkV_eJ4Kh#Eh5O5nxcSNcD(*{B;}YV1Cd1`+A*moMkKO*XC~?c zK{MWDDt&2hRXSYIQm=ztm*c5VKUzLPQ7$vf>Vulp&K&$3q{WwF_+EP+eP#ToKm-3j z%<|8LSq__z;tAd`%!U+{GZ9S#1W4cF2O^ju5nyZm7b?P}!?u6HaImhQKYteF?DA37 zq`Yl9HJ1asdUwQfZ|9-N$D`-NZOA64ssPJE**UVna_QfJFsfU1_qTCBGJFXbIhD>c zV^$_|hDz3P$@Gj+GwCqZ`nflb04lS15dDn*m*OsWSL8s#OdW$#=Dvo@&#=E}B>UI* zEE1GTyc1mI%H0+4Z}k(6VoS|7!TO}L?5j6j^q_ih-TH6QalM4wIX`NLO^7jK+B}bS zIdsAm%QP+$XbYj^wf8XT3T5D+^$%}Bq&n-PjW?bs^jrzE{x*t%=i6Jdu3vD)2D&nR+v|d4r>oPlCqndpYCZIgjX9SM5@qlDnUn9vm3APLd*Fi}xOA{Oc?b(Z^6Lzj^z% zbv(uY-!V@5q|D`FwYVWok$R>elxlirb_tDsh+ zN4FN|v1huY0(6t2c6k*F{5dPNCnJ-}qP`_LeBSx0hFP#sc#lJIBBgxqTt0lY3dBn% zTVp-n%^^>oJjn-$zh=fp04fzBnsG8voTWYa#CKz?venwK^M(ES+(>lLseb!2u9B*Q z?6*<;Upq@J_u!Xrwq0kcj7g#SGBcU7l%JFvoAs7C{qjKvrA3t%YOqFJzkfksmTOZO zmIzK3*Lkjs^>_{Jl)brxFsut|8tQgvc#`*ck(&`dJ?Gh-wB!yjO5P!{J^-KYQuE~H zS0x+2`twO4n{$19>nhzOP! zI*?li-f@JW&Ei-5mm!&L{uZ6zi5)aR9>d~i5p8W+bZo{=G+;b9JyTOo06g~?h(E8+ z^x`$%1_uXQ)SZq40rBk246R0OmD`URoNPGX9+q9yR;GVh4iL ze6*bf8!vu*6N@;G`XZqo{@&FQ81JAO zDLMJDd&-!xs!iihJ3H4eDuWRKZ6#SoRu<+|=4&8W_wAKMIRFj;Uw))PO&sP&Q}K~W zrS@_yo`y@M_I)TVj@LlZA@5l3z5i3LrlA3xB?o83umacx&7rh_k0H_PQ3rYj_S`CNlU9RA%ZGG{qFefojV1(4Pn)TP+R7Fpp1Ka;I zfS7ep(pDc_@p>aBg6x_bf0~g@J*5(s%I{du8INaFSxMy-p z&~&eKMF6xP4!a%0>Y-mwkCU*;9`bLxlvGr@SFIq!JGR^7eXXrtu4z${$=oggVsm?Q zH4S`Vupw@Abaa4cLz}9{Pl#~y{Kc-tM^3h5kA0fBojCimZWlsOX9HHZcWT^tj1q?VTwIfbXtLF+)9A zDi)U{yfm!$b73Qlgd=x=eDq7MkM(={JASu<+jLVPadc$hBQ34Ee8Eto(?WXFHRJrQ zgj2T^Dqvxr{k%Hc&4gmc2$KR~`bSvdu52jeFTQDW@1{a$v~BIbHBf>zu}npnB>{9ab`@%og@= z31Brt6v%Y;{ga>Ox@we!B)-s_vuHPkdB5XWMw+jkt}^oIZSP^A!<%&cC}PfK({Fmr zsCk?4^=hakUf)&w^6cr-=b!M%dQ|ds>Z_gLAPVzsi;#=#2=Q6YV^qcYA(AhDv1Wq% zB@qn7cea0scJC$4LN32$MqfxG?)t^+sk@)oJp&C;86Lz=CNoV96i@STP4Yiura8ZBLzv5S5D^oR!h8 zAx!ymd#wi|KOxGO%HlH=upF5#7h#T(burYVle^IT*rogfYEj>Nlf-gIfyo%2%x*ym zY{M>UGE~}d9Zg>vb!(=MjQI~(nQhBKMs=eGu%QU?bGBB0<>ln^0B@UVoXDGFB%#-|3Omnht3Z0HI(%XpTCd5)j2rAE*}^lx?BFSMOsgH=-6a%e2Y!^-jo_Lr3b7v z7X)N=os#NDO3$s1a{Bhq60%M6CfV6d$I&&YmC!TvSdJ!isT&yxnOLW+SAF;VN})Xj z*~`-hXQYc~{Gh5B>wP4zyH>DbF@R`d=96>P4nQ^ze)>9Uw>%Y@mBi?^nifANiGpv!tw zxrd;ZZ$VCKh*W*`RkBp-nh^9z?&-1qF?-iY|IRFkykM~`mvIxapQO}e9{by&yW=Bg z(_c7J1n>VsmD!&C2UW(n&p-S79|p@qG`uNZw-Sq`@jB1yT6c{t1M(R;5GwW-G4VIu zT~;mQc_&x0WJfaj*_>p;KHBnWs$f$SZ?EhNhN8w2_>cmhNkdp2zSZ!XQPN{RQSiB6 zSZ{kDmk@kXU&Eh9r^Y~|Ua}V2Jshhg-5XkPPnk)p0gp5&kehpqv^$NT7y4&~cAA1r z71tWY*`G?{FDuV~q-E}7L%XRfG&UW6K$#q>0VyNT6;(x&wSR87W$k3MCK{P4GvLB_ z^2TlM=f@tiQK4R!V7J=EaBGL{qTc+ya?MHtnxoNy=%iWC){cquP@i~2yCjUWD|fs2 zwQTLZucM)k4#$OZ0qDK2)1~yUaBtp~FvoSFgM}ajRol&`*Pda++dTWDtPHy`GOMH{ zL_^=H0$`WczcvRilZO#-sd_jER(nWAb?o*;U!!TEnhw2J)>o5pNvkT=!6?Ol!oQBF z5TZDS6MyueCK~1r#5DIR{j(@1Sr|E?1-NMZ{?|X)ybtwJ|CcX*QK>#aOzvx2?LoMvn?_t|Herd0eGk{8KKgSBoF$q$*EBxF1i%`3{y&^K zHB(kmp(l30@K2HDqZxo*k@IUpKv19%Mc-tgrM03LU0m1Xe!3n3rndPPVQMOB{!49> z=OZk)1h!VkYS^kSRw32!bEMhJ1IdSZO(GZ;C@Y4Ce2a=YAKVi8QPTibiaP3mQ$zYI z7Mr+Hy<)Fwxf9JE-f2h*SUD|Oo8NrHB~oKwvy8ZA1dhC9RSl(CY!=%Q8ieRwd5RDD zdH8*C@P5UZg^Zs2{hq1)k?rX^Gs;Pb6n4=IpaaVPEvBu*#YkS)+#LD9{xVNI88kbK~=yS{EcY+W(!9zt0{9r2Ehi4x_qS zPweU|`p?uEAC1g=-jBsS8h~cNlTJX*P0RDV&c|ifv)`)yh3p3d&cVsPftLMT5ND_T z%^oQ%Xox1nQon%8wqdYn9Uiu^%IGAe30W^}sS3e-wtw+MX&;EXi9=MUL6e9!j1}Ub zx`43)vF&C_iZ^Ht&rnUNTABv#=FpDydsSVD6~{R)(iX+z|2Lq`*pcWsnt!FACr1!d zq^U3mn4C;Yar_^kOw`;+i)^~&*Y9-Vue!La>_4NA=KL;V_Lp4!beSG zcR@YjHYd0i8-1O>{ltd8fFv>y$&l^r$yqV%bi-AB$9-v#bQJz3@yMy(BLa^!gn@3i zr=(F<6Q2|G7G12L9linbkS>BS5p)$usg`Zpb^`KSuc}Ytc-#xNs#Qe%^6x6)^up58 zCWzgB?tYVcw&64z`vQdtDv2UJh*BAA-JG7eOWwI(f@wC9HtyINIo?-A&o))wfK6M< z6+36benxzOBKCBC**_y)9Y%F3r`&9H8D_B4eak4Aw|1Wi@Myp*D{DQROzhXMgAefr zUVmGI2M5;ZjNNYMi^BmxWHy1^k~TLtPY%l-D%XGtG|ev-yxbX^hB1!Y-6V^SJtqYF zD>!&8uCsQS@2V_FupBg0c}xkEvDhXODR-1=X^g9kg&H=uQMf)ysNu2|Emu^utnKRJ zDxnfh+dXG1d*?Y5t4D%GpuMKvTfsgCI4ao|u*yD<)D!2eC_6u?fUk*;LFlf2 z_UzH?3V9jN$PE=mWR1);n|4#JZ?NXIptb6a*^>feI#?{gZ)Y79 zN7JbJH%byD1Pvs?odCgu2X~jj26q@-gOdQkE$AS@Wnge02uX1F!6$fdhoEzteD8Pn z?w&nozrAmYv!}Sm`BGRt4{zmET4lI9ZXnRoNWu* zK(Hk2npZ6&@;cnPF1{Q8X$Y|D8gql~?Rz=&L4^TuiIn^w-AKnKKbA)&4O(|YiZS2o zh@{JCG-6*~{h;tzBCD~Q-m))g#}^s*RM<%}Ty~Z;z~>o!MMX^}iYa_)eEAd;Vdbr1 z&mO1Eck$cRgV%NZ1?e1^PC2ZYXGFkWSuII38M&3^)^dWxY|I%<;2nIyl-srcAAr({ z6K_~z;)>Q2&e!4pTViBo@#}#H`sNzIYaKm3J!4~>!ooLz3ECL?Xk)sXUN49a7;KlC zUE@N%lm@jvuCwD}*CKoV;J2*3=$V&? zO{=v+s4}Izfm94-aGXGlPV?yx8yAE$mRr5re3QyDqnZBvf~jy~y4%xJ*>0TkDuY!_J=kDKd5ZmeULb z*VdkV5&ZxVyNlDEKAskamw>qC{Z`q*a#wR>BN*Z71Y95xs9vnGJ6#>RGolahMJDNg z7+{WAZ7{L1^Zj{Nq}uyaId^t;rsaYG`M<;TcSQb@xoJu@yWioV?#OMLds-PQxG9p9 zJG^W8m4N$ee*QGbYWGo^#|YpzVpK19PC_zLr3IU>wFNmlJ0s!Xz4=S@>8?mgG4lxV z?`P_}BT_HkxZ+_G0YEDV`x7C7|4$jcnqiYTdhO@L8OBYe7d07OCDAb!I<8KaTP9s@ z#0ynSu6GETUs-2UM;Ea=lEfgS5LSmyK zKhuC>{#8H(LN?#fl;=|YAv=R)ECqln2w3p8lBCG^kG5xhb#IRxA78wHcVc2<@OKfF zZB2^s{2E4GI+w0Oprw>?k|66Oxgx-luh*asGT*$Af5b!~x=pT(VMJpEiqwCkJNmGr z6uFTgD+olW_x--eI0TUSEaTzg4v&ti>FVmLsX3n?njfNLy>CAPZPwU8u1~mR1Og$b zk9$%<0iLVBx$%SF2af%$5fl_O@HvpgV_{-u&S2O5{rh($Aq!w{Yiz3i#+ge-sHmt2 zh#$iuBj;+Y6BOud?d(L^l054N*jU)`jXx^E{Y%iQo@6Zgy- zS|+OWX+DTb?TgC>5M5Tpo3}UhvxYvbKJzo0cfNAe);{kipapt3;ENda?UX!NI{EXvER^m2AcxxL$oUb7d)v=<8Ks0Xh8V z&!0f!l1^ZcY6a`**+P7bx+$Bzj_5$C45eVKGk`*(0Bj4xqCkP*?NwTC{29P%A08;* zu0*L*+jjoA3{4+lDyu)oTpYsgaj$I-ot3ri!}-?6X$gsXjxq&s|WEZ!W5zt z7X%X$2e%Oy{zHknwGdF^bW;uYao2ootkTr^B!2qU6;&G&6Rw-mEJ)7CEw==6n=? z!uU9wD$UNlgT|!{v^FQ(dDw6r*Ah%(=y;^?ZF7ad$+t7?VXx$usdR##?YUAIo3LsQ z&tq!$yhlu~U3*rJT7?zBtN?V0Tf# zT@Jc1P=bJz^_iT0gesA-290C6

m*WbDLyD@8pO3$MA|abqMB5N zf!<`BXP86|@G#=s+@Bs2L8oVBi9E>Z5SPZ8W;49{HWR!Y`}z#uio5m%B2`S4V*A2| zC#$4aExyXCgeTVR`uWWyarH=0ykOQ#Y7I+EhB1+ePr3Nt=Tkw9VZ7+=%ia&E z=JN|MWHs}hh@nNS=-0)QXYR-df){A{B65{N-u;bU4jw6f=7Xg#yzwpKuED28@Qo>xFV-0ORt899j- zW&IW&ETw2$b`k#WH1eY@jTF}5udX0txb^rGqK9w%1=U#zH+8>qdw<0x9tDXmd_E5U zYGXPz%uWfGlTj3iB}E_mmQqA*LBB8$Xe8M%nnLP0lD+ylmM`NK%pW7Aq9>=afM)CG zqB@S}dWHus%S(*W;2-;sKOb;!TJ%nR+07)!HR0?qBjKJXlPVZ4WV&KF!>bkCcxru# zl8HX9&a@+X@cVEg(LBVUcT`os3}3cXeSL6q2?uk+a{FSTo%oH87}ZHljn;Uq(1|;; z#Zw`&d&r4o!|ICsJJlUzW_1eQpF)+iA^wJAA(h!zcpH_HmoZQRRMh$Nro-+2 zm#6AhTeFJC`1%T)YMLBpU!K>3MPi+?UHW7!TROXQ8xOd6(qXhw{kU$2EyQ6{b%BAe zVCZ|l2=6S+Z(fkeP2?=H;UHWlCig|jmbJAfc&&af_=$z%9OHv+Aq|Rd8GVEOrcE{* zMaY|XLshBmnC)U|&e=k1*nNQ>>bZhTjjN$&l{Y_rCcn@Oh_Lqo9- zUoTOd=|%J(A4isQz90JaYC8;~oE<T-nr5;k_G6uTtHJDPNhG!xsjz0Pcf_hp>fUt^E=U^}F$kBd zWw61YzgLVgFZCdg3Qm&RoAM9vEo=)D9F##p^7QSF`h_l(G1%f<>nAvW43|ufay)3Z z=S*uRix5uS41ZS=%Rm%X6UA+DsA?gXt9DvF8_&C1M`~2`77VvxVrzZ7+n3*Yt=K^9 zy>r_wB%_oe#u0<>=Xyn$PKC`y<5JSbhw3KNNa5X=>L@oIf}W)|LxDUuh0%Ox4n3Y5 zBdt{@`H%O#&Cv|ULUc23r5M2^O;|*gg`Z(@XSx~bX}JBS?n-0#yg~~sRq7qsd0+iX z&b|V}@3sG=<)u!kA(WIP)P-W63t-RRRo)Yb;HgRx66nrz z*EkOUC6iqj>svmG13zf}ltrN8Tj#a5d+qTQJ4OC6XvYF)B=9+7OwxlmWAX{1=-?8i zfwL%_beJYGf%Mg(Se~@Bai?gNF{4Wulto?Ie1tf$j@r#*`I>GnqUH29#UKVZ53{RH zFib|hy`N1-+O{d{bJU48l_}fKD^ilm*o^J5(YD~b32_Q1ZYAy!cH$rFnI#*ah@0U5 z=+x#NuC;pY2NRcZr-ZT`tmtACm5}z|#IbKrXbBq2jLXT^Ynf-eo1*2WmeW^YLSt4B7Q;>6QGZ<`jD`0!^YuJ zVyYEZ@aD5KO}!CLtIgAatri7;L|3nIE-u3)M#Am^B1eo?DysKXtGfBr@Hq{>JM!gf zbgbHWaOc{F0ne`6YdBYtb#{e1xKGeKGILQb740@ZC;j*ev2zd^eBg(lGLa*^uwSU8 z^ZK4V|1#=!&iXHmjqjA*$(~TCZORy0Yo73gKYPIUhd4Je8Ow-#F3LzL2W=eT;bA?P z+|TH+lgwu>(mmCjub!lijcqEFm)-Ka9pwe{h#!a-^p?K*6bR?K%|jj}(W@V*`# zP4OHl^vr{M!Qt-2m!^JoJ0NL24aS+KVFQvp4D{^q-M)QY0zLrxI6=ptiqA+v80X>M ziY%~?(XtA_vhKIixwqs!o%7ZL;tm-s|5!?-7{m4tST*w3Zg%9?ZoaK!pqBqBt~>pw ztMFggI1TL@5l;Ktve*WQYybS!f5S!rAVzoUkgYcQcK6R%0O+@EZcf1Tt#im!n%+Fe z(*MJl{(2cBLvyjZaTg?YJDB(@!1wQPrT?~#fa@>`D){{vyuT53#nb=thGp$ZE~lnt zwB4#r!e$wwqy*vnkE=6!4SW}k_YO)*md<#bxM6vvU$ruZ{J{O1dxmq*==|HAxL-u@ z7W%$l^N}Q6zDh;hgVNvp7B-`f;RxH8Hip_bu zG*G(qwQ38?pJi}{Qx_}NPUy3L$b8PG!s)`J$KLbfa0=m$r!$&vYt+!K!k~qPm&YG( zOy2imWFc$7aK01c-eRI&+X1aDDN65$sI8;3y2~oa%Y##Y|$w=>2GUzp*xx#=R;^;<|OIcauxtNlNfO>qL8J_5UiB|RInV_ zfh(CXw(%0?@bx4f8C4xxUq0vedx-^={M5XK-!6wN{`6*xQsZQCC2{s?MibrimDJ{B zP{t5kP|Y$&!A)9AxH(~aEgF}NJ>Nkhxp??uDNzsQ8Et#ZUIL1r0PQ@%m89DTHpAwOG6d<0z_Qfrbb0qjH1FaK zJdN{`qtzyNrj`HqS~4jJy^oq|k7y{%#`7Hr?JZ|jhtQH=ZNz7Or7+_>n_;kMRMXfi zhWpIB!EKbC2UOfqPo{Yh}Ho28>GCP4^jSQ*Lfb~fOJ!FX_s%bxUKq>65#QYlGT~T z2BB7E^L}42`GYvVqA?laghScKU}B8j)?r?M{|^e|NN3H-AU+>C#F|KoeC#LcMH3r- zNxb%J(}g}aS-kq(5*joK!Sn>~MNlV?+)}fq7{p*xz83bxmBx8|jGc7EHL5Drs;TS>0GE-?PJ@e7AIps@LkW>qdi!1EC zP}yqO+S8QP3kQAcqO92`S~v&WtWeu*yZOz02BgkC5L+ctsDvP~NlVv~RAT(> zW9CKNS^jtaY7R-+ci+JC?`>~p}(5@75Cc~xwNYhIAFW}~02$k%H6+hG$EAM-L)uBQdcXSXXq z{ymO;{Q%xO3VmPb-BXsV@eJ>vVwNMMcw<=I1f?i$U-Wx^LEPHA?x8A#H*!3^Q0(D{ zP;Cwq<)){bGmI!hheKy`wg@Vp_U{|7h~3FxjS5UApw}uu2?KuoJn#gtOmz|nV16bY zPi&`+%K$?h&fh?ZeXdley8CPIF zw1r{^!YX2alrp2-TH3t0Ne(@^c*tJsIktfI7DWFx@EU5C}?j_lBB0E9v07)4;?O3cAse4RJ za54c!uWJuL%F7)54HB;>F?Z2>%TQSP^{TQ1SqWwNub6FtE6J|X*p@5}U%!(`OCX-t4z)X-}+5rD@! zOo@J%JN5oIM_HGbm;Qi;OubOaN$+h5CNZmPwM?L`GtU@+o&ZRtQ(+YN&FCi+&d~eI zc26QI1K*PgucI}m#5)-o8CgExujD#D6MC%l!}*Ap@NisU%)ps{mL{=+NngDZ0wRb2 z6_*wWq1rs8S(w1@3(W# zjy5z#1cZc&Q30Ykk|ripFN{@yjgQR=bn=`>c+-UWz$n!%1)a|uxr$M2zZNIj* zTs8w1f!rq!-Porm)0SD*T%_>beIRpH`V(MhEN!3zejHHTG2tdA(sNl6XH^NA<*|$* zQolFa0hDYCYrNui#wg(E@a!xmER1pty18j%2^b~0brrJgk6?OAq$~x12JekLS@QnA zH6ZtWqnW+1OkkqE{aOD!oq%LH4F?4 zjtf?zx9C?fi{11gD4lC}cb6_94E9zqSWI+`i!bK`pdg(Z8L6}wNCTd_fee9Uy96k7 z7KKtapfca$(b~pgsA{QoMBiU5&+laWRcv>jX^VS(QHv^H^VLJ1<0Fka3}2r2p8hVv z29n2g>m2J;b2oNZ4H}jE@Wyx=4D1k{nmYZ133U>ZA2|gIu}j=d$I#X1f&XVXI`UK^+HV^H+xF~UR;$0zhpC-mLCi)De`y8-dn3MT}oS zfZl<1aQ%qh?O}ucJU_rV;ks9cS!sR^K_5Pl`(HV!HTYh-Lm*|f2Z5BK+iL?*ee;|O zclJ4?p}#MXLJH>y@G)$}>(iaskUIaixz~Cb&8!&U*w{ZW#`3uj<7q_p>my)%ThWv2 z?I~7ztR-8ZR;)0@)LiQ*um4NCFk>%^hJ#P*{gHwefA!HOS|=&RtSbX`50H7`i6Yk# zFb%SPhHYGFT4^sAHom(YTzYvz<~k&D=2BX;Zw+!`X{ja9u# zudx+})*ikKZW5N!zPF<5Zp!|nRy-Q(<{4w`0EmO?l(>+Lpha)=PDLCTed<5GAU&&C zF>|`U494~)5HXTJ#N@D;(~GE5D(QZnklC%v!6mPr{Gy68wYZVus&;-Mie!3XO||*e zuzJrCCUt1LPyVr0<_Sa8J(AhPd4>AMi-5Q+lXhOwuNEGM9JlyXBNc{f@aA;eB&hYW zGTrHR08+`?8vcv$bUW@p2~VF)M?DM^loa$uX1GjshTGBUz7bFkPlF8%sW6E!t!c?O z>DAIXY=~KKexq`@AxtcZ(Xvxin`7-0Eb13To63AAA~~W8Q><=M&YP8pNDcozUQ;vf z*`WXrWH+SYa<^`@5-o}l7S!#OWGMdCY?sd=RG&(W2n4h5hlT5jvS%$h!qdp>oQwh` zyT5uZf%7@4=W5UP>%6WHH%a>zEm7eG-Hyl`CQ2Zm3@|O9F^;z3w+RDesFFR z*h74^#C6~ira~`WDMK9T8$e3j0xoZ)7Xcq~aBG2er1A6fAiVO2=b3(R@>zfPmBj&A! zwRvOZ@AeE15!!mo@{V`7J?x1$N*m<{v&*llt&RGS2Np<5s&b^pR4yufsEpw-U;?Ff zEcExMDd=wXmFrB6227hg<n%(ER;8Etc0`0kz>i_+ER=71!mpVS6#dndX(Mx9AsR!#L=Ot$k|#Hp!?suFy4NgGs*IO2<2 zl_u=tAfNW#^BF+KGIl0Y&n-*cSV$vKo4E1dZVP$A#>qP+B@e6p&9J;{36-DY)vAq_ zY3eY5eW5z7?BUam?}!L#Mr+48ESFLvav9FYOjulW2wX~9HR+#3X+7#jh9^nUb#!;1 zE+f^!zLE{^ZBss7C1SGpQi|=4=99fQJi$H2N^9~xde1gSzE|ly#c)6TB zm}+iS%f$BXwFBJ#SE6()ji~{x_}O!9pjq-RUcS+G>_!#Kq68z_9D~V^7rr0cGvLQ zC}<>?OzUzY(-|}dBBy)+L888F&#Aki_fYY z{z<@S@a>@R&(5Eo5>z8>%ES$SXV-1t`+E_{|Mwe#IX5Dw#fMu@YE>THo?BK@Ndhir G6!dTA3{|lJ literal 23480 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

4Q(^ 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 From 1499bd6a73c31b103e09d7591e956ba18ca7dc82 Mon Sep 17 00:00:00 2001 From: 38decibel <86261703+38decibel@users.noreply.github.com> Date: Sun, 15 Mar 2026 21:22:55 +0100 Subject: [PATCH 11/11] Fixe unavailable and new type light MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Corrections de bugs 500 avec wait: true non fatal Nouveau type light.py — lumière dimmable (type 4100) --- custom_components/airsend/__init__.py | 5 +- custom_components/airsend/device.py | 9 +++ custom_components/airsend/light.py | 111 ++++++++++++++++++++++++++ 3 files changed, 121 insertions(+), 4 deletions(-) create mode 100644 custom_components/airsend/light.py diff --git a/custom_components/airsend/__init__.py b/custom_components/airsend/__init__.py index b48e359..62be56a 100644 --- a/custom_components/airsend/__init__.py +++ b/custom_components/airsend/__init__.py @@ -10,7 +10,7 @@ from homeassistant.components.hassio import get_addons_info DOMAIN = "airsend" -AS_PLATFORMS = ["cover", "switch", "button", "sensor", "binary_sensor"] +AS_PLATFORMS = ["cover", "switch", "button", "light", "sensor", "binary_sensor"] _LOGGER = logging.getLogger(DOMAIN) @@ -27,7 +27,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data.setdefault(DOMAIN, {}) - # Create one coordinator per device and start them internal_url = entry.data.get(CONF_INTERNAL_URL, "") devices_config = entry.data.get("devices", {}) @@ -65,7 +64,6 @@ def load_airsend_yaml(hass: HomeAssistant) -> dict: _LOGGER.error("airsend.yaml not found at %s", path) return {} - # Load secrets.yaml secrets = {} secrets_path = os.path.join(config_dir, "secrets.yaml") if os.path.exists(secrets_path): @@ -75,7 +73,6 @@ def load_airsend_yaml(hass: HomeAssistant) -> dict: except Exception as e: _LOGGER.warning("Could not load secrets.yaml: %s", e) - # Custom constructor to resolve !secret tags def secret_constructor(loader, node): key = loader.construct_scalar(node) value = secrets.get(key) diff --git a/custom_components/airsend/device.py b/custom_components/airsend/device.py index 958981c..197854e 100644 --- a/custom_components/airsend/device.py +++ b/custom_components/airsend/device.py @@ -14,6 +14,7 @@ 4097: "AirSend Switch", 4098: "AirSend Cover", 4099: "AirSend Cover (position)", + 4100: "AirSend Light", } @@ -142,6 +143,10 @@ def is_cover_with_position(self) -> bool: def is_switch(self) -> bool: return self._rtype == 4097 + @property + def is_light(self) -> bool: + return self._rtype == 4100 + @property def refresh_value(self) -> int: """Return refresh value in seconds.""" @@ -267,5 +272,9 @@ async def async_transfer(self, note, entity_id=None) -> bool: if status_code == 200: return ret + # 500 with wait=True means RF confirmation not received but command was sent + if status_code == 500 and self._wait: + _LOGGER.warning("Transfer '%s' : no RF confirmation (500), command may have been sent", self.name) + return True _LOGGER.error("Transfer error '%s' : '%s'", self.name, status_code) return False diff --git a/custom_components/airsend/light.py b/custom_components/airsend/light.py new file mode 100644 index 0000000..b928e93 --- /dev/null +++ b/custom_components/airsend/light.py @@ -0,0 +1,111 @@ +"""AirSend lights (dimmable).""" +from typing import Any + +from .device import Device + +from homeassistant.components.light import LightEntity, ATTR_BRIGHTNESS, ColorMode +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.const import CONF_INTERNAL_URL + +from . import DOMAIN + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up AirSend lights from a config entry.""" + internal_url = entry.data.get(CONF_INTERNAL_URL, "") + devices_config = entry.data.get("devices", {}) + entities = [] + for name, options in devices_config.items(): + device = Device(name, options, internal_url) + if device.is_light: + entities.append(AirSendLight(hass, device)) + async_add_entities(entities) + + +class AirSendLight(LightEntity): + """Representation of an AirSend dimmable light.""" + + _attr_color_mode = ColorMode.BRIGHTNESS + _attr_supported_color_modes = {ColorMode.BRIGHTNESS} + + def __init__(self, hass: HomeAssistant, device: Device) -> None: + self._hass = hass + self._device = device + self._unique_id = DOMAIN + "_" + str(device.unique_channel_name) + "_light" + self._available = True + self._is_on = False + self._brightness = 255 # HA brightness 0-255 + + @property + def unique_id(self): + return self._unique_id + + @property + def name(self): + return self._device.name + + @property + def available(self): + return self._available + + @property + def should_poll(self): + return False + + @property + def assumed_state(self): + return True + + @property + def device_info(self) -> DeviceInfo: + return self._device.device_info + + @property + def is_on(self) -> bool: + return self._is_on + + @property + def brightness(self) -> int: + """Return brightness in HA scale (0-255).""" + return self._brightness + + async def _send(self, note: dict) -> bool: + """Send command and update availability.""" + result = await self._device.async_transfer(note, self.entity_id) + available = result is not False + if self._available != available: + self._available = available + self.async_write_ha_state() + return available + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn on or dim the light.""" + if ATTR_BRIGHTNESS in kwargs: + # Convert HA brightness (0-255) to AirSend level (0-100) + level = max(1, round(kwargs[ATTR_BRIGHTNESS] / 255 * 100)) + note = {"method": 1, "type": 9, "value": level} + if await self._send(note): + self._brightness = kwargs[ATTR_BRIGHTNESS] + self._is_on = level > 0 + self.async_write_ha_state() + else: + # Turn on at last brightness + level = max(1, round(self._brightness / 255 * 100)) + note = {"method": 1, "type": 9, "value": level} + if await self._send(note): + self._is_on = True + self.async_write_ha_state() + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn off the light.""" + note = {"method": 1, "type": 0, "value": "OFF"} + if await self._send(note): + self._is_on = False + self.async_write_ha_state()