Improve error handling, availability logic and state restore

- Add TransferResult enum (SUCCESS, SENT, SERVER_ERROR, NETWORK_ERROR)
  to distinguish network errors from server errors
- Only mark entities as unavailable on NETWORK_ERROR (addon unreachable)
- HTTP 401, 405, 500 no longer trigger unavailable state, logged instead
- Add RestoreEntity to cover, switch and light to restore last known
  state after HA restart
This commit is contained in:
Daniel Moindrot
2026-06-12 08:09:24 +02:00
committed by GitHub
parent 6949219c2b
commit 9047b75ce6
4 changed files with 83 additions and 73 deletions
+21 -38
View File
@@ -1,7 +1,7 @@
"""AirSend covers.""" """AirSend covers."""
from typing import Any from typing import Any
from .device import Device from .device import Device, TransferResult
from homeassistant.components.cover import CoverEntity from homeassistant.components.cover import CoverEntity
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
@@ -78,14 +78,32 @@ class AirSendCover(RestoreEntity, CoverEntity):
self._closed = component.state not in ('open', 'on', 'up') self._closed = component.state not in ('open', 'on', 'up')
return self._closed return self._closed
async def async_added_to_hass(self):
"""Restore last known state when added to hass."""
await super().async_added_to_hass()
last_state = await self.async_get_last_state()
if last_state and last_state.state not in ('unavailable', 'unknown'):
if self._device.is_cover_with_position:
if last_state.attributes.get('current_position') is not None:
self._attr_current_cover_position = last_state.attributes['current_position']
if last_state.state == 'closed':
self._attr_current_cover_position = 0
elif last_state.state == 'open':
self._attr_current_cover_position = 100
if last_state.state == 'closed':
self._closed = True
elif last_state.state == 'open':
self._closed = False
self.async_write_ha_state()
async def _send(self, note: dict) -> bool: async def _send(self, note: dict) -> bool:
"""Send a command and update availability accordingly.""" """Send a command and update availability accordingly."""
result = await self._device.async_transfer(note, self.entity_id) result = await self._device.async_transfer(note, self.entity_id)
available = result is not False available = result != TransferResult.NETWORK_ERROR
if self._available != available: if self._available != available:
self._available = available self._available = available
self.async_write_ha_state() self.async_write_ha_state()
return result is not False return result in (TransferResult.SUCCESS, TransferResult.SENT)
async def async_open_cover(self, **kwargs: Any) -> None: async def async_open_cover(self, **kwargs: Any) -> None:
note = {"method": 1, "type": 0, "value": "UP"} note = {"method": 1, "type": 0, "value": "UP"}
@@ -118,38 +136,3 @@ class AirSendCover(RestoreEntity, CoverEntity):
self._attr_current_cover_position = position self._attr_current_cover_position = position
self._closed = position == 0 self._closed = position == 0
self.async_write_ha_state() self.async_write_ha_state()
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()
# Only restore from a meaningful previous state. If HASS was
# restarted while the entity was "unavailable" (e.g. AirSend
# connectivity loss), don't carry that over: keep the defaults
# so the entity comes back available instead of staying stuck.
if last_state and last_state.state not in ('unavailable', 'unknown'):
# 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 usable last_state, keep the defaults (50% for position covers)
# Always publish the current state so the entity doesn't stay
# stuck on a stale cached state across a restart.
self.async_write_ha_state()
+33 -13
View File
@@ -13,12 +13,22 @@ RTYPE_LABELS = {
4096: "AirSend Button", 4096: "AirSend Button",
4097: "AirSend Switch", 4097: "AirSend Switch",
4098: "AirSend Cover", 4098: "AirSend Cover",
4099: "AirSend Slider", 4099: "AirSend Cover (position)",
4100: "AirSend Tilt", 4100: "AirSend Light",
4101: "AirSend Light",
} }
from enum import Enum
class TransferResult(Enum):
"""Result of an async_transfer call."""
SUCCESS = "success" # Command sent and confirmed
SENT = "sent" # Command sent, no confirmation (wait=True + 500)
SERVER_ERROR = "server_error" # Server responded with error (command may have been sent)
NETWORK_ERROR = "network_error" # Addon unreachable (timeout, connection refused)
class Device: class Device:
"""Representation of a Device.""" """Representation of a Device."""
@@ -91,7 +101,7 @@ class Device:
if self._channel: if self._channel:
result = str(self._channel['id']) result = str(self._channel['id'])
if result: if result:
uniquefield = ['source', 'mac', 'seed', 'token'] uniquefield = ['source', 'mac', 'seed']
for field in uniquefield: for field in uniquefield:
if field in self._channel: if field in self._channel:
result += "_" result += "_"
@@ -146,7 +156,7 @@ class Device:
@property @property
def is_light(self) -> bool: def is_light(self) -> bool:
return self._rtype == 4101 return self._rtype == 4100
@property @property
def refresh_value(self) -> int: def refresh_value(self) -> int:
@@ -182,7 +192,7 @@ class Device:
_LOGGER.debug("Bind error '%s': %s", self._name, err) _LOGGER.debug("Bind error '%s': %s", self._name, err)
return False return False
async def async_transfer(self, note, entity_id=None) -> bool: async def async_transfer(self, note, entity_id=None) -> "TransferResult":
"""Send a command (async).""" """Send a command (async)."""
status_code = 404 status_code = 404
ret = False ret = False
@@ -236,6 +246,7 @@ class Device:
status_code = response.status status_code = response.status
except aiohttp.ClientError as err: except aiohttp.ClientError as err:
_LOGGER.debug("Transfer local error '%s': %s", self._name, err) _LOGGER.debug("Transfer local error '%s': %s", self._name, err)
return TransferResult.NETWORK_ERROR
# Fallback to cloud API if local failed # Fallback to cloud API if local failed
if status_code != 200 and self._apikey: if status_code != 200 and self._apikey:
@@ -269,13 +280,22 @@ class Device:
status_code = response.status status_code = response.status
ret = True ret = True
except aiohttp.ClientError as err: except aiohttp.ClientError as err:
_LOGGER.debug("Transfer cloud error '%s': %s", self._name, err) _LOGGER.warning("Transfer cloud error '%s': %s — both local and cloud failed", self._name, err)
return TransferResult.NETWORK_ERROR
if status_code == 200: if status_code == 200:
return ret return TransferResult.SUCCESS
# 500 with wait=True means RF confirmation not received but command was sent if status_code == 401:
if status_code == 500 and self._wait: _LOGGER.error("Transfer '%s' : invalid locator (401) — check spurl in airsend.yaml", self.name)
return TransferResult.SERVER_ERROR
if status_code == 405:
_LOGGER.error("Transfer '%s' : invalid input (405) — check channel configuration", self.name)
return TransferResult.SERVER_ERROR
if status_code == 500:
if self._wait:
_LOGGER.warning("Transfer '%s' : no RF confirmation (500), command may have been sent", self.name) _LOGGER.warning("Transfer '%s' : no RF confirmation (500), command may have been sent", self.name)
return True return TransferResult.SENT
_LOGGER.error("Transfer error '%s' : '%s'", self.name, status_code) _LOGGER.warning("Transfer '%s' : server error (500), command may have been sent", self.name)
return False return TransferResult.SERVER_ERROR
_LOGGER.warning("Transfer '%s' : unexpected status %s", self.name, status_code)
return TransferResult.SERVER_ERROR
+17 -9
View File
@@ -1,13 +1,14 @@
"""AirSend lights (dimmable).""" """AirSend lights (dimmable)."""
from typing import Any from typing import Any
from .device import Device from .device import Device, TransferResult
from homeassistant.components.light import LightEntity, ATTR_BRIGHTNESS, ColorMode from homeassistant.components.light import LightEntity, ATTR_BRIGHTNESS, ColorMode
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.restore_state import RestoreEntity
from homeassistant.const import CONF_INTERNAL_URL from homeassistant.const import CONF_INTERNAL_URL
from . import DOMAIN from . import DOMAIN
@@ -29,7 +30,7 @@ async def async_setup_entry(
async_add_entities(entities) async_add_entities(entities)
class AirSendLight(LightEntity): class AirSendLight(RestoreEntity, LightEntity):
"""Representation of an AirSend dimmable light.""" """Representation of an AirSend dimmable light."""
_attr_color_mode = ColorMode.BRIGHTNESS _attr_color_mode = ColorMode.BRIGHTNESS
@@ -41,7 +42,7 @@ class AirSendLight(LightEntity):
self._unique_id = DOMAIN + "_" + str(device.unique_channel_name) + "_light" self._unique_id = DOMAIN + "_" + str(device.unique_channel_name) + "_light"
self._available = True self._available = True
self._is_on = False self._is_on = False
self._brightness = 255 # HA brightness 0-255 self._brightness = 255
@property @property
def unique_id(self): def unique_id(self):
@@ -73,30 +74,37 @@ class AirSendLight(LightEntity):
@property @property
def brightness(self) -> int: def brightness(self) -> int:
"""Return brightness in HA scale (0-255)."""
return self._brightness return self._brightness
async def async_added_to_hass(self):
"""Restore last known state when added to hass."""
await super().async_added_to_hass()
last_state = await self.async_get_last_state()
if last_state and last_state.state not in ('unavailable', 'unknown'):
self._is_on = last_state.state == 'on'
if last_state.attributes.get('brightness') is not None:
self._brightness = last_state.attributes['brightness']
self.async_write_ha_state()
async def _send(self, note: dict) -> bool: async def _send(self, note: dict) -> bool:
"""Send command and update availability.""" """Send command and update availability."""
result = await self._device.async_transfer(note, self.entity_id) result = await self._device.async_transfer(note, self.entity_id)
available = result is not False available = result != TransferResult.NETWORK_ERROR
if self._available != available: if self._available != available:
self._available = available self._available = available
self.async_write_ha_state() self.async_write_ha_state()
return available return result in (TransferResult.SUCCESS, TransferResult.SENT)
async def async_turn_on(self, **kwargs: Any) -> None: async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn on or dim the light.""" """Turn on or dim the light."""
if ATTR_BRIGHTNESS in kwargs: if ATTR_BRIGHTNESS in kwargs:
# Convert HA brightness (0-255) to AirSend level (0-100)
level = max(1, round(kwargs[ATTR_BRIGHTNESS] / 255 * 100)) level = max(1, round(kwargs[ATTR_BRIGHTNESS] / 255 * 100))
note = {"method": 1, "type": 9, "value": level} note = {"method": 1, "type": 9, "value": level}
if await self._send(note): if await self._send(note):
self._brightness = kwargs[ATTR_BRIGHTNESS] self._brightness = kwargs[ATTR_BRIGHTNESS]
self._is_on = level > 0 self._is_on = True
self.async_write_ha_state() self.async_write_ha_state()
else: else:
# Turn on at last brightness
level = max(1, round(self._brightness / 255 * 100)) level = max(1, round(self._brightness / 255 * 100))
note = {"method": 1, "type": 9, "value": level} note = {"method": 1, "type": 9, "value": level}
if await self._send(note): if await self._send(note):
+11 -12
View File
@@ -1,7 +1,7 @@
"""AirSend switches.""" """AirSend switches."""
from typing import Any from typing import Any
from .device import Device from .device import Device, TransferResult
from homeassistant.components.switch import SwitchEntity from homeassistant.components.switch import SwitchEntity
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
@@ -75,13 +75,21 @@ class AirSendSwitch(RestoreEntity, SwitchEntity):
self._state = component.state == 'on' self._state = component.state == 'on'
return self._state return self._state
async def async_added_to_hass(self):
"""Restore last known state when added to hass."""
await super().async_added_to_hass()
last_state = await self.async_get_last_state()
if last_state and last_state.state not in ('unavailable', 'unknown'):
self._state = last_state.state == 'on'
self.async_write_ha_state()
async def _send(self, note: dict) -> bool: async def _send(self, note: dict) -> bool:
result = await self._device.async_transfer(note, self.entity_id) result = await self._device.async_transfer(note, self.entity_id)
available = result is not False available = result != TransferResult.NETWORK_ERROR
if self._available != available: if self._available != available:
self._available = available self._available = available
self.async_write_ha_state() self.async_write_ha_state()
return result is not False return result in (TransferResult.SUCCESS, TransferResult.SENT)
async def async_turn_on(self, **kwargs: Any) -> None: async def async_turn_on(self, **kwargs: Any) -> None:
if await self._send({"method": 1, "type": 0, "value": "ON"}): if await self._send({"method": 1, "type": 0, "value": "ON"}):
@@ -92,12 +100,3 @@ class AirSendSwitch(RestoreEntity, SwitchEntity):
if await self._send({"method": 1, "type": 0, "value": "OFF"}): if await self._send({"method": 1, "type": 0, "value": "OFF"}):
self._state = False self._state = False
self.async_write_ha_state() self.async_write_ha_state()
async def async_added_to_hass(self) -> None:
"""Restore last known state when added to hass."""
await super().async_added_to_hass()
last_state = await self.async_get_last_state()
if last_state:
self._state = last_state.state == 'on'
self.async_write_ha_state()