Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 42 additions & 5 deletions custom_components/atomberg_local/api/device.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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:
Expand Down
76 changes: 59 additions & 17 deletions custom_components/atomberg_local/api/udp.py
Original file line number Diff line number Diff line change
Expand Up @@ -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: "<device_id>_<series>".
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: "<device_id>_<series>"
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):
Expand Down
2 changes: 1 addition & 1 deletion custom_components/atomberg_local/fan.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down