diff --git a/Sources/Controller/App/AppFeature.swift b/Sources/Controller/App/AppFeature.swift index 2c2b0566..b8bf6592 100644 --- a/Sources/Controller/App/AppFeature.swift +++ b/Sources/Controller/App/AppFeature.swift @@ -24,10 +24,10 @@ 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() + var rooms = RoomsFeature.State() var settings = SettingsFeature.State() var openWindowsCount: Int? { @@ -38,25 +38,25 @@ struct AppFeature: Sendable { // MARK: - Tab 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" } } 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" + case .rooms: return "square.grid.2x2" case .settings: return "gear" } } @@ -66,9 +66,9 @@ 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) + case rooms(RoomsFeature.Action) case settings(SettingsFeature.Action) // Live Activities & Push Notifications @@ -82,6 +82,7 @@ struct AppFeature: Sendable { // Scene phase changes case scenePhaseChanged(old: ScenePhase, new: ScenePhase) + case refreshAll case binding(BindingAction) } @@ -97,19 +98,19 @@ struct AppFeature: Sendable { var body: some ReducerOf { BindingReducer() - Scope(state: \.automations, action: \.automations) { - AutomationsFeature() + Scope(\.overview, action: \.overview) { + OverviewFeature() } - Scope(state: \.actions, action: \.actions) { - ActionsFeature() + Scope(\.automations, action: \.automations) { + AutomationsFeature() } - Scope(state: \.history, action: \.history) { - HistoryFeature() + Scope(\.rooms, action: \.rooms) { + RoomsFeature() } - Scope(state: \.settings, action: \.settings) { + Scope(\.settings, action: \.settings) { SettingsFeature() } @@ -119,13 +120,17 @@ struct AppFeature: Sendable { state.selectedTab = tab return .none - case .automations: + case let .overview(.delegate(.openAutomation(name))): + state.selectedTab = .automations + return .send(.automations(.openAutomation(name))) + + case .overview: return .none - case .actions: + case .automations: return .none - case .history: + case .rooms: return .none case .settings: @@ -201,11 +206,13 @@ struct AppFeature: Sendable { return .none } - // Refresh all tabs and start monitoring when app becomes active + return .send(.refreshAll) + + case .refreshAll: return .merge( + .send(.overview(.refresh)), .send(.automations(.refresh)), - .send(.actions(.refresh)), - .send(.history(.refresh)), + .send(.rooms(.refresh)), .send(.refreshWindowStates), .send(.startMonitoringLiveActivities), .send(.clearDeliveredNotifications) @@ -224,42 +231,33 @@ struct AppView: View { var body: some View { TabView(selection: $store.selectedTab) { Tab( - AppFeature.Tab.automations.title, - systemImage: AppFeature.Tab.automations.systemImage, - value: AppFeature.Tab.automations + AppFeature.Tab.overview.title, + systemImage: AppFeature.Tab.overview.systemImage, + value: AppFeature.Tab.overview ) { - AutomationsView( - store: store.scope( - state: \.automations, - action: \.automations - ) + OverviewView( + store: store.scope(\.overview, action: \.overview), + openWindows: store.settings.windowContentState?.windowStates ?? [] ) } + .badge(store.openWindowsCount ?? 0) Tab( - AppFeature.Tab.actions.title, - systemImage: AppFeature.Tab.actions.systemImage, - value: AppFeature.Tab.actions + AppFeature.Tab.automations.title, + systemImage: AppFeature.Tab.automations.systemImage, + value: AppFeature.Tab.automations ) { - ActionsView( - store: store.scope( - state: \.actions, - action: \.actions - ) + AutomationsView( + store: store.scope(\.automations, action: \.automations) ) } 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(\.rooms, action: \.rooms)) } Tab( @@ -268,15 +266,10 @@ struct AppView: View { value: AppFeature.Tab.settings ) { SettingsView( - store: store.scope( - state: \.settings, - action: \.settings - ) + store: store.scope(\.settings, action: \.settings) ) } - .badge(store.openWindowsCount ?? 0) } - .tabViewStyle(.sidebarAdaptable) .onSceneChange { oldPhase, newPhase in store.send(.scenePhaseChanged(old: oldPhase, new: newPhase)) } diff --git a/Sources/Controller/Charts/ChartPalette.swift b/Sources/Controller/Charts/ChartPalette.swift new file mode 100644 index 00000000..4c0ef8a5 --- /dev/null +++ b/Sources/Controller/Charts/ChartPalette.swift @@ -0,0 +1,36 @@ +// +// 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) + + /// 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 new file mode 100644 index 00000000..b5626bc4 --- /dev/null +++ b/Sources/Controller/Charts/DailyBarChart.swift @@ -0,0 +1,48 @@ +// +// 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 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(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(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) + } + } + .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/TimelineChart.swift b/Sources/Controller/Charts/TimelineChart.swift new file mode 100644 index 00000000..4aed0cac --- /dev/null +++ b/Sources/Controller/Charts/TimelineChart.swift @@ -0,0 +1,79 @@ +// +// 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] + /// Legend entry; lanes without one keep the chart legend hidden. + var kind: String? + 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(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 { + // .aligned keeps the last time label inside the plot instead of clipping it. + AxisMarks(preset: .aligned) { _ in + AxisGridLine() + AxisValueLabel(format: ChartPalette.axisFormat(for: domain)) + } + } + .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 + } + } +} + +#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..e8a3813a --- /dev/null +++ b/Sources/Controller/Charts/ValueChart.swift @@ -0,0 +1,73 @@ +// +// 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 bandColor = ChartPalette.night + var height: CGFloat = 180 + + var body: some View { + Chart { + ForEach(bands, id: \.start) { band in + RectangleMark(xStart: .value("Start", band.start), xEnd: .value("End", band.end)) + .foregroundStyle(bandColor) + } + 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) + // 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) + } + } + } + .chartXScale(domain: domain) + .chartYScale(type: isLogarithmic ? .log : .linear) + .chartYAxisLabel(unit) + .chartPlotStyle { $0.clipped() } + .chartXAxis { + // .aligned keeps the last time label inside the plot instead of clipping it. + AxisMarks(preset: .aligned) { _ in + AxisGridLine() + AxisValueLabel(format: ChartPalette.axisFormat(for: domain)) + } + } + .frame(height: height) + } +} + +#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/Dependencies/ServerClientDependency.swift b/Sources/Controller/Dependencies/ServerClientDependency.swift index 5fa0ad9c..589dbc68 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,15 @@ 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] + + /// The server's location, used for sunrise and sunset + var getLocation: @Sendable () async throws -> Location } // MARK: - Dependency Key Implementation @@ -61,26 +77,34 @@ 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 [] }, + getLocation: { Location(latitude: 52.5, longitude: 13.4) } ) 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 +137,7 @@ extension ServerClientDependency: TestDependencyKey { ) ] }, - getEntityHistory: { entityId, _, _, _, _ in + getEntityHistory: { entityId, _, _, _, _, _ in // Generate realistic data based on entity type let items: [EntityHistoryItem] @@ -178,8 +202,27 @@ extension ServerClientDependency: TestDependencyKey { } return EntityHistoryResponse(items: items, nextCursor: nil) - } + }, + getRoomHistory: { _, _, _, _ in [] }, + getLocation: { Location(latitude: 52.5, longitude: 13.4) } ) + + 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 +248,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 +272,21 @@ 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) + }, + getLocation: { + try await client.getLocation() } ) } 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/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 5a4dabc8..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) @@ -97,58 +102,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/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/ActionsFeature.swift b/Sources/Controller/Features/ActionsFeature.swift index f0fe8bb5..3cfc830c 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)) } } @@ -96,7 +109,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) } )) } @@ -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 { + ForEach(store.filteredActions) { item in + ActionRow(item: item) } - .searchable( - text: $store.searchText, - prompt: "Search actions..." - ) - .navigationTitle("Actions") - .toolbar { - ToolbarItem(placement: .primaryAction) { - Button { - store.send(.refresh) - } label: { - Label("Reload", systemImage: "arrow.clockwise") + } + .searchable( + text: $store.searchText, + prompt: "Search commands..." + ) + .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) } - .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) } + } - ToolbarItem(placement: .secondaryAction) { - Button(role: .destructive) { - store.send(.clearActions) - } label: { - Label("Clear All", systemImage: "trash") - } - .disabled(store.actions.isEmpty || store.isLoading) + ToolbarItem(placement: .secondaryAction) { + Button { + store.send(.refresh) + } label: { + Label("Reload", systemImage: "arrow.clockwise") } + .disabled(store.isLoading) } - .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 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(\.$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/ActivityFeedFeature.swift b/Sources/Controller/Features/ActivityFeedFeature.swift new file mode 100644 index 00000000..9e252c07 --- /dev/null +++ b/Sources/Controller/Features/ActivityFeedFeature.swift @@ -0,0 +1,99 @@ +// +// 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) + } + + var body: some ReducerOf { + BindingReducer() + } +} + +struct ActivityFeedView: View { + @Bindable var store: StoreOf + + var body: some View { + List { + 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) + } + } + } + .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) + } + } + } + } + } +} + +#Preview { + NavigationStack { + ActivityFeedView(store: Store(initialState: ActivityFeedFeature.State(runs: [], actions: [])) { + ActivityFeedFeature() + }) + } +} diff --git a/Sources/Controller/Features/AutomationDetails.swift b/Sources/Controller/Features/AutomationDetails.swift index 7ce9237b..c372b162 100644 --- a/Sources/Controller/Features/AutomationDetails.swift +++ b/Sources/Controller/Features/AutomationDetails.swift @@ -21,20 +21,64 @@ struct AutomationDetails: Sendable { @Shared var automation: AutomationInfo var isLoading = false var error: String? + /// Last 24 hours, newest first. + var runs: [AutomationRun] = [] + /// 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 historiesResponse(Result<[EntityHistory], Error>) + case locationResponse(Result) + 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 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 .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: + 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 @@ -73,7 +117,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 @@ -82,6 +126,22 @@ struct AutomationDetails: Sendable { } } } + + /// 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) } + })) + } + } } struct AutomationDetailView: View { @@ -90,55 +150,121 @@ struct AutomationDetailView: View { 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( + LabeledContent("Type", value: store.automation.typeLabel) + 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) + } + .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 + NavigationLink(state: AutomationsPath.State.entity(EntityHistoryDetailFeature.State(entity: EntityInfo(entityId: entityId)))) { + VStack(alignment: .leading, spacing: 2) { + Text(entityId.name) + Text(entityId.kindInRoom) + .font(.subheadline) + .foregroundStyle(.secondary) + } + } } } } Section { - Button("Stop Automation") { - store.send(.stopAutomation) + ForEach(store.runs) { run in + NavigationLink(state: AutomationsPath.State.run(RunDetailFeature.State(run: run))) { + RunRow(run: run, showsAutomationName: false) + } } + } header: { + Text("Runs") } 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.") + } else { + Text("Last 24 hours.") } } + + if store.isMotionAtNight { + motionAtNightSection + } } + .task { await store.send(.task).finish() } } } -#Preview { - AutomationDetailView( - store: Store( - initialState: AutomationDetails.State( - automation: Shared(value: AutomationInfo( - name: "Test Automation", - isActive: true, - isRunning: true - )) +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.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 + ) + } + 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: (MotionAtNightConstants.thresholdInLux, "Threshold \(Int(MotionAtNightConstants.thresholdInLux)) lx"), + bands: nights ) - ) { - AutomationDetails() + } header: { + Text("Trigger Conditions") + } footer: { + Text("Last 24 hours. Grey: sun below the horizon. Motion below the threshold turns the lights on.") } - ) + } +} + +#Preview { + 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 c869fa80..6157cd61 100644 --- a/Sources/Controller/Features/AutomationsFeature.swift +++ b/Sources/Controller/Features/AutomationsFeature.swift @@ -11,39 +11,66 @@ 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 { + 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 path = StackState() + @Presents var alert: AlertState? - 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) } + } } } // 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 automationOperationResponse(Result) - case dismissError - case selectedAutomation(PresentationAction) + case setActive(name: String, Bool) + case setActiveResponse(name: String, Result) + case groupingChanged(Grouping) + /// 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 @@ -53,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: @@ -75,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() } @@ -90,29 +106,63 @@ 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 .automationOperationResponse(.success): - // Refresh the list after a successful operation + case let .setActive(name, isActive): + 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 .dismissError: - state.error = nil + case let .setActiveResponse(name, .failure(error)): + state.$automations.withLock { $0[id: name]?.isActive.toggle() } + state.alert = Self.errorAlert("Failed to change \(name): \(error.localizedDescription)") return .none - case .selectedAutomation: + case let .groupingChanged(grouping): + state.$grouping.withLock { $0 = grouping } + return .none + + 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) } } } @@ -121,40 +171,29 @@ struct AutomationsView: View { @Bindable var store: StoreOf var body: some View { - NavigationStack { - List(selection: $store.selectedAutomationIndex) { - if !store.runningAutomations.isEmpty { - Section("Running") { - ForEach(store.runningAutomations) { automation in + 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.inactiveAutomations.isEmpty { - Section("Inactive") { - ForEach(store.inactiveAutomations, id: \.name) { automation in - automationRow(automation) - .tag(automation.id) + } + .navigationTitle("Automations") + .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") } } - - if store.automations.isEmpty && !store.isLoading { - ContentUnavailableView( - "No Automations", - systemImage: "lamp.floor", - description: Text("Pull to refresh") - ) - } } - .navigationTitle("Automations") - .navigationDestination(item: $store.scope(state: \.selectedAutomation, action: \.selectedAutomation)) { automationStore in - AutomationDetailView(store: automationStore) - .navigationTitle(automationStore.automation.name) - } - .sensoryFeedback(.selection, trigger: store.selectedAutomationIndex) .refreshable { store.send(.refresh) } @@ -164,6 +203,26 @@ 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) { + AutomationsPath.State.entity(EntityHistoryDetailFeature.State(entity: EntityInfo(entityId: $0))) } } } @@ -171,14 +230,30 @@ 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) + let toggle = Toggle(isOn: Binding( + get: { automation.isActive }, + set: { store.send(.setActive(name: automation.name, $0)) } + )) { + Label { + Text(automation.name) + Group { + if automation.isRunning { + Text("\(Text("Running").foregroundStyle(.green)) · \(automation.subtitle)") + } else { + Text(automation.subtitle) + } + } + .lineLimit(1) + } icon: { + 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 703bc9e4..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,46 +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, - 5000 // Higher limit for initial load - ) - } - )) - } - - 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 - ) - } - )) + 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 @@ -143,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)): @@ -188,51 +163,42 @@ 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 { chartView - .frame(height: 300) - .padding(.horizontal) } else if store.isLoading { ProgressView() - .frame(height: 300) + .frame(maxWidth: .infinity) } else { - ContentUnavailableView( - "No Data", - systemImage: "chart.line.uptrend.xyaxis", - description: Text("No history data available for this time range") - ) - .frame(height: 300) + Text("No history in this time range.") + .foregroundStyle(.secondary) } + } - // History list - if !store.historyItems.isEmpty { - VStack(spacing: 0) { - ForEach(store.historyItems) { item in - historyRow(item) - Divider() - } + if store.timeRange == .week { + Section("Per Day") { + dailyChart + } + } + + 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) @@ -240,68 +206,85 @@ struct EntityHistoryDetailView: View { .onAppear { store.send(.onAppear) } - .alert($store.scope(state: \.alert, action: \.alert)) + .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 - - 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) - ) - .foregroundStyle(Color.accentColor) - } - } - } - .chartXScale(domain: dateRange.start...dateRange.end) - .chartXAxis { - AxisMarks(values: .automatic) { _ in - AxisGridLine() - AxisValueLabel(format: .dateTime.hour().minute()) - } - } - .chartYAxis { - AxisMarks(position: .leading) + let range = dateRange.start...dateRange.end + let isBoolean = store.chartData.contains { $0.stateValue != nil } + let isLux = store.entity.entityId.characteristicType == .lightSensor + + if isBoolean { + TimelineChart( + lanes: [.init( + label: laneLabel, + 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 { + 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 + ) } } + /// Per-day summaries of the 7-day range: on-duration for lamps, max and time-weighted mean for lux and CO₂. @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) - ) - } + 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" + ) + } 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" : "ppm", + points: stats.map { ($0.day, $0.mean) }, + barLabel: "Daily Max" + ) + } + } - VStack(alignment: .leading, spacing: 4) { - Text(item.timestamp.formatted(date: .abbreviated, time: .shortened)) - .font(.caption) - .foregroundColor(.secondary) + private func historyRow(_ item: EntityHistoryItem) -> some View { + 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) - .font(.body) - .bold() } - .padding(.horizontal) - .padding(.vertical, 8) } } diff --git a/Sources/Controller/Features/HistoryFeature.swift b/Sources/Controller/Features/HistoryFeature.swift deleted file mode 100644 index 0a18fe65..00000000 --- a/Sources/Controller/Features/HistoryFeature.swift +++ /dev/null @@ -1,198 +0,0 @@ -// -// HistoryFeature.swift -// ControllerFeatures -// -// Feature for viewing entity history and charts -// - -import ComposableArchitecture -import Foundation -import HAModels -import Sharing -import SwiftUI - -@Reducer -struct HistoryFeature: Sendable { - - // MARK: - State - - @ObservableState - struct State: Equatable, Sendable { - var entities: [EntityInfo] = [] - var isLoading = false - var searchText = "" - var selectedEntityId: String? - @Presents var selectedEntityDetail: EntityHistoryDetailFeature.State? - @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) - } - } - } - - // MARK: - Action - - enum Action: Sendable, BindableAction { - case onAppear - case refresh - case entitiesResponse(Result<[EntityInfo], Error>) - case binding(BindingAction) - case selectedEntityDetail(PresentationAction) - case alert(PresentationAction) - - enum Alert: Sendable { - case dismissError - } - } - - // MARK: - Dependencies - - @Dependency(\.serverClient) var serverClient - - // MARK: - Body - - var body: some ReducerOf { - 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 - - case .onAppear: - return .run { send in - await send(.refresh) - } - - case .refresh: - state.isLoading = true - state.alert = nil - return .run { send in - do { - let entities = try await serverClient.getEntityIdsWithHistory() - await send(.entitiesResponse(.success(entities))) - } catch { - await send(.entitiesResponse(.failure(error))) - } - } - - case let .entitiesResponse(.success(entities)): - state.isLoading = false - state.entities = entities.sorted { $0.displayName < $1.displayName } - return .none - - case let .entitiesResponse(.failure(error)): - state.isLoading = false - state.alert = AlertState { - TextState("Error") - } actions: { - ButtonState(action: .dismissError) { - TextState("OK") - } - } message: { - TextState("Failed to load entities: \(error.localizedDescription)") - } - return .none - - case .selectedEntityDetail: - return .none - - case .alert: - return .none - } - } - .ifLet(\.$selectedEntityDetail, action: \.selectedEntityDetail) { - EntityHistoryDetailFeature() - } - .ifLet(\.$alert, action: \.alert) - } -} - -struct HistoryView: View { - @Bindable var store: StoreOf - - var body: some View { - NavigationStack { - contentView - .navigationTitle("History") - .navigationDestination( - item: $store.scope(state: \.selectedEntityDetail, action: \.selectedEntityDetail) - ) { detailStore in - EntityHistoryDetailView(store: detailStore) - .navigationTitle(detailStore.entity.displayName) - } - .sensoryFeedback(.selection, trigger: store.selectedEntityId) - .refreshable { - store.send(.refresh) - } - .onAppear { - store.send(.onAppear) - } - .overlay { - if store.isLoading && store.entities.isEmpty { - ProgressView() - } - } - .alert($store.scope(state: \.alert, action: \.alert)) - } - } - - @ViewBuilder - private var contentView: some View { - Group { - 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.") - ) - } else { - List(selection: $store.selectedEntityId) { - ForEach(store.filteredEntities) { entity in - entityRow(entity) - .tag(entity.id) - } - } - .searchable( - text: $store.searchText, - prompt: "Search entities..." - ) - } - } - } - - @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) - } - } -} - -#Preview { - HistoryView( - store: Store(initialState: HistoryFeature.State()) { - HistoryFeature() - } - ) -} 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 new file mode 100644 index 00000000..a57805a4 --- /dev/null +++ b/Sources/Controller/Features/OverviewFeature.swift @@ -0,0 +1,277 @@ +// +// OverviewFeature.swift +// Controller +// +// What is happening at home right now +// + +import ComposableArchitecture +import Foundation +import HAModels +import Sharing +import SwiftUI + +@Reducer +enum OverviewPath { + case entity(EntityHistoryDetailFeature) + 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 automationTapped(String) + case delegate(Delegate) + case path(StackActionOf) + case refresh + case refreshResponse(Result) + 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 let .automationTapped(name): + return .send(.delegate(.openAutomation(name))) + + case .delegate: + 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 = error.localizedDescription + 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(\.path, action: \.path)) { + List { + Section { + statusRow + } footer: { + if let error = store.error { + Text(error) + } + } + + if !openWindows.isEmpty { + Section("Open Windows") { + ForEach(openWindows, id: \.name) { window in + ProgressView(timerInterval: window.opened...window.end, countsDown: false) { + 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) + } + } + } + + let running = store.automations.filter(\.isRunning) + if !running.isEmpty { + Section("Running") { + ForEach(running) { automation in + 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) + } + Spacer() + Button("Stop") { + 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) + } + } + // `.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) + } + } + } + + Section("Recent Runs") { + ForEach(store.recentRuns.prefix(5)) { run in + NavigationLink(state: OverviewPath.State.run(RunDetailFeature.State(run: run))) { + RunRow(run: run) + } + } + 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 { + Text(hint) + } icon: { + Image(systemName: "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 .entity(entityStore): + EntityHistoryDetailView(store: entityStore) + .navigationTitle(entityStore.entity.displayName) + case let .feed(feedStore): + ActivityFeedView(store: feedStore) + case let .run(runStore): + RunDetailView(store: runStore) { + OverviewPath.State.entity(EntityHistoryDetailFeature.State(entity: EntityInfo(entityId: $0))) + } + } + } + } + + private var statusRow: some View { + HStack { + Image(systemName: "circle.fill") + .foregroundStyle(store.isHealthy == true ? Color.green : Color.red) + if store.error != nil { + Text("Server unreachable") + } else { + Text(store.isHealthy == false ? "Adapter disconnected" : "Adapter connected") + } + Spacer() + if let lastUpdated = store.lastUpdated { + Text("Updated \(Text(lastUpdated, style: .relative)) ago") + .font(.caption) + .foregroundStyle(.secondary) + } + } + } +} + +#Preview { + OverviewView( + store: Store(initialState: OverviewFeature.State()) { + OverviewFeature() + }, + openWindows: [] + ) +} diff --git a/Sources/Controller/Features/RoomFeature.swift b/Sources/Controller/Features/RoomFeature.swift new file mode 100644 index 00000000..d9526231 --- /dev/null +++ b/Sources/Controller/Features/RoomFeature.swift @@ -0,0 +1,203 @@ +// +// 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 + var weekHistories: [EntityHistory] = [] + + var start: Date { end.addingTimeInterval(-timeRange.rawValue) } + } + + enum Action: BindableAction, Sendable { + case automationsResponse(Result<[AutomationInfo], Error>) + case binding(BindingAction) + case historyResponse(Result<[EntityHistory], Error>) + case task + case weekHistoryResponse(Result<[EntityHistory], Error>) + } + + @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 .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 + + case .binding(\.timeRange): + return loadHistory(&state) + + case .binding: + return .none + + case .task: + return .merge( + 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) + })) + } + ) + } + } + } + + 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), + kind: history.entityId.characteristicType.displayName + ) + } + } + + 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 { + 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) + } + } header: { + 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 + NavigationLink(state: RoomsPath.State.entity(EntityHistoryDetailFeature.State(entity: entity))) { + Label { + Text(entity.entityId.name) + Text(entity.formattedCharacteristicDisplayName) + } icon: { + Image(systemName: 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() } + } +} + +#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 new file mode 100644 index 00000000..80b91eb6 --- /dev/null +++ b/Sources/Controller/Features/RoomsFeature.swift @@ -0,0 +1,177 @@ +// +// RoomsFeature.swift +// ControllerFeatures +// +// Rooms with recorded device history +// + +import ComposableArchitecture +import Foundation +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 { + + // MARK: - State + + @ObservableState + struct State: Equatable, Sendable { + var entities: [EntityInfo] = [] + var isLoading = false + var searchText = "" + var path = StackState() + @Presents var alert: AlertState? + + /// Rooms whose name or devices match the search, each with all of its devices. + var rooms: [(placeId: String, entities: [EntityInfo])] { + 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) } + } + } + + // MARK: - Action + + enum Action: Sendable, BindableAction { + case onAppear + case refresh + case entitiesResponse(Result<[EntityInfo], Error>) + case binding(BindingAction) + case path(StackActionOf) + case alert(PresentationAction) + + enum Alert: Sendable { + case dismissError + } + } + + // MARK: - Dependencies + + @Dependency(\.serverClient) var serverClient + + // MARK: - Body + + var body: some ReducerOf { + BindingReducer() + Reduce { state, action in + switch action { + case .binding: + return .none + + case .onAppear: + return .run { send in + await send(.refresh) + } + + case .refresh: + state.isLoading = true + state.alert = nil + return .run { send in + do { + let entities = try await serverClient.getEntityIdsWithHistory() + await send(.entitiesResponse(.success(entities))) + } catch { + await send(.entitiesResponse(.failure(error))) + } + } + + case let .entitiesResponse(.success(entities)): + state.isLoading = false + state.entities = entities.sorted { $0.entityId.name < $1.entityId.name } + return .none + + case let .entitiesResponse(.failure(error)): + state.isLoading = false + state.alert = AlertState { + TextState("Error") + } actions: { + ButtonState(action: .dismissError) { + TextState("OK") + } + } message: { + TextState("Failed to load entities: \(error.localizedDescription)") + } + return .none + + case .path: + return .none + + case .alert: + return .none + } + } + .forEach(\.path, action: \.path) + .ifLet(\.$alert, action: \.alert) + } +} + +struct RoomsView: View { + @Bindable var store: StoreOf + + var body: some View { + 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 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) + } + } + } +} + +#Preview { + RoomsView( + store: Store(initialState: RoomsFeature.State()) { + RoomsFeature() + } + ) +} diff --git a/Sources/Controller/Features/RunDetailFeature.swift b/Sources/Controller/Features/RunDetailFeature.swift new file mode 100644 index 00000000..d36da517 --- /dev/null +++ b/Sources/Controller/Features/RunDetailFeature.swift @@ -0,0 +1,135 @@ +// +// 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 + /// 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) + 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)) + .formatted(.units(allowed: [.hours, .minutes, .seconds], width: .abbreviated))) + } + LabeledContent("Outcome") { + StatusLabel(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: 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/Controller/Features/SettingsFeature.swift b/Sources/Controller/Features/SettingsFeature.swift index 83500bdb..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,8 +46,7 @@ struct SettingsFeature: Sendable { var isPushAuthorized: Bool = false var deviceToken: Data? - // Log viewer - @Presents var logViewer: LogViewerFeature.State? + var path = StackState() } // MARK: - Action @@ -54,8 +62,7 @@ struct SettingsFeature: Sendable { case windowStatesResponse(Result<[WindowContentState.WindowState], Error>) case requestPushAuthorization case dismissError - case showLogViewer - case logViewer(PresentationAction) + case path(StackActionOf) case binding(BindingAction) } @@ -171,20 +178,14 @@ struct SettingsFeature: Sendable { state.error = nil return .none - case .showLogViewer: - state.logViewer = LogViewerFeature.State() - return .none - - case .logViewer: + case .path: return .none case .binding: return .none } } - .ifLet(\.$logViewer, action: \.logViewer) { - LogViewerFeature() - } + .forEach(\.path, action: \.path) } } @@ -192,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 @@ -210,43 +211,31 @@ 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 { - store.send(.showLogViewer) - } label: { - HStack { - Label("View Logs", systemImage: "doc.text.magnifyingglass") - Spacer() - Image(systemName: "chevron.right") - .foregroundStyle(.secondary) - } + Section("Diagnostics") { + NavigationLink(state: SettingsPath.State.commandLog(ActionsFeature.State())) { + Label("Command Log", systemImage: "list.bullet.clipboard") + } + NavigationLink(state: SettingsPath.State.logs(LogViewerFeature.State())) { + Label("App Logs", systemImage: "doc.text.magnifyingglass") } - } header: { - Text("Diagnostics") } } .navigationTitle("Settings") - .navigationDestination( - item: $store.scope(state: \.logViewer, action: \.logViewer) - ) { logStore in - LogViewerFeatureView(store: logStore) - } .refreshable { store.send(.refreshWindowStates) } .onAppear { store.send(.onAppear) } + } destination: { pathStore in + switch pathStore.case { + case let .commandLog(commandLogStore): + ActionsView(store: commandLogStore) + case let .logs(logStore): + LogViewerFeatureView(store: logStore) + } } } @@ -266,7 +255,7 @@ struct SettingsView: View { Text(store.serverURL.absoluteString.trimmingCharacters(in: CharacterSet(charactersIn: "/"))) .font(.caption) - .foregroundColor(.secondary) + .foregroundStyle(.secondary) } if store.isEditingServerURL { @@ -302,22 +291,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/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/Sources/Controller/Shared/RunViews.swift b/Sources/Controller/Shared/RunViews.swift new file mode 100644 index 00000000..8f6d4f2c --- /dev/null +++ b/Sources/Controller/Shared/RunViews.swift @@ -0,0 +1,89 @@ +// +// RunViews.swift +// Controller +// + +import HAModels +import SwiftUI + +/// 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 { + 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) + } +} + +extension StatusLabel { + init(outcome: AutomationRun.Outcome) { + switch outcome { + 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", 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) + } + } +} + +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() + StatusLabel(outcome: run.outcome) + } + // Without this the separator starts at the status label, the row's last text. + .alignmentGuide(.listRowSeparatorLeading) { $0[.leading] } + } +} + +struct ActionRow: View { + let item: ActionLogItem + + var body: some View { + HStack { + VStack(alignment: .leading, spacing: 2) { + Text(item.detailDescription) + 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 { + StatusLabel(status: status) + } + } + .alignmentGuide(.listRowSeparatorLeading) { $0[.leading] } + } +} diff --git a/Sources/Controller/Shared/SharedKeys.swift b/Sources/Controller/Shared/SharedKeys.swift index dfc138de..d35e314d 100644 --- a/Sources/Controller/Shared/SharedKeys.swift +++ b/Sources/Controller/Shared/SharedKeys.swift @@ -34,6 +34,19 @@ extension SharedKey where Self == AppStorageKey.Default { } } +extension SharedKey where Self == AppStorageKey.Default { + static var automationGrouping: Self { + Self[.appStorage("automationGrouping", store: .standard), default: .status] + } +} + +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/HAApplicationLayer/AutomationService.swift b/Sources/HAApplicationLayer/AutomationService.swift index 4be67aca..8134b3a3 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,21 @@ public actor AutomationService { } self.log.info("Running automation \(automation.name)") + 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 automation.execute(using: self.homeManager) + try await AutomationRunContext.$runId.withValue(runId) { + try await automation.execute(using: self.homeManager) + } + await self.finish(await recording.value, 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(await recording.value, outcome: stopped ? .stopped : .superseded) } catch { self.log.error("Automation failed with error - \(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 @@ -65,7 +79,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 +92,36 @@ public actor AutomationService { runningTasks[id] = task } + + private func consumeStopRequest(for name: String) -> Bool { + stopRequested.remove(name) != 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(id: runId, automationName: automation.name, startedAt: startedAt, trigger: trigger, outcome: .running) + do { + try await runs.add(run) + return runId + } catch { + log.error("Failed to record automation run - \(error)") + return nil + } + } + + private func finish(_ runId: UUID?, outcome: AutomationRun.Outcome, error: String? = nil) async { + guard let runId else { return } + do { + try await runs.finish(runId, outcome: outcome, endedAt: Date(), errorDescription: error) + } catch { + log.error("Failed to finish automation run - \(error)") + } + } } diff --git a/Sources/HAApplicationLayer/HomeManager.swift b/Sources/HAApplicationLayer/HomeManager.swift index 5628a654..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") } @@ -234,12 +237,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) } @@ -275,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/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 205d26bc..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 @@ -23,9 +24,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/HAImplementations/Automations/MotionAtNight.swift b/Sources/HAImplementations/Automations/MotionAtNight.swift index c6ea25e6..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 { - // threshold under which the automation should be triggered - private 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,11 +72,21 @@ 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 } + 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/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/Automation/Automatable.swift b/Sources/HAModels/Automation/Automatable.swift index d4235609..5d8d774f 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)) } @@ -48,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 { @@ -57,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/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/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 +} 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/EntityHistoryItem.swift b/Sources/HAModels/EntityHistoryItem.swift index 18f09d38..70e46690 100644 --- a/Sources/HAModels/EntityHistoryItem.swift +++ b/Sources/HAModels/EntityHistoryItem.swift @@ -75,6 +75,97 @@ 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" + } +} + +/// 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/History/DailyTotals.swift b/Sources/HAModels/History/DailyTotals.swift new file mode 100644 index 00000000..2a4ba2b0 --- /dev/null +++ b/Sources/HAModels/History/DailyTotals.swift @@ -0,0 +1,52 @@ +// +// 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) }) + } + } + + /// 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.. 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/Sources/HAModels/HomeManagable.swift b/Sources/HAModels/HomeManagable.swift index 1dff7b4b..9ac97c64 100644 --- a/Sources/HAModels/HomeManagable.swift +++ b/Sources/HAModels/HomeManagable.swift @@ -28,14 +28,13 @@ 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 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/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/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/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/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..6e81d42d 100644 --- a/Sources/Server/Controllers/OpenAPIController.swift +++ b/Sources/Server/Controllers/OpenAPIController.swift @@ -18,22 +18,41 @@ 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.involvedEntityIds.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 { 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 +62,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 } @@ -131,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) - - // 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) + 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 + } + + 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))) @@ -236,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 @@ -281,4 +278,63 @@ 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 { + 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) + } +} + +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/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/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/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/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..663eee08 100644 --- a/Sources/Server/configure.swift +++ b/Sources/Server/configure.swift @@ -112,6 +112,8 @@ public func configure(_ app: Application) async throws { app.migrations.add(CreateEntityStorageDbItem()) app.migrations.add(DeviceTokenItem()) app.migrations.add(AddSensorFields()) + app.migrations.add(CreateConfigItem()) + app.migrations.add(CreateAutomationRun()) // Run migrations automatically try await app.autoMigrate() @@ -208,7 +210,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) @@ -225,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 }) @@ -240,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..8a416574 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)) @@ -35,12 +39,43 @@ 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)) } } + /// `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 + } + + /// 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)) + 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 @@ -77,8 +112,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 { @@ -95,7 +130,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) }) } } @@ -126,7 +163,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, @@ -136,41 +174,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 { @@ -185,3 +212,57 @@ 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) + } +} + +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/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/AppFeatureTests.swift b/Tests/ControllerTests/AppFeatureTests.swift index 59c354de..396cb217 100644 --- a/Tests/ControllerTests/AppFeatureTests.swift +++ b/Tests/ControllerTests/AppFeatureTests.swift @@ -9,232 +9,33 @@ 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: { $0.serverClient = .previewValue + $0.date.now = Date(timeIntervalSince1970: 0) $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 +74,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 +97,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/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/AutomationsFeatureTests.swift b/Tests/ControllerTests/AutomationsFeatureTests.swift new file mode 100644 index 00000000..9da7cf88 --- /dev/null +++ b/Tests/ControllerTests/AutomationsFeatureTests.swift @@ -0,0 +1,122 @@ +// +// AutomationsFeatureTests.swift +// ControllerTests +// + +import ComposableArchitecture +@testable import Controller +import Foundation +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() { + let 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() { + let 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.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") + 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))) + } +} diff --git a/Tests/ControllerTests/EntityHistoryDetailFeatureTests.swift b/Tests/ControllerTests/EntityHistoryDetailFeatureTests.swift new file mode 100644 index 00000000..d7abf444 --- /dev/null +++ b/Tests/ControllerTests/EntityHistoryDetailFeatureTests.swift @@ -0,0 +1,88 @@ +// +// 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] + } + } + + @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/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)") + } +} 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) diff --git a/Tests/ControllerTests/OverviewFeatureTests.swift b/Tests/ControllerTests/OverviewFeatureTests.swift new file mode 100644 index 00000000..ecb548be --- /dev/null +++ b/Tests/ControllerTests/OverviewFeatureTests.swift @@ -0,0 +1,154 @@ +// +// OverviewFeatureTests.swift +// ControllerTests +// + +import ComposableArchitecture +@testable import Controller +import Foundation +import HAModels +import Synchronization +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("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() + 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("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() + 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() + } + store.exhaustivity = .off + + await store.send(\.overview.path[id: 0].run.openAutomationButtonTapped) + 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") + } +} 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 + } + } +} 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() } } 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/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) + } +} diff --git a/Tests/HomeAutomationKitTests/AutomationServiceTests.swift b/Tests/HomeAutomationKitTests/AutomationServiceTests.swift new file mode 100644 index 00000000..388b4ee6 --- /dev/null +++ b/Tests/HomeAutomationKitTests/AutomationServiceTests.swift @@ -0,0 +1,155 @@ +// +// 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") + } + + /// 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) })) + + 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/HelperTests/ChartPreparationTests.swift b/Tests/HomeAutomationKitTests/HelperTests/ChartPreparationTests.swift new file mode 100644 index 00000000..d4b29705 --- /dev/null +++ b/Tests/HomeAutomationKitTests/HelperTests/ChartPreparationTests.swift @@ -0,0 +1,137 @@ +// +// 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]) + } +} + +struct DailyStatsTests { + @Test func meanIsWeightedByHowLongEachValueHeld() throws { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = try #require(TimeZone(identifier: "UTC")) + let day = Date(timeIntervalSince1970: 86_400) + // 400 from the previous day until 06:00, then 1000 until the end at 12:00. + let samples = [(date: day.addingTimeInterval(6 * 3600), value: 1_000.0), (date: day.addingTimeInterval(-3600), value: 400.0)] + + let stats = DailyTotals.dailyStats(samples, days: [day.addingTimeInterval(-86_400), day], end: day.addingTimeInterval(12 * 3600), calendar: calendar) + + #expect(stats.count == 2) + #expect(stats[1].day == day) + #expect(stats[1].max == 1_000) + #expect(stats[1].mean == 700) + } +} 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/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/InMemoryAutomationRunRepository.swift b/Tests/HomeAutomationKitTests/InMemoryAutomationRunRepository.swift new file mode 100644 index 00000000..2d7abfd3 --- /dev/null +++ b/Tests/HomeAutomationKitTests/InMemoryAutomationRunRepository.swift @@ -0,0 +1,67 @@ +// +// InMemoryAutomationRunRepository.swift +// HomeAutomationKit +// + +import Foundation +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? { + var iterator = finished.stream.makeAsyncIterator() + return await iterator.next() + } + + /// 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 + } + + 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/MockHomeAdapter.swift b/Tests/HomeAutomationKitTests/MockHomeAdapter.swift index 185509f9..e19814e7 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 { } @@ -155,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/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/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/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/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/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/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]) + } +} diff --git a/openapi.yaml b/openapi.yaml index 82287249..e8394c87 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 @@ -127,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. @@ -237,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. @@ -247,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: @@ -261,11 +395,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: @@ -326,6 +516,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 @@ -375,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: