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:
@@ -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 |
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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")
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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,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"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user