Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
397ce8a
Bump to 1.3.0 and remove inline descriptions from device option rows
tashda Apr 28, 2026
14034e8
Add OTA Check / Schedule / Unschedule actions to device long-press menu
tashda Apr 28, 2026
7c6714a
Add Developer Mode and MQTT Inspector
tashda Apr 28, 2026
bf6cea7
Add Backup management — trigger and download Z2M backups
tashda Apr 28, 2026
98b8648
Implement Schedule OTA flow + revise long-press menu visibility
tashda Apr 28, 2026
e397dc8
Add local Restore guide sheet, durable backup file location, richer s…
tashda Apr 28, 2026
7c441ff
Redesign MQTT Inspector — cleaner chrome, persistent state, prominent…
tashda Apr 28, 2026
e4da537
Polish MQTT Inspector — JSON colors, stable picker, unified Publish
tashda Apr 28, 2026
e621c30
Reword negated settings toggles and inline parenthesised units
tashda Apr 28, 2026
81a7926
Tighten settings labels for iOS-style consistency
tashda Apr 28, 2026
478cb6b
Restructure App settings — split Live Activities, slim General, renam…
tashda Apr 28, 2026
0cabd6a
Live Activities: single section so Scheduled OTAs reads as a sub-mode…
tashda Apr 28, 2026
f2d4dff
About: lift app info + nav links to top, add Sentry to Acknowledgements
tashda Apr 28, 2026
48cdb8d
About: add Rate / GitHub links; fix three stale footers
tashda Apr 28, 2026
8dd4bf5
About: wire Rate row to live App Store ID 6763139074
tashda Apr 28, 2026
31372f7
Fix Fast CI build: declare missing otaScheduledLiveActivityEnabledKey
tashda Apr 28, 2026
6677b7f
Networking: detect early auth rejection, raise frame cap to 64 MB
tashda Apr 28, 2026
81433a0
Backup: extract BackupPayload, verify zip integrity, polish share UI
tashda Apr 28, 2026
9eedad7
OTA: surface scheduled phase across filters, swipes, badges, Home
tashda Apr 28, 2026
5b8fdf6
Connection: reset session state on user-initiated connect
tashda Apr 28, 2026
2b483c2
Fan card: replace sheet drill-ins with NavigationLink, settings rows
tashda Apr 28, 2026
5458c91
About: explicit "Connect" section header, plain button style on rows
tashda Apr 28, 2026
3e0ada2
About: extend tap target to full row width
tashda Apr 28, 2026
368f934
Device cards: native iOS Settings sections beneath every category
tashda Apr 28, 2026
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
23 changes: 19 additions & 4 deletions Shellbee.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -200,9 +200,16 @@
Features/Settings/AboutView.swift,
Features/Settings/AcknowledgementsView.swift,
Features/Settings/AppGeneralView.swift,
Features/Settings/AppLiveActivitiesView.swift,
Features/Settings/AppNotificationSettingsView.swift,
Features/Settings/AppPerformanceView.swift,
Features/Settings/AvailabilitySettingsView.swift,
Features/Settings/Backup/BackupPayload.swift,
Features/Settings/Backup/BackupView.swift,
Features/Settings/Backup/RestoreGuideSheet.swift,
Features/Settings/Developer/DeveloperSettings.swift,
Features/Settings/Developer/DeveloperSettingsView.swift,
Features/Settings/Developer/MQTTInspectorView.swift,
Features/Settings/DeviceStatisticsView.swift,
Features/Settings/DocBrowserDetailView.swift,
Features/Settings/DocBrowserView.swift,
Expand All @@ -225,9 +232,12 @@
Shared/CardDisplayMode.swift,
Shared/ClimateControl/ClimateControlCard.swift,
Shared/ClimateControl/ClimateControlContext.swift,
Shared/ClimateControl/ClimateFeatureSections.swift,
Shared/Components/BeautifulPayloadView.swift,
Shared/Components/BeautifulRow.swift,
Shared/Components/CopyableRow.swift,
Shared/Components/DeviceExtras.swift,
Shared/Components/DeviceFeatureSectionRow.swift,
Shared/Components/DevicePickerRow.swift,
Shared/Components/Doc/DefaultDocSectionView.swift,
Shared/Components/Doc/DeviceInfoCardView.swift,
Expand All @@ -242,11 +252,14 @@
Shared/Components/Doc/OptionsSectionView.swift,
Shared/Components/Doc/PairingGuideExperienceView.swift,
Shared/Components/Doc/PairingSectionView.swift,
Shared/Components/FeatureGroupDetailView.swift,
Shared/Components/FilterChip.swift,
Shared/Components/MemberAvatarStack.swift,
Shared/Components/SafariBrowserView.swift,
Shared/Components/SettingsFormRow.swift,
Shared/CoverControl/CoverControlCard.swift,
Shared/CoverControl/CoverControlContext.swift,
Shared/CoverControl/CoverFeatureSections.swift,
"Shared/DesignTokens+Groups.swift",
Shared/ExposeCardView.swift,
Shared/FanControl/FanControlCard.swift,
Expand All @@ -261,13 +274,15 @@
Shared/LightControl/LightControlContext.swift,
Shared/LightControl/LightDisplayColor.swift,
Shared/LightControl/LightEffectsSheet.swift,
Shared/LightControl/LightFeatureSections.swift,
Shared/LightControl/LightTemperatureControl.swift,
Shared/LockControl/LockControlCard.swift,
Shared/LockControl/LockControlContext.swift,
Shared/RemoteCard/RemoteCard.swift,
Shared/SensorCard/SensorCard.swift,
Shared/SwitchControl/SwitchControlCard.swift,
Shared/SwitchControl/SwitchControlContext.swift,
Shared/SwitchControl/SwitchFeatureSections.swift,
ShellbeeApp.swift,
);
target = 0A9D45CD2F96232B00DF6DF5 /* ShellbeeWidgetsExtension */;
Expand Down Expand Up @@ -794,7 +809,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.2.0;
MARKETING_VERSION = 1.3.0;
PRODUCT_BUNDLE_IDENTIFIER = "$(APP_BUNDLE_ID)";
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = YES;
Expand Down Expand Up @@ -835,7 +850,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.2.0;
MARKETING_VERSION = 1.3.0;
PRODUCT_BUNDLE_IDENTIFIER = "$(APP_BUNDLE_ID)";
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
Expand Down Expand Up @@ -875,7 +890,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 1.2.0;
MARKETING_VERSION = 1.3.0;
PRODUCT_BUNDLE_IDENTIFIER = "$(APP_WIDGET_BUNDLE_ID)";
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
Expand Down Expand Up @@ -917,7 +932,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 1.2.0;
MARKETING_VERSION = 1.3.0;
PRODUCT_BUNDLE_IDENTIFIER = "$(APP_WIDGET_BUNDLE_ID)";
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
Expand Down
16 changes: 16 additions & 0 deletions Shellbee/App/ConnectionSessionController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@ final class ConnectionSessionController {
var errorMessage: String?
private(set) var hasBeenConnected = false

/// Receives every inbound (topic, payload) before routing. Used by the
/// MQTT inspector in Developer Mode. Set on view appear, clear on disappear.
var rawInboundTap: ((String, JSONValue) -> Void)?

private let store: AppStore
private let history: ConnectionHistory
private let client = Z2MWebSocketClient()
Expand All @@ -33,6 +37,7 @@ final class ConnectionSessionController {
static let maxReconnectAttemptsKey = "connectionMaxReconnectAttempts"
static let connectionLiveActivityEnabledKey = "connectionLiveActivityEnabled"
static let otaLiveActivityEnabledKey = "otaLiveActivityEnabled"
static let otaScheduledLiveActivityEnabledKey = "otaScheduledLiveActivityEnabled"
static let defaultMaxReconnectAttempts: Int = 3
static let maxReconnectAttemptsRange: ClosedRange<Int> = 1...20
private static let baseReconnectDelay: Double = 1
Expand Down Expand Up @@ -102,6 +107,14 @@ final class ConnectionSessionController {
}

func connect(config: ConnectionConfig) {
// A user-initiated connect is a fresh attempt — drop any prior session
// state so a failure routes the UI back to the setup screen instead of
// leaving the user on a stale homepage. Without this, switching from a
// working server to one with bad/missing auth would leave hasBeenConnected
// == true, sending the failure into the `.lost` branch.
hasBeenConnected = false
store.reset()
store.isConnected = false
connectionConfig = config
errorMessage = nil
startSession(config: config)
Expand Down Expand Up @@ -212,6 +225,9 @@ final class ConnectionSessionController {

switch socketEvent {
case .message(let data):
if let tap = rawInboundTap, let raw = Z2MMessageRouter.decodeRaw(data) {
tap(raw.topic, raw.payload)
}
if let event = router.route(data) {
store.apply(event)
}
Expand Down
9 changes: 7 additions & 2 deletions Shellbee/Core/Models/NetworkAnalysis.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,14 @@ enum DeviceCondition: String, CaseIterable, Sendable {
}
}

func matches(device: Device, state: [String: JSONValue], isAvailable: Bool) -> Bool {
func matches(device: Device, state: [String: JSONValue], isAvailable: Bool, otaStatus: OTAUpdateStatus? = nil) -> Bool {
switch self {
case .updatesAvailable: return state.hasUpdateAvailable || state.isUpdating
case .updatesAvailable:
if state.hasUpdateAvailable || state.isUpdating { return true }
switch otaStatus?.phase {
case .scheduled, .requested, .updating: return true
default: return false
}
case .online: return isAvailable
case .offline: return !isAvailable
case .batteryLow: return (state.battery ?? 100) < DesignTokens.Threshold.lowBattery
Expand Down
5 changes: 5 additions & 0 deletions Shellbee/Core/Networking/Z2MMessageRouter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,11 @@ struct Z2MMessageRouter: Sendable {
return dispatch(raw)
}

static func decodeRaw(_ data: Data) -> (topic: String, payload: JSONValue)? {
guard let raw = try? JSONDecoder().decode(RawMessage.self, from: data) else { return nil }
return (raw.topic, raw.payload)
}

private func dispatch(_ raw: RawMessage) -> Z2MEvent? {
switch raw.topic {
case Z2MTopics.bridgeInfo:
Expand Down
5 changes: 5 additions & 0 deletions Shellbee/Core/Networking/Z2MTopics.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ enum Z2MTopics {
static let bridgeEvent = "bridge/event"
static let bridgeResponseDeviceOTAUpdate = "bridge/response/device/ota_update/update"
static let bridgeResponseDeviceOTACheck = "bridge/response/device/ota_update/check"
static let bridgeResponseDeviceOTASchedule = "bridge/response/device/ota_update/schedule"
static let bridgeResponseDeviceOTAUnschedule = "bridge/response/device/ota_update/unschedule"
static let bridgeHealth = "bridge/health"
static let bridgeDefinitions = "bridge/definitions"

Expand All @@ -18,6 +20,7 @@ enum Z2MTopics {
static let bridgeResponseTouchlinkFactoryReset = "bridge/response/touchlink/factory_reset"
static let bridgeResponseDeviceRename = "bridge/response/device/rename"
static let bridgeResponseDeviceRemove = "bridge/response/device/remove"
static let bridgeResponseBackup = "bridge/response/backup"

enum Request {
static let deviceRename = "bridge/request/device/rename"
Expand All @@ -29,6 +32,8 @@ enum Z2MTopics {
static let deviceUnbind = "bridge/request/device/unbind"
static let deviceOTACheck = "bridge/request/device/ota_update/check"
static let deviceOTAUpdate = "bridge/request/device/ota_update/update"
static let deviceOTASchedule = "bridge/request/device/ota_update/schedule"
static let deviceOTAUnschedule = "bridge/request/device/ota_update/unschedule"
static let deviceReportingConfigure = "bridge/request/device/configure_reporting"
static let groupAdd = "bridge/request/group/add"
static let groupRemove = "bridge/request/group/remove"
Expand Down
66 changes: 66 additions & 0 deletions Shellbee/Core/Networking/Z2MWebSocketClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,19 @@ import Foundation
actor Z2MWebSocketClient {

private static let connectionTimeout: TimeInterval = 10
/// After the WS handshake succeeds we wait for the *first inbound message*
/// before declaring the connection valid. Z2M accepts the HTTP 101 upgrade
/// and only then either (a) immediately publishes the cached bridge state /
/// device list, or (b) closes the socket with a policy-violation if the
/// auth token is missing/invalid. Both arrive over the WS — receive() will
/// return data on success and throw on close. If neither happens within
/// this timeout, the bridge is unreachable.
private static let firstMessageTimeout: TimeInterval = 5
/// Default URLSessionWebSocketTask frame limit is 1 MB. Z2M `bridge/response/backup`
/// payloads carry the entire data folder as a base64 string inside JSON — a populated
/// install with many devices and rotated config backups can produce 5–10 MB frames.
/// Anything beyond this cap aborts the receive loop and disconnects.
static let maximumFrameSize = 64 * 1024 * 1024

private let delegate: Z2MWebSocketSessionDelegate
private let session: URLSession
Expand Down Expand Up @@ -37,12 +50,15 @@ actor Z2MWebSocketClient {

state = .connecting
let wsTask = session.webSocketTask(with: url)
wsTask.maximumMessageSize = Self.maximumFrameSize
task = wsTask
delegate.setExpectedTask(wsTask)
wsTask.resume()

let firstMessage: URLSessionWebSocketTask.Message
do {
try await delegate.waitForOpen(timeout: Self.connectionTimeout)
firstMessage = try await receiveFirstMessage(wsTask)
} catch {
wsTask.cancel(with: .normalClosure, reason: nil)
task = nil
Expand All @@ -53,6 +69,11 @@ actor Z2MWebSocketClient {
}

state = .connected
// Replay the validated first message into the stream before starting
// the regular receive loop, so the session controller sees it.
if let data = try? Self.extractData(firstMessage) {
continuation.yield(.message(data))
}
receiveLoopTask = Task { await self.receiveLoop(wsTask, continuation: continuation) }
return stream
}
Expand Down Expand Up @@ -90,6 +111,51 @@ actor Z2MWebSocketClient {
}
}

/// Wait for the first inbound message on the freshly-opened WS, racing
/// against a timeout. A close frame from the server (e.g. auth rejection)
/// surfaces as a thrown error from receive(); we re-throw it as a
/// requestFailed with a clear, user-facing reason.
private func receiveFirstMessage(_ wsTask: URLSessionWebSocketTask) async throws -> URLSessionWebSocketTask.Message {
try await withThrowingTaskGroup(of: URLSessionWebSocketTask.Message.self) { group in
group.addTask {
do {
return try await wsTask.receive()
} catch {
throw Z2MError.requestFailed(Self.describeEarlyClose(error, task: wsTask))
}
}
group.addTask {
try await Task.sleep(for: .seconds(Self.firstMessageTimeout))
throw Z2MError.timeout
}
guard let result = try await group.next() else {
throw Z2MError.timeout
}
group.cancelAll()
return result
}
}

private static func describeEarlyClose(_ error: Error, task: URLSessionWebSocketTask) -> String {
let code = task.closeCode
let reason = task.closeReason.flatMap { String(data: $0, encoding: .utf8) }?
.trimmingCharacters(in: .whitespacesAndNewlines)

// Code 1008 (policy violation) is what Z2M uses for auth rejection.
// Surface a clear, actionable message instead of the raw close reason.
if code == .policyViolation {
if let reason, reason.localizedCaseInsensitiveContains("token") {
return "Authentication failed: \(reason). Check the auth token."
}
return "Server rejected the connection. Check the auth token."
}

if let reason, !reason.isEmpty {
return "Server closed the connection: \(reason)"
}
return Z2MError.interpret(error)
}

private static func extractData(_ message: URLSessionWebSocketTask.Message) throws -> Data {
switch message {
case .data(let d): return d
Expand Down
33 changes: 32 additions & 1 deletion Shellbee/Core/Store/AppStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,11 @@ final class AppStore {
// bulk queue so it can advance to the next device.
var otaResponseForwarding: ((_ friendlyName: String, _ success: Bool, _ kind: OTABulkOperationQueue.Kind) -> Void)?

// One-shot callback invoked when the next bridge/response/backup arrives.
// BackupView sets this before sending the request and clears it on receipt.
// Tuple: (zipBase64, errorMessage) — exactly one is non-nil.
var backupResponseHandler: ((_ zipBase64: String?, _ error: String?) -> Void)?

// Set by AppEnvironment to filter out notifications the user disabled
// in Settings → App → Notifications. Returns true to allow.
var notificationFilter: ((InAppNotification) -> Bool)?
Expand Down Expand Up @@ -195,7 +200,18 @@ final class AppStore {
)
}

case .bridgeResponse(_, let payload):
case .bridgeResponse(let topic, let payload):
if topic == Z2MTopics.bridgeResponseBackup, let handler = backupResponseHandler {
backupResponseHandler = nil
if payload.object?["status"]?.stringValue == "ok",
let zip = payload.object?["data"]?.object?["zip"]?.stringValue {
handler(zip, nil)
} else {
let err = payload.object?["error"]?.stringValue ?? "Unknown error"
handler(nil, err)
}
break
}
// The options/info responses carry only `{restart_required}` (and
// echo the request on error). The full config is delivered via the
// separate `bridge/info` topic, so don't try to decode config here
Expand Down Expand Up @@ -533,6 +549,21 @@ final class AppStore {
)
}

func startOTASchedule(for friendlyName: String) {
otaUpdates[friendlyName] = OTAUpdateStatus(
deviceName: friendlyName,
phase: .scheduled,
progress: nil,
remaining: nil
)
}

func cancelOTASchedule(for friendlyName: String) {
if otaUpdates[friendlyName]?.phase == .scheduled {
otaUpdates.removeValue(forKey: friendlyName)
}
}

func reset() {
devices = []
groups = []
Expand Down
Loading
Loading