From 19bc01e95f070f6b5ad9bb0c801128433ff5650e Mon Sep 17 00:00:00 2001 From: Julian Kahnert Date: Sat, 3 Oct 2026 20:17:53 +0200 Subject: [PATCH 01/22] Store the server config in the database The config moves from /tmp/HomeAutomation-config.json into a single-row `config` table, imported once from the legacy file. Activating or deactivating an automation now persists, so the state survives restarts and container recreation. Retention lives in DatabaseCleanupJob only: MaintenanceAutomation no longer deletes entity history older than two days. --- Sources/HAApplicationLayer/HomeManager.swift | 6 --- .../Automations/MaintenanceAutomation.swift | 8 ++- Sources/HAModels/HomeManagable.swift | 1 - .../HomeAutomationConfigService.swift | 51 ++++++++++++------- .../Controllers/OpenAPIController.swift | 4 +- .../Migrations/04_CreateConfigItem.swift | 21 ++++++++ Sources/Server/Models/ConfigItem.swift | 33 ++++++++++++ Sources/Server/configure.swift | 3 +- .../MockHomeAdapter.swift | 3 -- .../AuthenticationIntegrationTests.swift | 2 +- .../HomeAutomationConfigServiceTests.swift | 29 +++++++++++ 11 files changed, 123 insertions(+), 38 deletions(-) create mode 100644 Sources/Server/Migrations/04_CreateConfigItem.swift create mode 100644 Sources/Server/Models/ConfigItem.swift create mode 100644 Tests/ServerTests/HomeAutomationConfigServiceTests.swift diff --git a/Sources/HAApplicationLayer/HomeManager.swift b/Sources/HAApplicationLayer/HomeManager.swift index 5628a654..d0ac9ce6 100644 --- a/Sources/HAApplicationLayer/HomeManager.swift +++ b/Sources/HAApplicationLayer/HomeManager.swift @@ -234,12 +234,6 @@ public final class HomeManager: HomeManagable { return didChange } - public func maintenance() async throws { - // delete storage entries older than 2 days - let date = Date().addingTimeInterval(-1 * 2 * 24 * 60 * 60) - try await storageRepo.deleteEntries(olderThan: date) - } - public func deleteStorageEntries(olderThan date: Date) async throws { try await storageRepo.deleteEntries(olderThan: date) } diff --git a/Sources/HAImplementations/Automations/MaintenanceAutomation.swift b/Sources/HAImplementations/Automations/MaintenanceAutomation.swift index 205d26bc..1e1983d0 100644 --- a/Sources/HAImplementations/Automations/MaintenanceAutomation.swift +++ b/Sources/HAImplementations/Automations/MaintenanceAutomation.swift @@ -23,9 +23,7 @@ public struct MaintenanceAutomation: Automatable { return time.isEqual(event) } - public func execute(using hm: HomeManagable) async throws { - log.debug("Turning on devices") - - try await hm.maintenance() - } + // Retention lives in the server's `DatabaseCleanupJob` only; the type stays so existing + // configs still decode. + public func execute(using hm: HomeManagable) async throws {} } diff --git a/Sources/HAModels/HomeManagable.swift b/Sources/HAModels/HomeManagable.swift index 1dff7b4b..96326ef5 100644 --- a/Sources/HAModels/HomeManagable.swift +++ b/Sources/HAModels/HomeManagable.swift @@ -28,7 +28,6 @@ public protocol HomeManagable: EntityValidator, Sendable { func perform(_ action: HomeManagableAction) async func trigger(scene sceneName: String) async - func maintenance() async throws func deleteStorageEntries(olderThan date: Date) async throws func getLocation() async -> Location func sendNotification(title: String, message: String, id: String) async diff --git a/Sources/Server/Controllers/HomeAutomationConfigService.swift b/Sources/Server/Controllers/HomeAutomationConfigService.swift index e8b7f316..6243ea2f 100644 --- a/Sources/Server/Controllers/HomeAutomationConfigService.swift +++ b/Sources/Server/Controllers/HomeAutomationConfigService.swift @@ -5,6 +5,7 @@ // Created by Julian Kahnert on 14.02.25. // +import Fluent import Foundation import HAImplementations import HAModels @@ -12,50 +13,62 @@ import Logging import Shared actor HomeAutomationConfigService: Log { - static let url = URL(fileURLWithPath: "/tmp/HomeAutomation-config.json") + private static let defaultLocation = Location(latitude: 53.14194, longitude: 8.21292) + // TODO: remove the one-time import one release after the config moved into the database + private static let legacyFileURL = URL(fileURLWithPath: "/tmp/HomeAutomation-config.json") + private(set) var location: Location private(set) var automations: [any Automatable] + private let persist: @Sendable (Data) async throws -> Void - init(location: Location, automations: [any Automatable]) { + init(location: Location, automations: [any Automatable], persist: @escaping @Sendable (Data) async throws -> Void) { self.location = location self.automations = automations + self.persist = persist } - func set(location: Location, automations: [any Automatable]) throws { + func set(location: Location, automations: [any Automatable]) async throws { self.location = location self.automations = automations - try save() + try await save() } - func setAutomationActive(with name: String, to value: Bool) { - let automations = self.automations.map { automation in + func setAutomationActive(with name: String, to value: Bool) async throws { + automations = automations.map { automation in var automation = automation if automation.name == name { automation.isActive = value } return automation } - self.automations = automations + try await save() } - func save() throws { - let automations = automations.map(AnyAutomation.create(from:)) - let configDto = ConfigDTO(location: location, automations: automations) - let data = try JSONEncoder().encode(configDto) - try data.write(to: Self.url) + func save() async throws { + let configDto = ConfigDTO(location: location, automations: automations.map(AnyAutomation.create(from:))) + try await persist(JSONEncoder().encode(configDto)) } - static func loadOrDefault() -> Self { + static func load(from database: any Database) async -> Self { + let persist: @Sendable (Data) async throws -> Void = { try await ConfigItem.save(json: $0, on: database) } do { - let data = try Data(contentsOf: url) + var data = try await ConfigItem.loadJSON(on: database) + if data == nil, FileManager.default.fileExists(atPath: legacyFileURL.path) { + log.notice("Importing legacy config file into the database") + let legacyData = try Data(contentsOf: legacyFileURL) + try await persist(legacyData) + data = legacyData + } + guard let data else { + log.info("No config stored yet - falling back to default config") + return Self(location: defaultLocation, automations: [], persist: persist) + } let config = try JSONDecoder().decode(ConfigDTO.self, from: data) - let automations = config.automations.map(\.automation) - return Self(location: config.location, automations: automations) + return Self(location: config.location, automations: config.automations.map(\.automation), persist: persist) } catch { - log.error("Failed to parse config file - falling back to defaults: \(error)") - log.info("Falling back to default config: \(Location(latitude: 53.14194, longitude: 8.21292)) - automations: []") - return .init(location: Location(latitude: 53.14194, longitude: 8.21292), automations: []) + log.error("Failed to load config - falling back to default config: \(error)") + return Self(location: defaultLocation, automations: [], persist: persist) } } } diff --git a/Sources/Server/Controllers/OpenAPIController.swift b/Sources/Server/Controllers/OpenAPIController.swift index 10835cb8..de591779 100644 --- a/Sources/Server/Controllers/OpenAPIController.swift +++ b/Sources/Server/Controllers/OpenAPIController.swift @@ -33,7 +33,7 @@ struct OpenAPIController: APIProtocol { throw Abort(.notFound, reason: "Automation name not provided") } - await request.application.homeAutomationConfigService.setAutomationActive(with: name, to: true) + try await request.application.homeAutomationConfigService.setAutomationActive(with: name, to: true) return .ok } @@ -43,7 +43,7 @@ struct OpenAPIController: APIProtocol { throw Abort(.notFound, reason: "Automation name not provided") } - await request.application.homeAutomationConfigService.setAutomationActive(with: name, to: false) + try await request.application.homeAutomationConfigService.setAutomationActive(with: name, to: false) return .ok } diff --git a/Sources/Server/Migrations/04_CreateConfigItem.swift b/Sources/Server/Migrations/04_CreateConfigItem.swift new file mode 100644 index 00000000..504cbb68 --- /dev/null +++ b/Sources/Server/Migrations/04_CreateConfigItem.swift @@ -0,0 +1,21 @@ +// +// 04_CreateConfigItem.swift +// HomeAutomationServer +// + +import Fluent + +struct CreateConfigItem: AsyncMigration { + func prepare(on database: Database) async throws { + try await database.schema(ConfigItem.schema) + .id() + // `.data` maps to MySQL BLOB, capped at 64 KB; configs may be larger. + .field("json", .custom("LONGBLOB"), .required) + .field("updatedAt", .datetime) + .create() + } + + func revert(on database: Database) async throws { + try await database.schema(ConfigItem.schema).delete() + } +} diff --git a/Sources/Server/Models/ConfigItem.swift b/Sources/Server/Models/ConfigItem.swift new file mode 100644 index 00000000..065b4b83 --- /dev/null +++ b/Sources/Server/Models/ConfigItem.swift @@ -0,0 +1,33 @@ +// +// ConfigItem.swift +// HomeAutomationServer +// + +import Fluent +import Foundation + +/// The server config (`ConfigDTO`) as one JSON blob — the table holds a single row. +final class ConfigItem: Model, @unchecked Sendable { + static let schema = "config" + + @ID(key: .id) + var id: UUID? + + @Field(key: "json") + var json: Data + + @Timestamp(key: "updatedAt", on: .update) + var updatedAt: Date? + + init() { } + + static func loadJSON(on database: any Database) async throws -> Data? { + try await query(on: database).first()?.json + } + + static func save(json: Data, on database: any Database) async throws { + let item = try await query(on: database).first() ?? ConfigItem() + item.json = json + try await item.save(on: database) + } +} diff --git a/Sources/Server/configure.swift b/Sources/Server/configure.swift index 64d7d42f..fd605fb5 100644 --- a/Sources/Server/configure.swift +++ b/Sources/Server/configure.swift @@ -112,6 +112,7 @@ public func configure(_ app: Application) async throws { app.migrations.add(CreateEntityStorageDbItem()) app.migrations.add(DeviceTokenItem()) app.migrations.add(AddSensorFields()) + app.migrations.add(CreateConfigItem()) // Run migrations automatically try await app.autoMigrate() @@ -208,7 +209,7 @@ public func configure(_ app: Application) async throws { // MARK: - home automation setup - app.homeAutomationConfigService = HomeAutomationConfigService.loadOrDefault() + app.homeAutomationConfigService = await HomeAutomationConfigService.load(from: app.db) let notificationSender = PushNotifcationService(database: app.db, apnsClient: apnsClient, notificationTopic: notificationTopic) diff --git a/Tests/HomeAutomationKitTests/MockHomeAdapter.swift b/Tests/HomeAutomationKitTests/MockHomeAdapter.swift index 185509f9..7065ca20 100644 --- a/Tests/HomeAutomationKitTests/MockHomeAdapter.swift +++ b/Tests/HomeAutomationKitTests/MockHomeAdapter.swift @@ -134,9 +134,6 @@ final class MockHomeAdapter: @unchecked Sendable, HomeManagable { return didChange } - func maintenance() async throws { - } - func deleteStorageEntries(olderThan date: Date) async throws { } diff --git a/Tests/ServerTests/AuthenticationIntegrationTests.swift b/Tests/ServerTests/AuthenticationIntegrationTests.swift index e297bc8c..40710c0a 100644 --- a/Tests/ServerTests/AuthenticationIntegrationTests.swift +++ b/Tests/ServerTests/AuthenticationIntegrationTests.swift @@ -27,7 +27,7 @@ final class AuthenticationIntegrationTests: XCTestCase { // Mock homeAutomationConfigService for testing let mockLocation = Location(latitude: 52.52, longitude: 13.405) - app.homeAutomationConfigService = HomeAutomationConfigService(location: mockLocation, automations: []) + app.homeAutomationConfigService = HomeAutomationConfigService(location: mockLocation, automations: [], persist: { _ in }) // The WebSocket route registration needs an actor system present app.starActorSystem = StarActorSystem(name: "server-tests") diff --git a/Tests/ServerTests/HomeAutomationConfigServiceTests.swift b/Tests/ServerTests/HomeAutomationConfigServiceTests.swift new file mode 100644 index 00000000..0d8f554b --- /dev/null +++ b/Tests/ServerTests/HomeAutomationConfigServiceTests.swift @@ -0,0 +1,29 @@ +// +// HomeAutomationConfigServiceTests.swift +// HomeAutomation +// + +import Foundation +import HAImplementations +import HAModels +@testable import Server +import Synchronization +import Testing + +struct HomeAutomationConfigServiceTests { + @Test func setAutomationActivePersistsTheNewState() async throws { + let persisted = Mutex(nil) + let service = HomeAutomationConfigService( + location: Location(latitude: 1, longitude: 2), + automations: [MaintenanceAutomation("Maintenance", at: Time(hour: 3, minute: 0))], + persist: { data in persisted.withLock { $0 = data } } + ) + + try await service.setAutomationActive(with: "Maintenance", to: false) + + let data = try #require(persisted.withLock { $0 }) + let config = try JSONDecoder().decode(ConfigDTO.self, from: data) + #expect(config.location.latitude == 1) + #expect(config.automations.map(\.automation.isActive) == [false]) + } +} From efbebef79baa66f515f553e8b66f88b239a860bb Mon Sep 17 00:00:00 2001 From: Julian Kahnert Date: Sat, 3 Oct 2026 20:23:28 +0200 Subject: [PATCH 02/22] Record automation runs with their trigger Every triggered automation now gets an AutomationRun row: what triggered it (derived from the HomeEvent, optionally enriched by the automation), when it started and ended, and whether it completed, was superseded, stopped, failed or interrupted by a server restart. HealthCheck and MaintenanceAutomation opt out via `recordsRuns`; MotionAtNight appends the illuminance that let it trigger. The API exposes GET /config/automations/{name}/runs and GET /runs, and the automation list gains `type`, `recordsRuns`, `lastRun` and `entities`. Runs are deleted after 30 days. --- .../Extensions/EntityHistoryItem+Chart.swift | 54 ------- .../AutomationService.swift | 52 ++++++- .../Automations/HealthCheck.swift | 1 + .../Automations/MaintenanceAutomation.swift | 1 + .../Automations/MotionAtNight.swift | 10 ++ Sources/HAModels/Automation/Automatable.swift | 11 ++ .../HAModels/Automation/AutomationInfo.swift | 11 +- .../HAModels/Automation/AutomationRun.swift | 93 ++++++++++++ .../Automation/AutomationRunContext.swift | 11 ++ Sources/HAModels/EntityHistoryItem.swift | 79 +++++++++++ Sources/Server/AppStorage.swift | 4 + .../Controllers/OpenAPIController.swift | 44 +++++- Sources/Server/Jobs/DatabaseCleanupJob.swift | 3 + .../Migrations/05_CreateAutomationRun.swift | 29 ++++ .../Server/Models/AutomationRunDbItem.swift | 85 +++++++++++ .../Models/AutomationRunDbRepository.swift | 72 ++++++++++ Sources/Server/configure.swift | 6 +- Sources/ServerClient/ServerClient.swift | 51 ++++++- .../AutomationServiceTests.swift | 129 +++++++++++++++++ .../HomeEventTriggerTests.swift | 33 +++++ .../InMemoryAutomationRunRepository.swift | 53 +++++++ .../MotionAtNightTriggerSummaryTests.swift | 25 ++++ .../AutomationRunMappingTests.swift | 46 ++++++ openapi.yaml | 133 ++++++++++++++++++ 24 files changed, 972 insertions(+), 64 deletions(-) create mode 100644 Sources/HAModels/Automation/AutomationRun.swift create mode 100644 Sources/HAModels/Automation/AutomationRunContext.swift create mode 100644 Sources/Server/Migrations/05_CreateAutomationRun.swift create mode 100644 Sources/Server/Models/AutomationRunDbItem.swift create mode 100644 Sources/Server/Models/AutomationRunDbRepository.swift create mode 100644 Tests/HomeAutomationKitTests/AutomationServiceTests.swift create mode 100644 Tests/HomeAutomationKitTests/HomeEventTriggerTests.swift create mode 100644 Tests/HomeAutomationKitTests/InMemoryAutomationRunRepository.swift create mode 100644 Tests/HomeAutomationKitTests/RealworldAutomationTests/MotionAtNightTriggerSummaryTests.swift create mode 100644 Tests/ServerTests/AutomationRunMappingTests.swift diff --git a/Sources/Controller/Extensions/EntityHistoryItem+Chart.swift b/Sources/Controller/Extensions/EntityHistoryItem+Chart.swift index 5a4dabc8..7a828a3e 100644 --- a/Sources/Controller/Extensions/EntityHistoryItem+Chart.swift +++ b/Sources/Controller/Extensions/EntityHistoryItem+Chart.swift @@ -97,58 +97,4 @@ extension EntityHistoryItem { } return nil } - - /// Human-readable description of the primary value - public var valueDescription: String { - if let temperatureInC { - return "\(String(format: "%.1f", temperatureInC))°C" - } - if let relativeHumidity { - return "\(String(format: "%.1f", relativeHumidity))%" - } - if let carbonDioxideSensorId { - return "\(carbonDioxideSensorId) ppm" - } - if let airQuality { - return "AQI: \(airQuality)" - } - if let pmDensity { - return "\(String(format: "%.1f", pmDensity)) µg/m³" - } - if let illuminanceInLux { - return "\(String(format: "%.1f", illuminanceInLux)) lux" - } - if let brightness { - return "\(brightness)%" - } - if let stateOfCharge { - return "\(stateOfCharge)%" - } - if let colorTemperature { - return "CT: \(String(format: "%.2f", colorTemperature))" - } - if let isDeviceOn { - return isDeviceOn ? "On" : "Off" - } - if let motionDetected { - return motionDetected ? "Motion" : "No Motion" - } - if let isContactOpen { - return isContactOpen ? "Open" : "Closed" - } - if let isDoorLocked { - return isDoorLocked ? "Locked" : "Unlocked" - } - if let isHeaterActive { - return isHeaterActive ? "Active" : "Inactive" - } - if let valveOpen { - return valveOpen ? "Open" : "Closed" - } - // Color as hue - if let hue { - return "\(String(format: "%.0f", hue))° hue" - } - return "No data" - } } diff --git a/Sources/HAApplicationLayer/AutomationService.swift b/Sources/HAApplicationLayer/AutomationService.swift index 4be67aca..278adf8f 100644 --- a/Sources/HAApplicationLayer/AutomationService.swift +++ b/Sources/HAApplicationLayer/AutomationService.swift @@ -5,17 +5,23 @@ // Created by Julian Kahnert on 01.07.24. // +import Foundation import HAModels import Logging public actor AutomationService { private let log = Logger(label: "AutomationManager") private let homeManager: HomeManagable + private let runs: AutomationRunRepository private let getAutomations: () async -> [any Automatable] private var runningTasks: [String: Task] = [:] + /// Names whose current task was cancelled by `stopAutomation`, so its run ends as `stopped` + /// rather than `superseded`. + private var stopRequested: Set = [] - public init(using homeManager: HomeManagable, getAutomations: @escaping () async -> [any Automatable]) throws { + public init(using homeManager: HomeManagable, runs: AutomationRunRepository, getAutomations: @escaping () async -> [any Automatable]) throws { self.homeManager = homeManager + self.runs = runs self.getAutomations = getAutomations } @@ -31,13 +37,19 @@ public actor AutomationService { } self.log.info("Running automation \(automation.name)") + let run = await self.startRun(of: automation, for: event) let task = Task { do { - try await automation.execute(using: self.homeManager) + try await AutomationRunContext.$runId.withValue(run?.id) { + try await automation.execute(using: self.homeManager) + } + await self.finish(run, outcome: .completed) } catch is CancellationError { - // do not throw anything when an automation was cancelled + let stopped = await self.consumeStopRequest(for: automation.name) + await self.finish(run, outcome: stopped ? .stopped : .superseded) } catch { self.log.error("Automation failed with error - \(error)") + await self.finish(run, outcome: .failed, error: String(describing: error)) } // cancel the current task after completion to get correct results of getActiveAutomationNames @@ -65,7 +77,9 @@ public actor AutomationService { public func stopAutomation(with name: String) async { log.debug("Cancel automation \(name)") - runningTasks[name]?.cancel() + guard let task = runningTasks[name], !task.isCancelled else { return } + stopRequested.insert(name) + task.cancel() } private func set(task: Task, with id: String) { @@ -76,4 +90,34 @@ public actor AutomationService { runningTasks[id] = task } + + private func consumeStopRequest(for name: String) -> Bool { + stopRequested.remove(name) != nil + } + + private func startRun(of automation: any Automatable, for event: HomeEvent) async -> AutomationRun? { + guard automation.recordsRuns else { return nil } + + var trigger = event.trigger + if let detail = await automation.triggerSummary(for: event, using: homeManager) { + trigger = trigger.appending(detail) + } + let run = AutomationRun(automationName: automation.name, startedAt: Date(), trigger: trigger, outcome: .running) + do { + try await runs.add(run) + return run + } catch { + log.error("Failed to record automation run - \(error)") + return nil + } + } + + private func finish(_ run: AutomationRun?, outcome: AutomationRun.Outcome, error: String? = nil) async { + guard let run else { return } + do { + try await runs.finish(run.id, outcome: outcome, endedAt: Date(), errorDescription: error) + } catch { + log.error("Failed to finish automation run - \(error)") + } + } } diff --git a/Sources/HAImplementations/Automations/HealthCheck.swift b/Sources/HAImplementations/Automations/HealthCheck.swift index 18ce70cb..b0802158 100644 --- a/Sources/HAImplementations/Automations/HealthCheck.swift +++ b/Sources/HAImplementations/Automations/HealthCheck.swift @@ -16,6 +16,7 @@ public struct HealthCheck: Automatable { public let name: String public let url: URL public var triggerEntityIds = Set() + public var recordsRuns: Bool { false } public init(_ name: String, url: URL) { self.name = name diff --git a/Sources/HAImplementations/Automations/MaintenanceAutomation.swift b/Sources/HAImplementations/Automations/MaintenanceAutomation.swift index 1e1983d0..2ee3b7f5 100644 --- a/Sources/HAImplementations/Automations/MaintenanceAutomation.swift +++ b/Sources/HAImplementations/Automations/MaintenanceAutomation.swift @@ -13,6 +13,7 @@ public struct MaintenanceAutomation: Automatable { public let name: String public private(set) var triggerEntityIds = Set() public let time: Time + public var recordsRuns: Bool { false } public init(_ name: String, at time: Time) { self.name = name diff --git a/Sources/HAImplementations/Automations/MotionAtNight.swift b/Sources/HAImplementations/Automations/MotionAtNight.swift index c6ea25e6..a942bebe 100644 --- a/Sources/HAImplementations/Automations/MotionAtNight.swift +++ b/Sources/HAImplementations/Automations/MotionAtNight.swift @@ -80,6 +80,16 @@ public struct MotionAtNight: Automatable { return shouldTrigger } + public func triggerSummary(for event: HomeEvent, using hm: HomeManagable) async -> String? { + do { + let illuminance = try await lightSensor.illuminanceState(with: hm) + return "at \(Int(illuminance.converted(to: .lux).value.rounded())) lx" + } catch { + log.warning("Failed to get illuminance for the trigger summary - \(error)") + return nil + } + } + /// Window contacts only matter after dark — a lit room with an open window draws insects, /// which is not a concern in daylight. Respected unless the sun position is unavailable, so /// an unknown sun state never silently switches insect protection off. diff --git a/Sources/HAModels/Automation/Automatable.swift b/Sources/HAModels/Automation/Automatable.swift index d4235609..ac5422c9 100644 --- a/Sources/HAModels/Automation/Automatable.swift +++ b/Sources/HAModels/Automation/Automatable.swift @@ -37,9 +37,20 @@ public protocol Automatable: Sendable, Codable { /// - Parameter hm: An instance of HomeManagable providing the current state or context of the home system. /// - Throws: An error if something goes wrong during the execution. func execute(using hm: HomeManagable) async throws + + /// Whether `AutomationService` records an `AutomationRun` per execution. Off for automations + /// that trigger every minute, which would flood the run history. + var recordsRuns: Bool { get } + + /// Extra detail appended to the generic trigger summary, e.g. the sensor value that decided it. + func triggerSummary(for event: HomeEvent, using hm: HomeManagable) async -> String? } public extension Automatable { + var recordsRuns: Bool { true } + + func triggerSummary(for event: HomeEvent, using hm: HomeManagable) async -> String? { nil } + var log: Logger { Logger(label: String(describing: Self.self)) } diff --git a/Sources/HAModels/Automation/AutomationInfo.swift b/Sources/HAModels/Automation/AutomationInfo.swift index 1b23db62..83baeb21 100644 --- a/Sources/HAModels/Automation/AutomationInfo.swift +++ b/Sources/HAModels/Automation/AutomationInfo.swift @@ -12,12 +12,21 @@ public struct AutomationInfo: Identifiable, Sendable, Codable, Equatable { public let name: String public var isActive: Bool public var isRunning: Bool + /// Type name of the automation, e.g. `MotionAtNight`; `nil` from servers that predate it. + public var type: String? + public var recordsRuns: Bool + public var lastRun: AutomationRun? + public var entities: [EntityId] public var id: String { name } - public init(name: String, isActive: Bool, isRunning: Bool) { + public init(name: String, isActive: Bool, isRunning: Bool, type: String? = nil, recordsRuns: Bool = true, lastRun: AutomationRun? = nil, entities: [EntityId] = []) { self.name = name self.isActive = isActive self.isRunning = isRunning + self.type = type + self.recordsRuns = recordsRuns + self.lastRun = lastRun + self.entities = entities } } diff --git a/Sources/HAModels/Automation/AutomationRun.swift b/Sources/HAModels/Automation/AutomationRun.swift new file mode 100644 index 00000000..f8cca14f --- /dev/null +++ b/Sources/HAModels/Automation/AutomationRun.swift @@ -0,0 +1,93 @@ +// +// AutomationRun.swift +// HAModels +// + +import Foundation + +/// One execution of an automation: what triggered it, when it ran and how it ended. +public struct AutomationRun: Identifiable, Codable, Sendable, Equatable { + public enum Outcome: String, Codable, Sendable, CaseIterable { + case running + case completed + /// A newer trigger of the same automation replaced this run. + case superseded + /// Stopped via the API. + case stopped + case failed + /// The server restarted while the run was in progress. + case interrupted + } + + public let id: UUID + public let automationName: String + public let startedAt: Date + public var endedAt: Date? + public let trigger: AutomationTrigger + public var outcome: Outcome + public var errorDescription: String? + + public init(id: UUID = UUID(), automationName: String, startedAt: Date, endedAt: Date? = nil, trigger: AutomationTrigger, outcome: Outcome, errorDescription: String? = nil) { + self.id = id + self.automationName = automationName + self.startedAt = startedAt + self.endedAt = endedAt + self.trigger = trigger + self.outcome = outcome + self.errorDescription = errorDescription + } +} + +/// The `HomeEvent` that started an `AutomationRun`, reduced to what a person needs to read. +public struct AutomationTrigger: Codable, Sendable, Equatable { + public enum Kind: String, Codable, Sendable, CaseIterable { + case entityChange + case time + case sunrise + case sunset + } + + public let kind: Kind + public let entityId: EntityId? + public let summary: String + + public init(kind: Kind, entityId: EntityId?, summary: String) { + self.kind = kind + self.entityId = entityId + self.summary = summary + } + + public func appending(_ detail: String) -> Self { + Self(kind: kind, entityId: entityId, summary: "\(summary) · \(detail)") + } +} + +extension HomeEvent { + public var trigger: AutomationTrigger { + switch self { + case .change(let item): + let summary = "\(EntityHistoryItem(item).valueDescription) · \(item.entityId.name) (\(item.entityId.placeId))" + return AutomationTrigger(kind: .entityChange, entityId: item.entityId, summary: summary) + case .time(let date): + return AutomationTrigger(kind: .time, entityId: nil, summary: "Schedule \(date.formatted(date: .omitted, time: .shortened))") + case .sunrise: + return AutomationTrigger(kind: .sunrise, entityId: nil, summary: "Sunrise") + case .sunset: + return AutomationTrigger(kind: .sunset, entityId: nil, summary: "Sunset") + } + } +} + +/// Persistence of `AutomationRun`s; implemented by the server's database. +public protocol AutomationRunRepository: Sendable { + func add(_ run: AutomationRun) async throws + func finish(_ id: UUID, outcome: AutomationRun.Outcome, endedAt: Date, errorDescription: String?) async throws + /// Newest first. + func runs(for automationName: String, startDate: Date?, endDate: Date?, limit: Int) async throws -> [AutomationRun] + /// Newest first, across all automations. + func latestRuns(since date: Date, limit: Int) async throws -> [AutomationRun] + /// Keyed by automation name. + func latestRunPerAutomation() async throws -> [String: AutomationRun] + func markRunningAsInterrupted() async throws + func deleteRuns(olderThan date: Date) async throws +} diff --git a/Sources/HAModels/Automation/AutomationRunContext.swift b/Sources/HAModels/Automation/AutomationRunContext.swift new file mode 100644 index 00000000..69cee345 --- /dev/null +++ b/Sources/HAModels/Automation/AutomationRunContext.swift @@ -0,0 +1,11 @@ +// +// AutomationRunContext.swift +// HAModels +// + +import Foundation + +/// Carries the current `AutomationRun` id down to the commands an automation sends. +public enum AutomationRunContext { + @TaskLocal public static var runId: UUID? +} diff --git a/Sources/HAModels/EntityHistoryItem.swift b/Sources/HAModels/EntityHistoryItem.swift index 18f09d38..c86b8545 100644 --- a/Sources/HAModels/EntityHistoryItem.swift +++ b/Sources/HAModels/EntityHistoryItem.swift @@ -75,6 +75,85 @@ public struct EntityHistoryItem: Identifiable, Sendable, Codable, Equatable, Has } } +extension EntityHistoryItem { + public init(_ item: EntityStorageItem) { + self.init(timestamp: item.timestamp, + motionDetected: item.motionDetected, + illuminanceInLux: item.illuminance?.value, + isDeviceOn: item.isDeviceOn, + brightness: item.brightness, + colorTemperature: item.colorTemperature, + colorRed: item.color?.red, + colorGreen: item.color?.green, + colorBlue: item.color?.blue, + isContactOpen: item.isContactOpen, + isDoorLocked: item.isDoorLocked, + stateOfCharge: item.stateOfCharge, + isHeaterActive: item.isHeaterActive, + temperatureInC: item.temperatureInC?.value, + relativeHumidity: item.relativeHumidity, + carbonDioxideSensorId: item.carbonDioxideSensorId, + pmDensity: item.pmDensity, + airQuality: item.airQuality, + valveOpen: item.valveOpen) + } + + /// Human-readable description of the primary value + public var valueDescription: String { + if let temperatureInC { + return "\(String(format: "%.1f", temperatureInC))°C" + } + if let relativeHumidity { + return "\(String(format: "%.1f", relativeHumidity))%" + } + if let carbonDioxideSensorId { + return "\(carbonDioxideSensorId) ppm" + } + if let airQuality { + return "AQI: \(airQuality)" + } + if let pmDensity { + return "\(String(format: "%.1f", pmDensity)) µg/m³" + } + if let illuminanceInLux { + return "\(String(format: "%.1f", illuminanceInLux)) lux" + } + if let brightness { + return "\(brightness)%" + } + if let stateOfCharge { + return "\(stateOfCharge)%" + } + if let colorTemperature { + return "CT: \(String(format: "%.2f", colorTemperature))" + } + if let isDeviceOn { + return isDeviceOn ? "On" : "Off" + } + if let motionDetected { + return motionDetected ? "Motion" : "No Motion" + } + if let isContactOpen { + return isContactOpen ? "Open" : "Closed" + } + if let isDoorLocked { + return isDoorLocked ? "Locked" : "Unlocked" + } + if let isHeaterActive { + return isHeaterActive ? "Active" : "Inactive" + } + if let valveOpen { + return valveOpen ? "Open" : "Closed" + } + // Color as hue + if let colorRed, let colorGreen, let colorBlue { + let hue = Double(RGB(red: colorRed, green: colorGreen, blue: colorBlue).hue) + return "\(String(format: "%.0f", hue))° hue" + } + return "No data" + } +} + /// Response wrapper for paginated entity history public struct EntityHistoryResponse: Sendable, Codable, Equatable { public let items: [EntityHistoryItem] diff --git a/Sources/Server/AppStorage.swift b/Sources/Server/AppStorage.swift index 38225e4d..7e167ad6 100644 --- a/Sources/Server/AppStorage.swift +++ b/Sources/Server/AppStorage.swift @@ -20,6 +20,10 @@ private let homeEvents = AsyncStream.makeStream(of: HomeEvent.self) EntityStorageDbRepository(database: db) } + var automationRunRepository: AutomationRunDbRepository { + AutomationRunDbRepository(database: db) + } + private struct HomeEventsStreamKey: StorageKey { typealias Value = AsyncStream } diff --git a/Sources/Server/Controllers/OpenAPIController.swift b/Sources/Server/Controllers/OpenAPIController.swift index de591779..c7053aa8 100644 --- a/Sources/Server/Controllers/OpenAPIController.swift +++ b/Sources/Server/Controllers/OpenAPIController.swift @@ -18,15 +18,34 @@ struct OpenAPIController: APIProtocol { func getAutomations(_ input: Operations.GetAutomations.Input) async throws -> Operations.GetAutomations.Output { let automationNames = await request.application.automationService.getActiveAutomationNames() + let lastRuns = try await request.application.automationRunRepository.latestRunPerAutomation() let automations = await request.application.homeAutomationConfigService.automations .map { tmp in Components.Schemas.Automation(name: tmp.name, isActive: tmp.isActive, - isRunning: automationNames.contains(tmp.name)) + isRunning: automationNames.contains(tmp.name), + _type: String(describing: type(of: tmp)), + recordsRuns: tmp.recordsRuns, + lastRun: lastRuns[tmp.name].map(Components.Schemas.AutomationRun.init), + entities: tmp.getEntityIds().map(Components.Schemas.EntityId.init)) } return .ok(.init(body: .json(automations))) } + func getAutomationRuns(_ input: Operations.GetAutomationRuns.Input) async throws -> Operations.GetAutomationRuns.Output { + let runs = try await request.application.automationRunRepository.runs(for: input.path.name, + startDate: input.query.startDate, + endDate: input.query.endDate, + limit: input.query.limit ?? 100) + return .ok(.init(body: .json(runs.map(Components.Schemas.AutomationRun.init)))) + } + + func getRecentRuns(_ input: Operations.GetRecentRuns.Input) async throws -> Operations.GetRecentRuns.Output { + let runs = try await request.application.automationRunRepository.latestRuns(since: input.query.since, + limit: input.query.limit ?? 100) + return .ok(.init(body: .json(runs.map(Components.Schemas.AutomationRun.init)))) + } + func activateAutomation(_ input: Operations.ActivateAutomation.Input) async throws -> Operations.ActivateAutomation.Output { guard let name = request.parameters.get("name"), !name.isEmpty else { @@ -282,3 +301,26 @@ struct OpenAPIController: APIProtocol { } } + +extension Components.Schemas.EntityId { + init(_ entityId: EntityId) { + self.init(placeId: entityId.placeId, + name: entityId.name, + characteristicsName: entityId.characteristicsName ?? "", + characteristicType: entityId.characteristicType.rawValue) + } +} + +extension Components.Schemas.AutomationRun { + init(_ run: AutomationRun) { + self.init(id: run.id.uuidString, + automationName: run.automationName, + startedAt: run.startedAt, + endedAt: run.endedAt, + trigger: .init(kind: .init(rawValue: run.trigger.kind.rawValue) ?? .entityChange, + entityId: run.trigger.entityId.map(Components.Schemas.EntityId.init), + summary: run.trigger.summary), + outcome: .init(rawValue: run.outcome.rawValue) ?? .failed, + errorDescription: run.errorDescription) + } +} diff --git a/Sources/Server/Jobs/DatabaseCleanupJob.swift b/Sources/Server/Jobs/DatabaseCleanupJob.swift index e9ab2259..7d0b16e6 100644 --- a/Sources/Server/Jobs/DatabaseCleanupJob.swift +++ b/Sources/Server/Jobs/DatabaseCleanupJob.swift @@ -13,9 +13,11 @@ import Shared struct DatabaseCleanupJob: Job, Log { private static let retentionDays = 90 + private static let runRetentionDays = 30 private static let cleanupInterval: Duration = .hours(24) // Run once per day let homeManager: any HomeManagable + let automationRuns: any AutomationRunRepository func run() async { log.info("Starting DatabaseCleanupJob - will run every 24 hours") @@ -35,6 +37,7 @@ struct DatabaseCleanupJob: Job, Log { log.info("Running database cleanup - deleting entries older than \(Self.retentionDays) days (before \(cutoffDate))") try await homeManager.deleteStorageEntries(olderThan: cutoffDate) + try await automationRuns.deleteRuns(olderThan: Date().addingTimeInterval(-1 * Duration.days(Self.runRetentionDays).timeInterval)) log.info("Database cleanup completed successfully") } catch { diff --git a/Sources/Server/Migrations/05_CreateAutomationRun.swift b/Sources/Server/Migrations/05_CreateAutomationRun.swift new file mode 100644 index 00000000..1a43b8bf --- /dev/null +++ b/Sources/Server/Migrations/05_CreateAutomationRun.swift @@ -0,0 +1,29 @@ +// +// 05_CreateAutomationRun.swift +// HomeAutomationServer +// + +import Fluent + +struct CreateAutomationRun: AsyncMigration { + func prepare(on database: Database) async throws { + try await database.schema(AutomationRunDbItem.schema) + .id() + .field("automationName", .string, .required) + .field("startedAt", .datetime, .required) + .field("endedAt", .datetime) + .field("triggerKind", .string, .required) + .field("triggerPlaceId", .string) + .field("triggerName", .string) + .field("triggerCharacteristicsName", .string) + .field("triggerCharacteristicType", .string) + .field("triggerSummary", .custom("VARCHAR(1000)"), .required) + .field("outcome", .string, .required) + .field("errorDescription", .custom("TEXT")) + .create() + } + + func revert(on database: Database) async throws { + try await database.schema(AutomationRunDbItem.schema).delete() + } +} diff --git a/Sources/Server/Models/AutomationRunDbItem.swift b/Sources/Server/Models/AutomationRunDbItem.swift new file mode 100644 index 00000000..41442041 --- /dev/null +++ b/Sources/Server/Models/AutomationRunDbItem.swift @@ -0,0 +1,85 @@ +// +// AutomationRunDbItem.swift +// HomeAutomationServer +// + +import Fluent +import Foundation +import HAModels + +final class AutomationRunDbItem: Model, @unchecked Sendable { + static let schema = "automationRuns" + + @ID(key: .id) + var id: UUID? + + @Field(key: "automationName") + var automationName: String + + @Field(key: "startedAt") + var startedAt: Date + + @OptionalField(key: "endedAt") + var endedAt: Date? + + @Field(key: "triggerKind") + var triggerKind: String + + @OptionalField(key: "triggerPlaceId") + var triggerPlaceId: String? + + @OptionalField(key: "triggerName") + var triggerName: String? + + @OptionalField(key: "triggerCharacteristicsName") + var triggerCharacteristicsName: String? + + @OptionalField(key: "triggerCharacteristicType") + var triggerCharacteristicType: String? + + @Field(key: "triggerSummary") + var triggerSummary: String + + @Field(key: "outcome") + var outcome: String + + @OptionalField(key: "errorDescription") + var errorDescription: String? + + init() { } + + init(_ run: AutomationRun) { + id = run.id + automationName = run.automationName + startedAt = run.startedAt + endedAt = run.endedAt + triggerKind = run.trigger.kind.rawValue + triggerPlaceId = run.trigger.entityId?.placeId + triggerName = run.trigger.entityId?.name + triggerCharacteristicsName = run.trigger.entityId?.characteristicsName + triggerCharacteristicType = run.trigger.entityId?.characteristicType.rawValue + triggerSummary = run.trigger.summary + outcome = run.outcome.rawValue + errorDescription = run.errorDescription + } + + func toRun() throws -> AutomationRun { + guard let id, + let kind = AutomationTrigger.Kind(rawValue: triggerKind), + let outcome = AutomationRun.Outcome(rawValue: outcome) else { + throw DecodingError.dataCorrupted(.init(codingPath: [], debugDescription: "Invalid automation run row \(String(describing: id))")) + } + var entityId: EntityId? + if let triggerPlaceId, let triggerName, + let characteristic = triggerCharacteristicType.flatMap(CharacteristicsType.init(rawValue:)) { + entityId = EntityId(placeId: triggerPlaceId, name: triggerName, characteristicsName: triggerCharacteristicsName, characteristic: characteristic) + } + return AutomationRun(id: id, + automationName: automationName, + startedAt: startedAt, + endedAt: endedAt, + trigger: AutomationTrigger(kind: kind, entityId: entityId, summary: triggerSummary), + outcome: outcome, + errorDescription: errorDescription) + } +} diff --git a/Sources/Server/Models/AutomationRunDbRepository.swift b/Sources/Server/Models/AutomationRunDbRepository.swift new file mode 100644 index 00000000..f0b08271 --- /dev/null +++ b/Sources/Server/Models/AutomationRunDbRepository.swift @@ -0,0 +1,72 @@ +// +// AutomationRunDbRepository.swift +// HomeAutomationServer +// + +import Fluent +import Foundation +import HAModels + +struct AutomationRunDbRepository: AutomationRunRepository { + let database: any Database + + func add(_ run: AutomationRun) async throws { + try await AutomationRunDbItem(run).create(on: database) + } + + func finish(_ id: UUID, outcome: AutomationRun.Outcome, endedAt: Date, errorDescription: String?) async throws { + try await AutomationRunDbItem.query(on: database) + .filter(\.$id == id) + .set(\.$outcome, to: outcome.rawValue) + .set(\.$endedAt, to: endedAt) + .set(\.$errorDescription, to: errorDescription) + .update() + } + + func runs(for automationName: String, startDate: Date?, endDate: Date?, limit: Int) async throws -> [AutomationRun] { + var query = AutomationRunDbItem.query(on: database) + .filter(\.$automationName == automationName) + if let startDate { + query = query.filter(\.$startedAt >= startDate) + } + if let endDate { + query = query.filter(\.$startedAt < endDate) + } + return try await query + .sort(\.$startedAt, .descending) + .limit(limit) + .all() + .map { try $0.toRun() } + } + + func latestRuns(since date: Date, limit: Int) async throws -> [AutomationRun] { + try await AutomationRunDbItem.query(on: database) + .filter(\.$startedAt >= date) + .sort(\.$startedAt, .descending) + .limit(limit) + .all() + .map { try $0.toRun() } + } + + func latestRunPerAutomation() async throws -> [String: AutomationRun] { + let names = try await AutomationRunDbItem.query(on: database).unique().all(\.$automationName) + var result: [String: AutomationRun] = [:] + for name in names { + result[name] = try await runs(for: name, startDate: nil, endDate: nil, limit: 1).first + } + return result + } + + func markRunningAsInterrupted() async throws { + try await AutomationRunDbItem.query(on: database) + .filter(\.$outcome == AutomationRun.Outcome.running.rawValue) + .set(\.$outcome, to: AutomationRun.Outcome.interrupted.rawValue) + .update() + } + + func deleteRuns(olderThan date: Date) async throws { + try await AutomationRunDbItem.query(on: database) + .filter(\.$startedAt < date) + .delete() + } +} diff --git a/Sources/Server/configure.swift b/Sources/Server/configure.swift index fd605fb5..663eee08 100644 --- a/Sources/Server/configure.swift +++ b/Sources/Server/configure.swift @@ -113,6 +113,7 @@ public func configure(_ app: Application) async throws { app.migrations.add(DeviceTokenItem()) app.migrations.add(AddSensorFields()) app.migrations.add(CreateConfigItem()) + app.migrations.add(CreateAutomationRun()) // Run migrations automatically try await app.autoMigrate() @@ -226,7 +227,10 @@ public func configure(_ app: Application) async throws { location: app.homeAutomationConfigService.location, actionLogManager: actionLogManager) app.homeManager = homeManager + // Runs still marked `running` belong to a previous server process that died mid-run. + try await app.automationRunRepository.markRunningAsInterrupted() let automationService = try AutomationService(using: homeManager, + runs: app.automationRunRepository, getAutomations: { await app.homeAutomationConfigService.automations }) @@ -241,7 +245,7 @@ public func configure(_ app: Application) async throws { HomeEventProcessingJob(homeEventsStream: app.homeEventsStream, automationService: automationService, homeManager: app.homeManager), - DatabaseCleanupJob(homeManager: app.homeManager) + DatabaseCleanupJob(homeManager: app.homeManager, automationRuns: app.automationRunRepository) ] Task.detached { diff --git a/Sources/ServerClient/ServerClient.swift b/Sources/ServerClient/ServerClient.swift index 225e8e6b..c51af096 100644 --- a/Sources/ServerClient/ServerClient.swift +++ b/Sources/ServerClient/ServerClient.swift @@ -35,12 +35,27 @@ public struct ServerClient { let response = try await client.getAutomations() return try response.ok.body.json .map { automation in - AutomationInfo(name: automation.name, - isActive: automation.isActive, - isRunning: automation.isRunning) + try AutomationInfo(name: automation.name, + isActive: automation.isActive, + isRunning: automation.isRunning, + type: automation._type, + recordsRuns: automation.recordsRuns ?? true, + lastRun: automation.lastRun.map(AutomationRun.init), + entities: (automation.entities ?? []).compactMap(EntityId.init)) } } + public func getRuns(automation name: String, startDate: Date? = nil, endDate: Date? = nil, limit: Int? = nil) async throws -> [AutomationRun] { + let response = try await client.getAutomationRuns(path: .init(name: name), + query: .init(startDate: startDate, endDate: endDate, limit: limit)) + return try response.ok.body.json.map(AutomationRun.init) + } + + public func getRecentRuns(since date: Date, limit: Int? = nil) async throws -> [AutomationRun] { + let response = try await client.getRecentRuns(query: .init(since: date, limit: limit)) + return try response.ok.body.json.map(AutomationRun.init) + } + public func activate(automation name: String) async throws { let response = try await client.activateAutomation(path: .init(name: name)) _ = try response.ok @@ -185,3 +200,33 @@ extension Components.Schemas.PushDevice.TokenTypePayload { } } } + +struct InvalidResponseError: Error { + let reason: String +} + +extension EntityId { + /// `nil` for a characteristic type this client does not know yet. + init?(_ entityId: Components.Schemas.EntityId) { + guard let characteristic = CharacteristicsType(rawValue: entityId.characteristicType) else { return nil } + let characteristicsName = entityId.characteristicsName?.isEmpty == false ? entityId.characteristicsName : nil + self.init(placeId: entityId.placeId, name: entityId.name, characteristicsName: characteristicsName, characteristic: characteristic) + } +} + +extension AutomationRun { + init(_ run: Components.Schemas.AutomationRun) throws { + guard let id = UUID(uuidString: run.id), + let kind = AutomationTrigger.Kind(rawValue: run.trigger.kind.rawValue), + let outcome = Outcome(rawValue: run.outcome.rawValue) else { + throw InvalidResponseError(reason: "Invalid automation run \(run.id)") + } + self.init(id: id, + automationName: run.automationName, + startedAt: run.startedAt, + endedAt: run.endedAt, + trigger: AutomationTrigger(kind: kind, entityId: run.trigger.entityId.flatMap(EntityId.init), summary: run.trigger.summary), + outcome: outcome, + errorDescription: run.errorDescription) + } +} diff --git a/Tests/HomeAutomationKitTests/AutomationServiceTests.swift b/Tests/HomeAutomationKitTests/AutomationServiceTests.swift new file mode 100644 index 00000000..d9edae77 --- /dev/null +++ b/Tests/HomeAutomationKitTests/AutomationServiceTests.swift @@ -0,0 +1,129 @@ +// +// AutomationServiceTests.swift +// HomeAutomationKit +// + +import Foundation +import HAApplicationLayer +import HAModels +import Testing + +/// Triggers on every event; `execute` behaves according to `mode`. +private struct TestAutomation: Automatable { + enum Mode: Codable { case complete, fail, waitForCancellation } + struct Failure: Error {} + + var isActive = true + let name: String + let mode: Mode + var recordsRuns = true + var summaryDetail: String? + var triggerEntityIds: Set { [] } + /// Receives the run id seen inside `execute`; not part of the config. + var onExecute: (@Sendable (UUID?) -> Void)? + + enum CodingKeys: CodingKey { case isActive, name, mode, recordsRuns, summaryDetail } + + func shouldTrigger(with event: HomeEvent, using hm: HomeManagable) async throws -> Bool { true } + + func triggerSummary(for event: HomeEvent, using hm: HomeManagable) async -> String? { summaryDetail } + + func execute(using hm: HomeManagable) async throws { + onExecute?(AutomationRunContext.runId) + switch mode { + case .complete: + return + case .fail: + throw Failure() + case .waitForCancellation: + try await Task.sleep(for: .seconds(3600)) + } + } +} + +@HomeManagerActor +struct AutomationServiceTests { + let repository = InMemoryAutomationRunRepository() + let homeManager = MockHomeAdapter() + + private func makeService(_ automation: TestAutomation) throws -> AutomationService { + try AutomationService(using: homeManager, runs: repository, getAutomations: { @Sendable in [automation] }) + } + + @Test func completedRunRecordsTriggerAndEnd() async throws { + let service = try makeService(TestAutomation(name: "a", mode: .complete)) + + await service.trigger(with: .sunrise) + + let run = try #require(await repository.nextFinishedRun()) + #expect(run.automationName == "a") + #expect(run.outcome == .completed) + #expect(run.trigger == AutomationTrigger(kind: .sunrise, entityId: nil, summary: "Sunrise")) + #expect(run.endedAt != nil) + } + + @Test func secondTriggerSupersedesTheRunningRun() async throws { + let service = try makeService(TestAutomation(name: "a", mode: .waitForCancellation)) + + await service.trigger(with: .sunrise) + await service.trigger(with: .sunset) + + let run = try #require(await repository.nextFinishedRun()) + #expect(run.trigger.kind == .sunrise) + #expect(run.outcome == .superseded) + await service.stopAutomation(with: "a") + } + + @Test func stopViaServiceEndsTheRunAsStopped() async throws { + let service = try makeService(TestAutomation(name: "a", mode: .waitForCancellation)) + + await service.trigger(with: .sunrise) + await service.stopAutomation(with: "a") + + let run = try #require(await repository.nextFinishedRun()) + #expect(run.outcome == .stopped) + } + + @Test func throwingExecuteEndsTheRunAsFailed() async throws { + let service = try makeService(TestAutomation(name: "a", mode: .fail)) + + await service.trigger(with: .sunrise) + + let run = try #require(await repository.nextFinishedRun()) + #expect(run.outcome == .failed) + #expect(run.errorDescription == "Failure()") + } + + @Test func automationWithoutRecordsRunsCreatesNoRun() async throws { + let (executed, continuation) = AsyncStream.makeStream(of: UUID?.self) + let service = try makeService(TestAutomation(name: "a", mode: .complete, recordsRuns: false, onExecute: { continuation.yield($0) })) + + await service.trigger(with: .sunrise) + + var iterator = executed.makeAsyncIterator() + let runId = await iterator.next() + #expect(runId == .some(nil)) + #expect(await repository.runs.isEmpty) + } + + @Test func triggerSummaryOfTheAutomationIsAppended() async throws { + let service = try makeService(TestAutomation(name: "a", mode: .complete, summaryDetail: "at 3 lx")) + + await service.trigger(with: .sunset) + + let run = try #require(await repository.nextFinishedRun()) + #expect(run.trigger.summary == "Sunset · at 3 lx") + } + + @Test func executeSeesTheRunIdAsTaskLocal() async throws { + let (executed, continuation) = AsyncStream.makeStream(of: UUID?.self) + let service = try makeService(TestAutomation(name: "a", mode: .complete, onExecute: { continuation.yield($0) })) + + await service.trigger(with: .sunrise) + + var iterator = executed.makeAsyncIterator() + let runId = try #require(await iterator.next()) + let run = try #require(await repository.nextFinishedRun()) + #expect(runId == run.id) + } +} diff --git a/Tests/HomeAutomationKitTests/HomeEventTriggerTests.swift b/Tests/HomeAutomationKitTests/HomeEventTriggerTests.swift new file mode 100644 index 00000000..59263990 --- /dev/null +++ b/Tests/HomeAutomationKitTests/HomeEventTriggerTests.swift @@ -0,0 +1,33 @@ +// +// HomeEventTriggerTests.swift +// HomeAutomationKit +// + +import Foundation +import HAModels +import Testing + +struct HomeEventTriggerTests { + @Test func changeNamesValueEntityAndPlace() { + let entityId = EntityId(placeId: "Hallway", name: "Eve Motion", characteristicsName: nil, characteristic: .motionSensor) + + let trigger = HomeEvent.change(entity: EntityStorageItem(entityId: entityId, motionDetected: true)).trigger + + #expect(trigger == AutomationTrigger(kind: .entityChange, entityId: entityId, summary: "Motion · Eve Motion (Hallway)")) + } + + @Test func timeNamesTheSchedule() { + let date = Date(timeIntervalSince1970: 0) + + let trigger = HomeEvent.time(date: date).trigger + + #expect(trigger.kind == .time) + #expect(trigger.entityId == nil) + #expect(trigger.summary == "Schedule \(date.formatted(date: .omitted, time: .shortened))") + } + + @Test func sunriseAndSunset() { + #expect(HomeEvent.sunrise.trigger == AutomationTrigger(kind: .sunrise, entityId: nil, summary: "Sunrise")) + #expect(HomeEvent.sunset.trigger == AutomationTrigger(kind: .sunset, entityId: nil, summary: "Sunset")) + } +} diff --git a/Tests/HomeAutomationKitTests/InMemoryAutomationRunRepository.swift b/Tests/HomeAutomationKitTests/InMemoryAutomationRunRepository.swift new file mode 100644 index 00000000..d271384d --- /dev/null +++ b/Tests/HomeAutomationKitTests/InMemoryAutomationRunRepository.swift @@ -0,0 +1,53 @@ +// +// InMemoryAutomationRunRepository.swift +// HomeAutomationKit +// + +import Foundation +import HAModels + +actor InMemoryAutomationRunRepository: AutomationRunRepository { + private(set) var runs: [UUID: AutomationRun] = [:] + private let finished = AsyncStream.makeStream(of: AutomationRun.self) + + /// Waits for the next run that `finish` is called for. + func nextFinishedRun() async -> AutomationRun? { + var iterator = finished.stream.makeAsyncIterator() + return await iterator.next() + } + + func add(_ run: AutomationRun) { + runs[run.id] = run + } + + func finish(_ id: UUID, outcome: AutomationRun.Outcome, endedAt: Date, errorDescription: String?) { + runs[id]?.outcome = outcome + runs[id]?.endedAt = endedAt + runs[id]?.errorDescription = errorDescription + if let run = runs[id] { + finished.continuation.yield(run) + } + } + + func runs(for automationName: String, startDate: Date?, endDate: Date?, limit: Int) -> [AutomationRun] { + Array(runs.values.filter { $0.automationName == automationName }.sorted { $0.startedAt > $1.startedAt }.prefix(limit)) + } + + func latestRuns(since date: Date, limit: Int) -> [AutomationRun] { + Array(runs.values.filter { $0.startedAt >= date }.sorted { $0.startedAt > $1.startedAt }.prefix(limit)) + } + + func latestRunPerAutomation() -> [String: AutomationRun] { + Dictionary(runs.values.map { ($0.automationName, $0) }) { lhs, rhs in lhs.startedAt > rhs.startedAt ? lhs : rhs } + } + + func markRunningAsInterrupted() { + for (id, run) in runs where run.outcome == .running { + runs[id]?.outcome = .interrupted + } + } + + func deleteRuns(olderThan date: Date) { + runs = runs.filter { $0.value.startedAt >= date } + } +} diff --git a/Tests/HomeAutomationKitTests/RealworldAutomationTests/MotionAtNightTriggerSummaryTests.swift b/Tests/HomeAutomationKitTests/RealworldAutomationTests/MotionAtNightTriggerSummaryTests.swift new file mode 100644 index 00000000..5ec028c7 --- /dev/null +++ b/Tests/HomeAutomationKitTests/RealworldAutomationTests/MotionAtNightTriggerSummaryTests.swift @@ -0,0 +1,25 @@ +// +// MotionAtNightTriggerSummaryTests.swift +// HomeAutomationKit +// + +import Foundation +import HAImplementations +import HAModels +import Testing + +@HomeManagerActor +struct MotionAtNightTriggerSummaryTests { + private let motion = EveMotion(query: .init(placeId: "room1", name: "motion1")) + + @Test func summaryNamesTheIlluminanceAtTriggerTime() async throws { + let automation = MotionAtNight("night", motionSensors: [motion], lightSensor: motion, lights: [], minBrightness: 0.1) + let homeManager = MockHomeAdapter() + let lightSensorId = try #require(motion.lightSensorId) + homeManager.storageItems = [EntityStorageItem(entityId: lightSensorId, illuminance: .init(value: 3.2, unit: .lux))] + + let summary = await automation.triggerSummary(for: .sunset, using: homeManager) + + #expect(summary == "at 3 lx") + } +} diff --git a/Tests/ServerTests/AutomationRunMappingTests.swift b/Tests/ServerTests/AutomationRunMappingTests.swift new file mode 100644 index 00000000..2adf9f3d --- /dev/null +++ b/Tests/ServerTests/AutomationRunMappingTests.swift @@ -0,0 +1,46 @@ +// +// AutomationRunMappingTests.swift +// HomeAutomation +// + +import Foundation +import HAModels +@testable import Server +import Testing + +/// The repository queries need MySQL; these cover the row and API mappings without a database. +struct AutomationRunMappingTests { + private let entityId = EntityId(placeId: "Hallway", name: "Eve Motion", characteristicsName: "Motion", characteristic: .motionSensor) + + @Test(arguments: AutomationRun.Outcome.allCases) + func dbItemRoundTripKeepsAllFields(outcome: AutomationRun.Outcome) throws { + let run = AutomationRun(automationName: "Night light", + startedAt: Date(timeIntervalSince1970: 1_000), + endedAt: Date(timeIntervalSince1970: 1_060), + trigger: AutomationTrigger(kind: .entityChange, entityId: entityId, summary: "Motion · Eve Motion (Hallway)"), + outcome: outcome, + errorDescription: "boom") + + #expect(try AutomationRunDbItem(run).toRun() == run) + } + + @Test(arguments: AutomationTrigger.Kind.allCases) + func dbItemRoundTripWithoutEntity(kind: AutomationTrigger.Kind) throws { + let run = AutomationRun(automationName: "a", + startedAt: Date(timeIntervalSince1970: 0), + trigger: AutomationTrigger(kind: kind, entityId: nil, summary: "s"), + outcome: .running) + + #expect(try AutomationRunDbItem(run).toRun() == run) + } + + @Test(arguments: AutomationRun.Outcome.allCases) + func apiSchemaCarriesEveryOutcome(outcome: AutomationRun.Outcome) { + let run = AutomationRun(automationName: "a", startedAt: Date(), trigger: AutomationTrigger(kind: .sunset, entityId: nil, summary: "Sunset"), outcome: outcome) + + let schema = Components.Schemas.AutomationRun(run) + + #expect(schema.outcome.rawValue == outcome.rawValue) + #expect(schema.trigger.kind.rawValue == "sunset") + } +} diff --git a/openapi.yaml b/openapi.yaml index 82287249..2f2e156a 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -73,6 +73,83 @@ paths: "200": description: The currently running automation was stopped or the automation is not running. + /config/automations/{name}/runs: + get: + summary: Get the runs of an automation + description: Retrieve the recorded runs of one automation, newest first. + operationId: getAutomationRuns + parameters: + - name: name + in: path + description: Name of the automation + required: true + schema: + type: string + - name: startDate + in: query + description: ISO 8601 timestamp; only runs started at or after it + required: false + schema: + type: string + format: date-time + - name: endDate + in: query + description: ISO 8601 timestamp; only runs started before it + required: false + schema: + type: string + format: date-time + - name: limit + in: query + description: Maximum number of runs to return (default 100, max 1000) + required: false + schema: + type: integer + minimum: 1 + maximum: 1000 + default: 100 + responses: + "200": + description: The runs of the automation. + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/AutomationRun" + + /runs: + get: + summary: Get recent runs + description: Retrieve the runs of all automations started since a date, newest first. + operationId: getRecentRuns + parameters: + - name: since + in: query + description: ISO 8601 timestamp; only runs started at or after it + required: true + schema: + type: string + format: date-time + - name: limit + in: query + description: Maximum number of runs to return (default 100, max 1000) + required: false + schema: + type: integer + minimum: 1 + maximum: 1000 + default: 100 + responses: + "200": + description: The recent runs. + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/AutomationRun" + /pushdevices: post: summary: Register a push device @@ -261,11 +338,67 @@ components: isRunning: type: boolean description: Indicates if the automation is currently running. + type: + type: string + description: Type name of the automation, e.g. MotionAtNight. + recordsRuns: + type: boolean + description: Whether runs of this automation are recorded. + lastRun: + $ref: "#/components/schemas/AutomationRun" + entities: + type: array + description: All entities the automation reads or controls. + items: + $ref: "#/components/schemas/EntityId" required: - name - isActive - isRunning + AutomationRun: + type: object + properties: + id: + type: string + format: uuid + automationName: + type: string + startedAt: + type: string + format: date-time + endedAt: + type: string + format: date-time + trigger: + $ref: "#/components/schemas/AutomationTrigger" + outcome: + type: string + enum: [running, completed, superseded, stopped, failed, interrupted] + errorDescription: + type: string + required: + - id + - automationName + - startedAt + - trigger + - outcome + + AutomationTrigger: + type: object + properties: + kind: + type: string + enum: [entityChange, time, sunrise, sunset] + entityId: + $ref: "#/components/schemas/EntityId" + summary: + type: string + description: Human-readable description of what triggered the run. + required: + - kind + - summary + PushDevice: type: object properties: From 2b934f1339e4521ca874b1807ec2aac252fe89ee Mon Sep 17 00:00:00 2001 From: Julian Kahnert Date: Sat, 3 Oct 2026 20:25:10 +0200 Subject: [PATCH 03/22] Link logged commands to the automation run that sent them ActionLogItem gains `runId` and `status` (executed, cacheHit, failed). The run id travels as a task local from AutomationService into HomeManager and is carried explicitly through the detached retry loop. GET /actions accepts `runId` to list one run's commands. --- Sources/HAApplicationLayer/HomeManager.swift | 23 +++++++------ .../Managers/ActionLogManager.swift | 21 +++++++++--- Sources/HAModels/ActionLogItem.swift | 22 ++++++++++-- Sources/HAModels/HomeManagable.swift | 2 +- .../Controllers/OpenAPIController.swift | 34 ++++++++++--------- Sources/ServerClient/ServerClient.swift | 8 +++-- .../ActionLogManagerTests.swift | 31 +++++++++++++++++ .../HomeManagerTests.swift | 23 ++++++++++++- .../MockHomeAdapter.swift | 2 +- openapi.yaml | 15 ++++++++ 10 files changed, 143 insertions(+), 38 deletions(-) diff --git a/Sources/HAApplicationLayer/HomeManager.swift b/Sources/HAApplicationLayer/HomeManager.swift index d0ac9ce6..05cec76c 100644 --- a/Sources/HAApplicationLayer/HomeManager.swift +++ b/Sources/HAApplicationLayer/HomeManager.swift @@ -28,7 +28,7 @@ public final class HomeManager: HomeManagable { /// intent wins. `EntityId` carries the characteristic, so a lamp's brightness and color /// temperature occupy separate slots; `turnOn` and `turnOff` share the switcher slot on purpose — /// replaying both would be contradictory. - private var failedActions: [EntityId: (action: HomeManagableAction, attempt: Int)] = [:] + private var failedActions: [EntityId: (action: HomeManagableAction, attempt: Int, runId: UUID?)] = [:] /// - Parameter retryTicks: Cadence at which queued failed actions are retried. Pass a stream to /// drive the retries deterministically; the default is a 5s timer. @@ -54,9 +54,9 @@ public final class HomeManager: HomeManagable { Task.detached(priority: .low) { for await _ in ticks { let queued = await self.popAllFailedActions() - for (action, attempt) in queued { + for (action, attempt, runId) in queued { self.log.debug("Performing failed action again: \(action) [attempt \(attempt + 1)/\(Self.maxAttempts)]") - await self.perform(action, attempt: attempt) + await self.perform(action, attempt: attempt, runId: runId) } } } @@ -106,10 +106,12 @@ public final class HomeManager: HomeManagable { public func perform(_ action: HomeManagableAction) async { // Round values to prevent excessive HomeKit updates let roundedAction = action.rounded() - await perform(roundedAction, attempt: 0) + await perform(roundedAction, attempt: 0, runId: AutomationRunContext.runId) } - private func perform(_ action: HomeManagableAction, attempt: Int) async { + /// - Parameter runId: Passed explicitly because the retry loop runs detached, without the + /// `AutomationRunContext` task local of the original run. + private func perform(_ action: HomeManagableAction, attempt: Int, runId: UUID?) async { // Cancellation is honoured at command boundaries only: a superseded automation run must not // start further commands, but a command that already started always runs to completion (see // the shield below). Checked before logging so a skipped command produces no log entry. @@ -119,7 +121,7 @@ public final class HomeManager: HomeManagable { } // Log action and check if it's a duplicate (cache hit) - let hasCacheHit = await actionLogManager.log(action: action) + let hasCacheHit = await actionLogManager.log(action: action, runId: runId) if hasCacheHit { log.info("Skipping duplicate command: [\(action)]") @@ -142,6 +144,7 @@ public final class HomeManager: HomeManagable { // Only a command that reached the device may deduplicate its successors. await actionLogManager.markExecuted(action) } catch { + await actionLogManager.markFailed(action) let entityId = action.entityId if let entity = try? await getCurrentEntity(with: entityId) { @@ -151,7 +154,7 @@ public final class HomeManager: HomeManagable { let nextAttempt = attempt + 1 if nextAttempt < Self.maxAttempts { - failedActions[entityId] = (action, nextAttempt) + failedActions[entityId] = (action, nextAttempt, runId) } else { log.critical("Giving up on action [\(action)] after \(Self.maxAttempts) attempts") } @@ -269,15 +272,15 @@ public final class HomeManager: HomeManagable { await windowManager.setWindowOpenState(entityId: entityId, to: state) } - public func getActionLog(limit: Int?) async -> [ActionLogItem] { - await actionLogManager.getActions(limit: limit) + public func getActionLog(limit: Int?, runId: UUID?) async -> [ActionLogItem] { + await actionLogManager.getActions(limit: limit, runId: runId) } public func clearActionLog() async { await actionLogManager.clear() } - private func popAllFailedActions() -> [(action: HomeManagableAction, attempt: Int)] { + private func popAllFailedActions() -> [(action: HomeManagableAction, attempt: Int, runId: UUID?)] { let queued = Array(failedActions.values) failedActions.removeAll() return queued diff --git a/Sources/HAApplicationLayer/Managers/ActionLogManager.swift b/Sources/HAApplicationLayer/Managers/ActionLogManager.swift index 4fa9f35d..388d43d5 100644 --- a/Sources/HAApplicationLayer/Managers/ActionLogManager.swift +++ b/Sources/HAApplicationLayer/Managers/ActionLogManager.swift @@ -38,9 +38,11 @@ public actor ActionLogManager { /// never reached the device (cancelled, adapter error) must stay a cache miss so the next /// attempt is allowed through. /// - /// - Parameter action: The action to log + /// - Parameters: + /// - action: The action to log + /// - runId: The `AutomationRun` that sent the action, if any. /// - Returns: true if this was a duplicate action (cache hit), false if it's a new action that should be executed - public func log(action: HomeManagableAction) async -> Bool { + public func log(action: HomeManagableAction, runId: UUID? = nil) async -> Bool { let cacheKey = CommandCacheKey(action) // Check if action is in cache @@ -56,7 +58,8 @@ public actor ActionLogManager { // Log the action let item = ActionLogItem( action: action, - hasCacheHit: hasCacheHit + hasCacheHit: hasCacheHit, + runId: runId ) // Insert at beginning (newest first) @@ -80,6 +83,13 @@ public actor ActionLogManager { await commandCache.insert(action, forKey: CommandCacheKey(action)) } + /// Marks the newest executed log entry of `action` as failed. + public func markFailed(_ action: HomeManagableAction) { + let detail = action.description + guard let index = actions.firstIndex(where: { $0.entityId == action.entityId && $0.detailDescription == detail && $0.status == .executed }) else { return } + actions[index].status = .failed + } + /// Reset cached commands for `item.entityId` that the freshly observed device state contradicts. /// /// This lets the automation engine re-issue a command after the device has drifted away from @@ -106,8 +116,11 @@ public actor ActionLogManager { return invalidatedActions } - public func getActions(limit: Int? = nil) -> [ActionLogItem] { + public func getActions(limit: Int? = nil, runId: UUID? = nil) -> [ActionLogItem] { var result = actions + if let runId { + result = result.filter { $0.runId == runId } + } // Apply limit if provided if let limit = limit { diff --git a/Sources/HAModels/ActionLogItem.swift b/Sources/HAModels/ActionLogItem.swift index 4ea4f0d0..b015fe3a 100644 --- a/Sources/HAModels/ActionLogItem.swift +++ b/Sources/HAModels/ActionLogItem.swift @@ -8,18 +8,32 @@ import Foundation public struct ActionLogItem: Identifiable, Sendable, Codable, Equatable { + public enum Status: String, Sendable, Codable, CaseIterable { + /// Sent to the adapter. + case executed + /// Skipped as a duplicate of a recently executed command. + case cacheHit + /// The adapter call threw. + case failed + } + public let id: UUID public let timestamp: Date public let entityId: EntityId public let actionName: String public let detailDescription: String public let hasCacheHit: Bool + /// The `AutomationRun` that sent the command; `nil` for API calls and older servers. + public let runId: UUID? + /// `nil` from servers that predate it. + public var status: Status? public init( id: UUID = UUID(), timestamp: Date = Date(), action: HomeManagableAction, - hasCacheHit: Bool + hasCacheHit: Bool, + runId: UUID? = nil ) { self.id = id self.timestamp = timestamp @@ -27,15 +41,19 @@ public struct ActionLogItem: Identifiable, Sendable, Codable, Equatable { self.actionName = action.actionName self.detailDescription = action.description self.hasCacheHit = hasCacheHit + self.runId = runId + self.status = hasCacheHit ? .cacheHit : .executed } - public init(id: UUID, timestamp: Date, entityId: EntityId, actionName: String, detailDescription: String, hasCacheHit: Bool) { + public init(id: UUID, timestamp: Date, entityId: EntityId, actionName: String, detailDescription: String, hasCacheHit: Bool, runId: UUID? = nil, status: Status? = nil) { self.id = id self.timestamp = timestamp self.entityId = entityId self.actionName = actionName self.detailDescription = detailDescription self.hasCacheHit = hasCacheHit + self.runId = runId + self.status = status } /// Human-readable description for display diff --git a/Sources/HAModels/HomeManagable.swift b/Sources/HAModels/HomeManagable.swift index 96326ef5..9ac97c64 100644 --- a/Sources/HAModels/HomeManagable.swift +++ b/Sources/HAModels/HomeManagable.swift @@ -34,7 +34,7 @@ public protocol HomeManagable: EntityValidator, Sendable { func clearWindowNotification(entityId: EntityId) async func getWindowStates() async -> [WindowOpenState] func setWindowOpenState(entityId: EntityId, to state: WindowOpenState?) async - func getActionLog(limit: Int?) async -> [ActionLogItem] + func getActionLog(limit: Int?, runId: UUID?) async -> [ActionLogItem] func clearActionLog() async } diff --git a/Sources/Server/Controllers/OpenAPIController.swift b/Sources/Server/Controllers/OpenAPIController.swift index c7053aa8..7adec55a 100644 --- a/Sources/Server/Controllers/OpenAPIController.swift +++ b/Sources/Server/Controllers/OpenAPIController.swift @@ -150,23 +150,25 @@ struct OpenAPIController: APIProtocol { // MARK: - /actions func getActions(_ input: Operations.GetActions.Input) async throws -> Operations.GetActions.Output { - let limit = input.query.limit - - let actionItems = await request.application.homeManager.getActionLog(limit: limit) + var runId: UUID? + if let runIdString = input.query.runId { + guard let parsed = UUID(uuidString: runIdString) else { + throw Abort(.badRequest, reason: "Invalid runId: \(runIdString)") + } + runId = parsed + } - // Map to OpenAPI schema types - let schemaItems = actionItems.compactMap { item -> Components.Schemas.ActionLogItem? in - let entityId = Components.Schemas.EntityId(placeId: item.entityId.placeId, - name: item.entityId.name, - characteristicsName: item.entityId.characteristicsName ?? "", - characteristicType: item.entityId.characteristicType.rawValue) - - return Components.Schemas.ActionLogItem(id: item.id.uuidString, - timestamp: item.timestamp, - entityId: entityId, - actionName: item.actionName, - detailDescription: item.detailDescription, - hasCacheHit: item.hasCacheHit) + let actionItems = await request.application.homeManager.getActionLog(limit: input.query.limit, runId: runId) + + let schemaItems = actionItems.map { item in + Components.Schemas.ActionLogItem(id: item.id.uuidString, + timestamp: item.timestamp, + entityId: .init(item.entityId), + actionName: item.actionName, + detailDescription: item.detailDescription, + hasCacheHit: item.hasCacheHit, + runId: item.runId?.uuidString, + status: item.status.flatMap { .init(rawValue: $0.rawValue) }) } return .ok(.init(body: .json(schemaItems))) diff --git a/Sources/ServerClient/ServerClient.swift b/Sources/ServerClient/ServerClient.swift index c51af096..112fc836 100644 --- a/Sources/ServerClient/ServerClient.swift +++ b/Sources/ServerClient/ServerClient.swift @@ -92,8 +92,8 @@ public struct ServerClient { } } - public func getActions(limit: Int? = nil) async throws -> [ActionLogItem] { - let response = try await client.getActions(query: .init(limit: limit)) + public func getActions(limit: Int? = nil, runId: UUID? = nil) async throws -> [ActionLogItem] { + let response = try await client.getActions(query: .init(limit: limit, runId: runId?.uuidString)) return try response.ok.body.json.compactMap { item -> ActionLogItem? in guard let id = UUID(uuidString: item.id), let characteristic = CharacteristicsType(rawValue: item.entityId.characteristicType) else { @@ -110,7 +110,9 @@ public struct ServerClient { entityId: entityId, actionName: item.actionName, detailDescription: item.detailDescription, - hasCacheHit: item.hasCacheHit) + hasCacheHit: item.hasCacheHit, + runId: item.runId.flatMap(UUID.init(uuidString:)), + status: item.status.flatMap { ActionLogItem.Status(rawValue: $0.rawValue) }) } } diff --git a/Tests/HomeAutomationKitTests/ActionLogManagerTests.swift b/Tests/HomeAutomationKitTests/ActionLogManagerTests.swift index 2c423343..4e3afc51 100644 --- a/Tests/HomeAutomationKitTests/ActionLogManagerTests.swift +++ b/Tests/HomeAutomationKitTests/ActionLogManagerTests.swift @@ -138,4 +138,35 @@ struct ActionLogManagerTests { // Dedup is gone (expired) → the command is a fresh miss again. #expect(await manager.log(action: action) == false) } + + // MARK: - run id and status + + @Test("Logged actions carry run id and status, filterable by run id") + func runIdAndStatus() async { + let manager = ActionLogManager() + let runId = UUID() + let action = HomeManagableAction.turnOn(switchId) + + _ = await manager.log(action: action, runId: runId) + await manager.markExecuted(action) + _ = await manager.log(action: action, runId: runId) + _ = await manager.log(action: .setBrightness(brightnessId, 0.5)) + + let runActions = await manager.getActions(runId: runId) + #expect(runActions.map(\.status) == [.cacheHit, .executed]) + #expect(runActions.allSatisfy { $0.runId == runId }) + #expect(await manager.getActions().count == 3) + } + + @Test("markFailed flags the newest executed entry of the action") + func markFailed() async { + let manager = ActionLogManager() + let action = HomeManagableAction.turnOn(switchId) + + _ = await manager.log(action: action) + _ = await manager.log(action: action) + await manager.markFailed(action) + + #expect(await manager.getActions().map(\.status) == [.failed, .executed]) + } } diff --git a/Tests/HomeAutomationKitTests/HomeManagerTests.swift b/Tests/HomeAutomationKitTests/HomeManagerTests.swift index b82097db..b6340474 100644 --- a/Tests/HomeAutomationKitTests/HomeManagerTests.swift +++ b/Tests/HomeAutomationKitTests/HomeManagerTests.swift @@ -140,7 +140,7 @@ struct HomeManagerTests { await run.value #expect(await probe.startedPerforms == 0) - #expect(await homeManager.getActionLog(limit: nil).isEmpty) + #expect(await homeManager.getActionLog(limit: nil, runId: nil).isEmpty) } @Test("Cancelling a run does not abort a command that already started") @@ -181,6 +181,27 @@ struct HomeManagerTests { #expect(await probe.startedPerforms == 3) } + @Test("Retries keep the run id, and every failed attempt is logged as failed") + func retryKeepsRunIdAndLogsFailure() async { + let (ticks, tickContinuation) = AsyncStream.makeStream(of: Void.self) + let probe = AdapterProbe(failure: .unreachable) + let homeManager = await makeHomeManager(probe: probe, retryTicks: ticks) + let runId = UUID() + + await AutomationRunContext.$runId.withValue(runId) { + await homeManager.perform(.turnOn(switchId)) + } + for _ in 0..<2 { + tickContinuation.yield(()) + } + await probe.waitForPerforms(3) + try? await Task.sleep(for: .milliseconds(200)) + + let log = await homeManager.getActionLog(limit: nil, runId: runId) + #expect(log.count == 3) + #expect(log.allSatisfy { $0.status == .failed }) + } + @Test("Failed commands for different characteristics of one device are retried independently") func retriesAreKeyedPerCharacteristic() async { let brightnessId = EntityId(placeId: "room", name: "light", characteristicsName: nil, characteristic: .brightness) diff --git a/Tests/HomeAutomationKitTests/MockHomeAdapter.swift b/Tests/HomeAutomationKitTests/MockHomeAdapter.swift index 7065ca20..e19814e7 100644 --- a/Tests/HomeAutomationKitTests/MockHomeAdapter.swift +++ b/Tests/HomeAutomationKitTests/MockHomeAdapter.swift @@ -152,7 +152,7 @@ final class MockHomeAdapter: @unchecked Sendable, HomeManagable { func setWindowOpenState(entityId: EntityId, to state: WindowOpenState?) async { } - func getActionLog(limit: Int?) async -> [ActionLogItem] { + func getActionLog(limit: Int?, runId: UUID?) async -> [ActionLogItem] { return [] } diff --git a/openapi.yaml b/openapi.yaml index 2f2e156a..e8a99cc9 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -204,6 +204,13 @@ paths: minimum: 1 maximum: 1000 default: 1000 + - name: runId + in: query + description: Only return the actions sent by this automation run + required: false + schema: + type: string + format: uuid responses: "200": description: A success response with action logs. @@ -459,6 +466,14 @@ components: hasCacheHit: type: boolean description: Indicates if the action was a cache hit. + runId: + type: string + format: uuid + description: The automation run that sent the action. + status: + type: string + enum: [executed, cacheHit, failed] + description: Whether the action was sent, skipped as a duplicate, or failed. required: - id - timestamp From 9707fe7262974efd0b1c431d1cba48b5babe6ea6 Mon Sep 17 00:00:00 2001 From: Julian Kahnert Date: Sat, 3 Oct 2026 20:27:43 +0200 Subject: [PATCH 04/22] Serve the history of a whole room in one request GET /entities/history/room returns the history of every entity in a place. With `includePrevious`, the last state before startDate is appended as the oldest item, so a light that has been on since yesterday starts at the left edge of a timeline. GET /entities/history accepts `includePrevious` as well. --- Sources/HAModels/EntityHistoryItem.swift | 12 ++++ Sources/HAModels/StorageRepository.swift | 24 +++++++ .../Controllers/OpenAPIController.swift | 66 ++++++++++-------- Sources/ServerClient/ServerClient.swift | 68 +++++++++++-------- .../MockStorageRepository.swift | 2 +- .../RoomHistoryTests.swift | 53 +++++++++++++++ openapi.yaml | 63 +++++++++++++++++ 7 files changed, 233 insertions(+), 55 deletions(-) create mode 100644 Tests/HomeAutomationKitTests/RoomHistoryTests.swift diff --git a/Sources/HAModels/EntityHistoryItem.swift b/Sources/HAModels/EntityHistoryItem.swift index c86b8545..70e46690 100644 --- a/Sources/HAModels/EntityHistoryItem.swift +++ b/Sources/HAModels/EntityHistoryItem.swift @@ -154,6 +154,18 @@ extension EntityHistoryItem { } } +/// The history of one entity, as returned per entity of a room. +public struct EntityHistory: Sendable, Equatable { + public let entityId: EntityId + /// Newest first. + public let items: [EntityHistoryItem] + + public init(entityId: EntityId, items: [EntityHistoryItem]) { + self.entityId = entityId + self.items = items + } +} + /// Response wrapper for paginated entity history public struct EntityHistoryResponse: Sendable, Codable, Equatable { public let items: [EntityHistoryItem] diff --git a/Sources/HAModels/StorageRepository.swift b/Sources/HAModels/StorageRepository.swift index 1635bb5b..6619338e 100644 --- a/Sources/HAModels/StorageRepository.swift +++ b/Sources/HAModels/StorageRepository.swift @@ -34,3 +34,27 @@ public protocol StorageRepository: Sendable { /// - Returns: Array of unique EntityIds func getAllEntityIds() async throws -> [EntityId] } + +extension StorageRepository { + /// Like `getHistory`, but with `includePrevious` the last state before `startDate` is appended as + /// the oldest item, so a state that began before the window is not lost. Only on the last page. + public func getHistory(for entityId: EntityId, startDate: Date?, endDate: Date?, cursor: Date?, limit: Int, includePrevious: Bool) async throws -> [EntityStorageItem] { + var items = try await getHistory(for: entityId, startDate: startDate, endDate: endDate, cursor: cursor, limit: limit) + guard includePrevious, let startDate, items.count < limit, + let previous = try await getHistory(for: entityId, startDate: nil, endDate: startDate, cursor: nil, limit: 1).first else { + return items + } + items.append(previous) + return items + } + + /// History of every entity in `placeId`, at most 1000 items each, newest first. + public func getRoomHistory(placeId: String, startDate: Date?, endDate: Date?, includePrevious: Bool) async throws -> [(entityId: EntityId, items: [EntityStorageItem])] { + var result: [(entityId: EntityId, items: [EntityStorageItem])] = [] + for entityId in try await getAllEntityIds() where entityId.placeId == placeId { + let items = try await getHistory(for: entityId, startDate: startDate, endDate: endDate, cursor: nil, limit: 1000, includePrevious: includePrevious) + result.append((entityId, items)) + } + return result + } +} diff --git a/Sources/Server/Controllers/OpenAPIController.swift b/Sources/Server/Controllers/OpenAPIController.swift index 7adec55a..1fa354b9 100644 --- a/Sources/Server/Controllers/OpenAPIController.swift +++ b/Sources/Server/Controllers/OpenAPIController.swift @@ -257,39 +257,15 @@ struct OpenAPIController: APIProtocol { let cursor = input.query.cursor let limit = input.query.limit ?? 100 - // Query history from repository let historyItems = try await request.application.entityStorageDbRepository.getHistory( for: entityId, startDate: startDate, endDate: endDate, cursor: cursor, - limit: limit + limit: limit, + includePrevious: input.query.includePrevious ?? false ) - - // Map to OpenAPI schema types - let schemaItems = historyItems.map { item in - Components.Schemas.EntityHistoryItem( - timestamp: item.timestamp, - motionDetected: item.motionDetected, - illuminanceInLux: item.illuminance?.value, - isDeviceOn: item.isDeviceOn, - brightness: item.brightness, - colorTemperature: item.colorTemperature, - colorRed: item.color?.red, - colorGreen: item.color?.green, - colorBlue: item.color?.blue, - isContactOpen: item.isContactOpen, - isDoorLocked: item.isDoorLocked, - stateOfCharge: item.stateOfCharge, - isHeaterActive: item.isHeaterActive, - temperatureInC: item.temperatureInC?.value, - relativeHumidity: item.relativeHumidity, - carbonDioxideSensorId: item.carbonDioxideSensorId, - pmDensity: item.pmDensity, - airQuality: item.airQuality, - valveOpen: item.valveOpen - ) - } + let schemaItems = historyItems.map(Components.Schemas.EntityHistoryItem.init) // Calculate next cursor (timestamp of last item, or nil if no more data) let nextCursor = schemaItems.last?.timestamp @@ -302,6 +278,18 @@ struct OpenAPIController: APIProtocol { return .ok(.init(body: .json(response))) } + func getRoomHistory(_ input: Operations.GetRoomHistory.Input) async throws -> Operations.GetRoomHistory.Output { + let histories = try await request.application.entityStorageDbRepository.getRoomHistory(placeId: input.query.placeId, + startDate: input.query.startDate, + endDate: input.query.endDate, + includePrevious: input.query.includePrevious ?? false) + let body = histories.map { history in + Components.Schemas.EntityHistory(entityId: .init(history.entityId), + items: history.items.map(Components.Schemas.EntityHistoryItem.init)) + } + return .ok(.init(body: .json(body))) + } + } extension Components.Schemas.EntityId { @@ -326,3 +314,27 @@ extension Components.Schemas.AutomationRun { errorDescription: run.errorDescription) } } + +extension Components.Schemas.EntityHistoryItem { + init(_ item: EntityStorageItem) { + self.init(timestamp: item.timestamp, + motionDetected: item.motionDetected, + illuminanceInLux: item.illuminance?.value, + isDeviceOn: item.isDeviceOn, + brightness: item.brightness, + colorTemperature: item.colorTemperature, + colorRed: item.color?.red, + colorGreen: item.color?.green, + colorBlue: item.color?.blue, + isContactOpen: item.isContactOpen, + isDoorLocked: item.isDoorLocked, + stateOfCharge: item.stateOfCharge, + isHeaterActive: item.isHeaterActive, + temperatureInC: item.temperatureInC?.value, + relativeHumidity: item.relativeHumidity, + carbonDioxideSensorId: item.carbonDioxideSensorId, + pmDensity: item.pmDensity, + airQuality: item.airQuality, + valveOpen: item.valveOpen) + } +} diff --git a/Sources/ServerClient/ServerClient.swift b/Sources/ServerClient/ServerClient.swift index 112fc836..5bf3e6d5 100644 --- a/Sources/ServerClient/ServerClient.swift +++ b/Sources/ServerClient/ServerClient.swift @@ -143,7 +143,8 @@ public struct ServerClient { startDate: Date? = nil, endDate: Date? = nil, cursor: Date? = nil, - limit: Int = 100 + limit: Int = 100, + includePrevious: Bool = false ) async throws -> EntityHistoryResponse { let query = Operations.GetEntityHistory.Input.Query( placeId: entityId.placeId, @@ -153,41 +154,30 @@ public struct ServerClient { startDate: startDate, endDate: endDate, cursor: cursor, - limit: limit + limit: limit, + includePrevious: includePrevious ) let response = try await client.getEntityHistory(query: query) let historyResponse = try response.ok.body.json - let items = historyResponse.items.compactMap { item -> EntityHistoryItem? in - EntityHistoryItem( - timestamp: item.timestamp, - motionDetected: item.motionDetected, - illuminanceInLux: item.illuminanceInLux, - isDeviceOn: item.isDeviceOn, - brightness: item.brightness, - colorTemperature: item.colorTemperature, - colorRed: item.colorRed, - colorGreen: item.colorGreen, - colorBlue: item.colorBlue, - isContactOpen: item.isContactOpen, - isDoorLocked: item.isDoorLocked, - stateOfCharge: item.stateOfCharge, - isHeaterActive: item.isHeaterActive, - temperatureInC: item.temperatureInC, - relativeHumidity: item.relativeHumidity, - carbonDioxideSensorId: item.carbonDioxideSensorId, - pmDensity: item.pmDensity, - airQuality: item.airQuality, - valveOpen: item.valveOpen - ) - } - return EntityHistoryResponse( - items: items, + items: historyResponse.items.map(EntityHistoryItem.init), nextCursor: historyResponse.nextCursor ) } + + /// History of every entity in `placeId`, newest first; entities of unknown characteristic types are skipped. + public func getRoomHistory(placeId: String, startDate: Date? = nil, endDate: Date? = nil, includePrevious: Bool = false) async throws -> [EntityHistory] { + let response = try await client.getRoomHistory(query: .init(placeId: placeId, + startDate: startDate, + endDate: endDate, + includePrevious: includePrevious)) + return try response.ok.body.json.compactMap { history in + guard let entityId = EntityId(history.entityId) else { return nil } + return EntityHistory(entityId: entityId, items: history.items.map(EntityHistoryItem.init)) + } + } } extension Components.Schemas.PushDevice.TokenTypePayload { @@ -232,3 +222,27 @@ extension AutomationRun { errorDescription: run.errorDescription) } } + +extension EntityHistoryItem { + init(_ item: Components.Schemas.EntityHistoryItem) { + self.init(timestamp: item.timestamp, + motionDetected: item.motionDetected, + illuminanceInLux: item.illuminanceInLux, + isDeviceOn: item.isDeviceOn, + brightness: item.brightness, + colorTemperature: item.colorTemperature, + colorRed: item.colorRed, + colorGreen: item.colorGreen, + colorBlue: item.colorBlue, + isContactOpen: item.isContactOpen, + isDoorLocked: item.isDoorLocked, + stateOfCharge: item.stateOfCharge, + isHeaterActive: item.isHeaterActive, + temperatureInC: item.temperatureInC, + relativeHumidity: item.relativeHumidity, + carbonDioxideSensorId: item.carbonDioxideSensorId, + pmDensity: item.pmDensity, + airQuality: item.airQuality, + valveOpen: item.valveOpen) + } +} diff --git a/Tests/HomeAutomationKitTests/MockStorageRepository.swift b/Tests/HomeAutomationKitTests/MockStorageRepository.swift index 1bc68b16..014bd1bf 100644 --- a/Tests/HomeAutomationKitTests/MockStorageRepository.swift +++ b/Tests/HomeAutomationKitTests/MockStorageRepository.swift @@ -45,7 +45,7 @@ final class MockStorageRepository: StorageRepository, @unchecked Sendable { } if let endDate { - filtered = filtered.filter { $0.timestamp <= endDate } + filtered = filtered.filter { $0.timestamp < endDate } } if let cursor { diff --git a/Tests/HomeAutomationKitTests/RoomHistoryTests.swift b/Tests/HomeAutomationKitTests/RoomHistoryTests.swift new file mode 100644 index 00000000..3f424055 --- /dev/null +++ b/Tests/HomeAutomationKitTests/RoomHistoryTests.swift @@ -0,0 +1,53 @@ +// +// RoomHistoryTests.swift +// HomeAutomationKit +// + +import Foundation +import HAModels +import Testing + +/// Covers the `StorageRepository` extension the server uses for `/entities/history/room`. +struct RoomHistoryTests { + private let light = EntityId(placeId: "Kitchen", name: "Worklight", characteristicsName: nil, characteristic: .switcher) + private let window = EntityId(placeId: "Kitchen", name: "Window", characteristicsName: nil, characteristic: .contactSensor) + private let hallway = EntityId(placeId: "Hallway", name: "Light", characteristicsName: nil, characteristic: .switcher) + private let start = Date(timeIntervalSince1970: 10_000) + + private func item(_ entityId: EntityId, at offset: TimeInterval, isOn: Bool = true) -> EntityStorageItem { + EntityStorageItem(entityId: entityId, timestamp: start.addingTimeInterval(offset), isDeviceOn: isOn) + } + + @Test func returnsEveryEntityOfThePlaceOnly() async throws { + let repository = MockStorageRepository(items: [item(light, at: 10), item(window, at: 20), item(hallway, at: 30)]) + + let history = try await repository.getRoomHistory(placeId: "Kitchen", startDate: start, endDate: nil, includePrevious: false) + + #expect(Set(history.map(\.entityId)) == [light, window]) + } + + @Test func includePreviousAppendsTheStateBeforeTheWindow() async throws { + let repository = MockStorageRepository(items: [item(light, at: -7_200, isOn: false), item(light, at: -3_600), item(light, at: 60, isOn: false)]) + + let history = try await repository.getRoomHistory(placeId: "Kitchen", startDate: start, endDate: nil, includePrevious: true) + + let timestamps = try #require(history.first).items.map(\.timestamp) + #expect(timestamps == [start.addingTimeInterval(60), start.addingTimeInterval(-3_600)]) + } + + @Test func withoutIncludePreviousTheWindowIsExact() async throws { + let repository = MockStorageRepository(items: [item(light, at: -3_600), item(light, at: 60)]) + + let items = try await repository.getHistory(for: light, startDate: start, endDate: nil, cursor: nil, limit: 100, includePrevious: false) + + #expect(items.map(\.timestamp) == [start.addingTimeInterval(60)]) + } + + @Test func includePreviousIsSkippedWhileMorePagesFollow() async throws { + let repository = MockStorageRepository(items: [item(light, at: -3_600), item(light, at: 60), item(light, at: 120)]) + + let items = try await repository.getHistory(for: light, startDate: start, endDate: nil, cursor: nil, limit: 2, includePrevious: true) + + #expect(items.count == 2) + } +} diff --git a/openapi.yaml b/openapi.yaml index e8a99cc9..e8394c87 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -321,6 +321,13 @@ paths: minimum: 1 maximum: 1000 default: 100 + - name: includePrevious + in: query + description: Append the last state before startDate as the oldest item (only on the last page) + required: false + schema: + type: boolean + default: false responses: "200": description: A success response with entity history data. @@ -331,6 +338,49 @@ paths: "400": description: Bad request - invalid parameters. + /entities/history/room: + get: + summary: Get the history of a room + description: Retrieve the history of all entities in one place, at most 1000 items per entity, newest first. + operationId: getRoomHistory + parameters: + - name: placeId + in: query + description: The place identifier of the room + required: true + schema: + type: string + - name: startDate + in: query + description: ISO 8601 timestamp for the start of the time range (inclusive) + required: false + schema: + type: string + format: date-time + - name: endDate + in: query + description: ISO 8601 timestamp for the end of the time range (exclusive) + required: false + schema: + type: string + format: date-time + - name: includePrevious + in: query + description: Append the last state before startDate as the oldest item of each entity + required: false + schema: + type: boolean + default: false + responses: + "200": + description: The history of every entity in the room. + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/EntityHistory" + components: schemas: Automation: @@ -523,6 +573,19 @@ components: - placeId - name + EntityHistory: + type: object + properties: + entityId: + $ref: "#/components/schemas/EntityId" + items: + type: array + items: + $ref: "#/components/schemas/EntityHistoryItem" + required: + - entityId + - items + EntityHistoryItem: type: object properties: From f14b6cfa67871f2e6397b572fef06d1269f93bf4 Mon Sep 17 00:00:00 2001 From: Julian Kahnert Date: Sat, 3 Oct 2026 20:47:00 +0200 Subject: [PATCH 05/22] Show automation runs, grouping and inline toggles in the Controller Add run, room history and health endpoints to the server client dependency, refresh all tabs through one refreshAll action, group automations by status or type with an optimistic toggle, and list a detail's devices and 24 h runs with a run detail showing the commands it sent. --- Sources/Controller/App/AppFeature.swift | 5 +- .../Dependencies/ServerClientDependency.swift | 81 ++++- .../Extensions/AutomationInfo+Display.swift | 57 +++ .../Controller/Features/ActionsFeature.swift | 2 +- .../Features/AutomationDetails.swift | 122 +++++-- .../Features/AutomationsFeature.swift | 101 ++++-- .../Features/EntityHistoryDetailFeature.swift | 6 +- .../Features/RunDetailFeature.swift | 125 +++++++ Sources/Controller/Shared/RunViews.swift | 86 +++++ Sources/Controller/Shared/SharedKeys.swift | 6 + Sources/ServerClient/ServerClient.swift | 11 + Tests/ControllerTests/AppFeatureTests.swift | 340 +----------------- .../AutomationsFeatureTests.swift | 58 +++ .../RunDetailFeatureTests.swift | 44 +++ .../ServerClientDependencyTests.swift | 12 +- 15 files changed, 649 insertions(+), 407 deletions(-) create mode 100644 Sources/Controller/Extensions/AutomationInfo+Display.swift create mode 100644 Sources/Controller/Features/RunDetailFeature.swift create mode 100644 Sources/Controller/Shared/RunViews.swift create mode 100644 Tests/ControllerTests/AutomationsFeatureTests.swift create mode 100644 Tests/ControllerTests/RunDetailFeatureTests.swift diff --git a/Sources/Controller/App/AppFeature.swift b/Sources/Controller/App/AppFeature.swift index 2c2b0566..5e841693 100644 --- a/Sources/Controller/App/AppFeature.swift +++ b/Sources/Controller/App/AppFeature.swift @@ -82,6 +82,7 @@ struct AppFeature: Sendable { // Scene phase changes case scenePhaseChanged(old: ScenePhase, new: ScenePhase) + case refreshAll case binding(BindingAction) } @@ -201,7 +202,9 @@ struct AppFeature: Sendable { return .none } - // Refresh all tabs and start monitoring when app becomes active + return .send(.refreshAll) + + case .refreshAll: return .merge( .send(.automations(.refresh)), .send(.actions(.refresh)), diff --git a/Sources/Controller/Dependencies/ServerClientDependency.swift b/Sources/Controller/Dependencies/ServerClientDependency.swift index 5fa0ad9c..033687b8 100644 --- a/Sources/Controller/Dependencies/ServerClientDependency.swift +++ b/Sources/Controller/Dependencies/ServerClientDependency.swift @@ -28,8 +28,17 @@ struct ServerClientDependency: Sendable { /// Stop an automation by name var stop: @Sendable (_ name: String) async throws -> Void - /// Get action log items - var getActions: @Sendable (_ limit: Int?) async throws -> [ActionLogItem] + /// `true` when the server reaches its adapter + var isHealthy: @Sendable () async throws -> Bool + + /// Runs of one automation, newest first + var getRuns: @Sendable (_ automation: String, _ startDate: Date?, _ endDate: Date?, _ limit: Int?) async throws -> [AutomationRun] + + /// Runs of all automations since a date, newest first + var getRecentRuns: @Sendable (_ since: Date, _ limit: Int?) async throws -> [AutomationRun] + + /// Get action log items, optionally only those sent by one run + var getActions: @Sendable (_ limit: Int?, _ runId: UUID?) async throws -> [ActionLogItem] /// Clear all action log items var clearActions: @Sendable () async throws -> Void @@ -49,8 +58,12 @@ struct ServerClientDependency: Sendable { _ startDate: Date?, _ endDate: Date?, _ cursor: Date?, - _ limit: Int + _ limit: Int, + _ includePrevious: Bool ) async throws -> EntityHistoryResponse + + /// History of every entity in a room, newest first per entity + var getRoomHistory: @Sendable (_ placeId: String, _ startDate: Date?, _ endDate: Date?, _ includePrevious: Bool) async throws -> [EntityHistory] } // MARK: - Dependency Key Implementation @@ -61,26 +74,33 @@ extension ServerClientDependency: TestDependencyKey { activate: { _ in }, deactivate: { _ in }, stop: { _ in }, - getActions: { _ in [] }, + isHealthy: { true }, + getRuns: { _, _, _, _ in [] }, + getRecentRuns: { _, _ in [] }, + getActions: { _, _ in [] }, clearActions: { }, getWindowStates: { [] }, registerDevice: { _ in }, getEntityIdsWithHistory: { [] }, - getEntityHistory: { _, _, _, _, _ in EntityHistoryResponse(items: [], nextCursor: nil) } + getEntityHistory: { _, _, _, _, _, _ in EntityHistoryResponse(items: [], nextCursor: nil) }, + getRoomHistory: { _, _, _, _ in [] } ) static let previewValue = Self( getAutomations: { [ - AutomationInfo(name: "Morning Routine", isActive: true, isRunning: true), - AutomationInfo(name: "Evening Lights", isActive: true, isRunning: false), - AutomationInfo(name: "Vacation Mode", isActive: false, isRunning: false) + AutomationInfo(name: "Hallway Night Light", isActive: true, isRunning: true, type: "MotionAtNight", lastRun: previewRuns[0]), + AutomationInfo(name: "Evening Lights", isActive: true, isRunning: false, type: "Turn", lastRun: previewRuns[1]), + AutomationInfo(name: "Vacation Mode", isActive: false, isRunning: false, type: "TriggerScene") ] }, activate: { _ in }, deactivate: { _ in }, stop: { _ in }, - getActions: { _ in + isHealthy: { true }, + getRuns: { name, _, _, _ in previewRuns.filter { $0.automationName == name } }, + getRecentRuns: { _, _ in previewRuns }, + getActions: { _, _ in [ ActionLogItem( id: UUID(), @@ -113,7 +133,7 @@ extension ServerClientDependency: TestDependencyKey { ) ] }, - getEntityHistory: { entityId, _, _, _, _ in + getEntityHistory: { entityId, _, _, _, _, _ in // Generate realistic data based on entity type let items: [EntityHistoryItem] @@ -178,8 +198,26 @@ extension ServerClientDependency: TestDependencyKey { } return EntityHistoryResponse(items: items, nextCursor: nil) - } + }, + getRoomHistory: { _, _, _, _ in [] } ) + + private static let previewRuns = [ + AutomationRun( + automationName: "Hallway Night Light", + startedAt: Date().addingTimeInterval(-120), + trigger: AutomationTrigger(kind: .entityChange, entityId: nil, summary: "Motion · Eve Motion (Hallway)"), + outcome: .running + ), + AutomationRun( + automationName: "Evening Lights", + startedAt: Date().addingTimeInterval(-3600), + endedAt: Date().addingTimeInterval(-3590), + trigger: AutomationTrigger(kind: .sunset, entityId: nil, summary: "Sunset"), + outcome: .failed, + errorDescription: "Ceiling Light (Living Room) not reachable" + ) + ] } extension ServerClientDependency: DependencyKey { @@ -205,8 +243,17 @@ extension ServerClientDependency: DependencyKey { stop: { name in try await client.stop(automation: name) }, - getActions: { limit in - try await client.getActions(limit: limit) + isHealthy: { + try await client.isHealthy() + }, + getRuns: { name, startDate, endDate, limit in + try await client.getRuns(automation: name, startDate: startDate, endDate: endDate, limit: limit) + }, + getRecentRuns: { since, limit in + try await client.getRecentRuns(since: since, limit: limit) + }, + getActions: { limit, runId in + try await client.getActions(limit: limit, runId: runId) }, clearActions: { try await client.clearActions() @@ -220,14 +267,18 @@ extension ServerClientDependency: DependencyKey { getEntityIdsWithHistory: { try await client.getEntityIdsWithHistory() }, - getEntityHistory: { entityId, startDate, endDate, cursor, limit in + getEntityHistory: { entityId, startDate, endDate, cursor, limit, includePrevious in try await client.getEntityHistory( entityId: entityId, startDate: startDate, endDate: endDate, cursor: cursor, - limit: limit + limit: limit, + includePrevious: includePrevious ) + }, + getRoomHistory: { placeId, startDate, endDate, includePrevious in + try await client.getRoomHistory(placeId: placeId, startDate: startDate, endDate: endDate, includePrevious: includePrevious) } ) } diff --git a/Sources/Controller/Extensions/AutomationInfo+Display.swift b/Sources/Controller/Extensions/AutomationInfo+Display.swift new file mode 100644 index 00000000..41285a5b --- /dev/null +++ b/Sources/Controller/Extensions/AutomationInfo+Display.swift @@ -0,0 +1,57 @@ +// +// AutomationInfo+Display.swift +// Controller +// + +import Foundation +import HAModels + +extension AutomationInfo { + /// Label, symbol and trigger configuration per automation type; keys match the server's type names. + private static let typeDisplay: [String: (label: String, systemImage: String, trigger: String)] = [ + "CreateScene": ("Scene", "sparkles", "Manual"), + "EnergyLowPrice": ("Energy Price", "bolt", "Electricity price"), + "GardenWatering": ("Garden", "drop", "Schedule"), + "HealthCheck": ("System", "stethoscope", "Every minute"), + "MaintenanceAutomation": ("System", "wrench.and.screwdriver", "Schedule"), + "MotionAtNight": ("Motion", "figure.walk", "Motion sensor"), + "RestartSystem": ("System", "arrow.clockwise", "Schedule"), + "SetLightProperties": ("Light", "lightbulb", "Light change"), + "TriggerScene": ("Scene", "sparkles", "Device change"), + "Turn": ("Schedule", "clock", "Schedule"), + "TurnOnForDuration": ("Light", "timer", "Device change"), + "UpsertScene": ("Scene", "sparkles", "Manual"), + "WindowOpen": ("Window", "window.casement", "Window contact") + ] + + var typeLabel: String { + type.flatMap { Self.typeDisplay[$0]?.label } ?? "Other" + } + + var systemImage: String { + type.flatMap { Self.typeDisplay[$0]?.systemImage } ?? "gearshape" + } + + var triggerDescription: String { + type.flatMap { Self.typeDisplay[$0]?.trigger } ?? "Unknown trigger" + } + + /// "last 21:05 · Motion · Eve Motion (Hallway)", or the trigger configuration before the first run. + var subtitle: String { + guard let lastRun else { return triggerDescription } + return "last \(lastRun.startedAt.formatted(date: .omitted, time: .shortened)) · \(lastRun.trigger.summary)" + } +} + +extension AutomationRun.Outcome { + var label: String { + switch self { + case .running: return "Running" + case .completed: return "Completed" + case .superseded: return "Superseded" + case .stopped: return "Stopped" + case .failed: return "Failed" + case .interrupted: return "Interrupted" + } + } +} diff --git a/Sources/Controller/Features/ActionsFeature.swift b/Sources/Controller/Features/ActionsFeature.swift index f0fe8bb5..ab6a42b1 100644 --- a/Sources/Controller/Features/ActionsFeature.swift +++ b/Sources/Controller/Features/ActionsFeature.swift @@ -96,7 +96,7 @@ struct ActionsFeature: Sendable { let limit = state.limit return .run { send in await send(.actionsResponse( - Result { try await serverClient.getActions(limit) } + Result { try await serverClient.getActions(limit, nil) } )) } diff --git a/Sources/Controller/Features/AutomationDetails.swift b/Sources/Controller/Features/AutomationDetails.swift index 7ce9237b..f22bd852 100644 --- a/Sources/Controller/Features/AutomationDetails.swift +++ b/Sources/Controller/Features/AutomationDetails.swift @@ -14,6 +14,12 @@ import SwiftUI @Reducer struct AutomationDetails: Sendable { + @Reducer + enum Destination { + case entity(EntityHistoryDetailFeature) + case run(RunDetailFeature) + } + // MARK: - State @ObservableState @@ -21,20 +27,59 @@ struct AutomationDetails: Sendable { @Shared var automation: AutomationInfo var isLoading = false var error: String? + /// Last 24 hours, newest first. + var runs: [AutomationRun] = [] + @Presents var destination: Destination.State? } enum Action: Sendable { + case destination(PresentationAction) + case entityTapped(EntityId) + case runTapped(AutomationRun) + case runsResponse(Result<[AutomationRun], Error>) + case task case stopAutomation case updateIsActive(Bool) case isActiveOperationResponse(Result) case stopOperationResponse(Result) } + @Dependency(\.date.now) var now @Dependency(\.serverClient) var serverClient var body: some ReducerOf { Reduce { state, action in switch action { + case .destination(.presented(.run(.delegate(.openAutomation)))): + state.destination = nil + return .none + + case .destination: + return .none + + case let .entityTapped(entityId): + state.destination = .entity(EntityHistoryDetailFeature.State(entity: EntityInfo(entityId: entityId))) + return .none + + case let .runTapped(run): + state.destination = .run(RunDetailFeature.State(run: run)) + return .none + + case let .runsResponse(.success(runs)): + state.runs = runs + return .none + + case let .runsResponse(.failure(error)): + state.error = "Failed to load runs: \(error.localizedDescription)" + return .none + + case .task: + guard state.automation.recordsRuns else { return .none } + return .run { [name = state.automation.name, now] send in + await send(.runsResponse(Result { + try await serverClient.getRuns(name, now.addingTimeInterval(-86400), nil, nil) + })) + } case .stopAutomation: state.error = nil state.isLoading = true @@ -73,7 +118,7 @@ struct AutomationDetails: Sendable { case .stopOperationResponse(.success): state.isLoading = false state.$automation.withLock { $0.isRunning = false } - return .none + return .send(.task) case let .stopOperationResponse(.failure(error)): state.isLoading = false @@ -81,49 +126,79 @@ struct AutomationDetails: Sendable { return .none } } + .ifLet(\.$destination, action: \.destination) } } +extension AutomationDetails.Destination.State: Equatable, Sendable {} +extension AutomationDetails.Destination.Action: Sendable {} + struct AutomationDetailView: View { @Bindable var store: StoreOf var body: some View { Form { Section { - HStack { - Text("Is Running") - Spacer() - Image(systemName: "circle.fill") - .foregroundStyle(store.automation.isRunning ? Color.accentColor : Color.gray.opacity(0.3)) - } - } - - Section { - Toggle("Is Active", isOn: Binding( + Label(store.automation.typeLabel, systemImage: store.automation.systemImage) + LabeledContent("Trigger", value: store.automation.triggerDescription) + Toggle("Active", isOn: Binding( get: { store.automation.isActive }, - set: { value in - store.send(.updateIsActive(value)) - } + set: { store.send(.updateIsActive($0)) } )) .disabled(store.isLoading) - .overlay { - if store.isLoading { - ProgressView() + if store.automation.isRunning { + Button("Stop", role: .destructive) { + store.send(.stopAutomation) + } + .foregroundStyle(.red) + .disabled(store.isLoading) + } + } footer: { + if let error = store.error { + Text(error) + .foregroundStyle(Color.red) + } + } + + if !store.automation.entities.isEmpty { + Section("Devices") { + ForEach(store.automation.entities, id: \.self) { entityId in + Button { + store.send(.entityTapped(entityId)) + } label: { + LabeledContent(entityId.name, value: entityId.characteristicType.displayName) + } } } } Section { - Button("Stop Automation") { - store.send(.stopAutomation) + ForEach(store.runs) { run in + Button { + store.send(.runTapped(run)) + } label: { + RunRow(run: run, showsAutomationName: false) + } } + } header: { + Text("Runs · 24 h") } footer: { - if let error = store.error { - Text(error) - .foregroundStyle(Color.red) + if !store.automation.recordsRuns { + Text("Runs are not recorded for this automation.") + } else if store.runs.isEmpty { + Text("No runs in the last 24 hours.") } } } + .buttonStyle(.plain) + .task { await store.send(.task).finish() } + .navigationDestination(item: $store.scope(state: \.destination?.entity, action: \.destination.entity)) { entityStore in + EntityHistoryDetailView(store: entityStore) + .navigationTitle(entityStore.entity.displayName) + } + .navigationDestination(item: $store.scope(state: \.destination?.run, action: \.destination.run)) { runStore in + RunDetailView(store: runStore) + } } } @@ -134,7 +209,8 @@ struct AutomationDetailView: View { automation: Shared(value: AutomationInfo( name: "Test Automation", isActive: true, - isRunning: true + isRunning: true, + type: "MotionAtNight" )) ) ) { diff --git a/Sources/Controller/Features/AutomationsFeature.swift b/Sources/Controller/Features/AutomationsFeature.swift index c869fa80..9d23e224 100644 --- a/Sources/Controller/Features/AutomationsFeature.swift +++ b/Sources/Controller/Features/AutomationsFeature.swift @@ -14,23 +14,35 @@ import SwiftUI @Reducer struct AutomationsFeature: Sendable { + enum Grouping: String, CaseIterable, Sendable { + case status + case type + } + // MARK: - State @ObservableState struct State: Equatable, Sendable { @Shared(.automations) var automations: IdentifiedArrayOf = [] + @Shared(.automationGrouping) var grouping: Grouping = .status var isLoading = false var selectedAutomationIndex: String? var error: String? @Presents var selectedAutomation: AutomationDetails.State? - var runningAutomations: [AutomationInfo] { - automations.filter(\.isRunning) - } - - var inactiveAutomations: [AutomationInfo] { - automations.filter { !$0.isRunning } + /// Running automations first within each section. + var sections: [(title: String, automations: [AutomationInfo])] { + let sorted = automations.sorted { ($0.isRunning ? 0 : 1, $0.name) < ($1.isRunning ? 0 : 1, $1.name) } + switch grouping { + case .status: + return [("Active", sorted.filter(\.isActive)), ("Inactive", sorted.filter { !$0.isActive })] + .filter { !$0.1.isEmpty } + case .type: + return Dictionary(grouping: sorted, by: \.typeLabel) + .sorted { $0.key < $1.key } + .map { ($0.key, $0.value) } + } } } @@ -41,8 +53,10 @@ struct AutomationsFeature: Sendable { case onAppear case refresh case automationsResponse(Result<[AutomationInfo], Error>) - case automationOperationResponse(Result) + case setActive(name: String, Bool) + case setActiveResponse(name: String, Result) case dismissError + case groupingChanged(Grouping) case selectedAutomation(PresentationAction) } @@ -93,20 +107,36 @@ struct AutomationsFeature: Sendable { state.error = "Failed to load automations: \(error.localizedDescription)" return .none - case .automationOperationResponse(.success): - // Refresh the list after a successful operation + case let .setActive(name, isActive): + state.error = nil + state.$automations.withLock { $0[id: name]?.isActive = isActive } return .run { send in - await send(.refresh) + await send(.setActiveResponse(name: name, Result { + if isActive { + try await serverClient.activate(name) + } else { + try await serverClient.deactivate(name) + } + return isActive + })) } - case let .automationOperationResponse(.failure(error)): - state.error = "Operation failed: \(error.localizedDescription)" + case .setActiveResponse(_, .success): + return .none + + case let .setActiveResponse(name, .failure(error)): + state.$automations.withLock { $0[id: name]?.isActive.toggle() } + state.error = "Failed to change \(name): \(error.localizedDescription)" return .none case .dismissError: state.error = nil return .none + case let .groupingChanged(grouping): + state.$grouping.withLock { $0 = grouping } + return .none + case .selectedAutomation: return .none } @@ -123,18 +153,21 @@ struct AutomationsView: View { var body: some View { NavigationStack { List(selection: $store.selectedAutomationIndex) { - if !store.runningAutomations.isEmpty { - Section("Running") { - ForEach(store.runningAutomations) { automation in - automationRow(automation) - .tag(automation.id) - } - } + Picker("Grouping", selection: Binding(get: { store.grouping }, set: { store.send(.groupingChanged($0)) })) { + Text("Active / Inactive").tag(AutomationsFeature.Grouping.status) + Text("By Type").tag(AutomationsFeature.Grouping.type) + } + .pickerStyle(.segmented) + .listRowBackground(Color.clear) + + if let error = store.error { + Text(error) + .foregroundStyle(.red) } - if !store.inactiveAutomations.isEmpty { - Section("Inactive") { - ForEach(store.inactiveAutomations, id: \.name) { automation in + ForEach(store.sections, id: \.title) { section in + Section(section.title) { + ForEach(section.automations) { automation in automationRow(automation) .tag(automation.id) } @@ -169,16 +202,26 @@ struct AutomationsView: View { } } - @ViewBuilder private func automationRow(_ automation: AutomationInfo) -> some View { HStack { - Text(automation.name) - .foregroundStyle(automation.isRunning ? Color.accentColor : Color.primary) - Spacer() - if !automation.isActive { - Image(systemName: "x.circle") - .foregroundStyle(Color.red) + Image(systemName: automation.systemImage) + .frame(width: 28) + .foregroundStyle(Color.accentColor) + VStack(alignment: .leading, spacing: 2) { + Text(automation.name) + Text(automation.subtitle) + .font(.subheadline) + .foregroundStyle(.secondary) + .lineLimit(1) } + Spacer() + Pill(outcome: .running) + .opacity(automation.isRunning ? 1 : 0) + Toggle("Active", isOn: Binding( + get: { automation.isActive }, + set: { store.send(.setActive(name: automation.name, $0)) } + )) + .labelsHidden() } } } diff --git a/Sources/Controller/Features/EntityHistoryDetailFeature.swift b/Sources/Controller/Features/EntityHistoryDetailFeature.swift index 703bc9e4..60f5b4fa 100644 --- a/Sources/Controller/Features/EntityHistoryDetailFeature.swift +++ b/Sources/Controller/Features/EntityHistoryDetailFeature.swift @@ -106,7 +106,8 @@ struct EntityHistoryDetailFeature: Sendable { dateRange.start, dateRange.end, nil, - 5000 // Higher limit for initial load + 1000, + true ) } )) @@ -128,7 +129,8 @@ struct EntityHistoryDetailFeature: Sendable { dateRange.start, dateRange.end, cursor, - 1000 + 1000, + false ) } )) diff --git a/Sources/Controller/Features/RunDetailFeature.swift b/Sources/Controller/Features/RunDetailFeature.swift new file mode 100644 index 00000000..1d9d9ac4 --- /dev/null +++ b/Sources/Controller/Features/RunDetailFeature.swift @@ -0,0 +1,125 @@ +// +// RunDetailFeature.swift +// Controller +// +// One automation run with the commands it sent +// + +import ComposableArchitecture +import Foundation +import HAModels +import SwiftUI + +@Reducer +struct RunDetailFeature: Sendable { + + @ObservableState + struct State: Equatable, Sendable { + let run: AutomationRun + var actions: [ActionLogItem] = [] + var error: String? + } + + enum Action: Sendable { + case actionsResponse(Result<[ActionLogItem], Error>) + case delegate(Delegate) + case openAutomationButtonTapped + case task + + enum Delegate: Sendable { + case openAutomation(String) + } + } + + @Dependency(\.serverClient) var serverClient + + var body: some ReducerOf { + Reduce { state, action in + switch action { + case let .actionsResponse(.success(actions)): + state.actions = actions + return .none + + case let .actionsResponse(.failure(error)): + state.error = "Failed to load commands: \(error.localizedDescription)" + return .none + + case .delegate: + return .none + + case .openAutomationButtonTapped: + return .send(.delegate(.openAutomation(state.run.automationName))) + + case .task: + return .run { [runId = state.run.id] send in + await send(.actionsResponse(Result { try await serverClient.getActions(nil, runId) })) + } + } + } + } +} + +struct RunDetailView: View { + let store: StoreOf + + var body: some View { + Form { + Section { + LabeledContent("Automation", value: store.run.automationName) + LabeledContent("Trigger", value: store.run.trigger.summary) + LabeledContent("Start", value: store.run.startedAt.formatted(date: .abbreviated, time: .standard)) + if let endedAt = store.run.endedAt { + LabeledContent("Duration", value: Duration.seconds(endedAt.timeIntervalSince(store.run.startedAt)) + .formatted(.units(allowed: [.hours, .minutes, .seconds], width: .abbreviated))) + } + LabeledContent("Outcome") { + Pill(outcome: store.run.outcome) + } + if let errorDescription = store.run.errorDescription { + LabeledContent("Error") { + Text(errorDescription).foregroundStyle(.red) + } + } + } + + Section { + ForEach(store.actions) { item in + ActionRow(item: item) + } + } header: { + Text("Commands") + } footer: { + if let error = store.error { + Text(error).foregroundStyle(.red) + } else if store.actions.isEmpty { + Text("No commands recorded. Commands of runs before the last server restart are gone.") + } + } + + Section { + Button("Open Automation") { + store.send(.openAutomationButtonTapped) + } + } + } + .navigationTitle("Run") + .task { await store.send(.task).finish() } + } +} + +#Preview { + NavigationStack { + RunDetailView( + store: Store(initialState: RunDetailFeature.State(run: AutomationRun( + automationName: "Bathroom Night Light", + startedAt: Date().addingTimeInterval(-60), + endedAt: Date().addingTimeInterval(-46), + trigger: AutomationTrigger(kind: .entityChange, entityId: nil, summary: "Motion · Eve Motion (Bathroom)"), + outcome: .failed, + errorDescription: "Mirror Light (Bathroom) not reachable" + ))) { + RunDetailFeature() + } + ) + } +} diff --git a/Sources/Controller/Shared/RunViews.swift b/Sources/Controller/Shared/RunViews.swift new file mode 100644 index 00000000..7703d9d3 --- /dev/null +++ b/Sources/Controller/Shared/RunViews.swift @@ -0,0 +1,86 @@ +// +// RunViews.swift +// Controller +// + +import HAModels +import SwiftUI + +struct Pill: View { + let text: String + let color: Color + + var body: some View { + Text(text) + .font(.caption.bold()) + .padding(.horizontal, 8) + .padding(.vertical, 2) + .foregroundStyle(color) + .background(color.opacity(0.15), in: .capsule) + } +} + +extension Pill { + init(outcome: AutomationRun.Outcome) { + switch outcome { + case .running: self.init(text: outcome.label, color: .green) + case .completed: self.init(text: outcome.label, color: .accentColor) + case .failed, .interrupted: self.init(text: outcome.label, color: .red) + case .superseded, .stopped: self.init(text: outcome.label, color: .secondary) + } + } + + init(status: ActionLogItem.Status) { + switch status { + case .executed: self.init(text: "Fresh", color: .green) + case .cacheHit: self.init(text: "Cache", color: .yellow) + case .failed: self.init(text: "Failed", color: .red) + } + } +} + +struct RunRow: View { + let run: AutomationRun + var showsAutomationName = true + + var body: some View { + HStack { + VStack(alignment: .leading, spacing: 2) { + if showsAutomationName { + Text(run.automationName) + } + Text(run.trigger.summary) + .font(.subheadline) + .foregroundStyle(.secondary) + Text(run.startedAt, format: .dateTime.weekday().hour().minute()) + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer() + Pill(outcome: run.outcome) + } + } +} + +struct ActionRow: View { + let item: ActionLogItem + + var body: some View { + HStack { + VStack(alignment: .leading, spacing: 2) { + Text(item.detailDescription) + .font(.body.monospaced()) + Text("\(item.entityId.name) (\(item.entityId.placeId))") + .font(.subheadline) + .foregroundStyle(.secondary) + } + Spacer() + Text(item.timestamp, format: .dateTime.hour().minute().second()) + .font(.caption) + .foregroundStyle(.secondary) + if let status = item.status { + Pill(status: status) + } + } + } +} diff --git a/Sources/Controller/Shared/SharedKeys.swift b/Sources/Controller/Shared/SharedKeys.swift index dfc138de..17e9d7ce 100644 --- a/Sources/Controller/Shared/SharedKeys.swift +++ b/Sources/Controller/Shared/SharedKeys.swift @@ -34,6 +34,12 @@ extension SharedKey where Self == AppStorageKey.Default { } } +extension SharedKey where Self == AppStorageKey.Default { + static var automationGrouping: Self { + Self[.appStorage("automationGrouping", store: .standard), default: .status] + } +} + // MARK: - In-Memory (volatile state) extension SharedKey where Self == InMemoryKey> { diff --git a/Sources/ServerClient/ServerClient.swift b/Sources/ServerClient/ServerClient.swift index 5bf3e6d5..0459e966 100644 --- a/Sources/ServerClient/ServerClient.swift +++ b/Sources/ServerClient/ServerClient.swift @@ -11,6 +11,8 @@ import OpenAPIURLSession public struct ServerClient { private let client: Client + private let url: URL + private let session: URLSession public init(url: URL, authToken: String? = nil) { // Create URLSession with authentication header if token is provided @@ -25,6 +27,8 @@ public struct ServerClient { session = URLSession.shared } + self.url = url + self.session = session self.client = Client( serverURL: url, transport: URLSessionTransport(configuration: .init(session: session)) @@ -45,6 +49,13 @@ public struct ServerClient { } } + /// `true` when the server and its adapter connection are up. `GET /health` is not part of the + /// OpenAPI spec because the Docker healthcheck owns it, so this calls it directly. + public func isHealthy() async throws -> Bool { + let (_, response) = try await session.data(from: url.appending(path: "health")) + return (response as? HTTPURLResponse)?.statusCode == 200 + } + public func getRuns(automation name: String, startDate: Date? = nil, endDate: Date? = nil, limit: Int? = nil) async throws -> [AutomationRun] { let response = try await client.getAutomationRuns(path: .init(name: name), query: .init(startDate: startDate, endDate: endDate, limit: limit)) diff --git a/Tests/ControllerTests/AppFeatureTests.swift b/Tests/ControllerTests/AppFeatureTests.swift index 59c354de..8489e538 100644 --- a/Tests/ControllerTests/AppFeatureTests.swift +++ b/Tests/ControllerTests/AppFeatureTests.swift @@ -9,199 +9,15 @@ import ComposableArchitecture @testable import Controller import Dependencies import HAModels +import SwiftUI import Testing @Suite("AppFeature Tests") struct AppFeatureTests { - @Test("scenePhaseChanged from inactive to active dispatches refresh to all child features") + @Test("activation from inactive or background sends refreshAll", arguments: [ScenePhase.inactive, .background]) @MainActor - func testScenePhaseChangedInactiveToActive() async { - let store = TestStore(initialState: AppFeature.State()) { - AppFeature() - } withDependencies: { - $0.serverClient = .testValue - $0.liveActivity = .testValue - $0.pushNotification = .testValue - } - - // Send the scene change action for inactive -> active transition - await store.send(.scenePhaseChanged(old: .inactive, new: .active)) - - // Verify that refresh actions are dispatched to all child features - // Note: Actions may arrive in any order due to parallel execution via .merge() - await store.receive(\.automations.refresh) { state in - state.automations.isLoading = true - state.automations.error = nil - } - - await store.receive(\.actions.refresh) { state in - state.actions.isLoading = true - state.actions.alert = nil - } - - await store.receive(\.history.refresh) { state in - state.history.isLoading = true - state.history.alert = nil - } - - await store.receive(\.refreshWindowStates) - - await store.receive(\.startMonitoringLiveActivities) - - await store.receive(\.clearDeliveredNotifications) - - await store.receive(\.settings.refreshWindowStates) { state in - state.settings.isLoadingWindowStates = true - state.settings.error = nil - } - - // Verify that the refresh operations complete - // History response comes first because it's synchronous - await store.receive(\.history.entitiesResponse) { state in - state.history.isLoading = false - state.history.entities = [] - } - - await store.receive(\.automations.automationsResponse) { state in - state.automations.isLoading = false - } - - await store.receive(\.actions.actionsResponse) { state in - state.actions.isLoading = false - } - - await store.receive(\.settings.windowStatesResponse) { state in - state.settings.isLoadingWindowStates = false - state.settings.windowContentState = WindowContentState(windowStates: []) - } - } - - @Test("scenePhaseChanged from background to active dispatches refresh to all child features") - @MainActor - func testScenePhaseChangedBackgroundToActive() async { - let store = TestStore(initialState: AppFeature.State()) { - AppFeature() - } withDependencies: { - $0.serverClient = .testValue - $0.liveActivity = .testValue - $0.pushNotification = .testValue - } - - // Send the scene change action for background -> active transition - await store.send(.scenePhaseChanged(old: .background, new: .active)) - - // Verify that refresh actions are dispatched to all child features - await store.receive(\.automations.refresh) { state in - state.automations.isLoading = true - state.automations.error = nil - } - - await store.receive(\.actions.refresh) { state in - state.actions.isLoading = true - state.actions.alert = nil - } - - await store.receive(\.history.refresh) { state in - state.history.isLoading = true - state.history.alert = nil - } - - await store.receive(\.refreshWindowStates) - - await store.receive(\.startMonitoringLiveActivities) - - await store.receive(\.clearDeliveredNotifications) - - await store.receive(\.settings.refreshWindowStates) { state in - state.settings.isLoadingWindowStates = true - state.settings.error = nil - } - - // Verify that the refresh operations complete - await store.receive(\.history.entitiesResponse) { state in - state.history.isLoading = false - state.history.entities = [] - } - - await store.receive(\.automations.automationsResponse) { state in - state.automations.isLoading = false - } - - await store.receive(\.actions.actionsResponse) { state in - state.actions.isLoading = false - } - - await store.receive(\.settings.windowStatesResponse) { state in - state.settings.isLoadingWindowStates = false - state.settings.windowContentState = WindowContentState(windowStates: []) - } - } - - @Test("scenePhaseChanged refreshes run in parallel") - @MainActor - func testParallelRefresh() async { - let store = TestStore(initialState: AppFeature.State()) { - AppFeature() - } withDependencies: { - $0.serverClient = .testValue - $0.liveActivity = .testValue - $0.pushNotification = .testValue - } - - // Send the scene change action - await store.send(.scenePhaseChanged(old: .background, new: .active)) - - // All refresh actions should be dispatched - await store.receive(\.automations.refresh) { state in - state.automations.isLoading = true - state.automations.error = nil - } - - await store.receive(\.actions.refresh) { state in - state.actions.isLoading = true - state.actions.alert = nil - } - - await store.receive(\.history.refresh) { state in - state.history.isLoading = true - state.history.alert = nil - } - - await store.receive(\.refreshWindowStates) - - await store.receive(\.startMonitoringLiveActivities) - - await store.receive(\.clearDeliveredNotifications) - - await store.receive(\.settings.refreshWindowStates) { state in - state.settings.isLoadingWindowStates = true - state.settings.error = nil - } - - // Complete all operations - await store.receive(\.history.entitiesResponse) { state in - state.history.isLoading = false - state.history.entities = [] - } - - await store.receive(\.automations.automationsResponse) { state in - state.automations.isLoading = false - } - - await store.receive(\.actions.actionsResponse) { state in - state.actions.isLoading = false - } - - await store.receive(\.settings.windowStatesResponse) { state in - state.settings.isLoadingWindowStates = false - state.settings.windowContentState = WindowContentState(windowStates: []) - } - } - - @Test("scenePhaseChanged with mock data refreshes successfully") - @MainActor - func testScenePhaseChangedWithMockData() async { + func activationRefreshesAll(oldPhase: ScenePhase) async { let store = TestStore(initialState: AppFeature.State()) { AppFeature() } withDependencies: { @@ -209,32 +25,16 @@ struct AppFeatureTests { $0.liveActivity = .testValue $0.pushNotification = .testValue } - - // Disable exhaustive testing to avoid issues with exact date matching in WindowState + // Each child owns its refresh; this test only pins that activation reaches them. store.exhaustivity = .off - await store.send(.scenePhaseChanged(old: .inactive, new: .active)) + await store.send(.scenePhaseChanged(old: oldPhase, new: .active)) + await store.receive(\.refreshAll) + await store.finish() + await store.skipReceivedActions() - // Wait for all actions to complete - await store.receive(\.automations.refresh) - await store.receive(\.actions.refresh) - await store.receive(\.history.refresh) - await store.receive(\.refreshWindowStates) - await store.receive(\.startMonitoringLiveActivities) - await store.receive(\.clearDeliveredNotifications) - await store.receive(\.settings.refreshWindowStates) - await store.receive(\.history.entitiesResponse) - await store.receive(\.automations.automationsResponse) - await store.receive(\.actions.actionsResponse) - await store.receive(\.settings.windowStatesResponse) - - // Verify the final state - let finalState = store.state - #expect(finalState.automations.automations.count == 3) - #expect(finalState.actions.actions.count == 1) - #expect(finalState.settings.windowContentState?.windowStates.count == 1) - #expect(finalState.settings.windowContentState?.windowStates.first?.name == "Living Room Window") - #expect(finalState.history.entities.count == 2) // Preview value has 2 entities + #expect(store.state.automations.automations.count == 3) + #expect(store.state.settings.windowContentState?.windowStates.first?.name == "Living Room Window") } @Test("scenePhaseChanged ignores non-active transitions") @@ -273,66 +73,6 @@ struct AppFeatureTests { // No refresh actions should be dispatched } - @Test("scenePhaseChanged triggers monitoring on app launch") - @MainActor - func testScenePhaseChangedTriggersMonitoring() async { - let store = TestStore(initialState: AppFeature.State()) { - AppFeature() - } withDependencies: { - $0.serverClient = .testValue - $0.liveActivity = .testValue - $0.pushNotification = .testValue - } - - // Initial app launch triggers monitoring - await store.send(.scenePhaseChanged(old: .inactive, new: .active)) - - await store.receive(\.automations.refresh) { state in - state.automations.isLoading = true - state.automations.error = nil - } - - await store.receive(\.actions.refresh) { state in - state.actions.isLoading = true - state.actions.alert = nil - } - - await store.receive(\.history.refresh) { state in - state.history.isLoading = true - state.history.alert = nil - } - - await store.receive(\.refreshWindowStates) - - await store.receive(\.startMonitoringLiveActivities) - - await store.receive(\.clearDeliveredNotifications) - - await store.receive(\.settings.refreshWindowStates) { state in - state.settings.isLoadingWindowStates = true - state.settings.error = nil - } - - // Wait for all operations to complete - await store.receive(\.history.entitiesResponse) { state in - state.history.isLoading = false - state.history.entities = [] - } - - await store.receive(\.automations.automationsResponse) { state in - state.automations.isLoading = false - } - - await store.receive(\.actions.actionsResponse) { state in - state.actions.isLoading = false - } - - await store.receive(\.settings.windowStatesResponse) { state in - state.settings.isLoadingWindowStates = false - state.settings.windowContentState = WindowContentState(windowStates: []) - } - } - @Test("refreshWindowStates delegates to settings feature") @MainActor func testRefreshWindowStates() async { @@ -356,64 +96,4 @@ struct AppFeatureTests { state.settings.windowContentState = WindowContentState(windowStates: []) } } - - @Test("scenePhaseChanged dispatches clearDeliveredNotifications on app activation") - @MainActor - func testClearDeliveredNotificationsOnActivation() async { - let store = TestStore(initialState: AppFeature.State()) { - AppFeature() - } withDependencies: { - $0.serverClient = .testValue - $0.liveActivity = .testValue - $0.pushNotification = .testValue - } - - await store.send(.scenePhaseChanged(old: .inactive, new: .active)) - - // Verify that all parallel actions are dispatched (including clearDeliveredNotifications) - // Note: Actions may arrive in any order due to parallel execution via .merge() - await store.receive(\.automations.refresh) { state in - state.automations.isLoading = true - state.automations.error = nil - } - - await store.receive(\.actions.refresh) { state in - state.actions.isLoading = true - state.actions.alert = nil - } - - await store.receive(\.history.refresh) { state in - state.history.isLoading = true - state.history.alert = nil - } - - await store.receive(\.refreshWindowStates) - - await store.receive(\.startMonitoringLiveActivities) - - await store.receive(\.clearDeliveredNotifications) - - await store.receive(\.settings.refreshWindowStates) { state in - state.settings.isLoadingWindowStates = true - state.settings.error = nil - } - - await store.receive(\.history.entitiesResponse) { state in - state.history.isLoading = false - state.history.entities = [] - } - - await store.receive(\.automations.automationsResponse) { state in - state.automations.isLoading = false - } - - await store.receive(\.actions.actionsResponse) { state in - state.actions.isLoading = false - } - - await store.receive(\.settings.windowStatesResponse) { state in - state.settings.isLoadingWindowStates = false - state.settings.windowContentState = WindowContentState(windowStates: []) - } - } } diff --git a/Tests/ControllerTests/AutomationsFeatureTests.swift b/Tests/ControllerTests/AutomationsFeatureTests.swift new file mode 100644 index 00000000..a284048d --- /dev/null +++ b/Tests/ControllerTests/AutomationsFeatureTests.swift @@ -0,0 +1,58 @@ +// +// AutomationsFeatureTests.swift +// ControllerTests +// + +import ComposableArchitecture +@testable import Controller +import HAModels +import Testing + +@MainActor +@Suite("AutomationsFeature") +struct AutomationsFeatureTests { + struct ActivateError: Error {} + + private let automations: IdentifiedArrayOf = [ + AutomationInfo(name: "Evening", isActive: true, isRunning: false, type: "Turn"), + AutomationInfo(name: "Hallway", isActive: true, isRunning: true, type: "MotionAtNight"), + AutomationInfo(name: "Vacation", isActive: false, isRunning: false, type: "TriggerScene") + ] + + @Test("status grouping splits active and inactive, running first") + func groupsByStatus() { + var state = AutomationsFeature.State() + state.$automations.withLock { $0 = automations } + state.$grouping.withLock { $0 = .status } + + #expect(state.sections.map(\.title) == ["Active", "Inactive"]) + #expect(state.sections[0].automations.map(\.name) == ["Hallway", "Evening"]) + } + + @Test("type grouping uses the type label") + func groupsByType() { + var state = AutomationsFeature.State() + state.$automations.withLock { $0 = automations } + state.$grouping.withLock { $0 = .type } + + #expect(state.sections.map(\.title) == ["Motion", "Scene", "Schedule"]) + } + + @Test("toggle is applied optimistically and rolled back on failure") + func toggleRollsBack() async { + let store = TestStore(initialState: AutomationsFeature.State()) { + AutomationsFeature() + } withDependencies: { + $0.serverClient.deactivate = { _ in throw ActivateError() } + } + store.state.$automations.withLock { $0 = automations } + + await store.send(.setActive(name: "Evening", false)) { + $0.$automations.withLock { $0[id: "Evening"]?.isActive = false } + } + await store.receive(\.setActiveResponse) { + $0.$automations.withLock { $0[id: "Evening"]?.isActive = true } + $0.error = "Failed to change Evening: \(ActivateError().localizedDescription)" + } + } +} diff --git a/Tests/ControllerTests/RunDetailFeatureTests.swift b/Tests/ControllerTests/RunDetailFeatureTests.swift new file mode 100644 index 00000000..bdb80b23 --- /dev/null +++ b/Tests/ControllerTests/RunDetailFeatureTests.swift @@ -0,0 +1,44 @@ +// +// RunDetailFeatureTests.swift +// ControllerTests +// + +import ComposableArchitecture +@testable import Controller +import Foundation +import HAModels +import Testing + +@MainActor +@Suite("RunDetailFeature") +struct RunDetailFeatureTests { + @Test("loads the commands of its run") + func loadsCommandsByRunId() async { + let run = AutomationRun( + automationName: "Hallway", + startedAt: Date(timeIntervalSince1970: 0), + trigger: AutomationTrigger(kind: .sunset, entityId: nil, summary: "Sunset"), + outcome: .completed + ) + let command = ActionLogItem( + id: UUID(), + timestamp: Date(timeIntervalSince1970: 1), + entityId: EntityId(placeId: "Hall", name: "Light", characteristicsName: nil, characteristic: .switcher), + actionName: "on", + detailDescription: "turnOn", + hasCacheHit: false, + runId: run.id, + status: .executed + ) + let store = TestStore(initialState: RunDetailFeature.State(run: run)) { + RunDetailFeature() + } withDependencies: { + $0.serverClient.getActions = { _, runId in runId == run.id ? [command] : [] } + } + + await store.send(.task) + await store.receive(\.actionsResponse) { + $0.actions = [command] + } + } +} diff --git a/Tests/ControllerTests/ServerClientDependencyTests.swift b/Tests/ControllerTests/ServerClientDependencyTests.swift index ef45710f..456a13e2 100644 --- a/Tests/ControllerTests/ServerClientDependencyTests.swift +++ b/Tests/ControllerTests/ServerClientDependencyTests.swift @@ -24,7 +24,7 @@ struct ServerClientDependencyTests { let automations = try await client.getAutomations() #expect(automations.isEmpty) - let actions = try await client.getActions(10) + let actions = try await client.getActions(10, nil) #expect(actions.isEmpty) let windowStates = try await client.getWindowStates() @@ -41,11 +41,11 @@ struct ServerClientDependencyTests { let automations = try await client.getAutomations() #expect(automations.count == 3) - #expect(automations[0].name == "Morning Routine") + #expect(automations[0].name == "Hallway Night Light") #expect(automations[0].isActive == true) #expect(automations[0].isRunning == true) - let actions = try await client.getActions(10) + let actions = try await client.getActions(10, nil) #expect(actions.count == 1) #expect(actions[0].actionName == "Turn On") @@ -62,9 +62,9 @@ struct ServerClientDependencyTests { } operation: { @Dependency(\.serverClient) var client - try await client.activate("Morning Routine") - try await client.deactivate("Morning Routine") - try await client.stop("Morning Routine") + try await client.activate("Hallway Night Light") + try await client.deactivate("Hallway Night Light") + try await client.stop("Hallway Night Light") try await client.clearActions() } } From cdd6ef53aadc3abfbd8a09712e968c35e19a5bbf Mon Sep 17 00:00:00 2001 From: Julian Kahnert Date: Sat, 3 Oct 2026 20:48:52 +0200 Subject: [PATCH 06/22] Add an Overview tab with status, running automations and an activity feed The overview polls the server every 30 s while visible: adapter health, open windows, running automations with a stop button, the last runs and notices derived from failed runs and failed commands. The feed merges runs and commands of the last 24 hours. --- Sources/Controller/App/AppFeature.swift | 32 ++- .../Features/ActivityFeedFeature.swift | 102 +++++++ .../Controller/Features/OverviewFeature.swift | 267 ++++++++++++++++++ Tests/ControllerTests/AppFeatureTests.swift | 1 + .../OverviewFeatureTests.swift | 87 ++++++ 5 files changed, 487 insertions(+), 2 deletions(-) create mode 100644 Sources/Controller/Features/ActivityFeedFeature.swift create mode 100644 Sources/Controller/Features/OverviewFeature.swift create mode 100644 Tests/ControllerTests/OverviewFeatureTests.swift diff --git a/Sources/Controller/App/AppFeature.swift b/Sources/Controller/App/AppFeature.swift index 5e841693..d868a662 100644 --- a/Sources/Controller/App/AppFeature.swift +++ b/Sources/Controller/App/AppFeature.swift @@ -24,7 +24,8 @@ struct AppFeature: Sendable { @ObservableState struct State: Equatable, Sendable { - var selectedTab: Tab = .automations + var selectedTab: Tab = .overview + var overview = OverviewFeature.State() var automations = AutomationsFeature.State() var actions = ActionsFeature.State() var history = HistoryFeature.State() @@ -38,6 +39,7 @@ struct AppFeature: Sendable { // MARK: - Tab enum Tab: Sendable, Equatable, CaseIterable { + case overview case automations case actions case history @@ -45,6 +47,7 @@ struct AppFeature: Sendable { var title: String { switch self { + case .overview: return "Overview" case .automations: return "Automations" case .actions: return "Actions" case .history: return "History" @@ -54,6 +57,7 @@ struct AppFeature: Sendable { var systemImage: String { switch self { + case .overview: return "house" case .automations: return "lamp.floor" case .actions: return "list.bullet.clipboard" case .history: return "chart.line.uptrend.xyaxis" @@ -66,6 +70,7 @@ struct AppFeature: Sendable { enum Action: Sendable, BindableAction { case selectedTabChanged(Tab) + case overview(OverviewFeature.Action) case automations(AutomationsFeature.Action) case actions(ActionsFeature.Action) case history(HistoryFeature.Action) @@ -98,6 +103,10 @@ struct AppFeature: Sendable { var body: some ReducerOf { BindingReducer() + Scope(state: \.overview, action: \.overview) { + OverviewFeature() + } + Scope(state: \.automations, action: \.automations) { AutomationsFeature() } @@ -120,6 +129,13 @@ struct AppFeature: Sendable { state.selectedTab = tab return .none + case let .overview(.delegate(.openAutomation(name))): + state.selectedTab = .automations + return .send(.automations(.binding(.set(\.selectedAutomationIndex, name)))) + + case .overview: + return .none + case .automations: return .none @@ -206,6 +222,7 @@ struct AppFeature: Sendable { case .refreshAll: return .merge( + .send(.overview(.refresh)), .send(.automations(.refresh)), .send(.actions(.refresh)), .send(.history(.refresh)), @@ -226,6 +243,18 @@ struct AppView: View { var body: some View { TabView(selection: $store.selectedTab) { + Tab( + AppFeature.Tab.overview.title, + systemImage: AppFeature.Tab.overview.systemImage, + value: AppFeature.Tab.overview + ) { + OverviewView( + store: store.scope(state: \.overview, action: \.overview), + openWindows: store.settings.windowContentState?.windowStates ?? [] + ) + } + .badge(store.openWindowsCount ?? 0) + Tab( AppFeature.Tab.automations.title, systemImage: AppFeature.Tab.automations.systemImage, @@ -277,7 +306,6 @@ struct AppView: View { ) ) } - .badge(store.openWindowsCount ?? 0) } .tabViewStyle(.sidebarAdaptable) .onSceneChange { oldPhase, newPhase in diff --git a/Sources/Controller/Features/ActivityFeedFeature.swift b/Sources/Controller/Features/ActivityFeedFeature.swift new file mode 100644 index 00000000..d807e632 --- /dev/null +++ b/Sources/Controller/Features/ActivityFeedFeature.swift @@ -0,0 +1,102 @@ +// +// ActivityFeedFeature.swift +// Controller +// +// Runs and commands of the last 24 hours in one list +// + +import ComposableArchitecture +import Foundation +import HAModels +import SwiftUI + +@Reducer +struct ActivityFeedFeature: Sendable { + + enum Filter: String, CaseIterable, Sendable { + case all = "All" + case runs = "Runs" + case commands = "Commands" + } + + enum Entry: Identifiable, Equatable { + case run(AutomationRun) + case command(ActionLogItem) + + var id: UUID { + switch self { + case let .run(run): return run.id + case let .command(item): return item.id + } + } + + var date: Date { + switch self { + case let .run(run): return run.startedAt + case let .command(item): return item.timestamp + } + } + } + + @ObservableState + struct State: Equatable, Sendable { + let runs: [AutomationRun] + let actions: [ActionLogItem] + var filter: Filter = .all + + /// Newest first. + var entries: [Entry] { + let runs = filter == .commands ? [] : runs.map(Entry.run) + let commands = filter == .runs ? [] : actions.map(Entry.command) + return (runs + commands).sorted { $0.date > $1.date } + } + } + + enum Action: BindableAction, Sendable { + case binding(BindingAction) + case runTapped(AutomationRun) + } + + var body: some ReducerOf { + BindingReducer() + } +} + +struct ActivityFeedView: View { + @Bindable var store: StoreOf + + var body: some View { + List { + Picker("Filter", selection: $store.filter) { + ForEach(ActivityFeedFeature.Filter.allCases, id: \.self) { filter in + Text(filter.rawValue).tag(filter) + } + } + .pickerStyle(.segmented) + .listRowBackground(Color.clear) + + ForEach(store.entries) { entry in + switch entry { + case let .run(run): + Button { + store.send(.runTapped(run)) + } label: { + RunRow(run: run) + } + .buttonStyle(.plain) + case let .command(item): + ActionRow(item: item) + } + } + } + .navigationTitle("Activity") + } +} + +#Preview { + NavigationStack { + ActivityFeedView(store: Store(initialState: ActivityFeedFeature.State(runs: [], actions: [])) { + ActivityFeedFeature() + }) + } +} diff --git a/Sources/Controller/Features/OverviewFeature.swift b/Sources/Controller/Features/OverviewFeature.swift new file mode 100644 index 00000000..5a682d6a --- /dev/null +++ b/Sources/Controller/Features/OverviewFeature.swift @@ -0,0 +1,267 @@ +// +// OverviewFeature.swift +// Controller +// +// What is happening at home right now +// + +import ComposableArchitecture +import Foundation +import HAModels +import Sharing +import SwiftUI + +@Reducer +enum OverviewPath { + case feed(ActivityFeedFeature) + case run(RunDetailFeature) +} + +extension OverviewPath.State: Equatable, Sendable {} +extension OverviewPath.Action: Sendable {} + +@Reducer +struct OverviewFeature: Sendable { + + struct Snapshot: Equatable, Sendable { + var isHealthy: Bool + var automations: [AutomationInfo] + var runs: [AutomationRun] + var actions: [ActionLogItem] + } + + @ObservableState + struct State: Equatable, Sendable { + @Shared(.automations) var automations: IdentifiedArrayOf = [] + var isHealthy: Bool? + var lastUpdated: Date? + /// Last 24 hours, newest first. + var recentRuns: [AutomationRun] = [] + /// Last 24 hours, newest first. + var recentActions: [ActionLogItem] = [] + var error: String? + var path = StackState() + + /// Failed runs, then devices whose commands failed. + var hints: [String] { + let runs = recentRuns + .filter { $0.outcome == .failed } + .map { "\($0.automationName) failed: \($0.errorDescription ?? "unknown error")" } + let devices = Dictionary(grouping: recentActions.filter { $0.status == .failed }, by: \.entityId) + .map { "\($0.key.name) (\($0.key.placeId)): \($0.value.count) failed commands" } + .sorted() + return runs + devices + } + } + + enum Action: Sendable { + case delegate(Delegate) + case path(StackActionOf) + case refresh + case refreshResponse(Result) + case runTapped(AutomationRun) + case showAllTapped + case stopButtonTapped(String) + case task + + enum Delegate: Sendable { + case openAutomation(String) + } + } + + @Dependency(\.continuousClock) var clock + @Dependency(\.date.now) var now + @Dependency(\.serverClient) var serverClient + + var body: some ReducerOf { + Reduce { state, action in + switch action { + case .delegate: + return .none + + case let .path(.element(_, .feed(.runTapped(run)))): + state.path.append(.run(RunDetailFeature.State(run: run))) + return .none + + case let .path(.element(_, .run(.delegate(.openAutomation(name))))): + return .send(.delegate(.openAutomation(name))) + + case .path: + return .none + + case .refresh: + let since = now.addingTimeInterval(-86400) + return .run { send in + await send(.refreshResponse(Result { + async let isHealthy = serverClient.isHealthy() + async let automations = serverClient.getAutomations() + async let runs = serverClient.getRecentRuns(since, 200) + async let actions = serverClient.getActions(1000, nil) + return try await Snapshot( + isHealthy: isHealthy, + automations: automations, + runs: runs, + actions: actions.filter { $0.timestamp >= since } + ) + })) + } + + case let .refreshResponse(.success(snapshot)): + state.error = nil + state.isHealthy = snapshot.isHealthy + state.lastUpdated = now + state.recentRuns = snapshot.runs + state.recentActions = snapshot.actions + state.$automations.withLock { + $0 = IdentifiedArrayOf(uniqueElements: snapshot.automations.sorted { $0.name < $1.name }) + } + return .none + + case let .refreshResponse(.failure(error)): + state.isHealthy = nil + state.error = "Server unreachable: \(error.localizedDescription)" + return .none + + case let .runTapped(run): + state.path.append(.run(RunDetailFeature.State(run: run))) + return .none + + case .showAllTapped: + state.path.append(.feed(ActivityFeedFeature.State(runs: state.recentRuns, actions: state.recentActions))) + return .none + + case let .stopButtonTapped(name): + return .run { send in + try await serverClient.stop(name) + await send(.refresh) + } catch: { error, send in + await send(.refreshResponse(.failure(error))) + } + + case .task: + // Ends with the view's task, so polling stops when the tab is left. + return .run { send in + await send(.refresh) + for await _ in clock.timer(interval: .seconds(30)) { + await send(.refresh) + } + } + } + } + .forEach(\.path, action: \.path) + } +} + +struct OverviewView: View { + @Bindable var store: StoreOf + let openWindows: [WindowContentState.WindowState] + + var body: some View { + NavigationStack(path: $store.scope(state: \.path, action: \.path)) { + List { + Section { + statusRow + } + + if !openWindows.isEmpty { + Section("Open Windows") { + ForEach(openWindows, id: \.name) { window in + ProgressView(timerInterval: window.opened...window.end, countsDown: false) { + Text(window.name) + } + .tint(Date() <= window.end ? Color.accentColor : Color.red) + } + } + } + + let running = store.automations.filter(\.isRunning) + if !running.isEmpty { + Section("Running") { + ForEach(running) { automation in + HStack { + VStack(alignment: .leading, spacing: 2) { + Text(automation.name) + Text(automation.lastRun?.trigger.summary ?? automation.triggerDescription) + .font(.subheadline) + .foregroundStyle(.secondary) + .lineLimit(1) + } + Spacer() + Button("Stop") { + store.send(.stopButtonTapped(automation.name)) + } + .buttonStyle(.bordered) + } + } + } + } + + Section { + ForEach(store.recentRuns.prefix(5)) { run in + Button { + store.send(.runTapped(run)) + } label: { + RunRow(run: run) + } + .buttonStyle(.plain) + } + } header: { + HStack { + Text("Recent Runs") + Spacer() + Button("Show All") { + store.send(.showAllTapped) + } + .font(.footnote) + } + } + + if !store.hints.isEmpty { + Section("Notices") { + ForEach(store.hints, id: \.self) { hint in + Label(hint, systemImage: "exclamationmark.triangle") + .foregroundStyle(.red) + } + } + } + } + .navigationTitle("Overview") + .refreshable { await store.send(.refresh).finish() } + .task { await store.send(.task).finish() } + } destination: { pathStore in + switch pathStore.case { + case let .feed(feedStore): + ActivityFeedView(store: feedStore) + case let .run(runStore): + RunDetailView(store: runStore) + } + } + } + + private var statusRow: some View { + HStack { + Image(systemName: "circle.fill") + .foregroundStyle(store.isHealthy == true ? Color.green : Color.red) + if let error = store.error { + Text(error) + } else { + Text(store.isHealthy == false ? "Adapter disconnected" : "Adapter connected") + } + Spacer() + if let lastUpdated = store.lastUpdated { + Text(lastUpdated, style: .relative) + .font(.caption) + .foregroundStyle(.secondary) + } + } + } +} + +#Preview { + OverviewView( + store: Store(initialState: OverviewFeature.State()) { + OverviewFeature() + }, + openWindows: [] + ) +} diff --git a/Tests/ControllerTests/AppFeatureTests.swift b/Tests/ControllerTests/AppFeatureTests.swift index 8489e538..396cb217 100644 --- a/Tests/ControllerTests/AppFeatureTests.swift +++ b/Tests/ControllerTests/AppFeatureTests.swift @@ -22,6 +22,7 @@ struct AppFeatureTests { AppFeature() } withDependencies: { $0.serverClient = .previewValue + $0.date.now = Date(timeIntervalSince1970: 0) $0.liveActivity = .testValue $0.pushNotification = .testValue } diff --git a/Tests/ControllerTests/OverviewFeatureTests.swift b/Tests/ControllerTests/OverviewFeatureTests.swift new file mode 100644 index 00000000..e42f387f --- /dev/null +++ b/Tests/ControllerTests/OverviewFeatureTests.swift @@ -0,0 +1,87 @@ +// +// OverviewFeatureTests.swift +// ControllerTests +// + +import ComposableArchitecture +@testable import Controller +import Foundation +import HAModels +import Testing + +@MainActor +@Suite("OverviewFeature") +struct OverviewFeatureTests { + private let light = EntityId(placeId: "Bath", name: "Mirror", characteristicsName: nil, characteristic: .switcher) + + private func run(_ outcome: AutomationRun.Outcome, error: String? = nil) -> AutomationRun { + AutomationRun( + automationName: "Night Light", + startedAt: Date(timeIntervalSince1970: 0), + trigger: AutomationTrigger(kind: .sunset, entityId: nil, summary: "Sunset"), + outcome: outcome, + errorDescription: error + ) + } + + private func command(_ status: ActionLogItem.Status) -> ActionLogItem { + ActionLogItem( + id: UUID(), + timestamp: Date(timeIntervalSince1970: 0), + entityId: light, + actionName: "on", + detailDescription: "turnOn", + hasCacheHit: false, + status: status + ) + } + + @Test("hints come from failed runs and failed commands per device") + func hintsFromFailures() { + var state = OverviewFeature.State() + state.recentRuns = [run(.failed, error: "not reachable"), run(.completed)] + state.recentActions = [command(.failed), command(.failed), command(.executed)] + + #expect(state.hints == ["Night Light failed: not reachable", "Mirror (Bath): 2 failed commands"]) + } + + @Test("polls every 30 seconds until the task is cancelled") + func pollingStopsOnCancel() async { + let clock = TestClock() + let store = TestStore(initialState: OverviewFeature.State()) { + OverviewFeature() + } withDependencies: { + $0.continuousClock = clock + $0.date.now = Date(timeIntervalSince1970: 100_000) + } + store.exhaustivity = .off + + let task = await store.send(.task) + await store.receive(\.refresh) + await store.receive(\.refreshResponse) + + await clock.advance(by: .seconds(30)) + await store.receive(\.refresh) + await store.receive(\.refreshResponse) + + await task.cancel() + await clock.advance(by: .seconds(60)) + #expect(store.state.isHealthy == true) + } + + @Test("a run's Open Automation button switches to its tab") + func openAutomationFromRun() async { + var state = AppFeature.State() + state.overview.path.append(.run(RunDetailFeature.State(run: run(.completed)))) + let store = TestStore(initialState: state) { + AppFeature() + } + store.exhaustivity = .off + + await store.send(\.overview.path[id: 0].run.openAutomationButtonTapped) + await store.receive(\.overview.delegate) + await store.receive(\.automations.binding) + #expect(store.state.selectedTab == .automations) + #expect(store.state.automations.selectedAutomationIndex == "Night Light") + } +} From de66162fc9fbdbeef42192dcec8b68a80120dda5 Mon Sep 17 00:00:00 2001 From: Julian Kahnert Date: Sat, 3 Oct 2026 20:54:01 +0200 Subject: [PATCH 07/22] Replace the History and Actions tabs with Rooms and a command log Entities are grouped by room with a symbol per characteristic. The entity chart draws on/off states as bars, uses a log axis for lux, starts from the state before the window and requests at most the API limit of 1000 items. The action log moves into Settings > Diagnostics as Command Log with status filters; open windows moved to the Overview. --- Sources/Controller/App/AppFeature.swift | 57 ++---- .../CharacteristicsType+Display.swift | 20 +++ .../Extensions/EntityHistoryItem+Chart.swift | 5 + .../Controller/Features/ActionsFeature.swift | 164 +++++++++--------- .../Features/EntityHistoryDetailFeature.swift | 53 ++++-- ...istoryFeature.swift => RoomsFeature.swift} | 57 +++--- .../Controller/Features/SettingsFeature.swift | 65 ++++--- .../AutomationsFeatureTests.swift | 25 +++ 8 files changed, 252 insertions(+), 194 deletions(-) rename Sources/Controller/Features/{HistoryFeature.swift => RoomsFeature.swift} (77%) diff --git a/Sources/Controller/App/AppFeature.swift b/Sources/Controller/App/AppFeature.swift index d868a662..81afce04 100644 --- a/Sources/Controller/App/AppFeature.swift +++ b/Sources/Controller/App/AppFeature.swift @@ -27,8 +27,7 @@ struct AppFeature: Sendable { var selectedTab: Tab = .overview var overview = OverviewFeature.State() var automations = AutomationsFeature.State() - var actions = ActionsFeature.State() - var history = HistoryFeature.State() + var rooms = RoomsFeature.State() var settings = SettingsFeature.State() var openWindowsCount: Int? { @@ -41,16 +40,14 @@ struct AppFeature: Sendable { enum Tab: Sendable, Equatable, CaseIterable { case overview case automations - case actions - case history + case rooms case settings var title: String { switch self { case .overview: return "Overview" case .automations: return "Automations" - case .actions: return "Actions" - case .history: return "History" + case .rooms: return "Rooms" case .settings: return "Settings" } } @@ -59,8 +56,7 @@ struct AppFeature: Sendable { switch self { case .overview: return "house" case .automations: return "lamp.floor" - case .actions: return "list.bullet.clipboard" - case .history: return "chart.line.uptrend.xyaxis" + case .rooms: return "square.grid.2x2" case .settings: return "gear" } } @@ -72,8 +68,7 @@ struct AppFeature: Sendable { case selectedTabChanged(Tab) case overview(OverviewFeature.Action) case automations(AutomationsFeature.Action) - case actions(ActionsFeature.Action) - case history(HistoryFeature.Action) + case rooms(RoomsFeature.Action) case settings(SettingsFeature.Action) // Live Activities & Push Notifications @@ -111,12 +106,8 @@ struct AppFeature: Sendable { AutomationsFeature() } - Scope(state: \.actions, action: \.actions) { - ActionsFeature() - } - - Scope(state: \.history, action: \.history) { - HistoryFeature() + Scope(state: \.rooms, action: \.rooms) { + RoomsFeature() } Scope(state: \.settings, action: \.settings) { @@ -139,10 +130,7 @@ struct AppFeature: Sendable { case .automations: return .none - case .actions: - return .none - - case .history: + case .rooms: return .none case .settings: @@ -224,8 +212,7 @@ struct AppFeature: Sendable { return .merge( .send(.overview(.refresh)), .send(.automations(.refresh)), - .send(.actions(.refresh)), - .send(.history(.refresh)), + .send(.rooms(.refresh)), .send(.refreshWindowStates), .send(.startMonitoringLiveActivities), .send(.clearDeliveredNotifications) @@ -269,29 +256,11 @@ struct AppView: View { } Tab( - AppFeature.Tab.actions.title, - systemImage: AppFeature.Tab.actions.systemImage, - value: AppFeature.Tab.actions - ) { - ActionsView( - store: store.scope( - state: \.actions, - action: \.actions - ) - ) - } - - Tab( - AppFeature.Tab.history.title, - systemImage: AppFeature.Tab.history.systemImage, - value: AppFeature.Tab.history + AppFeature.Tab.rooms.title, + systemImage: AppFeature.Tab.rooms.systemImage, + value: AppFeature.Tab.rooms ) { - HistoryView( - store: store.scope( - state: \.history, - action: \.history - ) - ) + RoomsView(store: store.scope(state: \.rooms, action: \.rooms)) } Tab( diff --git a/Sources/Controller/Extensions/CharacteristicsType+Display.swift b/Sources/Controller/Extensions/CharacteristicsType+Display.swift index c180bb4c..c54b78c7 100644 --- a/Sources/Controller/Extensions/CharacteristicsType+Display.swift +++ b/Sources/Controller/Extensions/CharacteristicsType+Display.swift @@ -46,4 +46,24 @@ extension CharacteristicsType { return "Heating" } } + + var systemImage: String { + switch self { + case .motionSensor: return "figure.walk" + case .lightSensor: return "sun.max" + case .batterySensor: return "battery.75percent" + case .contactSensor: return "window.casement" + case .temperatureSensor: return "thermometer.medium" + case .relativeHumiditySensor: return "humidity" + case .carbonDioxideSensorId: return "carbon.dioxide.cloud" + case .pmDensitySensor, .airQualitySensor: return "aqi.medium" + case .switcher: return "lightbulb" + case .brightness: return "light.max" + case .colorTemperature: return "thermometer.sun" + case .color: return "paintpalette" + case .valve: return "spigot" + case .lock: return "lock" + case .heating: return "heater.vertical" + } + } } diff --git a/Sources/Controller/Extensions/EntityHistoryItem+Chart.swift b/Sources/Controller/Extensions/EntityHistoryItem+Chart.swift index 7a828a3e..ea7b5f47 100644 --- a/Sources/Controller/Extensions/EntityHistoryItem+Chart.swift +++ b/Sources/Controller/Extensions/EntityHistoryItem+Chart.swift @@ -32,6 +32,11 @@ extension EntityHistoryItem { return Double(rgb.hue) } + /// The on/open/detected state of boolean entities, `nil` for measurements. + var stateValue: Bool? { + isDeviceOn ?? motionDetected ?? isContactOpen ?? isDoorLocked ?? isHeaterActive ?? valveOpen + } + /// Returns the primary value for this history item based on available data /// Used for chart visualization /// Priority order: numeric sensors > percentage sensors > boolean sensors > color (hue) diff --git a/Sources/Controller/Features/ActionsFeature.swift b/Sources/Controller/Features/ActionsFeature.swift index ab6a42b1..4eaf34bc 100644 --- a/Sources/Controller/Features/ActionsFeature.swift +++ b/Sources/Controller/Features/ActionsFeature.swift @@ -2,7 +2,7 @@ // ActionsFeature.swift // ControllerFeatures // -// Feature for viewing and managing action log +// Command log: every command the server sent, with its status // import ComposableArchitecture @@ -16,22 +16,35 @@ struct ActionsFeature: Sendable { // MARK: - State + enum Filter: String, CaseIterable, Sendable { + case all = "All" + case fresh = "Fresh" + case cache = "Cache" + case failed = "Failed" + + func matches(_ item: ActionLogItem) -> Bool { + switch self { + case .all: return true + case .fresh: return item.status == .executed || (item.status == nil && !item.hasCacheHit) + case .cache: return item.hasCacheHit + case .failed: return item.status == .failed + } + } + } + @ObservableState struct State: Equatable, Sendable { let limit = 1000 + var filter: Filter = .all var actions: [ActionLogItem] = [] var isLoading = false @Presents var alert: AlertState? var searchText: String = "" var filteredActions: [ActionLogItem] { - guard !searchText.isEmpty else { - return actions - } - let searchLowercased = searchText.localizedLowercase return actions.filter { item in - item.searchableText.contains(searchLowercased) + filter.matches(item) && (searchText.isEmpty || item.searchableText.contains(searchLowercased)) } } @@ -159,96 +172,81 @@ struct ActionsView: View { @Bindable var store: StoreOf var body: some View { - NavigationStack { - List { - ForEach(store.filteredActions) { item in - actionRow(item) + List { + Picker("Filter", selection: $store.filter) { + ForEach(ActionsFeature.Filter.allCases, id: \.self) { filter in + Text(filter.rawValue).tag(filter) } } - .searchable( - text: $store.searchText, - prompt: "Search actions..." - ) - .navigationTitle("Actions") - .toolbar { - ToolbarItem(placement: .primaryAction) { - Button { - store.send(.refresh) - } label: { - Label("Reload", systemImage: "arrow.clockwise") - } - .disabled(store.isLoading) - } - - ToolbarItem(placement: .secondaryAction) { - ShareLink( - item: store.exportAsText, - preview: SharePreview( - "HomeKit_Actions_\(Date().ISO8601Format()).txt", - image: Image(systemName: "doc.text") - ) - ) - .disabled(store.actions.isEmpty) - } + .pickerStyle(.segmented) + .listRowBackground(Color.clear) - ToolbarItem(placement: .secondaryAction) { - Button(role: .destructive) { - store.send(.clearActions) - } label: { - Label("Clear All", systemImage: "trash") - } - .disabled(store.actions.isEmpty || store.isLoading) - } - } - .refreshable { - store.send(.refresh) + ForEach(store.filteredActions) { item in + ActionRow(item: item) } - .onAppear { - store.send(.onAppear) + } + .searchable( + text: $store.searchText, + prompt: "Search commands..." + ) + .navigationTitle("Command Log") + .toolbar { + ToolbarItem(placement: .primaryAction) { + Button { + store.send(.refresh) + } label: { + Label("Reload", systemImage: "arrow.clockwise") + } + .disabled(store.isLoading) } - .overlay { - if store.isLoading && store.actions.isEmpty { - ProgressView() - } else if store.actions.isEmpty && !store.isLoading { - ContentUnavailableView( - "No Actions", - systemImage: "list.bullet.clipboard", - description: Text("Actions will appear here") + + ToolbarItem(placement: .secondaryAction) { + ShareLink( + item: store.exportAsText, + preview: SharePreview( + "HomeKit_Actions_\(Date().ISO8601Format()).txt", + image: Image(systemName: "doc.text") ) + ) + .disabled(store.actions.isEmpty) + } + + ToolbarItem(placement: .secondaryAction) { + Button(role: .destructive) { + store.send(.clearActions) + } label: { + Label("Clear All", systemImage: "trash") } + .disabled(store.actions.isEmpty || store.isLoading) } - .alert($store.scope(state: \.alert, action: \.alert)) } - } - - @ViewBuilder - private func actionRow(_ item: ActionLogItem) -> some View { - VStack(alignment: .leading, spacing: 4) { - // Action name and entity - Text(item.displayName) - .font(.headline) - - // Detailed action description with cache indicator - HStack(spacing: 4) { - Image(systemName: "internaldrive") - .opacity(item.hasCacheHit ? 1 : 0) - Text(item.detailDescription) - .font(.body) + .refreshable { + store.send(.refresh) + } + .onAppear { + store.send(.onAppear) + } + .overlay { + if store.isLoading && store.actions.isEmpty { + ProgressView() + } else if store.actions.isEmpty && !store.isLoading { + ContentUnavailableView( + "No Commands", + systemImage: "list.bullet.clipboard", + description: Text("Commands will appear here") + ) } - .foregroundColor(item.hasCacheHit ? Color.yellow : nil) - - // Timestamp - Text(item.timestamp.formatted(date: .numeric, time: .standard)) - .font(.caption) - .foregroundColor(.secondary) } + .alert($store.scope(state: \.alert, action: \.alert)) } } #Preview { - ActionsView( - store: Store(initialState: ActionsFeature.State()) { - ActionsFeature() - } - ) + NavigationStack { + ActionsView( + store: Store(initialState: ActionsFeature.State()) { + ActionsFeature() + } + ) + } } diff --git a/Sources/Controller/Features/EntityHistoryDetailFeature.swift b/Sources/Controller/Features/EntityHistoryDetailFeature.swift index 60f5b4fa..2af700fe 100644 --- a/Sources/Controller/Features/EntityHistoryDetailFeature.swift +++ b/Sources/Controller/Features/EntityHistoryDetailFeature.swift @@ -245,28 +245,57 @@ struct EntityHistoryDetailView: View { .alert($store.scope(state: \.alert, action: \.alert)) } + /// On/off periods of a boolean entity, clipped to the chart's range. + private func activeIntervals(in range: ClosedRange) -> [DateInterval] { + let ascending = Array(store.chartData.reversed()) + return ascending.indices.compactMap { index in + guard ascending[index].stateValue == true else { return nil } + let start = max(ascending[index].timestamp, range.lowerBound) + let end = index + 1 < ascending.count ? ascending[index + 1].timestamp : range.upperBound + return start < end ? DateInterval(start: start, end: end) : nil + } + } + @ViewBuilder private var chartView: some View { let dateRange = store.state.dateRange + let range = dateRange.start...dateRange.end + let isBoolean = store.chartData.contains { $0.stateValue != nil } + let isLux = store.entity.entityId.characteristicType == .lightSensor Chart { - ForEach(store.chartData) { item in - if let value = item.primaryValue { - LineMark( - x: .value("Time", item.timestamp), - y: .value("Value", value) - ) - .foregroundStyle(Color.accentColor) - - PointMark( - x: .value("Time", item.timestamp), - y: .value("Value", value) + if isBoolean { + ForEach(activeIntervals(in: range), id: \.start) { interval in + RectangleMark( + xStart: .value("Start", interval.start), + xEnd: .value("End", interval.end), + y: .value("State", "On") ) .foregroundStyle(Color.accentColor) } + } else { + ForEach(store.chartData) { item in + if let value = item.primaryValue { + // The log scale cannot place 0 lx. + let plotted = isLux ? max(value, 0.1) : value + LineMark( + x: .value("Time", item.timestamp), + y: .value("Value", plotted) + ) + .foregroundStyle(Color.accentColor) + + PointMark( + x: .value("Time", item.timestamp), + y: .value("Value", plotted) + ) + .foregroundStyle(Color.accentColor) + } + } } } - .chartXScale(domain: dateRange.start...dateRange.end) + .chartXScale(domain: range) + .chartYScale(type: isLux ? .log : .linear) + .chartPlotStyle { $0.clipped() } .chartXAxis { AxisMarks(values: .automatic) { _ in AxisGridLine() diff --git a/Sources/Controller/Features/HistoryFeature.swift b/Sources/Controller/Features/RoomsFeature.swift similarity index 77% rename from Sources/Controller/Features/HistoryFeature.swift rename to Sources/Controller/Features/RoomsFeature.swift index 0a18fe65..b71591ff 100644 --- a/Sources/Controller/Features/HistoryFeature.swift +++ b/Sources/Controller/Features/RoomsFeature.swift @@ -1,8 +1,8 @@ // -// HistoryFeature.swift +// RoomsFeature.swift // ControllerFeatures // -// Feature for viewing entity history and charts +// Entities grouped by room // import ComposableArchitecture @@ -12,7 +12,7 @@ import Sharing import SwiftUI @Reducer -struct HistoryFeature: Sendable { +struct RoomsFeature: Sendable { // MARK: - State @@ -36,6 +36,12 @@ struct HistoryFeature: Sendable { entity.formattedCharacteristicDisplayName.localizedLowercase.contains(searchLowercased) } } + + var rooms: [(placeId: String, entities: [EntityInfo])] { + Dictionary(grouping: filteredEntities, by: \.entityId.placeId) + .sorted { $0.key < $1.key } + .map { ($0.key, $0.value) } + } } // MARK: - Action @@ -94,7 +100,7 @@ struct HistoryFeature: Sendable { case let .entitiesResponse(.success(entities)): state.isLoading = false - state.entities = entities.sorted { $0.displayName < $1.displayName } + state.entities = entities.sorted { $0.entityId.name < $1.entityId.name } return .none case let .entitiesResponse(.failure(error)): @@ -124,13 +130,13 @@ struct HistoryFeature: Sendable { } } -struct HistoryView: View { - @Bindable var store: StoreOf +struct RoomsView: View { + @Bindable var store: StoreOf var body: some View { NavigationStack { contentView - .navigationTitle("History") + .navigationTitle("Rooms") .navigationDestination( item: $store.scope(state: \.selectedEntityDetail, action: \.selectedEntityDetail) ) { detailStore in @@ -159,19 +165,23 @@ struct HistoryView: View { if store.entities.isEmpty && !store.isLoading { ContentUnavailableView( "No Entities", - systemImage: "chart.line.uptrend.xyaxis", - description: Text("Entity history will appear here.\n\nNote: Entity discovery is not yet implemented. You can manually navigate to entity details once entities are tracked.") + systemImage: "square.grid.2x2", + description: Text("Devices with recorded history appear here.") ) } else { List(selection: $store.selectedEntityId) { - ForEach(store.filteredEntities) { entity in - entityRow(entity) - .tag(entity.id) + ForEach(store.rooms, id: \.placeId) { room in + Section(room.placeId) { + ForEach(room.entities) { entity in + entityRow(entity) + .tag(entity.id) + } + } } } .searchable( text: $store.searchText, - prompt: "Search entities..." + prompt: "Search devices..." ) } } @@ -179,20 +189,23 @@ struct HistoryView: View { @ViewBuilder private func entityRow(_ entity: EntityInfo) -> some View { - VStack(alignment: .leading, spacing: 4) { - Text(entity.displayName) - .font(.headline) - Text(entity.formattedCharacteristicDisplayName) - .font(.subheadline) - .foregroundColor(.secondary) + Label { + VStack(alignment: .leading, spacing: 2) { + Text(entity.entityId.name) + Text(entity.formattedCharacteristicDisplayName) + .font(.subheadline) + .foregroundStyle(.secondary) + } + } icon: { + Image(systemName: entity.entityId.characteristicType.systemImage) } } } #Preview { - HistoryView( - store: Store(initialState: HistoryFeature.State()) { - HistoryFeature() + RoomsView( + store: Store(initialState: RoomsFeature.State()) { + RoomsFeature() } ) } diff --git a/Sources/Controller/Features/SettingsFeature.swift b/Sources/Controller/Features/SettingsFeature.swift index 83500bdb..9299f392 100644 --- a/Sources/Controller/Features/SettingsFeature.swift +++ b/Sources/Controller/Features/SettingsFeature.swift @@ -37,7 +37,8 @@ struct SettingsFeature: Sendable { var isPushAuthorized: Bool = false var deviceToken: Data? - // Log viewer + // Diagnostics + @Presents var commandLog: ActionsFeature.State? @Presents var logViewer: LogViewerFeature.State? } @@ -54,6 +55,8 @@ struct SettingsFeature: Sendable { case windowStatesResponse(Result<[WindowContentState.WindowState], Error>) case requestPushAuthorization case dismissError + case showCommandLog + case commandLog(PresentationAction) case showLogViewer case logViewer(PresentationAction) case binding(BindingAction) @@ -171,6 +174,13 @@ struct SettingsFeature: Sendable { state.error = nil return .none + case .showCommandLog: + state.commandLog = ActionsFeature.State() + return .none + + case .commandLog: + return .none + case .showLogViewer: state.logViewer = LogViewerFeature.State() return .none @@ -182,6 +192,9 @@ struct SettingsFeature: Sendable { return .none } } + .ifLet(\.$commandLog, action: \.commandLog) { + ActionsFeature() + } .ifLet(\.$logViewer, action: \.logViewer) { LogViewerFeature() } @@ -210,32 +223,23 @@ struct SettingsView: View { } footer: { Text("Show window states in Dynamic Island and Lock Screen") } - - if let windowState = store.windowContentState { - Section { - windowStatesSection(windowState) - } header: { - Text("Window States") - } - } #endif Section { - Button { + diagnosticsButton("Command Log", systemImage: "list.bullet.clipboard") { + store.send(.showCommandLog) + } + diagnosticsButton("App Logs", systemImage: "doc.text.magnifyingglass") { store.send(.showLogViewer) - } label: { - HStack { - Label("View Logs", systemImage: "doc.text.magnifyingglass") - Spacer() - Image(systemName: "chevron.right") - .foregroundStyle(.secondary) - } } } header: { Text("Diagnostics") } } .navigationTitle("Settings") + .navigationDestination(item: $store.scope(state: \.commandLog, action: \.commandLog)) { commandLogStore in + ActionsView(store: commandLogStore) + } .navigationDestination( item: $store.scope(state: \.logViewer, action: \.logViewer) ) { logStore in @@ -250,6 +254,17 @@ struct SettingsView: View { } } + private func diagnosticsButton(_ title: String, systemImage: String, action: @escaping () -> Void) -> some View { + Button(action: action) { + HStack { + Label(title, systemImage: systemImage) + Spacer() + Image(systemName: "chevron.right") + .foregroundStyle(.secondary) + } + } + } + @ViewBuilder private var serverConfigSection: some View { VStack(alignment: .leading, spacing: 8) { @@ -302,22 +317,6 @@ struct SettingsView: View { set: { store.send(.toggleLiveActivities($0)) } )) } - - @ViewBuilder - private func windowStatesSection(_ windowState: WindowContentState) -> some View { - if windowState.windowStates.isEmpty { - Text("No open windows") - .foregroundColor(.secondary) - } else { - ForEach(windowState.windowStates, id: \.name) { window in - ProgressView(timerInterval: window.opened...window.end, countsDown: false) { - Text(window.name) - } - .tint(Date() <= window.end ? Color.accentColor : Color.red) - } - .listRowSeparator(.hidden) - } - } #endif } diff --git a/Tests/ControllerTests/AutomationsFeatureTests.swift b/Tests/ControllerTests/AutomationsFeatureTests.swift index a284048d..febc07fc 100644 --- a/Tests/ControllerTests/AutomationsFeatureTests.swift +++ b/Tests/ControllerTests/AutomationsFeatureTests.swift @@ -5,6 +5,7 @@ import ComposableArchitecture @testable import Controller +import Foundation import HAModels import Testing @@ -56,3 +57,27 @@ struct AutomationsFeatureTests { } } } + +@Suite("ActionsFeature") +struct ActionsFeatureTests { + @Test("command log filter matches status, falling back to the cache flag for old servers") + func filterMatchesStatus() { + let entityId = EntityId(placeId: "Hall", name: "Light", characteristicsName: nil, characteristic: .switcher) + func item(_ status: ActionLogItem.Status?, cached: Bool = false) -> ActionLogItem { + ActionLogItem( + id: UUID(), + timestamp: Date(), + entityId: entityId, + actionName: "on", + detailDescription: "turnOn", + hasCacheHit: cached, + status: status + ) + } + + #expect(ActionsFeature.Filter.fresh.matches(item(nil))) + #expect(ActionsFeature.Filter.cache.matches(item(.cacheHit, cached: true))) + #expect(ActionsFeature.Filter.failed.matches(item(.failed))) + #expect(!ActionsFeature.Filter.fresh.matches(item(.failed))) + } +} From aeeab2dfb9e120b3ef11ad393f40553af19bf861 Mon Sep 17 00:00:00 2001 From: Julian Kahnert Date: Sat, 3 Oct 2026 21:02:57 +0200 Subject: [PATCH 08/22] Add timeline charts and a room timeline to the Controller StateIntervals, DailyTotals and RunStatistics turn history and runs into chart data in HAModels, testable on Linux. TimelineChart and ValueChart are the two chart views every chart is configured from; the room header in Rooms now opens a timeline of its lamps, motion sensors and windows. --- Sources/Controller/Charts/ChartPalette.swift | 29 +++ Sources/Controller/Charts/TimelineChart.swift | 66 +++++++ Sources/Controller/Charts/ValueChart.swift | 69 +++++++ .../Features/AutomationsFeature.swift | 2 +- .../Features/EntityHistoryDetailFeature.swift | 65 +++---- Sources/Controller/Features/RoomFeature.swift | 183 ++++++++++++++++++ .../Controller/Features/RoomsFeature.swift | 30 ++- .../HAModels/Automation/RunStatistics.swift | 27 +++ Sources/HAModels/History/DailyTotals.swift | 27 +++ Sources/HAModels/History/StateIntervals.swift | 63 ++++++ .../HelperTests/ChartPreparationTests.swift | 120 ++++++++++++ 11 files changed, 640 insertions(+), 41 deletions(-) create mode 100644 Sources/Controller/Charts/ChartPalette.swift create mode 100644 Sources/Controller/Charts/TimelineChart.swift create mode 100644 Sources/Controller/Charts/ValueChart.swift create mode 100644 Sources/Controller/Features/RoomFeature.swift create mode 100644 Sources/HAModels/Automation/RunStatistics.swift create mode 100644 Sources/HAModels/History/DailyTotals.swift create mode 100644 Sources/HAModels/History/StateIntervals.swift create mode 100644 Tests/HomeAutomationKitTests/HelperTests/ChartPreparationTests.swift diff --git a/Sources/Controller/Charts/ChartPalette.swift b/Sources/Controller/Charts/ChartPalette.swift new file mode 100644 index 00000000..f29813da --- /dev/null +++ b/Sources/Controller/Charts/ChartPalette.swift @@ -0,0 +1,29 @@ +// +// ChartPalette.swift +// Controller +// + +import HAModels +import SwiftUI + +/// Chart colors follow the device type, never the series order, so a lamp is green in every chart. +enum ChartPalette { + static func color(for type: CharacteristicsType) -> Color { + switch type { + case .switcher, .brightness, .colorTemperature, .color: + return .green + case .motionSensor: + return .blue + case .contactSensor: + return .orange + case .lightSensor: + return .indigo + case .carbonDioxideSensorId: + return .teal + default: + return .gray + } + } + + static let night = Color.gray.opacity(0.15) +} diff --git a/Sources/Controller/Charts/TimelineChart.swift b/Sources/Controller/Charts/TimelineChart.swift new file mode 100644 index 00000000..b0687d4a --- /dev/null +++ b/Sources/Controller/Charts/TimelineChart.swift @@ -0,0 +1,66 @@ +// +// TimelineChart.swift +// Controller +// + +import Charts +import SwiftUI + +/// State lanes (lamp on, motion, window open, …) over a shared time axis. +struct TimelineChart: View { + struct Lane: Identifiable { + let label: String + let color: Color + let intervals: [DateInterval] + var id: String { label } + } + + let lanes: [Lane] + let domain: ClosedRange + var nights: [DateInterval] = [] + + var body: some View { + // Seconds-long motion pulses would vanish on a 24 h axis; keep each bar visible. + let minimumDuration = domain.upperBound.timeIntervalSince(domain.lowerBound) / 250 + Chart { + ForEach(nights, id: \.start) { night in + RectangleMark(xStart: .value("Start", night.start), xEnd: .value("End", night.end)) + .foregroundStyle(ChartPalette.night) + } + ForEach(lanes) { lane in + ForEach(lane.intervals, id: \.start) { interval in + RectangleMark( + xStart: .value("Start", interval.start), + xEnd: .value("End", max(interval.end, interval.start.addingTimeInterval(minimumDuration))), + y: .value("Lane", lane.label), + height: .fixed(12) + ) + .foregroundStyle(lane.color) + .clipShape(.rect(cornerRadius: 3)) + } + } + } + .chartXScale(domain: domain) + .chartYScale(domain: lanes.map(\.label)) + .chartPlotStyle { $0.clipped() } + .chartXAxis { + AxisMarks { _ in + AxisGridLine() + AxisValueLabel(format: .dateTime.hour().minute()) + } + } + .frame(height: CGFloat(lanes.count) * 30 + 30) + } +} + +#Preview { + let now = Date() + TimelineChart( + lanes: [ + .init(label: "Worklight", color: .green, intervals: [DateInterval(start: now.addingTimeInterval(-7_200), end: now.addingTimeInterval(-3_600))]), + .init(label: "Window", color: .orange, intervals: [DateInterval(start: now.addingTimeInterval(-600), duration: 60)]) + ], + domain: now.addingTimeInterval(-86_400)...now + ) + .padding() +} diff --git a/Sources/Controller/Charts/ValueChart.swift b/Sources/Controller/Charts/ValueChart.swift new file mode 100644 index 00000000..cb89495c --- /dev/null +++ b/Sources/Controller/Charts/ValueChart.swift @@ -0,0 +1,69 @@ +// +// ValueChart.swift +// Controller +// + +import Charts +import SwiftUI + +/// A measurement over time with an optional threshold line and background bands. +struct ValueChart: View { + let points: [(date: Date, value: Double)] + let domain: ClosedRange + let color: Color + let unit: String + var isLogarithmic = false + var threshold: (value: Double, label: String)? + var bands: [DateInterval] = [] + + var body: some View { + Chart { + ForEach(bands, id: \.start) { band in + RectangleMark(xStart: .value("Start", band.start), xEnd: .value("End", band.end)) + .foregroundStyle(ChartPalette.night) + } + ForEach(points.indices, id: \.self) { index in + LineMark( + x: .value("Time", points[index].date), + // The log scale cannot place 0. + y: .value(unit, isLogarithmic ? max(points[index].value, 0.1) : points[index].value) + ) + .foregroundStyle(color) + } + if let threshold { + RuleMark(y: .value("Threshold", threshold.value)) + .lineStyle(StrokeStyle(lineWidth: 1, dash: [4, 3])) + .foregroundStyle(.secondary) + .annotation(position: .top, alignment: .trailing) { + Text(threshold.label) + .font(.caption2) + .foregroundStyle(.secondary) + } + } + } + .chartXScale(domain: domain) + .chartYScale(type: isLogarithmic ? .log : .linear) + .chartYAxisLabel(unit) + .chartPlotStyle { $0.clipped() } + .chartXAxis { + AxisMarks { _ in + AxisGridLine() + AxisValueLabel(format: .dateTime.hour().minute()) + } + } + .frame(height: 180) + } +} + +#Preview { + let now = Date() + ValueChart( + points: (0..<24).map { (now.addingTimeInterval(Double($0 - 24) * 3_600), Double($0 * $0)) }, + domain: now.addingTimeInterval(-86_400)...now, + color: .indigo, + unit: "lx", + isLogarithmic: true, + threshold: (60, "Threshold 60 lx") + ) + .padding() +} diff --git a/Sources/Controller/Features/AutomationsFeature.swift b/Sources/Controller/Features/AutomationsFeature.swift index 9d23e224..d212e0d9 100644 --- a/Sources/Controller/Features/AutomationsFeature.swift +++ b/Sources/Controller/Features/AutomationsFeature.swift @@ -183,7 +183,7 @@ struct AutomationsView: View { } } .navigationTitle("Automations") - .navigationDestination(item: $store.scope(state: \.selectedAutomation, action: \.selectedAutomation)) { automationStore in + .navigationDestination(item: $store.scope(\.$selectedAutomation, action: \.selectedAutomation)) { automationStore in AutomationDetailView(store: automationStore) .navigationTitle(automationStore.automation.name) } diff --git a/Sources/Controller/Features/EntityHistoryDetailFeature.swift b/Sources/Controller/Features/EntityHistoryDetailFeature.swift index 2af700fe..33e5e045 100644 --- a/Sources/Controller/Features/EntityHistoryDetailFeature.swift +++ b/Sources/Controller/Features/EntityHistoryDetailFeature.swift @@ -242,18 +242,7 @@ struct EntityHistoryDetailView: View { .onAppear { store.send(.onAppear) } - .alert($store.scope(state: \.alert, action: \.alert)) - } - - /// On/off periods of a boolean entity, clipped to the chart's range. - private func activeIntervals(in range: ClosedRange) -> [DateInterval] { - let ascending = Array(store.chartData.reversed()) - return ascending.indices.compactMap { index in - guard ascending[index].stateValue == true else { return nil } - let start = max(ascending[index].timestamp, range.lowerBound) - let end = index + 1 < ascending.count ? ascending[index + 1].timestamp : range.upperBound - return start < end ? DateInterval(start: start, end: end) : nil - } + .alert($store.scope(\.$alert, action: \.alert)) } @ViewBuilder @@ -263,33 +252,37 @@ struct EntityHistoryDetailView: View { let isBoolean = store.chartData.contains { $0.stateValue != nil } let isLux = store.entity.entityId.characteristicType == .lightSensor + if isBoolean { + TimelineChart( + lanes: [.init( + label: "On", + color: ChartPalette.color(for: store.entity.entityId.characteristicType), + intervals: StateIntervals.intervals(items: store.chartData, isActive: \.stateValue, from: dateRange.start, to: dateRange.end) + )], + domain: range + ) + } else { + lineChart(range: range, isLux: isLux) + } + } + + private func lineChart(range: ClosedRange, isLux: Bool) -> some View { Chart { - if isBoolean { - ForEach(activeIntervals(in: range), id: \.start) { interval in - RectangleMark( - xStart: .value("Start", interval.start), - xEnd: .value("End", interval.end), - y: .value("State", "On") + ForEach(store.chartData) { item in + if let value = item.primaryValue { + // The log scale cannot place 0 lx. + let plotted = isLux ? max(value, 0.1) : value + LineMark( + x: .value("Time", item.timestamp), + y: .value("Value", plotted) + ) + .foregroundStyle(Color.accentColor) + + PointMark( + x: .value("Time", item.timestamp), + y: .value("Value", plotted) ) .foregroundStyle(Color.accentColor) - } - } else { - ForEach(store.chartData) { item in - if let value = item.primaryValue { - // The log scale cannot place 0 lx. - let plotted = isLux ? max(value, 0.1) : value - LineMark( - x: .value("Time", item.timestamp), - y: .value("Value", plotted) - ) - .foregroundStyle(Color.accentColor) - - PointMark( - x: .value("Time", item.timestamp), - y: .value("Value", plotted) - ) - .foregroundStyle(Color.accentColor) - } } } } diff --git a/Sources/Controller/Features/RoomFeature.swift b/Sources/Controller/Features/RoomFeature.swift new file mode 100644 index 00000000..96fad86f --- /dev/null +++ b/Sources/Controller/Features/RoomFeature.swift @@ -0,0 +1,183 @@ +// +// RoomFeature.swift +// ControllerFeatures +// +// Timeline of lamps, motion and windows of one room +// + +import ComposableArchitecture +import Foundation +import HAModels +import SwiftUI + +@Reducer +struct RoomFeature: Sendable { + + enum TimeRange: TimeInterval, CaseIterable, Sendable { + case hour = 3_600 + case day = 86_400 + } + + @ObservableState + struct State: Equatable, Sendable { + let placeId: String + let entities: [EntityInfo] + var automations: [AutomationInfo] = [] + var end = Date() + var error: String? + var histories: [EntityHistory] = [] + var timeRange: TimeRange = .day + @Presents var entityDetail: EntityHistoryDetailFeature.State? + + var start: Date { end.addingTimeInterval(-timeRange.rawValue) } + } + + enum Action: BindableAction, Sendable { + case automationsResponse(Result<[AutomationInfo], Error>) + case binding(BindingAction) + case entityDetail(PresentationAction) + case entityTapped(EntityInfo) + case historyResponse(Result<[EntityHistory], Error>) + case task + } + + @Dependency(\.date.now) var now + @Dependency(\.serverClient) var serverClient + + var body: some ReducerOf { + BindingReducer() + Reduce { state, action in + switch action { + case let .automationsResponse(.success(automations)): + state.automations = automations.filter { $0.entities.contains { $0.placeId == state.placeId } } + return .none + + case let .historyResponse(.success(histories)): + state.histories = histories + return .none + + case let .automationsResponse(.failure(error)), let .historyResponse(.failure(error)): + state.error = error.localizedDescription + return .none + + case .binding(\.timeRange): + return loadHistory(&state) + + case .binding, .entityDetail: + return .none + + case let .entityTapped(entity): + state.entityDetail = EntityHistoryDetailFeature.State(entity: entity) + return .none + + case .task: + return .merge( + loadHistory(&state), + .run { send in + await send(.automationsResponse(Result { try await serverClient.getAutomations() })) + } + ) + } + } + .ifLet(\.$entityDetail, action: \.entityDetail) { + EntityHistoryDetailFeature() + } + } + + private func loadHistory(_ state: inout State) -> Effect { + state.end = now + state.error = nil + return .run { [placeId = state.placeId, start = state.start, end = state.end] send in + await send(.historyResponse(Result { + try await serverClient.getRoomHistory(placeId, start, end, true) + })) + } + } +} + +struct RoomView: View { + @Bindable var store: StoreOf + + private static let laneTypes: [CharacteristicsType] = [.switcher, .motionSensor, .contactSensor] + + private var lanes: [TimelineChart.Lane] { + store.histories + .filter { Self.laneTypes.contains($0.entityId.characteristicType) } + .sorted { ($0.entityId.characteristicType.rawValue, $0.entityId.name) < ($1.entityId.characteristicType.rawValue, $1.entityId.name) } + .map { history in + TimelineChart.Lane( + label: history.entityId.name, + color: ChartPalette.color(for: history.entityId.characteristicType), + intervals: StateIntervals.intervals(items: history.items, isActive: \.stateValue, from: store.start, to: store.end) + ) + } + } + + var body: some View { + List { + Section { + Picker("Time Range", selection: $store.timeRange) { + Text("Last Hour").tag(RoomFeature.TimeRange.hour) + Text("24 Hours").tag(RoomFeature.TimeRange.day) + } + .pickerStyle(.segmented) + if lanes.isEmpty { + Text(store.error ?? "No lamps, motion sensors or windows with history.") + .foregroundStyle(.secondary) + } else { + TimelineChart(lanes: lanes, domain: store.start...store.end) + legend + } + } header: { + Text("Timeline") + } + + Section("Devices") { + ForEach(store.entities) { entity in + Button { + store.send(.entityTapped(entity)) + } label: { + Label(entity.formattedCharacteristicDisplayName, systemImage: entity.entityId.characteristicType.systemImage) + } + } + } + + if !store.automations.isEmpty { + Section("Automations") { + ForEach(store.automations) { automation in + Label(automation.name, systemImage: automation.systemImage) + } + } + } + } + .navigationTitle(store.placeId) + .task { await store.send(.task).finish() } + .navigationDestination(item: $store.scope(\.$entityDetail, action: \.entityDetail)) { detailStore in + EntityHistoryDetailView(store: detailStore) + .navigationTitle(detailStore.entity.displayName) + } + } + + private var legend: some View { + HStack(spacing: 16) { + ForEach(Self.laneTypes.filter { type in store.histories.contains { $0.entityId.characteristicType == type } }, id: \.self) { type in + Label { + Text(type.displayName) + } icon: { + Circle().fill(ChartPalette.color(for: type)).frame(width: 10, height: 10) + } + .font(.caption) + } + } + } +} + +#Preview { + NavigationStack { + RoomView( + store: Store(initialState: RoomFeature.State(placeId: "Kitchen", entities: [])) { + RoomFeature() + } + ) + } +} diff --git a/Sources/Controller/Features/RoomsFeature.swift b/Sources/Controller/Features/RoomsFeature.swift index b71591ff..4b8e9d57 100644 --- a/Sources/Controller/Features/RoomsFeature.swift +++ b/Sources/Controller/Features/RoomsFeature.swift @@ -23,6 +23,7 @@ struct RoomsFeature: Sendable { var searchText = "" var selectedEntityId: String? @Presents var selectedEntityDetail: EntityHistoryDetailFeature.State? + @Presents var room: RoomFeature.State? @Presents var alert: AlertState? var filteredEntities: [EntityInfo] { @@ -52,6 +53,8 @@ struct RoomsFeature: Sendable { case entitiesResponse(Result<[EntityInfo], Error>) case binding(BindingAction) case selectedEntityDetail(PresentationAction) + case room(PresentationAction) + case roomTapped(String) case alert(PresentationAction) enum Alert: Sendable { @@ -116,7 +119,11 @@ struct RoomsFeature: Sendable { } return .none - case .selectedEntityDetail: + case .selectedEntityDetail, .room: + return .none + + case let .roomTapped(placeId): + state.room = RoomFeature.State(placeId: placeId, entities: state.entities.filter { $0.entityId.placeId == placeId }) return .none case .alert: @@ -126,6 +133,9 @@ struct RoomsFeature: Sendable { .ifLet(\.$selectedEntityDetail, action: \.selectedEntityDetail) { EntityHistoryDetailFeature() } + .ifLet(\.$room, action: \.room) { + RoomFeature() + } .ifLet(\.$alert, action: \.alert) } } @@ -138,11 +148,14 @@ struct RoomsView: View { contentView .navigationTitle("Rooms") .navigationDestination( - item: $store.scope(state: \.selectedEntityDetail, action: \.selectedEntityDetail) + item: $store.scope(\.$selectedEntityDetail, action: \.selectedEntityDetail) ) { detailStore in EntityHistoryDetailView(store: detailStore) .navigationTitle(detailStore.entity.displayName) } + .navigationDestination(item: $store.scope(\.$room, action: \.room)) { roomStore in + RoomView(store: roomStore) + } .sensoryFeedback(.selection, trigger: store.selectedEntityId) .refreshable { store.send(.refresh) @@ -155,7 +168,7 @@ struct RoomsView: View { ProgressView() } } - .alert($store.scope(state: \.alert, action: \.alert)) + .alert($store.scope(\.$alert, action: \.alert)) } } @@ -171,11 +184,20 @@ struct RoomsView: View { } else { List(selection: $store.selectedEntityId) { ForEach(store.rooms, id: \.placeId) { room in - Section(room.placeId) { + Section { ForEach(room.entities) { entity in entityRow(entity) .tag(entity.id) } + } header: { + Button { + store.send(.roomTapped(room.placeId)) + } label: { + HStack(spacing: 4) { + Text(room.placeId) + Image(systemName: "chevron.right") + } + } } } } diff --git a/Sources/HAModels/Automation/RunStatistics.swift b/Sources/HAModels/Automation/RunStatistics.swift new file mode 100644 index 00000000..ea552537 --- /dev/null +++ b/Sources/HAModels/Automation/RunStatistics.swift @@ -0,0 +1,27 @@ +// +// RunStatistics.swift +// HAModels +// + +import Foundation + +/// Counts over `AutomationRun`s for the run statistics charts. +public enum RunStatistics { + public static func perDay(_ runs: [AutomationRun], days: [Date], calendar: Calendar) -> [(day: Date, count: Int)] { + DailyTotals.countsPerDay(runs.map(\.startedAt), days: days, calendar: calendar) + } + + /// Grouped by the triggering entity, else by the summary's first part (before " · "); most frequent first. + public static func byTrigger(_ runs: [AutomationRun]) -> [(trigger: String, count: Int)] { + let groups = Dictionary(grouping: runs) { run in + run.trigger.entityId?.name ?? String(run.trigger.summary.split(separator: " · ").first ?? "") + } + return groups + .map { ($0.key, $0.value.count) } + .sorted { ($1.1, $0.0) < ($0.1, $1.0) } + } + + public static func byOutcome(_ runs: [AutomationRun]) -> [AutomationRun.Outcome: Int] { + runs.reduce(into: [:]) { $0[$1.outcome, default: 0] += 1 } + } +} diff --git a/Sources/HAModels/History/DailyTotals.swift b/Sources/HAModels/History/DailyTotals.swift new file mode 100644 index 00000000..5567b7cb --- /dev/null +++ b/Sources/HAModels/History/DailyTotals.swift @@ -0,0 +1,27 @@ +// +// DailyTotals.swift +// HAModels +// + +import Foundation + +/// Per-day aggregation of intervals and events for bar charts. +public enum DailyTotals { + /// Time covered by `intervals` on each day; `days` are the start-of-day dates to report. + public static func dailyTotals(_ intervals: [DateInterval], days: [Date], calendar: Calendar) -> [(day: Date, duration: TimeInterval)] { + days.map { day in + guard let dayInterval = calendar.dateInterval(of: .day, for: day) else { return (day, 0) } + let duration = intervals.reduce(0) { sum, interval in + sum + (interval.intersection(with: dayInterval)?.duration ?? 0) + } + return (day, duration) + } + } + + /// Number of `dates` falling on each of `days`. + public static func countsPerDay(_ dates: [Date], days: [Date], calendar: Calendar) -> [(day: Date, count: Int)] { + days.map { day in + (day, dates.count { calendar.isDate($0, inSameDayAs: day) }) + } + } +} diff --git a/Sources/HAModels/History/StateIntervals.swift b/Sources/HAModels/History/StateIntervals.swift new file mode 100644 index 00000000..69a426df --- /dev/null +++ b/Sources/HAModels/History/StateIntervals.swift @@ -0,0 +1,63 @@ +// +// StateIntervals.swift +// HAModels +// + +import Foundation + +/// Turns a change log of a boolean state into the periods in which it held. +public enum StateIntervals { + /// Periods in which `isActive` returned `true`, clipped to `start...end`. + /// + /// Items may come in any order: the server sends newest first and appends the `includePrevious` + /// item last, which sets the state at the left edge. Items where `isActive` is `nil` are skipped, + /// and an interval still open at the newest item is closed at `end`. + public static func intervals(items: [EntityHistoryItem], isActive: (EntityHistoryItem) -> Bool?, from start: Date, to end: Date) -> [DateInterval] { + var result: [DateInterval] = [] + var activeSince: Date? + let close = { (since: Date, until: Date) in + let clippedStart = max(since, start) + let clippedEnd = min(until, end) + if clippedStart < clippedEnd { + result.append(DateInterval(start: clippedStart, end: clippedEnd)) + } + } + for item in items.sorted(by: { $0.timestamp < $1.timestamp }) { + guard let active = isActive(item) else { continue } + if active, activeSince == nil { + activeSince = item.timestamp + } else if !active, let since = activeSince { + close(since, item.timestamp) + activeSince = nil + } + } + if let activeSince { + close(activeSince, end) + } + return result + } + + /// Periods within `range` in which the sun is below the horizon; days without sunrise or sunset are skipped. + public static func nights(in range: DateInterval, latitude: Double, longitude: Double, calendar: Calendar = .current) -> [DateInterval] { + var daylight: [DateInterval] = [] + var day = calendar.startOfDay(for: range.start) + while day < range.end { + if let schedule = Sun.schedule(latitude: latitude, longitude: longitude, date: day.addingTimeInterval(12 * 3600), calendar: calendar, timeZone: calendar.timeZone), + let sunrise = schedule.sunrise?.date, let sunset = schedule.sunset?.date, sunrise < sunset { + daylight.append(DateInterval(start: sunrise, end: sunset)) + } + guard let next = calendar.date(byAdding: .day, value: 1, to: day) else { break } + day = next + } + var nights: [DateInterval] = [] + var cursor = range.start + for interval in daylight + [DateInterval(start: range.end, duration: 0)] { + let end = min(interval.start, range.end) + if cursor < end { + nights.append(DateInterval(start: cursor, end: end)) + } + cursor = max(cursor, interval.end) + } + return nights + } +} diff --git a/Tests/HomeAutomationKitTests/HelperTests/ChartPreparationTests.swift b/Tests/HomeAutomationKitTests/HelperTests/ChartPreparationTests.swift new file mode 100644 index 00000000..ef0efde4 --- /dev/null +++ b/Tests/HomeAutomationKitTests/HelperTests/ChartPreparationTests.swift @@ -0,0 +1,120 @@ +// +// ChartPreparationTests.swift +// HomeAutomationKit +// + +import Foundation +import HAModels +import Testing + +struct StateIntervalsTests { + private let start = Date(timeIntervalSince1970: 100_000) + private var end: Date { start.addingTimeInterval(1_000) } + + private func item(_ offset: TimeInterval, _ isOn: Bool?) -> EntityHistoryItem { + EntityHistoryItem(timestamp: start.addingTimeInterval(offset), isDeviceOn: isOn) + } + + @Test func previousStateStartsAtLeftEdgeAndOpenEndClosesAtTo() { + // Server order: newest first, the includePrevious item appended last. + let items = [item(800, true), item(500, false), item(100, true), item(-3_600, true)] + + let intervals = StateIntervals.intervals(items: items, isActive: \.isDeviceOn, from: start, to: end) + + #expect(intervals == [ + DateInterval(start: start, end: start.addingTimeInterval(500)), + DateInterval(start: start.addingTimeInterval(800), end: end) + ]) + } + + @Test func itemsWithoutTheStateAreIgnored() { + let items = [item(100, true), item(200, nil), item(300, false)] + + let intervals = StateIntervals.intervals(items: items, isActive: \.isDeviceOn, from: start, to: end) + + #expect(intervals == [DateInterval(start: start.addingTimeInterval(100), end: start.addingTimeInterval(300))]) + } + + @Test func intervalEndingBeforeTheRangeIsDropped() { + let items = [item(-200, true), item(-100, false)] + + #expect(StateIntervals.intervals(items: items, isActive: \.isDeviceOn, from: start, to: end).isEmpty) + } +} + +struct NightsTests { + @Test func nightsCoverMidnightButNotNoon() throws { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = try #require(TimeZone(identifier: "Europe/Berlin")) + let midnight = try #require(calendar.date(from: DateComponents(year: 2026, month: 6, day: 2))) + let range = DateInterval(start: midnight.addingTimeInterval(-6 * 3600), end: midnight.addingTimeInterval(18 * 3600)) + + let nights = StateIntervals.nights(in: range, latitude: 52.5, longitude: 13.4, calendar: calendar) + + #expect(nights.count == 1) + #expect(nights.contains { $0.contains(midnight) }) + #expect(!nights.contains { $0.contains(midnight.addingTimeInterval(12 * 3600)) }) + } +} + +struct DailyTotalsTests { + private var calendar: Calendar { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = .gmt + return calendar + } + private let day1 = Date(timeIntervalSince1970: 86_400 * 10) + private var day2: Date { day1.addingTimeInterval(86_400) } + + @Test func intervalAcrossMidnightIsSplitBetweenDays() { + let interval = DateInterval(start: day2.addingTimeInterval(-600), end: day2.addingTimeInterval(300)) + + let totals = DailyTotals.dailyTotals([interval], days: [day1, day2], calendar: calendar) + + #expect(totals.map(\.day) == [day1, day2]) + #expect(totals.map(\.duration) == [600, 300]) + } + + @Test func countsPerDayBucketsDates() { + let dates = [day1.addingTimeInterval(10), day1.addingTimeInterval(20), day2.addingTimeInterval(5), day2.addingTimeInterval(86_400)] + + let counts = DailyTotals.countsPerDay(dates, days: [day1, day2], calendar: calendar) + + #expect(counts.map(\.count) == [2, 1]) + } +} + +struct RunStatisticsTests { + private let motion = EntityId(placeId: "Hallway", name: "Eve Motion", characteristicsName: nil, characteristic: .motionSensor) + private let start = Date(timeIntervalSince1970: 86_400 * 10) + + private func run(_ trigger: AutomationTrigger, _ outcome: AutomationRun.Outcome, at offset: TimeInterval = 0) -> AutomationRun { + AutomationRun(automationName: "Night", startedAt: start.addingTimeInterval(offset), trigger: trigger, outcome: outcome) + } + + private var runs: [AutomationRun] { + let byMotion = AutomationTrigger(kind: .entityChange, entityId: motion, summary: "Motion · Eve Motion (Hallway)") + let bySunset = AutomationTrigger(kind: .sunset, entityId: nil, summary: "Sunset · Lights on") + return [run(byMotion, .completed), run(byMotion, .failed, at: 86_400), run(bySunset, .completed, at: 60)] + } + + @Test func byTriggerGroupsByEntityOrSummaryPrefixMostFirst() { + let groups = RunStatistics.byTrigger(runs) + + #expect(groups.map(\.trigger) == ["Eve Motion", "Sunset"]) + #expect(groups.map(\.count) == [2, 1]) + } + + @Test func byOutcomeCountsEachOutcome() { + #expect(RunStatistics.byOutcome(runs) == [.completed: 2, .failed: 1]) + } + + @Test func perDayCountsRunStarts() { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = .gmt + + let counts = RunStatistics.perDay(runs, days: [start, start.addingTimeInterval(86_400)], calendar: calendar) + + #expect(counts.map(\.count) == [2, 1]) + } +} From b62531ec21473128af2636bbb0bb0546edf4a18b Mon Sep 17 00:00:00 2001 From: Julian Kahnert Date: Sat, 3 Oct 2026 21:02:57 +0200 Subject: [PATCH 09/22] Show why MotionAtNight triggered in its automation details Motion and light lanes above the illuminance curve with the trigger threshold and night bands, from the server location stored once from GET /config. --- Package.swift | 1 + .../Dependencies/ServerClientDependency.swift | 12 ++- .../Features/AutomationDetails.swift | 99 +++++++++++++++++-- Sources/Controller/Shared/SharedKeys.swift | 7 ++ .../Automations/MotionAtNight.swift | 4 +- Sources/HAModels/Location.swift | 2 +- Sources/ServerClient/ServerClient.swift | 9 ++ 7 files changed, 121 insertions(+), 13 deletions(-) diff --git a/Package.swift b/Package.swift index 468e704b..f5159cef 100644 --- a/Package.swift +++ b/Package.swift @@ -137,6 +137,7 @@ let package = Package( .product(name: "ComposableArchitecture", package: "swift-composable-architecture"), .product(name: "Dependencies", package: "swift-dependencies"), .product(name: "Sharing", package: "swift-sharing"), + "HAImplementations", "HAModels", "Shared", "ServerClient", diff --git a/Sources/Controller/Dependencies/ServerClientDependency.swift b/Sources/Controller/Dependencies/ServerClientDependency.swift index 033687b8..589dbc68 100644 --- a/Sources/Controller/Dependencies/ServerClientDependency.swift +++ b/Sources/Controller/Dependencies/ServerClientDependency.swift @@ -64,6 +64,9 @@ struct ServerClientDependency: Sendable { /// History of every entity in a room, newest first per entity var getRoomHistory: @Sendable (_ placeId: String, _ startDate: Date?, _ endDate: Date?, _ includePrevious: Bool) async throws -> [EntityHistory] + + /// The server's location, used for sunrise and sunset + var getLocation: @Sendable () async throws -> Location } // MARK: - Dependency Key Implementation @@ -83,7 +86,8 @@ extension ServerClientDependency: TestDependencyKey { registerDevice: { _ in }, getEntityIdsWithHistory: { [] }, getEntityHistory: { _, _, _, _, _, _ in EntityHistoryResponse(items: [], nextCursor: nil) }, - getRoomHistory: { _, _, _, _ in [] } + getRoomHistory: { _, _, _, _ in [] }, + getLocation: { Location(latitude: 52.5, longitude: 13.4) } ) static let previewValue = Self( @@ -199,7 +203,8 @@ extension ServerClientDependency: TestDependencyKey { return EntityHistoryResponse(items: items, nextCursor: nil) }, - getRoomHistory: { _, _, _, _ in [] } + getRoomHistory: { _, _, _, _ in [] }, + getLocation: { Location(latitude: 52.5, longitude: 13.4) } ) private static let previewRuns = [ @@ -279,6 +284,9 @@ extension ServerClientDependency: DependencyKey { }, getRoomHistory: { placeId, startDate, endDate, includePrevious in try await client.getRoomHistory(placeId: placeId, startDate: startDate, endDate: endDate, includePrevious: includePrevious) + }, + getLocation: { + try await client.getLocation() } ) } diff --git a/Sources/Controller/Features/AutomationDetails.swift b/Sources/Controller/Features/AutomationDetails.swift index f22bd852..a3577ed5 100644 --- a/Sources/Controller/Features/AutomationDetails.swift +++ b/Sources/Controller/Features/AutomationDetails.swift @@ -7,6 +7,7 @@ import ComposableArchitecture import Foundation +import HAImplementations import HAModels import Sharing import SwiftUI @@ -30,11 +31,20 @@ struct AutomationDetails: Sendable { /// Last 24 hours, newest first. var runs: [AutomationRun] = [] @Presents var destination: Destination.State? + /// Entity histories of the debug chart, only loaded for `MotionAtNight`. + var histories: [EntityHistory] = [] + var end = Date() + @Shared(.serverLocation) var location + + var isMotionAtNight: Bool { automation.type == "MotionAtNight" } + var start: Date { end.addingTimeInterval(-86400) } } enum Action: Sendable { case destination(PresentationAction) case entityTapped(EntityId) + case historiesResponse(Result<[EntityHistory], Error>) + case locationResponse(Result) case runTapped(AutomationRun) case runsResponse(Result<[AutomationRun], Error>) case task @@ -61,6 +71,18 @@ struct AutomationDetails: Sendable { state.destination = .entity(EntityHistoryDetailFeature.State(entity: EntityInfo(entityId: entityId))) return .none + case let .historiesResponse(.success(histories)): + state.histories = histories + return .none + + case let .locationResponse(.success(location)): + state.$location.withLock { $0 = location } + return .none + + case let .historiesResponse(.failure(error)), let .locationResponse(.failure(error)): + state.error = "Failed to load chart data: \(error.localizedDescription)" + return .none + case let .runTapped(run): state.destination = .run(RunDetailFeature.State(run: run)) return .none @@ -74,12 +96,15 @@ struct AutomationDetails: Sendable { return .none case .task: - guard state.automation.recordsRuns else { return .none } - return .run { [name = state.automation.name, now] send in - await send(.runsResponse(Result { - try await serverClient.getRuns(name, now.addingTimeInterval(-86400), nil, nil) - })) - } + state.end = now + return .merge( + state.automation.recordsRuns ? .run { [name = state.automation.name, start = state.start] send in + await send(.runsResponse(Result { + try await serverClient.getRuns(name, start, nil, nil) + })) + } : .none, + state.isMotionAtNight ? loadDebugChart(state) : .none + ) case .stopAutomation: state.error = nil state.isLoading = true @@ -128,6 +153,22 @@ struct AutomationDetails: Sendable { } .ifLet(\.$destination, action: \.destination) } + + /// One room-history request per room the automation's devices are in. + private func loadDebugChart(_ state: State) -> Effect { + .run { [entities = state.automation.entities, start = state.start, end = state.end, hasLocation = state.location != nil] send in + if !hasLocation { + await send(.locationResponse(Result { try await serverClient.getLocation() })) + } + await send(.historiesResponse(Result { + var histories: [EntityHistory] = [] + for placeId in Set(entities.map(\.placeId)) { + histories += try await serverClient.getRoomHistory(placeId, start, end, true) + } + return histories.filter { entities.contains($0.entityId) } + })) + } + } } extension AutomationDetails.Destination.State: Equatable, Sendable {} @@ -189,19 +230,61 @@ struct AutomationDetailView: View { Text("No runs in the last 24 hours.") } } + + if store.isMotionAtNight { + motionAtNightSection + } } .buttonStyle(.plain) .task { await store.send(.task).finish() } - .navigationDestination(item: $store.scope(state: \.destination?.entity, action: \.destination.entity)) { entityStore in + .navigationDestination(item: $store.scope(\.$destination, action: \.destination).entity) { entityStore in EntityHistoryDetailView(store: entityStore) .navigationTitle(entityStore.entity.displayName) } - .navigationDestination(item: $store.scope(state: \.destination?.run, action: \.destination.run)) { runStore in + .navigationDestination(item: $store.scope(\.$destination, action: \.destination).run) { runStore in RunDetailView(store: runStore) } } } +extension AutomationDetailView { + private var motionAtNightSection: some View { + let nights = store.location.map { + StateIntervals.nights(in: DateInterval(start: store.start, end: store.end), latitude: $0.latitude, longitude: $0.longitude) + } ?? [] + let lanes = store.histories + .filter { [.switcher, .motionSensor].contains($0.entityId.characteristicType) } + .map { history in + TimelineChart.Lane( + label: history.entityId.name, + color: ChartPalette.color(for: history.entityId.characteristicType), + intervals: StateIntervals.intervals(items: history.items, isActive: \.stateValue, from: store.start, to: store.end) + ) + } + let lux = store.histories + .filter { $0.entityId.characteristicType == .lightSensor } + .flatMap(\.items) + .compactMap { item in item.illuminanceInLux.map { (date: item.timestamp, value: $0) } } + .sorted { $0.date < $1.date } + return Section { + TimelineChart(lanes: lanes, domain: store.start...store.end, nights: nights) + ValueChart( + points: lux, + domain: store.start...store.end, + color: ChartPalette.color(for: .lightSensor), + unit: "lx", + isLogarithmic: true, + threshold: (MotionAtNight.thresholdInLux, "Threshold \(Int(MotionAtNight.thresholdInLux)) lx"), + bands: nights + ) + } header: { + Text("Why did it trigger? · 24 h") + } footer: { + Text("Grey: sun below the horizon. Motion below the threshold turns the lights on.") + } + } +} + #Preview { AutomationDetailView( store: Store( diff --git a/Sources/Controller/Shared/SharedKeys.swift b/Sources/Controller/Shared/SharedKeys.swift index 17e9d7ce..d35e314d 100644 --- a/Sources/Controller/Shared/SharedKeys.swift +++ b/Sources/Controller/Shared/SharedKeys.swift @@ -40,6 +40,13 @@ extension SharedKey where Self == AppStorageKey.Def } } +extension SharedKey where Self == AppStorageKey.Default { + /// Server location from `GET /config`, for night bands in charts + static var serverLocation: Self { + Self[.appStorage("serverLocation", store: .standard), default: nil] + } +} + // MARK: - In-Memory (volatile state) extension SharedKey where Self == InMemoryKey> { diff --git a/Sources/HAImplementations/Automations/MotionAtNight.swift b/Sources/HAImplementations/Automations/MotionAtNight.swift index a942bebe..4a22b3d7 100644 --- a/Sources/HAImplementations/Automations/MotionAtNight.swift +++ b/Sources/HAImplementations/Automations/MotionAtNight.swift @@ -9,8 +9,8 @@ import Foundation import HAModels public struct MotionAtNight: Automatable { - // threshold under which the automation should be triggered - private static let thresholdInLux = 60.0 + /// Illuminance below which motion turns the lights on. + public static let thresholdInLux = 60.0 public var isActive = true public let name: String diff --git a/Sources/HAModels/Location.swift b/Sources/HAModels/Location.swift index d935d0f0..def463fd 100644 --- a/Sources/HAModels/Location.swift +++ b/Sources/HAModels/Location.swift @@ -5,7 +5,7 @@ // Created by Julian Kahnert on 05.02.25. // -public struct Location: Sendable, Codable { +public struct Location: Sendable, Codable, Equatable { public let latitude: Double public let longitude: Double diff --git a/Sources/ServerClient/ServerClient.swift b/Sources/ServerClient/ServerClient.swift index 0459e966..8a416574 100644 --- a/Sources/ServerClient/ServerClient.swift +++ b/Sources/ServerClient/ServerClient.swift @@ -56,6 +56,15 @@ public struct ServerClient { return (response as? HTTPURLResponse)?.statusCode == 200 } + /// The server's location from `GET /config`, which is not part of the OpenAPI spec because its + /// automations are polymorphic JSON; only `location` is decoded. + public func getLocation() async throws -> Location { + struct Config: Decodable { let location: Location } + let (data, response) = try await session.data(from: url.appending(path: "config")) + guard (response as? HTTPURLResponse)?.statusCode == 200 else { throw URLError(.badServerResponse) } + return try JSONDecoder().decode(Config.self, from: data).location + } + public func getRuns(automation name: String, startDate: Date? = nil, endDate: Date? = nil, limit: Int? = nil) async throws -> [AutomationRun] { let response = try await client.getAutomationRuns(path: .init(name: name), query: .init(startDate: startDate, endDate: endDate, limit: limit)) From 1ab68cec72ff1bc029552429f66c84b6deba4a5d Mon Sep 17 00:00:00 2001 From: Julian Kahnert Date: Sat, 3 Oct 2026 21:13:45 +0200 Subject: [PATCH 10/22] Add daily ventilation, on-duration, CO2 and daily max/mean charts Room detail shows CO2 with a 1000 ppm threshold and open-window bands, plus ventilation minutes per day over 7 days. Entity detail in the 7-day range shows on-hours per day for lamps and daily max with time-weighted mean for lux and CO2. --- Sources/Controller/Charts/DailyBarChart.swift | 44 ++++++++++++++++++ Sources/Controller/Charts/ValueChart.swift | 3 +- .../Features/EntityHistoryDetailFeature.swift | 30 ++++++++++++ Sources/Controller/Features/RoomFeature.swift | 46 ++++++++++++++++++- Sources/HAModels/History/DailyTotals.swift | 25 ++++++++++ .../HelperTests/ChartPreparationTests.swift | 17 +++++++ 6 files changed, 163 insertions(+), 2 deletions(-) create mode 100644 Sources/Controller/Charts/DailyBarChart.swift diff --git a/Sources/Controller/Charts/DailyBarChart.swift b/Sources/Controller/Charts/DailyBarChart.swift new file mode 100644 index 00000000..6c94e3d6 --- /dev/null +++ b/Sources/Controller/Charts/DailyBarChart.swift @@ -0,0 +1,44 @@ +// +// DailyBarChart.swift +// Controller +// + +import Charts +import SwiftUI + +/// One bar per day, with an optional point per day (e.g. the mean on top of the maximum). +struct DailyBarChart: View { + let bars: [(day: Date, value: Double)] + let color: Color + let unit: String + var points: [(day: Date, value: Double)] = [] + + var body: some View { + Chart { + ForEach(bars.indices, id: \.self) { index in + BarMark(x: .value("Day", bars[index].day, unit: .day), y: .value(unit, bars[index].value)) + .foregroundStyle(color) + } + ForEach(points.indices, id: \.self) { index in + PointMark(x: .value("Day", points[index].day, unit: .day), y: .value(unit, points[index].value)) + .foregroundStyle(.primary) + } + } + .chartYAxisLabel(unit) + .chartXAxis { + AxisMarks(values: .stride(by: .day)) { _ in + AxisValueLabel(format: .dateTime.weekday(.abbreviated), centered: true) + } + } + .frame(height: 140) + } +} + +#Preview { + DailyBarChart( + bars: (0..<7).map { (Date().addingTimeInterval(Double($0 - 6) * 86_400), Double($0 * 10)) }, + color: .orange, + unit: "min" + ) + .padding() +} diff --git a/Sources/Controller/Charts/ValueChart.swift b/Sources/Controller/Charts/ValueChart.swift index cb89495c..cb5c846d 100644 --- a/Sources/Controller/Charts/ValueChart.swift +++ b/Sources/Controller/Charts/ValueChart.swift @@ -15,12 +15,13 @@ struct ValueChart: View { var isLogarithmic = false var threshold: (value: Double, label: String)? var bands: [DateInterval] = [] + var bandColor = ChartPalette.night var body: some View { Chart { ForEach(bands, id: \.start) { band in RectangleMark(xStart: .value("Start", band.start), xEnd: .value("End", band.end)) - .foregroundStyle(ChartPalette.night) + .foregroundStyle(bandColor) } ForEach(points.indices, id: \.self) { index in LineMark( diff --git a/Sources/Controller/Features/EntityHistoryDetailFeature.swift b/Sources/Controller/Features/EntityHistoryDetailFeature.swift index 33e5e045..0fc428f7 100644 --- a/Sources/Controller/Features/EntityHistoryDetailFeature.swift +++ b/Sources/Controller/Features/EntityHistoryDetailFeature.swift @@ -221,6 +221,11 @@ struct EntityHistoryDetailView: View { .frame(height: 300) } + if store.timeRange == .week { + dailyChart + .padding(.horizontal) + } + // History list if !store.historyItems.isEmpty { VStack(spacing: 0) { @@ -266,6 +271,31 @@ struct EntityHistoryDetailView: View { } } + /// Per-day summaries of the 7-day range: on-duration for lamps, max and time-weighted mean for lux and CO₂. + @ViewBuilder + private var dailyChart: some View { + let dateRange = store.state.dateRange + let type = store.entity.entityId.characteristicType + let days = DailyTotals.days(count: 7, endingAt: dateRange.end, calendar: .current) + if type == .switcher { + let intervals = StateIntervals.intervals(items: store.chartData, isActive: \.isDeviceOn, from: dateRange.start, to: dateRange.end) + DailyBarChart( + bars: DailyTotals.dailyTotals(intervals, days: days, calendar: .current).map { ($0.day, $0.duration / 3_600) }, + color: ChartPalette.color(for: type), + unit: "h on" + ) + } else if type == .lightSensor || type == .carbonDioxideSensorId { + let samples = store.chartData.compactMap { item in item.primaryValue.map { (date: item.timestamp, value: $0) } } + let stats = DailyTotals.dailyStats(samples, days: days, end: dateRange.end, calendar: .current) + DailyBarChart( + bars: stats.map { ($0.day, $0.max) }, + color: ChartPalette.color(for: type).opacity(0.6), + unit: type == .lightSensor ? "lx (max, ● mean)" : "ppm (max, ● mean)", + points: stats.map { ($0.day, $0.mean) } + ) + } + } + private func lineChart(range: ClosedRange, isLux: Bool) -> some View { Chart { ForEach(store.chartData) { item in diff --git a/Sources/Controller/Features/RoomFeature.swift b/Sources/Controller/Features/RoomFeature.swift index 96fad86f..6c1d0c16 100644 --- a/Sources/Controller/Features/RoomFeature.swift +++ b/Sources/Controller/Features/RoomFeature.swift @@ -27,6 +27,7 @@ struct RoomFeature: Sendable { var error: String? var histories: [EntityHistory] = [] var timeRange: TimeRange = .day + var weekHistories: [EntityHistory] = [] @Presents var entityDetail: EntityHistoryDetailFeature.State? var start: Date { end.addingTimeInterval(-timeRange.rawValue) } @@ -39,6 +40,7 @@ struct RoomFeature: Sendable { case entityTapped(EntityInfo) case historyResponse(Result<[EntityHistory], Error>) case task + case weekHistoryResponse(Result<[EntityHistory], Error>) } @Dependency(\.date.now) var now @@ -56,7 +58,11 @@ struct RoomFeature: Sendable { state.histories = histories return .none - case let .automationsResponse(.failure(error)), let .historyResponse(.failure(error)): + case let .weekHistoryResponse(.success(histories)): + state.weekHistories = histories + return .none + + case let .automationsResponse(.failure(error)), let .historyResponse(.failure(error)), let .weekHistoryResponse(.failure(error)): state.error = error.localizedDescription return .none @@ -75,6 +81,11 @@ struct RoomFeature: Sendable { loadHistory(&state), .run { send in await send(.automationsResponse(Result { try await serverClient.getAutomations() })) + }, + .run { [placeId = state.placeId, end = state.end] send in + await send(.weekHistoryResponse(Result { + try await serverClient.getRoomHistory(placeId, end.addingTimeInterval(-7 * 86_400), end, true) + })) } ) } @@ -113,6 +124,14 @@ struct RoomView: View { } } + private var weekStart: Date { store.end.addingTimeInterval(-7 * 86_400) } + + private func windowIntervals(_ histories: [EntityHistory], from start: Date, to end: Date) -> [DateInterval] { + histories + .filter { $0.entityId.characteristicType == .contactSensor } + .flatMap { StateIntervals.intervals(items: $0.items, isActive: \.isContactOpen, from: start, to: end) } + } + var body: some View { List { Section { @@ -132,6 +151,31 @@ struct RoomView: View { Text("Timeline") } + if let co2 = store.histories.first(where: { $0.entityId.characteristicType == .carbonDioxideSensorId }) { + Section("CO₂") { + ValueChart( + points: co2.items.compactMap { item in item.carbonDioxideSensorId.map { (item.timestamp, Double($0)) } }, + domain: store.start...store.end, + color: ChartPalette.color(for: .carbonDioxideSensorId), + unit: "ppm", + threshold: (1_000, "1000 ppm"), + bands: windowIntervals(store.histories, from: store.start, to: store.end), + bandColor: ChartPalette.color(for: .contactSensor).opacity(0.2) + ) + } + } + + if store.weekHistories.contains(where: { $0.entityId.characteristicType == .contactSensor }) { + Section("Ventilation per Day") { + DailyBarChart( + bars: DailyTotals.dailyTotals(windowIntervals(store.weekHistories, from: weekStart, to: store.end), days: DailyTotals.days(count: 7, endingAt: store.end, calendar: .current), calendar: .current) + .map { ($0.day, $0.duration / 60) }, + color: ChartPalette.color(for: .contactSensor), + unit: "min" + ) + } + } + Section("Devices") { ForEach(store.entities) { entity in Button { diff --git a/Sources/HAModels/History/DailyTotals.swift b/Sources/HAModels/History/DailyTotals.swift index 5567b7cb..2a4ba2b0 100644 --- a/Sources/HAModels/History/DailyTotals.swift +++ b/Sources/HAModels/History/DailyTotals.swift @@ -24,4 +24,29 @@ public enum DailyTotals { (day, dates.count { calendar.isDate($0, inSameDayAs: day) }) } } + + /// Time-weighted mean and maximum per day; each sample holds until the next one, the newest until `end`. + /// Days without any covered time are left out. + public static func dailyStats(_ samples: [(date: Date, value: Double)], days: [Date], end: Date, calendar: Calendar) -> [(day: Date, max: Double, mean: Double)] { + let sorted = samples.sorted { $0.date < $1.date } + let segments = sorted.indices.compactMap { index -> (interval: DateInterval, value: Double)? in + let until = index + 1 < sorted.count ? sorted[index + 1].date : end + guard sorted[index].date < until else { return nil } + return (DateInterval(start: sorted[index].date, end: until), sorted[index].value) + } + return days.compactMap { day in + guard let dayInterval = calendar.dateInterval(of: .day, for: day) else { return nil } + let parts = segments.compactMap { segment in + segment.interval.intersection(with: dayInterval).map { (duration: $0.duration, value: segment.value) } + }.filter { $0.duration > 0 } + let total = parts.reduce(0) { $0 + $1.duration } + guard total > 0, let max = parts.map(\.value).max() else { return nil } + return (day, max, parts.reduce(0) { $0 + $1.duration * $1.value } / total) + } + } + + /// Start-of-day dates of the last `count` days, oldest first, ending with the day of `end`. + public static func days(count: Int, endingAt end: Date, calendar: Calendar) -> [Date] { + (0.. Date: Sat, 3 Oct 2026 21:29:52 +0200 Subject: [PATCH 11/22] Use native list rows and status labels in the Controller The entity detail drew its own card list inside a ScrollView; it is now a List with sections. The custom capsule badge becomes a tinted SF Symbol label. Room devices show their name, and state lanes say Open/Motion instead of On for contacts and motion sensors. --- .../Features/AutomationsFeature.swift | 2 +- .../Features/EntityHistoryDetailFeature.swift | 47 +++++++++---------- Sources/Controller/Features/RoomFeature.swift | 11 ++++- .../Features/RunDetailFeature.swift | 2 +- Sources/Controller/Shared/RunViews.swift | 31 ++++++------ 5 files changed, 49 insertions(+), 44 deletions(-) diff --git a/Sources/Controller/Features/AutomationsFeature.swift b/Sources/Controller/Features/AutomationsFeature.swift index d212e0d9..673e7e48 100644 --- a/Sources/Controller/Features/AutomationsFeature.swift +++ b/Sources/Controller/Features/AutomationsFeature.swift @@ -215,7 +215,7 @@ struct AutomationsView: View { .lineLimit(1) } Spacer() - Pill(outcome: .running) + StatusLabel(outcome: .running) .opacity(automation.isRunning ? 1 : 0) Toggle("Active", isOn: Binding( get: { automation.isActive }, diff --git a/Sources/Controller/Features/EntityHistoryDetailFeature.swift b/Sources/Controller/Features/EntityHistoryDetailFeature.swift index 0fc428f7..0d652270 100644 --- a/Sources/Controller/Features/EntityHistoryDetailFeature.swift +++ b/Sources/Controller/Features/EntityHistoryDetailFeature.swift @@ -190,56 +190,47 @@ struct EntityHistoryDetailView: View { @Bindable var store: StoreOf var body: some View { - ScrollView { - VStack(spacing: 16) { - // Time range picker + List { + Section { Picker("Time Range", selection: $store.timeRange) { ForEach(EntityHistoryDetailFeature.TimeRange.allCases, id: \.self) { range in Text(range.displayName).tag(range) } } .pickerStyle(.segmented) - .padding(.horizontal) .onChange(of: store.timeRange) { _, newValue in store.send(.timeRangeChanged(newValue)) } - // Chart if !store.chartData.isEmpty { + // A single state lane needs far less height than a value chart. chartView - .frame(height: 300) - .padding(.horizontal) + .frame(height: store.chartData.contains { $0.stateValue != nil } ? 100 : 300) } else if store.isLoading { ProgressView() - .frame(height: 300) + .frame(maxWidth: .infinity, minHeight: 300) } else { ContentUnavailableView( "No Data", systemImage: "chart.line.uptrend.xyaxis", description: Text("No history data available for this time range") ) - .frame(height: 300) } + } - if store.timeRange == .week { + if store.timeRange == .week { + Section("Per Day") { dailyChart - .padding(.horizontal) } + } - // History list - if !store.historyItems.isEmpty { - VStack(spacing: 0) { - ForEach(store.historyItems) { item in - historyRow(item) - Divider() - } + if !store.historyItems.isEmpty { + Section("History") { + ForEach(store.historyItems) { item in + historyRow(item) } - .background(.secondary.opacity(0.1)) - .cornerRadius(8) - .padding(.horizontal) } } - .padding(.vertical) } .refreshable { store.send(.refresh) @@ -250,6 +241,14 @@ struct EntityHistoryDetailView: View { .alert($store.scope(\.$alert, action: \.alert)) } + private var laneLabel: String { + switch store.entity.entityId.characteristicType { + case .contactSensor: "Open" + case .motionSensor: "Motion" + default: "On" + } + } + @ViewBuilder private var chartView: some View { let dateRange = store.state.dateRange @@ -260,7 +259,7 @@ struct EntityHistoryDetailView: View { if isBoolean { TimelineChart( lanes: [.init( - label: "On", + label: laneLabel, color: ChartPalette.color(for: store.entity.entityId.characteristicType), intervals: StateIntervals.intervals(items: store.chartData, isActive: \.stateValue, from: dateRange.start, to: dateRange.end) )], @@ -354,8 +353,6 @@ struct EntityHistoryDetailView: View { .font(.body) .bold() } - .padding(.horizontal) - .padding(.vertical, 8) } } diff --git a/Sources/Controller/Features/RoomFeature.swift b/Sources/Controller/Features/RoomFeature.swift index 6c1d0c16..274f544c 100644 --- a/Sources/Controller/Features/RoomFeature.swift +++ b/Sources/Controller/Features/RoomFeature.swift @@ -181,7 +181,16 @@ struct RoomView: View { Button { store.send(.entityTapped(entity)) } label: { - Label(entity.formattedCharacteristicDisplayName, systemImage: entity.entityId.characteristicType.systemImage) + Label { + VStack(alignment: .leading) { + Text(entity.entityId.name) + Text(entity.formattedCharacteristicDisplayName) + .font(.caption) + .foregroundStyle(.secondary) + } + } icon: { + Image(systemName: entity.entityId.characteristicType.systemImage) + } } } } diff --git a/Sources/Controller/Features/RunDetailFeature.swift b/Sources/Controller/Features/RunDetailFeature.swift index 1d9d9ac4..4db182b3 100644 --- a/Sources/Controller/Features/RunDetailFeature.swift +++ b/Sources/Controller/Features/RunDetailFeature.swift @@ -73,7 +73,7 @@ struct RunDetailView: View { .formatted(.units(allowed: [.hours, .minutes, .seconds], width: .abbreviated))) } LabeledContent("Outcome") { - Pill(outcome: store.run.outcome) + StatusLabel(outcome: store.run.outcome) } if let errorDescription = store.run.errorDescription { LabeledContent("Error") { diff --git a/Sources/Controller/Shared/RunViews.swift b/Sources/Controller/Shared/RunViews.swift index 7703d9d3..d4cd8f18 100644 --- a/Sources/Controller/Shared/RunViews.swift +++ b/Sources/Controller/Shared/RunViews.swift @@ -6,35 +6,34 @@ import HAModels import SwiftUI -struct Pill: View { +/// Outcome or command status as a tinted SF Symbol label, the way system lists show state. +struct StatusLabel: View { let text: String + let systemImage: String let color: Color var body: some View { - Text(text) - .font(.caption.bold()) - .padding(.horizontal, 8) - .padding(.vertical, 2) + Label(text, systemImage: systemImage) + .font(.caption) .foregroundStyle(color) - .background(color.opacity(0.15), in: .capsule) } } -extension Pill { +extension StatusLabel { init(outcome: AutomationRun.Outcome) { switch outcome { - case .running: self.init(text: outcome.label, color: .green) - case .completed: self.init(text: outcome.label, color: .accentColor) - case .failed, .interrupted: self.init(text: outcome.label, color: .red) - case .superseded, .stopped: self.init(text: outcome.label, color: .secondary) + case .running: self.init(text: outcome.label, systemImage: "play.circle.fill", color: .green) + case .completed: self.init(text: outcome.label, systemImage: "checkmark.circle.fill", color: .accentColor) + case .failed, .interrupted: self.init(text: outcome.label, systemImage: "exclamationmark.triangle.fill", color: .red) + case .superseded, .stopped: self.init(text: outcome.label, systemImage: "stop.circle", color: .secondary) } } init(status: ActionLogItem.Status) { switch status { - case .executed: self.init(text: "Fresh", color: .green) - case .cacheHit: self.init(text: "Cache", color: .yellow) - case .failed: self.init(text: "Failed", color: .red) + case .executed: self.init(text: "Fresh", systemImage: "bolt.fill", color: .green) + case .cacheHit: self.init(text: "Cache", systemImage: "arrow.uturn.backward.circle", color: .yellow) + case .failed: self.init(text: "Failed", systemImage: "exclamationmark.triangle.fill", color: .red) } } } @@ -57,7 +56,7 @@ struct RunRow: View { .foregroundStyle(.secondary) } Spacer() - Pill(outcome: run.outcome) + StatusLabel(outcome: run.outcome) } } } @@ -79,7 +78,7 @@ struct ActionRow: View { .font(.caption) .foregroundStyle(.secondary) if let status = item.status { - Pill(status: status) + StatusLabel(status: status) } } } From b032111c48a9538655b1c05984407931cae17fcb Mon Sep 17 00:00:00 2001 From: Julian Kahnert Date: Sat, 3 Oct 2026 21:46:33 +0200 Subject: [PATCH 12/22] Keep the Controller independent of HAImplementations The MotionAtNight debug chart only needs the lux threshold, so the constant moves to HAModels and the Controller stops linking every automation. --- Package.swift | 1 - Sources/Controller/Features/AutomationDetails.swift | 3 +-- .../HAImplementations/Automations/MotionAtNight.swift | 5 +---- .../HAModels/Automation/MotionAtNightConstants.swift | 10 ++++++++++ 4 files changed, 12 insertions(+), 7 deletions(-) create mode 100644 Sources/HAModels/Automation/MotionAtNightConstants.swift diff --git a/Package.swift b/Package.swift index f5159cef..468e704b 100644 --- a/Package.swift +++ b/Package.swift @@ -137,7 +137,6 @@ let package = Package( .product(name: "ComposableArchitecture", package: "swift-composable-architecture"), .product(name: "Dependencies", package: "swift-dependencies"), .product(name: "Sharing", package: "swift-sharing"), - "HAImplementations", "HAModels", "Shared", "ServerClient", diff --git a/Sources/Controller/Features/AutomationDetails.swift b/Sources/Controller/Features/AutomationDetails.swift index a3577ed5..fd76b3dd 100644 --- a/Sources/Controller/Features/AutomationDetails.swift +++ b/Sources/Controller/Features/AutomationDetails.swift @@ -7,7 +7,6 @@ import ComposableArchitecture import Foundation -import HAImplementations import HAModels import Sharing import SwiftUI @@ -274,7 +273,7 @@ extension AutomationDetailView { color: ChartPalette.color(for: .lightSensor), unit: "lx", isLogarithmic: true, - threshold: (MotionAtNight.thresholdInLux, "Threshold \(Int(MotionAtNight.thresholdInLux)) lx"), + threshold: (MotionAtNightConstants.thresholdInLux, "Threshold \(Int(MotionAtNightConstants.thresholdInLux)) lx"), bands: nights ) } header: { diff --git a/Sources/HAImplementations/Automations/MotionAtNight.swift b/Sources/HAImplementations/Automations/MotionAtNight.swift index 4a22b3d7..9746aa0e 100644 --- a/Sources/HAImplementations/Automations/MotionAtNight.swift +++ b/Sources/HAImplementations/Automations/MotionAtNight.swift @@ -9,9 +9,6 @@ import Foundation import HAModels public struct MotionAtNight: Automatable { - /// Illuminance below which motion turns the lights on. - public static let thresholdInLux = 60.0 - public var isActive = true public let name: String /// Time to wait after last motion detection before starting the dim/off sequence @@ -75,7 +72,7 @@ public struct MotionAtNight: Automatable { } guard let illuminance else { return false } - let shouldTrigger = motionDetected && illuminance.converted(to: .lux).value < Self.thresholdInLux + let shouldTrigger = motionDetected && illuminance.converted(to: .lux).value < MotionAtNightConstants.thresholdInLux log.debug("Should trigger [\(shouldTrigger)] - [motion: \(motionDetected), \(illuminance)]") return shouldTrigger } diff --git a/Sources/HAModels/Automation/MotionAtNightConstants.swift b/Sources/HAModels/Automation/MotionAtNightConstants.swift new file mode 100644 index 00000000..8fa50fc0 --- /dev/null +++ b/Sources/HAModels/Automation/MotionAtNightConstants.swift @@ -0,0 +1,10 @@ +// +// MotionAtNightConstants.swift +// HAModels +// + +/// Lives in HAModels so the Controller can draw the threshold without linking every automation. +public enum MotionAtNightConstants { + /// Illuminance below which motion turns the lights on. + public static let thresholdInLux = 60.0 +} From 7bc6ecdec48ca4fbc40f9266f64938bafe54769c Mon Sep 17 00:00:00 2001 From: Julian Kahnert Date: Sat, 3 Oct 2026 21:46:33 +0200 Subject: [PATCH 13/22] Let the Live Activity tests compile for iOS Swift 6 rejects mutating a captured var from the token callback; the iOS-only tests never built because CI runs them on macOS only. --- .../LiveActivityDependencyTests.swift | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/Tests/ControllerTests/LiveActivityDependencyTests.swift b/Tests/ControllerTests/LiveActivityDependencyTests.swift index e3886774..a6754083 100644 --- a/Tests/ControllerTests/LiveActivityDependencyTests.swift +++ b/Tests/ControllerTests/LiveActivityDependencyTests.swift @@ -23,11 +23,11 @@ struct LiveActivityDependencyTests { @Dependency(\.liveActivity) var liveActivity // Callback-based: testValue closure body is empty, returns immediately - var tokenCount = 0 + let tokenCount = LockIsolated(0) await liveActivity.pushTokenUpdates { _ in - tokenCount += 1 + tokenCount.withValue { $0 += 1 } } - #expect(tokenCount == 0) + #expect(tokenCount.value == 0) let hasActive = await liveActivity.hasActiveActivities() #expect(hasActive == false) @@ -41,13 +41,13 @@ struct LiveActivityDependencyTests { } operation: { @Dependency(\.liveActivity) var liveActivity - var tokens: [PushToken] = [] + let tokens = LockIsolated<[PushToken]>([]) await liveActivity.pushTokenUpdates { token in - tokens.append(token) + tokens.withValue { $0.append(token) } } - #expect(tokens.count == 1) - #expect(tokens[0].deviceName == "preview") - #expect(tokens[0].tokenString == "1234") + #expect(tokens.value.count == 1) + #expect(tokens.value.first?.deviceName == "preview") + #expect(tokens.value.first?.tokenString == "1234") let hasActive = await liveActivity.hasActiveActivities() #expect(hasActive == true) From 9beb7bc8cdb4ea35e0a01d29545816ff55691038 Mon Sep 17 00:00:00 2001 From: Julian Kahnert Date: Sat, 3 Oct 2026 21:46:33 +0200 Subject: [PATCH 14/22] Use the standard tab bar and system text styles Drop the sidebar-adaptable tab style and the bold/monospaced overrides. Open windows now say how long they are open and what the limit is, and run and command rows get full-width separators. --- Sources/Controller/App/AppFeature.swift | 1 - Sources/Controller/Features/EntityHistoryDetailFeature.swift | 2 -- Sources/Controller/Features/LogViewerView.swift | 2 +- Sources/Controller/Features/OverviewFeature.swift | 4 +++- Sources/Controller/Shared/RunViews.swift | 4 +++- 5 files changed, 7 insertions(+), 6 deletions(-) diff --git a/Sources/Controller/App/AppFeature.swift b/Sources/Controller/App/AppFeature.swift index 81afce04..d11eeaf3 100644 --- a/Sources/Controller/App/AppFeature.swift +++ b/Sources/Controller/App/AppFeature.swift @@ -276,7 +276,6 @@ struct AppView: View { ) } } - .tabViewStyle(.sidebarAdaptable) .onSceneChange { oldPhase, newPhase in store.send(.scenePhaseChanged(old: oldPhase, new: newPhase)) } diff --git a/Sources/Controller/Features/EntityHistoryDetailFeature.swift b/Sources/Controller/Features/EntityHistoryDetailFeature.swift index 0d652270..596d302d 100644 --- a/Sources/Controller/Features/EntityHistoryDetailFeature.swift +++ b/Sources/Controller/Features/EntityHistoryDetailFeature.swift @@ -350,8 +350,6 @@ struct EntityHistoryDetailView: View { } Spacer() Text(item.valueDescription) - .font(.body) - .bold() } } } diff --git a/Sources/Controller/Features/LogViewerView.swift b/Sources/Controller/Features/LogViewerView.swift index 0dc57992..4ded1870 100644 --- a/Sources/Controller/Features/LogViewerView.swift +++ b/Sources/Controller/Features/LogViewerView.swift @@ -84,7 +84,7 @@ struct LogViewerView: View { VStack(alignment: .leading, spacing: 4) { HStack { Text(entry.level.uppercased()) - .font(isCritical ? .caption.bold() : .caption2.bold()) + .font(isCritical ? .caption : .caption2) .padding(.horizontal, 6) .padding(.vertical, 2) .background(levelColor(entry.level).opacity(isCritical ? 0.4 : 0.2)) diff --git a/Sources/Controller/Features/OverviewFeature.swift b/Sources/Controller/Features/OverviewFeature.swift index 5a682d6a..9fb4da56 100644 --- a/Sources/Controller/Features/OverviewFeature.swift +++ b/Sources/Controller/Features/OverviewFeature.swift @@ -167,7 +167,9 @@ struct OverviewView: View { Section("Open Windows") { ForEach(openWindows, id: \.name) { window in ProgressView(timerInterval: window.opened...window.end, countsDown: false) { - Text(window.name) + LabeledContent(window.name, value: "Limit \(Int(window.maxOpenDuration / 60)) min") + } currentValueLabel: { + Text("Open for \(Text(window.opened, style: .relative))") } .tint(Date() <= window.end ? Color.accentColor : Color.red) } diff --git a/Sources/Controller/Shared/RunViews.swift b/Sources/Controller/Shared/RunViews.swift index d4cd8f18..a8d53ad4 100644 --- a/Sources/Controller/Shared/RunViews.swift +++ b/Sources/Controller/Shared/RunViews.swift @@ -58,6 +58,8 @@ struct RunRow: View { Spacer() StatusLabel(outcome: run.outcome) } + // Without this the separator starts at the status label, the row's last text. + .alignmentGuide(.listRowSeparatorLeading) { $0[.leading] } } } @@ -68,7 +70,6 @@ struct ActionRow: View { HStack { VStack(alignment: .leading, spacing: 2) { Text(item.detailDescription) - .font(.body.monospaced()) Text("\(item.entityId.name) (\(item.entityId.placeId))") .font(.subheadline) .foregroundStyle(.secondary) @@ -81,5 +82,6 @@ struct ActionRow: View { StatusLabel(status: status) } } + .alignmentGuide(.listRowSeparatorLeading) { $0[.leading] } } } From a5812dede5d06c51bdcd58559425426ad440efad Mon Sep 17 00:00:00 2001 From: Julian Kahnert Date: Sat, 3 Oct 2026 22:05:26 +0200 Subject: [PATCH 15/22] Polish the Controller layouts with native rows and chart legends Checked every screen on the iPhone simulator in light and dark mode: - automation rows are plain toggles; a running automation says so in its subtitle - labeled rows no longer blow up when they hold a label with an icon - timeline charts use the Swift Charts legend instead of a hand-built one - time axes keep the last label visible and show weekdays for 7-day ranges - entity value charts reuse ValueChart; the daily chart labels max and mean in its legend - button rows in lists keep primary text and tint only the icon --- Sources/Controller/Charts/ChartPalette.swift | 7 +++ Sources/Controller/Charts/DailyBarChart.swift | 8 ++- Sources/Controller/Charts/TimelineChart.swift | 21 ++++++-- Sources/Controller/Charts/ValueChart.swift | 11 ++-- .../Features/ActivityFeedFeature.swift | 22 ++++---- .../Features/AutomationDetails.swift | 5 +- .../Features/AutomationsFeature.swift | 32 ++++++------ .../Features/EntityHistoryDetailFeature.swift | 52 +++++-------------- Sources/Controller/Features/RoomFeature.swift | 22 +++----- .../Controller/Features/SettingsFeature.swift | 8 ++- Sources/Controller/Shared/RunViews.swift | 2 + 11 files changed, 94 insertions(+), 96 deletions(-) diff --git a/Sources/Controller/Charts/ChartPalette.swift b/Sources/Controller/Charts/ChartPalette.swift index f29813da..4c0ef8a5 100644 --- a/Sources/Controller/Charts/ChartPalette.swift +++ b/Sources/Controller/Charts/ChartPalette.swift @@ -26,4 +26,11 @@ enum ChartPalette { } static let night = Color.gray.opacity(0.15) + + /// Times for a day or less, weekdays beyond that; a week of "00:00" labels says nothing. + static func axisFormat(for domain: ClosedRange) -> Date.FormatStyle { + domain.upperBound.timeIntervalSince(domain.lowerBound) > 86_400 * 1.5 + ? .dateTime.weekday(.abbreviated) + : .dateTime.hour().minute() + } } diff --git a/Sources/Controller/Charts/DailyBarChart.swift b/Sources/Controller/Charts/DailyBarChart.swift index 6c94e3d6..b5626bc4 100644 --- a/Sources/Controller/Charts/DailyBarChart.swift +++ b/Sources/Controller/Charts/DailyBarChart.swift @@ -12,19 +12,23 @@ struct DailyBarChart: View { let color: Color let unit: String var points: [(day: Date, value: Double)] = [] + var barLabel = "Total" + var pointLabel = "Mean" var body: some View { Chart { ForEach(bars.indices, id: \.self) { index in BarMark(x: .value("Day", bars[index].day, unit: .day), y: .value(unit, bars[index].value)) - .foregroundStyle(color) + .foregroundStyle(by: .value("Series", barLabel)) } ForEach(points.indices, id: \.self) { index in PointMark(x: .value("Day", points[index].day, unit: .day), y: .value(unit, points[index].value)) - .foregroundStyle(.primary) + .foregroundStyle(by: .value("Series", pointLabel)) } } .chartYAxisLabel(unit) + .chartForegroundStyleScale([barLabel: color, pointLabel: Color.primary]) + .chartLegend(points.isEmpty ? .hidden : .visible) .chartXAxis { AxisMarks(values: .stride(by: .day)) { _ in AxisValueLabel(format: .dateTime.weekday(.abbreviated), centered: true) diff --git a/Sources/Controller/Charts/TimelineChart.swift b/Sources/Controller/Charts/TimelineChart.swift index b0687d4a..4aed0cac 100644 --- a/Sources/Controller/Charts/TimelineChart.swift +++ b/Sources/Controller/Charts/TimelineChart.swift @@ -12,6 +12,8 @@ struct TimelineChart: View { let label: String let color: Color let intervals: [DateInterval] + /// Legend entry; lanes without one keep the chart legend hidden. + var kind: String? var id: String { label } } @@ -35,21 +37,32 @@ struct TimelineChart: View { y: .value("Lane", lane.label), height: .fixed(12) ) - .foregroundStyle(lane.color) + .foregroundStyle(by: .value("Kind", lane.kind ?? lane.label)) .clipShape(.rect(cornerRadius: 3)) } } } .chartXScale(domain: domain) .chartYScale(domain: lanes.map(\.label)) + .chartForegroundStyleScale(domain: legendEntries.map(\.kind), range: legendEntries.map(\.color)) + .chartLegend(lanes.contains { $0.kind != nil } ? .visible : .hidden) .chartPlotStyle { $0.clipped() } .chartXAxis { - AxisMarks { _ in + // .aligned keeps the last time label inside the plot instead of clipping it. + AxisMarks(preset: .aligned) { _ in AxisGridLine() - AxisValueLabel(format: .dateTime.hour().minute()) + AxisValueLabel(format: ChartPalette.axisFormat(for: domain)) } } - .frame(height: CGFloat(lanes.count) * 30 + 30) + .frame(height: CGFloat(lanes.count) * 30 + (lanes.contains { $0.kind != nil } ? 54 : 30)) + } + + private var legendEntries: [(kind: String, color: Color)] { + var seen = Set() + return lanes.compactMap { lane in + let kind = lane.kind ?? lane.label + return seen.insert(kind).inserted ? (kind, lane.color) : nil + } } } diff --git a/Sources/Controller/Charts/ValueChart.swift b/Sources/Controller/Charts/ValueChart.swift index cb5c846d..e8a3813a 100644 --- a/Sources/Controller/Charts/ValueChart.swift +++ b/Sources/Controller/Charts/ValueChart.swift @@ -16,6 +16,7 @@ struct ValueChart: View { var threshold: (value: Double, label: String)? var bands: [DateInterval] = [] var bandColor = ChartPalette.night + var height: CGFloat = 180 var body: some View { Chart { @@ -35,7 +36,8 @@ struct ValueChart: View { RuleMark(y: .value("Threshold", threshold.value)) .lineStyle(StrokeStyle(lineWidth: 1, dash: [4, 3])) .foregroundStyle(.secondary) - .annotation(position: .top, alignment: .trailing) { + // Leading keeps the label over the night end of the curve instead of across the daylight peak. + .annotation(position: .top, alignment: .leading) { Text(threshold.label) .font(.caption2) .foregroundStyle(.secondary) @@ -47,12 +49,13 @@ struct ValueChart: View { .chartYAxisLabel(unit) .chartPlotStyle { $0.clipped() } .chartXAxis { - AxisMarks { _ in + // .aligned keeps the last time label inside the plot instead of clipping it. + AxisMarks(preset: .aligned) { _ in AxisGridLine() - AxisValueLabel(format: .dateTime.hour().minute()) + AxisValueLabel(format: ChartPalette.axisFormat(for: domain)) } } - .frame(height: 180) + .frame(height: height) } } diff --git a/Sources/Controller/Features/ActivityFeedFeature.swift b/Sources/Controller/Features/ActivityFeedFeature.swift index d807e632..cec5f1d2 100644 --- a/Sources/Controller/Features/ActivityFeedFeature.swift +++ b/Sources/Controller/Features/ActivityFeedFeature.swift @@ -75,17 +75,19 @@ struct ActivityFeedView: View { .pickerStyle(.segmented) .listRowBackground(Color.clear) - ForEach(store.entries) { entry in - switch entry { - case let .run(run): - Button { - store.send(.runTapped(run)) - } label: { - RunRow(run: run) + Section { + ForEach(store.entries) { entry in + switch entry { + case let .run(run): + Button { + store.send(.runTapped(run)) + } label: { + RunRow(run: run) + } + .buttonStyle(.plain) + case let .command(item): + ActionRow(item: item) } - .buttonStyle(.plain) - case let .command(item): - ActionRow(item: item) } } } diff --git a/Sources/Controller/Features/AutomationDetails.swift b/Sources/Controller/Features/AutomationDetails.swift index fd76b3dd..e9a10c33 100644 --- a/Sources/Controller/Features/AutomationDetails.swift +++ b/Sources/Controller/Features/AutomationDetails.swift @@ -179,7 +179,7 @@ struct AutomationDetailView: View { var body: some View { Form { Section { - Label(store.automation.typeLabel, systemImage: store.automation.systemImage) + LabeledContent("Type", value: store.automation.typeLabel) LabeledContent("Trigger", value: store.automation.triggerDescription) Toggle("Active", isOn: Binding( get: { store.automation.isActive }, @@ -257,7 +257,8 @@ extension AutomationDetailView { TimelineChart.Lane( label: history.entityId.name, color: ChartPalette.color(for: history.entityId.characteristicType), - intervals: StateIntervals.intervals(items: history.items, isActive: \.stateValue, from: store.start, to: store.end) + intervals: StateIntervals.intervals(items: history.items, isActive: \.stateValue, from: store.start, to: store.end), + kind: history.entityId.characteristicType.displayName ) } let lux = store.histories diff --git a/Sources/Controller/Features/AutomationsFeature.swift b/Sources/Controller/Features/AutomationsFeature.swift index 673e7e48..7dd35519 100644 --- a/Sources/Controller/Features/AutomationsFeature.swift +++ b/Sources/Controller/Features/AutomationsFeature.swift @@ -203,25 +203,23 @@ struct AutomationsView: View { } private func automationRow(_ automation: AutomationInfo) -> some View { - HStack { - Image(systemName: automation.systemImage) - .frame(width: 28) - .foregroundStyle(Color.accentColor) - VStack(alignment: .leading, spacing: 2) { + Toggle(isOn: Binding( + get: { automation.isActive }, + set: { store.send(.setActive(name: automation.name, $0)) } + )) { + Label { Text(automation.name) - Text(automation.subtitle) - .font(.subheadline) - .foregroundStyle(.secondary) - .lineLimit(1) + Group { + if automation.isRunning { + Text("\(Text("Running").foregroundStyle(.green)) · \(automation.subtitle)") + } else { + Text(automation.subtitle) + } + } + .lineLimit(1) + } icon: { + Image(systemName: automation.systemImage) } - Spacer() - StatusLabel(outcome: .running) - .opacity(automation.isRunning ? 1 : 0) - Toggle("Active", isOn: Binding( - get: { automation.isActive }, - set: { store.send(.setActive(name: automation.name, $0)) } - )) - .labelsHidden() } } } diff --git a/Sources/Controller/Features/EntityHistoryDetailFeature.swift b/Sources/Controller/Features/EntityHistoryDetailFeature.swift index 596d302d..9c2efb3b 100644 --- a/Sources/Controller/Features/EntityHistoryDetailFeature.swift +++ b/Sources/Controller/Features/EntityHistoryDetailFeature.swift @@ -203,9 +203,7 @@ struct EntityHistoryDetailView: View { } if !store.chartData.isEmpty { - // A single state lane needs far less height than a value chart. chartView - .frame(height: store.chartData.contains { $0.stateValue != nil } ? 100 : 300) } else if store.isLoading { ProgressView() .frame(maxWidth: .infinity, minHeight: 300) @@ -266,7 +264,14 @@ struct EntityHistoryDetailView: View { domain: range ) } else { - lineChart(range: range, isLux: isLux) + ValueChart( + points: store.chartData.compactMap { item in item.primaryValue.map { (item.timestamp, $0) } }, + domain: range, + color: ChartPalette.color(for: store.entity.entityId.characteristicType), + unit: store.entity.entityId.characteristicType == .lightSensor ? "lx" : "", + isLogarithmic: isLux, + height: 240 + ) } } @@ -281,7 +286,7 @@ struct EntityHistoryDetailView: View { DailyBarChart( bars: DailyTotals.dailyTotals(intervals, days: days, calendar: .current).map { ($0.day, $0.duration / 3_600) }, color: ChartPalette.color(for: type), - unit: "h on" + unit: "h" ) } else if type == .lightSensor || type == .carbonDioxideSensorId { let samples = store.chartData.compactMap { item in item.primaryValue.map { (date: item.timestamp, value: $0) } } @@ -289,46 +294,13 @@ struct EntityHistoryDetailView: View { DailyBarChart( bars: stats.map { ($0.day, $0.max) }, color: ChartPalette.color(for: type).opacity(0.6), - unit: type == .lightSensor ? "lx (max, ● mean)" : "ppm (max, ● mean)", - points: stats.map { ($0.day, $0.mean) } + unit: type == .lightSensor ? "lx" : "ppm", + points: stats.map { ($0.day, $0.mean) }, + barLabel: "Daily Max" ) } } - private func lineChart(range: ClosedRange, isLux: Bool) -> some View { - Chart { - ForEach(store.chartData) { item in - if let value = item.primaryValue { - // The log scale cannot place 0 lx. - let plotted = isLux ? max(value, 0.1) : value - LineMark( - x: .value("Time", item.timestamp), - y: .value("Value", plotted) - ) - .foregroundStyle(Color.accentColor) - - PointMark( - x: .value("Time", item.timestamp), - y: .value("Value", plotted) - ) - .foregroundStyle(Color.accentColor) - } - } - } - .chartXScale(domain: range) - .chartYScale(type: isLux ? .log : .linear) - .chartPlotStyle { $0.clipped() } - .chartXAxis { - AxisMarks(values: .automatic) { _ in - AxisGridLine() - AxisValueLabel(format: .dateTime.hour().minute()) - } - } - .chartYAxis { - AxisMarks(position: .leading) - } - } - @ViewBuilder private func historyRow(_ item: EntityHistoryItem) -> some View { HStack { diff --git a/Sources/Controller/Features/RoomFeature.swift b/Sources/Controller/Features/RoomFeature.swift index 274f544c..ccc972bd 100644 --- a/Sources/Controller/Features/RoomFeature.swift +++ b/Sources/Controller/Features/RoomFeature.swift @@ -119,7 +119,8 @@ struct RoomView: View { TimelineChart.Lane( label: history.entityId.name, color: ChartPalette.color(for: history.entityId.characteristicType), - intervals: StateIntervals.intervals(items: history.items, isActive: \.stateValue, from: store.start, to: store.end) + intervals: StateIntervals.intervals(items: history.items, isActive: \.stateValue, from: store.start, to: store.end), + kind: history.entityId.characteristicType.displayName ) } } @@ -145,7 +146,6 @@ struct RoomView: View { .foregroundStyle(.secondary) } else { TimelineChart(lanes: lanes, domain: store.start...store.end) - legend } } header: { Text("Timeline") @@ -185,12 +185,15 @@ struct RoomView: View { VStack(alignment: .leading) { Text(entity.entityId.name) Text(entity.formattedCharacteristicDisplayName) - .font(.caption) + .font(.subheadline) .foregroundStyle(.secondary) } } icon: { Image(systemName: entity.entityId.characteristicType.systemImage) + .foregroundStyle(.tint) } + // A button would tint the whole row; only the icon carries the accent. + .foregroundStyle(.primary) } } } @@ -210,19 +213,6 @@ struct RoomView: View { .navigationTitle(detailStore.entity.displayName) } } - - private var legend: some View { - HStack(spacing: 16) { - ForEach(Self.laneTypes.filter { type in store.histories.contains { $0.entityId.characteristicType == type } }, id: \.self) { type in - Label { - Text(type.displayName) - } icon: { - Circle().fill(ChartPalette.color(for: type)).frame(width: 10, height: 10) - } - .font(.caption) - } - } - } } #Preview { diff --git a/Sources/Controller/Features/SettingsFeature.swift b/Sources/Controller/Features/SettingsFeature.swift index 9299f392..fbe45a41 100644 --- a/Sources/Controller/Features/SettingsFeature.swift +++ b/Sources/Controller/Features/SettingsFeature.swift @@ -257,7 +257,13 @@ struct SettingsView: View { private func diagnosticsButton(_ title: String, systemImage: String, action: @escaping () -> Void) -> some View { Button(action: action) { HStack { - Label(title, systemImage: systemImage) + Label { + Text(title) + } icon: { + Image(systemName: systemImage) + .foregroundStyle(.tint) + } + .foregroundStyle(.primary) Spacer() Image(systemName: "chevron.right") .foregroundStyle(.secondary) diff --git a/Sources/Controller/Shared/RunViews.swift b/Sources/Controller/Shared/RunViews.swift index a8d53ad4..8f6d4f2c 100644 --- a/Sources/Controller/Shared/RunViews.swift +++ b/Sources/Controller/Shared/RunViews.swift @@ -14,6 +14,8 @@ struct StatusLabel: View { var body: some View { Label(text, systemImage: systemImage) + // Forms otherwise lay a label out like a row title, with a wide icon column. + .labelStyle(.titleAndIcon) .font(.caption) .foregroundStyle(color) } From 0c76d004c21e794b9db95812fdce30f9855629e7 Mon Sep 17 00:00:00 2001 From: Julian Kahnert Date: Sat, 3 Oct 2026 22:47:23 +0200 Subject: [PATCH 16/22] Record automation runs without delaying the first command The run insert was awaited before the execute task started, so the time from "motion detected" to the first light depended on the database. The run id is now created up front; the trigger summary and the insert run in a parallel task, and `finish` waits for that task so no update can reach the database before its insert. --- .../AutomationService.swift | 28 +++++++++++-------- .../AutomationServiceTests.swift | 26 +++++++++++++++++ .../InMemoryAutomationRunRepository.swift | 16 ++++++++++- 3 files changed, 57 insertions(+), 13 deletions(-) diff --git a/Sources/HAApplicationLayer/AutomationService.swift b/Sources/HAApplicationLayer/AutomationService.swift index 278adf8f..8134b3a3 100644 --- a/Sources/HAApplicationLayer/AutomationService.swift +++ b/Sources/HAApplicationLayer/AutomationService.swift @@ -37,19 +37,21 @@ public actor AutomationService { } self.log.info("Running automation \(automation.name)") - let run = await self.startRun(of: automation, for: event) + let runId = automation.recordsRuns ? UUID() : nil let task = Task { + // Persisted alongside `execute`: the database must never delay the first command. + let recording = Task { await self.record(runId, of: automation, for: event) } do { - try await AutomationRunContext.$runId.withValue(run?.id) { + try await AutomationRunContext.$runId.withValue(runId) { try await automation.execute(using: self.homeManager) } - await self.finish(run, outcome: .completed) + await self.finish(await recording.value, outcome: .completed) } catch is CancellationError { let stopped = await self.consumeStopRequest(for: automation.name) - await self.finish(run, outcome: stopped ? .stopped : .superseded) + await self.finish(await recording.value, outcome: stopped ? .stopped : .superseded) } catch { self.log.error("Automation failed with error - \(error)") - await self.finish(run, outcome: .failed, error: String(describing: error)) + await self.finish(await recording.value, outcome: .failed, error: String(describing: error)) } // cancel the current task after completion to get correct results of getActiveAutomationNames @@ -95,27 +97,29 @@ public actor AutomationService { stopRequested.remove(name) != nil } - private func startRun(of automation: any Automatable, for event: HomeEvent) async -> AutomationRun? { - guard automation.recordsRuns else { return nil } + /// - Returns: `runId` once the run is stored, `nil` when nothing was (or could be) recorded. + private func record(_ runId: UUID?, of automation: any Automatable, for event: HomeEvent) async -> UUID? { + guard let runId else { return nil } + let startedAt = Date() var trigger = event.trigger if let detail = await automation.triggerSummary(for: event, using: homeManager) { trigger = trigger.appending(detail) } - let run = AutomationRun(automationName: automation.name, startedAt: Date(), trigger: trigger, outcome: .running) + let run = AutomationRun(id: runId, automationName: automation.name, startedAt: startedAt, trigger: trigger, outcome: .running) do { try await runs.add(run) - return run + return runId } catch { log.error("Failed to record automation run - \(error)") return nil } } - private func finish(_ run: AutomationRun?, outcome: AutomationRun.Outcome, error: String? = nil) async { - guard let run else { return } + private func finish(_ runId: UUID?, outcome: AutomationRun.Outcome, error: String? = nil) async { + guard let runId else { return } do { - try await runs.finish(run.id, outcome: outcome, endedAt: Date(), errorDescription: error) + try await runs.finish(runId, outcome: outcome, endedAt: Date(), errorDescription: error) } catch { log.error("Failed to finish automation run - \(error)") } diff --git a/Tests/HomeAutomationKitTests/AutomationServiceTests.swift b/Tests/HomeAutomationKitTests/AutomationServiceTests.swift index d9edae77..388b4ee6 100644 --- a/Tests/HomeAutomationKitTests/AutomationServiceTests.swift +++ b/Tests/HomeAutomationKitTests/AutomationServiceTests.swift @@ -115,6 +115,32 @@ struct AutomationServiceTests { #expect(run.trigger.summary == "Sunset · at 3 lx") } + /// The time from "motion detected" to the first command must not depend on the database. + @Test func executeStartsWhileTheRunIsStillBeingPersisted() async throws { + let (executed, continuation) = AsyncStream.makeStream(of: UUID?.self) + await repository.stallAdds() + let service = try makeService(TestAutomation(name: "a", mode: .complete, onExecute: { continuation.yield($0) })) + + let trigger = Task { await service.trigger(with: .sunrise) } + let runId = await withTaskGroup(of: UUID?.self) { group in + group.addTask { + var iterator = executed.makeAsyncIterator() + return await iterator.next().flatMap { $0 } + } + group.addTask { + try? await Task.sleep(for: .seconds(2)) + return nil + } + let first = await group.next() + group.cancelAll() + return first.flatMap { $0 } + } + + #expect(runId != nil, "execute did not run while the insert was pending") + await repository.releaseAdds() + await trigger.value + } + @Test func executeSeesTheRunIdAsTaskLocal() async throws { let (executed, continuation) = AsyncStream.makeStream(of: UUID?.self) let service = try makeService(TestAutomation(name: "a", mode: .complete, onExecute: { continuation.yield($0) })) diff --git a/Tests/HomeAutomationKitTests/InMemoryAutomationRunRepository.swift b/Tests/HomeAutomationKitTests/InMemoryAutomationRunRepository.swift index d271384d..2d7abfd3 100644 --- a/Tests/HomeAutomationKitTests/InMemoryAutomationRunRepository.swift +++ b/Tests/HomeAutomationKitTests/InMemoryAutomationRunRepository.swift @@ -9,6 +9,7 @@ import HAModels actor InMemoryAutomationRunRepository: AutomationRunRepository { private(set) var runs: [UUID: AutomationRun] = [:] private let finished = AsyncStream.makeStream(of: AutomationRun.self) + private var addGate: (stream: AsyncStream, continuation: AsyncStream.Continuation)? /// Waits for the next run that `finish` is called for. func nextFinishedRun() async -> AutomationRun? { @@ -16,7 +17,20 @@ actor InMemoryAutomationRunRepository: AutomationRunRepository { return await iterator.next() } - func add(_ run: AutomationRun) { + /// Makes `add` suspend until `releaseAdds()`, simulating a slow database. + func stallAdds() { + addGate = AsyncStream.makeStream(of: Void.self) + } + + func releaseAdds() { + addGate?.continuation.finish() + addGate = nil + } + + func add(_ run: AutomationRun) async { + if let gate = addGate { + for await _ in gate.stream {} + } runs[run.id] = run } From 0b569bcbee7916b6218fce29fb5c1fd967a2e86c Mon Sep 17 00:00:00 2001 From: Julian Kahnert Date: Sat, 3 Oct 2026 23:28:05 +0200 Subject: [PATCH 17/22] Push Controller details onto the navigation stack and load history in one effect Entity and run details are NavigationLink pushes instead of sheet destinations, the activity filter moves into a toolbar menu, and room rows inflect their device count instead of showing the raw markup. The history detail loads every page from a single cancellable effect. Paging through actions let a late page reach a screen that was already popped (TCA "missing element" warning) and append items of a previous time range after a refresh. Deprecated TCA `scope(state:action:)` and `Scope(state:action:child:)` calls use the renamed APIs. --- Sources/Controller/App/AppFeature.swift | 24 +-- .../Controller/Features/ActionsFeature.swift | 18 +- .../Features/ActivityFeedFeature.swift | 35 ++-- .../Features/AutomationDetails.swift | 84 +++------ .../Features/AutomationsFeature.swift | 148 ++++++++++------ .../Features/EntityHistoryDetailFeature.swift | 106 ++++------- .../Controller/Features/OverviewFeature.swift | 56 +++--- Sources/Controller/Features/RoomFeature.swift | 31 +--- .../Controller/Features/RoomsFeature.swift | 166 ++++++------------ .../Controller/Features/SettingsFeature.swift | 84 +++------ Sources/Controller/Shared/FilterMenu.swift | 35 ++++ .../AutomationsFeatureTests.swift | 45 ++++- .../EntityHistoryDetailFeatureTests.swift | 45 +++++ .../OverviewFeatureTests.swift | 9 +- 14 files changed, 414 insertions(+), 472 deletions(-) create mode 100644 Sources/Controller/Shared/FilterMenu.swift create mode 100644 Tests/ControllerTests/EntityHistoryDetailFeatureTests.swift diff --git a/Sources/Controller/App/AppFeature.swift b/Sources/Controller/App/AppFeature.swift index d11eeaf3..b8bf6592 100644 --- a/Sources/Controller/App/AppFeature.swift +++ b/Sources/Controller/App/AppFeature.swift @@ -98,19 +98,19 @@ struct AppFeature: Sendable { var body: some ReducerOf { BindingReducer() - Scope(state: \.overview, action: \.overview) { + Scope(\.overview, action: \.overview) { OverviewFeature() } - Scope(state: \.automations, action: \.automations) { + Scope(\.automations, action: \.automations) { AutomationsFeature() } - Scope(state: \.rooms, action: \.rooms) { + Scope(\.rooms, action: \.rooms) { RoomsFeature() } - Scope(state: \.settings, action: \.settings) { + Scope(\.settings, action: \.settings) { SettingsFeature() } @@ -122,7 +122,7 @@ struct AppFeature: Sendable { case let .overview(.delegate(.openAutomation(name))): state.selectedTab = .automations - return .send(.automations(.binding(.set(\.selectedAutomationIndex, name)))) + return .send(.automations(.openAutomation(name))) case .overview: return .none @@ -236,7 +236,7 @@ struct AppView: View { value: AppFeature.Tab.overview ) { OverviewView( - store: store.scope(state: \.overview, action: \.overview), + store: store.scope(\.overview, action: \.overview), openWindows: store.settings.windowContentState?.windowStates ?? [] ) } @@ -248,10 +248,7 @@ struct AppView: View { value: AppFeature.Tab.automations ) { AutomationsView( - store: store.scope( - state: \.automations, - action: \.automations - ) + store: store.scope(\.automations, action: \.automations) ) } @@ -260,7 +257,7 @@ struct AppView: View { systemImage: AppFeature.Tab.rooms.systemImage, value: AppFeature.Tab.rooms ) { - RoomsView(store: store.scope(state: \.rooms, action: \.rooms)) + RoomsView(store: store.scope(\.rooms, action: \.rooms)) } Tab( @@ -269,10 +266,7 @@ struct AppView: View { value: AppFeature.Tab.settings ) { SettingsView( - store: store.scope( - state: \.settings, - action: \.settings - ) + store: store.scope(\.settings, action: \.settings) ) } } diff --git a/Sources/Controller/Features/ActionsFeature.swift b/Sources/Controller/Features/ActionsFeature.swift index 4eaf34bc..3cfc830c 100644 --- a/Sources/Controller/Features/ActionsFeature.swift +++ b/Sources/Controller/Features/ActionsFeature.swift @@ -173,14 +173,6 @@ struct ActionsView: View { var body: some View { List { - Picker("Filter", selection: $store.filter) { - ForEach(ActionsFeature.Filter.allCases, id: \.self) { filter in - Text(filter.rawValue).tag(filter) - } - } - .pickerStyle(.segmented) - .listRowBackground(Color.clear) - ForEach(store.filteredActions) { item in ActionRow(item: item) } @@ -192,6 +184,14 @@ struct ActionsView: View { .navigationTitle("Command Log") .toolbar { ToolbarItem(placement: .primaryAction) { + FilterMenu(selection: $store.filter, isActive: store.filter != .all) { + ForEach(ActionsFeature.Filter.allCases, id: \.self) { filter in + Text(filter.rawValue).tag(filter) + } + } + } + + ToolbarItem(placement: .secondaryAction) { Button { store.send(.refresh) } label: { @@ -237,7 +237,7 @@ struct ActionsView: View { ) } } - .alert($store.scope(state: \.alert, action: \.alert)) + .alert($store.scope(\.$alert, action: \.alert)) } } diff --git a/Sources/Controller/Features/ActivityFeedFeature.swift b/Sources/Controller/Features/ActivityFeedFeature.swift index cec5f1d2..9e252c07 100644 --- a/Sources/Controller/Features/ActivityFeedFeature.swift +++ b/Sources/Controller/Features/ActivityFeedFeature.swift @@ -54,7 +54,6 @@ struct ActivityFeedFeature: Sendable { enum Action: BindableAction, Sendable { case binding(BindingAction) - case runTapped(AutomationRun) } var body: some ReducerOf { @@ -67,31 +66,27 @@ struct ActivityFeedView: View { var body: some View { List { - Picker("Filter", selection: $store.filter) { - ForEach(ActivityFeedFeature.Filter.allCases, id: \.self) { filter in - Text(filter.rawValue).tag(filter) + ForEach(store.entries) { entry in + switch entry { + case let .run(run): + NavigationLink(state: OverviewPath.State.run(RunDetailFeature.State(run: run))) { + RunRow(run: run) + } + case let .command(item): + ActionRow(item: item) } } - .pickerStyle(.segmented) - .listRowBackground(Color.clear) - - Section { - ForEach(store.entries) { entry in - switch entry { - case let .run(run): - Button { - store.send(.runTapped(run)) - } label: { - RunRow(run: run) - } - .buttonStyle(.plain) - case let .command(item): - ActionRow(item: item) + } + .navigationTitle("Activity") + .toolbar { + ToolbarItem(placement: .primaryAction) { + FilterMenu(selection: $store.filter, isActive: store.filter != .all) { + ForEach(ActivityFeedFeature.Filter.allCases, id: \.self) { filter in + Text(filter.rawValue).tag(filter) } } } } - .navigationTitle("Activity") } } diff --git a/Sources/Controller/Features/AutomationDetails.swift b/Sources/Controller/Features/AutomationDetails.swift index e9a10c33..d7bf12ae 100644 --- a/Sources/Controller/Features/AutomationDetails.swift +++ b/Sources/Controller/Features/AutomationDetails.swift @@ -14,12 +14,6 @@ import SwiftUI @Reducer struct AutomationDetails: Sendable { - @Reducer - enum Destination { - case entity(EntityHistoryDetailFeature) - case run(RunDetailFeature) - } - // MARK: - State @ObservableState @@ -29,7 +23,6 @@ struct AutomationDetails: Sendable { var error: String? /// Last 24 hours, newest first. var runs: [AutomationRun] = [] - @Presents var destination: Destination.State? /// Entity histories of the debug chart, only loaded for `MotionAtNight`. var histories: [EntityHistory] = [] var end = Date() @@ -40,11 +33,8 @@ struct AutomationDetails: Sendable { } enum Action: Sendable { - case destination(PresentationAction) - case entityTapped(EntityId) case historiesResponse(Result<[EntityHistory], Error>) case locationResponse(Result) - case runTapped(AutomationRun) case runsResponse(Result<[AutomationRun], Error>) case task case stopAutomation @@ -59,17 +49,6 @@ struct AutomationDetails: Sendable { var body: some ReducerOf { Reduce { state, action in switch action { - case .destination(.presented(.run(.delegate(.openAutomation)))): - state.destination = nil - return .none - - case .destination: - return .none - - case let .entityTapped(entityId): - state.destination = .entity(EntityHistoryDetailFeature.State(entity: EntityInfo(entityId: entityId))) - return .none - case let .historiesResponse(.success(histories)): state.histories = histories return .none @@ -82,10 +61,6 @@ struct AutomationDetails: Sendable { state.error = "Failed to load chart data: \(error.localizedDescription)" return .none - case let .runTapped(run): - state.destination = .run(RunDetailFeature.State(run: run)) - return .none - case let .runsResponse(.success(runs)): state.runs = runs return .none @@ -150,7 +125,6 @@ struct AutomationDetails: Sendable { return .none } } - .ifLet(\.$destination, action: \.destination) } /// One room-history request per room the automation's devices are in. @@ -170,9 +144,6 @@ struct AutomationDetails: Sendable { } } -extension AutomationDetails.Destination.State: Equatable, Sendable {} -extension AutomationDetails.Destination.Action: Sendable {} - struct AutomationDetailView: View { @Bindable var store: StoreOf @@ -190,7 +161,6 @@ struct AutomationDetailView: View { Button("Stop", role: .destructive) { store.send(.stopAutomation) } - .foregroundStyle(.red) .disabled(store.isLoading) } } footer: { @@ -203,9 +173,7 @@ struct AutomationDetailView: View { if !store.automation.entities.isEmpty { Section("Devices") { ForEach(store.automation.entities, id: \.self) { entityId in - Button { - store.send(.entityTapped(entityId)) - } label: { + NavigationLink(state: AutomationsPath.State.entity(EntityHistoryDetailFeature.State(entity: EntityInfo(entityId: entityId)))) { LabeledContent(entityId.name, value: entityId.characteristicType.displayName) } } @@ -214,19 +182,19 @@ struct AutomationDetailView: View { Section { ForEach(store.runs) { run in - Button { - store.send(.runTapped(run)) - } label: { + NavigationLink(state: AutomationsPath.State.run(RunDetailFeature.State(run: run))) { RunRow(run: run, showsAutomationName: false) } } } header: { - Text("Runs · 24 h") + Text("Runs") } footer: { if !store.automation.recordsRuns { Text("Runs are not recorded for this automation.") } else if store.runs.isEmpty { Text("No runs in the last 24 hours.") + } else { + Text("Last 24 hours.") } } @@ -234,15 +202,7 @@ struct AutomationDetailView: View { motionAtNightSection } } - .buttonStyle(.plain) .task { await store.send(.task).finish() } - .navigationDestination(item: $store.scope(\.$destination, action: \.destination).entity) { entityStore in - EntityHistoryDetailView(store: entityStore) - .navigationTitle(entityStore.entity.displayName) - } - .navigationDestination(item: $store.scope(\.$destination, action: \.destination).run) { runStore in - RunDetailView(store: runStore) - } } } @@ -278,26 +238,28 @@ extension AutomationDetailView { bands: nights ) } header: { - Text("Why did it trigger? · 24 h") + Text("Trigger Conditions") } footer: { - Text("Grey: sun below the horizon. Motion below the threshold turns the lights on.") + Text("Last 24 hours. Grey: sun below the horizon. Motion below the threshold turns the lights on.") } } } #Preview { - AutomationDetailView( - store: Store( - initialState: AutomationDetails.State( - automation: Shared(value: AutomationInfo( - name: "Test Automation", - isActive: true, - isRunning: true, - type: "MotionAtNight" - )) - ) - ) { - AutomationDetails() - } - ) + NavigationStack { + AutomationDetailView( + store: Store( + initialState: AutomationDetails.State( + automation: Shared(value: AutomationInfo( + name: "Test Automation", + isActive: true, + isRunning: true, + type: "MotionAtNight" + )) + ) + ) { + AutomationDetails() + } + ) + } } diff --git a/Sources/Controller/Features/AutomationsFeature.swift b/Sources/Controller/Features/AutomationsFeature.swift index 7dd35519..b6f70a65 100644 --- a/Sources/Controller/Features/AutomationsFeature.swift +++ b/Sources/Controller/Features/AutomationsFeature.swift @@ -11,6 +11,16 @@ import HAModels import Sharing import SwiftUI +@Reducer +enum AutomationsPath { + case details(AutomationDetails) + case entity(EntityHistoryDetailFeature) + case run(RunDetailFeature) +} + +extension AutomationsPath.State: Equatable, Sendable {} +extension AutomationsPath.Action: Sendable {} + @Reducer struct AutomationsFeature: Sendable { @@ -26,10 +36,8 @@ struct AutomationsFeature: Sendable { @Shared(.automations) var automations: IdentifiedArrayOf = [] @Shared(.automationGrouping) var grouping: Grouping = .status var isLoading = false - var selectedAutomationIndex: String? - var error: String? - - @Presents var selectedAutomation: AutomationDetails.State? + var path = StackState() + @Presents var alert: AlertState? /// Running automations first within each section. var sections: [(title: String, automations: [AutomationInfo])] { @@ -48,16 +56,21 @@ struct AutomationsFeature: Sendable { // MARK: - Action - enum Action: BindableAction, Sendable { - case binding(BindingAction) + enum Action: Sendable { + case alert(PresentationAction) case onAppear case refresh case automationsResponse(Result<[AutomationInfo], Error>) case setActive(name: String, Bool) case setActiveResponse(name: String, Result) - case dismissError case groupingChanged(Grouping) - case selectedAutomation(PresentationAction) + /// Replaces the stack with the details of the named automation, e.g. from a run in another tab. + case openAutomation(String) + case path(StackActionOf) + + enum Alert: Sendable { + case dismissError + } } // MARK: - Dependencies @@ -67,19 +80,9 @@ struct AutomationsFeature: Sendable { // MARK: - Body var body: some ReducerOf { - BindingReducer() Reduce { state, action in switch action { - case .binding(\.selectedAutomationIndex): - if let selectedAutomationIndex = state.selectedAutomationIndex, - let automation = Shared(state.$automations[id: selectedAutomationIndex]) { - state.selectedAutomation = .init(automation: automation) - } else { - state.selectedAutomation = nil - } - return .none - - case .binding: + case .alert: return .none case .onAppear: @@ -89,7 +92,6 @@ struct AutomationsFeature: Sendable { case .refresh: state.isLoading = true - state.error = nil return .run { send in await send(.automationsResponse( Result { try await serverClient.getAutomations() } @@ -104,11 +106,10 @@ struct AutomationsFeature: Sendable { case let .automationsResponse(.failure(error)): state.isLoading = false - state.error = "Failed to load automations: \(error.localizedDescription)" + state.alert = Self.errorAlert("Failed to load automations: \(error.localizedDescription)") return .none case let .setActive(name, isActive): - state.error = nil state.$automations.withLock { $0[id: name]?.isActive = isActive } return .run { send in await send(.setActiveResponse(name: name, Result { @@ -126,23 +127,42 @@ struct AutomationsFeature: Sendable { case let .setActiveResponse(name, .failure(error)): state.$automations.withLock { $0[id: name]?.isActive.toggle() } - state.error = "Failed to change \(name): \(error.localizedDescription)" - return .none - - case .dismissError: - state.error = nil + state.alert = Self.errorAlert("Failed to change \(name): \(error.localizedDescription)") return .none case let .groupingChanged(grouping): state.$grouping.withLock { $0 = grouping } return .none - case .selectedAutomation: + case let .openAutomation(name): + guard let automation = Shared(state.$automations[id: name]) else { + return .none + } + state.path = StackState([.details(AutomationDetails.State(automation: automation))]) + return .none + + case let .path(.element(id, .run(.delegate(.openAutomation)))): + // A run is only reachable from its automation's details, so going back opens them. + state.path.pop(from: id) + return .none + + case .path: return .none } } - .ifLet(\.$selectedAutomation, action: \.selectedAutomation) { - AutomationDetails() + .forEach(\.path, action: \.path) + .ifLet(\.$alert, action: \.alert) + } + + private static func errorAlert(_ message: String) -> AlertState { + AlertState { + TextState("Error") + } actions: { + ButtonState(action: .dismissError) { + TextState("OK") + } + } message: { + TextState(message) } } } @@ -151,43 +171,29 @@ struct AutomationsView: View { @Bindable var store: StoreOf var body: some View { - NavigationStack { - List(selection: $store.selectedAutomationIndex) { - Picker("Grouping", selection: Binding(get: { store.grouping }, set: { store.send(.groupingChanged($0)) })) { - Text("Active / Inactive").tag(AutomationsFeature.Grouping.status) - Text("By Type").tag(AutomationsFeature.Grouping.type) - } - .pickerStyle(.segmented) - .listRowBackground(Color.clear) - - if let error = store.error { - Text(error) - .foregroundStyle(.red) - } - + NavigationStack(path: $store.scope(\.path, action: \.path)) { + List { ForEach(store.sections, id: \.title) { section in Section(section.title) { ForEach(section.automations) { automation in automationRow(automation) - .tag(automation.id) } } } - - if store.automations.isEmpty && !store.isLoading { - ContentUnavailableView( - "No Automations", - systemImage: "lamp.floor", - description: Text("Pull to refresh") - ) - } } .navigationTitle("Automations") - .navigationDestination(item: $store.scope(\.$selectedAutomation, action: \.selectedAutomation)) { automationStore in - AutomationDetailView(store: automationStore) - .navigationTitle(automationStore.automation.name) + .toolbar { + ToolbarItem(placement: .primaryAction) { + Menu { + Picker("Group By", selection: Binding(get: { store.grouping }, set: { store.send(.groupingChanged($0)) })) { + Text("Active / Inactive").tag(AutomationsFeature.Grouping.status) + Text("By Type").tag(AutomationsFeature.Grouping.type) + } + } label: { + Label("Group By", systemImage: "rectangle.3.group") + } + } } - .sensoryFeedback(.selection, trigger: store.selectedAutomationIndex) .refreshable { store.send(.refresh) } @@ -197,13 +203,32 @@ struct AutomationsView: View { .overlay { if store.isLoading && store.automations.isEmpty { ProgressView() + } else if store.automations.isEmpty { + ContentUnavailableView( + "No Automations", + systemImage: "lamp.floor", + description: Text("Pull to refresh") + ) } } + .alert($store.scope(\.$alert, action: \.alert)) + } destination: { pathStore in + switch pathStore.case { + case let .details(detailsStore): + AutomationDetailView(store: detailsStore) + .navigationTitle(detailsStore.automation.name) + case let .entity(entityStore): + EntityHistoryDetailView(store: entityStore) + .navigationTitle(entityStore.entity.displayName) + case let .run(runStore): + RunDetailView(store: runStore) + } } } + @ViewBuilder private func automationRow(_ automation: AutomationInfo) -> some View { - Toggle(isOn: Binding( + let toggle = Toggle(isOn: Binding( get: { automation.isActive }, set: { store.send(.setActive(name: automation.name, $0)) } )) { @@ -221,6 +246,13 @@ struct AutomationsView: View { Image(systemName: automation.systemImage) } } + if let shared = Shared(store.state.$automations[id: automation.id]) { + NavigationLink(state: AutomationsPath.State.details(AutomationDetails.State(automation: shared))) { + toggle + } + } else { + toggle + } } } diff --git a/Sources/Controller/Features/EntityHistoryDetailFeature.swift b/Sources/Controller/Features/EntityHistoryDetailFeature.swift index 9c2efb3b..c251d5db 100644 --- a/Sources/Controller/Features/EntityHistoryDetailFeature.swift +++ b/Sources/Controller/Features/EntityHistoryDetailFeature.swift @@ -22,7 +22,6 @@ struct EntityHistoryDetailFeature: Sendable { var historyItems: [EntityHistoryItem] = [] var isLoading = false var timeRange: TimeRange = .hour - var nextCursor: Date? @Presents var alert: AlertState? var chartData: [EntityHistoryItem] { @@ -63,7 +62,6 @@ struct EntityHistoryDetailFeature: Sendable { enum Action: Sendable, BindableAction { case onAppear case refresh - case loadNextPage case historyResponse(Result) case timeRangeChanged(TimeRange) case binding(BindingAction) @@ -78,6 +76,8 @@ struct EntityHistoryDetailFeature: Sendable { @Dependency(\.serverClient) var serverClient + private enum CancelID { case history } + // MARK: - Body var body: some ReducerOf { @@ -93,48 +93,30 @@ struct EntityHistoryDetailFeature: Sendable { state.isLoading = true state.alert = nil state.historyItems = [] - state.nextCursor = nil - - let entityId = state.entity.entityId - let dateRange = state.dateRange - - return .run { send in - await send(.historyResponse( - Result { - try await serverClient.getEntityHistory( - entityId, - dateRange.start, - dateRange.end, - nil, - 1000, - true - ) - } - )) - } - - case .loadNextPage: - guard let cursor = state.nextCursor else { - return .none - } let entityId = state.entity.entityId let dateRange = state.dateRange + // All pages in one effect: it is cancelled as a whole when the screen is popped or + // the range changes, so no late page reaches a removed element or a new range. return .run { send in - await send(.historyResponse( - Result { - try await serverClient.getEntityHistory( - entityId, - dateRange.start, - dateRange.end, - cursor, - 1000, - false - ) - } - )) + var cursor: Date? + repeat { + let response = try await serverClient.getEntityHistory( + entityId, + dateRange.start, + dateRange.end, + cursor, + 1000, + cursor == nil + ) + await send(.historyResponse(.success(response))) + cursor = response.nextCursor + } while cursor != nil + } catch: { error, send in + await send(.historyResponse(.failure(error))) } + .cancellable(id: CancelID.history, cancelInFlight: true) case let .historyResponse(.success(response)): state.isLoading = false @@ -145,15 +127,6 @@ struct EntityHistoryDetailFeature: Sendable { } state.historyItems.append(contentsOf: newItems) state.historyItems.sort { $0.timestamp > $1.timestamp } - - state.nextCursor = response.nextCursor - - // Automatically load next page if there's more data - if response.nextCursor != nil { - return .run { send in - await send(.loadNextPage) - } - } return .none case let .historyResponse(.failure(error)): @@ -206,13 +179,10 @@ struct EntityHistoryDetailView: View { chartView } else if store.isLoading { ProgressView() - .frame(maxWidth: .infinity, minHeight: 300) + .frame(maxWidth: .infinity) } else { - ContentUnavailableView( - "No Data", - systemImage: "chart.line.uptrend.xyaxis", - description: Text("No history data available for this time range") - ) + Text("No history in this time range.") + .foregroundStyle(.secondary) } } @@ -301,27 +271,19 @@ struct EntityHistoryDetailView: View { } } - @ViewBuilder private func historyRow(_ item: EntityHistoryItem) -> some View { - HStack { - // Color indicator (if color data available) - if let color = item.color { - Circle() - .fill(color) - .frame(width: 24, height: 24) - .overlay( - Circle() - .stroke(Color.secondary.opacity(0.3), lineWidth: 1) - ) - } - - VStack(alignment: .leading, spacing: 4) { - Text(item.timestamp.formatted(date: .abbreviated, time: .shortened)) - .font(.caption) - .foregroundColor(.secondary) + LabeledContent { + Text(item.timestamp, format: .dateTime.day().month().hour().minute()) + } label: { + HStack { + if let color = item.color { + Circle() + .fill(color) + .frame(width: 24, height: 24) + .overlay(Circle().stroke(Color.secondary.opacity(0.3), lineWidth: 1)) + } + Text(item.valueDescription) } - Spacer() - Text(item.valueDescription) } } } diff --git a/Sources/Controller/Features/OverviewFeature.swift b/Sources/Controller/Features/OverviewFeature.swift index 9fb4da56..2b573963 100644 --- a/Sources/Controller/Features/OverviewFeature.swift +++ b/Sources/Controller/Features/OverviewFeature.swift @@ -59,8 +59,6 @@ struct OverviewFeature: Sendable { case path(StackActionOf) case refresh case refreshResponse(Result) - case runTapped(AutomationRun) - case showAllTapped case stopButtonTapped(String) case task @@ -79,10 +77,6 @@ struct OverviewFeature: Sendable { case .delegate: return .none - case let .path(.element(_, .feed(.runTapped(run)))): - state.path.append(.run(RunDetailFeature.State(run: run))) - return .none - case let .path(.element(_, .run(.delegate(.openAutomation(name))))): return .send(.delegate(.openAutomation(name))) @@ -119,15 +113,7 @@ struct OverviewFeature: Sendable { case let .refreshResponse(.failure(error)): state.isHealthy = nil - state.error = "Server unreachable: \(error.localizedDescription)" - return .none - - case let .runTapped(run): - state.path.append(.run(RunDetailFeature.State(run: run))) - return .none - - case .showAllTapped: - state.path.append(.feed(ActivityFeedFeature.State(runs: state.recentRuns, actions: state.recentActions))) + state.error = error.localizedDescription return .none case let .stopButtonTapped(name): @@ -157,10 +143,14 @@ struct OverviewView: View { let openWindows: [WindowContentState.WindowState] var body: some View { - NavigationStack(path: $store.scope(state: \.path, action: \.path)) { + NavigationStack(path: $store.scope(\.path, action: \.path)) { List { Section { statusRow + } footer: { + if let error = store.error { + Text(error) + } } if !openWindows.isEmpty { @@ -198,31 +188,27 @@ struct OverviewView: View { } } - Section { + Section("Recent Runs") { ForEach(store.recentRuns.prefix(5)) { run in - Button { - store.send(.runTapped(run)) - } label: { + NavigationLink(state: OverviewPath.State.run(RunDetailFeature.State(run: run))) { RunRow(run: run) } - .buttonStyle(.plain) - } - } header: { - HStack { - Text("Recent Runs") - Spacer() - Button("Show All") { - store.send(.showAllTapped) - } - .font(.footnote) } + NavigationLink( + "Show All", + state: OverviewPath.State.feed(ActivityFeedFeature.State(runs: store.recentRuns, actions: store.recentActions)) + ) } if !store.hints.isEmpty { Section("Notices") { ForEach(store.hints, id: \.self) { hint in - Label(hint, systemImage: "exclamationmark.triangle") - .foregroundStyle(.red) + Label { + Text(hint) + } icon: { + Image(systemName: "exclamationmark.triangle") + .foregroundStyle(.red) + } } } } @@ -244,14 +230,14 @@ struct OverviewView: View { HStack { Image(systemName: "circle.fill") .foregroundStyle(store.isHealthy == true ? Color.green : Color.red) - if let error = store.error { - Text(error) + if store.error != nil { + Text("Server unreachable") } else { Text(store.isHealthy == false ? "Adapter disconnected" : "Adapter connected") } Spacer() if let lastUpdated = store.lastUpdated { - Text(lastUpdated, style: .relative) + Text("Updated \(Text(lastUpdated, style: .relative)) ago") .font(.caption) .foregroundStyle(.secondary) } diff --git a/Sources/Controller/Features/RoomFeature.swift b/Sources/Controller/Features/RoomFeature.swift index ccc972bd..d9526231 100644 --- a/Sources/Controller/Features/RoomFeature.swift +++ b/Sources/Controller/Features/RoomFeature.swift @@ -28,7 +28,6 @@ struct RoomFeature: Sendable { var histories: [EntityHistory] = [] var timeRange: TimeRange = .day var weekHistories: [EntityHistory] = [] - @Presents var entityDetail: EntityHistoryDetailFeature.State? var start: Date { end.addingTimeInterval(-timeRange.rawValue) } } @@ -36,8 +35,6 @@ struct RoomFeature: Sendable { enum Action: BindableAction, Sendable { case automationsResponse(Result<[AutomationInfo], Error>) case binding(BindingAction) - case entityDetail(PresentationAction) - case entityTapped(EntityInfo) case historyResponse(Result<[EntityHistory], Error>) case task case weekHistoryResponse(Result<[EntityHistory], Error>) @@ -69,11 +66,7 @@ struct RoomFeature: Sendable { case .binding(\.timeRange): return loadHistory(&state) - case .binding, .entityDetail: - return .none - - case let .entityTapped(entity): - state.entityDetail = EntityHistoryDetailFeature.State(entity: entity) + case .binding: return .none case .task: @@ -90,9 +83,6 @@ struct RoomFeature: Sendable { ) } } - .ifLet(\.$entityDetail, action: \.entityDetail) { - EntityHistoryDetailFeature() - } } private func loadHistory(_ state: inout State) -> Effect { @@ -178,22 +168,13 @@ struct RoomView: View { Section("Devices") { ForEach(store.entities) { entity in - Button { - store.send(.entityTapped(entity)) - } label: { + NavigationLink(state: RoomsPath.State.entity(EntityHistoryDetailFeature.State(entity: entity))) { Label { - VStack(alignment: .leading) { - Text(entity.entityId.name) - Text(entity.formattedCharacteristicDisplayName) - .font(.subheadline) - .foregroundStyle(.secondary) - } + Text(entity.entityId.name) + Text(entity.formattedCharacteristicDisplayName) } icon: { Image(systemName: entity.entityId.characteristicType.systemImage) - .foregroundStyle(.tint) } - // A button would tint the whole row; only the icon carries the accent. - .foregroundStyle(.primary) } } } @@ -208,10 +189,6 @@ struct RoomView: View { } .navigationTitle(store.placeId) .task { await store.send(.task).finish() } - .navigationDestination(item: $store.scope(\.$entityDetail, action: \.entityDetail)) { detailStore in - EntityHistoryDetailView(store: detailStore) - .navigationTitle(detailStore.entity.displayName) - } } } diff --git a/Sources/Controller/Features/RoomsFeature.swift b/Sources/Controller/Features/RoomsFeature.swift index 4b8e9d57..80b91eb6 100644 --- a/Sources/Controller/Features/RoomsFeature.swift +++ b/Sources/Controller/Features/RoomsFeature.swift @@ -2,7 +2,7 @@ // RoomsFeature.swift // ControllerFeatures // -// Entities grouped by room +// Rooms with recorded device history // import ComposableArchitecture @@ -11,6 +11,15 @@ import HAModels import Sharing import SwiftUI +@Reducer +enum RoomsPath { + case room(RoomFeature) + case entity(EntityHistoryDetailFeature) +} + +extension RoomsPath.State: Equatable, Sendable {} +extension RoomsPath.Action: Sendable {} + @Reducer struct RoomsFeature: Sendable { @@ -21,25 +30,17 @@ struct RoomsFeature: Sendable { var entities: [EntityInfo] = [] var isLoading = false var searchText = "" - var selectedEntityId: String? - @Presents var selectedEntityDetail: EntityHistoryDetailFeature.State? - @Presents var room: RoomFeature.State? + var path = StackState() @Presents var alert: AlertState? - var filteredEntities: [EntityInfo] { - guard !searchText.isEmpty else { - return entities - } - - let searchLowercased = searchText.localizedLowercase - return entities.filter { entity in - entity.displayName.localizedLowercase.contains(searchLowercased) || - entity.formattedCharacteristicDisplayName.localizedLowercase.contains(searchLowercased) - } - } - + /// Rooms whose name or devices match the search, each with all of its devices. var rooms: [(placeId: String, entities: [EntityInfo])] { - Dictionary(grouping: filteredEntities, by: \.entityId.placeId) + Dictionary(grouping: entities, by: \.entityId.placeId) + .filter { placeId, entities in + searchText.isEmpty + || placeId.localizedStandardContains(searchText) + || entities.contains { $0.entityId.name.localizedStandardContains(searchText) } + } .sorted { $0.key < $1.key } .map { ($0.key, $0.value) } } @@ -52,9 +53,7 @@ struct RoomsFeature: Sendable { case refresh case entitiesResponse(Result<[EntityInfo], Error>) case binding(BindingAction) - case selectedEntityDetail(PresentationAction) - case room(PresentationAction) - case roomTapped(String) + case path(StackActionOf) case alert(PresentationAction) enum Alert: Sendable { @@ -72,15 +71,6 @@ struct RoomsFeature: Sendable { BindingReducer() Reduce { state, action in switch action { - case .binding(\.selectedEntityId): - if let selectedEntityId = state.selectedEntityId, - let entity = state.entities.first(where: { $0.id == selectedEntityId }) { - state.selectedEntityDetail = .init(entity: entity) - } else { - state.selectedEntityDetail = nil - } - return .none - case .binding: return .none @@ -119,23 +109,14 @@ struct RoomsFeature: Sendable { } return .none - case .selectedEntityDetail, .room: - return .none - - case let .roomTapped(placeId): - state.room = RoomFeature.State(placeId: placeId, entities: state.entities.filter { $0.entityId.placeId == placeId }) + case .path: return .none case .alert: return .none } } - .ifLet(\.$selectedEntityDetail, action: \.selectedEntityDetail) { - EntityHistoryDetailFeature() - } - .ifLet(\.$room, action: \.room) { - RoomFeature() - } + .forEach(\.path, action: \.path) .ifLet(\.$alert, action: \.alert) } } @@ -144,82 +125,45 @@ struct RoomsView: View { @Bindable var store: StoreOf var body: some View { - NavigationStack { - contentView - .navigationTitle("Rooms") - .navigationDestination( - item: $store.scope(\.$selectedEntityDetail, action: \.selectedEntityDetail) - ) { detailStore in - EntityHistoryDetailView(store: detailStore) - .navigationTitle(detailStore.entity.displayName) - } - .navigationDestination(item: $store.scope(\.$room, action: \.room)) { roomStore in - RoomView(store: roomStore) - } - .sensoryFeedback(.selection, trigger: store.selectedEntityId) - .refreshable { - store.send(.refresh) - } - .onAppear { - store.send(.onAppear) - } - .overlay { - if store.isLoading && store.entities.isEmpty { - ProgressView() - } - } - .alert($store.scope(\.$alert, action: \.alert)) - } - } - - @ViewBuilder - private var contentView: some View { - Group { - if store.entities.isEmpty && !store.isLoading { - ContentUnavailableView( - "No Entities", - systemImage: "square.grid.2x2", - description: Text("Devices with recorded history appear here.") - ) - } else { - List(selection: $store.selectedEntityId) { - ForEach(store.rooms, id: \.placeId) { room in - Section { - ForEach(room.entities) { entity in - entityRow(entity) - .tag(entity.id) - } - } header: { - Button { - store.send(.roomTapped(room.placeId)) - } label: { - HStack(spacing: 4) { - Text(room.placeId) - Image(systemName: "chevron.right") - } - } + NavigationStack(path: $store.scope(\.path, action: \.path)) { + List { + ForEach(store.rooms, id: \.placeId) { room in + NavigationLink(state: RoomsPath.State.room(RoomFeature.State(placeId: room.placeId, entities: room.entities))) { + LabeledContent(room.placeId) { + // `Text` takes a LocalizedStringKey, which is what parses the inflect markup. + Text("^[\(room.entities.count) device](inflect: true)") } } } - .searchable( - text: $store.searchText, - prompt: "Search devices..." - ) } - } - } - - @ViewBuilder - private func entityRow(_ entity: EntityInfo) -> some View { - Label { - VStack(alignment: .leading, spacing: 2) { - Text(entity.entityId.name) - Text(entity.formattedCharacteristicDisplayName) - .font(.subheadline) - .foregroundStyle(.secondary) + .searchable(text: $store.searchText, prompt: "Search rooms and devices") + .navigationTitle("Rooms") + .refreshable { + store.send(.refresh) + } + .onAppear { + store.send(.onAppear) + } + .overlay { + if store.isLoading && store.entities.isEmpty { + ProgressView() + } else if store.entities.isEmpty { + ContentUnavailableView( + "No Rooms", + systemImage: "square.grid.2x2", + description: Text("Rooms with recorded device history appear here.") + ) + } + } + .alert($store.scope(\.$alert, action: \.alert)) + } destination: { pathStore in + switch pathStore.case { + case let .room(roomStore): + RoomView(store: roomStore) + case let .entity(entityStore): + EntityHistoryDetailView(store: entityStore) + .navigationTitle(entityStore.entity.displayName) } - } icon: { - Image(systemName: entity.entityId.characteristicType.systemImage) } } } diff --git a/Sources/Controller/Features/SettingsFeature.swift b/Sources/Controller/Features/SettingsFeature.swift index fbe45a41..c698a05e 100644 --- a/Sources/Controller/Features/SettingsFeature.swift +++ b/Sources/Controller/Features/SettingsFeature.swift @@ -12,6 +12,15 @@ import Logging import Sharing import SwiftUI +@Reducer +enum SettingsPath { + case commandLog(ActionsFeature) + case logs(LogViewerFeature) +} + +extension SettingsPath.State: Equatable, Sendable {} +extension SettingsPath.Action: Sendable {} + @Reducer struct SettingsFeature: Sendable { @@ -37,9 +46,7 @@ struct SettingsFeature: Sendable { var isPushAuthorized: Bool = false var deviceToken: Data? - // Diagnostics - @Presents var commandLog: ActionsFeature.State? - @Presents var logViewer: LogViewerFeature.State? + var path = StackState() } // MARK: - Action @@ -55,10 +62,7 @@ struct SettingsFeature: Sendable { case windowStatesResponse(Result<[WindowContentState.WindowState], Error>) case requestPushAuthorization case dismissError - case showCommandLog - case commandLog(PresentationAction) - case showLogViewer - case logViewer(PresentationAction) + case path(StackActionOf) case binding(BindingAction) } @@ -174,30 +178,14 @@ struct SettingsFeature: Sendable { state.error = nil return .none - case .showCommandLog: - state.commandLog = ActionsFeature.State() - return .none - - case .commandLog: - return .none - - case .showLogViewer: - state.logViewer = LogViewerFeature.State() - return .none - - case .logViewer: + case .path: return .none case .binding: return .none } } - .ifLet(\.$commandLog, action: \.commandLog) { - ActionsFeature() - } - .ifLet(\.$logViewer, action: \.logViewer) { - LogViewerFeature() - } + .forEach(\.path, action: \.path) } } @@ -205,7 +193,7 @@ struct SettingsView: View { @Bindable var store: StoreOf var body: some View { - NavigationStack { + NavigationStack(path: $store.scope(\.path, action: \.path)) { Form { Section { serverConfigSection @@ -225,48 +213,28 @@ struct SettingsView: View { } #endif - Section { - diagnosticsButton("Command Log", systemImage: "list.bullet.clipboard") { - store.send(.showCommandLog) + Section("Diagnostics") { + NavigationLink(state: SettingsPath.State.commandLog(ActionsFeature.State())) { + Label("Command Log", systemImage: "list.bullet.clipboard") } - diagnosticsButton("App Logs", systemImage: "doc.text.magnifyingglass") { - store.send(.showLogViewer) + NavigationLink(state: SettingsPath.State.logs(LogViewerFeature.State())) { + Label("App Logs", systemImage: "doc.text.magnifyingglass") } - } header: { - Text("Diagnostics") } } .navigationTitle("Settings") - .navigationDestination(item: $store.scope(state: \.commandLog, action: \.commandLog)) { commandLogStore in - ActionsView(store: commandLogStore) - } - .navigationDestination( - item: $store.scope(state: \.logViewer, action: \.logViewer) - ) { logStore in - LogViewerFeatureView(store: logStore) - } .refreshable { store.send(.refreshWindowStates) } .onAppear { store.send(.onAppear) } - } - } - - private func diagnosticsButton(_ title: String, systemImage: String, action: @escaping () -> Void) -> some View { - Button(action: action) { - HStack { - Label { - Text(title) - } icon: { - Image(systemName: systemImage) - .foregroundStyle(.tint) - } - .foregroundStyle(.primary) - Spacer() - Image(systemName: "chevron.right") - .foregroundStyle(.secondary) + } destination: { pathStore in + switch pathStore.case { + case let .commandLog(commandLogStore): + ActionsView(store: commandLogStore) + case let .logs(logStore): + LogViewerFeatureView(store: logStore) } } } @@ -287,7 +255,7 @@ struct SettingsView: View { Text(store.serverURL.absoluteString.trimmingCharacters(in: CharacterSet(charactersIn: "/"))) .font(.caption) - .foregroundColor(.secondary) + .foregroundStyle(.secondary) } if store.isEditingServerURL { diff --git a/Sources/Controller/Shared/FilterMenu.swift b/Sources/Controller/Shared/FilterMenu.swift new file mode 100644 index 00000000..2acdc4d6 --- /dev/null +++ b/Sources/Controller/Shared/FilterMenu.swift @@ -0,0 +1,35 @@ +// +// FilterMenu.swift +// Controller +// + +import SwiftUI + +/// Toolbar filter the way Mail shows it: a menu with one checkmarked option, filled while a filter is on. +struct FilterMenu: View { + @Binding var selection: Selection + let isActive: Bool + @ViewBuilder let options: () -> Options + + var body: some View { + Menu { + Picker("Filter", selection: $selection, content: options) + } label: { + Label("Filter", systemImage: isActive ? "line.3.horizontal.decrease.circle.fill" : "line.3.horizontal.decrease.circle") + } + } +} + +#Preview { + @Previewable @State var selection = "All" + NavigationStack { + List { + Text(selection) + } + .toolbar { + FilterMenu(selection: $selection, isActive: selection != "All") { + ForEach(["All", "Failed"], id: \.self) { Text($0).tag($0) } + } + } + } +} diff --git a/Tests/ControllerTests/AutomationsFeatureTests.swift b/Tests/ControllerTests/AutomationsFeatureTests.swift index febc07fc..9da7cf88 100644 --- a/Tests/ControllerTests/AutomationsFeatureTests.swift +++ b/Tests/ControllerTests/AutomationsFeatureTests.swift @@ -22,7 +22,7 @@ struct AutomationsFeatureTests { @Test("status grouping splits active and inactive, running first") func groupsByStatus() { - var state = AutomationsFeature.State() + let state = AutomationsFeature.State() state.$automations.withLock { $0 = automations } state.$grouping.withLock { $0 = .status } @@ -32,7 +32,7 @@ struct AutomationsFeatureTests { @Test("type grouping uses the type label") func groupsByType() { - var state = AutomationsFeature.State() + let state = AutomationsFeature.State() state.$automations.withLock { $0 = automations } state.$grouping.withLock { $0 = .type } @@ -53,11 +53,50 @@ struct AutomationsFeatureTests { } await store.receive(\.setActiveResponse) { $0.$automations.withLock { $0[id: "Evening"]?.isActive = true } - $0.error = "Failed to change Evening: \(ActivateError().localizedDescription)" + $0.alert = AlertState { + TextState("Error") + } actions: { + ButtonState(action: .dismissError) { + TextState("OK") + } + } message: { + TextState("Failed to change Evening: \(ActivateError().localizedDescription)") + } } } } +@Suite("RoomsFeature") +struct RoomsFeatureTests { + private let entities = [ + EntityInfo.preview(placeId: "Kitchen", name: "Ceiling"), + EntityInfo.preview(placeId: "Kitchen", name: "Window", type: .contactSensor), + EntityInfo.preview(placeId: "Bath", name: "Mirror") + ] + + @Test("rooms are listed alphabetically with all their devices") + func roomsGroupDevices() { + var state = RoomsFeature.State() + state.entities = entities + + #expect(state.rooms.map(\.placeId) == ["Bath", "Kitchen"]) + #expect(state.rooms[1].entities.map(\.entityId.name) == ["Ceiling", "Window"]) + } + + @Test("search matches room names and device names, keeping the whole room") + func searchKeepsWholeRoom() { + var state = RoomsFeature.State() + state.entities = entities + + state.searchText = "window" + #expect(state.rooms.map(\.placeId) == ["Kitchen"]) + #expect(state.rooms[0].entities.count == 2) + + state.searchText = "bath" + #expect(state.rooms.map(\.placeId) == ["Bath"]) + } +} + @Suite("ActionsFeature") struct ActionsFeatureTests { @Test("command log filter matches status, falling back to the cache flag for old servers") diff --git a/Tests/ControllerTests/EntityHistoryDetailFeatureTests.swift b/Tests/ControllerTests/EntityHistoryDetailFeatureTests.swift new file mode 100644 index 00000000..482cbe5e --- /dev/null +++ b/Tests/ControllerTests/EntityHistoryDetailFeatureTests.swift @@ -0,0 +1,45 @@ +// +// EntityHistoryDetailFeatureTests.swift +// ControllerTests +// + +import ComposableArchitecture +@testable import Controller +import Foundation +import HAModels +import Testing + +@MainActor +@Suite("EntityHistoryDetailFeature") +struct EntityHistoryDetailFeatureTests { + private let entity = EntityInfo(entityId: EntityId(placeId: "Bath", name: "Sensor", characteristicsName: nil, characteristic: .temperatureSensor)) + + /// Paging through actions let a late page reach a screen that was already popped (TCA + /// "missing element" warning); every page has to arrive from one effect. + @Test("refresh loads every page from one effect") + func refreshLoadsAllPages() async { + let first = EntityHistoryItem(timestamp: Date(timeIntervalSince1970: 10), temperatureInC: 21) + let second = EntityHistoryItem(timestamp: Date(timeIntervalSince1970: 5), temperatureInC: 20) + let store = TestStore(initialState: EntityHistoryDetailFeature.State(entity: entity)) { + EntityHistoryDetailFeature() + } withDependencies: { + $0.serverClient.getEntityHistory = { _, _, _, cursor, _, _ in + if cursor == nil { + return EntityHistoryResponse(items: [first], nextCursor: first.timestamp) + } + return EntityHistoryResponse(items: [second], nextCursor: nil) + } + } + + await store.send(.refresh) { + $0.isLoading = true + } + await store.receive(\.historyResponse.success) { + $0.isLoading = false + $0.historyItems = [first] + } + await store.receive(\.historyResponse.success) { + $0.historyItems = [first, second] + } + } +} diff --git a/Tests/ControllerTests/OverviewFeatureTests.swift b/Tests/ControllerTests/OverviewFeatureTests.swift index e42f387f..a795237f 100644 --- a/Tests/ControllerTests/OverviewFeatureTests.swift +++ b/Tests/ControllerTests/OverviewFeatureTests.swift @@ -69,9 +69,12 @@ struct OverviewFeatureTests { #expect(store.state.isHealthy == true) } - @Test("a run's Open Automation button switches to its tab") + @Test("a run's Open Automation button switches to its tab and opens the details") func openAutomationFromRun() async { var state = AppFeature.State() + state.automations.$automations.withLock { + $0 = [AutomationInfo(name: "Night Light", isActive: true, isRunning: false, type: "MotionAtNight")] + } state.overview.path.append(.run(RunDetailFeature.State(run: run(.completed)))) let store = TestStore(initialState: state) { AppFeature() @@ -80,8 +83,8 @@ struct OverviewFeatureTests { await store.send(\.overview.path[id: 0].run.openAutomationButtonTapped) await store.receive(\.overview.delegate) - await store.receive(\.automations.binding) + await store.receive(\.automations.openAutomation) #expect(store.state.selectedTab == .automations) - #expect(store.state.automations.selectedAutomationIndex == "Night Light") + #expect(store.state.automations.path.first?.details?.automation.name == "Night Light") } } From 8383f80ec100ceeaf44a34ba62fbeef05d3f2073 Mon Sep 17 00:00:00 2001 From: Julian Kahnert Date: Sat, 3 Oct 2026 23:36:35 +0200 Subject: [PATCH 18/22] List only the devices an automation uses and link a run's trigger to its device The devices of an automation came from a reflection over the whole object, so battery sensors no automation reads and devices held twice (the light sensor is usually one of the motion sensors) showed up. The server now sends `involvedEntityIds`, which drops both. The reflection also skipped every id of a device subclass such as `EveMotion`, because `Mirror` lists only the subclass's own properties; it now walks the `superclassMirror` chain. A run's trigger row opens the history of the device that fired it. --- .../Features/AutomationsFeature.swift | 4 +++- .../Controller/Features/OverviewFeature.swift | 8 ++++++- .../Features/RunDetailFeature.swift | 18 ++++++++++++---- Sources/HAModels/Automation/Automatable.swift | 18 +++++++++++++++- .../Controllers/OpenAPIController.swift | 2 +- .../AutomationEntityIdsTests.swift | 21 +++++++++++++++++++ 6 files changed, 63 insertions(+), 8 deletions(-) create mode 100644 Tests/HomeAutomationKitTests/AutomationEntityIdsTests.swift diff --git a/Sources/Controller/Features/AutomationsFeature.swift b/Sources/Controller/Features/AutomationsFeature.swift index b6f70a65..6157cd61 100644 --- a/Sources/Controller/Features/AutomationsFeature.swift +++ b/Sources/Controller/Features/AutomationsFeature.swift @@ -221,7 +221,9 @@ struct AutomationsView: View { EntityHistoryDetailView(store: entityStore) .navigationTitle(entityStore.entity.displayName) case let .run(runStore): - RunDetailView(store: runStore) + RunDetailView(store: runStore) { + AutomationsPath.State.entity(EntityHistoryDetailFeature.State(entity: EntityInfo(entityId: $0))) + } } } } diff --git a/Sources/Controller/Features/OverviewFeature.swift b/Sources/Controller/Features/OverviewFeature.swift index 2b573963..ece6a456 100644 --- a/Sources/Controller/Features/OverviewFeature.swift +++ b/Sources/Controller/Features/OverviewFeature.swift @@ -13,6 +13,7 @@ import SwiftUI @Reducer enum OverviewPath { + case entity(EntityHistoryDetailFeature) case feed(ActivityFeedFeature) case run(RunDetailFeature) } @@ -218,10 +219,15 @@ struct OverviewView: View { .task { await store.send(.task).finish() } } destination: { pathStore in switch pathStore.case { + case let .entity(entityStore): + EntityHistoryDetailView(store: entityStore) + .navigationTitle(entityStore.entity.displayName) case let .feed(feedStore): ActivityFeedView(store: feedStore) case let .run(runStore): - RunDetailView(store: runStore) + RunDetailView(store: runStore) { + OverviewPath.State.entity(EntityHistoryDetailFeature.State(entity: EntityInfo(entityId: $0))) + } } } } diff --git a/Sources/Controller/Features/RunDetailFeature.swift b/Sources/Controller/Features/RunDetailFeature.swift index 4db182b3..d36da517 100644 --- a/Sources/Controller/Features/RunDetailFeature.swift +++ b/Sources/Controller/Features/RunDetailFeature.swift @@ -59,14 +59,22 @@ struct RunDetailFeature: Sendable { } } -struct RunDetailView: View { +struct RunDetailView: View { let store: StoreOf + /// The stack element showing a device's history; the view sits in more than one stack. + let entityDestination: (EntityId) -> EntityDestination var body: some View { Form { Section { LabeledContent("Automation", value: store.run.automationName) - LabeledContent("Trigger", value: store.run.trigger.summary) + if let entityId = store.run.trigger.entityId { + NavigationLink(state: entityDestination(entityId)) { + LabeledContent("Trigger", value: store.run.trigger.summary) + } + } else { + LabeledContent("Trigger", value: store.run.trigger.summary) + } LabeledContent("Start", value: store.run.startedAt.formatted(date: .abbreviated, time: .standard)) if let endedAt = store.run.endedAt { LabeledContent("Duration", value: Duration.seconds(endedAt.timeIntervalSince(store.run.startedAt)) @@ -114,12 +122,14 @@ struct RunDetailView: View { automationName: "Bathroom Night Light", startedAt: Date().addingTimeInterval(-60), endedAt: Date().addingTimeInterval(-46), - trigger: AutomationTrigger(kind: .entityChange, entityId: nil, summary: "Motion · Eve Motion (Bathroom)"), + trigger: AutomationTrigger(kind: .entityChange, + entityId: EntityId(placeId: "Bathroom", name: "Eve Motion", characteristicsName: nil, characteristic: .motionSensor), + summary: "Motion · Eve Motion (Bathroom)"), outcome: .failed, errorDescription: "Mirror Light (Bathroom) not reachable" ))) { RunDetailFeature() } - ) + ) { $0 } } } diff --git a/Sources/HAModels/Automation/Automatable.swift b/Sources/HAModels/Automation/Automatable.swift index ac5422c9..5d8d774f 100644 --- a/Sources/HAModels/Automation/Automatable.swift +++ b/Sources/HAModels/Automation/Automatable.swift @@ -59,6 +59,13 @@ public extension Automatable { findAllEntityIds(in: self, maxDepth: 20) } + /// The entities worth showing for this automation: `getEntityIds()` without duplicates and + /// without battery sensors, which devices carry but no automation reads. + var involvedEntityIds: [EntityId] { + var seen = Set() + return getEntityIds().filter { $0.characteristicType != .batterySensor && seen.insert($0).inserted } + } + private func findAllEntityIds(in object: Any, maxDepth: Int) -> [EntityId] { // If the maximum depth is reached, stop recursion guard maxDepth > 0 else { @@ -68,7 +75,16 @@ public extension Automatable { var entityIds: [EntityId] = [] let mirror = Mirror(reflecting: object) - for child in mirror.children { + // Devices are subclasses (`EveMotion: MotionSensorDevice`) whose ids live in the base + // class, and `Mirror` lists those only via `superclassMirror`. + var children = Array(mirror.children) + var superclassMirror = mirror.superclassMirror + while let current = superclassMirror { + children.append(contentsOf: current.children) + superclassMirror = current.superclassMirror + } + + for child in children { let reflectedChild = Mirror(reflecting: child.value) switch reflectedChild.displayStyle { diff --git a/Sources/Server/Controllers/OpenAPIController.swift b/Sources/Server/Controllers/OpenAPIController.swift index 1fa354b9..6e81d42d 100644 --- a/Sources/Server/Controllers/OpenAPIController.swift +++ b/Sources/Server/Controllers/OpenAPIController.swift @@ -27,7 +27,7 @@ struct OpenAPIController: APIProtocol { _type: String(describing: type(of: tmp)), recordsRuns: tmp.recordsRuns, lastRun: lastRuns[tmp.name].map(Components.Schemas.AutomationRun.init), - entities: tmp.getEntityIds().map(Components.Schemas.EntityId.init)) + entities: tmp.involvedEntityIds.map(Components.Schemas.EntityId.init)) } return .ok(.init(body: .json(automations))) } diff --git a/Tests/HomeAutomationKitTests/AutomationEntityIdsTests.swift b/Tests/HomeAutomationKitTests/AutomationEntityIdsTests.swift new file mode 100644 index 00000000..27010a0a --- /dev/null +++ b/Tests/HomeAutomationKitTests/AutomationEntityIdsTests.swift @@ -0,0 +1,21 @@ +// +// AutomationEntityIdsTests.swift +// HomeAutomationKit +// + +import HAImplementations +import HAModels +import Testing + +struct AutomationEntityIdsTests { + @Test func involvedEntityIdsSkipBatterySensorsAndDuplicates() throws { + let motion = EveMotion(query: .init(placeId: "room1", name: "motion1")) + let automation = MotionAtNight("night", motionSensors: [motion], lightSensor: motion, lights: [], minBrightness: 0.1) + let lightSensorId = try #require(motion.lightSensorId) + + let ids = automation.involvedEntityIds + + #expect(Set(ids) == [motion.motionSensorId, lightSensorId]) + #expect(ids.count == 2) + } +} From b4d5176d8928f7e34575584e12cb2ee5d799a281 Mon Sep 17 00:00:00 2001 From: Julian Kahnert Date: Sat, 3 Oct 2026 23:46:51 +0200 Subject: [PATCH 19/22] Cover the Controller reducers of the redesign with unit tests Activity feed entries and filter, room automations and time-range reloads, automation details (runs only when recorded, the MotionAtNight chart limited to the automation's devices, stop marks not running and reloads), the overview snapshot and stop, history time-range change and failure alert, and the automation type labels. --- .../ActivityFeedFeatureTests.swift | 46 +++++++ .../AutomationDetailsTests.swift | 112 ++++++++++++++++++ .../AutomationInfoDisplayTests.swift | 43 +++++++ .../EntityHistoryDetailFeatureTests.swift | 43 +++++++ .../OverviewFeatureTests.swift | 46 +++++++ Tests/ControllerTests/RoomFeatureTests.swift | 75 ++++++++++++ 6 files changed, 365 insertions(+) create mode 100644 Tests/ControllerTests/ActivityFeedFeatureTests.swift create mode 100644 Tests/ControllerTests/AutomationDetailsTests.swift create mode 100644 Tests/ControllerTests/AutomationInfoDisplayTests.swift create mode 100644 Tests/ControllerTests/RoomFeatureTests.swift diff --git a/Tests/ControllerTests/ActivityFeedFeatureTests.swift b/Tests/ControllerTests/ActivityFeedFeatureTests.swift new file mode 100644 index 00000000..581f3b63 --- /dev/null +++ b/Tests/ControllerTests/ActivityFeedFeatureTests.swift @@ -0,0 +1,46 @@ +// +// ActivityFeedFeatureTests.swift +// ControllerTests +// + +@testable import Controller +import Foundation +import HAModels +import Testing + +@Suite("ActivityFeedFeature") +struct ActivityFeedFeatureTests { + private let run = AutomationRun( + automationName: "Night Light", + startedAt: Date(timeIntervalSince1970: 10), + trigger: AutomationTrigger(kind: .sunset, entityId: nil, summary: "Sunset"), + outcome: .completed + ) + private let command = ActionLogItem( + id: UUID(), + timestamp: Date(timeIntervalSince1970: 20), + entityId: EntityId(placeId: "Bath", name: "Mirror", characteristicsName: nil, characteristic: .switcher), + actionName: "on", + detailDescription: "turnOn", + hasCacheHit: false, + status: .executed + ) + + @Test("entries mix runs and commands, newest first") + func entriesNewestFirst() { + let state = ActivityFeedFeature.State(runs: [run], actions: [command]) + + #expect(state.entries.map(\.id) == [command.id, run.id]) + } + + @Test("filter limits the entries to runs or commands") + func filterLimitsEntries() { + var state = ActivityFeedFeature.State(runs: [run], actions: [command]) + + state.filter = .runs + #expect(state.entries.map(\.id) == [run.id]) + + state.filter = .commands + #expect(state.entries.map(\.id) == [command.id]) + } +} diff --git a/Tests/ControllerTests/AutomationDetailsTests.swift b/Tests/ControllerTests/AutomationDetailsTests.swift new file mode 100644 index 00000000..e9e24585 --- /dev/null +++ b/Tests/ControllerTests/AutomationDetailsTests.swift @@ -0,0 +1,112 @@ +// +// AutomationDetailsTests.swift +// ControllerTests +// + +import ComposableArchitecture +@testable import Controller +import Foundation +import HAModels +import Testing + +@MainActor +@Suite("AutomationDetails") +struct AutomationDetailsTests { + private let now = Date(timeIntervalSince1970: 1_000_000) + private let motion = EntityId(placeId: "Hall", name: "Eve Motion", characteristicsName: nil, characteristic: .motionSensor) + private let thermo = EntityId(placeId: "Hall", name: "Thermo", characteristicsName: nil, characteristic: .temperatureSensor) + + private func info(type: String, recordsRuns: Bool = true, isRunning: Bool = false, entities: [EntityId] = []) -> AutomationInfo { + AutomationInfo(name: "Night Light", isActive: true, isRunning: isRunning, type: type, recordsRuns: recordsRuns, entities: entities) + } + + @Test("runs of the last 24 hours are loaded when the automation records them") + func runsLoadedWhenRecorded() async { + let recorded = AutomationRun( + automationName: "Night Light", + startedAt: now, + trigger: AutomationTrigger(kind: .sunset, entityId: nil, summary: "Sunset"), + outcome: .completed + ) + let store = TestStore(initialState: AutomationDetails.State(automation: Shared(value: info(type: "Turn")))) { + AutomationDetails() + } withDependencies: { + $0.date.now = now + $0.serverClient.getRuns = { name, start, _, _ in + name == "Night Light" && start == now.addingTimeInterval(-86_400) ? [recorded] : [] + } + } + + await store.send(.task) { + $0.end = now + } + await store.receive(\.runsResponse.success) { + $0.runs = [recorded] + } + } + + @Test("an automation without run recording requests no runs") + func noRunsWhenNotRecorded() async { + let store = TestStore(initialState: AutomationDetails.State(automation: Shared(value: info(type: "HealthCheck", recordsRuns: false)))) { + AutomationDetails() + } withDependencies: { + $0.date.now = now + $0.serverClient.getRuns = { _, _, _, _ in + Issue.record("runs must not be requested") + return [] + } + } + + await store.send(.task) { + $0.end = now + } + } + + @Test("the MotionAtNight chart loads the location and only the histories of its devices") + func motionAtNightChartLoadsOwnDevices() async { + let motionHistory = EntityHistory(entityId: motion, items: []) + let location = Location(latitude: 1, longitude: 2) + let store = TestStore(initialState: AutomationDetails.State(automation: Shared(value: info(type: "MotionAtNight", recordsRuns: false, entities: [motion])))) { + AutomationDetails() + } withDependencies: { + $0.date.now = now + $0.serverClient.getLocation = { location } + $0.serverClient.getRoomHistory = { placeId, _, _, _ in + placeId == "Hall" ? [motionHistory, EntityHistory(entityId: thermo, items: [])] : [] + } + } + // `.serverLocation` lives in `UserDefaults.standard`, so a previous run may have stored one. + store.state.$location.withLock { $0 = nil } + + await store.send(.task) { + $0.end = now + } + await store.receive(\.locationResponse.success) { + $0.$location.withLock { $0 = location } + } + await store.receive(\.historiesResponse.success) { + $0.histories = [motionHistory] + } + } + + @Test("a successful stop marks the automation as not running and reloads") + func stopMarksNotRunningAndReloads() async { + let store = TestStore(initialState: AutomationDetails.State(automation: Shared(value: info(type: "Turn", recordsRuns: false, isRunning: true)))) { + AutomationDetails() + } withDependencies: { + $0.date.now = now + } + + await store.send(.stopAutomation) { + $0.isLoading = true + } + // `\.stopOperationResponse.success` on `Result` crashes the Swift 6.4 beta compiler. + await store.receive(\.stopOperationResponse) { + $0.isLoading = false + $0.$automation.withLock { $0.isRunning = false } + } + await store.receive(\.task) { + $0.end = now + } + } +} diff --git a/Tests/ControllerTests/AutomationInfoDisplayTests.swift b/Tests/ControllerTests/AutomationInfoDisplayTests.swift new file mode 100644 index 00000000..012f4513 --- /dev/null +++ b/Tests/ControllerTests/AutomationInfoDisplayTests.swift @@ -0,0 +1,43 @@ +// +// AutomationInfoDisplayTests.swift +// ControllerTests +// + +@testable import Controller +import Foundation +import HAModels +import Testing + +@Suite("AutomationInfo display") +struct AutomationInfoDisplayTests { + @Test("known types get a label and symbol, unknown and missing types fall back") + func typeLabels() { + let motion = AutomationInfo(name: "a", isActive: true, isRunning: false, type: "MotionAtNight") + #expect(motion.typeLabel == "Motion") + #expect(motion.systemImage == "figure.walk") + #expect(motion.triggerDescription == "Motion sensor") + + let unknown = AutomationInfo(name: "b", isActive: true, isRunning: false, type: "Something") + #expect(unknown.typeLabel == "Other") + #expect(unknown.systemImage == "gearshape") + #expect(unknown.triggerDescription == "Unknown trigger") + + let oldServer = AutomationInfo(name: "c", isActive: true, isRunning: false) + #expect(oldServer.typeLabel == "Other") + } + + @Test("subtitle shows the trigger configuration until the first run, then the last run") + func subtitleFollowsLastRun() { + var info = AutomationInfo(name: "a", isActive: true, isRunning: false, type: "MotionAtNight") + #expect(info.subtitle == "Motion sensor") + + info.lastRun = AutomationRun( + automationName: "a", + startedAt: Date(timeIntervalSince1970: 0), + trigger: AutomationTrigger(kind: .sunset, entityId: nil, summary: "Sunset"), + outcome: .completed + ) + #expect(info.subtitle.hasPrefix("last ")) + #expect(info.subtitle.hasSuffix(" · Sunset")) + } +} diff --git a/Tests/ControllerTests/EntityHistoryDetailFeatureTests.swift b/Tests/ControllerTests/EntityHistoryDetailFeatureTests.swift index 482cbe5e..d7abf444 100644 --- a/Tests/ControllerTests/EntityHistoryDetailFeatureTests.swift +++ b/Tests/ControllerTests/EntityHistoryDetailFeatureTests.swift @@ -42,4 +42,47 @@ struct EntityHistoryDetailFeatureTests { $0.historyItems = [first, second] } } + + @Test("changing the time range reloads from scratch") + func timeRangeReloads() async { + let store = TestStore(initialState: EntityHistoryDetailFeature.State(entity: entity)) { + EntityHistoryDetailFeature() + } + + await store.send(.timeRangeChanged(.week)) { + $0.timeRange = .week + } + await store.receive(\.refresh) { + $0.isLoading = true + } + await store.receive(\.historyResponse.success) { + $0.isLoading = false + } + } + + @Test("a failed load shows an alert") + func failureShowsAlert() async { + struct LoadError: Error {} + let store = TestStore(initialState: EntityHistoryDetailFeature.State(entity: entity)) { + EntityHistoryDetailFeature() + } withDependencies: { + $0.serverClient.getEntityHistory = { _, _, _, _, _, _ in throw LoadError() } + } + + await store.send(.refresh) { + $0.isLoading = true + } + await store.receive(\.historyResponse.failure) { + $0.isLoading = false + $0.alert = AlertState { + TextState("Error") + } actions: { + ButtonState(action: .dismissError) { + TextState("OK") + } + } message: { + TextState("Failed to load history: \(LoadError().localizedDescription)") + } + } + } } diff --git a/Tests/ControllerTests/OverviewFeatureTests.swift b/Tests/ControllerTests/OverviewFeatureTests.swift index a795237f..b11843eb 100644 --- a/Tests/ControllerTests/OverviewFeatureTests.swift +++ b/Tests/ControllerTests/OverviewFeatureTests.swift @@ -7,6 +7,7 @@ import ComposableArchitecture @testable import Controller import Foundation import HAModels +import Synchronization import Testing @MainActor @@ -45,6 +46,51 @@ struct OverviewFeatureTests { #expect(state.hints == ["Night Light failed: not reachable", "Mirror (Bath): 2 failed commands"]) } + @Test("refresh fills the status, drops commands older than a day and sorts the shared automations") + func refreshFillsSnapshot() async { + let now = Date(timeIntervalSince1970: 100_000) + let completed = run(.completed) + // Stamped at 1970, long before the 24 hour window. + let oldCommand = command(.executed) + let alpha = AutomationInfo(name: "Alpha", isActive: true, isRunning: false) + let zebra = AutomationInfo(name: "Zebra", isActive: true, isRunning: false) + let store = TestStore(initialState: OverviewFeature.State()) { + OverviewFeature() + } withDependencies: { + $0.date.now = now + $0.serverClient.getAutomations = { [zebra, alpha] } + $0.serverClient.getRecentRuns = { _, _ in [completed] } + $0.serverClient.getActions = { _, _ in [oldCommand] } + } + + await store.send(.refresh) + await store.receive(\.refreshResponse.success) { + $0.isHealthy = true + $0.lastUpdated = now + $0.recentRuns = [completed] + $0.recentActions = [] + $0.$automations.withLock { $0 = [alpha, zebra] } + } + } + + @Test("stopping an automation refreshes the overview") + func stopRefreshes() async { + let stopped = Mutex<[String]>([]) + let store = TestStore(initialState: OverviewFeature.State()) { + OverviewFeature() + } withDependencies: { + $0.date.now = Date(timeIntervalSince1970: 100_000) + $0.serverClient.stop = { name in stopped.withLock { $0.append(name) } } + } + store.exhaustivity = .off + + await store.send(.stopButtonTapped("Night Light")) + await store.receive(\.refresh) + await store.receive(\.refreshResponse.success) + + #expect(stopped.withLock { $0 } == ["Night Light"]) + } + @Test("polls every 30 seconds until the task is cancelled") func pollingStopsOnCancel() async { let clock = TestClock() diff --git a/Tests/ControllerTests/RoomFeatureTests.swift b/Tests/ControllerTests/RoomFeatureTests.swift new file mode 100644 index 00000000..d2de1871 --- /dev/null +++ b/Tests/ControllerTests/RoomFeatureTests.swift @@ -0,0 +1,75 @@ +// +// RoomFeatureTests.swift +// ControllerTests +// + +import ComposableArchitecture +@testable import Controller +import Foundation +import HAModels +import Synchronization +import Testing + +@MainActor +@Suite("RoomFeature") +struct RoomFeatureTests { + private let ceiling = EntityInfo.preview(placeId: "Kitchen", name: "Ceiling") + private let now = Date(timeIntervalSince1970: 1_000_000) + + @Test("only automations with a device in the room are kept") + func automationsFilteredToRoom() async { + let kitchen = AutomationInfo(name: "Kitchen Light", isActive: true, isRunning: false, entities: [ceiling.entityId]) + let hall = AutomationInfo(name: "Hall Light", isActive: true, isRunning: false, entities: [EntityInfo.preview(placeId: "Hall").entityId]) + let store = TestStore(initialState: RoomFeature.State(placeId: "Kitchen", entities: [ceiling])) { + RoomFeature() + } withDependencies: { + $0.date.now = now + $0.serverClient.getAutomations = { [kitchen, hall] } + } + store.exhaustivity = .off + + await store.send(.task) + await store.receive(\.automationsResponse.success) { + $0.automations = [kitchen] + } + } + + @Test("changing the time range reloads the room history for that range") + func timeRangeReloadsHistory() async { + let requestedStarts = Mutex<[Date?]>([]) + let store = TestStore(initialState: RoomFeature.State(placeId: "Kitchen", entities: [ceiling])) { + RoomFeature() + } withDependencies: { + $0.date.now = now + $0.serverClient.getRoomHistory = { _, start, _, _ in + requestedStarts.withLock { $0.append(start) } + return [] + } + } + + await store.send(.binding(.set(\.timeRange, .hour))) { + $0.timeRange = .hour + $0.end = now + } + await store.receive(\.historyResponse.success) + + #expect(requestedStarts.withLock { $0 } == [now.addingTimeInterval(-3_600)]) + } + + @Test("a failed history load is shown as error") + func failureSetsError() async { + struct LoadError: Error {} + let store = TestStore(initialState: RoomFeature.State(placeId: "Kitchen", entities: [ceiling])) { + RoomFeature() + } withDependencies: { + $0.date.now = now + $0.serverClient.getRoomHistory = { _, _, _, _ in throw LoadError() } + } + store.exhaustivity = .off + + await store.send(.task) + await store.receive(\.historyResponse.failure) { + $0.error = LoadError().localizedDescription + } + } +} From 164323cfa936c390206dd654410e324b6a58b178 Mon Sep 17 00:00:00 2001 From: Julian Kahnert Date: Sat, 3 Oct 2026 23:54:02 +0200 Subject: [PATCH 20/22] Open a running automation from the Overview tab Tapping a row in the Running section switches to the Automations tab and opens that automation's details, the same way a run's Open Automation button does. The Stop button keeps its own action inside the row. --- .../Controller/Features/OverviewFeature.swift | 34 ++++++++++++------- .../OverviewFeatureTests.swift | 18 ++++++++++ 2 files changed, 40 insertions(+), 12 deletions(-) diff --git a/Sources/Controller/Features/OverviewFeature.swift b/Sources/Controller/Features/OverviewFeature.swift index ece6a456..382f8c2c 100644 --- a/Sources/Controller/Features/OverviewFeature.swift +++ b/Sources/Controller/Features/OverviewFeature.swift @@ -56,6 +56,7 @@ struct OverviewFeature: Sendable { } enum Action: Sendable { + case automationTapped(String) case delegate(Delegate) case path(StackActionOf) case refresh @@ -75,6 +76,9 @@ struct OverviewFeature: Sendable { var body: some ReducerOf { Reduce { state, action in switch action { + case let .automationTapped(name): + return .send(.delegate(.openAutomation(name))) + case .delegate: return .none @@ -171,19 +175,25 @@ struct OverviewView: View { if !running.isEmpty { Section("Running") { ForEach(running) { automation in - HStack { - VStack(alignment: .leading, spacing: 2) { - Text(automation.name) - Text(automation.lastRun?.trigger.summary ?? automation.triggerDescription) - .font(.subheadline) - .foregroundStyle(.secondary) - .lineLimit(1) - } - Spacer() - Button("Stop") { - store.send(.stopButtonTapped(automation.name)) + Button { + store.send(.automationTapped(automation.name)) + } label: { + HStack { + VStack(alignment: .leading, spacing: 2) { + Text(automation.name) + Text(automation.lastRun?.trigger.summary ?? automation.triggerDescription) + .font(.subheadline) + .foregroundStyle(.secondary) + .lineLimit(1) + } + // A button would tint the whole row; the row opens the automation in its tab. + .foregroundStyle(.primary) + Spacer() + Button("Stop") { + store.send(.stopButtonTapped(automation.name)) + } + .buttonStyle(.bordered) } - .buttonStyle(.bordered) } } } diff --git a/Tests/ControllerTests/OverviewFeatureTests.swift b/Tests/ControllerTests/OverviewFeatureTests.swift index b11843eb..ecb548be 100644 --- a/Tests/ControllerTests/OverviewFeatureTests.swift +++ b/Tests/ControllerTests/OverviewFeatureTests.swift @@ -115,6 +115,24 @@ struct OverviewFeatureTests { #expect(store.state.isHealthy == true) } + @Test("tapping a running automation switches to its tab and opens the details") + func openAutomationFromRunningRow() async { + var state = AppFeature.State() + state.automations.$automations.withLock { + $0 = [AutomationInfo(name: "Night Light", isActive: true, isRunning: true, type: "MotionAtNight")] + } + let store = TestStore(initialState: state) { + AppFeature() + } + store.exhaustivity = .off + + await store.send(\.overview.automationTapped, "Night Light") + await store.receive(\.overview.delegate) + await store.receive(\.automations.openAutomation) + #expect(store.state.selectedTab == .automations) + #expect(store.state.automations.path.first?.details?.automation.name == "Night Light") + } + @Test("a run's Open Automation button switches to its tab and opens the details") func openAutomationFromRun() async { var state = AppFeature.State() From 6c4809d255bef4d91fece0cf3a8a8df6054b4eb4 Mon Sep 17 00:00:00 2001 From: Julian Kahnert Date: Sat, 3 Oct 2026 23:55:40 +0200 Subject: [PATCH 21/22] Show the running automation row with a chevron instead of a tinted label --- Sources/Controller/Features/OverviewFeature.swift | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/Sources/Controller/Features/OverviewFeature.swift b/Sources/Controller/Features/OverviewFeature.swift index 382f8c2c..555e9cfa 100644 --- a/Sources/Controller/Features/OverviewFeature.swift +++ b/Sources/Controller/Features/OverviewFeature.swift @@ -186,14 +186,17 @@ struct OverviewView: View { .foregroundStyle(.secondary) .lineLimit(1) } - // A button would tint the whole row; the row opens the automation in its tab. - .foregroundStyle(.primary) Spacer() Button("Stop") { store.send(.stopButtonTapped(automation.name)) } .buttonStyle(.bordered) + // Reads like a navigation row: it leaves the tab, so it cannot be a NavigationLink. + Image(systemName: "chevron.forward") + .font(.footnote.weight(.semibold)) + .foregroundStyle(.tertiary) } + .foregroundStyle(.primary) } } } From 603b8b195e2a7649032b8c80f81bbdcd22a22d3e Mon Sep 17 00:00:00 2001 From: Julian Kahnert Date: Sun, 4 Oct 2026 00:03:14 +0200 Subject: [PATCH 22/22] Name the room next to a device's kind and keep the running row in label color Automation details list each device on two lines, "Eve Motion" over "Motion Sensor (Arbeitszimmer)", and the MotionAtNight chart labels its lanes with the room as well, so devices with the same name in different rooms no longer share a lane. The running row in the Overview tints the row button with the label color: `.primary` resolves relative to the tint inside a button label, so overriding the foreground style alone left the text accent-colored. The Stop button keeps the accent explicitly. --- .../Extensions/EntityId+Display.swift | 18 ++++++++++++++++++ .../Extensions/EntityInfo+Display.swift | 2 +- .../Features/AutomationDetails.swift | 9 +++++++-- .../Controller/Features/OverviewFeature.swift | 5 ++++- .../EntityIdDisplayTests.swift | 19 +++++++++++++++++++ 5 files changed, 49 insertions(+), 4 deletions(-) create mode 100644 Sources/Controller/Extensions/EntityId+Display.swift create mode 100644 Tests/ControllerTests/EntityIdDisplayTests.swift diff --git a/Sources/Controller/Extensions/EntityId+Display.swift b/Sources/Controller/Extensions/EntityId+Display.swift new file mode 100644 index 00000000..0da03e25 --- /dev/null +++ b/Sources/Controller/Extensions/EntityId+Display.swift @@ -0,0 +1,18 @@ +// +// EntityId+Display.swift +// Controller +// + +import HAModels + +extension EntityId { + /// "Eve Motion (Arbeitszimmer)" — the room disambiguates devices in lists that span rooms. + var displayName: String { + "\(name) (\(placeId))" + } + + /// "Motion Sensor (Arbeitszimmer)" + var kindInRoom: String { + "\(characteristicType.displayName) (\(placeId))" + } +} diff --git a/Sources/Controller/Extensions/EntityInfo+Display.swift b/Sources/Controller/Extensions/EntityInfo+Display.swift index e8761ab3..79639bdf 100644 --- a/Sources/Controller/Extensions/EntityInfo+Display.swift +++ b/Sources/Controller/Extensions/EntityInfo+Display.swift @@ -11,7 +11,7 @@ import HAModels extension EntityInfo { /// Human-readable display name for the entity public var displayName: String { - "\(entityId.name) (\(entityId.placeId))" + entityId.displayName } /// Formatted characteristic display name using the CharacteristicsType extension diff --git a/Sources/Controller/Features/AutomationDetails.swift b/Sources/Controller/Features/AutomationDetails.swift index d7bf12ae..c372b162 100644 --- a/Sources/Controller/Features/AutomationDetails.swift +++ b/Sources/Controller/Features/AutomationDetails.swift @@ -174,7 +174,12 @@ struct AutomationDetailView: View { Section("Devices") { ForEach(store.automation.entities, id: \.self) { entityId in NavigationLink(state: AutomationsPath.State.entity(EntityHistoryDetailFeature.State(entity: EntityInfo(entityId: entityId)))) { - LabeledContent(entityId.name, value: entityId.characteristicType.displayName) + VStack(alignment: .leading, spacing: 2) { + Text(entityId.name) + Text(entityId.kindInRoom) + .font(.subheadline) + .foregroundStyle(.secondary) + } } } } @@ -215,7 +220,7 @@ extension AutomationDetailView { .filter { [.switcher, .motionSensor].contains($0.entityId.characteristicType) } .map { history in TimelineChart.Lane( - label: history.entityId.name, + label: history.entityId.displayName, color: ChartPalette.color(for: history.entityId.characteristicType), intervals: StateIntervals.intervals(items: history.items, isActive: \.stateValue, from: store.start, to: store.end), kind: history.entityId.characteristicType.displayName diff --git a/Sources/Controller/Features/OverviewFeature.swift b/Sources/Controller/Features/OverviewFeature.swift index 555e9cfa..a57805a4 100644 --- a/Sources/Controller/Features/OverviewFeature.swift +++ b/Sources/Controller/Features/OverviewFeature.swift @@ -191,13 +191,16 @@ struct OverviewView: View { store.send(.stopButtonTapped(automation.name)) } .buttonStyle(.bordered) + .tint(.accentColor) // Reads like a navigation row: it leaves the tab, so it cannot be a NavigationLink. Image(systemName: "chevron.forward") .font(.footnote.weight(.semibold)) .foregroundStyle(.tertiary) } - .foregroundStyle(.primary) } + // `.primary` and `.secondary` resolve relative to the button's tint, so the + // label stays accent-colored unless the tint itself is the label color. + .tint(.primary) } } } diff --git a/Tests/ControllerTests/EntityIdDisplayTests.swift b/Tests/ControllerTests/EntityIdDisplayTests.swift new file mode 100644 index 00000000..f7399cc4 --- /dev/null +++ b/Tests/ControllerTests/EntityIdDisplayTests.swift @@ -0,0 +1,19 @@ +// +// EntityIdDisplayTests.swift +// ControllerTests +// + +@testable import Controller +import HAModels +import Testing + +@Suite("EntityId display") +struct EntityIdDisplayTests { + @Test("name and kind carry the room, like EntityId.description") + func nameAndKindCarryTheRoom() { + let motion = EntityId(placeId: "Arbeitszimmer", name: "Eve Motion", characteristicsName: nil, characteristic: .motionSensor) + + #expect(motion.displayName == "Eve Motion (Arbeitszimmer)") + #expect(motion.kindInRoom == "Motion Sensor (Arbeitszimmer)") + } +}