diff --git a/custom_components/narwal/const.py b/custom_components/narwal/const.py index 0fa56a2..1707338 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 FanLevel, MopHumidity, MopStrengthLevel, WorkMode DOMAIN = "narwal" DEFAULT_PORT = 9002 @@ -29,13 +29,45 @@ Platform.SENSOR, Platform.BINARY_SENSOR, Platform.CAMERA, + Platform.SELECT, + Platform.NUMBER, ] -FAN_SPEED_MAP: dict[str, FanLevel] = { - "quiet": FanLevel.QUIET, +# HA fan_speed labels → FanLevel, verbatim from the app's user-visible suction names (sentence case, as HA shows fan_speed values directly). The enum members keep the app's internal identifiers, so DEEP surfaces as "Super powerful" and SUPER as "Ultra powerful". +_FAN_SPEED_CANONICAL: dict[str, FanLevel] = { + "Quiet": FanLevel.MUTE, + "Standard": FanLevel.NORMAL, + "Strong": FanLevel.STRONG, + "Super powerful": FanLevel.DEEP, + "Ultra powerful": FanLevel.SUPER, +} + +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.SUPER, +} + +# Select option id → robot enum. Option ids are rendered to the app's user-visible labels via translations. +WORK_MODE_MAP: dict[str, WorkMode] = { + "vacuum": WorkMode.VACUUM, + "mop": WorkMode.MOP, + "vacuum_then_mop": WorkMode.VACUUM_THEN_MOP, + "vacuum_and_mop": WorkMode.VACUUM_AND_MOP, +} +WATER_MAP: dict[str, MopHumidity] = { + "dry": MopHumidity.DRY, + "normal": MopHumidity.NORMAL, + "wet": MopHumidity.WET, +} +MOP_STRENGTH_MAP: dict[str, MopStrengthLevel] = { + "normal": MopStrengthLevel.NORMAL, + "high": MopStrengthLevel.HIGH, } -FAN_SPEED_LIST: list[str] = list(FAN_SPEED_MAP.keys()) +PASSES_MIN = 1 +PASSES_MAX = 3 diff --git a/custom_components/narwal/coordinator.py b/custom_components/narwal/coordinator.py index 0970307..cf2ec00 100644 --- a/custom_components/narwal/coordinator.py +++ b/custom_components/narwal/coordinator.py @@ -5,13 +5,22 @@ import asyncio import logging import time +from dataclasses import dataclass from datetime import timedelta from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .narwal_client import NarwalClient, NarwalConnectionError, NarwalState +from .narwal_client import ( + WorkMode, + FanLevel, + MopHumidity, + MopStrengthLevel, + NarwalClient, + NarwalConnectionError, + NarwalState, +) from .narwal_client.const import WorkingStatus from .const import DOMAIN @@ -25,6 +34,20 @@ FAST_POLL_MAX = 6 # up to 60s of fast polling before falling back to normal +@dataclass +class CleanSettings: + """User-selected clean parameters, applied at the next room clean start. + + Single source of truth the select/number entities mutate and the clean-start path reads; each entity persists its value via RestoreEntity, so they survive restarts. Only fan and water also have live setters — work_mode/mop_strength/passes take effect at the next start. + """ + + work_mode: WorkMode = WorkMode.VACUUM_AND_MOP + fan: FanLevel = FanLevel.NORMAL + water: MopHumidity = MopHumidity.NORMAL + mop_strength: MopStrengthLevel = MopStrengthLevel.NORMAL + passes: int = 1 + + class NarwalCoordinator(DataUpdateCoordinator[NarwalState]): """Push-mode coordinator for Narwal vacuum. @@ -54,6 +77,7 @@ def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: device_id=entry.data.get("device_id", ""), topic_prefix=topic_prefix, ) + self.clean_settings = CleanSettings() self._listen_task: asyncio.Task[None] | None = None self._fast_poll_remaining = 0 self._prev_working_status = WorkingStatus.UNKNOWN diff --git a/custom_components/narwal/narwal_client/__init__.py b/custom_components/narwal/narwal_client/__init__.py index 8eb1f06..b36b2ae 100644 --- a/custom_components/narwal/narwal_client/__init__.py +++ b/custom_components/narwal/narwal_client/__init__.py @@ -1,7 +1,7 @@ """Narwal robot vacuum client library — local WebSocket API.""" from .client import NarwalClient, NarwalCommandError, NarwalConnectionError -from .const import CommandResult, FanLevel, MopHumidity, WorkingStatus +from .const import CommandResult, FanLevel, MopHumidity, MopStrengthLevel, WorkMode, WorkingStatus from .models import CommandResponse, DeviceInfo, MapData, MapDisplayData, NarwalState, RoomInfo from .protocol import build_frame, parse_frame @@ -17,7 +17,9 @@ "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..c7230f0 100644 --- a/custom_components/narwal/narwal_client/client.py +++ b/custom_components/narwal/narwal_client/client.py @@ -42,7 +42,8 @@ TOPIC_CMD_RESUME, TOPIC_CMD_SET_FAN_LEVEL, TOPIC_CMD_SET_MOP_HUMIDITY, - TOPIC_CMD_START_CLEAN, + TOPIC_CMD_PLAN_START, + TOPIC_CMD_CLEAN_TASK, TOPIC_CMD_TAKE_PICTURE, TOPIC_CMD_SET_LED, TOPIC_CMD_WASH_MOP, @@ -52,6 +53,9 @@ CommandResult, FanLevel, MopHumidity, + MopStrengthLevel, + WorkMode, + WorkingStatus, ) from .models import CommandResponse, DeviceInfo, MapData, MapDisplayData, NarwalState from .protocol import ( @@ -915,7 +919,7 @@ async def start(self, **kwargs) -> CommandResponse: so we know which rooms to include. """ resp = await self.send_command( - TOPIC_CMD_START_CLEAN, + TOPIC_CMD_PLAN_START, payload=self._DEFAULT_CLEAN_PAYLOAD, timeout=10.0, ) @@ -944,7 +948,7 @@ async def start(self, **kwargs) -> CommandResponse: ) payload = self._build_clean_payload_v2(room_ids) return await self.send_command( - TOPIC_CMD_START_CLEAN, payload=payload, timeout=10.0, + TOPIC_CMD_PLAN_START, payload=payload, timeout=10.0, ) def _build_clean_payload_v2( @@ -958,9 +962,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 @@ -1046,126 +1048,152 @@ 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. - - 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, + ) -> 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 omitted — live-validated as ignored here. 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. """ - 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_typedef = { + 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), + } + 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": "uint"}, - "2": {"type": "int"}, + "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"}, - "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 = { - "1": { - "2": field_2_value, - "5": { - "1": {"1": 3, "2": 2, "3": 1}, - "5": {} - } - } - } - typedef = { - "1": { - "type": "message", - "message_typedef": { - "2": room_typedef, - "5": { - "type": "message", - "message_typedef": { - "1": { - "type": "message", - "message_typedef": { - "1": {"type": "int"}, - "2": {"type": "int"}, - "3": {"type": "int"} - } - }, - "5": {"type": "message", "message_typedef": {}} - } - } - } - } + }, } - return blackboxprotobuf.encode_message(msg, typedef) + 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}, 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.NORMAL, + water: MopHumidity = MopHumidity.NORMAL, + mop_strength: MopStrengthLevel = MopStrengthLevel.NORMAL, + passes: int = 1, ) -> 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: 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 + map_data = self.state.map_data + if not map_data or not map_data.map_id: + map_data = await self.get_map() + map_id = map_data.map_id if map_data else 0 + if not map_id: + _LOGGER.warning( + "start_rooms: no active map id available; cannot start room clean" + ) + return CommandResponse(result_code=CommandResult.NOT_APPLICABLE) - # 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 = self._build_start_clean_payload( + room_ids, map_id, work_mode=work_mode, fan=fan, water=water, + mop_strength=mop_strength, passes=passes, ) - payload_legacy = self._build_room_clean_payload(room_ids) - return await self.send_command( - TOPIC_CMD_START_CLEAN, payload=payload_legacy, timeout=10.0, + 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, + ) + return resp async def start_easy_clean(self) -> CommandResponse: """Start quick/easy clean.""" @@ -1196,19 +1224,21 @@ 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 — the app maps + FanLevel.SUPER -> STRONG here. Ints otherwise match FanLevel (MUTE 1, NORMAL 2, + STRONG 3, DEEP 4). """ - payload = b"\x08" + bytes([int(level) & 0x7F]) + live = FanLevel.STRONG if int(level) == FanLevel.SUPER else int(level) + 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 int (1=dry, 2=normal, 3=wet). """ payload = b"\x08" + bytes([int(level) & 0x7F]) return await self.send_command(TOPIC_CMD_SET_MOP_HUMIDITY, payload) diff --git a/custom_components/narwal/narwal_client/const.py b/custom_components/narwal/narwal_client/const.py index 0c877c7..cb0d609 100644 --- a/custom_components/narwal/narwal_client/const.py +++ b/custom_components/narwal/narwal_client/const.py @@ -82,8 +82,8 @@ TOPIC_CMD_DUST_GATHERING = "supply/dust_gathering" # 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" @@ -141,6 +141,7 @@ class CommandResult(IntEnum): SUCCESS = 1 NOT_APPLICABLE = 2 # e.g., set_fan_level when not cleaning CONFLICT = 3 # e.g., recall when already recalling + NOT_READY = 4 # clean/start_clean while not docked (robot in STANDBY) class WorkingStatus(IntEnum): @@ -179,20 +180,40 @@ class WorkingStatus(IntEnum): class FanLevel(IntEnum): - """Suction fan speed levels (SweepMode from APK).""" + """CleanParam suction level (CleanTask.pbenum FanLevel). Live clean/set_fan_level carries a SweepFanLevel instead — identical ints 0-4, but it has no SUPER, so the app maps SUPER->STRONG on that path.""" - QUIET = 0 - NORMAL = 1 - STRONG = 2 - MAX = 3 + UNSPECIFIED = 0 + MUTE = 1 + NORMAL = 2 + STRONG = 3 + DEEP = 4 + SUPER = 5 class MopHumidity(IntEnum): - """Mop wetness levels.""" + """Water volume. CleanParam tag 4 and the live clean/set_mop_humidity command share these ints.""" + + UNSPECIFIED = 0 + DRY = 1 + NORMAL = 2 + WET = 3 + + +class MopStrengthLevel(IntEnum): + """Mop scrub intensity (CleanParam tag 3).""" - DRY = 0 + UNSPECIFIED = 0 NORMAL = 1 - WET = 2 + HIGH = 2 + + +class WorkMode(IntEnum): + """Clean work mode — the app's robot_work_mode_* selector (Vacuum / Mop / Vacuum then mop / Vacuum and mop). Its value IS the CleanTask.taskType the robot executes; the per-item CleanParam.mode (the proto's own CleanMode enum) is derived separately in client._WORK_MODE_PARAM.""" + + VACUUM = 1 + MOP = 2 + VACUUM_THEN_MOP = 3 + VACUUM_AND_MOP = 4 # robot_base_status field numbers diff --git a/custom_components/narwal/narwal_client/models.py b/custom_components/narwal/narwal_client/models.py index 11a69c6..0c6e0dd 100644 --- a/custom_components/narwal/narwal_client/models.py +++ b/custom_components/narwal/narwal_client/models.py @@ -241,6 +241,7 @@ def _parse_obstacles(field32: dict) -> list[ObstacleInfo]: class MapData: """Map data from get_map response.""" + map_id: int = 0 # active map id (field 2.1) — required by clean/start_clean width: int = 0 height: int = 0 resolution: int = 0 @@ -344,6 +345,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, diff --git a/custom_components/narwal/number.py b/custom_components/narwal/number.py new file mode 100644 index 0000000..81091f2 --- /dev/null +++ b/custom_components/narwal/number.py @@ -0,0 +1,64 @@ +"""Number entity for the Narwal clean pass count. + +Holds a pending value applied at the next room clean; the builder routes it to the right +CleanParam tag for the current mode (sweep->5, mop->6, sweep_then_mop->5+6, sync->7). +""" + +from __future__ import annotations + +from homeassistant.components.number import NumberMode, RestoreNumber +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import NarwalConfigEntry +from .const import PASSES_MAX, PASSES_MIN +from .coordinator import NarwalCoordinator +from .entity import NarwalEntity + + +async def async_setup_entry( + hass: HomeAssistant, + entry: NarwalConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the Narwal passes number entity.""" + async_add_entities([NarwalPassesNumber(entry.runtime_data)]) + + +class NarwalPassesNumber(NarwalEntity, RestoreNumber): + """Pending clean pass count, applied at the next room clean; restored across restarts.""" + + _attr_translation_key = "passes" + _attr_entity_category = EntityCategory.CONFIG + _attr_native_min_value = PASSES_MIN + _attr_native_max_value = PASSES_MAX + _attr_native_step = 1 + _attr_mode = NumberMode.BOX + + def __init__(self, coordinator: NarwalCoordinator) -> None: + """Initialize the number entity.""" + super().__init__(coordinator) + self._attr_unique_id = f"{coordinator.config_entry.data['device_id']}_passes" + + async def async_added_to_hass(self) -> None: + """Restore the last pass count into clean_settings (persists across restarts).""" + await super().async_added_to_hass() + last = await self.async_get_last_number_data() + if last is not None and last.native_value is not None: + self.coordinator.clean_settings.passes = int(last.native_value) + + @property + def available(self) -> bool: + """Editable even while the robot sleeps — this is a pending setting.""" + return True + + @property + def native_value(self) -> float: + """Return the stored pass count.""" + return self.coordinator.clean_settings.passes + + async def async_set_native_value(self, value: float) -> None: + """Store the pass count.""" + self.coordinator.clean_settings.passes = int(value) + self.async_write_ha_state() diff --git a/custom_components/narwal/select.py b/custom_components/narwal/select.py new file mode 100644 index 0000000..127981e --- /dev/null +++ b/custom_components/narwal/select.py @@ -0,0 +1,119 @@ +"""Clean-parameter select entities for Narwal vacuum. + +These hold pending values applied at the next room clean (CleanParam is only sent in the +start payload). water additionally writes live via clean/set_mop_humidity while cleaning. +""" + +from __future__ import annotations + +from dataclasses import dataclass + +from homeassistant.components.select import SelectEntity, SelectEntityDescription +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.restore_state import RestoreEntity + +from . import NarwalConfigEntry +from .const import WORK_MODE_MAP, MOP_STRENGTH_MAP, WATER_MAP +from .coordinator import NarwalCoordinator +from .entity import NarwalEntity + + +@dataclass(frozen=True, kw_only=True) +class NarwalSelectEntityDescription(SelectEntityDescription): + """Describes a Narwal clean-param select.""" + + attr: str # CleanSettings field this select reads/writes + mapping: dict[str, int] # option label -> robot enum value + live_setter: str | None = None # NarwalClient coroutine applied live while cleaning + + +SELECT_DESCRIPTIONS: tuple[NarwalSelectEntityDescription, ...] = ( + NarwalSelectEntityDescription( + key="work_mode", + translation_key="work_mode", + entity_category=EntityCategory.CONFIG, + attr="work_mode", + mapping=WORK_MODE_MAP, + options=list(WORK_MODE_MAP), + ), + NarwalSelectEntityDescription( + key="water", + translation_key="water", + entity_category=EntityCategory.CONFIG, + attr="water", + mapping=WATER_MAP, + live_setter="set_mop_humidity", + options=list(WATER_MAP), + ), + NarwalSelectEntityDescription( + key="mop_strength", + translation_key="mop_strength", + entity_category=EntityCategory.CONFIG, + attr="mop_strength", + mapping=MOP_STRENGTH_MAP, + options=list(MOP_STRENGTH_MAP), + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: NarwalConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Narwal clean-param select entities.""" + coordinator = entry.runtime_data + async_add_entities( + NarwalSelect(coordinator, description) for description in SELECT_DESCRIPTIONS + ) + + +class NarwalSelect(NarwalEntity, RestoreEntity, SelectEntity): + """A clean-parameter select backed by coordinator.clean_settings; restored across restarts.""" + + entity_description: NarwalSelectEntityDescription + + def __init__( + self, + coordinator: NarwalCoordinator, + description: NarwalSelectEntityDescription, + ) -> 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._labels = {int(v): k for k, v in description.mapping.items()} + + async def async_added_to_hass(self) -> None: + """Restore the last selection into clean_settings (persists across restarts).""" + await super().async_added_to_hass() + last = await self.async_get_last_state() + if last is not None and last.state in self.entity_description.mapping: + setattr( + self.coordinator.clean_settings, + self.entity_description.attr, + self.entity_description.mapping[last.state], + ) + + @property + def available(self) -> bool: + """Editable even while the robot sleeps — these are pending settings.""" + return True + + @property + def current_option(self) -> str | None: + """Return the stored option label.""" + value = getattr(self.coordinator.clean_settings, self.entity_description.attr) + return self._labels.get(int(value)) + + async def async_select_option(self, option: str) -> None: + """Store the selection and, for live controls, apply it if cleaning.""" + value = self.entity_description.mapping[option] + setattr(self.coordinator.clean_settings, self.entity_description.attr, value) + self.async_write_ha_state() + state = self.coordinator.data + if self.entity_description.live_setter and state is not None and state.is_cleaning: + await getattr(self.coordinator.client, self.entity_description.live_setter)(value) diff --git a/custom_components/narwal/strings.json b/custom_components/narwal/strings.json index 08f563c..3e076c2 100644 --- a/custom_components/narwal/strings.json +++ b/custom_components/narwal/strings.json @@ -55,6 +55,37 @@ "charging": { "name": "Charging" } + }, + "select": { + "work_mode": { + "name": "Clean mode", + "state": { + "vacuum": "Vacuum", + "mop": "Mop", + "vacuum_then_mop": "Vacuum then mop", + "vacuum_and_mop": "Vacuum and mop" + } + }, + "water": { + "name": "Mopping humidity", + "state": { + "dry": "Slightly dry", + "normal": "Normal", + "wet": "Slightly wet" + } + }, + "mop_strength": { + "name": "Mop strength", + "state": { + "normal": "Normal", + "high": "High" + } + } + }, + "number": { + "passes": { + "name": "Cleaning passes" + } } } } diff --git a/custom_components/narwal/translations/en.json b/custom_components/narwal/translations/en.json index e4cb63f..d79c177 100644 --- a/custom_components/narwal/translations/en.json +++ b/custom_components/narwal/translations/en.json @@ -51,6 +51,37 @@ "off": "Undocked" } } + }, + "select": { + "work_mode": { + "name": "Clean mode", + "state": { + "vacuum": "Vacuum", + "mop": "Mop", + "vacuum_then_mop": "Vacuum then mop", + "vacuum_and_mop": "Vacuum and mop" + } + }, + "water": { + "name": "Mopping humidity", + "state": { + "dry": "Slightly dry", + "normal": "Normal", + "wet": "Slightly wet" + } + }, + "mop_strength": { + "name": "Mop strength", + "state": { + "normal": "Normal", + "high": "High" + } + } + }, + "number": { + "passes": { + "name": "Cleaning passes" + } } } } diff --git a/custom_components/narwal/translations/fr.json b/custom_components/narwal/translations/fr.json index f7177d7..2b694b9 100644 --- a/custom_components/narwal/translations/fr.json +++ b/custom_components/narwal/translations/fr.json @@ -51,6 +51,37 @@ "off": "Non stationné" } } + }, + "select": { + "work_mode": { + "name": "Mode de nettoyage", + "state": { + "vacuum": "Aspiration", + "mop": "Serpillière", + "vacuum_then_mop": "Aspiration puis serpillière", + "vacuum_and_mop": "Aspiration et serpillière" + } + }, + "water": { + "name": "Humidité de la serpillière", + "state": { + "dry": "Légèrement sec", + "normal": "Normal", + "wet": "Légèrement humide" + } + }, + "mop_strength": { + "name": "Intensité de la serpillière", + "state": { + "normal": "Normale", + "high": "Élevée" + } + } + }, + "number": { + "passes": { + "name": "Passages de nettoyage" + } } } } diff --git a/custom_components/narwal/vacuum.py b/custom_components/narwal/vacuum.py index 95073a5..32c0a84 100644 --- a/custom_components/narwal/vacuum.py +++ b/custom_components/narwal/vacuum.py @@ -18,8 +18,9 @@ Segment = None # HA < 2026.3 — room cleaning unavailable from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.restore_state import RestoreEntity -from .narwal_client import CommandResult, FanLevel, NarwalCommandError, WorkingStatus +from .narwal_client import CommandResult, WorkingStatus from . import NarwalConfigEntry from .const import FAN_SPEED_LIST, FAN_SPEED_MAP @@ -39,6 +40,9 @@ WorkingStatus.ERROR: VacuumActivity.ERROR, } +# FanLevel value -> fan_speed label (canonical labels only; FAN_SPEED_MAP also holds back-compat aliases). +_FAN_LABELS: dict[int, str] = {int(FAN_SPEED_MAP[label]): label for label in FAN_SPEED_LIST} + async def async_setup_entry( hass: HomeAssistant, @@ -50,7 +54,7 @@ async def async_setup_entry( async_add_entities([NarwalVacuum(coordinator)]) -class NarwalVacuum(NarwalEntity, StateVacuumEntity): +class NarwalVacuum(NarwalEntity, RestoreEntity, StateVacuumEntity): """Representation of a Narwal robot vacuum.""" _attr_translation_key = "vacuum" @@ -69,7 +73,13 @@ def __init__(self, coordinator: NarwalCoordinator) -> None: """Initialize the vacuum entity.""" super().__init__(coordinator) self._attr_unique_id = coordinator.config_entry.data["device_id"] - self._last_fan_speed: str | None = None + + async def async_added_to_hass(self) -> None: + """Restore the pending fan speed into clean_settings (persists across restarts).""" + await super().async_added_to_hass() + last = await self.async_get_last_state() + if last is not None and (fan := last.attributes.get("fan_speed")) in FAN_SPEED_MAP: + self.coordinator.clean_settings.fan = FAN_SPEED_MAP[fan] @property def activity(self) -> VacuumActivity: @@ -109,13 +119,13 @@ def activity(self) -> VacuumActivity: @property def fan_speed(self) -> str | None: - """Return the current fan speed. + """Return the selected fan speed. - The robot protocol does not broadcast the active fan speed setting, - so we track the last value set via the integration. Returns None - until the user sets a fan speed for the first time. + The robot does not broadcast the active fan level, so this reflects the + pending value held in coordinator.clean_settings (applied at the next clean + and, while cleaning, written live via set_fan_speed). """ - return self._last_fan_speed + return _FAN_LABELS.get(int(self.coordinator.clean_settings.fan)) # Timeout for action commands (start/stop/return) — robot may need # time to load map, plan route, etc., especially after waking. @@ -143,17 +153,55 @@ async def async_start(self) -> None: ) if is_cleaning and state.is_paused: await self.coordinator.client.resume(timeout=self._ACTION_TIMEOUT) + return + + # Whole-house clean enumerates every room via clean/start_clean, matching the + # app's allRoomIds() path. clean/plan/start (StartWithPlan) would instead re-run + # the saved current plan — i.e. the last room selection — not the whole house. + room_ids = await self._all_room_ids() + if room_ids: + settings = self.coordinator.clean_settings + resp = await self.coordinator.client.start_rooms( + room_ids, + work_mode=settings.work_mode, + fan=settings.fan, + water=settings.water, + mop_strength=settings.mop_strength, + passes=settings.passes, + ) else: + # No map rooms known — best-effort fall back to the saved-plan start. resp = await self.coordinator.client.start() - _LOGGER.info( - "Start command response: code=%s, success=%s", - resp.result_code, resp.success, + _LOGGER.info( + "Whole-house start: code=%s, success=%s, rooms=%s", + resp.result_code, resp.success, room_ids or "(saved plan)", + ) + if not resp.success: + _LOGGER.warning( + "Start command did not succeed (code=%s) — robot may not have started", + resp.result_code, ) - if not resp.success: - _LOGGER.warning( - "Start command did not succeed (code=%s) — robot may not have started", - resp.result_code, - ) + + async def _all_room_ids(self) -> list[int]: + """Every cleanable room id for a whole-house clean; fetches the map if not cached.""" + state = self.coordinator.data + if state is None or state.map_data is None: + try: + await self.coordinator.client.get_map() + except Exception: # noqa: BLE001 — best-effort prefetch; fall through to fallback + _LOGGER.debug("get_map for whole-house clean failed") + state = self.coordinator.data + if state and state.map_data: + return [r.room_id for r in state.map_data.rooms if r.room_id > 0] + # Map still unavailable — reuse the HA segment cache (Segment.id == str(room_id)). + cached = getattr(self, "last_seen_segments", None) or [] + ids: list[int] = [] + for seg in cached: + try: + ids.append(int(seg.id)) + except (ValueError, AttributeError, TypeError): + continue + return ids async def async_stop(self, **kwargs) -> None: """Stop cleaning.""" @@ -185,12 +233,19 @@ async def async_locate(self, **kwargs) -> None: await self.coordinator.client.locate() async def async_set_fan_speed(self, fan_speed: str, **kwargs) -> None: - """Set the fan speed.""" + """Set the fan speed. + + Stores it as the pending suction for the next clean; if the robot is + currently cleaning, also writes it live via set_fan_speed. + """ level = FAN_SPEED_MAP.get(fan_speed) - if level is not None: + if level is None: + return + self.coordinator.clean_settings.fan = level + self.async_write_ha_state() + state = self.coordinator.data + if state is not None and state.is_cleaning: await self.coordinator.client.set_fan_speed(level) - self._last_fan_speed = fan_speed - self.async_write_ha_state() # --- Segment API (HA 2026.3 room-specific cleaning) --- @@ -231,8 +286,21 @@ 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.clean_settings + _LOGGER.info( + "Starting room-specific clean: rooms=%s mode=%s fan=%s water=%s " + "mop_strength=%s passes=%s", + room_ids, settings.work_mode.name, settings.fan.name, + settings.water.name, settings.mop_strength.name, settings.passes, + ) + resp = await self.coordinator.client.start_rooms( + room_ids, + work_mode=settings.work_mode, + fan=settings.fan, + water=settings.water, + mop_strength=settings.mop_strength, + passes=settings.passes, + ) try: result_name = CommandResult(resp.result_code).name except ValueError: diff --git a/narwal_client/__init__.py b/narwal_client/__init__.py index 8eb1f06..b36b2ae 100644 --- a/narwal_client/__init__.py +++ b/narwal_client/__init__.py @@ -1,7 +1,7 @@ """Narwal robot vacuum client library — local WebSocket API.""" from .client import NarwalClient, NarwalCommandError, NarwalConnectionError -from .const import CommandResult, FanLevel, MopHumidity, WorkingStatus +from .const import CommandResult, FanLevel, MopHumidity, MopStrengthLevel, WorkMode, WorkingStatus from .models import CommandResponse, DeviceInfo, MapData, MapDisplayData, NarwalState, RoomInfo from .protocol import build_frame, parse_frame @@ -17,7 +17,9 @@ "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..c7230f0 100644 --- a/narwal_client/client.py +++ b/narwal_client/client.py @@ -42,7 +42,8 @@ TOPIC_CMD_RESUME, TOPIC_CMD_SET_FAN_LEVEL, TOPIC_CMD_SET_MOP_HUMIDITY, - TOPIC_CMD_START_CLEAN, + TOPIC_CMD_PLAN_START, + TOPIC_CMD_CLEAN_TASK, TOPIC_CMD_TAKE_PICTURE, TOPIC_CMD_SET_LED, TOPIC_CMD_WASH_MOP, @@ -52,6 +53,9 @@ CommandResult, FanLevel, MopHumidity, + MopStrengthLevel, + WorkMode, + WorkingStatus, ) from .models import CommandResponse, DeviceInfo, MapData, MapDisplayData, NarwalState from .protocol import ( @@ -915,7 +919,7 @@ async def start(self, **kwargs) -> CommandResponse: so we know which rooms to include. """ resp = await self.send_command( - TOPIC_CMD_START_CLEAN, + TOPIC_CMD_PLAN_START, payload=self._DEFAULT_CLEAN_PAYLOAD, timeout=10.0, ) @@ -944,7 +948,7 @@ async def start(self, **kwargs) -> CommandResponse: ) payload = self._build_clean_payload_v2(room_ids) return await self.send_command( - TOPIC_CMD_START_CLEAN, payload=payload, timeout=10.0, + TOPIC_CMD_PLAN_START, payload=payload, timeout=10.0, ) def _build_clean_payload_v2( @@ -958,9 +962,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 @@ -1046,126 +1048,152 @@ 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. - - 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, + ) -> 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 omitted — live-validated as ignored here. 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. """ - 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_typedef = { + 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), + } + 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": "uint"}, - "2": {"type": "int"}, + "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"}, - "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 = { - "1": { - "2": field_2_value, - "5": { - "1": {"1": 3, "2": 2, "3": 1}, - "5": {} - } - } - } - typedef = { - "1": { - "type": "message", - "message_typedef": { - "2": room_typedef, - "5": { - "type": "message", - "message_typedef": { - "1": { - "type": "message", - "message_typedef": { - "1": {"type": "int"}, - "2": {"type": "int"}, - "3": {"type": "int"} - } - }, - "5": {"type": "message", "message_typedef": {}} - } - } - } - } + }, } - return blackboxprotobuf.encode_message(msg, typedef) + 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}, 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.NORMAL, + water: MopHumidity = MopHumidity.NORMAL, + mop_strength: MopStrengthLevel = MopStrengthLevel.NORMAL, + passes: int = 1, ) -> 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: 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 + map_data = self.state.map_data + if not map_data or not map_data.map_id: + map_data = await self.get_map() + map_id = map_data.map_id if map_data else 0 + if not map_id: + _LOGGER.warning( + "start_rooms: no active map id available; cannot start room clean" + ) + return CommandResponse(result_code=CommandResult.NOT_APPLICABLE) - # 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 = self._build_start_clean_payload( + room_ids, map_id, work_mode=work_mode, fan=fan, water=water, + mop_strength=mop_strength, passes=passes, ) - payload_legacy = self._build_room_clean_payload(room_ids) - return await self.send_command( - TOPIC_CMD_START_CLEAN, payload=payload_legacy, timeout=10.0, + 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, + ) + return resp async def start_easy_clean(self) -> CommandResponse: """Start quick/easy clean.""" @@ -1196,19 +1224,21 @@ 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 — the app maps + FanLevel.SUPER -> STRONG here. Ints otherwise match FanLevel (MUTE 1, NORMAL 2, + STRONG 3, DEEP 4). """ - payload = b"\x08" + bytes([int(level) & 0x7F]) + live = FanLevel.STRONG if int(level) == FanLevel.SUPER else int(level) + 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 int (1=dry, 2=normal, 3=wet). """ payload = b"\x08" + bytes([int(level) & 0x7F]) return await self.send_command(TOPIC_CMD_SET_MOP_HUMIDITY, payload) diff --git a/narwal_client/const.py b/narwal_client/const.py index 0c877c7..cb0d609 100644 --- a/narwal_client/const.py +++ b/narwal_client/const.py @@ -82,8 +82,8 @@ TOPIC_CMD_DUST_GATHERING = "supply/dust_gathering" # 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" @@ -141,6 +141,7 @@ class CommandResult(IntEnum): SUCCESS = 1 NOT_APPLICABLE = 2 # e.g., set_fan_level when not cleaning CONFLICT = 3 # e.g., recall when already recalling + NOT_READY = 4 # clean/start_clean while not docked (robot in STANDBY) class WorkingStatus(IntEnum): @@ -179,20 +180,40 @@ class WorkingStatus(IntEnum): class FanLevel(IntEnum): - """Suction fan speed levels (SweepMode from APK).""" + """CleanParam suction level (CleanTask.pbenum FanLevel). Live clean/set_fan_level carries a SweepFanLevel instead — identical ints 0-4, but it has no SUPER, so the app maps SUPER->STRONG on that path.""" - QUIET = 0 - NORMAL = 1 - STRONG = 2 - MAX = 3 + UNSPECIFIED = 0 + MUTE = 1 + NORMAL = 2 + STRONG = 3 + DEEP = 4 + SUPER = 5 class MopHumidity(IntEnum): - """Mop wetness levels.""" + """Water volume. CleanParam tag 4 and the live clean/set_mop_humidity command share these ints.""" + + UNSPECIFIED = 0 + DRY = 1 + NORMAL = 2 + WET = 3 + + +class MopStrengthLevel(IntEnum): + """Mop scrub intensity (CleanParam tag 3).""" - DRY = 0 + UNSPECIFIED = 0 NORMAL = 1 - WET = 2 + HIGH = 2 + + +class WorkMode(IntEnum): + """Clean work mode — the app's robot_work_mode_* selector (Vacuum / Mop / Vacuum then mop / Vacuum and mop). Its value IS the CleanTask.taskType the robot executes; the per-item CleanParam.mode (the proto's own CleanMode enum) is derived separately in client._WORK_MODE_PARAM.""" + + VACUUM = 1 + MOP = 2 + VACUUM_THEN_MOP = 3 + VACUUM_AND_MOP = 4 # robot_base_status field numbers diff --git a/narwal_client/models.py b/narwal_client/models.py index 11a69c6..0c6e0dd 100644 --- a/narwal_client/models.py +++ b/narwal_client/models.py @@ -241,6 +241,7 @@ def _parse_obstacles(field32: dict) -> list[ObstacleInfo]: class MapData: """Map data from get_map response.""" + map_id: int = 0 # active map id (field 2.1) — required by clean/start_clean width: int = 0 height: int = 0 resolution: int = 0 @@ -344,6 +345,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, diff --git a/tests/ha_stubs.py b/tests/ha_stubs.py index 470df84..cdf33af 100644 --- a/tests/ha_stubs.py +++ b/tests/ha_stubs.py @@ -8,6 +8,7 @@ from __future__ import annotations import sys +from dataclasses import dataclass from types import ModuleType from unittest.mock import MagicMock @@ -43,6 +44,12 @@ def _mod(name: str, parent: ModuleType | None = None) -> ModuleType: ha_const = _mod("homeassistant.const", ha) ha_const.Platform = MagicMock() # type: ignore[attr-defined] + class _EntityCategory: + CONFIG = "config" + DIAGNOSTIC = "diagnostic" + + ha_const.EntityCategory = _EntityCategory # type: ignore[attr-defined] + # homeassistant.core ha_core = _mod("homeassistant.core", ha) ha_core.HomeAssistant = MagicMock # type: ignore[attr-defined] @@ -123,6 +130,19 @@ def _handle_coordinator_update(self) -> None: ha_ep = _mod("homeassistant.helpers.entity_platform", ha_helpers) ha_ep.AddConfigEntryEntitiesCallback = MagicMock # type: ignore[attr-defined] + ha_rs = _mod("homeassistant.helpers.restore_state", ha_helpers) + + class _RestoreEntity: + """Stub for RestoreEntity.""" + + async def async_added_to_hass(self) -> None: + pass + + async def async_get_last_state(self) -> None: + return None + + ha_rs.RestoreEntity = _RestoreEntity # type: ignore[attr-defined] + # homeassistant.components.* ha_comp = _mod("homeassistant.components", ha) @@ -181,6 +201,57 @@ def __ror__(self, other: object) -> int: ha_vac.VacuumEntityFeature = _VacuumEntityFeature # type: ignore[attr-defined] + ha_select = _mod("homeassistant.components.select", ha_comp) + + @dataclass(frozen=True, kw_only=True) + class _SelectEntityDescription: + """Stub for SelectEntityDescription (the EntityDescription fields our code sets).""" + + key: str + name: str | None = None + translation_key: str | None = None + entity_category: object | None = None + options: list | None = None + + ha_select.SelectEntityDescription = _SelectEntityDescription # type: ignore[attr-defined] + + class _SelectEntity: + """Stub for SelectEntity base class.""" + + def __init_subclass__(cls, **kw: object) -> None: + pass + + def async_write_ha_state(self) -> None: + pass + + ha_select.SelectEntity = _SelectEntity # type: ignore[attr-defined] + + ha_number = _mod("homeassistant.components.number", ha_comp) + + class _NumberMode: + AUTO = "auto" + BOX = "box" + SLIDER = "slider" + + ha_number.NumberMode = _NumberMode # type: ignore[attr-defined] + + class _RestoreNumber: + """Stub for RestoreNumber base class.""" + + def __init_subclass__(cls, **kw: object) -> None: + pass + + async def async_added_to_hass(self) -> None: + pass + + async def async_get_last_number_data(self) -> None: + return None + + def async_write_ha_state(self) -> None: + pass + + ha_number.RestoreNumber = _RestoreNumber # 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] diff --git a/tests/test_clean_settings_entities.py b/tests/test_clean_settings_entities.py new file mode 100644 index 0000000..18bf508 --- /dev/null +++ b/tests/test_clean_settings_entities.py @@ -0,0 +1,107 @@ +"""Tests for the clean-settings select and number entities (#50). + +Importing these modules at all is the regression guard for the bad +`RestoreSelect` import; the rest exercises the value round-trip, the live +mop-humidity setter, and the RestoreEntity/RestoreNumber restore paths. +""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, MagicMock, patch + +import tests.ha_stubs # noqa: E402 + +tests.ha_stubs.install() + +from homeassistant.components.select import SelectEntity # noqa: E402 +from homeassistant.helpers.restore_state import RestoreEntity # noqa: E402 + +from narwal_client.const import MopHumidity, MopStrengthLevel, WorkMode # noqa: E402 +from custom_components.narwal.coordinator import CleanSettings # noqa: E402 +from custom_components.narwal.number import NarwalPassesNumber # noqa: E402 +from custom_components.narwal.select import NarwalSelect, SELECT_DESCRIPTIONS # noqa: E402 + +_DESCS = {d.key: d for d in SELECT_DESCRIPTIONS} + + +def _coordinator(*, settings: CleanSettings | None = None, state: object | None = None) -> MagicMock: + coord = MagicMock() + coord.config_entry = MagicMock() + coord.config_entry.data = {"device_id": "dev1"} + coord.config_entry.title = "Narwal Test" + coord.client = MagicMock() + coord.client.state = MagicMock() + coord.client.state.firmware_version = "1.0.0" + coord.last_update_success = True + coord.clean_settings = settings or CleanSettings() + coord.data = state + return coord + + +def test_select_bases_use_restore_entity() -> None: + """The select restores via RestoreEntity (HA has no RestoreSelect).""" + assert issubclass(NarwalSelect, RestoreEntity) + assert issubclass(NarwalSelect, SelectEntity) + + +class TestNarwalSelect: + def test_current_option_reflects_settings(self) -> None: + coord = _coordinator(settings=CleanSettings(work_mode=WorkMode.MOP)) + sel = NarwalSelect(coord, _DESCS["work_mode"]) + assert sel.current_option == "mop" + + async def test_select_option_stores_value(self) -> None: + coord = _coordinator() + sel = NarwalSelect(coord, _DESCS["mop_strength"]) + await sel.async_select_option("high") + assert coord.clean_settings.mop_strength == MopStrengthLevel.HIGH + assert sel.current_option == "high" + + async def test_water_applies_live_while_cleaning(self) -> None: + coord = _coordinator(state=MagicMock(is_cleaning=True)) + coord.client.set_mop_humidity = AsyncMock() + sel = NarwalSelect(coord, _DESCS["water"]) + await sel.async_select_option("wet") + assert coord.clean_settings.water == MopHumidity.WET + coord.client.set_mop_humidity.assert_awaited_once_with(MopHumidity.WET) + + async def test_no_live_setter_when_not_cleaning(self) -> None: + coord = _coordinator(state=MagicMock(is_cleaning=False)) + coord.client.set_mop_humidity = AsyncMock() + sel = NarwalSelect(coord, _DESCS["water"]) + await sel.async_select_option("dry") + coord.client.set_mop_humidity.assert_not_awaited() + + async def test_restore_from_last_state(self) -> None: + coord = _coordinator() + sel = NarwalSelect(coord, _DESCS["work_mode"]) + with patch.object(sel, "async_get_last_state", AsyncMock(return_value=MagicMock(state="mop"))): + await sel.async_added_to_hass() + assert coord.clean_settings.work_mode == WorkMode.MOP + + async def test_restore_ignores_unknown_option(self) -> None: + coord = _coordinator(settings=CleanSettings(work_mode=WorkMode.VACUUM)) + sel = NarwalSelect(coord, _DESCS["work_mode"]) + with patch.object(sel, "async_get_last_state", AsyncMock(return_value=MagicMock(state="bogus"))): + await sel.async_added_to_hass() + assert coord.clean_settings.work_mode == WorkMode.VACUUM + + +class TestNarwalPassesNumber: + def test_native_value_reflects_settings(self) -> None: + coord = _coordinator(settings=CleanSettings(passes=2)) + assert NarwalPassesNumber(coord).native_value == 2 + + async def test_set_native_value_stores_int(self) -> None: + coord = _coordinator() + num = NarwalPassesNumber(coord) + await num.async_set_native_value(3.0) + assert coord.clean_settings.passes == 3 + + async def test_restore_from_last_number_data(self) -> None: + coord = _coordinator() + num = NarwalPassesNumber(coord) + data = MagicMock(native_value=2) + with patch.object(num, "async_get_last_number_data", AsyncMock(return_value=data)): + await num.async_added_to_hass() + assert coord.clean_settings.passes == 2 diff --git a/tests/test_client_rooms.py b/tests/test_client_rooms.py index ce221aa..03f36b4 100644 --- a/tests/test_client_rooms.py +++ b/tests/test_client_rooms.py @@ -1,195 +1,173 @@ -"""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 pytest +import blackboxprotobuf from narwal_client.client import NarwalClient -from narwal_client.const import CommandResult +from narwal_client.const import ( + CommandResult, + 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] - 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.""" - client = NarwalClient("127.0.0.1") - payload = client._build_room_clean_payload([]) - assert payload == client._DEFAULT_CLEAN_PAYLOAD - def test_room_payload_differs_from_default(self) -> None: - """Room-specific payload is different from whole-house default.""" - 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 +# 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"}), +} -class TestStartRooms: - """Tests for start_rooms async method.""" +class TestBuildStartCleanPayload: + """The CleanTask/CleanParam encoding.""" - def test_empty_rooms_calls_start(self) -> None: - """start_rooms([]) falls back to whole-house start().""" + 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") - client._ws = AsyncMock() # fake connected state - client._connected = True - - 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([])) - 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.""" + 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") - client._ws = AsyncMock() - client._connected = True - - success = CommandResponse(result_code=CommandResult.SUCCESS) - 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([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 + 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_map_zone_and_order(self) -> None: + """map_id, room zone refs, and 1-based order encode correctly.""" + client = NarwalClient("127.0.0.1") + 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 TestStartRoomsV2First: - """Tests for the v2-first schema order in start_rooms (#37 fix). - 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. - """ +class TestStartRooms: + """start_rooms dispatch and settings threading.""" - def _connected_client(self) -> NarwalClient: + def _client(self) -> NarwalClient: client = NarwalClient("127.0.0.1") client._ws = AsyncMock() - client._connected = True + client.state.map_data = MagicMock(map_id=1) return client - 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() - 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]) - ) - - assert result is success - mock_send.assert_awaited_once() + 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: + _run(client.start_rooms([])) + mock_start.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_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: + 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() - not_applicable = CommandResponse(result_code=CommandResult.NOT_APPLICABLE) + _run(client.start_rooms( + [5], work_mode=WorkMode.MOP, fan=FanLevel.STRONG, + water=MopHumidity.DRY, mop_strength=MopStrengthLevel.HIGH, passes=3, + )) + 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 + + def test_no_map_id_returns_not_applicable(self) -> None: + """No active map id (and none from get_map) → bail without sending.""" + client = self._client() + 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) + result = _run(client.start_rooms([5])) + mock_send.assert_not_awaited() + assert result.result_code == CommandResult.NOT_APPLICABLE + + 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: - mock_send.side_effect = [not_applicable, success] - result = asyncio.get_event_loop().run_until_complete( - client.start_rooms([5, 7]) - ) - + 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 - 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 - 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_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 - with patch.object( - client, "send_command", new_callable=AsyncMock - ) as mock_send: + 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 = asyncio.get_event_loop().run_until_complete( - client.start_rooms([5]) - ) - + result = _run(client.start_rooms([5])) mock_send.assert_awaited_once() assert result.result_code == CommandResult.CONFLICT diff --git a/tests/test_vacuum_segments.py b/tests/test_vacuum_segments.py index 986555f..bd724f3 100644 --- a/tests/test_vacuum_segments.py +++ b/tests/test_vacuum_segments.py @@ -17,6 +17,7 @@ tests.ha_stubs.install() from narwal_client.models import MapData, NarwalState, RoomInfo # noqa: E402 +from custom_components.narwal.coordinator import CleanSettings # noqa: E402 from custom_components.narwal.vacuum import NarwalVacuum # noqa: E402 # Grab Segment class from stubs for assertions @@ -36,12 +37,12 @@ def _make_vacuum(state: NarwalState | None = None) -> NarwalVacuum: coordinator.client.state = MagicMock() coordinator.client.state.firmware_version = "1.0.0" coordinator.last_update_success = True + coordinator.clean_settings = CleanSettings() vac = NarwalVacuum.__new__(NarwalVacuum) vac.coordinator = coordinator vac._attr_unique_id = "test_dev_001" vac._attr_device_info = {} - vac._last_fan_speed = None # Stub StateVacuumEntity attributes vac.last_seen_segments = None @@ -166,9 +167,10 @@ class TestAsyncCleanSegments: """Tests for async_clean_segments.""" async def test_converts_string_ids_and_calls_start_rooms(self) -> None: - """Converts string segment IDs to int and calls client.start_rooms.""" + """Converts string segment IDs to int and calls start_rooms with the settings.""" state = NarwalState() vac = _make_vacuum(state=state) + settings = vac.coordinator.clean_settings vac.coordinator.client.start_rooms = AsyncMock( return_value=MagicMock(result_code=0, success=True) ) @@ -178,7 +180,14 @@ async def test_converts_string_ids_and_calls_start_rooms(self) -> None: 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=settings.work_mode, + fan=settings.fan, + water=settings.water, + mop_strength=settings.mop_strength, + passes=settings.passes, + ) class TestCheckSegmentChanges: @@ -242,3 +251,42 @@ def test_no_map_data_does_nothing(self) -> None: vac._check_segment_changes() vac.async_create_segments_issue.assert_not_called() + + +class TestAsyncStartWholeHouse: + """async_start runs a whole-house clean via start_rooms(all rooms), not the saved plan.""" + + async def test_enumerates_all_rooms(self) -> None: + """Whole-house start passes every room id to clean/start_clean, skipping plan/start.""" + state = NarwalState() + state.map_data = MapData(map_id=2, rooms=[ + RoomInfo(room_id=1), RoomInfo(room_id=2), RoomInfo(room_id=0), # 0 filtered + ]) + vac = _make_vacuum(state=state) + vac.coordinator.client.robot_awake = True + vac.coordinator.client.start_rooms = AsyncMock( + return_value=MagicMock(result_code=1, success=True) + ) + vac.coordinator.client.start = AsyncMock() + + await vac.async_start() + + vac.coordinator.client.start_rooms.assert_awaited_once() + assert vac.coordinator.client.start_rooms.await_args.args[0] == [1, 2] + vac.coordinator.client.start.assert_not_called() + + async def test_falls_back_to_saved_plan_without_map(self) -> None: + """With no map rooms available, falls back to the saved-plan start().""" + state = NarwalState() # no map_data + vac = _make_vacuum(state=state) + vac.coordinator.client.robot_awake = True + vac.coordinator.client.get_map = AsyncMock() # does not populate map_data + vac.coordinator.client.start = AsyncMock( + return_value=MagicMock(result_code=1, success=True) + ) + vac.coordinator.client.start_rooms = AsyncMock() + + await vac.async_start() + + vac.coordinator.client.start.assert_awaited_once() + vac.coordinator.client.start_rooms.assert_not_called()