Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions custom_components/narwal/narwal_client/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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.

Expand All @@ -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:
Expand Down
8 changes: 8 additions & 0 deletions custom_components/narwal/sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
),
)


Expand Down
3 changes: 3 additions & 0 deletions custom_components/narwal/strings.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,9 @@
"fully_charged": "Fully Charged",
"not_charging": "Not Charging"
}
},
"current_room": {
"name": "Current room"
}
},
"binary_sensor": {
Expand Down
3 changes: 3 additions & 0 deletions custom_components/narwal/translations/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,9 @@
"fully_charged": "Fully Charged",
"not_charging": "Not Charging"
}
},
"current_room": {
"name": "Current room"
}
},
"binary_sensor": {
Expand Down
30 changes: 30 additions & 0 deletions narwal_client/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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.

Expand All @@ -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:
Expand Down
133 changes: 133 additions & 0 deletions tests/test_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
MapData,
NarwalState,
ObstacleInfo,
RoomInfo,
_parse_obstacles,
)

Expand Down Expand Up @@ -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"