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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ Most services are `@Observable @MainActor` classes passed through the SwiftUI en
| `HomeService` | `Services/Home/` | Home / Room CRUD, primary-home promotion |
| `DeviceService` | `Services/Device/` | PlannedDevice CRUD; routes `SceneActionSnapshot` to the right bridge |
| `DeviceStateStore` | `Services/Device/` | In-memory live state for all connected devices — rebuilt from bridges on each launch, never persisted |
| `SceneService` | `Services/Scene/` | Scene CRUD, execution, geofence-triggered automation. Owns a cancellable `monitoringTask` for the geofence poller |
| `SceneService` | `Services/Scene/` | Scene CRUD, execution, geofence-triggered + daily-schedule automation. Owns cancellable `monitoringTask` (geofence poller) and `scheduleTask` (schedule poller) |
| `LocationService` | `Services/` | CLLocationManager wrapper; publishes `GeofenceEvent` when the user crosses the home radius. Gates first-check event emission via `hasCompletedFirstCheck` so launching at home does not fire a spurious arrival |
| `NotificationService` | `Services/` | UNUserNotificationCenter wrapper; called by `SceneService` after automation fires |
| `SensorObservationService` | `Services/Intelligence/` | Subscribes to motion/contact `AsyncStream`s from all capable devices. Wired in `RootView` via `DeviceStateStore.onDevicesDiscovered/onDevicesRemoved` |
Expand Down Expand Up @@ -131,6 +131,7 @@ A convention: when view code carries non-trivial logic, lift it into a pure `str
| `ReasoningCalculator` | `LumenReasoningView` | Signal list + suggestion label from ambient state (incl. confidence / habit signals from the scored layer) |
| `SuggestionEngine` | `HomeDashboardView` | Scored-heuristic ranking of scenes → the "Lumen noticed" suggestion + explainable factors (see below) |
| `SceneService.scenesMatching(event:in:)` | `SceneService.handleGeofenceEvent` | Pure routing — which scenes fire for a given event |
| `ScheduleTiming` / `SceneService.scenesDue(at:in:lastFired:)` | `SceneService` schedule poller | Pure: whether a daily-schedule scene is due now (grace window, no same-day refire, midnight-safe) |

Follow this pattern for new feature work.

Expand Down Expand Up @@ -214,6 +215,10 @@ The flag defaults to `true`. Tests rely on it indirectly: `DeviceService.addPlan
- During tests, `RootView` skips registering the HomeKit bridge (`if ProcessInfo.processInfo.environment["XCTestConfigurationFilePath"] == nil`). The poller still starts but has nothing to react to.
- Arrival and departure events trigger a status overlay in `HomeDashboardView` (e.g., “🏠 Welcome Home!” or “🌙 Away Mode”) to inform the user of the detected state change.

#### Schedule automation

A scene can carry a daily schedule (`Scene.scheduleMinutesSinceMidnight: Int?`, nullable → inferred migration, no schema bump). `SceneService.startMonitoringScheduledScenes()` runs a `scheduleTask` poller (30 s tick) that fires due scenes via the same `execute(_:)` path and posts a notification. **Scheduled scenes fire directly** — the user set the time ahead of time, which is the consent — a deliberate decision logged in `ROADMAP.md` (distinct from the forbidden auto-apply-on-a-hunch; inferred `SuggestionEngine` suggestions stay confirmation-gated). Purity lives in `ScheduleTiming` (grace window so a missed schedule doesn't fire stale; no same-day refire) and `SceneService.scenesDue(...)`. Authored in `SceneDetailView`'s automation section (toggle + `DatePicker`), persisted via `SceneService.setSchedule(...)`. The poller starts in `RootView.bootstrap` next to the geofence poller.

### Tests

The `LumenTests` target uses XCTest with an in-memory `ModelContainer` via `PersistenceCoordinator.makeInMemoryContainer()`. Tests are `@MainActor` where they touch services or view models.
Expand All @@ -236,6 +241,7 @@ Coverage groups (~195 tests at time of writing):
| `SceneApprovalTests` | Approval flow (request/cancel/confirm), `SceneActionDescription` humanization |
| `SceneActionBuilderTests` | Eligible-device filtering (read-only exclusion), sorted controllable-capability options, default payload per capability |
| `GeofenceRoutingTests` | `scenesMatching` routes events to correctly-triggered scenes |
| `ScheduleTests` | `ScheduleTiming` due-logic (grace window, no same-day refire, next-day, midnight) + `SceneService.scenesDue` routing |
| `RhythmTests` | `RhythmTiming` block math, midnight wrap |
| `ReasoningTests` | `ReasoningCalculator` signal generation, suggestion labels, confidence/habit signals from the scored layer |
| `SuggestionEngineTests` | `SuggestionEngine` scoring, surface threshold, ±1h midnight-wrap habit window, presence/geofence boost, deterministic ranking, explainable factors |
Expand Down
8 changes: 7 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ Most services are `@Observable @MainActor` classes passed through the SwiftUI en
| `HomeService` | `Services/Home/` | Home / Room CRUD, primary-home promotion |
| `DeviceService` | `Services/Device/` | PlannedDevice CRUD; routes `SceneActionSnapshot` to the right bridge |
| `DeviceStateStore` | `Services/Device/` | In-memory live state for all connected devices — rebuilt from bridges on each launch, never persisted |
| `SceneService` | `Services/Scene/` | Scene CRUD, execution, geofence-triggered automation. Owns a cancellable `monitoringTask` for the geofence poller |
| `SceneService` | `Services/Scene/` | Scene CRUD, execution, geofence-triggered + daily-schedule automation. Owns cancellable `monitoringTask` (geofence poller) and `scheduleTask` (schedule poller) |
| `LocationService` | `Services/` | CLLocationManager wrapper; publishes `GeofenceEvent` when the user crosses the home radius. Gates first-check event emission via `hasCompletedFirstCheck` so launching at home does not fire a spurious arrival |
| `NotificationService` | `Services/` | UNUserNotificationCenter wrapper; called by `SceneService` after automation fires |
| `SensorObservationService` | `Services/Intelligence/` | Subscribes to motion/contact `AsyncStream`s from all capable devices. Wired in `RootView` via `DeviceStateStore.onDevicesDiscovered/onDevicesRemoved` |
Expand Down Expand Up @@ -131,6 +131,7 @@ A convention: when view code carries non-trivial logic, lift it into a pure `str
| `ReasoningCalculator` | `LumenReasoningView` | Signal list + suggestion label from ambient state (incl. confidence / habit signals from the scored layer) |
| `SuggestionEngine` | `HomeDashboardView` | Scored-heuristic ranking of scenes → the "Lumen noticed" suggestion + explainable factors (see below) |
| `SceneService.scenesMatching(event:in:)` | `SceneService.handleGeofenceEvent` | Pure routing — which scenes fire for a given event |
| `ScheduleTiming` / `SceneService.scenesDue(at:in:lastFired:)` | `SceneService` schedule poller | Pure: whether a daily-schedule scene is due now (grace window, no same-day refire, midnight-safe) |

Follow this pattern for new feature work.

Expand Down Expand Up @@ -214,6 +215,10 @@ The flag defaults to `true`. Tests rely on it indirectly: `DeviceService.addPlan
- During tests, `RootView` skips registering the HomeKit bridge (`if ProcessInfo.processInfo.environment["XCTestConfigurationFilePath"] == nil`). The poller still starts but has nothing to react to.
- Arrival and departure events trigger a status overlay in `HomeDashboardView` (e.g., “🏠 Welcome Home!” or “🌙 Away Mode”) to inform the user of the detected state change.

#### Schedule automation

A scene can carry a daily schedule (`Scene.scheduleMinutesSinceMidnight: Int?`, nullable → inferred migration, no schema bump). `SceneService.startMonitoringScheduledScenes()` runs a `scheduleTask` poller (30 s tick) that fires due scenes via the same `execute(_:)` path and posts a notification. **Scheduled scenes fire directly** — the user set the time ahead of time, which is the consent — a deliberate decision logged in `ROADMAP.md` (distinct from the forbidden auto-apply-on-a-hunch; inferred `SuggestionEngine` suggestions stay confirmation-gated). Purity lives in `ScheduleTiming` (grace window so a missed schedule doesn't fire stale; no same-day refire) and `SceneService.scenesDue(...)`. Authored in `SceneDetailView`'s automation section (toggle + `DatePicker`), persisted via `SceneService.setSchedule(...)`. The poller starts in `RootView.bootstrap` next to the geofence poller.

### Tests

The `LumenTests` target uses XCTest with an in-memory `ModelContainer` via `PersistenceCoordinator.makeInMemoryContainer()`. Tests are `@MainActor` where they touch services or view models.
Expand All @@ -236,6 +241,7 @@ Coverage groups (~195 tests at time of writing):
| `SceneApprovalTests` | Approval flow (request/cancel/confirm), `SceneActionDescription` humanization |
| `SceneActionBuilderTests` | Eligible-device filtering (read-only exclusion), sorted controllable-capability options, default payload per capability |
| `GeofenceRoutingTests` | `scenesMatching` routes events to correctly-triggered scenes |
| `ScheduleTests` | `ScheduleTiming` due-logic (grace window, no same-day refire, next-day, midnight) + `SceneService.scenesDue` routing |
| `RhythmTests` | `RhythmTiming` block math, midnight wrap |
| `ReasoningTests` | `ReasoningCalculator` signal generation, suggestion labels, confidence/habit signals from the scored layer |
| `SuggestionEngineTests` | `SuggestionEngine` scoring, surface threshold, ±1h midnight-wrap habit window, presence/geofence boost, deterministic ranking, explainable factors |
Expand Down
14 changes: 13 additions & 1 deletion Lumen/Domain/Models/Automation/Scene.swift
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,10 @@ final class Scene {
var sortOrder: Int
var isFavorite: Bool
var geofenceTrigger: GeofenceTrigger
// Daily schedule as minutes since local midnight (0…1439). `nil` means the
// scene has no schedule. Optional, so SwiftData infers a lightweight
// nullable-column migration (no schema-version bump), like Home.latitude.
var scheduleMinutesSinceMidnight: Int?
var createdAt: Date
var updatedAt: Date

Expand All @@ -38,19 +42,27 @@ final class Scene {
name: String,
iconName: String = "sparkles",
sortOrder: Int = 0,
geofenceTrigger: GeofenceTrigger = .none
geofenceTrigger: GeofenceTrigger = .none,
scheduleMinutesSinceMidnight: Int? = nil
) {
self.id = id
self.name = name
self.iconName = iconName
self.sortOrder = sortOrder
self.geofenceTrigger = geofenceTrigger
self.scheduleMinutesSinceMidnight = scheduleMinutesSinceMidnight
self.isFavorite = false
self.createdAt = Date()
self.updatedAt = Date()
self.actions = []
}

/// The scene's schedule as (hour, minute), or nil when unscheduled.
var scheduledTime: (hour: Int, minute: Int)? {
guard let minutes = scheduleMinutesSinceMidnight, (0..<1440).contains(minutes) else { return nil }
return (minutes / 60, minutes % 60)
}

func asSnapshots() -> [SceneActionSnapshot] {
actions
.sorted { $0.sortOrder < $1.sortOrder }
Expand Down
3 changes: 3 additions & 0 deletions Lumen/Features/RootView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,9 @@ struct RootView: View {

// Start geofence event monitoring
sceneService.startMonitoringGeofenceEvents(from: locationService)

// Start daily-schedule monitoring (scheduled scenes fire directly)
sceneService.startMonitoringScheduledScenes()

if ProcessInfo.processInfo.environment["XCTestConfigurationFilePath"] == nil {
let hkBridge = HomeKitBridge()
Expand Down
69 changes: 69 additions & 0 deletions Lumen/Features/Scenes/SceneDetailView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@ struct SceneDetailView: View {
@State private var isShowingAddAction = false
@State private var editName: String = ""
@State private var selectedGeofenceTrigger: GeofenceTrigger = .none
@State private var isScheduled: Bool = false
@State private var scheduleTime: Date = Calendar.current.date(
bySettingHour: 21, minute: 0, second: 0, of: Date()
) ?? Date()

var body: some View {
ZStack {
Expand All @@ -32,6 +36,14 @@ struct SceneDetailView: View {
.onAppear {
editName = scene.name
selectedGeofenceTrigger = scene.geofenceTrigger
if let (hour, minute) = scene.scheduledTime {
isScheduled = true
scheduleTime = Calendar.current.date(
bySettingHour: hour, minute: minute, second: 0, of: Date()
) ?? scheduleTime
} else {
isScheduled = false
}
}
.sheet(isPresented: $isShowingAddAction) {
AddSceneActionSheet(
Expand Down Expand Up @@ -131,7 +143,54 @@ struct SceneDetailView: View {
}
}
.background(Color.white.opacity(0.06), in: RoundedRectangle(cornerRadius: 16))

scheduleCard
}
}

private var scheduleCard: some View {
VStack(spacing: 0) {
Toggle(isOn: $isScheduled) {
Label("Run on a schedule", systemImage: "clock.fill")
.font(.system(size: 15, weight: .medium))
.foregroundStyle(.white)
}
.tint(Color(hex: "#C49A6C"))
.onChange(of: isScheduled) { _, enabled in
// Ignore the toggle flip that comes from onAppear syncing to the model.
guard enabled != (scene.scheduleMinutesSinceMidnight != nil) else { return }
viewModel.setSchedule(
minutesSinceMidnight: enabled ? Self.minutes(from: scheduleTime) : nil,
on: scene
)
}
.padding(16)

if isScheduled {
Divider().overlay(Color.white.opacity(0.08))
DatePicker(
"Time",
selection: $scheduleTime,
displayedComponents: .hourAndMinute
)
.datePickerStyle(.compact)
.tint(Color(hex: "#C49A6C"))
.foregroundStyle(.white)
.onChange(of: scheduleTime) { _, newValue in
let mins = Self.minutes(from: newValue)
guard mins != scene.scheduleMinutesSinceMidnight else { return }
viewModel.setSchedule(minutesSinceMidnight: mins, on: scene)
}
.padding(16)

Divider().overlay(Color.white.opacity(0.08))
Text(scheduleHint)
.font(.system(size: 13))
.foregroundStyle(Color.white.opacity(0.45))
.padding(16)
}
}
.background(Color.white.opacity(0.06), in: RoundedRectangle(cornerRadius: 16))
}

private var geofenceHint: String {
Expand All @@ -145,6 +204,16 @@ struct SceneDetailView: View {
}
}

private var scheduleHint: String {
let formatted = scheduleTime.formatted(date: .omitted, time: .shortened)
return "Runs automatically at \(formatted) every day. It fires on its own — you'll get a notification each time."
}

private static func minutes(from date: Date) -> Int {
let comps = Calendar.current.dateComponents([.hour, .minute], from: date)
return (comps.hour ?? 0) * 60 + (comps.minute ?? 0)
}

// MARK: - Actions

private var actionsSection: some View {
Expand Down
9 changes: 9 additions & 0 deletions Lumen/Features/Scenes/SceneViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,15 @@ final class SceneViewModel {
}
}

/// Sets (minutes since midnight) or clears (nil) a scene's daily schedule.
func setSchedule(minutesSinceMidnight: Int?, on scene: Scene) {
do {
try sceneService.setSchedule(minutesSinceMidnight: minutesSinceMidnight, on: scene)
} catch {
self.error = error
}
}

func addAction(
to scene: Scene,
deviceID: UUID,
Expand Down
3 changes: 3 additions & 0 deletions Lumen/Services/NotificationService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@ final class NotificationService {
case "departure":
content.subtitle = "You left home"
content.body = "'\(sceneName)' scene activated for \(deviceCount) device\(deviceCount == 1 ? "" : "s")"
case "schedule":
content.subtitle = "Ran on schedule"
content.body = "'\(sceneName)' ran on schedule — you set this up. Tap to adjust."
default:
content.body = "'\(sceneName)' scene activated"
}
Expand Down
80 changes: 80 additions & 0 deletions Lumen/Services/Scene/SceneService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ final class SceneService {
private(set) var lastAutoExecutionEvent: (scene: Scene, event: GeofenceEvent)?

private var monitoringTask: Task<Void, Never>?
private var scheduleTask: Task<Void, Never>?
private var lastScheduledFire: [UUID: Date] = [:]

init(modelContext: ModelContext, deviceService: DeviceService) {
self.modelContext = modelContext
Expand Down Expand Up @@ -92,6 +94,70 @@ final class SceneService {
}
}

// MARK: - Schedule Monitoring
// Scheduled scenes fire directly — the user set the time ahead of time, which
// is the consent (see ROADMAP.md "Competitive watch"). Every fire posts a
// notification so the automation stays explainable and reversible.

func startMonitoringScheduledScenes(tick: Duration = .seconds(30), grace: TimeInterval = 120) {
scheduleTask?.cancel()
scheduleTask = Task {
while !Task.isCancelled {
await handleScheduledFires(now: Date(), grace: grace)
try? await Task.sleep(for: tick)
}
}
}

func stopMonitoringScheduledScenes() {
scheduleTask?.cancel()
scheduleTask = nil
}

private func handleScheduledFires(now: Date, grace: TimeInterval) async {
let descriptor = FetchDescriptor<Scene>()
guard let scenes = try? modelContext.fetch(descriptor) else { return }

let due = Self.scenesDue(at: now, in: scenes, lastFired: lastScheduledFire, grace: grace)
for scene in due {
lastScheduledFire[scene.id] = now
do {
try await execute(scene)
NotificationService.shared.notifyAutomationExecuted(
sceneName: scene.name,
eventType: "schedule",
deviceCount: scene.actions.count
)
} catch {
NotificationService.shared.notifyAutomationFailed(
sceneName: scene.name,
reason: error.localizedDescription
)
}
}
}

// Pure routing: which scenes are due to fire at `now`, given when each last
// fired. Testable without SwiftData, timers, or notifications.
static func scenesDue(
at now: Date,
in scenes: [Scene],
lastFired: [UUID: Date],
grace: TimeInterval = 120,
calendar: Calendar = .current
) -> [Scene] {
scenes.filter { scene in
guard let minutes = scene.scheduleMinutesSinceMidnight else { return false }
return ScheduleTiming.isDue(
minutesSinceMidnight: minutes,
now: now,
lastFired: lastFired[scene.id],
grace: grace,
calendar: calendar
)
}
}

// MARK: - Scene CRUD

@discardableResult
Expand Down Expand Up @@ -147,6 +213,20 @@ final class SceneService {
try modelContext.save()
}

/// Sets (or clears, with `nil`) a scene's daily schedule. `minutesSinceMidnight`
/// is clamped to a valid time of day. Clears the last-fired record so a newly
/// set time can still fire today.
func setSchedule(minutesSinceMidnight: Int?, on scene: Scene) throws {
if let minutes = minutesSinceMidnight {
scene.scheduleMinutesSinceMidnight = min(max(minutes, 0), 1439)
} else {
scene.scheduleMinutesSinceMidnight = nil
}
lastScheduledFire[scene.id] = nil
scene.updatedAt = Date()
try modelContext.save()
}

func deleteScene(_ scene: Scene) throws {
modelContext.delete(scene)
try modelContext.save()
Expand Down
Loading
Loading