@glassly/bluetooth-sdk 0.1.0-dev.99 → 0.2.8-dev.444

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (155) hide show
  1. package/README.md +47 -6
  2. package/android/build.gradle +7 -7
  3. package/android/lc3Lib/build.gradle +2 -2
  4. package/android/src/main/assets/G2Firmware/README.md +27 -2
  5. package/android/src/main/assets/G2Firmware/{g2-stock-2.2.9.22.evenota.bin → g2-glassly-cfw-2.3.0.24.evenota.bin} +0 -0
  6. package/android/src/main/assets/G2Firmware/{g2-glassly-cfw-2.2.9.22.evenota.bin → g2-stock-2.3.0.24.evenota.bin} +0 -0
  7. package/android/src/main/java/com/glassly/bluetoothsdk/BluetoothSdk.kt +38 -0
  8. package/android/src/main/java/com/glassly/bluetoothsdk/BluetoothSdkModule.kt +50 -14
  9. package/android/src/main/java/com/glassly/bluetoothsdk/Bridge.kt +18 -0
  10. package/android/src/main/java/com/glassly/bluetoothsdk/DeviceManager.kt +139 -10
  11. package/android/src/main/java/com/glassly/bluetoothsdk/DeviceStore.kt +28 -1
  12. package/android/src/main/java/com/glassly/bluetoothsdk/DisplayPacer.kt +199 -0
  13. package/android/src/main/java/com/glassly/bluetoothsdk/GeneratedChangelogCatalog.kt +10 -1
  14. package/android/src/main/java/com/glassly/bluetoothsdk/GeneratedReleaseMetadata.kt +4 -4
  15. package/android/src/main/java/com/glassly/bluetoothsdk/GlassesNotifications.kt +155 -0
  16. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2.kt +1065 -255
  17. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2BleLink.kt +119 -0
  18. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2CfwClassification.kt +29 -28
  19. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2CfwRenderer.kt +138 -33
  20. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2CfwScene.kt +673 -0
  21. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2CfwTextureCache.kt +235 -162
  22. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2CfwTransport.kt +402 -0
  23. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2DisplayDiagnostics.kt +84 -0
  24. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2FlashProtocol.kt +3 -3
  25. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2Flasher.kt +9 -9
  26. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2Keyboard.kt +689 -0
  27. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2KeyboardBridge.kt +452 -0
  28. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2KeyboardHid.kt +208 -0
  29. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2NotificationFile.kt +181 -0
  30. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2RingBattery.kt +20 -0
  31. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2Sensors.kt +67 -0
  32. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2TouchInput.kt +39 -0
  33. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/MentraLive.kt +5 -5
  34. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/MentraNex.kt +14 -14
  35. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/{GlasslyBle.java → NexProto.java} +6462 -7618
  36. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/NotificationMessageIds.kt +58 -0
  37. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/SGCManager.kt +77 -2
  38. package/android/src/main/java/com/glassly/bluetoothsdk/speakerid/SpeakerIdEngine.kt +1 -1
  39. package/android/src/main/java/com/glassly/bluetoothsdk/status/DeviceStatus.kt +55 -0
  40. package/android/src/main/java/com/glassly/bluetoothsdk/stt/STTTools.kt +1 -1
  41. package/android/src/main/java/com/glassly/bluetoothsdk/stt/SherpaOnnxTranscriber.kt +1 -1
  42. package/android/src/main/java/com/glassly/bluetoothsdk/tts/TTSTools.kt +1 -1
  43. package/android/src/main/java/com/glassly/bluetoothsdk/utils/NexSGCUtils.kt +24 -149
  44. package/android/src/test/java/com/glassly/bluetoothsdk/DisplayPacerTest.kt +277 -0
  45. package/android/src/test/java/com/glassly/bluetoothsdk/G2BleLinkTest.kt +70 -0
  46. package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/G2CfwClassificationTest.kt +45 -56
  47. package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/G2CfwRendererTest.kt +78 -5
  48. package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/G2CfwSceneTest.kt +489 -0
  49. package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/G2CfwTextureCacheTest.kt +100 -43
  50. package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/G2CfwTransportTest.kt +252 -0
  51. package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/G2DisplayDiagnosticsTest.kt +64 -0
  52. package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/G2KeyboardTest.kt +271 -0
  53. package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/G2NotificationFileTest.kt +204 -0
  54. package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/G2RingBatteryTest.kt +56 -0
  55. package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/G2SensorsTest.kt +56 -0
  56. package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/G2TouchInputTest.kt +60 -0
  57. package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/NexProtoDescriptorTest.kt +32 -0
  58. package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/NotificationMessageIdsTest.kt +58 -0
  59. package/build/BluetoothSdk.types.d.ts +209 -20
  60. package/build/BluetoothSdk.types.d.ts.map +1 -1
  61. package/build/BluetoothSdk.types.js.map +1 -1
  62. package/build/_internal.d.ts +1 -1
  63. package/build/_internal.d.ts.map +1 -1
  64. package/build/_internal.js.map +1 -1
  65. package/build/_private/BluetoothSdkModule.d.ts +30 -1
  66. package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
  67. package/build/_private/BluetoothSdkModule.js.map +1 -1
  68. package/build/generated/changelogCatalog.d.ts +28 -1
  69. package/build/generated/changelogCatalog.d.ts.map +1 -1
  70. package/build/generated/changelogCatalog.js +37 -1
  71. package/build/generated/changelogCatalog.js.map +1 -1
  72. package/build/generated/releaseMetadata.js +4 -4
  73. package/build/generated/releaseMetadata.js.map +1 -1
  74. package/build/index.d.ts +1 -1
  75. package/build/index.d.ts.map +1 -1
  76. package/build/index.js +14 -0
  77. package/build/index.js.map +1 -1
  78. package/ios/BluetoothSdkModule.swift +102 -13
  79. package/ios/GlasslyBluetoothSDK.podspec +1 -1
  80. package/ios/Packages/SherpaOnnx/SherpaOnnx.swift +8 -3
  81. package/ios/Source/BluetoothSDK.swift +53 -0
  82. package/ios/Source/BluetoothSdkDefaults.swift +1 -1
  83. package/ios/Source/Bridge.swift +13 -0
  84. package/ios/Source/DeviceManager.swift +151 -33
  85. package/ios/Source/DeviceStore.swift +28 -14
  86. package/ios/Source/DisplayPacer.swift +180 -0
  87. package/ios/Source/GeneratedChangelogCatalog.swift +10 -1
  88. package/ios/Source/GeneratedReleaseMetadata.swift +4 -4
  89. package/ios/Source/GlassesNotifications.swift +181 -0
  90. package/ios/Source/ObservableStore.swift +14 -2
  91. package/ios/Source/Resources/G2Firmware/README.md +27 -2
  92. package/ios/Source/Resources/G2Firmware/{g2-stock-2.2.9.22.evenota.bin → g2-glassly-cfw-2.3.0.24.evenota.bin} +0 -0
  93. package/ios/Source/Resources/G2Firmware/{g2-glassly-cfw-2.2.9.22.evenota.bin → g2-stock-2.3.0.24.evenota.bin} +0 -0
  94. package/ios/Source/controllers/R1.swift +0 -1
  95. package/ios/Source/internal/BluetoothAvailability.swift +31 -0
  96. package/ios/Source/sgcs/G2.swift +1420 -495
  97. package/ios/Source/sgcs/G2AncsRelay.swift +268 -0
  98. package/ios/Source/sgcs/G2BleLink.swift +102 -0
  99. package/ios/Source/sgcs/G2CfwClassification.swift +45 -27
  100. package/ios/Source/sgcs/G2CfwRenderer.swift +166 -33
  101. package/ios/Source/sgcs/G2CfwScene.swift +779 -0
  102. package/ios/Source/sgcs/G2CfwTextureCache.swift +244 -117
  103. package/ios/Source/sgcs/G2CfwTransport.swift +485 -0
  104. package/ios/Source/sgcs/G2DisplayDiagnostics.swift +154 -0
  105. package/ios/Source/sgcs/G2FlashProtocol.swift +2 -2
  106. package/ios/Source/sgcs/G2Flasher.swift +9 -9
  107. package/ios/Source/sgcs/G2Keyboard.swift +780 -0
  108. package/ios/Source/sgcs/G2KeyboardBridge.swift +438 -0
  109. package/ios/Source/sgcs/G2KeyboardHid.swift +290 -0
  110. package/ios/Source/sgcs/G2NotificationTuning.swift +24 -0
  111. package/ios/Source/sgcs/G2RingBattery.swift +22 -0
  112. package/ios/Source/sgcs/G2Sensors.swift +75 -0
  113. package/ios/Source/sgcs/G2TouchInput.swift +51 -0
  114. package/ios/Source/sgcs/G2WritePacing.swift +18 -0
  115. package/ios/Source/sgcs/MentraNex.swift +44 -44
  116. package/ios/Source/sgcs/SGCManager.swift +120 -13
  117. package/ios/Source/sgcs/{glassly_ble.pb.swift → nex_ble.pb.swift} +405 -465
  118. package/ios/Source/speakerid/SpeakerIdEngine.swift +2 -2
  119. package/ios/Source/status/DeviceStatus.swift +27 -0
  120. package/ios/Source/stt/SherpaOnnxTranscriber.swift +15 -14
  121. package/ios/Source/utils/Enums.swift +0 -1
  122. package/ios/Source/utils/G1Text.swift +0 -1
  123. package/ios/Source/utils/G2NotifyFile.swift +20 -10
  124. package/ios/Source/utils/JSCExperiment.swift +6 -6
  125. package/ios/Source/utils/Models.swift +0 -1
  126. package/ios/Tests/BluetoothSDKTests/BluetoothAvailabilityTests.swift +29 -0
  127. package/ios/Tests/BluetoothSDKTests/DisplayPacerTests.swift +255 -0
  128. package/ios/Tests/BluetoothSDKTests/G2AncsRelayTests.swift +186 -0
  129. package/ios/Tests/BluetoothSDKTests/G2BleLinkTests.swift +52 -0
  130. package/ios/Tests/BluetoothSDKTests/G2CfwClassificationTests.swift +46 -51
  131. package/ios/Tests/BluetoothSDKTests/G2CfwRendererTests.swift +164 -5
  132. package/ios/Tests/BluetoothSDKTests/G2CfwSceneTests.swift +497 -0
  133. package/ios/Tests/BluetoothSDKTests/G2CfwTextureCacheTests.swift +108 -44
  134. package/ios/Tests/BluetoothSDKTests/G2CfwTransportTests.swift +259 -0
  135. package/ios/Tests/BluetoothSDKTests/G2DisplayDiagnosticsTests.swift +89 -0
  136. package/ios/Tests/BluetoothSDKTests/G2FlashProtocolTests.swift +3 -2
  137. package/ios/Tests/BluetoothSDKTests/G2ImageAckTests.swift +104 -0
  138. package/ios/Tests/BluetoothSDKTests/G2KeyboardTests.swift +274 -0
  139. package/ios/Tests/BluetoothSDKTests/G2NotifyFileTests.swift +21 -5
  140. package/ios/Tests/BluetoothSDKTests/G2RingBatteryTests.swift +56 -0
  141. package/ios/Tests/BluetoothSDKTests/G2SensorsTests.swift +56 -0
  142. package/ios/Tests/BluetoothSDKTests/G2TouchInputTests.swift +71 -0
  143. package/ios/Tests/BluetoothSDKTests/G2WritePacingTests.swift +35 -0
  144. package/ios/Tests/BluetoothSDKTests/GlassesNotificationTests.swift +95 -0
  145. package/package.json +1 -1
  146. package/plugin/build/withAndroid.js +2 -2
  147. package/plugin/build/withIos.js +1 -1
  148. package/scripts/gen-nex-proto.sh +12 -0
  149. package/scripts/inject-ios-sdk-version.mjs +1 -1
  150. package/src/BluetoothSdk.types.ts +214 -19
  151. package/src/_internal.ts +1 -1
  152. package/src/_private/BluetoothSdkModule.ts +40 -0
  153. package/src/generated/changelogCatalog.ts +37 -1
  154. package/src/generated/releaseMetadata.ts +4 -4
  155. package/src/index.ts +24 -0
@@ -0,0 +1,290 @@
1
+ import Foundation
2
+
3
+ /// Bounded HID report-descriptor decoder for the G2 keyboard bridge. HOGP
4
+ /// passes the Report ID out of band (Report Reference descriptor), so a
5
+ /// characteristic value is decoded as-is; byte zero is never stripped the way
6
+ /// USB HID would. Ported from g2flash `demos/keyboard-hid.ts`; the descriptor
7
+ /// limits match the firmware's 512-byte Report Map cap.
8
+ enum G2KeyboardHid {
9
+ struct Usage: Hashable {
10
+ let page: Int
11
+ let usage: Int
12
+ }
13
+
14
+ struct KeyEvent: Equatable {
15
+ let page: Int
16
+ let usage: Int
17
+ let down: Bool
18
+ }
19
+
20
+ struct Field {
21
+ let bit: Int
22
+ let size: Int
23
+ let count: Int
24
+ let variable: Bool
25
+ let min: Int
26
+ let max: Int
27
+ let usages: [Usage]
28
+ let range: (Usage, Usage)?
29
+ }
30
+
31
+ struct Report {
32
+ let id: Int
33
+ var bits: Int
34
+ var fields: [Field]
35
+ }
36
+
37
+ struct DescriptorError: Error, CustomStringConvertible {
38
+ let reason: String
39
+ var description: String {
40
+ "Unsupported or malformed HID descriptor: \(reason)"
41
+ }
42
+ }
43
+
44
+ struct ReportError: Error, CustomStringConvertible {
45
+ let reason: String
46
+ var description: String {
47
+ reason
48
+ }
49
+ }
50
+
51
+ private struct Global {
52
+ var page = 0, min = 0, max = 0, size = 0, count = 0, id = 0
53
+ }
54
+
55
+ /// Parse a Report Map into its input reports keyed by Report ID (0 when the
56
+ /// map has no Report ID items). Rejects long items, usage delimiters, mixed
57
+ /// numbered/unnumbered reports and oversized dimensions.
58
+ static func parseReportMap(_ bytes: [UInt8]) throws -> [Int: Report] {
59
+ func fail(_ reason: String) -> DescriptorError {
60
+ DescriptorError(reason: reason)
61
+ }
62
+ guard !bytes.isEmpty, bytes.count <= 512 else { throw fail("report map must contain 1..512 bytes") }
63
+ var reports: [Int: Report] = [:]
64
+ var order: [Int] = []
65
+ var g = Global()
66
+ var stack: [Global] = []
67
+ var usages: [Usage] = []
68
+ var lo: Usage?
69
+ var hi: Usage?
70
+ var depth = 0
71
+ var p = 0
72
+ while p < bytes.count {
73
+ let prefix = Int(bytes[p])
74
+ p += 1
75
+ if prefix == 0xFE {
76
+ throw fail("long items")
77
+ }
78
+ let n = [0, 1, 2, 4][prefix & 3]
79
+ guard p + n <= bytes.count else { throw fail("truncated item") }
80
+ var v = 0
81
+ for j in 0 ..< n {
82
+ v += Int(bytes[p + j]) << (8 * j)
83
+ }
84
+ p += n
85
+ let signed = n > 0 && v >= 1 << (n * 8 - 1) ? v - (1 << (n * 8)) : v
86
+ let type = (prefix >> 2) & 3
87
+ let tag = prefix >> 4
88
+ func usage(_ v: Int, _ n: Int) -> Usage {
89
+ n == 4 ? Usage(page: v >> 16, usage: v & 0xFFFF) : Usage(page: g.page, usage: v)
90
+ }
91
+ switch type {
92
+ case 1:
93
+ switch tag {
94
+ case 0: g.page = v
95
+ case 1: g.min = signed
96
+ case 2: g.max = g.min < 0 ? signed : v
97
+ case 7: g.size = v
98
+ case 8:
99
+ if v == 0 || v > 255 {
100
+ throw fail("Report ID")
101
+ }
102
+ g.id = v
103
+ case 9: g.count = v
104
+ case 10:
105
+ if stack.count >= 8 {
106
+ throw fail("global stack overflow")
107
+ }
108
+ stack.append(g)
109
+ case 11:
110
+ guard let popped = stack.popLast() else { throw fail("global stack underflow") }
111
+ g = popped
112
+ case 3, 4, 5, 6: break // physical range, unit/exponent
113
+ default: throw fail("unknown global item")
114
+ }
115
+ case 2:
116
+ if tag == 0 {
117
+ if usages.count >= 512 {
118
+ throw fail("too many usages")
119
+ }
120
+ usages.append(usage(v, n))
121
+ } else if tag == 1 {
122
+ lo = usage(v, n)
123
+ } else if tag == 2 {
124
+ hi = usage(v, n)
125
+ } else if tag == 10 {
126
+ throw fail("usage delimiters")
127
+ } else if tag > 9 {
128
+ throw fail("unknown local item")
129
+ }
130
+ case 0:
131
+ if tag == 10 {
132
+ depth += 1
133
+ if depth > 32 {
134
+ throw fail("collection depth")
135
+ }
136
+ } else if tag == 12 {
137
+ depth -= 1
138
+ if depth < 0 {
139
+ throw fail("collection underflow")
140
+ }
141
+ } else if tag == 8 {
142
+ if g.size == 0 || g.size > 32 || g.count == 0 || g.count > 512 {
143
+ throw fail("field dimensions")
144
+ }
145
+ if reports[g.id] == nil {
146
+ if reports.count >= 16 {
147
+ throw fail("too many input reports")
148
+ }
149
+ reports[g.id] = Report(id: g.id, bits: 0, fields: [])
150
+ order.append(g.id)
151
+ }
152
+ var r = reports[g.id]!
153
+ var range: (Usage, Usage)?
154
+ if lo != nil || hi != nil {
155
+ guard let lo, let hi, lo.page == hi.page, hi.usage >= lo.usage else { throw fail("usage range") }
156
+ range = (lo, hi)
157
+ }
158
+ let field = Field(
159
+ bit: r.bits, size: g.size, count: g.count, variable: v & 2 != 0,
160
+ min: g.min, max: g.max, usages: usages, range: range
161
+ )
162
+ r.bits += g.size * g.count
163
+ if r.bits > 4096 {
164
+ throw fail("report exceeds 512 bytes")
165
+ }
166
+ // Constant padding and relative pointer axes are not keys.
167
+ if v & 1 == 0, v & 4 == 0 {
168
+ r.fields.append(field)
169
+ }
170
+ reports[g.id] = r
171
+ } else if tag != 9, tag != 11 {
172
+ throw fail("unknown main item")
173
+ }
174
+ usages = []
175
+ lo = nil
176
+ hi = nil
177
+ default:
178
+ throw fail("reserved item type")
179
+ }
180
+ }
181
+ if depth != 0 || !stack.isEmpty {
182
+ throw fail("unbalanced collection/global stack")
183
+ }
184
+ if reports[0] != nil, reports.count > 1 {
185
+ throw fail("mixed numbered and unnumbered input reports")
186
+ }
187
+ if reports.isEmpty {
188
+ throw fail("no input reports")
189
+ }
190
+ return reports
191
+ }
192
+
193
+ private static func usageAt(_ f: Field, _ index: Int) -> Usage? {
194
+ if index < 0 {
195
+ return nil
196
+ }
197
+ if index < f.usages.count {
198
+ return f.usages[index]
199
+ }
200
+ let rangeIndex = index - f.usages.count
201
+ if let range = f.range, rangeIndex <= range.1.usage - range.0.usage {
202
+ return Usage(page: range.0.page, usage: range.0.usage + rangeIndex)
203
+ }
204
+ // Variable items reuse their last usage when Report Count exceeds usages.
205
+ if f.variable {
206
+ return f.range?.1 ?? f.usages.last
207
+ }
208
+ return nil
209
+ }
210
+
211
+ /// Keyboard (page 7) and consumer (page 12) usages currently asserted in
212
+ /// one input report value. Throws on a length mismatch or a rollover report.
213
+ static func reportKeys(_ report: Report, _ data: [UInt8]) throws -> [Usage] {
214
+ guard data.count == (report.bits + 7) / 8 else {
215
+ throw ReportError(reason: "HID input length does not match Report Map")
216
+ }
217
+ var keys: [Usage] = []
218
+ var seen = Set<Usage>()
219
+ for f in report.fields {
220
+ for i in 0 ..< f.count {
221
+ var value = 0
222
+ for j in 0 ..< f.size {
223
+ let bit = f.bit + i * f.size + j
224
+ if data[bit >> 3] & UInt8(1 << (bit & 7)) != 0 {
225
+ value += 1 << j
226
+ }
227
+ }
228
+ if f.min < 0, value >= 1 << (f.size - 1) {
229
+ value -= 1 << f.size
230
+ }
231
+ if value < f.min || value > f.max {
232
+ continue
233
+ } // null state
234
+ guard let u = usageAt(f, f.variable ? i : value - f.min) else { continue }
235
+ if f.variable, value == 0 {
236
+ continue
237
+ }
238
+ if u.usage == 0 {
239
+ continue
240
+ }
241
+ if u.page == 7, u.usage <= 3 {
242
+ throw ReportError(reason: "HID keyboard rollover/error report")
243
+ }
244
+ if u.page == 7 || u.page == 12, !seen.contains(u) {
245
+ seen.insert(u)
246
+ keys.append(u)
247
+ }
248
+ }
249
+ }
250
+ return keys
251
+ }
252
+
253
+ /// Key ownership is per characteristic, then unioned: releasing one report
254
+ /// cannot release a modifier still held by another report.
255
+ struct HeldKeys {
256
+ private var reports: [UInt16: [Usage]] = [:]
257
+ private var held: [Usage] = []
258
+
259
+ mutating func update(handle: UInt16, report: Report, data: [UInt8]) throws -> [KeyEvent] {
260
+ reports[handle] = try reportKeys(report, data)
261
+ return diff()
262
+ }
263
+
264
+ mutating func reset() -> [KeyEvent] {
265
+ reports.removeAll()
266
+ return diff()
267
+ }
268
+
269
+ private mutating func diff() -> [KeyEvent] {
270
+ var next: [Usage] = []
271
+ var nextSet = Set<Usage>()
272
+ for handle in reports.keys.sorted() {
273
+ for u in reports[handle]! where !nextSet.contains(u) {
274
+ nextSet.insert(u)
275
+ next.append(u)
276
+ }
277
+ }
278
+ let heldSet = Set(held)
279
+ var events: [KeyEvent] = []
280
+ for u in held where !nextSet.contains(u) {
281
+ events.append(KeyEvent(page: u.page, usage: u.usage, down: false))
282
+ }
283
+ for u in next where !heldSet.contains(u) {
284
+ events.append(KeyEvent(page: u.page, usage: u.usage, down: true))
285
+ }
286
+ held = next
287
+ return events
288
+ }
289
+ }
290
+ }
@@ -0,0 +1,24 @@
1
+ import Foundation
2
+
3
+ /// Config values snapped to what the G2 notification firmware accepts.
4
+ enum G2NotificationTuning {
5
+ /// The firmware takes 0 (its own choice) or one of three popup durations.
6
+ static let popupSecondsChoices: [Int] = [5, 7, 10]
7
+
8
+ static func snapPopupSeconds(_ seconds: Int) -> Int32 {
9
+ guard seconds > 0 else { return 0 }
10
+ // Ties round up: distance compared first, then the larger choice wins.
11
+ let snapped = popupSecondsChoices.min { lhs, rhs in
12
+ let dl = abs(lhs - seconds)
13
+ let dr = abs(rhs - seconds)
14
+ return dl == dr ? lhs > rhs : dl < dr
15
+ }
16
+ return Int32(snapped ?? popupSecondsChoices[0])
17
+ }
18
+
19
+ /// The firmware filter is an allowlist; with nothing blocked it stays off so
20
+ /// an incomplete allowlist can never drop a notification.
21
+ static func filterEnabled(for config: GlassesNotificationConfig) -> Bool {
22
+ !config.blockedApps.isEmpty
23
+ }
24
+ }
@@ -0,0 +1,22 @@
1
+ import Foundation
2
+
3
+ /// Settings field 106, sent as its own notification after the device-info poll by GLASSLYCFW/24 and later.
4
+ /// This is the R1 connected to the glasses, independent of the phone controller.
5
+ struct G2RingBattery: Equatable {
6
+ let connected: Bool
7
+ let level: Int
8
+ let charging: Bool
9
+
10
+ static let disconnected = G2RingBattery(connected: false, level: -1, charging: false)
11
+
12
+ static func decode(_ data: Data) -> G2RingBattery? {
13
+ let bytes = Array(data)
14
+ guard bytes.count == 5, bytes[0] == 0x52, bytes[1] == 0x42, bytes[2] == 1 else { return nil }
15
+ guard bytes[3] & 1 != 0 else { return .disconnected }
16
+ guard bytes[3] & 2 != 0 else {
17
+ return G2RingBattery(connected: true, level: -1, charging: false)
18
+ }
19
+ guard bytes[4] <= 100 else { return nil }
20
+ return G2RingBattery(connected: true, level: Int(bytes[4]), charging: bytes[3] & 4 != 0)
21
+ }
22
+ }
@@ -0,0 +1,75 @@
1
+ import Foundation
2
+
3
+ /// Upstream mode 16 / settings field 105 and mode 10 / navigation field 100.
4
+ /// Validate the report format independently of the advertised CFW revision.
5
+ enum G2Sensors {
6
+ static func heading(_ fields: [Int: Any]) -> Int? {
7
+ guard let raw = fields[1] else { return 0 }
8
+ guard let heading = raw as? Int32, (0 ... 359).contains(heading) else { return nil }
9
+ return Int(heading)
10
+ }
11
+
12
+ static func command(sensor: String, operation: String, options: [String: Double]) -> Data? {
13
+ func number(_ key: String, _ fallback: Int, _ range: ClosedRange<Int>) -> Int? {
14
+ guard let value = options[key] else { return fallback }
15
+ guard value.isFinite, value.rounded() == value,
16
+ value >= Double(range.lowerBound), value <= Double(range.upperBound) else { return nil }
17
+ return Int(value)
18
+ }
19
+ func u16(_ n: Int) -> [UInt8] {
20
+ [UInt8(n & 255), UInt8(n >> 8)]
21
+ }
22
+ if sensor == "light" {
23
+ if operation == "query" {
24
+ return Data([16, 0])
25
+ }
26
+ if operation == "stop" {
27
+ return Data([16, 2])
28
+ }
29
+ guard operation == "start", let interval = number("intervalMs", 500, 100 ... 5000),
30
+ let delta = number("minDelta", 1, 0 ... 65535),
31
+ let heartbeat = number("heartbeatMs", 5000, 0 ... 65535) else { return nil }
32
+ return Data([16, 1, 1] + u16(interval) + u16(delta) + u16(heartbeat))
33
+ }
34
+ if sensor == "compass" {
35
+ if operation == "stop" {
36
+ return Data([10, 0])
37
+ }
38
+ guard operation == "start", let interval = number("intervalMs", 1000, 50 ... 2000),
39
+ let change = number("minChangeDegrees", 5, 0 ... 359) else { return nil }
40
+ return Data([10, 2] + u16(interval) + u16(change))
41
+ }
42
+ return nil
43
+ }
44
+
45
+ private static func uint(_ b: [UInt8], _ offset: Int, _ count: Int) -> Int64 {
46
+ (0 ..< count).reduce(Int64(0)) { $0 | (Int64(b[offset + $1]) << ($1 * 8)) }
47
+ }
48
+
49
+ static func light(_ data: Data) -> [String: Any]? {
50
+ let b = Array(data)
51
+ guard b.count == 24, b[0] == 65, b[1] == 76, b[2] == 1, b[3] <= 3 else { return nil }
52
+ return [
53
+ "reason": ["query", "started", "poll", "stopped"][Int(b[3])],
54
+ "opened": b[4] & 1 != 0, "passive": b[4] & 2 != 0,
55
+ "autoBrightness": b[4] & 4 != 0, "readSucceeded": b[4] & 8 != 0,
56
+ "status": Int(b[5]), "value": uint(b, 6, 4), "peak": uint(b, 10, 4),
57
+ "targetLevel": uint(b, 14, 2), "brightness": Int(b[16]), "gear": Int(b[17]),
58
+ "scaleQ10": uint(b, 18, 2), "sampleTimestampMs": uint(b, 20, 4),
59
+ ]
60
+ }
61
+
62
+ static func compass(_ data: Data) -> [String: Any]? {
63
+ let b = Array(data)
64
+ guard b.count == 12, b[0] == 67, b[1] == 77, b[2] == 1,
65
+ b[3] <= 3 || b[3] == 255, b[4] <= 2 || b[4] == 255, b[5] <= 3 else { return nil }
66
+ return [
67
+ "accuracy": b[3] == 255 ? NSNull() : Int(b[3]) as Any,
68
+ "anomalies": b[4] == 255 ? NSNull() : Int(b[4]) as Any,
69
+ "source": ["unknown", "grv", "gmrv", "rv"][Int(b[5])],
70
+ "sampleMatched": b[6] & 128 != 0, "grvValid": b[6] & 1 != 0,
71
+ "gmrvValid": b[6] & 2 != 0, "rvValid": b[6] & 4 != 0,
72
+ "magneticDataFresh": b[6] & 8 != 0, "sampleTimestampMs": uint(b, 8, 4),
73
+ ]
74
+ }
75
+ }
@@ -0,0 +1,51 @@
1
+ import Foundation
2
+
3
+ /// Capture before the BLE callback hops to the main queue. Processing time is
4
+ /// not input time: several seconds of queued input can drain in milliseconds.
5
+ struct G2InputArrival {
6
+ let uptime: TimeInterval
7
+ let timestamp: Int64
8
+
9
+ init(uptime: TimeInterval = ProcessInfo.processInfo.systemUptime,
10
+ timestamp: Int64 = Int64(Date().timeIntervalSince1970 * 1000))
11
+ {
12
+ self.uptime = uptime
13
+ self.timestamp = timestamp
14
+ }
15
+ }
16
+
17
+ /// CFW's field-100 extension identifies raw R1 reports forwarded once through
18
+ /// the right lens. Its tick is a ring clock, not a phone timestamp.
19
+ struct G2RingInput {
20
+ let wireType: UInt8
21
+ let tick: UInt32
22
+
23
+ init?(fields: [Int: Any]) {
24
+ guard fields[2] as? Int32 == 2, let data = fields[100] as? Data else { return nil }
25
+ let bytes = [UInt8](data)
26
+ guard bytes.count == 12, bytes[0] == 0x52, bytes[1] == 0x49,
27
+ bytes[2] == 1, bytes[3] == 1, bytes[7] == 0 else { return nil }
28
+ let events: [UInt8: Int32] = [0: 9, 1: 0, 2: 3, 4: 1, 5: 2, 8: 10, 9: 11, 10: 14]
29
+ guard fields[1] as? Int32 == events[bytes[4], default: 127] else { return nil }
30
+ wireType = bytes[4]
31
+ tick = UInt32(bytes[8]) | UInt32(bytes[9]) << 8 | UInt32(bytes[10]) << 16 | UInt32(bytes[11]) << 24
32
+ }
33
+ }
34
+
35
+ struct G2TouchDebounce {
36
+ private var lastAcceptedAt: TimeInterval?
37
+
38
+ mutating func accepts(event: Int32?, ring: G2RingInput?, receivedAt: TimeInterval) -> Bool {
39
+ // The raw ring stream deliberately bypasses stock suppression. Do not
40
+ // reinstate it on the phone or discard click/swipe completions after a
41
+ // touch_down. Edges and unknown markers never consume a debounce slot.
42
+ if ring != nil || event == 9 || event == 10 || event == 14 || event == 127 {
43
+ return true
44
+ }
45
+ if let lastAcceptedAt, receivedAt - lastAcceptedAt < 0.1 {
46
+ return false
47
+ }
48
+ lastAcceptedAt = receivedAt
49
+ return true
50
+ }
51
+ }
@@ -0,0 +1,18 @@
1
+ import UIKit
2
+
3
+ /// Background CFW traffic uses the original G2 timed writer. Reports show
4
+ /// readiness remaining false for seconds even while gestures and timers run.
5
+ /// Keep foreground throughput without letting that gate freeze background UI.
6
+ enum G2WritePacing: Equatable {
7
+ case timed, creditBurst
8
+
9
+ static let packetDelayNs: UInt64 = 6_000_000
10
+
11
+ init(cfwEnabled: Bool, applicationState: UIApplication.State) {
12
+ self = cfwEnabled && applicationState == .active ? .creditBurst : .timed
13
+ }
14
+
15
+ func canWrite(peripheralReady: Bool) -> Bool {
16
+ self == .timed || peripheralReady
17
+ }
18
+ }