Skip to content

feat(clean): expose clean settings as HA entities (work mode / fan / water / mop strength / passes) - #50

Merged
sjmotew merged 3 commits into
sjmotew:masterfrom
jgus:feat/clean-settings
Aug 8, 2026
Merged

feat(clean): expose clean settings as HA entities (work mode / fan / water / mop strength / passes)#50
sjmotew merged 3 commits into
sjmotew:masterfrom
jgus:feat/clean-settings

Conversation

@jgus

@jgus jgus commented Jun 14, 2026

Copy link
Copy Markdown
Collaborator

Builds on #49 (fix(clean): room cleaning via clean/start_clean with a parameterized CleanParam) and is intended to be merged after it — it uses that PR's WorkMode / parameterized start_rooms. While #49 is open, this PR's diff includes its commit as well; merge #49 first and this reduces to just the HA layer below, or take both together if you prefer.

Summary

Exposes the room-clean parameters decoded in #49 as Home Assistant controls, so users can set the work mode, suction, water, mop strength, and pass count and have them applied to room cleans.

What it adds

  • select entities (config category):
    • Clean mode — Vacuum / Mop / Vacuum then mop / Vacuum and mop
    • Mopping humidity — Slightly dry / Normal / Slightly wet
    • Mop strength — Normal / High
  • number entity: Cleaning passes (1–3)
  • The vacuum's existing fan_speed is threaded through the same settings.

All are backed by a single CleanSettings dataclass on the coordinator (the source the clean-start path reads). async_clean_segments threads them into start_rooms; water and fan also apply live while the robot is cleaning.

Persistence

Values persist across restarts via HA's RestoreEntity (RestoreSelect / RestoreNumber / RestoreEntity) — set once and kept. No manual storage; restore_state replays the last value into CleanSettings on startup.

Labels

Option labels use the app's user-visible wording (same convention as #49's fan labels). Two caveats:

  • Mop strength — the app exposes no user-visible labels for it, so "Normal/High" are best-effort (it's a real CleanParam field the app otherwise auto-manages).
  • French select labels are best-effort, not verified against the app's fr-FR.

Testing

@greptile-apps

greptile-apps Bot commented Jun 14, 2026

Copy link
Copy Markdown

Greptile Summary

This PR adds Home Assistant select and number entities that expose the room-clean parameters decoded in #49 — work mode, mopping humidity, mop strength, and pass count — plus threads the existing fan speed through the same CleanSettings dataclass on the coordinator. All values persist across restarts via RestoreEntity/RestoreNumber and are applied to both whole-house and room-specific cleans.

  • New entities (all config category): three SelectEntity instances (clean mode, mopping humidity, mop strength) and one RestoreNumber (cleaning passes 1–3), all sharing a single CleanSettings dataclass on the coordinator that start_rooms reads at clean-start time.
  • Fan speed overhaul: FanLevel enum values are updated to match the actual APK proto integers (MUTE=1, NORMAL=2, STRONG=3, DEEP=4, SUPER=5); old lowercase aliases (quiet, normal, max) are kept in FAN_SPEED_MAP for backward-compat service calls while the reported fan_speed and the dropdown list switch to sentence-case canonical labels.
  • Whole-house start rerouted: async_start now enumerates all rooms via clean/start_clean (matching the app's allRoomIds() path) instead of clean/plan/start, applying CleanSettings; a best-effort fallback to client.start() fires only when no map rooms are available.

Confidence Score: 5/5

Safe to merge; the new entity layer is well-isolated, restore logic is straightforward, and the protobuf payload changes are covered by real-device validation and 167 passing tests.

The change introduces a clean shared-state design (CleanSettings dataclass) that three independent entity types write to and the clean-start path reads from. The FanLevel enum realignment is intentional and correctly handled through both the backward-compat alias map and the _FAN_LABELS reverse map. The whole-house start rerouting through start_rooms is architecturally sound.

No files require special attention.

Important Files Changed

Filename Overview
custom_components/narwal/select.py New file adding three config SelectEntity instances backed by coordinator.clean_settings; uses RestoreEntity for persistence, live setter for mop humidity while cleaning.
custom_components/narwal/number.py New file adding a RestoreNumber for cleaning pass count (1–3); reads/writes coordinator.clean_settings.passes.
custom_components/narwal/vacuum.py async_start rerouted to enumerate all map rooms via start_rooms; fan speed persistence moved to coordinator.clean_settings via RestoreEntity.
narwal_client/client.py _build_start_clean_payload replaces _build_room_clean_payload with full CleanParam support; start_rooms uses clean/start_clean with NOT_READY retry.
narwal_client/const.py FanLevel enum updated to APK proto integers; adds MopStrengthLevel, WorkMode enums; NOT_READY added.

Reviews (3): Last reviewed commit: "fix(clean): whole-house start cleans all..." | Re-trigger Greptile

Comment thread custom_components/narwal/const.py Outdated
Comment thread narwal_client/client.py
Comment thread custom_components/narwal/select.py
jgus and others added 2 commits June 14, 2026 18:09
…CleanParam (sjmotew#25, sjmotew#37)

Room cleans were sent to clean/plan/start, but on Flow firmware that is
StartWithPlan{planId, mapId} — it starts a saved plan by id and ignores any room
payload, so the robot undocked and wandered instead of cleaning the selection.

Switch start_rooms() to clean/start_clean (StartClean → CleanTask). Track the
active map id (MapData.map_id, get_map field 2.1), which the CleanTask requires.
clean/start_clean only works docked; from STANDBY the robot returns a new code 4
(CommandResult.NOT_READY) — retry briefly while docked.

Build the CleanParam from named parameters: start_rooms() takes
work_mode/fan/water/mop_strength/passes with defaults at the call site
(vacuum-and-mop, standard suction/water/mop, single pass). Names and enums match the
app's CleanTask proto: WorkMode (= robot_work_mode_*, whose value is CleanTask.taskType),
corrected FanLevel/MopHumidity, added MopStrengthLevel; fan_speed labels use the app's
user-visible suction names (quiet/standard/strong/super powerful/ultra powerful).
Pre-rename fan_speed values (normal/max) remain accepted for back-compat.

Validated live on a Flow 2: room clean returns SUCCESS and the robot cleans the
selected rooms (confirmed via clean/current_clean_task/get). Both client copies synced.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…water / mop strength / passes)

Adds Home Assistant controls for the room-clean parameters, backed by a CleanSettings
dataclass on the coordinator (the single source the clean-start path reads):

- select entities: work mode (Vacuum / Mop / Vacuum then mop / Vacuum and mop),
  mopping humidity (Slightly dry / Normal / Slightly wet), mop strength (Normal / High);
- number entity: cleaning passes (1-3);
- the vacuum's fan_speed is threaded through the same settings.

Entity labels use the app's user-visible wording. Values persist across restarts via
RestoreEntity (RestoreSelect / RestoreNumber / RestoreEntity) — set once and kept.
async_clean_segments threads them into start_rooms; water and fan also apply live while
cleaning.

Built on sjmotew#49 (parameterized start_rooms / WorkMode).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@jgus
jgus force-pushed the feat/clean-settings branch from cc8b075 to 225274e Compare June 15, 2026 00:13
async_start sent clean/plan/start (StartWithPlan), which replays the saved current plan — the last room selection — so a whole-house Start re-ran the previous room-subset clean instead of cleaning the house. Enumerate every cleanable room and clean via clean/start_clean (start_rooms), matching the app's allRoomIds() path; fall back to the saved-plan start only when no map rooms are known.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@shin906710

Copy link
Copy Markdown

Tested feat/clean-settings on hardware — works. 🎉

All the new controls show up (work mode / water / mop strength selects + passes number,
plus the vacuum's fan_speed) and thread into room cleans correctly. Verified via the
info log, changing settings between two cleans:

rooms=[2] mode=VACUUM fan=NORMAL water=NORMAL mop_strength=NORMAL passes=3 
rooms=[7] mode=VACUUM fan=SUPER  water=NORMAL mop_strength=NORMAL passes=1 

Each cleaned only the selected room, vacuum-only as set, and passes/fan tracked the entity
values (fan "Ultra powerful" → SUPER = top tier).


Native French speaker, app in fr-FR. French labels:

Work mode (Mode de nettoyage):

  • Aspiration (vacuum)
  • Vadrouille (mop) ← app says "Vadrouille", not "Serpillère"
  • Aspiration puis vadrouille (vacuum then mop)
  • Aspiration et vadrouille (vacuum and mop)

Mopping humidity (Humidité de la vadrouille):

  • Légèrement sec (dry)
  • Standard (normal)
  • Légèrement humide (wet)

Passes: the app splits this by mode — "Cycle d'aspiration" (vacuum tab) and
"Cycle de lavage à la vadrouille" (mop tab), both x1 / x2 / x3.


Two things beyond labels:

  1. Suction has only FOUR levels in the app, not five:
    Silencieux / Standard / Puissant / Super puissant.
    The current 5-entry fan list has one tier too many on this model — there's no "Ultra"
    in the app UI. Selecting "Ultra powerful" (→ FanLevel.SUPER) sends a level the app
    doesn't present. Worth checking whether DEEP or SUPER is the real top here and dropping
    the extra?

  2. The app has no visible "mop strength" control, but it does have a
    "Précision de la couverture" toggle (Standard / Méticuleux) — and crucially it appears
    in BOTH the Aspiration (suction) and Vadrouille (mopping) tabs. Since it's not mop-specific, it looks like a
    coverage/path setting (could be ZoneOption f4, the "coverage path" from Room clean ignores HA selection, reverts to Narwal-app shortcut (newer firmware) #37/fix(clean): room cleaning via clean/start_clean with a parameterized CleanParam (#25, #37) #49?) rather
    than a mop parameter.

@sjmotew

sjmotew commented Aug 2, 2026

Copy link
Copy Markdown
Owner

@shin906710 — this is a second hardware confirmation and two protocol findings in one comment. Taking them in order of how much they change things.

The test result

rooms=[2] mode=VACUUM fan=NORMAL … passes=3 then rooms=[7] … fan=SUPER … passes=1, each cleaning only the selected room with the settings actually applied — that's exactly the evidence this PR needed. Combined with your #49 run, the v01.02 line is now the best-covered firmware in the project.

@jgus#50 is queued directly behind #49, unchanged, and the merge order in #66 stands.

French translations — taking them as given

Thank you for pulling these from the app rather than translating them. "Vadrouille" over "Serpillère" is the kind of thing only a native speaker with the app open catches, and getting it wrong is exactly how a translation starts feeling machine-made.

The passes label being split by mode — Cycle d'aspiration / Cycle de lavage à la vadrouille — is a real problem for us, because #50 exposes one number entity for passes while the app has two, one per tab. I'm not going to hold the PR for it; a single "Cleaning passes" / "Cycles de nettoyage" is honest enough as long as the entity applies to whichever mode is selected. But it's worth knowing that we're flattening something the app models as two settings, and if the robot actually accepts two independent pass counts we're leaving a control on the table. @jgus — does the proto carry one pass field or two?

Suction: four tiers or five

You're right that this needs resolving, and the current state is messier than either of us implied. Master today ships four levels, zero-based:

QUIET = 0, NORMAL = 1, STRONG = 2, MAX = 3

#49/#50 replaces that with five, one-based, read out of the APK proto:

MUTE = 1, NORMAL = 2, STRONG = 3, DEEP = 4, SUPER = 5

So it isn't simply "one tier too many" — the whole scale shifted by one, and if the five-value version is right then every fan speed master has ever sent was off by one tier. That's a bug worth landing regardless of how the count resolves.

On the count itself: a proto enum having five values doesn't mean every model exposes five. DEEP may well be a tier that exists in the firmware and that the AX26 UI doesn't surface, or that only the Z Ultra line has. What I don't want to do is guess, drop a value, and quietly cap your robot's top speed.

The decisive test is the one @ken99999 introduced — capture the app's own task and read the integer it sends. In the Narwal app, set suction to Super puissant, start any clean, then capture with clean/get_current_task and post the CleanParam hex. If the top tier reports 4, then SUPER = 5 is unreachable on your model and the dropdown should be trimmed or model-gated. If it reports 5, then DEEP = 4 is a real tier the fr-FR UI collapses and the five-entry list is correct.

"Précision de la couverture" — this may be the last unknown field

This is the finding I'd put first, and I don't think you realised what you were looking at.

There is exactly one field in the app's clean payload that we cannot account for. From @ken99999's capture on a Flow 2 (#25), decoded against #49's builder with matched settings:

#49 : 0a1c080112140a0408011003120a0804100418012003380318011a002804
app : 0a1e080112160a0408011003120c08041004180120033803400218011a002804
                                                     ^^^^  CleanParam tag 8 = 2

Everything else matches. CleanParam tag 8 = 2 is the one thing the app sends and we never do — the last unexplained field in the clean command.

Now look at what you found: a two-value toggle (Standard / Méticuleux), appearing in both the suction and mopping tabs, therefore not a mop parameter — sitting next to a field whose observed value is 2. If Standard = 1 and Méticuleux = 2, that's a complete explanation, and it means the app was in Méticuleux when @ken99999 captured.

This is testable in one step, and it would close the question: capture clean/get_current_task twice on the same robot, once with Précision de la couverture set to Standard and once to Méticuleux, changing nothing else. If tag 8 flips between the two, we can name the field and expose it as a proper HA control instead of shipping a payload with a field we don't understand.

One correction to your guess, though: it's almost certainly not ZoneOption f4. Tag 8 lives inside CleanParam, a different message — and f4 is the field @sytchi believed was required on v01.08 that @ken99999's capture showed the app omitting entirely. Different field, different message, and the f4 question is closed.

@ken99999 — this narrows the ask I made in #25. Rather than varying every setting, the one to try is the coverage-precision toggle.


Separately: your report drove 64c6edd, which puts Narwal Freo Z10 Pro / Turbo in the model dropdown and promotes AX26 to the confirmed block. Both ship in v1.0.2. Three substantive findings from you in two days on a robot I don't own — much appreciated.


How to run the captures

@shin906710 — I referenced clean/get_current_task above without telling you how to reach it. @ken99999 worked out a method that needs no packet capture, documented in #25:

  1. Disable the Narwal config entry in HA — the robot accepts one WebSocket connection per IP, and it will close yours with connection with same ip, close old one otherwise
  2. Set the option you're testing in the Narwal app and start a clean from the app
  3. Call get_current_task() from the standalone client and post the raw hex

Post the bytes rather than your decoding — I'd rather diff them against the two payloads above myself, and the field names in this protocol have already been misread more than once.

@jgus

jgus commented Aug 2, 2026

Copy link
Copy Markdown
Collaborator Author

@sjmotew — pulled the answer straight out of the APK (blutter decompile of the arm64 libapp.so; the generated protobuf BuilderInfo in package:app_protol/proto/core/CleanTask.pb.dart reconstructs the full message). CleanParam:

message CleanParam {
CleanMode mode = 1; // UNSPECIFIED=0, SWEEP=2, MOP=3, SWEEP_MOP_SYNC=4, SWEEP_THEN_MOP=5
FanLevel fanLevel = 2; // UNSPECIFIED=0, MUTE=1, NORMAL=2, STRONG=3, DEEP=4, SUPER=5
MopStrengthLevel mopStrengthLevel = 3;
MopHumidity mopHumidity = 4;
int32 sweepTime = 5;
int32 mopTime = 6;
int32 sweepMopSyncTime = 7;
OverlapLevel overlapLevel = 8; // UNSPECIFIED=0, NORMAL=1, DENSE=2
bool enableSmartMode = 9;
bool enableHeavyDirtyClean = 10;
}
Passes: three fields, one per mode family. The app's two per-tab settings map to sweepTime (5) and mopTime (6); sync mode has its own counter (7). #50's builder already routes its single passes value to the tag(s) for the selected mode — vacuum→5, mop→6, vacuum-and-mop→7, and vacuum-then-mop writes the same value to both 5 and 6. So the flattening only bites in sweep-then-mop, where the app can set the two counts independently. Happy to split it into two entities in a follow-up if you'd rather model it 1:1.

Tag 8 has a name: overlapLevel. Your Standard=1 / Méticuleux=2 guess is exactly right — OVERLAP_LEVEL_NORMAL=1, OVERLAP_LEVEL_DENSE=2 — and @ken99999's app was in Méticuleux when captured. The A/B capture is now confirmation rather than discovery, though still worth running to see whether the robot honors it: in my Flow 2 testing, omitting it made no observable difference (hence #50 not sending it).

Suction: the proto is unambiguous — one-based, five tiers, MUTE=1 … SUPER=5, so master's zero-based four-tier scale was indeed off by one. Also worth noting: the Flow 2 capture itself carries fanLevel=4 (DEEP) on the wire, so tier 4 is sent by the app on at least that model. Whether the AX26 UI can reach it is still the open hardware question your capture test would answer.

Cross-checked the reconstructed schema against both payloads above: mode=4 (SWEEP_MOP_SYNC), fanLevel=4, mopStrengthLevel=1, mopHumidity=3, sweepMopSyncTime=3, plus overlapLevel=2 in the app's. Every byte accounted for — no unknown fields remain in CleanParam.

@shin906710

Copy link
Copy Markdown

French translations — taking them as given

Thank you for pulling these from the app rather than translating them. "Vadrouille" over "Serpillère" is the kind of thing only a native speaker with the app open catches, and getting it wrong is exactly how a translation starts feeling machine-made.

The passes label being split by mode — Cycle d'aspiration / Cycle de lavage à la vadrouille — is a real problem for us, because #50 exposes one number entity for passes while the app has two, one per tab. I'm not going to hold the PR for it; a single "Cleaning passes" / "Cycles de nettoyage" is honest enough as long as the entity applies to whichever mode is selected. But it's worth knowing that we're flattening something the app models as two settings, and if the robot actually accepts two independent pass counts we're leaving a control on the table. @jgus — does the proto carry one pass field or two?

To be honest, I suspect the app uses Canadian French across all French locales. In France, "vadrouille" isn't used for "mop" or "mopping" (we'd say "serpillère" or "lavage"). That said, since the app currently uses "vadrouille", it might be best to stick with it for consistency so users aren't confused by different terminology.

@shin906710

shin906710 commented Aug 3, 2026

Copy link
Copy Markdown

Three captures of the Narwal app's own clean command on a Freo Z10 Pro (firmware v01.02.00.15), read via clean/current_clean_task/get with the HA integration disabled.

CAPTURE: Succion "super puissant" + "standard"
result_code = 1 (success=True)
device_id = f387465fc712458ebd03d8ea2eeb2dc8
prefix = /qV6BujoYLz
raw hex :
0801121e080112160a0408011005120c08021004180120022801400118011a002801

CAPTURE: Succion "puissant" + "standard"
result_code = 1 (success=True)
device_id = f387465fc712458ebd03d8ea2eeb2dc8
prefix = /qV6BujoYLz
raw hex (post this line, unedited):
0801121e080112160a0408011005120c08041003180120013801400118011a002804

CAPTURE: Succion "puissant" + "méticuleux"
result_code = 1 (success=True)
device_id = f387465fc712458ebd03d8ea2eeb2dc8
prefix = /qV6BujoYLz
raw hex (post this line, unedited):
0801121e080112160a0408011005120c08041003180120013801400218011a002804

Let me know if you need anything more.

Regards,

@sjmotew
sjmotew merged commit ed51bad into sjmotew:master Aug 8, 2026
sjmotew added a commit that referenced this pull request Aug 16, 2026
@shin906710's three app captures on a Freo Z10 Pro (#70) settle the
fifth-tier question left open by #49. The app's top suction tier sends
CleanParam tag 2 = 4 (DEEP), the tier below it 3 (STRONG), so the
five-value enum is correct and SUPER (5) is unreachable from that app.

Two changes follow:

- The offered labels drop the "powerful" suffix: Quiet, Standard,
  Strong, Super, Ultra. The v1.0.2/v1.0.3 spellings "Super powerful" and
  "Ultra powerful" stay in FAN_SPEED_MAP as aliases, alongside the older
  lowercase quiet/normal/strong/max, so no automation breaks.
- Ultra is model-gated. On AX26 it is not merely absent from the app —
  clean/set_fan_level carries SweepFanLevel, which has no SUPER, so the
  client already maps 5 -> STRONG. Offering it meant a picker entry that
  silently applied Strong mid-clean. fan_speed_list_for() withholds it
  there and leaves every other model untouched, including entries with
  no stored product_key.

Adds tests/test_fan_speed.py — the fan block shipped in #49/#50 with no
label coverage at all, so the rename passed a green suite on its own.

Also records both answers in docs: CleanParam tag 8 is the
coverage-precision toggle (1 = Standard, 2 = Meticulous), confirmed by
the controlled capture pair where only 40 01 -> 40 02 moved. That closes
the tag-8 unknown carried since Phase 9 (#25).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YAo9szBPifvDJrsag2oW6Y
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants