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

Better mqtt handling when connection is interrupted

This commit is contained in:
Nathan Spencer
2025-11-22 20:51:17 +00:00
parent 886d7598f3
commit ecad472bbd
14 changed files with 260 additions and 100 deletions

View File

@@ -78,4 +78,3 @@ class OasisDeviceButtonEntity(OasisDeviceEntity, ButtonEntity):
async def async_press(self) -> None: async def async_press(self) -> None:
"""Press the button.""" """Press the button."""
await self.entity_description.press_fn(self.device) await self.entity_description.press_fn(self.device)
await self.coordinator.async_request_refresh()

View File

@@ -33,7 +33,7 @@ class OasisDeviceCoordinator(DataUpdateCoordinator[list[OasisDevice]]):
hass, hass,
_LOGGER, _LOGGER,
name=DOMAIN, name=DOMAIN,
update_interval=timedelta(seconds=10), update_interval=timedelta(minutes=10),
always_update=False, always_update=False,
) )
self.cloud_client = cloud_client self.cloud_client = cloud_client

View File

@@ -53,7 +53,7 @@ class OasisDeviceLightEntity(OasisDeviceEntity, LightEntity):
@property @property
def brightness(self) -> int: def brightness(self) -> int:
"""Return the brightness of this light between 0..255.""" """Return the brightness of this light between 0..255."""
scale = (1, self.device.max_brightness) scale = (1, self.device.brightness_max)
return value_to_brightness(scale, self.device.brightness) return value_to_brightness(scale, self.device.brightness)
@property @property
@@ -99,15 +99,14 @@ class OasisDeviceLightEntity(OasisDeviceEntity, LightEntity):
async def async_turn_off(self, **kwargs: Any) -> None: async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the entity off.""" """Turn the entity off."""
await self.device.async_set_led(brightness=0) await self.device.async_set_led(brightness=0)
await self.coordinator.async_request_refresh()
async def async_turn_on(self, **kwargs: Any) -> None: async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the entity on.""" """Turn the entity on."""
if brightness := kwargs.get(ATTR_BRIGHTNESS): if brightness := kwargs.get(ATTR_BRIGHTNESS):
scale = (1, self.device.max_brightness) scale = (1, self.device.brightness_max)
brightness = math.ceil(brightness_to_value(scale, brightness)) brightness = math.ceil(brightness_to_value(scale, brightness))
else: else:
brightness = self.device.brightness or 100 brightness = self.device.brightness or self.device.brightness_on
if color := kwargs.get(ATTR_RGB_COLOR): if color := kwargs.get(ATTR_RGB_COLOR):
color = f"#{color_rgb_to_hex(*color)}" color = f"#{color_rgb_to_hex(*color)}"
@@ -120,4 +119,3 @@ class OasisDeviceLightEntity(OasisDeviceEntity, LightEntity):
await self.device.async_set_led( await self.device.async_set_led(
brightness=brightness, color=color, led_effect=led_effect brightness=brightness, color=color, led_effect=led_effect
) )
await self.coordinator.async_request_refresh()

View File

@@ -134,19 +134,16 @@ class OasisDeviceMediaPlayerEntity(OasisDeviceEntity, MediaPlayerEntity):
"""Send pause command.""" """Send pause command."""
self.abort_if_busy() self.abort_if_busy()
await self.device.async_pause() await self.device.async_pause()
await self.coordinator.async_request_refresh()
async def async_media_play(self) -> None: async def async_media_play(self) -> None:
"""Send play command.""" """Send play command."""
self.abort_if_busy() self.abort_if_busy()
await self.device.async_play() await self.device.async_play()
await self.coordinator.async_request_refresh()
async def async_media_stop(self) -> None: async def async_media_stop(self) -> None:
"""Send stop command.""" """Send stop command."""
self.abort_if_busy() self.abort_if_busy()
await self.device.async_stop() await self.device.async_stop()
await self.coordinator.async_request_refresh()
async def async_set_repeat(self, repeat: RepeatMode) -> None: async def async_set_repeat(self, repeat: RepeatMode) -> None:
"""Set repeat mode.""" """Set repeat mode."""
@@ -154,7 +151,6 @@ class OasisDeviceMediaPlayerEntity(OasisDeviceEntity, MediaPlayerEntity):
repeat != RepeatMode.OFF repeat != RepeatMode.OFF
and not (repeat == RepeatMode.ONE and self.repeat == RepeatMode.ALL) and not (repeat == RepeatMode.ONE and self.repeat == RepeatMode.ALL)
) )
await self.coordinator.async_request_refresh()
async def async_media_previous_track(self) -> None: async def async_media_previous_track(self) -> None:
"""Send previous track command.""" """Send previous track command."""
@@ -162,7 +158,6 @@ class OasisDeviceMediaPlayerEntity(OasisDeviceEntity, MediaPlayerEntity):
if (index := self.device.playlist_index - 1) < 0: if (index := self.device.playlist_index - 1) < 0:
index = len(self.device.playlist) - 1 index = len(self.device.playlist) - 1
await self.device.async_change_track(index) await self.device.async_change_track(index)
await self.coordinator.async_request_refresh()
async def async_media_next_track(self) -> None: async def async_media_next_track(self) -> None:
"""Send next track command.""" """Send next track command."""
@@ -170,7 +165,6 @@ class OasisDeviceMediaPlayerEntity(OasisDeviceEntity, MediaPlayerEntity):
if (index := self.device.playlist_index + 1) >= len(self.device.playlist): if (index := self.device.playlist_index + 1) >= len(self.device.playlist):
index = 0 index = 0
await self.device.async_change_track(index) await self.device.async_change_track(index)
await self.coordinator.async_request_refresh()
async def async_play_media( async def async_play_media(
self, self,
@@ -220,10 +214,7 @@ class OasisDeviceMediaPlayerEntity(OasisDeviceEntity, MediaPlayerEntity):
): ):
await device.async_play() await device.async_play()
await self.coordinator.async_request_refresh()
async def async_clear_playlist(self) -> None: async def async_clear_playlist(self) -> None:
"""Clear players playlist.""" """Clear players playlist."""
self.abort_if_busy() self.abort_if_busy()
await self.device.async_clear_playlist() await self.device.async_clear_playlist()
await self.coordinator.async_request_refresh()

View File

@@ -63,8 +63,8 @@ class OasisDeviceNumberEntity(OasisDeviceEntity, NumberEntity):
async def async_set_native_value(self, value: float) -> None: async def async_set_native_value(self, value: float) -> None:
"""Set new value.""" """Set new value."""
value = int(value)
if self.entity_description.key == "ball_speed": if self.entity_description.key == "ball_speed":
await self.device.async_set_ball_speed(value) await self.device.async_set_ball_speed(value)
elif self.entity_description.key == "led_speed": elif self.entity_description.key == "led_speed":
await self.device.async_set_led(led_speed=value) await self.device.async_set_led(led_speed=value)
await self.coordinator.async_request_refresh()

View File

@@ -7,9 +7,7 @@ from typing import Any
from aiohttp import ClientSession from aiohttp import ClientSession
from ..const import AUTOPLAY_MAP
from ..device import OasisDevice from ..device import OasisDevice
from ..utils import _bit_to_bool, _parse_int
from .transport import OasisClientProtocol from .transport import OasisClientProtocol
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@@ -179,37 +177,4 @@ class OasisHttpClient(OasisClientProtocol):
return return
_LOGGER.debug("Status for %s: %s", device.serial_number, raw_status) _LOGGER.debug("Status for %s: %s", device.serial_number, raw_status)
device.update_from_status_string(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

@@ -11,9 +11,8 @@ from typing import Any, Final
import aiomqtt import aiomqtt
from ..const import AUTOPLAY_MAP
from ..device import OasisDevice from ..device import OasisDevice
from ..utils import _bit_to_bool from ..utils import _bit_to_bool, _parse_int
from .transport import OasisClientProtocol from .transport import OasisClientProtocol
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@@ -26,6 +25,9 @@ USERNAME: Final = "YXBw"
PASSWORD: Final = "RWdETFlKMDczfi4t" PASSWORD: Final = "RWdETFlKMDczfi4t"
RECONNECT_INTERVAL: Final = 4 RECONNECT_INTERVAL: Final = 4
# Command queue behaviour
MAX_PENDING_COMMANDS: Final = 10
class OasisMqttClient(OasisClientProtocol): class OasisMqttClient(OasisClientProtocol):
"""MQTT-based Oasis transport using WSS. """MQTT-based Oasis transport using WSS.
@@ -58,6 +60,11 @@ class OasisMqttClient(OasisClientProtocol):
self._subscribed_serials: set[str] = set() self._subscribed_serials: set[str] = set()
self._subscription_lock = asyncio.Lock() self._subscription_lock = asyncio.Lock()
# Pending command queue: (serial, payload)
self._command_queue: asyncio.Queue[tuple[str, str]] = asyncio.Queue(
maxsize=MAX_PENDING_COMMANDS
)
def register_device(self, device: OasisDevice) -> None: def register_device(self, device: OasisDevice) -> None:
"""Register a device so MQTT messages can be routed to it.""" """Register a device so MQTT messages can be routed to it."""
if not device.serial_number: if not device.serial_number:
@@ -70,6 +77,10 @@ class OasisMqttClient(OasisClientProtocol):
self._first_status_events.setdefault(serial, asyncio.Event()) self._first_status_events.setdefault(serial, asyncio.Event())
self._mac_events.setdefault(serial, asyncio.Event()) self._mac_events.setdefault(serial, asyncio.Event())
# Attach ourselves as the client if the device doesn't already have one
if not device.client:
device.attach_client(self)
# If we're already connected, subscribe to this device's topics # If we're already connected, subscribe to this device's topics
if self._client is not None: if self._client is not None:
try: try:
@@ -81,9 +92,6 @@ class OasisMqttClient(OasisClientProtocol):
"Could not schedule subscription for %s (no running loop)", serial "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: def unregister_device(self, device: OasisDevice) -> None:
serial = device.serial_number serial = device.serial_number
if not serial: if not serial:
@@ -168,6 +176,14 @@ class OasisMqttClient(OasisClientProtocol):
finally: finally:
self._client = None self._client = None
# Drop pending commands on stop
while not self._command_queue.empty():
try:
self._command_queue.get_nowait()
self._command_queue.task_done()
except asyncio.QueueEmpty:
break
async def wait_until_ready( async def wait_until_ready(
self, device: OasisDevice, timeout: float = 10.0, request_status: bool = True self, device: OasisDevice, timeout: float = 10.0, request_status: bool = True
) -> bool: ) -> bool:
@@ -260,7 +276,7 @@ class OasisMqttClient(OasisClientProtocol):
brightness: int, brightness: int,
) -> None: ) -> None:
payload = f"WRILED={led_effect};0;{color};{led_speed};{brightness}" payload = f"WRILED={led_effect};0;{color};{led_speed};{brightness}"
await self._publish_command(device, payload) await self._publish_command(device, payload, bool(brightness))
async def async_send_sleep_command(self, device: OasisDevice) -> None: async def async_send_sleep_command(self, device: OasisDevice) -> None:
await self._publish_command(device, "CMDSLEEP") await self._publish_command(device, "CMDSLEEP")
@@ -328,7 +344,7 @@ class OasisMqttClient(OasisClientProtocol):
await self._publish_command(device, payload) await self._publish_command(device, payload)
async def async_send_play_command(self, device: OasisDevice) -> None: async def async_send_play_command(self, device: OasisDevice) -> None:
await self._publish_command(device, "CMDPLAY") await self._publish_command(device, "CMDPLAY", True)
async def async_send_pause_command(self, device: OasisDevice) -> None: async def async_send_pause_command(self, device: OasisDevice) -> None:
await self._publish_command(device, "CMDPAUSE") await self._publish_command(device, "CMDPAUSE")
@@ -339,21 +355,96 @@ class OasisMqttClient(OasisClientProtocol):
async def async_send_reboot_command(self, device: OasisDevice) -> None: async def async_send_reboot_command(self, device: OasisDevice) -> None:
await self._publish_command(device, "CMDBOOT") await self._publish_command(device, "CMDBOOT")
async def async_get_all(self, device: OasisDevice) -> None:
"""Request FULLSTATUS + SCHEDULE (compact snapshot)."""
await self._publish_command(device, "GETALL")
async def async_get_status(self, device: OasisDevice) -> None: async def async_get_status(self, device: OasisDevice) -> None:
"""Ask device to publish STATUS topics.""" """Ask device to publish STATUS topics."""
await self._publish_command(device, "GETSTATUS") await self._publish_command(device, "GETSTATUS")
async def _publish_command(self, device: OasisDevice, payload: str) -> None: async def _enqueue_command(self, serial: str, payload: str) -> None:
if not self._client: """Queue a command to be sent when connected.
raise RuntimeError("MQTT client not connected yet")
serial = device.serial_number If the queue is full, drop the oldest command to make room.
if not serial: """
raise RuntimeError("Device has no serial_number set") if self._command_queue.full():
try:
dropped = self._command_queue.get_nowait()
self._command_queue.task_done()
_LOGGER.debug(
"Command queue full, dropping oldest command: %s", dropped
)
except asyncio.QueueEmpty:
# race: became empty between full() and get_nowait()
pass
await self._command_queue.put((serial, payload))
_LOGGER.debug("Queued command for %s: %s", serial, payload)
async def _flush_pending_commands(self) -> None:
"""Send any queued commands now that we're connected."""
if not self._client:
return
while not self._command_queue.empty():
try:
serial, payload = self._command_queue.get_nowait()
except asyncio.QueueEmpty:
break
try:
# Skip commands for unknown devices
if serial not in self._devices:
_LOGGER.debug(
"Skipping queued command for unknown device %s: %s",
serial,
payload,
)
self._command_queue.task_done()
continue
topic = f"{serial}/COMMAND/CMD" topic = f"{serial}/COMMAND/CMD"
_LOGGER.debug("Flushing queued MQTT command %s => %s", topic, payload)
await self._client.publish(topic, payload.encode(), qos=1)
except Exception:
_LOGGER.debug(
"Failed to flush queued command for %s, re-queuing", serial
)
# Put it back and break; we'll try again on next reconnect
await self._enqueue_command(serial, payload)
self._command_queue.task_done()
break
self._command_queue.task_done()
async def _publish_command(
self, device: OasisDevice, payload: str, wake: bool = False
) -> None:
serial = device.serial_number
if not serial:
raise RuntimeError("Device has no serial number set")
if wake and device.is_sleeping:
await self.async_get_all(device)
# If not connected, just queue the command
if not self._client or not self._connected_event.is_set():
_LOGGER.debug(
"MQTT not connected, queueing command for %s: %s", serial, payload
)
await self._enqueue_command(serial, payload)
return
topic = f"{serial}/COMMAND/CMD"
try:
_LOGGER.debug("MQTT publish %s => %s", topic, payload) _LOGGER.debug("MQTT publish %s => %s", topic, payload)
await self._client.publish(topic, payload.encode(), qos=1) await self._client.publish(topic, payload.encode(), qos=1)
except Exception:
_LOGGER.debug(
"MQTT publish failed, queueing command for %s: %s", serial, payload
)
await self._enqueue_command(serial, payload)
async def _mqtt_loop(self) -> None: async def _mqtt_loop(self) -> None:
loop = asyncio.get_running_loop() loop = asyncio.get_running_loop()
@@ -361,12 +452,7 @@ class OasisMqttClient(OasisClientProtocol):
while not self._stop_event.is_set(): while not self._stop_event.is_set():
try: try:
_LOGGER.debug( _LOGGER.info("Connecting MQTT WSS to wss://%s:%s/%s", HOST, PORT, PATH)
"Connecting MQTT WSS to wss://%s:%s/%s",
HOST,
PORT,
PATH,
)
async with aiomqtt.Client( async with aiomqtt.Client(
hostname=HOST, hostname=HOST,
@@ -386,6 +472,9 @@ class OasisMqttClient(OasisClientProtocol):
# Subscribe only to STATUS topics for known devices # Subscribe only to STATUS topics for known devices
await self._resubscribe_all() await self._resubscribe_all()
# Flush any queued commands now that we're connected
await self._flush_pending_commands()
async for msg in client.messages: async for msg in client.messages:
if self._stop_event.is_set(): if self._stop_event.is_set():
break break
@@ -394,13 +483,13 @@ class OasisMqttClient(OasisClientProtocol):
except asyncio.CancelledError: except asyncio.CancelledError:
break break
except Exception: except Exception:
_LOGGER.debug("MQTT connection error") _LOGGER.info("MQTT connection error")
finally: finally:
if self._connected_event.is_set(): if self._connected_event.is_set():
self._connected_event.clear() self._connected_event.clear()
if self._connected_at: if self._connected_at:
_LOGGER.debug( _LOGGER.info(
"MQTT was connected for %s", "MQTT was connected for %s",
datetime.now(UTC) - self._connected_at, datetime.now(UTC) - self._connected_at,
) )
@@ -409,14 +498,13 @@ class OasisMqttClient(OasisClientProtocol):
self._subscribed_serials.clear() self._subscribed_serials.clear()
if not self._stop_event.is_set(): if not self._stop_event.is_set():
_LOGGER.debug( _LOGGER.info(
"Disconnected from broker, retrying in %.1fs", RECONNECT_INTERVAL "Disconnected from broker, retrying in %.1fs", RECONNECT_INTERVAL
) )
await asyncio.sleep(RECONNECT_INTERVAL) await asyncio.sleep(RECONNECT_INTERVAL)
async def _handle_status_message(self, msg: aiomqtt.Message) -> None: async def _handle_status_message(self, msg: aiomqtt.Message) -> None:
"""Map MQTT STATUS topics → OasisDevice.update_from_status_dict payloads.""" """Map MQTT STATUS topics → OasisDevice.update_from_status_dict payloads."""
topic_str = str(msg.topic) if msg.topic is not None else "" topic_str = str(msg.topic) if msg.topic is not None else ""
payload = msg.payload.decode(errors="replace") payload = msg.payload.decode(errors="replace")
@@ -429,7 +517,6 @@ class OasisMqttClient(OasisClientProtocol):
device = self._devices.get(serial) device = self._devices.get(serial)
if not device: if not device:
# Ignore devices we don't know about
_LOGGER.debug("Received MQTT for unknown device %s: %s", serial, topic_str) _LOGGER.debug("Received MQTT for unknown device %s: %s", serial, topic_str)
return return
@@ -451,13 +538,13 @@ class OasisMqttClient(OasisClientProtocol):
elif status_name == "LED_EFFECT": elif status_name == "LED_EFFECT":
data["led_effect"] = payload data["led_effect"] = payload
elif status_name == "LED_EFFECT_COLOR": elif status_name == "LED_EFFECT_COLOR":
data["led_effect_color"] = payload data["led_color_id"] = payload
elif status_name == "LED_SPEED": elif status_name == "LED_SPEED":
data["led_speed"] = int(payload) data["led_speed"] = int(payload)
elif status_name == "LED_BRIGHTNESS": elif status_name == "LED_BRIGHTNESS":
data["brightness"] = int(payload) data["brightness"] = int(payload)
elif status_name == "LED_MAX": elif status_name == "LED_MAX":
data["max_brightness"] = int(payload) data["brightness_max"] = int(payload)
elif status_name == "LED_EFFECT_PARAM": elif status_name == "LED_EFFECT_PARAM":
data["color"] = payload if payload.startswith("#") else None data["color"] = payload if payload.startswith("#") else None
elif status_name == "SYSTEM_BUSY": elif status_name == "SYSTEM_BUSY":
@@ -467,7 +554,7 @@ class OasisMqttClient(OasisClientProtocol):
elif status_name == "REPEAT_JOB": elif status_name == "REPEAT_JOB":
data["repeat_playlist"] = payload in ("1", "true", "True") data["repeat_playlist"] = payload in ("1", "true", "True")
elif status_name == "WAIT_AFTER_JOB": elif status_name == "WAIT_AFTER_JOB":
data["autoplay"] = AUTOPLAY_MAP.get(payload, payload) data["autoplay"] = _parse_int(payload)
elif status_name == "AUTO_CLEAN": elif status_name == "AUTO_CLEAN":
data["auto_clean"] = payload in ("1", "true", "True") data["auto_clean"] = payload in ("1", "true", "True")
elif status_name == "SOFTWARE_VER": elif status_name == "SOFTWARE_VER":
@@ -494,6 +581,9 @@ class OasisMqttClient(OasisClientProtocol):
data["schedule"] = payload data["schedule"] = payload
elif status_name == "ENVIRONMENT": elif status_name == "ENVIRONMENT":
data["environment"] = payload data["environment"] = payload
elif status_name == "FULLSTATUS":
if parsed := device.parse_status_string(payload):
data = parsed
else: else:
_LOGGER.warning( _LOGGER.warning(
"Unknown status received for %s: %s=%s", "Unknown status received for %s: %s=%s",

View File

@@ -83,3 +83,7 @@ class OasisClientProtocol(Protocol):
async def async_send_stop_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: ... async def async_send_reboot_command(self, device: OasisDevice) -> None: ...
async def async_get_all(self, device: OasisDevice) -> None: ...
async def async_get_status(self, device: OasisDevice) -> None: ...

View File

@@ -21,6 +21,9 @@ AUTOPLAY_MAP: Final[dict[str, str]] = {
"2": "5 minutes", "2": "5 minutes",
"3": "10 minutes", "3": "10 minutes",
"4": "30 minutes", "4": "30 minutes",
"6": "1 hour",
"7": "6 hours",
"8": "12 hours",
"5": "24 hours", "5": "24 hours",
} }
@@ -91,13 +94,14 @@ LED_EFFECTS: Final[dict[str, str]] = {
"41": "Color Comets", "41": "Color Comets",
} }
STATUS_CODE_SLEEPING: Final = 6
STATUS_CODE_MAP: Final[dict[int, str]] = { STATUS_CODE_MAP: Final[dict[int, str]] = {
0: "booting", 0: "booting",
2: "stopped", 2: "stopped",
3: "centering", 3: "centering",
4: "playing", 4: "playing",
5: "paused", 5: "paused",
6: "sleeping", STATUS_CODE_SLEEPING: "sleeping",
9: "error", 9: "error",
11: "updating", 11: "updating",
13: "downloading", 13: "downloading",

View File

@@ -5,7 +5,14 @@ from __future__ import annotations
import logging import logging
from typing import TYPE_CHECKING, Any, Callable, Final, Iterable from typing import TYPE_CHECKING, Any, Callable, Final, Iterable
from .const import ERROR_CODE_MAP, LED_EFFECTS, STATUS_CODE_MAP, TRACKS from .const import (
ERROR_CODE_MAP,
LED_EFFECTS,
STATUS_CODE_MAP,
STATUS_CODE_SLEEPING,
TRACKS,
)
from .utils import _bit_to_bool, _parse_int
if TYPE_CHECKING: # avoid runtime circular imports if TYPE_CHECKING: # avoid runtime circular imports
from .clients.transport import OasisClientProtocol from .clients.transport import OasisClientProtocol
@@ -18,6 +25,7 @@ LED_SPEED_MAX: Final = 90
LED_SPEED_MIN: Final = -90 LED_SPEED_MIN: Final = -90
_STATE_FIELDS = ( _STATE_FIELDS = (
"auto_clean",
"autoplay", "autoplay",
"ball_speed", "ball_speed",
"brightness", "brightness",
@@ -28,7 +36,6 @@ _STATE_FIELDS = (
"led_effect", "led_effect",
"led_speed", "led_speed",
"mac_address", "mac_address",
"max_brightness",
"playlist", "playlist",
"playlist_index", "playlist_index",
"progress", "progress",
@@ -69,17 +76,19 @@ class OasisDevice:
# Status # Status
self.auto_clean: bool = False self.auto_clean: bool = False
self.autoplay: str = "off" self.autoplay: int = 0
self.ball_speed: int = BALL_SPEED_MIN self.ball_speed: int = BALL_SPEED_MIN
self.brightness: int = 0 self._brightness: int = 0
self.brightness_max: int = 200
self.brightness_on: int = 0
self.busy: bool = False self.busy: bool = False
self.color: str | None = None self.color: str | None = None
self.download_progress: int = 0 self.download_progress: int = 0
self.error: int = 0 self.error: int = 0
self.led_color_id: str = "0"
self.led_effect: str = "0" self.led_effect: str = "0"
self.led_speed: int = 0 self.led_speed: int = 0
self.mac_address: str | None = None self.mac_address: str | None = None
self.max_brightness: int = 200
self.playlist: list[int] = [] self.playlist: list[int] = []
self.playlist_index: int = 0 self.playlist_index: int = 0
self.progress: int = 0 self.progress: int = 0
@@ -99,6 +108,22 @@ class OasisDevice:
# Track metadata cache (used if you hydrate from cloud) # Track metadata cache (used if you hydrate from cloud)
self._track: dict | None = None self._track: dict | None = None
@property
def brightness(self) -> int:
"""Return the brightness."""
return 0 if self.is_sleeping else self._brightness
@brightness.setter
def brightness(self, value: int) -> None:
self._brightness = value
if value:
self.brightness_on = value
@property
def is_sleeping(self) -> bool:
"""Return `True` if the status is set to sleeping."""
return self.status_code == STATUS_CODE_SLEEPING
def attach_client(self, client: OasisClientProtocol) -> None: def attach_client(self, client: OasisClientProtocol) -> None:
"""Attach a transport client (MQTT, HTTP, etc.) to this device.""" """Attach a transport client (MQTT, HTTP, etc.) to this device."""
self._client = client self._client = client
@@ -142,6 +167,66 @@ class OasisDevice:
if changed: if changed:
self._notify_listeners() self._notify_listeners()
def parse_status_string(self, raw_status: str) -> dict[str, Any] | None:
"""Parse a semicolon-separated status string into a state dict.
Used by:
- HTTP GETSTATUS response
- MQTT FULLSTATUS payload (includes software_version)
"""
if not raw_status:
return None
values = raw_status.split(";")
# We rely on indices 0..17 existing (18 fields)
if (n := len(values)) < 18:
_LOGGER.warning(
"Unexpected status format for %s: %s", self.serial_number, values
)
return None
playlist = [_parse_int(track) for track in values[3].split(",") if track]
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_color_id": values[7],
"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]),
"download_progress": _parse_int(values[12]),
"brightness_max": _parse_int(values[13]),
"wifi_connected": _bit_to_bool(values[14]),
"repeat_playlist": _bit_to_bool(values[15]),
"autoplay": _parse_int(values[16]),
"auto_clean": _bit_to_bool(values[17]),
}
# Optional trailing field(s)
if n > 18:
status["software_version"] = values[18]
except Exception: # noqa: BLE001
_LOGGER.exception(
"Error parsing status string for %s: %r", self.serial_number, raw_status
)
return None
return status
def update_from_status_string(self, raw_status: str) -> None:
"""Parse and apply a raw status string."""
if status := self.parse_status_string(raw_status):
self.update_from_status_dict(status)
def as_dict(self) -> dict[str, Any]: def as_dict(self) -> dict[str, Any]:
"""Return core state as a dict.""" """Return core state as a dict."""
return {field: getattr(self, field) for field in _STATE_FIELDS} return {field: getattr(self, field) for field in _STATE_FIELDS}
@@ -259,7 +344,7 @@ class OasisDevice:
raise ValueError("Invalid led effect specified") raise ValueError("Invalid led effect specified")
if not LED_SPEED_MIN <= led_speed <= LED_SPEED_MAX: if not LED_SPEED_MIN <= led_speed <= LED_SPEED_MAX:
raise ValueError("Invalid led speed specified") raise ValueError("Invalid led speed specified")
if not 0 <= brightness <= self.max_brightness: if not 0 <= brightness <= self.brightness_max:
raise ValueError("Invalid brightness specified") raise ValueError("Invalid brightness specified")
client = self._require_client() client = self._require_client()

View File

@@ -17,6 +17,8 @@ from .entity import OasisDeviceEntity
from .pyoasiscontrol import OasisDevice from .pyoasiscontrol import OasisDevice
from .pyoasiscontrol.const import AUTOPLAY_MAP, TRACKS from .pyoasiscontrol.const import AUTOPLAY_MAP, TRACKS
AUTOPLAY_MAP_LIST = list(AUTOPLAY_MAP)
def playlists_update_handler(entity: OasisDeviceSelectEntity) -> None: def playlists_update_handler(entity: OasisDeviceSelectEntity) -> None:
"""Handle playlists updates.""" """Handle playlists updates."""
@@ -96,15 +98,17 @@ DESCRIPTORS = (
OasisDeviceSelectEntityDescription( OasisDeviceSelectEntityDescription(
key="autoplay", key="autoplay",
translation_key="autoplay", translation_key="autoplay",
options=list(AUTOPLAY_MAP.values()), options=AUTOPLAY_MAP_LIST,
current_value=lambda device: device.autoplay, current_value=lambda device: str(device.autoplay),
select_fn=lambda device, option: device.async_set_autoplay(option), select_fn=lambda device, index: (
device.async_set_autoplay(AUTOPLAY_MAP_LIST[index])
),
), ),
OasisDeviceSelectEntityDescription( OasisDeviceSelectEntityDescription(
key="queue", key="queue",
translation_key="queue", translation_key="queue",
current_value=lambda device: (device.playlist.copy(), device.playlist_index), current_value=lambda device: (device.playlist.copy(), device.playlist_index),
select_fn=lambda device, option: device.async_change_track(option), select_fn=lambda device, index: device.async_change_track(index),
update_handler=queue_update_handler, update_handler=queue_update_handler,
), ),
) )
@@ -113,8 +117,8 @@ CLOUD_DESCRIPTORS = (
key="playlists", key="playlists",
translation_key="playlist", translation_key="playlist",
current_value=lambda device: (device.playlists, device.playlist.copy()), current_value=lambda device: (device.playlists, device.playlist.copy()),
select_fn=lambda device, option: device.async_set_playlist( select_fn=lambda device, index: device.async_set_playlist(
[pattern["id"] for pattern in device.playlists[option]["patterns"]] [pattern["id"] for pattern in device.playlists[index]["patterns"]]
), ),
update_handler=playlists_update_handler, update_handler=playlists_update_handler,
), ),
@@ -140,7 +144,6 @@ class OasisDeviceSelectEntity(OasisDeviceEntity, SelectEntity):
async def async_select_option(self, option: str) -> None: async def async_select_option(self, option: str) -> None:
"""Change the selected option.""" """Change the selected option."""
await self.entity_description.select_fn(self.device, self.options.index(option)) await self.entity_description.select_fn(self.device, self.options.index(option))
await self.coordinator.async_request_refresh()
@callback @callback
def _handle_coordinator_update(self) -> None: def _handle_coordinator_update(self) -> None:
@@ -152,8 +155,8 @@ class OasisDeviceSelectEntity(OasisDeviceEntity, SelectEntity):
if update_handler := self.entity_description.update_handler: if update_handler := self.entity_description.update_handler:
update_handler(self) update_handler(self)
else: else:
self._attr_current_option = getattr( self._attr_current_option = str(
self.device, self.entity_description.key getattr(self.device, self.entity_description.key)
) )
if self.hass: if self.hass:
return super()._handle_coordinator_update() return super()._handle_coordinator_update()

View File

@@ -52,8 +52,7 @@ DESCRIPTORS = {
entity_category=EntityCategory.DIAGNOSTIC, entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False, entity_registry_enabled_default=False,
) )
for key in ("error", "status") for key in ("error", "led_color_id", "status")
# for key in ("error", "led_color_id", "status")
# for key in ("error_message", "led_color_id", "status") # for key in ("error_message", "led_color_id", "status")
} }

View File

@@ -74,7 +74,18 @@
}, },
"select": { "select": {
"autoplay": { "autoplay": {
"name": "Autoplay" "name": "Autoplay",
"state": {
"0": "on",
"1": "off",
"2": "5 minutes",
"3": "10 minutes",
"4": "30 minutes",
"6": "1 hour",
"7": "6 hours",
"8": "12 hours",
"5": "24 hours"
}
}, },
"playlist": { "playlist": {
"name": "Playlist" "name": "Playlist"

View File

@@ -74,7 +74,18 @@
}, },
"select": { "select": {
"autoplay": { "autoplay": {
"name": "Autoplay" "name": "Autoplay",
"state": {
"0": "on",
"1": "off",
"2": "5 minutes",
"3": "10 minutes",
"4": "30 minutes",
"6": "1 hour",
"7": "6 hours",
"8": "12 hours",
"5": "24 hours"
}
}, },
"playlist": { "playlist": {
"name": "Playlist" "name": "Playlist"