diff --git a/custom_components/atomberg_local/api/device.py b/custom_components/atomberg_local/api/device.py index 1c70930..0ca6cb9 100644 --- a/custom_components/atomberg_local/api/device.py +++ b/custom_components/atomberg_local/api/device.py @@ -111,6 +111,14 @@ def _apply_optimistic(self, command: dict) -> None: for k, v in command.items(): if hasattr(s, k): setattr(s, k, v) + if "speed" in command and command["speed"] > 0: + s.power = True + if "power" in command and not command["power"]: + s.power = False + if "brightness" in command and command["brightness"] > 0: + s.led = True + if "light_mode" in command: + s.led = True # ---- control with fallback ---- async def async_send(self, command: dict, verify: bool = True) -> str: @@ -121,41 +129,70 @@ async def async_send(self, command: dict, verify: bool = True) -> str: if transport == "wifi" and self.wifi_available: try: self._state_event.clear() - await asyncio.get_running_loop().run_in_executor( + _LOGGER.info( + "[%s] Sending Wi-Fi command to %s:5600: %s", + self.device_id, self.wifi_ip, command + ) + direct_state = await asyncio.get_running_loop().run_in_executor( None, udp_mod.send_command, self.wifi_ip, command ) self._apply_optimistic(command) + if direct_state is not None: + _LOGGER.info( + "[%s] Wi-Fi command confirmed via direct UDP response (speed=%s, power=%s)", + self.device_id, direct_state.speed, direct_state.power + ) + self.update_wifi(self.wifi_ip, direct_state.series or self.series, direct_state) + return "wifi" + if not verify: return "wifi" - # The fan broadcasts fresh state after accepting a command. + try: await asyncio.wait_for(self._state_event.wait(), timeout=1.5) + _LOGGER.info( + "[%s] Wi-Fi command confirmed via :5625 broadcast (speed=%s, power=%s)", + self.device_id, + self.state.speed if self.state else None, + self.state.power if self.state else None, + ) return "wifi" except asyncio.TimeoutError: - _LOGGER.debug("%s: Wi-Fi command unconfirmed, falling back", self.device_id) + _LOGGER.warning( + "[%s] Wi-Fi command unconfirmed after 1.5s, falling back to BLE", self.device_id + ) except OSError as err: last_err = err + _LOGGER.warning("[%s] Wi-Fi command failed with socket error: %s", self.device_id, err) elif transport == "ble" and self.ble_available: try: + _LOGGER.info("[%s] Sending BLE command: %s", self.device_id, command) async with ble_mod.BleTransport(self.ble_device) as bt: await bt.send_command(command) state = await bt.read_state() if state is not None: self.state = state + _LOGGER.info( + "[%s] BLE command confirmed (speed=%s, power=%s)", + self.device_id, state.speed, state.power + ) else: self._apply_optimistic(command) + _LOGGER.info("[%s] BLE command sent (optimistic state applied)", self.device_id) return "ble" except Exception as err: # noqa: BLE001 last_err = err - _LOGGER.debug("%s: BLE command failed: %s", self.device_id, err) + _LOGGER.warning("[%s] BLE command failed: %s", self.device_id, err) raise NoTransportAvailable(str(last_err) if last_err else "no transport") async def async_refresh(self) -> None: """Ask the fan for its current state (Wi-Fi read command or BLE read).""" if self.wifi_available and not self.prefer_ble: - await asyncio.get_running_loop().run_in_executor( + direct_state = await asyncio.get_running_loop().run_in_executor( None, udp_mod.send_command, self.wifi_ip, READ_COMMAND ) + if direct_state is not None: + self.update_wifi(self.wifi_ip, direct_state.series or self.series, direct_state) elif self.ble_available: try: async with ble_mod.BleTransport(self.ble_device) as bt: diff --git a/custom_components/atomberg_local/api/udp.py b/custom_components/atomberg_local/api/udp.py index 42d380b..4679d3c 100644 --- a/custom_components/atomberg_local/api/udp.py +++ b/custom_components/atomberg_local/api/udp.py @@ -19,33 +19,75 @@ def parse_datagram(data: bytes, src_ip: str) -> tuple[str, str | None, FanState | None] | None: """Return (device_id, series, state) from a :5625 datagram, or None.""" - msg = data.decode(errors="ignore") + msg = data.decode(errors="ignore").strip() if msg.startswith("PROXY "): parts = msg.split() if len(parts) >= 6 and parts[1] == "TCP4": - msg = " ".join(parts[6:]) - # Full-state broadcast: hex-encoded JSON with a state_string. - try: - payload = json.loads(bytes.fromhex(msg)) - except ValueError: - payload = None + msg = " ".join(parts[6:]).strip() + + payload = None + if msg.startswith("{") and msg.endswith("}"): + try: + payload = json.loads(msg) + except (ValueError, TypeError): + payload = None + + if payload is None: + try: + payload = json.loads(bytes.fromhex(msg)) + except (ValueError, TypeError): + payload = None + if isinstance(payload, dict) and "device_id" in payload: state = decode_state(payload["state_string"]) if "state_string" in payload else None - series = state.series if state else None - return payload["device_id"], series, state - # Presence beacon: "_". - if "_" in msg: - did, _, series = msg.partition("_") - if did: - return did, (series.split("_")[0] or None), None + series = state.series if state else payload.get("series") + return str(payload["device_id"]).lower(), series, state + + # Presence beacon: "_" + if "_" in msg and not msg.startswith("{"): + did, _, series_part = msg.partition("_") + did = did.strip().lower() + if did and all(c in "0123456789abcdef" for c in did) and 8 <= len(did) <= 16: + series = series_part.split("_")[0].strip() if series_part else None + return did, series or None, None + return None -def send_command(ip: str, command: dict) -> None: - """Fire a JSON command datagram to the fan (:5600). Non-blocking.""" +def send_command(ip: str, command: dict, timeout: float = 0.4) -> FanState | None: + """Send JSON command datagram to :5600 and return direct state reply if present.""" data = json.dumps(command).encode() with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock: - sock.sendto(data, (ip, CMD_PORT)) + sock.settimeout(timeout) + try: + sock.sendto(data, (ip, CMD_PORT)) + except OSError as err: + _LOGGER.debug("UDP send error to %s:%d: %s", ip, CMD_PORT, err) + raise + + try: + resp_data, _ = sock.recvfrom(2048) + resp_msg = resp_data.decode(errors="ignore").strip() + state = None + if "," in resp_msg: + state = decode_state(resp_msg) + if state is None and resp_msg.startswith("{"): + try: + payload = json.loads(resp_msg) + if isinstance(payload, dict) and "state_string" in payload: + state = decode_state(payload["state_string"]) + except Exception: + pass + if state is None: + try: + payload = json.loads(bytes.fromhex(resp_msg)) + if isinstance(payload, dict) and "state_string" in payload: + state = decode_state(payload["state_string"]) + except Exception: + pass + return state + except (socket.timeout, OSError): + return None class UdpListener(asyncio.DatagramProtocol): diff --git a/custom_components/atomberg_local/fan.py b/custom_components/atomberg_local/fan.py index 803086f..064233f 100644 --- a/custom_components/atomberg_local/fan.py +++ b/custom_components/atomberg_local/fan.py @@ -65,7 +65,7 @@ async def async_set_percentage(self, percentage: int) -> None: else: speed = round(percentage_to_ranged_value(self._speed_range, percentage)) speed = max(MIN_SPEED, min(MAX_SPEED, speed)) - await self.device.async_send(build_command(power=True, speed=speed)) + await self.device.async_send(build_command(speed=speed)) self.async_write_ha_state() async def async_turn_on(