feat: complete migration to config flow and bugfixes (@38decibel)

* 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

* Add icon for local brand

* Revise installation steps for AirSend integration

* Correction du crash websocket

raise Exception remplacé par return False

* Update README with AirSend configuration steps

Added instructions for configuring AirSend in Home Assistant.

* Update README with clearer instructions for YAML

Clarified instructions for editing the airsend.yaml file.

* Add files via upload

* Add English translation for AirSend configuration

* Add fr translations

* Update screenshot following latest version

* Fixe unavailable and new type light

Corrections de bugs
500 avec wait: true non fatal

Nouveau type
light.py — lumière dimmable (type 4100)
This commit is contained in:
38decibel
2026-03-31 18:02:14 +02:00
committed by GitHub
parent 50c9b382cc
commit 7009d6ee89
17 changed files with 905 additions and 483 deletions
+11 -8
View File
@@ -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).
@@ -30,19 +39,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.
+101 -30
View File
@@ -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
DOMAIN = "airsend"
AS_TYPE = ["button", "cover", "sensor", "switch"]
from homeassistant.components.hassio import get_addons_info
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:
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)
DOMAIN = "airsend"
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)
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:
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
return ""
@@ -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
Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

+29 -32
View File
@@ -4,73 +4,70 @@ from typing import Any
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()
+135
View File
@@ -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,
)
+78
View File
@@ -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")
+49 -80
View File
@@ -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 @@ class AirSendCover(CoverEntity, RestoreEntity):
@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()
+115 -111
View File
@@ -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 @@ class Device:
@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 @@ class Device:
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 @@ class Device:
"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 @@ class Device:
"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
+111
View File
@@ -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()
+3 -3
View File
@@ -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"
}
+79 -176
View File
@@ -1,71 +1,74 @@
"""AirSend sensors."""
from typing import Any
from datetime import timedelta
from homeassistant.components.binary_sensor import BinarySensorEntity, BinarySensorDeviceClass
from homeassistant.components.sensor import SensorEntity, SensorDeviceClass
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 .device import Device
from . import DOMAIN
import logging
from homeassistant.components.sensor import SensorEntity, SensorDeviceClass
from homeassistant.config_entries import ConfigEntry
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 CoordinatorEntity
from homeassistant.const import CONF_INTERNAL_URL, UnitOfTemperature, LIGHT_LUX
from .coordinator import AirSendCoordinator
from .device import Device
from . import DOMAIN
_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."""
"""Generic AirSend sensor (type 1) — no coordinator, push only."""
def __init__(
self,
hass: HomeAssistant,
device: Device,
) -> None:
"""Initialize a sensor."""
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 @@ class AirSendAnySensor(SensorEntity):
@property
def should_poll(self) -> bool:
"""Return the polling state."""
return False
@property
def device_info(self) -> DeviceInfo:
return self._device.device_info
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)
class AirSendTempSensor(CoordinatorEntity, SensorEntity):
"""AirSend device temperature sensor — uses shared coordinator."""
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 + "_state"
return self.coordinator.device.name + "_temp"
@property
def device_class(self) -> BinarySensorDeviceClass | None:
"""Cette entité"""
return BinarySensorDeviceClass.RUNNING
def available(self) -> bool:
return self.coordinator.data.get("available", True)
@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)
@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
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
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
+31
View File
@@ -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."
}
}
}
+40 -43
View File
@@ -4,93 +4,90 @@ from typing import Any
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:
self._state = True
self.schedule_update_ha_state()
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
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_on(self, **kwargs: Any) -> None:
if await self._send({"method": 1, "type": 0, "value": "ON"}):
self._state = True
self.async_write_ha_state()
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()
@@ -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."
}
}
}
@@ -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."
}
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 46 KiB