Conversation
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>
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>
…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.
This was referenced Apr 30, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
First v1.4.0 release. Five tracked features and three bug-fix issues filed during dev.
Features
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 realConnectionHistorySection+ConnectionDiscoverySection(saved + nearby + manual add); Test page auto-advances onconnectionState == .connected. Skip available everywhere except Connect (forced) and Done (Get Started is the only sensible action).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 liveDeviceListRowfor 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.Closes #40) — first-class Identify action via<friendlyName>/setwith{"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.Closes #38) — per-server "Allow Self-Signed Certificates" toggle, only visible when Protocol = HTTPS. Trust override viaURLSessionDelegateis scoped per connection and explicit / off-by-default with an inline insecure-network warning.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_interviewevent now also mirrors the status into the localDeviceentry;Device.interviewing/interviewCompletedflip fromlettovarto allow the in-place update.Closes #42— Recently Added section disappeared on every app restart (AppStore.reset() was wiping persisteddeviceFirstSeen). 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 frombridgeInfo(permitJoinTargetis new) with optimistic writes from both surfaces.Notable polish (not separately ticketed)
MARKETING_VERSIONbumped to1.4.0in all four configs.ConnectionEditorViewConnect button moved from toolbar to bottom action bar to match the Rename Device sheet's style and disabled-when-invalid behavior.ConnectionDiscoverySectionkeeps the Scanning indicator visible alongside results so the user knows the sweep continues after the first match.BridgeInfodecoder no longer recomputespermitJoinEndon every refresh while permit_join is active — fixes a flicker / disappearing countdown that was visible in the wizard.Test plan
device/join), confirm the row shows "Ready" within ~1s ofdevice_interview successful.🤖 Generated with Claude Code