From 4afe853addaa8e39a339afc37694e02d972594c3 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 02:09:56 +0000 Subject: [PATCH] feat(local-network): add LocalNetworkBridge for non-HomeKit LAN devices MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a second real SmartHomeBridge alongside HomeKit — the 'control devices Apple Home can't see' surface that Home Assistant and Homebridge exist for. LocalNetworkBridge reaches user-configured LAN devices over their local HTTP APIs (no cloud, no account), routing through DeviceService.registerBridge and BridgeID so they flow through the same DeviceStateStore -> capability UI -> scene pipeline as HomeKit with no view changes. - LocalDeviceTransport: the value-type seam (mirrors IRTransport), with a vendor-neutral LocalComponent (relay/light) so more protocols can conform. - ShellyGen2Transport: Shelly Gen2 RPC over local HTTP, with pure unit-tested URL-building and JSON-parsing helpers (like HTTPIRTransport). - LocalNetworkDevice + capabilities: on/off and brightness read/write through the transport (like the HomeKit capability structs). - LocalNetworkBridge: an actor conforming to SmartHomeBridge, driven by injected [LocalDeviceConfig] + a transport factory so the whole vertical is testable without a network. - LocalNetworkTests: Shelly codec vs crafted URLs/JSON + bridge/device/ capability flow vs a fake transport. Engine only: persisting LocalDeviceConfigs and a Settings surface to author them (then registering the bridge in RootView) are the next step, documented as scaffolded-but-not-yet-wired in CLAUDE.md/AGENTS.md. Co-Authored-By: Claude Opus 4.8 --- AGENTS.md | 13 + CLAUDE.md | 13 + .../LocalNetwork/LocalDeviceTransport.swift | 69 +++++ .../LocalNetwork/LocalNetworkBridge.swift | 107 ++++++++ .../LocalNetwork/LocalNetworkDevice.swift | 172 +++++++++++++ .../LocalNetwork/ShellyGen2Transport.swift | 123 +++++++++ LumenTests/LocalNetworkTests.swift | 243 ++++++++++++++++++ docs/competitive-feature-scope.md | 7 + 8 files changed, 747 insertions(+) create mode 100644 Lumen/Integrations/LocalNetwork/LocalDeviceTransport.swift create mode 100644 Lumen/Integrations/LocalNetwork/LocalNetworkBridge.swift create mode 100644 Lumen/Integrations/LocalNetwork/LocalNetworkDevice.swift create mode 100644 Lumen/Integrations/LocalNetwork/ShellyGen2Transport.swift create mode 100644 LumenTests/LocalNetworkTests.swift diff --git a/AGENTS.md b/AGENTS.md index ed6c82d..229150f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -169,6 +169,18 @@ 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/`) + +The second real `SmartHomeBridge` alongside HomeKit — the "control devices Apple Home can't see" surface (Home Assistant / Homebridge cover the same need). `LocalNetworkBridge` (an `actor`) reaches user-configured LAN devices over their local HTTP APIs — no cloud, no account, staying local-first. Because it registers through `DeviceService.registerBridge` and routes by `BridgeID` (`.localNetwork`), these devices flow through the exact same `DeviceStateStore` → capability UI → scene pipeline as HomeKit, with **no view changes**. + +The seam is `Integrations/LocalNetwork/LocalDeviceTransport.swift`, which passes value types only (`LocalTarget` / `LocalDeviceCommand` / `LocalDeviceReading`, never a model), mirroring `IRTransport`. One transport conforms today: +- **`ShellyGen2Transport`** — Shelly Gen2 RPC over local HTTP (`GET /rpc/Switch.Set`, `Light.Set`, `*.GetStatus`). URL-building and JSON-parsing are pure static helpers (`normalizedBaseURL`, `setURL`, `statusURL`, `parseReading`), unit-tested without networking exactly like `HTTPIRTransport`. The protocol-agnostic `LocalComponent` (`relay`/`light`) keeps the seam vendor-neutral, so Tasmota/ESPHome/generic-REST can conform later. + +`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. #### IR remotes (`Features/Remote/`, `Integrations/IR/`, `Domain/Models/Remote/`) @@ -227,6 +239,7 @@ Coverage groups (~195 tests at time of writing): | `RoomViewModelTests` | RoomVM CRUD wrapper | | `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) | | `DashboardPresentationTests` | Dashboard notice / presentation helpers | | `SensoryProfileTests` | Sensory profile defaults and persistence helpers | diff --git a/CLAUDE.md b/CLAUDE.md index ed6c82d..229150f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -169,6 +169,18 @@ 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/`) + +The second real `SmartHomeBridge` alongside HomeKit — the "control devices Apple Home can't see" surface (Home Assistant / Homebridge cover the same need). `LocalNetworkBridge` (an `actor`) reaches user-configured LAN devices over their local HTTP APIs — no cloud, no account, staying local-first. Because it registers through `DeviceService.registerBridge` and routes by `BridgeID` (`.localNetwork`), these devices flow through the exact same `DeviceStateStore` → capability UI → scene pipeline as HomeKit, with **no view changes**. + +The seam is `Integrations/LocalNetwork/LocalDeviceTransport.swift`, which passes value types only (`LocalTarget` / `LocalDeviceCommand` / `LocalDeviceReading`, never a model), mirroring `IRTransport`. One transport conforms today: +- **`ShellyGen2Transport`** — Shelly Gen2 RPC over local HTTP (`GET /rpc/Switch.Set`, `Light.Set`, `*.GetStatus`). URL-building and JSON-parsing are pure static helpers (`normalizedBaseURL`, `setURL`, `statusURL`, `parseReading`), unit-tested without networking exactly like `HTTPIRTransport`. The protocol-agnostic `LocalComponent` (`relay`/`light`) keeps the seam vendor-neutral, so Tasmota/ESPHome/generic-REST can conform later. + +`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. #### IR remotes (`Features/Remote/`, `Integrations/IR/`, `Domain/Models/Remote/`) @@ -227,6 +239,7 @@ Coverage groups (~195 tests at time of writing): | `RoomViewModelTests` | RoomVM CRUD wrapper | | `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) | | `DashboardPresentationTests` | Dashboard notice / presentation helpers | | `SensoryProfileTests` | Sensory profile defaults and persistence helpers | diff --git a/Lumen/Integrations/LocalNetwork/LocalDeviceTransport.swift b/Lumen/Integrations/LocalNetwork/LocalDeviceTransport.swift new file mode 100644 index 0000000..0f23619 --- /dev/null +++ b/Lumen/Integrations/LocalNetwork/LocalDeviceTransport.swift @@ -0,0 +1,69 @@ +import Foundation + +// MARK: - Local Device Transport +// The seam between the app and however a local-network device is actually +// controlled over the LAN. This is the "Homebridge move": it reaches devices +// that never made it into Apple Home (Shelly, and later Tasmota / ESPHome / +// generic REST), keeping Lumen local-first — no cloud, no account. +// +// Only value types cross this boundary (never a SwiftData model), matching the +// SmartHomeBridge snapshot convention and the IRTransport seam. One transport +// conforms today: ShellyGen2Transport (Shelly Gen2 RPC over local HTTP). + +/// Addressing for a local device. `address` is what the user typed — a bare IP +/// or host ("192.168.1.50", "shelly.local"), "host:port", or a full http URL. +struct LocalHost: Sendable, Equatable { + let address: String + init(address: String) { self.address = address } +} + +/// A protocol-agnostic view of *what kind of control point* a target is, so the +/// seam stays independent of any one vendor's component naming. `relay` is a +/// plain on/off point; `light` additionally accepts brightness. Each concrete +/// transport maps these onto its own components (Shelly: `relay`→`Switch`, +/// `light`→`Light`). +enum LocalComponent: String, Sendable, Equatable, Codable { + case relay + case light +} + +/// Addresses one controllable point on a device: a host, which component it is, +/// and which channel/index (multi-relay devices expose several). +struct LocalTarget: Sendable, Equatable { + let host: LocalHost + let component: LocalComponent + let channel: Int + + init(host: LocalHost, component: LocalComponent, channel: Int = 0) { + self.host = host + self.component = component + self.channel = channel + } +} + +/// A control command to apply to a local target. +enum LocalDeviceCommand: Sendable, Equatable { + case power(Bool) + /// Normalised 0.0…1.0. Transports scale to whatever the device expects. + case brightness(Double) +} + +/// A read-back snapshot of a target's controllable state. Fields are optional so +/// a relay (no brightness) and a light report through the same value type. +struct LocalDeviceReading: Sendable, Equatable { + var isOn: Bool? + var brightness: Double? + + init(isOn: Bool? = nil, brightness: Double? = nil) { + self.isOn = isOn + self.brightness = brightness + } +} + +protocol LocalDeviceTransport: Sendable { + /// Apply a single command to a target. Throws on a bad address or transport failure. + func apply(_ command: LocalDeviceCommand, to target: LocalTarget) async throws + + /// Read the current state of a target. + func read(from target: LocalTarget) async throws -> LocalDeviceReading +} diff --git a/Lumen/Integrations/LocalNetwork/LocalNetworkBridge.swift b/Lumen/Integrations/LocalNetwork/LocalNetworkBridge.swift new file mode 100644 index 0000000..0dd2391 --- /dev/null +++ b/Lumen/Integrations/LocalNetwork/LocalNetworkBridge.swift @@ -0,0 +1,107 @@ +import Foundation + +// MARK: - Local Network Bridge +// A second real SmartHomeBridge alongside HomeKit, for devices Apple Home can't +// see. It reaches user-configured LAN devices over their local HTTP APIs. Unlike +// HomeKit there is no OS authorization gate and (today) no push channel — local +// HTTP devices are polled/read on demand, so the state stream stays open for a +// future poller but does not emit unprompted. +// +// The bridge is intentionally driven by injected `[LocalDeviceConfig]` value +// types and a transport factory, so the whole integration is testable without a +// network. Persisting configs + a Settings surface to author them is the next +// step; this is the engine that surface will drive. + +actor LocalNetworkBridge: SmartHomeBridge { + + let id: BridgeID = .localNetwork + let displayName: String = "Local Network" + + private(set) var status: BridgeStatus = .idle + + private let configProvider: @Sendable () -> [LocalDeviceConfig] + private let transportFactory: @Sendable (LocalDeviceKind) -> any LocalDeviceTransport + + private var configsByID: [DeviceID: LocalDeviceConfig] = [:] + private var reachabilityByID: [DeviceID: DeviceReachability] = [:] + private var stateStreamContinuation: AsyncStream.Continuation? + + init( + configProvider: @escaping @Sendable () -> [LocalDeviceConfig], + transportFactory: @escaping @Sendable (LocalDeviceKind) -> any LocalDeviceTransport = { _ in ShellyGen2Transport() } + ) { + self.configProvider = configProvider + self.transportFactory = transportFactory + } + + /// No OS permission gate for LAN control — becomes authorized immediately. + func authorize() async throws { + status = .authorized + } + + func discover() async throws -> [any SmartDevice] { + guard status.isOperational else { + throw AppError.bridgeAuthorizationDenied(.localNetwork) + } + + var result: [LocalNetworkDevice] = [] + for config in configProvider() { + configsByID[config.id] = config + let transport = transportFactory(config.kind) + // Probe reachability without failing discovery: an offline device + // still lists, marked unreachable, exactly like HomeKit. + let reachable = (try? await transport.read(from: config.target)) != nil + let reachability: DeviceReachability = reachable ? .reachable : .unreachable + reachabilityByID[config.id] = reachability + result.append(LocalNetworkDevice(config: config, transport: transport, reachability: reachability)) + } + return result + } + + func deviceStateStream() -> AsyncStream { + AsyncStream { continuation in + self.stateStreamContinuation = continuation + continuation.onTermination = { [weak self] _ in + Task { await self?.clearStreamContinuation() } + } + } + } + + func device(withID id: DeviceID) async -> (any SmartDevice)? { + guard let config = configsByID[id] else { return nil } + let reachability = reachabilityByID[id] ?? .unknown + return LocalNetworkDevice( + config: config, + transport: transportFactory(config.kind), + reachability: reachability + ) + } + + func executeAction(_ action: SceneActionSnapshot) async throws { + guard let config = configsByID[action.deviceID] else { + throw AppError.deviceNotFound(action.deviceID) + } + let device = LocalNetworkDevice(config: config, transport: transportFactory(config.kind)) + try await device.execute(action) + // Local HTTP has no push; echo the change so the store refreshes state. + emitStateChange(DeviceStateChange(deviceID: action.deviceID, capabilityID: action.capabilityID)) + } + + func shutdown() async { + stateStreamContinuation?.finish() + stateStreamContinuation = nil + configsByID.removeAll() + reachabilityByID.removeAll() + status = .idle + } + + // MARK: - Private + + func emitStateChange(_ change: DeviceStateChange) { + stateStreamContinuation?.yield(change) + } + + private func clearStreamContinuation() { + stateStreamContinuation = nil + } +} diff --git a/Lumen/Integrations/LocalNetwork/LocalNetworkDevice.swift b/Lumen/Integrations/LocalNetwork/LocalNetworkDevice.swift new file mode 100644 index 0000000..1ec22ba --- /dev/null +++ b/Lumen/Integrations/LocalNetwork/LocalNetworkDevice.swift @@ -0,0 +1,172 @@ +import Foundation + +// MARK: - Local Device Configuration +// A value-type description of a local-network device the user has added. The +// bridge turns each config into a LocalNetworkDevice at discovery time. Kept +// deliberately plain (no SwiftData) so the integration layer is fully testable; +// persistence + a Settings surface to author these is the next step. + +/// The concrete kind of local device. Drives both the component the transport +/// talks to and the capability set the UI renders. +enum LocalDeviceKind: String, Sendable, Equatable, Codable, CaseIterable { + case shellySwitch // Shelly Gen2 Switch — on/off + case shellyDimmer // Shelly Gen2 Light — on/off + brightness + + var component: LocalComponent { + switch self { + case .shellySwitch: return .relay + case .shellyDimmer: return .light + } + } + + var displayName: String { + switch self { + case .shellySwitch: return "Shelly Switch" + case .shellyDimmer: return "Shelly Dimmer" + } + } +} + +struct LocalDeviceConfig: Sendable, Equatable, Identifiable { + let id: DeviceID + var displayName: String + var roomName: String? + var host: LocalHost + var kind: LocalDeviceKind + var channel: Int + var category: DeviceCategory + + init( + id: DeviceID = UUID(), + displayName: String, + roomName: String? = nil, + host: LocalHost, + kind: LocalDeviceKind, + channel: Int = 0, + category: DeviceCategory = .lighting + ) { + self.id = id + self.displayName = displayName + self.roomName = roomName + self.host = host + self.kind = kind + self.channel = channel + self.category = category + } + + var target: LocalTarget { + LocalTarget(host: host, component: kind.component, channel: channel) + } +} + +// MARK: - Local Network Device +// Wraps a LocalDeviceConfig as a SmartDevice. The transport never escapes into +// the rest of the app — like HomeKitDevice keeps HMAccessory private. + +struct LocalNetworkDevice: SmartDevice { + + let id: DeviceID + let displayName: String + let roomName: String? + let reachability: DeviceReachability + let bridgeID: BridgeID = .localNetwork + let category: DeviceCategory + let capabilities: [any DeviceCapability] + + private let config: LocalDeviceConfig + private let transport: any LocalDeviceTransport + + init(config: LocalDeviceConfig, transport: any LocalDeviceTransport, reachability: DeviceReachability = .unknown) { + self.id = config.id + self.displayName = config.displayName + self.roomName = config.roomName + self.reachability = reachability + self.category = config.category + self.config = config + self.transport = transport + self.capabilities = Self.buildCapabilities(config: config, transport: transport) + } + + // MARK: - Action Execution (called by LocalNetworkBridge) + + func execute(_ action: SceneActionSnapshot) async throws { + switch action.capabilityID { + case .onOff: + guard case .bool(let on) = action.payload else { return } + try await transport.apply(.power(on), to: config.target) + + case .brightness: + guard case .double(let level) = action.payload else { return } + try await transport.apply(.brightness(level), to: config.target) + + default: + throw AppError.capabilityNotSupported(action.capabilityID, deviceID: id) + } + } + + // MARK: - Capability Discovery + + private static func buildCapabilities( + config: LocalDeviceConfig, + transport: any LocalDeviceTransport + ) -> [any DeviceCapability] { + var caps: [any DeviceCapability] = [ + LocalNetworkOnOffCapability(target: config.target, transport: transport) + ] + if config.kind == .shellyDimmer { + caps.append(LocalNetworkBrightnessCapability(target: config.target, transport: transport)) + } + return caps + } +} + +// MARK: - Capabilities +// Each capability holds the (Sendable) transport plus its target and reads/writes +// state on demand — the local-HTTP analogue of the HomeKit capability structs. + +struct LocalNetworkOnOffCapability: OnOffCapability { + let capabilityID: CapabilityID = .onOff + let displayName = "Power" + let isReadOnly = false + + let target: LocalTarget + let transport: any LocalDeviceTransport + + var isOn: Bool { + get async { + (try? await transport.read(from: target).isOn) ?? false + } + } + + func setPower(_ on: Bool) async throws { + try await transport.apply(.power(on), to: target) + } + + func toggle() async throws { + try await setPower(!(await isOn)) + } +} + +struct LocalNetworkBrightnessCapability: BrightnessCapability { + let capabilityID: CapabilityID = .brightness + let displayName = "Brightness" + let isReadOnly = false + let brightnessRange: ClosedRange = 0.0...1.0 + + let target: LocalTarget + let transport: any LocalDeviceTransport + + var brightness: Double { + get async { + (try? await transport.read(from: target).brightness) ?? 0 + } + } + + func setBrightness(_ value: Double) async throws { + try await transport.apply(.brightness(value.clamped(to: 0.0...1.0)), to: target) + } +} + +extension BridgeID { + static let localNetwork = BridgeID("localNetwork") +} diff --git a/Lumen/Integrations/LocalNetwork/ShellyGen2Transport.swift b/Lumen/Integrations/LocalNetwork/ShellyGen2Transport.swift new file mode 100644 index 0000000..abfabd3 --- /dev/null +++ b/Lumen/Integrations/LocalNetwork/ShellyGen2Transport.swift @@ -0,0 +1,123 @@ +import Foundation + +// MARK: - Shelly Gen2 Transport +// Controls Shelly Gen2 devices over the local network via their HTTP RPC API — +// no cloud, no account. The URL-building and JSON-parsing are pure static +// helpers so they can be unit-tested without any networking, exactly like +// HTTPIRTransport. A device's RPC lives at `http:///rpc/`; we use +// the GET form with query parameters. +// +// Relay on: GET /rpc/Switch.Set?id=0&on=true +// Light dim: GET /rpc/Light.Set?id=0&brightness=40 +// Read a relay: GET /rpc/Switch.GetStatus?id=0 → { "output": true, … } +// Read a light: GET /rpc/Light.GetStatus?id=0 → { "output": true, "brightness": 40, … } + +struct ShellyGen2Transport: LocalDeviceTransport { + + private let session: URLSession + + init(session: URLSession = .shared) { + self.session = session + } + + func apply(_ command: LocalDeviceCommand, to target: LocalTarget) async throws { + let base = try Self.normalizedBaseURL(from: target.host.address) + guard let url = Self.setURL(base: base, target: target, command: command) else { + throw URLError(.unsupportedURL) // command not expressible on this component + } + let (_, response) = try await session.data(from: url) + try Self.validate(response) + } + + func read(from target: LocalTarget) async throws -> LocalDeviceReading { + let base = try Self.normalizedBaseURL(from: target.host.address) + let url = Self.statusURL(base: base, target: target) + let (data, response) = try await session.data(from: url) + try Self.validate(response) + return Self.parseReading(data: data) + } + + // MARK: - Pure Helpers (unit-tested) + + /// The Shelly RPC component name for a protocol-agnostic component. + static func rpcComponent(for component: LocalComponent) -> String { + switch component { + case .relay: return "Switch" + case .light: return "Light" + } + } + + /// Builds the RPC URL that applies `command` to `target`. Returns nil when the + /// command is not expressible on the target's component (e.g. brightness on a + /// plain relay) — callers surface that as an invalid request. + static func setURL(base: URL, target: LocalTarget, command: LocalDeviceCommand) -> URL? { + var query: [URLQueryItem] = [URLQueryItem(name: "id", value: String(target.channel))] + + switch command { + case .power(let on): + query.append(URLQueryItem(name: "on", value: on ? "true" : "false")) + case .brightness(let value): + guard target.component == .light else { return nil } + let percent = Int((value.clamped(to: 0.0...1.0) * 100).rounded()) + query.append(URLQueryItem(name: "brightness", value: String(percent))) + } + + return rpcURL(base: base, method: "\(rpcComponent(for: target.component)).Set", query: query) + } + + /// Builds the RPC URL that reads `target`'s status. + static func statusURL(base: URL, target: LocalTarget) -> URL { + rpcURL( + base: base, + method: "\(rpcComponent(for: target.component)).GetStatus", + query: [URLQueryItem(name: "id", value: String(target.channel))] + )! + } + + /// Parses a Shelly `*.GetStatus` JSON body into a reading. Unknown fields are + /// ignored; `output` maps to power, `brightness` (0–100) to 0.0–1.0. + static func parseReading(data: Data) -> LocalDeviceReading { + guard let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + return LocalDeviceReading() + } + let isOn = object["output"] as? Bool + var brightness: Double? + if let raw = object["brightness"] as? NSNumber { + brightness = (raw.doubleValue / 100.0).clamped(to: 0.0...1.0) + } + return LocalDeviceReading(isOn: isOn, brightness: brightness) + } + + /// Normalises a user-entered address into a base URL. Accepts a bare IP or + /// host, "host:port", or a full http(s) URL. Pure and side-effect free. + static func normalizedBaseURL(from address: String) throws -> URL { + let trimmed = address.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { throw AppError.invalidBridgeHostname(address) } + + let withScheme = trimmed.contains("://") ? trimmed : "http://\(trimmed)" + guard let url = URL(string: withScheme), + let scheme = url.scheme?.lowercased(), + scheme == "http" || scheme == "https", + let host = url.host, !host.isEmpty else { + throw AppError.invalidBridgeHostname(address) + } + return url + } + + // MARK: - Private + + private static func rpcURL(base: URL, method: String, query: [URLQueryItem]) -> URL? { + let endpoint = base.appending(path: "rpc").appending(path: method) + guard var components = URLComponents(url: endpoint, resolvingAgainstBaseURL: false) else { + return nil + } + components.queryItems = query + return components.url + } + + private static func validate(_ response: URLResponse) throws { + if let http = response as? HTTPURLResponse, !(200..<300).contains(http.statusCode) { + throw URLError(.badServerResponse) + } + } +} diff --git a/LumenTests/LocalNetworkTests.swift b/LumenTests/LocalNetworkTests.swift new file mode 100644 index 0000000..8350dcf --- /dev/null +++ b/LumenTests/LocalNetworkTests.swift @@ -0,0 +1,243 @@ +import XCTest +@testable import Lumen + +final class LocalNetworkTests: XCTestCase { + + // MARK: - Pure Shelly codec + + func testNormalizedBaseURLAcceptsBareHostPortAndURL() throws { + XCTAssertEqual(try ShellyGen2Transport.normalizedBaseURL(from: "192.168.1.50").absoluteString, "http://192.168.1.50") + XCTAssertEqual(try ShellyGen2Transport.normalizedBaseURL(from: "shelly.local:8080").absoluteString, "http://shelly.local:8080") + XCTAssertEqual(try ShellyGen2Transport.normalizedBaseURL(from: "https://shelly.local").absoluteString, "https://shelly.local") + } + + func testNormalizedBaseURLRejectsEmpty() { + XCTAssertThrowsError(try ShellyGen2Transport.normalizedBaseURL(from: " ")) { error in + guard case AppError.invalidBridgeHostname = error else { + return XCTFail("expected invalidBridgeHostname, got \(error)") + } + } + } + + func testRelayPowerURL() throws { + let base = try ShellyGen2Transport.normalizedBaseURL(from: "10.0.0.5") + let target = LocalTarget(host: LocalHost(address: "10.0.0.5"), component: .relay, channel: 0) + let url = try XCTUnwrap(ShellyGen2Transport.setURL(base: base, target: target, command: .power(true))) + XCTAssertEqual(url.path, "/rpc/Switch.Set") + XCTAssertEqual(Self.query(url), ["id": "0", "on": "true"]) + } + + func testLightBrightnessURLScalesToPercent() throws { + let base = try ShellyGen2Transport.normalizedBaseURL(from: "10.0.0.5") + let target = LocalTarget(host: LocalHost(address: "10.0.0.5"), component: .light, channel: 2) + let url = try XCTUnwrap(ShellyGen2Transport.setURL(base: base, target: target, command: .brightness(0.4))) + XCTAssertEqual(url.path, "/rpc/Light.Set") + XCTAssertEqual(Self.query(url), ["id": "2", "brightness": "40"]) + } + + func testBrightnessOnRelayIsUnrepresentable() throws { + let base = try ShellyGen2Transport.normalizedBaseURL(from: "10.0.0.5") + let target = LocalTarget(host: LocalHost(address: "10.0.0.5"), component: .relay) + XCTAssertNil(ShellyGen2Transport.setURL(base: base, target: target, command: .brightness(0.5))) + } + + func testStatusURLPerComponent() throws { + let base = try ShellyGen2Transport.normalizedBaseURL(from: "10.0.0.5") + let relay = LocalTarget(host: LocalHost(address: "10.0.0.5"), component: .relay, channel: 1) + let light = LocalTarget(host: LocalHost(address: "10.0.0.5"), component: .light, channel: 0) + XCTAssertEqual(ShellyGen2Transport.statusURL(base: base, target: relay).path, "/rpc/Switch.GetStatus") + XCTAssertEqual(Self.query(ShellyGen2Transport.statusURL(base: base, target: relay)), ["id": "1"]) + XCTAssertEqual(ShellyGen2Transport.statusURL(base: base, target: light).path, "/rpc/Light.GetStatus") + } + + func testParseReading() { + let light = ShellyGen2Transport.parseReading(data: Data(#"{"id":0,"output":true,"brightness":75}"#.utf8)) + XCTAssertEqual(light.isOn, true) + XCTAssertEqual(light.brightness ?? 0, 0.75, accuracy: 0.0001) + + let relay = ShellyGen2Transport.parseReading(data: Data(#"{"id":0,"output":false}"#.utf8)) + XCTAssertEqual(relay.isOn, false) + XCTAssertNil(relay.brightness) + + let garbage = ShellyGen2Transport.parseReading(data: Data("not json".utf8)) + XCTAssertNil(garbage.isOn) + XCTAssertNil(garbage.brightness) + } + + // MARK: - Device capabilities + + func testDimmerExposesOnOffAndBrightnessSwitchOnlyOnOff() { + let transport = FakeLocalTransport() + let dimmer = LocalNetworkDevice(config: Self.dimmerConfig, transport: transport) + let toggle = LocalNetworkDevice(config: Self.switchConfig, transport: transport) + + XCTAssertTrue(dimmer.supports(.onOff)) + XCTAssertTrue(dimmer.supports(.brightness)) + XCTAssertTrue(toggle.supports(.onOff)) + XCTAssertFalse(toggle.supports(.brightness)) + } + + func testOnOffCapabilityReadsAndWritesThroughTransport() async throws { + let transport = FakeLocalTransport(reading: LocalDeviceReading(isOn: true)) + let device = LocalNetworkDevice(config: Self.switchConfig, transport: transport) + let onOff = try XCTUnwrap(device.capability(of: LocalNetworkOnOffCapability.self)) + + let value = await onOff.isOn + XCTAssertTrue(value) + + try await onOff.setPower(false) + let applied = await transport.applied + XCTAssertEqual(applied.map { $0.0 }, [.power(false)]) + XCTAssertEqual(applied.first?.1.component, .relay) + } + + func testBrightnessCapabilityScalesRead() async throws { + let transport = FakeLocalTransport(reading: LocalDeviceReading(isOn: true, brightness: 0.5)) + let device = LocalNetworkDevice(config: Self.dimmerConfig, transport: transport) + let brightness = try XCTUnwrap(device.capability(of: LocalNetworkBrightnessCapability.self)) + + let value = await brightness.brightness + XCTAssertEqual(value, 0.5, accuracy: 0.0001) + + try await brightness.setBrightness(0.3) + let applied = await transport.applied + XCTAssertEqual(applied.map { $0.0 }, [.brightness(0.3)]) + } + + func testExecuteRoutesSnapshotToTransport() async throws { + let transport = FakeLocalTransport() + let device = LocalNetworkDevice(config: Self.dimmerConfig, transport: transport) + + try await device.execute(SceneActionSnapshot(deviceID: device.id, capabilityID: .onOff, payload: .bool(true))) + try await device.execute(SceneActionSnapshot(deviceID: device.id, capabilityID: .brightness, payload: .double(0.8))) + + let applied = await transport.applied + XCTAssertEqual(applied.map { $0.0 }, [.power(true), .brightness(0.8)]) + } + + func testExecuteRejectsUnsupportedCapability() async { + let device = LocalNetworkDevice(config: Self.switchConfig, transport: FakeLocalTransport()) + do { + try await device.execute(SceneActionSnapshot(deviceID: device.id, capabilityID: .lock, payload: .lockState(.locked))) + XCTFail("expected throw") + } catch { + guard case AppError.capabilityNotSupported = error else { + return XCTFail("expected capabilityNotSupported, got \(error)") + } + } + } + + // MARK: - Bridge flow + + func testBridgeDiscoversConfiguredDevices() async throws { + let transport = FakeLocalTransport(reading: LocalDeviceReading(isOn: false)) + let bridge = Self.bridge(configs: [Self.switchConfig, Self.dimmerConfig], transport: transport) + + try await bridge.authorize() + let devices = try await bridge.discover() + + XCTAssertEqual(Set(devices.map(\.id)), [Self.switchConfig.id, Self.dimmerConfig.id]) + XCTAssertTrue(devices.allSatisfy { $0.bridgeID == .localNetwork }) + XCTAssertTrue(devices.allSatisfy { $0.reachability == .reachable }) + } + + func testBridgeMarksUnreachableWhenProbeFails() async throws { + let transport = FakeLocalTransport(failReads: true) + let bridge = Self.bridge(configs: [Self.switchConfig], transport: transport) + + try await bridge.authorize() + let devices = try await bridge.discover() + XCTAssertEqual(devices.first?.reachability, .unreachable) + } + + func testBridgeExecuteActionRoutesAndEmits() async throws { + let transport = FakeLocalTransport() + let bridge = Self.bridge(configs: [Self.dimmerConfig], transport: transport) + try await bridge.authorize() + _ = try await bridge.discover() + + let stream = await bridge.deviceStateStream() + try await bridge.executeAction( + SceneActionSnapshot(deviceID: Self.dimmerConfig.id, capabilityID: .onOff, payload: .bool(true)) + ) + + let applied = await transport.applied + XCTAssertEqual(applied.map { $0.0 }, [.power(true)]) + + var iterator = stream.makeAsyncIterator() + let change = await iterator.next() + XCTAssertEqual(change?.deviceID, Self.dimmerConfig.id) + XCTAssertEqual(change?.capabilityID, .onOff) + } + + func testBridgeExecuteUnknownDeviceThrows() async throws { + let bridge = Self.bridge(configs: [], transport: FakeLocalTransport()) + try await bridge.authorize() + do { + try await bridge.executeAction( + SceneActionSnapshot(deviceID: UUID(), capabilityID: .onOff, payload: .bool(true)) + ) + XCTFail("expected throw") + } catch { + guard case AppError.deviceNotFound = error else { + return XCTFail("expected deviceNotFound, got \(error)") + } + } + } + + func testBridgeDeviceLookup() async throws { + let bridge = Self.bridge(configs: [Self.switchConfig], transport: FakeLocalTransport()) + try await bridge.authorize() + _ = try await bridge.discover() + + let found = await bridge.device(withID: Self.switchConfig.id) + XCTAssertEqual(found?.displayName, Self.switchConfig.displayName) + let missing = await bridge.device(withID: UUID()) + XCTAssertNil(missing) + } + + // MARK: - Fixtures + + static let switchConfig = LocalDeviceConfig( + displayName: "Porch Relay", + host: LocalHost(address: "10.0.0.5"), + kind: .shellySwitch + ) + + static let dimmerConfig = LocalDeviceConfig( + displayName: "Hallway Dimmer", + host: LocalHost(address: "10.0.0.6"), + kind: .shellyDimmer + ) + + static func bridge(configs: [LocalDeviceConfig], transport: FakeLocalTransport) -> LocalNetworkBridge { + LocalNetworkBridge(configProvider: { configs }, transportFactory: { _ in transport }) + } + + static func query(_ url: URL) -> [String: String] { + let items = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems ?? [] + return Dictionary(uniqueKeysWithValues: items.map { ($0.name, $0.value ?? "") }) + } +} + +// MARK: - Fake Local Transport + +private actor FakeLocalTransport: LocalDeviceTransport { + private let reading: LocalDeviceReading + private let failReads: Bool + private(set) var applied: [(LocalDeviceCommand, LocalTarget)] = [] + + init(reading: LocalDeviceReading = LocalDeviceReading(), failReads: Bool = false) { + self.reading = reading + self.failReads = failReads + } + + func apply(_ command: LocalDeviceCommand, to target: LocalTarget) async throws { + applied.append((command, target)) + } + + func read(from target: LocalTarget) async throws -> LocalDeviceReading { + if failReads { throw URLError(.cannotConnectToHost) } + return reading + } +} diff --git a/docs/competitive-feature-scope.md b/docs/competitive-feature-scope.md index a6c3c7c..94f580a 100644 --- a/docs/competitive-feature-scope.md +++ b/docs/competitive-feature-scope.md @@ -29,6 +29,13 @@ 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`. + **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 single most common "I can't switch to your app" objection Homebridge exists to solve.