1
0
mirror of https://github.com/natekspencer/hacs-oasis_mini.git synced 2025-12-06 18:44:14 -05:00

Switch to using mqtt

This commit is contained in:
Nathan Spencer
2025-11-22 04:40:58 +00:00
parent 171a608314
commit 886d7598f3
34 changed files with 2036 additions and 1057 deletions

View File

@@ -0,0 +1,7 @@
"""Oasis control clients."""
from .cloud_client import OasisCloudClient
from .http_client import OasisHttpClient
from .mqtt_client import OasisMqttClient
__all__ = ["OasisCloudClient", "OasisHttpClient", "OasisMqttClient"]

View File

@@ -0,0 +1,191 @@
"""Oasis cloud client."""
from __future__ import annotations
from datetime import timedelta
import logging
from typing import Any
from urllib.parse import urljoin
from aiohttp import ClientResponseError, ClientSession
from ..exceptions import UnauthenticatedError
from ..utils import now
_LOGGER = logging.getLogger(__name__)
BASE_URL = "https://app.grounded.so"
PLAYLISTS_REFRESH_LIMITER = timedelta(minutes=5)
class OasisCloudClient:
"""Cloud client for Oasis.
Responsibilities:
- Manage aiohttp session (optionally owned)
- Manage access token
- Provide async_* helpers for:
* login/logout
* user info
* devices
* tracks/playlists
* latest software metadata
"""
_session: ClientSession | None
_owns_session: bool
_access_token: str | None
# these are "cache" fields for tracks/playlists
_playlists_next_refresh: float
playlists: list[dict[str, Any]]
_playlist_details: dict[int, dict[str, str]]
def __init__(
self,
*,
session: ClientSession | None = None,
access_token: str | None = None,
) -> None:
self._session = session
self._owns_session = session is None
self._access_token = access_token
# simple in-memory caches
self._playlists_next_refresh = 0.0
self.playlists = []
self._playlist_details = {}
@property
def session(self) -> ClientSession:
"""Return (or lazily create) the aiohttp ClientSession."""
if self._session is None or self._session.closed:
self._session = ClientSession()
self._owns_session = True
return self._session
async def async_close(self) -> None:
"""Close owned session (call from HA unload / cleanup)."""
if self._session and not self._session.closed and self._owns_session:
await self._session.close()
@property
def access_token(self) -> str | None:
return self._access_token
@access_token.setter
def access_token(self, value: str | None) -> None:
self._access_token = value
async def async_login(self, email: str, password: str) -> None:
"""Login via the cloud and store the access token."""
response = await self._async_request(
"POST",
urljoin(BASE_URL, "api/auth/login"),
json={"email": email, "password": password},
)
token = response.get("access_token") if isinstance(response, dict) else None
self.access_token = token
_LOGGER.debug("Cloud login succeeded, token set: %s", bool(token))
async def async_logout(self) -> None:
"""Logout from the cloud."""
await self._async_auth_request("GET", "api/auth/logout")
self.access_token = None
async def async_get_user(self) -> dict:
"""Get current user info."""
return await self._async_auth_request("GET", "api/auth/user")
async def async_get_devices(self) -> list[dict[str, Any]]:
"""Get user devices (raw JSON from API)."""
return await self._async_auth_request("GET", "api/user/devices")
async def async_get_playlists(
self, personal_only: bool = False
) -> list[dict[str, Any]]:
"""Get playlists from the cloud (cached by PLAYLISTS_REFRESH_LIMITER)."""
if self._playlists_next_refresh <= now():
params = {"my_playlists": str(personal_only).lower()}
playlists = await self._async_auth_request(
"GET", "api/playlist", params=params
)
if playlists:
self.playlists = playlists
self._playlists_next_refresh = now() + PLAYLISTS_REFRESH_LIMITER
return self.playlists
async def async_get_track_info(self, track_id: int) -> dict[str, Any] | None:
"""Get single track info from the cloud."""
try:
return await self._async_auth_request("GET", f"api/track/{track_id}")
except ClientResponseError as err:
if err.status == 404:
return {"id": track_id, "name": f"Unknown Title (#{track_id})"}
except Exception as ex: # noqa: BLE001
_LOGGER.exception("Error fetching track %s: %s", track_id, ex)
return None
async def async_get_tracks(
self, tracks: list[int] | None = None
) -> list[dict[str, Any]]:
"""Get multiple tracks info from the cloud (handles pagination)."""
response = await self._async_auth_request(
"GET",
"api/track",
params={"ids[]": tracks or []},
)
if not response:
return []
track_details = response.get("data", [])
while next_page_url := response.get("next_page_url"):
response = await self._async_auth_request("GET", next_page_url)
track_details += response.get("data", [])
return track_details
async def async_get_latest_software_details(self) -> dict[str, int | str]:
"""Get latest software metadata from cloud."""
return await self._async_auth_request("GET", "api/software/last-version")
async def _async_auth_request(self, method: str, url: str, **kwargs: Any) -> Any:
"""Perform an authenticated cloud request."""
if not self.access_token:
raise UnauthenticatedError("Unauthenticated")
headers = kwargs.pop("headers", {}) or {}
headers["Authorization"] = f"Bearer {self.access_token}"
return await self._async_request(
method,
url if url.startswith("http") else urljoin(BASE_URL, url),
headers=headers,
**kwargs,
)
async def _async_request(self, method: str, url: str, **kwargs: Any) -> Any:
"""Low-level HTTP helper for both cloud and (if desired) device HTTP."""
session = self.session
_LOGGER.debug(
"%s %s",
method,
session._build_url(url).update_query( # pylint: disable=protected-access
kwargs.get("params"),
),
)
response = await session.request(method, url, **kwargs)
if response.status == 200:
if response.content_type == "application/json":
return await response.json()
if response.content_type == "text/plain":
return await response.text()
if response.content_type == "text/html" and BASE_URL in url:
text = await response.text()
if "login-page" in text:
raise UnauthenticatedError("Unauthenticated")
return None
if response.status == 401:
raise UnauthenticatedError("Unauthenticated")
response.raise_for_status()

View File

@@ -0,0 +1,215 @@
"""Oasis HTTP client (per-device)."""
from __future__ import annotations
import logging
from typing import Any
from aiohttp import ClientSession
from ..const import AUTOPLAY_MAP
from ..device import OasisDevice
from ..utils import _bit_to_bool, _parse_int
from .transport import OasisClientProtocol
_LOGGER = logging.getLogger(__name__)
class OasisHttpClient(OasisClientProtocol):
"""HTTP-based Oasis transport.
This client is typically used per-device (per host/IP).
It implements the OasisClientProtocol so OasisDevice can delegate
all commands through it.
"""
def __init__(self, host: str, session: ClientSession | None = None) -> None:
self._host = host
self._session: ClientSession | None = session
self._owns_session: bool = session is None
@property
def session(self) -> ClientSession:
if self._session is None or self._session.closed:
self._session = ClientSession()
self._owns_session = True
return self._session
async def async_close(self) -> None:
"""Close owned session."""
if self._session and not self._session.closed and self._owns_session:
await self._session.close()
@property
def url(self) -> str:
# These devices are plain HTTP, no TLS
return f"http://{self._host}/"
async def _async_request(self, method: str, url: str, **kwargs: Any) -> Any:
"""Low-level HTTP helper."""
session = self.session
_LOGGER.debug(
"%s %s",
method,
session._build_url(url).update_query( # pylint: disable=protected-access
kwargs.get("params"),
),
)
resp = await session.request(method, url, **kwargs)
if resp.status == 200:
if resp.content_type == "text/plain":
return await resp.text()
if resp.content_type == "application/json":
return await resp.json()
return None
resp.raise_for_status()
async def _async_get(self, **kwargs: Any) -> str | None:
return await self._async_request("GET", self.url, **kwargs)
async def _async_command(self, **kwargs: Any) -> str | None:
result = await self._async_get(**kwargs)
_LOGGER.debug("Result: %s", result)
return result
async def async_get_mac_address(self, device: OasisDevice) -> str | None:
"""Fetch MAC address via HTTP GETMAC."""
try:
mac = await self._async_get(params={"GETMAC": ""})
if isinstance(mac, str):
return mac.strip()
except Exception: # noqa: BLE001
_LOGGER.exception(
"Failed to get MAC address via HTTP for %s", device.serial_number
)
return None
async def async_send_ball_speed_command(
self,
device: OasisDevice,
speed: int,
) -> None:
await self._async_command(params={"WRIOASISSPEED": speed})
async def async_send_led_command(
self,
device: OasisDevice,
led_effect: str,
color: str,
led_speed: int,
brightness: int,
) -> None:
payload = f"{led_effect};0;{color};{led_speed};{brightness}"
await self._async_command(params={"WRILED": payload})
async def async_send_sleep_command(self, device: OasisDevice) -> None:
await self._async_command(params={"CMDSLEEP": ""})
async def async_send_move_job_command(
self,
device: OasisDevice,
from_index: int,
to_index: int,
) -> None:
await self._async_command(params={"MOVEJOB": f"{from_index};{to_index}"})
async def async_send_change_track_command(
self,
device: OasisDevice,
index: int,
) -> None:
await self._async_command(params={"CMDCHANGETRACK": index})
async def async_send_add_joblist_command(
self,
device: OasisDevice,
tracks: list[int],
) -> None:
# The old code passed the list directly; if the device expects CSV:
await self._async_command(params={"ADDJOBLIST": ",".join(map(str, tracks))})
async def async_send_set_playlist_command(
self,
device: OasisDevice,
playlist: list[int],
) -> None:
await self._async_command(params={"WRIJOBLIST": ",".join(map(str, playlist))})
# optional: optimistic state update
device.update_from_status_dict({"playlist": playlist})
async def async_send_set_repeat_playlist_command(
self,
device: OasisDevice,
repeat: bool,
) -> None:
await self._async_command(params={"WRIREPEATJOB": 1 if repeat else 0})
async def async_send_set_autoplay_command(
self,
device: OasisDevice,
option: str,
) -> None:
await self._async_command(params={"WRIWAITAFTER": option})
async def async_send_upgrade_command(
self,
device: OasisDevice,
beta: bool,
) -> None:
await self._async_command(params={"CMDUPGRADE": 1 if beta else 0})
async def async_send_play_command(self, device: OasisDevice) -> None:
await self._async_command(params={"CMDPLAY": ""})
async def async_send_pause_command(self, device: OasisDevice) -> None:
await self._async_command(params={"CMDPAUSE": ""})
async def async_send_stop_command(self, device: OasisDevice) -> None:
await self._async_command(params={"CMDSTOP": ""})
async def async_send_reboot_command(self, device: OasisDevice) -> None:
await self._async_command(params={"CMDBOOT": ""})
async def async_get_status(self, device: OasisDevice) -> None:
"""Fetch status via GETSTATUS and update the device."""
raw_status = await self._async_get(params={"GETSTATUS": ""})
if raw_status is None:
return
_LOGGER.debug("Status for %s: %s", device.serial_number, raw_status)
values = raw_status.split(";")
if len(values) < 7:
_LOGGER.warning(
"Unexpected status format for %s: %s", device.serial_number, values
)
return
playlist = [_parse_int(track) for track in values[3].split(",") if track]
shift = len(values) - 18 if len(values) > 17 else 0
try:
status: dict[str, Any] = {
"status_code": _parse_int(values[0]),
"error": _parse_int(values[1]),
"ball_speed": _parse_int(values[2]),
"playlist": playlist,
"playlist_index": min(_parse_int(values[4]), len(playlist)),
"progress": _parse_int(values[5]),
"led_effect": values[6],
"led_speed": _parse_int(values[8]),
"brightness": _parse_int(values[9]),
"color": values[10] if "#" in values[10] else None,
"busy": _bit_to_bool(values[11 + shift]),
"download_progress": _parse_int(values[12 + shift]),
"max_brightness": _parse_int(values[13 + shift]),
"repeat_playlist": _bit_to_bool(values[15 + shift]),
"autoplay": AUTOPLAY_MAP.get(value := values[16 + shift], value),
}
except Exception: # noqa: BLE001
_LOGGER.exception("Error parsing HTTP status for %s", device.serial_number)
return
device.update_from_status_dict(status)

View File

@@ -0,0 +1,517 @@
"""Oasis MQTT client (multi-device)."""
from __future__ import annotations
import asyncio
import base64
from datetime import UTC, datetime
import logging
import ssl
from typing import Any, Final
import aiomqtt
from ..const import AUTOPLAY_MAP
from ..device import OasisDevice
from ..utils import _bit_to_bool
from .transport import OasisClientProtocol
_LOGGER = logging.getLogger(__name__)
# mqtt connection parameters
HOST: Final = "mqtt.grounded.so"
PORT: Final = 8084
PATH: Final = "mqtt"
USERNAME: Final = "YXBw"
PASSWORD: Final = "RWdETFlKMDczfi4t"
RECONNECT_INTERVAL: Final = 4
class OasisMqttClient(OasisClientProtocol):
"""MQTT-based Oasis transport using WSS.
Responsibilities:
- Maintain a single MQTT connection to:
wss://mqtt.grounded.so:8084/mqtt
- Subscribe only to <serial>/STATUS/# for devices it knows about.
- Publish commands to <serial>/COMMAND/CMD
- Map MQTT payloads to OasisDevice.update_from_status_dict()
"""
def __init__(self) -> None:
# MQTT connection state
self._client: aiomqtt.Client | None = None
self._loop_task: asyncio.Task | None = None
self._connected_at: datetime | None = None
self._connected_event: asyncio.Event = asyncio.Event()
self._stop_event: asyncio.Event = asyncio.Event()
# Known devices by serial
self._devices: dict[str, OasisDevice] = {}
# Per-device events
self._first_status_events: dict[str, asyncio.Event] = {}
self._mac_events: dict[str, asyncio.Event] = {}
# Subscription bookkeeping
self._subscribed_serials: set[str] = set()
self._subscription_lock = asyncio.Lock()
def register_device(self, device: OasisDevice) -> None:
"""Register a device so MQTT messages can be routed to it."""
if not device.serial_number:
raise ValueError("Device must have serial_number set before registration")
serial = device.serial_number
self._devices[serial] = device
# Ensure we have per-device events
self._first_status_events.setdefault(serial, asyncio.Event())
self._mac_events.setdefault(serial, asyncio.Event())
# If we're already connected, subscribe to this device's topics
if self._client is not None:
try:
loop = asyncio.get_running_loop()
loop.create_task(self._subscribe_serial(serial))
except RuntimeError:
# No running loop (unlikely in HA), so just log
_LOGGER.debug(
"Could not schedule subscription for %s (no running loop)", serial
)
if not device.client:
device.attach_client(self)
def unregister_device(self, device: OasisDevice) -> None:
serial = device.serial_number
if not serial:
return
self._devices.pop(serial, None)
self._first_status_events.pop(serial, None)
self._mac_events.pop(serial, None)
# If connected and we were subscribed, unsubscribe
if self._client is not None and serial in self._subscribed_serials:
try:
loop = asyncio.get_running_loop()
loop.create_task(self._unsubscribe_serial(serial))
except RuntimeError:
_LOGGER.debug(
"Could not schedule unsubscription for %s (no running loop)",
serial,
)
async def _subscribe_serial(self, serial: str) -> None:
"""Subscribe to STATUS topics for a single device."""
if not self._client:
return
async with self._subscription_lock:
if not self._client or serial in self._subscribed_serials:
return
topic = f"{serial}/STATUS/#"
await self._client.subscribe([(topic, 1)])
self._subscribed_serials.add(serial)
_LOGGER.info("Subscribed to %s", topic)
async def _unsubscribe_serial(self, serial: str) -> None:
"""Unsubscribe from STATUS topics for a single device."""
if not self._client:
return
async with self._subscription_lock:
if not self._client or serial not in self._subscribed_serials:
return
topic = f"{serial}/STATUS/#"
await self._client.unsubscribe(topic)
self._subscribed_serials.discard(serial)
_LOGGER.info("Unsubscribed from %s", topic)
async def _resubscribe_all(self) -> None:
"""Resubscribe to all known devices after (re)connect."""
self._subscribed_serials.clear()
for serial in list(self._devices):
await self._subscribe_serial(serial)
def start(self) -> None:
"""Start MQTT connection loop."""
if self._loop_task is None or self._loop_task.done():
self._stop_event.clear()
loop = asyncio.get_running_loop()
self._loop_task = loop.create_task(self._mqtt_loop())
async def async_close(self) -> None:
"""Close connection loop and MQTT client."""
await self.stop()
async def stop(self) -> None:
"""Stop MQTT connection loop."""
self._stop_event.set()
if self._loop_task:
self._loop_task.cancel()
try:
await self._loop_task
except asyncio.CancelledError:
pass
if self._client:
try:
await self._client.disconnect()
except Exception:
_LOGGER.exception("Error disconnecting MQTT client")
finally:
self._client = None
async def wait_until_ready(
self, device: OasisDevice, timeout: float = 10.0, request_status: bool = True
) -> bool:
"""
Wait until:
1. MQTT client is connected
2. Device sends at least one STATUS message
If request_status=True, a request status command is sent *after* connection.
"""
serial = device.serial_number
if not serial:
raise RuntimeError("Device has no serial_number set")
first_status_event = self._first_status_events.setdefault(
serial, asyncio.Event()
)
# Wait for MQTT connection
try:
await asyncio.wait_for(self._connected_event.wait(), timeout=timeout)
except asyncio.TimeoutError:
_LOGGER.debug(
"Timeout (%.1fs) waiting for MQTT connection (device %s)",
timeout,
serial,
)
return False
# Optionally request a status refresh
if request_status:
try:
first_status_event.clear()
await self.async_get_status(device)
except Exception:
_LOGGER.debug(
"Could not request status for %s (not fully connected yet?)",
serial,
)
# Wait for first status
try:
await asyncio.wait_for(first_status_event.wait(), timeout=timeout)
return True
except asyncio.TimeoutError:
_LOGGER.debug(
"Timeout (%.1fs) waiting for first STATUS message from %s",
timeout,
serial,
)
return False
async def async_get_mac_address(self, device: OasisDevice) -> str | None:
"""For MQTT, GETSTATUS causes MAC_ADDRESS to be published."""
# If already known on the device, return it
if device.mac_address:
return device.mac_address
serial = device.serial_number
if not serial:
raise RuntimeError("Device has no serial_number set")
mac_event = self._mac_events.setdefault(serial, asyncio.Event())
mac_event.clear()
# Ask device to refresh status (including MAC_ADDRESS)
await self.async_get_status(device)
try:
await asyncio.wait_for(mac_event.wait(), timeout=3.0)
except asyncio.TimeoutError:
_LOGGER.debug("Timed out waiting for MAC_ADDRESS for %s", serial)
return device.mac_address
async def async_send_ball_speed_command(
self,
device: OasisDevice,
speed: int,
) -> None:
payload = f"WRIOASISSPEED={speed}"
await self._publish_command(device, payload)
async def async_send_led_command(
self,
device: OasisDevice,
led_effect: str,
color: str,
led_speed: int,
brightness: int,
) -> None:
payload = f"WRILED={led_effect};0;{color};{led_speed};{brightness}"
await self._publish_command(device, payload)
async def async_send_sleep_command(self, device: OasisDevice) -> None:
await self._publish_command(device, "CMDSLEEP")
async def async_send_move_job_command(
self,
device: OasisDevice,
from_index: int,
to_index: int,
) -> None:
payload = f"MOVEJOB={from_index};{to_index}"
await self._publish_command(device, payload)
async def async_send_change_track_command(
self,
device: OasisDevice,
index: int,
) -> None:
payload = f"CMDCHANGETRACK={index}"
await self._publish_command(device, payload)
async def async_send_add_joblist_command(
self,
device: OasisDevice,
tracks: list[int],
) -> None:
track_str = ",".join(map(str, tracks))
payload = f"ADDJOBLIST={track_str}"
await self._publish_command(device, payload)
async def async_send_set_playlist_command(
self,
device: OasisDevice,
playlist: list[int],
) -> None:
track_str = ",".join(map(str, playlist))
payload = f"WRIJOBLIST={track_str}"
await self._publish_command(device, payload)
# local state optimistic update
device.update_from_status_dict({"playlist": playlist})
async def async_send_set_repeat_playlist_command(
self,
device: OasisDevice,
repeat: bool,
) -> None:
payload = f"WRIREPEATJOB={1 if repeat else 0}"
await self._publish_command(device, payload)
async def async_send_set_autoplay_command(
self,
device: OasisDevice,
option: str,
) -> None:
payload = f"WRIWAITAFTER={option}"
await self._publish_command(device, payload)
async def async_send_upgrade_command(
self,
device: OasisDevice,
beta: bool,
) -> None:
payload = f"CMDUPGRADE={1 if beta else 0}"
await self._publish_command(device, payload)
async def async_send_play_command(self, device: OasisDevice) -> None:
await self._publish_command(device, "CMDPLAY")
async def async_send_pause_command(self, device: OasisDevice) -> None:
await self._publish_command(device, "CMDPAUSE")
async def async_send_stop_command(self, device: OasisDevice) -> None:
await self._publish_command(device, "CMDSTOP")
async def async_send_reboot_command(self, device: OasisDevice) -> None:
await self._publish_command(device, "CMDBOOT")
async def async_get_status(self, device: OasisDevice) -> None:
"""Ask device to publish STATUS topics."""
await self._publish_command(device, "GETSTATUS")
async def _publish_command(self, device: OasisDevice, payload: str) -> None:
if not self._client:
raise RuntimeError("MQTT client not connected yet")
serial = device.serial_number
if not serial:
raise RuntimeError("Device has no serial_number set")
topic = f"{serial}/COMMAND/CMD"
_LOGGER.debug("MQTT publish %s => %s", topic, payload)
await self._client.publish(topic, payload.encode(), qos=1)
async def _mqtt_loop(self) -> None:
loop = asyncio.get_running_loop()
tls_context = await loop.run_in_executor(None, ssl.create_default_context)
while not self._stop_event.is_set():
try:
_LOGGER.debug(
"Connecting MQTT WSS to wss://%s:%s/%s",
HOST,
PORT,
PATH,
)
async with aiomqtt.Client(
hostname=HOST,
port=PORT,
transport="websockets",
tls_context=tls_context,
username=base64.b64decode(USERNAME).decode(),
password=base64.b64decode(PASSWORD).decode(),
keepalive=30,
websocket_path=f"/{PATH}",
) as client:
self._client = client
self._connected_event.set()
self._connected_at = datetime.now(UTC)
_LOGGER.info("Connected to MQTT broker")
# Subscribe only to STATUS topics for known devices
await self._resubscribe_all()
async for msg in client.messages:
if self._stop_event.is_set():
break
await self._handle_status_message(msg)
except asyncio.CancelledError:
break
except Exception:
_LOGGER.debug("MQTT connection error")
finally:
if self._connected_event.is_set():
self._connected_event.clear()
if self._connected_at:
_LOGGER.debug(
"MQTT was connected for %s",
datetime.now(UTC) - self._connected_at,
)
self._connected_at = None
self._client = None
self._subscribed_serials.clear()
if not self._stop_event.is_set():
_LOGGER.debug(
"Disconnected from broker, retrying in %.1fs", RECONNECT_INTERVAL
)
await asyncio.sleep(RECONNECT_INTERVAL)
async def _handle_status_message(self, msg: aiomqtt.Message) -> None:
"""Map MQTT STATUS topics → OasisDevice.update_from_status_dict payloads."""
topic_str = str(msg.topic) if msg.topic is not None else ""
payload = msg.payload.decode(errors="replace")
parts = topic_str.split("/")
# Expect: "<serial>/STATUS/<STATUS_NAME>"
if len(parts) < 3:
return
serial, _, status_name = parts[:3]
device = self._devices.get(serial)
if not device:
# Ignore devices we don't know about
_LOGGER.debug("Received MQTT for unknown device %s: %s", serial, topic_str)
return
data: dict[str, Any] = {}
try:
if status_name == "OASIS_STATUS":
data["status_code"] = int(payload)
elif status_name == "OASIS_ERROR":
data["error"] = int(payload)
elif status_name == "OASIS_SPEEED":
data["ball_speed"] = int(payload)
elif status_name == "JOBLIST":
data["playlist"] = [int(x) for x in payload.split(",") if x]
elif status_name == "CURRENTJOB":
data["playlist_index"] = int(payload)
elif status_name == "CURRENTLINE":
data["progress"] = int(payload)
elif status_name == "LED_EFFECT":
data["led_effect"] = payload
elif status_name == "LED_EFFECT_COLOR":
data["led_effect_color"] = payload
elif status_name == "LED_SPEED":
data["led_speed"] = int(payload)
elif status_name == "LED_BRIGHTNESS":
data["brightness"] = int(payload)
elif status_name == "LED_MAX":
data["max_brightness"] = int(payload)
elif status_name == "LED_EFFECT_PARAM":
data["color"] = payload if payload.startswith("#") else None
elif status_name == "SYSTEM_BUSY":
data["busy"] = payload in ("1", "true", "True")
elif status_name == "DOWNLOAD_PROGRESS":
data["download_progress"] = int(payload)
elif status_name == "REPEAT_JOB":
data["repeat_playlist"] = payload in ("1", "true", "True")
elif status_name == "WAIT_AFTER_JOB":
data["autoplay"] = AUTOPLAY_MAP.get(payload, payload)
elif status_name == "AUTO_CLEAN":
data["auto_clean"] = payload in ("1", "true", "True")
elif status_name == "SOFTWARE_VER":
data["software_version"] = payload
elif status_name == "MAC_ADDRESS":
data["mac_address"] = payload
mac_event = self._mac_events.setdefault(serial, asyncio.Event())
mac_event.set()
elif status_name == "WIFI_SSID":
data["wifi_ssid"] = payload
elif status_name == "WIFI_IP":
data["wifi_ip"] = payload
elif status_name == "WIFI_PDNS":
data["wifi_pdns"] = payload
elif status_name == "WIFI_SDNS":
data["wifi_sdns"] = payload
elif status_name == "WIFI_GATE":
data["wifi_gate"] = payload
elif status_name == "WIFI_SUB":
data["wifi_sub"] = payload
elif status_name == "WIFI_STATUS":
data["wifi_connected"] = _bit_to_bool(payload)
elif status_name == "SCHEDULE":
data["schedule"] = payload
elif status_name == "ENVIRONMENT":
data["environment"] = payload
else:
_LOGGER.warning(
"Unknown status received for %s: %s=%s",
serial,
status_name,
payload,
)
except Exception: # noqa: BLE001
_LOGGER.exception(
"Error parsing MQTT payload for %s %s: %r", serial, status_name, payload
)
return
if data:
device.update_from_status_dict(data)
first_status_event = self._first_status_events.setdefault(
serial, asyncio.Event()
)
if not first_status_event.is_set():
first_status_event.set()

View File

@@ -0,0 +1,85 @@
from __future__ import annotations
from typing import Protocol, runtime_checkable
from ..device import OasisDevice
@runtime_checkable
class OasisClientProtocol(Protocol):
"""Transport/client interface for an Oasis device.
Concrete implementations:
- MQTT client (remote connection)
- HTTP client (direct LAN)
"""
async def async_get_mac_address(self, device: OasisDevice) -> str | None: ...
async def async_send_ball_speed_command(
self,
device: OasisDevice,
speed: int,
) -> None: ...
async def async_send_led_command(
self,
device: OasisDevice,
led_effect: str,
color: str,
led_speed: int,
brightness: int,
) -> None: ...
async def async_send_sleep_command(self, device: OasisDevice) -> None: ...
async def async_send_move_job_command(
self,
device: OasisDevice,
from_index: int,
to_index: int,
) -> None: ...
async def async_send_change_track_command(
self,
device: OasisDevice,
index: int,
) -> None: ...
async def async_send_add_joblist_command(
self,
device: OasisDevice,
tracks: list[int],
) -> None: ...
async def async_send_set_playlist_command(
self,
device: OasisDevice,
playlist: list[int],
) -> None: ...
async def async_send_set_repeat_playlist_command(
self,
device: OasisDevice,
repeat: bool,
) -> None: ...
async def async_send_set_autoplay_command(
self,
device: OasisDevice,
option: str,
) -> None: ...
async def async_send_upgrade_command(
self,
device: OasisDevice,
beta: bool,
) -> None: ...
async def async_send_play_command(self, device: OasisDevice) -> None: ...
async def async_send_pause_command(self, device: OasisDevice) -> None: ...
async def async_send_stop_command(self, device: OasisDevice) -> None: ...
async def async_send_reboot_command(self, device: OasisDevice) -> None: ...