-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathLidAngleSource.swift
More file actions
391 lines (350 loc) · 17.3 KB
/
Copy pathLidAngleSource.swift
File metadata and controls
391 lines (350 loc) · 17.3 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
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
#if !RAW_SWIFTC_LUMP
import YameteCore
#endif
@preconcurrency import Foundation
@preconcurrency import IOKit
@preconcurrency import IOKit.hid
import os
// MARK: - LidAngleSource — direct-publish reaction source for lid hinge angle
//
// Apple ships hinge angle on a dedicated HID device, NOT on the SPU
// IMU stream. There is exactly ONE source on Apple Silicon — no SMC
// fallback (the SMC plane was retired with the M-series transition,
// `LIDA`/`MSLD` keys do not exist), no public `CMHinge` CoreMotion
// type, no IOPlatform property. Three independent open-source
// decoders agree byte-for-byte (samhenrigold/LidAngleSensor,
// deepakness/LidAngle, wangfu91/lid-angle-rs):
//
// • IOHID match: VendorID 0x05AC, ProductID 0x8104,
// UsagePage 0x0020 (HID Sensors), Usage 0x008A.
// • Transport: Feature report, ReportID = 1, fetched on demand via
// `IOHIDDeviceGetReport(.., kIOHIDReportTypeFeature, 1, ..)`. Not
// an Input Report stream — there is no callback, just a poll. An
// input-report streamed shape exists (olvvier/macimu) but needs
// root + a property-set wake sequence, so we use the polled feature
// report path.
// • Layout: `[reportID, angle_lo, angle_hi]` (length ≥ 3); first byte
// echoes the report ID `0x01`.
// • Decode: `UInt16 LE` at bytes [1..2], unsigned, **already in whole
// degrees** — no Int16, no `÷100`, no sign extension.
// • Range: 0..~135°, 1° resolution. Physical clamshell limit is well
// under 180° on every shipping MacBook.
//
// Per-model coverage table. The runtime probe (match dict + feature-
// report read) is the source of truth — this table is documentation
// of expected coverage, not a dispatch table. New silicon respins are
// frequent enough that hardcoding identifiers rots quickly.
//
// ╭──────────────────────────────────────────╥─────────────────────╮
// │ Hardware ║ Lid HID strategy │
// ╞══════════════════════════════════════════╬═════════════════════╡
// │ M1 MacBook Air (MacBookAir10,1) ║ none — no sensor │
// │ M1 13" MacBook Pro (MacBookPro17,1) ║ none — no sensor │
// │ M1 Pro/Max 14"/16" MacBook Pro ║ HID 0x0020/0x008A │
// │ M2 base MacBook Air (Mac14,2) ║ device on wrong │
// │ M2 base 13" MBP (Mac14,7) ║ usage page 0xFF00 │
// │ ║ → match fails → │
// │ ║ none │
// │ M2 Pro/Max 14"/16" MBP ║ HID 0x0020/0x008A │
// │ M3 / M3 Pro / M3 Max — Air & Pro ║ HID 0x0020/0x008A │
// │ M4 / M4 Pro / M4 Max — Air & Pro ║ HID 0x0020/0x008A │
// │ Mac mini / iMac / Mac Studio / Mac Pro ║ no clamshell │
// ╰──────────────────────────────────────────╨─────────────────────╯
//
// On models where the strategy is "none," `isAvailable` returns false,
// the Stimuli > Lid Angle menu row hides, and the source is never
// started. There is no second mechanism to fall back to.
//
// Earlier revisions of this source subscribed to the SPU broker for
// `usagePage 0xFF00 / usage 8` and decoded an `Int16 LE / 100` at byte
// offset 18 of the 22-byte report. That channel was wrong on every
// count — `0xFF00 / usage 3` is accel, `0xFF00 / usage 9` is gyro,
// `0xFF00 / usage 5` is ALS, and offsets 6/10/14 (3× Int32 LE) consume
// the entire IMU payload. Bytes 18..21 are uninitialized tail. The old
// decoder produced negative-half garbage that drove the slam state
// machine into a loop; the wire format documented above is the actual
// source of truth.
private let log = AppLog(category: "LidAngleSource")
// MARK: - HID driver seam
/// Abstraction over the dedicated lid-angle HID device. Polled, not
/// pushed — the macOS lid sensor surfaces hinge angle as a Feature
/// Report fetched via `IOHIDDeviceGetReport`. Production wires
/// `RealLidAngleHIDDriver`; tests inject angle traces directly through
/// the source's `_testInjectAngle` seam and pair it with a no-op
/// driver so no IOKit machinery is touched.
public protocol LidAngleHIDDriver: Sendable {
/// True when a matching lid-angle device is present in the
/// IORegistry. Cheap — a `IOServiceGetMatchingServices` walk and
/// an iterator drain.
var isHardwarePresent: Bool { get }
/// Read one Feature Report and return the decoded angle in
/// degrees. Returns `nil` when no device is open or the read
/// fails (transient — caller polls again).
func readAngleDeg() -> Double?
}
/// Production driver. Opens an `IOHIDManager` matching the dedicated
/// lid-angle device, retains the first matched device, and answers
/// `readAngleDeg()` by fetching Feature Report 1.
public final class RealLidAngleHIDDriver: LidAngleHIDDriver, @unchecked Sendable {
private static let vendorID: Int = 0x05AC
private static let usagePage: Int = 0x0020
private static let usage: Int = 0x008A
private static let reportID: CFIndex = 1
private static let reportSize: Int = 8
private struct State {
var manager: IOHIDManager?
var device: IOHIDDevice?
}
private let state = OSAllocatedUnfairLock<State>(initialState: State())
public init() {}
deinit {
state.withLock { s in
if let device = s.device {
IOHIDDeviceClose(device, IOOptionBits(kIOHIDOptionsTypeNone))
}
if let manager = s.manager {
IOHIDManagerClose(manager, IOOptionBits(kIOHIDOptionsTypeNone))
}
s.manager = nil
s.device = nil
}
}
public var isHardwarePresent: Bool {
// Productive side-effect: matching, opening, and caching the
// device here also primes `readAngleDeg()` for the lifetime
// of the driver.
return state.withLock { s in
if s.device != nil { return true }
return Self.resolveAndOpen(into: &s)
}
}
public func readAngleDeg() -> Double? {
return state.withLock { s in
// Lazy resolve — caller may invoke `readAngleDeg()` before
// ever asking `isHardwarePresent`.
if s.device == nil {
_ = Self.resolveAndOpen(into: &s)
}
guard let device = s.device else { return nil }
var buffer = [UInt8](repeating: 0, count: Self.reportSize)
var length: CFIndex = CFIndex(Self.reportSize)
let result = buffer.withUnsafeMutableBufferPointer { bp -> IOReturn in
IOHIDDeviceGetReport(device, kIOHIDReportTypeFeature, Self.reportID, bp.baseAddress!, &length)
}
guard result == kIOReturnSuccess, length >= 3 else {
log.debug("activity:HIDGetReport result=\(String(format:"0x%08X", UInt32(bitPattern: result))) length=\(length)")
return nil
}
// First byte echoes the report ID. A mismatch indicates we
// matched a device that responds to feature-report 1 with
// an unrelated payload — defensive guard, not expected to
// trigger because the match dict already filters on
// UsagePage 0x0020 / Usage 0x008A.
guard buffer[0] == UInt8(Self.reportID) else {
log.warning("entity:LidAngleHIDDriver wasInvalidatedBy activity:ReportIDMismatch reportID=\(buffer[0])")
return nil
}
// UInt16 LE at bytes [1..2]; unsigned; whole degrees.
let raw = UInt16(buffer[1]) | (UInt16(buffer[2]) << 8)
return Double(raw)
}
}
/// Build the IOHIDManager (if missing), match on the lid device,
/// pick the first hit, and open it for Feature-report I/O. Caches
/// the manager and device on the state struct. Returns true when
/// a device was successfully opened.
///
/// `IOHIDManagerOpen` opens the manager scope; it does NOT
/// implicitly open each matched device for I/O. `IOHIDDeviceGetReport`
/// against an unopened device returns `kIOReturnNotPermitted`
/// (0xE00002C2) silently, which is the failure mode that surfaced
/// on first deploy.
private static func resolveAndOpen(into s: inout State) -> Bool {
let manager = s.manager ?? makeManager()
s.manager = manager
guard let device = firstMatchedDevice(in: manager) else { return false }
let openResult = IOHIDDeviceOpen(device, IOOptionBits(kIOHIDOptionsTypeNone))
guard openResult == kIOReturnSuccess else {
log.warning("entity:LidAngleHIDDriver wasInvalidatedBy activity:DeviceOpen result=\(String(format:"0x%08X", UInt32(bitPattern: openResult)))")
return false
}
s.device = device
return true
}
private static func makeManager() -> IOHIDManager {
let manager = IOHIDManagerCreate(kCFAllocatorDefault, IOOptionBits(kIOHIDOptionsTypeNone))
let match: [String: Any] = [
kIOHIDVendorIDKey: vendorID,
kIOHIDDeviceUsagePageKey: usagePage,
kIOHIDDeviceUsageKey: usage,
]
IOHIDManagerSetDeviceMatching(manager, match as CFDictionary)
IOHIDManagerOpen(manager, IOOptionBits(kIOHIDOptionsTypeNone))
return manager
}
private static func firstMatchedDevice(in manager: IOHIDManager) -> IOHIDDevice? {
guard let set = IOHIDManagerCopyDevices(manager) as? Set<IOHIDDevice> else { return nil }
return set.first
}
}
// MARK: - LidAngleSource
/// Direct-publish reaction source over the dedicated lid-angle HID
/// device. Polls Feature Report 1 at a configurable interval and
/// passes the decoded angle through `LidAngleStateMachine` for
/// open/close/slam classification.
public final class LidAngleSource: Sendable {
public let id = SensorID.lidAngle
public var name: String {
NSLocalizedString("sensor_lid_angle", comment: "Lid angle sensor name")
}
private let machineConfig: LidAngleStateMachineConfig
/// Polling interval in microseconds. 33,333 µs ≈ 30 Hz, matching the
/// reference open-source decoders. `LidAngleStateMachine` smooths over
/// `smoothingWindowMs` so the exact rate is not load-bearing.
private let pollIntervalUS: Int
private let driver: LidAngleHIDDriver
private struct State {
var machine: LidAngleStateMachine?
var bus: ReactionBus?
var pollTask: Task<Void, Never>?
}
private let state: OSAllocatedUnfairLock<State>
/// Public init. Defaults match the reference decoders (~30 Hz poll)
/// and `Defaults.lid*` for the state-machine thresholds.
public convenience init(openThresholdDeg: Double = Defaults.lidOpenThresholdDeg,
closedThresholdDeg: Double = Defaults.lidClosedThresholdDeg,
slamRateDegPerSec: Double = Defaults.lidSlamRateDegPerSec,
smoothingWindowMs: Int = Defaults.lidSmoothingWindowMs,
pollIntervalUS: Int = 33_333) {
let config = LidAngleStateMachineConfig(
openThresholdDeg: openThresholdDeg,
closedThresholdDeg: closedThresholdDeg,
slamRateDegPerSec: slamRateDegPerSec,
smoothingWindowMs: smoothingWindowMs
)
self.init(machineConfig: config,
pollIntervalUS: pollIntervalUS,
driver: RealLidAngleHIDDriver())
}
/// Designated initializer accepting an HID driver injection. Tests
/// pair this with a no-op driver and drive the state machine via
/// `_testInjectAngle`; production callers reach the convenience
/// overload which builds a real driver.
internal init(machineConfig: LidAngleStateMachineConfig,
pollIntervalUS: Int = 33_333,
driver: LidAngleHIDDriver) {
self.machineConfig = machineConfig
self.pollIntervalUS = pollIntervalUS
self.driver = driver
self.state = OSAllocatedUnfairLock(initialState: State())
}
/// True when a dedicated lid-angle HID device is matched in the
/// IORegistry. Returns false on M1 and M2 Air; true on M2 Pro/Max,
/// M3, and M4 family Macs.
public var isAvailable: Bool { driver.isHardwarePresent }
// MARK: - Lifecycle
public func start(publishingTo bus: ReactionBus) {
let alreadyRunning = state.withLock { $0.pollTask != nil }
if alreadyRunning { return }
let machine = LidAngleStateMachine(config: machineConfig)
let driver = self.driver
let intervalNs = UInt64(pollIntervalUS) * 1_000
// One-shot probe: emit the first successful angle read so the
// log proves the device decode works on this host. Without
// this, a host where the lid never moves leaves the source
// silent — indistinguishable from a broken decoder.
if let probe = driver.readAngleDeg() {
log.info("activity:Probe wasGeneratedBy entity:LidAngleSource angle=\(String(format: "%.1f", probe))°")
} else {
log.warning("entity:LidAngleSource wasInvalidatedBy activity:Probe — initial read returned nil")
}
let task = Task.detached { [weak self] in
while !Task.isCancelled {
if let angle = driver.readAngleDeg() {
self?.handleAngle(angle, timestamp: Date())
}
try? await Task.sleep(nanoseconds: intervalNs)
}
}
state.withLock { s in
s.machine = machine
s.bus = bus
s.pollTask = task
}
log.info("entity:LidAngleSource wasGeneratedBy activity:Start pollHz=\(String(format: "%.0f", 1_000_000.0 / Double(pollIntervalUS)))")
}
public func stop() {
let task: Task<Void, Never>? = state.withLock { s in
let t = s.pollTask
s.pollTask = nil
s.machine = nil
s.bus = nil
return t
}
if let task {
task.cancel()
log.info("entity:LidAngleSource wasInvalidatedBy activity:Stop")
}
}
// MARK: - Sample handling
/// Run one decoded angle sample through the state machine and
/// publish any emitted transition onto the bus. Same publish
/// pattern as the prior SPU-broker implementation — resolve
/// under-lock, publish outside the lock so we never await with
/// an unfair lock held.
internal func handleAngle(_ angleDeg: Double, timestamp: Date) {
// Defensive sanity gate. Even with the correct decoder, a
// misbehaving device or an unmapped variant could surface
// out-of-range values; the state machine assumes physical
// angles, so reject anything outside the clamshell envelope.
guard (0...180).contains(angleDeg) else { return }
struct Pending {
let bus: ReactionBus
let event: LidEvent
}
let pending: Pending? = state.withLock { s in
guard let machine = s.machine, let bus = s.bus else { return nil }
guard let event = machine.process(angleDeg: angleDeg, timestamp: timestamp) else { return nil }
return Pending(bus: bus, event: event)
}
if let pending {
let reaction: Reaction
switch pending.event {
case .opened: reaction = .lidOpened
case .closed: reaction = .lidClosed
case .slammed: reaction = .lidSlammed
}
log.info("activity:Publish wasGeneratedBy entity:LidAngleSource event=\(pending.event) angle=\(String(format: "%.1f", angleDeg))°")
Task { await pending.bus.publish(reaction) }
}
}
// MARK: - Test seams
#if DEBUG
/// Inject a single decoded angle, bypassing the HID driver.
/// Drives the state machine directly so cells can author
/// deterministic angle traces with no IOKit involvement.
internal func _testInjectAngle(_ angleDeg: Double, at timestamp: Date) {
handleAngle(angleDeg, timestamp: timestamp)
}
/// True while the polling task is alive. Replaces the prior
/// broker subscription-count assertion in lifecycle cells.
internal var _testIsRunning: Bool {
state.withLock { $0.pollTask != nil }
}
#endif
}
// MARK: - Test no-op driver
#if DEBUG
/// Driver that reports no hardware and never returns a sample. Tests
/// pair this with `_testInjectAngle` so the source's state machine
/// runs deterministically without IOKit traffic.
public final class NoOpLidAngleHIDDriver: LidAngleHIDDriver, @unchecked Sendable {
private let presenceState: OSAllocatedUnfairLock<Bool>
public init(isPresent: Bool = false) {
self.presenceState = OSAllocatedUnfairLock(initialState: isPresent)
}
public var isHardwarePresent: Bool { presenceState.withLock { $0 } }
public func _setPresent(_ value: Bool) { presenceState.withLock { $0 = value } }
public func readAngleDeg() -> Double? { nil }
}
#endif