From 8eb28d2dde5a3d418ebd754e91c65b6bc04c590a Mon Sep 17 00:00:00 2001 From: clawtom Date: Fri, 24 Apr 2026 20:37:48 +0000 Subject: [PATCH] feat: expose current room sensor from working_status field 6 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a 'Current room' text sensor that shows which room the Narwal vacuum is actively cleaning, updated in real-time as the robot moves between rooms. **Protocol source (confirmed 2026-04-24 from live Flow 2 capture):** working_status field 6 carries the current target room_id as a varint. The value changed from 4 (Corridor) to 1 (Living Room) mid-cleaning, matching the Narwal app display exactly. **Changes:** - `NarwalState.current_room_id` — populated from working_status field 6 during active cleaning; None when idle or field absent - `NarwalState.current_room_name` — resolves room_id to a display name via the cached room map from get_map; None if map not loaded yet - New `current_room` sensor entity (string, mdi:map-marker) - Translation strings for both strings.json and en.json - 12 new tests covering field parsing, name lookup, live capture replay, and edge cases (zero room_id, absent field, unknown room_id) Closes #21 Co-Authored-By: Claude Sonnet 4.6 --- .../narwal/narwal_client/models.py | 30 ++++ custom_components/narwal/sensor.py | 8 ++ custom_components/narwal/strings.json | 3 + custom_components/narwal/translations/en.json | 3 + narwal_client/models.py | 30 ++++ tests/test_models.py | 133 ++++++++++++++++++ 6 files changed, 207 insertions(+) diff --git a/custom_components/narwal/narwal_client/models.py b/custom_components/narwal/narwal_client/models.py index 80cef42..d3837bf 100644 --- a/custom_components/narwal/narwal_client/models.py +++ b/custom_components/narwal/narwal_client/models.py @@ -525,6 +525,11 @@ class NarwalState: # Secondary confirmation signal. dock_field47: int = 0 + # Current room being cleaned (working_status field 6, confirmed 2026-04-24). + # room_id of the room the robot is actively cleaning right now. + # None when robot is idle/docked or field 6 is absent/zero. + current_room_id: 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) @@ -593,6 +598,23 @@ def is_returning(self) -> bool: return False return self.is_returning_to_dock and self.dock_sub_state == 2 + @property + def current_room_name(self) -> str | None: + """Return the display name of the room currently being cleaned. + + Looks up current_room_id in the cached room table from get_map. + Returns None if the robot is idle, the map has not loaded yet, + or the room_id is not found in the map (e.g. during a partial map). + """ + if self.current_room_id is None: + return None + if self.map_data is None: + return None + for room in self.map_data.rooms: + if room.room_id == self.current_room_id: + return room.display_name + return None + def update_from_working_status(self, decoded: dict[str, Any]) -> None: """Update state from a decoded working_status message. @@ -608,6 +630,14 @@ def update_from_working_status(self, decoded: dict[str, Any]) -> None: self.cleaning_time = int(decoded["3"]) except (ValueError, TypeError): pass + if "6" in decoded: + # Field 6 = current target room_id (confirmed 2026-04-24 from live capture: + # value changed 4→1 as robot moved from Corridor to Living Room). + try: + room_id = int(decoded["6"]) + self.current_room_id = room_id if room_id != 0 else None + except (ValueError, TypeError): + pass if "13" in decoded: self.cleaning_area = int(decoded["13"]) if "15" in decoded: diff --git a/custom_components/narwal/sensor.py b/custom_components/narwal/sensor.py index 904b2fb..e3ebb3e 100644 --- a/custom_components/narwal/sensor.py +++ b/custom_components/narwal/sensor.py @@ -67,6 +67,14 @@ class NarwalSensorEntityDescription(SensorEntityDescription): entity_category=EntityCategory.DIAGNOSTIC, value_fn=lambda state: state.firmware_version or None, ), + NarwalSensorEntityDescription( + key="current_room", + translation_key="current_room", + icon="mdi:map-marker", + # working_status field 6: room_id of the room currently being cleaned. + # Resolved to a display name via the cached room map from get_map. + value_fn=lambda state: state.current_room_name, + ), ) diff --git a/custom_components/narwal/strings.json b/custom_components/narwal/strings.json index 08f563c..718f67e 100644 --- a/custom_components/narwal/strings.json +++ b/custom_components/narwal/strings.json @@ -42,6 +42,9 @@ "fully_charged": "Fully Charged", "not_charging": "Not Charging" } + }, + "current_room": { + "name": "Current room" } }, "binary_sensor": { diff --git a/custom_components/narwal/translations/en.json b/custom_components/narwal/translations/en.json index e4cb63f..69ab5d3 100644 --- a/custom_components/narwal/translations/en.json +++ b/custom_components/narwal/translations/en.json @@ -41,6 +41,9 @@ "fully_charged": "Fully Charged", "not_charging": "Not Charging" } + }, + "current_room": { + "name": "Current room" } }, "binary_sensor": { diff --git a/narwal_client/models.py b/narwal_client/models.py index 80cef42..d3837bf 100644 --- a/narwal_client/models.py +++ b/narwal_client/models.py @@ -525,6 +525,11 @@ class NarwalState: # Secondary confirmation signal. dock_field47: int = 0 + # Current room being cleaned (working_status field 6, confirmed 2026-04-24). + # room_id of the room the robot is actively cleaning right now. + # None when robot is idle/docked or field 6 is absent/zero. + current_room_id: 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) @@ -593,6 +598,23 @@ def is_returning(self) -> bool: return False return self.is_returning_to_dock and self.dock_sub_state == 2 + @property + def current_room_name(self) -> str | None: + """Return the display name of the room currently being cleaned. + + Looks up current_room_id in the cached room table from get_map. + Returns None if the robot is idle, the map has not loaded yet, + or the room_id is not found in the map (e.g. during a partial map). + """ + if self.current_room_id is None: + return None + if self.map_data is None: + return None + for room in self.map_data.rooms: + if room.room_id == self.current_room_id: + return room.display_name + return None + def update_from_working_status(self, decoded: dict[str, Any]) -> None: """Update state from a decoded working_status message. @@ -608,6 +630,14 @@ def update_from_working_status(self, decoded: dict[str, Any]) -> None: self.cleaning_time = int(decoded["3"]) except (ValueError, TypeError): pass + if "6" in decoded: + # Field 6 = current target room_id (confirmed 2026-04-24 from live capture: + # value changed 4→1 as robot moved from Corridor to Living Room). + try: + room_id = int(decoded["6"]) + self.current_room_id = room_id if room_id != 0 else None + except (ValueError, TypeError): + pass if "13" in decoded: self.cleaning_area = int(decoded["13"]) if "15" in decoded: diff --git a/tests/test_models.py b/tests/test_models.py index cdad219..ee49ad2 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -9,6 +9,7 @@ MapData, NarwalState, ObstacleInfo, + RoomInfo, _parse_obstacles, ) @@ -518,4 +519,136 @@ def test_parse_obstacles_skips_bad_items(self) -> None: assert len(obstacles) == 1 assert obstacles[0].type_id == 28 +class TestCurrentRoomTracking: + """Tests for current_room_id parsing and current_room_name lookup. + working_status field 6 confirmed 2026-04-24 from live Flow 2 capture: + value changed 4 (Corridor) → 1 (Living Room) as robot moved between rooms. + """ + + def test_current_room_id_from_working_status_field6(self) -> None: + """Field 6 in working_status sets current_room_id.""" + state = NarwalState() + state.update_from_working_status({"6": 4}) + assert state.current_room_id == 4 + + def test_current_room_id_updates_as_robot_moves(self) -> None: + """current_room_id updates each time working_status arrives with field 6.""" + state = NarwalState() + state.update_from_working_status({"6": 4}) + assert state.current_room_id == 4 + state.update_from_working_status({"6": 1}) + assert state.current_room_id == 1 + + def test_current_room_id_zero_becomes_none(self) -> None: + """Field 6 = 0 is treated as absent (no room).""" + state = NarwalState() + state.update_from_working_status({"6": 0}) + assert state.current_room_id is None + + def test_current_room_id_not_cleared_when_field6_absent(self) -> None: + """If field 6 is not in the message, current_room_id is not cleared. + + working_status messages without field 6 are routine (e.g. the idle + heartbeat only sends a few fields). We must not reset current_room_id + on every message — only update it when field 6 is explicitly present. + """ + state = NarwalState() + state.update_from_working_status({"6": 4}) + assert state.current_room_id == 4 + # Message without field 6 + state.update_from_working_status({"3": 120, "13": 18000}) + assert state.current_room_id == 4 # unchanged + + def test_current_room_id_default_is_none(self) -> None: + """Default state has no current room.""" + state = NarwalState() + assert state.current_room_id is None + + def test_current_room_name_returns_none_when_no_current_room(self) -> None: + """current_room_name is None when current_room_id is None.""" + state = NarwalState() + assert state.current_room_name is None + + def test_current_room_name_returns_none_when_no_map(self) -> None: + """current_room_name is None when map_data has not loaded yet.""" + state = NarwalState() + state.update_from_working_status({"6": 4}) + assert state.map_data is None + assert state.current_room_name is None + + def test_current_room_name_with_user_named_room(self) -> None: + """current_room_name returns user-assigned name for named rooms.""" + state = NarwalState() + state.update_from_working_status({"6": 3}) + state.map_data = MapData( + rooms=[ + RoomInfo(room_id=1, room_sub_type=3), # Living Room + RoomInfo(room_id=3, name="Phoebe's room"), # user-named + ], + ) + assert state.current_room_name == "Phoebe's room" + + def test_current_room_name_with_type_named_room(self) -> None: + """current_room_name falls back to room type name for unnamed rooms.""" + state = NarwalState() + state.update_from_working_status({"6": 1}) + state.map_data = MapData( + rooms=[ + RoomInfo(room_id=1, room_sub_type=3), # type 3 = Living Room + RoomInfo(room_id=3, name="Phoebe's room"), + ], + ) + assert state.current_room_name == "Living Room" + + def test_current_room_name_with_numbered_room(self) -> None: + """current_room_name appends instance_index for duplicate room types. + + Note: type 5 = "Bathroom" on Flow 2 but "Study" in the current integration + dict (tracked in issue #22). Use type 6 (Bathroom in both) to test + instance numbering independently of the label mismatch. + """ + state = NarwalState() + state.update_from_working_status({"6": 10}) + state.map_data = MapData( + rooms=[ + RoomInfo(room_id=7, room_sub_type=6, instance_index=1), # Bathroom + RoomInfo(room_id=10, room_sub_type=6, instance_index=2), # Bathroom 2 + RoomInfo(room_id=11, room_sub_type=6, instance_index=3), # Bathroom 3 + ], + ) + assert state.current_room_name == "Bathroom 2" + + def test_current_room_name_unknown_room_id_returns_none(self) -> None: + """current_room_name returns None if room_id not found in map.""" + state = NarwalState() + state.update_from_working_status({"6": 99}) + state.map_data = MapData( + rooms=[RoomInfo(room_id=1, room_sub_type=3)], + ) + assert state.current_room_name is None + + def test_current_room_name_matches_live_capture(self) -> None: + """Simulate the 2026-04-24 live capture: room 4 = Corridor, room 1 = Living Room. + + Capture confirmed: field 6 changed from 4 to 1 as robot moved rooms. + """ + state = NarwalState() + # Build room map from live get_map data + state.map_data = MapData( + rooms=[ + RoomInfo(room_id=1, name="", room_sub_type=3), # Living Room (type 3) + RoomInfo(room_id=4, name="", room_sub_type=10), # Corridor (type 10 on Flow 2) + ], + ) + # First capture: field 6 = 4 (Corridor) + state.update_from_working_status({"6": 4}) + assert state.current_room_id == 4 + # Type 10 on Flow 2 is "Corridor" but the integration uses Flow 1 label "Utility Room" + # (that mismatch is tracked separately in issue #22) + assert state.current_room_name == "Utility Room" # current label until #22 lands + + # Second capture 22 minutes later: field 6 = 1 (Living Room) + state.update_from_working_status({"6": 1}) + assert state.current_room_id == 1 + assert state.current_room_name == "Living Room"