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:
@@ -15,6 +15,15 @@ Component for sending radio commands through the AirSend (RF433) or AirSend duo
|
|||||||
- Select your devices, for local connection, select `spurl`
|
- Select your devices, for local connection, select `spurl`
|
||||||
- Click `Export YAML` to save the airsend.yaml
|
- Click `Export YAML` to save the airsend.yaml
|
||||||
- In the `config` folder of Home Assistant, place the `airsend.yaml` file.
|
- 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**:
|
3. **Edit the `secrets.yaml` File**:
|
||||||
- Add a line to the `secrets.yaml` file with the AirSend - Local IP - / - Password - (and IPv4 address).
|
- 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.
|
- 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**:
|
4. **Install the Custom Component**:
|
||||||
- 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**:
|
|
||||||
- In the Home Assistant terminal, run the following command to install the component:
|
- In the Home Assistant terminal, run the following command to install the component:
|
||||||
```bash
|
```bash
|
||||||
wget -q -O - https://raw.githubusercontent.com/devmel/hass_airsend/master/install | 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 Home Assistant.
|
||||||
- Restart the AirSend addon.
|
- Restart the AirSend addon.
|
||||||
|
|
||||||
|
|||||||
@@ -1,37 +1,108 @@
|
|||||||
"""The AirSend component."""
|
"""The AirSend component."""
|
||||||
|
import os
|
||||||
|
import logging
|
||||||
|
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
|
from homeassistant.config_entries import ConfigEntry
|
||||||
from homeassistant.helpers.typing import ConfigType
|
from homeassistant.helpers.typing import ConfigType
|
||||||
from homeassistant.helpers import discovery
|
|
||||||
from homeassistant.components.hassio import (
|
|
||||||
get_addons_info,
|
|
||||||
)
|
|
||||||
from homeassistant.const import CONF_INTERNAL_URL
|
from homeassistant.const import CONF_INTERNAL_URL
|
||||||
|
|
||||||
DOMAIN = "airsend"
|
from homeassistant.components.hassio import get_addons_info
|
||||||
AS_TYPE = ["button", "cover", "sensor", "switch"]
|
|
||||||
|
|
||||||
async def async_setup(hass: HomeAssistant, config: ConfigType):
|
DOMAIN = "airsend"
|
||||||
"""Set up the AirSend component."""
|
AS_PLATFORMS = ["cover", "switch", "button", "light", "sensor", "binary_sensor"]
|
||||||
if DOMAIN not in config:
|
|
||||||
return True
|
_LOGGER = logging.getLogger(DOMAIN)
|
||||||
internalurl = ""
|
|
||||||
try:
|
|
||||||
internalurl = config[DOMAIN][CONF_INTERNAL_URL]
|
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||||
except KeyError:
|
"""Legacy YAML setup — no longer used for device loading."""
|
||||||
pass
|
|
||||||
if internalurl == "":
|
|
||||||
try:
|
|
||||||
addons_info = get_addons_info(hass)
|
|
||||||
for name, options in addons_info.items():
|
|
||||||
if "_airsend" in name:
|
|
||||||
ip = options["ip_address"]
|
|
||||||
if ip:
|
|
||||||
internalurl = "http://" + str(ip) + ":33863/"
|
|
||||||
except:
|
|
||||||
pass
|
|
||||||
if internalurl != "" and not internalurl.endswith('/'):
|
|
||||||
internalurl += "/"
|
|
||||||
config[DOMAIN][CONF_INTERNAL_URL] = internalurl
|
|
||||||
for plateform in AS_TYPE:
|
|
||||||
discovery.load_platform(hass, plateform, DOMAIN, config[DOMAIN].copy(), config)
|
|
||||||
return True
|
return 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 .device import Device
|
||||||
|
|
||||||
from homeassistant.components.button import ButtonEntity
|
from homeassistant.components.button import ButtonEntity
|
||||||
|
from homeassistant.config_entries import ConfigEntry
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.helpers.typing import ConfigType
|
from homeassistant.helpers.entity import DeviceInfo
|
||||||
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||||
from homeassistant.const import CONF_DEVICES, CONF_INTERNAL_URL
|
from homeassistant.const import CONF_INTERNAL_URL
|
||||||
|
|
||||||
from . import DOMAIN
|
from . import DOMAIN
|
||||||
|
|
||||||
|
|
||||||
async def async_setup_platform(
|
async def async_setup_entry(
|
||||||
hass: HomeAssistant, config: ConfigType, async_add_entities, discovery_info=None
|
hass: HomeAssistant,
|
||||||
|
entry: ConfigEntry,
|
||||||
|
async_add_entities: AddEntitiesCallback,
|
||||||
) -> None:
|
) -> None:
|
||||||
if discovery_info is None:
|
internal_url = entry.data.get(CONF_INTERNAL_URL, "")
|
||||||
return
|
devices_config = entry.data.get("devices", {})
|
||||||
for name, options in discovery_info[CONF_DEVICES].items():
|
entities = []
|
||||||
device = Device(name, options, discovery_info[CONF_INTERNAL_URL])
|
for name, options in devices_config.items():
|
||||||
|
device = Device(name, options, internal_url)
|
||||||
if device.is_button:
|
if device.is_button:
|
||||||
entity = AirSendButton(
|
entities.append(AirSendButton(hass, device))
|
||||||
hass,
|
async_add_entities(entities)
|
||||||
device,
|
|
||||||
)
|
|
||||||
async_add_entities([entity])
|
|
||||||
|
|
||||||
|
|
||||||
class AirSendButton(ButtonEntity):
|
class AirSendButton(ButtonEntity):
|
||||||
"""Representation of an AirSend Button."""
|
"""Representation of an AirSend Button."""
|
||||||
|
|
||||||
def __init__(
|
def __init__(self, hass: HomeAssistant, device: Device) -> None:
|
||||||
self,
|
|
||||||
hass: HomeAssistant,
|
|
||||||
device: Device,
|
|
||||||
) -> None:
|
|
||||||
"""Initialize a button."""
|
|
||||||
self._device = device
|
self._device = device
|
||||||
uname = DOMAIN + device.name
|
self._unique_id = DOMAIN + "_" + str(device.unique_channel_name) + "_button"
|
||||||
self._unique_id = "_".join(x for x in uname)
|
self._available = True
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def unique_id(self):
|
def unique_id(self):
|
||||||
"""Return unique identifier of remote device."""
|
|
||||||
return self._unique_id
|
return self._unique_id
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def available(self):
|
def available(self):
|
||||||
return True
|
return self._available
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def should_poll(self):
|
def should_poll(self):
|
||||||
"""No polling needed."""
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def name(self):
|
def name(self):
|
||||||
"""Return the name of the device if any."""
|
|
||||||
return self._device.name
|
return self._device.name
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def extra_state_attributes(self):
|
def extra_state_attributes(self):
|
||||||
"""Return the device state attributes."""
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def assumed_state(self):
|
def assumed_state(self):
|
||||||
"""Return true if unable to access real state of entity."""
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def press(self, **kwargs: Any) -> None:
|
@property
|
||||||
"""Handle the button press."""
|
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"}
|
note = {"method": 1, "type": 0, "value": "TOGGLE"}
|
||||||
if self._device.transfer(note, self.entity_id) == True:
|
result = await self._device.async_transfer(note, self.entity_id)
|
||||||
self.schedule_update_ha_state()
|
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 typing import Any
|
||||||
|
|
||||||
from .device import Device
|
from .device import Device
|
||||||
|
|
||||||
from homeassistant.components.cover import CoverEntity
|
from homeassistant.components.cover import CoverEntity
|
||||||
|
from homeassistant.config_entries import ConfigEntry
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.helpers.typing import ConfigType
|
from homeassistant.helpers.entity import DeviceInfo
|
||||||
from homeassistant.helpers.restore_state import RestoreEntity
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||||
from homeassistant.const import CONF_DEVICES, CONF_INTERNAL_URL
|
from homeassistant.const import CONF_INTERNAL_URL
|
||||||
|
|
||||||
from . import DOMAIN
|
from . import DOMAIN
|
||||||
|
|
||||||
|
|
||||||
async def async_setup_platform(
|
async def async_setup_entry(
|
||||||
hass: HomeAssistant, config: ConfigType, async_add_entities, discovery_info=None
|
hass: HomeAssistant,
|
||||||
|
entry: ConfigEntry,
|
||||||
|
async_add_entities: AddEntitiesCallback,
|
||||||
) -> None:
|
) -> None:
|
||||||
if discovery_info is None:
|
"""Set up AirSend covers from a config entry."""
|
||||||
return
|
internal_url = entry.data.get(CONF_INTERNAL_URL, "")
|
||||||
for name, options in discovery_info[CONF_DEVICES].items():
|
devices_config = entry.data.get("devices", {})
|
||||||
device = Device(name, options, discovery_info[CONF_INTERNAL_URL])
|
entities = []
|
||||||
|
for name, options in devices_config.items():
|
||||||
|
device = Device(name, options, internal_url)
|
||||||
if device.is_cover:
|
if device.is_cover:
|
||||||
entity = AirSendCover(
|
entities.append(AirSendCover(hass, device))
|
||||||
hass,
|
async_add_entities(entities)
|
||||||
device,
|
|
||||||
)
|
|
||||||
async_add_entities([entity])
|
|
||||||
|
|
||||||
|
|
||||||
class AirSendCover(CoverEntity, RestoreEntity):
|
class AirSendCover(CoverEntity):
|
||||||
"""Representation of an AirSend Cover."""
|
"""Representation of an AirSend Cover."""
|
||||||
|
|
||||||
def __init__(
|
def __init__(self, hass: HomeAssistant, device: Device) -> None:
|
||||||
self,
|
|
||||||
hass: HomeAssistant,
|
|
||||||
device: Device,
|
|
||||||
) -> None:
|
|
||||||
"""Initialize a cover device."""
|
|
||||||
self._hass = hass
|
self._hass = hass
|
||||||
self._device = device
|
self._device = device
|
||||||
uname = DOMAIN + device.name
|
self._unique_id = DOMAIN + "_" + str(device.unique_channel_name) + "_cover"
|
||||||
self._unique_id = "_".join(x for x in uname)
|
|
||||||
self._closed = None
|
self._closed = None
|
||||||
|
self._available = True
|
||||||
if device.is_cover_with_position:
|
if device.is_cover_with_position:
|
||||||
self._attr_current_cover_position = 50
|
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
|
@property
|
||||||
def unique_id(self):
|
def unique_id(self):
|
||||||
"""Return unique identifier of remote device."""
|
|
||||||
return self._unique_id
|
return self._unique_id
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def available(self):
|
def available(self):
|
||||||
return True
|
return self._available
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def should_poll(self):
|
def should_poll(self):
|
||||||
"""No polling needed."""
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def name(self):
|
def name(self):
|
||||||
"""Return the name of the device if any."""
|
|
||||||
return self._device.name
|
return self._device.name
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -96,55 +63,57 @@ class AirSendCover(CoverEntity, RestoreEntity):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def assumed_state(self):
|
def assumed_state(self):
|
||||||
"""Return true if unable to access real state of entity."""
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
@property
|
||||||
|
def device_info(self) -> DeviceInfo:
|
||||||
|
return self._device.device_info
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def is_closed(self):
|
def is_closed(self):
|
||||||
"""Return if the cover is closed."""
|
|
||||||
if self._device.is_async and self._hass:
|
if self._device.is_async and self._hass:
|
||||||
component = self._hass.states.get(self.entity_id)
|
component = self._hass.states.get(self.entity_id)
|
||||||
if component is not None:
|
if component is not None:
|
||||||
if component.state == 'open' or component.state == 'on' or component.state == 'up':
|
self._closed = component.state not in ('open', 'on', 'up')
|
||||||
self._closed = False
|
|
||||||
else:
|
|
||||||
self._closed = True
|
|
||||||
return self._closed
|
return self._closed
|
||||||
|
|
||||||
def open_cover(self, **kwargs: Any) -> None:
|
async def _send(self, note: dict) -> bool:
|
||||||
"""Open the cover."""
|
"""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"}
|
note = {"method": 1, "type": 0, "value": "UP"}
|
||||||
if self._device.transfer(note, self.entity_id) == True:
|
if await self._send(note):
|
||||||
self._closed = False
|
self._closed = False
|
||||||
if self._device.is_cover_with_position:
|
if self._device.is_cover_with_position:
|
||||||
self._attr_current_cover_position = 100
|
self._attr_current_cover_position = 100
|
||||||
self.schedule_update_ha_state()
|
self.async_write_ha_state()
|
||||||
|
|
||||||
def close_cover(self, **kwargs: Any) -> None:
|
async def async_close_cover(self, **kwargs: Any) -> None:
|
||||||
"""Close cover."""
|
|
||||||
note = {"method": 1, "type": 0, "value": "DOWN"}
|
note = {"method": 1, "type": 0, "value": "DOWN"}
|
||||||
if self._device.transfer(note, self.entity_id) == True:
|
if await self._send(note):
|
||||||
self._closed = True
|
self._closed = True
|
||||||
if self._device.is_cover_with_position:
|
if self._device.is_cover_with_position:
|
||||||
self._attr_current_cover_position = 0
|
self._attr_current_cover_position = 0
|
||||||
self.schedule_update_ha_state()
|
self.async_write_ha_state()
|
||||||
|
|
||||||
def stop_cover(self, **kwargs):
|
async def async_stop_cover(self, **kwargs: Any) -> None:
|
||||||
"""Stop the cover."""
|
|
||||||
note = {"method": 1, "type": 0, "value": "STOP"}
|
note = {"method": 1, "type": 0, "value": "STOP"}
|
||||||
if self._device.transfer(note, self.entity_id) == True:
|
if await self._send(note):
|
||||||
self._closed = False
|
self._closed = False
|
||||||
if self._device.is_cover_with_position:
|
if self._device.is_cover_with_position:
|
||||||
self._attr_current_cover_position = 50
|
self._attr_current_cover_position = 50
|
||||||
self.schedule_update_ha_state()
|
self.async_write_ha_state()
|
||||||
|
|
||||||
def set_cover_position(self, **kwargs):
|
async def async_set_cover_position(self, **kwargs: Any) -> None:
|
||||||
"""Move the cover to a specific position."""
|
|
||||||
position = int(kwargs["position"])
|
position = int(kwargs["position"])
|
||||||
note = {"method": 1, "type": 9, "value": 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._attr_current_cover_position = position
|
||||||
self._closed = False
|
self._closed = position == 0
|
||||||
if self._attr_current_cover_position == 0:
|
self.async_write_ha_state()
|
||||||
self._closed = True
|
|
||||||
self.schedule_update_ha_state()
|
|
||||||
|
|||||||
+115
-111
@@ -2,11 +2,21 @@
|
|||||||
import logging
|
import logging
|
||||||
import json
|
import json
|
||||||
import hashlib
|
import hashlib
|
||||||
from requests import get, post, exceptions
|
import aiohttp
|
||||||
from . import DOMAIN
|
from . import DOMAIN
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(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:
|
class Device:
|
||||||
"""Representation of a Device."""
|
"""Representation of a Device."""
|
||||||
@@ -76,7 +86,7 @@ class Device:
|
|||||||
@property
|
@property
|
||||||
def unique_channel_name(self) -> str:
|
def unique_channel_name(self) -> str:
|
||||||
if self._uid:
|
if self._uid:
|
||||||
return self._uid
|
return str(self._uid)
|
||||||
if self._channel:
|
if self._channel:
|
||||||
result = str(self._channel['id'])
|
result = str(self._channel['id'])
|
||||||
if result:
|
if result:
|
||||||
@@ -88,120 +98,113 @@ class Device:
|
|||||||
return result
|
return result
|
||||||
return self._name
|
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
|
@property
|
||||||
def extra_state_attributes(self):
|
def extra_state_attributes(self):
|
||||||
if self._channel:
|
if self._channel:
|
||||||
self._attrs = {
|
return {"channel": self._channel}
|
||||||
"channel": self._channel
|
|
||||||
}
|
|
||||||
return self._attrs
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def is_async(self) -> bool:
|
def is_async(self) -> bool:
|
||||||
"""Return if asynchronous state."""
|
"""Return if asynchronous state."""
|
||||||
if self._wait == False:
|
return self._wait == False
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def is_airsend(self) -> bool:
|
def is_airsend(self) -> bool:
|
||||||
"""Return if is an AirSend."""
|
return self._rtype == 0
|
||||||
if self._rtype == 0:
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def is_sensor(self) -> bool:
|
def is_sensor(self) -> bool:
|
||||||
"""Return if is a sensor to listen."""
|
return self._rtype == 1
|
||||||
if self._rtype == 1:
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def is_button(self) -> bool:
|
def is_button(self) -> bool:
|
||||||
"""Return if is a button."""
|
return self._rtype == 4096
|
||||||
if self._rtype == 4096:
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def is_cover(self) -> bool:
|
def is_cover(self) -> bool:
|
||||||
"""Return if is a cover."""
|
return self._rtype in (4098, 4099)
|
||||||
if self._rtype in (4098, 4099):
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def is_cover_with_position(self) -> bool:
|
def is_cover_with_position(self) -> bool:
|
||||||
"""Return if is a cover with position."""
|
return self._rtype == 4099
|
||||||
if self._rtype == 4099:
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def is_switch(self) -> bool:
|
def is_switch(self) -> bool:
|
||||||
"""Return if is a switch."""
|
return self._rtype == 4097
|
||||||
if self._rtype == 4097:
|
|
||||||
return True
|
@property
|
||||||
return False
|
def is_light(self) -> bool:
|
||||||
|
return self._rtype == 4100
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def refresh_value(self) -> int:
|
def refresh_value(self) -> int:
|
||||||
"""Return refresh value in seconds."""
|
"""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 self._refresh
|
||||||
return (5 * 60)
|
return 5 * 60
|
||||||
|
|
||||||
def bind(self) -> bool:
|
async def async_bind(self) -> bool:
|
||||||
"""Bind a channel to listen."""
|
"""Bind a channel to listen (async)."""
|
||||||
ret = False
|
if not (self._serviceurl and self._spurl and isinstance(self._bind, int) and self._bind > 0):
|
||||||
if self._serviceurl and self._spurl and type(self._bind) is int and self._bind > 0:
|
return False
|
||||||
payload = ('{"channel":{"id": '+str(self._bind)+'},\"duration\":0,\"callback\":\"http://127.0.0.1/\"}')
|
payload = json.dumps({
|
||||||
headers = {
|
"channel": {"id": self._bind},
|
||||||
"Authorization": "Bearer " + self._spurl,
|
"duration": 0,
|
||||||
"content-type": "application/json",
|
"callback": "http://127.0.0.1/"
|
||||||
"User-Agent": "hass_airsend",
|
})
|
||||||
}
|
headers = {
|
||||||
try:
|
"Authorization": "Bearer " + self._spurl,
|
||||||
response = post(
|
"content-type": "application/json",
|
||||||
|
"User-Agent": "hass_airsend",
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
async with aiohttp.ClientSession() as session:
|
||||||
|
async with session.post(
|
||||||
self._serviceurl + "airsend/bind",
|
self._serviceurl + "airsend/bind",
|
||||||
headers=headers,
|
headers=headers,
|
||||||
data=payload,
|
data=payload,
|
||||||
timeout=6,
|
timeout=aiohttp.ClientTimeout(total=6),
|
||||||
)
|
) as response:
|
||||||
if response.status_code == 200:
|
return response.status == 200
|
||||||
ret = True
|
except aiohttp.ClientError as err:
|
||||||
except exceptions.RequestException:
|
_LOGGER.debug("Bind error '%s': %s", self._name, err)
|
||||||
pass
|
return False
|
||||||
return ret
|
|
||||||
|
|
||||||
def transfer(self, note, entity_id = None) -> bool:
|
async def async_transfer(self, note, entity_id=None) -> bool:
|
||||||
"""Send a command."""
|
"""Send a command (async)."""
|
||||||
status_code = 404
|
status_code = 404
|
||||||
ret = False
|
ret = False
|
||||||
wait = 'false, "callback":"http://127.0.0.1/"'
|
wait = 'false, "callback":"http://127.0.0.1/"'
|
||||||
if self._wait == True:
|
if self._wait:
|
||||||
wait = 'true'
|
wait = 'true'
|
||||||
|
|
||||||
if self._serviceurl and self._spurl and entity_id is not None:
|
if self._serviceurl and self._spurl and entity_id is not None:
|
||||||
uid = hashlib.sha256(entity_id.encode('utf-8')).hexdigest()[:12]
|
uid = hashlib.sha256(entity_id.encode('utf-8')).hexdigest()[:12]
|
||||||
jnote = json.dumps(note)
|
jnote = json.dumps(note)
|
||||||
if (
|
if (
|
||||||
self._note is not None
|
self._note is not None
|
||||||
and "method" in self._note
|
and all(k in self._note for k in ("method", "type", "value"))
|
||||||
and "type" in self._note
|
and all(k in note for k in ("method", "type", "value"))
|
||||||
and "value" in self._note
|
|
||||||
and "method" in note.keys()
|
|
||||||
and "type" in note.keys()
|
|
||||||
and "value" in note.keys()
|
|
||||||
and note["method"] == 1 and note["type"] == 0
|
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)
|
jnote = json.dumps(self._note)
|
||||||
|
|
||||||
payload = (
|
payload = (
|
||||||
'{"wait": '+wait+', "channel":'
|
'{"wait": ' + wait + ', "channel":'
|
||||||
+ json.dumps(self._channel)
|
+ json.dumps(self._channel)
|
||||||
+ ', "thingnotes":{"uid":"0x'+uid+'", "notes":['
|
+ ', "thingnotes":{"uid":"0x' + uid + '", "notes":['
|
||||||
+ jnote
|
+ jnote
|
||||||
+ "]}}"
|
+ "]}}"
|
||||||
)
|
)
|
||||||
@@ -211,53 +214,44 @@ class Device:
|
|||||||
"User-Agent": "hass_airsend",
|
"User-Agent": "hass_airsend",
|
||||||
}
|
}
|
||||||
try:
|
try:
|
||||||
response = post(
|
async with aiohttp.ClientSession() as session:
|
||||||
self._serviceurl + "airsend/transfer",
|
async with session.post(
|
||||||
headers=headers,
|
self._serviceurl + "airsend/transfer",
|
||||||
data=payload,
|
headers=headers,
|
||||||
timeout=6,
|
data=payload,
|
||||||
)
|
timeout=aiohttp.ClientTimeout(total=6),
|
||||||
if self._wait == True:
|
) as response:
|
||||||
ret = True
|
if self._wait:
|
||||||
status_code = 500
|
ret = True
|
||||||
jdata = json.loads(response.text)
|
status_code = 500
|
||||||
if jdata["type"] < 0x100:
|
try:
|
||||||
status_code = response.status_code
|
jdata = await response.json(content_type=None)
|
||||||
else:
|
if jdata.get("type", 0x100) < 0x100:
|
||||||
ret = None
|
status_code = response.status
|
||||||
status_code = response.status_code
|
except Exception:
|
||||||
except exceptions.RequestException:
|
pass
|
||||||
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:
|
if status_code != 200 and self._apikey:
|
||||||
action = "command"
|
action = "command"
|
||||||
value = 6
|
value = 6
|
||||||
if (
|
if all(k in note for k in ("method", "type", "value")):
|
||||||
"method" in note.keys()
|
|
||||||
and "type" in note.keys()
|
|
||||||
and "value" in note.keys()
|
|
||||||
):
|
|
||||||
if note["method"] == 1 and note["type"] == 0:
|
if note["method"] == 1 and note["type"] == 0:
|
||||||
if note["value"] == "OFF":
|
value = {
|
||||||
value = 0
|
"OFF": 0, "ON": 1, "STOP": 3, "DOWN": 4, "UP": 5
|
||||||
if note["value"] == "ON":
|
}.get(note["value"], 6)
|
||||||
value = 1
|
elif note["method"] == 1 and note["type"] == 9:
|
||||||
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:
|
|
||||||
action = "level"
|
action = "level"
|
||||||
value = int(note["value"])
|
value = int(note["value"])
|
||||||
|
|
||||||
cloud_url = (
|
cloud_url = (
|
||||||
"https://airsend.cloud/device/"
|
"https://airsend.cloud/device/"
|
||||||
+ str(self._uid)
|
+ str(self._uid) + "/" + action + "/" + str(value) + "/"
|
||||||
+ "/"
|
|
||||||
+ action
|
|
||||||
+ "/"
|
|
||||||
+ str(value)
|
|
||||||
+ "/"
|
|
||||||
)
|
)
|
||||||
headers = {
|
headers = {
|
||||||
"Authorization": "Bearer " + self._apikey,
|
"Authorization": "Bearer " + self._apikey,
|
||||||
@@ -265,12 +259,22 @@ class Device:
|
|||||||
"User-Agent": "hass_airsend",
|
"User-Agent": "hass_airsend",
|
||||||
}
|
}
|
||||||
try:
|
try:
|
||||||
response = get(cloud_url, headers=headers, timeout=10)
|
async with aiohttp.ClientSession() as session:
|
||||||
status_code = response.status_code
|
async with session.get(
|
||||||
ret = True
|
cloud_url,
|
||||||
except exceptions.RequestException:
|
headers=headers,
|
||||||
pass
|
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:
|
if status_code == 200:
|
||||||
return ret
|
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)
|
_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",
|
"name": "AirSend",
|
||||||
"documentation": "https://github.com/devmel/hass_airsend",
|
"documentation": "https://github.com/devmel/hass_airsend",
|
||||||
"dependencies": ["http"],
|
"dependencies": ["http"],
|
||||||
"config_flow": false,
|
"config_flow": true,
|
||||||
"codeowners": ["@devmel"],
|
"codeowners": ["@devmel"],
|
||||||
"requirements": [],
|
"requirements": [],
|
||||||
"version": "3.4",
|
"version": "4.0",
|
||||||
"iot_class": "cloud_polling"
|
"iot_class": "local_push"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,71 +1,74 @@
|
|||||||
"""AirSend sensors."""
|
"""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
|
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)
|
_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:
|
) -> None:
|
||||||
if discovery_info is None:
|
coordinators: dict[str, AirSendCoordinator] = (
|
||||||
return
|
hass.data[DOMAIN][entry.entry_id]["coordinators"]
|
||||||
for name, options in discovery_info[CONF_DEVICES].items():
|
)
|
||||||
device = Device(name, options, discovery_info[CONF_INTERNAL_URL])
|
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:
|
if device.is_airsend:
|
||||||
entity = AirSendStateSensor(hass, device)
|
|
||||||
async_add_entities([entity])
|
|
||||||
sensors = False
|
sensors = False
|
||||||
try:
|
try:
|
||||||
sensors = eval(str(options["sensors"]))
|
sensors = eval(str(options.get("sensors", False)))
|
||||||
except KeyError:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
if sensors == True:
|
if sensors:
|
||||||
entityTmp = AirSendTempSensor(hass, device)
|
coordinator.set_has_sensors(True)
|
||||||
entityIll = AirSendIllSensor(hass, device)
|
entities.append(AirSendTempSensor(coordinator))
|
||||||
async_add_entities([entityTmp, entityIll])
|
entities.append(AirSendIllSensor(coordinator))
|
||||||
if device.is_sensor:
|
|
||||||
entity = AirSendAnySensor(hass, device)
|
async_add_entities(entities)
|
||||||
async_add_entities([entity])
|
|
||||||
|
|
||||||
class AirSendAnySensor(SensorEntity):
|
class AirSendAnySensor(SensorEntity):
|
||||||
"""Representation of an AirSend device temperature."""
|
"""Generic AirSend sensor (type 1) — no coordinator, push only."""
|
||||||
|
|
||||||
def __init__(
|
def __init__(self, hass: HomeAssistant, device: Device, internal_url: str) -> None:
|
||||||
self,
|
|
||||||
hass: HomeAssistant,
|
|
||||||
device: Device,
|
|
||||||
) -> None:
|
|
||||||
"""Initialize a sensor."""
|
|
||||||
self._device = device
|
self._device = device
|
||||||
uname = DOMAIN + device.name
|
self._unique_id = DOMAIN + "_" + str(device.unique_channel_name) + "_sensor"
|
||||||
self._unique_id = "_".join(x for x in uname)
|
self.entity_id = generate_entity_id("sensor.{}", device.unique_channel_name, hass=hass)
|
||||||
self.entity_id = generate_entity_id("sensor.{}", self._device.unique_channel_name, hass=hass)
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def unique_id(self):
|
def unique_id(self):
|
||||||
"""Return unique identifier of device."""
|
|
||||||
return self._unique_id
|
return self._unique_id
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def name(self):
|
def name(self):
|
||||||
"""Return the name of the device if any."""
|
|
||||||
return self._device.name
|
return self._device.name
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def extra_state_attributes(self):
|
def extra_state_attributes(self):
|
||||||
"""Return the device state attributes."""
|
|
||||||
return self._device.extra_state_attributes
|
return self._device.extra_state_attributes
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -74,180 +77,80 @@ class AirSendAnySensor(SensorEntity):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def should_poll(self) -> bool:
|
def should_poll(self) -> bool:
|
||||||
"""Return the polling state."""
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
@property
|
||||||
|
def device_info(self) -> DeviceInfo:
|
||||||
|
return self._device.device_info
|
||||||
|
|
||||||
class AirSendStateSensor(BinarySensorEntity):
|
|
||||||
"""Representation of an AirSend device."""
|
|
||||||
|
|
||||||
def __init__(
|
class AirSendTempSensor(CoordinatorEntity, SensorEntity):
|
||||||
self,
|
"""AirSend device temperature sensor — uses shared coordinator."""
|
||||||
hass: HomeAssistant,
|
|
||||||
device: Device,
|
def __init__(self, coordinator: AirSendCoordinator) -> None:
|
||||||
) -> None:
|
super().__init__(coordinator)
|
||||||
"""Initialize a sensor."""
|
self._unique_id = DOMAIN + "_" + str(coordinator.device.unique_channel_name) + "_temp"
|
||||||
self.hass = hass
|
|
||||||
self._bind = None
|
|
||||||
self._device = device
|
|
||||||
uname = DOMAIN + device.name + "_state"
|
|
||||||
self._unique_id = "_".join(x for x in uname)
|
|
||||||
self._coordinator = DataUpdateCoordinator(
|
|
||||||
hass,
|
|
||||||
_LOGGER,
|
|
||||||
name=uname,
|
|
||||||
update_method=self.async_update_data,
|
|
||||||
update_interval=timedelta(seconds=10),
|
|
||||||
)
|
|
||||||
def null_callback():
|
|
||||||
return
|
|
||||||
self._coordinator.async_add_listener(null_callback)
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def unique_id(self):
|
def unique_id(self):
|
||||||
"""Return unique identifier of device."""
|
|
||||||
return self._unique_id
|
return self._unique_id
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def name(self):
|
def name(self):
|
||||||
"""Return the name of the device if any."""
|
return self.coordinator.device.name + "_temp"
|
||||||
return self._device.name + "_state"
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def device_class(self) -> BinarySensorDeviceClass | None:
|
def available(self) -> bool:
|
||||||
"""Cette entité"""
|
return self.coordinator.data.get("available", True)
|
||||||
return BinarySensorDeviceClass.RUNNING
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def available(self):
|
def device_class(self) -> SensorDeviceClass:
|
||||||
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é"""
|
|
||||||
return SensorDeviceClass.TEMPERATURE
|
return SensorDeviceClass.TEMPERATURE
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def native_unit_of_measurement(self):
|
def native_unit_of_measurement(self):
|
||||||
"""Return measurement unit."""
|
|
||||||
return UnitOfTemperature.CELSIUS
|
return UnitOfTemperature.CELSIUS
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def should_poll(self) -> bool:
|
def native_value(self):
|
||||||
"""Return the polling state."""
|
return self.coordinator.data.get("temperature")
|
||||||
return False
|
|
||||||
|
|
||||||
async def async_update_data(self):
|
@property
|
||||||
"""Register update callback."""
|
def device_info(self) -> DeviceInfo:
|
||||||
self._coordinator.update_interval = timedelta(seconds=self._device.refresh_value)
|
return self.coordinator.device.device_info
|
||||||
note = {"method": "QUERY", "type": "TEMPERATURE"}
|
|
||||||
await self.hass.async_add_executor_job( lambda: self._device.transfer(note, self.entity_id) )
|
|
||||||
|
|
||||||
|
|
||||||
class AirSendIllSensor(SensorEntity):
|
class AirSendIllSensor(CoordinatorEntity, SensorEntity):
|
||||||
"""Representation of an AirSend device temperature."""
|
"""AirSend device illuminance sensor — uses shared coordinator."""
|
||||||
|
|
||||||
def __init__(
|
def __init__(self, coordinator: AirSendCoordinator) -> None:
|
||||||
self,
|
super().__init__(coordinator)
|
||||||
hass: HomeAssistant,
|
self._unique_id = DOMAIN + "_" + str(coordinator.device.unique_channel_name) + "_ill"
|
||||||
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)
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def unique_id(self):
|
def unique_id(self):
|
||||||
"""Return unique identifier of device."""
|
|
||||||
return self._unique_id
|
return self._unique_id
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def name(self):
|
def name(self):
|
||||||
"""Return the name of the device if any."""
|
return self.coordinator.device.name + "_ill"
|
||||||
return self._device.name + "_ill"
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def available(self):
|
def available(self) -> bool:
|
||||||
return True
|
return self.coordinator.data.get("available", True)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def device_class(self) -> SensorDeviceClass | None:
|
def device_class(self) -> SensorDeviceClass:
|
||||||
"""Cette entité"""
|
|
||||||
return SensorDeviceClass.ILLUMINANCE
|
return SensorDeviceClass.ILLUMINANCE
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def native_unit_of_measurement(self):
|
def native_unit_of_measurement(self):
|
||||||
"""Return measurement unit."""
|
|
||||||
return LIGHT_LUX
|
return LIGHT_LUX
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def should_poll(self) -> bool:
|
def native_value(self):
|
||||||
"""Return the polling state."""
|
return self.coordinator.data.get("illuminance")
|
||||||
return False
|
|
||||||
|
|
||||||
async def async_update_data(self):
|
@property
|
||||||
"""Register update callback."""
|
def device_info(self) -> DeviceInfo:
|
||||||
self._coordinator.update_interval = timedelta(seconds=self._device.refresh_value)
|
return self.coordinator.device.device_info
|
||||||
note = {"method": "QUERY", "type": "ILLUMINANCE"}
|
|
||||||
await self.hass.async_add_executor_job( lambda: self._device.transfer(note, self.entity_id) )
|
|
||||||
|
|||||||
@@ -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 .device import Device
|
||||||
|
|
||||||
from homeassistant.components.switch import SwitchEntity
|
from homeassistant.components.switch import SwitchEntity
|
||||||
|
from homeassistant.config_entries import ConfigEntry
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.helpers.typing import ConfigType
|
from homeassistant.helpers.entity import DeviceInfo
|
||||||
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||||
from homeassistant.const import CONF_DEVICES, CONF_INTERNAL_URL
|
from homeassistant.const import CONF_INTERNAL_URL
|
||||||
|
|
||||||
from . import DOMAIN
|
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:
|
) -> None:
|
||||||
if discovery_info is None:
|
internal_url = entry.data.get(CONF_INTERNAL_URL, "")
|
||||||
return
|
devices_config = entry.data.get("devices", {})
|
||||||
for name, options in discovery_info[CONF_DEVICES].items():
|
entities = []
|
||||||
device = Device(name, options, discovery_info[CONF_INTERNAL_URL])
|
for name, options in devices_config.items():
|
||||||
|
device = Device(name, options, internal_url)
|
||||||
if device.is_switch:
|
if device.is_switch:
|
||||||
entity = AirSendSwitch(
|
entities.append(AirSendSwitch(hass, device))
|
||||||
hass,
|
async_add_entities(entities)
|
||||||
device,
|
|
||||||
)
|
|
||||||
async_add_entities([entity])
|
|
||||||
|
|
||||||
|
|
||||||
class AirSendSwitch(SwitchEntity):
|
class AirSendSwitch(SwitchEntity):
|
||||||
"""Representation of an AirSend Switch."""
|
"""Representation of an AirSend Switch."""
|
||||||
|
|
||||||
def __init__(
|
def __init__(self, hass: HomeAssistant, device: Device) -> None:
|
||||||
self,
|
|
||||||
hass: HomeAssistant,
|
|
||||||
device: Device,
|
|
||||||
) -> None:
|
|
||||||
"""Initialize a switch or light device."""
|
|
||||||
self._hass = hass
|
self._hass = hass
|
||||||
self._device = device
|
self._device = device
|
||||||
uname = DOMAIN + device.name
|
self._unique_id = DOMAIN + "_" + str(device.unique_channel_name) + "_switch"
|
||||||
self._unique_id = "_".join(x for x in uname)
|
|
||||||
self._state = None
|
self._state = None
|
||||||
|
self._available = True
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def unique_id(self):
|
def unique_id(self):
|
||||||
"""Return unique identifier of remote device."""
|
|
||||||
return self._unique_id
|
return self._unique_id
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def available(self):
|
def available(self):
|
||||||
return True
|
return self._available
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def should_poll(self):
|
def should_poll(self):
|
||||||
"""No polling needed."""
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def name(self):
|
def name(self):
|
||||||
"""Return the name of the device if any."""
|
|
||||||
return self._device.name
|
return self._device.name
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def extra_state_attributes(self):
|
def extra_state_attributes(self):
|
||||||
"""Return the device state attributes."""
|
|
||||||
return self._device.extra_state_attributes
|
return self._device.extra_state_attributes
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def assumed_state(self):
|
def assumed_state(self):
|
||||||
"""Return true if unable to access real state of entity."""
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
@property
|
||||||
|
def device_info(self) -> DeviceInfo:
|
||||||
|
return self._device.device_info
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def is_on(self):
|
def is_on(self):
|
||||||
if self._device.is_async and self._hass:
|
if self._device.is_async and self._hass:
|
||||||
component = self._hass.states.get(self.entity_id)
|
component = self._hass.states.get(self.entity_id)
|
||||||
if component is not None:
|
if component is not None:
|
||||||
if component.state == 'on':
|
self._state = component.state == 'on'
|
||||||
self._state = True
|
|
||||||
else:
|
|
||||||
self._state = False
|
|
||||||
return self._state
|
return self._state
|
||||||
|
|
||||||
def turn_on(self, **kwargs: Any) -> None:
|
async def _send(self, note: dict) -> bool:
|
||||||
"""Turn the device on."""
|
result = await self._device.async_transfer(note, self.entity_id)
|
||||||
note = {"method": 1, "type": 0, "value": "ON"}
|
available = result is not False
|
||||||
if self._device.transfer(note, self.entity_id) == True:
|
if self._available != available:
|
||||||
self._state = True
|
self._available = available
|
||||||
self.schedule_update_ha_state()
|
self.async_write_ha_state()
|
||||||
|
return result is not False
|
||||||
|
|
||||||
def turn_off(self, **kwargs: Any) -> None:
|
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||||
"""Turn the device off."""
|
if await self._send({"method": 1, "type": 0, "value": "ON"}):
|
||||||
note = {"method": 1, "type": 0, "value": "OFF"}
|
self._state = True
|
||||||
if self._device.transfer(note, self.entity_id) == 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._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 |
Reference in New Issue
Block a user