diff --git a/README.md b/README.md index 9717da0..30981a3 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,7 @@ Models marked **Not Compatible** use a different protocol or are cloud-only. Thi ### Sensors - Battery level, cleaning area, cleaning time, firmware version - Docked status (binary sensor), charging state (Charging / Fully Charged / Not Charging) +- Flow 2 maintenance prompts for common components ### Live Map - Color-coded floor plan with room labels (all rooms — user-named and auto-generated) @@ -80,6 +81,7 @@ Models marked **Not Compatible** use a different protocol or are cloud-only. Thi - **Single connection** — close the Narwal app before using HA to avoid conflicts. - **Fan speed is set-only** — robot doesn't broadcast its current level. - **Default clean settings** — start and room-specific clean use max suction, wet mop, single pass. Per-room customization is not yet available. +- **Maintenance alerts are limited** — the Flow 2 base-station replaceable cleaning-filter prompt and used-hours counter are exposed; other consumable/app alerts need live protocol captures before they can be mapped safely. - **Map may be stale** — robot can return an old map. A new clean cycle typically refreshes it. ## Future Features (On Hold) diff --git a/custom_components/narwal/__init__.py b/custom_components/narwal/__init__.py index 81aeb80..8c54867 100644 --- a/custom_components/narwal/__init__.py +++ b/custom_components/narwal/__init__.py @@ -3,19 +3,371 @@ from __future__ import annotations 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, + DOCK_LIGHT_SERVICE_MODES, + DOMAIN, + PLATFORMS, + SERVICE_CLEAN_ROOMS, + SERVICE_SET_DOCK_LIGHT, + SERVICE_SET_LED, + is_dock_light_supported, +) from .coordinator import NarwalCoordinator -from .narwal_client import NarwalConnectionError +from .narwal_client import ( + CleaningRoute, + CommandResult, + FanLevel, + MopHumidity, + MopStrengthLevel, + NarwalConnectionError, + WorkMode, +) _LOGGER = logging.getLogger(__name__) -NarwalConfigEntry: TypeAlias = ConfigEntry[NarwalCoordinator] +type NarwalConfigEntry = 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" +FIELD_ON = "on" +FIELD_LED_MODE = "mode" + +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), + } +) + +SET_DOCK_LIGHT_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.Optional(FIELD_ON): cv.boolean, + vol.Optional(FIELD_LED_MODE): vol.In(DOCK_LIGHT_SERVICE_MODES), + } +) + + +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") + + await _async_validate_target_permissions(hass, call, vacuum_entity_ids) + return vacuum_entity_ids + + +async def _async_validate_target_permissions( + hass: HomeAssistant, + call, + entity_ids: list[str], +) -> None: + """Validate control permission for service target entities.""" + + user_id = call.context.user_id + if not user_id: + return + 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 + for entity_id in entity_ids: + if not user.permissions.check_entity(entity_id, POLICY_CONTROL): + raise Unauthorized( + context=call.context, + entity_id=entity_id, + permission=POLICY_CONTROL, + ) + + +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) + + async def async_set_dock_light(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, + ) + command_sent = False + for coordinator in coordinators: + if not is_dock_light_supported( + coordinator.config_entry.data, + coordinator.config_entry.options, + ): + _LOGGER.warning( + "Ignoring dock light command for unsupported device: %s", + coordinator.config_entry.title, + ) + continue + client = coordinator.client + if not client.robot_awake: + await client.wake(timeout=10.0) + if FIELD_LED_MODE in call.data: + mode_name = call.data[FIELD_LED_MODE] + mode = DOCK_LIGHT_SERVICE_MODES[mode_name] + elif FIELD_ON in call.data: + mode_name = "fireplace" if call.data[FIELD_ON] else "off" + mode = DOCK_LIGHT_SERVICE_MODES[mode_name] + else: + raise HomeAssistantError("Set either mode or on") + resp = await client.set_ambient_light_mode(mode) + command_sent = True + if resp is None: + raise HomeAssistantError("Narwal dock light command failed") + try: + result_name = CommandResult(resp.result_code).name + except ValueError: + result_name = f"UNKNOWN({resp.result_code})" + _LOGGER.info( + "Set dock light response: %s (code=%s), mode=%s", + result_name, + resp.result_code, + mode_name, + ) + if resp.result_code not in ( + 0, + CommandResult.SUCCESS, + CommandResult.APPLIED, + ): + raise HomeAssistantError( + f"Narwal dock light command failed: {result_name} " + f"({resp.result_code})" + ) + await coordinator.async_request_refresh() + if not command_sent: + raise HomeAssistantError("Target does not support the Narwal dock light") + + hass.services.async_register( + DOMAIN, + SERVICE_CLEAN_ROOMS, + async_clean_rooms, + schema=CLEAN_ROOMS_SCHEMA, + ) + hass.services.async_register( + DOMAIN, + SERVICE_SET_DOCK_LIGHT, + async_set_dock_light, + schema=SET_DOCK_LIGHT_SCHEMA, + ) + hass.services.async_register( + DOMAIN, + SERVICE_SET_LED, + async_set_dock_light, + schema=SET_DOCK_LIGHT_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: @@ -31,7 +383,9 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> if CONF_MODEL not in new_data: new_data[CONF_MODEL] = "Narwal Flow" hass.config_entries.async_update_entry( - config_entry, data=new_data, version=2, + config_entry, + data=new_data, + version=2, ) _LOGGER.info("Migration complete: product_key=%s", new_data[CONF_PRODUCT_KEY]) return True @@ -48,6 +402,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 +416,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/binary_sensor.py b/custom_components/narwal/binary_sensor.py index aa46ee7..7eda688 100644 --- a/custom_components/narwal/binary_sensor.py +++ b/custom_components/narwal/binary_sensor.py @@ -2,16 +2,44 @@ from __future__ import annotations +from collections.abc import Callable +from dataclasses import dataclass + from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, BinarySensorEntity, + BinarySensorEntityDescription, ) +from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import NarwalConfigEntry +from .const import is_maintenance_alerts_supported from .coordinator import NarwalCoordinator from .entity import NarwalEntity +from .narwal_client import MAINTENANCE_COMPONENT_IDS, NarwalState + + +@dataclass(frozen=True, kw_only=True) +class NarwalProblemBinarySensorEntityDescription(BinarySensorEntityDescription): + """Describes a Narwal problem binary sensor.""" + + is_on_fn: Callable[[NarwalState], bool] + + +PROBLEM_DESCRIPTIONS: tuple[NarwalProblemBinarySensorEntityDescription, ...] = ( + *( + NarwalProblemBinarySensorEntityDescription( + key=key, + translation_key=key, + device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, + is_on_fn=lambda state, alert_key=key: alert_key in state.maintenance_alerts, + ) + for key in MAINTENANCE_COMPONENT_IDS + ), +) async def async_setup_entry( @@ -21,9 +49,16 @@ async def async_setup_entry( ) -> None: """Set up Narwal binary sensor entities.""" coordinator = entry.runtime_data - async_add_entities([ - NarwalDockedSensor(coordinator), - ]) + entities: list[BinarySensorEntity] = [NarwalDockedSensor(coordinator)] + device_info = coordinator.client.state.device_info + if is_maintenance_alerts_supported( + entry.data, device_info.product_key if device_info else None + ): + entities.extend( + NarwalProblemBinarySensor(coordinator, description) + for description in PROBLEM_DESCRIPTIONS + ) + async_add_entities(entities) class NarwalDockedSensor(NarwalEntity, BinarySensorEntity): @@ -46,3 +81,26 @@ def is_on(self) -> bool | None: return state.is_docked +class NarwalProblemBinarySensor(NarwalEntity, BinarySensorEntity): + """Binary sensor for a Narwal maintenance problem.""" + + entity_description: NarwalProblemBinarySensorEntityDescription + + def __init__( + self, + coordinator: NarwalCoordinator, + description: NarwalProblemBinarySensorEntityDescription, + ) -> None: + """Initialize the maintenance problem sensor.""" + super().__init__(coordinator) + self.entity_description = description + device_id = coordinator.config_entry.data["device_id"] + self._attr_unique_id = f"{device_id}_{description.key}" + + @property + def is_on(self) -> bool | None: + """Return True when the maintenance problem is active.""" + state = self.coordinator.data + if state is None: + return None + return self.entity_description.is_on_fn(state) 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..c2a27c6 100644 --- a/custom_components/narwal/camera.py +++ b/custom_components/narwal/camera.py @@ -12,9 +12,19 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import NarwalConfigEntry +from .const import ( + CONF_SHOW_FURNITURE, + CONF_SHOW_FURNITURE_LABELS, + CONF_SHOW_ROOM_LABELS, + CONF_MAP_ROTATION, + CONF_MAP_ZOOM, + MAP_OPTION_DEFAULTS, + MAP_ROTATION_DEFAULT, + MAP_ZOOM_DEFAULT, +) 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__) @@ -25,6 +35,9 @@ # Trail recording (used in both debug and normal modes) _TRAIL_MAX_POINTS = 50000 # full cleaning session worth _TRAIL_RECORD_INTERVAL = 3 # seconds between trail point recordings +_TRAIL_MIN_GRID_DELTA = 0.25 +_TRAIL_MAX_GRID_JUMP_FRACTION = 0.18 +_TRAIL_MAX_GRID_JUMP_MIN = 24.0 # Debug view: blank canvas with robot dot + trail. # Set to False to use the real map renderer instead. @@ -64,6 +77,12 @@ def __init__(self, coordinator: NarwalCoordinator) -> None: # Cached base map (PIL Image) — only re-rendered when static map changes self._base_map_image = None # PIL Image or None self._base_map_ts: int = 0 # created_at of the static map used for base + self._base_map_options_key: tuple[bool, bool, bool] = ( + MAP_OPTION_DEFAULTS[CONF_SHOW_ROOM_LABELS], + MAP_OPTION_DEFAULTS[CONF_SHOW_FURNITURE], + MAP_OPTION_DEFAULTS[CONF_SHOW_FURNITURE_LABELS], + ) + self._room_label_points: list[tuple[str, float, float]] = [] # Trail state — accumulated grid-coordinate positions during cleaning self._trail: list[tuple[float, float]] = [] self._last_trail_record: float = 0.0 @@ -76,6 +95,42 @@ def __init__(self, coordinator: NarwalCoordinator) -> None: self._vp_max_y: float = 0.0 self._vp_initialized: bool = False + def _map_option(self, key: str) -> bool: + """Return a persisted map display option.""" + return bool( + self.coordinator.config_entry.options.get( + key, + MAP_OPTION_DEFAULTS[key], + ) + ) + + def _map_rotation(self) -> int: + """Return persisted clockwise map rotation in degrees.""" + try: + rotation = int( + self.coordinator.config_entry.options.get( + CONF_MAP_ROTATION, + MAP_ROTATION_DEFAULT, + ) + ) + except (TypeError, ValueError): + return MAP_ROTATION_DEFAULT + rotation %= 360 + return rotation if rotation in (0, 90, 180, 270) else MAP_ROTATION_DEFAULT + + def _map_zoom(self) -> float: + """Return persisted map zoom factor.""" + try: + zoom = float( + self.coordinator.config_entry.options.get( + CONF_MAP_ZOOM, + MAP_ZOOM_DEFAULT, + ) + ) + except (TypeError, ValueError): + return MAP_ZOOM_DEFAULT + return max(1.0, min(2.0, zoom)) + async def async_camera_image( self, width: int | None = None, height: int | None = None, ) -> bytes | None: @@ -138,11 +193,44 @@ def _reset_trail(self) -> None: self._trail.clear() self._last_trail_record = 0.0 - def _record_trail_position(self, grid_x: float, grid_y: float) -> None: + def _record_trail_position( + self, + grid_x: float, + grid_y: float, + map_width: int, + map_height: int, + ) -> None: """Record a grid-coordinate position to the cleaning trail.""" + if not math.isfinite(grid_x) or not math.isfinite(grid_y): + return + if grid_x < 0 or grid_y < 0 or grid_x >= map_width or grid_y >= map_height: + _LOGGER.debug( + "Skipping Narwal trail point outside map bounds: %.1f, %.1f (%dx%d)", + grid_x, + grid_y, + map_width, + map_height, + ) + return + now = time.monotonic() if now - self._last_trail_record >= _TRAIL_RECORD_INTERVAL: if len(self._trail) < _TRAIL_MAX_POINTS: + if self._trail: + last_x, last_y = self._trail[-1] + distance = math.hypot(grid_x - last_x, grid_y - last_y) + if distance < _TRAIL_MIN_GRID_DELTA: + self._last_trail_record = now + return + max_jump = max( + _TRAIL_MAX_GRID_JUMP_MIN, + min(map_width, map_height) * _TRAIL_MAX_GRID_JUMP_FRACTION, + ) + if distance > max_jump: + _LOGGER.debug( + "Splitting Narwal trail at large jump: %.1f grid px", + distance, + ) self._trail.append((grid_x, grid_y)) self._last_trail_record = now @@ -154,17 +242,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): @@ -181,29 +269,64 @@ def _handle_coordinator_update(self) -> None: self.async_write_ha_state() return - # Record trail in grid coordinates - if display and not (display.robot_x == 0.0 and display.robot_y == 0.0): + # Record trail in grid coordinates only while actively cleaning. + if ( + is_cleaning + and display + and not (display.robot_x == 0.0 and display.robot_y == 0.0) + ): grid_pos = display.to_grid_coords( static_map.resolution, static_map.origin_x, static_map.origin_y, ) if grid_pos is not None: - self._record_trail_position(grid_pos[0], grid_pos[1]) + self._record_trail_position( + grid_pos[0], + grid_pos[1], + static_map.width, + static_map.height, + ) static_ts = static_map.created_at or 0 trail_len = len(self._trail) + map_options_key = ( + self._map_option(CONF_SHOW_ROOM_LABELS), + self._map_option(CONF_SHOW_FURNITURE), + self._map_option(CONF_SHOW_FURNITURE_LABELS), + ) + view_options_key = (self._map_rotation(), self._map_zoom()) if display: - new_key = (static_ts, display.robot_x, display.robot_y, display.robot_heading, trail_len) + new_key = ( + static_ts, + map_options_key, + view_options_key, + display.robot_x, + display.robot_y, + display.robot_heading, + trail_len, + ) else: - new_key = (static_ts,) + new_key = (static_ts, map_options_key, view_options_key) now = time.monotonic() since_render = now - self._last_render_time if self._last_render_time else 999 + options_changed = ( + not _DEBUG_VIEW + and bool(self._cache_key) + and ( + self._cache_key[1] != map_options_key + or self._cache_key[2] != view_options_key + ) + ) if new_key == self._cache_key and self._cached_image: self.async_write_ha_state() return - if self._cached_image and since_render < _MIN_RENDER_INTERVAL: + if ( + self._cached_image + and since_render < _MIN_RENDER_INTERVAL + and not options_changed + ): self.async_write_ha_state() return @@ -247,31 +370,56 @@ async def _async_render(self, display, new_key) -> None: self.async_write_ha_state() return - from .narwal_client.map_renderer import render_base_map, render_overlay + from .narwal_client.map_renderer import ( + render_base_map, + render_overlay, + room_label_points, + ) # Rebuild base map only when static map data changes static_ts = static_map.created_at or 0 - if self._base_map_image is None or static_ts != self._base_map_ts: + map_options_key = ( + self._map_option(CONF_SHOW_ROOM_LABELS), + self._map_option(CONF_SHOW_FURNITURE), + self._map_option(CONF_SHOW_FURNITURE_LABELS), + ) + if ( + self._base_map_image is None + or static_ts != self._base_map_ts + or map_options_key != self._base_map_options_key + ): room_names: dict[int, str] | None = None - if static_map.rooms: + if map_options_key[0] and static_map.rooms: room_names = { r.room_id: r.display_name for r in static_map.rooms } + obstacles = static_map.obstacles if map_options_key[1] else None base_img = await self.hass.async_add_executor_job( render_base_map, static_map.compressed_map, static_map.width, static_map.height, - static_map.dock_x, - static_map.dock_y, + None, + None, room_names, - static_map.obstacles, + obstacles, static_map.origin_x, static_map.origin_y, + map_options_key[2], + False, + False, ) if base_img: self._base_map_image = base_img self._base_map_ts = static_ts + self._base_map_options_key = map_options_key + self._room_label_points = await self.hass.async_add_executor_job( + room_label_points, + static_map.compressed_map, + static_map.width, + static_map.height, + room_names, + ) _LOGGER.info("Base map rendered (ts=%d, %dx%d)", static_ts, static_map.width, static_map.height) else: self.async_write_ha_state() @@ -337,6 +485,11 @@ async def _async_render(self, display, new_key) -> None: robot_y, robot_heading, trail, + self._map_rotation(), + self._map_zoom(), + self._room_label_points, + static_map.dock_x, + static_map.dock_y, ) if png_bytes: 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..ee9a533 100644 --- a/custom_components/narwal/const.py +++ b/custom_components/narwal/const.py @@ -2,7 +2,7 @@ from homeassistant.const import Platform -from .narwal_client import FanLevel +from .narwal_client import AmbientLightCtrlType, FanLevel DOMAIN = "narwal" DEFAULT_PORT = 9002 @@ -27,15 +27,82 @@ PLATFORMS: list[Platform] = [ Platform.VACUUM, Platform.SENSOR, + Platform.SELECT, Platform.BINARY_SENSOR, Platform.CAMERA, + Platform.SWITCH, + Platform.BUTTON, + Platform.LIGHT, ] -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" +CONF_DOCK_LIGHT_SUPPORTED = "dock_light_supported" +SERVICE_CLEAN_ROOMS = "clean_rooms" +SERVICE_SET_DOCK_LIGHT = "set_dock_light" +SERVICE_SET_LED = "set_led" + +FLOW_2_PRODUCT_KEYS = frozenset({"QxMSPG6VSO", "iSuVlI1If2"}) +DOCK_LIGHT_PRODUCT_KEYS = FLOW_2_PRODUCT_KEYS + + +def is_dock_light_supported(data: dict, options: dict | None = None) -> bool: + """Return whether this configured model exposes dock ambient lighting.""" + if options and CONF_DOCK_LIGHT_SUPPORTED in options: + return bool(options[CONF_DOCK_LIGHT_SUPPORTED]) + return data.get(CONF_PRODUCT_KEY) in DOCK_LIGHT_PRODUCT_KEYS + + +def is_maintenance_alerts_supported( + data: dict, discovered_product_key: str | None = None +) -> bool: + """Return whether this configured model reports maintenance alerts.""" + return ( + data.get(CONF_PRODUCT_KEY) in FLOW_2_PRODUCT_KEYS + or discovered_product_key in FLOW_2_PRODUCT_KEYS + ) + + +DOCK_LIGHT_MODES: dict[str, AmbientLightCtrlType] = { + "Off": AmbientLightCtrlType.OFF, + "Fireplace": AmbientLightCtrlType.WINTER_WARMTH, + "Nightlight": AmbientLightCtrlType.NIGHT_LIGHT, + "Purple": AmbientLightCtrlType.PURPLE_LIGHT, +} +DOCK_LIGHT_SERVICE_MODES: dict[str, AmbientLightCtrlType] = { + key.lower(): value for key, value in DOCK_LIGHT_MODES.items() +} +DOCK_LIGHT_MODE_NAMES: dict[int, str] = { + int(value): key for key, value in DOCK_LIGHT_MODES.items() +} + +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..c5ce8e3 100644 --- a/custom_components/narwal/coordinator.py +++ b/custom_components/narwal/coordinator.py @@ -12,9 +12,9 @@ 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 +from .const import DOMAIN, is_maintenance_alerts_supported _LOGGER = logging.getLogger(__name__) @@ -23,6 +23,31 @@ # Fast re-poll when state is incomplete (robot asleep at startup) FAST_POLL_INTERVAL = timedelta(seconds=10) FAST_POLL_MAX = 6 # up to 60s of fast polling before falling back to normal +ACTIVE_TASK_REFRESH_INTERVAL = 10.0 +MAINTENANCE_REFRESH_INTERVAL_SECONDS = 900.0 + + +def _response_has_active_task(response: object, *, task_active: bool = False) -> bool: + """Return whether a task-status response reports an active clean.""" + data = getattr(response, "data", None) + if not isinstance(data, dict): + return False + payload = data.get("2") + if not isinstance(payload, dict): + return False + try: + progress = int(payload.get("1")) + except (TypeError, ValueError): + return False + return 0 < progress < 100 or (progress == 0 and task_active) + + +def _refresh_active_task_marker(client: NarwalClient) -> None: + """Keep state and stale-base suppression on the same active timestamp.""" + client.state.refresh_active_cleaning() + client._last_active_working_status_time = ( + client.state.last_active_working_status_time + ) class NarwalCoordinator(DataUpdateCoordinator[NarwalState]): @@ -59,8 +84,12 @@ def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: self._prev_working_status = WorkingStatus.UNKNOWN self._map_fetch_pending = False self._last_display_map_resub: float = 0.0 + self._last_status_resub: float = 0.0 + self._last_task_details_refresh: float = 0.0 + self._last_maintenance_refresh: 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. @@ -70,7 +99,9 @@ async def async_setup(self) -> None: try/except so setup never crashes if the robot is asleep. The listener's keepalive loop handles waking independently. """ + _LOGGER.debug("Narwal setup start for %s", self.config_entry.title) await self.client.connect() + _LOGGER.debug("Narwal connected for %s", self.config_entry.title) # Fetch initial state BEFORE starting listener (no concurrent recv) try: @@ -80,9 +111,18 @@ async def async_setup(self) -> None: try: await self.client.get_status(full_update=True) + if self.client.state.is_docked: + task_status = await self.client.get_robot_task_status() + if not _response_has_active_task( + task_status, task_active=self.client.state.task_active + ): + await asyncio.sleep(0.25) + await self.client.get_robot_task_status() except Exception: _LOGGER.debug("Could not fetch initial status") + await self._refresh_maintenance_details(force=True) + try: await self.client.get_map() except Exception: @@ -99,11 +139,7 @@ async def async_setup(self) -> None: # Set up push callback and start persistent listener self.client.on_state_update = self._on_state_update - self._listen_task = self.config_entry.async_create_background_task( - self.hass, - self.client.start_listening(), - f"{DOMAIN}_ws_listener", - ) + self._ensure_listener_running() state = self.client.state _LOGGER.info( @@ -144,8 +180,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,12 +189,21 @@ 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 = ( + not state.is_station_active + and ( + state.is_cleaning + or ( + not state.is_docked + and state.working_status in ACTIVE_CLEANING_STATUSES + ) + or state.has_recent_active_working_status + ) ) if is_cleaning: display_age = self.client.last_display_map_age now = time.monotonic() + status_recovery_scheduled = False if ( display_age > 30.0 and now - self._last_display_map_resub > 45.0 @@ -174,6 +218,36 @@ def _on_state_update(self, state: NarwalState) -> None: self._resub_topics(), f"{DOMAIN}_resub", ) + if now - self._last_status_resub > 30.0: + _LOGGER.info("Refreshing active clean base status") + self._last_status_resub = now + self._last_task_details_refresh = now + self.config_entry.async_create_background_task( + self.hass, + self._recover_status_broadcasts(), + f"{DOMAIN}_status_recover", + ) + status_recovery_scheduled = True + if ( + not status_recovery_scheduled + and now - getattr(self, "_last_task_details_refresh", 0.0) + > ACTIVE_TASK_REFRESH_INTERVAL + ): + self._last_task_details_refresh = now + self.config_entry.async_create_background_task( + self.hass, + self._refresh_task_details(cleaning=True), + f"{DOMAIN}_task_details", + ) + elif state.is_station_active: + now = time.monotonic() + if now - getattr(self, "_last_task_details_refresh", 0.0) > 30.0: + self._last_task_details_refresh = now + self.config_entry.async_create_background_task( + self.hass, + self._refresh_task_details(cleaning=False), + f"{DOMAIN}_task_details", + ) self.async_set_updated_data(state) @@ -186,6 +260,17 @@ def _on_state_update(self, state: NarwalState) -> None: state.working_status.name, ) + def _ensure_listener_running(self) -> None: + """Start the WebSocket listener if it is not already active.""" + if self._listen_task is not None and not self._listen_task.done(): + return + self.client.on_state_update = self._on_state_update + self._listen_task = self.config_entry.async_create_background_task( + self.hass, + self.client.start_listening(), + f"{DOMAIN}_ws_listener", + ) + async def _fetch_missing_map(self) -> None: """Fetch static map when it's missing (get_map failed at startup).""" try: @@ -208,28 +293,87 @@ async def _resub_topics(self) -> None: except Exception: _LOGGER.debug("Topic re-subscription failed") + async def _recover_status_broadcasts(self) -> None: + """Recover missing status/base broadcasts while display_map still flows.""" + try: + await self.client.subscribe_to_topics() + await self.client.get_clean_progress_info() + task_status = await self.client.get_robot_task_status() + except Exception: + _LOGGER.debug("Status broadcast recovery failed") + return + if _response_has_active_task( + task_status, task_active=self.client.state.task_active + ): + _refresh_active_task_marker(self.client) + self.async_set_updated_data(self.client.state) + + async def _refresh_task_details(self, *, cleaning: bool) -> None: + """Query app-style task detail endpoints while a task is active.""" + try: + if cleaning: + await self.client.get_clean_progress_info() + else: + await self.client.get_dry_mop_remain_time() + task_status = await self.client.get_robot_task_status() + except Exception as err: + _LOGGER.debug("Task detail refresh failed: %s", err) + return + if cleaning and _response_has_active_task( + task_status, task_active=self.client.state.task_active + ): + _refresh_active_task_marker(self.client) + self.async_set_updated_data(self.client.state) + async def _refresh_dock_status(self) -> None: """Immediate get_status() after return-to-dock to refresh dock fields.""" try: await self.client.get_status(full_update=True) + if self.client.state.is_docked: + await self.client.get_robot_task_status() self.async_set_updated_data(self.client.state) except Exception: _LOGGER.debug("Failed to refresh dock status after transition") + async def _refresh_maintenance_details(self, *, force: bool = False) -> None: + """Refresh maintenance counters exposed by the task-detail endpoint.""" + device_info = self.client.state.device_info + if not is_maintenance_alerts_supported( + self.config_entry.data, + device_info.product_key if device_info else None, + ): + return + now = time.monotonic() + if ( + not force + and self._last_maintenance_refresh > 0 + and now - self._last_maintenance_refresh + < MAINTENANCE_REFRESH_INTERVAL_SECONDS + ): + return + try: + await self.client.get_clean_progress_info() + except Exception as err: + _LOGGER.debug("Maintenance detail refresh failed: %s", err) + return + self._last_maintenance_refresh = now + async def _async_update_data(self) -> NarwalState: """Polling fallback — fetch status if no push updates arrived. - Reconnection is handled by the listener loop's exponential backoff. - We do NOT call client.connect() here to avoid racing with the listener - and violating the single-WS-connection-per-IP constraint. - - On poll failure, returns stale data for up to _max_failures consecutive - failures (~5 minutes) before raising UpdateFailed. + Reconnection is handled by the listener loop's exponential backoff. If + that listener has exited, restart it. Keep the last known data briefly, + then mark the coordinator unavailable after the failure threshold. """ try: if not self.client.connected: + self._ensure_listener_running() raise NarwalConnectionError("Not connected") - await self.client.get_status(full_update=True) + await self.client.get_status( + full_update=not self.client.state.has_recent_active_working_status + ) + if self.client.state.is_docked: + await self.client.get_robot_task_status() except Exception as err: self._consecutive_failures += 1 if self._consecutive_failures >= self._max_failures: @@ -238,12 +382,16 @@ async def _async_update_data(self) -> NarwalState: ) from err _LOGGER.debug( "Poll %d/%d failed (robot may be asleep): %s", - self._consecutive_failures, self._max_failures, err, + self._consecutive_failures, + self._max_failures, + err, ) - return self.client.state # stale data keeps entities available + return self.client.state else: self._consecutive_failures = 0 + await self._refresh_maintenance_details() + # Retry map fetch if it failed during setup if self.client.state.map_data is None: try: diff --git a/custom_components/narwal/light.py b/custom_components/narwal/light.py new file mode 100644 index 0000000..40c002f --- /dev/null +++ b/custom_components/narwal/light.py @@ -0,0 +1,143 @@ +"""Light entities for Narwal dock lighting.""" + +from __future__ import annotations + +from typing import Any + +from homeassistant.components.light import ( + ATTR_EFFECT, + ColorMode, + LightEntity, + LightEntityFeature, +) +from homeassistant.components.light import ( + DOMAIN as LIGHT_DOMAIN, +) +from homeassistant.components.select import DOMAIN as SELECT_DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import NarwalConfigEntry +from .const import ( + DOCK_LIGHT_MODE_NAMES, + DOCK_LIGHT_MODES, + DOMAIN, + is_dock_light_supported, +) +from .coordinator import NarwalCoordinator +from .entity import NarwalEntity +from .narwal_client import CommandResult + +_EFFECTS = tuple(mode for mode in DOCK_LIGHT_MODES if mode != "Off") +_DEFAULT_EFFECT = "Nightlight" + + +async def async_setup_entry( + hass: HomeAssistant, + entry: NarwalConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Narwal dock light entities.""" + _cleanup_dock_light_registry(hass, entry) + if not is_dock_light_supported(entry.data, entry.options): + return + async_add_entities([NarwalDockLight(entry.runtime_data)]) + + +def _cleanup_dock_light_registry(hass: HomeAssistant, entry: NarwalConfigEntry) -> None: + """Remove stale dock light entities from older development builds.""" + device_id = entry.data["device_id"] + registry = er.async_get(hass) + stale_unique_ids = { + SELECT_DOMAIN: f"{device_id}_dock_light", + LIGHT_DOMAIN: f"{device_id}_dock_light_light", + } + for registry_entry in list(registry.entities.values()): + if registry_entry.platform != DOMAIN: + continue + for domain, unique_id in stale_unique_ids.items(): + if ( + registry_entry.entity_id.startswith(f"{domain}.") + and registry_entry.unique_id == unique_id + ): + registry.async_remove(registry_entry.entity_id) + + +class NarwalDockLight(NarwalEntity, LightEntity): + """Narwal dock ambient light with app effects.""" + + _attr_translation_key = "dock_light" + _attr_icon = "mdi:led-strip-variant" + _attr_supported_features = LightEntityFeature.EFFECT + _attr_supported_color_modes = {ColorMode.ONOFF} + _attr_color_mode = ColorMode.ONOFF + _attr_effect_list = list(_EFFECTS) + + def __init__(self, coordinator: NarwalCoordinator) -> None: + """Initialize the dock light.""" + super().__init__(coordinator) + device_id = coordinator.config_entry.data["device_id"] + self._attr_unique_id = f"{device_id}_dock_light" + self._last_effect = _DEFAULT_EFFECT + + @property + def is_on(self) -> bool | None: + """Return True when the dock light is on.""" + mode = self._current_mode + if mode is None: + return None + return mode != "Off" + + @property + def effect(self) -> str | None: + """Return the active dock light effect.""" + mode = self._current_mode + if mode is None or mode == "Off": + return None + self._last_effect = mode + return mode + + @property + def _current_mode(self) -> str | None: + """Return the current dock light mode name.""" + state = self.coordinator.data + if state is None: + return None + if state.dock_light_mode is None: + return None + return DOCK_LIGHT_MODE_NAMES.get(state.dock_light_mode) + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the dock light on with the requested effect.""" + effect = kwargs.get(ATTR_EFFECT) + if effect is None: + effect = self.effect or self._last_effect + if effect not in _EFFECTS: + raise HomeAssistantError(f"Unsupported Narwal dock light effect: {effect}") + await self._set_mode(effect) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the dock light off.""" + await self._set_mode("Off") + + async def _set_mode(self, mode: str) -> None: + """Send the dock light command.""" + client = self.coordinator.client + if not client.robot_awake: + await client.wake(timeout=10.0) + response = await client.set_ambient_light_mode(DOCK_LIGHT_MODES[mode]) + if response is None: + raise HomeAssistantError("Narwal dock light command failed") + if response.result_code not in (0, CommandResult.SUCCESS, CommandResult.APPLIED): + try: + result_name = CommandResult(response.result_code).name + except ValueError: + result_name = f"UNKNOWN({response.result_code})" + raise HomeAssistantError(f"Narwal dock light command failed: {result_name}") + + if mode != "Off": + self._last_effect = mode + await self.coordinator.async_request_refresh() + self.async_write_ha_state() diff --git a/custom_components/narwal/narwal_client/__init__.py b/custom_components/narwal/narwal_client/__init__.py index 8eb1f06..52e22a9 100644 --- a/custom_components/narwal/narwal_client/__init__.py +++ b/custom_components/narwal/narwal_client/__init__.py @@ -1,8 +1,27 @@ """Narwal robot vacuum client library — local WebSocket API.""" from .client import NarwalClient, NarwalCommandError, NarwalConnectionError -from .const import CommandResult, FanLevel, MopHumidity, WorkingStatus -from .models import CommandResponse, DeviceInfo, MapData, MapDisplayData, NarwalState, RoomInfo +from .const import ( + AmbientLightCtrlType, + CleaningRoute, + CommandResult, + FanLevel, + MopHumidity, + MopStrengthLevel, + WorkMode, + WorkingStatus, +) +from .models import ( + MAINTENANCE_BASE_STATION_CLEANING_FILTER, + MAINTENANCE_BASE_STATION_CLEANING_FILTER_COMPONENT, + MAINTENANCE_COMPONENT_IDS, + CommandResponse, + DeviceInfo, + MapData, + MapDisplayData, + NarwalState, + RoomInfo, +) from .protocol import build_frame, parse_frame __all__ = [ @@ -10,14 +29,21 @@ "NarwalCommandError", "NarwalConnectionError", "NarwalState", + "MAINTENANCE_BASE_STATION_CLEANING_FILTER", + "MAINTENANCE_BASE_STATION_CLEANING_FILTER_COMPONENT", + "MAINTENANCE_COMPONENT_IDS", "CommandResponse", + "AmbientLightCtrlType", "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..21554cc 100644 --- a/custom_components/narwal/narwal_client/client.py +++ b/custom_components/narwal/narwal_client/client.py @@ -16,42 +16,62 @@ 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_AMBIENT_LIGHT_CTRL, 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, + AmbientLightCtrlType, + CleaningRoute, CommandResult, FanLevel, MopHumidity, + MopStrengthLevel, + WorkingStatus, + WorkMode, ) from .models import CommandResponse, DeviceInfo, MapData, MapDisplayData, NarwalState from .protocol import ( @@ -64,6 +84,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 +216,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 +254,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 +443,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 +524,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 +603,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 +623,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 +684,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 +894,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 +911,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 +1063,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" @@ -902,6 +1116,19 @@ async def locate(self) -> CommandResponse: # start() falls back to the v2 room-list schema. See issue #36. _DEFAULT_CLEAN_PAYLOAD = bytes.fromhex("0a0e12002a0a0a060803100218012a00") + def _apply_clean_start_response( + self, response: CommandResponse + ) -> CommandResponse: + """Normalize accepted clean responses and mark the task active.""" + if response.result_code == 0 and "1" in response.data: + response.result_code = CommandResult.SUCCESS + if response.success: + self.state.mark_active_cleaning() + self._last_active_working_status_time = ( + self.state.last_active_working_status_time + ) + return response + async def start(self, **kwargs) -> CommandResponse: """Start whole-house cleaning. @@ -915,12 +1142,12 @@ 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, ) if resp.result_code != CommandResult.NOT_APPLICABLE: - return resp + return self._apply_clean_start_response(resp) # Legacy payload rejected — likely newer firmware. Need the room # list from the cached map to build a v2 payload. @@ -929,23 +1156,24 @@ async def start(self, **kwargs) -> CommandResponse: "start() got NOT_APPLICABLE and no map rooms cached; " "cannot build v2 payload. Call get_map() first." ) - return resp + return self._apply_clean_start_response(resp) room_ids = [r.room_id for r in self.state.map_data.rooms if r.room_id] if not room_ids: _LOGGER.warning( "start() got NOT_APPLICABLE but cached map has 0 rooms with IDs" ) - return resp + return self._apply_clean_start_response(resp) _LOGGER.info( "start(): legacy payload rejected, retrying with v2 schema (%d rooms)", 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 self._apply_clean_start_response(resp) def _build_clean_payload_v2( self, @@ -958,9 +1186,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 +1270,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 +1383,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,54 +1405,144 @@ 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, - ) - if resp.result_code != CommandResult.NOT_APPLICABLE: - 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), - ) - payload_legacy = self._build_room_clean_payload(room_ids) - return await self.send_command( - TOPIC_CMD_START_CLEAN, payload=payload_legacy, 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 + and supports_legacy_room_clean + ): + _LOGGER.info( + "start_rooms: clean/start_clean rejected, trying " + "clean/plan/start compatibility payloads" + ) + 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: + resp = await self.send_command( + TOPIC_CMD_PLAN_START, + payload=self._build_room_clean_payload(room_ids), + timeout=10.0, + ) + return self._apply_clean_start_response(resp) async def start_easy_clean(self) -> CommandResponse: """Start quick/easy clean.""" @@ -1196,27 +1573,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 +1633,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 +1690,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 +1724,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) @@ -1320,18 +1776,50 @@ async def take_picture(self) -> bytes | None: _LOGGER.warning("take_picture returned result_code=%d", resp.result_code) return None - async def set_led(self, on: bool) -> None: - """Turn the camera LED fill light on or off. + async def set_led(self, on: bool) -> CommandResponse | None: + """Turn the camera LED fill light on or off.""" + return await self.set_led_mode(1 if on else 0) + + async def set_led_mode(self, mode: int) -> CommandResponse | None: + """Set the camera LED fill mode. - Payload: 0x08 0x01 = on, 0x08 0x00 = off (protobuf field 1, varint). + Payload: protobuf field 1, varint. """ - payload = b"\x08\x01" if on else b"\x08\x00" + payload = b"\x08" + bytes([mode & 0x7F]) try: resp = await self.send_command(TOPIC_CMD_SET_LED, payload=payload) except Exception: - _LOGGER.warning("set_led(%s) command failed", on) - return + _LOGGER.warning("set_led_mode(%s) command failed", mode) + return None if resp.result_code not in (CommandResult.SUCCESS, CommandResult.NOT_APPLICABLE): _LOGGER.warning( - "set_led(%s) unexpected result_code=%d", on, resp.result_code + "set_led_mode(%s) unexpected result_code=%d", mode, resp.result_code ) + return resp + + async def set_ambient_light_mode( + self, mode: AmbientLightCtrlType | int + ) -> CommandResponse | None: + """Set the base station ambient light mode.""" + mode = AmbientLightCtrlType(mode) + payload = self._encode_varint_field(1, int(mode)) + try: + resp = await self.send_command( + TOPIC_CMD_AMBIENT_LIGHT_CTRL, + payload=payload, + ) + except Exception: + _LOGGER.warning("set_ambient_light_mode(%s) command failed", mode) + return None + if resp.result_code not in ( + 0, + CommandResult.SUCCESS, + CommandResult.NOT_APPLICABLE, + CommandResult.APPLIED, + ): + _LOGGER.warning( + "set_ambient_light_mode(%s) unexpected result_code=%d", + mode, + resp.result_code, + ) + return resp diff --git a/custom_components/narwal/narwal_client/const.py b/custom_components/narwal/narwal_client/const.py index 0c877c7..2da3471 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 @@ -76,18 +83,26 @@ TOPIC_CMD_CANCEL = "task/cancel" # Supply/dock +TOPIC_CMD_AMBIENT_LIGHT_CTRL = "supply/ambient_light_ctrl" 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 +156,17 @@ 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) + APPLIED = 6 # observed on ambient light commands after the dock light changes + + +class AmbientLightCtrlType(IntEnum): + """Base station ambient light mode.""" + + OFF = 0 + NIGHT_LIGHT = 1 + WINTER_WARMTH = 2 + PURPLE_LIGHT = 3 class WorkingStatus(IntEnum): @@ -149,8 +175,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 +196,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 +208,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 - DRY = 0 + +class MopStrengthLevel(IntEnum): + """Mop scrub intensity (CleanParam tag 3).""" + + 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/map_renderer.py b/custom_components/narwal/narwal_client/map_renderer.py index 59cce62..2154f19 100644 --- a/custom_components/narwal/narwal_client/map_renderer.py +++ b/custom_components/narwal/narwal_client/map_renderer.py @@ -16,6 +16,7 @@ import io import logging +import math import zlib _LOGGER = logging.getLogger(__name__) @@ -98,10 +99,116 @@ OBSTACLE_COLOR_DEFAULT = (200, 200, 200) # Special pixel colors -COLOR_UNKNOWN = (40, 40, 40) # outside map / unmapped +COLOR_UNKNOWN = (0, 0, 0, 0) # outside map / unmapped, transparent COLOR_UNASSIGNED_FLOOR = (200, 200, 200) # floor not assigned to a room COLOR_UNASSIGNED_OBSTACLE = (80, 80, 80) # obstacle not in a room COLOR_FALLBACK = (180, 180, 180) # unknown room ID +MAP_RENDER_SCALE = 3 +ROOM_LABEL_FONT_SCALE = 10 +OBSTACLE_LABEL_FONT_SCALE = 6 +FONT_PATHS = ( + "/usr/share/fonts/truetype/dejavu/DejaVuSans-SemiBold.ttf", + "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", + "arial.ttf", +) + + +def _opaque(color: tuple[int, int, int]) -> tuple[int, int, int, int]: + """Return an RGB color with a fully opaque alpha channel.""" + return (*color, 255) + + +def _load_font(image_font: object, size: int): + """Load a crisp TrueType font, falling back to Pillow's default font.""" + for path in FONT_PATHS: + try: + return image_font.truetype(path, size) + except (IOError, OSError): + continue + return image_font.load_default() + + +def _scaled_coord(value: float, scale: float, size: int) -> int: + """Return a scaled grid coordinate centred in the rendered map cell.""" + coordinate = int(round(value * scale + (scale / 2))) + return max(0, min(coordinate, size - 1)) + + +def _draw_label( + draw: "ImageDraw.ImageDraw", + xy: tuple[int, int], + text: str, + font: object, + *, + fill: tuple[int, int, int] = (255, 255, 255), + stroke_fill: tuple[int, int, int] = (20, 20, 20), + padding: int = 4, +) -> None: + """Draw a readable centred map label.""" + cx, cy = xy + bbox = font.getbbox(text) + tw = bbox[2] - bbox[0] + th = bbox[3] - bbox[1] + tx = cx - tw // 2 + ty = cy - th // 2 + bg = [ + tx - padding, + ty - padding, + tx + tw + padding, + ty + th + padding, + ] + draw.rounded_rectangle(bg, radius=padding * 2, fill=(0, 0, 0, 120)) + draw.text( + (tx, ty), + text, + fill=fill, + font=font, + stroke_width=max(1, padding // 2), + stroke_fill=stroke_fill, + ) + + +def _normalize_rotation(rotation_degrees: int) -> int: + """Return a rotation supported by both bitmap and point transforms.""" + rotation = int(rotation_degrees or 0) % 360 + return rotation if rotation in (0, 90, 180, 270) else 0 + + +def _transform_point( + x: float, + y: float, + width: int, + height: int, + rotation_degrees: int, + zoom: float, +) -> tuple[float, float] | None: + """Transform an unrotated image-space point into final view coordinates.""" + rotation = _normalize_rotation(rotation_degrees) + if rotation == 0: + rx, ry = x, y + rotated_width, rotated_height = width, height + elif rotation == 90: + rx, ry = height - 1 - y, x + rotated_width, rotated_height = height, width + elif rotation == 180: + rx, ry = width - 1 - x, height - 1 - y + rotated_width, rotated_height = width, height + elif rotation == 270: + rx, ry = y, width - 1 - x + rotated_width, rotated_height = height, width + zoom = max(1.0, min(2.0, float(zoom or 1.0))) + crop_width = max(1, int(rotated_width / zoom)) + crop_height = max(1, int(rotated_height / zoom)) + left = max(0, (rotated_width - crop_width) // 2) + top = max(0, (rotated_height - crop_height) // 2) + return rx - left, ry - top + + +def _rotated_heading(heading: float | None, rotation_degrees: int) -> float | None: + """Rotate robot heading to match the final clockwise map rotation.""" + if heading is None: + return None + return (heading - _normalize_rotation(rotation_degrees)) % 360 def decompress_map(compressed: bytes) -> bytes: @@ -222,6 +329,55 @@ def lookup_room_at_grid( return (room_id, f"room_{room_id}{wall}") +def room_label_points( + compressed: bytes, + width: int, + height: int, + room_names: dict[int, str] | None, +) -> list[tuple[str, float, float]]: + """Return room label centre points in unflipped grid coordinates.""" + if not compressed or width <= 0 or height <= 0 or not room_names: + return [] + + decompressed = decompress_map(compressed) + if not decompressed: + return [] + + pixels = _decode_packed_varints(decompressed) + expected = width * height + if len(pixels) < expected: + pixels.extend([0] * (expected - len(pixels))) + elif len(pixels) > expected: + pixels = pixels[:expected] + + room_sum_x: dict[int, int] = {} + room_sum_y: dict[int, int] = {} + room_count: dict[int, int] = {} + for i, val in enumerate(pixels): + if val == 0 or val in (0x20, 0x28): + continue + room_id = val >> 8 + ptype = val & 0xFF + if room_id not in room_names or ptype & 0x10: + continue + x = i % width + y = i // width + room_sum_x[room_id] = room_sum_x.get(room_id, 0) + x + room_sum_y[room_id] = room_sum_y.get(room_id, 0) + y + room_count[room_id] = room_count.get(room_id, 0) + 1 + + points: list[tuple[str, float, float]] = [] + for room_id, name in room_names.items(): + if not name or room_id not in room_count: + continue + points.append(( + name, + room_sum_x[room_id] / room_count[room_id], + room_sum_y[room_id] / room_count[room_id], + )) + return points + + def _darken(color: tuple[int, int, int], amount: int = 80) -> tuple[int, int, int]: """Darken an RGB color by subtracting from each channel.""" return ( @@ -237,15 +393,37 @@ def _draw_dock( dock_y: int, size: int = 6, ) -> None: - """Draw a dock/charging station icon at the given grid coordinates. - - Renders as a small white filled circle (matching the Narwal app style). - """ - radius = size // 2 + """Draw a small Flow-style dock marker at the given image coordinates.""" + half_w = max(5, size // 2) + half_h = max(4, int(size * 0.42)) + outline_width = max(1, size // 12) + draw.rounded_rectangle( + [dock_x - half_w, dock_y - half_h, dock_x + half_w, dock_y + half_h], + radius=max(3, size // 5), + fill=(248, 249, 250, 245), + outline=(90, 96, 104, 220), + width=outline_width, + ) + slot_h = max(2, size // 7) + draw.rounded_rectangle( + [ + dock_x - int(half_w * 0.55), + dock_y - slot_h, + dock_x + int(half_w * 0.55), + dock_y + slot_h, + ], + radius=max(1, slot_h), + fill=(38, 42, 48, 235), + ) + led = max(1, size // 10) draw.ellipse( - [dock_x - radius, dock_y - radius, dock_x + radius, dock_y + radius], - fill=(255, 255, 255), - outline=(180, 180, 180), + [ + dock_x + int(half_w * 0.58) - led, + dock_y + int(half_h * 0.35) - led, + dock_x + int(half_w * 0.58) + led, + dock_y + int(half_h * 0.35) + led, + ], + fill=(80, 190, 120, 255), ) @@ -256,7 +434,7 @@ def _draw_robot( heading: float | None, radius: int, ) -> None: - """Draw robot position with optional heading arrow. + """Draw a small Narwal Flow-style robot marker. Args: draw: PIL ImageDraw instance. @@ -268,27 +446,75 @@ def _draw_robot( """ import math - # Blue filled circle with white outline + outline_width = max(1, radius // 8) + + if heading is None: + heading = 90 + rad = math.radians(heading) + front_dx = math.cos(rad) + front_dy = -math.sin(rad) + side_dx = -front_dy + side_dy = front_dx + + def point(forward: float, side: float) -> tuple[float, float]: + return ( + rx + (front_dx * forward) + (side_dx * side), + ry + (front_dy * forward) + (side_dy * side), + ) + + # Soft shadow/halo for contrast on bright room colours. + draw.ellipse( + [ + rx - radius - 2, + ry - radius - 2, + rx + radius + 2, + ry + radius + 2, + ], + fill=(0, 0, 0, 55), + ) + + # Main circular body. draw.ellipse( [rx - radius, ry - radius, rx + radius, ry + radius], - fill=(0, 120, 255), - outline=(255, 255, 255), + fill=(246, 247, 248, 245), + outline=(96, 102, 110, 210), + width=outline_width, ) - # Heading arrow — white line from center in heading direction - if heading is not None: - # Convert degrees to radians. Heading 0=right, 90=up in world coords. - # Image Y is flipped (down = positive), so negate the Y component. - rad = math.radians(heading) - arrow_len = radius * 2.5 - dx = math.cos(rad) * arrow_len - dy = -math.sin(rad) * arrow_len # negate for image Y-down - draw.line( - [(rx, ry), (rx + dx, ry + dy)], - fill=(255, 255, 255), - width=2, + # Flow-style front sensor bar, rotated with heading. + front_centre = point(radius * 0.58, 0) + bar_half_width = radius * 0.38 + bar_half_height = max(2, radius * 0.13) + bar = [ + point(radius * 0.58 - bar_half_height, -bar_half_width), + point(radius * 0.58 + bar_half_height, -bar_half_width), + point(radius * 0.58 + bar_half_height, bar_half_width), + point(radius * 0.58 - bar_half_height, bar_half_width), + ] + draw.polygon(bar, fill=(30, 33, 38, 245)) + lens_radius = max(1, radius // 8) + for side in (-bar_half_width * 0.55, bar_half_width * 0.55): + lx, ly = point(radius * 0.58, side) + draw.ellipse( + [lx - lens_radius, ly - lens_radius, lx + lens_radius, ly + lens_radius], + fill=(85, 160, 225, 255), ) + # Top dial and subtle heading notch. + dial_radius = max(2, radius // 3) + draw.ellipse( + [rx - dial_radius, ry - dial_radius, rx + dial_radius, ry + dial_radius], + fill=(232, 234, 236, 255), + outline=(150, 155, 160, 230), + width=max(1, outline_width // 2), + ) + notch = [ + point(radius * 0.95, 0), + point(radius * 0.55, -radius * 0.16), + point(radius * 0.55, radius * 0.16), + ] + draw.polygon(notch, fill=(255, 255, 255, 235)) + def render_map_png( decompressed: bytes, @@ -346,7 +572,7 @@ def render_map_png( elif len(pixels) > expected: pixels = pixels[:expected] - img = Image.new("RGB", (width, height), COLOR_UNKNOWN) + img = Image.new("RGBA", (width, height), COLOR_UNKNOWN) px = img.load() # Track room pixel sums for centroid computation @@ -361,9 +587,9 @@ def render_map_png( if val == 0: continue # already set to COLOR_UNKNOWN elif val == 0x20: - px[x, y] = COLOR_UNASSIGNED_FLOOR + px[x, y] = _opaque(COLOR_UNASSIGNED_FLOOR) elif val == 0x28: - px[x, y] = COLOR_UNASSIGNED_OBSTACLE + px[x, y] = _opaque(COLOR_UNASSIGNED_OBSTACLE) else: room_id = val >> 8 ptype = val & 0xFF @@ -374,9 +600,9 @@ def render_map_png( base = COLOR_FALLBACK if ptype & 0x10: # wall/border edge - px[x, y] = _darken(base) + px[x, y] = _opaque(_darken(base)) else: - px[x, y] = base + px[x, y] = _opaque(base) # Accumulate for centroid (floor pixels only, not walls) if room_names and room_id in room_names and not (ptype & 0x10): @@ -388,43 +614,70 @@ def render_map_png( # Y increasing upward (math coordinates) but images render Y downward. # Overlays (labels, dock, robot) use flipped coordinates so text is right-side up. img = img.transpose(Image.FLIP_TOP_BOTTOM) + scale = MAP_RENDER_SCALE + if scale > 1: + img = img.resize( + (width * scale, height * scale), + getattr(Image, "Resampling", Image).NEAREST, + ) draw = ImageDraw.Draw(img) + scaled_height = height * scale # Draw room labels at flipped centroids if room_names: - try: - font = ImageFont.truetype("arial.ttf", 10) - except (IOError, OSError): - font = ImageFont.load_default() + font = _load_font(ImageFont, ROOM_LABEL_FONT_SCALE * scale) for rid, name in room_names.items(): if not name or rid not in room_count: continue - cx = room_sum_x[rid] // room_count[rid] - cy = height - 1 - (room_sum_y[rid] // room_count[rid]) - bbox = font.getbbox(name) - tw = bbox[2] - bbox[0] - th = bbox[3] - bbox[1] - tx = cx - tw // 2 - ty = cy - th // 2 - # Dark outline for readability - for ox, oy in [(-1, 0), (1, 0), (0, -1), (0, 1)]: - draw.text((tx + ox, ty + oy), name, fill=(0, 0, 0), font=font) - draw.text((tx, ty), name, fill=(255, 255, 255), font=font) + cx = _scaled_coord( + room_sum_x[rid] // room_count[rid], scale, img.width + ) + cy = _scaled_coord( + height - 1 - (room_sum_y[rid] // room_count[rid]), + scale, + img.height, + ) + _draw_label( + draw, + (cx, cy), + name, + font=font, + padding=max(3, scale), + ) # Draw dock position (before robot so robot draws on top) # Flip dock Y to match the flipped image - if dock_x is not None and dock_y is not None: - dock_size = max(4, min(width, height) // 60) - _draw_dock(draw, int(dock_x), height - 1 - int(dock_y), dock_size) + marker_radius = max(3 * scale, min(width, height) * scale // 80) + + if ( + dock_x is not None + and dock_y is not None + and math.isfinite(dock_x) + and math.isfinite(dock_y) + and 0 <= dock_x < width + and 0 <= dock_y < height + ): + dock_size = marker_radius * 2 + _draw_dock( + draw, + _scaled_coord(dock_x, scale, img.width), + _scaled_coord(height - 1 - dock_y, scale, img.height), + dock_size, + ) # Draw robot position (flip Y) — skip if out of bounds - if robot_x is not None and robot_y is not None: - rx = int(robot_x) - ry = height - 1 - int(robot_y) - if 0 <= rx < width and 0 <= ry < height: - radius = max(3, min(width, height) // 80) - _draw_robot(draw, rx, ry, robot_heading, radius) + if ( + robot_x is not None + and robot_y is not None + and math.isfinite(robot_x) + and math.isfinite(robot_y) + and 0 <= robot_x < width + and 0 <= robot_y < height + ): + rx = _scaled_coord(robot_x, scale, img.width) + ry = _scaled_coord(height - 1 - robot_y, scale, scaled_height) + _draw_robot(draw, rx, ry, robot_heading, marker_radius) buf = io.BytesIO() img.save(buf, format="PNG") @@ -441,6 +694,9 @@ def render_base_map( obstacles: "list | None" = None, origin_x: int = 0, origin_y: int = 0, + show_obstacle_labels: bool = True, + show_room_labels: bool = True, + show_dock: bool = True, ) -> "Image.Image | None": """Render the static floor plan as a PIL Image (no robot overlay). @@ -451,6 +707,9 @@ def render_base_map( obstacles: List of ObstacleInfo objects to render (optional). origin_x: Map origin X offset for obstacle coordinate transform. origin_y: Map origin Y offset for obstacle coordinate transform. + show_obstacle_labels: Whether to draw furniture/obstacle labels. + show_room_labels: Whether to draw room labels into the base image. + show_dock: Whether to draw the dock into the base image. """ try: from PIL import Image, ImageDraw, ImageFont @@ -470,7 +729,7 @@ def render_base_map( elif len(pixels) > expected: pixels = pixels[:expected] - img = Image.new("RGB", (width, height), COLOR_UNKNOWN) + img = Image.new("RGBA", (width, height), COLOR_UNKNOWN) px = img.load() room_sum_x: dict[int, int] = {} @@ -484,9 +743,9 @@ def render_base_map( if val == 0: continue elif val == 0x20: - px[x, y] = COLOR_UNASSIGNED_FLOOR + px[x, y] = _opaque(COLOR_UNASSIGNED_FLOOR) elif val == 0x28: - px[x, y] = COLOR_UNASSIGNED_OBSTACLE + px[x, y] = _opaque(COLOR_UNASSIGNED_OBSTACLE) else: room_id = val >> 8 ptype = val & 0xFF @@ -497,9 +756,9 @@ def render_base_map( base = COLOR_FALLBACK if ptype & 0x10: - px[x, y] = _darken(base) + px[x, y] = _opaque(_darken(base)) else: - px[x, y] = base + px[x, y] = _opaque(base) if room_names and room_id in room_names and not (ptype & 0x10): room_sum_x[room_id] = room_sum_x.get(room_id, 0) + x @@ -507,62 +766,82 @@ def render_base_map( room_count[room_id] = room_count.get(room_id, 0) + 1 img = img.transpose(Image.FLIP_TOP_BOTTOM) + scale = MAP_RENDER_SCALE + if scale > 1: + img = img.resize( + (width * scale, height * scale), + getattr(Image, "Resampling", Image).NEAREST, + ) draw = ImageDraw.Draw(img) - if room_names: - try: - font = ImageFont.truetype("arial.ttf", 10) - except (IOError, OSError): - font = ImageFont.load_default() + if room_names and show_room_labels: + font = _load_font(ImageFont, ROOM_LABEL_FONT_SCALE * scale) for rid, name in room_names.items(): if not name or rid not in room_count: continue - cx = room_sum_x[rid] // room_count[rid] - cy = height - 1 - (room_sum_y[rid] // room_count[rid]) - bbox = font.getbbox(name) - tw = bbox[2] - bbox[0] - th = bbox[3] - bbox[1] - tx = cx - tw // 2 - ty = cy - th // 2 - for ox, oy in [(-1, 0), (1, 0), (0, -1), (0, 1)]: - draw.text((tx + ox, ty + oy), name, fill=(0, 0, 0), font=font) - draw.text((tx, ty), name, fill=(255, 255, 255), font=font) + cx = _scaled_coord( + room_sum_x[rid] // room_count[rid], scale, img.width + ) + cy = _scaled_coord( + height - 1 - (room_sum_y[rid] // room_count[rid]), + scale, + img.height, + ) + _draw_label( + draw, + (cx, cy), + name, + font=font, + padding=max(3, scale), + ) # Draw obstacle/furniture annotations (static data from get_map field 2.32) if obstacles: - try: - obs_font = ImageFont.truetype("arial.ttf", 8) - except (IOError, OSError): - obs_font = ImageFont.load_default() + obs_font = _load_font(ImageFont, OBSTACLE_LABEL_FONT_SCALE * scale) for obs in obstacles: gx, gy = obs.to_grid_coords(origin_x, origin_y) # Skip out-of-bounds obstacles if gx < 0 or gx >= width or gy < 0 or gy >= height: continue - img_x = int(gx) - img_y = height - 1 - int(gy) - half_w = max(1, int(obs.width / 2)) - half_h = max(1, int(obs.height / 2)) + img_x = _scaled_coord(gx, scale, img.width) + img_y = _scaled_coord(height - 1 - gy, scale, img.height) + half_w = max(scale, int(obs.width * scale / 2)) + half_h = max(scale, int(obs.height * scale / 2)) color = OBSTACLE_COLORS.get(obs.type_id, OBSTACLE_COLOR_DEFAULT) draw.rectangle( [img_x - half_w, img_y - half_h, img_x + half_w, img_y + half_h], - outline=color, width=1, + outline=color, width=max(1, scale // 2), ) - # Draw label centered above the rectangle - label = obs.display_name - bbox = obs_font.getbbox(label) - tw = bbox[2] - bbox[0] - th = bbox[3] - bbox[1] - lx = img_x - tw // 2 - ly = img_y - half_h - th - 2 - # Dark outline for readability - for ox, oy in [(-1, 0), (1, 0), (0, -1), (0, 1)]: - draw.text((lx + ox, ly + oy), label, fill=(0, 0, 0), font=obs_font) - draw.text((lx, ly), label, fill=color, font=obs_font) - - if dock_x is not None and dock_y is not None: - dock_size = max(4, min(width, height) // 60) - _draw_dock(draw, int(dock_x), height - 1 - int(dock_y), dock_size) + if show_obstacle_labels: + label = obs.display_name + bbox = obs_font.getbbox(label) + th = bbox[3] - bbox[1] + _draw_label( + draw, + (img_x, img_y - half_h - th - (3 * scale)), + label, + fill=color, + font=obs_font, + padding=max(2, scale), + ) + + if ( + show_dock + and dock_x is not None + and dock_y is not None + and math.isfinite(dock_x) + and math.isfinite(dock_y) + and 0 <= dock_x < width + and 0 <= dock_y < height + ): + marker_radius = max(3 * scale, min(width, height) * scale // 80) + dock_size = marker_radius * 2 + _draw_dock( + draw, + _scaled_coord(dock_x, scale, img.width), + _scaled_coord(height - 1 - dock_y, scale, img.height), + dock_size, + ) return img @@ -574,6 +853,11 @@ def render_overlay( robot_y: float | None = None, robot_heading: float | None = None, trail: list[tuple[float, float]] | None = None, + rotation_degrees: int = 0, + zoom: float = 1.0, + room_labels: list[tuple[str, float, float]] | None = None, + dock_x: float | None = None, + dock_y: float | None = None, ) -> bytes: """Draw robot position and trail on a copy of the cached base map. @@ -584,41 +868,158 @@ def render_overlay( robot_y: Robot Y in grid coordinates. robot_heading: Heading in degrees. trail: List of (grid_x, grid_y) positions to draw as cleaning path. + rotation_degrees: Clockwise map rotation in degrees. + zoom: Centre zoom factor. + room_labels: Room label centre points in grid coordinates. + dock_x: Dock X position in grid coordinates. + dock_y: Dock Y position in grid coordinates. Returns: PNG bytes of the composited image. """ - from PIL import ImageDraw + from PIL import ImageDraw, ImageFont img = base_img.copy() - draw = ImageDraw.Draw(img) - width = img.width + original_width = img.width + original_height = img.height + scale = img.height / height if height > 0 else 1.0 + map_width = original_width / scale if scale > 0 else original_width + max_grid_segment = max(24.0, min(map_width, height) * 0.18) + + img = _apply_view_transform(img, rotation_degrees, zoom) + draw = ImageDraw.Draw(img, "RGBA") + + def is_valid_grid_point(grid_x: float, grid_y: float) -> bool: + return ( + math.isfinite(grid_x) + and math.isfinite(grid_y) + and 0 <= grid_x < map_width + and 0 <= grid_y < height + ) - # Draw trail (blue path showing where robot has cleaned) + def final_point(grid_x: float, grid_y: float) -> tuple[int, int] | None: + if not is_valid_grid_point(grid_x, grid_y): + return None + x = _scaled_coord(grid_x, scale, original_width) + y = _scaled_coord(height - 1 - grid_y, scale, original_height) + transformed = _transform_point( + x, y, original_width, original_height, rotation_degrees, zoom + ) + if transformed is None: + return None + tx, ty = transformed + return int(round(tx)), int(round(ty)) + + # Draw trail, splitting at invalid samples or impossible jumps. if trail and len(trail) >= 2: recent_start = max(len(trail) - 200, 0) + trail_width = max(3, int(round(2 * scale))) + previous_grid: tuple[float, float] | None = None + previous_point: tuple[int, int] | None = None for i in range(len(trail) - 1): - if i >= recent_start: - color = (30, 120, 255) # bright blue for recent - else: - color = (15, 60, 130) # dim blue for older - x1, y1 = int(trail[i][0]), height - 1 - int(trail[i][1]) - x2, y2 = int(trail[i + 1][0]), height - 1 - int(trail[i + 1][1]) - draw.line([(x1, y1), (x2, y2)], fill=color, width=2) + grid_x, grid_y = trail[i] + if not is_valid_grid_point(grid_x, grid_y): + previous_grid = None + previous_point = None + continue + + point = final_point(grid_x, grid_y) + if point is None: + previous_grid = None + previous_point = None + continue + + if previous_grid is not None and previous_point is not None: + distance = math.hypot( + grid_x - previous_grid[0], + grid_y - previous_grid[1], + ) + if distance <= max_grid_segment: + color = ( + (255, 255, 255, 220) + if i >= recent_start + else (215, 220, 225, 130) + ) + draw.line([previous_point, point], fill=color, width=trail_width) + + previous_grid = (grid_x, grid_y) + previous_point = point + + last_grid_x, last_grid_y = trail[-1] + if previous_grid is not None and is_valid_grid_point(last_grid_x, last_grid_y): + last_point = final_point(last_grid_x, last_grid_y) + if last_point is not None: + distance = math.hypot( + last_grid_x - previous_grid[0], + last_grid_y - previous_grid[1], + ) + if distance <= max_grid_segment: + draw.line( + [previous_point, last_point], + fill=(255, 255, 255, 220), + width=trail_width, + ) + + if room_labels: + font = _load_font(ImageFont, ROOM_LABEL_FONT_SCALE * int(round(scale))) + for label, grid_x, grid_y in room_labels: + point = final_point(grid_x, grid_y) + if point is None: + continue + x, y = point + if -80 <= x <= img.width + 80 and -80 <= y <= img.height + 80: + _draw_label(draw, (x, y), label, font, padding=max(4, int(scale))) + + if dock_x is not None and dock_y is not None: + point = final_point(dock_x, dock_y) + if point is not None: + dx, dy = point + if 0 <= dx < img.width and 0 <= dy < img.height: + robot_radius = max(5 * int(round(scale)), min(img.width, img.height) // 64) + dock_size = robot_radius * 2 + _draw_dock(draw, dx, dy, dock_size) # Draw robot if robot_x is not None and robot_y is not None: - rx = int(robot_x) - ry = height - 1 - int(robot_y) - if 0 <= rx < width and 0 <= ry < height: - radius = max(3, min(width, height) // 80) - _draw_robot(draw, rx, ry, robot_heading, radius) + point = final_point(robot_x, robot_y) + if point is not None: + rx, ry = point + if 0 <= rx < img.width and 0 <= ry < img.height: + radius = max(5 * int(round(scale)), min(img.width, img.height) // 64) + _draw_robot( + draw, + rx, + ry, + _rotated_heading(robot_heading, rotation_degrees), + radius, + ) buf = io.BytesIO() img.save(buf, format="PNG") return buf.getvalue() +def _apply_view_transform( + img: "Image.Image", + rotation_degrees: int = 0, + zoom: float = 1.0, +) -> "Image.Image": + """Apply final map rotation and centre zoom.""" + rotation = _normalize_rotation(rotation_degrees) + if rotation: + img = img.rotate(-rotation, expand=True) + + zoom = max(1.0, min(2.0, float(zoom or 1.0))) + if zoom <= 1.0: + return img + + crop_width = max(1, int(img.width / zoom)) + crop_height = max(1, int(img.height / zoom)) + left = max(0, (img.width - crop_width) // 2) + top = max(0, (img.height - crop_height) // 2) + return img.crop((left, top, left + crop_width, top + crop_height)) + + def render_map_from_compressed( compressed: bytes, width: int, diff --git a/custom_components/narwal/narwal_client/models.py b/custom_components/narwal/narwal_client/models.py index 11a69c6..4618c91 100644 --- a/custom_components/narwal/narwal_client/models.py +++ b/custom_components/narwal/narwal_client/models.py @@ -4,12 +4,44 @@ import logging import struct +import time from dataclasses import dataclass, field from typing import Any, ClassVar _LOGGER = logging.getLogger(__name__) - -from .const import CommandResult, FanLevel, MopHumidity, WorkingStatus +_ACTIVE_WORKING_STATUS_TTL = 15.0 +MAINTENANCE_BASE_STATION_CLEANING_FILTER = "base_station_cleaning_filter" +MAINTENANCE_BASE_STATION_CLEANING_FILTER_COMPONENT = 4 +MAINTENANCE_COMPONENT_IDS: dict[str, int] = { + "dust_bin": 1, + "dust_bin_filter": 2, + "dirty_water_tank": 3, + MAINTENANCE_BASE_STATION_CLEANING_FILTER: ( + MAINTENANCE_BASE_STATION_CLEANING_FILTER_COMPONENT + ), + "smart_water_module": 5, + "caster": 6, + "sensor": 7, + "edge_sensor": 8, + "roller_brush": 9, + "dust_bin_bag": 13, + "sponge_filter": 15, +} +_MAINTENANCE_ALERT_PATHS: dict[str, tuple[tuple[str, ...], ...]] = { + key: () for key in MAINTENANCE_COMPONENT_IDS +} +_MAINTENANCE_ALERT_PATHS[MAINTENANCE_BASE_STATION_CLEANING_FILTER] = ( + ("48", "1", "15", "7"), +) +_MAINTENANCE_COMPONENT_ALERTS: dict[str, int] = MAINTENANCE_COMPONENT_IDS + +from .const import ( + ACTIVE_CLEANING_STATUSES, + TOPIC_CMD_GET_ROBOT_TASK_STATUS, + TOPIC_ROBOT_TASK_STATUS, + CommandResult, + WorkingStatus, +) @dataclass @@ -55,6 +87,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 +274,134 @@ 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 _has_field_path(value: Any, path: tuple[str, ...]) -> bool: + if not path: + return True + if isinstance(value, list): + return any(_has_field_path(item, path) for item in value) + if not isinstance(value, dict): + return False + key = path[0] + if key not in value: + return False + return _has_field_path(value[key], path[1:]) + + +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 + + +def _as_sequence(value: Any) -> tuple[Any, ...]: + if isinstance(value, list): + return tuple(value) + if value is None: + return () + return (value,) + + +def _get_nested(value: Any, path: tuple[str, ...]) -> Any: + current = value + for key in path: + if not isinstance(current, dict): + return None + current = current.get(key) + return current + + +def _component_alert_active(decoded: dict[str, Any], component_id: int) -> bool: + """Return true when field48 contains an alert for a maintenance component.""" + for item in _as_sequence(_get_nested(decoded, ("48", "1"))): + if _optional_int(_get_nested(item, ("5", "1", "1"))) == component_id: + return True + return False + + +def _maintenance_alerts_from_base_status(decoded: dict[str, Any]) -> tuple[str, ...]: + alerts: list[str] = [] + for alert, paths in _MAINTENANCE_ALERT_PATHS.items(): + component_id = _MAINTENANCE_COMPONENT_ALERTS.get(alert) + if any(_has_field_path(decoded, path) for path in paths) or ( + component_id is not None and _component_alert_active(decoded, component_id) + ): + alerts.append(alert) + return tuple(alerts) + + +def _maintenance_hours_from_base_status(decoded: dict[str, Any]) -> dict[int, int]: + hours: dict[int, int] = {} + for item in _as_sequence(decoded.get("32")): + if not isinstance(item, dict): + continue + component_id = _optional_int(item.get("18")) + used_hours = _optional_int(item.get("1")) + if component_id is not None and used_hours is not None: + hours[component_id] = used_hours + return hours + + @dataclass class MapData: """Map data from get_map response.""" @@ -254,6 +419,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 +510,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 +526,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 +588,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 +663,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 +734,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 +751,19 @@ 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 = "" + task_active: bool = False + task_paused: bool = False + dry_mop_remaining_time: int | None = None + _previous_task_metrics: tuple[int, int] | None = field( + default=None, init=False, repr=False + ) + maintenance_alerts: tuple[str, ...] = () + maintenance_component_hours: dict[int, int] = field(default_factory=dict) # Map map_data: MapData | None = None @@ -534,10 +787,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) @@ -552,15 +817,130 @@ class NarwalState: # Secondary confirmation signal. dock_field47: int = 0 + # Base station ambient light mode from top-level base_status field 50. + # Values validated against the app: 1=Nightlight, + # 2=Fireplace / Winter warmth, 3=Purple. When the light is off the robot + # omits field 50 from base_status, so missing field 50 is treated as 0. + dock_light_mode: int | None = None + # 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 + + def mark_active_cleaning(self) -> None: + """Mark a newly accepted clean as active until telemetry catches up.""" + if self.cleaning_time > 0 or self.cleaning_area > 0: + self._previous_task_metrics = (self.cleaning_time, self.cleaning_area) + self.cleaning_area = 0 + self.cleaning_time = 0 + self.task_progress_percent = None + self.task_elapsed_time = 0 + self.current_room_id = None + self.current_room_name = "" + self.task_active = True + self.task_paused = False + self.is_paused = False + self.is_returning_to_dock = False + self.inferred_docked_from_battery = False + self.dock_field11 = 1 + self.dock_field47 = 2 + self.dock_sub_state = 0 + self.dock_activity = 0 + self.station_activity = 0 + self.refresh_active_cleaning() + + def mark_task_paused(self) -> None: + """Keep a paused clean resumable while the robot returns to its dock.""" + self.task_active = True + self.task_paused = True + self.is_paused = True + + def mark_task_resumed(self) -> None: + """Mark a paused clean active again.""" + self.task_active = True + self.task_paused = False + self.is_paused = False + self.refresh_active_cleaning() + + def clear_active_task(self) -> None: + """Clear resumable-task state after completion or cancellation.""" + if self.working_status in ACTIVE_CLEANING_STATUSES: + self.working_status = WorkingStatus.STANDBY + self.task_active = False + self.task_paused = False + self.is_paused = False + self.last_active_working_status_time = 0.0 + self.task_progress_percent = None + self.task_elapsed_time = 0 + self.current_room_id = None + self.current_room_name = "" + self.is_returning_to_dock = False + + def refresh_active_cleaning(self) -> None: + """Keep an active clean authoritative while task telemetry is current.""" + self.working_status = WorkingStatus.CLEANING + self.last_active_working_status_time = time.monotonic() @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 +961,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 +994,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,16 +1013,60 @@ def update_from_working_status(self, decoded: dict[str, Any]) -> None: Field 15 = 600 during cleaning (purpose uncertain) """ self.raw_working_status = decoded - if "3" in decoded: - try: - self.cleaning_time = int(decoded["3"]) - except (ValueError, TypeError): - pass - if "13" in decoded: - self.cleaning_area = int(decoded["13"]) + incoming_cleaning_time = ( + _optional_int(decoded.get("3")) if "3" in decoded else None + ) + incoming_cleaning_area = ( + _optional_int(decoded.get("13")) if "13" in decoded else None + ) + if self._previous_task_metrics is not None: + previous_task_time, previous_task_area = self._previous_task_metrics + supplied_metrics = [ + (incoming_cleaning_time, previous_task_time), + (incoming_cleaning_area, previous_task_area), + ] + supplied_metrics = [pair for pair in supplied_metrics if pair[0] is not None] + if supplied_metrics and all(value == old for value, old in supplied_metrics): + return + if supplied_metrics: + self._previous_task_metrics = None + previous_cleaning_time = self.cleaning_time + previous_cleaning_area = self.cleaning_area + if incoming_cleaning_time is not None: + self.cleaning_time = incoming_cleaning_time + if incoming_cleaning_area is not None: + self.cleaning_area = incoming_cleaning_area 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 +1082,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 @@ -667,6 +1091,10 @@ def update_from_base_status(self, decoded: dict[str, Any]) -> None: Note: field 32 mirrors field 3 exactly (redundant). """ self.raw_base_status = decoded + # Base broadcasts omit field 48 intermittently. The periodic clean- + # progress refresh is authoritative when clearing maintenance alerts. + if "48" in decoded: + self.maintenance_alerts = _maintenance_alerts_from_base_status(decoded) # Field 11 = dock indicator (2=docked, 1=undocked) if "11" in decoded: try: @@ -679,6 +1107,13 @@ def update_from_base_status(self, decoded: dict[str, Any]) -> None: self.dock_field47 = int(decoded["47"]) except (ValueError, TypeError): self.dock_field47 = 0 + if "50" in decoded: + try: + self.dock_light_mode = int(decoded["50"]) + except (ValueError, TypeError): + self.dock_light_mode = None + else: + self.dock_light_mode = 0 # Field 3 is a nested message: {1: state_int, ...} # Sub-field layout differs across firmware versions: # Old FW: {1: ws, 2: paused, 3: dock_presence, 7: returning, 10: dock_sub, 12: dock_activity} @@ -690,7 +1125,7 @@ def update_from_base_status(self, decoded: dict[str, Any]) -> None: if isinstance(field3, dict): if "1" in field3: try: - self.working_status = WorkingStatus(int(field3["1"])) + new_working_status = WorkingStatus(int(field3["1"])) except (ValueError, TypeError): raw_val = field3["1"] _LOGGER.warning( @@ -698,7 +1133,8 @@ def update_from_base_status(self, decoded: dict[str, Any]) -> None: "Please report this value at the GitHub repo.", raw_val, ) - self.working_status = WorkingStatus.UNKNOWN + new_working_status = WorkingStatus.UNKNOWN + self.working_status = new_working_status # Sub-field 2: paused overlay (0 or absent = not paused, 1 = paused) self.is_paused = bool(field3.get("2")) # Sub-field 7: returning to dock on old FW (value 1 = returning). @@ -715,13 +1151,69 @@ 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 self.task_active or not self.is_docked: + self.task_active = True + if self.is_paused: + self.task_paused = True + elif not self.is_docked: + self.task_paused = False + 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 + elif ( + self.working_status == WorkingStatus.TASK_COMPLETED + or ( + self.working_status + in { + WorkingStatus.STANDBY, + WorkingStatus.DOCKED, + WorkingStatus.CHARGED, + WorkingStatus.DOCKED_V2, + } + and self.is_docked + and self.task_active + and not self.task_paused + and ( + self.task_progress_percent not in (None, 0) + or self.cleaning_time > 0 + or self.cleaning_area > 0 + or self.current_room_id is not None + ) + ) + ): + self.clear_active_task() # 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 +1226,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 +1267,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 +1298,72 @@ 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 == "info/get_clean_progress_info": + payload = decoded.get("2") + if isinstance(payload, dict): + self.maintenance_alerts = _maintenance_alerts_from_base_status(payload) + if "32" in payload: + self.maintenance_component_hours = ( + _maintenance_hours_from_base_status(payload) + ) + 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: + task_was_active = self.task_active + self.task_progress_percent = max(0, min(100, progress)) + if progress >= 100: + self.clear_active_task() + return + if ( + progress > 0 + or self.task_active + or ( + self.working_status in ACTIVE_CLEANING_STATUSES + and not self.is_docked + ) + ): + self.task_active = True + self.task_paused = ( + self.task_paused + or self.is_paused + or (not task_was_active and progress > 0 and self.is_docked) + ) + 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..02d6a32 --- /dev/null +++ b/custom_components/narwal/select.py @@ -0,0 +1,260 @@ +"""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 + or state.task_active + ) 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..6501ae9 100644 --- a/custom_components/narwal/sensor.py +++ b/custom_components/narwal/sensor.py @@ -15,11 +15,15 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .narwal_client import NarwalState - from . import NarwalConfigEntry +from .const import is_maintenance_alerts_supported from .coordinator import NarwalCoordinator from .entity import NarwalEntity +from .narwal_client import ( + MAINTENANCE_BASE_STATION_CLEANING_FILTER_COMPONENT, + NarwalState, + WorkingStatus, +) @dataclass(frozen=True, kw_only=True) @@ -29,6 +33,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 +66,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,12 +76,62 @@ 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( + key="base_station_cleaning_filter_used_hours", + translation_key="base_station_cleaning_filter_used_hours", + device_class=SensorDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.HOURS, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda state: state.maintenance_component_hours.get( + MAINTENANCE_BASE_STATION_CLEANING_FILTER_COMPONENT + ), + ), NarwalSensorEntityDescription( key="firmware_version", translation_key="firmware_version", @@ -77,10 +148,17 @@ async def async_setup_entry( ) -> None: """Set up Narwal sensor entities.""" coordinator = entry.runtime_data + device_info = coordinator.client.state.device_info entities: list[SensorEntity] = [ - NarwalSensor(coordinator, description) for description in SENSOR_DESCRIPTIONS + NarwalSensor(coordinator, description) + for description in SENSOR_DESCRIPTIONS + if description.key != "base_station_cleaning_filter_used_hours" + or is_maintenance_alerts_supported( + entry.data, device_info.product_key if device_info else None + ) ] entities.append(NarwalChargingStateSensor(coordinator)) + entities.append(NarwalTaskStatusSensor(coordinator)) async_add_entities(entities) @@ -108,7 +186,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 +224,76 @@ 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.task_active and (state.task_paused or state.is_paused): + return "paused" + if state.is_returning: + return "returning" + if state.is_station_active: + return "station_active" + if state.task_active: + return "cleaning" + if state.is_docked: + return "docked" + if state.working_status == WorkingStatus.TASK_COMPLETED: + return "returning" + if state.is_paused and is_cleaning_status: + return "paused" + 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..8a1eab0 --- /dev/null +++ b/custom_components/narwal/services.yaml @@ -0,0 +1,118 @@ +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 + +set_dock_light: + name: Set dock light + description: Set Narwal's base station ambient light mode. + target: + entity: + integration: narwal + domain: vacuum + fields: + "on": + name: On + description: Backwards-compatible toggle. If mode is set, mode wins. + required: false + selector: + boolean: + mode: + name: Mode + required: false + selector: + select: + options: + - "off" + - "fireplace" + - "nightlight" + - "purple" + +set_led: + name: Set dock light + description: Legacy alias for Narwal base station ambient light mode. + target: + entity: + integration: narwal + domain: vacuum + fields: + "on": + name: On + description: Backwards-compatible toggle. If mode is set, mode wins. + required: false + selector: + boolean: + mode: + name: Mode + required: false + selector: + select: + options: + - "off" + - "fireplace" + - "nightlight" + - "purple" diff --git a/custom_components/narwal/strings.json b/custom_components/narwal/strings.json index 08f563c..0f15372 100644 --- a/custom_components/narwal/strings.json +++ b/custom_components/narwal/strings.json @@ -32,6 +32,28 @@ "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" + }, + "base_station_cleaning_filter_used_hours": { + "name": "Base station cleaning filter used" + }, "firmware_version": { "name": "Firmware version" }, @@ -42,6 +64,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": { @@ -52,8 +87,100 @@ "off": "Undocked" } }, + "base_station_cleaning_filter": { + "name": "Base station cleaning filter" + }, + "caster": { + "name": "Caster" + }, "charging": { "name": "Charging" + }, + "dirty_water_tank": { + "name": "Dirty water tank" + }, + "dust_bin": { + "name": "Dust bin" + }, + "dust_bin_bag": { + "name": "Dust bin / bag" + }, + "dust_bin_filter": { + "name": "Dust bin filter" + }, + "edge_sensor": { + "name": "Edge sensor" + }, + "roller_brush": { + "name": "Roller brush" + }, + "sensor": { + "name": "Sensor" + }, + "smart_water_module": { + "name": "Smart water module" + }, + "sponge_filter": { + "name": "Sponge filter" + } + }, + "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" + }, + "dock_light": { + "name": "Dock light" + } + }, + "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" + } + }, + "light": { + "dock_light": { + "name": "Dock light" } } } diff --git a/custom_components/narwal/switch.py b/custom_components/narwal/switch.py new file mode 100644 index 0000000..d5b209e --- /dev/null +++ b/custom_components/narwal/switch.py @@ -0,0 +1,105 @@ +"""Switch entities for Narwal map display options.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import EntityCategory +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import NarwalConfigEntry +from .const import ( + CONF_SHOW_FURNITURE, + CONF_SHOW_FURNITURE_LABELS, + CONF_SHOW_ROOM_LABELS, + MAP_OPTION_DEFAULTS, +) +from .coordinator import NarwalCoordinator +from .entity import NarwalEntity + + +@dataclass(frozen=True, kw_only=True) +class NarwalMapSwitchEntityDescription(SwitchEntityDescription): + """Description for a Narwal map display switch.""" + + default: bool + + +MAP_SWITCHES: tuple[NarwalMapSwitchEntityDescription, ...] = ( + NarwalMapSwitchEntityDescription( + key=CONF_SHOW_ROOM_LABELS, + translation_key=CONF_SHOW_ROOM_LABELS, + default=MAP_OPTION_DEFAULTS[CONF_SHOW_ROOM_LABELS], + ), + NarwalMapSwitchEntityDescription( + key=CONF_SHOW_FURNITURE, + translation_key=CONF_SHOW_FURNITURE, + default=MAP_OPTION_DEFAULTS[CONF_SHOW_FURNITURE], + ), + NarwalMapSwitchEntityDescription( + key=CONF_SHOW_FURNITURE_LABELS, + translation_key=CONF_SHOW_FURNITURE_LABELS, + default=MAP_OPTION_DEFAULTS[CONF_SHOW_FURNITURE_LABELS], + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: NarwalConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Narwal switch entities.""" + coordinator = entry.runtime_data + async_add_entities( + NarwalMapOptionSwitch(coordinator, description) + for description in MAP_SWITCHES + ) + + +class NarwalMapOptionSwitch(NarwalEntity, SwitchEntity): + """Persistent map display switch backed by config entry options.""" + + entity_description: NarwalMapSwitchEntityDescription + _attr_entity_category = EntityCategory.CONFIG + + def __init__( + self, + coordinator: NarwalCoordinator, + description: NarwalMapSwitchEntityDescription, + ) -> None: + """Initialize a map option switch.""" + super().__init__(coordinator) + self.entity_description = description + device_id = coordinator.config_entry.data["device_id"] + self._attr_unique_id = f"{device_id}_map_{description.key}" + + @property + def is_on(self) -> bool: + """Return the current map option value.""" + return bool( + self.coordinator.config_entry.options.get( + self.entity_description.key, + self.entity_description.default, + ) + ) + + async def async_turn_on(self, **kwargs) -> None: + """Turn the map option on.""" + await self._async_set_option(True) + + async def async_turn_off(self, **kwargs) -> None: + """Turn the map option off.""" + await self._async_set_option(False) + + async def _async_set_option(self, value: bool) -> None: + """Persist the map option and notify camera listeners.""" + entry = self.coordinator.config_entry + options = dict(entry.options) + options[self.entity_description.key] = value + self.hass.config_entries.async_update_entry(entry, options=options) + self.async_write_ha_state() + if self.coordinator.data is not None: + self.coordinator.async_update_listeners() diff --git a/custom_components/narwal/translations/en.json b/custom_components/narwal/translations/en.json index e4cb63f..03431af 100644 --- a/custom_components/narwal/translations/en.json +++ b/custom_components/narwal/translations/en.json @@ -31,6 +31,28 @@ "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" + }, + "base_station_cleaning_filter_used_hours": { + "name": "Base station cleaning filter used" + }, "firmware_version": { "name": "Firmware version" }, @@ -41,6 +63,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 +85,101 @@ "on": "Docked", "off": "Undocked" } + }, + "base_station_cleaning_filter": { + "name": "Base station cleaning filter" + }, + "caster": { + "name": "Caster" + }, + "charging": { + "name": "Charging" + }, + "dirty_water_tank": { + "name": "Dirty water tank" + }, + "dust_bin": { + "name": "Dust bin" + }, + "dust_bin_bag": { + "name": "Dust bin / bag" + }, + "dust_bin_filter": { + "name": "Dust bin filter" + }, + "edge_sensor": { + "name": "Edge sensor" + }, + "roller_brush": { + "name": "Roller brush" + }, + "sensor": { + "name": "Sensor" + }, + "smart_water_module": { + "name": "Smart water module" + }, + "sponge_filter": { + "name": "Sponge filter" + } + }, + "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" + }, + "dock_light": { + "name": "Dock light" + } + }, + "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" + } + }, + "light": { + "dock_light": { + "name": "Dock light" } } } diff --git a/custom_components/narwal/translations/fr.json b/custom_components/narwal/translations/fr.json index f7177d7..7312b83 100644 --- a/custom_components/narwal/translations/fr.json +++ b/custom_components/narwal/translations/fr.json @@ -31,6 +31,28 @@ "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" + }, + "base_station_cleaning_filter_used_hours": { + "name": "Utilisation du filtre de nettoyage de la station" + }, "firmware_version": { "name": "Version du firmware" }, @@ -41,6 +63,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 +85,101 @@ "on": "Stationné", "off": "Non stationné" } + }, + "base_station_cleaning_filter": { + "name": "Filtre de nettoyage de la station" + }, + "caster": { + "name": "Roulette" + }, + "charging": { + "name": "Charge" + }, + "dirty_water_tank": { + "name": "Réservoir d’eau sale" + }, + "dust_bin": { + "name": "Bac à poussière" + }, + "dust_bin_bag": { + "name": "Bac / sac à poussière" + }, + "dust_bin_filter": { + "name": "Filtre du bac à poussière" + }, + "edge_sensor": { + "name": "Capteur latéral" + }, + "roller_brush": { + "name": "Brosse principale" + }, + "sensor": { + "name": "Capteur" + }, + "smart_water_module": { + "name": "Module d’eau intelligent" + }, + "sponge_filter": { + "name": "Filtre éponge" + } + }, + "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" + }, + "dock_light": { + "name": "Dock light" + } + }, + "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" + } + }, + "light": { + "dock_light": { + "name": "Dock light" } } } diff --git a/custom_components/narwal/vacuum.py b/custom_components/narwal/vacuum.py index 95073a5..bdaf88a 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,32 +110,36 @@ 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.working_status == WorkingStatus.ERROR: + return VacuumActivity.ERROR + is_cleaning_state = ( + state.working_status in ACTIVE_CLEANING_STATUSES + or state.has_recent_active_working_status + or state.task_active ) # is_paused (field 3.2) stays stale after docking — only trust # during cleaning states. Paused takes priority over returning # since the robot physically stops when paused mid-return. - if state.is_paused and is_cleaning_state: + if state.task_active and (state.is_paused or state.task_paused): return VacuumActivity.PAUSED - # Check returning before cleaning — robot keeps working_status=CLEANING - # while navigating back to dock (field 3.7=1 indicates returning) + # Robot keeps working_status=CLEANING while navigating back to dock + # (field 3.7=1 indicates returning). if state.is_returning: return VacuumActivity.RETURNING - if state.is_cleaning: + if state.task_active: return VacuumActivity.CLEANING if state.is_docked: return VacuumActivity.DOCKED + if state.is_paused and is_cleaning_state: + return VacuumActivity.PAUSED + if state.is_cleaning: + return VacuumActivity.CLEANING 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 @@ -137,19 +174,29 @@ async def async_start(self) -> None: """Start or resume cleaning.""" 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, - ) - if is_cleaning and state.is_paused: - await self.coordinator.client.resume(timeout=self._ACTION_TIMEOUT) + if state and state.task_active and (state.is_paused or state.task_paused): + resp = await self.coordinator.client.resume(timeout=self._ACTION_TIMEOUT) + _LOGGER.info( + "Resume command response: code=%s, success=%s", + resp.result_code, resp.success, + ) + if resp.success: + state.mark_task_resumed() + self.coordinator.client._last_active_working_status_time = ( + state.last_active_working_status_time + ) + self.coordinator.async_set_updated_data(state) else: resp = await self.coordinator.client.start() _LOGGER.info( "Start command response: code=%s, success=%s", resp.result_code, resp.success, ) - if not resp.success: + if resp.success: + self.coordinator.async_set_updated_data( + self.coordinator.client.state + ) + else: _LOGGER.warning( "Start command did not succeed (code=%s) — robot may not have started", resp.result_code, @@ -160,11 +207,17 @@ async def async_stop(self, **kwargs) -> None: await self._ensure_awake() resp = await self.coordinator.client.stop() _LOGGER.info("Stop response: code=%s, success=%s", resp.result_code, resp.success) + if resp.success: + self.coordinator.client.state.clear_active_task() + self.coordinator.async_set_updated_data(self.coordinator.client.state) async def async_pause(self) -> None: """Pause cleaning.""" resp = await self.coordinator.client.pause() _LOGGER.info("Pause response: code=%s, success=%s", resp.result_code, resp.success) + if resp.success: + self.coordinator.client.state.mark_task_paused() + self.coordinator.async_set_updated_data(self.coordinator.client.state) async def async_return_to_base(self, **kwargs) -> None: """Return to the dock.""" @@ -178,6 +231,9 @@ async def async_return_to_base(self, **kwargs) -> None: _LOGGER.warning( "Return-to-base did not succeed (code=%s)", resp.result_code, ) + elif self.coordinator.client.state.task_active: + self.coordinator.client.state.mark_task_paused() + self.coordinator.async_set_updated_data(self.coordinator.client.state) async def async_locate(self, **kwargs) -> None: """Locate the vacuum — robot says 'Robot is here'.""" @@ -189,7 +245,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 +295,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: @@ -249,6 +321,10 @@ async def async_clean_segments( "Try again after the robot is idle on the dock.", result_name, resp.result_code, room_ids, ) + else: + self.coordinator.async_set_updated_data( + self.coordinator.client.state + ) @callback def _handle_coordinator_update(self) -> None: diff --git a/narwal_client/__init__.py b/narwal_client/__init__.py index 8eb1f06..52e22a9 100644 --- a/narwal_client/__init__.py +++ b/narwal_client/__init__.py @@ -1,8 +1,27 @@ """Narwal robot vacuum client library — local WebSocket API.""" from .client import NarwalClient, NarwalCommandError, NarwalConnectionError -from .const import CommandResult, FanLevel, MopHumidity, WorkingStatus -from .models import CommandResponse, DeviceInfo, MapData, MapDisplayData, NarwalState, RoomInfo +from .const import ( + AmbientLightCtrlType, + CleaningRoute, + CommandResult, + FanLevel, + MopHumidity, + MopStrengthLevel, + WorkMode, + WorkingStatus, +) +from .models import ( + MAINTENANCE_BASE_STATION_CLEANING_FILTER, + MAINTENANCE_BASE_STATION_CLEANING_FILTER_COMPONENT, + MAINTENANCE_COMPONENT_IDS, + CommandResponse, + DeviceInfo, + MapData, + MapDisplayData, + NarwalState, + RoomInfo, +) from .protocol import build_frame, parse_frame __all__ = [ @@ -10,14 +29,21 @@ "NarwalCommandError", "NarwalConnectionError", "NarwalState", + "MAINTENANCE_BASE_STATION_CLEANING_FILTER", + "MAINTENANCE_BASE_STATION_CLEANING_FILTER_COMPONENT", + "MAINTENANCE_COMPONENT_IDS", "CommandResponse", + "AmbientLightCtrlType", "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..21554cc 100644 --- a/narwal_client/client.py +++ b/narwal_client/client.py @@ -16,42 +16,62 @@ 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_AMBIENT_LIGHT_CTRL, 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, + AmbientLightCtrlType, + CleaningRoute, CommandResult, FanLevel, MopHumidity, + MopStrengthLevel, + WorkingStatus, + WorkMode, ) from .models import CommandResponse, DeviceInfo, MapData, MapDisplayData, NarwalState from .protocol import ( @@ -64,6 +84,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 +216,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 +254,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 +443,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 +524,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 +603,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 +623,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 +684,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 +894,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 +911,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 +1063,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" @@ -902,6 +1116,19 @@ async def locate(self) -> CommandResponse: # start() falls back to the v2 room-list schema. See issue #36. _DEFAULT_CLEAN_PAYLOAD = bytes.fromhex("0a0e12002a0a0a060803100218012a00") + def _apply_clean_start_response( + self, response: CommandResponse + ) -> CommandResponse: + """Normalize accepted clean responses and mark the task active.""" + if response.result_code == 0 and "1" in response.data: + response.result_code = CommandResult.SUCCESS + if response.success: + self.state.mark_active_cleaning() + self._last_active_working_status_time = ( + self.state.last_active_working_status_time + ) + return response + async def start(self, **kwargs) -> CommandResponse: """Start whole-house cleaning. @@ -915,12 +1142,12 @@ 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, ) if resp.result_code != CommandResult.NOT_APPLICABLE: - return resp + return self._apply_clean_start_response(resp) # Legacy payload rejected — likely newer firmware. Need the room # list from the cached map to build a v2 payload. @@ -929,23 +1156,24 @@ async def start(self, **kwargs) -> CommandResponse: "start() got NOT_APPLICABLE and no map rooms cached; " "cannot build v2 payload. Call get_map() first." ) - return resp + return self._apply_clean_start_response(resp) room_ids = [r.room_id for r in self.state.map_data.rooms if r.room_id] if not room_ids: _LOGGER.warning( "start() got NOT_APPLICABLE but cached map has 0 rooms with IDs" ) - return resp + return self._apply_clean_start_response(resp) _LOGGER.info( "start(): legacy payload rejected, retrying with v2 schema (%d rooms)", 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 self._apply_clean_start_response(resp) def _build_clean_payload_v2( self, @@ -958,9 +1186,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 +1270,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 +1383,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,54 +1405,144 @@ 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, - ) - if resp.result_code != CommandResult.NOT_APPLICABLE: - 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), - ) - payload_legacy = self._build_room_clean_payload(room_ids) - return await self.send_command( - TOPIC_CMD_START_CLEAN, payload=payload_legacy, 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 + and supports_legacy_room_clean + ): + _LOGGER.info( + "start_rooms: clean/start_clean rejected, trying " + "clean/plan/start compatibility payloads" + ) + 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: + resp = await self.send_command( + TOPIC_CMD_PLAN_START, + payload=self._build_room_clean_payload(room_ids), + timeout=10.0, + ) + return self._apply_clean_start_response(resp) async def start_easy_clean(self) -> CommandResponse: """Start quick/easy clean.""" @@ -1196,27 +1573,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 +1633,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 +1690,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 +1724,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) @@ -1320,18 +1776,50 @@ async def take_picture(self) -> bytes | None: _LOGGER.warning("take_picture returned result_code=%d", resp.result_code) return None - async def set_led(self, on: bool) -> None: - """Turn the camera LED fill light on or off. + async def set_led(self, on: bool) -> CommandResponse | None: + """Turn the camera LED fill light on or off.""" + return await self.set_led_mode(1 if on else 0) + + async def set_led_mode(self, mode: int) -> CommandResponse | None: + """Set the camera LED fill mode. - Payload: 0x08 0x01 = on, 0x08 0x00 = off (protobuf field 1, varint). + Payload: protobuf field 1, varint. """ - payload = b"\x08\x01" if on else b"\x08\x00" + payload = b"\x08" + bytes([mode & 0x7F]) try: resp = await self.send_command(TOPIC_CMD_SET_LED, payload=payload) except Exception: - _LOGGER.warning("set_led(%s) command failed", on) - return + _LOGGER.warning("set_led_mode(%s) command failed", mode) + return None if resp.result_code not in (CommandResult.SUCCESS, CommandResult.NOT_APPLICABLE): _LOGGER.warning( - "set_led(%s) unexpected result_code=%d", on, resp.result_code + "set_led_mode(%s) unexpected result_code=%d", mode, resp.result_code ) + return resp + + async def set_ambient_light_mode( + self, mode: AmbientLightCtrlType | int + ) -> CommandResponse | None: + """Set the base station ambient light mode.""" + mode = AmbientLightCtrlType(mode) + payload = self._encode_varint_field(1, int(mode)) + try: + resp = await self.send_command( + TOPIC_CMD_AMBIENT_LIGHT_CTRL, + payload=payload, + ) + except Exception: + _LOGGER.warning("set_ambient_light_mode(%s) command failed", mode) + return None + if resp.result_code not in ( + 0, + CommandResult.SUCCESS, + CommandResult.NOT_APPLICABLE, + CommandResult.APPLIED, + ): + _LOGGER.warning( + "set_ambient_light_mode(%s) unexpected result_code=%d", + mode, + resp.result_code, + ) + return resp diff --git a/narwal_client/const.py b/narwal_client/const.py index 0c877c7..2da3471 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 @@ -76,18 +83,26 @@ TOPIC_CMD_CANCEL = "task/cancel" # Supply/dock +TOPIC_CMD_AMBIENT_LIGHT_CTRL = "supply/ambient_light_ctrl" 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 +156,17 @@ 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) + APPLIED = 6 # observed on ambient light commands after the dock light changes + + +class AmbientLightCtrlType(IntEnum): + """Base station ambient light mode.""" + + OFF = 0 + NIGHT_LIGHT = 1 + WINTER_WARMTH = 2 + PURPLE_LIGHT = 3 class WorkingStatus(IntEnum): @@ -149,8 +175,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 +196,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 +208,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 - DRY = 0 + +class MopStrengthLevel(IntEnum): + """Mop scrub intensity (CleanParam tag 3).""" + + 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/map_renderer.py b/narwal_client/map_renderer.py index 59cce62..2154f19 100644 --- a/narwal_client/map_renderer.py +++ b/narwal_client/map_renderer.py @@ -16,6 +16,7 @@ import io import logging +import math import zlib _LOGGER = logging.getLogger(__name__) @@ -98,10 +99,116 @@ OBSTACLE_COLOR_DEFAULT = (200, 200, 200) # Special pixel colors -COLOR_UNKNOWN = (40, 40, 40) # outside map / unmapped +COLOR_UNKNOWN = (0, 0, 0, 0) # outside map / unmapped, transparent COLOR_UNASSIGNED_FLOOR = (200, 200, 200) # floor not assigned to a room COLOR_UNASSIGNED_OBSTACLE = (80, 80, 80) # obstacle not in a room COLOR_FALLBACK = (180, 180, 180) # unknown room ID +MAP_RENDER_SCALE = 3 +ROOM_LABEL_FONT_SCALE = 10 +OBSTACLE_LABEL_FONT_SCALE = 6 +FONT_PATHS = ( + "/usr/share/fonts/truetype/dejavu/DejaVuSans-SemiBold.ttf", + "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", + "arial.ttf", +) + + +def _opaque(color: tuple[int, int, int]) -> tuple[int, int, int, int]: + """Return an RGB color with a fully opaque alpha channel.""" + return (*color, 255) + + +def _load_font(image_font: object, size: int): + """Load a crisp TrueType font, falling back to Pillow's default font.""" + for path in FONT_PATHS: + try: + return image_font.truetype(path, size) + except (IOError, OSError): + continue + return image_font.load_default() + + +def _scaled_coord(value: float, scale: float, size: int) -> int: + """Return a scaled grid coordinate centred in the rendered map cell.""" + coordinate = int(round(value * scale + (scale / 2))) + return max(0, min(coordinate, size - 1)) + + +def _draw_label( + draw: "ImageDraw.ImageDraw", + xy: tuple[int, int], + text: str, + font: object, + *, + fill: tuple[int, int, int] = (255, 255, 255), + stroke_fill: tuple[int, int, int] = (20, 20, 20), + padding: int = 4, +) -> None: + """Draw a readable centred map label.""" + cx, cy = xy + bbox = font.getbbox(text) + tw = bbox[2] - bbox[0] + th = bbox[3] - bbox[1] + tx = cx - tw // 2 + ty = cy - th // 2 + bg = [ + tx - padding, + ty - padding, + tx + tw + padding, + ty + th + padding, + ] + draw.rounded_rectangle(bg, radius=padding * 2, fill=(0, 0, 0, 120)) + draw.text( + (tx, ty), + text, + fill=fill, + font=font, + stroke_width=max(1, padding // 2), + stroke_fill=stroke_fill, + ) + + +def _normalize_rotation(rotation_degrees: int) -> int: + """Return a rotation supported by both bitmap and point transforms.""" + rotation = int(rotation_degrees or 0) % 360 + return rotation if rotation in (0, 90, 180, 270) else 0 + + +def _transform_point( + x: float, + y: float, + width: int, + height: int, + rotation_degrees: int, + zoom: float, +) -> tuple[float, float] | None: + """Transform an unrotated image-space point into final view coordinates.""" + rotation = _normalize_rotation(rotation_degrees) + if rotation == 0: + rx, ry = x, y + rotated_width, rotated_height = width, height + elif rotation == 90: + rx, ry = height - 1 - y, x + rotated_width, rotated_height = height, width + elif rotation == 180: + rx, ry = width - 1 - x, height - 1 - y + rotated_width, rotated_height = width, height + elif rotation == 270: + rx, ry = y, width - 1 - x + rotated_width, rotated_height = height, width + zoom = max(1.0, min(2.0, float(zoom or 1.0))) + crop_width = max(1, int(rotated_width / zoom)) + crop_height = max(1, int(rotated_height / zoom)) + left = max(0, (rotated_width - crop_width) // 2) + top = max(0, (rotated_height - crop_height) // 2) + return rx - left, ry - top + + +def _rotated_heading(heading: float | None, rotation_degrees: int) -> float | None: + """Rotate robot heading to match the final clockwise map rotation.""" + if heading is None: + return None + return (heading - _normalize_rotation(rotation_degrees)) % 360 def decompress_map(compressed: bytes) -> bytes: @@ -222,6 +329,55 @@ def lookup_room_at_grid( return (room_id, f"room_{room_id}{wall}") +def room_label_points( + compressed: bytes, + width: int, + height: int, + room_names: dict[int, str] | None, +) -> list[tuple[str, float, float]]: + """Return room label centre points in unflipped grid coordinates.""" + if not compressed or width <= 0 or height <= 0 or not room_names: + return [] + + decompressed = decompress_map(compressed) + if not decompressed: + return [] + + pixels = _decode_packed_varints(decompressed) + expected = width * height + if len(pixels) < expected: + pixels.extend([0] * (expected - len(pixels))) + elif len(pixels) > expected: + pixels = pixels[:expected] + + room_sum_x: dict[int, int] = {} + room_sum_y: dict[int, int] = {} + room_count: dict[int, int] = {} + for i, val in enumerate(pixels): + if val == 0 or val in (0x20, 0x28): + continue + room_id = val >> 8 + ptype = val & 0xFF + if room_id not in room_names or ptype & 0x10: + continue + x = i % width + y = i // width + room_sum_x[room_id] = room_sum_x.get(room_id, 0) + x + room_sum_y[room_id] = room_sum_y.get(room_id, 0) + y + room_count[room_id] = room_count.get(room_id, 0) + 1 + + points: list[tuple[str, float, float]] = [] + for room_id, name in room_names.items(): + if not name or room_id not in room_count: + continue + points.append(( + name, + room_sum_x[room_id] / room_count[room_id], + room_sum_y[room_id] / room_count[room_id], + )) + return points + + def _darken(color: tuple[int, int, int], amount: int = 80) -> tuple[int, int, int]: """Darken an RGB color by subtracting from each channel.""" return ( @@ -237,15 +393,37 @@ def _draw_dock( dock_y: int, size: int = 6, ) -> None: - """Draw a dock/charging station icon at the given grid coordinates. - - Renders as a small white filled circle (matching the Narwal app style). - """ - radius = size // 2 + """Draw a small Flow-style dock marker at the given image coordinates.""" + half_w = max(5, size // 2) + half_h = max(4, int(size * 0.42)) + outline_width = max(1, size // 12) + draw.rounded_rectangle( + [dock_x - half_w, dock_y - half_h, dock_x + half_w, dock_y + half_h], + radius=max(3, size // 5), + fill=(248, 249, 250, 245), + outline=(90, 96, 104, 220), + width=outline_width, + ) + slot_h = max(2, size // 7) + draw.rounded_rectangle( + [ + dock_x - int(half_w * 0.55), + dock_y - slot_h, + dock_x + int(half_w * 0.55), + dock_y + slot_h, + ], + radius=max(1, slot_h), + fill=(38, 42, 48, 235), + ) + led = max(1, size // 10) draw.ellipse( - [dock_x - radius, dock_y - radius, dock_x + radius, dock_y + radius], - fill=(255, 255, 255), - outline=(180, 180, 180), + [ + dock_x + int(half_w * 0.58) - led, + dock_y + int(half_h * 0.35) - led, + dock_x + int(half_w * 0.58) + led, + dock_y + int(half_h * 0.35) + led, + ], + fill=(80, 190, 120, 255), ) @@ -256,7 +434,7 @@ def _draw_robot( heading: float | None, radius: int, ) -> None: - """Draw robot position with optional heading arrow. + """Draw a small Narwal Flow-style robot marker. Args: draw: PIL ImageDraw instance. @@ -268,27 +446,75 @@ def _draw_robot( """ import math - # Blue filled circle with white outline + outline_width = max(1, radius // 8) + + if heading is None: + heading = 90 + rad = math.radians(heading) + front_dx = math.cos(rad) + front_dy = -math.sin(rad) + side_dx = -front_dy + side_dy = front_dx + + def point(forward: float, side: float) -> tuple[float, float]: + return ( + rx + (front_dx * forward) + (side_dx * side), + ry + (front_dy * forward) + (side_dy * side), + ) + + # Soft shadow/halo for contrast on bright room colours. + draw.ellipse( + [ + rx - radius - 2, + ry - radius - 2, + rx + radius + 2, + ry + radius + 2, + ], + fill=(0, 0, 0, 55), + ) + + # Main circular body. draw.ellipse( [rx - radius, ry - radius, rx + radius, ry + radius], - fill=(0, 120, 255), - outline=(255, 255, 255), + fill=(246, 247, 248, 245), + outline=(96, 102, 110, 210), + width=outline_width, ) - # Heading arrow — white line from center in heading direction - if heading is not None: - # Convert degrees to radians. Heading 0=right, 90=up in world coords. - # Image Y is flipped (down = positive), so negate the Y component. - rad = math.radians(heading) - arrow_len = radius * 2.5 - dx = math.cos(rad) * arrow_len - dy = -math.sin(rad) * arrow_len # negate for image Y-down - draw.line( - [(rx, ry), (rx + dx, ry + dy)], - fill=(255, 255, 255), - width=2, + # Flow-style front sensor bar, rotated with heading. + front_centre = point(radius * 0.58, 0) + bar_half_width = radius * 0.38 + bar_half_height = max(2, radius * 0.13) + bar = [ + point(radius * 0.58 - bar_half_height, -bar_half_width), + point(radius * 0.58 + bar_half_height, -bar_half_width), + point(radius * 0.58 + bar_half_height, bar_half_width), + point(radius * 0.58 - bar_half_height, bar_half_width), + ] + draw.polygon(bar, fill=(30, 33, 38, 245)) + lens_radius = max(1, radius // 8) + for side in (-bar_half_width * 0.55, bar_half_width * 0.55): + lx, ly = point(radius * 0.58, side) + draw.ellipse( + [lx - lens_radius, ly - lens_radius, lx + lens_radius, ly + lens_radius], + fill=(85, 160, 225, 255), ) + # Top dial and subtle heading notch. + dial_radius = max(2, radius // 3) + draw.ellipse( + [rx - dial_radius, ry - dial_radius, rx + dial_radius, ry + dial_radius], + fill=(232, 234, 236, 255), + outline=(150, 155, 160, 230), + width=max(1, outline_width // 2), + ) + notch = [ + point(radius * 0.95, 0), + point(radius * 0.55, -radius * 0.16), + point(radius * 0.55, radius * 0.16), + ] + draw.polygon(notch, fill=(255, 255, 255, 235)) + def render_map_png( decompressed: bytes, @@ -346,7 +572,7 @@ def render_map_png( elif len(pixels) > expected: pixels = pixels[:expected] - img = Image.new("RGB", (width, height), COLOR_UNKNOWN) + img = Image.new("RGBA", (width, height), COLOR_UNKNOWN) px = img.load() # Track room pixel sums for centroid computation @@ -361,9 +587,9 @@ def render_map_png( if val == 0: continue # already set to COLOR_UNKNOWN elif val == 0x20: - px[x, y] = COLOR_UNASSIGNED_FLOOR + px[x, y] = _opaque(COLOR_UNASSIGNED_FLOOR) elif val == 0x28: - px[x, y] = COLOR_UNASSIGNED_OBSTACLE + px[x, y] = _opaque(COLOR_UNASSIGNED_OBSTACLE) else: room_id = val >> 8 ptype = val & 0xFF @@ -374,9 +600,9 @@ def render_map_png( base = COLOR_FALLBACK if ptype & 0x10: # wall/border edge - px[x, y] = _darken(base) + px[x, y] = _opaque(_darken(base)) else: - px[x, y] = base + px[x, y] = _opaque(base) # Accumulate for centroid (floor pixels only, not walls) if room_names and room_id in room_names and not (ptype & 0x10): @@ -388,43 +614,70 @@ def render_map_png( # Y increasing upward (math coordinates) but images render Y downward. # Overlays (labels, dock, robot) use flipped coordinates so text is right-side up. img = img.transpose(Image.FLIP_TOP_BOTTOM) + scale = MAP_RENDER_SCALE + if scale > 1: + img = img.resize( + (width * scale, height * scale), + getattr(Image, "Resampling", Image).NEAREST, + ) draw = ImageDraw.Draw(img) + scaled_height = height * scale # Draw room labels at flipped centroids if room_names: - try: - font = ImageFont.truetype("arial.ttf", 10) - except (IOError, OSError): - font = ImageFont.load_default() + font = _load_font(ImageFont, ROOM_LABEL_FONT_SCALE * scale) for rid, name in room_names.items(): if not name or rid not in room_count: continue - cx = room_sum_x[rid] // room_count[rid] - cy = height - 1 - (room_sum_y[rid] // room_count[rid]) - bbox = font.getbbox(name) - tw = bbox[2] - bbox[0] - th = bbox[3] - bbox[1] - tx = cx - tw // 2 - ty = cy - th // 2 - # Dark outline for readability - for ox, oy in [(-1, 0), (1, 0), (0, -1), (0, 1)]: - draw.text((tx + ox, ty + oy), name, fill=(0, 0, 0), font=font) - draw.text((tx, ty), name, fill=(255, 255, 255), font=font) + cx = _scaled_coord( + room_sum_x[rid] // room_count[rid], scale, img.width + ) + cy = _scaled_coord( + height - 1 - (room_sum_y[rid] // room_count[rid]), + scale, + img.height, + ) + _draw_label( + draw, + (cx, cy), + name, + font=font, + padding=max(3, scale), + ) # Draw dock position (before robot so robot draws on top) # Flip dock Y to match the flipped image - if dock_x is not None and dock_y is not None: - dock_size = max(4, min(width, height) // 60) - _draw_dock(draw, int(dock_x), height - 1 - int(dock_y), dock_size) + marker_radius = max(3 * scale, min(width, height) * scale // 80) + + if ( + dock_x is not None + and dock_y is not None + and math.isfinite(dock_x) + and math.isfinite(dock_y) + and 0 <= dock_x < width + and 0 <= dock_y < height + ): + dock_size = marker_radius * 2 + _draw_dock( + draw, + _scaled_coord(dock_x, scale, img.width), + _scaled_coord(height - 1 - dock_y, scale, img.height), + dock_size, + ) # Draw robot position (flip Y) — skip if out of bounds - if robot_x is not None and robot_y is not None: - rx = int(robot_x) - ry = height - 1 - int(robot_y) - if 0 <= rx < width and 0 <= ry < height: - radius = max(3, min(width, height) // 80) - _draw_robot(draw, rx, ry, robot_heading, radius) + if ( + robot_x is not None + and robot_y is not None + and math.isfinite(robot_x) + and math.isfinite(robot_y) + and 0 <= robot_x < width + and 0 <= robot_y < height + ): + rx = _scaled_coord(robot_x, scale, img.width) + ry = _scaled_coord(height - 1 - robot_y, scale, scaled_height) + _draw_robot(draw, rx, ry, robot_heading, marker_radius) buf = io.BytesIO() img.save(buf, format="PNG") @@ -441,6 +694,9 @@ def render_base_map( obstacles: "list | None" = None, origin_x: int = 0, origin_y: int = 0, + show_obstacle_labels: bool = True, + show_room_labels: bool = True, + show_dock: bool = True, ) -> "Image.Image | None": """Render the static floor plan as a PIL Image (no robot overlay). @@ -451,6 +707,9 @@ def render_base_map( obstacles: List of ObstacleInfo objects to render (optional). origin_x: Map origin X offset for obstacle coordinate transform. origin_y: Map origin Y offset for obstacle coordinate transform. + show_obstacle_labels: Whether to draw furniture/obstacle labels. + show_room_labels: Whether to draw room labels into the base image. + show_dock: Whether to draw the dock into the base image. """ try: from PIL import Image, ImageDraw, ImageFont @@ -470,7 +729,7 @@ def render_base_map( elif len(pixels) > expected: pixels = pixels[:expected] - img = Image.new("RGB", (width, height), COLOR_UNKNOWN) + img = Image.new("RGBA", (width, height), COLOR_UNKNOWN) px = img.load() room_sum_x: dict[int, int] = {} @@ -484,9 +743,9 @@ def render_base_map( if val == 0: continue elif val == 0x20: - px[x, y] = COLOR_UNASSIGNED_FLOOR + px[x, y] = _opaque(COLOR_UNASSIGNED_FLOOR) elif val == 0x28: - px[x, y] = COLOR_UNASSIGNED_OBSTACLE + px[x, y] = _opaque(COLOR_UNASSIGNED_OBSTACLE) else: room_id = val >> 8 ptype = val & 0xFF @@ -497,9 +756,9 @@ def render_base_map( base = COLOR_FALLBACK if ptype & 0x10: - px[x, y] = _darken(base) + px[x, y] = _opaque(_darken(base)) else: - px[x, y] = base + px[x, y] = _opaque(base) if room_names and room_id in room_names and not (ptype & 0x10): room_sum_x[room_id] = room_sum_x.get(room_id, 0) + x @@ -507,62 +766,82 @@ def render_base_map( room_count[room_id] = room_count.get(room_id, 0) + 1 img = img.transpose(Image.FLIP_TOP_BOTTOM) + scale = MAP_RENDER_SCALE + if scale > 1: + img = img.resize( + (width * scale, height * scale), + getattr(Image, "Resampling", Image).NEAREST, + ) draw = ImageDraw.Draw(img) - if room_names: - try: - font = ImageFont.truetype("arial.ttf", 10) - except (IOError, OSError): - font = ImageFont.load_default() + if room_names and show_room_labels: + font = _load_font(ImageFont, ROOM_LABEL_FONT_SCALE * scale) for rid, name in room_names.items(): if not name or rid not in room_count: continue - cx = room_sum_x[rid] // room_count[rid] - cy = height - 1 - (room_sum_y[rid] // room_count[rid]) - bbox = font.getbbox(name) - tw = bbox[2] - bbox[0] - th = bbox[3] - bbox[1] - tx = cx - tw // 2 - ty = cy - th // 2 - for ox, oy in [(-1, 0), (1, 0), (0, -1), (0, 1)]: - draw.text((tx + ox, ty + oy), name, fill=(0, 0, 0), font=font) - draw.text((tx, ty), name, fill=(255, 255, 255), font=font) + cx = _scaled_coord( + room_sum_x[rid] // room_count[rid], scale, img.width + ) + cy = _scaled_coord( + height - 1 - (room_sum_y[rid] // room_count[rid]), + scale, + img.height, + ) + _draw_label( + draw, + (cx, cy), + name, + font=font, + padding=max(3, scale), + ) # Draw obstacle/furniture annotations (static data from get_map field 2.32) if obstacles: - try: - obs_font = ImageFont.truetype("arial.ttf", 8) - except (IOError, OSError): - obs_font = ImageFont.load_default() + obs_font = _load_font(ImageFont, OBSTACLE_LABEL_FONT_SCALE * scale) for obs in obstacles: gx, gy = obs.to_grid_coords(origin_x, origin_y) # Skip out-of-bounds obstacles if gx < 0 or gx >= width or gy < 0 or gy >= height: continue - img_x = int(gx) - img_y = height - 1 - int(gy) - half_w = max(1, int(obs.width / 2)) - half_h = max(1, int(obs.height / 2)) + img_x = _scaled_coord(gx, scale, img.width) + img_y = _scaled_coord(height - 1 - gy, scale, img.height) + half_w = max(scale, int(obs.width * scale / 2)) + half_h = max(scale, int(obs.height * scale / 2)) color = OBSTACLE_COLORS.get(obs.type_id, OBSTACLE_COLOR_DEFAULT) draw.rectangle( [img_x - half_w, img_y - half_h, img_x + half_w, img_y + half_h], - outline=color, width=1, + outline=color, width=max(1, scale // 2), ) - # Draw label centered above the rectangle - label = obs.display_name - bbox = obs_font.getbbox(label) - tw = bbox[2] - bbox[0] - th = bbox[3] - bbox[1] - lx = img_x - tw // 2 - ly = img_y - half_h - th - 2 - # Dark outline for readability - for ox, oy in [(-1, 0), (1, 0), (0, -1), (0, 1)]: - draw.text((lx + ox, ly + oy), label, fill=(0, 0, 0), font=obs_font) - draw.text((lx, ly), label, fill=color, font=obs_font) - - if dock_x is not None and dock_y is not None: - dock_size = max(4, min(width, height) // 60) - _draw_dock(draw, int(dock_x), height - 1 - int(dock_y), dock_size) + if show_obstacle_labels: + label = obs.display_name + bbox = obs_font.getbbox(label) + th = bbox[3] - bbox[1] + _draw_label( + draw, + (img_x, img_y - half_h - th - (3 * scale)), + label, + fill=color, + font=obs_font, + padding=max(2, scale), + ) + + if ( + show_dock + and dock_x is not None + and dock_y is not None + and math.isfinite(dock_x) + and math.isfinite(dock_y) + and 0 <= dock_x < width + and 0 <= dock_y < height + ): + marker_radius = max(3 * scale, min(width, height) * scale // 80) + dock_size = marker_radius * 2 + _draw_dock( + draw, + _scaled_coord(dock_x, scale, img.width), + _scaled_coord(height - 1 - dock_y, scale, img.height), + dock_size, + ) return img @@ -574,6 +853,11 @@ def render_overlay( robot_y: float | None = None, robot_heading: float | None = None, trail: list[tuple[float, float]] | None = None, + rotation_degrees: int = 0, + zoom: float = 1.0, + room_labels: list[tuple[str, float, float]] | None = None, + dock_x: float | None = None, + dock_y: float | None = None, ) -> bytes: """Draw robot position and trail on a copy of the cached base map. @@ -584,41 +868,158 @@ def render_overlay( robot_y: Robot Y in grid coordinates. robot_heading: Heading in degrees. trail: List of (grid_x, grid_y) positions to draw as cleaning path. + rotation_degrees: Clockwise map rotation in degrees. + zoom: Centre zoom factor. + room_labels: Room label centre points in grid coordinates. + dock_x: Dock X position in grid coordinates. + dock_y: Dock Y position in grid coordinates. Returns: PNG bytes of the composited image. """ - from PIL import ImageDraw + from PIL import ImageDraw, ImageFont img = base_img.copy() - draw = ImageDraw.Draw(img) - width = img.width + original_width = img.width + original_height = img.height + scale = img.height / height if height > 0 else 1.0 + map_width = original_width / scale if scale > 0 else original_width + max_grid_segment = max(24.0, min(map_width, height) * 0.18) + + img = _apply_view_transform(img, rotation_degrees, zoom) + draw = ImageDraw.Draw(img, "RGBA") + + def is_valid_grid_point(grid_x: float, grid_y: float) -> bool: + return ( + math.isfinite(grid_x) + and math.isfinite(grid_y) + and 0 <= grid_x < map_width + and 0 <= grid_y < height + ) - # Draw trail (blue path showing where robot has cleaned) + def final_point(grid_x: float, grid_y: float) -> tuple[int, int] | None: + if not is_valid_grid_point(grid_x, grid_y): + return None + x = _scaled_coord(grid_x, scale, original_width) + y = _scaled_coord(height - 1 - grid_y, scale, original_height) + transformed = _transform_point( + x, y, original_width, original_height, rotation_degrees, zoom + ) + if transformed is None: + return None + tx, ty = transformed + return int(round(tx)), int(round(ty)) + + # Draw trail, splitting at invalid samples or impossible jumps. if trail and len(trail) >= 2: recent_start = max(len(trail) - 200, 0) + trail_width = max(3, int(round(2 * scale))) + previous_grid: tuple[float, float] | None = None + previous_point: tuple[int, int] | None = None for i in range(len(trail) - 1): - if i >= recent_start: - color = (30, 120, 255) # bright blue for recent - else: - color = (15, 60, 130) # dim blue for older - x1, y1 = int(trail[i][0]), height - 1 - int(trail[i][1]) - x2, y2 = int(trail[i + 1][0]), height - 1 - int(trail[i + 1][1]) - draw.line([(x1, y1), (x2, y2)], fill=color, width=2) + grid_x, grid_y = trail[i] + if not is_valid_grid_point(grid_x, grid_y): + previous_grid = None + previous_point = None + continue + + point = final_point(grid_x, grid_y) + if point is None: + previous_grid = None + previous_point = None + continue + + if previous_grid is not None and previous_point is not None: + distance = math.hypot( + grid_x - previous_grid[0], + grid_y - previous_grid[1], + ) + if distance <= max_grid_segment: + color = ( + (255, 255, 255, 220) + if i >= recent_start + else (215, 220, 225, 130) + ) + draw.line([previous_point, point], fill=color, width=trail_width) + + previous_grid = (grid_x, grid_y) + previous_point = point + + last_grid_x, last_grid_y = trail[-1] + if previous_grid is not None and is_valid_grid_point(last_grid_x, last_grid_y): + last_point = final_point(last_grid_x, last_grid_y) + if last_point is not None: + distance = math.hypot( + last_grid_x - previous_grid[0], + last_grid_y - previous_grid[1], + ) + if distance <= max_grid_segment: + draw.line( + [previous_point, last_point], + fill=(255, 255, 255, 220), + width=trail_width, + ) + + if room_labels: + font = _load_font(ImageFont, ROOM_LABEL_FONT_SCALE * int(round(scale))) + for label, grid_x, grid_y in room_labels: + point = final_point(grid_x, grid_y) + if point is None: + continue + x, y = point + if -80 <= x <= img.width + 80 and -80 <= y <= img.height + 80: + _draw_label(draw, (x, y), label, font, padding=max(4, int(scale))) + + if dock_x is not None and dock_y is not None: + point = final_point(dock_x, dock_y) + if point is not None: + dx, dy = point + if 0 <= dx < img.width and 0 <= dy < img.height: + robot_radius = max(5 * int(round(scale)), min(img.width, img.height) // 64) + dock_size = robot_radius * 2 + _draw_dock(draw, dx, dy, dock_size) # Draw robot if robot_x is not None and robot_y is not None: - rx = int(robot_x) - ry = height - 1 - int(robot_y) - if 0 <= rx < width and 0 <= ry < height: - radius = max(3, min(width, height) // 80) - _draw_robot(draw, rx, ry, robot_heading, radius) + point = final_point(robot_x, robot_y) + if point is not None: + rx, ry = point + if 0 <= rx < img.width and 0 <= ry < img.height: + radius = max(5 * int(round(scale)), min(img.width, img.height) // 64) + _draw_robot( + draw, + rx, + ry, + _rotated_heading(robot_heading, rotation_degrees), + radius, + ) buf = io.BytesIO() img.save(buf, format="PNG") return buf.getvalue() +def _apply_view_transform( + img: "Image.Image", + rotation_degrees: int = 0, + zoom: float = 1.0, +) -> "Image.Image": + """Apply final map rotation and centre zoom.""" + rotation = _normalize_rotation(rotation_degrees) + if rotation: + img = img.rotate(-rotation, expand=True) + + zoom = max(1.0, min(2.0, float(zoom or 1.0))) + if zoom <= 1.0: + return img + + crop_width = max(1, int(img.width / zoom)) + crop_height = max(1, int(img.height / zoom)) + left = max(0, (img.width - crop_width) // 2) + top = max(0, (img.height - crop_height) // 2) + return img.crop((left, top, left + crop_width, top + crop_height)) + + def render_map_from_compressed( compressed: bytes, width: int, diff --git a/narwal_client/models.py b/narwal_client/models.py index 11a69c6..4618c91 100644 --- a/narwal_client/models.py +++ b/narwal_client/models.py @@ -4,12 +4,44 @@ import logging import struct +import time from dataclasses import dataclass, field from typing import Any, ClassVar _LOGGER = logging.getLogger(__name__) - -from .const import CommandResult, FanLevel, MopHumidity, WorkingStatus +_ACTIVE_WORKING_STATUS_TTL = 15.0 +MAINTENANCE_BASE_STATION_CLEANING_FILTER = "base_station_cleaning_filter" +MAINTENANCE_BASE_STATION_CLEANING_FILTER_COMPONENT = 4 +MAINTENANCE_COMPONENT_IDS: dict[str, int] = { + "dust_bin": 1, + "dust_bin_filter": 2, + "dirty_water_tank": 3, + MAINTENANCE_BASE_STATION_CLEANING_FILTER: ( + MAINTENANCE_BASE_STATION_CLEANING_FILTER_COMPONENT + ), + "smart_water_module": 5, + "caster": 6, + "sensor": 7, + "edge_sensor": 8, + "roller_brush": 9, + "dust_bin_bag": 13, + "sponge_filter": 15, +} +_MAINTENANCE_ALERT_PATHS: dict[str, tuple[tuple[str, ...], ...]] = { + key: () for key in MAINTENANCE_COMPONENT_IDS +} +_MAINTENANCE_ALERT_PATHS[MAINTENANCE_BASE_STATION_CLEANING_FILTER] = ( + ("48", "1", "15", "7"), +) +_MAINTENANCE_COMPONENT_ALERTS: dict[str, int] = MAINTENANCE_COMPONENT_IDS + +from .const import ( + ACTIVE_CLEANING_STATUSES, + TOPIC_CMD_GET_ROBOT_TASK_STATUS, + TOPIC_ROBOT_TASK_STATUS, + CommandResult, + WorkingStatus, +) @dataclass @@ -55,6 +87,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 +274,134 @@ 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 _has_field_path(value: Any, path: tuple[str, ...]) -> bool: + if not path: + return True + if isinstance(value, list): + return any(_has_field_path(item, path) for item in value) + if not isinstance(value, dict): + return False + key = path[0] + if key not in value: + return False + return _has_field_path(value[key], path[1:]) + + +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 + + +def _as_sequence(value: Any) -> tuple[Any, ...]: + if isinstance(value, list): + return tuple(value) + if value is None: + return () + return (value,) + + +def _get_nested(value: Any, path: tuple[str, ...]) -> Any: + current = value + for key in path: + if not isinstance(current, dict): + return None + current = current.get(key) + return current + + +def _component_alert_active(decoded: dict[str, Any], component_id: int) -> bool: + """Return true when field48 contains an alert for a maintenance component.""" + for item in _as_sequence(_get_nested(decoded, ("48", "1"))): + if _optional_int(_get_nested(item, ("5", "1", "1"))) == component_id: + return True + return False + + +def _maintenance_alerts_from_base_status(decoded: dict[str, Any]) -> tuple[str, ...]: + alerts: list[str] = [] + for alert, paths in _MAINTENANCE_ALERT_PATHS.items(): + component_id = _MAINTENANCE_COMPONENT_ALERTS.get(alert) + if any(_has_field_path(decoded, path) for path in paths) or ( + component_id is not None and _component_alert_active(decoded, component_id) + ): + alerts.append(alert) + return tuple(alerts) + + +def _maintenance_hours_from_base_status(decoded: dict[str, Any]) -> dict[int, int]: + hours: dict[int, int] = {} + for item in _as_sequence(decoded.get("32")): + if not isinstance(item, dict): + continue + component_id = _optional_int(item.get("18")) + used_hours = _optional_int(item.get("1")) + if component_id is not None and used_hours is not None: + hours[component_id] = used_hours + return hours + + @dataclass class MapData: """Map data from get_map response.""" @@ -254,6 +419,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 +510,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 +526,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 +588,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 +663,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 +734,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 +751,19 @@ 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 = "" + task_active: bool = False + task_paused: bool = False + dry_mop_remaining_time: int | None = None + _previous_task_metrics: tuple[int, int] | None = field( + default=None, init=False, repr=False + ) + maintenance_alerts: tuple[str, ...] = () + maintenance_component_hours: dict[int, int] = field(default_factory=dict) # Map map_data: MapData | None = None @@ -534,10 +787,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) @@ -552,15 +817,130 @@ class NarwalState: # Secondary confirmation signal. dock_field47: int = 0 + # Base station ambient light mode from top-level base_status field 50. + # Values validated against the app: 1=Nightlight, + # 2=Fireplace / Winter warmth, 3=Purple. When the light is off the robot + # omits field 50 from base_status, so missing field 50 is treated as 0. + dock_light_mode: int | None = None + # 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 + + def mark_active_cleaning(self) -> None: + """Mark a newly accepted clean as active until telemetry catches up.""" + if self.cleaning_time > 0 or self.cleaning_area > 0: + self._previous_task_metrics = (self.cleaning_time, self.cleaning_area) + self.cleaning_area = 0 + self.cleaning_time = 0 + self.task_progress_percent = None + self.task_elapsed_time = 0 + self.current_room_id = None + self.current_room_name = "" + self.task_active = True + self.task_paused = False + self.is_paused = False + self.is_returning_to_dock = False + self.inferred_docked_from_battery = False + self.dock_field11 = 1 + self.dock_field47 = 2 + self.dock_sub_state = 0 + self.dock_activity = 0 + self.station_activity = 0 + self.refresh_active_cleaning() + + def mark_task_paused(self) -> None: + """Keep a paused clean resumable while the robot returns to its dock.""" + self.task_active = True + self.task_paused = True + self.is_paused = True + + def mark_task_resumed(self) -> None: + """Mark a paused clean active again.""" + self.task_active = True + self.task_paused = False + self.is_paused = False + self.refresh_active_cleaning() + + def clear_active_task(self) -> None: + """Clear resumable-task state after completion or cancellation.""" + if self.working_status in ACTIVE_CLEANING_STATUSES: + self.working_status = WorkingStatus.STANDBY + self.task_active = False + self.task_paused = False + self.is_paused = False + self.last_active_working_status_time = 0.0 + self.task_progress_percent = None + self.task_elapsed_time = 0 + self.current_room_id = None + self.current_room_name = "" + self.is_returning_to_dock = False + + def refresh_active_cleaning(self) -> None: + """Keep an active clean authoritative while task telemetry is current.""" + self.working_status = WorkingStatus.CLEANING + self.last_active_working_status_time = time.monotonic() @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 +961,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 +994,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,16 +1013,60 @@ def update_from_working_status(self, decoded: dict[str, Any]) -> None: Field 15 = 600 during cleaning (purpose uncertain) """ self.raw_working_status = decoded - if "3" in decoded: - try: - self.cleaning_time = int(decoded["3"]) - except (ValueError, TypeError): - pass - if "13" in decoded: - self.cleaning_area = int(decoded["13"]) + incoming_cleaning_time = ( + _optional_int(decoded.get("3")) if "3" in decoded else None + ) + incoming_cleaning_area = ( + _optional_int(decoded.get("13")) if "13" in decoded else None + ) + if self._previous_task_metrics is not None: + previous_task_time, previous_task_area = self._previous_task_metrics + supplied_metrics = [ + (incoming_cleaning_time, previous_task_time), + (incoming_cleaning_area, previous_task_area), + ] + supplied_metrics = [pair for pair in supplied_metrics if pair[0] is not None] + if supplied_metrics and all(value == old for value, old in supplied_metrics): + return + if supplied_metrics: + self._previous_task_metrics = None + previous_cleaning_time = self.cleaning_time + previous_cleaning_area = self.cleaning_area + if incoming_cleaning_time is not None: + self.cleaning_time = incoming_cleaning_time + if incoming_cleaning_area is not None: + self.cleaning_area = incoming_cleaning_area 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 +1082,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 @@ -667,6 +1091,10 @@ def update_from_base_status(self, decoded: dict[str, Any]) -> None: Note: field 32 mirrors field 3 exactly (redundant). """ self.raw_base_status = decoded + # Base broadcasts omit field 48 intermittently. The periodic clean- + # progress refresh is authoritative when clearing maintenance alerts. + if "48" in decoded: + self.maintenance_alerts = _maintenance_alerts_from_base_status(decoded) # Field 11 = dock indicator (2=docked, 1=undocked) if "11" in decoded: try: @@ -679,6 +1107,13 @@ def update_from_base_status(self, decoded: dict[str, Any]) -> None: self.dock_field47 = int(decoded["47"]) except (ValueError, TypeError): self.dock_field47 = 0 + if "50" in decoded: + try: + self.dock_light_mode = int(decoded["50"]) + except (ValueError, TypeError): + self.dock_light_mode = None + else: + self.dock_light_mode = 0 # Field 3 is a nested message: {1: state_int, ...} # Sub-field layout differs across firmware versions: # Old FW: {1: ws, 2: paused, 3: dock_presence, 7: returning, 10: dock_sub, 12: dock_activity} @@ -690,7 +1125,7 @@ def update_from_base_status(self, decoded: dict[str, Any]) -> None: if isinstance(field3, dict): if "1" in field3: try: - self.working_status = WorkingStatus(int(field3["1"])) + new_working_status = WorkingStatus(int(field3["1"])) except (ValueError, TypeError): raw_val = field3["1"] _LOGGER.warning( @@ -698,7 +1133,8 @@ def update_from_base_status(self, decoded: dict[str, Any]) -> None: "Please report this value at the GitHub repo.", raw_val, ) - self.working_status = WorkingStatus.UNKNOWN + new_working_status = WorkingStatus.UNKNOWN + self.working_status = new_working_status # Sub-field 2: paused overlay (0 or absent = not paused, 1 = paused) self.is_paused = bool(field3.get("2")) # Sub-field 7: returning to dock on old FW (value 1 = returning). @@ -715,13 +1151,69 @@ 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 self.task_active or not self.is_docked: + self.task_active = True + if self.is_paused: + self.task_paused = True + elif not self.is_docked: + self.task_paused = False + 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 + elif ( + self.working_status == WorkingStatus.TASK_COMPLETED + or ( + self.working_status + in { + WorkingStatus.STANDBY, + WorkingStatus.DOCKED, + WorkingStatus.CHARGED, + WorkingStatus.DOCKED_V2, + } + and self.is_docked + and self.task_active + and not self.task_paused + and ( + self.task_progress_percent not in (None, 0) + or self.cleaning_time > 0 + or self.cleaning_area > 0 + or self.current_room_id is not None + ) + ) + ): + self.clear_active_task() # 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 +1226,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 +1267,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 +1298,72 @@ 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 == "info/get_clean_progress_info": + payload = decoded.get("2") + if isinstance(payload, dict): + self.maintenance_alerts = _maintenance_alerts_from_base_status(payload) + if "32" in payload: + self.maintenance_component_hours = ( + _maintenance_hours_from_base_status(payload) + ) + 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: + task_was_active = self.task_active + self.task_progress_percent = max(0, min(100, progress)) + if progress >= 100: + self.clear_active_task() + return + if ( + progress > 0 + or self.task_active + or ( + self.working_status in ACTIVE_CLEANING_STATUSES + and not self.is_docked + ) + ): + self.task_active = True + self.task_paused = ( + self.task_paused + or self.is_paused + or (not task_was_active and progress > 0 and self.is_docked) + ) + 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..1c35b5b 100644 --- a/tests/ha_stubs.py +++ b/tests/ha_stubs.py @@ -8,8 +8,9 @@ from __future__ import annotations import sys +from dataclasses import dataclass from types import ModuleType -from unittest.mock import MagicMock +from unittest.mock import AsyncMock, MagicMock _INSTALLED = False @@ -35,14 +36,42 @@ 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.PERCENTAGE = "%" # type: ignore[attr-defined] ha_const.Platform = MagicMock() # type: ignore[attr-defined] + class _EntityCategory: + DIAGNOSTIC = "diagnostic" + + class _UnitOfArea: + SQUARE_METERS = "m²" + + class _UnitOfTime: + HOURS = "h" + SECONDS = "s" + + ha_const.EntityCategory = _EntityCategory # type: ignore[attr-defined] + ha_const.UnitOfArea = _UnitOfArea # type: ignore[attr-defined] + ha_const.UnitOfTime = _UnitOfTime # type: ignore[attr-defined] + # homeassistant.core ha_core = _mod("homeassistant.core", ha) ha_core.HomeAssistant = MagicMock # type: ignore[attr-defined] @@ -51,6 +80,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 +122,19 @@ 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_cv.boolean = 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 +177,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: @@ -182,13 +233,75 @@ def __ror__(self, other: object) -> int: ha_vac.VacuumEntityFeature = _VacuumEntityFeature # type: ignore[attr-defined] ha_sensor = _mod("homeassistant.components.sensor", ha_comp) - ha_sensor.SensorEntity = MagicMock # type: ignore[attr-defined] - ha_sensor.SensorDeviceClass = MagicMock # type: ignore[attr-defined] - ha_sensor.SensorStateClass = MagicMock # type: ignore[attr-defined] + + class _SensorEntity: + def __init_subclass__(cls, **kw: object) -> None: + pass + + @dataclass(frozen=True, kw_only=True) + class _SensorEntityDescription: + key: str + translation_key: str | None = None + device_class: str | None = None + native_unit_of_measurement: str | None = None + state_class: str | None = None + options: list[str] | None = None + entity_category: str | None = None + + class _SensorDeviceClass: + BATTERY = "battery" + DURATION = "duration" + ENUM = "enum" + + class _SensorStateClass: + MEASUREMENT = "measurement" + + ha_sensor.SensorEntity = _SensorEntity # type: ignore[attr-defined] + ha_sensor.SensorEntityDescription = _SensorEntityDescription # type: ignore[attr-defined] + ha_sensor.SensorDeviceClass = _SensorDeviceClass # type: ignore[attr-defined] + ha_sensor.SensorStateClass = _SensorStateClass # type: ignore[attr-defined] + + ha_select = _mod("homeassistant.components.select", ha_comp) + ha_select.DOMAIN = "select" # type: ignore[attr-defined] + ha_select.SelectEntity = MagicMock # type: ignore[attr-defined] + ha_select.SelectEntityDescription = MagicMock # type: ignore[attr-defined] ha_bs = _mod("homeassistant.components.binary_sensor", ha_comp) + + @dataclass(frozen=True, kw_only=True) + class _BinarySensorEntityDescription: + key: str + translation_key: str | None = None + device_class: str | None = None + entity_category: str | None = None + ha_bs.BinarySensorEntity = MagicMock # type: ignore[attr-defined] - ha_bs.BinarySensorDeviceClass = MagicMock # type: ignore[attr-defined] + ha_bs.BinarySensorEntityDescription = _BinarySensorEntityDescription # type: ignore[attr-defined] + ha_bs.BinarySensorDeviceClass = MagicMock(PROBLEM="problem") # type: ignore[attr-defined] + + ha_button = _mod("homeassistant.components.button", ha_comp) + ha_button.ButtonEntity = MagicMock # type: ignore[attr-defined] + ha_button.ButtonEntityDescription = MagicMock # type: ignore[attr-defined] + + ha_light = _mod("homeassistant.components.light", ha_comp) + ha_light.ATTR_EFFECT = "effect" # type: ignore[attr-defined] + ha_light.DOMAIN = "light" # type: ignore[attr-defined] + ha_light.ColorMode = MagicMock(ONOFF="onoff") # type: ignore[attr-defined] + ha_light.LightEntityFeature = MagicMock(EFFECT=1) # type: ignore[attr-defined] + + class _LightEntity: + """Stub for LightEntity base class.""" + + def __init_subclass__(cls, **kw: object) -> None: + pass + + def __init__(self) -> None: + pass + + def async_write_ha_state(self) -> None: + pass + + ha_light.LightEntity = _LightEntity # type: ignore[attr-defined] ha_cam = _mod("homeassistant.components.camera", ha_comp) diff --git a/tests/test_client.py b/tests/test_client.py index dd658ce..1e9e5bd 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 AmbientLightCtrlType, CommandResult, WorkingStatus from narwal_client.models import CommandResponse, MapData, RoomInfo @@ -31,17 +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( - client.send_raw("test/topic", b"\x08\x01") - ) + asyncio.run(client.send_raw("test/topic", b"\x08\x01")) class TestBuildCleanPayloadV2: @@ -128,31 +229,42 @@ def test_start_returns_legacy_response_on_success(self) -> None: client = self._connected_client() 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.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 + def test_start_marks_code_zero_as_active_cleaning(self) -> None: + client = self._connected_client() + accepted = CommandResponse(result_code=0, data={"1": 0}) + + with patch.object(client, "send_command", new_callable=AsyncMock) as mock_send: + mock_send.return_value = accepted + result = asyncio.run(client.start()) + + assert result.result_code == CommandResult.SUCCESS + assert result.success + assert client.state.is_cleaning + assert client._active_working_status_is_recent() + def test_start_falls_back_to_v2_on_not_applicable(self) -> None: """NOT_APPLICABLE from legacy triggers v2 retry with cached rooms.""" client = self._connected_client() - client.state.map_data = MapData(rooms=[ - RoomInfo(room_id=11), - RoomInfo(room_id=14), - ]) + client.state.map_data = MapData( + rooms=[ + RoomInfo(room_id=11), + RoomInfo(room_id=14), + ] + ) 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()) + result = asyncio.run(client.start()) assert mock_send.await_count == 2 # Second call's payload must be v2 (different bytes from legacy) @@ -169,15 +281,36 @@ def test_start_returns_not_applicable_when_no_map_cached(self) -> None: not_applicable = CommandResponse(result_code=CommandResult.NOT_APPLICABLE) - 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.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 + +class TestAmbientLight: + """Tests for ambient light commands.""" + + def _connected_client(self) -> NarwalClient: + client = NarwalClient("127.0.0.1") + client._ws = AsyncMock() + client._connected = True + return client + + def test_applied_result_code_is_returned_without_retry(self) -> None: + """Dock light command code 6 is a known applied response.""" + client = self._connected_client() + applied = CommandResponse(result_code=CommandResult.APPLIED) + + with patch.object(client, "send_command", new_callable=AsyncMock) as mock_send: + mock_send.return_value = applied + result = asyncio.run(client.set_ambient_light_mode(AmbientLightCtrlType.NIGHT_LIGHT)) + + assert result is applied + assert not result.success + mock_send.assert_awaited_once() + def test_start_skips_v2_when_map_has_no_room_ids(self) -> None: """Map present but every room_id is 0 — don't send junk.""" client = self._connected_client() @@ -185,11 +318,9 @@ def test_start_skips_v2_when_map_has_no_room_ids(self) -> None: not_applicable = CommandResponse(result_code=CommandResult.NOT_APPLICABLE) - 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.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..14dc9fb 100644 --- a/tests/test_client_rooms.py +++ b/tests/test_client_rooms.py @@ -1,195 +1,390 @@ -"""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_result_code_zero_marks_clean_active(self) -> None: + """Flow firmware result code zero is an accepted clean response.""" + client = self._client() + accepted = CommandResponse(result_code=0, data={"1": 0}) + with patch.object(client, "send_command", new_callable=AsyncMock) as mock_send: + mock_send.return_value = accepted + result = _run(client.start_rooms([5])) + + assert result is accepted + assert result.success + assert result.result_code == CommandResult.SUCCESS + assert client.state.has_recent_active_working_status + assert client._active_working_status_is_recent() + assert client.state.is_cleaning + + def test_missing_result_code_does_not_mark_clean_active(self) -> None: + """A response without an explicit result code is not a clean ack.""" + client = self._client() + response = CommandResponse(result_code=0, data={"2": {"1": 50}}) + with patch.object(client, "send_command", new_callable=AsyncMock) as mock_send: + mock_send.return_value = response + result = _run(client.start_rooms([5])) + + assert result is response + assert result.result_code == 0 + assert not client.state.has_recent_active_working_status + assert not client._active_working_status_is_recent() + + 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_const.py b/tests/test_const.py new file mode 100644 index 0000000..b9e4094 --- /dev/null +++ b/tests/test_const.py @@ -0,0 +1,53 @@ +"""Tests for integration constants and capability helpers.""" + +from __future__ import annotations + +import tests.ha_stubs + +tests.ha_stubs.install() + +from custom_components.narwal.const import ( # noqa: E402 + CONF_DOCK_LIGHT_SUPPORTED, + CONF_MODEL, + CONF_PRODUCT_KEY, + is_dock_light_supported, + is_maintenance_alerts_supported, +) + + +def test_dock_light_supported_for_flow_2_product_keys() -> None: + assert is_dock_light_supported({CONF_PRODUCT_KEY: "QxMSPG6VSO"}) + assert is_dock_light_supported({CONF_PRODUCT_KEY: "iSuVlI1If2"}) + + +def test_dock_light_hidden_for_flow_1_by_default() -> None: + assert not is_dock_light_supported({CONF_PRODUCT_KEY: "QoEsI5qYXO", CONF_MODEL: "Narwal Flow"}) + + +def test_dock_light_ignores_stale_model_label() -> None: + assert not is_dock_light_supported( + {CONF_PRODUCT_KEY: "QoEsI5qYXO", CONF_MODEL: "Narwal Flow 2"} + ) + + +def test_dock_light_option_override() -> None: + assert is_dock_light_supported( + {CONF_PRODUCT_KEY: "QoEsI5qYXO"}, + {CONF_DOCK_LIGHT_SUPPORTED: True}, + ) + assert not is_dock_light_supported( + {CONF_PRODUCT_KEY: "QxMSPG6VSO"}, + {CONF_DOCK_LIGHT_SUPPORTED: False}, + ) + + +def test_maintenance_alerts_supported_for_flow_2_only() -> None: + assert is_maintenance_alerts_supported({CONF_PRODUCT_KEY: "QxMSPG6VSO"}) + assert is_maintenance_alerts_supported({CONF_PRODUCT_KEY: "iSuVlI1If2"}) + assert not is_maintenance_alerts_supported({CONF_PRODUCT_KEY: "QoEsI5qYXO"}) + + +def test_maintenance_alerts_use_discovered_flow_2_product_key() -> None: + assert is_maintenance_alerts_supported( + {CONF_PRODUCT_KEY: "QoEsI5qYXO"}, "QxMSPG6VSO" + ) diff --git a/tests/test_coordinator.py b/tests/test_coordinator.py index 2ccf06c..403845a 100644 --- a/tests/test_coordinator.py +++ b/tests/test_coordinator.py @@ -6,8 +6,8 @@ from __future__ import annotations -import sys -from unittest.mock import AsyncMock, MagicMock, PropertyMock, patch +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock, PropertyMock import pytest @@ -17,9 +17,12 @@ tests.ha_stubs.install() 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 custom_components.narwal.narwal_client import ( # noqa: E402 + NarwalConnectionError, + NarwalState, + WorkingStatus, +) +from homeassistant.helpers.update_coordinator import UpdateFailed # noqa: E402 class TestCoordinatorResilience: @@ -49,6 +52,7 @@ def _make_coordinator(self) -> NarwalCoordinator: coordinator._listen_task = None coordinator._map_fetch_pending = False coordinator._last_display_map_resub = 0.0 + coordinator._last_maintenance_refresh = 0.0 coordinator._prev_working_status = MagicMock() coordinator.update_interval = None # Prevent background task warnings @@ -75,17 +79,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 @@ -109,6 +112,17 @@ async def test_success_resets_failure_counter(self) -> None: assert coordinator._consecutive_failures == 0 assert result is coordinator.client.state + async def test_poll_preserves_recent_active_task_marker(self) -> None: + """A poll only refreshes battery while task status is fresher.""" + coordinator = self._make_coordinator() + type(coordinator.client).connected = PropertyMock(return_value=True) + coordinator.client.state.refresh_active_cleaning() + coordinator.client.get_status = AsyncMock() + + await coordinator._async_update_data() + + coordinator.client.get_status.assert_awaited_once_with(full_update=False) + async def test_push_update_resets_failure_counter(self) -> None: """_on_state_update resets _consecutive_failures to 0.""" coordinator = self._make_coordinator() @@ -147,3 +161,156 @@ async def test_connected_but_get_status_fails(self) -> None: assert result is coordinator.client.state assert coordinator._consecutive_failures == 1 + + async def test_maintenance_details_refresh_when_forced(self) -> None: + """Idle maintenance counters can be populated independently of a task.""" + coordinator = self._make_coordinator() + coordinator.config_entry.data["product_key"] = "QxMSPG6VSO" + coordinator.client.get_clean_progress_info = AsyncMock() + + await coordinator._refresh_maintenance_details(force=True) + + coordinator.client.get_clean_progress_info.assert_awaited_once_with() + + async def test_maintenance_details_refresh_is_throttled(self) -> None: + """Normal polls do not query maintenance details every minute.""" + coordinator = self._make_coordinator() + coordinator.config_entry.data["product_key"] = "QxMSPG6VSO" + coordinator.client.get_clean_progress_info = AsyncMock() + + await coordinator._refresh_maintenance_details(force=True) + await coordinator._refresh_maintenance_details() + + coordinator.client.get_clean_progress_info.assert_awaited_once_with() + + async def test_maintenance_details_retry_after_failure(self) -> None: + """A failed maintenance refresh does not suppress the next attempt.""" + coordinator = self._make_coordinator() + coordinator.config_entry.data["product_key"] = "QxMSPG6VSO" + coordinator.client.get_clean_progress_info = AsyncMock( + side_effect=[NarwalConnectionError("recv timeout"), None] + ) + + await coordinator._refresh_maintenance_details() + await coordinator._refresh_maintenance_details() + + assert coordinator.client.get_clean_progress_info.await_count == 2 + + async def test_maintenance_details_skip_unsupported_models(self) -> None: + """Models without maintenance entities are not polled for their data.""" + coordinator = self._make_coordinator() + coordinator.client.get_clean_progress_info = AsyncMock() + + await coordinator._refresh_maintenance_details(force=True) + + coordinator.client.get_clean_progress_info.assert_not_awaited() + + async def test_status_recovery_preserves_stale_active_state(self) -> None: + """Recovery avoids a full status update without fresh active telemetry.""" + coordinator = self._make_coordinator() + coordinator.async_set_updated_data = MagicMock() + coordinator.client.subscribe_to_topics = AsyncMock() + coordinator.client.get_status = AsyncMock() + coordinator.client.get_clean_progress_info = AsyncMock() + coordinator.client.get_robot_task_status = AsyncMock() + + await coordinator._recover_status_broadcasts() + + coordinator.client.get_status.assert_not_awaited() + coordinator.client.get_clean_progress_info.assert_awaited_once_with() + coordinator.client.get_robot_task_status.assert_awaited_once_with() + + async def test_status_recovery_avoids_fresh_base_status_poll(self) -> None: + """Recovery never overlays an active clean with stale dock status.""" + coordinator = self._make_coordinator() + coordinator.async_set_updated_data = MagicMock() + coordinator.client.state.mark_active_cleaning() + coordinator.client.subscribe_to_topics = AsyncMock() + coordinator.client.get_status = AsyncMock() + coordinator.client.get_clean_progress_info = AsyncMock() + coordinator.client.get_robot_task_status = AsyncMock() + + await coordinator._recover_status_broadcasts() + + coordinator.client.get_status.assert_not_awaited() + coordinator.client.get_clean_progress_info.assert_awaited_once_with() + coordinator.client.get_robot_task_status.assert_awaited_once_with() + + async def test_status_recovery_renews_active_task_marker(self) -> None: + """Current task progress keeps stale base status from ending a clean.""" + coordinator = self._make_coordinator() + coordinator.async_set_updated_data = MagicMock() + coordinator.client.subscribe_to_topics = AsyncMock() + coordinator.client.get_clean_progress_info = AsyncMock() + coordinator.client.get_robot_task_status = AsyncMock( + return_value=SimpleNamespace(data={"1": 1, "2": {"1": 42}}) + ) + + await coordinator._recover_status_broadcasts() + + assert coordinator.client.state.has_recent_active_working_status + assert ( + coordinator.client._last_active_working_status_time + == coordinator.client.state.last_active_working_status_time + ) + + async def test_status_recovery_rejects_idle_zero_progress(self) -> None: + """An idle zero-progress response cannot revive stale cleaning state.""" + coordinator = self._make_coordinator() + coordinator.async_set_updated_data = MagicMock() + coordinator.client.state.working_status = WorkingStatus.CLEANING + coordinator.client.subscribe_to_topics = AsyncMock() + coordinator.client.get_clean_progress_info = AsyncMock() + coordinator.client.get_robot_task_status = AsyncMock( + return_value=SimpleNamespace(data={"1": 1, "2": {"1": 0}}) + ) + + await coordinator._recover_status_broadcasts() + + assert not coordinator.client.state.has_recent_active_working_status + + async def test_status_recovery_preserves_accepted_zero_progress(self) -> None: + """An accepted task remains active while it still reports zero progress.""" + coordinator = self._make_coordinator() + coordinator.async_set_updated_data = MagicMock() + coordinator.client.state.mark_active_cleaning() + coordinator.client.state.last_active_working_status_time = 0.0 + coordinator.client._last_active_working_status_time = 0.0 + coordinator.client.subscribe_to_topics = AsyncMock() + coordinator.client.get_clean_progress_info = AsyncMock() + coordinator.client.get_robot_task_status = AsyncMock( + return_value=SimpleNamespace(data={"1": 1, "2": {"1": 0}}) + ) + + await coordinator._recover_status_broadcasts() + + assert coordinator.client.state.has_recent_active_working_status + + async def test_task_detail_refresh_renews_client_active_marker(self) -> None: + """Task-detail polling also renews stale-base suppression.""" + coordinator = self._make_coordinator() + coordinator.async_set_updated_data = MagicMock() + coordinator.client.get_clean_progress_info = AsyncMock() + coordinator.client.get_robot_task_status = AsyncMock( + return_value=SimpleNamespace(data={"1": 1, "2": {"1": 42}}) + ) + + await coordinator._refresh_task_details(cleaning=True) + + assert ( + coordinator.client._last_active_working_status_time + == coordinator.client.state.last_active_working_status_time + ) + + async def test_completed_task_details_do_not_renew_active_marker(self) -> None: + """A completed task response does not revive cleaning state.""" + coordinator = self._make_coordinator() + coordinator.async_set_updated_data = MagicMock() + coordinator.client.get_clean_progress_info = AsyncMock() + coordinator.client.get_robot_task_status = AsyncMock( + return_value=SimpleNamespace(data={"1": 1, "2": {"1": 100}}) + ) + + await coordinator._refresh_task_details(cleaning=True) + + assert not coordinator.client.state.has_recent_active_working_status diff --git a/tests/test_coordinator_map.py b/tests/test_coordinator_map.py index 47d9fb7..acb639b 100644 --- a/tests/test_coordinator_map.py +++ b/tests/test_coordinator_map.py @@ -8,10 +8,7 @@ from __future__ import annotations -import sys -from unittest.mock import AsyncMock, MagicMock, PropertyMock - -import pytest +from unittest.mock import MagicMock, patch # Install HA stubs before any custom_components import import tests.ha_stubs # noqa: E402 @@ -48,10 +45,18 @@ def _make_coordinator(self) -> NarwalCoordinator: coordinator._listen_task = None coordinator._map_fetch_pending = False coordinator._last_display_map_resub = 0.0 + coordinator._last_status_resub = 0.0 + coordinator._last_task_details_refresh = 0.0 coordinator._prev_working_status = WorkingStatus.UNKNOWN coordinator.update_interval = None coordinator.async_set_updated_data = MagicMock() - mock_entry.async_create_background_task = MagicMock() + def _close_background_task(*args: object) -> None: + for arg in args: + if hasattr(arg, "close"): + arg.close() + + mock_entry.async_create_background_task = MagicMock(side_effect=_close_background_task) + mock_hass.async_create_task = MagicMock(side_effect=_close_background_task) # Prevent TypeError on display_map dropout check when is_cleaning coordinator.client.last_display_map_age = 0.0 return coordinator @@ -172,3 +177,39 @@ def test_push_update_resets_fast_poll(self) -> None: assert coordinator._fast_poll_remaining == 0 assert coordinator.update_interval == POLL_INTERVAL + + def test_active_task_details_refresh_before_marker_expires(self) -> None: + """Active task details refresh more often than the active-state TTL.""" + coordinator = self._make_coordinator() + coordinator._last_status_resub = 100.0 + coordinator._last_task_details_refresh = 89.0 + state = NarwalState() + state.map_data = MagicMock() + state.working_status = WorkingStatus.CLEANING + + with patch("custom_components.narwal.coordinator.time.monotonic", return_value=100.0): + coordinator._on_state_update(state) + + task_names = [ + call.args[2] + for call in coordinator.config_entry.async_create_background_task.call_args_list + ] + assert "narwal_task_details" in task_names + + def test_docked_stale_cleaning_does_not_refresh_active_task(self) -> None: + """Stale CLEANING status at the dock does not revive an old task.""" + coordinator = self._make_coordinator() + state = NarwalState() + state.map_data = MagicMock() + state.working_status = WorkingStatus.CLEANING + state.dock_field11 = 2 + state.dock_field47 = 3 + + coordinator._on_state_update(state) + + task_names = [ + call.args[2] + for call in coordinator.config_entry.async_create_background_task.call_args_list + ] + assert "narwal_status_recover" not in task_names + assert "narwal_task_details" not in task_names diff --git a/tests/test_light.py b/tests/test_light.py new file mode 100644 index 0000000..f11c171 --- /dev/null +++ b/tests/test_light.py @@ -0,0 +1,47 @@ +"""Tests for Narwal light entities.""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, MagicMock + +import tests.ha_stubs + +tests.ha_stubs.install() + +from custom_components.narwal.light import NarwalDockLight # noqa: E402 +from custom_components.narwal.narwal_client import CommandResult # noqa: E402 +from custom_components.narwal.narwal_client.models import CommandResponse # noqa: E402 + + +def _coordinator() -> MagicMock: + coordinator = MagicMock() + coordinator.config_entry.data = {"device_id": "test_device"} + coordinator.config_entry.title = "Narwal Test" + coordinator.client.state.firmware_version = "test" + return coordinator + + +def test_dock_light_preserves_unknown_state() -> None: + """An unread dock-light mode must not be presented as off.""" + coordinator = _coordinator() + coordinator.data.dock_light_mode = None + + dock_light = NarwalDockLight(coordinator) + + assert dock_light.is_on is None + + +async def test_dock_light_accepts_applied_result_code() -> None: + """Result code 6 is accepted when the dock light visibly applies the command.""" + coordinator = _coordinator() + coordinator.client.robot_awake = True + coordinator.client.set_ambient_light_mode = AsyncMock( + return_value=CommandResponse(result_code=CommandResult.APPLIED) + ) + coordinator.async_request_refresh = AsyncMock() + + dock_light = NarwalDockLight(coordinator) + await dock_light._set_mode("Nightlight") + + coordinator.client.set_ambient_light_mode.assert_awaited_once() + coordinator.async_request_refresh.assert_awaited_once() diff --git a/tests/test_map_renderer.py b/tests/test_map_renderer.py index 78d0ef0..177c067 100644 --- a/tests/test_map_renderer.py +++ b/tests/test_map_renderer.py @@ -12,12 +12,16 @@ import zlib from narwal_client.map_renderer import ( + MAP_RENDER_SCALE, render_base_map, render_overlay, decompress_map, _decode_packed_varints, + _scaled_coord, OBSTACLE_COLORS, OBSTACLE_COLOR_DEFAULT, + render_map_png, + _transform_point, ) from narwal_client.models import ObstacleInfo @@ -77,7 +81,13 @@ def test_returns_pil_image_with_rooms(self) -> None: assert result is not None assert isinstance(result, Image.Image) - assert result.size == (width, height) + assert result.size == (width * MAP_RENDER_SCALE, height * MAP_RENDER_SCALE) + assert result.mode == "RGBA" + assert result.getpixel((0, 0))[3] == 255 + + def test_scaled_coordinate_is_clamped_to_image_edge(self) -> None: + """Fractional coordinates in the last grid cell remain visible.""" + assert _scaled_coord(29.9, MAP_RENDER_SCALE, 90) == 89 def test_with_dock_position(self) -> None: """Given MapData with dock_x/dock_y, render_base_map includes dock.""" @@ -96,10 +106,14 @@ def test_with_dock_position(self) -> None: # The dock is drawn as a white circle — check that the center pixel # at the dock position (Y-flipped) is white or near-white dock_px_y = height - 1 - 15 # Y-flip - r, g, b = result.getpixel((15, dock_px_y)) - assert r > 200 and g > 200 and b > 200, ( - f"Expected white-ish dock pixel, got ({r}, {g}, {b})" - ) + dock_px = int(round(15 * MAP_RENDER_SCALE + (MAP_RENDER_SCALE / 2))) + dock_py = int(round(dock_px_y * MAP_RENDER_SCALE + (MAP_RENDER_SCALE / 2))) + region = [ + result.getpixel((x, y))[:3] + for x in range(dock_px - 5, dock_px + 6) + for y in range(dock_py - 5, dock_py + 6) + ] + assert any(r > 200 and g > 200 and b > 200 for r, g, b in region) def test_empty_compressed_data(self) -> None: """Given empty compressed data, returns None gracefully.""" @@ -168,6 +182,10 @@ def test_no_robot_position(self) -> None: assert isinstance(result, bytes) assert result[:8] == b"\x89PNG\r\n\x1a\n" + def test_unsupported_rotation_uses_unrotated_transform(self) -> None: + """Bitmap and overlay points share the same fallback rotation.""" + assert _transform_point(5, 10, 30, 20, 45, 1.0) == (5, 10) + def test_does_not_modify_base(self) -> None: """render_overlay does not mutate the base image.""" from PIL import Image @@ -207,7 +225,24 @@ def test_full_pipeline_base_then_overlay(self) -> None: # Verify we can open the PNG from PIL import Image img = Image.open(io.BytesIO(png)) - assert img.size == (width, height) + assert img.size == (width * MAP_RENDER_SCALE, height * MAP_RENDER_SCALE) + + +class TestRenderMapPng: + """Tests for the one-shot map rendering path.""" + + def test_room_pixels_are_opaque_rgba(self) -> None: + """Room colors match the RGBA image mode.""" + from PIL import Image + + width, height = 10, 10 + compressed = _make_room_grid(width, height, room_id=1) + + png = render_map_png(zlib.decompress(compressed), width, height) + image = Image.open(io.BytesIO(png)) + + assert image.mode == "RGBA" + assert image.getpixel((0, 0))[3] == 255 class TestObstacleRendering: @@ -229,7 +264,7 @@ def test_render_base_map_with_obstacles(self) -> None: ) assert result is not None assert isinstance(result, Image.Image) - assert result.size == (width, height) + assert result.size == (width * MAP_RENDER_SCALE, height * MAP_RENDER_SCALE) def test_obstacle_type_colors_exist(self) -> None: """OBSTACLE_COLORS dict has entries for all furniture enum types.""" @@ -298,5 +333,3 @@ def test_obstacle_modifies_image(self) -> None: assert result_with is not None # Images should differ (obstacle drawn on one but not other) assert list(result_without.getdata()) != list(result_with.getdata()) - - diff --git a/tests/test_models.py b/tests/test_models.py index b707fdc..76e9476 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -3,9 +3,14 @@ from __future__ import annotations import struct +import time +import zlib from narwal_client.const import WorkingStatus from narwal_client.models import ( + MAINTENANCE_BASE_STATION_CLEANING_FILTER, + MAINTENANCE_BASE_STATION_CLEANING_FILTER_COMPONENT, + MAINTENANCE_COMPONENT_IDS, MapData, NarwalState, ObstacleInfo, @@ -14,6 +19,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 +59,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 +161,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 +204,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 +312,231 @@ def test_update_from_download_status(self) -> None: state.update_from_download_status({"1": 2}) assert state.download_status == 2 + def test_base_station_cleaning_filter_alert_from_base_status(self) -> None: + """Flow 2 base-status field 48 carries the cleaning-filter prompt.""" + state = NarwalState() + state.update_from_base_status({"48": {"1": {"15": {"7": {}}}}}) + assert state.maintenance_alerts == (MAINTENANCE_BASE_STATION_CLEANING_FILTER,) + + def test_base_station_cleaning_filter_alert_from_repeated_block(self) -> None: + """The same field48 block can decode as a repeated list.""" + state = NarwalState() + state.update_from_base_status( + {"48": {"1": [{"2": {}, "5": {"1": {"1": 4, "2": 2}}}]}} + ) + assert state.maintenance_alerts == (MAINTENANCE_BASE_STATION_CLEANING_FILTER,) + + def test_maintenance_alert_from_repeated_component_id(self) -> None: + state = NarwalState() + state.update_from_base_status( + { + "48": { + "1": [ + {"2": {}, "5": {"1": {"1": MAINTENANCE_COMPONENT_IDS["sensor"]}}} + ] + } + } + ) + assert state.maintenance_alerts == ("sensor",) + + def test_base_station_cleaning_filter_alert_from_progress_payload(self) -> None: + """Clean-progress response embeds the same base-status alert block.""" + state = NarwalState() + state.update_from_aux_status( + "info/get_clean_progress_info", + {"1": 1, "2": {"48": {"1": {"15": {"7": {}}}}}}, + ) + assert state.maintenance_alerts == (MAINTENANCE_BASE_STATION_CLEANING_FILTER,) + + def test_base_station_cleaning_filter_hours_from_progress_payload(self) -> None: + state = NarwalState() + state.update_from_aux_status( + "info/get_clean_progress_info", + { + "2": { + "32": [ + { + "1": 19, + "18": MAINTENANCE_BASE_STATION_CLEANING_FILTER_COMPONENT, + } + ] + } + }, + ) + assert ( + state.maintenance_component_hours[ + MAINTENANCE_BASE_STATION_CLEANING_FILTER_COMPONENT + ] + == 19 + ) + + def test_base_status_field_32_is_not_treated_as_maintenance(self) -> None: + state = NarwalState() + state.update_from_base_status( + {"32": [{"1": 19, "18": MAINTENANCE_BASE_STATION_CLEANING_FILTER_COMPONENT}]} + ) + assert state.maintenance_component_hours == {} + + def test_progress_without_maintenance_clears_base_alert(self) -> None: + state = NarwalState() + state.update_from_base_status({"48": {"1": {"15": {"7": {}}}}}) + state.update_from_aux_status("info/get_clean_progress_info", {"1": 1, "2": {"3": 4}}) + assert state.maintenance_alerts == () + + def test_base_status_without_maintenance_does_not_clear_alert(self) -> None: + state = NarwalState() + state.update_from_base_status({"48": {"1": {"15": {"7": {}}}}}) + state.update_from_base_status({"3": {"1": 10}}) + assert state.maintenance_alerts == (MAINTENANCE_BASE_STATION_CLEANING_FILTER,) + + def test_maintenance_alerts_clear_when_absent(self) -> None: + state = NarwalState() + state.update_from_base_status({"48": {"1": {"15": {"7": {}}}}}) + state.update_from_base_status({"48": {}}) + assert state.maintenance_alerts == () + + 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_status_at_dock_marks_resumable_pause(self) -> None: + """Incomplete docked task status identifies a resumable clean.""" + state = NarwalState() + state.update_from_base_status({"3": {"1": 2}, "11": 2, "47": 3}) + + state.update_from_aux_status( + "robot/task/status", + {"2": {"1": 42, "6": {"1": 5, "3": "Landing"}}}, + ) + + assert state.task_active + assert state.task_paused + assert state.task_progress_percent == 42 + + def test_completed_task_status_clears_resumable_pause(self) -> None: + """Completed task status cannot leave a stale resumable clean.""" + state = NarwalState(task_active=True, task_paused=True, is_paused=True) + + state.update_from_aux_status("robot/task/status", {"2": {"1": 100}}) + + assert not state.task_active + assert not state.task_paused + assert state.task_progress_percent is None + + def test_zero_progress_at_idle_dock_is_not_active(self) -> None: + """An idle zero-progress response is not a resumable clean.""" + state = NarwalState() + state.update_from_base_status({"3": {"1": 2}, "11": 2, "47": 3}) + + state.update_from_aux_status("robot/task/status", {"2": {"1": 0}}) + + assert not state.task_active + assert not state.task_paused + + def test_zero_progress_preserves_accepted_task(self) -> None: + """Zero progress remains valid after a clean command was accepted.""" + state = NarwalState() + state.mark_active_cleaning() + + state.update_from_aux_status("robot/task/status", {"2": {"1": 0}}) + + assert state.task_active + assert not state.task_paused + + def test_clearing_accepted_task_resets_synthetic_cleaning(self) -> None: + """A cleared command cannot remain cleaning before telemetry catches up.""" + state = NarwalState() + state.mark_active_cleaning() + + state.clear_active_task() + + assert state.working_status == WorkingStatus.STANDBY + assert not state.is_cleaning + + def test_terminal_dock_status_clears_active_task(self) -> None: + """A completed clean cannot remain active after an idle dock status.""" + state = NarwalState(task_active=True, task_progress_percent=42) + + state.update_from_base_status({"3": {"1": 2}, "11": 2, "47": 3}) + + assert state.is_docked + assert not state.task_active + + def test_terminal_dock_status_preserves_accepted_task(self) -> None: + """Dock preparation remains active before the robot starts moving.""" + state = NarwalState() + state.mark_active_cleaning() + state.last_active_working_status_time = 0.0 + + state.update_from_base_status({"3": {"1": 2}, "11": 2, "47": 3}) + state.update_from_aux_status("robot/task/status", {"2": {"1": 0}}) + + assert state.task_active + assert not state.task_paused + + def test_terminal_dock_status_preserves_paused_task(self) -> None: + """A confirmed paused clean remains resumable after reaching the dock.""" + state = NarwalState(task_active=True, task_paused=True, is_paused=True) + + state.update_from_base_status({"3": {"1": 2}, "11": 2, "47": 3}) + + assert state.is_docked + assert state.task_active + assert state.task_paused + + def test_stale_docked_pause_does_not_mark_task_active(self) -> None: + """A stale base-status pause bit alone is not a resumable task.""" + state = NarwalState() + + state.update_from_base_status( + {"3": {"1": 4, "2": 1, "10": 1}, "11": 2, "47": 3} + ) + + assert state.is_docked + assert state.is_paused + assert not state.task_active + assert not state.task_paused + + 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 +556,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 +619,185 @@ 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_mark_active_cleaning_clears_stale_return(self) -> None: + """A newly accepted clean does not inherit the previous return flag.""" + state = NarwalState() + state.is_returning_to_dock = True + + state.mark_active_cleaning() + + assert not state.is_returning_to_dock + assert state.is_cleaning + + def test_mark_active_cleaning_overrides_docked_state(self) -> None: + """Accepted clean command should show active before broadcasts arrive.""" + state = NarwalState() + state.update_from_base_status({"3": {"1": 2}, "11": 2, "47": 3}) + state.is_paused = True + + state.mark_active_cleaning() + + assert state.working_status == WorkingStatus.CLEANING + assert state.has_recent_active_working_status + assert not state.is_paused + assert state.is_cleaning + assert not state.is_docked + + def test_mark_active_cleaning_clears_previous_task_metrics(self) -> None: + """A newly accepted clean does not expose the previous task's metrics.""" + state = NarwalState( + cleaning_area=18000, + cleaning_time=120, + task_progress_percent=93, + task_elapsed_time=694, + current_room_id=5, + current_room_name="Landing", + ) + + state.mark_active_cleaning() + + assert state.cleaning_area == 0 + assert state.cleaning_time == 0 + assert state.task_progress_percent is None + assert state.task_elapsed_time == 0 + assert state.current_room_id is None + assert state.current_room_name == "" + + def test_mark_active_cleaning_ignores_previous_task_payload(self) -> None: + """A late payload from the previous task does not restore stale metrics.""" + state = NarwalState(cleaning_area=18000, cleaning_time=120) + state.mark_active_cleaning() + active_since = state.last_active_working_status_time + + state.update_from_working_status({"3": 120, "13": 18000}) + + assert state.cleaning_area == 0 + assert state.cleaning_time == 0 + assert state.last_active_working_status_time == active_since + + state.update_from_working_status({"3": 1, "13": 0}) + + assert state.cleaning_area == 0 + assert state.cleaning_time == 1 + assert state.last_active_working_status_time >= active_since + + def test_refresh_active_cleaning_preserves_task_overlays(self) -> None: + """Task-detail refresh does not erase pause or return telemetry.""" + state = NarwalState( + is_paused=True, + is_returning_to_dock=True, + dock_sub_state=2, + ) + + state.refresh_active_cleaning() + + assert state.is_paused + assert state.is_returning_to_dock + assert state.dock_sub_state == 2 + assert state.is_returning + 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 +852,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 +1112,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..237a3d9 --- /dev/null +++ b/tests/test_services.py @@ -0,0 +1,357 @@ +"""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_LED_MODE, + 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, + SERVICE_SET_DOCK_LIGHT, +) +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: + service.async_extract_entity_ids.reset_mock() + 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) + handlers = { + registration.args[1]: registration.args[2] + for registration in hass.services.async_register.call_args_list + } + handler = handlers[SERVICE_CLEAN_ROOMS] + + 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() + coordinator.async_set_updated_data.assert_called_once_with(client.state) + hass.services.async_register.assert_any_call( + 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 + + assert hass.services.async_register.call_count == 3 + + +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"] + + +def test_set_dock_light_awaits_entity_target_extraction() -> None: + service.async_extract_entity_ids.reset_mock() + hass = MagicMock() + client = SimpleNamespace( + robot_awake=True, + set_ambient_light_mode=AsyncMock( + return_value=SimpleNamespace(result_code=CommandResult.APPLIED) + ), + ) + coordinator = SimpleNamespace( + client=client, + config_entry=SimpleNamespace( + data={"product_key": "QxMSPG6VSO"}, + options={}, + title="Flow 2", + ), + async_request_refresh=AsyncMock(), + ) + call = SimpleNamespace( + context=SimpleNamespace(user_id=None), + data={ + "entity_id": ["vacuum.flow_2"], + FIELD_LED_MODE: "nightlight", + } + ) + + _async_register_services(hass) + handlers = { + registration.args[1]: registration.args[2] + for registration in hass.services.async_register.call_args_list + } + handler = handlers[SERVICE_SET_DOCK_LIGHT] + + registry = MagicMock() + registry.async_get.return_value = SimpleNamespace(platform=DOMAIN) + with ( + patch("custom_components.narwal.er.async_get", return_value=registry), + patch( + "custom_components.narwal._async_get_service_coordinators", + new=AsyncMock(return_value=[coordinator]), + ), + ): + asyncio.run(handler(call)) + + service.async_extract_entity_ids.assert_awaited_once_with(call) + client.set_ambient_light_mode.assert_awaited_once() + coordinator.async_request_refresh.assert_awaited_once() + + +def test_set_dock_light_rejects_empty_explicit_target() -> None: + service.async_extract_entity_ids.reset_mock() + service.async_extract_entity_ids.return_value = set() + hass = MagicMock() + call = SimpleNamespace( + context=SimpleNamespace(user_id=None), + data={ + "area_id": ["bedroom"], + FIELD_LED_MODE: "nightlight", + } + ) + + _async_register_services(hass) + handlers = { + registration.args[1]: registration.args[2] + for registration in hass.services.async_register.call_args_list + } + + with pytest.raises(HomeAssistantError, match="Target does not contain"): + asyncio.run(handlers[SERVICE_SET_DOCK_LIGHT](call)) + + service.async_extract_entity_ids.assert_awaited_once_with(call) + + +def test_set_dock_light_rejects_unauthorized_target() -> None: + service.async_extract_entity_ids.reset_mock() + hass = MagicMock() + 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"], + FIELD_LED_MODE: "nightlight", + }, + ) + + _async_register_services(hass) + handlers = { + registration.args[1]: registration.args[2] + for registration in hass.services.async_register.call_args_list + } + + registry = MagicMock() + registry.async_get.return_value = SimpleNamespace(platform=DOMAIN) + with ( + patch("custom_components.narwal.er.async_get", return_value=registry), + pytest.raises(Unauthorized), + ): + asyncio.run(handlers[SERVICE_SET_DOCK_LIGHT](call)) + + user.permissions.check_entity.assert_called_once_with( + "vacuum.flow_2", "control" + ) + + +def test_set_dock_light_filters_indirect_non_vacuum_targets() -> None: + hass = MagicMock() + coordinator = SimpleNamespace( + client=SimpleNamespace( + robot_awake=True, + set_ambient_light_mode=AsyncMock( + return_value=SimpleNamespace(result_code=CommandResult.APPLIED) + ), + ), + config_entry=SimpleNamespace( + data={"product_key": "QxMSPG6VSO"}, + options={}, + title="Flow 2", + ), + async_request_refresh=AsyncMock(), + ) + call = SimpleNamespace( + context=SimpleNamespace(user_id=None), + data={"device_id": ["flow-2-device"], FIELD_LED_MODE: "nightlight"}, + ) + registry = MagicMock() + registry.async_get.side_effect = { + "vacuum.flow_2": SimpleNamespace(platform=DOMAIN), + "sensor.flow_2_battery": SimpleNamespace(platform=DOMAIN), + }.get + + _async_register_services(hass) + handlers = { + registration.args[1]: registration.args[2] + for registration in hass.services.async_register.call_args_list + } + extract_targets = AsyncMock( + return_value={"sensor.flow_2_battery", "vacuum.flow_2"} + ) + get_coordinators = AsyncMock(return_value=[coordinator]) + + with ( + patch( + "custom_components.narwal.service.async_extract_entity_ids", + new=extract_targets, + ), + patch("custom_components.narwal.er.async_get", return_value=registry), + patch( + "custom_components.narwal._async_get_service_coordinators", + new=get_coordinators, + ), + ): + asyncio.run(handlers[SERVICE_SET_DOCK_LIGHT](call)) + + get_coordinators.assert_awaited_once_with(hass, ["vacuum.flow_2"]) diff --git a/tests/test_vacuum_segments.py b/tests/test_vacuum_segments.py index 986555f..6b03ec4 100644 --- a/tests/test_vacuum_segments.py +++ b/tests/test_vacuum_segments.py @@ -17,7 +17,16 @@ 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 +from custom_components.narwal.sensor import NarwalTaskStatusSensor # noqa: E402 # Grab Segment class from stubs for assertions import sys @@ -51,6 +60,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.""" @@ -169,6 +192,7 @@ async def test_converts_string_ids_and_calls_start_rooms(self) -> None: """Converts string segment IDs to int and calls client.start_rooms.""" state = NarwalState() vac = _make_vacuum(state=state) + vac.coordinator.client.state = state vac.coordinator.client.start_rooms = AsyncMock( return_value=MagicMock(result_code=0, success=True) ) @@ -176,9 +200,162 @@ 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, + ) + vac.coordinator.async_set_updated_data.assert_called_once_with(state) + + +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.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() + vac.coordinator.async_set_updated_data.assert_called_once_with(state) + + async def test_docked_resumable_task_is_resumed(self) -> None: + """A confirmed paused task at the dock resumes instead of conflicting.""" + state = NarwalState() + state.update_from_base_status({"3": {"1": 2}, "11": 2, "47": 3}) + state.task_active = True + state.task_paused = True + vac = _make_vacuum(state=state) + vac.coordinator.client.robot_awake = True + vac.coordinator.client.start = AsyncMock() + vac.coordinator.client.resume = AsyncMock( + return_value=MagicMock(result_code=1, success=True) + ) + + await vac.async_start() + + vac.coordinator.client.resume.assert_awaited_once() + vac.coordinator.client.start.assert_not_awaited() + assert state.task_active + assert not state.task_paused + + +class TestVacuumActivity: + """Tests for derived vacuum activity.""" + + def test_error_wins_over_active_task(self) -> None: + """A current robot error must not be hidden by stale task state.""" + state = NarwalState( + working_status=WorkingStatus.ERROR, + task_active=True, + ) + + assert _make_vacuum(state=state).activity == "error" + + 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_resumable_task_at_dock_reports_paused(self) -> None: + """A confirmed resumable task remains visible while docked.""" + state = NarwalState() + state.update_from_base_status({"3": {"1": 2}, "11": 2, "47": 3}) + state.task_active = True + state.task_paused = True + + assert _make_vacuum(state=state).activity == "paused" + + def test_paused_task_while_returning_reports_paused(self) -> None: + """A paused return remains resumable rather than merely returning.""" + state = NarwalState( + working_status=WorkingStatus.CLEANING, + task_active=True, + task_paused=True, + is_paused=True, + is_returning_to_dock=True, + dock_sub_state=2, + dock_field11=1, + dock_field47=2, + ) + + assert _make_vacuum(state=state).activity == "paused" + + def test_accepted_task_at_dock_reports_cleaning(self) -> None: + """Dock preparation remains visible after the active marker expires.""" + state = NarwalState() + state.update_from_base_status({"3": {"1": 2}, "11": 2, "47": 3}) + state.task_active = True + + assert _make_vacuum(state=state).activity == "cleaning" + + 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 TestTaskStatusSensor: + """Tests for the user-facing task status ordering.""" + + def test_completed_status_at_dock_reports_docked(self) -> None: + """Definitive dock fields win over a transitional completed status.""" + state = NarwalState() + state.update_from_base_status({"3": {"1": 19}, "11": 2, "47": 3}) + coordinator = _make_vacuum(state=state).coordinator + + assert NarwalTaskStatusSensor(coordinator).native_value == "docked" + + def test_paused_task_while_returning_reports_paused(self) -> None: + """Task status agrees that a paused return remains resumable.""" + state = NarwalState( + working_status=WorkingStatus.CLEANING, + task_active=True, + task_paused=True, + is_paused=True, + is_returning_to_dock=True, + dock_sub_state=2, + dock_field11=1, + dock_field47=2, + ) + coordinator = _make_vacuum(state=state).coordinator + + assert NarwalTaskStatusSensor(coordinator).native_value == "paused" class TestCheckSegmentChanges: