-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathMotionAtNight.swift
More file actions
174 lines (152 loc) · 7.32 KB
/
Copy pathMotionAtNight.swift
File metadata and controls
174 lines (152 loc) · 7.32 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
//
// MotionAtNight.swift
//
//
// Created by Julian Kahnert on 01.07.24.
//
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
public private(set) var noMotionWait: Duration = .seconds(60)
/// Time to keep lights dimmed before turning them off completely
public private(set) var dimWait: Duration = .seconds(10)
/// Time to wait between turning on lights and setting color temperature to prevent flickering
public private(set) var colorTemperatureDelay: Duration = .seconds(1)
public let motionSensors: [MotionSensorDevice]
public let lightSensor: MotionSensorDevice
public let windowContacts: [ContactSensorDevice]
public let minBrightness: Float
public let maxBrightness: Float
public let maxTemperature: Float
public let lights: [SwitchDevice]
public var triggerEntityIds: Set<EntityId> {
var ids = Set(motionSensors.map(\.motionSensorId) + windowContacts.map(\.contactSensorId))
if let lightSensorId = lightSensor.lightSensorId {
ids.insert(lightSensorId)
}
return ids
}
public init(_ name: String, noMotionWait: Duration? = nil, dimWait: Duration? = nil, colorTemperatureDelay: Duration? = nil, motionSensors: [MotionSensorDevice], lightSensor: MotionSensorDevice, lights: [SwitchDevice], windowContacts: [ContactSensorDevice] = [], minBrightness: Float, maxBrightness: Float = 1, maxTemperature: Float = 1) {
self.name = name
if let noMotionWait { self.noMotionWait = noMotionWait }
if let dimWait { self.dimWait = dimWait }
if let colorTemperatureDelay { self.colorTemperatureDelay = colorTemperatureDelay }
self.motionSensors = motionSensors
self.lightSensor = lightSensor
self.lights = lights
self.windowContacts = windowContacts
self.minBrightness = minBrightness
self.maxBrightness = maxBrightness
self.maxTemperature = maxTemperature
}
public func shouldTrigger(with event: HomeEvent, using hm: HomeManagable) async throws -> Bool {
let sensorIds = Set(motionSensors.map(\.motionSensorId)).union(windowContacts.map(\.contactSensorId))
guard case let HomeEvent.change(item) = event,
sensorIds.contains(item.entityId) else {
return false
}
// was any motion sensor triggered
let motionDetected = await motionSensors.asyncMap({ motionSensor in
do {
return try await motionSensor.motionDetectedState(with: hm)
} catch {
log.warning("Failed to get motion sensor data - \(error)")
return false
}
}).contains { $0 }
var illuminance: Measurement<UnitIlluminance>?
do {
illuminance = try await lightSensor.illuminanceState(with: hm)
} catch {
log.warning("Failed to get illuminance state - \(error)")
}
guard let illuminance else { return false }
let shouldTrigger = motionDetected && illuminance.converted(to: .lux).value < Self.thresholdInLux
log.debug("Should trigger [\(shouldTrigger)] - [motion: \(motionDetected), \(illuminance)]")
return shouldTrigger
}
/// 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.
func shouldRespectWindowContacts(at date: Date, location: Location) -> Bool {
guard let isSunBelowHorizon = Sun.isSunBelowHorizon(for: date, latitude: location.latitude, longitude: location.longitude) else {
log.warning("Failed to determine sun position - respecting window contacts")
return true
}
return isSunBelowHorizon
}
public func execute(using hm: HomeManagable) async throws {
let location = await hm.getLocation()
let isWindowOpen: Bool
if shouldRespectWindowContacts(at: Date(), location: location) {
isWindowOpen = await windowContacts.asyncMap({ windowSensor in
do {
return try await windowSensor.isContactOpen(with: hm)
} catch {
log.warning("Failed to get contact sensor - \(error)")
return false
}
}).contains { $0 }
} else {
isWindowOpen = false
}
let colorTemperatureValue = getNormalizedColorTemperatureValue().scale(to: 0.1...maxTemperature)
let brightnessValue = getNormalizedBrightnessValue().scale(to: minBrightness...maxBrightness)
if !isWindowOpen {
log.debug("Adjusting lights")
// We reduce the number of invocations/calls to the device to avoid flickering of it.
// Note: First set brightness, then color temperature
await withTaskGroup(of: Void.self) { group in
// Each light runs its own sequence: turn on -> wait -> set color temperature
for light in lights {
group.addTask {
// Turn on the light
if light.brightnessId != nil {
await light.setBrightness(to: brightnessValue, with: hm)
} else {
await light.turnOn(with: hm)
}
// Wait before adjusting color temperature to prevent flickering
try? await Task.sleep(for: colorTemperatureDelay)
// Set color temperature if supported
if light.hasColorTemperatureSupport {
await light.setColorTemperature(to: colorTemperatureValue, with: hm)
}
}
}
// Wait for all lights to complete their sequence before proceeding
await group.waitForAll()
}
// wait for x seconds or until this task wil be suspended
try await Task.sleep(for: noMotionWait)
}
// dim lights before turning them off
log.debug("Dimming lights before turning them off")
await withTaskGroup(of: Void.self) { group in
for light in lights {
group.addTask {
// do not change the brightness, if it is currently turned off
guard (try? await hm.getCurrentEntity(with: light.switchId).isDeviceOn ?? true) == true else { return }
await light.setBrightness(to: min(0.05, brightnessValue), with: hm)
}
}
await group.waitForAll()
}
try await Task.sleep(for: dimWait)
// turn off lights in parallel
await withTaskGroup(of: Void.self) { group in
for light in lights {
group.addTask {
log.debug("Turn off device \(light.switchId)")
await light.turnOff(with: hm)
}
}
await group.waitForAll()
}
}
}