diff --git a/custom_components/narwal/__init__.py b/custom_components/narwal/__init__.py index 81aeb80..0aebbe6 100644 --- a/custom_components/narwal/__init__.py +++ b/custom_components/narwal/__init__.py @@ -5,18 +5,274 @@ import logging from typing import TypeAlias +import voluptuous as vol + +from homeassistant.auth.permissions.const import POLICY_CONTROL +from homeassistant.components.vacuum import DOMAIN as VACUUM_DOMAIN from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ATTR_AREA_ID, ATTR_DEVICE_ID, ATTR_ENTITY_ID from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.exceptions import ( + ConfigEntryNotReady, + HomeAssistantError, + Unauthorized, + UnknownUser, +) +from homeassistant.helpers import config_validation as cv, service +from homeassistant.helpers import entity_registry as er -from .const import CONF_MODEL, CONF_PRODUCT_KEY, PLATFORMS +from .const import ( + CONF_MODEL, + CONF_PRODUCT_KEY, + DOMAIN, + PLATFORMS, + SERVICE_CLEAN_ROOMS, +) from .coordinator import NarwalCoordinator -from .narwal_client import NarwalConnectionError +from .narwal_client import ( + CommandResult, + CleaningRoute, + FanLevel, + MopHumidity, + MopStrengthLevel, + NarwalConnectionError, + WorkMode, +) _LOGGER = logging.getLogger(__name__) NarwalConfigEntry: TypeAlias = ConfigEntry[NarwalCoordinator] +FIELD_ROOMS = "rooms" +FIELD_MODE = "mode" +FIELD_SUCTION = "suction" +FIELD_WATER = "water" +FIELD_MOP_STRENGTH = "mop_strength" +FIELD_PASSES = "passes" +FIELD_ROUTE = "route" + +WORK_MODE_OPTIONS: dict[str, WorkMode] = { + "vacuum": WorkMode.VACUUM, + "mop": WorkMode.MOP, + "vacuum_then_mop": WorkMode.VACUUM_THEN_MOP, + "vacuum_and_mop": WorkMode.VACUUM_AND_MOP, +} +SUCTION_OPTIONS: dict[str, FanLevel] = { + "ai": FanLevel.UNSPECIFIED, + "quiet": FanLevel.MUTE, + "standard": FanLevel.NORMAL, + "strong": FanLevel.STRONG, + "super_powerful": FanLevel.DEEP, + "ultra_powerful": FanLevel.SUPER, +} +WATER_OPTIONS: dict[str, MopHumidity] = { + "dry": MopHumidity.DRY, + "normal": MopHumidity.NORMAL, + "wet": MopHumidity.WET, +} +MOP_STRENGTH_OPTIONS: dict[str, MopStrengthLevel] = { + "normal": MopStrengthLevel.NORMAL, + "high": MopStrengthLevel.HIGH, +} +ROUTE_OPTIONS: dict[str, CleaningRoute] = { + "standard": CleaningRoute.STANDARD, + "meticulous": CleaningRoute.METICULOUS, +} + +CLEAN_ROOMS_SCHEMA = vol.Schema( + { + vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, + vol.Optional(ATTR_DEVICE_ID): cv.ensure_list, + vol.Optional(ATTR_AREA_ID): cv.ensure_list, + vol.Required(FIELD_ROOMS): cv.ensure_list, + vol.Optional(FIELD_MODE, default="vacuum_and_mop"): vol.In(WORK_MODE_OPTIONS), + vol.Optional(FIELD_SUCTION, default="standard"): vol.In(SUCTION_OPTIONS), + vol.Optional(FIELD_WATER, default="normal"): vol.In(WATER_OPTIONS), + vol.Optional(FIELD_MOP_STRENGTH, default="normal"): vol.In(MOP_STRENGTH_OPTIONS), + vol.Optional(FIELD_PASSES, default=1): vol.All(vol.Coerce(int), vol.Range(min=1, max=3)), + vol.Optional(FIELD_ROUTE): vol.In(ROUTE_OPTIONS), + } +) + +def _normalise_room_ids(raw_rooms: list) -> list[int]: + """Return room IDs from HA service data.""" + room_ids: list[int] = [] + for item in raw_rooms: + if isinstance(item, str): + cleaned = item.strip().strip("[]") + if cleaned.lower() == "all": + continue + values = cleaned.replace(",", " ").split() + room_ids.extend(int(value) for value in values if value) + else: + room_ids.append(int(item)) + return room_ids + + +def _rooms_requested_all(raw_rooms: list) -> bool: + """Return True if a service call requests all current map rooms.""" + return any(isinstance(item, str) and item.strip().lower() == "all" for item in raw_rooms) + + +async def _async_room_ids_for_coordinator( + coordinator: NarwalCoordinator, + raw_rooms: list, +) -> list[int]: + """Resolve configured service rooms for a single Narwal coordinator.""" + if not _rooms_requested_all(raw_rooms): + return _normalise_room_ids(raw_rooms) + + state = coordinator.client.state + if state.map_data is None: + await coordinator.client.get_map() + if state.map_data is None: + return [] + return [room.room_id for room in state.map_data.rooms if room.room_id > 0] + + +def _domain_data(hass: HomeAssistant) -> dict: + """Return Narwal domain runtime data.""" + return hass.data.setdefault(DOMAIN, {}) + + +async def _async_get_service_coordinators( + hass: HomeAssistant, + entity_ids: list[str] | None, +) -> list[NarwalCoordinator]: + """Resolve service entity IDs to Narwal coordinators.""" + data = _domain_data(hass) + if not entity_ids: + raise HomeAssistantError("Target a Narwal vacuum") + + registry = er.async_get(hass) + coordinators: list[NarwalCoordinator] = [] + for entity_id in entity_ids: + registry_entry = registry.async_get(entity_id) + if registry_entry is None or registry_entry.config_entry_id is None: + continue + coordinator = data.get(registry_entry.config_entry_id) + if not isinstance(coordinator, NarwalCoordinator): + continue + if coordinator not in coordinators: + coordinators.append(coordinator) + if not coordinators: + raise HomeAssistantError("Target does not contain a Narwal entity") + return coordinators + + +async def _async_validate_clean_rooms_targets( + hass: HomeAssistant, + call, + entity_ids: list[str], +) -> list[str]: + """Validate clean-room target domains and user permissions.""" + registry = er.async_get(hass) + vacuum_entity_ids: list[str] = [] + for entity_id in entity_ids: + registry_entry = registry.async_get(entity_id) + if ( + entity_id.startswith(f"{VACUUM_DOMAIN}.") + and registry_entry is not None + and registry_entry.platform == DOMAIN + ): + vacuum_entity_ids.append(entity_id) + + direct_entity_ids = call.data.get(ATTR_ENTITY_ID, []) + if isinstance(direct_entity_ids, str): + direct_entity_ids = [direct_entity_ids] + direct_entity_ids = [ + entity_id + for entity_id in direct_entity_ids + if entity_id != "all" and not entity_id.startswith("group.") + ] + if any(entity_id not in vacuum_entity_ids for entity_id in direct_entity_ids): + raise HomeAssistantError("Target must be a Narwal vacuum entity") + + user_id = call.context.user_id + if not user_id: + return vacuum_entity_ids + user = await hass.auth.async_get_user(user_id) + if user is None: + raise UnknownUser(context=call.context, user_id=user_id) + if user.is_admin: + return vacuum_entity_ids + for entity_id in vacuum_entity_ids: + if not user.permissions.check_entity(entity_id, POLICY_CONTROL): + raise Unauthorized( + context=call.context, + entity_id=entity_id, + permission=POLICY_CONTROL, + ) + return vacuum_entity_ids + + +def _async_register_services(hass: HomeAssistant) -> None: + """Register Narwal domain services.""" + + async def async_clean_rooms(call) -> None: + entity_ids = list(await service.async_extract_entity_ids(call)) + if not entity_ids and any( + key in call.data for key in (ATTR_ENTITY_ID, ATTR_DEVICE_ID, ATTR_AREA_ID) + ): + raise HomeAssistantError("Target does not contain a Narwal entity") + entity_ids = await _async_validate_clean_rooms_targets(hass, call, entity_ids) + coordinators = await _async_get_service_coordinators( + hass, + entity_ids, + ) + for coordinator in coordinators: + client = coordinator.client + if not client.robot_awake: + await client.wake(timeout=10.0) + room_ids = await _async_room_ids_for_coordinator( + coordinator, + call.data[FIELD_ROOMS], + ) + if not room_ids: + raise HomeAssistantError("At least one room must be selected") + resp = await client.start_rooms( + room_ids, + work_mode=WORK_MODE_OPTIONS[call.data[FIELD_MODE]], + fan=SUCTION_OPTIONS[call.data[FIELD_SUCTION]], + water=WATER_OPTIONS[call.data[FIELD_WATER]], + mop_strength=MOP_STRENGTH_OPTIONS[call.data[FIELD_MOP_STRENGTH]], + passes=call.data[FIELD_PASSES], + route=ROUTE_OPTIONS[call.data[FIELD_ROUTE]] + if FIELD_ROUTE in call.data + else None, + ) + if resp.result_code == 0: + result_name = "ACCEPTED" + else: + try: + result_name = CommandResult(resp.result_code).name + except ValueError: + result_name = f"UNKNOWN({resp.result_code})" + _LOGGER.info( + "Clean rooms response: %s (code=%s), rooms=%s", + result_name, + resp.result_code, + room_ids, + ) + if resp.result_code not in (0, CommandResult.SUCCESS): + raise HomeAssistantError( + f"Narwal room clean failed: {result_name} ({resp.result_code})" + ) + coordinator.async_set_updated_data(client.state) + + hass.services.async_register( + DOMAIN, + SERVICE_CLEAN_ROOMS, + async_clean_rooms, + schema=CLEAN_ROOMS_SCHEMA, + ) + + +async def async_setup(hass: HomeAssistant, config: dict) -> bool: + """Set up Narwal services.""" + _async_register_services(hass) + return True + async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: """Migrate old config entries to version 2 (add product_key).""" @@ -48,6 +304,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: NarwalConfigEntry) -> bo ) from err entry.runtime_data = coordinator + data = _domain_data(hass) + data[entry.entry_id] = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) @@ -60,5 +318,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: NarwalConfigEntry) -> b if unload_ok: await entry.runtime_data.async_shutdown() + data = _domain_data(hass) + data.pop(entry.entry_id, None) return unload_ok diff --git a/custom_components/narwal/button.py b/custom_components/narwal/button.py new file mode 100644 index 0000000..1afe740 --- /dev/null +++ b/custom_components/narwal/button.py @@ -0,0 +1,133 @@ +"""Button entities for Narwal station maintenance actions.""" + +from __future__ import annotations + +from collections.abc import Awaitable, Callable +from dataclasses import dataclass + +from homeassistant.components.button import ButtonEntity, ButtonEntityDescription +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import NarwalConfigEntry +from .coordinator import NarwalCoordinator +from .entity import NarwalEntity +from .narwal_client import CommandResponse, CommandResult + + +@dataclass(frozen=True, kw_only=True) +class NarwalButtonEntityDescription(ButtonEntityDescription): + """Description for a Narwal action button.""" + + action: str + icon: str + + +BUTTON_DESCRIPTIONS: tuple[NarwalButtonEntityDescription, ...] = ( + NarwalButtonEntityDescription( + key="empty_dustbin", + translation_key="empty_dustbin", + action="empty_dustbin", + icon="mdi:delete-empty", + ), + NarwalButtonEntityDescription( + key="wash_mop", + translation_key="wash_mop", + action="wash_mop", + icon="mdi:waves-arrow-up", + ), + NarwalButtonEntityDescription( + key="dry_mop", + translation_key="dry_mop", + action="dry_mop", + icon="mdi:fan", + ), + NarwalButtonEntityDescription( + key="wash_and_dry_mop", + translation_key="wash_and_dry_mop", + action="wash_and_dry_mop", + icon="mdi:creation", + ), + NarwalButtonEntityDescription( + key="dry_dust_bin", + translation_key="dry_dust_bin", + action="dry_dust_bag", + icon="mdi:air-filter", + ), + NarwalButtonEntityDescription( + key="dry_dock_bag", + translation_key="dry_dock_bag", + action="dry_station_bag", + icon="mdi:shield-sun-outline", + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: NarwalConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Narwal button entities.""" + coordinator = entry.runtime_data + async_add_entities( + NarwalActionButton(coordinator, description) + for description in BUTTON_DESCRIPTIONS + ) + + +class NarwalActionButton(NarwalEntity, ButtonEntity): + """Button entity for a dock/station maintenance command.""" + + entity_description: NarwalButtonEntityDescription + + def __init__( + self, + coordinator: NarwalCoordinator, + description: NarwalButtonEntityDescription, + ) -> None: + """Initialize the button.""" + super().__init__(coordinator) + self.entity_description = description + device_id = coordinator.config_entry.data["device_id"] + self._attr_unique_id = f"{device_id}_{description.key}" + self._attr_icon = description.icon + + @property + def available(self) -> bool: + """Return True when the robot is docked and can run station actions.""" + if not super().available: + return False + state = self.coordinator.data + return state is None or state.is_docked + + async def async_press(self) -> None: + """Run the Narwal station action.""" + client = self.coordinator.client + if not client.robot_awake: + await client.wake(timeout=10.0) + + command: Callable[[], Awaitable[CommandResponse]] = getattr( + client, + self.entity_description.action, + ) + response = await command() + if ( + self.entity_description.action == "wash_mop" + and response.not_applicable + ): + response = await client.wash_mop_by_robot_status() + if not response.success and response.result_code != 0: + try: + result_name = CommandResult(response.result_code).name + except ValueError: + result_name = f"UNKNOWN({response.result_code})" + raise HomeAssistantError( + f"Narwal {self.entity_description.key} failed: {result_name}" + ) + + if self.entity_description.action in ("dry_mop", "wash_and_dry_mop"): + await client.get_dry_mop_remain_time() + + self.coordinator.async_set_updated_data(client.state) diff --git a/custom_components/narwal/camera.py b/custom_components/narwal/camera.py index d3b18bf..7070de0 100644 --- a/custom_components/narwal/camera.py +++ b/custom_components/narwal/camera.py @@ -14,7 +14,7 @@ from . import NarwalConfigEntry from .coordinator import NarwalCoordinator from .entity import NarwalEntity -from .narwal_client.const import WorkingStatus +from .narwal_client.const import ACTIVE_CLEANING_STATUSES, WorkingStatus _LOGGER = logging.getLogger(__name__) @@ -154,17 +154,17 @@ def _handle_coordinator_update(self) -> None: # Detect cleaning session transitions — clear trail on new session current_status = state.working_status - was_cleaning = self._last_cleaning_status in ( - WorkingStatus.CLEANING, WorkingStatus.CLEANING_ALT, - ) - is_cleaning = current_status in ( - WorkingStatus.CLEANING, WorkingStatus.CLEANING_ALT, + was_cleaning = self._last_cleaning_status in ACTIVE_CLEANING_STATUSES + is_cleaning = ( + current_status in ACTIVE_CLEANING_STATUSES and not state.is_docked ) if is_cleaning and not was_cleaning: _LOGGER.info("New cleaning session — clearing trail and vision obstacles") self._reset_trail() if current_status != WorkingStatus.UNKNOWN: - self._last_cleaning_status = current_status + self._last_cleaning_status = ( + current_status if not state.is_docked else WorkingStatus.STANDBY + ) if _DEBUG_VIEW: if not display or (display.robot_x == 0.0 and display.robot_y == 0.0): diff --git a/custom_components/narwal/config_flow.py b/custom_components/narwal/config_flow.py index 928c247..6299ca1 100644 --- a/custom_components/narwal/config_flow.py +++ b/custom_components/narwal/config_flow.py @@ -43,47 +43,69 @@ async def async_step_user( model_label = user_input[CONF_MODEL] product_key = NARWAL_MODELS[model_label] - # If user selected a specific model, set topic prefix directly - topic_prefix = None if product_key == "auto" else f"/{product_key}" - - client = NarwalClient( - host=host, port=port, topic_prefix=topic_prefix, - ) - try: - await client.connect() - # Discover device_id from broadcast, then query info - await client.discover_device_id(timeout=15.0) - # Drain any stale field5 responses left in the WebSocket - # buffer from discover's wake probes before sending a - # real command - await client.drain_ws_buffer() - device_info = await client.get_device_info() - except Exception as ex: - _LOGGER.warning( - "Setup failed: %s: %s", type(ex).__name__, ex, - ) - errors["base"] = "cannot_connect" + # Try the selected model key first for speed. Some Flow/Flow 2 + # units only wake on a sibling key before reporting their actual + # product key, so fall back to auto-discovery before failing. + topic_prefixes: list[str | None] + if product_key == "auto": + topic_prefixes = [None] else: - device_id = device_info.device_id - await self.async_set_unique_id(device_id) - self._abort_if_unique_id_configured() - - # Use the product key that actually worked (may have been - # auto-detected during discovery even if user picked "auto") - resolved_key = client.topic_prefix.lstrip("/") - - return self.async_create_entry( - title=model_label if product_key != "auto" else f"Narwal {resolved_key}", - data={ - "host": host, - "port": port, - "device_id": device_id, - CONF_PRODUCT_KEY: resolved_key, - CONF_MODEL: model_label, - }, + topic_prefixes = [f"/{product_key}", None] + + last_error: Exception | None = None + for topic_prefix in topic_prefixes: + client = NarwalClient( + host=host, port=port, topic_prefix=topic_prefix, + ) + try: + await client.connect() + # Discover device_id from broadcast, then query info + await client.discover_device_id(timeout=15.0) + # Drain any stale field5 responses left in the WebSocket + # buffer from discover's wake probes before sending a + # real command + await client.drain_ws_buffer() + device_info = await client.get_device_info() + except Exception as ex: + last_error = ex + _LOGGER.debug( + "Setup probe failed with prefix %s: %s: %s", + topic_prefix or "auto", + type(ex).__name__, + ex, + ) + await client.disconnect() + continue + + try: + device_id = device_info.device_id + await self.async_set_unique_id(device_id) + self._abort_if_unique_id_configured() + + # Use the product key that actually worked (may have been + # auto-detected during discovery even if user picked "auto") + resolved_key = client.topic_prefix.lstrip("/") + + return self.async_create_entry( + title=model_label if product_key != "auto" else f"Narwal {resolved_key}", + data={ + "host": host, + "port": port, + "device_id": device_id, + CONF_PRODUCT_KEY: resolved_key, + CONF_MODEL: model_label, + }, + ) + finally: + await client.disconnect() + + if last_error is not None: + _LOGGER.warning( + "Setup failed: %s: %s", + type(last_error).__name__, + last_error, ) - finally: - await client.disconnect() + errors["base"] = "cannot_connect" return self.async_show_form( step_id="user", diff --git a/custom_components/narwal/const.py b/custom_components/narwal/const.py index 0fa56a2..48fb67a 100644 --- a/custom_components/narwal/const.py +++ b/custom_components/narwal/const.py @@ -27,15 +27,43 @@ PLATFORMS: list[Platform] = [ Platform.VACUUM, Platform.SENSOR, + Platform.SELECT, Platform.BINARY_SENSOR, Platform.CAMERA, + Platform.BUTTON, ] -FAN_SPEED_MAP: dict[str, FanLevel] = { - "quiet": FanLevel.QUIET, +CONF_SHOW_ROOM_LABELS = "show_room_labels" +CONF_SHOW_FURNITURE = "show_furniture" +CONF_SHOW_FURNITURE_LABELS = "show_furniture_labels" +CONF_MAP_ROTATION = "map_rotation" +CONF_MAP_ZOOM = "map_zoom" +SERVICE_CLEAN_ROOMS = "clean_rooms" + +MAP_OPTION_DEFAULTS: dict[str, bool] = { + CONF_SHOW_ROOM_LABELS: True, + CONF_SHOW_FURNITURE: False, + CONF_SHOW_FURNITURE_LABELS: False, +} + +MAP_ROTATION_DEFAULT = 0 +MAP_ZOOM_DEFAULT = 1.0 + +# HA fan_speed labels for the live clean/set_fan_level command. Its +# SweepFanLevel enum stops at DEEP; SUPER remains available to clean settings. +_FAN_SPEED_CANONICAL: dict[str, FanLevel] = { + "Quiet": FanLevel.MUTE, + "Standard": FanLevel.NORMAL, + "Strong": FanLevel.STRONG, + "Super powerful": FanLevel.DEEP, +} + +FAN_SPEED_LIST: list[str] = list(_FAN_SPEED_CANONICAL) + +# FAN_SPEED_MAP also accepts the original lowercase fan_speed values (quiet/normal/strong/max) so existing automations keep working; these aliases are not offered in FAN_SPEED_LIST. +FAN_SPEED_MAP: dict[str, FanLevel] = _FAN_SPEED_CANONICAL | { + "quiet": FanLevel.MUTE, "normal": FanLevel.NORMAL, "strong": FanLevel.STRONG, - "max": FanLevel.MAX, + "max": FanLevel.DEEP, } - -FAN_SPEED_LIST: list[str] = list(FAN_SPEED_MAP.keys()) diff --git a/custom_components/narwal/coordinator.py b/custom_components/narwal/coordinator.py index 0970307..1176f54 100644 --- a/custom_components/narwal/coordinator.py +++ b/custom_components/narwal/coordinator.py @@ -12,7 +12,7 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .narwal_client import NarwalClient, NarwalConnectionError, NarwalState -from .narwal_client.const import WorkingStatus +from .narwal_client.const import ACTIVE_CLEANING_STATUSES, WorkingStatus from .const import DOMAIN @@ -61,6 +61,7 @@ def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: self._last_display_map_resub: float = 0.0 self._consecutive_failures = 0 self._max_failures = 5 # 5 * 60s = 5 minutes before entities go unavailable + self.select_options: dict[str, str] = {} async def async_setup(self) -> None: """Connect to the vacuum and start the WebSocket listener. @@ -144,8 +145,7 @@ def _on_state_update(self, state: NarwalState) -> None: state.working_status in ( WorkingStatus.STANDBY, WorkingStatus.DOCKED_V2, ) - and self._prev_working_status - in (WorkingStatus.CLEANING, WorkingStatus.CLEANING_ALT) + and self._prev_working_status in ACTIVE_CLEANING_STATUSES ): _LOGGER.info("Return-to-dock detected, refreshing dock status") self.hass.async_create_task(self._refresh_dock_status()) @@ -154,9 +154,7 @@ def _on_state_update(self, state: NarwalState) -> None: # display_map dropout recovery: if cleaning but no display_map for # 30s, re-send topic subscription. Only subscription — no wake burst # (wake bursts during cleaning cause pause bouncing). - is_cleaning = state.working_status in ( - WorkingStatus.CLEANING, WorkingStatus.CLEANING_ALT, - ) + is_cleaning = state.working_status in ACTIVE_CLEANING_STATUSES if is_cleaning: display_age = self.client.last_display_map_age now = time.monotonic() diff --git a/custom_components/narwal/narwal_client/__init__.py b/custom_components/narwal/narwal_client/__init__.py index 8eb1f06..2bb01c1 100644 --- a/custom_components/narwal/narwal_client/__init__.py +++ b/custom_components/narwal/narwal_client/__init__.py @@ -1,7 +1,15 @@ """Narwal robot vacuum client library — local WebSocket API.""" from .client import NarwalClient, NarwalCommandError, NarwalConnectionError -from .const import CommandResult, FanLevel, MopHumidity, WorkingStatus +from .const import ( + CleaningRoute, + CommandResult, + FanLevel, + MopHumidity, + MopStrengthLevel, + WorkMode, + WorkingStatus, +) from .models import CommandResponse, DeviceInfo, MapData, MapDisplayData, NarwalState, RoomInfo from .protocol import build_frame, parse_frame @@ -12,12 +20,15 @@ "NarwalState", "CommandResponse", "CommandResult", + "CleaningRoute", "DeviceInfo", "FanLevel", "MapData", "MapDisplayData", "MopHumidity", + "MopStrengthLevel", "RoomInfo", + "WorkMode", "WorkingStatus", "build_frame", "parse_frame", diff --git a/custom_components/narwal/narwal_client/client.py b/custom_components/narwal/narwal_client/client.py index ca4c1e2..4b731cf 100644 --- a/custom_components/narwal/narwal_client/client.py +++ b/custom_components/narwal/narwal_client/client.py @@ -16,42 +16,60 @@ BROADCAST_STALE_TIMEOUT, COMMAND_RESPONSE_TIMEOUT, DEFAULT_PORT, + DEFAULT_TOPIC_PREFIX, HEARTBEAT_INTERVAL, KEEPALIVE_INTERVAL, KNOWN_PRODUCT_KEYS, + LEGACY_ROOM_CLEAN_PRODUCT_KEYS, RECONNECT_BACKOFF_FACTOR, RECONNECT_INITIAL_DELAY, RECONNECT_MAX_DELAY, TOPIC_CMD_ACTIVE_ROBOT, TOPIC_CMD_APP_HEARTBEAT, TOPIC_CMD_CANCEL, + TOPIC_CMD_CLEAN_TASK, + TOPIC_CMD_DRY_DUST_BAG, TOPIC_CMD_DRY_MOP, + TOPIC_CMD_DRY_STATION_BAG, TOPIC_CMD_DUST_GATHERING, TOPIC_CMD_EASY_CLEAN, TOPIC_CMD_FORCE_END, TOPIC_CMD_GET_ALL_MAPS, TOPIC_CMD_GET_BASE_STATUS, + TOPIC_CMD_GET_CLEAN_PROGRESS_INFO, TOPIC_CMD_GET_CURRENT_TASK, TOPIC_CMD_GET_DEVICE_INFO, + TOPIC_CMD_GET_DRY_MOP_REMAIN_TIME, TOPIC_CMD_GET_FEATURE_LIST, TOPIC_CMD_GET_MAP, + TOPIC_CMD_GET_ROBOT_TASK_STATUS, TOPIC_CMD_NOTIFY_APP_EVENT, TOPIC_CMD_PAUSE, - TOPIC_CMD_PING, + TOPIC_CMD_PLAN_START, TOPIC_CMD_RECALL, TOPIC_CMD_RESUME, TOPIC_CMD_SET_FAN_LEVEL, + TOPIC_CMD_SET_LED, TOPIC_CMD_SET_MOP_HUMIDITY, - TOPIC_CMD_START_CLEAN, TOPIC_CMD_TAKE_PICTURE, - TOPIC_CMD_SET_LED, + TOPIC_CMD_WASH_AND_DRY_MOP, TOPIC_CMD_WASH_MOP, + TOPIC_CMD_WASH_MOP_BY_ROBOT_STATUS, TOPIC_CMD_YELL, - DEFAULT_TOPIC_PREFIX, + TOPIC_PLANNING_DEBUG, + TOPIC_POINT_NAVI_PLAN_TRAJ, + TOPIC_ROBOT_CURRENT_STATUS, + TOPIC_ROBOT_STATUS, + TOPIC_ROBOT_TASK_STATUS, + TOPIC_TIMELINE_STATUS, WAKE_TIMEOUT, + CleaningRoute, CommandResult, FanLevel, MopHumidity, + MopStrengthLevel, + WorkingStatus, + WorkMode, ) from .models import CommandResponse, DeviceInfo, MapData, MapDisplayData, NarwalState from .protocol import ( @@ -64,6 +82,92 @@ _LOGGER = logging.getLogger(__name__) +_ACTIVE_WORKING_STATUS_TTL = 15.0 +_STALE_DOCK_BASE_STATUSES = { + WorkingStatus.UNKNOWN, + WorkingStatus.STANDBY, + WorkingStatus.DOCKED, + WorkingStatus.CHARGED, + WorkingStatus.DOCKED_V2, +} +_AUX_STATUS_TOPICS = { + TOPIC_TIMELINE_STATUS, + TOPIC_POINT_NAVI_PLAN_TRAJ, + TOPIC_PLANNING_DEBUG, + TOPIC_ROBOT_STATUS, + TOPIC_ROBOT_CURRENT_STATUS, + TOPIC_ROBOT_TASK_STATUS, +} + + +def _short_repr(value: Any, limit: int = 1200) -> str: + text = repr(value) + if len(text) <= limit: + return text + return f"{text[:limit]}…" + + +def _normalise_blackboxprotobuf_typedef(typedef: dict[str, Any]) -> dict[str, Any]: + """Add field names expected by some blackboxprotobuf releases.""" + for info in typedef.values(): + info.setdefault("name", "") + message_typedef = info.get("message_typedef") + if isinstance(message_typedef, dict): + _normalise_blackboxprotobuf_typedef(message_typedef) + alt_typedefs = info.get("alt_typedefs") + if isinstance(alt_typedefs, dict): + for alt_typedef in alt_typedefs.values(): + if isinstance(alt_typedef, dict): + _normalise_blackboxprotobuf_typedef(alt_typedef) + return typedef + + +def _base_status_working_status(decoded: dict[str, Any] | object) -> WorkingStatus | None: + """Extract robot_base_status field 3.1.""" + if not isinstance(decoded, dict): + return None + field3 = decoded.get("3") + if isinstance(field3, list): + field3 = field3[0] if field3 else None + if not isinstance(field3, dict) or "1" not in field3: + return None + try: + return WorkingStatus(int(field3["1"])) + except (TypeError, ValueError): + return None + + +def _base_status_confirms_docked( + decoded: dict[str, Any] | object, status: WorkingStatus | None +) -> bool: + """Return true when a terminal status also carries live dock indicators.""" + if not isinstance(decoded, dict) or status not in { + WorkingStatus.STANDBY, + WorkingStatus.DOCKED, + WorkingStatus.CHARGED, + WorkingStatus.DOCKED_V2, + }: + return False + field3 = decoded.get("3") + if isinstance(field3, list): + field3 = field3[0] if field3 else None + field3 = field3 if isinstance(field3, dict) else {} + + def int_field(container: dict[str, Any], field: str) -> int: + try: + return int(container.get(field, 0)) + except (TypeError, ValueError): + return 0 + + return ( + int_field(decoded, "11") >= 2 + or int_field(decoded, "47") in (1, 3) + or int_field(field3, "3") in (1, 6) + or int_field(field3, "10") == 1 + or int_field(field3, "12") > 0 + or int_field(field3, "18") > 0 + ) + class NarwalConnectionError(Exception): """Raised when connection to the vacuum fails.""" @@ -110,7 +214,11 @@ def __init__( self._listener_active = False # True when start_listening() is running recv loop self._robot_awake = False # True once we receive a broadcast self._last_broadcast_time: float = 0.0 # monotonic time of last broadcast + self._last_status_time: float = 0.0 # monotonic time of last status/base broadcast self._last_display_map_time: float = 0.0 # monotonic time of last display_map + self._last_active_working_status_time: float = 0.0 + self._last_aux_log_time: dict[str, float] = {} + self._last_base_status_log: tuple[Any, Any, Any] | None = None # Queue for field5 command responses self._response_queue: asyncio.Queue[NarwalMessage] = asyncio.Queue() # Lock to prevent concurrent send_command calls from racing on the queue @@ -144,6 +252,89 @@ def last_display_map_age(self) -> float: return 999.0 return time.monotonic() - self._last_display_map_time + @property + def last_status_age(self) -> float: + """Seconds since last status/base broadcast (999.0 if none received).""" + if self._last_status_time <= 0: + return 999.0 + return time.monotonic() - self._last_status_time + + def _active_working_status_is_recent(self, now: float | None = None) -> bool: + """Return true while fresh working_status telemetry is contradicting base_status.""" + if self._last_active_working_status_time <= 0: + return False + now = time.monotonic() if now is None else now + return now - self._last_active_working_status_time <= _ACTIVE_WORKING_STATUS_TTL + + def _state_needs_keepalive(self) -> bool: + """Return true when the app-style keepalive should keep the robot awake.""" + state = self.state + if state.working_status == WorkingStatus.UNKNOWN: + return True + if state.working_status == WorkingStatus.ERROR: + return True + if state.is_cleaning or state.is_returning: + return True + if state.is_station_active: + return True + if ( + state.is_paused + and state._working_status_is_cleaning_like() + and not state.is_docked + ): + return True + if state.working_status == WorkingStatus.CLEANING_ALT and state.is_docked: + return True + return not state.is_docked + + def _update_from_working_status_broadcast( + self, decoded: dict[str, Any], now: float | None = None + ) -> None: + """Update state from a working_status broadcast.""" + self.state.update_from_working_status(decoded) + if self.state.has_recent_active_working_status: + self._last_active_working_status_time = ( + self.state.last_active_working_status_time + ) + + def _update_from_base_status_broadcast( + self, decoded: dict[str, Any], now: float | None = None + ) -> None: + """Update state from robot_base_status, ignoring stale dock overlays mid-task.""" + now = time.monotonic() if now is None else now + base_status = _base_status_working_status(decoded) + signature = (decoded.get("3"), decoded.get("11"), decoded.get("47")) + if signature != self._last_base_status_log: + self._last_base_status_log = signature + _LOGGER.debug( + "%s robot_base_status field3=%r field11=%r field47=%r", + self.host, + decoded.get("3"), + decoded.get("11"), + decoded.get("47"), + ) + if ( + base_status in _STALE_DOCK_BASE_STATUSES + and self._active_working_status_is_recent(now) + and not _base_status_confirms_docked(decoded, base_status) + ): + _LOGGER.debug( + "Ignoring stale %s base_status while active working_status is fresh", + base_status.name, + ) + self.state.update_battery_from_base_status(decoded) + return + self.state.update_from_base_status(decoded) + + def _update_from_aux_status_broadcast( + self, short_topic: str, decoded: dict[str, Any] + ) -> None: + self.state.update_from_aux_status(short_topic, decoded) + now = time.monotonic() + if now - self._last_aux_log_time.get(short_topic, 0.0) > 30.0: + self._last_aux_log_time[short_topic] = now + _LOGGER.debug("%s decoded status: %s", short_topic, _short_repr(decoded)) + async def connect(self) -> None: """Establish WebSocket connection to the vacuum. @@ -250,6 +441,9 @@ async def discover_device_id(self, timeout: float = 15.0) -> str: else: raw_id = str(raw_id).strip() if raw_id: + parts = msg.topic.split("/") if msg.topic else [] + if len(parts) >= 2 and parts[1]: + self.topic_prefix = f"/{parts[1]}" self.device_id = raw_id _LOGGER.info("Discovered device_id from response: %s", self.device_id) return self.device_id @@ -328,12 +522,11 @@ async def start_listening(self) -> None: try: if not self.connected: await self.connect() - # Immediate wake burst on (re)connect — the fresh TCP - # connection may trigger the robot's deep-sleep wake - # interrupt, but only if we send commands before it - # expires. Don't wait for the keepalive loop's first - # tick (15s delay would be too late). - await self._send_wake_burst() + if self._state_needs_keepalive(): + await self._send_wake_burst() + else: + await self.subscribe_to_topics() + _LOGGER.debug("Robot is docked/idle; not sending reconnect wake") retry_delay = RECONNECT_INITIAL_DELAY # reset on success self._heartbeat_task = asyncio.create_task(self._heartbeat_loop()) @@ -408,10 +601,13 @@ async def _handle_message(self, data: bytes) -> None: _LOGGER.debug("Failed to decode protobuf for topic %s", short_topic) return + now = time.monotonic() if short_topic == "status/working_status": - self.state.update_from_working_status(decoded) + self._last_status_time = now + self._update_from_working_status_broadcast(decoded, now) elif short_topic == "status/robot_base_status": - self.state.update_from_base_status(decoded) + self._last_status_time = now + self._update_from_base_status_broadcast(decoded, now) elif short_topic == "upgrade/upgrade_status": self.state.update_from_upgrade_status(decoded) elif short_topic == "status/download_status": @@ -425,6 +621,8 @@ async def _handle_message(self, data: bytes) -> None: self.state.map_display_data.robot_y, self.state.map_display_data.timestamp, ) + elif short_topic in _AUX_STATUS_TOPICS: + self._update_from_aux_status_broadcast(short_topic, decoded) if self.on_state_update: self.on_state_update(self.state) @@ -484,9 +682,12 @@ def _encode_string_field(cls, field_num: int, text: str) -> bytes: "upgrade/upgrade_status", "status/download_status", "map/display_map", - "status/time_line_status", - "status/point_navi_plan_traj", - "developer/planning_debug_info", + TOPIC_TIMELINE_STATUS, + TOPIC_POINT_NAVI_PLAN_TRAJ, + TOPIC_PLANNING_DEBUG, + TOPIC_ROBOT_STATUS, + TOPIC_ROBOT_CURRENT_STATUS, + TOPIC_ROBOT_TASK_STATUS, ] def _build_topic_subscription(self, duration: int = 600) -> bytes: @@ -691,9 +892,12 @@ async def _keepalive_loop(self) -> None: except Exception: _LOGGER.debug("Topic re-subscribe failed") + if not self._state_needs_keepalive(): + _LOGGER.debug("Robot is docked/idle; skipping app keepalive") + continue + # Send lightweight heartbeat to keep robot awake. - # The Narwal app sends this continuously regardless of - # robot state — it's safe during cleaning. + # This is only needed while an active task is in progress. try: payload = self._encode_varint_field(1, 1) frame = build_frame( @@ -705,6 +909,11 @@ async def _keepalive_loop(self) -> None: _LOGGER.debug("Keepalive send failed") break else: + if not self._state_needs_keepalive(): + consecutive_wake_failures = 0 + _LOGGER.debug("Robot is docked/idle; not waking") + continue + # Robot appears asleep — send full wake burst # (wake burst includes topic subscription) consecutive_wake_failures += 1 @@ -852,16 +1061,19 @@ async def _wait_for_field5_response( except Exception: continue + now = time.monotonic() if short_topic == "status/working_status": - self.state.update_from_working_status(decoded) + self._update_from_working_status_broadcast(decoded, now) elif short_topic == "status/robot_base_status": - self.state.update_from_base_status(decoded) + self._update_from_base_status_broadcast(decoded, now) elif short_topic == "upgrade/upgrade_status": self.state.update_from_upgrade_status(decoded) elif short_topic == "status/download_status": self.state.update_from_download_status(decoded) elif short_topic == "map/display_map": self.state.map_display_data = MapDisplayData.from_broadcast(decoded) + elif short_topic in _AUX_STATUS_TOPICS: + self._update_from_aux_status_broadcast(short_topic, decoded) raise NarwalCommandError( f"No field5 response within {timeout}s" @@ -915,7 +1127,7 @@ async def start(self, **kwargs) -> CommandResponse: so we know which rooms to include. """ resp = await self.send_command( - TOPIC_CMD_START_CLEAN, + TOPIC_CMD_PLAN_START, payload=self._DEFAULT_CLEAN_PAYLOAD, timeout=10.0, ) @@ -943,9 +1155,10 @@ async def start(self, **kwargs) -> CommandResponse: len(room_ids), ) payload = self._build_clean_payload_v2(room_ids) - return await self.send_command( - TOPIC_CMD_START_CLEAN, payload=payload, timeout=10.0, + resp = await self.send_command( + TOPIC_CMD_PLAN_START, payload=payload, timeout=10.0, ) + return resp def _build_clean_payload_v2( self, @@ -958,9 +1171,7 @@ def _build_clean_payload_v2( """Build clean task payload using the v2 schema (firmware v01.07.22+). Observed in issue #36 from a Flow on firmware v01.07.22.00. - Each room entry uses a nested room_id (different from the flat - schema in _build_room_clean_payload used for room-targeted cleans - on older firmware): + Each room entry uses a nested room_id: { 1: {1: 1, 2: }, # nested room ref @@ -1044,43 +1255,110 @@ def _build_clean_payload_v2( }, } } - return blackboxprotobuf.encode_message(msg, typedef) - - def _build_room_clean_payload(self, room_ids: list[int]) -> bytes: - """Build CleanTask protobuf with per-room clean params in field 1.2. + return blackboxprotobuf.encode_message( + msg, _normalise_blackboxprotobuf_typedef(typedef) + ) - Each room entry in field 1.2 requires full MapCleanParamInfo fields - (from APK proto analysis): - field 1: roomId (uint32) - field 2: cleanMode (int32) — 0=sweep, 1=mop, 2=sweep+mop - field 3: cleanTimes (int32) — number of passes - field 6: sweepMode (int32) — suction level (3=max) - field 7: mopMode (int32) — mop humidity (2=wet) + # WorkMode -> (CleanParam.mode tag 1, pass-count tags to set from `passes`). The robot's + # execution mode is CleanTask.taskType (= the WorkMode value); CleanParam.mode and the + # pass tag are derived here so the two can't drift. Live-validated on a Flow 2; see + # project_history.md "CleanParam — fully decoded". + _WORK_MODE_PARAM: dict[WorkMode, tuple[int, tuple[str, ...]]] = { + WorkMode.VACUUM: (2, ("5",)), # sweepTime + WorkMode.MOP: (3, ("6",)), # mopTime + WorkMode.VACUUM_THEN_MOP: (5, ("5", "6")), # sweep + mop pass counts + WorkMode.VACUUM_AND_MOP: (4, ("7",)), # sweepMopSyncTime + } + + def _build_start_clean_payload( + self, + room_ids: list[int], + map_id: int, + *, + work_mode: WorkMode = WorkMode.VACUUM_AND_MOP, + fan: FanLevel = FanLevel.NORMAL, + water: MopHumidity = MopHumidity.NORMAL, + mop_strength: MopStrengthLevel = MopStrengthLevel.NORMAL, + passes: int = 1, + route: CleaningRoute | None = None, + ) -> bytes: + """Build a clean/start_clean request for the given rooms. - A bare roomId without clean params is silently ignored by the robot. + StartClean_Request{1: CleanTask{1: map_id, 2: [CleanItem...], 3: {} (TaskOption), + 5: taskType}}; CleanItem{1: ZoneOption{1: 1 (room zone), 2: room_id}, 2: CleanParam, + 3: order}. taskType (the execution-mode carrier) and CleanParam.mode/pass-tag are + derived from work_mode. overlapLevel is CleanParam tag 8 when supplied. Args: - room_ids: List of room IDs from RoomInfo.room_id. - - Returns: - Encoded protobuf bytes for clean/plan/start. + room_ids: Robot room IDs (RoomInfo.room_id). + map_id: Active map id (MapData.map_id, get_map field 2.1). + work_mode: Vacuum / mop / vacuum-then-mop / vacuum-and-mop. + fan: Suction level (CleanParam tag 2). + water: Mop water volume (tag 4). + mop_strength: Mop scrub intensity (tag 3). + passes: Clean count, routed to the pass tag(s) for the mode. + route: Optional route overlap level (tag 8). """ + import blackboxprotobuf + + param_mode, pass_tags = self._WORK_MODE_PARAM[work_mode] + param: dict[str, int] = { + "1": int(param_mode), + "2": int(fan), + "3": int(mop_strength), + "4": int(water), + } + if route is not None: + param["8"] = int(route) + for tag in pass_tags: + param[tag] = int(passes) + + items = [ + {"1": {"1": 1, "2": rid}, "2": dict(param), "3": idx + 1} + for idx, rid in enumerate(room_ids) + ] + task = { + "1": map_id, + "2": items if len(items) > 1 else items[0], + "3": {}, + "5": int(work_mode), # CleanTask.taskType + } + item_typedef = { + "type": "message", + "seen_repeated": True, + "message_typedef": { + "1": {"type": "message", "message_typedef": { + "1": {"type": "int"}, "2": {"type": "int"}, + }}, + # Derive the CleanParam typedef from the emitted dict — bbpb silently + # drops any tag absent from the typedef. + "2": {"type": "message", "message_typedef": { + k: {"type": "int"} for k in param + }}, + "3": {"type": "int"}, + }, + } + typedef = {"1": {"type": "message", "message_typedef": { + "1": {"type": "int"}, + "2": item_typedef, + "3": {"type": "message", "message_typedef": {}}, + "5": {"type": "int"}, + }}} + return blackboxprotobuf.encode_message( + {"1": task}, _normalise_blackboxprotobuf_typedef(typedef) + ) + + def _build_room_clean_payload(self, room_ids: list[int]) -> bytes: + """Build the legacy flat room-clean payload for older firmware.""" if not room_ids: return self._DEFAULT_CLEAN_PAYLOAD import blackboxprotobuf - # Build per-room entries with default clean settings - room_entries = [] - for rid in room_ids: - room_entries.append({ - "1": rid, # roomId - "2": 2, # cleanMode = sweep+mop - "3": 1, # cleanTimes = 1 pass - "6": 3, # sweepMode = max suction - "7": 2, # mopMode = wet - }) - + room_entries = [ + {"1": room_id, "2": 2, "3": 1, "6": 3, "7": 2} + for room_id in room_ids + ] room_typedef = { "type": "message", "seen_repeated": True, @@ -1090,19 +1368,13 @@ def _build_room_clean_payload(self, room_ids: list[int]) -> bytes: "3": {"type": "int"}, "6": {"type": "int"}, "7": {"type": "int"}, - } + }, } - - # Single room: field 1.2 is a message; multiple: repeated message field_2_value = room_entries[0] if len(room_entries) == 1 else room_entries - - msg = { + message = { "1": { "2": field_2_value, - "5": { - "1": {"1": 3, "2": 2, "3": 1}, - "5": {} - } + "5": {"1": {"1": 3, "2": 2, "3": 1}, "5": {}}, } } typedef = { @@ -1118,53 +1390,145 @@ def _build_room_clean_payload(self, room_ids: list[int]) -> bytes: "message_typedef": { "1": {"type": "int"}, "2": {"type": "int"}, - "3": {"type": "int"} - } + "3": {"type": "int"}, + }, }, - "5": {"type": "message", "message_typedef": {}} - } - } - } + "5": {"type": "message", "message_typedef": {}}, + }, + }, + }, } } - return blackboxprotobuf.encode_message(msg, typedef) + return blackboxprotobuf.encode_message(message, typedef) async def start_rooms( - self, room_ids: list[int], + self, + room_ids: list[int], + *, + work_mode: WorkMode = WorkMode.VACUUM_AND_MOP, + fan: FanLevel = FanLevel.DEEP, + water: MopHumidity = MopHumidity.WET, + mop_strength: MopStrengthLevel = MopStrengthLevel.NORMAL, + passes: int = 1, + route: CleaningRoute | None = None, ) -> CommandResponse: - """Start room-specific cleaning. + """Start cleaning the given rooms via clean/start_clean. - Sends clean/plan/start with the user-selected rooms. Tries the v2 - nested-room schema first (required by firmware v01.07.22+, and fixes - the ack-but-ignore behavior in #37 where legacy schema returns - SUCCESS but the robot runs the app shortcut instead of HA-selected - rooms). Falls back to legacy flat-room schema on NOT_APPLICABLE - for older firmware. + Room cleaning must use clean/start_clean (StartClean → CleanTask), not + clean/plan/start: on Flow firmware the latter is StartWithPlan{planId, + mapId} and ignores any room payload — the root cause of #25/#37, where + the robot undocks and wanders instead of cleaning the selected rooms. + The CleanTask carries the active map id (get_map field 2.1). - Args: - room_ids: List of room IDs from RoomInfo.room_id. + clean/start_clean only works while docked; from STANDBY the robot + returns NOT_READY (4). Callers should start from the dock; this retries + briefly to cover the dock settling transition. - Returns: - CommandResponse with result code from whichever schema landed. + Args: + room_ids: Robot room IDs (RoomInfo.room_id), mapped from HA areas. + work_mode, fan, water, mop_strength, passes, route: CleanParam settings — + see _build_start_clean_payload. """ if not room_ids: return await self.start() - payload_v2 = self._build_clean_payload_v2(room_ids) - resp = await self.send_command( - TOPIC_CMD_START_CLEAN, payload=payload_v2, timeout=10.0, + product_key = ( + self.state.device_info.product_key + if self.state.device_info is not None + and self.state.device_info.product_key + else self.topic_prefix.removeprefix("/") ) + supports_legacy_room_clean = product_key in LEGACY_ROOM_CLEAN_PRODUCT_KEYS + map_data = self.state.map_data + if not map_data or not map_data.map_id: + try: + map_data = await self.get_map() + except NarwalCommandError: + if not supports_legacy_room_clean: + raise + _LOGGER.debug( + "start_rooms: map fetch failed; trying legacy room-clean commands" + ) + map_data = None + map_id = map_data.map_id if map_data else 0 + if not map_id and not supports_legacy_room_clean: + return CommandResponse(result_code=CommandResult.NOT_APPLICABLE) + resp = CommandResponse(result_code=CommandResult.NOT_APPLICABLE) + if map_id: + payload = self._build_start_clean_payload( + room_ids, + map_id, + work_mode=work_mode, + fan=fan, + water=water, + mop_strength=mop_strength, + passes=passes, + route=route, + ) + resp = await self.send_command( + TOPIC_CMD_CLEAN_TASK, payload=payload, timeout=10.0, + ) + for _ in range(3): + if resp.result_code != CommandResult.NOT_READY: + break + if not self.state.is_docked: + _LOGGER.warning( + "start_rooms: robot not docked (status=%s); " + "clean/start_clean requires the robot on the dock", + self.state.working_status.name, + ) + break + _LOGGER.info( + "start_rooms: robot docking/settling, retrying " + "clean/start_clean" + ) + await asyncio.sleep(3.0) + resp = await self.send_command( + TOPIC_CMD_CLEAN_TASK, payload=payload, timeout=10.0, + ) + else: + _LOGGER.warning( + "start_rooms: no active map id available; trying legacy room clean" + ) if resp.result_code != CommandResult.NOT_APPLICABLE: return resp + if not supports_legacy_room_clean: + return resp - # v2 rejected — try legacy flat-room schema (older firmware) _LOGGER.info( - "start_rooms(): v2 payload rejected, retrying with legacy schema (%d rooms)", - len(room_ids), + "start_rooms: clean/start_clean rejected, trying clean/plan/start " + "compatibility payloads" ) - payload_legacy = self._build_room_clean_payload(room_ids) + legacy_suction = { + FanLevel.UNSPECIFIED: 3, + FanLevel.MUTE: 0, + FanLevel.NORMAL: 1, + FanLevel.STRONG: 2, + FanLevel.DEEP: 3, + FanLevel.SUPER: 3, + }[FanLevel(fan)] + legacy_water = { + MopHumidity.UNSPECIFIED: 2, + MopHumidity.DRY: 0, + MopHumidity.NORMAL: 1, + MopHumidity.WET: 2, + }[MopHumidity(water)] + legacy_v2 = self._build_clean_payload_v2( + room_ids, + suction=legacy_suction, + mop_humidity=legacy_water, + passes=passes, + ) + resp = await self.send_command( + TOPIC_CMD_PLAN_START, payload=legacy_v2, timeout=10.0, + ) + if resp.result_code != CommandResult.NOT_APPLICABLE: + return resp + return await self.send_command( - TOPIC_CMD_START_CLEAN, payload=payload_legacy, timeout=10.0, + TOPIC_CMD_PLAN_START, + payload=self._build_room_clean_payload(room_ids), + timeout=10.0, ) async def start_easy_clean(self) -> CommandResponse: @@ -1196,27 +1560,58 @@ async def return_to_base(self, timeout: float = COMMAND_RESPONSE_TIMEOUT) -> Com return await self.send_command(TOPIC_CMD_RECALL, timeout=timeout) async def set_fan_speed(self, level: FanLevel | int) -> CommandResponse: - """Set suction fan speed. + """Set suction fan speed live (clean/set_fan_level, field 1 = SweepFanLevel). - Args: - level: FanLevel enum or int (0=quiet, 1=normal, 2=strong, 3=max). + The live command's enum is SweepFanLevel, which has no SUPER. Use its + highest available level, DEEP, when callers request SUPER. Bare integer + values retain the original 0=quiet through 3=max API mapping. """ - payload = b"\x08" + bytes([int(level) & 0x7F]) + if isinstance(level, FanLevel): + live = min(int(level), int(FanLevel.DEEP)) + else: + legacy_levels = { + 0: FanLevel.MUTE, + 1: FanLevel.NORMAL, + 2: FanLevel.STRONG, + 3: FanLevel.DEEP, + } + try: + live = int(legacy_levels[level]) + except KeyError as err: + raise ValueError(f"Invalid legacy fan level: {level}") from err + payload = b"\x08" + bytes([live & 0x7F]) return await self.send_command(TOPIC_CMD_SET_FAN_LEVEL, payload) async def set_mop_humidity(self, level: MopHumidity | int) -> CommandResponse: - """Set mop wetness level. + """Set mop water volume live (clean/set_mop_humidity, field 1 = MopHumidity). Args: - level: MopHumidity enum or int (0=dry, 1=normal, 2=wet). + level: MopHumidity enum, or the legacy int mapping + (0=dry, 1=normal, 2=wet). """ - payload = b"\x08" + bytes([int(level) & 0x7F]) + if isinstance(level, MopHumidity): + live = int(level) + else: + legacy_levels = { + 0: MopHumidity.DRY, + 1: MopHumidity.NORMAL, + 2: MopHumidity.WET, + } + try: + live = int(legacy_levels[level]) + except KeyError as err: + raise ValueError(f"Invalid legacy mop humidity: {level}") from err + payload = b"\x08" + bytes([live & 0x7F]) return await self.send_command(TOPIC_CMD_SET_MOP_HUMIDITY, payload) async def wash_mop(self) -> CommandResponse: """Wash the mop pads at the station.""" return await self.send_command(TOPIC_CMD_WASH_MOP) + async def wash_mop_by_robot_status(self) -> CommandResponse: + """Wash mop pads using the app's status-gated station command.""" + return await self.send_command(TOPIC_CMD_WASH_MOP_BY_ROBOT_STATUS) + async def dry_mop(self) -> CommandResponse: """Dry the mop pads at the station.""" return await self.send_command(TOPIC_CMD_DRY_MOP) @@ -1225,6 +1620,18 @@ async def empty_dustbin(self) -> CommandResponse: """Empty the dustbin at the station.""" return await self.send_command(TOPIC_CMD_DUST_GATHERING) + async def wash_and_dry_mop(self) -> CommandResponse: + """Wash and dry the mop pads at the station.""" + return await self.send_command(TOPIC_CMD_WASH_AND_DRY_MOP) + + async def dry_dust_bag(self) -> CommandResponse: + """Dry/disinfect the robot dust bin/canister.""" + return await self.send_command(TOPIC_CMD_DRY_DUST_BAG) + + async def dry_station_bag(self) -> CommandResponse: + """Dry/disinfect the dock dust bag.""" + return await self.send_command(TOPIC_CMD_DRY_STATION_BAG) + # --- Query commands --- async def get_device_info(self) -> DeviceInfo: @@ -1270,13 +1677,28 @@ async def get_status(self, full_update: bool = True) -> CommandResponse: """ resp = await self.send_command(TOPIC_CMD_GET_BASE_STATUS) status_data = resp.data.get("2", {}) + if status_data and not isinstance(status_data, dict): + _LOGGER.debug( + "%s get_status response field 2 is %s, not a base-status object: %r", + self.host, + type(status_data).__name__, + status_data, + ) + return resp if status_data: _LOGGER.debug( - "get_status response (full=%s): field3=%r, field2=%r", + "%s get_status response (full=%s): field3=%r, field2=%r", + self.host, full_update, status_data.get("3") if isinstance(status_data, dict) else None, status_data.get("2") if isinstance(status_data, dict) else None, ) + _LOGGER.debug( + "%s get_status decoded base_status (full=%s): %r", + self.host, + full_update, + status_data, + ) if full_update: self.state.update_from_base_status(status_data) else: @@ -1289,6 +1711,27 @@ async def get_current_task(self) -> CommandResponse: """Query the current clean task.""" return await self.send_command(TOPIC_CMD_GET_CURRENT_TASK) + async def get_clean_progress_info(self) -> CommandResponse: + """Query active clean progress information.""" + resp = await self.send_command(TOPIC_CMD_GET_CLEAN_PROGRESS_INFO) + self.state.update_from_aux_status(TOPIC_CMD_GET_CLEAN_PROGRESS_INFO, resp.data) + _LOGGER.debug("%s clean_progress_info response: %r", self.host, resp.data) + return resp + + async def get_dry_mop_remain_time(self) -> CommandResponse: + """Query remaining mop drying time.""" + resp = await self.send_command(TOPIC_CMD_GET_DRY_MOP_REMAIN_TIME) + self.state.update_from_aux_status(TOPIC_CMD_GET_DRY_MOP_REMAIN_TIME, resp.data) + _LOGGER.debug("%s dry_mop_remain_time response: %r", self.host, resp.data) + return resp + + async def get_robot_task_status(self) -> CommandResponse: + """Query the robot task status model.""" + resp = await self.send_command(TOPIC_CMD_GET_ROBOT_TASK_STATUS) + self.state.update_from_aux_status(TOPIC_CMD_GET_ROBOT_TASK_STATUS, resp.data) + _LOGGER.debug("%s robot_task_status response: %r", self.host, resp.data) + return resp + async def get_map(self) -> MapData: """Download the full map data.""" resp = await self.send_command(TOPIC_CMD_GET_MAP, timeout=15.0) diff --git a/custom_components/narwal/narwal_client/const.py b/custom_components/narwal/narwal_client/const.py index 0c877c7..c80ae16 100644 --- a/custom_components/narwal/narwal_client/const.py +++ b/custom_components/narwal/narwal_client/const.py @@ -24,6 +24,7 @@ # Confirmed working (local WebSocket) "QoEsI5qYXO", # AX12 — Narwal Flow (primary, confirmed) "QxMSPG6VSO", # Narwal Flow 2 (confirmed working via local WebSocket) + "iSuVlI1If2", # Narwal Flow 2 alternate key (confirmed working locally) "DrzDKQ0MU8", # CX4 — Freo Z10 Ultra (confirmed by @irekkl-maker) # Confirmed cloud-only (port 9002 open but no local broadcasts) "BYWBPqSxeC", # CX7 — Freo Z Ultra (cloud-only, confirmed by @gabrielozcomidi) @@ -51,6 +52,8 @@ "cUlfJN5JYP", # Unknown model (APK, contributed by @northwestsupra) ] +LEGACY_ROOM_CLEAN_PRODUCT_KEYS = {"QoEsI5qYXO"} + # --- Status topics (robot → client, field 4 / 0x22 frames) --- TOPIC_WORKING_STATUS = "status/working_status" TOPIC_ROBOT_BASE_STATUS = "status/robot_base_status" @@ -58,7 +61,11 @@ TOPIC_DOWNLOAD_STATUS = "status/download_status" TOPIC_DISPLAY_MAP = "map/display_map" TOPIC_TIMELINE_STATUS = "status/time_line_status" +TOPIC_POINT_NAVI_PLAN_TRAJ = "status/point_navi_plan_traj" TOPIC_PLANNING_DEBUG = "developer/planning_debug_info" +TOPIC_ROBOT_STATUS = "status/robot" +TOPIC_ROBOT_CURRENT_STATUS = "status/robot/current" +TOPIC_ROBOT_TASK_STATUS = "robot/task/status" # --- Command topics (client → robot, confirmed working) --- # Common @@ -78,16 +85,23 @@ # Supply/dock TOPIC_CMD_RECALL = "supply/recall" TOPIC_CMD_WASH_MOP = "supply/wash_mop" +TOPIC_CMD_WASH_MOP_BY_ROBOT_STATUS = "supply/wash_mop_by_robot_status" TOPIC_CMD_DRY_MOP = "supply/dry_mop" TOPIC_CMD_DUST_GATHERING = "supply/dust_gathering" +TOPIC_CMD_WASH_AND_DRY_MOP = "supply/wash_and_dry_mop" +TOPIC_CMD_DRY_DUST_BAG = "supply/dry_dust_bag" +TOPIC_CMD_DRY_STATION_BAG = "supply/dry_station_bag" # Cleaning (Pita protocol — correct for AX12) -TOPIC_CMD_START_CLEAN = "clean/plan/start" # whole-house clean (empty payload) -TOPIC_CMD_START_CLEAN_LEGACY = "clean/start_clean" # does NOT work from STANDBY +TOPIC_CMD_PLAN_START = "clean/plan/start" # whole-house clean (empty payload) +TOPIC_CMD_CLEAN_TASK = "clean/start_clean" # room/zone CleanTask; only works docked TOPIC_CMD_EASY_CLEAN = "clean/easy_clean/start" TOPIC_CMD_SET_FAN_LEVEL = "clean/set_fan_level" TOPIC_CMD_SET_MOP_HUMIDITY = "clean/set_mop_humidity" TOPIC_CMD_GET_CURRENT_TASK = "clean/current_clean_task/get" +TOPIC_CMD_GET_CLEAN_PROGRESS_INFO = "info/get_clean_progress_info" +TOPIC_CMD_GET_DRY_MOP_REMAIN_TIME = "supply/get_dry_mop_remain_time" +TOPIC_CMD_GET_ROBOT_TASK_STATUS = "robot/task/status/get" # Map TOPIC_CMD_GET_MAP = "map/get_map" @@ -141,6 +155,7 @@ class CommandResult(IntEnum): SUCCESS = 1 NOT_APPLICABLE = 2 # e.g., set_fan_level when not cleaning CONFLICT = 3 # e.g., recall when already recalling + NOT_READY = 4 # clean/start_clean while not docked (robot in STANDBY) class WorkingStatus(IntEnum): @@ -149,8 +164,10 @@ class WorkingStatus(IntEnum): Values confirmed via live WebSocket monitoring: 1 = STANDBY (idle, transition state between cleaning and docked) 2 = DOCKED_V2 (on dock; confirmed v01.07.23.00 while charging at 10-36%) + 3 = CLEANING_V2 (active room clean; confirmed on Flow 2 v01.07.23) 4 = CLEANING (plan-based start; also stays 4 while returning to dock on older FW) 5 = CLEANING_ALT (observed live: robot was physically stuck when reporting 5) + 7 = CLEANING_FLOW2 (active cleaning on Flow 2 v01.07.10.33) 10 = DOCKED (on dock, charging) 14 = CHARGED (on dock, fully charged) 19 = TASK_COMPLETED (transitional: scheduled task finished, returning to base) @@ -168,8 +185,10 @@ class WorkingStatus(IntEnum): UNKNOWN = 0 STANDBY = 1 # idle / transition state DOCKED_V2 = 2 # on dock (v01.07.23.00+ — replaces DOCKED=10/CHARGED=14 from older FW) + CLEANING_V2 = 3 # active cleaning on Flow 2 firmware v01.07.23+ CLEANING = 4 # active cleaning (stays 4 even while returning to dock) CLEANING_ALT = 5 # cleaning — observed when robot was physically stuck; may indicate error/stuck state + CLEANING_FLOW2 = 7 # active cleaning on Flow 2 v01.07.10.33 DOCKED = 10 # on dock (does NOT reliably indicate charging vs charged) CHARGED = 14 # on dock (reported before 100% — use battery_level for charge state) TASK_COMPLETED = 19 # transitional: task finished, robot returning to base (#41) @@ -178,21 +197,60 @@ class WorkingStatus(IntEnum): ERROR = 99 +ACTIVE_CLEANING_STATUSES = frozenset( + { + WorkingStatus.CLEANING_V2, + WorkingStatus.CLEANING, + WorkingStatus.CLEANING_ALT, + WorkingStatus.CLEANING_FLOW2, + } +) + + class FanLevel(IntEnum): - """Suction fan speed levels (SweepMode from APK).""" + """CleanParam suction level (CleanTask.pbenum FanLevel).""" - QUIET = 0 - NORMAL = 1 - STRONG = 2 - MAX = 3 + UNSPECIFIED = 0 + MUTE = 1 + QUIET = MUTE + NORMAL = 2 + STRONG = 3 + DEEP = 4 + MAX = DEEP + SUPER = 5 class MopHumidity(IntEnum): - """Mop wetness levels.""" + """Water volume. CleanParam tag 4 and the live clean/set_mop_humidity command share these ints.""" + + UNSPECIFIED = 0 + DRY = 1 + NORMAL = 2 + WET = 3 + + +class MopStrengthLevel(IntEnum): + """Mop scrub intensity (CleanParam tag 3).""" - DRY = 0 + UNSPECIFIED = 0 NORMAL = 1 - WET = 2 + HIGH = 2 + + +class CleaningRoute(IntEnum): + """Cleaning route overlap level (CleanParam tag 8).""" + + STANDARD = 1 + METICULOUS = 2 + + +class WorkMode(IntEnum): + """Clean work mode — the app's robot_work_mode_* selector (Vacuum / Mop / Vacuum then mop / Vacuum and mop). Its value IS the CleanTask.taskType the robot executes; the per-item CleanParam.mode (the proto's own CleanMode enum) is derived separately in client._WORK_MODE_PARAM.""" + + VACUUM = 1 + MOP = 2 + VACUUM_THEN_MOP = 3 + VACUUM_AND_MOP = 4 # robot_base_status field numbers diff --git a/custom_components/narwal/narwal_client/models.py b/custom_components/narwal/narwal_client/models.py index 11a69c6..067eddb 100644 --- a/custom_components/narwal/narwal_client/models.py +++ b/custom_components/narwal/narwal_client/models.py @@ -4,12 +4,20 @@ import logging import struct +import time from dataclasses import dataclass, field from typing import Any, ClassVar _LOGGER = logging.getLogger(__name__) +_ACTIVE_WORKING_STATUS_TTL = 15.0 -from .const import CommandResult, FanLevel, MopHumidity, WorkingStatus +from .const import ( + ACTIVE_CLEANING_STATUSES, + TOPIC_CMD_GET_ROBOT_TASK_STATUS, + TOPIC_ROBOT_TASK_STATUS, + CommandResult, + WorkingStatus, +) @dataclass @@ -55,6 +63,11 @@ class RoomInfo: 5: "Bathroom", 10: "Corridor", }, + "iSuVlI1If2": { # Flow 2 alternate product key + 1: "Master Bedroom", + 5: "Bathroom", + 10: "Corridor", + }, } def __post_init__(self): @@ -237,6 +250,73 @@ def _parse_obstacles(field32: dict) -> list[ObstacleInfo]: return obstacles +def _coerce_bytes(value: Any) -> bytes: + """Return a protobuf bytes value as bytes.""" + if isinstance(value, bytes): + return value + if isinstance(value, bytearray): + return bytes(value) + if isinstance(value, str): + return value.encode("latin-1", "ignore") + return b"" + + +def _count_cleaned_pixels(value: Any, expected_pixels: int) -> int: + """Count non-zero cells in a display_map cleaned-area overlay.""" + data = _coerce_bytes(value) + if not data: + return 0 + + from .map_renderer import _decode_packed_varints, decompress_map + + decompressed = decompress_map(data) + pixels = _decode_packed_varints(decompressed) + if pixels: + if expected_pixels > 0: + pixels = pixels[:expected_pixels] + return sum(1 for pixel in pixels if pixel) + + raw = decompressed or data + if expected_pixels > 0: + raw = raw[:expected_pixels] + return sum(1 for byte in raw if byte) + + +def _extract_ints(value: Any) -> list[int]: + """Extract integer values from a loosely-decoded protobuf field.""" + if isinstance(value, bool): + return [] + if isinstance(value, int): + return [value] + if isinstance(value, list): + result: list[int] = [] + for item in value: + result.extend(_extract_ints(item)) + return result + if isinstance(value, dict): + result: list[int] = [] + for item in value.values(): + result.extend(_extract_ints(item)) + return result + return [] + + +def _positive_int_field(decoded: dict[str, Any], field: str) -> bool: + """Return true when a decoded protobuf field is a positive integer.""" + try: + return int(decoded.get(field, 0) or 0) > 0 + except (TypeError, ValueError): + return False + + +def _optional_int(value: Any) -> int | None: + """Return value coerced to int, or None when it cannot be coerced.""" + try: + return int(value) + except (TypeError, ValueError): + return None + + @dataclass class MapData: """Map data from get_map response.""" @@ -254,6 +334,7 @@ class MapData: origin_y: int = 0 # y pixel offset from field 2.6.1 obstacles: list[ObstacleInfo] = field(default_factory=list) raw: dict[str, Any] = field(default_factory=dict) + map_id: int = 0 # active map id (field 2.1) — required by clean/start_clean @classmethod def from_response( @@ -344,6 +425,7 @@ def from_response( obstacles = _parse_obstacles(field32) return cls( + map_id=int(payload.get("1", 0)), width=int(payload.get("4", 0)), height=int(payload.get("5", 0)), resolution=resolution, @@ -359,6 +441,43 @@ def from_response( raw=payload, ) + def cleanable_area_cm2(self, room_ids: list[int] | None = None) -> int: + """Estimate cleanable area in cm² from room floor pixels.""" + if not self.compressed_map or self.width <= 0 or self.height <= 0: + return 0 + if self.resolution <= 0: + return 0 + + from .map_renderer import _decode_packed_varints, decompress_map + + pixels = _decode_packed_varints(decompress_map(self.compressed_map)) + expected = self.width * self.height + if len(pixels) < expected: + pixels.extend([0] * (expected - len(pixels))) + elif len(pixels) > expected: + pixels = pixels[:expected] + + selected = {int(room_id) for room_id in room_ids or []} + floor_pixels = 0 + for val in pixels: + if val in (0, 0x28): + continue + if val == 0x20: + if selected: + continue + floor_pixels += 1 + continue + room_id = val >> 8 + pixel_type = val & 0xFF + if pixel_type & 0x10: + continue + if selected and room_id not in selected: + continue + floor_pixels += 1 + + cm_per_pixel = self.resolution / 10 + return round(floor_pixels * cm_per_pixel * cm_per_pixel) + @dataclass class MapDisplayData: @@ -384,6 +503,10 @@ class MapDisplayData: # Dock/reference position from field 5 (same coordinate system as robot) dock_ref_x: float = 0.0 dock_ref_y: float = 0.0 + cleaned_width: int = 0 + cleaned_height: int = 0 + cleaned_pixel_count: int = 0 + active_room_ids: list[int] = field(default_factory=list) def to_grid_coords( self, resolution: int, origin_x: int, origin_y: int, @@ -455,8 +578,39 @@ def from_broadcast(cls, decoded: dict[str, Any]) -> MapDisplayData: except (ValueError, TypeError): pass + field7 = decoded.get("7") + if isinstance(field7, list): + field7 = field7[0] if field7 else None + if isinstance(field7, dict): + try: + result.cleaned_width = int(field7.get("1", 0)) + result.cleaned_height = int(field7.get("2", 0)) + except (ValueError, TypeError): + result.cleaned_width = 0 + result.cleaned_height = 0 + result.cleaned_pixel_count = _count_cleaned_pixels( + field7.get("3"), + result.cleaned_width * result.cleaned_height, + ) + + if "12" in decoded: + seen: set[int] = set() + room_ids: list[int] = [] + for room_id in _extract_ints(decoded["12"]): + if room_id > 0 and room_id not in seen: + seen.add(room_id) + room_ids.append(room_id) + result.active_room_ids = room_ids + return result + def cleaned_area_cm2(self, resolution: int) -> int: + """Return the cleaned overlay area in cm².""" + if self.cleaned_pixel_count <= 0 or resolution <= 0: + return 0 + cm_per_pixel = resolution / 10 + return round(self.cleaned_pixel_count * cm_per_pixel * cm_per_pixel) + @dataclass class Position: @@ -495,6 +649,7 @@ class NarwalState: working_status: WorkingStatus = WorkingStatus.UNKNOWN battery_level: int = 0 # real-time SOC from field 2 (float32) battery_health: int = 0 # static design capacity from field 38 (always 100) + inferred_docked_from_battery: bool = False firmware_version: str = "" firmware_target: str = "" @@ -511,6 +666,12 @@ class NarwalState: # Cleaning stats cleaning_area: int = 0 # cm² cleaning_time: int = 0 # seconds + last_active_working_status_time: float = 0.0 + task_progress_percent: int | None = None + task_elapsed_time: int = 0 + current_room_id: int | None = None + current_room_name: str = "" + dry_mop_remaining_time: int | None = None # Map map_data: MapData | None = None @@ -534,10 +695,22 @@ class NarwalState: # Dock activity (field 3 sub-field 12: 2/6 observed when docked) dock_activity: int = 0 + # Station activity (field 3 sub-field 18) + # Observed: 1 during dust gathering, 4 during dock dry/disinfection work. + station_activity: int = 0 + # Dock presence (field 3 sub-field 3) # Values observed: 1=on dock, 2=off dock, 6=on dock (charged idle) dock_presence: int = 0 + # Newer Flow firmware sub-state (field 3 sub-field 4). + # Observed during active clean startup/navigation: 8 -> 7 -> 3. + flow_activity: int = 0 + + # Newer Flow firmware task flag (field 3 sub-field 14). Observed as 1 + # during active cleaning/navigation. + flow_task_flag: int = 0 + # Dock indicator from field 11 (top-level base_status field) # Validated via dock_research.py guided test (5 captures): # 2 = on dock (all 3 on-dock captures) @@ -555,12 +728,67 @@ class NarwalState: # Raw data for fields we haven't fully decoded yet raw_base_status: dict[str, Any] = field(default_factory=dict) raw_working_status: dict[str, Any] = field(default_factory=dict) + raw_aux_status: dict[str, dict[str, Any]] = field(default_factory=dict) + + def _working_status_is_cleaning_like(self) -> bool: + """True when the cached working_status looks like a cleaning task.""" + return self.working_status in ( + WorkingStatus.CLEANING, + WorkingStatus.CLEANING_V2, + WorkingStatus.CLEANING_ALT, + WorkingStatus.CLEANING_FLOW2, + ) + + def _dock_fields_indicate_docked(self) -> bool: + """True when base-status dock fields indicate the robot is on dock.""" + if self.dock_sub_state == 1: + return True + if self.dock_activity > 0: + return True + if self.station_activity > 0: + return True + if self.dock_field11 >= 2: + return True + if self.dock_field47 in (1, 3): + return True + return False + + @property + def is_station_active(self) -> bool: + """True when the base station is running a dock-side task.""" + return self.station_activity > 0 or ( + self.working_status == WorkingStatus.CLEANING_ALT and self.is_docked + ) + + def _set_battery_level(self, battery_level: int) -> None: + """Update battery level and infer docked state from charging trend.""" + if ( + self.battery_level > 0 + and battery_level > self.battery_level + and self._working_status_is_cleaning_like() + and not self.has_recent_active_working_status + ): + self.inferred_docked_from_battery = True + self.battery_level = battery_level + + @property + def has_recent_active_working_status(self) -> bool: + """True while working_status is actively reporting task counters.""" + if self.working_status not in ACTIVE_CLEANING_STATUSES: + return False + if self.last_active_working_status_time <= 0: + return False + return time.monotonic() - self.last_active_working_status_time <= _ACTIVE_WORKING_STATUS_TTL @property def is_cleaning(self) -> bool: """True when actively cleaning (not paused, not returning to dock).""" + if self.has_recent_active_working_status: + return not self.is_paused and not self.is_returning + if self._dock_fields_indicate_docked() or self.inferred_docked_from_battery: + return False return ( - self.working_status in (WorkingStatus.CLEANING, WorkingStatus.CLEANING_ALT) + self._working_status_is_cleaning_like() and not self.is_paused and not self.is_returning_to_dock ) @@ -581,25 +809,21 @@ def is_docked(self) -> bool: cleaning is not active, since the robot can report unmapped states (e.g. self-test) while physically docked. """ + if self.has_recent_active_working_status: + return False if self.working_status in ( WorkingStatus.DOCKED, WorkingStatus.CHARGED, WorkingStatus.DOCKED_V2, ): return True - if self.working_status in (WorkingStatus.CLEANING, WorkingStatus.CLEANING_ALT): - return False # For STANDBY, UNKNOWN, or any other status: check dock field signals. # Values differ across firmware versions: # Old FW: dock_sub_state=1, dock_field11=2, dock_field47=3 # v01.07.23.00: dock_sub_state absent, dock_field11=3, dock_field47=1 - if self.dock_sub_state == 1: - return True - if self.dock_activity > 0: - return True - if self.dock_field11 >= 2: - return True - if self.dock_field47 in (1, 3): - return True - return False + # + # Narwal can keep reporting a stale CLEANING working_status after the + # active working_status payload has stopped. Once that live payload is + # no longer recent, dock fields are a better source of truth. + return self._dock_fields_indicate_docked() or self.inferred_docked_from_battery @property def is_returning(self) -> bool: @@ -618,8 +842,11 @@ def is_returning(self) -> bool: transitions to STANDBY/DOCKED/CHARGED, it has already docked even if field 3.7 is momentarily still set. """ - if self.working_status not in ( - WorkingStatus.CLEANING, WorkingStatus.CLEANING_ALT, + if not self.has_recent_active_working_status and self.working_status not in ( + WorkingStatus.CLEANING, + WorkingStatus.CLEANING_V2, + WorkingStatus.CLEANING_ALT, + WorkingStatus.CLEANING_FLOW2, ): return False return self.is_returning_to_dock and self.dock_sub_state == 2 @@ -634,6 +861,8 @@ def update_from_working_status(self, decoded: dict[str, Any]) -> None: Field 15 = 600 during cleaning (purpose uncertain) """ self.raw_working_status = decoded + previous_cleaning_time = self.cleaning_time + previous_cleaning_area = self.cleaning_area if "3" in decoded: try: self.cleaning_time = int(decoded["3"]) @@ -644,6 +873,34 @@ def update_from_working_status(self, decoded: dict[str, Any]) -> None: if "15" in decoded: # Field 15 may be cumulative time; prefer field 3 for current session pass + has_active_payload = any( + _positive_int_field(decoded, field) for field in ("3", "13") + ) + active_payload_changed = ( + self.cleaning_time != previous_cleaning_time + or self.cleaning_area != previous_cleaning_area + ) + if has_active_payload and active_payload_changed: + self.last_active_working_status_time = time.monotonic() + self.inferred_docked_from_battery = False + if ( + has_active_payload + and active_payload_changed + and self.working_status + in ( + WorkingStatus.UNKNOWN, + WorkingStatus.STANDBY, + WorkingStatus.DOCKED, + WorkingStatus.CHARGED, + WorkingStatus.DOCKED_V2, + ) + ): + self.working_status = WorkingStatus.CLEANING + self.dock_field11 = 1 + self.dock_field47 = 2 + self.dock_sub_state = 0 + self.dock_activity = 0 + self.station_activity = 0 def update_from_base_status(self, decoded: dict[str, Any]) -> None: """Update state from a decoded robot_base_status message. @@ -659,6 +916,7 @@ def update_from_base_status(self, decoded: dict[str, Any]) -> None: 3.7 = 1 means RETURNING to dock (live-validated) 3.10 = dock sub-state (1=docked, 2=docking in progress) 3.12 = dock activity (values 2, 6 observed) + 3.18 = station activity (1=dust gathering, 4=dry/disinfection observed) Dock indicators (validated via dock_research.py, 5 captures): Field 11 = 2 when docked, 1 when undocked @@ -715,13 +973,41 @@ def update_from_base_status(self, decoded: dict[str, Any]) -> None: self.dock_activity = int(field3["12"]) except (ValueError, TypeError): pass + self.station_activity = 0 + if "18" in field3: + try: + self.station_activity = int(field3["18"]) + except (ValueError, TypeError): + pass if "3" in field3: try: self.dock_presence = int(field3["3"]) except (ValueError, TypeError): pass + self.flow_activity = 0 + if "4" in field3: + try: + self.flow_activity = int(field3["4"]) + except (ValueError, TypeError): + pass + self.flow_task_flag = 0 + if "14" in field3: + try: + self.flow_task_flag = int(field3["14"]) + except (ValueError, TypeError): + pass + if self.working_status in ACTIVE_CLEANING_STATUSES: + if "11" not in decoded: + self.dock_field11 = 1 + if "47" not in decoded: + self.dock_field47 = 2 + if "10" not in field3: + self.dock_sub_state = 0 + if "12" not in field3: + self.dock_activity = 0 + self.inferred_docked_from_battery = False # Log unrecognized sub-fields for future firmware mapping - _known_f3 = {"1", "2", "3", "7", "10", "12"} + _known_f3 = {"1", "2", "3", "4", "7", "10", "11", "12", "14", "18"} _unknown_f3 = set(field3.keys()) - _known_f3 if _unknown_f3: _LOGGER.debug( @@ -734,12 +1020,21 @@ def update_from_base_status(self, decoded: dict[str, Any]) -> None: "Please report this at the GitHub repo.", type(field3).__name__, field3, ) + explicitly_off_dock = ( + ("11" in decoded and self.dock_field11 == 1) + or ("47" in decoded and self.dock_field47 == 2) + ) + if not self.is_returning_to_dock and explicitly_off_dock: + self.dock_sub_state = 0 + self.dock_activity = 0 if "2" in decoded: # Field 2 = real-time battery SOC as float32 # (e.g. 1118175232 → 83.0%; bbp may return int or float) bat = _to_float32(decoded["2"]) if bat is not None: - self.battery_level = round(bat) + self._set_battery_level(round(bat)) + if explicitly_off_dock: + self.inferred_docked_from_battery = False if "38" in decoded: # Field 38 = static battery health (always 100, design capacity) self.battery_health = int(decoded["38"]) @@ -766,7 +1061,7 @@ def update_battery_from_base_status(self, decoded: dict[str, Any]) -> None: if "2" in decoded: bat = _to_float32(decoded["2"]) if bat is not None: - self.battery_level = round(bat) + self._set_battery_level(round(bat)) if "38" in decoded: self.battery_health = int(decoded["38"]) if "36" in decoded: @@ -797,3 +1092,46 @@ def update_from_download_status(self, decoded: dict[str, Any]) -> None: """Update state from a decoded download_status message.""" if "1" in decoded: self.download_status = int(decoded["1"]) + + def update_from_aux_status(self, topic: str, decoded: dict[str, Any]) -> None: + """Store decoded status payloads that are not mapped yet.""" + self.raw_aux_status[topic] = decoded + if topic in {TOPIC_ROBOT_TASK_STATUS, TOPIC_CMD_GET_ROBOT_TASK_STATUS}: + payload = decoded.get("2") + if not isinstance(payload, dict): + return + progress = _optional_int(payload.get("1")) + if progress is not None: + self.task_progress_percent = max(0, min(100, progress)) + elapsed = _optional_int(payload.get("2")) + if elapsed is not None: + self.task_elapsed_time = elapsed + room = payload.get("6") + if not isinstance(room, dict): + room = payload.get("8") + if isinstance(room, dict): + self.current_room_id = _optional_int(room.get("1")) + name = room.get("3") + if isinstance(name, (bytes, bytearray)): + self.current_room_name = bytes(name).decode( + "utf-8", errors="replace" + ) + else: + self.current_room_name = str(name) if name else "" + if self.current_room_name.startswith( + "b'" + ) and self.current_room_name.endswith("'"): + self.current_room_name = self.current_room_name[2:-1] + if self.current_room_id is not None and self.map_data is not None: + map_room = next( + ( + candidate + for candidate in self.map_data.rooms + if candidate.room_id == self.current_room_id + ), + None, + ) + if map_room is not None: + self.current_room_name = map_room.display_name + elif topic == "supply/get_dry_mop_remain_time": + self.dry_mop_remaining_time = _optional_int(decoded.get("2")) diff --git a/custom_components/narwal/select.py b/custom_components/narwal/select.py new file mode 100644 index 0000000..87d8c54 --- /dev/null +++ b/custom_components/narwal/select.py @@ -0,0 +1,259 @@ +"""Select entities for Narwal vacuum controls.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from homeassistant.components.select import SelectEntity, SelectEntityDescription +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.restore_state import RestoreEntity + +from . import NarwalConfigEntry +from .const import ( + FAN_SPEED_LIST, + FAN_SPEED_MAP, +) +from .coordinator import NarwalCoordinator +from .entity import NarwalEntity +from .narwal_client import CommandResult, MopHumidity, WorkingStatus + +MODE_OPTIONS = ("Vacuum", "Mop", "Vacuum then mop", "Vacuum and mop") +DEFAULT_MODE = "Vacuum and mop" +SUCTION_OPTIONS = ("AI", *FAN_SPEED_LIST) +WATER_OPTIONS = ("Dry", "Normal", "Wet") +SCRUB_OPTIONS = ("Normal", "High") +ROUTE_OPTIONS = ("Standard", "Meticulous") +PASSES_OPTIONS = ("1", "2", "3") + +ACTIVE_CLEANING_STATUSES = ( + WorkingStatus.CLEANING, + WorkingStatus.CLEANING_V2, + WorkingStatus.CLEANING_ALT, + WorkingStatus.CLEANING_FLOW2, +) + +MOP_MODES = {"Mop", "Vacuum then mop", "Vacuum and mop"} +VACUUM_MODES = {"Vacuum", "Vacuum then mop", "Vacuum and mop"} +START_ONLY_SETTINGS = {"mode", "passes", "route", "scrub"} + +RUNTIME_SUCTION_KEY = "runtime_suction" +RUNTIME_WATER_KEY = "runtime_water" + +SETTING_KEYS = { + "mode", + "suction", + "water", + "scrub", + "route", + "passes", +} + +WATER_OPTION_VALUES: dict[str, MopHumidity] = { + "Dry": MopHumidity.DRY, + "Normal": MopHumidity.NORMAL, + "Wet": MopHumidity.WET, +} + + +@dataclass(frozen=True, kw_only=True) +class NarwalSettingSelectEntityDescription(SelectEntityDescription): + """Describes a Narwal setting select.""" + + setting_key: str + setting_options: tuple[str, ...] + default_option: str + icon: str + + +SETTING_SELECT_DESCRIPTIONS: tuple[NarwalSettingSelectEntityDescription, ...] = ( + NarwalSettingSelectEntityDescription( + key="mode", + setting_key="mode", + translation_key="mode", + setting_options=MODE_OPTIONS, + default_option=DEFAULT_MODE, + icon="mdi:robot-vacuum", + ), + NarwalSettingSelectEntityDescription( + key=RUNTIME_SUCTION_KEY, + setting_key="suction", + translation_key="suction", + setting_options=SUCTION_OPTIONS, + default_option="AI", + icon="mdi:fan", + ), + NarwalSettingSelectEntityDescription( + key=RUNTIME_WATER_KEY, + setting_key="water", + translation_key="water", + setting_options=WATER_OPTIONS, + default_option="Wet", + icon="mdi:water", + ), + NarwalSettingSelectEntityDescription( + key="scrub", + setting_key="scrub", + translation_key="scrub", + setting_options=SCRUB_OPTIONS, + default_option="High", + icon="mdi:brush", + ), + NarwalSettingSelectEntityDescription( + key="route", + setting_key="route", + translation_key="route", + setting_options=ROUTE_OPTIONS, + default_option="Meticulous", + icon="mdi:routes", + ), + NarwalSettingSelectEntityDescription( + key="passes", + setting_key="passes", + translation_key="passes", + setting_options=PASSES_OPTIONS, + default_option="2", + icon="mdi:counter", + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: NarwalConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Narwal select entities.""" + coordinator = entry.runtime_data + entities: list[SelectEntity] = [ + NarwalSettingSelect(coordinator, description) + for description in SETTING_SELECT_DESCRIPTIONS + ] + async_add_entities(entities) + + +class NarwalSettingSelect(NarwalEntity, SelectEntity, RestoreEntity): + """Select entity for Narwal start settings and supported runtime controls.""" + + entity_description: NarwalSettingSelectEntityDescription + + def __init__( + self, + coordinator: NarwalCoordinator, + description: NarwalSettingSelectEntityDescription, + ) -> None: + """Initialize the select.""" + super().__init__(coordinator) + self.entity_description = description + device_id = coordinator.config_entry.data["device_id"] + self._attr_unique_id = f"{device_id}_{description.key}" + self._attr_icon = description.icon + self._attr_options = description.setting_options + + @property + def _settings(self) -> dict[str, str]: + """Return coordinator-backed selected options.""" + return self.coordinator.select_options + + async def async_added_to_hass(self) -> None: + """Restore the last selected option.""" + await super().async_added_to_hass() + last_state = await self.async_get_last_state() + if last_state is not None and last_state.state in self.options: + self._settings[self.entity_description.setting_key] = last_state.state + return + self._settings.setdefault( + self.entity_description.setting_key, + self._default_option, + ) + + @property + def _default_option(self) -> str: + """Return the default option.""" + return self.entity_description.default_option + + @property + def current_option(self) -> str | None: + """Return the current selected option.""" + return self._settings.get( + self.entity_description.setting_key, + self._default_option, + ) + + @property + def available(self) -> bool: + """Return True when the setting can be changed.""" + if not super().available: + return False + + mode = self._selected_mode + key = self.entity_description.setting_key + if key == "water" and mode not in MOP_MODES: + return False + if key == "scrub" and mode not in MOP_MODES: + return False + if key == "suction" and mode not in VACUUM_MODES: + return False + if key in START_ONLY_SETTINGS and self._is_cleaning_or_paused: + return False + return True + + @property + def _selected_mode(self) -> str: + """Return the selected clean mode.""" + return self._settings.get("mode", DEFAULT_MODE) + + @property + def _is_cleaning_or_paused(self) -> bool: + """Return True while the robot is in an active clean session.""" + state = self.coordinator.data + if state is None: + return False + return ( + state.working_status in ACTIVE_CLEANING_STATUSES + or state.has_recent_active_working_status + ) and not state.is_docked and not state.is_returning + + async def async_select_option(self, option: str) -> None: + """Apply a setting option.""" + if option not in self.options: + raise HomeAssistantError(f"Unsupported Narwal option: {option}") + + key = self.entity_description.setting_key + if key == "water" and self._selected_mode not in MOP_MODES: + raise HomeAssistantError("Water level is not available in vacuum-only mode") + if key == "scrub" and self._selected_mode not in MOP_MODES: + raise HomeAssistantError("Scrub level is not available in vacuum-only mode") + if key == "suction" and self._selected_mode not in VACUUM_MODES: + raise HomeAssistantError("Suction is not available in mop-only mode") + if key in START_ONLY_SETTINGS and self._is_cleaning_or_paused: + raise HomeAssistantError("This Narwal setting cannot be changed mid-clean") + if key == "suction" and option == "AI" and self._is_cleaning_or_paused: + raise HomeAssistantError("AI suction cannot be selected mid-clean") + + response = None + if self._is_cleaning_or_paused: + if key == "suction": + response = await self.coordinator.client.set_fan_speed( + FAN_SPEED_MAP[option] + ) + elif key == "water": + response = await self.coordinator.client.set_mop_humidity( + WATER_OPTION_VALUES[option] + ) + + if ( + response is not None + and response.result_code not in (0, CommandResult.SUCCESS) + ): + try: + result_name = CommandResult(response.result_code).name + except ValueError: + result_name = f"UNKNOWN({response.result_code})" + raise HomeAssistantError( + f"Narwal setting command failed: {result_name}" + ) + + self._settings[key] = option + self.async_write_ha_state() diff --git a/custom_components/narwal/sensor.py b/custom_components/narwal/sensor.py index 904b2fb..7494bb7 100644 --- a/custom_components/narwal/sensor.py +++ b/custom_components/narwal/sensor.py @@ -15,11 +15,10 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .narwal_client import NarwalState - from . import NarwalConfigEntry from .coordinator import NarwalCoordinator from .entity import NarwalEntity +from .narwal_client import NarwalState, WorkingStatus @dataclass(frozen=True, kw_only=True) @@ -29,6 +28,25 @@ class NarwalSensorEntityDescription(SensorEntityDescription): value_fn: Callable[[NarwalState], float | str | None] +def _has_active_cleaning_metrics(state: NarwalState) -> bool: + return state.is_cleaning or state.has_recent_active_working_status + + +def _station_task(state: NarwalState) -> str | None: + """Return the active dock task.""" + if not state.is_station_active: + return None + if state.station_activity == 1: + return "emptying_dustbin" + if state.station_activity in (2, 3): + return "washing_mop" + if state.dry_mop_remaining_time is not None and state.dry_mop_remaining_time > 0: + return "drying_mop" + if state.station_activity == 4: + return "drying_or_disinfecting" + return "station_active" + + SENSOR_DESCRIPTIONS: tuple[NarwalSensorEntityDescription, ...] = ( NarwalSensorEntityDescription( key="battery", @@ -43,10 +61,8 @@ class NarwalSensorEntityDescription(SensorEntityDescription): translation_key="cleaning_area", native_unit_of_measurement=UnitOfArea.SQUARE_METERS, state_class=SensorStateClass.MEASUREMENT, - # working_status field 13 is cm²; divide by 10000 for m². - # NEEDS LIVE VALIDATION: only populated during active cleaning. value_fn=lambda state: round(state.cleaning_area / 10000, 2) - if state.cleaning_area > 0 + if state.cleaning_area > 0 and _has_active_cleaning_metrics(state) else None, ), NarwalSensorEntityDescription( @@ -55,10 +71,49 @@ class NarwalSensorEntityDescription(SensorEntityDescription): device_class=SensorDeviceClass.DURATION, native_unit_of_measurement=UnitOfTime.SECONDS, state_class=SensorStateClass.MEASUREMENT, - # working_status field 3 is session elapsed seconds. - # NEEDS LIVE VALIDATION: only populated during active cleaning. value_fn=lambda state: state.cleaning_time - if state.cleaning_time > 0 + if state.cleaning_time > 0 and _has_active_cleaning_metrics(state) + else None, + ), + NarwalSensorEntityDescription( + key="task_progress", + translation_key="task_progress", + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda state: state.task_progress_percent + if state.task_progress_percent is not None and _has_active_cleaning_metrics(state) + else None, + ), + NarwalSensorEntityDescription( + key="current_room", + translation_key="current_room", + value_fn=lambda state: state.current_room_name + if state.current_room_name and _has_active_cleaning_metrics(state) + else None, + ), + NarwalSensorEntityDescription( + key="station_task", + translation_key="station_task", + device_class=SensorDeviceClass.ENUM, + options=[ + "emptying_dustbin", + "washing_mop", + "drying_mop", + "drying_or_disinfecting", + "station_active", + ], + value_fn=_station_task, + ), + NarwalSensorEntityDescription( + key="dry_mop_remaining_time", + translation_key="dry_mop_remaining_time", + device_class=SensorDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.SECONDS, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda state: state.dry_mop_remaining_time + if state.is_station_active + and state.dry_mop_remaining_time is not None + and state.dry_mop_remaining_time > 0 else None, ), NarwalSensorEntityDescription( @@ -81,6 +136,7 @@ async def async_setup_entry( NarwalSensor(coordinator, description) for description in SENSOR_DESCRIPTIONS ] entities.append(NarwalChargingStateSensor(coordinator)) + entities.append(NarwalTaskStatusSensor(coordinator)) async_add_entities(entities) @@ -108,7 +164,6 @@ def native_value(self) -> float | str | None: return None return self.entity_description.value_fn(state) - class NarwalChargingStateSensor(NarwalEntity, SensorEntity): """Sensor showing charging state: Charging, Fully Charged, or unavailable.""" @@ -147,3 +202,73 @@ def icon(self) -> str: if self.native_value == "not_charging": return "mdi:battery-off-outline" return "mdi:battery-unknown" + + +class NarwalTaskStatusSensor(NarwalEntity, SensorEntity): + """Sensor showing the active cleaning or dock task state.""" + + _attr_device_class = SensorDeviceClass.ENUM + _attr_translation_key = "task_status" + _attr_options = [ + "cleaning", + "returning", + "paused", + "station_active", + "docked", + "idle", + "error", + "unknown", + ] + + def __init__(self, coordinator: NarwalCoordinator) -> None: + """Initialize the task status sensor.""" + super().__init__(coordinator) + device_id = coordinator.config_entry.data["device_id"] + self._attr_unique_id = f"{device_id}_task_status" + + @property + def native_value(self) -> str | None: + """Return the active task status.""" + state = self.coordinator.data + if state is None: + return None + is_cleaning_status = state.working_status in ( + WorkingStatus.CLEANING, + WorkingStatus.CLEANING_V2, + WorkingStatus.CLEANING_ALT, + WorkingStatus.CLEANING_FLOW2, + ) or state.has_recent_active_working_status + if state.working_status == WorkingStatus.ERROR: + return "error" + if state.is_station_active: + return "station_active" + if state.is_docked: + return "docked" + if state.is_paused and is_cleaning_status: + return "paused" + if ( + state.working_status == WorkingStatus.TASK_COMPLETED + or state.is_returning + ): + return "returning" + if state.is_cleaning: + return "cleaning" + if state.working_status == WorkingStatus.STANDBY: + return "idle" + return "unknown" + + @property + def icon(self) -> str: + """Return icon based on task status.""" + value = self.native_value + if value == "station_active": + return "mdi:home-automation" + if value == "cleaning": + return "mdi:robot-vacuum" + if value == "returning": + return "mdi:home-import-outline" + if value == "paused": + return "mdi:pause" + if value == "error": + return "mdi:alert-circle-outline" + return "mdi:information-outline" diff --git a/custom_components/narwal/services.yaml b/custom_components/narwal/services.yaml new file mode 100644 index 0000000..8eb7206 --- /dev/null +++ b/custom_components/narwal/services.yaml @@ -0,0 +1,68 @@ +clean_rooms: + name: Clean rooms + description: Start a room clean using Narwal's local clean/start_clean command. + target: + entity: + integration: narwal + domain: vacuum + fields: + rooms: + name: Rooms + description: Narwal room IDs to clean, in order, or "all" for all current map rooms. + required: true + selector: + object: + mode: + name: Mode + default: vacuum_and_mop + selector: + select: + options: + - vacuum + - mop + - vacuum_then_mop + - vacuum_and_mop + suction: + name: Suction + default: standard + selector: + select: + options: + - ai + - quiet + - standard + - strong + - super_powerful + - ultra_powerful + water: + name: Water + default: normal + selector: + select: + options: + - dry + - normal + - wet + mop_strength: + name: Mop strength + default: normal + selector: + select: + options: + - normal + - high + passes: + name: Passes + default: 1 + selector: + number: + min: 1 + max: 3 + mode: box + route: + name: Route + selector: + select: + options: + - standard + - meticulous diff --git a/custom_components/narwal/strings.json b/custom_components/narwal/strings.json index 08f563c..e57f19b 100644 --- a/custom_components/narwal/strings.json +++ b/custom_components/narwal/strings.json @@ -32,6 +32,25 @@ "cleaning_time": { "name": "Cleaning time" }, + "task_progress": { + "name": "Progress" + }, + "current_room": { + "name": "Current room" + }, + "station_task": { + "name": "Station task", + "state": { + "emptying_dustbin": "Emptying dustbin", + "washing_mop": "Washing mop", + "drying_mop": "Drying mop", + "drying_or_disinfecting": "Drying / disinfecting", + "station_active": "Station active" + } + }, + "dry_mop_remaining_time": { + "name": "Drying time left" + }, "firmware_version": { "name": "Firmware version" }, @@ -42,6 +61,19 @@ "fully_charged": "Fully Charged", "not_charging": "Not Charging" } + }, + "task_status": { + "name": "Status", + "state": { + "cleaning": "Cleaning", + "returning": "Returning", + "paused": "Paused", + "station_active": "Station active", + "docked": "Docked", + "idle": "Idle", + "error": "Error", + "unknown": "Unknown" + } } }, "binary_sensor": { @@ -55,6 +87,57 @@ "charging": { "name": "Charging" } + }, + "switch": { + "show_room_labels": { + "name": "Show room labels" + }, + "show_furniture": { + "name": "Show furniture" + }, + "show_furniture_labels": { + "name": "Show furniture labels" + } + }, + "select": { + "mode": { + "name": "Mode" + }, + "suction": { + "name": "Suction" + }, + "water": { + "name": "Water" + }, + "scrub": { + "name": "Scrub" + }, + "route": { + "name": "Route" + }, + "passes": { + "name": "Passes" + } + }, + "button": { + "empty_dustbin": { + "name": "Empty dustbin" + }, + "wash_mop": { + "name": "Wash mop" + }, + "dry_mop": { + "name": "Dry mop" + }, + "wash_and_dry_mop": { + "name": "Wash and dry mop" + }, + "dry_dust_bin": { + "name": "Dry and disinfect dust bin" + }, + "dry_dock_bag": { + "name": "Dry and disinfect dock bag" + } } } } diff --git a/custom_components/narwal/translations/en.json b/custom_components/narwal/translations/en.json index e4cb63f..b0f7a02 100644 --- a/custom_components/narwal/translations/en.json +++ b/custom_components/narwal/translations/en.json @@ -31,6 +31,25 @@ "cleaning_time": { "name": "Cleaning time" }, + "task_progress": { + "name": "Progress" + }, + "current_room": { + "name": "Current room" + }, + "station_task": { + "name": "Station task", + "state": { + "emptying_dustbin": "Emptying dustbin", + "washing_mop": "Washing mop", + "drying_mop": "Drying mop", + "drying_or_disinfecting": "Drying / disinfecting", + "station_active": "Station active" + } + }, + "dry_mop_remaining_time": { + "name": "Drying time left" + }, "firmware_version": { "name": "Firmware version" }, @@ -41,6 +60,19 @@ "fully_charged": "Fully Charged", "not_charging": "Not Charging" } + }, + "task_status": { + "name": "Status", + "state": { + "cleaning": "Cleaning", + "returning": "Returning", + "paused": "Paused", + "station_active": "Station active", + "docked": "Docked", + "idle": "Idle", + "error": "Error", + "unknown": "Unknown" + } } }, "binary_sensor": { @@ -50,6 +82,60 @@ "on": "Docked", "off": "Undocked" } + }, + "charging": { + "name": "Charging" + } + }, + "switch": { + "show_room_labels": { + "name": "Show room labels" + }, + "show_furniture": { + "name": "Show furniture" + }, + "show_furniture_labels": { + "name": "Show furniture labels" + } + }, + "select": { + "mode": { + "name": "Mode" + }, + "suction": { + "name": "Suction" + }, + "water": { + "name": "Water" + }, + "scrub": { + "name": "Scrub" + }, + "route": { + "name": "Route" + }, + "passes": { + "name": "Passes" + } + }, + "button": { + "empty_dustbin": { + "name": "Empty dustbin" + }, + "wash_mop": { + "name": "Wash mop" + }, + "dry_mop": { + "name": "Dry mop" + }, + "wash_and_dry_mop": { + "name": "Wash and dry mop" + }, + "dry_dust_bin": { + "name": "Dry and disinfect dust bin" + }, + "dry_dock_bag": { + "name": "Dry and disinfect dock bag" } } } diff --git a/custom_components/narwal/translations/fr.json b/custom_components/narwal/translations/fr.json index f7177d7..294fe3f 100644 --- a/custom_components/narwal/translations/fr.json +++ b/custom_components/narwal/translations/fr.json @@ -31,6 +31,25 @@ "cleaning_time": { "name": "Temps de nettoyage" }, + "task_progress": { + "name": "Progress" + }, + "current_room": { + "name": "Current room" + }, + "station_task": { + "name": "Station task", + "state": { + "emptying_dustbin": "Emptying dustbin", + "washing_mop": "Washing mop", + "drying_mop": "Drying mop", + "drying_or_disinfecting": "Drying / disinfecting", + "station_active": "Station active" + } + }, + "dry_mop_remaining_time": { + "name": "Drying time left" + }, "firmware_version": { "name": "Version du firmware" }, @@ -41,6 +60,19 @@ "fully_charged": "Chargé complètement", "not_charging": "Pas en charge" } + }, + "task_status": { + "name": "État", + "state": { + "cleaning": "Nettoyage", + "returning": "Retour", + "paused": "En pause", + "station_active": "Station active", + "docked": "Stationné", + "idle": "Inactif", + "error": "Erreur", + "unknown": "Inconnu" + } } }, "binary_sensor": { @@ -50,6 +82,60 @@ "on": "Stationné", "off": "Non stationné" } + }, + "charging": { + "name": "Charge" + } + }, + "switch": { + "show_room_labels": { + "name": "Afficher les noms des pièces" + }, + "show_furniture": { + "name": "Afficher les meubles" + }, + "show_furniture_labels": { + "name": "Afficher les noms des meubles" + } + }, + "select": { + "mode": { + "name": "Mode" + }, + "suction": { + "name": "Aspiration" + }, + "water": { + "name": "Eau" + }, + "scrub": { + "name": "Récurage" + }, + "route": { + "name": "Trajet" + }, + "passes": { + "name": "Passages" + } + }, + "button": { + "empty_dustbin": { + "name": "Empty dustbin" + }, + "wash_mop": { + "name": "Wash mop" + }, + "dry_mop": { + "name": "Dry mop" + }, + "wash_and_dry_mop": { + "name": "Wash and dry mop" + }, + "dry_dust_bin": { + "name": "Dry and disinfect dust bin" + }, + "dry_dock_bag": { + "name": "Dry and disinfect dock bag" } } } diff --git a/custom_components/narwal/vacuum.py b/custom_components/narwal/vacuum.py index 95073a5..217335f 100644 --- a/custom_components/narwal/vacuum.py +++ b/custom_components/narwal/vacuum.py @@ -19,7 +19,17 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .narwal_client import CommandResult, FanLevel, NarwalCommandError, WorkingStatus +from .narwal_client import ( + CleaningRoute, + CommandResult, + FanLevel, + MopHumidity, + MopStrengthLevel, + NarwalCommandError, + WorkMode, + WorkingStatus, +) +from .narwal_client.const import ACTIVE_CLEANING_STATUSES from . import NarwalConfigEntry from .const import FAN_SPEED_LIST, FAN_SPEED_MAP @@ -28,13 +38,36 @@ _LOGGER = logging.getLogger(__name__) +ROOM_CLEAN_MODES = { + "Vacuum": WorkMode.VACUUM, + "Mop": WorkMode.MOP, + "Vacuum then mop": WorkMode.VACUUM_THEN_MOP, + "Vacuum and mop": WorkMode.VACUUM_AND_MOP, +} +ROOM_CLEAN_SUCTION = {"AI": FanLevel.UNSPECIFIED, **FAN_SPEED_MAP} +ROOM_CLEAN_WATER = { + "Dry": MopHumidity.DRY, + "Normal": MopHumidity.NORMAL, + "Wet": MopHumidity.WET, +} +ROOM_CLEAN_SCRUB = { + "Normal": MopStrengthLevel.NORMAL, + "High": MopStrengthLevel.HIGH, +} +ROOM_CLEAN_ROUTES = { + "Standard": CleaningRoute.STANDARD, + "Meticulous": CleaningRoute.METICULOUS, +} + WORKING_STATUS_TO_ACTIVITY: dict[WorkingStatus, VacuumActivity] = { WorkingStatus.DOCKED: VacuumActivity.DOCKED, WorkingStatus.CHARGED: VacuumActivity.DOCKED, WorkingStatus.DOCKED_V2: VacuumActivity.DOCKED, WorkingStatus.STANDBY: VacuumActivity.IDLE, WorkingStatus.CLEANING: VacuumActivity.CLEANING, + WorkingStatus.CLEANING_V2: VacuumActivity.CLEANING, WorkingStatus.CLEANING_ALT: VacuumActivity.CLEANING, + WorkingStatus.CLEANING_FLOW2: VacuumActivity.CLEANING, WorkingStatus.TASK_COMPLETED: VacuumActivity.RETURNING, WorkingStatus.ERROR: VacuumActivity.ERROR, } @@ -77,8 +110,11 @@ def activity(self) -> VacuumActivity: state = self.coordinator.data if state is None: return VacuumActivity.IDLE - is_cleaning_state = state.working_status in ( - WorkingStatus.CLEANING, WorkingStatus.CLEANING_ALT, + if state.is_docked: + return VacuumActivity.DOCKED + is_cleaning_state = ( + state.working_status in ACTIVE_CLEANING_STATUSES + or state.has_recent_active_working_status ) # is_paused (field 3.2) stays stale after docking — only trust # during cleaning states. Paused takes priority over returning @@ -91,18 +127,12 @@ def activity(self) -> VacuumActivity: return VacuumActivity.RETURNING if state.is_cleaning: return VacuumActivity.CLEANING - if state.is_docked: - return VacuumActivity.DOCKED activity = WORKING_STATUS_TO_ACTIVITY.get(state.working_status) if activity is not None: return activity - # Unknown working_status value — infer from dock signals so we - # don't report IDLE while the robot is clearly active off-dock. - # New firmware versions may introduce values we haven't mapped yet. - if not state.is_docked: + if state.working_status == WorkingStatus.UNKNOWN and not state.is_docked: _LOGGER.warning( - "Unmapped working_status %s (%d) while off-dock — reporting CLEANING", - state.working_status.name, state.working_status.value, + "Unknown working status while off-dock; reporting cleaning" ) return VacuumActivity.CLEANING return VacuumActivity.IDLE @@ -138,8 +168,13 @@ async def async_start(self) -> None: await self._ensure_awake() state = self.coordinator.data # is_paused stays stale after docking — only trust it during cleaning - is_cleaning = state and state.working_status in ( - WorkingStatus.CLEANING, WorkingStatus.CLEANING_ALT, + is_cleaning = bool( + state + and not state.is_docked + and ( + state.working_status in ACTIVE_CLEANING_STATUSES + or state.has_recent_active_working_status + ) ) if is_cleaning and state.is_paused: await self.coordinator.client.resume(timeout=self._ACTION_TIMEOUT) @@ -189,7 +224,14 @@ async def async_set_fan_speed(self, fan_speed: str, **kwargs) -> None: level = FAN_SPEED_MAP.get(fan_speed) if level is not None: await self.coordinator.client.set_fan_speed(level) - self._last_fan_speed = fan_speed + self._last_fan_speed = next( + ( + label + for label in FAN_SPEED_LIST + if FAN_SPEED_MAP[label] == level + ), + fan_speed, + ) self.async_write_ha_state() # --- Segment API (HA 2026.3 room-specific cleaning) --- @@ -232,7 +274,16 @@ async def async_clean_segments( await self._ensure_awake() room_ids = [int(sid) for sid in segment_ids] _LOGGER.info("Starting room-specific clean: rooms=%s", room_ids) - resp = await self.coordinator.client.start_rooms(room_ids) + settings = self.coordinator.select_options + resp = await self.coordinator.client.start_rooms( + room_ids, + work_mode=ROOM_CLEAN_MODES[settings.get("mode", "Vacuum and mop")], + fan=ROOM_CLEAN_SUCTION[settings.get("suction", "AI")], + water=ROOM_CLEAN_WATER[settings.get("water", "Wet")], + mop_strength=ROOM_CLEAN_SCRUB[settings.get("scrub", "High")], + passes=int(settings.get("passes", "2")), + route=ROOM_CLEAN_ROUTES[settings.get("route", "Meticulous")], + ) try: result_name = CommandResult(resp.result_code).name except ValueError: diff --git a/narwal_client/__init__.py b/narwal_client/__init__.py index 8eb1f06..2bb01c1 100644 --- a/narwal_client/__init__.py +++ b/narwal_client/__init__.py @@ -1,7 +1,15 @@ """Narwal robot vacuum client library — local WebSocket API.""" from .client import NarwalClient, NarwalCommandError, NarwalConnectionError -from .const import CommandResult, FanLevel, MopHumidity, WorkingStatus +from .const import ( + CleaningRoute, + CommandResult, + FanLevel, + MopHumidity, + MopStrengthLevel, + WorkMode, + WorkingStatus, +) from .models import CommandResponse, DeviceInfo, MapData, MapDisplayData, NarwalState, RoomInfo from .protocol import build_frame, parse_frame @@ -12,12 +20,15 @@ "NarwalState", "CommandResponse", "CommandResult", + "CleaningRoute", "DeviceInfo", "FanLevel", "MapData", "MapDisplayData", "MopHumidity", + "MopStrengthLevel", "RoomInfo", + "WorkMode", "WorkingStatus", "build_frame", "parse_frame", diff --git a/narwal_client/client.py b/narwal_client/client.py index ca4c1e2..4b731cf 100644 --- a/narwal_client/client.py +++ b/narwal_client/client.py @@ -16,42 +16,60 @@ BROADCAST_STALE_TIMEOUT, COMMAND_RESPONSE_TIMEOUT, DEFAULT_PORT, + DEFAULT_TOPIC_PREFIX, HEARTBEAT_INTERVAL, KEEPALIVE_INTERVAL, KNOWN_PRODUCT_KEYS, + LEGACY_ROOM_CLEAN_PRODUCT_KEYS, RECONNECT_BACKOFF_FACTOR, RECONNECT_INITIAL_DELAY, RECONNECT_MAX_DELAY, TOPIC_CMD_ACTIVE_ROBOT, TOPIC_CMD_APP_HEARTBEAT, TOPIC_CMD_CANCEL, + TOPIC_CMD_CLEAN_TASK, + TOPIC_CMD_DRY_DUST_BAG, TOPIC_CMD_DRY_MOP, + TOPIC_CMD_DRY_STATION_BAG, TOPIC_CMD_DUST_GATHERING, TOPIC_CMD_EASY_CLEAN, TOPIC_CMD_FORCE_END, TOPIC_CMD_GET_ALL_MAPS, TOPIC_CMD_GET_BASE_STATUS, + TOPIC_CMD_GET_CLEAN_PROGRESS_INFO, TOPIC_CMD_GET_CURRENT_TASK, TOPIC_CMD_GET_DEVICE_INFO, + TOPIC_CMD_GET_DRY_MOP_REMAIN_TIME, TOPIC_CMD_GET_FEATURE_LIST, TOPIC_CMD_GET_MAP, + TOPIC_CMD_GET_ROBOT_TASK_STATUS, TOPIC_CMD_NOTIFY_APP_EVENT, TOPIC_CMD_PAUSE, - TOPIC_CMD_PING, + TOPIC_CMD_PLAN_START, TOPIC_CMD_RECALL, TOPIC_CMD_RESUME, TOPIC_CMD_SET_FAN_LEVEL, + TOPIC_CMD_SET_LED, TOPIC_CMD_SET_MOP_HUMIDITY, - TOPIC_CMD_START_CLEAN, TOPIC_CMD_TAKE_PICTURE, - TOPIC_CMD_SET_LED, + TOPIC_CMD_WASH_AND_DRY_MOP, TOPIC_CMD_WASH_MOP, + TOPIC_CMD_WASH_MOP_BY_ROBOT_STATUS, TOPIC_CMD_YELL, - DEFAULT_TOPIC_PREFIX, + TOPIC_PLANNING_DEBUG, + TOPIC_POINT_NAVI_PLAN_TRAJ, + TOPIC_ROBOT_CURRENT_STATUS, + TOPIC_ROBOT_STATUS, + TOPIC_ROBOT_TASK_STATUS, + TOPIC_TIMELINE_STATUS, WAKE_TIMEOUT, + CleaningRoute, CommandResult, FanLevel, MopHumidity, + MopStrengthLevel, + WorkingStatus, + WorkMode, ) from .models import CommandResponse, DeviceInfo, MapData, MapDisplayData, NarwalState from .protocol import ( @@ -64,6 +82,92 @@ _LOGGER = logging.getLogger(__name__) +_ACTIVE_WORKING_STATUS_TTL = 15.0 +_STALE_DOCK_BASE_STATUSES = { + WorkingStatus.UNKNOWN, + WorkingStatus.STANDBY, + WorkingStatus.DOCKED, + WorkingStatus.CHARGED, + WorkingStatus.DOCKED_V2, +} +_AUX_STATUS_TOPICS = { + TOPIC_TIMELINE_STATUS, + TOPIC_POINT_NAVI_PLAN_TRAJ, + TOPIC_PLANNING_DEBUG, + TOPIC_ROBOT_STATUS, + TOPIC_ROBOT_CURRENT_STATUS, + TOPIC_ROBOT_TASK_STATUS, +} + + +def _short_repr(value: Any, limit: int = 1200) -> str: + text = repr(value) + if len(text) <= limit: + return text + return f"{text[:limit]}…" + + +def _normalise_blackboxprotobuf_typedef(typedef: dict[str, Any]) -> dict[str, Any]: + """Add field names expected by some blackboxprotobuf releases.""" + for info in typedef.values(): + info.setdefault("name", "") + message_typedef = info.get("message_typedef") + if isinstance(message_typedef, dict): + _normalise_blackboxprotobuf_typedef(message_typedef) + alt_typedefs = info.get("alt_typedefs") + if isinstance(alt_typedefs, dict): + for alt_typedef in alt_typedefs.values(): + if isinstance(alt_typedef, dict): + _normalise_blackboxprotobuf_typedef(alt_typedef) + return typedef + + +def _base_status_working_status(decoded: dict[str, Any] | object) -> WorkingStatus | None: + """Extract robot_base_status field 3.1.""" + if not isinstance(decoded, dict): + return None + field3 = decoded.get("3") + if isinstance(field3, list): + field3 = field3[0] if field3 else None + if not isinstance(field3, dict) or "1" not in field3: + return None + try: + return WorkingStatus(int(field3["1"])) + except (TypeError, ValueError): + return None + + +def _base_status_confirms_docked( + decoded: dict[str, Any] | object, status: WorkingStatus | None +) -> bool: + """Return true when a terminal status also carries live dock indicators.""" + if not isinstance(decoded, dict) or status not in { + WorkingStatus.STANDBY, + WorkingStatus.DOCKED, + WorkingStatus.CHARGED, + WorkingStatus.DOCKED_V2, + }: + return False + field3 = decoded.get("3") + if isinstance(field3, list): + field3 = field3[0] if field3 else None + field3 = field3 if isinstance(field3, dict) else {} + + def int_field(container: dict[str, Any], field: str) -> int: + try: + return int(container.get(field, 0)) + except (TypeError, ValueError): + return 0 + + return ( + int_field(decoded, "11") >= 2 + or int_field(decoded, "47") in (1, 3) + or int_field(field3, "3") in (1, 6) + or int_field(field3, "10") == 1 + or int_field(field3, "12") > 0 + or int_field(field3, "18") > 0 + ) + class NarwalConnectionError(Exception): """Raised when connection to the vacuum fails.""" @@ -110,7 +214,11 @@ def __init__( self._listener_active = False # True when start_listening() is running recv loop self._robot_awake = False # True once we receive a broadcast self._last_broadcast_time: float = 0.0 # monotonic time of last broadcast + self._last_status_time: float = 0.0 # monotonic time of last status/base broadcast self._last_display_map_time: float = 0.0 # monotonic time of last display_map + self._last_active_working_status_time: float = 0.0 + self._last_aux_log_time: dict[str, float] = {} + self._last_base_status_log: tuple[Any, Any, Any] | None = None # Queue for field5 command responses self._response_queue: asyncio.Queue[NarwalMessage] = asyncio.Queue() # Lock to prevent concurrent send_command calls from racing on the queue @@ -144,6 +252,89 @@ def last_display_map_age(self) -> float: return 999.0 return time.monotonic() - self._last_display_map_time + @property + def last_status_age(self) -> float: + """Seconds since last status/base broadcast (999.0 if none received).""" + if self._last_status_time <= 0: + return 999.0 + return time.monotonic() - self._last_status_time + + def _active_working_status_is_recent(self, now: float | None = None) -> bool: + """Return true while fresh working_status telemetry is contradicting base_status.""" + if self._last_active_working_status_time <= 0: + return False + now = time.monotonic() if now is None else now + return now - self._last_active_working_status_time <= _ACTIVE_WORKING_STATUS_TTL + + def _state_needs_keepalive(self) -> bool: + """Return true when the app-style keepalive should keep the robot awake.""" + state = self.state + if state.working_status == WorkingStatus.UNKNOWN: + return True + if state.working_status == WorkingStatus.ERROR: + return True + if state.is_cleaning or state.is_returning: + return True + if state.is_station_active: + return True + if ( + state.is_paused + and state._working_status_is_cleaning_like() + and not state.is_docked + ): + return True + if state.working_status == WorkingStatus.CLEANING_ALT and state.is_docked: + return True + return not state.is_docked + + def _update_from_working_status_broadcast( + self, decoded: dict[str, Any], now: float | None = None + ) -> None: + """Update state from a working_status broadcast.""" + self.state.update_from_working_status(decoded) + if self.state.has_recent_active_working_status: + self._last_active_working_status_time = ( + self.state.last_active_working_status_time + ) + + def _update_from_base_status_broadcast( + self, decoded: dict[str, Any], now: float | None = None + ) -> None: + """Update state from robot_base_status, ignoring stale dock overlays mid-task.""" + now = time.monotonic() if now is None else now + base_status = _base_status_working_status(decoded) + signature = (decoded.get("3"), decoded.get("11"), decoded.get("47")) + if signature != self._last_base_status_log: + self._last_base_status_log = signature + _LOGGER.debug( + "%s robot_base_status field3=%r field11=%r field47=%r", + self.host, + decoded.get("3"), + decoded.get("11"), + decoded.get("47"), + ) + if ( + base_status in _STALE_DOCK_BASE_STATUSES + and self._active_working_status_is_recent(now) + and not _base_status_confirms_docked(decoded, base_status) + ): + _LOGGER.debug( + "Ignoring stale %s base_status while active working_status is fresh", + base_status.name, + ) + self.state.update_battery_from_base_status(decoded) + return + self.state.update_from_base_status(decoded) + + def _update_from_aux_status_broadcast( + self, short_topic: str, decoded: dict[str, Any] + ) -> None: + self.state.update_from_aux_status(short_topic, decoded) + now = time.monotonic() + if now - self._last_aux_log_time.get(short_topic, 0.0) > 30.0: + self._last_aux_log_time[short_topic] = now + _LOGGER.debug("%s decoded status: %s", short_topic, _short_repr(decoded)) + async def connect(self) -> None: """Establish WebSocket connection to the vacuum. @@ -250,6 +441,9 @@ async def discover_device_id(self, timeout: float = 15.0) -> str: else: raw_id = str(raw_id).strip() if raw_id: + parts = msg.topic.split("/") if msg.topic else [] + if len(parts) >= 2 and parts[1]: + self.topic_prefix = f"/{parts[1]}" self.device_id = raw_id _LOGGER.info("Discovered device_id from response: %s", self.device_id) return self.device_id @@ -328,12 +522,11 @@ async def start_listening(self) -> None: try: if not self.connected: await self.connect() - # Immediate wake burst on (re)connect — the fresh TCP - # connection may trigger the robot's deep-sleep wake - # interrupt, but only if we send commands before it - # expires. Don't wait for the keepalive loop's first - # tick (15s delay would be too late). - await self._send_wake_burst() + if self._state_needs_keepalive(): + await self._send_wake_burst() + else: + await self.subscribe_to_topics() + _LOGGER.debug("Robot is docked/idle; not sending reconnect wake") retry_delay = RECONNECT_INITIAL_DELAY # reset on success self._heartbeat_task = asyncio.create_task(self._heartbeat_loop()) @@ -408,10 +601,13 @@ async def _handle_message(self, data: bytes) -> None: _LOGGER.debug("Failed to decode protobuf for topic %s", short_topic) return + now = time.monotonic() if short_topic == "status/working_status": - self.state.update_from_working_status(decoded) + self._last_status_time = now + self._update_from_working_status_broadcast(decoded, now) elif short_topic == "status/robot_base_status": - self.state.update_from_base_status(decoded) + self._last_status_time = now + self._update_from_base_status_broadcast(decoded, now) elif short_topic == "upgrade/upgrade_status": self.state.update_from_upgrade_status(decoded) elif short_topic == "status/download_status": @@ -425,6 +621,8 @@ async def _handle_message(self, data: bytes) -> None: self.state.map_display_data.robot_y, self.state.map_display_data.timestamp, ) + elif short_topic in _AUX_STATUS_TOPICS: + self._update_from_aux_status_broadcast(short_topic, decoded) if self.on_state_update: self.on_state_update(self.state) @@ -484,9 +682,12 @@ def _encode_string_field(cls, field_num: int, text: str) -> bytes: "upgrade/upgrade_status", "status/download_status", "map/display_map", - "status/time_line_status", - "status/point_navi_plan_traj", - "developer/planning_debug_info", + TOPIC_TIMELINE_STATUS, + TOPIC_POINT_NAVI_PLAN_TRAJ, + TOPIC_PLANNING_DEBUG, + TOPIC_ROBOT_STATUS, + TOPIC_ROBOT_CURRENT_STATUS, + TOPIC_ROBOT_TASK_STATUS, ] def _build_topic_subscription(self, duration: int = 600) -> bytes: @@ -691,9 +892,12 @@ async def _keepalive_loop(self) -> None: except Exception: _LOGGER.debug("Topic re-subscribe failed") + if not self._state_needs_keepalive(): + _LOGGER.debug("Robot is docked/idle; skipping app keepalive") + continue + # Send lightweight heartbeat to keep robot awake. - # The Narwal app sends this continuously regardless of - # robot state — it's safe during cleaning. + # This is only needed while an active task is in progress. try: payload = self._encode_varint_field(1, 1) frame = build_frame( @@ -705,6 +909,11 @@ async def _keepalive_loop(self) -> None: _LOGGER.debug("Keepalive send failed") break else: + if not self._state_needs_keepalive(): + consecutive_wake_failures = 0 + _LOGGER.debug("Robot is docked/idle; not waking") + continue + # Robot appears asleep — send full wake burst # (wake burst includes topic subscription) consecutive_wake_failures += 1 @@ -852,16 +1061,19 @@ async def _wait_for_field5_response( except Exception: continue + now = time.monotonic() if short_topic == "status/working_status": - self.state.update_from_working_status(decoded) + self._update_from_working_status_broadcast(decoded, now) elif short_topic == "status/robot_base_status": - self.state.update_from_base_status(decoded) + self._update_from_base_status_broadcast(decoded, now) elif short_topic == "upgrade/upgrade_status": self.state.update_from_upgrade_status(decoded) elif short_topic == "status/download_status": self.state.update_from_download_status(decoded) elif short_topic == "map/display_map": self.state.map_display_data = MapDisplayData.from_broadcast(decoded) + elif short_topic in _AUX_STATUS_TOPICS: + self._update_from_aux_status_broadcast(short_topic, decoded) raise NarwalCommandError( f"No field5 response within {timeout}s" @@ -915,7 +1127,7 @@ async def start(self, **kwargs) -> CommandResponse: so we know which rooms to include. """ resp = await self.send_command( - TOPIC_CMD_START_CLEAN, + TOPIC_CMD_PLAN_START, payload=self._DEFAULT_CLEAN_PAYLOAD, timeout=10.0, ) @@ -943,9 +1155,10 @@ async def start(self, **kwargs) -> CommandResponse: len(room_ids), ) payload = self._build_clean_payload_v2(room_ids) - return await self.send_command( - TOPIC_CMD_START_CLEAN, payload=payload, timeout=10.0, + resp = await self.send_command( + TOPIC_CMD_PLAN_START, payload=payload, timeout=10.0, ) + return resp def _build_clean_payload_v2( self, @@ -958,9 +1171,7 @@ def _build_clean_payload_v2( """Build clean task payload using the v2 schema (firmware v01.07.22+). Observed in issue #36 from a Flow on firmware v01.07.22.00. - Each room entry uses a nested room_id (different from the flat - schema in _build_room_clean_payload used for room-targeted cleans - on older firmware): + Each room entry uses a nested room_id: { 1: {1: 1, 2: }, # nested room ref @@ -1044,43 +1255,110 @@ def _build_clean_payload_v2( }, } } - return blackboxprotobuf.encode_message(msg, typedef) - - def _build_room_clean_payload(self, room_ids: list[int]) -> bytes: - """Build CleanTask protobuf with per-room clean params in field 1.2. + return blackboxprotobuf.encode_message( + msg, _normalise_blackboxprotobuf_typedef(typedef) + ) - Each room entry in field 1.2 requires full MapCleanParamInfo fields - (from APK proto analysis): - field 1: roomId (uint32) - field 2: cleanMode (int32) — 0=sweep, 1=mop, 2=sweep+mop - field 3: cleanTimes (int32) — number of passes - field 6: sweepMode (int32) — suction level (3=max) - field 7: mopMode (int32) — mop humidity (2=wet) + # WorkMode -> (CleanParam.mode tag 1, pass-count tags to set from `passes`). The robot's + # execution mode is CleanTask.taskType (= the WorkMode value); CleanParam.mode and the + # pass tag are derived here so the two can't drift. Live-validated on a Flow 2; see + # project_history.md "CleanParam — fully decoded". + _WORK_MODE_PARAM: dict[WorkMode, tuple[int, tuple[str, ...]]] = { + WorkMode.VACUUM: (2, ("5",)), # sweepTime + WorkMode.MOP: (3, ("6",)), # mopTime + WorkMode.VACUUM_THEN_MOP: (5, ("5", "6")), # sweep + mop pass counts + WorkMode.VACUUM_AND_MOP: (4, ("7",)), # sweepMopSyncTime + } + + def _build_start_clean_payload( + self, + room_ids: list[int], + map_id: int, + *, + work_mode: WorkMode = WorkMode.VACUUM_AND_MOP, + fan: FanLevel = FanLevel.NORMAL, + water: MopHumidity = MopHumidity.NORMAL, + mop_strength: MopStrengthLevel = MopStrengthLevel.NORMAL, + passes: int = 1, + route: CleaningRoute | None = None, + ) -> bytes: + """Build a clean/start_clean request for the given rooms. - A bare roomId without clean params is silently ignored by the robot. + StartClean_Request{1: CleanTask{1: map_id, 2: [CleanItem...], 3: {} (TaskOption), + 5: taskType}}; CleanItem{1: ZoneOption{1: 1 (room zone), 2: room_id}, 2: CleanParam, + 3: order}. taskType (the execution-mode carrier) and CleanParam.mode/pass-tag are + derived from work_mode. overlapLevel is CleanParam tag 8 when supplied. Args: - room_ids: List of room IDs from RoomInfo.room_id. - - Returns: - Encoded protobuf bytes for clean/plan/start. + room_ids: Robot room IDs (RoomInfo.room_id). + map_id: Active map id (MapData.map_id, get_map field 2.1). + work_mode: Vacuum / mop / vacuum-then-mop / vacuum-and-mop. + fan: Suction level (CleanParam tag 2). + water: Mop water volume (tag 4). + mop_strength: Mop scrub intensity (tag 3). + passes: Clean count, routed to the pass tag(s) for the mode. + route: Optional route overlap level (tag 8). """ + import blackboxprotobuf + + param_mode, pass_tags = self._WORK_MODE_PARAM[work_mode] + param: dict[str, int] = { + "1": int(param_mode), + "2": int(fan), + "3": int(mop_strength), + "4": int(water), + } + if route is not None: + param["8"] = int(route) + for tag in pass_tags: + param[tag] = int(passes) + + items = [ + {"1": {"1": 1, "2": rid}, "2": dict(param), "3": idx + 1} + for idx, rid in enumerate(room_ids) + ] + task = { + "1": map_id, + "2": items if len(items) > 1 else items[0], + "3": {}, + "5": int(work_mode), # CleanTask.taskType + } + item_typedef = { + "type": "message", + "seen_repeated": True, + "message_typedef": { + "1": {"type": "message", "message_typedef": { + "1": {"type": "int"}, "2": {"type": "int"}, + }}, + # Derive the CleanParam typedef from the emitted dict — bbpb silently + # drops any tag absent from the typedef. + "2": {"type": "message", "message_typedef": { + k: {"type": "int"} for k in param + }}, + "3": {"type": "int"}, + }, + } + typedef = {"1": {"type": "message", "message_typedef": { + "1": {"type": "int"}, + "2": item_typedef, + "3": {"type": "message", "message_typedef": {}}, + "5": {"type": "int"}, + }}} + return blackboxprotobuf.encode_message( + {"1": task}, _normalise_blackboxprotobuf_typedef(typedef) + ) + + def _build_room_clean_payload(self, room_ids: list[int]) -> bytes: + """Build the legacy flat room-clean payload for older firmware.""" if not room_ids: return self._DEFAULT_CLEAN_PAYLOAD import blackboxprotobuf - # Build per-room entries with default clean settings - room_entries = [] - for rid in room_ids: - room_entries.append({ - "1": rid, # roomId - "2": 2, # cleanMode = sweep+mop - "3": 1, # cleanTimes = 1 pass - "6": 3, # sweepMode = max suction - "7": 2, # mopMode = wet - }) - + room_entries = [ + {"1": room_id, "2": 2, "3": 1, "6": 3, "7": 2} + for room_id in room_ids + ] room_typedef = { "type": "message", "seen_repeated": True, @@ -1090,19 +1368,13 @@ def _build_room_clean_payload(self, room_ids: list[int]) -> bytes: "3": {"type": "int"}, "6": {"type": "int"}, "7": {"type": "int"}, - } + }, } - - # Single room: field 1.2 is a message; multiple: repeated message field_2_value = room_entries[0] if len(room_entries) == 1 else room_entries - - msg = { + message = { "1": { "2": field_2_value, - "5": { - "1": {"1": 3, "2": 2, "3": 1}, - "5": {} - } + "5": {"1": {"1": 3, "2": 2, "3": 1}, "5": {}}, } } typedef = { @@ -1118,53 +1390,145 @@ def _build_room_clean_payload(self, room_ids: list[int]) -> bytes: "message_typedef": { "1": {"type": "int"}, "2": {"type": "int"}, - "3": {"type": "int"} - } + "3": {"type": "int"}, + }, }, - "5": {"type": "message", "message_typedef": {}} - } - } - } + "5": {"type": "message", "message_typedef": {}}, + }, + }, + }, } } - return blackboxprotobuf.encode_message(msg, typedef) + return blackboxprotobuf.encode_message(message, typedef) async def start_rooms( - self, room_ids: list[int], + self, + room_ids: list[int], + *, + work_mode: WorkMode = WorkMode.VACUUM_AND_MOP, + fan: FanLevel = FanLevel.DEEP, + water: MopHumidity = MopHumidity.WET, + mop_strength: MopStrengthLevel = MopStrengthLevel.NORMAL, + passes: int = 1, + route: CleaningRoute | None = None, ) -> CommandResponse: - """Start room-specific cleaning. + """Start cleaning the given rooms via clean/start_clean. - Sends clean/plan/start with the user-selected rooms. Tries the v2 - nested-room schema first (required by firmware v01.07.22+, and fixes - the ack-but-ignore behavior in #37 where legacy schema returns - SUCCESS but the robot runs the app shortcut instead of HA-selected - rooms). Falls back to legacy flat-room schema on NOT_APPLICABLE - for older firmware. + Room cleaning must use clean/start_clean (StartClean → CleanTask), not + clean/plan/start: on Flow firmware the latter is StartWithPlan{planId, + mapId} and ignores any room payload — the root cause of #25/#37, where + the robot undocks and wanders instead of cleaning the selected rooms. + The CleanTask carries the active map id (get_map field 2.1). - Args: - room_ids: List of room IDs from RoomInfo.room_id. + clean/start_clean only works while docked; from STANDBY the robot + returns NOT_READY (4). Callers should start from the dock; this retries + briefly to cover the dock settling transition. - Returns: - CommandResponse with result code from whichever schema landed. + Args: + room_ids: Robot room IDs (RoomInfo.room_id), mapped from HA areas. + work_mode, fan, water, mop_strength, passes, route: CleanParam settings — + see _build_start_clean_payload. """ if not room_ids: return await self.start() - payload_v2 = self._build_clean_payload_v2(room_ids) - resp = await self.send_command( - TOPIC_CMD_START_CLEAN, payload=payload_v2, timeout=10.0, + product_key = ( + self.state.device_info.product_key + if self.state.device_info is not None + and self.state.device_info.product_key + else self.topic_prefix.removeprefix("/") ) + supports_legacy_room_clean = product_key in LEGACY_ROOM_CLEAN_PRODUCT_KEYS + map_data = self.state.map_data + if not map_data or not map_data.map_id: + try: + map_data = await self.get_map() + except NarwalCommandError: + if not supports_legacy_room_clean: + raise + _LOGGER.debug( + "start_rooms: map fetch failed; trying legacy room-clean commands" + ) + map_data = None + map_id = map_data.map_id if map_data else 0 + if not map_id and not supports_legacy_room_clean: + return CommandResponse(result_code=CommandResult.NOT_APPLICABLE) + resp = CommandResponse(result_code=CommandResult.NOT_APPLICABLE) + if map_id: + payload = self._build_start_clean_payload( + room_ids, + map_id, + work_mode=work_mode, + fan=fan, + water=water, + mop_strength=mop_strength, + passes=passes, + route=route, + ) + resp = await self.send_command( + TOPIC_CMD_CLEAN_TASK, payload=payload, timeout=10.0, + ) + for _ in range(3): + if resp.result_code != CommandResult.NOT_READY: + break + if not self.state.is_docked: + _LOGGER.warning( + "start_rooms: robot not docked (status=%s); " + "clean/start_clean requires the robot on the dock", + self.state.working_status.name, + ) + break + _LOGGER.info( + "start_rooms: robot docking/settling, retrying " + "clean/start_clean" + ) + await asyncio.sleep(3.0) + resp = await self.send_command( + TOPIC_CMD_CLEAN_TASK, payload=payload, timeout=10.0, + ) + else: + _LOGGER.warning( + "start_rooms: no active map id available; trying legacy room clean" + ) if resp.result_code != CommandResult.NOT_APPLICABLE: return resp + if not supports_legacy_room_clean: + return resp - # v2 rejected — try legacy flat-room schema (older firmware) _LOGGER.info( - "start_rooms(): v2 payload rejected, retrying with legacy schema (%d rooms)", - len(room_ids), + "start_rooms: clean/start_clean rejected, trying clean/plan/start " + "compatibility payloads" ) - payload_legacy = self._build_room_clean_payload(room_ids) + legacy_suction = { + FanLevel.UNSPECIFIED: 3, + FanLevel.MUTE: 0, + FanLevel.NORMAL: 1, + FanLevel.STRONG: 2, + FanLevel.DEEP: 3, + FanLevel.SUPER: 3, + }[FanLevel(fan)] + legacy_water = { + MopHumidity.UNSPECIFIED: 2, + MopHumidity.DRY: 0, + MopHumidity.NORMAL: 1, + MopHumidity.WET: 2, + }[MopHumidity(water)] + legacy_v2 = self._build_clean_payload_v2( + room_ids, + suction=legacy_suction, + mop_humidity=legacy_water, + passes=passes, + ) + resp = await self.send_command( + TOPIC_CMD_PLAN_START, payload=legacy_v2, timeout=10.0, + ) + if resp.result_code != CommandResult.NOT_APPLICABLE: + return resp + return await self.send_command( - TOPIC_CMD_START_CLEAN, payload=payload_legacy, timeout=10.0, + TOPIC_CMD_PLAN_START, + payload=self._build_room_clean_payload(room_ids), + timeout=10.0, ) async def start_easy_clean(self) -> CommandResponse: @@ -1196,27 +1560,58 @@ async def return_to_base(self, timeout: float = COMMAND_RESPONSE_TIMEOUT) -> Com return await self.send_command(TOPIC_CMD_RECALL, timeout=timeout) async def set_fan_speed(self, level: FanLevel | int) -> CommandResponse: - """Set suction fan speed. + """Set suction fan speed live (clean/set_fan_level, field 1 = SweepFanLevel). - Args: - level: FanLevel enum or int (0=quiet, 1=normal, 2=strong, 3=max). + The live command's enum is SweepFanLevel, which has no SUPER. Use its + highest available level, DEEP, when callers request SUPER. Bare integer + values retain the original 0=quiet through 3=max API mapping. """ - payload = b"\x08" + bytes([int(level) & 0x7F]) + if isinstance(level, FanLevel): + live = min(int(level), int(FanLevel.DEEP)) + else: + legacy_levels = { + 0: FanLevel.MUTE, + 1: FanLevel.NORMAL, + 2: FanLevel.STRONG, + 3: FanLevel.DEEP, + } + try: + live = int(legacy_levels[level]) + except KeyError as err: + raise ValueError(f"Invalid legacy fan level: {level}") from err + payload = b"\x08" + bytes([live & 0x7F]) return await self.send_command(TOPIC_CMD_SET_FAN_LEVEL, payload) async def set_mop_humidity(self, level: MopHumidity | int) -> CommandResponse: - """Set mop wetness level. + """Set mop water volume live (clean/set_mop_humidity, field 1 = MopHumidity). Args: - level: MopHumidity enum or int (0=dry, 1=normal, 2=wet). + level: MopHumidity enum, or the legacy int mapping + (0=dry, 1=normal, 2=wet). """ - payload = b"\x08" + bytes([int(level) & 0x7F]) + if isinstance(level, MopHumidity): + live = int(level) + else: + legacy_levels = { + 0: MopHumidity.DRY, + 1: MopHumidity.NORMAL, + 2: MopHumidity.WET, + } + try: + live = int(legacy_levels[level]) + except KeyError as err: + raise ValueError(f"Invalid legacy mop humidity: {level}") from err + payload = b"\x08" + bytes([live & 0x7F]) return await self.send_command(TOPIC_CMD_SET_MOP_HUMIDITY, payload) async def wash_mop(self) -> CommandResponse: """Wash the mop pads at the station.""" return await self.send_command(TOPIC_CMD_WASH_MOP) + async def wash_mop_by_robot_status(self) -> CommandResponse: + """Wash mop pads using the app's status-gated station command.""" + return await self.send_command(TOPIC_CMD_WASH_MOP_BY_ROBOT_STATUS) + async def dry_mop(self) -> CommandResponse: """Dry the mop pads at the station.""" return await self.send_command(TOPIC_CMD_DRY_MOP) @@ -1225,6 +1620,18 @@ async def empty_dustbin(self) -> CommandResponse: """Empty the dustbin at the station.""" return await self.send_command(TOPIC_CMD_DUST_GATHERING) + async def wash_and_dry_mop(self) -> CommandResponse: + """Wash and dry the mop pads at the station.""" + return await self.send_command(TOPIC_CMD_WASH_AND_DRY_MOP) + + async def dry_dust_bag(self) -> CommandResponse: + """Dry/disinfect the robot dust bin/canister.""" + return await self.send_command(TOPIC_CMD_DRY_DUST_BAG) + + async def dry_station_bag(self) -> CommandResponse: + """Dry/disinfect the dock dust bag.""" + return await self.send_command(TOPIC_CMD_DRY_STATION_BAG) + # --- Query commands --- async def get_device_info(self) -> DeviceInfo: @@ -1270,13 +1677,28 @@ async def get_status(self, full_update: bool = True) -> CommandResponse: """ resp = await self.send_command(TOPIC_CMD_GET_BASE_STATUS) status_data = resp.data.get("2", {}) + if status_data and not isinstance(status_data, dict): + _LOGGER.debug( + "%s get_status response field 2 is %s, not a base-status object: %r", + self.host, + type(status_data).__name__, + status_data, + ) + return resp if status_data: _LOGGER.debug( - "get_status response (full=%s): field3=%r, field2=%r", + "%s get_status response (full=%s): field3=%r, field2=%r", + self.host, full_update, status_data.get("3") if isinstance(status_data, dict) else None, status_data.get("2") if isinstance(status_data, dict) else None, ) + _LOGGER.debug( + "%s get_status decoded base_status (full=%s): %r", + self.host, + full_update, + status_data, + ) if full_update: self.state.update_from_base_status(status_data) else: @@ -1289,6 +1711,27 @@ async def get_current_task(self) -> CommandResponse: """Query the current clean task.""" return await self.send_command(TOPIC_CMD_GET_CURRENT_TASK) + async def get_clean_progress_info(self) -> CommandResponse: + """Query active clean progress information.""" + resp = await self.send_command(TOPIC_CMD_GET_CLEAN_PROGRESS_INFO) + self.state.update_from_aux_status(TOPIC_CMD_GET_CLEAN_PROGRESS_INFO, resp.data) + _LOGGER.debug("%s clean_progress_info response: %r", self.host, resp.data) + return resp + + async def get_dry_mop_remain_time(self) -> CommandResponse: + """Query remaining mop drying time.""" + resp = await self.send_command(TOPIC_CMD_GET_DRY_MOP_REMAIN_TIME) + self.state.update_from_aux_status(TOPIC_CMD_GET_DRY_MOP_REMAIN_TIME, resp.data) + _LOGGER.debug("%s dry_mop_remain_time response: %r", self.host, resp.data) + return resp + + async def get_robot_task_status(self) -> CommandResponse: + """Query the robot task status model.""" + resp = await self.send_command(TOPIC_CMD_GET_ROBOT_TASK_STATUS) + self.state.update_from_aux_status(TOPIC_CMD_GET_ROBOT_TASK_STATUS, resp.data) + _LOGGER.debug("%s robot_task_status response: %r", self.host, resp.data) + return resp + async def get_map(self) -> MapData: """Download the full map data.""" resp = await self.send_command(TOPIC_CMD_GET_MAP, timeout=15.0) diff --git a/narwal_client/const.py b/narwal_client/const.py index 0c877c7..c80ae16 100644 --- a/narwal_client/const.py +++ b/narwal_client/const.py @@ -24,6 +24,7 @@ # Confirmed working (local WebSocket) "QoEsI5qYXO", # AX12 — Narwal Flow (primary, confirmed) "QxMSPG6VSO", # Narwal Flow 2 (confirmed working via local WebSocket) + "iSuVlI1If2", # Narwal Flow 2 alternate key (confirmed working locally) "DrzDKQ0MU8", # CX4 — Freo Z10 Ultra (confirmed by @irekkl-maker) # Confirmed cloud-only (port 9002 open but no local broadcasts) "BYWBPqSxeC", # CX7 — Freo Z Ultra (cloud-only, confirmed by @gabrielozcomidi) @@ -51,6 +52,8 @@ "cUlfJN5JYP", # Unknown model (APK, contributed by @northwestsupra) ] +LEGACY_ROOM_CLEAN_PRODUCT_KEYS = {"QoEsI5qYXO"} + # --- Status topics (robot → client, field 4 / 0x22 frames) --- TOPIC_WORKING_STATUS = "status/working_status" TOPIC_ROBOT_BASE_STATUS = "status/robot_base_status" @@ -58,7 +61,11 @@ TOPIC_DOWNLOAD_STATUS = "status/download_status" TOPIC_DISPLAY_MAP = "map/display_map" TOPIC_TIMELINE_STATUS = "status/time_line_status" +TOPIC_POINT_NAVI_PLAN_TRAJ = "status/point_navi_plan_traj" TOPIC_PLANNING_DEBUG = "developer/planning_debug_info" +TOPIC_ROBOT_STATUS = "status/robot" +TOPIC_ROBOT_CURRENT_STATUS = "status/robot/current" +TOPIC_ROBOT_TASK_STATUS = "robot/task/status" # --- Command topics (client → robot, confirmed working) --- # Common @@ -78,16 +85,23 @@ # Supply/dock TOPIC_CMD_RECALL = "supply/recall" TOPIC_CMD_WASH_MOP = "supply/wash_mop" +TOPIC_CMD_WASH_MOP_BY_ROBOT_STATUS = "supply/wash_mop_by_robot_status" TOPIC_CMD_DRY_MOP = "supply/dry_mop" TOPIC_CMD_DUST_GATHERING = "supply/dust_gathering" +TOPIC_CMD_WASH_AND_DRY_MOP = "supply/wash_and_dry_mop" +TOPIC_CMD_DRY_DUST_BAG = "supply/dry_dust_bag" +TOPIC_CMD_DRY_STATION_BAG = "supply/dry_station_bag" # Cleaning (Pita protocol — correct for AX12) -TOPIC_CMD_START_CLEAN = "clean/plan/start" # whole-house clean (empty payload) -TOPIC_CMD_START_CLEAN_LEGACY = "clean/start_clean" # does NOT work from STANDBY +TOPIC_CMD_PLAN_START = "clean/plan/start" # whole-house clean (empty payload) +TOPIC_CMD_CLEAN_TASK = "clean/start_clean" # room/zone CleanTask; only works docked TOPIC_CMD_EASY_CLEAN = "clean/easy_clean/start" TOPIC_CMD_SET_FAN_LEVEL = "clean/set_fan_level" TOPIC_CMD_SET_MOP_HUMIDITY = "clean/set_mop_humidity" TOPIC_CMD_GET_CURRENT_TASK = "clean/current_clean_task/get" +TOPIC_CMD_GET_CLEAN_PROGRESS_INFO = "info/get_clean_progress_info" +TOPIC_CMD_GET_DRY_MOP_REMAIN_TIME = "supply/get_dry_mop_remain_time" +TOPIC_CMD_GET_ROBOT_TASK_STATUS = "robot/task/status/get" # Map TOPIC_CMD_GET_MAP = "map/get_map" @@ -141,6 +155,7 @@ class CommandResult(IntEnum): SUCCESS = 1 NOT_APPLICABLE = 2 # e.g., set_fan_level when not cleaning CONFLICT = 3 # e.g., recall when already recalling + NOT_READY = 4 # clean/start_clean while not docked (robot in STANDBY) class WorkingStatus(IntEnum): @@ -149,8 +164,10 @@ class WorkingStatus(IntEnum): Values confirmed via live WebSocket monitoring: 1 = STANDBY (idle, transition state between cleaning and docked) 2 = DOCKED_V2 (on dock; confirmed v01.07.23.00 while charging at 10-36%) + 3 = CLEANING_V2 (active room clean; confirmed on Flow 2 v01.07.23) 4 = CLEANING (plan-based start; also stays 4 while returning to dock on older FW) 5 = CLEANING_ALT (observed live: robot was physically stuck when reporting 5) + 7 = CLEANING_FLOW2 (active cleaning on Flow 2 v01.07.10.33) 10 = DOCKED (on dock, charging) 14 = CHARGED (on dock, fully charged) 19 = TASK_COMPLETED (transitional: scheduled task finished, returning to base) @@ -168,8 +185,10 @@ class WorkingStatus(IntEnum): UNKNOWN = 0 STANDBY = 1 # idle / transition state DOCKED_V2 = 2 # on dock (v01.07.23.00+ — replaces DOCKED=10/CHARGED=14 from older FW) + CLEANING_V2 = 3 # active cleaning on Flow 2 firmware v01.07.23+ CLEANING = 4 # active cleaning (stays 4 even while returning to dock) CLEANING_ALT = 5 # cleaning — observed when robot was physically stuck; may indicate error/stuck state + CLEANING_FLOW2 = 7 # active cleaning on Flow 2 v01.07.10.33 DOCKED = 10 # on dock (does NOT reliably indicate charging vs charged) CHARGED = 14 # on dock (reported before 100% — use battery_level for charge state) TASK_COMPLETED = 19 # transitional: task finished, robot returning to base (#41) @@ -178,21 +197,60 @@ class WorkingStatus(IntEnum): ERROR = 99 +ACTIVE_CLEANING_STATUSES = frozenset( + { + WorkingStatus.CLEANING_V2, + WorkingStatus.CLEANING, + WorkingStatus.CLEANING_ALT, + WorkingStatus.CLEANING_FLOW2, + } +) + + class FanLevel(IntEnum): - """Suction fan speed levels (SweepMode from APK).""" + """CleanParam suction level (CleanTask.pbenum FanLevel).""" - QUIET = 0 - NORMAL = 1 - STRONG = 2 - MAX = 3 + UNSPECIFIED = 0 + MUTE = 1 + QUIET = MUTE + NORMAL = 2 + STRONG = 3 + DEEP = 4 + MAX = DEEP + SUPER = 5 class MopHumidity(IntEnum): - """Mop wetness levels.""" + """Water volume. CleanParam tag 4 and the live clean/set_mop_humidity command share these ints.""" + + UNSPECIFIED = 0 + DRY = 1 + NORMAL = 2 + WET = 3 + + +class MopStrengthLevel(IntEnum): + """Mop scrub intensity (CleanParam tag 3).""" - DRY = 0 + UNSPECIFIED = 0 NORMAL = 1 - WET = 2 + HIGH = 2 + + +class CleaningRoute(IntEnum): + """Cleaning route overlap level (CleanParam tag 8).""" + + STANDARD = 1 + METICULOUS = 2 + + +class WorkMode(IntEnum): + """Clean work mode — the app's robot_work_mode_* selector (Vacuum / Mop / Vacuum then mop / Vacuum and mop). Its value IS the CleanTask.taskType the robot executes; the per-item CleanParam.mode (the proto's own CleanMode enum) is derived separately in client._WORK_MODE_PARAM.""" + + VACUUM = 1 + MOP = 2 + VACUUM_THEN_MOP = 3 + VACUUM_AND_MOP = 4 # robot_base_status field numbers diff --git a/narwal_client/models.py b/narwal_client/models.py index 11a69c6..067eddb 100644 --- a/narwal_client/models.py +++ b/narwal_client/models.py @@ -4,12 +4,20 @@ import logging import struct +import time from dataclasses import dataclass, field from typing import Any, ClassVar _LOGGER = logging.getLogger(__name__) +_ACTIVE_WORKING_STATUS_TTL = 15.0 -from .const import CommandResult, FanLevel, MopHumidity, WorkingStatus +from .const import ( + ACTIVE_CLEANING_STATUSES, + TOPIC_CMD_GET_ROBOT_TASK_STATUS, + TOPIC_ROBOT_TASK_STATUS, + CommandResult, + WorkingStatus, +) @dataclass @@ -55,6 +63,11 @@ class RoomInfo: 5: "Bathroom", 10: "Corridor", }, + "iSuVlI1If2": { # Flow 2 alternate product key + 1: "Master Bedroom", + 5: "Bathroom", + 10: "Corridor", + }, } def __post_init__(self): @@ -237,6 +250,73 @@ def _parse_obstacles(field32: dict) -> list[ObstacleInfo]: return obstacles +def _coerce_bytes(value: Any) -> bytes: + """Return a protobuf bytes value as bytes.""" + if isinstance(value, bytes): + return value + if isinstance(value, bytearray): + return bytes(value) + if isinstance(value, str): + return value.encode("latin-1", "ignore") + return b"" + + +def _count_cleaned_pixels(value: Any, expected_pixels: int) -> int: + """Count non-zero cells in a display_map cleaned-area overlay.""" + data = _coerce_bytes(value) + if not data: + return 0 + + from .map_renderer import _decode_packed_varints, decompress_map + + decompressed = decompress_map(data) + pixels = _decode_packed_varints(decompressed) + if pixels: + if expected_pixels > 0: + pixels = pixels[:expected_pixels] + return sum(1 for pixel in pixels if pixel) + + raw = decompressed or data + if expected_pixels > 0: + raw = raw[:expected_pixels] + return sum(1 for byte in raw if byte) + + +def _extract_ints(value: Any) -> list[int]: + """Extract integer values from a loosely-decoded protobuf field.""" + if isinstance(value, bool): + return [] + if isinstance(value, int): + return [value] + if isinstance(value, list): + result: list[int] = [] + for item in value: + result.extend(_extract_ints(item)) + return result + if isinstance(value, dict): + result: list[int] = [] + for item in value.values(): + result.extend(_extract_ints(item)) + return result + return [] + + +def _positive_int_field(decoded: dict[str, Any], field: str) -> bool: + """Return true when a decoded protobuf field is a positive integer.""" + try: + return int(decoded.get(field, 0) or 0) > 0 + except (TypeError, ValueError): + return False + + +def _optional_int(value: Any) -> int | None: + """Return value coerced to int, or None when it cannot be coerced.""" + try: + return int(value) + except (TypeError, ValueError): + return None + + @dataclass class MapData: """Map data from get_map response.""" @@ -254,6 +334,7 @@ class MapData: origin_y: int = 0 # y pixel offset from field 2.6.1 obstacles: list[ObstacleInfo] = field(default_factory=list) raw: dict[str, Any] = field(default_factory=dict) + map_id: int = 0 # active map id (field 2.1) — required by clean/start_clean @classmethod def from_response( @@ -344,6 +425,7 @@ def from_response( obstacles = _parse_obstacles(field32) return cls( + map_id=int(payload.get("1", 0)), width=int(payload.get("4", 0)), height=int(payload.get("5", 0)), resolution=resolution, @@ -359,6 +441,43 @@ def from_response( raw=payload, ) + def cleanable_area_cm2(self, room_ids: list[int] | None = None) -> int: + """Estimate cleanable area in cm² from room floor pixels.""" + if not self.compressed_map or self.width <= 0 or self.height <= 0: + return 0 + if self.resolution <= 0: + return 0 + + from .map_renderer import _decode_packed_varints, decompress_map + + pixels = _decode_packed_varints(decompress_map(self.compressed_map)) + expected = self.width * self.height + if len(pixels) < expected: + pixels.extend([0] * (expected - len(pixels))) + elif len(pixels) > expected: + pixels = pixels[:expected] + + selected = {int(room_id) for room_id in room_ids or []} + floor_pixels = 0 + for val in pixels: + if val in (0, 0x28): + continue + if val == 0x20: + if selected: + continue + floor_pixels += 1 + continue + room_id = val >> 8 + pixel_type = val & 0xFF + if pixel_type & 0x10: + continue + if selected and room_id not in selected: + continue + floor_pixels += 1 + + cm_per_pixel = self.resolution / 10 + return round(floor_pixels * cm_per_pixel * cm_per_pixel) + @dataclass class MapDisplayData: @@ -384,6 +503,10 @@ class MapDisplayData: # Dock/reference position from field 5 (same coordinate system as robot) dock_ref_x: float = 0.0 dock_ref_y: float = 0.0 + cleaned_width: int = 0 + cleaned_height: int = 0 + cleaned_pixel_count: int = 0 + active_room_ids: list[int] = field(default_factory=list) def to_grid_coords( self, resolution: int, origin_x: int, origin_y: int, @@ -455,8 +578,39 @@ def from_broadcast(cls, decoded: dict[str, Any]) -> MapDisplayData: except (ValueError, TypeError): pass + field7 = decoded.get("7") + if isinstance(field7, list): + field7 = field7[0] if field7 else None + if isinstance(field7, dict): + try: + result.cleaned_width = int(field7.get("1", 0)) + result.cleaned_height = int(field7.get("2", 0)) + except (ValueError, TypeError): + result.cleaned_width = 0 + result.cleaned_height = 0 + result.cleaned_pixel_count = _count_cleaned_pixels( + field7.get("3"), + result.cleaned_width * result.cleaned_height, + ) + + if "12" in decoded: + seen: set[int] = set() + room_ids: list[int] = [] + for room_id in _extract_ints(decoded["12"]): + if room_id > 0 and room_id not in seen: + seen.add(room_id) + room_ids.append(room_id) + result.active_room_ids = room_ids + return result + def cleaned_area_cm2(self, resolution: int) -> int: + """Return the cleaned overlay area in cm².""" + if self.cleaned_pixel_count <= 0 or resolution <= 0: + return 0 + cm_per_pixel = resolution / 10 + return round(self.cleaned_pixel_count * cm_per_pixel * cm_per_pixel) + @dataclass class Position: @@ -495,6 +649,7 @@ class NarwalState: working_status: WorkingStatus = WorkingStatus.UNKNOWN battery_level: int = 0 # real-time SOC from field 2 (float32) battery_health: int = 0 # static design capacity from field 38 (always 100) + inferred_docked_from_battery: bool = False firmware_version: str = "" firmware_target: str = "" @@ -511,6 +666,12 @@ class NarwalState: # Cleaning stats cleaning_area: int = 0 # cm² cleaning_time: int = 0 # seconds + last_active_working_status_time: float = 0.0 + task_progress_percent: int | None = None + task_elapsed_time: int = 0 + current_room_id: int | None = None + current_room_name: str = "" + dry_mop_remaining_time: int | None = None # Map map_data: MapData | None = None @@ -534,10 +695,22 @@ class NarwalState: # Dock activity (field 3 sub-field 12: 2/6 observed when docked) dock_activity: int = 0 + # Station activity (field 3 sub-field 18) + # Observed: 1 during dust gathering, 4 during dock dry/disinfection work. + station_activity: int = 0 + # Dock presence (field 3 sub-field 3) # Values observed: 1=on dock, 2=off dock, 6=on dock (charged idle) dock_presence: int = 0 + # Newer Flow firmware sub-state (field 3 sub-field 4). + # Observed during active clean startup/navigation: 8 -> 7 -> 3. + flow_activity: int = 0 + + # Newer Flow firmware task flag (field 3 sub-field 14). Observed as 1 + # during active cleaning/navigation. + flow_task_flag: int = 0 + # Dock indicator from field 11 (top-level base_status field) # Validated via dock_research.py guided test (5 captures): # 2 = on dock (all 3 on-dock captures) @@ -555,12 +728,67 @@ class NarwalState: # Raw data for fields we haven't fully decoded yet raw_base_status: dict[str, Any] = field(default_factory=dict) raw_working_status: dict[str, Any] = field(default_factory=dict) + raw_aux_status: dict[str, dict[str, Any]] = field(default_factory=dict) + + def _working_status_is_cleaning_like(self) -> bool: + """True when the cached working_status looks like a cleaning task.""" + return self.working_status in ( + WorkingStatus.CLEANING, + WorkingStatus.CLEANING_V2, + WorkingStatus.CLEANING_ALT, + WorkingStatus.CLEANING_FLOW2, + ) + + def _dock_fields_indicate_docked(self) -> bool: + """True when base-status dock fields indicate the robot is on dock.""" + if self.dock_sub_state == 1: + return True + if self.dock_activity > 0: + return True + if self.station_activity > 0: + return True + if self.dock_field11 >= 2: + return True + if self.dock_field47 in (1, 3): + return True + return False + + @property + def is_station_active(self) -> bool: + """True when the base station is running a dock-side task.""" + return self.station_activity > 0 or ( + self.working_status == WorkingStatus.CLEANING_ALT and self.is_docked + ) + + def _set_battery_level(self, battery_level: int) -> None: + """Update battery level and infer docked state from charging trend.""" + if ( + self.battery_level > 0 + and battery_level > self.battery_level + and self._working_status_is_cleaning_like() + and not self.has_recent_active_working_status + ): + self.inferred_docked_from_battery = True + self.battery_level = battery_level + + @property + def has_recent_active_working_status(self) -> bool: + """True while working_status is actively reporting task counters.""" + if self.working_status not in ACTIVE_CLEANING_STATUSES: + return False + if self.last_active_working_status_time <= 0: + return False + return time.monotonic() - self.last_active_working_status_time <= _ACTIVE_WORKING_STATUS_TTL @property def is_cleaning(self) -> bool: """True when actively cleaning (not paused, not returning to dock).""" + if self.has_recent_active_working_status: + return not self.is_paused and not self.is_returning + if self._dock_fields_indicate_docked() or self.inferred_docked_from_battery: + return False return ( - self.working_status in (WorkingStatus.CLEANING, WorkingStatus.CLEANING_ALT) + self._working_status_is_cleaning_like() and not self.is_paused and not self.is_returning_to_dock ) @@ -581,25 +809,21 @@ def is_docked(self) -> bool: cleaning is not active, since the robot can report unmapped states (e.g. self-test) while physically docked. """ + if self.has_recent_active_working_status: + return False if self.working_status in ( WorkingStatus.DOCKED, WorkingStatus.CHARGED, WorkingStatus.DOCKED_V2, ): return True - if self.working_status in (WorkingStatus.CLEANING, WorkingStatus.CLEANING_ALT): - return False # For STANDBY, UNKNOWN, or any other status: check dock field signals. # Values differ across firmware versions: # Old FW: dock_sub_state=1, dock_field11=2, dock_field47=3 # v01.07.23.00: dock_sub_state absent, dock_field11=3, dock_field47=1 - if self.dock_sub_state == 1: - return True - if self.dock_activity > 0: - return True - if self.dock_field11 >= 2: - return True - if self.dock_field47 in (1, 3): - return True - return False + # + # Narwal can keep reporting a stale CLEANING working_status after the + # active working_status payload has stopped. Once that live payload is + # no longer recent, dock fields are a better source of truth. + return self._dock_fields_indicate_docked() or self.inferred_docked_from_battery @property def is_returning(self) -> bool: @@ -618,8 +842,11 @@ def is_returning(self) -> bool: transitions to STANDBY/DOCKED/CHARGED, it has already docked even if field 3.7 is momentarily still set. """ - if self.working_status not in ( - WorkingStatus.CLEANING, WorkingStatus.CLEANING_ALT, + if not self.has_recent_active_working_status and self.working_status not in ( + WorkingStatus.CLEANING, + WorkingStatus.CLEANING_V2, + WorkingStatus.CLEANING_ALT, + WorkingStatus.CLEANING_FLOW2, ): return False return self.is_returning_to_dock and self.dock_sub_state == 2 @@ -634,6 +861,8 @@ def update_from_working_status(self, decoded: dict[str, Any]) -> None: Field 15 = 600 during cleaning (purpose uncertain) """ self.raw_working_status = decoded + previous_cleaning_time = self.cleaning_time + previous_cleaning_area = self.cleaning_area if "3" in decoded: try: self.cleaning_time = int(decoded["3"]) @@ -644,6 +873,34 @@ def update_from_working_status(self, decoded: dict[str, Any]) -> None: if "15" in decoded: # Field 15 may be cumulative time; prefer field 3 for current session pass + has_active_payload = any( + _positive_int_field(decoded, field) for field in ("3", "13") + ) + active_payload_changed = ( + self.cleaning_time != previous_cleaning_time + or self.cleaning_area != previous_cleaning_area + ) + if has_active_payload and active_payload_changed: + self.last_active_working_status_time = time.monotonic() + self.inferred_docked_from_battery = False + if ( + has_active_payload + and active_payload_changed + and self.working_status + in ( + WorkingStatus.UNKNOWN, + WorkingStatus.STANDBY, + WorkingStatus.DOCKED, + WorkingStatus.CHARGED, + WorkingStatus.DOCKED_V2, + ) + ): + self.working_status = WorkingStatus.CLEANING + self.dock_field11 = 1 + self.dock_field47 = 2 + self.dock_sub_state = 0 + self.dock_activity = 0 + self.station_activity = 0 def update_from_base_status(self, decoded: dict[str, Any]) -> None: """Update state from a decoded robot_base_status message. @@ -659,6 +916,7 @@ def update_from_base_status(self, decoded: dict[str, Any]) -> None: 3.7 = 1 means RETURNING to dock (live-validated) 3.10 = dock sub-state (1=docked, 2=docking in progress) 3.12 = dock activity (values 2, 6 observed) + 3.18 = station activity (1=dust gathering, 4=dry/disinfection observed) Dock indicators (validated via dock_research.py, 5 captures): Field 11 = 2 when docked, 1 when undocked @@ -715,13 +973,41 @@ def update_from_base_status(self, decoded: dict[str, Any]) -> None: self.dock_activity = int(field3["12"]) except (ValueError, TypeError): pass + self.station_activity = 0 + if "18" in field3: + try: + self.station_activity = int(field3["18"]) + except (ValueError, TypeError): + pass if "3" in field3: try: self.dock_presence = int(field3["3"]) except (ValueError, TypeError): pass + self.flow_activity = 0 + if "4" in field3: + try: + self.flow_activity = int(field3["4"]) + except (ValueError, TypeError): + pass + self.flow_task_flag = 0 + if "14" in field3: + try: + self.flow_task_flag = int(field3["14"]) + except (ValueError, TypeError): + pass + if self.working_status in ACTIVE_CLEANING_STATUSES: + if "11" not in decoded: + self.dock_field11 = 1 + if "47" not in decoded: + self.dock_field47 = 2 + if "10" not in field3: + self.dock_sub_state = 0 + if "12" not in field3: + self.dock_activity = 0 + self.inferred_docked_from_battery = False # Log unrecognized sub-fields for future firmware mapping - _known_f3 = {"1", "2", "3", "7", "10", "12"} + _known_f3 = {"1", "2", "3", "4", "7", "10", "11", "12", "14", "18"} _unknown_f3 = set(field3.keys()) - _known_f3 if _unknown_f3: _LOGGER.debug( @@ -734,12 +1020,21 @@ def update_from_base_status(self, decoded: dict[str, Any]) -> None: "Please report this at the GitHub repo.", type(field3).__name__, field3, ) + explicitly_off_dock = ( + ("11" in decoded and self.dock_field11 == 1) + or ("47" in decoded and self.dock_field47 == 2) + ) + if not self.is_returning_to_dock and explicitly_off_dock: + self.dock_sub_state = 0 + self.dock_activity = 0 if "2" in decoded: # Field 2 = real-time battery SOC as float32 # (e.g. 1118175232 → 83.0%; bbp may return int or float) bat = _to_float32(decoded["2"]) if bat is not None: - self.battery_level = round(bat) + self._set_battery_level(round(bat)) + if explicitly_off_dock: + self.inferred_docked_from_battery = False if "38" in decoded: # Field 38 = static battery health (always 100, design capacity) self.battery_health = int(decoded["38"]) @@ -766,7 +1061,7 @@ def update_battery_from_base_status(self, decoded: dict[str, Any]) -> None: if "2" in decoded: bat = _to_float32(decoded["2"]) if bat is not None: - self.battery_level = round(bat) + self._set_battery_level(round(bat)) if "38" in decoded: self.battery_health = int(decoded["38"]) if "36" in decoded: @@ -797,3 +1092,46 @@ def update_from_download_status(self, decoded: dict[str, Any]) -> None: """Update state from a decoded download_status message.""" if "1" in decoded: self.download_status = int(decoded["1"]) + + def update_from_aux_status(self, topic: str, decoded: dict[str, Any]) -> None: + """Store decoded status payloads that are not mapped yet.""" + self.raw_aux_status[topic] = decoded + if topic in {TOPIC_ROBOT_TASK_STATUS, TOPIC_CMD_GET_ROBOT_TASK_STATUS}: + payload = decoded.get("2") + if not isinstance(payload, dict): + return + progress = _optional_int(payload.get("1")) + if progress is not None: + self.task_progress_percent = max(0, min(100, progress)) + elapsed = _optional_int(payload.get("2")) + if elapsed is not None: + self.task_elapsed_time = elapsed + room = payload.get("6") + if not isinstance(room, dict): + room = payload.get("8") + if isinstance(room, dict): + self.current_room_id = _optional_int(room.get("1")) + name = room.get("3") + if isinstance(name, (bytes, bytearray)): + self.current_room_name = bytes(name).decode( + "utf-8", errors="replace" + ) + else: + self.current_room_name = str(name) if name else "" + if self.current_room_name.startswith( + "b'" + ) and self.current_room_name.endswith("'"): + self.current_room_name = self.current_room_name[2:-1] + if self.current_room_id is not None and self.map_data is not None: + map_room = next( + ( + candidate + for candidate in self.map_data.rooms + if candidate.room_id == self.current_room_id + ), + None, + ) + if map_room is not None: + self.current_room_name = map_room.display_name + elif topic == "supply/get_dry_mop_remain_time": + self.dry_mop_remaining_time = _optional_int(decoded.get("2")) diff --git a/tests/ha_stubs.py b/tests/ha_stubs.py index 470df84..cf9d468 100644 --- a/tests/ha_stubs.py +++ b/tests/ha_stubs.py @@ -9,7 +9,7 @@ import sys from types import ModuleType -from unittest.mock import MagicMock +from unittest.mock import AsyncMock, MagicMock _INSTALLED = False @@ -35,12 +35,25 @@ def _mod(name: str, parent: ModuleType | None = None) -> ModuleType: vol.Required = MagicMock(side_effect=lambda *a, **kw: a[0] if a else "key") # type: ignore[attr-defined] vol.Optional = MagicMock(side_effect=lambda *a, **kw: a[0] if a else "key") # type: ignore[attr-defined] vol.In = MagicMock() # type: ignore[attr-defined] + vol.All = MagicMock() # type: ignore[attr-defined] + vol.Coerce = MagicMock() # type: ignore[attr-defined] + vol.Range = MagicMock() # type: ignore[attr-defined] # --- homeassistant --- ha = _mod("homeassistant") + ha_auth = _mod("homeassistant.auth", ha) + ha_permissions = _mod("homeassistant.auth.permissions", ha_auth) + ha_permissions_const = _mod( + "homeassistant.auth.permissions.const", ha_permissions + ) + ha_permissions_const.POLICY_CONTROL = "control" # type: ignore[attr-defined] + # homeassistant.const ha_const = _mod("homeassistant.const", ha) + ha_const.ATTR_AREA_ID = "area_id" # type: ignore[attr-defined] + ha_const.ATTR_DEVICE_ID = "device_id" # type: ignore[attr-defined] + ha_const.ATTR_ENTITY_ID = "entity_id" # type: ignore[attr-defined] ha_const.Platform = MagicMock() # type: ignore[attr-defined] # homeassistant.core @@ -51,6 +64,14 @@ def _mod(name: str, parent: ModuleType | None = None) -> ModuleType: # homeassistant.exceptions ha_exc = _mod("homeassistant.exceptions", ha) ha_exc.ConfigEntryNotReady = type("ConfigEntryNotReady", (Exception,), {}) # type: ignore[attr-defined] + ha_exc.HomeAssistantError = type("HomeAssistantError", (Exception,), {}) # type: ignore[attr-defined] + + class _PermissionError(Exception): + def __init__(self, *args: object, **kwargs: object) -> None: + super().__init__(*args) + + ha_exc.Unauthorized = _PermissionError # type: ignore[attr-defined] + ha_exc.UnknownUser = _PermissionError # type: ignore[attr-defined] # homeassistant.config_entries ha_ce = _mod("homeassistant.config_entries", ha) @@ -85,6 +106,18 @@ def __init__(self, reason: str) -> None: # homeassistant.helpers (and sub-modules) ha_helpers = _mod("homeassistant.helpers", ha) + ha_cv = _mod("homeassistant.helpers.config_validation", ha_helpers) + ha_cv.entity_ids = MagicMock() # type: ignore[attr-defined] + ha_cv.ensure_list = MagicMock(side_effect=lambda value: value) # type: ignore[attr-defined] + + ha_er = _mod("homeassistant.helpers.entity_registry", ha_helpers) + ha_er.async_get = MagicMock() # type: ignore[attr-defined] + + ha_service = _mod("homeassistant.helpers.service", ha_helpers) + ha_service.async_extract_entity_ids = AsyncMock( # type: ignore[attr-defined] + side_effect=lambda call: set(call.data.get("entity_id", [])) + ) + ha_uc = _mod("homeassistant.helpers.update_coordinator", ha_helpers) class _DataUpdateCoordinator: @@ -127,6 +160,7 @@ def _handle_coordinator_update(self) -> None: ha_comp = _mod("homeassistant.components", ha) ha_vac = _mod("homeassistant.components.vacuum", ha_comp) + ha_vac.DOMAIN = "vacuum" # type: ignore[attr-defined] class _Segment: """Stub for homeassistant.components.vacuum.Segment.""" def __init__(self, *, id: str, name: str, group: str | None = None) -> None: diff --git a/tests/test_client.py b/tests/test_client.py index dd658ce..c1b2433 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -8,7 +8,7 @@ import pytest from narwal_client.client import NarwalClient, NarwalConnectionError -from narwal_client.const import CommandResult +from narwal_client.const import CommandResult, WorkingStatus from narwal_client.models import CommandResponse, MapData, RoomInfo @@ -31,15 +31,118 @@ def test_initial_state(self) -> None: assert not client.connected assert client.state.battery_level == 0 + def test_confirmed_dock_status_ends_active_marker(self) -> None: + """Fresh dock indicators are not discarded as stale task state.""" + client = NarwalClient("10.0.0.1") + client.state.working_status = WorkingStatus.CLEANING + client._last_active_working_status_time = 100.0 + + client._update_from_base_status_broadcast( + {"3": {"1": 10}, "11": 2, "47": 3}, now=101.0 + ) + + assert client.state.working_status == WorkingStatus.DOCKED + assert client.state.is_docked + + @pytest.mark.parametrize("dock_field", [{"10": 1}, {"12": 2}]) + def test_nested_dock_status_ends_active_marker( + self, dock_field: dict[str, int] + ) -> None: + """Nested old-firmware dock indicators confirm a terminal status.""" + client = NarwalClient("10.0.0.1") + client.state.working_status = WorkingStatus.CLEANING + client._last_active_working_status_time = 100.0 + + client._update_from_base_status_broadcast( + {"3": {"1": 10, **dock_field}}, now=101.0 + ) + + assert client.state.working_status == WorkingStatus.DOCKED + assert client.state.is_docked + + def test_unconfirmed_standby_status_remains_stale(self) -> None: + """An idle overlay does not replace a newly accepted clean.""" + client = NarwalClient("10.0.0.1") + client.state.working_status = WorkingStatus.CLEANING + client._last_active_working_status_time = 100.0 + + client._update_from_base_status_broadcast( + {"3": {"1": 1}, "11": 1, "47": 2}, now=101.0 + ) + + assert client.state.working_status == WorkingStatus.CLEANING + def test_commands_require_connection(self) -> None: client = NarwalClient("10.0.0.1") with pytest.raises(NarwalConnectionError): - asyncio.get_event_loop().run_until_complete(client.start()) + asyncio.run(client.start()) + + def test_idle_keepalive_renews_topic_subscription(self) -> None: + client = NarwalClient("10.0.0.1") + client._connected.set() + client._ws = AsyncMock() + client._robot_awake = True + client._TOPIC_RESUB_INTERVAL = -1 + + sleep_count = 0 + + async def stop_after_first_iteration(_delay: float) -> None: + nonlocal sleep_count + sleep_count += 1 + if sleep_count == 2: + client._connected.clear() + + with ( + patch("narwal_client.client.asyncio.sleep", stop_after_first_iteration), + patch.object(client, "_state_needs_keepalive", return_value=False), + ): + asyncio.run(client._keepalive_loop()) + + client._ws.send.assert_awaited_once() + + def test_idle_reconnect_subscribes_without_waking(self) -> None: + """An idle reconnect restores subscriptions without a wake burst.""" + client = NarwalClient("10.0.0.1") + + class EmptyWebSocket: + def __aiter__(self): + return self + + async def __anext__(self): + client._should_reconnect = False + raise StopAsyncIteration + + async def connect() -> None: + client._ws = EmptyWebSocket() + client._connected.set() + + client.connect = AsyncMock(side_effect=connect) + client.subscribe_to_topics = AsyncMock() + client._send_wake_burst = AsyncMock() + client._heartbeat_loop = AsyncMock() + client._keepalive_loop = AsyncMock() + + with patch.object(client, "_state_needs_keepalive", return_value=False): + asyncio.run(client.start_listening()) + + client.subscribe_to_topics.assert_awaited_once_with() + client._send_wake_burst.assert_not_awaited() + + def test_stale_paused_overlay_does_not_wake_docked_robot(self) -> None: + """Dock fields override stale pause bits for keepalive decisions.""" + client = NarwalClient("10.0.0.1") + client.state.working_status = WorkingStatus.CLEANING + client.state.is_paused = True + client.state.dock_field11 = 2 + client.state.dock_field47 = 3 + + assert client.state.is_docked + assert not client._state_needs_keepalive() def test_send_raw_without_connection_raises(self) -> None: client = NarwalClient("10.0.0.1") with pytest.raises(NarwalConnectionError): - asyncio.get_event_loop().run_until_complete( + asyncio.run( client.send_raw("test/topic", b"\x08\x01") ) @@ -132,7 +235,7 @@ def test_start_returns_legacy_response_on_success(self) -> None: client, "send_command", new_callable=AsyncMock ) as mock_send: mock_send.return_value = success - result = asyncio.get_event_loop().run_until_complete(client.start()) + result = asyncio.run(client.start()) assert result is success mock_send.assert_awaited_once() # no fallback fired @@ -152,7 +255,7 @@ def test_start_falls_back_to_v2_on_not_applicable(self) -> None: client, "send_command", new_callable=AsyncMock ) as mock_send: mock_send.side_effect = [not_applicable, success] - result = asyncio.get_event_loop().run_until_complete(client.start()) + result = asyncio.run(client.start()) assert mock_send.await_count == 2 # Second call's payload must be v2 (different bytes from legacy) @@ -173,7 +276,7 @@ def test_start_returns_not_applicable_when_no_map_cached(self) -> None: client, "send_command", new_callable=AsyncMock ) as mock_send: mock_send.return_value = not_applicable - result = asyncio.get_event_loop().run_until_complete(client.start()) + result = asyncio.run(client.start()) mock_send.assert_awaited_once() # no v2 retry without rooms assert result.result_code == CommandResult.NOT_APPLICABLE @@ -189,7 +292,7 @@ def test_start_skips_v2_when_map_has_no_room_ids(self) -> None: client, "send_command", new_callable=AsyncMock ) as mock_send: mock_send.return_value = not_applicable - result = asyncio.get_event_loop().run_until_complete(client.start()) + result = asyncio.run(client.start()) mock_send.assert_awaited_once() assert result.result_code == CommandResult.NOT_APPLICABLE diff --git a/tests/test_client_rooms.py b/tests/test_client_rooms.py index ce221aa..6d1b8a1 100644 --- a/tests/test_client_rooms.py +++ b/tests/test_client_rooms.py @@ -1,195 +1,362 @@ -"""Tests for narwal_client room-specific clean payload and start_rooms.""" +"""Tests for the room-clean payload (_build_start_clean_payload) and start_rooms.""" from __future__ import annotations import asyncio -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock, MagicMock, patch +import blackboxprotobuf import pytest -from narwal_client.client import NarwalClient -from narwal_client.const import CommandResult +from narwal_client.client import NarwalClient, NarwalCommandError +from narwal_client.const import ( + CommandResult, + CleaningRoute, + FanLevel, + MopHumidity, + MopStrengthLevel, + WorkMode, +) from narwal_client.models import CommandResponse -class TestBuildRoomCleanPayload: - """Tests for _build_room_clean_payload protobuf encoding.""" +def _run(coro): + loop = asyncio.new_event_loop() + try: + return loop.run_until_complete(coro) + finally: + loop.close() - def test_single_room_encodes_room_id_and_params(self) -> None: - """Single room has roomId + clean params in field 1.2.""" - import blackboxprotobuf - client = NarwalClient("127.0.0.1") - payload = client._build_room_clean_payload([11]) - decoded, _ = blackboxprotobuf.decode_message(payload) - - field1_2 = decoded["1"]["2"] - # Single room: field 1.2.1 = roomId - assert field1_2["1"] == 11 - # Per-room clean params must be present (robot ignores bare roomId) - assert field1_2["2"] == 2, "cleanMode should be 2 (sweep+mop)" - assert field1_2["3"] == 1, "cleanTimes should be 1" - assert field1_2["6"] == 3, "sweepMode should be 3 (max suction)" - assert field1_2["7"] == 2, "mopMode should be 2 (wet)" - - def test_multiple_rooms_encodes_all(self) -> None: - """Multiple rooms encode as repeated messages in field 1.2.""" - import blackboxprotobuf +def _task(payload: bytes) -> dict: + """Decode a StartClean payload to its CleanTask (field 1).""" + decoded, _ = blackboxprotobuf.decode_message(payload) + return decoded["1"] - client = NarwalClient("127.0.0.1") - payload = client._build_room_clean_payload([11, 9]) - decoded, _ = blackboxprotobuf.decode_message(payload) - - field1_2 = decoded["1"]["2"] - assert isinstance(field1_2, list), "Multiple rooms should be a list" - room_ids = [entry["1"] for entry in field1_2] - assert 11 in room_ids - assert 9 in room_ids - # Each entry has clean params - for entry in field1_2: - assert entry["2"] == 2, "cleanMode" - assert entry["6"] == 3, "sweepMode" - - def test_preserves_global_clean_settings(self) -> None: - """Payload preserves suction=3, mop=2, passes=1 in field 1.5.""" - import blackboxprotobuf - client = NarwalClient("127.0.0.1") - payload = client._build_room_clean_payload([11]) - decoded, _ = blackboxprotobuf.decode_message(payload) +def _items(task: dict) -> list[dict]: + items = task["2"] + return items if isinstance(items, list) else [items] + + +# WorkMode -> (expected taskType, expected CleanParam.mode, expected pass tags) +_MODE_EXPECT = { + WorkMode.VACUUM: (1, 2, {"5"}), + WorkMode.MOP: (2, 3, {"6"}), + WorkMode.VACUUM_THEN_MOP: (3, 5, {"5", "6"}), + WorkMode.VACUUM_AND_MOP: (4, 4, {"7"}), +} - settings = decoded["1"]["5"]["1"] - assert settings["1"] == 3, "Suction should be 3 (max)" - assert settings["2"] == 2, "Mop humidity should be 2 (wet)" - assert settings["3"] == 1, "Passes should be 1 (single)" - def test_empty_room_ids_returns_default(self) -> None: - """Empty room list returns the default whole-house payload.""" +class TestBuildStartCleanPayload: + """The CleanTask/CleanParam encoding.""" + + def test_legacy_fan_level_names_remain_available(self) -> None: + """External client users keep the original QUIET and MAX names.""" + assert FanLevel.QUIET is FanLevel.MUTE + assert FanLevel.MAX is FanLevel.DEEP + + def test_task_type_and_param_mode_per_mode(self) -> None: + """taskType (the carrier) and CleanParam.mode/pass-tags follow work_mode.""" + client = NarwalClient("127.0.0.1") + for mode, (task_type, param_mode, pass_tags) in _MODE_EXPECT.items(): + payload = client._build_start_clean_payload([2], 1, work_mode=mode, passes=2) + task = _task(payload) + assert task["5"] == task_type, f"taskType for {mode.name}" + param = _items(task)[0]["2"] + assert param["1"] == param_mode, f"CleanParam.mode for {mode.name}" + for tag in pass_tags: + assert param[tag] == 2, f"pass tag {tag} for {mode.name}" + for tag in {"5", "6", "7"} - pass_tags: + assert tag not in param, f"unexpected pass tag {tag} for {mode.name}" + + def test_fan_water_strength_encoded(self) -> None: + """fan/water/mop_strength land in their CleanParam tags.""" client = NarwalClient("127.0.0.1") - payload = client._build_room_clean_payload([]) - assert payload == client._DEFAULT_CLEAN_PAYLOAD + payload = client._build_start_clean_payload( + [2], 1, work_mode=WorkMode.MOP, + fan=FanLevel.DEEP, water=MopHumidity.WET, mop_strength=MopStrengthLevel.HIGH, + ) + param = _items(_task(payload))[0]["2"] + assert param["2"] == FanLevel.DEEP + assert param["4"] == MopHumidity.WET + assert param["3"] == MopStrengthLevel.HIGH + + def test_overlap_not_sent(self) -> None: + """overlapLevel (tag 8) is omitted — live-validated as ignored.""" + client = NarwalClient("127.0.0.1") + param = _items(_task(client._build_start_clean_payload([2], 1)))[0]["2"] + assert "8" not in param - def test_room_payload_differs_from_default(self) -> None: - """Room-specific payload is different from whole-house default.""" + def test_route_encoded_when_requested(self) -> None: + """Route overlap is encoded in CleanParam tag 8.""" + client = NarwalClient("127.0.0.1") + payload = client._build_start_clean_payload( + [2], 1, route=CleaningRoute.METICULOUS, + ) + param = _items(_task(payload))[0]["2"] + assert param["8"] == CleaningRoute.METICULOUS + + def test_map_zone_and_order(self) -> None: + """map_id, room zone refs, and 1-based order encode correctly.""" client = NarwalClient("127.0.0.1") - room_payload = client._build_room_clean_payload([11]) - assert room_payload != client._DEFAULT_CLEAN_PAYLOAD - assert len(room_payload) > 0 + task = _task(client._build_start_clean_payload([2, 12], 7)) + assert task["1"] == 7 + items = _items(task) + assert [it["1"]["2"] for it in items] == [2, 12] + assert all(it["1"]["1"] == 1 for it in items) # zoneType = ROOM + assert [it["3"] for it in items] == [1, 2] class TestStartRooms: - """Tests for start_rooms async method.""" + """start_rooms dispatch and settings threading.""" - def test_empty_rooms_calls_start(self) -> None: - """start_rooms([]) falls back to whole-house start().""" + def _client(self) -> NarwalClient: client = NarwalClient("127.0.0.1") - client._ws = AsyncMock() # fake connected state - client._connected = True + client._ws = AsyncMock() + client.state.device_info = MagicMock(product_key="QoEsI5qYXO") + client.state.map_data = MagicMock(map_id=1) + return client + def test_empty_rooms_calls_start(self) -> None: + """start_rooms([]) falls back to whole-house start().""" + client = self._client() with patch.object(client, "start", new_callable=AsyncMock) as mock_start: - mock_start.return_value = AsyncMock() - asyncio.get_event_loop().run_until_complete(client.start_rooms([])) + _run(client.start_rooms([])) mock_start.assert_awaited_once() - def test_room_ids_sends_room_payload(self) -> None: - """start_rooms with IDs sends room-specific payload via send_command.""" - client = NarwalClient("127.0.0.1") - client._ws = AsyncMock() - client._connected = True - + def test_forwards_settings_to_payload(self) -> None: + """Settings passed to start_rooms reach the encoded CleanTask.""" + client = self._client() + success = CommandResponse(result_code=CommandResult.SUCCESS) + with patch.object(client, "send_command", new_callable=AsyncMock) as mock_send: + mock_send.return_value = success + _run(client.start_rooms( + [5], work_mode=WorkMode.MOP, fan=FanLevel.STRONG, + water=MopHumidity.DRY, mop_strength=MopStrengthLevel.HIGH, passes=3, + route=CleaningRoute.METICULOUS, + )) + mock_send.assert_awaited_once() + param = _items(_task(mock_send.await_args.kwargs["payload"]))[0]["2"] + assert param["1"] == 3 # CleanParam.mode MOP + assert param["2"] == FanLevel.STRONG + assert param["3"] == MopStrengthLevel.HIGH + assert param["4"] == MopHumidity.DRY + assert param["6"] == 3 # mopTime pass count + assert param["8"] == CleaningRoute.METICULOUS + + def test_defaults_match_documented_clean(self) -> None: + """Default room cleaning uses max suction and wet mopping.""" + client = self._client() + success = CommandResponse(result_code=CommandResult.SUCCESS) + with patch.object(client, "send_command", new_callable=AsyncMock) as mock_send: + mock_send.return_value = success + _run(client.start_rooms([5])) + + param = _items(_task(mock_send.await_args.kwargs["payload"]))[0]["2"] + assert param["2"] == FanLevel.DEEP + assert param["4"] == MopHumidity.WET + + def test_no_map_id_uses_legacy_fallback(self) -> None: + """No active map id skips start_clean but retains the legacy path.""" + client = self._client() + client.state.device_info = MagicMock(product_key="QoEsI5qYXO") + client.state.map_data = MagicMock(map_id=0) + with patch.object(client, "get_map", new_callable=AsyncMock) as mock_get_map, \ + patch.object(client, "send_command", new_callable=AsyncMock) as mock_send: + mock_get_map.return_value = MagicMock(map_id=0) + mock_send.return_value = CommandResponse(result_code=CommandResult.SUCCESS) + result = _run(client.start_rooms([5])) + mock_send.assert_awaited_once() + assert mock_send.await_args.args[0] == "clean/plan/start" + assert result.result_code == CommandResult.SUCCESS + + def test_map_fetch_failure_uses_legacy_fallback(self) -> None: + """A sleeping robot can still receive the legacy cached-room command.""" + client = self._client() + client.state.device_info = MagicMock(product_key="QoEsI5qYXO") + client.state.map_data = None success = CommandResponse(result_code=CommandResult.SUCCESS) with patch.object( - client, "send_command", new_callable=AsyncMock + client, + "get_map", + new_callable=AsyncMock, + side_effect=NarwalCommandError("timeout"), + ), patch.object( + client, "send_command", new_callable=AsyncMock, return_value=success ) as mock_send: - mock_send.return_value = success - asyncio.get_event_loop().run_until_complete(client.start_rooms([11, 9])) - mock_send.assert_awaited_once() - payload_arg = mock_send.await_args.kwargs.get("payload") - assert payload_arg is not None - assert payload_arg != client._DEFAULT_CLEAN_PAYLOAD + result = _run(client.start_rooms([5])) + assert result is success + mock_send.assert_awaited_once() + assert mock_send.await_args.args[0] == "clean/plan/start" -class TestStartRoomsV2First: - """Tests for the v2-first schema order in start_rooms (#37 fix). + def test_flow2_map_fetch_failure_does_not_start_legacy_clean(self) -> None: + """A missing Flow 2 map cannot fall back to an unsafe whole-home clean.""" + client = self._client() + client.state.device_info = MagicMock(product_key="QxMSPG6VSO") + client.state.map_data = None + with patch.object( + client, + "get_map", + new_callable=AsyncMock, + side_effect=NarwalCommandError("timeout"), + ), patch.object(client, "send_command", new_callable=AsyncMock) as mock_send: + try: + _run(client.start_rooms([5])) + except NarwalCommandError: + pass + else: + raise AssertionError("Flow 2 map failure should surface to the caller") + + mock_send.assert_not_awaited() + + def test_super_live_fan_uses_highest_supported_level(self) -> None: + """The live command never reports an unavailable SUPER level.""" + client = self._client() + with patch.object(client, "send_command", new_callable=AsyncMock) as mock_send: + _run(client.set_fan_speed(FanLevel.SUPER)) + + assert mock_send.await_args.args[0] == "clean/set_fan_level" + assert mock_send.await_args.args[1] == b"\x08\x04" + + @pytest.mark.parametrize( + ("legacy_level", "wire_level"), + ((0, 1), (1, 2), (2, 3), (3, 4)), + ) + def test_live_fan_preserves_legacy_integer_levels( + self, legacy_level: int, wire_level: int + ) -> None: + client = self._client() + with patch.object(client, "send_command", new_callable=AsyncMock) as mock_send: + _run(client.set_fan_speed(legacy_level)) + + assert mock_send.await_args.args[1] == bytes((0x08, wire_level)) + + @pytest.mark.parametrize( + ("legacy_level", "wire_level"), + ((0, 1), (1, 2), (2, 3)), + ) + def test_live_mop_preserves_legacy_integer_levels( + self, legacy_level: int, wire_level: int + ) -> None: + client = self._client() + with patch.object(client, "send_command", new_callable=AsyncMock) as mock_send: + _run(client.set_mop_humidity(legacy_level)) + + assert mock_send.await_args.args[1] == bytes((0x08, wire_level)) + + def test_not_ready_retries_while_docked(self) -> None: + """NOT_READY on the dock retries clean/start_clean (dock settling).""" + client = self._client() + client.state.update_from_base_status({"3": {"1": 10, "10": 1}}) # docked + assert client.state.is_docked + not_ready = CommandResponse(result_code=CommandResult.NOT_READY) + success = CommandResponse(result_code=CommandResult.SUCCESS) + with patch.object(client, "send_command", new_callable=AsyncMock) as mock_send, \ + patch("narwal_client.client.asyncio.sleep", new_callable=AsyncMock): + mock_send.side_effect = [not_ready, success] + result = _run(client.start_rooms([5])) + assert mock_send.await_count == 2 + assert result is success - start_rooms() now tries v2 nested-room schema first (fixes ack-and-ignore - on newer firmware), falling back to legacy flat-room on NOT_APPLICABLE. - """ + def test_not_ready_off_dock_does_not_retry(self) -> None: + """NOT_READY off the dock surfaces as-is — start_clean needs the dock.""" + client = self._client() + assert not client.state.is_docked + not_ready = CommandResponse(result_code=CommandResult.NOT_READY) + with patch.object(client, "send_command", new_callable=AsyncMock) as mock_send: + mock_send.return_value = not_ready + result = _run(client.start_rooms([5])) + mock_send.assert_awaited_once() + assert result.result_code == CommandResult.NOT_READY - def _connected_client(self) -> NarwalClient: - client = NarwalClient("127.0.0.1") - client._ws = AsyncMock() - client._connected = True - return client + def test_conflict_surfaces_without_retry(self) -> None: + """A CONFLICT response (robot busy) is returned as-is.""" + client = self._client() + conflict = CommandResponse(result_code=CommandResult.CONFLICT) + with patch.object(client, "send_command", new_callable=AsyncMock) as mock_send: + mock_send.return_value = conflict + result = _run(client.start_rooms([5])) + mock_send.assert_awaited_once() + assert result.result_code == CommandResult.CONFLICT - def test_success_on_v2_does_not_retry(self) -> None: - """If the v2 room payload is accepted, no legacy retry happens.""" - client = self._connected_client() + def test_not_applicable_falls_back_to_plan_start(self) -> None: + """Older firmware retains the clean/plan/start compatibility path.""" + client = self._client() + not_applicable = CommandResponse(result_code=CommandResult.NOT_APPLICABLE) success = CommandResponse(result_code=CommandResult.SUCCESS) - - with patch.object( - client, "send_command", new_callable=AsyncMock - ) as mock_send: - mock_send.return_value = success - result = asyncio.get_event_loop().run_until_complete( - client.start_rooms([5]) - ) + with patch.object(client, "send_command", new_callable=AsyncMock) as mock_send: + mock_send.side_effect = [not_applicable, success] + result = _run(client.start_rooms([5])) assert result is success + assert mock_send.await_count == 2 + assert mock_send.await_args_list[0].args[0] == "clean/start_clean" + assert mock_send.await_args_list[1].args[0] == "clean/plan/start" + + def test_flow2_rejection_does_not_start_legacy_clean(self) -> None: + """Flow 2 never falls back to plan/start whole-home behaviour.""" + client = self._client() + client.state.device_info = MagicMock(product_key="QxMSPG6VSO") + not_applicable = CommandResponse(result_code=CommandResult.NOT_APPLICABLE) + with patch.object(client, "send_command", new_callable=AsyncMock) as mock_send: + mock_send.return_value = not_applicable + result = _run(client.start_rooms([5])) + + assert result is not_applicable mock_send.assert_awaited_once() - def test_v2_sends_correct_room_ids(self) -> None: - """v2 payload encodes exactly the requested rooms.""" - client = self._connected_client() + def test_legacy_fallback_translates_water_level(self) -> None: + """CleanParam wet maps to the legacy plan/start wet value.""" + client = self._client() + not_applicable = CommandResponse(result_code=CommandResult.NOT_APPLICABLE) success = CommandResponse(result_code=CommandResult.SUCCESS) + with patch.object(client, "send_command", new_callable=AsyncMock) as mock_send: + mock_send.side_effect = [not_applicable, success] + _run(client.start_rooms([5], water=MopHumidity.WET)) - with patch.object( - client, "send_command", new_callable=AsyncMock - ) as mock_send: - mock_send.return_value = success - asyncio.get_event_loop().run_until_complete( - client.start_rooms([5, 7]) - ) - - import blackboxprotobuf - payload = mock_send.await_args.kwargs.get("payload") - decoded, _ = blackboxprotobuf.decode_message(payload) - entries = decoded["1"]["2"] - ids = [e["1"]["2"] for e in entries] - assert ids == [5, 7] - - def test_not_applicable_triggers_legacy_retry(self) -> None: - """NOT_APPLICABLE on v2 triggers a legacy flat-room retry.""" - client = self._connected_client() + legacy_payload = mock_send.await_args_list[1].kwargs["payload"] + decoded, _ = blackboxprotobuf.decode_message(legacy_payload) + assert decoded["1"]["2"]["2"]["7"] == 2 + + def test_legacy_fallback_translates_fan_level(self) -> None: + """Flow 2 suction levels stay within the legacy plan/start range.""" + client = self._client() not_applicable = CommandResponse(result_code=CommandResult.NOT_APPLICABLE) success = CommandResponse(result_code=CommandResult.SUCCESS) - - with patch.object( - client, "send_command", new_callable=AsyncMock - ) as mock_send: + with patch.object(client, "send_command", new_callable=AsyncMock) as mock_send: mock_send.side_effect = [not_applicable, success] - result = asyncio.get_event_loop().run_until_complete( - client.start_rooms([5, 7]) - ) + _run(client.start_rooms([5], fan=FanLevel.SUPER)) - assert mock_send.await_count == 2 - v2_payload = mock_send.await_args_list[0].kwargs.get("payload") - legacy_payload = mock_send.await_args_list[1].kwargs.get("payload") - assert v2_payload != legacy_payload - assert result is success + legacy_payload = mock_send.await_args_list[1].kwargs["payload"] + decoded, _ = blackboxprotobuf.decode_message(legacy_payload) + assert decoded["1"]["2"]["2"]["1"] == 3 - def test_conflict_does_not_trigger_retry(self) -> None: - """A CONFLICT response (robot busy) surfaces as-is — no retry.""" - client = self._connected_client() - conflict = CommandResponse(result_code=CommandResult.CONFLICT) + def test_legacy_fallback_translates_strong_fan_level(self) -> None: + """CleanParam strong maps to the legacy strong value.""" + client = self._client() + not_applicable = CommandResponse(result_code=CommandResult.NOT_APPLICABLE) + success = CommandResponse(result_code=CommandResult.SUCCESS) + with patch.object(client, "send_command", new_callable=AsyncMock) as mock_send: + mock_send.side_effect = [not_applicable, success] + _run(client.start_rooms([5], fan=FanLevel.STRONG)) - with patch.object( - client, "send_command", new_callable=AsyncMock - ) as mock_send: - mock_send.return_value = conflict - result = asyncio.get_event_loop().run_until_complete( - client.start_rooms([5]) - ) + legacy_payload = mock_send.await_args_list[1].kwargs["payload"] + decoded, _ = blackboxprotobuf.decode_message(legacy_payload) + assert decoded["1"]["2"]["2"]["1"] == 2 - mock_send.assert_awaited_once() - assert result.result_code == CommandResult.CONFLICT + def test_both_new_payloads_rejected_use_legacy_payload(self) -> None: + """Older firmware receives the legacy flat-room payload last.""" + client = self._client() + not_applicable = CommandResponse(result_code=CommandResult.NOT_APPLICABLE) + success = CommandResponse(result_code=CommandResult.SUCCESS) + with patch.object(client, "send_command", new_callable=AsyncMock) as mock_send: + mock_send.side_effect = [not_applicable, not_applicable, success] + result = _run(client.start_rooms([5])) + + assert result is success + assert mock_send.await_count == 3 + legacy_payload = mock_send.await_args_list[2].kwargs["payload"] + decoded, _ = blackboxprotobuf.decode_message(legacy_payload) + assert decoded["1"]["2"]["1"] == 5 diff --git a/tests/test_config_flow.py b/tests/test_config_flow.py index edc0080..10b436a 100644 --- a/tests/test_config_flow.py +++ b/tests/test_config_flow.py @@ -101,7 +101,7 @@ async def test_connection_error_shows_form_with_error(self) -> None: flow.async_show_form.assert_called_once() call_kwargs = flow.async_show_form.call_args.kwargs assert call_kwargs["errors"] == {"base": "cannot_connect"} - mock_client.disconnect.assert_awaited_once() + assert mock_client.disconnect.await_count == 2 async def test_duplicate_device_aborts(self) -> None: """async_step_user with duplicate unique_id aborts with already_configured.""" diff --git a/tests/test_coordinator.py b/tests/test_coordinator.py index 2ccf06c..4981b70 100644 --- a/tests/test_coordinator.py +++ b/tests/test_coordinator.py @@ -6,8 +6,7 @@ from __future__ import annotations -import sys -from unittest.mock import AsyncMock, MagicMock, PropertyMock, patch +from unittest.mock import AsyncMock, MagicMock, PropertyMock import pytest @@ -18,8 +17,7 @@ from custom_components.narwal.coordinator import NarwalCoordinator # noqa: E402 from custom_components.narwal.narwal_client import NarwalConnectionError, NarwalState # noqa: E402 - -UpdateFailed = sys.modules["homeassistant.helpers.update_coordinator"].UpdateFailed +from homeassistant.helpers.update_coordinator import UpdateFailed # noqa: E402 class TestCoordinatorResilience: @@ -75,17 +73,16 @@ async def test_stale_data_on_consecutive_failures_below_threshold(self) -> None: assert result is coordinator.client.state assert coordinator._consecutive_failures == i + 1 - async def test_update_failed_after_max_failures(self) -> None: - """_async_update_data raises UpdateFailed after 5 consecutive failures.""" + async def test_stale_data_after_max_failures(self) -> None: + """_async_update_data raises after the failure threshold.""" coordinator = self._make_coordinator() type(coordinator.client).connected = PropertyMock(return_value=False) - # Burn through 4 failures (stale data returned) for _ in range(4): - await coordinator._async_update_data() + result = await coordinator._async_update_data() + assert result is coordinator.client.state - # 5th failure raises UpdateFailed - with pytest.raises(UpdateFailed, match="5 consecutive polls"): + with pytest.raises(UpdateFailed): await coordinator._async_update_data() assert coordinator._consecutive_failures == 5 diff --git a/tests/test_models.py b/tests/test_models.py index b707fdc..dbd0d45 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -3,6 +3,8 @@ from __future__ import annotations import struct +import time +import zlib from narwal_client.const import WorkingStatus from narwal_client.models import ( @@ -14,6 +16,33 @@ ) +def test_map_data_preserves_existing_positional_arguments() -> None: + room = RoomInfo(room_id=7) + obstacle = ObstacleInfo(id=1) + + map_data = MapData( + 10, + 20, + 5, + [room], + b"map", + 200, + 1234, + 1.5, + 2.5, + 3, + 4, + [obstacle], + {"source": "test"}, + ) + + assert map_data.width == 10 + assert map_data.height == 20 + assert map_data.rooms == [room] + assert map_data.raw == {"source": "test"} + assert map_data.map_id == 0 + + class TestNarwalState: """Tests for NarwalState data model.""" @@ -27,13 +56,27 @@ def test_default_state(self) -> None: assert not state.is_returning def test_update_from_working_status(self) -> None: - """working_status topic sets cleaning metrics, not robot state.""" + """working_status topic sets cleaning metrics and infers active cleaning.""" state = NarwalState() state.update_from_working_status({"3": 120, "13": 18000, "15": 600}) assert state.cleaning_time == 120 assert state.cleaning_area == 18000 - # working_status is NOT set by this method (comes from base_status) - assert state.working_status == WorkingStatus.UNKNOWN + assert state.working_status == WorkingStatus.CLEANING + + def test_working_status_clears_stale_station_activity(self) -> None: + """A fresh clean must override stale dock-side activity.""" + state = NarwalState( + working_status=WorkingStatus.DOCKED, + dock_sub_state=1, + station_activity=4, + ) + + state.update_from_working_status({"3": 120, "13": 18000}) + + assert state.working_status == WorkingStatus.CLEANING + assert state.station_activity == 0 + assert state.is_cleaning + assert not state.is_docked def test_update_from_base_status_cleaning(self) -> None: state = NarwalState() @@ -115,6 +158,22 @@ def test_update_from_base_status_standby_dock_activity(self) -> None: assert state.working_status == WorkingStatus.STANDBY assert state.is_docked + def test_update_from_base_status_station_activity(self) -> None: + """field3.18 means station-side work is active.""" + state = NarwalState() + state.update_from_base_status({"3": {"1": 19, "18": 4}}) + assert state.station_activity == 4 + assert state.is_station_active + assert state.is_docked + + def test_station_activity_resets_when_absent(self) -> None: + """field3.18 resets when later base status omits it.""" + state = NarwalState() + state.update_from_base_status({"3": {"1": 19, "18": 4}}) + state.update_from_base_status({"3": {"1": 1, "3": 1}}) + assert state.station_activity == 0 + assert not state.is_station_active + def test_update_from_base_status_paused(self) -> None: """Paused overlay: field 3 sub-field 2 = 1.""" state = NarwalState() @@ -142,6 +201,37 @@ def test_new_fw_field3_unknown_subfields_logged(self) -> None: state.update_from_base_status({"3": {"1": 2, "4": 99, "11": 3}}) assert state.working_status == WorkingStatus.DOCKED_V2 + def test_off_dock_fields_clear_stale_dock_subfields(self) -> None: + """Flow 2 off-dock fields override dock subfields omitted by new firmware.""" + state = NarwalState() + state.update_from_base_status( + {"3": {"1": 10, "10": 1, "12": 2}, "11": 2, "47": 3} + ) + assert state.is_docked + + state.update_from_base_status( + {"3": {"1": 3, "4": 7}, "11": 1, "47": 2} + ) + + assert state.dock_sub_state == 0 + assert state.dock_activity == 0 + assert state.is_cleaning + assert not state.is_docked + + def test_active_status_clears_stale_dock_subfields(self) -> None: + """An active status clears dock fields omitted by Flow 2 firmware.""" + state = NarwalState() + state.update_from_base_status( + {"3": {"1": 10, "10": 1, "12": 2}, "11": 2, "47": 3} + ) + + state.update_from_base_status({"3": {"1": 3, "4": 7}}) + + assert state.dock_sub_state == 0 + assert state.dock_activity == 0 + assert state.is_cleaning + assert not state.is_docked + def test_new_fw_dock_field11_gte2(self) -> None: """v01.07.23 dock_field11=3 detected as docked via >= 2 check.""" state = NarwalState() @@ -219,6 +309,50 @@ def test_update_from_download_status(self) -> None: state.update_from_download_status({"1": 2}) assert state.download_status == 2 + def test_update_from_robot_task_status(self) -> None: + """Polled and broadcast task status expose progress and room.""" + for topic in ("robot/task/status/get", "robot/task/status"): + state = NarwalState() + state.update_from_aux_status( + topic, + { + "1": 1, + "2": { + "1": 93, + "2": 694, + "6": {"1": 5, "2": 1, "3": "Landing"}, + }, + }, + ) + assert state.task_progress_percent == 93 + assert state.task_elapsed_time == 694 + assert state.current_room_id == 5 + assert state.current_room_name == "Landing" + + def test_update_from_robot_task_status_decodes_room_name(self) -> None: + """Task room names are decoded from protobuf bytes.""" + state = NarwalState() + + state.update_from_aux_status( + "robot/task/status", + {"2": {"1": 42, "6": {"1": 5, "3": b"Landing"}}}, + ) + + assert state.current_room_name == "Landing" + + def test_task_room_uses_current_map_display_name(self) -> None: + """Task telemetry uses the current user-facing map room name.""" + state = NarwalState( + map_data=MapData(rooms=[RoomInfo(room_id=6, name="Bathroom")]) + ) + + state.update_from_aux_status( + "robot/task/status", + {"2": {"1": 42, "6": {"1": 6, "3": "浴室"}}}, + ) + + assert state.current_room_name == "Bathroom" + def test_incremental_updates(self) -> None: """State should accumulate across multiple topic updates.""" state = NarwalState() @@ -238,6 +372,12 @@ def test_raw_data_preserved(self) -> None: state.update_from_base_status(raw) assert state.raw_base_status == raw + def test_aux_status_preserved(self) -> None: + state = NarwalState() + raw = {"1": {"2": 40}, "3": 120} + state.update_from_aux_status("status/robot", raw) + assert state.raw_aux_status["status/robot"] == raw + def test_battery_field2_float32_83(self) -> None: """Field 2 = 1118175232 → 83.0% battery (confirmed from monitor capture).""" state = NarwalState() @@ -295,11 +435,107 @@ def test_battery_only_update_ignores_working_status(self) -> None: assert state.working_status == WorkingStatus.DOCKED # NOT overwritten assert state.is_docked # still correct + def test_repeated_stale_working_status_does_not_refresh_activity(self) -> None: + """Unchanged working_status counters are stale, not active cleaning.""" + state = NarwalState() + state.update_from_base_status({ + "3": {"1": 4}, + "11": 1, + "47": 2, + "2": _float_to_uint32(80.0), + }) + state.update_from_working_status({"3": 120, "13": 18000}) + assert state.has_recent_active_working_status + + state.last_active_working_status_time = time.monotonic() - 20 + state.update_from_working_status({"3": 120, "13": 18000}) + + assert not state.has_recent_active_working_status + + def test_explicit_docked_status_ends_recent_activity(self) -> None: + """A terminal base status wins immediately over recent task counters.""" + state = NarwalState() + state.update_from_base_status({"3": {"1": 4}, "11": 1, "47": 2}) + state.update_from_working_status({"3": 120, "13": 18000}) + assert state.has_recent_active_working_status + + state.update_from_base_status({"3": {"1": 10}, "11": 2, "47": 3}) + + assert not state.has_recent_active_working_status + assert state.is_docked + assert not state.is_cleaning + + def test_rising_battery_infers_stale_cleaning_is_docked(self) -> None: + """A rising battery after stale cleaning telemetry means charging/docked.""" + state = NarwalState() + state.update_from_base_status({ + "3": {"1": 4}, + "11": 1, + "47": 2, + "2": _float_to_uint32(80.0), + }) + state.update_from_working_status({"3": 120, "13": 18000}) + state.last_active_working_status_time = time.monotonic() - 20 + + state.update_battery_from_base_status({"2": _float_to_uint32(81.0)}) + + assert state.inferred_docked_from_battery + assert state.is_docked + assert not state.is_cleaning + + def test_explicit_off_dock_status_clears_inferred_docked_state(self) -> None: + """Fresh off-dock fields override battery-based dock inference.""" + state = NarwalState(inferred_docked_from_battery=True) + + state.update_from_base_status({"3": {"1": 4}, "11": 1, "47": 2}) + + assert not state.inferred_docked_from_battery + assert not state.is_docked + + def test_explicit_off_dock_status_wins_over_rising_battery(self) -> None: + """Battery inference cannot override off-dock fields in the same packet.""" + state = NarwalState( + working_status=WorkingStatus.CLEANING, + battery_level=80, + ) + + state.update_from_base_status( + {"3": {"1": 4}, "11": 1, "47": 2, "2": _float_to_uint32(81.0)} + ) + + assert not state.inferred_docked_from_battery + assert not state.is_docked + + def test_dock_fields_override_stale_task_completed_status(self) -> None: + """Flow 2 can report TASK_COMPLETED while dock fields already say docked.""" + state = NarwalState() + state.update_from_base_status({ + "3": {"1": 4}, + "11": 1, + "47": 2, + "2": _float_to_uint32(80.0), + }) + state.update_from_working_status({"3": 120, "13": 18000}) + state.last_active_working_status_time = time.monotonic() - 20 + + state.update_from_base_status({ + "3": {"1": 19, "18": 4}, + "11": 2, + "47": 3, + "2": _float_to_uint32(82.0), + }) + + assert state.working_status == WorkingStatus.TASK_COMPLETED + assert state.is_docked + assert not state.is_cleaning + def test_returning_to_dock_field7(self) -> None: """Field 3.7=1 indicates returning to dock (confirmed live).""" state = NarwalState() # Live data: {1=4, 7=1, 10=2} — CLEANING + returning + docking - state.update_from_base_status({"3": {"1": 4, "7": 1, "10": 2}}) + state.update_from_base_status( + {"3": {"1": 4, "7": 1, "10": 2}, "11": 1, "47": 2} + ) assert state.working_status == WorkingStatus.CLEANING assert state.is_returning_to_dock assert state.dock_sub_state == 2 @@ -354,6 +590,17 @@ def _float_to_uint32(f: float) -> int: class TestMapData: """Tests for MapData.from_response().""" + def test_unassigned_obstacles_are_not_cleanable_floor(self) -> None: + """Whole-map estimates exclude unassigned obstacle pixels.""" + map_data = MapData( + width=2, + height=1, + resolution=10, + compressed_map=zlib.compress(bytes([0x0A, 0x02, 0x20, 0x28])), + ) + + assert map_data.cleanable_area_cm2() == 1 + def test_basic_map_parsing(self) -> None: decoded = {"2": { "3": 60, @@ -603,10 +850,13 @@ def test_flow_1_uses_default_names(self) -> None: def test_flow_2_overrides_apply(self) -> None: """Flow 2 product key renames sub-types 1, 5, 10.""" - flow2 = "QxMSPG6VSO" - assert RoomInfo(room_sub_type=1, model_key=flow2).display_name == "Master Bedroom" - assert RoomInfo(room_sub_type=5, model_key=flow2).display_name == "Bathroom" - assert RoomInfo(room_sub_type=10, model_key=flow2).display_name == "Corridor" + for flow2 in ("QxMSPG6VSO", "iSuVlI1If2"): + assert ( + RoomInfo(room_sub_type=1, model_key=flow2).display_name + == "Master Bedroom" + ) + assert RoomInfo(room_sub_type=5, model_key=flow2).display_name == "Bathroom" + assert RoomInfo(room_sub_type=10, model_key=flow2).display_name == "Corridor" def test_flow_2_non_overridden_types_use_defaults(self) -> None: """Sub-types not in the Flow 2 override map use the base names.""" diff --git a/tests/test_services.py b/tests/test_services.py new file mode 100644 index 0000000..3c56025 --- /dev/null +++ b/tests/test_services.py @@ -0,0 +1,186 @@ +"""Tests for Narwal integration services.""" + +from __future__ import annotations + +import asyncio +from types import SimpleNamespace +from unittest.mock import ANY, AsyncMock, MagicMock, patch + +import pytest +import tests.ha_stubs + +tests.ha_stubs.install() + +from custom_components.narwal import ( + FIELD_MODE, + FIELD_MOP_STRENGTH, + FIELD_PASSES, + FIELD_ROOMS, + FIELD_SUCTION, + FIELD_WATER, + _async_get_service_coordinators, + _async_register_services, + _async_validate_clean_rooms_targets, + async_setup, +) +from custom_components.narwal.const import DOMAIN, SERVICE_CLEAN_ROOMS +from custom_components.narwal.coordinator import NarwalCoordinator +from custom_components.narwal.narwal_client import CommandResult +from homeassistant.exceptions import HomeAssistantError, Unauthorized +from homeassistant.helpers import service + + +def test_clean_rooms_awaits_entity_target_extraction() -> None: + hass = MagicMock() + client = SimpleNamespace( + robot_awake=True, + state=MagicMock(), + start_rooms=AsyncMock( + return_value=SimpleNamespace(result_code=CommandResult.SUCCESS) + ), + ) + coordinator = SimpleNamespace(client=client, async_set_updated_data=MagicMock()) + call = SimpleNamespace( + context=SimpleNamespace(user_id=None), + data={ + "entity_id": ["vacuum.flow_2"], + FIELD_ROOMS: [1], + FIELD_MODE: "vacuum", + FIELD_SUCTION: "standard", + FIELD_WATER: "normal", + FIELD_MOP_STRENGTH: "normal", + FIELD_PASSES: 1, + } + ) + + _async_register_services(hass) + handler = hass.services.async_register.call_args.args[2] + + with ( + patch( + "custom_components.narwal._async_get_service_coordinators", + new=AsyncMock(return_value=[coordinator]), + ), + patch( + "custom_components.narwal._async_room_ids_for_coordinator", + new=AsyncMock(return_value=[1]), + ), + patch( + "custom_components.narwal._async_validate_clean_rooms_targets", + new=AsyncMock(return_value=["vacuum.flow_2"]), + ), + ): + asyncio.run(handler(call)) + + service.async_extract_entity_ids.assert_awaited_once_with(call) + client.start_rooms.assert_awaited_once() + hass.services.async_register.assert_called_once_with( + DOMAIN, + SERVICE_CLEAN_ROOMS, + handler, + schema=ANY, + ) + + +async def test_services_are_registered_during_integration_setup() -> None: + hass = MagicMock() + + assert await async_setup(hass, {}) is True + + hass.services.async_register.assert_called_once() + + +async def test_clean_rooms_requires_an_explicit_target() -> None: + hass = MagicMock() + hass.data = {DOMAIN: {"entry": NarwalCoordinator.__new__(NarwalCoordinator)}} + + with pytest.raises(HomeAssistantError, match="Target a Narwal vacuum"): + await _async_get_service_coordinators(hass, []) + + +async def test_clean_rooms_rejects_unauthorized_target() -> None: + hass = MagicMock() + registry = MagicMock() + registry.async_get.return_value = SimpleNamespace(platform=DOMAIN) + user = SimpleNamespace( + is_admin=False, + permissions=SimpleNamespace(check_entity=MagicMock(return_value=False)), + ) + hass.auth.async_get_user = AsyncMock(return_value=user) + call = SimpleNamespace( + context=SimpleNamespace(user_id="restricted-user"), + data={"entity_id": ["vacuum.flow_2"]}, + ) + + with ( + patch("custom_components.narwal.er.async_get", return_value=registry), + pytest.raises(Unauthorized), + ): + await _async_validate_clean_rooms_targets( + hass, call, ["vacuum.flow_2"] + ) + + user.permissions.check_entity.assert_called_once_with( + "vacuum.flow_2", "control" + ) + + +async def test_clean_rooms_rejects_non_vacuum_target() -> None: + hass = MagicMock() + registry = MagicMock() + registry.async_get.return_value = SimpleNamespace(platform=DOMAIN) + call = SimpleNamespace( + context=SimpleNamespace(user_id=None), + data={"entity_id": ["sensor.flow_2_battery"]}, + ) + + with ( + patch("custom_components.narwal.er.async_get", return_value=registry), + pytest.raises(HomeAssistantError, match="Narwal vacuum entity"), + ): + await _async_validate_clean_rooms_targets( + hass, call, ["sensor.flow_2_battery"] + ) + + +async def test_clean_rooms_filters_indirect_non_vacuum_targets() -> None: + hass = MagicMock() + registry = MagicMock() + registry.async_get.side_effect = { + "vacuum.flow_2": SimpleNamespace(platform=DOMAIN), + "sensor.flow_2_battery": SimpleNamespace(platform=DOMAIN), + }.get + call = SimpleNamespace( + context=SimpleNamespace(user_id=None), + data={"device_id": ["flow-2-device"]}, + ) + + with patch("custom_components.narwal.er.async_get", return_value=registry): + entity_ids = await _async_validate_clean_rooms_targets( + hass, + call, + ["sensor.flow_2_battery", "vacuum.flow_2"], + ) + + assert entity_ids == ["vacuum.flow_2"] + + +@pytest.mark.parametrize("direct_target", ["all", "group.vacuums"]) +async def test_clean_rooms_accepts_expanded_targets(direct_target: str) -> None: + """Expanded all and group targets validate their resolved Narwal entities.""" + hass = MagicMock() + registry = MagicMock() + registry.async_get.return_value = SimpleNamespace(platform=DOMAIN) + call = SimpleNamespace( + context=SimpleNamespace(user_id=None), + data={"entity_id": [direct_target]}, + ) + + with patch("custom_components.narwal.er.async_get", return_value=registry): + entity_ids = await _async_validate_clean_rooms_targets( + hass, + call, + ["vacuum.flow_2"], + ) + + assert entity_ids == ["vacuum.flow_2"] diff --git a/tests/test_vacuum_segments.py b/tests/test_vacuum_segments.py index 986555f..63dab5a 100644 --- a/tests/test_vacuum_segments.py +++ b/tests/test_vacuum_segments.py @@ -17,6 +17,14 @@ tests.ha_stubs.install() from narwal_client.models import MapData, NarwalState, RoomInfo # noqa: E402 +from narwal_client.const import ( # noqa: E402 + CleaningRoute, + FanLevel, + MopHumidity, + MopStrengthLevel, + WorkMode, + WorkingStatus, +) from custom_components.narwal.vacuum import NarwalVacuum # noqa: E402 # Grab Segment class from stubs for assertions @@ -51,6 +59,20 @@ def _make_vacuum(state: NarwalState | None = None) -> NarwalVacuum: return vac +class TestFanSpeed: + """Live suction controls expose only levels accepted by the robot.""" + + async def test_max_alias_reports_highest_live_level(self) -> None: + """The legacy max alias reports the level actually sent.""" + vac = _make_vacuum() + vac.coordinator.client.set_fan_speed = AsyncMock() + + await vac.async_set_fan_speed("max") + + vac.coordinator.client.set_fan_speed.assert_awaited_once() + assert vac._last_fan_speed == "Super powerful" + + class TestAsyncGetSegments: """Tests for async_get_segments.""" @@ -176,9 +198,70 @@ async def test_converts_string_ids_and_calls_start_rooms(self) -> None: vac.coordinator.client.robot_awake = True vac.coordinator.client.wake = AsyncMock() + vac.coordinator.select_options = { + "mode": "Mop", + "suction": "Strong", + "water": "Normal", + "scrub": "High", + "passes": "3", + "route": "Standard", + } + await vac.async_clean_segments(["11", "9"]) - vac.coordinator.client.start_rooms.assert_awaited_once_with([11, 9]) + vac.coordinator.client.start_rooms.assert_awaited_once_with( + [11, 9], + work_mode=WorkMode.MOP, + fan=FanLevel.STRONG, + water=MopHumidity.NORMAL, + mop_strength=MopStrengthLevel.HIGH, + passes=3, + route=CleaningRoute.STANDARD, + ) + + +class TestAsyncStart: + """Tests for starting and resuming cleans.""" + + async def test_docked_stale_pause_starts_new_clean(self) -> None: + """A stale paused status on the dock must not resume an old task.""" + state = NarwalState() + state.working_status = WorkingStatus.CLEANING + state.is_paused = True + state.dock_sub_state = 1 + vac = _make_vacuum(state=state) + vac.coordinator.client.robot_awake = True + vac.coordinator.client.start = AsyncMock( + return_value=MagicMock(result_code=1, success=True) + ) + vac.coordinator.client.resume = AsyncMock() + + await vac.async_start() + + vac.coordinator.client.start.assert_awaited_once() + vac.coordinator.client.resume.assert_not_awaited() + + +class TestVacuumActivity: + """Tests for derived vacuum activity.""" + + def test_docked_state_wins_over_stale_pause(self) -> None: + """Stale cleaning and pause fields must not hide a docked robot.""" + state = NarwalState() + state.working_status = WorkingStatus.CLEANING_FLOW2 + state.is_paused = True + state.dock_sub_state = 1 + + assert _make_vacuum(state=state).activity == "docked" + + def test_unknown_off_dock_status_reports_cleaning(self) -> None: + """New firmware states stay active until their enum is mapped.""" + state = NarwalState() + state.working_status = WorkingStatus.UNKNOWN + state.dock_field11 = 1 + state.dock_field47 = 2 + + assert _make_vacuum(state=state).activity == "cleaning" class TestCheckSegmentChanges: