Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/`)

Expand Down Expand Up @@ -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 |

Expand Down
13 changes: 13 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/`)

Expand Down Expand Up @@ -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 |

Expand Down
69 changes: 69 additions & 0 deletions Lumen/Integrations/LocalNetwork/LocalDeviceTransport.swift
Original file line number Diff line number Diff line change
@@ -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
}
107 changes: 107 additions & 0 deletions Lumen/Integrations/LocalNetwork/LocalNetworkBridge.swift
Original file line number Diff line number Diff line change
@@ -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<DeviceStateChange>.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<DeviceStateChange> {
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
}
}
Loading
Loading