Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
29 changes: 14 additions & 15 deletions custom_components/narwal/narwal_client/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -227,20 +227,19 @@ class UpgradeStatusField(IntEnum):
class WorkingStatusField(IntEnum):
"""Field numbers in the working_status protobuf message.

Confirmed via live test (2026-02-27):
3 = current session elapsed seconds (confirmed: 2136→2159 over 35-min clean)
13 = cleaning area in cm² (confirmed: 18000 = 1.8m²)
15 = 600 during cleaning (possibly cumulative or constant)
6 = 1 during cleaning (observed in plan-based clean; may vary by mode)
10 = time since docked in seconds (post-dock only, counts up)
11 = 2700 post-dock (unknown, constant)

Also broadcast during cleaning:
status/time_line_status — timeline/history data
developer/planning_debug_info — navigation debug (collision count, stall count)
Names from the decompiled WorkingStatus proto BuilderInfo:
1 = workingProgress (double)
2 = coveredArea (float32, PbFieldType 0x100) — area cleaned this session, m²
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Outdated
3 = timeConsuming (seconds) — session elapsed time
4 = remainedTime (seconds)
6 = cleaningZoneId
8..17 = station drying/sterilization/dust-bag timers (seconds); the
cumulative "total*" counters (9/11/13/15/17) stay constant while
idle. Field 13 = totalDryStationBagTime (18000 = 5h) — earlier
misread as cleaning area because 18000/10000 looked like 1.8 m².
"""

ELAPSED_TIME = 3 # current session elapsed seconds — CONFIRMED
AREA = 13 # cm² — CONFIRMED (18000 = 1.8m²)
CUMULATIVE_TIME = 15 # 600 during cleaning (purpose uncertain)
TIME_SINCE_DOCKED = 10 # seconds since docked (post-dock only)
PROGRESS = 1 # workingProgress (float32, 0..1)
AREA = 2 # coveredArea (float32) — m²
ELAPSED_TIME = 3 # timeConsuming — session elapsed seconds
REMAINING_TIME = 4 # remainedTime — seconds
23 changes: 12 additions & 11 deletions custom_components/narwal/narwal_client/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -509,7 +509,7 @@ class NarwalState:
position: Position | None = None

# Cleaning stats
cleaning_area: int = 0 # cm²
cleaning_area: float = 0.0 # m² (coveredArea)
cleaning_time: int = 0 # seconds

# Map
Expand Down Expand Up @@ -627,23 +627,24 @@ def is_returning(self) -> bool:
def update_from_working_status(self, decoded: dict[str, Any]) -> None:
"""Update state from a decoded working_status message.

Confirmed via 35-min monitor capture (2026-02-27):
Field 3 = current session elapsed time (seconds)
(confirmed: 2136→2159 over 35-min clean)
Field 13 = cleaning area (cm²) — CONFIRMED (18000 = 1.8m²)
Field 15 = 600 during cleaning (purpose uncertain)
WorkingStatus proto fields (decompiled BuilderInfo):
Field 2 = coveredArea (float32, PbFieldType 0x100) — area cleaned this session, m²
Field 3 = timeConsuming (seconds) — session elapsed time
(confirmed: 2136→2159 over a 35-min clean)

Field 13 is totalDryStationBagTime (cumulative station timer, 18000 = 5h),
not area — reading it as area is why the sensor was stuck at 1.8 m².
"""
self.raw_working_status = decoded
if "3" in decoded:
try:
self.cleaning_time = int(decoded["3"])
except (ValueError, TypeError):
pass
if "13" in decoded:
self.cleaning_area = int(decoded["13"])
if "15" in decoded:
# Field 15 may be cumulative time; prefer field 3 for current session
pass
if "2" in decoded:
area = _to_float32(decoded["2"])
if area is not None and area >= 0:
self.cleaning_area = area

def update_from_base_status(self, decoded: dict[str, Any]) -> None:
"""Update state from a decoded robot_base_status message.
Expand Down
6 changes: 3 additions & 3 deletions custom_components/narwal/sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,9 +43,9 @@ class NarwalSensorEntityDescription(SensorEntityDescription):
translation_key="cleaning_area",
native_unit_of_measurement=UnitOfArea.SQUARE_METERS,
state_class=SensorStateClass.MEASUREMENT,
# working_status field 13 is cm²; divide by 10000 for m².
# NEEDS LIVE VALIDATION: only populated during active cleaning.
value_fn=lambda state: round(state.cleaning_area / 10000, 2)
# working_status field 2 (coveredArea) is already m²; populated only
# during active cleaning. Units (m² vs scaled) need a live-clean check.
value_fn=lambda state: round(state.cleaning_area, 2)
Comment thread
jgus marked this conversation as resolved.
Outdated
if state.cleaning_area > 0
else None,
),
Expand Down
29 changes: 14 additions & 15 deletions narwal_client/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -227,20 +227,19 @@ class UpgradeStatusField(IntEnum):
class WorkingStatusField(IntEnum):
"""Field numbers in the working_status protobuf message.

Confirmed via live test (2026-02-27):
3 = current session elapsed seconds (confirmed: 2136→2159 over 35-min clean)
13 = cleaning area in cm² (confirmed: 18000 = 1.8m²)
15 = 600 during cleaning (possibly cumulative or constant)
6 = 1 during cleaning (observed in plan-based clean; may vary by mode)
10 = time since docked in seconds (post-dock only, counts up)
11 = 2700 post-dock (unknown, constant)

Also broadcast during cleaning:
status/time_line_status — timeline/history data
developer/planning_debug_info — navigation debug (collision count, stall count)
Names from the decompiled WorkingStatus proto BuilderInfo:
1 = workingProgress (double)
2 = coveredArea (float32, PbFieldType 0x100) — area cleaned this session, m²
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Outdated
3 = timeConsuming (seconds) — session elapsed time
4 = remainedTime (seconds)
6 = cleaningZoneId
8..17 = station drying/sterilization/dust-bag timers (seconds); the
cumulative "total*" counters (9/11/13/15/17) stay constant while
idle. Field 13 = totalDryStationBagTime (18000 = 5h) — earlier
misread as cleaning area because 18000/10000 looked like 1.8 m².
"""

ELAPSED_TIME = 3 # current session elapsed seconds — CONFIRMED
AREA = 13 # cm² — CONFIRMED (18000 = 1.8m²)
CUMULATIVE_TIME = 15 # 600 during cleaning (purpose uncertain)
TIME_SINCE_DOCKED = 10 # seconds since docked (post-dock only)
PROGRESS = 1 # workingProgress (float32, 0..1)
AREA = 2 # coveredArea (float32) — m²
ELAPSED_TIME = 3 # timeConsuming — session elapsed seconds
REMAINING_TIME = 4 # remainedTime — seconds
23 changes: 12 additions & 11 deletions narwal_client/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -509,7 +509,7 @@ class NarwalState:
position: Position | None = None

# Cleaning stats
cleaning_area: int = 0 # cm²
cleaning_area: float = 0.0 # m² (coveredArea)
cleaning_time: int = 0 # seconds

# Map
Expand Down Expand Up @@ -627,23 +627,24 @@ def is_returning(self) -> bool:
def update_from_working_status(self, decoded: dict[str, Any]) -> None:
"""Update state from a decoded working_status message.

Confirmed via 35-min monitor capture (2026-02-27):
Field 3 = current session elapsed time (seconds)
(confirmed: 2136→2159 over 35-min clean)
Field 13 = cleaning area (cm²) — CONFIRMED (18000 = 1.8m²)
Field 15 = 600 during cleaning (purpose uncertain)
WorkingStatus proto fields (decompiled BuilderInfo):
Field 2 = coveredArea (float32, PbFieldType 0x100) — area cleaned this session, m²
Field 3 = timeConsuming (seconds) — session elapsed time
(confirmed: 2136→2159 over a 35-min clean)

Field 13 is totalDryStationBagTime (cumulative station timer, 18000 = 5h),
not area — reading it as area is why the sensor was stuck at 1.8 m².
"""
self.raw_working_status = decoded
if "3" in decoded:
try:
self.cleaning_time = int(decoded["3"])
except (ValueError, TypeError):
pass
if "13" in decoded:
self.cleaning_area = int(decoded["13"])
if "15" in decoded:
# Field 15 may be cumulative time; prefer field 3 for current session
pass
if "2" in decoded:
area = _to_float32(decoded["2"])
if area is not None and area >= 0:
self.cleaning_area = area

def update_from_base_status(self, decoded: dict[str, Any]) -> None:
"""Update state from a decoded robot_base_status message.
Expand Down
11 changes: 7 additions & 4 deletions tests/test_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,12 @@ def test_default_state(self) -> None:
def test_update_from_working_status(self) -> None:
"""working_status topic sets cleaning metrics, not robot state."""
state = NarwalState()
state.update_from_working_status({"3": 120, "13": 18000, "15": 600})
# Field 2 = coveredArea (float32, m²); field 13 = totalDryStationBagTime, ignored.
state.update_from_working_status(
{"2": _float_to_uint32(12.5), "3": 120, "13": 18000}
)
assert state.cleaning_time == 120
assert state.cleaning_area == 18000
assert state.cleaning_area == 12.5
# working_status is NOT set by this method (comes from base_status)
assert state.working_status == WorkingStatus.UNKNOWN

Expand Down Expand Up @@ -223,13 +226,13 @@ def test_incremental_updates(self) -> None:
"""State should accumulate across multiple topic updates."""
state = NarwalState()
state.update_from_base_status({"3": {"1": 4}, "2": _float_to_uint32(95.0)})
state.update_from_working_status({"3": 120, "13": 18000})
state.update_from_working_status({"3": 120, "2": _float_to_uint32(12.5)})
state.update_from_upgrade_status({"7": "v01.02.19.02"})

assert state.battery_level == 95
assert state.is_cleaning
assert state.cleaning_time == 120
assert state.cleaning_area == 18000
assert state.cleaning_area == 12.5
assert state.firmware_version == "v01.02.19.02"

def test_raw_data_preserved(self) -> None:
Expand Down