diff --git a/custom_components/airsend/cover.py b/custom_components/airsend/cover.py index d7384a8..d605364 100644 --- a/custom_components/airsend/cover.py +++ b/custom_components/airsend/cover.py @@ -1,7 +1,7 @@ """AirSend covers.""" from typing import Any -from .device import Device +from .device import Device, TransferResult from homeassistant.components.cover import CoverEntity from homeassistant.config_entries import ConfigEntry @@ -78,14 +78,32 @@ class AirSendCover(RestoreEntity, CoverEntity): self._closed = component.state not in ('open', 'on', 'up') 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: """Send a command and update availability accordingly.""" result = await self._device.async_transfer(note, self.entity_id) - available = result is not False + available = result != TransferResult.NETWORK_ERROR if self._available != available: self._available = available 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: note = {"method": 1, "type": 0, "value": "UP"} @@ -118,38 +136,3 @@ class AirSendCover(RestoreEntity, CoverEntity): self._attr_current_cover_position = position self._closed = position == 0 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() diff --git a/custom_components/airsend/device.py b/custom_components/airsend/device.py index 568ab52..d90e7b2 100644 --- a/custom_components/airsend/device.py +++ b/custom_components/airsend/device.py @@ -13,12 +13,22 @@ RTYPE_LABELS = { 4096: "AirSend Button", 4097: "AirSend Switch", 4098: "AirSend Cover", - 4099: "AirSend Slider", - 4100: "AirSend Tilt", - 4101: "AirSend Light", + 4099: "AirSend Cover (position)", + 4100: "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: """Representation of a Device.""" @@ -91,7 +101,7 @@ class Device: if self._channel: result = str(self._channel['id']) if result: - uniquefield = ['source', 'mac', 'seed', 'token'] + uniquefield = ['source', 'mac', 'seed'] for field in uniquefield: if field in self._channel: result += "_" @@ -146,7 +156,7 @@ class Device: @property def is_light(self) -> bool: - return self._rtype == 4101 + return self._rtype == 4100 @property def refresh_value(self) -> int: @@ -182,7 +192,7 @@ class Device: _LOGGER.debug("Bind error '%s': %s", self._name, err) 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).""" status_code = 404 ret = False @@ -236,6 +246,7 @@ class Device: status_code = response.status except aiohttp.ClientError as err: _LOGGER.debug("Transfer local error '%s': %s", self._name, err) + return TransferResult.NETWORK_ERROR # Fallback to cloud API if local failed if status_code != 200 and self._apikey: @@ -269,13 +280,22 @@ class Device: status_code = response.status ret = True 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: - 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) - return False + return TransferResult.SUCCESS + if status_code == 401: + _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) + return TransferResult.SENT + _LOGGER.warning("Transfer '%s' : server error (500), command may have been sent", self.name) + return TransferResult.SERVER_ERROR + _LOGGER.warning("Transfer '%s' : unexpected status %s", self.name, status_code) + return TransferResult.SERVER_ERROR diff --git a/custom_components/airsend/light.py b/custom_components/airsend/light.py index b928e93..9dc3383 100644 --- a/custom_components/airsend/light.py +++ b/custom_components/airsend/light.py @@ -1,13 +1,14 @@ """AirSend lights (dimmable).""" from typing import Any -from .device import Device +from .device import Device, TransferResult 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.helpers.restore_state import RestoreEntity from homeassistant.const import CONF_INTERNAL_URL from . import DOMAIN @@ -29,7 +30,7 @@ async def async_setup_entry( async_add_entities(entities) -class AirSendLight(LightEntity): +class AirSendLight(RestoreEntity, LightEntity): """Representation of an AirSend dimmable light.""" _attr_color_mode = ColorMode.BRIGHTNESS @@ -41,7 +42,7 @@ class AirSendLight(LightEntity): self._unique_id = DOMAIN + "_" + str(device.unique_channel_name) + "_light" self._available = True self._is_on = False - self._brightness = 255 # HA brightness 0-255 + self._brightness = 255 @property def unique_id(self): @@ -73,30 +74,37 @@ class AirSendLight(LightEntity): @property def brightness(self) -> int: - """Return brightness in HA scale (0-255).""" 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: """Send command and update availability.""" result = await self._device.async_transfer(note, self.entity_id) - available = result is not False + available = result != TransferResult.NETWORK_ERROR if self._available != available: self._available = available self.async_write_ha_state() - return available + return result in (TransferResult.SUCCESS, TransferResult.SENT) 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._is_on = True 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): diff --git a/custom_components/airsend/switch.py b/custom_components/airsend/switch.py index d50e9c7..da84510 100644 --- a/custom_components/airsend/switch.py +++ b/custom_components/airsend/switch.py @@ -1,7 +1,7 @@ """AirSend switches.""" from typing import Any -from .device import Device +from .device import Device, TransferResult from homeassistant.components.switch import SwitchEntity from homeassistant.config_entries import ConfigEntry @@ -75,13 +75,21 @@ class AirSendSwitch(RestoreEntity, SwitchEntity): self._state = component.state == 'on' 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: result = await self._device.async_transfer(note, self.entity_id) - available = result is not False + available = result != TransferResult.NETWORK_ERROR if self._available != available: self._available = available 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: 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"}): self._state = False 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() \ No newline at end of file