Skip to content

feat: add Freo Z Ultra local control - #76

Merged
sjmotew merged 3 commits into
sjmotew:masterfrom
KakatkarAkshay:master
Aug 16, 2026
Merged

feat: add Freo Z Ultra local control#76
sjmotew merged 3 commits into
sjmotew:masterfrom
KakatkarAkshay:master

Conversation

@KakatkarAkshay

@KakatkarAkshay KakatkarAkshay commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

Summary

  • add Freo Z Ultra hardware CX7 support using the locally verified J5 product key (hEA7OEshlx)
  • request a cloud-assigned Device ID through the generic non-broadcast setup step while keeping the integration fully local
  • make non-broadcast capability generic: addressed responses establish reachability, wake waits and broadcast subscriptions are skipped, and missing broadcasts no longer force reconnects
  • correct device model and firmware metadata and document Device ID retrieval options, polling limitations, and unresolved variants
  • retain BYWBPqSxeC as an unresolved discovery key rather than treating it as the verified CX7 key

Hardware verification

Verified on one global Freo Z Ultra (hardware CX7, cloud identity J5) running firmware v01.13.11.02. Addressed local WebSocket queries return base status, maps, consumables, and device information. The Home Assistant integration created 28 entities and rendered the static map.

The CX7 does not emit broadcasts, so live cleaning position and progress are unavailable; regular status polling and commands continue to work locally. The polling-only runtime was observed beyond the previous reconnect threshold without wake-burst or forced-reconnect warnings.

Variant scope

Issue #5 also reports firmware 1.12.10.02 and a BYWBPqSxeC identity. Those variants are not yet locally verified with this implementation. Additional owner testing is requested before claiming universal CX7 support.

Testing

  • pytest (241 passed)
  • deployed to Home Assistant 2026.8.1 and validated against live hardware

Related to #5

@greptile-apps greptile-apps Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Your trial has ended. Reactivate Greptile to resume code reviews.

@greptile-apps greptile-apps Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Your trial has ended. Reactivate Greptile to resume code reviews.

@sjmotew

sjmotew commented Aug 16, 2026

Copy link
Copy Markdown
Owner

@KakatkarAkshay — reviewed, and this is good work. I've approved the workflow runs (they were sitting on action_required, which is first-time-contributor gating on this repo, not anything you did wrong).

Two things I checked specifically, because they're the ones that would have blocked it:

1. It keeps the local-only guarantee. I scanned the whole diff for aiohttp / requests / urllib / MQTT / any https:// call — there are none. The Device ID is a value the user pastes into a config-flow step; the integration itself never contacts Narwal. Given I'd just restated "no cloud, no account" over on #5, I wanted that on the record rather than inferred. Your README wording ("Account and MQTT tooling is deliberately kept separate") draws the line in exactly the right place.

2. It doesn't regress #73. Every broadcast path — startup subscribe, post-map subscribe, _resub_topics, and the 240 s renewal in _async_update_data — is gated on supports_broadcasts, which is True for every existing product key and for entries with no stored product_key at all. The v1.0.3 fix is untouched for current users. That gating is the part I was most prepared to find broken, and it's clean.

Independent confirmation arrived too — @northwestsupra tested this on their CX7/J5 and reports everything populating correctly. That clears the second-device bar you set for yourself in the PR description.

One question before merge

On a non-broadcasting model, polling stops being a fallback and becomes the only source of state. _async_update_data passes full_update=not has_recent_active_working_status, which for a CX7 is always True — so working_status should refresh from the polled base_status every 60 s, and the vacuum entity should track a clean at 60 s granularity rather than freezing. I believe that's correct by reading, but it's precisely the failure mode of #73, so I'd rather have it confirmed than reasoned about:

During an actual clean on the CX7, does the vacuum entity show cleaning, and roughly how long after the robot starts? If it sits at docked, that's a blocker; if it lags ~60 s, that's expected and worth a README line.

Related, and your call: for a model where broadcasts never arrive, is a 60 s SCAN_INTERVAL the right cadence, or should non-broadcasting models poll faster? Happy to take that as a follow-up rather than hold this PR.

Two minor notes

  • master moved about an hour ago — the fan-speed labels shortened to Quiet/Standard/Strong/Super/Ultra and gained a per-model gate, which touches const.py in a different region than your NO_BROADCAST_PRODUCT_KEYS block. I don't expect a conflict, but a rebase before merge will confirm it.
  • Your branch is your fork's master. It works fine for this PR, but it will make your next one awkward. A topic branch per PR is worth adopting.

Answer the cleaning-state question and, assuming CI is green, I'll merge this and ship it. Thank you for the scope discipline — retaining BYWBPqSxeC as unresolved rather than claiming universal CX7 support is exactly right, and it's the kind of restraint that keeps the compatibility table trustworthy.

@greptile-apps greptile-apps Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Your trial has ended. Reactivate Greptile to resume code reviews.

@KakatkarAkshay

Copy link
Copy Markdown
Contributor Author

Answered below — short version: it shows cleaning, 31 s after start, and it doesn't freeze.

I couldn't run a fresh clean at a reasonable hour, so this is from Home Assistant's recorder history of a scheduled run earlier today rather than a live observation. It's the same evidence either way — service calls from events, state changes from states. All times IST, 2026-08-16:

Time Event
15:00:00 Automation fires, vacuum.start called
15:00:31 vacuum.…_cx7_vacuum: dockedcleaning; binary_sensor.…_dockedoff
15:03:31 → 15:19:31 sensor.…_battery 98 → 70, one row every 60 s, every timestamp on :31
15:48:31 vacuumdocked, docked sensor → on

So ~31 s to cleaning, and return-to-dock lands on a poll boundary too. Your reading was right: last_active_working_status_time is only ever assigned in the broadcast path in update_from_working_status, which never runs on a CX7, so full_update stays True on every poll and working_status refreshes from base_status each time. The battery series landing on :31 for sixteen consecutive polls is the independent confirmation that the coordinator is on POLL_INTERVAL and nothing is stalling.

One thing worth recording alongside that, because it's the limitation and not just the happy path: sensor.…_cleaning_time, …_cleaning_area and …_current_room produced zero state changes across the entire 48-minute clean and sat at unknown throughout. Those three read working_status fields 2, 3 and 6, so on a non-broadcasting model they have no source. That's the "no live progress" caveat with a number on it, and the README line now covers both it and the 60 s state granularity.

On scan interval

I'd keep 60 s and not hold this PR for it. I use this integration almost entirely for automations — the 3 PM run above is one — rather than for watching cleaning progress in the UI, and 60 s is comfortably inside what automation needs.

Worth adding that faster polling wouldn't buy what it looks like it buys. The fields that would actually benefit from a tighter cadence are the three that are unknown regardless, because they only arrive by broadcast. Polling faster refreshes working_status sooner and nothing else. If cadence is worth revisiting later, adaptive polling — normal while docked, tighter while working_status is active, reusing the existing FAST_POLL_INTERVAL machinery — seems more defensible than a fixed lower number or a user-facing setting, since HA already lets anyone disable polling on the config entry and drive homeassistant.update_entity themselves.

The more interesting follow-up is whether those progress metrics are reachable at all on a non-broadcasting robot. Three candidates I'd like to probe, none of which belong in this PR: a full base_status field dump during a clean (your bcebf70 debug logging is exactly the tool, and the documented field-3 sub-map is state-only — the CX7 may carry more); clean/current_clean_task/get, which is implemented but whose response is never parsed; and /info/get_clean_time_line, rows 74/75 in the topic table, still unprobed. Happy to open a separate issue for that once this lands.

Rebase

Rebased onto master and pushed — no conflicts. The fan-tier change and NO_BROADCAST_PRODUCT_KEYS don't touch the same region of const.py, as you expected. Test count is 250 now that your new tests are in, all passing locally.

And noted on topic branches — next one won't come off my fork's master.

@sjmotew
sjmotew merged commit 136047d into sjmotew:master Aug 16, 2026
3 checks passed
sjmotew added a commit that referenced this pull request Aug 16, 2026
#76 restructured async_step_user (device-ID step, _async_create_entry),
which collided with the discovery block and with the test file where both
sides appended.

Resolution: kept the whole discovery block plus master's one-line
async_step_user signature, and kept both sets of tests — master's
non-broadcast device-ID cases inside TestNarwalConfigFlow, TestDiscovery
after them. 262 passing = master's 250 plus this branch's 12, which is the
check that neither side was silently dropped.

The two features compose: discovery pre-fills the host, and a CX7 picked on
that form still routes to the device-ID step.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YAo9szBPifvDJrsag2oW6Y
sjmotew added a commit that referenced this pull request Aug 17, 2026
Bumps the manifest, adds release notes, and updates the README's release
banner and status table.

Headline is the consumable alert fix (#80): both alert sensors had
reported "no problem" on every robot since the feature shipped, because
packed varints parsed to nothing and an empty list means healthy. Also
ships Freo Z Ultra support (#76) and the fan-tier rename with the AX26
gate.

Verified before tagging: 255 tests, CI green, deployed to a live Home
Assistant on real hardware with all 28 entities correct and both alert
sensors reporting their item lists.

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.

2 participants