diff --git a/AGENTS.md b/AGENTS.md index 229150f..98bbf70 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -73,6 +73,7 @@ Most services are `@Observable @MainActor` classes passed through the SwiftUI en | `NotificationService` | `Services/` | UNUserNotificationCenter wrapper; called by `SceneService` after automation fires | | `SensorObservationService` | `Services/Intelligence/` | Subscribes to motion/contact `AsyncStream`s from all capable devices. Wired in `RootView` via `DeviceStateStore.onDevicesDiscovered/onDevicesRemoved` | | `RemoteService` | `Services/Remote/` | Sends IR commands to a user-configured bridge over HTTP via an injectable `IRTransport` (default `HTTPIRTransport`). Standalone, **not** a `SmartHomeBridge` — IR is fire-and-forget | +| `LocalDeviceService` | `Services/LocalNetwork/` | `LocalDeviceRecord` CRUD; keeps the `LocalNetworkBridge` in sync by republishing a thread-safe config snapshot and re-registering the bridge through `DeviceService` on every change | | `KeychainService` | `Services/` | Singleton (`KeychainService.shared`) wrapping the Security framework for secure string/data storage. **Not** an `@Observable` environment service — used directly when a feature needs secure storage | #### Calm-tone surfaces (the consent + explainability layer) @@ -145,8 +146,8 @@ The "Lumen noticed" dashboard suggestion is produced by `SuggestionEngine`, a pu #### SwiftData persistence (`Services/Persistence/`) -- Schema is versioned in `LumenSchema.swift`: `LumenSchemaV1` → `V2` → `V3`, with a lightweight `LumenSchemaMigrationPlan`. `PersistenceCoordinator` always uses `LumenSchemaV3`. -- The registered `@Model` types (identical across V1–V3 except `ExecutionEvent`, added in V2) are: `Home`, `Room`, `Zone`, `PlannedDevice`, `Scene`, `SceneAction`, `RemoteProfile`, `IRCommand`, `ExecutionEvent`. V2→V3 only drops `@Attribute(.unique)` from `id` fields for CloudKit compatibility; `Home.latitude`/`longitude`, and later `RemoteProfile.transportKindRaw`/`broadlinkMAC`/`broadlinkDeviceType`, were added via SwiftData's inferred nullable-column migration (no new version). +- Schema is versioned in `LumenSchema.swift`: `LumenSchemaV1` → `V2` → `V3` → `V4`, with a lightweight `LumenSchemaMigrationPlan`. `PersistenceCoordinator` always uses `LumenSchemaV4`. +- The registered `@Model` types are: `Home`, `Room`, `Zone`, `PlannedDevice`, `Scene`, `SceneAction`, `RemoteProfile`, `IRCommand`, `ExecutionEvent` (added in V2), `LocalDeviceRecord` (added in V4). V2→V3 only drops `@Attribute(.unique)` from `id` fields for CloudKit compatibility; V3→V4 adds the `LocalDeviceRecord` table (additive/lightweight). `Home.latitude`/`longitude`, and later `RemoteProfile.transportKindRaw`/`broadlinkMAC`/`broadlinkDeviceType`, were added via SwiftData's inferred nullable-column migration (no new version). - CloudKit sync is **off** (`PersistenceCoordinator.enableCloudKitSync = false`). The flag is guarded by a test (`PersistenceTests.testCloudKitSyncIsGatedOffForBeta`). Flip only after provisioning `iCloud.com.muharafiq.lumen` in the Apple Developer portal. - The old `MuhomeDataModels.swift` / `SceneModels.swift` legacy-struct files (`MuhaScene`, `MuhaSceneRecord`, etc.) have been **removed**. `TimeOfDay` — the one enum from that era still in use — now lives in its own file, `Lumen/Models/TimeOfDay.swift`. There is no dead legacy schema to avoid anymore. @@ -161,6 +162,7 @@ SwiftData `@Model` types are split by domain across two roots: | `Lumen/Models/` | `TimeOfDay`, `PlanningStage` (planned→commissioned lifecycle enum for `PlannedDevice`) | | `Lumen/Domain/Models/Automation/` | `Scene`, `SceneAction` | | `Lumen/Domain/Models/Remote/` | `RemoteProfile`, `IRCommand` | +| `Lumen/Domain/Models/LocalNetwork/` | `LocalDeviceRecord` (authoring record for a local-network device → `LocalDeviceConfig`) | `Home` owns a cascade relationship to `[Zone]`; `Zone` can hang off either a `Home` (top-level) or a `Room` (sub-zone) with optional normalised `positionX/Y` coordinates. @@ -169,7 +171,6 @@ SwiftData `@Model` types are split by domain across two roots: Some surfaces are persisted in the schema and have view/view-model code, but are **not** reachable from navigation yet. Treat them as in-progress, not dead code — extend rather than delete: - **Zones** (`Models/Space/Zone.swift`): part of the schema and relationships, but no service or UI surfaces zones yet. -- **Local-network devices** (`Integrations/LocalNetwork/`): the `LocalNetworkBridge` engine + Shelly transport are built and tested, but nothing persists `LocalDeviceConfig`s or registers the bridge yet. Next step: a SwiftData config model + a Settings surface to author devices, then register the bridge in `RootView` like `HomeKitBridge`. See the subsection below. #### Local-network devices (`Integrations/LocalNetwork/`) @@ -180,7 +181,9 @@ The seam is `Integrations/LocalNetwork/LocalDeviceTransport.swift`, which passes `LocalDeviceKind` maps a device to its component + capability set (`shellySwitch` → on/off; `shellyDimmer` → on/off + brightness). Unlike HomeKit there is no OS authorization gate and no push channel — local HTTP is read on demand, so the state stream stays open for a future poller but only emits an echo after an executed action. The bridge is driven by injected `[LocalDeviceConfig]` + a transport factory, so the whole vertical is testable without a network. -Tests: pure Shelly codec (vs crafted URLs/JSON) + bridge/device/capability flow (vs a fake `LocalDeviceTransport`) are covered by `LumenTests/LocalNetworkTests.swift`. Unlike IR, this **is** a `SmartHomeBridge` — local devices have controllable state and belong in the device/scene pipeline. +**Wired and reachable via Settings → Local Devices.** `LocalDeviceRecord` (`@Model`, schema V4) persists the user-authored devices; `LocalDeviceService` (`@Observable @MainActor`) owns their CRUD and keeps the bridge in sync — on any change it republishes a thread-safe `LocalDeviceConfigProvider` snapshot and **re-registers** the bridge through `DeviceService` (so added devices are discovered and removed ones pruned). `LocalDeviceListView` → `LocalDeviceDetailView` author name/address/kind/channel. The bridge is registered in `RootView.bootstrap` (behind the same `XCTest` guard as HomeKit) via `LocalDeviceService.reloadBridge()`. + +Tests: pure Shelly codec (vs crafted URLs/JSON) + bridge/device/capability flow (vs a fake `LocalDeviceTransport`) are covered by `LumenTests/LocalNetworkTests.swift`; `LocalDeviceService` CRUD → bridge (re)registration by `LumenTests/LocalDeviceServiceTests.swift`. Unlike IR, this **is** a `SmartHomeBridge` — local devices have controllable state and belong in the device/scene pipeline. #### IR remotes (`Features/Remote/`, `Integrations/IR/`, `Domain/Models/Remote/`) @@ -240,6 +243,7 @@ Coverage groups (~195 tests at time of writing): | `RemoteIRTests` | IR endpoint normalization, HTTP request building, `RemoteService` transport routing + learn-capability (fake transports), `RemoteViewModel` command/hostname/transport CRUD | | `BroadlinkTests` | Broadlink codec vs crafted vectors (checksum, AES round-trip, packet framing, auth, IR/learn/discovery payloads) + `BroadlinkTransport` actor flow vs a fake `UDPChannel` (send, learn, timeout) | | `LocalNetworkTests` | Shelly Gen2 codec (URL building, brightness scaling, status parsing, address normalization) + `LocalNetworkBridge`/device/capability flow vs a fake `LocalDeviceTransport` (discover, reachability probe, action routing, device lookup) | +| `LocalDeviceServiceTests` | `LocalDeviceService` CRUD → bridge (re)registration: add surfaces a device in the store, delete prunes it, kind/address edits republish, all vs a stub transport | | `DashboardPresentationTests` | Dashboard notice / presentation helpers | | `SensoryProfileTests` | Sensory profile defaults and persistence helpers | diff --git a/CLAUDE.md b/CLAUDE.md index 229150f..98bbf70 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -73,6 +73,7 @@ Most services are `@Observable @MainActor` classes passed through the SwiftUI en | `NotificationService` | `Services/` | UNUserNotificationCenter wrapper; called by `SceneService` after automation fires | | `SensorObservationService` | `Services/Intelligence/` | Subscribes to motion/contact `AsyncStream`s from all capable devices. Wired in `RootView` via `DeviceStateStore.onDevicesDiscovered/onDevicesRemoved` | | `RemoteService` | `Services/Remote/` | Sends IR commands to a user-configured bridge over HTTP via an injectable `IRTransport` (default `HTTPIRTransport`). Standalone, **not** a `SmartHomeBridge` — IR is fire-and-forget | +| `LocalDeviceService` | `Services/LocalNetwork/` | `LocalDeviceRecord` CRUD; keeps the `LocalNetworkBridge` in sync by republishing a thread-safe config snapshot and re-registering the bridge through `DeviceService` on every change | | `KeychainService` | `Services/` | Singleton (`KeychainService.shared`) wrapping the Security framework for secure string/data storage. **Not** an `@Observable` environment service — used directly when a feature needs secure storage | #### Calm-tone surfaces (the consent + explainability layer) @@ -145,8 +146,8 @@ The "Lumen noticed" dashboard suggestion is produced by `SuggestionEngine`, a pu #### SwiftData persistence (`Services/Persistence/`) -- Schema is versioned in `LumenSchema.swift`: `LumenSchemaV1` → `V2` → `V3`, with a lightweight `LumenSchemaMigrationPlan`. `PersistenceCoordinator` always uses `LumenSchemaV3`. -- The registered `@Model` types (identical across V1–V3 except `ExecutionEvent`, added in V2) are: `Home`, `Room`, `Zone`, `PlannedDevice`, `Scene`, `SceneAction`, `RemoteProfile`, `IRCommand`, `ExecutionEvent`. V2→V3 only drops `@Attribute(.unique)` from `id` fields for CloudKit compatibility; `Home.latitude`/`longitude`, and later `RemoteProfile.transportKindRaw`/`broadlinkMAC`/`broadlinkDeviceType`, were added via SwiftData's inferred nullable-column migration (no new version). +- Schema is versioned in `LumenSchema.swift`: `LumenSchemaV1` → `V2` → `V3` → `V4`, with a lightweight `LumenSchemaMigrationPlan`. `PersistenceCoordinator` always uses `LumenSchemaV4`. +- The registered `@Model` types are: `Home`, `Room`, `Zone`, `PlannedDevice`, `Scene`, `SceneAction`, `RemoteProfile`, `IRCommand`, `ExecutionEvent` (added in V2), `LocalDeviceRecord` (added in V4). V2→V3 only drops `@Attribute(.unique)` from `id` fields for CloudKit compatibility; V3→V4 adds the `LocalDeviceRecord` table (additive/lightweight). `Home.latitude`/`longitude`, and later `RemoteProfile.transportKindRaw`/`broadlinkMAC`/`broadlinkDeviceType`, were added via SwiftData's inferred nullable-column migration (no new version). - CloudKit sync is **off** (`PersistenceCoordinator.enableCloudKitSync = false`). The flag is guarded by a test (`PersistenceTests.testCloudKitSyncIsGatedOffForBeta`). Flip only after provisioning `iCloud.com.muharafiq.lumen` in the Apple Developer portal. - The old `MuhomeDataModels.swift` / `SceneModels.swift` legacy-struct files (`MuhaScene`, `MuhaSceneRecord`, etc.) have been **removed**. `TimeOfDay` — the one enum from that era still in use — now lives in its own file, `Lumen/Models/TimeOfDay.swift`. There is no dead legacy schema to avoid anymore. @@ -161,6 +162,7 @@ SwiftData `@Model` types are split by domain across two roots: | `Lumen/Models/` | `TimeOfDay`, `PlanningStage` (planned→commissioned lifecycle enum for `PlannedDevice`) | | `Lumen/Domain/Models/Automation/` | `Scene`, `SceneAction` | | `Lumen/Domain/Models/Remote/` | `RemoteProfile`, `IRCommand` | +| `Lumen/Domain/Models/LocalNetwork/` | `LocalDeviceRecord` (authoring record for a local-network device → `LocalDeviceConfig`) | `Home` owns a cascade relationship to `[Zone]`; `Zone` can hang off either a `Home` (top-level) or a `Room` (sub-zone) with optional normalised `positionX/Y` coordinates. @@ -169,7 +171,6 @@ SwiftData `@Model` types are split by domain across two roots: Some surfaces are persisted in the schema and have view/view-model code, but are **not** reachable from navigation yet. Treat them as in-progress, not dead code — extend rather than delete: - **Zones** (`Models/Space/Zone.swift`): part of the schema and relationships, but no service or UI surfaces zones yet. -- **Local-network devices** (`Integrations/LocalNetwork/`): the `LocalNetworkBridge` engine + Shelly transport are built and tested, but nothing persists `LocalDeviceConfig`s or registers the bridge yet. Next step: a SwiftData config model + a Settings surface to author devices, then register the bridge in `RootView` like `HomeKitBridge`. See the subsection below. #### Local-network devices (`Integrations/LocalNetwork/`) @@ -180,7 +181,9 @@ The seam is `Integrations/LocalNetwork/LocalDeviceTransport.swift`, which passes `LocalDeviceKind` maps a device to its component + capability set (`shellySwitch` → on/off; `shellyDimmer` → on/off + brightness). Unlike HomeKit there is no OS authorization gate and no push channel — local HTTP is read on demand, so the state stream stays open for a future poller but only emits an echo after an executed action. The bridge is driven by injected `[LocalDeviceConfig]` + a transport factory, so the whole vertical is testable without a network. -Tests: pure Shelly codec (vs crafted URLs/JSON) + bridge/device/capability flow (vs a fake `LocalDeviceTransport`) are covered by `LumenTests/LocalNetworkTests.swift`. Unlike IR, this **is** a `SmartHomeBridge` — local devices have controllable state and belong in the device/scene pipeline. +**Wired and reachable via Settings → Local Devices.** `LocalDeviceRecord` (`@Model`, schema V4) persists the user-authored devices; `LocalDeviceService` (`@Observable @MainActor`) owns their CRUD and keeps the bridge in sync — on any change it republishes a thread-safe `LocalDeviceConfigProvider` snapshot and **re-registers** the bridge through `DeviceService` (so added devices are discovered and removed ones pruned). `LocalDeviceListView` → `LocalDeviceDetailView` author name/address/kind/channel. The bridge is registered in `RootView.bootstrap` (behind the same `XCTest` guard as HomeKit) via `LocalDeviceService.reloadBridge()`. + +Tests: pure Shelly codec (vs crafted URLs/JSON) + bridge/device/capability flow (vs a fake `LocalDeviceTransport`) are covered by `LumenTests/LocalNetworkTests.swift`; `LocalDeviceService` CRUD → bridge (re)registration by `LumenTests/LocalDeviceServiceTests.swift`. Unlike IR, this **is** a `SmartHomeBridge` — local devices have controllable state and belong in the device/scene pipeline. #### IR remotes (`Features/Remote/`, `Integrations/IR/`, `Domain/Models/Remote/`) @@ -240,6 +243,7 @@ Coverage groups (~195 tests at time of writing): | `RemoteIRTests` | IR endpoint normalization, HTTP request building, `RemoteService` transport routing + learn-capability (fake transports), `RemoteViewModel` command/hostname/transport CRUD | | `BroadlinkTests` | Broadlink codec vs crafted vectors (checksum, AES round-trip, packet framing, auth, IR/learn/discovery payloads) + `BroadlinkTransport` actor flow vs a fake `UDPChannel` (send, learn, timeout) | | `LocalNetworkTests` | Shelly Gen2 codec (URL building, brightness scaling, status parsing, address normalization) + `LocalNetworkBridge`/device/capability flow vs a fake `LocalDeviceTransport` (discover, reachability probe, action routing, device lookup) | +| `LocalDeviceServiceTests` | `LocalDeviceService` CRUD → bridge (re)registration: add surfaces a device in the store, delete prunes it, kind/address edits republish, all vs a stub transport | | `DashboardPresentationTests` | Dashboard notice / presentation helpers | | `SensoryProfileTests` | Sensory profile defaults and persistence helpers | diff --git a/Lumen/Domain/Models/LocalNetwork/LocalDeviceRecord.swift b/Lumen/Domain/Models/LocalNetwork/LocalDeviceRecord.swift new file mode 100644 index 0000000..9d2e89b --- /dev/null +++ b/Lumen/Domain/Models/LocalNetwork/LocalDeviceRecord.swift @@ -0,0 +1,68 @@ +import Foundation +import SwiftData + +// MARK: - Local Device Record (@Model) +// The persisted authoring record for a local-network device. Turned into a +// value-type LocalDeviceConfig for the LocalNetworkBridge — the model never +// reaches the integration layer, matching the RemoteProfile → IRHost convention. + +@Model +final class LocalDeviceRecord { + var id: UUID + var displayName: String + var roomName: String? + var address: String // IP or hostname of the device on the LAN + var kindRaw: String // LocalDeviceKind + var channel: Int // component index (multi-relay devices) + var categoryRaw: String // DeviceCategory, for UI grouping + var sortOrder: Int + var createdAt: Date + var updatedAt: Date + + init( + id: UUID = UUID(), + displayName: String, + roomName: String? = nil, + address: String, + kind: LocalDeviceKind = .shellySwitch, + channel: Int = 0, + category: DeviceCategory = .lighting, + sortOrder: Int = 0 + ) { + self.id = id + self.displayName = displayName + self.roomName = roomName + self.address = address + self.kindRaw = kind.rawValue + self.channel = channel + self.categoryRaw = category.rawValue + self.sortOrder = sortOrder + self.createdAt = Date() + self.updatedAt = Date() + } + + /// Device kind. Legacy/unknown rows fall back to a plain switch. + var kind: LocalDeviceKind { + get { LocalDeviceKind(rawValue: kindRaw) ?? .shellySwitch } + set { kindRaw = newValue.rawValue } + } + + /// UI grouping category. Unknown rows fall back to `.other`. + var category: DeviceCategory { + get { DeviceCategory(rawValue: categoryRaw) ?? .other } + set { categoryRaw = newValue.rawValue } + } + + /// The value-type config handed to the bridge. + var config: LocalDeviceConfig { + LocalDeviceConfig( + id: id, + displayName: displayName, + roomName: roomName, + host: LocalHost(address: address), + kind: kind, + channel: channel, + category: category + ) + } +} diff --git a/Lumen/Features/LocalNetwork/LocalDeviceDetailView.swift b/Lumen/Features/LocalNetwork/LocalDeviceDetailView.swift new file mode 100644 index 0000000..984ccba --- /dev/null +++ b/Lumen/Features/LocalNetwork/LocalDeviceDetailView.swift @@ -0,0 +1,56 @@ +import SwiftUI + +// MARK: - Local Device Detail View +// Edit a single local device's address, kind, and channel. Changes save through +// LocalDeviceService, which re-registers the bridge so control reflects edits. + +struct LocalDeviceDetailView: View { + + @Environment(LocalDeviceService.self) private var service + @Environment(\.dismiss) private var dismiss + + let record: LocalDeviceRecord + + @State private var address: String = "" + @State private var kind: LocalDeviceKind = .shellySwitch + @State private var channel: Int = 0 + + var body: some View { + Form { + Section("Address") { + TextField("IP or hostname", text: $address) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .onSubmit { service.setAddress(address, on: record) } + } + + Section("Type") { + Picker("Kind", selection: $kind) { + ForEach(LocalDeviceKind.allCases, id: \.self) { option in + Text(option.displayName).tag(option) + } + } + .onChange(of: kind) { _, newValue in service.setKind(newValue, on: record) } + + Stepper("Channel: \(channel)", value: $channel, in: 0...8) + .onChange(of: channel) { _, newValue in service.setChannel(newValue, on: record) } + } + + Section { + Button(role: .destructive) { + service.deleteDevice(record) + dismiss() + } label: { + Text("Remove Device") + } + } + } + .navigationTitle(record.displayName) + .navigationBarTitleDisplayMode(.inline) + .onAppear { + address = record.address + kind = record.kind + channel = record.channel + } + } +} diff --git a/Lumen/Features/LocalNetwork/LocalDeviceListView.swift b/Lumen/Features/LocalNetwork/LocalDeviceListView.swift new file mode 100644 index 0000000..ab467f8 --- /dev/null +++ b/Lumen/Features/LocalNetwork/LocalDeviceListView.swift @@ -0,0 +1,134 @@ +import SwiftUI +import SwiftData + +// MARK: - Local Device List View +// Settings → Local Devices. Authors LocalDeviceRecords that drive the +// LocalNetworkBridge — the "control devices Apple Home can't see" surface. + +struct LocalDeviceListView: View { + + @Environment(LocalDeviceService.self) private var service + @Query(sort: \LocalDeviceRecord.sortOrder) private var devices: [LocalDeviceRecord] + + @State private var isShowingAdd = false + @State private var newName = "" + @State private var newAddress = "" + @State private var newKind: LocalDeviceKind = .shellySwitch + @State private var newChannel = 0 + + var body: some View { + Group { + if devices.isEmpty { + EmptyStateView( + icon: "wifi.router", + title: "No Local Devices", + message: "Add a Shelly switch or dimmer on your Wi-Fi to control it from Lumen — no cloud, no Apple Home needed.", + action: { isShowingAdd = true }, + actionTitle: "Add Device" + ) + } else { + deviceList + } + } + .navigationTitle("Local Devices") + .navigationBarTitleDisplayMode(.large) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button { isShowingAdd = true } label: { Image(systemName: "plus") } + } + } + .sheet(isPresented: $isShowingAdd) { addSheet } + .alert( + "Something went wrong", + isPresented: Binding( + get: { service.error != nil }, + set: { if !$0 { service.error = nil } } + ) + ) { + Button("OK", role: .cancel) { service.error = nil } + } message: { + Text(service.error?.localizedDescription ?? "") + } + } + + private var deviceList: some View { + List { + ForEach(devices, id: \.id) { device in + NavigationLink { + LocalDeviceDetailView(record: device) + } label: { + HStack(spacing: 12) { + Image(systemName: device.kind == .shellyDimmer ? "lightbulb" : "power") + .font(.body) + .foregroundStyle(Color("MuhaBrown")) + .frame(width: 28) + VStack(alignment: .leading, spacing: 2) { + Text(device.displayName) + .font(.body) + .foregroundStyle(Color("PrimaryText")) + Text("\(device.kind.displayName) · \(device.address)") + .font(.caption) + .foregroundStyle(Color("SecondaryText")) + } + } + .padding(.vertical, 4) + } + } + .onDelete { indexSet in + for i in indexSet { service.deleteDevice(devices[i]) } + } + } + .listStyle(.insetGrouped) + } + + private var addSheet: some View { + NavigationStack { + Form { + Section("Device") { + TextField("Name (e.g. Porch Light)", text: $newName) + TextField("Address (e.g. 192.168.1.50)", text: $newAddress) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + } + Section("Type") { + Picker("Kind", selection: $newKind) { + ForEach(LocalDeviceKind.allCases, id: \.self) { kind in + Text(kind.displayName).tag(kind) + } + } + Stepper("Channel: \(newChannel)", value: $newChannel, in: 0...8) + } + } + .navigationTitle("New Local Device") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { resetAndDismiss() } + } + ToolbarItem(placement: .confirmationAction) { + Button("Add") { + service.addDevice( + name: newName.trimmingCharacters(in: .whitespacesAndNewlines), + address: newAddress.trimmingCharacters(in: .whitespacesAndNewlines), + kind: newKind, + channel: newChannel + ) + resetAndDismiss() + } + .disabled(!canAdd) + } + } + } + .presentationDetents([.height(360)]) + } + + private var canAdd: Bool { + !newName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + && !newAddress.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + + private func resetAndDismiss() { + isShowingAdd = false + newName = ""; newAddress = ""; newKind = .shellySwitch; newChannel = 0 + } +} diff --git a/Lumen/Features/RootView.swift b/Lumen/Features/RootView.swift index 12cece2..b8c714a 100644 --- a/Lumen/Features/RootView.swift +++ b/Lumen/Features/RootView.swift @@ -14,6 +14,7 @@ struct RootView: View { @Environment(SceneService.self) private var sceneService @Environment(SensorObservationService.self) private var sensorService @Environment(LocationService.self) private var locationService + @Environment(LocalDeviceService.self) private var localDeviceService @Environment(\.modelContext) private var modelContext @Environment(\.horizontalSizeClass) private var sizeClass @@ -158,6 +159,7 @@ struct RootView: View { if ProcessInfo.processInfo.environment["XCTestConfigurationFilePath"] == nil { let hkBridge = HomeKitBridge() deviceService.registerBridge(hkBridge) + await localDeviceService.reloadBridge() } } } diff --git a/Lumen/Features/Settings/SettingsView.swift b/Lumen/Features/Settings/SettingsView.swift index 6f6b1e2..b8dcc6c 100644 --- a/Lumen/Features/Settings/SettingsView.swift +++ b/Lumen/Features/Settings/SettingsView.swift @@ -39,6 +39,7 @@ struct SettingsView: View { homeSection bridgesSection remotesSection + localDevicesSection preferencesSection sensoryProfileSection aboutSection @@ -185,6 +186,28 @@ struct SettingsView: View { } } + // MARK: - Local Devices Section + + private var localDevicesSection: some View { + SettingsDarkCard(title: "LOCAL DEVICES") { + NavigationLink { + LocalDeviceListView() + } label: { + HStack { + Text("Wi-Fi Devices") + .font(.system(size: 15)) + .foregroundStyle(.white) + Spacer() + Image(systemName: "chevron.right") + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(Color.white.opacity(0.3)) + } + .padding(.horizontal, 16) + .padding(.vertical, 14) + } + } + } + // MARK: - Preferences Section private var preferencesSection: some View { diff --git a/Lumen/Services/LocalNetwork/LocalDeviceService.swift b/Lumen/Services/LocalNetwork/LocalDeviceService.swift new file mode 100644 index 0000000..3ed0789 --- /dev/null +++ b/Lumen/Services/LocalNetwork/LocalDeviceService.swift @@ -0,0 +1,138 @@ +import Foundation +import Observation +import SwiftData + +// MARK: - Local Device Config Provider +// A tiny thread-safe box the LocalNetworkBridge reads its configs from. The +// bridge is an actor whose `@Sendable` config closure may run off the main +// actor, so the snapshot lives behind a lock rather than on the main-actor +// service directly. + +final class LocalDeviceConfigProvider: @unchecked Sendable { + private let lock = NSLock() + private var configs: [LocalDeviceConfig] = [] + + func update(_ newConfigs: [LocalDeviceConfig]) { + lock.lock(); defer { lock.unlock() } + configs = newConfigs + } + + func snapshot() -> [LocalDeviceConfig] { + lock.lock(); defer { lock.unlock() } + return configs + } +} + +// MARK: - Local Device Service +// Owns LocalDeviceRecord CRUD and keeps the LocalNetworkBridge in sync with it. +// On any change it republishes the config snapshot and re-registers the bridge +// through DeviceService, so added devices are discovered and removed devices are +// pruned (re-registration tears down the old bridge's devices first). + +@MainActor +@Observable +final class LocalDeviceService { + + private let modelContext: ModelContext + private let deviceService: DeviceService + private let provider = LocalDeviceConfigProvider() + private let transportFactory: @Sendable (LocalDeviceKind) -> any LocalDeviceTransport + + var error: (any Error)? + + init( + modelContext: ModelContext, + deviceService: DeviceService, + transportFactory: @escaping @Sendable (LocalDeviceKind) -> any LocalDeviceTransport = { _ in ShellyGen2Transport() } + ) { + self.modelContext = modelContext + self.deviceService = deviceService + self.transportFactory = transportFactory + } + + // MARK: - Bridge Lifecycle + + /// Registers (or re-registers) the local-network bridge with the current set + /// of records. Safe to call repeatedly — the previous bridge is unregistered + /// first, which removes its now-stale devices from the state store. + func reloadBridge() async { + refreshSnapshot() + await deviceService.unregisterBridge(.localNetwork) + let provider = provider + let factory = transportFactory + deviceService.registerBridge( + LocalNetworkBridge(configProvider: { provider.snapshot() }, transportFactory: factory) + ) + } + + // MARK: - CRUD + + func addDevice( + name: String, + address: String, + kind: LocalDeviceKind, + channel: Int = 0, + roomName: String? = nil + ) { + let record = LocalDeviceRecord( + displayName: name, + roomName: roomName, + address: address, + kind: kind, + channel: channel, + category: .lighting, + sortOrder: nextSortOrder() + ) + modelContext.insert(record) + persist() + } + + func deleteDevice(_ record: LocalDeviceRecord) { + modelContext.delete(record) + persist() + } + + func setAddress(_ address: String, on record: LocalDeviceRecord) { + record.address = address.trimmingCharacters(in: .whitespacesAndNewlines) + record.updatedAt = Date() + persist() + } + + func setKind(_ kind: LocalDeviceKind, on record: LocalDeviceRecord) { + record.kind = kind + record.updatedAt = Date() + persist() + } + + func setChannel(_ channel: Int, on record: LocalDeviceRecord) { + record.channel = max(0, channel) + record.updatedAt = Date() + persist() + } + + // MARK: - Private + + private func persist() { + do { + try modelContext.save() + Task { await reloadBridge() } + } catch { + self.error = error + } + } + + private func refreshSnapshot() { + provider.update(fetchRecords().map(\.config)) + } + + private func fetchRecords() -> [LocalDeviceRecord] { + let descriptor = FetchDescriptor( + sortBy: [SortDescriptor(\.sortOrder), SortDescriptor(\.createdAt)] + ) + return (try? modelContext.fetch(descriptor)) ?? [] + } + + private func nextSortOrder() -> Int { + (fetchRecords().map(\.sortOrder).max() ?? -1) + 1 + } +} diff --git a/Lumen/Services/Persistence/LumenSchema.swift b/Lumen/Services/Persistence/LumenSchema.swift index a2af7fa..8f9ebb5 100644 --- a/Lumen/Services/Persistence/LumenSchema.swift +++ b/Lumen/Services/Persistence/LumenSchema.swift @@ -56,6 +56,25 @@ enum LumenSchemaV3: VersionedSchema { ] } +// MARK: - Versioned Schema V4 + +enum LumenSchemaV4: VersionedSchema { + static var versionIdentifier = Schema.Version(4, 0, 0) + + static var models: [any PersistentModel.Type] = [ + Home.self, + Room.self, + Zone.self, + PlannedDevice.self, + Scene.self, + SceneAction.self, + RemoteProfile.self, + IRCommand.self, + ExecutionEvent.self, + LocalDeviceRecord.self, // new in V4 — local-network device authoring records + ] +} + // MARK: - Migration Plan enum LumenSchemaMigrationPlan: SchemaMigrationPlan { @@ -63,6 +82,7 @@ enum LumenSchemaMigrationPlan: SchemaMigrationPlan { LumenSchemaV1.self, LumenSchemaV2.self, LumenSchemaV3.self, + LumenSchemaV4.self, ] static var stages: [MigrationStage] = [ @@ -79,5 +99,10 @@ enum LumenSchemaMigrationPlan: SchemaMigrationPlan { fromVersion: LumenSchemaV2.self, toVersion: LumenSchemaV3.self ), + // V3 → V4: add LocalDeviceRecord model (a new table — additive/lightweight). + MigrationStage.lightweight( + fromVersion: LumenSchemaV3.self, + toVersion: LumenSchemaV4.self + ), ] } diff --git a/Lumen/Services/Persistence/PersistenceCoordinator.swift b/Lumen/Services/Persistence/PersistenceCoordinator.swift index 52650d2..efe2c1d 100644 --- a/Lumen/Services/Persistence/PersistenceCoordinator.swift +++ b/Lumen/Services/Persistence/PersistenceCoordinator.swift @@ -33,8 +33,8 @@ enum PersistenceCoordinator { ) let schema = Schema( - LumenSchemaV3.models, - version: LumenSchemaV3.versionIdentifier + LumenSchemaV4.models, + version: LumenSchemaV4.versionIdentifier ) // When CloudKit is enabled, prefer the synced store but degrade to local-only @@ -75,8 +75,8 @@ enum PersistenceCoordinator { static func makeInMemoryContainer() -> ModelContainer { let schema = Schema( - LumenSchemaV3.models, - version: LumenSchemaV3.versionIdentifier + LumenSchemaV4.models, + version: LumenSchemaV4.versionIdentifier ) // cloudKitDatabase: .none is required — without it SwiftData defaults to // .automatic, detects the host app's iCloud entitlement, and tries to set up diff --git a/Lumen/Views/LumenApp.swift b/Lumen/Views/LumenApp.swift index 808b37e..2a3798d 100644 --- a/Lumen/Views/LumenApp.swift +++ b/Lumen/Views/LumenApp.swift @@ -16,6 +16,7 @@ struct LumenApp: App { @State private var sensorService = SensorObservationService() @State private var locationService = LocationService() @State private var remoteService = RemoteService() + @State private var localDeviceService: LocalDeviceService init() { let c = PersistenceCoordinator.makeContainer() @@ -26,11 +27,13 @@ struct LumenApp: App { let home = HomeService(modelContext: ctx) let dev = DeviceService(modelContext: ctx, stateStore: store) let scene = SceneService(modelContext: ctx, deviceService: dev) + let local = LocalDeviceService(modelContext: ctx, deviceService: dev) _stateStore = State(wrappedValue: store) _homeService = State(wrappedValue: home) _deviceService = State(wrappedValue: dev) _sceneService = State(wrappedValue: scene) + _localDeviceService = State(wrappedValue: local) } var body: some SwiftUI.Scene { @@ -44,6 +47,7 @@ struct LumenApp: App { .environment(sensorService) .environment(locationService) .environment(remoteService) + .environment(localDeviceService) } .modelContainer(container) } diff --git a/LumenTests/LocalDeviceServiceTests.swift b/LumenTests/LocalDeviceServiceTests.swift new file mode 100644 index 0000000..d71328e --- /dev/null +++ b/LumenTests/LocalDeviceServiceTests.swift @@ -0,0 +1,103 @@ +import XCTest +import SwiftData +@testable import Lumen + +// Covers LocalDeviceService: CRUD over LocalDeviceRecord and the resulting bridge +// (re)registration, so that authored devices surface in the DeviceStateStore and +// removed ones disappear — all against a fake transport (no networking). +@MainActor +final class LocalDeviceServiceTests: XCTestCase { + + private var container: ModelContainer! + + private func makeService() -> (LocalDeviceService, DeviceService, DeviceStateStore) { + let container = PersistenceCoordinator.makeInMemoryContainer() + self.container = container + let store = DeviceStateStore() + let deviceService = DeviceService(modelContext: container.mainContext, stateStore: store) + let service = LocalDeviceService( + modelContext: container.mainContext, + deviceService: deviceService, + transportFactory: { _ in StubLocalTransport() } + ) + return (service, deviceService, store) + } + + private func localDevices(in store: DeviceStateStore) -> [any SmartDevice] { + store.allDevices.filter { $0.bridgeID == .localNetwork } + } + + func testReloadRegistersBridge() async { + let (service, deviceService, store) = makeService() + await service.reloadBridge() + XCTAssertTrue(deviceService.registeredBridges.keys.contains(.localNetwork)) + XCTAssertNotNil(store.bridgeStatuses[.localNetwork]) + } + + func testAddDeviceSurfacesInStateStore() async { + let (service, _, store) = makeService() + service.addDevice(name: "Porch", address: "10.0.0.5", kind: .shellySwitch) + + await waitUntil { self.localDevices(in: store).count == 1 } + let device = localDevices(in: store).first + XCTAssertEqual(device?.displayName, "Porch") + XCTAssertTrue(device?.supports(.onOff) ?? false) + } + + func testAddDimmerExposesBrightness() async { + let (service, _, store) = makeService() + service.addDevice(name: "Hall", address: "10.0.0.6", kind: .shellyDimmer) + + await waitUntil { self.localDevices(in: store).first?.supports(.brightness) == true } + XCTAssertTrue(localDevices(in: store).first?.supports(.onOff) ?? false) + } + + func testDeleteDeviceRemovesFromStateStore() async throws { + let (service, _, store) = makeService() + service.addDevice(name: "Porch", address: "10.0.0.5", kind: .shellySwitch) + await waitUntil { self.localDevices(in: store).count == 1 } + + let record = try XCTUnwrap(fetchRecords().first) + service.deleteDevice(record) + + await waitUntil { self.localDevices(in: store).isEmpty } + XCTAssertEqual(fetchRecords().count, 0) + } + + func testSetAddressPersistsAndRepublishes() async throws { + let (service, _, store) = makeService() + service.addDevice(name: "Porch", address: "10.0.0.5", kind: .shellySwitch) + await waitUntil { self.localDevices(in: store).count == 1 } + + let record = try XCTUnwrap(fetchRecords().first) + service.setAddress("10.0.0.9", on: record) + await waitUntil { self.fetchRecords().first?.address == "10.0.0.9" } + + // Still exactly one local device after the re-registration cycle. + await waitUntil { self.localDevices(in: store).count == 1 } + } + + func testSetKindSwitchesCapabilitySet() async throws { + let (service, _, store) = makeService() + service.addDevice(name: "Porch", address: "10.0.0.5", kind: .shellySwitch) + await waitUntil { self.localDevices(in: store).first?.supports(.brightness) == false } + + let record = try XCTUnwrap(fetchRecords().first) + service.setKind(.shellyDimmer, on: record) + await waitUntil { self.localDevices(in: store).first?.supports(.brightness) == true } + } + + private func fetchRecords() -> [LocalDeviceRecord] { + let descriptor = FetchDescriptor(sortBy: [SortDescriptor(\.sortOrder)]) + return (try? container.mainContext.fetch(descriptor)) ?? [] + } +} + +// MARK: - Stub Transport + +private actor StubLocalTransport: LocalDeviceTransport { + func apply(_ command: LocalDeviceCommand, to target: LocalTarget) async throws {} + func read(from target: LocalTarget) async throws -> LocalDeviceReading { + LocalDeviceReading(isOn: false) + } +} diff --git a/docs/competitive-feature-scope.md b/docs/competitive-feature-scope.md index 94f580a..a225caf 100644 --- a/docs/competitive-feature-scope.md +++ b/docs/competitive-feature-scope.md @@ -29,12 +29,12 @@ Everything below is filtered so it strengthens the moat instead of diluting it. ### 1. A second real bridge: local-LAN device support (the Homebridge move) -> **Status (July 2026):** engine landed. `LocalNetworkBridge` + the -> `LocalDeviceTransport` seam + `ShellyGen2Transport` + capabilities + tests are -> in `Integrations/LocalNetwork/` (`LumenTests/LocalNetworkTests.swift`). -> Remaining: a SwiftData `LocalDeviceConfig` model + a Settings surface to author -> devices, then register the bridge in `RootView`. Tracked as "scaffolded but not -> yet wired" in `CLAUDE.md`. +> **Status (July 2026): shipped & wired.** `LocalNetworkBridge` + the +> `LocalDeviceTransport` seam + `ShellyGen2Transport` + capabilities live in +> `Integrations/LocalNetwork/`. Persistence (`LocalDeviceRecord`, schema V4) + +> `LocalDeviceService` + **Settings → Local Devices** (`LocalDeviceListView` / +> `LocalDeviceDetailView`) now let users author devices; the bridge registers in +> `RootView.bootstrap`. Covered by `LocalNetworkTests` + `LocalDeviceServiceTests`. **Why:** Today, a Shelly relay, a Tasmota/ESPHome switch, a LIFX bulb on LAN, or a Tuya device that never made it into Apple Home is invisible to Lumen. This is the