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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -54,3 +54,4 @@ robot_commands_i18n.txt
nul
run_analysis*.py
prompt.md
coverage_*.json
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ Models marked **Not Compatible** use a different protocol or are cloud-only. Thi
- **Start / Stop / Pause / Resume** — all commands validated on hardware
- **Room-specific cleaning** — select rooms from the HA UI (requires HA 2026.3+)
- **Return to dock** / **Locate** (robot announces "Robot is here")
- **Fan speed** — Quiet, Normal, Strong, Max (set-only; robot doesn't broadcast current level)
- **Fan speed** — Quiet, Normal, Strong, Super Powerful (Flow 2 uses 1=Quiet, 2=Normal, 3=Strong, 4=Super Powerful; set-only)

### Sensors
- Battery level, cleaning area, cleaning time, firmware version
Expand Down Expand Up @@ -77,7 +77,7 @@ Models marked **Not Compatible** use a different protocol or are cloud-only. Thi

- **Wake from deep sleep is unreliable** — robot may not respond after long idle periods. Opening the Narwal app briefly can help.
- **Single connection** — close the Narwal app before using HA to avoid conflicts.
- **Fan speed is set-only** — robot doesn't broadcast its current level.
- **Fan speed is set-only** — robot doesn't broadcast its current level; Flow 2's suction labels are 1..4.
- **Default clean settings** — start and room-specific clean use max suction, wet mop, single pass. Per-room customization is not yet available.
- **Map may be stale** — robot can return an old map. A new clean cycle typically refreshes it.

Expand Down
213 changes: 213 additions & 0 deletions custom_components/narwal/binary_sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,16 @@
BinarySensorDeviceClass,
BinarySensorEntity,
)
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback

from . import NarwalConfigEntry
from .coordinator import NarwalCoordinator
from .entity import NarwalEntity
from .narwal_client import ERROR_CODES

CLEAN_WATER_TANK_ERROR_CODE = 0x01010037


async def async_setup_entry(
Expand All @@ -23,6 +27,11 @@ async def async_setup_entry(
coordinator = entry.runtime_data
async_add_entities([
NarwalDockedSensor(coordinator),
NarwalActiveErrorSensor(coordinator),
NarwalStationTankErrorSensor(coordinator),
NarwalCleanWaterTankSensor(coordinator),
NarwalRemoteControlSensor(coordinator),
NarwalUserActionSensor(coordinator),
])


Expand All @@ -46,3 +55,207 @@ def is_on(self) -> bool | None:
return state.is_docked


class NarwalStationTankErrorSensor(NarwalEntity, BinarySensorEntity):
"""On when the base station reports a tank-related fault.

Flow 2 reports station tank faults through robot_base_status field 25.*,
not the generic field 48.1.2 error channel. In live testing,
base.25.6={1:1,2:16842806} appeared while the official app highlighted
Dirty Water Tank in red.
"""

_attr_device_class = BinarySensorDeviceClass.PROBLEM
_attr_translation_key = "station_tank_error"

def __init__(self, coordinator: NarwalCoordinator) -> None:
super().__init__(coordinator)
device_id = coordinator.config_entry.data["device_id"]
self._attr_unique_id = f"{device_id}_station_tank_error"

@property
def is_on(self) -> bool | None:
state = self.coordinator.data
if state is None:
return None
return state.station_error_code != 0

@property
def extra_state_attributes(self) -> dict[str, str | int]:
state = self.coordinator.data
if state is None or state.station_error_code == 0:
return {}
return {
"code": state.station_error_code,
"code_hex": f"0x{state.station_error_code:08x}",
"identifier": ERROR_CODES.get(state.station_error_code, "unknown"),
"severity": state.station_error_severity,
"field25_slot": state.station_error_slot,
}


class NarwalCleanWaterTankSensor(NarwalEntity, BinarySensorEntity):
"""On when the base station reports clean-water tank empty/missing.

Flow 2 live test: starting a mop clean with the clean-water tank removed
kept the app green until the station needed water. Then field 48.1.2
reported code 16842807 with message "clean water tank empty or not
installed (no Hall sensor) while washing mop".
"""

_attr_device_class = BinarySensorDeviceClass.PROBLEM
_attr_translation_key = "clean_water_tank"

def __init__(self, coordinator: NarwalCoordinator) -> None:
super().__init__(coordinator)
device_id = coordinator.config_entry.data["device_id"]
self._attr_unique_id = f"{device_id}_clean_water_tank"

@property
def is_on(self) -> bool | None:
state = self.coordinator.data
if state is None:
return None
return state.error_code == CLEAN_WATER_TANK_ERROR_CODE

@property
def extra_state_attributes(self) -> dict[str, str | int]:
state = self.coordinator.data
if state is None or state.error_code != CLEAN_WATER_TANK_ERROR_CODE:
return {}
return {
"code": state.error_code,
"code_hex": f"0x{state.error_code:08x}",
"identifier": ERROR_CODES.get(state.error_code, "unknown"),
"severity": state.error_severity,
"message": state.error_message,
"localized_message": state.error_message_localized,
}


class NarwalRemoteControlSensor(NarwalEntity, BinarySensorEntity):
"""On while the Flow 2 app live camera / manual control mode is active.

Live test (2026-05-12): opening the app's robot camera/manual steering
mode added robot_base_status.31=1 and field 3.4=15. Both changed back
after leaving live mode. The video stream itself is not exposed here;
this only reports the robot's local mode flag.
"""

_attr_device_class = BinarySensorDeviceClass.RUNNING
_attr_translation_key = "remote_control"
_attr_entity_category = EntityCategory.DIAGNOSTIC

def __init__(self, coordinator: NarwalCoordinator) -> None:
super().__init__(coordinator)
device_id = coordinator.config_entry.data["device_id"]
self._attr_unique_id = f"{device_id}_remote_control"

@property
def is_on(self) -> bool | None:
state = self.coordinator.data
if state is None:
return None
return state.remote_control_active

@property
def extra_state_attributes(self) -> dict[str, int | bool]:
state = self.coordinator.data
if state is None:
return {}
return {
"base_31": bool(state.raw_base_status.get("31")),
"base_3_4": state.remote_control_sub_state,
}


class NarwalActiveErrorSensor(NarwalEntity, BinarySensorEntity):
"""Binary sensor that turns on when the robot reports an active fault.

Driven by robot_base_status field 48.1.2: empty {} = no error, populated
{1, 2, 3} = active fault (e.g. clean water tank empty, mop washer
blocked, dock disconnected). Code + message are exposed as separate
sensors so users can build automations that react to the specific
fault.
"""

_attr_device_class = BinarySensorDeviceClass.PROBLEM
_attr_translation_key = "active_error"

def __init__(self, coordinator: NarwalCoordinator) -> None:
super().__init__(coordinator)
device_id = coordinator.config_entry.data["device_id"]
self._attr_unique_id = f"{device_id}_active_error"

@property
def is_on(self) -> bool | None:
state = self.coordinator.data
if state is None:
return None
return state.error_code != 0

@property
def extra_state_attributes(self) -> dict[str, str | int]:
state = self.coordinator.data
if state is None or state.error_code == 0:
return {}
return {
"code": state.error_code,
"code_hex": f"0x{state.error_code:08x}",
"identifier": ERROR_CODES.get(state.error_code, "unknown"),
"severity": state.error_severity,
"message": state.error_message,
"localized_message": state.error_message_localized,
}


# Map base.3.16 to a stable identifier so automations don't depend on
# the raw integer.
_USER_ACTION_TYPES: dict[int, str] = {
2: "fill_water_tank",
3: "return_to_dock_after_clean",
4: "carry_to_dock_to_start",
}


class NarwalUserActionSensor(NarwalEntity, BinarySensorEntity):
"""On while the robot is waiting for the user to do something physical.

The Flow 2 firmware broadcasts a structured prompt at base.3.16 +
a countdown at ws.22 (elapsed/target seconds). When the user does
the requested action — fill the tank, carry the robot to the dock,
etc. — both clear and the sensor flips back off. extra_state_attributes
expose the action type and the seconds left so automations can
surface a notification only after a grace period.
"""

_attr_device_class = BinarySensorDeviceClass.PROBLEM
_attr_translation_key = "user_action_required"

def __init__(self, coordinator: NarwalCoordinator) -> None:
super().__init__(coordinator)
device_id = coordinator.config_entry.data["device_id"]
self._attr_unique_id = f"{device_id}_user_action_required"

@property
def is_on(self) -> bool | None:
state = self.coordinator.data
if state is None:
return None
return state.user_action_type != 0

@property
def extra_state_attributes(self) -> dict[str, str | int]:
state = self.coordinator.data
if state is None or state.user_action_type == 0:
return {}
target = state.user_action_target
elapsed = state.user_action_elapsed
return {
"type": _USER_ACTION_TYPES.get(state.user_action_type, "unknown"),
"type_code": state.user_action_type,
"elapsed_s": elapsed,
"target_s": target,
"remaining_s": max(target - elapsed, 0) if target else 0,
}


84 changes: 83 additions & 1 deletion custom_components/narwal/config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
import voluptuous as vol

from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo

from .narwal_client import NarwalClient, NarwalCommandError, NarwalConnectionError

Expand All @@ -31,6 +33,76 @@ class NarwalConfigFlow(ConfigFlow, domain=DOMAIN):

VERSION = 2

def __init__(self) -> None:
super().__init__()
# Pre-filled host when discovery (zeroconf / DHCP) finds the
# robot before the user starts the flow.
self._discovered_host: str | None = None

async def _check_already_configured(
self, host: str, fallback_uid: str,
) -> ConfigFlowResult | None:
"""Return an abort result when this host is already a config
entry, otherwise None and seed the discovery unique_id.

The user step assigns unique_id from the robot's device_id
(queried over the WebSocket), which mDNS / DHCP can't see.
Without a host-based check the same robot would re-appear as
a "Discovered" card forever after a manual add.
"""
for entry in self._async_current_entries(include_ignore=False):
if entry.data.get("host") == host:
# Update host on the matching entry in case the IP
# drifted (DHCP renewal, robot moved subnets) and
# abort the discovery flow.
self.hass.config_entries.async_update_entry(
entry, data={**entry.data, "host": host},
)
return self.async_abort(reason="already_configured")
await self.async_set_unique_id(fallback_uid)
self._abort_if_unique_id_configured(updates={"host": host})
return None

async def async_step_zeroconf(
self, discovery_info: ZeroconfServiceInfo
) -> ConfigFlowResult:
"""Pick up Narwal robots advertising `_narwal_sweeper._tcp.local.`.

Robots broadcast an instance name like
``_app_wss_server_<6hex>._narwal_sweeper._tcp.local.`` with a
hostname ``NARWAL_<6hex>.local.`` on port 9002. We only use
the IP for now — the user still picks the model on the next
step, the model name isn't in the mDNS payload.
"""
host = str(discovery_info.host)
already = await self._check_already_configured(
host, discovery_info.hostname.rstrip("."),
)
if already is not None:
return already
self._discovered_host = host
self.context["title_placeholders"] = {"host": host}
return await self.async_step_user()

async def async_step_dhcp(
self, discovery_info: DhcpServiceInfo
) -> ConfigFlowResult:
"""Pick up Narwal robots from DHCP hostname matches.

Backup for when zeroconf is filtered (some routers / VLANs).
Hostname pattern ``NARWAL_*`` / ``narwal_*`` is declared in
manifest.json.
"""
host = str(discovery_info.ip)
already = await self._check_already_configured(
host, discovery_info.hostname or host,
)
if already is not None:
return already
self._discovered_host = host
self.context["title_placeholders"] = {"host": host}
return await self.async_step_user()

async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
Expand Down Expand Up @@ -85,8 +157,18 @@ async def async_step_user(
finally:
await client.disconnect()

# If we got here from a discovery step, pre-fill the host so
# the user only has to confirm the model.
schema = STEP_USER_DATA_SCHEMA
if self._discovered_host and user_input is None:
schema = vol.Schema({
vol.Required("host", default=self._discovered_host): str,
vol.Optional("port", default=DEFAULT_PORT): int,
vol.Required(CONF_MODEL, default=MODEL_OPTIONS[0]): vol.In(MODEL_OPTIONS),
})

return self.async_show_form(
step_id="user",
data_schema=STEP_USER_DATA_SCHEMA,
data_schema=schema,
errors=errors,
)
10 changes: 6 additions & 4 deletions custom_components/narwal/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
# "auto" cycles all known keys during discovery (slower, fallback).
NARWAL_MODELS: dict[str, str] = {
"Narwal Flow": "QoEsI5qYXO",
"Narwal Flow 2": "QxMSPG6VSO",
"Narwal Freo Z10 Ultra": "DrzDKQ0MU8",
"Narwal Freo X10 Pro": "CNbforyZWI",
"Other / Auto-detect": "auto",
Expand All @@ -28,13 +29,14 @@
Platform.SENSOR,
Platform.BINARY_SENSOR,
Platform.CAMERA,
Platform.SELECT,
]

FAN_SPEED_MAP: dict[str, FanLevel] = {
"quiet": FanLevel.QUIET,
"normal": FanLevel.NORMAL,
"strong": FanLevel.STRONG,
"max": FanLevel.MAX,
"Quiet": FanLevel.QUIET,
"Normal": FanLevel.NORMAL,
"Strong": FanLevel.STRONG,
"Super Powerful": FanLevel.MAX,
}

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