From 93f2c73f61aa07087488090c1b5d4e4defb7987f Mon Sep 17 00:00:00 2001 From: tashda Date: Mon, 4 May 2026 12:06:05 +0200 Subject: [PATCH 1/2] Remove custom code signing identity overrides --- Config/BuildSettings.local.example.xcconfig | 1 - Config/BuildSettings.xcconfig | 1 - Shellbee.xcodeproj/project.pbxproj | 2 -- ci_scripts/ci_post_clone.sh | 1 - 4 files changed, 5 deletions(-) diff --git a/Config/BuildSettings.local.example.xcconfig b/Config/BuildSettings.local.example.xcconfig index fa25d2d..9d5c88d 100644 --- a/Config/BuildSettings.local.example.xcconfig +++ b/Config/BuildSettings.local.example.xcconfig @@ -5,7 +5,6 @@ // archives. Do not commit the local file. APP_DEVELOPMENT_TEAM = YOURTEAMID -APP_IPHONEOS_CODE_SIGN_IDENTITY = APP_BUNDLE_ID = com.example.shellbee APP_WIDGET_BUNDLE_SUFFIX = widgets APP_WIDGET_BUNDLE_ID = $(APP_BUNDLE_ID).$(APP_WIDGET_BUNDLE_SUFFIX) diff --git a/Config/BuildSettings.xcconfig b/Config/BuildSettings.xcconfig index 723e029..8e2b57f 100644 --- a/Config/BuildSettings.xcconfig +++ b/Config/BuildSettings.xcconfig @@ -3,7 +3,6 @@ // which is ignored by git and should be used for device builds and App Store archives. APP_DEVELOPMENT_TEAM = -APP_IPHONEOS_CODE_SIGN_IDENTITY = APP_BUNDLE_ID = dev.echodb.shellbee APP_WIDGET_BUNDLE_SUFFIX = shellbeeWidgets APP_WIDGET_BUNDLE_ID = $(APP_BUNDLE_ID).$(APP_WIDGET_BUNDLE_SUFFIX) diff --git a/Shellbee.xcodeproj/project.pbxproj b/Shellbee.xcodeproj/project.pbxproj index ae00701..73311ef 100644 --- a/Shellbee.xcodeproj/project.pbxproj +++ b/Shellbee.xcodeproj/project.pbxproj @@ -871,7 +871,6 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO; CODE_SIGN_ENTITLEMENTS = Shellbee.entitlements; - "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "$(APP_IPHONEOS_CODE_SIGN_IDENTITY)"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = "$(APP_DEVELOPMENT_TEAM)"; @@ -948,7 +947,6 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; CODE_SIGN_ENTITLEMENTS = ShellbeeWidgets.entitlements; - "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "$(APP_IPHONEOS_CODE_SIGN_IDENTITY)"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = "$(APP_DEVELOPMENT_TEAM)"; diff --git a/ci_scripts/ci_post_clone.sh b/ci_scripts/ci_post_clone.sh index 6490291..59e173a 100755 --- a/ci_scripts/ci_post_clone.sh +++ b/ci_scripts/ci_post_clone.sh @@ -28,7 +28,6 @@ TARGET="$REPO_ROOT/Config/BuildSettings.local.xcconfig" { echo "APP_DEVELOPMENT_TEAM = $SHELLBEE_TEAM_ID" - echo "APP_IPHONEOS_CODE_SIGN_IDENTITY = Apple Distribution" echo "APP_BUNDLE_ID = $SHELLBEE_BUNDLE_ID" echo "APP_WIDGET_BUNDLE_SUFFIX = $SHELLBEE_WIDGET_SUFFIX" echo "APP_WIDGET_BUNDLE_ID = \$(APP_BUNDLE_ID).\$(APP_WIDGET_BUNDLE_SUFFIX)" From a0fdc0a19bcbab3b9a9634cf71d7ee3c0598d266 Mon Sep 17 00:00:00 2001 From: tashda Date: Mon, 4 May 2026 14:56:53 +0200 Subject: [PATCH 2/2] Bump to 1.6.3, decode interview_state from bridge/devices MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Recent Z2M replaced the legacy interviewing/interview_completed boolean pair on device records with a single interview_state enum (PENDING / IN_PROGRESS / SUCCESSFUL / FAILED). The legacy fields can stay false on fully-online devices, which made DeviceRowView show "Interviewing" while DeviceCard correctly showed "Online" — same data, two different reads. Add Device.interviewState plus a single Device.isInterviewing accessor that prefers the new enum and falls back to the legacy pair, then route every existing call site (rows, cards, footer, list filters, recents, network analysis, home snapshot, pairing wizard) through it. Mirror the new enum on device_interview events so optimistic updates also clear. Seeder fixtures and join scenario emit interview_state for parity. Fixes #101 Co-Authored-By: Claude Opus 4.7 (1M context) --- Shellbee.xcodeproj/project.pbxproj | 8 ++++---- Shellbee/Core/Models/Device.swift | 19 +++++++++++++++++++ Shellbee/Core/Models/NetworkAnalysis.swift | 2 +- Shellbee/Core/Store/AppStore+Events.swift | 5 ++++- .../Features/Devices/DeviceListView.swift | 2 +- .../Devices/DeviceListViewModel.swift | 2 +- Shellbee/Features/Devices/DeviceRowView.swift | 4 +--- Shellbee/Features/Home/HomeSnapshot.swift | 2 +- .../Features/Pairing/PairingWizardModel.swift | 7 +++++++ Shellbee/Shared/Components/DeviceCard.swift | 6 +++--- .../Components/DeviceCardFooterBar.swift | 4 ++-- docker/seeder/control.py | 3 +++ docker/seeder/fixtures.py | 1 + docker/seeder/seeder.py | 1 + 14 files changed, 49 insertions(+), 17 deletions(-) diff --git a/Shellbee.xcodeproj/project.pbxproj b/Shellbee.xcodeproj/project.pbxproj index 73311ef..0e7295d 100644 --- a/Shellbee.xcodeproj/project.pbxproj +++ b/Shellbee.xcodeproj/project.pbxproj @@ -846,7 +846,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.6.2; + MARKETING_VERSION = 1.6.3; PRODUCT_BUNDLE_IDENTIFIER = "$(APP_BUNDLE_ID)"; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = YES; @@ -884,7 +884,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.6.2; + MARKETING_VERSION = 1.6.3; PRODUCT_BUNDLE_IDENTIFIER = "$(APP_BUNDLE_ID)"; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = YES; @@ -922,7 +922,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 1.6.2; + MARKETING_VERSION = 1.6.3; PRODUCT_BUNDLE_IDENTIFIER = "$(APP_WIDGET_BUNDLE_ID)"; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; @@ -961,7 +961,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 1.6.2; + MARKETING_VERSION = 1.6.3; PRODUCT_BUNDLE_IDENTIFIER = "$(APP_WIDGET_BUNDLE_ID)"; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; diff --git a/Shellbee/Core/Models/Device.swift b/Shellbee/Core/Models/Device.swift index b04b88b..6fad5db 100644 --- a/Shellbee/Core/Models/Device.swift +++ b/Shellbee/Core/Models/Device.swift @@ -15,6 +15,7 @@ struct Device: Codable, Identifiable, Sendable, Equatable, Hashable { var manufacturer: String? var interviewCompleted: Bool var interviewing: Bool + var interviewState: InterviewState? var softwareBuildId: String? var dateCode: String? var endpoints: [String: JSONValue]? @@ -44,6 +45,16 @@ struct Device: Codable, Identifiable, Sendable, Equatable, Hashable { } } + /// Single source of truth for interview progress across the app. Prefers the + /// modern `interview_state` enum that recent Z2M emits and falls back to the + /// legacy `interviewing` / `interview_completed` boolean pair for older bridges. + var isInterviewing: Bool { + if let state = interviewState { + return state == .pending || state == .inProgress + } + return interviewing || !interviewCompleted + } + func hash(into hasher: inout Hasher) { hasher.combine(id) } static func == (lhs: Device, rhs: Device) -> Bool { lhs.ieeeAddress == rhs.ieeeAddress } @@ -55,12 +66,20 @@ struct Device: Codable, Identifiable, Sendable, Equatable, Hashable { case powerSource = "power_source" case modelId = "model_id" case interviewCompleted = "interview_completed" + case interviewState = "interview_state" case softwareBuildId = "software_build_id" case dateCode = "date_code" case endpoints, options, availability } } +enum InterviewState: String, Codable, Sendable, Equatable, Hashable { + case pending = "PENDING" + case inProgress = "IN_PROGRESS" + case successful = "SUCCESSFUL" + case failed = "FAILED" +} + enum DeviceType: String, Codable, Sendable, Equatable, ChipRepresentable { case router = "Router" case endDevice = "EndDevice" diff --git a/Shellbee/Core/Models/NetworkAnalysis.swift b/Shellbee/Core/Models/NetworkAnalysis.swift index f5f5345..721202f 100644 --- a/Shellbee/Core/Models/NetworkAnalysis.swift +++ b/Shellbee/Core/Models/NetworkAnalysis.swift @@ -51,7 +51,7 @@ enum DeviceCondition: String, CaseIterable, Sendable { case .availabilityOff: return availabilityStatus == .untracked case .batteryLow: return (state.battery ?? 100) < DesignTokens.Threshold.lowBattery case .weakSignal: return (state.linkQuality ?? 999) < DesignTokens.Threshold.weakSignal - case .interviewing: return device.interviewing || !device.interviewCompleted + case .interviewing: return device.isInterviewing case .unsupported: return !device.supported } } diff --git a/Shellbee/Core/Store/AppStore+Events.swift b/Shellbee/Core/Store/AppStore+Events.swift index 4491468..3693eda 100644 --- a/Shellbee/Core/Store/AppStore+Events.swift +++ b/Shellbee/Core/Store/AppStore+Events.swift @@ -42,7 +42,7 @@ extension AppStore { // freshly added. for device in list where device.type != .coordinator { guard deviceFirstSeen[device.ieeeAddress] == nil else { continue } - if device.interviewing || !device.interviewCompleted { + if device.isInterviewing { recordFirstSeen(ieee: device.ieeeAddress) } } @@ -122,9 +122,11 @@ extension AppStore { case "started": devices[idx].interviewing = true devices[idx].interviewCompleted = false + devices[idx].interviewState = .inProgress case "successful": devices[idx].interviewing = false devices[idx].interviewCompleted = true + devices[idx].interviewState = .successful // A device that just finished interviewing is by // definition online — Z2M only completes interview // when the device responds. Optimistically reflect @@ -139,6 +141,7 @@ extension AppStore { case "failed": devices[idx].interviewing = false devices[idx].interviewCompleted = false + devices[idx].interviewState = .failed default: break } diff --git a/Shellbee/Features/Devices/DeviceListView.swift b/Shellbee/Features/Devices/DeviceListView.swift index 019221a..1a0ce7b 100644 --- a/Shellbee/Features/Devices/DeviceListView.swift +++ b/Shellbee/Features/Devices/DeviceListView.swift @@ -376,7 +376,7 @@ private struct DeviceListContent: View { return environment.allDevices .filter { $0.device.type != .coordinator } .filter { bound in - if bound.device.interviewing { return true } + if bound.device.isInterviewing { return true } let store = environment.registry.session(for: bound.bridgeID)?.store if let joined = store?.deviceFirstSeen[bound.device.ieeeAddress], joined >= cutoff { return true diff --git a/Shellbee/Features/Devices/DeviceListViewModel.swift b/Shellbee/Features/Devices/DeviceListViewModel.swift index 6699b8c..079aac9 100644 --- a/Shellbee/Features/Devices/DeviceListViewModel.swift +++ b/Shellbee/Features/Devices/DeviceListViewModel.swift @@ -127,7 +127,7 @@ final class DeviceListViewModel { return store.devices .filter { $0.type != .coordinator } .filter { device in - if device.interviewing { return true } + if device.isInterviewing { return true } if let joined = store.deviceFirstSeen[device.ieeeAddress], joined >= cutoff { return true } diff --git a/Shellbee/Features/Devices/DeviceRowView.swift b/Shellbee/Features/Devices/DeviceRowView.swift index f7f0ccb..955b0b9 100644 --- a/Shellbee/Features/Devices/DeviceRowView.swift +++ b/Shellbee/Features/Devices/DeviceRowView.swift @@ -51,9 +51,7 @@ struct DeviceRowView: View { .padding(.vertical, DesignTokens.Spacing.xs) } - private var isInterviewing: Bool { - device.interviewing || !device.interviewCompleted - } + private var isInterviewing: Bool { device.isInterviewing } @ViewBuilder private var rightDetailView: some View { diff --git a/Shellbee/Features/Home/HomeSnapshot.swift b/Shellbee/Features/Home/HomeSnapshot.swift index 07778bd..7b7d059 100644 --- a/Shellbee/Features/Home/HomeSnapshot.swift +++ b/Shellbee/Features/Home/HomeSnapshot.swift @@ -101,7 +101,7 @@ struct HomeSnapshot: Sendable { guard let quality = (states[$0.friendlyName] ?? [:]).linkQuality else { return false } return quality < DesignTokens.Threshold.weakSignal }.count - interviewingDevices = nonCoordinatorDevices.filter { $0.interviewing || !$0.interviewCompleted }.count + interviewingDevices = nonCoordinatorDevices.filter { $0.isInterviewing }.count let lqiValues = nonCoordinatorDevices.compactMap { states[$0.friendlyName]?.linkQuality } averageLinkQuality = lqiValues.isEmpty ? nil : lqiValues.reduce(0, +) / lqiValues.count diff --git a/Shellbee/Features/Pairing/PairingWizardModel.swift b/Shellbee/Features/Pairing/PairingWizardModel.swift index 82739f2..d5c51ac 100644 --- a/Shellbee/Features/Pairing/PairingWizardModel.swift +++ b/Shellbee/Features/Pairing/PairingWizardModel.swift @@ -28,6 +28,13 @@ final class PairingWizardModel { } func interviewStatus(for device: Device) -> InterviewStatus { + if let state = device.interviewState { + switch state { + case .inProgress: return .running + case .successful: return .completed + case .pending, .failed: return .pending + } + } if device.interviewing { return .running } if device.interviewCompleted { return .completed } return .pending diff --git a/Shellbee/Shared/Components/DeviceCard.swift b/Shellbee/Shared/Components/DeviceCard.swift index 8ef18bc..e916e1f 100644 --- a/Shellbee/Shared/Components/DeviceCard.swift +++ b/Shellbee/Shared/Components/DeviceCard.swift @@ -299,7 +299,7 @@ struct DeviceCard: View { } } - if device.interviewing { + if device.isInterviewing { return "Interviewing" } @@ -312,14 +312,14 @@ struct DeviceCard: View { private var statusColor: Color { if otaStatus?.isActive == true { return .blue } - if device.interviewing { return .orange } + if device.isInterviewing { return .orange } if !device.availabilityTrackingEnabled { return .secondary } return isAvailable ? .green : .red } private var statusIcon: String { if otaStatus?.isActive == true { return "arrow.triangle.2.circlepath.circle.fill" } - if device.interviewing { return "dot.radiowaves.left.and.right" } + if device.isInterviewing { return "dot.radiowaves.left.and.right" } if !device.availabilityTrackingEnabled { return "minus.circle.fill" } return isAvailable ? "checkmark.circle.fill" : "xmark.circle.fill" } diff --git a/Shellbee/Shared/Components/DeviceCardFooterBar.swift b/Shellbee/Shared/Components/DeviceCardFooterBar.swift index 103c4b8..692087e 100644 --- a/Shellbee/Shared/Components/DeviceCardFooterBar.swift +++ b/Shellbee/Shared/Components/DeviceCardFooterBar.swift @@ -81,7 +81,7 @@ struct DeviceCardFooterBar: View { } } - if device.interviewing { + if device.isInterviewing { return "Interviewing" } @@ -94,7 +94,7 @@ struct DeviceCardFooterBar: View { private var statusColor: Color { if otaStatus?.isActive == true { return .blue } - if device.interviewing { return .orange } + if device.isInterviewing { return .orange } if !device.availabilityTrackingEnabled { return .secondary } return isAvailable ? .green : .red } diff --git a/docker/seeder/control.py b/docker/seeder/control.py index 8643e2f..53f686b 100644 --- a/docker/seeder/control.py +++ b/docker/seeder/control.py @@ -209,6 +209,7 @@ def _build_device(model: str, name: str, ieee: str) -> tuple[dict, dict]: "manufacturer": m["vendor"], "interview_completed": False, "interviewing": True, + "interview_state": "IN_PROGRESS", "software_build_id": None, "date_code": None, "endpoints": {"1": {"inputClusters": [], "outputClusters": [], @@ -258,6 +259,7 @@ def run(): with seeder._lock: device["interview_completed"] = False device["interviewing"] = False + device["interview_state"] = "FAILED" seeder._publish_devices(client) seeder._emit_event(client, "device_interview", { "friendly_name": name, "ieee_address": ieee, @@ -268,6 +270,7 @@ def run(): with seeder._lock: device["interview_completed"] = True device["interviewing"] = False + device["interview_state"] = "SUCCESSFUL" seeder._publish_devices(client) seeder._emit_event(client, "device_interview", { "friendly_name": name, "ieee_address": ieee, diff --git a/docker/seeder/fixtures.py b/docker/seeder/fixtures.py index 03266a0..8d04e26 100644 --- a/docker/seeder/fixtures.py +++ b/docker/seeder/fixtures.py @@ -157,6 +157,7 @@ def device( "manufacturer": m["vendor"], "interview_completed": True, "interviewing": False, + "interview_state": "SUCCESSFUL", "software_build_id": None, "date_code": None, "endpoints": {"1": {"inputClusters": [], "outputClusters": [], diff --git a/docker/seeder/seeder.py b/docker/seeder/seeder.py index e7bbeee..973945c 100644 --- a/docker/seeder/seeder.py +++ b/docker/seeder/seeder.py @@ -396,6 +396,7 @@ def run(): with _lock: device["interview_completed"] = True device["interviewing"] = False + device["interview_state"] = "SUCCESSFUL" _emit_event(client, "device_interview", { "friendly_name": name, "status": "successful", "ieee_address": ieee, "supported": True,