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
129 changes: 126 additions & 3 deletions custom_components/narwal/binary_sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,28 +2,120 @@

from __future__ import annotations

from collections.abc import Callable
from dataclasses import dataclass
from typing import Any

from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass,
BinarySensorEntity,
BinarySensorEntityDescription,
)
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback

from .narwal_client import NarwalState

from . import NarwalConfigEntry
from .const import ERROR_HELP_URL_TEMPLATE
from .coordinator import NarwalCoordinator
from .entity import NarwalEntity


@dataclass(frozen=True, kw_only=True)
class NarwalBinarySensorEntityDescription(BinarySensorEntityDescription):
"""Describes a Narwal binary sensor; value_fn returns None when unavailable."""

value_fn: Callable[[NarwalState], bool | None]
attrs_fn: Callable[[NarwalState], dict[str, Any] | None] | None = None


def _tank_problem(attr: str, bad: frozenset[int]) -> Callable[[NarwalState], bool | None]:
"""A station tank/bag state is a problem when its enum value is one of `bad`.

The state attr is None when this model doesn't report that field, which
keeps the entity unavailable rather than asserting "OK".
"""
def fn(state: NarwalState) -> bool | None:
value = getattr(state, attr)
return None if value is None else value in bad
return fn


# Station tank/bag problem sensors. Bad-value sets come from the decoded enums
# (RobotBaseStatus.pbenum): every named value ≥ 2 is an attention state
# (empty / abnormal / not-installed / suggest-replace); 0=unspecified, 1=ok.
BINARY_SENSOR_DESCRIPTIONS: tuple[NarwalBinarySensorEntityDescription, ...] = (
NarwalBinarySensorEntityDescription(
key="error",
translation_key="error",
device_class=BinarySensorDeviceClass.PROBLEM,
entity_category=EntityCategory.DIAGNOSTIC,
# base_status field 1 errorCode: empty when healthy, populated on a fault.
value_fn=lambda s: s.has_error if s.raw_base_status else None,
# Expose the fault detail (numeric code(s), severity, debug string, help link) when present.
attrs_fn=lambda s: {
"codes": s.error_codes,
"level": s.error_level,
"detail": s.error_detail,
**(
{"help_url": ERROR_HELP_URL_TEMPLATE.format(code=s.error_codes[0])}
if s.error_codes else {}
),
} if s.raw_base_status else None,
),
NarwalBinarySensorEntityDescription(
key="clean_water_tank",
translation_key="clean_water_tank",
device_class=BinarySensorDeviceClass.PROBLEM,
entity_category=EntityCategory.DIAGNOSTIC,
value_fn=_tank_problem("clean_water_tank_state", frozenset({2, 3, 4})),
),
NarwalBinarySensorEntityDescription(
key="sewage_tank",
translation_key="sewage_tank",
device_class=BinarySensorDeviceClass.PROBLEM,
entity_category=EntityCategory.DIAGNOSTIC,
value_fn=_tank_problem("sewage_tank_state", frozenset({2, 3})),
),
NarwalBinarySensorEntityDescription(
key="dust_box",
translation_key="dust_box",
device_class=BinarySensorDeviceClass.PROBLEM,
entity_category=EntityCategory.DIAGNOSTIC,
value_fn=_tank_problem("dust_box_state", frozenset({2, 3, 4})),
),
NarwalBinarySensorEntityDescription(
key="dust_bag",
translation_key="dust_bag",
device_class=BinarySensorDeviceClass.PROBLEM,
entity_category=EntityCategory.DIAGNOSTIC,
value_fn=_tank_problem("dust_bag_state", frozenset({2, 3, 4})),
),
NarwalBinarySensorEntityDescription(
key="station_bag",
translation_key="station_bag",
device_class=BinarySensorDeviceClass.PROBLEM,
entity_category=EntityCategory.DIAGNOSTIC,
value_fn=_tank_problem("station_bag_state", frozenset({2, 3, 4})),
),
)


async def async_setup_entry(
hass: HomeAssistant,
entry: NarwalConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Narwal binary sensor entities."""
coordinator = entry.runtime_data
async_add_entities([
NarwalDockedSensor(coordinator),
])
entities: list[BinarySensorEntity] = [NarwalDockedSensor(coordinator)]
entities += [
NarwalBinarySensor(coordinator, description)
for description in BINARY_SENSOR_DESCRIPTIONS
]
async_add_entities(entities)


class NarwalDockedSensor(NarwalEntity, BinarySensorEntity):
Expand All @@ -46,3 +138,34 @@ def is_on(self) -> bool | None:
return state.is_docked


class NarwalBinarySensor(NarwalEntity, BinarySensorEntity):
"""A description-driven Narwal binary sensor (fault / station consumables)."""

entity_description: NarwalBinarySensorEntityDescription

def __init__(
self,
coordinator: NarwalCoordinator,
description: NarwalBinarySensorEntityDescription,
) -> None:
"""Initialize the binary sensor."""
super().__init__(coordinator)
self.entity_description = description
device_id = coordinator.config_entry.data["device_id"]
self._attr_unique_id = f"{device_id}_{description.key}"

@property
def is_on(self) -> bool | None:
"""Return the sensor value (None = unavailable)."""
state = self.coordinator.data
if state is None:
return None
return self.entity_description.value_fn(state)

@property
def extra_state_attributes(self) -> dict[str, Any] | None:
"""Optional per-sensor attributes (e.g. fault code detail)."""
state = self.coordinator.data
if state is None or self.entity_description.attrs_fn is None:
return None
return self.entity_description.attrs_fn(state)
8 changes: 8 additions & 0 deletions custom_components/narwal/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,3 +39,11 @@
}

FAN_SPEED_LIST: list[str] = list(FAN_SPEED_MAP.keys())

# Best-effort help-center deep link for a robot error code. The app's goHelpCenterByCode
# builds <localized help base>?code=<n>&deviceId=…&lang=…; the exact base is a runtime
# i18n value we can't read, so this is inferred from the Flow's help-center family and
# should be corrected if a real error opens a different path. The raw code is the fallback.
ERROR_HELP_URL_TEMPLATE = (
"https://help.narwal.com/helpcenter/vall/#/p2/question/all?eType=1&code={code}&lang=en-US"
)
53 changes: 36 additions & 17 deletions custom_components/narwal/narwal_client/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,11 @@ class CommandResult(IntEnum):
class WorkingStatus(IntEnum):
"""Robot working state from robot_base_status field 3 → sub-field 1.

These values are EMPIRICAL and intentionally do NOT match the app's compiled
RobotTaskStatus.TaskType enum (sub-field 1's declared type): on this firmware
the robot reports e.g. 14=charged where TaskType 14=WASH_AND_DRY_MOP. Trust
the live-observed mapping below, not re/ENUMS.md, for this field (and f47).

Values confirmed via live WebSocket monitoring:
1 = STANDBY (idle, transition state between cleaning and docked)
2 = DOCKED_V2 (on dock; confirmed v01.07.23.00 while charging at 10-36%)
Expand Down Expand Up @@ -197,28 +202,42 @@ class MopHumidity(IntEnum):

# robot_base_status field numbers
class BaseStatusField(IntEnum):
"""Field numbers in the robot_base_status protobuf message.
"""Field numbers in the robot_base_status (RobotBaseStatus) message.

Battery notes (confirmed via 35-min monitor capture, 2026-02-27):
Field 2 = real-time battery level as IEEE 754 float32
(e.g. 1118175232 → 83.0%, matching app display ~84%)
Field 38 = static battery health (always 100; design capacity, not SOC)
Names from the decompiled BuilderInfo, several live-validated on dock.
Field 2 is float32 (PbFieldType 0x100), e.g. 1120403456 → 100.0.
Most state fields (5,11,12,14,15,20-24,26,28,29,31,33,39,40,42,47,49,50)
are enums whose value→label tables are not yet decoded.
"""

BATTERY_LEVEL = 2 # real-time SOC as float32 — CONFIRMED
MODE_STATE = 3
SESSION_ID = 13
SENSOR_DATA = 25
TIMESTAMP = 36
BATTERY_HEALTH = 38 # static, always 100 (design capacity)
BATTERY_CAPACITY = 41


# upgrade_status field numbers
ERROR_CODE = 1 # repeated ErrorCode; empty when no fault
BATTERY_LEVEL = 2 # batteryPercentage, float32
ROBOT_TASK_STATUS = 3 # nested task-status message
BINDED_UUID = 13 # bound account/device UUID (string)
CLEAN_WATER_TANK_STATE = 23 # enum
SEWAGE_TANK_STATE = 24 # enum
DEVICE_STATUS_CODE_LIST = 25
FAN_LEVEL = 26 # active suction (FanLevel enum)
MOP_HUMIDITY = 29 # active water level (MopHumidity enum)
STATION_BAG_HEALTH_SCORE = 35 # float32, %
STATION_BAG_HEALTH_RESET_TIME = 36 # epoch
CURING_AGENT_CONSUMPTION_PERCENT = 38
HEAVY_DETERGENT_REMAIN_PERCENT = 41
CHARGING_STATUS = 47 # canonical charging state (enum)


# upgrade_status field numbers (OTAUpgradeStatus)
class UpgradeStatusField(IntEnum):
"""Field numbers in the upgrade_status protobuf message."""
"""Field numbers in the upgrade_status (OTAUpgradeStatus) message.

Names from the decompiled BuilderInfo: 1 type, 2 status, 3 progress,
4 stage, 5 errorCode, 6 detailErrorCode, 7 currentVersion, 8 targetVersion.
"""

STATUS_CODE = 4
STATUS = 2
PROGRESS = 3
STAGE = 4
ERROR_CODE = 5
CURRENT_FIRMWARE = 7
TARGET_FIRMWARE = 8

Expand Down
Loading