diff --git a/custom_components/oasis_mini/__init__.py b/custom_components/oasis_mini/__init__.py old mode 100755 new mode 100644 index 204928b..2c8a42a --- a/custom_components/oasis_mini/__init__.py +++ b/custom_components/oasis_mini/__init__.py @@ -44,13 +44,31 @@ def setup_platform_from_coordinator( make_entities: Callable[[OasisDevice], Iterable[OasisDeviceEntity]], update_before_add: bool = False, ) -> None: - """Generic pattern: add entities per device, including newly discovered ones.""" + """ + Populate entities for devices managed by the coordinator and add entities for any devices discovered later. + + This registers a listener on the coordinator to detect newly discovered devices by serial number and calls `make_entities` to construct entity objects for those devices, passing them to `async_add_entities`. The initial device set is processed immediately; subsequent discoveries are handled via the coordinator listener. + + Parameters: + entry: Config entry containing the coordinator in its `runtime_data`. + async_add_entities: Home Assistant callback to add entities to the platform. + make_entities: Callable that accepts an iterable of `OasisDevice` objects and returns an iterable of `OasisDeviceEntity` instances to add. + update_before_add: If true, entities will be updated before being added. + """ coordinator = entry.runtime_data known_serials: set[str] = set() @callback def _check_devices() -> None: + """ + Detect newly discovered Oasis devices from the coordinator and register their entities. + + Scans the coordinator's current device list for devices with a serial number that has not + been seen before. For any newly discovered devices, creates entity instances via + make_entities and adds them to Home Assistant using async_add_entities with the + update_before_add flag. Does not return a value. + """ devices = coordinator.data or [] new_devices: list[OasisDevice] = [] @@ -75,7 +93,12 @@ def setup_platform_from_coordinator( async def async_setup_entry(hass: HomeAssistant, entry: OasisDeviceConfigEntry) -> bool: - """Set up Oasis devices from a config entry.""" + """ + Initialize Oasis cloud and MQTT integration for a config entry, create and refresh the device coordinator, register update listeners for discovered devices, forward platform setup, and update the entry's metadata as needed. + + Returns: + True if the config entry was set up successfully. + """ cloud_client = create_client(hass, entry.data) try: user = await cloud_client.async_get_user() @@ -101,6 +124,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: OasisDeviceConfigEntry) entry.runtime_data = coordinator def _on_oasis_update() -> None: + """ + Update the coordinator's last-updated timestamp and notify its listeners. + + Sets the coordinator's last_updated to the current time and triggers its update listeners so dependent entities and tasks refresh. + """ coordinator.last_updated = dt_util.now() coordinator.async_update_listeners() @@ -115,7 +143,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: OasisDeviceConfigEntry) async def async_unload_entry( hass: HomeAssistant, entry: OasisDeviceConfigEntry ) -> bool: - """Unload a config entry.""" + """ + Cleanly unload an Oasis device config entry. + + Closes the MQTT and cloud clients stored on the entry and unloads all supported platforms. + + Returns: + `True` if all platforms were unloaded successfully, `False` otherwise. + """ mqtt_client = entry.runtime_data.mqtt_client await mqtt_client.async_close() @@ -128,7 +163,11 @@ async def async_unload_entry( async def async_remove_entry( hass: HomeAssistant, entry: OasisDeviceConfigEntry ) -> None: - """Handle removal of an entry.""" + """ + Perform logout and cleanup for the cloud client associated with the config entry. + + Attempts to call the cloud client's logout method and logs any exception encountered, then ensures the client is closed. + """ cloud_client = create_client(hass, entry.data) try: await cloud_client.async_logout() @@ -138,7 +177,20 @@ async def async_remove_entry( async def async_migrate_entry(hass: HomeAssistant, entry: OasisDeviceConfigEntry): - """Migrate old entry.""" + """ + Migrate an Oasis config entry to the current schema (minor version 3). + + Performs in-place migrations for older entries: + - Renames select entity unique IDs ending with `-playlist` to `-queue`. + - When migrating to the auth-required schema, moves relevant options into entry data and clears options. + - Updates the config entry's data, options, minor_version, title (from CONF_EMAIL or "Oasis Control"), unique_id, and version. + + Parameters: + entry: The config entry to migrate. + + Returns: + `True` if migration succeeded, `False` if migration could not be performed (e.g., entry.version is greater than supported). + """ _LOGGER.debug( "Migrating configuration from version %s.%s", entry.version, entry.minor_version ) @@ -157,7 +209,15 @@ async def async_migrate_entry(hass: HomeAssistant, entry: OasisDeviceConfigEntry def migrate_unique_id( entity_entry: er.RegistryEntry, ) -> dict[str, Any] | None: - """Migrate the playlist unique ID to queue.""" + """ + Update a registry entry's unique_id suffix from "-playlist" to "-queue" when applicable. + + Parameters: + entity_entry (er.RegistryEntry): Registry entry to inspect. + + Returns: + dict[str, Any] | None: A mapping {"new_unique_id": } if the entry is in the "select" domain and its unique_id ends with "-playlist"; otherwise `None`. + """ if entity_entry.domain == "select" and entity_entry.unique_id.endswith( "-playlist" ): @@ -194,10 +254,19 @@ async def async_migrate_entry(hass: HomeAssistant, entry: OasisDeviceConfigEntry async def async_remove_config_entry_device( hass: HomeAssistant, config_entry: OasisDeviceConfigEntry, device_entry: DeviceEntry ) -> bool: - """Remove a config entry from a device.""" + """ + Determine whether the config entry is no longer associated with the given device. + + Parameters: + config_entry (OasisDeviceConfigEntry): The config entry whose runtime data contains device serial numbers. + device_entry (DeviceEntry): The device registry entry to check for matching identifiers. + + Returns: + bool: `true` if none of the device's identifiers match serial numbers present in the config entry's runtime data, `false` otherwise. + """ current_serials = {d.serial_number for d in (config_entry.runtime_data.data or [])} return not any( identifier for identifier in device_entry.identifiers if identifier[0] == DOMAIN and identifier[1] in current_serials - ) + ) \ No newline at end of file diff --git a/custom_components/oasis_mini/binary_sensor.py b/custom_components/oasis_mini/binary_sensor.py index a6f9b15..0fdbfb0 100644 --- a/custom_components/oasis_mini/binary_sensor.py +++ b/custom_components/oasis_mini/binary_sensor.py @@ -21,9 +21,25 @@ async def async_setup_entry( entry: OasisDeviceConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: - """Set up Oasis device sensors using config entry.""" + """ + Set up Oasis device binary sensor entities for a config entry. + + Registers a factory that creates an OasisDeviceBinarySensorEntity for each device and descriptor defined in DESCRIPTORS, and forwards those entities to Home Assistant via the provided add-entities callback. + + Parameters: + entry (OasisDeviceConfigEntry): Configuration entry for the Oasis integration containing runtime data and coordinator used to create entities. + """ def make_entities(new_devices: list[OasisDevice]): + """ + Create binary sensor entity instances for each provided Oasis device using the module's descriptors. + + Parameters: + new_devices (list[OasisDevice]): Devices to generate entities for. + + Returns: + list[OasisDeviceBinarySensorEntity]: A list of binary sensor entities pairing each device with every descriptor in DESCRIPTORS. + """ return [ OasisDeviceBinarySensorEntity(entry.runtime_data, device, descriptor) for device in new_devices @@ -55,5 +71,10 @@ class OasisDeviceBinarySensorEntity(OasisDeviceEntity, BinarySensorEntity): @property def is_on(self) -> bool: - """Return true if the binary sensor is on.""" - return getattr(self.device, self.entity_description.key) + """ + Indicates whether the binary sensor is currently active. + + Returns: + bool: True if the sensor is on, False otherwise. + """ + return getattr(self.device, self.entity_description.key) \ No newline at end of file diff --git a/custom_components/oasis_mini/button.py b/custom_components/oasis_mini/button.py index 8352128..f71103c 100644 --- a/custom_components/oasis_mini/button.py +++ b/custom_components/oasis_mini/button.py @@ -28,9 +28,24 @@ async def async_setup_entry( entry: OasisDeviceConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: - """Set up Oasis device button using config entry.""" + """ + Create and add button entities for each Oasis device defined in the config entry. + + Parameters: + entry (OasisDeviceConfigEntry): Config entry containing runtime data and registered Oasis devices. + async_add_entities (AddEntitiesCallback): Callback used to register the created entities with Home Assistant. + """ def make_entities(new_devices: list[OasisDevice]): + """ + Create button entities for each provided Oasis device using the module descriptors. + + Parameters: + new_devices (list[OasisDevice]): Devices to create button entities for. + + Returns: + list[OasisDeviceButtonEntity]: Button entity instances created for each device and each descriptor in DESCRIPTORS. + """ return [ OasisDeviceButtonEntity(entry.runtime_data, device, descriptor) for device in new_devices @@ -41,7 +56,17 @@ async def async_setup_entry( async def play_random_track(device: OasisDevice) -> None: - """Play random track.""" + """ + Play a random track on the given Oasis device. + + Selects a track at random from the available TRACKS and attempts to add it to the device's queue and play it. Raises HomeAssistantError if adding the track times out. + + Parameters: + device: The Oasis device on which to play the track. + + Raises: + HomeAssistantError: If adding the selected track to the device's queue times out. + """ track = random.choice(list(TRACKS)) try: await add_and_play_track(device, track) @@ -82,5 +107,9 @@ class OasisDeviceButtonEntity(OasisDeviceEntity, ButtonEntity): entity_description: OasisDeviceButtonEntityDescription async def async_press(self) -> None: - """Press the button.""" - await self.entity_description.press_fn(self.device) + """ + Trigger the button's configured action on the associated device. + + Calls the entity description's `press_fn` with the device to perform the button's effect. + """ + await self.entity_description.press_fn(self.device) \ No newline at end of file diff --git a/custom_components/oasis_mini/config_flow.py b/custom_components/oasis_mini/config_flow.py old mode 100755 new mode 100644 index bdc420f..9a9b3bc --- a/custom_components/oasis_mini/config_flow.py +++ b/custom_components/oasis_mini/config_flow.py @@ -33,13 +33,28 @@ class OasisDeviceConfigFlow(ConfigFlow, domain=DOMAIN): async def async_step_reauth( self, entry_data: Mapping[str, Any] ) -> ConfigFlowResult: - """Perform reauth upon an API authentication error.""" + """ + Begin the reauthentication flow for an existing config entry. + + Parameters: + entry_data (Mapping[str, Any]): Data from the existing config entry that triggered the reauthentication flow. + + Returns: + ConfigFlowResult: Result that presents the reauthentication confirmation dialog to the user. + """ return await self.async_step_reauth_confirm() async def async_step_reauth_confirm( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: - """Dialog that informs the user that reauth is required.""" + """ + Present a reauthentication confirmation form to the user. + + If `user_input` is provided it will be used as the form values; otherwise the existing entry's data are used as suggested values. + + Returns: + ConfigFlowResult: Result of the config flow step that renders the reauthentication form or advances the flow. + """ entry = self.hass.config_entries.async_get_entry(self.context["entry_id"]) assert entry @@ -51,7 +66,15 @@ class OasisDeviceConfigFlow(ConfigFlow, domain=DOMAIN): async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: - """Handle the initial step.""" + """ + Handle the initial user configuration step for the Oasis integration. + + Parameters: + user_input (dict[str, Any] | None): Optional prefilled values (e.g., `email`, `password`) submitted by the user. + + Returns: + ConfigFlowResult: Result of the "user" step — a form prompting for credentials, an abort, or a created/updated config entry. + """ return await self._async_step( "user", STEP_USER_DATA_SCHEMA, user_input, user_input ) @@ -75,7 +98,20 @@ class OasisDeviceConfigFlow(ConfigFlow, domain=DOMAIN): user_input: dict[str, Any] | None = None, suggested_values: dict[str, Any] | None = None, ) -> ConfigFlowResult: - """Handle step setup.""" + """ + Handle a single config flow step: validate input, create or update entries, or render the form. + + If valid credentials are provided, this will create a new config entry (title set to the provided email) or update an existing entry and trigger a reload. The step will abort if the validated account conflicts with an existing entry's unique ID. If no input is provided or validation fails, the flow returns a form populated with the given schema, any suggested values, and validation errors. + + Parameters: + step_id: Identifier of the flow step to render or process. + schema: Voluptuous schema used to build the form. + user_input: Submitted values from the form; when present, used for validation and entry creation/update. + suggested_values: Values to pre-fill into the form schema when rendering. + + Returns: + A ConfigFlowResult representing either a created entry, an update-and-reload abort, an abort due to a unique-id conflict, or a form to display with errors and suggested values. + """ errors = {} if user_input is not None: @@ -105,7 +141,21 @@ class OasisDeviceConfigFlow(ConfigFlow, domain=DOMAIN): ) async def validate_client(self, user_input: dict[str, Any]) -> dict[str, str]: - """Validate client setup.""" + """ + Validate provided credentials by attempting to authenticate with the Oasis API and retrieve the user's identity. + + Parameters: + user_input (dict[str, Any]): Mutable credential mapping containing at least `email` and `password`. + On success, this mapping will be updated with `CONF_ACCESS_TOKEN` (the received access token) + and the `password` key will be removed. + + Returns: + dict[str, str]: A mapping of form field names to error keys. Common keys: + - `"base": "invalid_auth"` when credentials are incorrect or connection refused. + - `"base": "timeout_connect"` when the authentication request times out. + - `"base": "unknown"` for unexpected errors. + - `"base": ""` when the server returns an HTTP error. + """ errors = {} try: async with asyncio.timeout(10): @@ -134,4 +184,4 @@ class OasisDeviceConfigFlow(ConfigFlow, domain=DOMAIN): errors["base"] = "unknown" finally: await client.async_close() - return errors + return errors \ No newline at end of file diff --git a/custom_components/oasis_mini/coordinator.py b/custom_components/oasis_mini/coordinator.py index 9c4a43b..41a60a2 100644 --- a/custom_components/oasis_mini/coordinator.py +++ b/custom_components/oasis_mini/coordinator.py @@ -30,7 +30,13 @@ class OasisDeviceCoordinator(DataUpdateCoordinator[list[OasisDevice]]): cloud_client: OasisCloudClient, mqtt_client: OasisMqttClient, ) -> None: - """Initialize.""" + """ + Create an OasisDeviceCoordinator that manages OasisDevice discovery and updates using cloud and MQTT clients. + + Parameters: + cloud_client (OasisCloudClient): Client for communicating with the Oasis cloud API and fetching device data. + mqtt_client (OasisMqttClient): Client for registering devices and coordinating MQTT-based readiness/status. + """ super().__init__( hass, _LOGGER, @@ -42,7 +48,15 @@ class OasisDeviceCoordinator(DataUpdateCoordinator[list[OasisDevice]]): self.mqtt_client = mqtt_client async def _async_update_data(self) -> list[OasisDevice]: - """Update the data.""" + """ + Fetch and assemble the current list of OasisDevice objects, reconcile removed devices in Home Assistant, register discovered devices with MQTT, and verify per-device readiness. + + Returns: + A list of OasisDevice instances representing devices currently available for the account. + + Raises: + UpdateFailed: If no devices can be read after repeated attempts or an unexpected error persists past retry limits. + """ devices: list[OasisDevice] = [] self.attempt += 1 @@ -157,4 +171,4 @@ class OasisDeviceCoordinator(DataUpdateCoordinator[list[OasisDevice]]): if devices != self.data: self.last_updated = dt_util.now() - return devices + return devices \ No newline at end of file diff --git a/custom_components/oasis_mini/entity.py b/custom_components/oasis_mini/entity.py index bdc3bd5..2a6a511 100644 --- a/custom_components/oasis_mini/entity.py +++ b/custom_components/oasis_mini/entity.py @@ -22,7 +22,16 @@ class OasisDeviceEntity(CoordinatorEntity[OasisDeviceCoordinator]): device: OasisDevice, description: EntityDescription, ) -> None: - """Construct an Oasis device entity.""" + """ + Initialize an entity representing an Oasis device. + + Sets the entity's unique_id from the device serial number and the provided description key, stores the given device on the entity, and constructs DeviceInfo containing identifiers, name, manufacturer, model, software version, and a network MAC connection if the device exposes a MAC address. + + Parameters: + coordinator: The coordinator responsible for updating the device state. + device: OasisDevice instance providing metadata and identifiers (serial_number, mac_address, name, manufacturer, model, software_version). + description: EntityDescription used to derive the entity key for the unique_id. + """ super().__init__(coordinator) self.device = device self.entity_description = description @@ -42,4 +51,4 @@ class OasisDeviceEntity(CoordinatorEntity[OasisDeviceCoordinator]): model=device.model, serial_number=serial_number, sw_version=device.software_version, - ) + ) \ No newline at end of file diff --git a/custom_components/oasis_mini/helpers.py b/custom_components/oasis_mini/helpers.py old mode 100755 new mode 100644 index 0cc5f75..9120ba4 --- a/custom_components/oasis_mini/helpers.py +++ b/custom_components/oasis_mini/helpers.py @@ -19,13 +19,33 @@ _LOGGER = logging.getLogger(__name__) def create_client(hass: HomeAssistant, data: dict[str, Any]) -> OasisCloudClient: - """Create a Oasis cloud client.""" + """ + Create an Oasis cloud client configured with the Home Assistant HTTP session and access token. + + Parameters: + hass: Home Assistant instance used to obtain the shared HTTP client session. + data: Configuration mapping; the function reads the `CONF_ACCESS_TOKEN` key for the cloud access token. + + Returns: + An `OasisCloudClient` initialized with the Home Assistant HTTP session and the configured access token. + """ session = async_get_clientsession(hass) return OasisCloudClient(session=session, access_token=data.get(CONF_ACCESS_TOKEN)) async def add_and_play_track(device: OasisDevice, track: int) -> None: - """Add and play a track.""" + """ + Ensure a track is present in the device playlist, position it as the next item, select it, and start playback if necessary. + + Adds the specified track to the device playlist if missing, waits up to 10 seconds for the track to appear, moves it to be the next item after the current playlist index if needed, selects that track, and starts playback when the device is not already playing. + + Parameters: + device (OasisDevice): The target Oasis device. + track (int): The track id to add and play. + + Raises: + async_timeout.TimeoutError: If the operation does not complete within 10 seconds. + """ async with async_timeout.timeout(10): if track not in device.playlist: await device.async_add_track_to_playlist(track) @@ -46,9 +66,14 @@ async def add_and_play_track(device: OasisDevice, track: int) -> None: def get_track_id(track: str) -> int | None: - """Get a track id. - - `track` can be either an id or title + """ + Convert a track identifier or title to its integer track id. + + Parameters: + track: A track reference, either a numeric id as a string or a track title. + + Returns: + The integer track id if the input is a valid id or matches a known title, `None` if the input is invalid. """ track = track.lower().strip() if track not in map(str, TRACKS): @@ -60,4 +85,4 @@ def get_track_id(track: str) -> int | None: return int(track) except ValueError: _LOGGER.warning("Invalid track: %s", track) - return None + return None \ No newline at end of file diff --git a/custom_components/oasis_mini/image.py b/custom_components/oasis_mini/image.py index 7e32f16..4250ac1 100644 --- a/custom_components/oasis_mini/image.py +++ b/custom_components/oasis_mini/image.py @@ -19,9 +19,27 @@ async def async_setup_entry( entry: OasisDeviceConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: - """Set up Oasis device image using config entry.""" + """ + Set up image entities for Oasis devices from a config entry. + + Creates an OasisDeviceImageEntity for each device in the entry's runtime data and registers them with Home Assistant. + + Parameters: + hass (HomeAssistant): Home Assistant core instance. + entry (OasisDeviceConfigEntry): Config entry containing runtime data and device registrations. + async_add_entities (AddEntitiesCallback): Callback to add created entities to Home Assistant. + """ def make_entities(new_devices: list[OasisDevice]): + """ + Create an Image entity for each OasisDevice using the enclosing config entry's runtime data. + + Parameters: + new_devices (list[OasisDevice]): Devices to create image entities for. + + Returns: + list[OasisDeviceImageEntity]: A list of image entity instances, one per device. + """ return [ OasisDeviceImageEntity(entry.runtime_data, device, IMAGE) for device in new_devices @@ -45,13 +63,29 @@ class OasisDeviceImageEntity(OasisDeviceEntity, ImageEntity): device: OasisDevice, description: ImageEntityDescription, ) -> None: - """Initialize the entity.""" + """ + Create an Oasis device image entity tied to a coordinator and a specific device. + + Initializes the entity with the provided coordinator, device, and image description and synchronizes its initial state from the coordinator. + + Parameters: + coordinator (OasisDeviceCoordinator): Coordinator providing updates and Home Assistant context. + device (OasisDevice): The Oasis device this entity represents. + description (ImageEntityDescription): Metadata describing the image entity. + """ super().__init__(coordinator, device, description) ImageEntity.__init__(self, coordinator.hass) self._handle_coordinator_update() def image(self) -> bytes | None: - """Return bytes of image.""" + """ + Provide the entity's image bytes, generating and caching an SVG from the device when available. + + If the device cannot produce an SVG, the entity's image URL and last-updated timestamp are set and no bytes are returned. When an SVG is produced, the content type is set to "image/svg+xml" and the SVG bytes are cached for future calls. + + Returns: + bytes: The image content bytes, or `None` if no image is available yet. + """ if not self._cached_image: if (svg := self.device.create_svg()) is None: self._attr_image_url = self.device.track_image_url @@ -63,7 +97,11 @@ class OasisDeviceImageEntity(OasisDeviceEntity, ImageEntity): @callback def _handle_coordinator_update(self) -> None: - """Handle updated data from the coordinator.""" + """ + Update image metadata and cached image when the coordinator reports changes to the device's track or progress. + + If the device's track_id or progress changed and updates are allowed (the device is playing or there is no cached image), update image last-updated timestamp, record the new track_id and progress, clear the cached image to force regeneration, and set the image URL to UNDEFINED when the track contains inline SVG content or to the device's track_image_url otherwise. When Home Assistant is available, propagate the update to the base class handler. + """ device = self.device track_changed = self._track_id != device.track_id @@ -82,4 +120,4 @@ class OasisDeviceImageEntity(OasisDeviceEntity, ImageEntity): self._attr_image_url = device.track_image_url if self.hass: - super()._handle_coordinator_update() + super()._handle_coordinator_update() \ No newline at end of file diff --git a/custom_components/oasis_mini/light.py b/custom_components/oasis_mini/light.py index 5fcc394..2953e0c 100644 --- a/custom_components/oasis_mini/light.py +++ b/custom_components/oasis_mini/light.py @@ -37,6 +37,15 @@ async def async_setup_entry( """Set up Oasis device lights using config entry.""" def make_entities(new_devices: list[OasisDevice]): + """ + Create OasisDeviceLightEntity instances for each provided Oasis device. + + Parameters: + new_devices (list[OasisDevice]): Devices to wrap as light entities. + + Returns: + list[OasisDeviceLightEntity]: A list of light entity instances corresponding to the input devices. + """ return [ OasisDeviceLightEntity(entry.runtime_data, device, DESCRIPTOR) for device in new_devices @@ -55,7 +64,12 @@ class OasisDeviceLightEntity(OasisDeviceEntity, LightEntity): @property def brightness(self) -> int: - """Return the brightness of this light between 0..255.""" + """ + Get the light's brightness on a 0–255 scale. + + Returns: + int: Brightness value between 0 and 255. + """ scale = (1, self.device.brightness_max) return value_to_brightness(scale, self.device.brightness) @@ -104,7 +118,24 @@ class OasisDeviceLightEntity(OasisDeviceEntity, LightEntity): await self.device.async_set_led(brightness=0) async def async_turn_on(self, **kwargs: Any) -> None: - """Turn the entity on.""" + """ + Turn the light on and set its LED state. + + Processes optional keyword arguments to compute the device-specific LED parameters, then updates the device's LEDs with the resulting brightness, color, and effect. + + Parameters: + kwargs: Optional control parameters recognized by the method: + ATTR_BRIGHTNESS (int): Brightness in the 0–255 Home Assistant scale. When provided, + it is converted and rounded up to the device's brightness scale (1..device.brightness_max). + When omitted, uses self.device.brightness or self.device.brightness_on. + ATTR_RGB_COLOR (tuple[int, int, int]): RGB tuple (R, G, B). When provided, it is + converted to a hex color string prefixed with '#'. + ATTR_EFFECT (str): Human-readable effect name. When provided, it is mapped to the + device's internal effect key; if no mapping exists, `None` is used. + + Side effects: + Updates the underlying device LED state with the computed `brightness`, `color`, and `led_effect`. + """ if brightness := kwargs.get(ATTR_BRIGHTNESS): scale = (1, self.device.brightness_max) brightness = math.ceil(brightness_to_value(scale, brightness)) @@ -121,4 +152,4 @@ class OasisDeviceLightEntity(OasisDeviceEntity, LightEntity): await self.device.async_set_led( brightness=brightness, color=color, led_effect=led_effect - ) + ) \ No newline at end of file diff --git a/custom_components/oasis_mini/media_player.py b/custom_components/oasis_mini/media_player.py index 4d2efab..a025052 100644 --- a/custom_components/oasis_mini/media_player.py +++ b/custom_components/oasis_mini/media_player.py @@ -33,6 +33,15 @@ async def async_setup_entry( """Set up Oasis device media_players using config entry.""" def make_entities(new_devices: list[OasisDevice]): + """ + Create media player entities for the given Oasis devices. + + Parameters: + new_devices (list[OasisDevice]): Devices to wrap as media player entities. + + Returns: + list[OasisDeviceMediaPlayerEntity]: Media player entities corresponding to each device. + """ return [ OasisDeviceMediaPlayerEntity(entry.runtime_data, device, DESCRIPTOR) for device in new_devices @@ -74,12 +83,22 @@ class OasisDeviceMediaPlayerEntity(OasisDeviceEntity, MediaPlayerEntity): @property def media_image_url(self) -> str | None: - """Image url of current playing media.""" + """ + URL of the image representing the currently playing media. + + Returns: + The image URL as a string, or `None` if no image is available. + """ return self.device.track_image_url @property def media_position(self) -> int: - """Position of current playing media in seconds.""" + """ + Playback position of the current media in seconds. + + Returns: + int: Position in seconds of the currently playing media. + """ return self.device.progress @property @@ -89,12 +108,22 @@ class OasisDeviceMediaPlayerEntity(OasisDeviceEntity, MediaPlayerEntity): @property def media_title(self) -> str | None: - """Title of current playing media.""" + """ + Provide the title of the currently playing track. + + Returns: + str | None: The track title, or None if no title is available. + """ return self.device.track_name @property def repeat(self) -> RepeatMode: - """Return current repeat mode.""" + """ + Get the current repeat mode for the device. + + Returns: + `RepeatMode.ALL` if the device is configured to repeat the playlist, `RepeatMode.OFF` otherwise. + """ return RepeatMode.ALL if self.device.repeat_playlist else RepeatMode.OFF @property @@ -125,36 +154,71 @@ class OasisDeviceMediaPlayerEntity(OasisDeviceEntity, MediaPlayerEntity): ) async def async_media_pause(self) -> None: - """Send pause command.""" + """ + Pause playback on the device. + + Raises: + ServiceValidationError: If the device is busy and cannot accept commands. + """ self.abort_if_busy() await self.device.async_pause() async def async_media_play(self) -> None: - """Send play command.""" + """ + Start playback on the device. + + Raises: + ServiceValidationError: If the device is currently busy. + """ self.abort_if_busy() await self.device.async_play() async def async_media_stop(self) -> None: - """Send stop command.""" + """ + Stop playback on the Oasis device. + + Raises: + ServiceValidationError: If the device is currently busy. + """ self.abort_if_busy() await self.device.async_stop() async def async_set_repeat(self, repeat: RepeatMode) -> None: - """Set repeat mode.""" + """ + Set the device playlist repeat behavior. + + Enables or disables looping of the playlist according to the provided RepeatMode: + - RepeatMode.OFF disables playlist repeat. + - RepeatMode.ALL enables playlist repeat for the entire playlist. + - RepeatMode.ONE enables single-track repeat, except when the device is currently repeating the entire playlist; in that case the playlist repeat is disabled to preserve single-track semantics. + + Parameters: + repeat (RepeatMode): The desired repeat mode to apply to the device playlist. + """ await self.device.async_set_repeat_playlist( repeat != RepeatMode.OFF and not (repeat == RepeatMode.ONE and self.repeat == RepeatMode.ALL) ) async def async_media_previous_track(self) -> None: - """Send previous track command.""" + """ + Move playback to the previous track in the device's playlist, wrapping to the last track when currently at the first. + + Raises: + ServiceValidationError: If the device is busy. + """ self.abort_if_busy() if (index := self.device.playlist_index - 1) < 0: index = len(self.device.playlist) - 1 await self.device.async_change_track(index) async def async_media_next_track(self) -> None: - """Send next track command.""" + """ + Advance the device to the next track in its playlist, wrapping to the first track when at the end. + + Raises: + ServiceValidationError: if the device is busy. + """ self.abort_if_busy() if (index := self.device.playlist_index + 1) >= len(self.device.playlist): index = 0 @@ -167,7 +231,19 @@ class OasisDeviceMediaPlayerEntity(OasisDeviceEntity, MediaPlayerEntity): enqueue: MediaPlayerEnqueue | None = None, **kwargs: Any, ) -> None: - """Play a piece of media.""" + """ + Play or enqueue one or more Oasis tracks on the device. + + Validates the media type and parses one or more track identifiers from `media_id`, then updates the device playlist according to `enqueue`. Depending on the enqueue mode the method can replace the playlist, append tracks, move appended tracks to the next play position, and optionally start playback. + + Parameters: + media_type (MediaType | str): The media type being requested. + media_id (str): A comma-separated string of track identifiers. + enqueue (MediaPlayerEnqueue | None): How to insert the tracks into the playlist; if omitted defaults to NEXT. + + Raises: + ServiceValidationError: If the device is busy, if `media_type` is a playlist (playlists are unsupported), or if `media_id` does not contain any valid track identifiers. + """ self.abort_if_busy() if media_type == MediaType.PLAYLIST: raise ServiceValidationError( @@ -209,6 +285,11 @@ class OasisDeviceMediaPlayerEntity(OasisDeviceEntity, MediaPlayerEntity): await device.async_play() async def async_clear_playlist(self) -> None: - """Clear players playlist.""" + """ + Clear the device's playlist. + + Raises: + ServiceValidationError: If the device is busy and cannot accept commands. + """ self.abort_if_busy() - await self.device.async_clear_playlist() + await self.device.async_clear_playlist() \ No newline at end of file diff --git a/custom_components/oasis_mini/number.py b/custom_components/oasis_mini/number.py index 38ba738..2b4c721 100644 --- a/custom_components/oasis_mini/number.py +++ b/custom_components/oasis_mini/number.py @@ -27,9 +27,22 @@ async def async_setup_entry( entry: OasisDeviceConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: - """Set up Oasis device numbers using config entry.""" + """ + Set up number entities for Oasis devices from a configuration entry. + + Creates number entities for each discovered Oasis device and each descriptor in DESCRIPTORS, then registers those entities with the platform coordinator so they are added to Home Assistant. + """ def make_entities(new_devices: list[OasisDevice]): + """ + Create number entity instances for each provided Oasis device using the module's DESCRIPTORS. + + Parameters: + new_devices (list[OasisDevice]): Devices to create entities for. + + Returns: + list[OasisDeviceNumberEntity]: A flat list of number entities (one per descriptor for each device). + """ return [ OasisDeviceNumberEntity(entry.runtime_data, device, descriptor) for device in new_devices @@ -64,13 +77,25 @@ class OasisDeviceNumberEntity(OasisDeviceEntity, NumberEntity): @property def native_value(self) -> str | None: - """Return the value reported by the number.""" + """ + Get the current value of the number entity from the underlying device. + + Returns: + str | None: The current value as a string, or `None` if the device has no value. + """ return getattr(self.device, self.entity_description.key) async def async_set_native_value(self, value: float) -> None: - """Set new value.""" + """ + Set the configured numeric value on the underlying Oasis device. + + The provided value is converted to an integer and applied to the device property indicated by this entity's description key: if the key is "ball_speed" the device's ball speed is updated; if the key is "led_speed" the device's LED speed is updated. + + Parameters: + value (float): New numeric value to apply; will be converted to an integer. + """ value = int(value) if self.entity_description.key == "ball_speed": await self.device.async_set_ball_speed(value) 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) \ No newline at end of file diff --git a/custom_components/oasis_mini/pyoasiscontrol/clients/cloud_client.py b/custom_components/oasis_mini/pyoasiscontrol/clients/cloud_client.py index ac9b3e3..420176e 100644 --- a/custom_components/oasis_mini/pyoasiscontrol/clients/cloud_client.py +++ b/custom_components/oasis_mini/pyoasiscontrol/clients/cloud_client.py @@ -40,6 +40,15 @@ class OasisCloudClient: session: ClientSession | None = None, access_token: str | None = None, ) -> None: + """ + Initialize the OasisCloudClient. + + Sets the optional aiohttp session and access token, records whether the client owns the session, and initializes caches and asyncio locks for playlists and software metadata. + + Parameters: + session (ClientSession | None): Optional aiohttp ClientSession to use. If None, the client will create and own a session later. + access_token (str | None): Optional initial access token for authenticated requests. + """ self._session = session self._owns_session = session is None self._access_token = access_token @@ -58,27 +67,52 @@ class OasisCloudClient: @property def session(self) -> ClientSession: - """Return (or lazily create) the aiohttp ClientSession.""" + """ + Get the active aiohttp ClientSession, creating and owning a new session if none exists or the existing session is closed. + + Returns: + ClientSession: The active aiohttp ClientSession; a new session is created and marked as owned by this client when necessary. + """ 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).""" + """ + Close the aiohttp ClientSession owned by this client if it exists and is open. + + This should be called during teardown when the client is responsible for the session. + """ if self._session and not self._session.closed and self._owns_session: await self._session.close() @property def access_token(self) -> str | None: + """ + Access token used for authenticated requests or None if not set. + + Returns: + The current access token string, or `None` if no token is stored. + """ return self._access_token @access_token.setter def access_token(self, value: str | None) -> None: + """ + Set the access token used for authenticated requests. + + Parameters: + value (str | None): The bearer token to store; pass None to clear the stored token. + """ self._access_token = value async def async_login(self, email: str, password: str) -> None: - """Login via the cloud and store the access token.""" + """ + Log in to the Oasis cloud and store the received access token on the client. + + Performs an authentication request using the provided credentials and saves the returned access token to self.access_token for use in subsequent authenticated requests. + """ response = await self._async_request( "POST", urljoin(BASE_URL, "api/auth/login"), @@ -89,25 +123,58 @@ class OasisCloudClient: _LOGGER.debug("Cloud login succeeded, token set: %s", bool(token)) async def async_logout(self) -> None: - """Logout from the cloud.""" + """ + End the current authenticated session with the Oasis cloud. + + Performs a logout request and clears the stored access token on success. + """ 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 information about the currently authenticated user. + + Returns: + dict: A mapping containing the user's details as returned by the cloud API. + + Raises: + UnauthenticatedError: If no access token is available or the request is unauthorized. + """ 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).""" + """ + Retrieve the current user's devices from the cloud API. + + Returns: + list[dict[str, Any]]: A list of device objects as returned by the 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).""" + """ + Retrieve playlists from the Oasis cloud, optionally limited to the authenticated user's personal playlists. + + The result is cached and will be refreshed according to PLAYLISTS_REFRESH_LIMITER to avoid frequent network requests. + + Parameters: + personal_only (bool): If True, return only playlists owned by the authenticated user; otherwise return all available playlists. + + Returns: + list[dict[str, Any]]: A list of playlist objects represented as dictionaries; an empty list if no playlists are available. + """ now_dt = now() def _is_cache_valid() -> bool: + """ + Determine whether the playlists cache is still valid. + + Returns: + `true` if the playlists cache contains data and the next refresh timestamp is later than the current time, `false` otherwise. + """ return self._playlists_next_refresh > now_dt and bool(self.playlists) if _is_cache_valid(): @@ -133,7 +200,12 @@ class OasisCloudClient: return self.playlists async def async_get_track_info(self, track_id: int) -> dict[str, Any] | None: - """Get single track info from the cloud.""" + """ + Retrieve information for a single track from the cloud. + + Returns: + dict: Track detail dictionary. If the track is not found (HTTP 404), returns a dict with keys `id` and `name` where `name` is "Unknown Title (#{id})". Returns `None` on other failures. + """ try: return await self._async_auth_request("GET", f"api/track/{track_id}") except ClientResponseError as err: @@ -146,7 +218,15 @@ class OasisCloudClient: async def async_get_tracks( self, tracks: list[int] | None = None ) -> list[dict[str, Any]]: - """Get multiple tracks info from the cloud (handles pagination).""" + """ + Retrieve track details for the given track IDs, following pagination until all pages are fetched. + + Parameters: + tracks (list[int] | None): Optional list of track IDs to request. If omitted or None, an empty list is sent to the API. + + Returns: + list[dict[str, Any]]: A list of track detail dictionaries returned by the cloud, aggregated across all pages (may be empty). + """ response = await self._async_auth_request( "GET", "api/track", @@ -163,10 +243,24 @@ class OasisCloudClient: async def async_get_latest_software_details( self, *, force_refresh: bool = False ) -> dict[str, int | str] | None: - """Get latest software metadata from cloud (cached).""" + """ + Retrieve the latest software metadata from the cloud, using an internal cache to limit requests. + + Parameters: + force_refresh (bool): If True, bypass the cache and fetch fresh metadata from the cloud. + + Returns: + details (dict[str, int | str] | None): A mapping of software metadata keys to integer or string values, or `None` if no metadata is available. + """ now_dt = now() def _is_cache_valid() -> bool: + """ + Determine whether the cached software metadata should be used instead of fetching fresh data. + + Returns: + True if the software cache exists, has not expired, and a force refresh was not requested; False otherwise. + """ return ( not force_refresh and self._software_details is not None @@ -193,7 +287,23 @@ class OasisCloudClient: return self._software_details async def _async_auth_request(self, method: str, url: str, **kwargs: Any) -> Any: - """Perform an authenticated cloud request.""" + """ + Perform a cloud API request using the stored access token. + + If `url` is relative it will be joined with the module `BASE_URL`. The method will + inject an `Authorization: Bearer ` header into the request. + + Parameters: + method (str): HTTP method (e.g., "GET", "POST"). + url (str): Absolute URL or path relative to `BASE_URL`. + **kwargs: Passed through to the underlying request helper. + + Returns: + The parsed response value (JSON object, text, or None) as returned by the underlying request helper. + + Raises: + UnauthenticatedError: If no access token is set. + """ if not self.access_token: raise UnauthenticatedError("Unauthenticated") @@ -208,7 +318,30 @@ class OasisCloudClient: ) async def _async_request(self, method: str, url: str, **kwargs: Any) -> Any: - """Low-level HTTP helper for both cloud and (if desired) device HTTP.""" + """ + Perform a single HTTP request and return a normalized response value. + + Performs the request using the client's session and: + - If the response status is 200: + - returns parsed JSON for `application/json`. + - returns text for `text/plain`. + - if `text/html` and the URL targets the cloud base URL and contains a login page, raises UnauthenticatedError. + - returns `None` for other content types. + - If the response status is 401, raises UnauthenticatedError. + - For other non-200 statuses, re-raises the response's HTTP error. + + Parameters: + method: HTTP method to use (e.g., "GET", "POST"). + url: Request URL or path. + **kwargs: Passed through to the session request (e.g., `params`, `json`, `headers`). + + Returns: + The parsed JSON object, response text, or `None` depending on the response content type. + + Raises: + UnauthenticatedError: when the server indicates the client is unauthenticated (401) or a cloud login page is returned. + aiohttp.ClientResponseError: for other non-success HTTP statuses raised by `response.raise_for_status()`. + """ session = self.session _LOGGER.debug( "%s %s", @@ -233,4 +366,4 @@ class OasisCloudClient: if response.status == 401: raise UnauthenticatedError("Unauthenticated") - response.raise_for_status() + response.raise_for_status() \ No newline at end of file diff --git a/custom_components/oasis_mini/pyoasiscontrol/clients/http_client.py b/custom_components/oasis_mini/pyoasiscontrol/clients/http_client.py index db6159f..1bbdacc 100644 --- a/custom_components/oasis_mini/pyoasiscontrol/clients/http_client.py +++ b/custom_components/oasis_mini/pyoasiscontrol/clients/http_client.py @@ -22,29 +22,64 @@ class OasisHttpClient(OasisClientProtocol): """ def __init__(self, host: str, session: ClientSession | None = None) -> None: + """ + Initialize the HTTP client for a specific device host. + + Parameters: + host (str): Hostname or IP address of the target device (used to build the base HTTP URL). + session (ClientSession | None): Optional aiohttp ClientSession to reuse for requests. If omitted, a new session will be created and owned by this client. + """ self._host = host self._session: ClientSession | None = session self._owns_session: bool = session is None @property def session(self) -> ClientSession: + """ + Ensure and return a usable aiohttp ClientSession for this client. + + If no session exists or the existing session is closed, a new ClientSession is created and the client records ownership of that session so it can be closed later. + + Returns: + An active aiohttp ClientSession instance associated with this client. + """ 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.""" + """ + Close the client's owned HTTP session if one exists and is open. + + Does nothing when there is no session, the session is already closed, or the client does not own the 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 + """ + Base HTTP URL for the target device. + + Returns: + The device base URL using plain HTTP (no TLS), including a trailing slash (e.g. "http://{host}/"). + """ return f"http://{self._host}/" async def _async_request(self, method: str, url: str, **kwargs: Any) -> Any: - """Low-level HTTP helper.""" + """ + Perform an HTTP request using the client's session and decode the response. + + Logs the request URL and query parameters. If the response status is 200, returns the response body as a string for `text/plain`, the parsed JSON for `application/json`, or `None` for other content types. On non-200 responses, raises the client response error. + + Returns: + The response body as `str` for `text/plain`, the parsed JSON value for `application/json`, or `None` for other content types. + + Raises: + aiohttp.ClientResponseError: If the response status is not 200. + """ session = self.session _LOGGER.debug( "%s %s", @@ -65,15 +100,38 @@ class OasisHttpClient(OasisClientProtocol): resp.raise_for_status() async def _async_get(self, **kwargs: Any) -> str | None: + """ + Perform a GET request to the client's base URL using the provided request keyword arguments. + + Parameters: + **kwargs: Additional request keyword arguments forwarded to the underlying request (for example `params`, `headers`, `timeout`). + + Returns: + `str` response text when the server responds with `text/plain`, `None` otherwise. + """ return await self._async_request("GET", self.url, **kwargs) async def _async_command(self, **kwargs: Any) -> str | None: + """ + Execute a device command by issuing a GET request with the provided query parameters and return the parsed response. + + Parameters: + **kwargs: Mapping of query parameter names to values sent with the GET request. + + Returns: + str | None: The device response as a string, a parsed JSON value, or None when the response has an unsupported content type. + """ 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.""" + """ + Fetch the device MAC address using the device's HTTP GETMAC endpoint. + + Returns: + str: The MAC address with surrounding whitespace removed, or `None` if it could not be retrieved. + """ try: mac = await self._async_get(params={"GETMAC": ""}) if isinstance(mac, str): @@ -89,6 +147,13 @@ class OasisHttpClient(OasisClientProtocol): device: OasisDevice, speed: int, ) -> None: + """ + Send a ball speed command to the specified device. + + Parameters: + device (OasisDevice): Target device for the command. + speed (int): Speed value to set for the device's ball mechanism. + """ await self._async_command(params={"WRIOASISSPEED": speed}) async def async_send_led_command( @@ -99,10 +164,25 @@ class OasisHttpClient(OasisClientProtocol): led_speed: int, brightness: int, ) -> None: + """ + Send an LED control command to the device. + + Parameters: + device (OasisDevice): Target device to receive the command. + led_effect (str): Effect name or identifier to apply to the LEDs. + color (str): Color value recognized by the device (e.g., hex code or device color name). + led_speed (int): Animation speed value; larger values increase animation speed. + brightness (int): Brightness level for the LEDs. + """ 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: + """ + Send a sleep command to the device. + + Requests the device to enter sleep mode. + """ await self._async_command(params={"CMDSLEEP": ""}) async def async_send_move_job_command( @@ -111,6 +191,14 @@ class OasisHttpClient(OasisClientProtocol): from_index: int, to_index: int, ) -> None: + """ + Move a job in the device's playlist from one index to another. + + Parameters: + device (OasisDevice): Target device whose job list will be modified. + from_index (int): Zero-based index of the job to move. + to_index (int): Zero-based destination index where the job will be placed. + """ await self._async_command(params={"MOVEJOB": f"{from_index};{to_index}"}) async def async_send_change_track_command( @@ -118,6 +206,12 @@ class OasisHttpClient(OasisClientProtocol): device: OasisDevice, index: int, ) -> None: + """ + Change the device's current track to the specified track index. + + Parameters: + index (int): Zero-based index of the track to select. + """ await self._async_command(params={"CMDCHANGETRACK": index}) async def async_send_add_joblist_command( @@ -126,6 +220,15 @@ class OasisHttpClient(OasisClientProtocol): tracks: list[int], ) -> None: # The old code passed the list directly; if the device expects CSV: + """ + Send an "add joblist" command to the device with a list of track indices. + + The provided track indices are serialized as a comma-separated string and sent to the device using the `ADDJOBLIST` parameter. + + Parameters: + device (OasisDevice): Target device to receive the command. + tracks (list[int]): Track indices to add; these are sent as a CSV string (e.g., [1,2,3] -> "1,2,3"). + """ await self._async_command(params={"ADDJOBLIST": ",".join(map(str, tracks))}) async def async_send_set_playlist_command( @@ -133,6 +236,13 @@ class OasisHttpClient(OasisClientProtocol): device: OasisDevice, playlist: list[int], ) -> None: + """ + Set the device's playlist on the target device and optimistically update the local device state. + + Parameters: + device (OasisDevice): Target device to receive the playlist command; its state will be updated optimistically. + playlist (list[int]): Ordered list of track indices to set as the device's playlist. + """ await self._async_command(params={"WRIJOBLIST": ",".join(map(str, playlist))}) # optional: optimistic state update device.update_from_status_dict({"playlist": playlist}) @@ -142,6 +252,12 @@ class OasisHttpClient(OasisClientProtocol): device: OasisDevice, repeat: bool, ) -> None: + """ + Set the device's playlist repeat flag. + + Parameters: + repeat (bool): `True` to enable playlist repeat, `False` to disable it. + """ await self._async_command(params={"WRIREPEATJOB": 1 if repeat else 0}) async def async_send_set_autoplay_command( @@ -149,6 +265,13 @@ class OasisHttpClient(OasisClientProtocol): device: OasisDevice, option: str, ) -> None: + """ + Set the device's autoplay (wait-after) option. + + Parameters: + device (OasisDevice): Target device whose autoplay option will be updated. + option (str): The value for the device's wait-after/autoplay setting as expected by the device firmware. + """ await self._async_command(params={"WRIWAITAFTER": option}) async def async_send_upgrade_command( @@ -156,25 +279,55 @@ class OasisHttpClient(OasisClientProtocol): device: OasisDevice, beta: bool, ) -> None: + """ + Send a firmware upgrade command to the specified device. + + Parameters: + device (OasisDevice): Target device to receive the upgrade command. + beta (bool): If True, request the beta firmware; if False, request the stable firmware. + """ await self._async_command(params={"CMDUPGRADE": 1 if beta else 0}) async def async_send_play_command(self, device: OasisDevice) -> None: + """ + Send the play command to the device. + """ await self._async_command(params={"CMDPLAY": ""}) async def async_send_pause_command(self, device: OasisDevice) -> None: + """ + Send a pause command to the device. + """ await self._async_command(params={"CMDPAUSE": ""}) async def async_send_stop_command(self, device: OasisDevice) -> None: + """ + Sends the device stop command to halt playback or activity. + + Sends an HTTP command to request the device stop its current operation. + """ await self._async_command(params={"CMDSTOP": ""}) async def async_send_reboot_command(self, device: OasisDevice) -> None: + """ + Send a reboot command to the device. + + Sends a reboot request to the target device using the CMDBOOT control parameter. + """ await self._async_command(params={"CMDBOOT": ""}) async def async_get_status(self, device: OasisDevice) -> None: - """Fetch status via GETSTATUS and update the device.""" + """ + Retrieve the device status from the device and apply it to the given OasisDevice. + + If the device does not return a status, the device object is not modified. + + Parameters: + device (OasisDevice): Device instance to update with the fetched status. + """ 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) - device.update_from_status_string(raw_status) + device.update_from_status_string(raw_status) \ No newline at end of file diff --git a/custom_components/oasis_mini/pyoasiscontrol/clients/mqtt_client.py b/custom_components/oasis_mini/pyoasiscontrol/clients/mqtt_client.py index 96a1db5..de6eec1 100644 --- a/custom_components/oasis_mini/pyoasiscontrol/clients/mqtt_client.py +++ b/custom_components/oasis_mini/pyoasiscontrol/clients/mqtt_client.py @@ -42,6 +42,24 @@ class OasisMqttClient(OasisClientProtocol): def __init__(self) -> None: # MQTT connection state + """ + Initialize internal state for the MQTT transport client. + + Sets up connection state, per-device registries and events, subscription bookkeeping, and a bounded pending command queue capped by MAX_PENDING_COMMANDS. + + Attributes: + _client: Active aiomqtt client or None. + _loop_task: Background MQTT loop task or None. + _connected_at: Timestamp of last successful connection or None. + _connected_event: Event signaled when a connection is established. + _stop_event: Event signaled to request the loop to stop. + _devices: Mapping of device serial to OasisDevice instances. + _first_status_events: Per-serial events signaled on receiving the first STATUS message. + _mac_events: Per-serial events signaled when a device MAC address is received. + _subscribed_serials: Set of serials currently subscribed to STATUS topics. + _subscription_lock: Lock protecting subscribe/unsubscribe operations. + _command_queue: Bounded queue of pending (serial, payload) commands. + """ self._client: aiomqtt.Client | None = None self._loop_task: asyncio.Task | None = None self._connected_at: datetime | None = None @@ -66,7 +84,17 @@ class OasisMqttClient(OasisClientProtocol): ) def register_device(self, device: OasisDevice) -> None: - """Register a device so MQTT messages can be routed to it.""" + """ + Register an OasisDevice so MQTT messages for its serial are routed to that device. + + Ensures the device has a serial_number (raises ValueError if not), stores the device in the client's registry, creates per-device asyncio.Events for first-status and MAC-address arrival, attaches this client to the device if it has no client, and schedules a subscription for the device's STATUS topics if the MQTT client is currently connected. + + Parameters: + device (OasisDevice): The device instance to register. + + Raises: + ValueError: If `device.serial_number` is not set. + """ if not device.serial_number: raise ValueError("Device must have serial_number set before registration") @@ -93,11 +121,24 @@ class OasisMqttClient(OasisClientProtocol): ) def register_devices(self, devices: Iterable[OasisDevice]) -> None: - """Convenience method to register multiple devices.""" + """ + Register multiple OasisDevice instances with the client. + + Parameters: + devices (Iterable[OasisDevice]): Iterable of devices to register. + """ for device in devices: self.register_device(device) def unregister_device(self, device: OasisDevice) -> None: + """ + Unregisters a device from MQTT routing and cleans up related per-device state. + + Removes the device's registration, first-status and MAC events. If there is an active MQTT client and the device's serial is currently subscribed, schedules an asynchronous unsubscription task. If the device has no serial_number, the call is a no-op. + + Parameters: + device (OasisDevice): The device to unregister; must have `serial_number` set. + """ serial = device.serial_number if not serial: return @@ -118,7 +159,11 @@ class OasisMqttClient(OasisClientProtocol): ) async def _subscribe_serial(self, serial: str) -> None: - """Subscribe to STATUS topics for a single device.""" + """ + Subscribe to the device's STATUS topic pattern and mark the device as subscribed. + + Subscribes to "/STATUS/#" with QoS 1 and records the subscription; does nothing if the MQTT client is not connected or the serial is already subscribed. + """ if not self._client: return @@ -132,7 +177,13 @@ class OasisMqttClient(OasisClientProtocol): _LOGGER.info("Subscribed to %s", topic) async def _unsubscribe_serial(self, serial: str) -> None: - """Unsubscribe from STATUS topics for a single device.""" + """ + Unsubscribe from the device's STATUS topic and update subscription state. + + If there is no active MQTT client or the serial is not currently subscribed, this is a no-op. + Parameters: + serial (str): Device serial used to build the topic "/STATUS/#". + """ if not self._client: return @@ -164,7 +215,11 @@ class OasisMqttClient(OasisClientProtocol): await self.stop() async def stop(self) -> None: - """Stop MQTT connection loop.""" + """ + Stop the MQTT client and clean up resources. + + Signals the background MQTT loop to stop, cancels the loop task, disconnects the MQTT client if connected, and clears any pending commands from the internal command queue. + """ self._stop_event.set() if self._loop_task: @@ -194,11 +249,20 @@ class OasisMqttClient(OasisClientProtocol): 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. + Block until the MQTT client is connected and the device has emitted at least one STATUS message. + + If `request_status` is True, a status request is sent after the client is connected to prompt the device to report its state. + + Parameters: + device (OasisDevice): The device to wait for; must have `serial_number` set. + timeout (float): Maximum seconds to wait for connection and for the first STATUS message. + request_status (bool): If True, issue a status refresh after connection to encourage a STATUS update. + + Returns: + bool: `True` if the device's first STATUS message was observed within the timeout, `False` otherwise. + + Raises: + RuntimeError: If the provided device does not have a `serial_number`. """ serial = device.serial_number if not serial: @@ -243,7 +307,20 @@ class OasisMqttClient(OasisClientProtocol): return False async def async_get_mac_address(self, device: OasisDevice) -> str | None: - """For MQTT, GETSTATUS causes MAC_ADDRESS to be published.""" + """ + Request a device's MAC address via an MQTT STATUS refresh and return it if available. + + If the device already has a MAC address, it is returned immediately. Otherwise the function requests a status update (which causes the device to publish MAC_ADDRESS) and waits up to 3 seconds for the MAC to arrive. + + Parameters: + device (OasisDevice): The device whose MAC address will be requested. + + Returns: + str | None: The device MAC address if obtained, `None` if the wait timed out and no MAC was received. + + Raises: + RuntimeError: If the provided device has no serial_number set. + """ # If already known on the device, return it if device.mac_address: return device.mac_address @@ -268,7 +345,13 @@ class OasisMqttClient(OasisClientProtocol): async def async_send_auto_clean_command( self, device: OasisDevice, auto_clean: bool ) -> None: - """Send auto clean command.""" + """ + Set the device's automatic cleaning mode. + + Parameters: + device (OasisDevice): Target Oasis device to send the command to. + auto_clean (bool): True to enable automatic cleaning, False to disable. + """ payload = f"WRIAUTOCLEAN={1 if auto_clean else 0}" await self._publish_command(device, payload) @@ -277,6 +360,13 @@ class OasisMqttClient(OasisClientProtocol): device: OasisDevice, speed: int, ) -> None: + """ + Set the device's ball speed. + + Parameters: + device (OasisDevice): Target device. + speed (int): Speed value to apply. + """ payload = f"WRIOASISSPEED={speed}" await self._publish_command(device, payload) @@ -288,10 +378,28 @@ class OasisMqttClient(OasisClientProtocol): led_speed: int, brightness: int, ) -> None: + """ + Send an LED configuration command to the device. + + If `brightness` is greater than zero, the device is woken before sending the command. + + Parameters: + device (OasisDevice): Target device (must have a serial number). + led_effect (str): LED effect identifier to apply. + color (str): Color value for the LED effect (format expected by device). + led_speed (int): Speed/tempo for the LED effect. + brightness (int): Brightness level to set; also used to determine wake behavior. + """ payload = f"WRILED={led_effect};0;{color};{led_speed};{brightness}" await self._publish_command(device, payload, bool(brightness)) async def async_send_sleep_command(self, device: OasisDevice) -> None: + """ + Send the sleep command to the specified Oasis device. + + Parameters: + device (OasisDevice): Target device; must have a valid serial_number. If the MQTT client is not connected, the command may be queued for delivery when a connection is available. + """ await self._publish_command(device, "CMDSLEEP") async def async_send_move_job_command( @@ -300,6 +408,14 @@ class OasisMqttClient(OasisClientProtocol): from_index: int, to_index: int, ) -> None: + """ + Move a job in the device's playlist from one index to another. + + Parameters: + device (OasisDevice): Target device to receive the command. + from_index (int): Source index of the job in the playlist. + to_index (int): Destination index where the job should be placed. + """ payload = f"MOVEJOB={from_index};{to_index}" await self._publish_command(device, payload) @@ -308,6 +424,13 @@ class OasisMqttClient(OasisClientProtocol): device: OasisDevice, index: int, ) -> None: + """ + Change the device's current track to the specified track index. + + Parameters: + device (OasisDevice): Target Oasis device. + index (int): Track index to switch to (zero-based). + """ payload = f"CMDCHANGETRACK={index}" await self._publish_command(device, payload) @@ -316,6 +439,13 @@ class OasisMqttClient(OasisClientProtocol): device: OasisDevice, tracks: list[int], ) -> None: + """ + Send an ADDJOBLIST command to add multiple tracks to the device's job list. + + Parameters: + device (OasisDevice): Target device to receive the command. + tracks (list[int]): List of track indices to add; elements will be joined as a comma-separated list in the command payload. + """ track_str = ",".join(map(str, tracks)) payload = f"ADDJOBLIST={track_str}" await self._publish_command(device, payload) @@ -325,6 +455,13 @@ class OasisMqttClient(OasisClientProtocol): device: OasisDevice, playlist: list[int], ) -> None: + """ + Set the device's playlist to the specified ordered list of track indices. + + Parameters: + device (OasisDevice): Target Oasis device to receive the playlist command. + playlist (list[int]): Ordered list of track indices to apply as the device's playlist. + """ track_str = ",".join(map(str, playlist)) payload = f"WRIJOBLIST={track_str}" await self._publish_command(device, payload) @@ -334,6 +471,13 @@ class OasisMqttClient(OasisClientProtocol): device: OasisDevice, repeat: bool, ) -> None: + """ + Send a command to enable or disable repeating the device's playlist. + + Parameters: + device (OasisDevice): Target device; must have a serial number. + repeat (bool): True to enable playlist repeat, False to disable it. + """ payload = f"WRIREPEATJOB={1 if repeat else 0}" await self._publish_command(device, payload) @@ -342,6 +486,15 @@ class OasisMqttClient(OasisClientProtocol): device: OasisDevice, option: str, ) -> None: + """ + Set the device's wait-after-job / autoplay option. + + Publishes a "WRIWAITAFTER=