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

* feat: migrate to config flow with device registry and brand icons

- Migrate all platforms to async_setup_entry (config flow)
- Each device now appears as a real HA device in the device registry
- Add device_info to all entities (cover, switch, button, sensor, binary_sensor)
- Add config_flow.py with airsend.yaml + !secret resolution
- Add strings.json for UI labels
- Fix unique_id bug (was splitting each character of the name)
- Split binary_sensor.py from sensor.py
- Bump version to 4.0

* Add icon for local brand

* Revise installation steps for AirSend integration

* Correction du crash websocket

raise Exception remplacé par return False

* Update README with AirSend configuration steps

Added instructions for configuring AirSend in Home Assistant.

* Update README with clearer instructions for YAML

Clarified instructions for editing the airsend.yaml file.

* Add files via upload

* Add English translation for AirSend configuration

* Add fr translations

* Update screenshot following latest version

* Fixe unavailable and new type light

Corrections de bugs
500 avec wait: true non fatal

Nouveau type
light.py — lumière dimmable (type 4100)
This commit is contained in:
38decibel
2026-03-31 18:02:14 +02:00
committed by GitHub
parent 50c9b382cc
commit 7009d6ee89
17 changed files with 905 additions and 483 deletions
+115 -111
View File
@@ -2,11 +2,21 @@
import logging
import json
import hashlib
from requests import get, post, exceptions
import aiohttp
from . import DOMAIN
_LOGGER = logging.getLogger(DOMAIN)
RTYPE_LABELS = {
0: "AirSend",
1: "AirSend Sensor",
4096: "AirSend Button",
4097: "AirSend Switch",
4098: "AirSend Cover",
4099: "AirSend Cover (position)",
4100: "AirSend Light",
}
class Device:
"""Representation of a Device."""
@@ -76,7 +86,7 @@ class Device:
@property
def unique_channel_name(self) -> str:
if self._uid:
return self._uid
return str(self._uid)
if self._channel:
result = str(self._channel['id'])
if result:
@@ -88,120 +98,113 @@ class Device:
return result
return self._name
@property
def device_info(self) -> dict:
"""Return device info for Home Assistant device registry."""
return {
"identifiers": {(DOMAIN, self.unique_channel_name)},
"name": self._name,
"manufacturer": "AirSend",
"model": RTYPE_LABELS.get(self._rtype, "AirSend"),
}
@property
def extra_state_attributes(self):
if self._channel:
self._attrs = {
"channel": self._channel
}
return self._attrs
return {"channel": self._channel}
return None
@property
def is_async(self) -> bool:
"""Return if asynchronous state."""
if self._wait == False:
return True
return False
return self._wait == False
@property
def is_airsend(self) -> bool:
"""Return if is an AirSend."""
if self._rtype == 0:
return True
return False
return self._rtype == 0
@property
def is_sensor(self) -> bool:
"""Return if is a sensor to listen."""
if self._rtype == 1:
return True
return False
return self._rtype == 1
@property
def is_button(self) -> bool:
"""Return if is a button."""
if self._rtype == 4096:
return True
return False
return self._rtype == 4096
@property
def is_cover(self) -> bool:
"""Return if is a cover."""
if self._rtype in (4098, 4099):
return True
return False
return self._rtype in (4098, 4099)
@property
def is_cover_with_position(self) -> bool:
"""Return if is a cover with position."""
if self._rtype == 4099:
return True
return False
return self._rtype == 4099
@property
def is_switch(self) -> bool:
"""Return if is a switch."""
if self._rtype == 4097:
return True
return False
return self._rtype == 4097
@property
def is_light(self) -> bool:
return self._rtype == 4100
@property
def refresh_value(self) -> int:
"""Return refresh value in seconds."""
if type(self._refresh) is int and self._refresh > 0:
if isinstance(self._refresh, int) and self._refresh > 0:
return self._refresh
return (5 * 60)
return 5 * 60
def bind(self) -> bool:
"""Bind a channel to listen."""
ret = False
if self._serviceurl and self._spurl and type(self._bind) is int and self._bind > 0:
payload = ('{"channel":{"id": '+str(self._bind)+'},\"duration\":0,\"callback\":\"http://127.0.0.1/\"}')
headers = {
"Authorization": "Bearer " + self._spurl,
"content-type": "application/json",
"User-Agent": "hass_airsend",
}
try:
response = post(
async def async_bind(self) -> bool:
"""Bind a channel to listen (async)."""
if not (self._serviceurl and self._spurl and isinstance(self._bind, int) and self._bind > 0):
return False
payload = json.dumps({
"channel": {"id": self._bind},
"duration": 0,
"callback": "http://127.0.0.1/"
})
headers = {
"Authorization": "Bearer " + self._spurl,
"content-type": "application/json",
"User-Agent": "hass_airsend",
}
try:
async with aiohttp.ClientSession() as session:
async with session.post(
self._serviceurl + "airsend/bind",
headers=headers,
data=payload,
timeout=6,
)
if response.status_code == 200:
ret = True
except exceptions.RequestException:
pass
return ret
timeout=aiohttp.ClientTimeout(total=6),
) as response:
return response.status == 200
except aiohttp.ClientError as err:
_LOGGER.debug("Bind error '%s': %s", self._name, err)
return False
def transfer(self, note, entity_id = None) -> bool:
"""Send a command."""
async def async_transfer(self, note, entity_id=None) -> bool:
"""Send a command (async)."""
status_code = 404
ret = False
wait = 'false, "callback":"http://127.0.0.1/"'
if self._wait == True:
if self._wait:
wait = 'true'
if self._serviceurl and self._spurl and entity_id is not None:
uid = hashlib.sha256(entity_id.encode('utf-8')).hexdigest()[:12]
jnote = json.dumps(note)
if (
self._note is not None
and "method" in self._note
and "type" in self._note
and "value" in self._note
and "method" in note.keys()
and "type" in note.keys()
and "value" in note.keys()
and all(k in self._note for k in ("method", "type", "value"))
and all(k in note for k in ("method", "type", "value"))
and note["method"] == 1 and note["type"] == 0
and (note["value"] == "TOGGLE" or note["value"] == 6)
and note["value"] in ("TOGGLE", 6)
):
jnote = json.dumps(self._note)
payload = (
'{"wait": '+wait+', "channel":'
'{"wait": ' + wait + ', "channel":'
+ json.dumps(self._channel)
+ ', "thingnotes":{"uid":"0x'+uid+'", "notes":['
+ ', "thingnotes":{"uid":"0x' + uid + '", "notes":['
+ jnote
+ "]}}"
)
@@ -211,53 +214,44 @@ class Device:
"User-Agent": "hass_airsend",
}
try:
response = post(
self._serviceurl + "airsend/transfer",
headers=headers,
data=payload,
timeout=6,
)
if self._wait == True:
ret = True
status_code = 500
jdata = json.loads(response.text)
if jdata["type"] < 0x100:
status_code = response.status_code
else:
ret = None
status_code = response.status_code
except exceptions.RequestException:
pass
async with aiohttp.ClientSession() as session:
async with session.post(
self._serviceurl + "airsend/transfer",
headers=headers,
data=payload,
timeout=aiohttp.ClientTimeout(total=6),
) as response:
if self._wait:
ret = True
status_code = 500
try:
jdata = await response.json(content_type=None)
if jdata.get("type", 0x100) < 0x100:
status_code = response.status
except Exception:
pass
else:
ret = None
status_code = response.status
except aiohttp.ClientError as err:
_LOGGER.debug("Transfer local error '%s': %s", self._name, err)
# Fallback to cloud API if local failed
if status_code != 200 and self._apikey:
action = "command"
value = 6
if (
"method" in note.keys()
and "type" in note.keys()
and "value" in note.keys()
):
if all(k in note for k in ("method", "type", "value")):
if note["method"] == 1 and note["type"] == 0:
if note["value"] == "OFF":
value = 0
if note["value"] == "ON":
value = 1
if note["value"] == "STOP":
value = 3
if note["value"] == "DOWN":
value = 4
if note["value"] == "UP":
value = 5
if note["method"] == 1 and note["type"] == 9:
value = {
"OFF": 0, "ON": 1, "STOP": 3, "DOWN": 4, "UP": 5
}.get(note["value"], 6)
elif note["method"] == 1 and note["type"] == 9:
action = "level"
value = int(note["value"])
cloud_url = (
"https://airsend.cloud/device/"
+ str(self._uid)
+ "/"
+ action
+ "/"
+ str(value)
+ "/"
+ str(self._uid) + "/" + action + "/" + str(value) + "/"
)
headers = {
"Authorization": "Bearer " + self._apikey,
@@ -265,12 +259,22 @@ class Device:
"User-Agent": "hass_airsend",
}
try:
response = get(cloud_url, headers=headers, timeout=10)
status_code = response.status_code
ret = True
except exceptions.RequestException:
pass
async with aiohttp.ClientSession() as session:
async with session.get(
cloud_url,
headers=headers,
timeout=aiohttp.ClientTimeout(total=10),
) as response:
status_code = response.status
ret = True
except aiohttp.ClientError as err:
_LOGGER.debug("Transfer cloud error '%s': %s", self._name, err)
if status_code == 200:
return ret
# 500 with wait=True means RF confirmation not received but command was sent
if status_code == 500 and self._wait:
_LOGGER.warning("Transfer '%s' : no RF confirmation (500), command may have been sent", self.name)
return True
_LOGGER.error("Transfer error '%s' : '%s'", self.name, status_code)
raise Exception("Transfer error " + self.name + " : " + str(status_code))
return False