Skip to content

Release v1.4.0: onboarding + pairing wizards, identify, self-signed TLS#44

Merged
tashda merged 27 commits into
mainfrom
dev
Apr 30, 2026
Merged

Release v1.4.0: onboarding + pairing wizards, identify, self-signed TLS#44
tashda merged 27 commits into
mainfrom
dev

Conversation

@tashda
Copy link
Copy Markdown
Owner

@tashda tashda commented Apr 30, 2026

Summary

First v1.4.0 release. Five tracked features and three bug-fix issues filed during dev.

Features

  • Onboarding wizard (Closes #18) — first-launch full-screen flow: Welcome → Connect → Test → Done. Welcome page uses the splash icon over the home gradient; Connect page embeds the real ConnectionHistorySection + ConnectionDiscoverySection (saved + nearby + manual add); Test page auto-advances on connectionState == .connected. Skip available everywhere except Connect (forced) and Done (Get Started is the only sensible action).
  • Pairing wizard (Closes #20) — + on the Devices toolbar opens a single-page sheet that morphs by state. Network closed: duration + via-router pickers + Start Permit Join. Network open: status row with countdown and via-target ("Network is open via Kitchen Relay") plus the live DeviceListRow for each device that joined this session — same hero image, swipe actions, and context menu users already know. Cancel prompts to leave the network open or close it; Done appears once a device has joined and dismisses without prompt.
  • Identify cluster (Closes #40) — first-class Identify action via <friendlyName>/set with {"identify": "identify"}. Available on the device list swipe menu, context menu, and detail screen for devices that expose the Identify cluster. The pairing wizard reuses the same code path.
  • Self-signed certificate support (Closes #38) — per-server "Allow Self-Signed Certificates" toggle, only visible when Protocol = HTTPS. Trust override via URLSessionDelegate is scoped per connection and explicit / off-by-default with an inline insecure-network warning.
  • HA add-on port discovery (Closes #39) — LAN sweep now probes both 8080 (standalone Docker default) and 8099 (HA community add-on default). Discovered hosts surface their port and pre-fill it in the editor. Also: results stream as the probe finds them instead of after the /24 sweep finishes.

Bug fixes

  • Closes #41 — Interview state stuck on "Interviewing" until view re-rendered. device_interview event now also mirrors the status into the local Device entry; Device.interviewing / interviewCompleted flip from let to var to allow the in-place update.
  • Closes #42 — Recently Added section disappeared on every app restart (AppStore.reset() was wiping persisted deviceFirstSeen). Also makes the window length configurable in Settings → General → Devices.
  • Closes #43 — Permit-join active state was local to HomeView, so toolbar sheet showed empty countdown when permit_join was started from the wizard. Now derived from bridgeInfo (permitJoinTarget is new) with optimistic writes from both surfaces.

Notable polish (not separately ticketed)

  • MARKETING_VERSION bumped to 1.4.0 in all four configs.
  • ConnectionEditorView Connect button moved from toolbar to bottom action bar to match the Rename Device sheet's style and disabled-when-invalid behavior.
  • "Show Welcome Wizard" row removed from Settings (redundant clutter).
  • ConnectionDiscoverySection keeps the Scanning indicator visible alongside results so the user knows the sweep continues after the first match.
  • BridgeInfo decoder no longer recomputes permitJoinEnd on every refresh while permit_join is active — fixes a flicker / disappearing countdown that was visible in the wizard.

Test plan

  • First-launch flow on a clean install: onboarding presents, connect succeeds, test step auto-advances, Done dismisses.
  • Pairing wizard: open the network, pair a device via the test center (device/join), confirm the row shows "Ready" within ~1s of device_interview successful.
  • Pairing wizard cancel-with-network-open alert forces a Keep Open / Close Network choice and respects each.
  • Permit_join started from the wizard: navigate to Home, tap toolbar button — countdown and via-target render correctly.
  • Identify on a device that exposes the cluster physically blinks/beeps; rows for non-supporting devices don't show the action.
  • HTTPS connection to a self-signed bridge with the toggle on succeeds; with it off fails as before.
  • LAN discovery surfaces an HA add-on running on 8099 and pre-fills the port.
  • Pair a device, force-quit and relaunch — Recently Added section still shows it.
  • Settings → General → Recently Added Window picker takes effect immediately on the device list.

🤖 Generated with Claude Code

tashda and others added 17 commits April 30, 2026 07:38
Users running Z2M behind a reverse proxy with a self-signed cert (or an
internal CA) couldn't connect over HTTPS — iOS rejected the trust challenge
with no override. The connection editor now exposes a toggle (visible only
when Protocol = HTTPS, off by default) that flags the server as accepting
invalid certificates. The WebSocket session delegate handles the trust
challenge with .useCredential only when the active config has the flag set,
so the bypass is scoped per-connection. Footer warns the user the connection
is only encrypted, not authenticated.

Fixes #38

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
LAN discovery only probed 8080, the standalone Docker default. Users
running Zigbee2MQTT as the Home Assistant community add-on
(zigbee2mqtt/hassio-zigbee2mqtt) serve their frontend on 8099 and were
silently missed. Probe both ports per host, surface the matching port in
the discovery list, and prefill it in the editor so the user lands on the
right port instead of the 8080 default.

Bumps MARKETING_VERSION to 1.4.0.

Fixes #39

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Touchlink Identify existed but was specific to that protocol; the standard
Zigbee Identify cluster — the one Z2M surfaces on most lights and many
sensors as a writable enum property — was unreachable from the UI, and
the generic expose card actively filtered the property out as diagnostic
noise. Users had no way to ask "blink so I can tell which physical device
this is" from a list of identical bulbs.

Adds a first-class Identify action wired through AppEnvironment that sends
`<friendlyName>/set` with `{"identify": "identify"}` (the form Z2M
accepts — verified against the seeder's models.json fixture). The action
shows up in the device list swipe menu, the device list context menu, and
the device detail toolbar, but only on devices whose definition exposes a
writable `identify` property. While in flight the row reports "Identifying"
for ~3s and the button disables — the call is fire-and-forget so there's
no response to await.

Prerequisite for the pairing wizard (#20).

Fixes #40

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a + button to the Devices toolbar that presents a three-step sheet:
open the network (permit join with duration + via-router picker), watch
new devices arrive with live interview status, and offer per-device setup
actions (Identify, Rename, Add to Group). The wizard scopes "this session"
by stamping a session start timestamp and filtering AppStore.devices
against the existing deviceFirstSeen map with a small grace window — no
new persistent state. Reuses the Identify cluster action shipped in #40
and the existing RenameDeviceSheet, and routes Add to Group through
bridge/request/group/members/add.

Fixes #20

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Drops new users into a five-step wizard before the connection form:
welcome, a short concepts primer (coordinator / routers / end devices /
mesh), the connect form inline, a live test step that watches
environment.connectionState and auto-advances on success, and a done
summary with the device count. Shown via fullScreenCover from RootView
when no connection has been saved and the onboardingCompleted
@AppStorage flag is unset; replayable from Settings → Show Welcome
Wizard. Mid-wizard close resumes on the same step thanks to a stored
page index. Skip is available everywhere except the connect step, which
needs a real attempt before continuing.

Fixes #18

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Welcome page now shows the app icon (matches the splash screen) with a
one-line tagline and a bottom-action-bar Continue button styled like the
Rename Device sheet's Save button. Drops the Concepts primer entirely —
Shellbee connects to existing Z2M servers, it doesn't help users set one
up, so the coordinator/router/end-device explanation was framing the
wrong job.

Connect page now embeds the real ConnectionHistorySection and
ConnectionDiscoverySection used by the main Connect screen — saved
servers, the Nearby Servers scan, and a + to add a new one — instead of
an inline editor form. Wizard advances to the test page automatically
when the user kicks off any connection attempt. ConnectionEditorView
itself moves its Connect button from the toolbar into a bottom action
bar so the manual-add flow matches the Rename sheet's button style and
disabled-when-invalid behavior.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Welcome page: lighter tagline ("Let's get you connected."), home gradient
  background for the brand-identity moment, Skip in the toolbar.
- Connect page: inline help line at the top explaining what to do.
- Done page: Skip removed (only sensible action is Get Started).
- Discovery section: streams matches as the probe finds them instead of
  withholding them until the /24 sweep finishes; the Scanning indicator
  remains visible alongside results so the user knows the search is
  still running. Affects both the onboarding wizard and the main Add
  Server screen since they share ConnectionDiscoverySection.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Wizard collapses three pages into one Apple-native sheet that morphs by
state. Network closed: duration + via-router pickers above a primary
"Start Permit Join" button. Network open: a single status row with
animated icon and live countdown plus a destructive "Disable Join"
button, followed by Section-per-device cards with iOS Settings-style
icon-tinted action rows (Identify, Rename, Add to Group, Check for
Update, Remove). Toolbar drops the misleading "Done"/"Next" combo for a
single Cancel that prompts whether to leave the network open or close
it on the way out. Per-device actions disable while the device is
interviewing — pre-interview is exactly the wrong moment to be renaming
or removing — and re-enable as soon as Z2M reports "successful".

Interview state staleness (#41): the `device_interview successful` event
only finished the Interview Live Activity; the actual device row kept
the "Interviewing" badge until the next `bridge/devices` snapshot
arrived (often a delay; sometimes never on a congested wire). Now the
event handler also mirrors the status into the local `Device` entry by
flipping `interviewing`/`interviewCompleted` in place — Device drops
`let` for `var` on those two fields to allow it. Snapshot is still the
source of truth; the local mirror just removes the stall window.

Fixes #41

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`Set.remove` returns the removed element, and `MainActor.run` propagates
its closure's return value, so the result was implicitly discarded —
which Swift 6 warns on. Explicitly bind to `_` inside the closure.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Network-open section is now a single status row (icon + "Network is
  open" + countdown). The Disable Join button is gone — closing the
  network already happens via the Cancel toolbar's "Close Network"
  option, so the inline button was a redundant second path.
- Per-device cards replaced with the live `DeviceListRow` from the
  Devices tab. The wizard now shows the same hero image, status, OTA
  progress, swipe actions, and context menu the user already knows from
  the device list — instead of bespoke action rows.
- Removed the Add-to-Group action from the wizard. Users typically build
  groups deliberately later; bundling it into pairing was speculative
  and the picker added complexity for no daily payoff.
- Toolbar gains a Done button on the right once at least one device has
  joined this session. Done dismisses without prompting (the network
  can stay open in the background to catch latecomers); Cancel still
  prompts about closing the network when permit join is active.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…etwork-open prompt

confirmationDialog renders as an action sheet that floats from the
bottom (or as a popover on iPad) and includes an implicit Cancel that
silently dismisses without resolving the network state. The wizard
should force a binary decision — leave the network open or close it —
so switch to .alert. With only two non-cancel buttons the user must
explicitly pick one before the wizard closes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Network-open footer now explains what to do next: "Put your device
  into pairing mode now…" with an inline link into the device library
  for users who don't know how to put their specific device into
  pairing mode. The hint hides once at least one device has joined —
  by then the next-step guidance is the device list itself.
- DeviceListRow gains a `navigates: Bool = true` parameter. The pairing
  wizard sets it false so rows render inline without a NavigationLink
  wrapper — no chevron, no tap highlight on a row that doesn't push
  anywhere (the wizard sheet has no device-detail destination).
- New Devices section gets a footer telling the user about swipe and
  long-press actions, since most discovery of those gestures happens
  by accident.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Z2M's permit_join can be scoped to a specific router so only that node
relays the join, but `bridge/info` doesn't include the target device —
only the `bridge/event permit_join` payload does. The wizard had no way
to surface this, so a user who opened pairing via "Kitchen Relay" saw
the same generic "Network is open" as a global open, with no signal
that the scope they picked was actually in effect.

BridgeInfo gains an optional `permitJoinTarget` field. The bridgeEvent
handler in AppStore captures it from `permit_join` events and merges it
into the current BridgeInfo via a new `copyUpdatingPermitJoin` helper.
The wizard's `sendPermitJoin` does the same optimistically when the
user taps Start — so the row updates immediately without waiting for
the bridge round-trip — and the network-open row now reads "Network is
open via <name>" when scoped, falling back to plain "Network is open"
for the global case.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Z2M sends the via-device only on `bridge/event permit_join`, never on
`bridge/info`. Every periodic info snapshot was therefore decoding with
permitJoinTarget = nil and wiping out the value the event handler had
just captured — the wizard flashed "Network is open via X" for a beat
before falling back to plain "Network is open". When a fresh info
snapshot arrives and permit_join is still on, keep the previously
captured target rather than overwriting it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The "Recently Added" section in the device list is meant to outlive a
connection bounce or app restart — it's a 30-minute UX window keyed by
IEEE, persisted to UserDefaults precisely so it survives. But
ConnectionSessionController.connect() calls store.reset() before every
connection attempt (including the auto-reconnect on app launch), and
reset() was clearing both the in-memory map and the UserDefaults entry
— so every launch immediately killed the section.

reset() now leaves deviceFirstSeen alone. The 30-minute window in
DeviceListViewModel already prunes stale entries from the visible list,
and switching to a different bridge naturally hides them too because
the device IEEEs won't match the new server's snapshot.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…oss info refreshes

- Settings → General now has a "Recently Added Window" picker under a
  new Devices section. Options: Off, 5/15/30/60/120/240 minutes, 1 day.
  Default stays 30 minutes. Stored under DeviceList.recentWindowMinutes;
  DeviceListViewModel reads through AppConfig.UX.configuredRecentDeviceWindow
  on each render so changes take effect immediately. "Off" hides the
  Recently Added section entirely (devices currently interviewing still
  get surfaced — that's a different signal than recency).
- bridge/info refresh while permit_join is active no longer recomputes
  permitJoinEnd. Z2M sends the timeout once on activation and either
  omits it on later snapshots (zeroing our end → countdown disappears)
  or keeps it static (jumping the end forward each refresh → countdown
  resets). Hold the original end and target until the bridge tells us
  permit_join has actually changed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…me Wizard from Settings

- HomeView's permit-join toolbar sheet was reading start time / duration
  / target from local @State that only got populated when the user
  started permit_join from the Home toolbar. Starting from the Add
  Devices wizard left those vars empty, so tapping the now-active
  toolbar button showed an empty active sheet. Derive all three from
  bridgeInfo (permitJoinEnd, permitJoinTimeout, permitJoinTarget) so the
  countdown is correct regardless of where permit_join was kicked off
  — including external Z2M clients. HomeView's sendPermitJoin also does
  an optimistic bridgeInfo write so the toolbar updates instantly.
- Recently Added Window picker drops the "Off" option. The Sort menu's
  "Show Recents" toggle on the Devices tab is the single source of
  truth for visibility; the picker only governs window length. Footer
  points users to that toggle.
- Settings → Application → Show Welcome Wizard removed. The wizard
  remains accessible on first launch and via re-running with
  onboardingCompleted reset; this row was redundant clutter.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@tashda tashda added this to the v1.4.0 milestone Apr 30, 2026
@tashda tashda added bug Something isn't working enhancement New feature or request area:ui UI / UX redesign area:onboarding Onboarding / pairing / first-launch priority:medium labels Apr 30, 2026
@tashda tashda self-assigned this Apr 30, 2026
tashda and others added 6 commits April 30, 2026 11:01
The card's "Permit Join open — Ns remaining" was reading from
HomeSnapshot.permitJoinRemaining, which only recomputes when the
snapshot is rebuilt — i.e. on observable mutations of bridgeInfo etc.
There is no per-second tick driving it, so the number sat there
appearing frozen and only ticked down sporadically when something
unrelated invalidated the snapshot. The toolbar's active sheet has the
real countdown via TimelineView reading from bridgeInfo; the home card
is a status row at glance-cadence and looks better with a static
"Permit Join open" badge.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
A successful interview means the device responded to Z2M — by definition
it's online at that moment. We were leaving deviceAvailability untouched
and waiting on the next <name>/availability publish to update the row,
which (a) can lag behind the interview-success event by seconds and (b)
in the same-session remove + re-pair case sometimes never arrived
before the user got frustrated, leaving the device greyed out with a
red dot until the app was restarted and MQTT replayed retained values.
Setting availability = true on `device_interview status: "successful"`
matches Z2M's frontend behaviour and removes the stall window. A real
availability publish landing later overrides the optimistic value (true
or false) as before.
…art row

OTASettingsView gains a Bulk Check section at the bottom with the
Concurrency and Device Timeout fields that used to live behind the
separate Bulk OTA row in the Application section. The fields are
AppStorage-backed so the @AppStorage keys are unchanged — just a
relocation. AppPerformanceView and its membershipException entry are
deleted.

SettingsView.dangerSection drops Restart Zigbee2MQTT. ServerDetailView
already exposes Restart from its toolbar menu, and the restart-required
banner at the top of Settings still fires the same alert; the bottom
button was a third path to the same operation.

Fixes #46

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Group Card hero showed whichever two members happened to be first
in the membership list — not always the most recognisable pair. Tapping
the hero avatar now opens a member-picker sheet: tap up to two members
to slot them into the avatar, tap a third to replace the earliest
pick, Reset returns to the default first-two. Selection persists per
group in UserDefaults under `group.avatar.<groupID>` and is filtered
through current membership on resolve so removing a picked device
silently falls back to defaults rather than showing a missing icon.
The compact GroupCard row and the device-list group row keep their
original non-interactive icons; only the prominent hero is tappable.

Fixes #47

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two refinements after the first hands-on:

- The hero card avatar didn't re-render when the picker dismissed —
  it was reading from UserDefaults via GroupAvatarStore on every
  body call, which doesn't trigger SwiftUI invalidation. The card
  now mirrors the selection in @State (loaded onAppear, written
  through the picker's Binding) so saves take effect immediately.
- The picker opened with nothing checked when the user hadn't
  configured a selection yet. It now prefills with the current
  default first-two members so the user can tweak rather than start
  from blank.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The picker saved selections correctly and the prominent hero refreshed
immediately, but the GroupRowView in the device-list-style group list
and the GroupCard's compact-mode + GroupCardHeader avatars all kept
using the raw `memberDevices` first-two — so the user's customised
avatar didn't show up there. Route every GroupIconView render through
GroupAvatarStore.resolvedDevices so the saved choice surfaces wherever
the group is shown. The compact GroupCard reads the same @State as the
prominent one (which loads onAppear), and the list rows + header
re-resolve from UserDefaults on each render — both paths pick up
changes when the user navigates back from a group whose avatar was
just edited.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
tashda and others added 4 commits April 30, 2026 11:29
…vely update

Static methods reading UserDefaults don't trigger SwiftUI invalidation,
so navigating back from the Group Card to the group list still showed
the old avatar even though the picker had saved a new one. Refactor
GroupAvatarStore from a stateless enum into an @observable class with a
shared instance and an in-memory selections cache mirrored to
UserDefaults. Every render path (GroupRowView, GroupCardHeader,
GroupCard.compactHeader) reads through the shared instance, so a save
mutates an observed property and SwiftUI re-evaluates the bodies of
every view that touched it — list rows update on next render without
needing the user to push into and back out of detail.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
ContentUnavailableView already supports an `actions:` builder, which is
the iOS-native pattern for surfacing the recovery action right in the
empty state (Apple uses it in Files, Photos, Notes). The previous
placeholder asked the user to add devices but routed them to the
toolbar — extra friction for the most-likely next step. Plumb an
optional onAdd callback through GroupMembersSection and trigger the
existing AddGroupMembersSheet from the empty state. Toolbar entry
remains for groups that already have members.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
ContentUnavailableView's actions builder rendered the .borderedProminent
button as a freakish vertical pill inside the Section row whose insets
we'd zeroed for the placeholder. Drop the actions builder; render a
plain Form-row Button beneath the placeholder instead. That matches how
the rest of the app surfaces row actions (Settings, Device list,
Server detail) and inherits standard system row chrome — tap target,
press feedback, accessibility — without bespoke button styling.

Fixes #48

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Hand-rolled the empty state instead of fighting ContentUnavailableView
+ List row layout. The placeholder is a centered VStack (icon, headline,
description, button) inside a single Section row whose insets are
zeroed, so the content sits in the natural empty space without the
List's row chrome competing for layout. The button is a proper
.borderedProminent capsule (system-tint pill, .controlSize(.large),
buttonBorderShape(.capsule), bold label with a + glyph) — the same
look the App Store / Photos / Settings use for primary recovery
actions on empty states.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:onboarding Onboarding / pairing / first-launch area:ui UI / UX redesign bug Something isn't working enhancement New feature or request priority:medium

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant