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..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; @@ -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)"; @@ -885,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; @@ -923,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; @@ -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)"; @@ -963,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/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)" 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,