@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,268 @@
1
+ import Foundation
2
+
3
+ /// Glassly CFW ANCS relay (contract revision 20): the right lens forwards the
4
+ /// iOS notifications it reads over ANCS as private records on the G2 settings
5
+ /// channel, and the phone enables it with a fail-open 90 s lease. The wire
6
+ /// format is pinned by `patches/ancs_relay.c` in g2flash.
7
+ ///
8
+ /// Phone -> glasses, G2SettingPackage field 126: `['A','N',1,op,…]`.
9
+ /// Glasses -> phone, field 125: `['A','N',1,kind,…]`, one record per message:
10
+ /// 1 SOURCE [event][flags][category][count][uid LE32]
11
+ /// 2 ATTR [uid LE32][attr id][total LE16][offset LE16][chunk]
12
+ /// 3 APP [total LE16][offset LE16][id_len][bundle id][chunk] (display name)
13
+ /// 4 STATUS [enabled][side][drops LE16][seq LE16][send errs]
14
+ ///
15
+ /// Pure so record reassembly stays unit-testable: chunks of one attribute can
16
+ /// arrive in any order, a notification completes once all eight attributes are
17
+ /// whole, and the app display name is learned from the APP record that follows.
18
+ struct G2AncsRelay {
19
+ static let protocolVersion: UInt8 = 1
20
+
21
+ enum Op: UInt8 {
22
+ case enable = 1, disable = 2, query = 3, action = 4, renew = 5
23
+ }
24
+
25
+ enum EventID: UInt8 {
26
+ case added = 0, modified = 1, removed = 2
27
+ }
28
+
29
+ struct Flags: OptionSet {
30
+ let rawValue: UInt8
31
+ static let silent = Flags(rawValue: 1 << 0)
32
+ static let important = Flags(rawValue: 1 << 1)
33
+ static let preExisting = Flags(rawValue: 1 << 2)
34
+ static let positiveAction = Flags(rawValue: 1 << 3)
35
+ static let negativeAction = Flags(rawValue: 1 << 4)
36
+ }
37
+
38
+ /// One fully reassembled notification.
39
+ struct Notification: Equatable {
40
+ let uid: UInt32
41
+ let event: EventID
42
+ let flags: Flags
43
+ let category: UInt8
44
+ let categoryCount: UInt8
45
+ let appIdentifier: String
46
+ let title: String
47
+ let subtitle: String
48
+ let message: String
49
+ /// ANCS date attribute, `yyyyMMdd'T'HHmmss` in local time; empty if absent.
50
+ let date: String
51
+ let positiveActionLabel: String
52
+ let negativeActionLabel: String
53
+
54
+ var notificationId: String { "ancs-\(uid)" }
55
+ }
56
+
57
+ struct Status: Equatable {
58
+ let enabled: Bool
59
+ let side: UInt8
60
+ let drops: UInt16
61
+ let sent: UInt16
62
+ let sendErrors: UInt8
63
+ }
64
+
65
+ enum Event: Equatable {
66
+ case notification(Notification)
67
+ case removed(uid: UInt32, appIdentifier: String?)
68
+ case appName(appIdentifier: String, displayName: String)
69
+ case status(Status)
70
+ }
71
+
72
+ // MARK: - Encoding (phone -> glasses)
73
+
74
+ /// G2SettingPackage{commandId=1 (DEVICE_RECEIVE_INFO), magic=0, field 126}.
75
+ /// Hand-framed like the firmware's replies: tag 126/wire 2 is 1010 = f2 07.
76
+ static func controlPacket(op: Op, payload: [UInt8] = []) -> Data {
77
+ let body = [0x41, 0x4E, protocolVersion, op.rawValue] + payload
78
+ return Data([0x08, 0x01, 0x10, 0x00, 0xF2, 0x07, UInt8(body.count)] + body)
79
+ }
80
+
81
+ /// Perform the notification's positive (0) or negative (1) action.
82
+ static func actionPacket(uid: UInt32, positive: Bool) -> Data {
83
+ controlPacket(op: .action, payload: [
84
+ UInt8(uid & 0xFF), UInt8((uid >> 8) & 0xFF), UInt8((uid >> 16) & 0xFF), UInt8(uid >> 24),
85
+ positive ? 0 : 1,
86
+ ])
87
+ }
88
+
89
+ /// ANCS dates are local wall-clock without a zone; fall back to `now`.
90
+ static func timestampMs(date: String, now: Date = Date()) -> Int64 {
91
+ guard !date.isEmpty else { return Int64(now.timeIntervalSince1970 * 1000) }
92
+ let formatter = DateFormatter()
93
+ formatter.locale = Locale(identifier: "en_US_POSIX")
94
+ formatter.calendar = Calendar(identifier: .gregorian)
95
+ formatter.timeZone = .current
96
+ formatter.dateFormat = "yyyyMMdd'T'HHmmss"
97
+ guard let parsed = formatter.date(from: date) else {
98
+ return Int64(now.timeIntervalSince1970 * 1000)
99
+ }
100
+ return Int64(parsed.timeIntervalSince1970 * 1000)
101
+ }
102
+
103
+ // MARK: - Decoding (glasses -> phone)
104
+
105
+ private struct PendingAttribute {
106
+ var total: Int
107
+ var bytes: [UInt8]
108
+ var received: Int
109
+ var filled: [Bool]
110
+ var complete: Bool { received >= total }
111
+
112
+ init(total: Int) {
113
+ self.total = total
114
+ bytes = [UInt8](repeating: 0, count: total)
115
+ filled = [Bool](repeating: false, count: total)
116
+ received = 0
117
+ }
118
+
119
+ mutating func fill(offset: Int, chunk: ArraySlice<UInt8>) {
120
+ for (i, byte) in chunk.enumerated() {
121
+ let at = offset + i
122
+ guard at < total else { return }
123
+ if !filled[at] {
124
+ filled[at] = true
125
+ received += 1
126
+ }
127
+ bytes[at] = byte
128
+ }
129
+ }
130
+ }
131
+
132
+ private struct Pending {
133
+ var event: EventID
134
+ var flags: Flags
135
+ var category: UInt8
136
+ var categoryCount: UInt8
137
+ var attributes: [UInt8: PendingAttribute] = [:]
138
+ var startedAt: Date
139
+ }
140
+
141
+ /// Notification attribute ids the firmware requests, all of which must land.
142
+ private static let attributeIDs: [UInt8] = [0, 1, 2, 3, 4, 5, 6, 7]
143
+ private static let maxPending = 32
144
+
145
+ private var pending: [UInt32: Pending] = [:]
146
+ /// uid -> bundle id, so a removal can name the app after the notification is gone.
147
+ private var appIdentifiers: [UInt32: String] = [:]
148
+ private var partialAppName: PendingAttribute?
149
+ private var partialAppNameFor: String?
150
+ private(set) var appNames: [String: String] = [:]
151
+
152
+ /// Feed one field-125 payload. Returns the event it completes, if any.
153
+ mutating func ingest(_ record: Data, now: Date = Date()) -> Event? {
154
+ let b = [UInt8](record)
155
+ guard b.count >= 4, b[0] == 0x41, b[1] == 0x4E, b[2] == Self.protocolVersion else { return nil }
156
+ switch b[3] {
157
+ case 1: return ingestSource(b, now: now)
158
+ case 2: return ingestAttribute(b)
159
+ case 3: return ingestAppName(b)
160
+ case 4: return ingestStatus(b)
161
+ default: return nil
162
+ }
163
+ }
164
+
165
+ /// Drop reassemblies that never completed (a lost chunk, a lens reset).
166
+ mutating func prune(olderThan seconds: TimeInterval, now: Date = Date()) {
167
+ pending = pending.filter { now.timeIntervalSince($0.value.startedAt) < seconds }
168
+ }
169
+
170
+ mutating func reset() {
171
+ pending.removeAll()
172
+ partialAppName = nil
173
+ partialAppNameFor = nil
174
+ }
175
+
176
+ private mutating func ingestSource(_ b: [UInt8], now: Date) -> Event? {
177
+ guard b.count >= 12, let event = EventID(rawValue: b[4]) else { return nil }
178
+ let uid = Self.u32(b, 8)
179
+ if event == .removed {
180
+ pending[uid] = nil
181
+ return .removed(uid: uid, appIdentifier: appIdentifiers.removeValue(forKey: uid))
182
+ }
183
+ if pending.count >= Self.maxPending, pending[uid] == nil,
184
+ let oldest = pending.min(by: { $0.value.startedAt < $1.value.startedAt })
185
+ {
186
+ pending[oldest.key] = nil
187
+ }
188
+ pending[uid] = Pending(
189
+ event: event, flags: Flags(rawValue: b[5]), category: b[6], categoryCount: b[7],
190
+ startedAt: now
191
+ )
192
+ return nil
193
+ }
194
+
195
+ private mutating func ingestAttribute(_ b: [UInt8]) -> Event? {
196
+ guard b.count >= 13 else { return nil }
197
+ let uid = Self.u32(b, 4)
198
+ let attrID = b[8]
199
+ let total = Int(Self.u16(b, 9))
200
+ let offset = Int(Self.u16(b, 11))
201
+ guard var entry = pending[uid], Self.attributeIDs.contains(attrID),
202
+ total <= 4096, offset <= total
203
+ else { return nil }
204
+ var attr = entry.attributes[attrID] ?? PendingAttribute(total: total)
205
+ if attr.total != total { attr = PendingAttribute(total: total) }
206
+ attr.fill(offset: offset, chunk: b[13...])
207
+ entry.attributes[attrID] = attr
208
+ let complete = Self.attributeIDs.allSatisfy { entry.attributes[$0]?.complete ?? false }
209
+ guard complete else {
210
+ pending[uid] = entry
211
+ return nil
212
+ }
213
+ pending[uid] = nil
214
+ func text(_ id: UInt8) -> String {
215
+ String(decoding: entry.attributes[id]?.bytes ?? [], as: UTF8.self)
216
+ }
217
+ let appIdentifier = text(0)
218
+ appIdentifiers[uid] = appIdentifier
219
+ return .notification(Notification(
220
+ uid: uid, event: entry.event, flags: entry.flags,
221
+ category: entry.category, categoryCount: entry.categoryCount,
222
+ appIdentifier: appIdentifier, title: text(1), subtitle: text(2), message: text(3),
223
+ date: text(5), positiveActionLabel: text(6), negativeActionLabel: text(7)
224
+ ))
225
+ }
226
+
227
+ private mutating func ingestAppName(_ b: [UInt8]) -> Event? {
228
+ guard b.count >= 9 else { return nil }
229
+ let total = Int(Self.u16(b, 4))
230
+ let offset = Int(Self.u16(b, 6))
231
+ let idLen = Int(b[8])
232
+ guard b.count >= 9 + idLen, total <= 1024, offset <= total else { return nil }
233
+ let appIdentifier = String(decoding: b[9 ..< 9 + idLen], as: UTF8.self)
234
+ var attr: PendingAttribute
235
+ if let partial = partialAppName, partialAppNameFor == appIdentifier, partial.total == total {
236
+ attr = partial
237
+ } else {
238
+ attr = PendingAttribute(total: total)
239
+ }
240
+ attr.fill(offset: offset, chunk: b[(9 + idLen)...])
241
+ guard attr.complete else {
242
+ partialAppName = attr
243
+ partialAppNameFor = appIdentifier
244
+ return nil
245
+ }
246
+ partialAppName = nil
247
+ partialAppNameFor = nil
248
+ let name = String(decoding: attr.bytes, as: UTF8.self)
249
+ appNames[appIdentifier] = name
250
+ return .appName(appIdentifier: appIdentifier, displayName: name)
251
+ }
252
+
253
+ private func ingestStatus(_ b: [UInt8]) -> Event? {
254
+ guard b.count >= 11 else { return nil }
255
+ return .status(Status(
256
+ enabled: b[4] != 0, side: b[5], drops: Self.u16(b, 6), sent: Self.u16(b, 8),
257
+ sendErrors: b[10]
258
+ ))
259
+ }
260
+
261
+ private static func u16(_ b: [UInt8], _ i: Int) -> UInt16 {
262
+ UInt16(b[i]) | (UInt16(b[i + 1]) << 8)
263
+ }
264
+
265
+ private static func u32(_ b: [UInt8], _ i: Int) -> UInt32 {
266
+ UInt32(b[i]) | (UInt32(b[i + 1]) << 8) | (UInt32(b[i + 2]) << 16) | (UInt32(b[i + 3]) << 24)
267
+ }
268
+ }
@@ -0,0 +1,102 @@
1
+ import Foundation
2
+
3
+ /// Glassly CFW BLE link speed control (contract revision 26). The firmware
4
+ /// boots on the stock connection profile; the phone can switch each lens to
5
+ /// the 7.5 ms / latency-0 fast profile (slow mode suppressed) and back. The
6
+ /// wire format is pinned by `patches/ble_link.c` in g2flash.
7
+ ///
8
+ /// Phone -> glasses, G2SettingPackage field 127: `['B','L',1,op]`.
9
+ /// Glasses -> phone, field 128 on every settings read reply:
10
+ /// `['B','L',1, fast, side, wanted, applied, min16, max16, latency16,
11
+ /// liveInterval16, liveLatency16]`
12
+ /// Intervals are little-endian in 1.25 ms units. Modes: 0xa3 fast, 0xa4 slow,
13
+ /// 0 = update pending. min/max/latency are the profile the last request used;
14
+ /// the live pair is the connection as the central granted it (absent from the
15
+ /// first revision 26 builds, which sent 13 bytes).
16
+ enum G2BleLink {
17
+ static let protocolVersion: UInt8 = 1
18
+
19
+ enum Op: UInt8 {
20
+ case stock = 0, fast = 1, query = 2
21
+ }
22
+
23
+ enum Mode: Equatable {
24
+ case fast, slow, pending, other(UInt8)
25
+
26
+ init(_ raw: UInt8) {
27
+ switch raw {
28
+ case 0xA3: self = .fast
29
+ case 0xA4: self = .slow
30
+ case 0: self = .pending
31
+ default: self = .other(raw)
32
+ }
33
+ }
34
+
35
+ var name: String {
36
+ switch self {
37
+ case .fast: "fast"
38
+ case .slow: "slow"
39
+ case .pending: "pending"
40
+ case let .other(raw): String(format: "0x%02x", raw)
41
+ }
42
+ }
43
+ }
44
+
45
+ struct Status: Equatable {
46
+ let fast: Bool
47
+ let side: UInt8
48
+ let wanted: Mode
49
+ let applied: Mode
50
+ /// The profile the last request asked for.
51
+ let requestedMinMs: Double
52
+ let requestedMaxMs: Double
53
+ let requestedLatency: UInt16
54
+ /// What the central granted; nil on firmware that predates the field.
55
+ let liveIntervalMs: Double?
56
+ let liveLatency: UInt16?
57
+
58
+ var summary: String {
59
+ var text = "\(fast ? "FAST" : "stock") wanted=\(wanted.name) applied=\(applied.name)"
60
+ + String(format: " requested %.2f-%.2f ms / latency %d", requestedMinMs, requestedMaxMs, requestedLatency)
61
+ if let liveIntervalMs, let liveLatency {
62
+ text += String(format: " live %.2f ms / latency %d", liveIntervalMs, liveLatency)
63
+ }
64
+ return text
65
+ }
66
+ }
67
+
68
+ // MARK: - Encoding (phone -> glasses)
69
+
70
+ /// G2SettingPackage{commandId=1 (DEVICE_RECEIVE_INFO), magic=0, field 127}.
71
+ /// Hand-framed like the ANCS control: tag 127/wire 2 is 1018 = fa 07.
72
+ static func controlPacket(op: Op) -> Data {
73
+ Data([0x08, 0x01, 0x10, 0x00, 0xFA, 0x07, 4, 0x42, 0x4C, protocolVersion, op.rawValue])
74
+ }
75
+
76
+ // MARK: - Decoding (glasses -> phone)
77
+
78
+ static func decodeStatus(_ data: Data) -> Status? {
79
+ let b = [UInt8](data)
80
+ guard b.count >= 13, b[0] == 0x42, b[1] == 0x4C, b[2] == protocolVersion else { return nil }
81
+ let live = b.count >= 17
82
+ return Status(
83
+ fast: b[3] != 0,
84
+ side: b[4],
85
+ wanted: Mode(b[5]),
86
+ applied: Mode(b[6]),
87
+ requestedMinMs: ms(u16(b, 7)),
88
+ requestedMaxMs: ms(u16(b, 9)),
89
+ requestedLatency: u16(b, 11),
90
+ liveIntervalMs: live ? ms(u16(b, 13)) : nil,
91
+ liveLatency: live ? u16(b, 15) : nil
92
+ )
93
+ }
94
+
95
+ private static func ms(_ units: UInt16) -> Double {
96
+ Double(units) * 1.25
97
+ }
98
+
99
+ private static func u16(_ b: [UInt8], _ i: Int) -> UInt16 {
100
+ UInt16(b[i]) | (UInt16(b[i + 1]) << 8)
101
+ }
102
+ }
@@ -1,41 +1,59 @@
1
1
  import Foundation
2
2
 
3
- /// Stock-vs-Glassly-CFW classification of one G2 settings response's
4
- /// top-level field 100 ("EVENCFW/NN feat feat …"; stock firmware never emits
5
- /// the field).
6
- ///
7
- /// Pure so the identity rules stay unit-testable — they gate the connect
8
- /// banner: legacy text drawn before classification and cleared after it
9
- /// splits draw/clear across render paths, and the stock text container then
10
- /// never leaves the glass.
3
+ /// Firmware identity advertised in settings field 100. Retain its revision for
4
+ /// future protocol changes; current features depend only on the firmware family.
11
5
  struct G2CfwClassification: Equatable {
12
- /// nil = classified stock.
6
+ enum Family { case stock, upstream, glassly }
7
+
8
+ let family: Family
13
9
  let capabilityString: String?
14
- let features: Set<String>
15
- let protocolVersion: Int
16
10
 
17
- var isCfw: Bool { capabilityString != nil }
11
+ /// Numeric contract revision, or nil for stock/unknown revision text.
12
+ var revision: Int? {
13
+ guard let header = capabilityString?.split(separator: " ").first,
14
+ let revision = Int(header.split(separator: "/", maxSplits: 1).last ?? ""),
15
+ revision > 0
16
+ else { return nil }
17
+ return revision
18
+ }
19
+
20
+ var isCfw: Bool {
21
+ family != .stock
22
+ }
23
+
24
+ var isGlassly: Bool {
25
+ family == .glassly
26
+ }
27
+
28
+ var displayName: String? {
29
+ switch family {
30
+ case .stock: nil
31
+ case .upstream: "g2flash CFW"
32
+ case .glassly: revision.map { "Glassly CFW v\($0)" } ?? "Glassly CFW"
33
+ }
34
+ }
35
+
36
+ static let stock = G2CfwClassification(family: .stock, capabilityString: nil)
18
37
 
19
- static let stock = G2CfwClassification(capabilityString: nil, features: [], protocolVersion: 0)
38
+ /// The one revision gate: everything the app uses (SID-0xf0 transport,
39
+ /// scenes, settle reports, fast BLE link) shipped together in this
40
+ /// firmware line, so nothing older is special-cased; it is treated as stock.
41
+ static let minimumRevision = 39
20
42
 
21
- /// Classify one settings response. Returns nil when the packet proves
22
- /// nothing: a non-definitive response without the CFW banner (individual
23
- /// settings packets can legitimately omit the field or arrive truncated),
24
- /// or an absent banner on a connection already classified CFW — firmware
25
- /// identity is connection-sticky, and downgrading would reinterpret an
26
- /// installed mode-6 carrier as a stock BMP.
43
+ /// Partial settings notifications can omit field 100. Keep the connection's
44
+ /// known identity so a missing field cannot switch the active render path.
27
45
  static func classify(
28
46
  field100 data: Data?, definitive: Bool, currentCapability: String?
29
47
  ) -> G2CfwClassification? {
30
- let capability = data.flatMap { String(data: $0, encoding: .utf8) }?
48
+ if let capability = data.flatMap({ String(data: $0, encoding: .utf8) })?
31
49
  .trimmingCharacters(in: CharacterSet(charactersIn: "\0"))
32
- if let capability, capability.hasPrefix("EVENCFW/") {
33
- let tokens = capability.split(separator: " ").map(String.init)
34
- return G2CfwClassification(
35
- capabilityString: capability,
36
- features: Set(tokens.dropFirst()),
37
- protocolVersion: tokens.first.flatMap { Int($0.dropFirst("EVENCFW/".count)) } ?? 0
38
- )
50
+ {
51
+ if capability.hasPrefix("GLASSLYCFW/") {
52
+ return G2CfwClassification(family: .glassly, capabilityString: capability)
53
+ }
54
+ if capability.hasPrefix("EVENCFW/") {
55
+ return G2CfwClassification(family: .upstream, capabilityString: capability)
56
+ }
39
57
  }
40
58
  guard definitive, currentCapability == nil else { return nil }
41
59
  return .stock