diff --git a/README.md b/README.md index 85d0b3e..ab943ee 100644 --- a/README.md +++ b/README.md @@ -15,6 +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. + - 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). @@ -28,19 +37,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. diff --git a/custom_components/airsend/__init__.py b/custom_components/airsend/__init__.py index dd3349b..62be56a 100644 --- a/custom_components/airsend/__init__.py +++ b/custom_components/airsend/__init__.py @@ -1,37 +1,108 @@ """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", "light", "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.""" + from .coordinator import AirSendCoordinator + from .device import Device + + hass.data.setdefault(DOMAIN, {}) + + 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 + + +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 + + +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 {} + + 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) + + 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) -async def async_setup(hass: HomeAssistant, config: ConfigType): - """Set up the AirSend component.""" - if DOMAIN not in config: - return True - internalurl = "" try: - internalurl = config[DOMAIN][CONF_INTERNAL_URL] - except KeyError: + 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: + 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..b619cb2 --- /dev/null +++ b/custom_components/airsend/binary_sensor.py @@ -0,0 +1,61 @@ +"""AirSend binary sensors — state monitoring for AirSend boxes (type 0).""" +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 CoordinatorEntity + +from .coordinator import AirSendCoordinator +from . import DOMAIN + +_LOGGER = logging.getLogger(DOMAIN) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + coordinators: dict[str, AirSendCoordinator] = ( + hass.data[DOMAIN][entry.entry_id]["coordinators"] + ) + entities = [] + for name, coordinator in coordinators.items(): + if coordinator.device.is_airsend: + entities.append(AirSendStateSensor(coordinator)) + async_add_entities(entities) + + +class AirSendStateSensor(CoordinatorEntity, BinarySensorEntity): + """Binary sensor representing the running state of an AirSend box.""" + + 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): + return self._unique_id + + @property + def name(self): + return self.coordinator.device.name + "_state" + + @property + def device_class(self) -> BinarySensorDeviceClass: + return BinarySensorDeviceClass.RUNNING + + @property + def available(self) -> bool: + return self.coordinator.data.get("available", True) + + @property + def is_on(self) -> bool | None: + return self.coordinator.data.get("available", True) + + @property + def device_info(self) -> DeviceInfo: + return self.coordinator.device.device_info diff --git a/custom_components/airsend/brand/icon.png b/custom_components/airsend/brand/icon.png new file mode 100644 index 0000000..a24c0c9 Binary files /dev/null and b/custom_components/airsend/brand/icon.png differ diff --git a/custom_components/airsend/button.py b/custom_components/airsend/button.py index 5539650..df4ce90 100644 --- a/custom_components/airsend/button.py +++ b/custom_components/airsend/button.py @@ -4,73 +4,70 @@ 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]) + 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" + self._available = True @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 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 - def press(self, **kwargs: Any) -> None: - """Handle the button press.""" + @property + def device_info(self) -> DeviceInfo: + return self._device.device_info + + async def async_press(self, **kwargs: Any) -> None: note = {"method": 1, "type": 0, "value": "TOGGLE"} - if self._device.transfer(note, self.entity_id) == True: - 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 new file mode 100644 index 0000000..f8e0b4f --- /dev/null +++ b/custom_components/airsend/config_flow.py @@ -0,0 +1,135 @@ +"""Config flow for AirSend integration.""" +import os +import logging +import aiohttp +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_INTERNAL_URL + +from . import DOMAIN, load_airsend_yaml, get_internal_url + +_LOGGER = logging.getLogger(DOMAIN) + + +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): + """Handle the AirSend config flow.""" + + VERSION = 1 + + async def async_step_user(self, user_input=None): + """First step: detect URL, test connection, load devices.""" + errors = {} + + 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 += "/" + + # Test de connexion + connection_error = await test_connection(internal_url) + if connection_error: + errors["base"] = connection_error + else: + # 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 and not errors: + 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 += "/" + + # Test de connexion + connection_error = await test_connection(internal_url) + if connection_error: + errors["base"] = connection_error + else: + 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/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 ce68b03..aa403f0 100644 --- a/custom_components/airsend/cover.py +++ b/custom_components/airsend/cover.py @@ -1,93 +1,60 @@ -"""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.""" + 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: - 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__( - self, - hass: HomeAssistant, - device: Device, - ) -> None: - """Initialize a cover 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) + "_cover" self._closed = None + self._available = True 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.""" 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 @@ -96,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 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.""" + 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) == True: + 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) == True: + 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): - """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) == True: + 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): - """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) == True: + if await self._send(note): self._attr_current_cover_position = position - self._closed = False - if self._attr_current_cover_position == 0: - self._closed = True - self.schedule_update_ha_state() + self._closed = position == 0 + self.async_write_ha_state() diff --git a/custom_components/airsend/device.py b/custom_components/airsend/device.py index 0ce1318..197854e 100644 --- a/custom_components/airsend/device.py +++ b/custom_components/airsend/device.py @@ -2,11 +2,21 @@ import logging import json import hashlib -from requests import get, post, exceptions +import aiohttp from . import DOMAIN _LOGGER = logging.getLogger(DOMAIN) +RTYPE_LABELS = { + 0: "AirSend", + 1: "AirSend Sensor", + 4096: "AirSend Button", + 4097: "AirSend Switch", + 4098: "AirSend Cover", + 4099: "AirSend Cover (position)", + 4100: "AirSend Light", +} + class Device: """Representation of a Device.""" @@ -76,7 +86,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,120 +98,113 @@ 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: - 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 is_light(self) -> bool: + return self._rtype == 4100 @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 + "]}}" ) @@ -211,53 +214,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, @@ -265,12 +259,22 @@ 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 + # 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) - raise Exception("Transfer error " + self.name + " : " + str(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() 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..a5b2f4b 100644 --- a/custom_components/airsend/sensor.py +++ b/custom_components/airsend/sensor.py @@ -1,71 +1,74 @@ """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.update_coordinator import DataUpdateCoordinator -from homeassistant.helpers.typing import ConfigType -from homeassistant.const import CONF_DEVICES, CONF_INTERNAL_URL, UnitOfTemperature, LIGHT_LUX +from homeassistant.helpers.entity import DeviceInfo, generate_entity_id +from homeassistant.helpers.entity_platform import AddEntitiesCallback +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 -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]) + 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, coordinator in coordinators.items(): + device = coordinator.device + options = devices_config.get(name, {}) + + # Generic external sensor (type 1) — no coordinator needed + if device.is_sensor: + entities.append(AirSendAnySensor(hass, device, internal_url)) + + # AirSend box sensors (type 0) 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: + coordinator.set_has_sensors(True) + entities.append(AirSendTempSensor(coordinator)) + entities.append(AirSendIllSensor(coordinator)) + + 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) — no coordinator, push only.""" + + def __init__(self, hass: HomeAssistant, device: Device, internal_url: str) -> 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,180 +77,80 @@ 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 + def device_info(self) -> DeviceInfo: + return self._device.device_info - @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 +class AirSendTempSensor(CoordinatorEntity, SensorEntity): + """AirSend device temperature sensor — uses shared coordinator.""" - @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() ) - - -class AirSendTempSensor(SensorEntity): - """Representation of an AirSend device temperature.""" - - def __init__( - self, - hass: HomeAssistant, - device: Device, - ) -> None: - """Initialize a sensor.""" - self._device = device - uname = DOMAIN + device.name + "_temp" - 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=12), - ) - def null_callback(): - return - self._coordinator.async_add_listener(null_callback) + 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): - """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" + 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 | 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 + def native_value(self): + return self.coordinator.data.get("temperature") - 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) ) + @property + def device_info(self) -> DeviceInfo: + return self.coordinator.device.device_info -class AirSendIllSensor(SensorEntity): - """Representation of an AirSend device temperature.""" +class AirSendIllSensor(CoordinatorEntity, SensorEntity): + """AirSend device illuminance sensor — uses shared coordinator.""" - def __init__( - self, - hass: HomeAssistant, - device: Device, - ) -> None: - """Initialize a sensor.""" - self._device = device - uname = DOMAIN + device.name + "_ill" - 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=12), - ) - def null_callback(): - return - self._coordinator.async_add_listener(null_callback) + 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): - """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" + 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 | 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 + def native_value(self): + return self.coordinator.data.get("illuminance") - 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) ) + @property + def device_info(self) -> DeviceInfo: + return self.coordinator.device.device_info diff --git a/custom_components/airsend/strings.json b/custom_components/airsend/strings.json new file mode 100644 index 0000000..15dbf65 --- /dev/null +++ b/custom_components/airsend/strings.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." + } + } +} diff --git a/custom_components/airsend/switch.py b/custom_components/airsend/switch.py index bc94222..0c010c9 100644 --- a/custom_components/airsend/switch.py +++ b/custom_components/airsend/switch.py @@ -4,93 +4,90 @@ 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]) + 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 + self._available = True @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 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: + 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: - """Turn the device off.""" - note = {"method": 1, "type": 0, "value": "OFF"} - if self._device.transfer(note, self.entity_id) == True: + 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() 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." + } + } +} 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." + } + } +} diff --git a/img/screenshot.png b/img/screenshot.png index b02aace..baba05e 100644 Binary files a/img/screenshot.png and b/img/screenshot.png differ