@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,485 @@
1
+ import Foundation
2
+ import zlib
3
+
4
+ /// Private SID-0xf0 message-stream transport of the Glassly CFW (GLASSLYCFW/31,
5
+ /// upstream Faceclaw/10+). Custom commands (modes 3/6/8/9/10/11/15/16/17/18/19/
6
+ /// 20/36/37/38) no longer ride an EvenHub image container: they are records in
7
+ /// a byte stream that the firmware reconstructs before any TPL reassembly, on
8
+ /// whichever lenses the options byte selects (the other lens receives them over
9
+ /// the frame bridge, in arrival order), and each record is answered with an ACK
10
+ /// or NACK notification through the BLE ingress lens.
11
+ ///
12
+ /// Wire format (see g2flash `patches/message_transport.c`):
13
+ ///
14
+ /// * packet: `[0xAA][0x21][seq][len+2][1][1][0xF0][0][options][stream bytes][crc16 LE]`
15
+ /// where `options` = lens bits (1 left, 2 right) | 0x80 RESET on the first
16
+ /// packet of a stream | 0x40 END on its last, `seq` increments per packet
17
+ /// (uint8 wrap) and the CRC (CCITT-FALSE) covers `options` + stream bytes.
18
+ /// The stream id is the RESET packet's `seq`.
19
+ /// * record: `[flags][len:u16][crc16 of the plain message:u16][body]` where
20
+ /// flags = lens bits | 4 COMPRESSED | 8 RESET_CONTEXT. A compressed body is
21
+ /// the output of one persistent deflate context ending in a SYNC_FLUSH
22
+ /// (never Z_FINISH); RESET_CONTEXT tells the glasses to start a fresh
23
+ /// inflater, which the phone must set whenever its own deflater is new.
24
+ /// * ACK: `[1][stream][ordinal:u16][lens][size:u16][crc:u16]` + up to three
25
+ /// 7-byte history entries `[stream][ordinal:u16][size:u16][crc:u16]` of
26
+ /// preceding successes; NACK is `[3]...` with no history.
27
+ ///
28
+ /// This client sends one record per stream (RESET..END), so every ordinal is
29
+ /// 0 and a stream id identifies the message. After a NACK or a timeout the
30
+ /// glasses have dropped their inflater, so the encoder starts a new context
31
+ /// and the next record carries RESET_CONTEXT.
32
+ enum G2CfwTransport {
33
+ static let serviceId: UInt8 = 0xF0
34
+ static let lensLeft: UInt8 = 1
35
+ static let lensRight: UInt8 = 2
36
+ static let lensBoth: UInt8 = 3
37
+ static let flagCompressed: UInt8 = 4
38
+ static let flagResetContext: UInt8 = 8
39
+ static let packetReset: UInt8 = 0x80
40
+ static let packetEnd: UInt8 = 0x40
41
+ static let ackKind: UInt8 = 1
42
+ static let nackKind: UInt8 = 3
43
+ /// GLASSLYCFW/38: an object-cache reply, sent by each lens before its ACK.
44
+ static let replyKind: UInt8 = 5
45
+ /// A record body is length-prefixed with two bytes.
46
+ static let maxMessageBytes = 0xFFFF
47
+ /// The firmware caps a packet at 252 stream bytes; our writes stay within
48
+ /// the driver's 232-byte packet payload (options byte + chunk + CRC).
49
+ static let maxChunkBytes = 229
50
+
51
+ /// Persistent deflate context: records share dictionary state across
52
+ /// messages, so a keyframe after a delta compresses against it.
53
+ final class Encoder {
54
+ private var stream = z_stream()
55
+ private var ready = false
56
+ private(set) var needsContextReset = true
57
+
58
+ deinit {
59
+ if ready { deflateEnd(&stream) }
60
+ }
61
+
62
+ /// Drop the context: the next record carries RESET_CONTEXT so the
63
+ /// glasses discard theirs too (they already have after a NACK).
64
+ func resetContext() {
65
+ if ready {
66
+ deflateEnd(&stream)
67
+ ready = false
68
+ }
69
+ needsContextReset = true
70
+ }
71
+
72
+ /// One stream record for `message`, or nil when it cannot be framed.
73
+ /// Falls back to an uncompressed record (still resetting the context)
74
+ /// when deflate would not fit the two-byte length.
75
+ ///
76
+ /// Each lens inflates only the records addressed to it, so a record for
77
+ /// one lens can neither continue the shared context (the other lens
78
+ /// would fall out of sync) nor be continued from: it is deflated from a
79
+ /// fresh context and the next record starts fresh again.
80
+ func record(_ message: Data, targets: UInt8) -> Data? {
81
+ guard message.count <= G2CfwTransport.maxMessageBytes, targets & ~G2CfwTransport.lensBoth == 0
82
+ else { return nil }
83
+ let exclusive = targets != G2CfwTransport.lensBoth
84
+ if exclusive { resetContext() }
85
+ defer { if exclusive { resetContext() } }
86
+ let crc = BinaryCodec.crc16CCITT(message)
87
+
88
+ var flags = targets
89
+ var body: Data
90
+ if let compressed = deflateSyncFlush(message), compressed.count <= G2CfwTransport.maxMessageBytes {
91
+ body = compressed
92
+ flags |= G2CfwTransport.flagCompressed
93
+ if needsContextReset {
94
+ flags |= G2CfwTransport.flagResetContext
95
+ needsContextReset = false
96
+ }
97
+ } else {
98
+ // The glasses cannot resume a context this record skipped.
99
+ resetContext()
100
+ body = message
101
+ flags |= G2CfwTransport.flagResetContext
102
+ }
103
+ var record = Data(capacity: 5 + body.count)
104
+ record.append(flags)
105
+ record.append(UInt8(body.count & 0xFF))
106
+ record.append(UInt8((body.count >> 8) & 0xFF))
107
+ record.append(UInt8(crc & 0xFF))
108
+ record.append(UInt8((crc >> 8) & 0xFF))
109
+ record.append(body)
110
+ return record
111
+ }
112
+
113
+ private func deflateSyncFlush(_ message: Data) -> Data? {
114
+ if !ready {
115
+ stream = z_stream()
116
+ guard deflateInit_(&stream, Z_DEFAULT_COMPRESSION, ZLIB_VERSION, Int32(MemoryLayout<z_stream>.size)) == Z_OK
117
+ else { return nil }
118
+ ready = true
119
+ needsContextReset = true
120
+ }
121
+ let bound = Int(deflateBound(&stream, UInt(message.count))) + 16
122
+ var output = Data(count: bound)
123
+ var input = message
124
+ var produced = 0
125
+ let status: Int32 = input.withUnsafeMutableBytes { inRaw -> Int32 in
126
+ output.withUnsafeMutableBytes { outRaw -> Int32 in
127
+ guard let outBase = outRaw.bindMemory(to: Bytef.self).baseAddress else { return Z_MEM_ERROR }
128
+ stream.next_in = inRaw.bindMemory(to: Bytef.self).baseAddress
129
+ stream.avail_in = uInt(message.count)
130
+ stream.next_out = outBase
131
+ stream.avail_out = uInt(bound)
132
+ let rc = deflate(&stream, Z_SYNC_FLUSH)
133
+ produced = bound - Int(stream.avail_out)
134
+ stream.next_in = nil
135
+ stream.next_out = nil
136
+ return rc
137
+ }
138
+ }
139
+ guard status == Z_OK, stream.avail_in == 0, produced >= 4 else {
140
+ resetContext()
141
+ return nil
142
+ }
143
+ output.removeSubrange(produced ..< output.count)
144
+ return output
145
+ }
146
+ }
147
+
148
+ /// Split `stream` (one or more records) into SID-0xf0 packets. `sequence`
149
+ /// is the first packet's `seq` and the stream id; packets count up from it.
150
+ static func packets(stream: Data, sequence: UInt8, targets: UInt8, chunkBytes: Int = maxChunkBytes) -> [Data] {
151
+ let chunk = max(1, min(chunkBytes, 252))
152
+ var packets: [Data] = []
153
+ var offset = 0
154
+ var index: UInt8 = 0
155
+ repeat {
156
+ let end = min(offset + chunk, stream.count)
157
+ var options = targets & lensBoth
158
+ if offset == 0 { options |= packetReset }
159
+ if end >= stream.count { options |= packetEnd }
160
+ var body = Data(capacity: 1 + end - offset)
161
+ body.append(options)
162
+ if end > offset { body.append(stream[stream.startIndex + offset ..< stream.startIndex + end]) }
163
+ var packet = Data(capacity: 10 + body.count)
164
+ packet.append(contentsOf: [0xAA, 0x21, sequence &+ index, UInt8(body.count + 2), 1, 1, serviceId, 0])
165
+ packet.append(body)
166
+ packet.append(contentsOf: BinaryCodec.crc16CCITTLittleEndian(body))
167
+ packets.append(packet)
168
+ index &+= 1
169
+ offset = end
170
+ } while offset < stream.count
171
+ return packets
172
+ }
173
+
174
+ struct Ack: Equatable {
175
+ struct Entry: Equatable {
176
+ let streamId: UInt8
177
+ let ordinal: UInt16
178
+ let size: UInt16
179
+ let crc: UInt16
180
+ }
181
+
182
+ let success: Bool
183
+ let lens: UInt8
184
+ /// The message this reply is about, then preceding successes (newest first).
185
+ let entries: [Entry]
186
+ var current: Entry { entries[0] }
187
+ }
188
+
189
+ /// Parse a reply body (the packet payload with header and CRC stripped).
190
+ static func parseAck(_ body: Data) -> Ack? {
191
+ let b = [UInt8](body)
192
+ guard b.count >= 9, (b.count - 9) % 7 == 0, b.count <= 9 + 3 * 7,
193
+ b[0] == ackKind || b[0] == nackKind,
194
+ b[4] == lensLeft || b[4] == lensRight
195
+ else { return nil }
196
+ if b[0] == nackKind, b.count != 9 { return nil }
197
+ var entries = [Ack.Entry(
198
+ streamId: b[1], ordinal: UInt16(b[2]) | UInt16(b[3]) << 8,
199
+ size: UInt16(b[5]) | UInt16(b[6]) << 8, crc: UInt16(b[7]) | UInt16(b[8]) << 8
200
+ )]
201
+ var i = 9
202
+ while i < b.count {
203
+ entries.append(Ack.Entry(
204
+ streamId: b[i], ordinal: UInt16(b[i + 1]) | UInt16(b[i + 2]) << 8,
205
+ size: UInt16(b[i + 3]) | UInt16(b[i + 4]) << 8, crc: UInt16(b[i + 5]) | UInt16(b[i + 6]) << 8
206
+ ))
207
+ i += 7
208
+ }
209
+ return Ack(success: b[0] == ackKind, lens: b[4], entries: entries)
210
+ }
211
+
212
+ /// Object-cache reply (GLASSLYCFW/38): `[5][stream][ordinal:u16][lens]` then
213
+ /// `[mode][request:u16][status][epoch:u16][revision:u32][extra]`.
214
+ struct CacheReply: Equatable {
215
+ static let applied: UInt8 = 0, stale: UInt8 = 1, missing: UInt8 = 2, capacity: UInt8 = 3
216
+ static let refused: UInt8 = 4, unchanged: UInt8 = 5, delta: UInt8 = 6, snapshot: UInt8 = 7, reset: UInt8 = 8
217
+ let streamId: UInt8
218
+ let ordinal: UInt16
219
+ let lens: UInt8
220
+ let mode: UInt8
221
+ let request: UInt16
222
+ let status: UInt8
223
+ let epoch: UInt16
224
+ let revision: UInt32
225
+ let extra: [UInt8]
226
+
227
+ /// A RESET reply's store size in bytes (0 when the reply did not carry it).
228
+ var storeBytes: Int {
229
+ guard mode == 41, status == Self.applied, extra.count >= 3 else { return 0 }
230
+ return (Int(extra[1]) | Int(extra[2]) << 8) * 1024
231
+ }
232
+
233
+ /// Ids a MISSING reply names (objects and assets share the field).
234
+ var missingIds: [UInt32] {
235
+ guard status == Self.missing, extra.count >= 2 else { return [] }
236
+ var out: [UInt32] = []
237
+ var i = 2
238
+ while i + 4 <= extra.count {
239
+ out.append(UInt32(extra[i]) | UInt32(extra[i + 1]) << 8 | UInt32(extra[i + 2]) << 16 | UInt32(extra[i + 3]) << 24)
240
+ i += 4
241
+ }
242
+ return out
243
+ }
244
+
245
+ var summary: String {
246
+ let names = ["applied", "stale", "missing", "capacity", "refused", "unchanged", "delta", "snapshot", "reset"]
247
+ let name = Int(status) < names.count ? names[Int(status)] : "status \(status)"
248
+ switch status {
249
+ case Self.missing: return "\(name) \(missingIds)"
250
+ case Self.refused: return "\(name) reason \(extra.first ?? 0)"
251
+ case Self.applied, Self.capacity: return (extra.first ?? 0) > 0 ? "\(name), evicted \(extra[0])" : name
252
+ default: return name
253
+ }
254
+ }
255
+ }
256
+
257
+ static func parseCacheReply(_ body: Data) -> CacheReply? {
258
+ let b = [UInt8](body)
259
+ guard b.count >= 15, b[0] == replyKind, b[4] == lensLeft || b[4] == lensRight else { return nil }
260
+ return CacheReply(
261
+ streamId: b[1], ordinal: UInt16(b[2]) | UInt16(b[3]) << 8, lens: b[4],
262
+ mode: b[5], request: UInt16(b[6]) | UInt16(b[7]) << 8, status: b[8],
263
+ epoch: UInt16(b[9]) | UInt16(b[10]) << 8,
264
+ revision: UInt32(b[11]) | UInt32(b[12]) << 8 | UInt32(b[13]) << 16 | UInt32(b[14]) << 24,
265
+ extra: Array(b[15...])
266
+ )
267
+ }
268
+
269
+ /// True for messages of the object cache (modes 37-41), which carry an epoch and a request id.
270
+ static func isCacheMessage(_ message: Data) -> Bool {
271
+ message.count >= 5 && message[message.startIndex] >= 37 && message[message.startIndex] <= 41
272
+ }
273
+
274
+ /// Copy of a cache message with the session epoch filled in and, when the
275
+ /// encoder left the request id 0 (never for STATE, whose bytes are the
276
+ /// revision), `request` stamped: the firmware acknowledges a SHOW or HIDE
277
+ /// repeating the last applied request id without applying it again, so
278
+ /// every distinct message needs its own. Other messages return unchanged.
279
+ static func stamp(_ message: Data, epoch: UInt16, request: UInt16) -> Data {
280
+ guard isCacheMessage(message) else { return message }
281
+ var out = Data(message)
282
+ out[out.startIndex + 1] = UInt8(epoch & 0xFF)
283
+ out[out.startIndex + 2] = UInt8(epoch >> 8)
284
+ if out[out.startIndex] != 40, out[out.startIndex + 3] == 0, out[out.startIndex + 4] == 0, request != 0 {
285
+ out[out.startIndex + 3] = UInt8(request & 0xFF)
286
+ out[out.startIndex + 4] = UInt8(request >> 8)
287
+ }
288
+ return out
289
+ }
290
+
291
+ /// Read-only, single-packet parse of a raw notify frame: the reply body if
292
+ /// this is a complete SID-0xf0 frame with a valid CRC.
293
+ static func replyBody(fromNotifyFrame raw: Data) -> Data? {
294
+ guard raw.count >= 10 else { return nil }
295
+ let b = { (i: Int) -> UInt8 in raw[raw.startIndex + i] }
296
+ guard b(0) == 0xAA, Int(b(3)) + 8 == raw.count, b(4) == 1, b(5) == 1, b(6) == serviceId else { return nil }
297
+ let resultCode = (Int(b(7)) >> 1) & 0x0F
298
+ guard resultCode == 0 else { return nil }
299
+ let body = raw.subdata(in: (raw.startIndex + 8) ..< (raw.endIndex - 2))
300
+ let crc = BinaryCodec.crc16CCITT(body)
301
+ guard b(raw.count - 2) == UInt8(crc & 0xFF), b(raw.count - 1) == UInt8(crc >> 8) else { return nil }
302
+ return body
303
+ }
304
+
305
+ /// ACK deadline for a message of `streamBytes`: a both-lens stream acks at
306
+ /// ~1.1 KB/s once the right lens has it over the frame bridge (GLASSLYCFW/39),
307
+ /// so allow 1 ms per byte on top of 3.5 s. A tighter deadline times out
308
+ /// uploads that are still landing, and each timeout re-uploads more.
309
+ static func ackTimeoutNs(streamBytes: Int) -> UInt64 {
310
+ 3_500_000_000 + UInt64(streamBytes) * 1_000_000
311
+ }
312
+ }
313
+
314
+ /// Thread-safe holder for the single in-flight stream ACK continuation — the
315
+ /// SID-0xf0 counterpart of `ImgAckBox`, resolved inline on the BLE callback
316
+ /// queue. A message sent to both lenses completes when each lens has acked
317
+ /// it (the right lens's reply returns through the left, our ingress); any
318
+ /// NACK, a timeout, or cancellation fails it.
319
+ final class G2CfwAckBox {
320
+ private let lock = NSLock()
321
+ private var streamId: UInt8?
322
+ private var expectedLenses: UInt8 = 0
323
+ private var ackedLenses: UInt8 = 0
324
+ private var cont: CheckedContinuation<Bool, Never>?
325
+ private var token: UInt64 = 0
326
+ private var timer: Task<Void, Never>?
327
+ private var lastSuccessMs: Int64 = 0
328
+ private var lastOutcome: (at: TimeInterval, reason: String)?
329
+ /// Object-cache replies of the armed stream, by lens; kept until the next arm.
330
+ private var replies: [UInt8: G2CfwTransport.CacheReply] = [:]
331
+
332
+ func outcome() -> (at: TimeInterval, reason: String)? {
333
+ lock.lock()
334
+ defer { lock.unlock() }
335
+ return lastOutcome
336
+ }
337
+
338
+ /// The cache replies the last message drew, by lens (1 left, 2 right).
339
+ func cacheReplies() -> [UInt8: G2CfwTransport.CacheReply] {
340
+ lock.lock()
341
+ defer { lock.unlock() }
342
+ return replies
343
+ }
344
+
345
+ /// Feed one cache reply; it is kept when it belongs to the armed stream.
346
+ func record(_ reply: G2CfwTransport.CacheReply) {
347
+ lock.lock()
348
+ if let armed = streamId, armed == reply.streamId, reply.ordinal == 0 {
349
+ replies[reply.lens] = reply
350
+ }
351
+ lock.unlock()
352
+ }
353
+
354
+ /// Milliseconds since the last fully acked stream, or Int64.max if none yet.
355
+ func msSinceLastSuccess() -> Int64 {
356
+ lock.lock()
357
+ let last = lastSuccessMs
358
+ lock.unlock()
359
+ guard last > 0 else { return .max }
360
+ return Int64(Date().timeIntervalSince1970 * 1000) - last
361
+ }
362
+
363
+ /// Arm before sending, then `send`. The supplied guard expires with this
364
+ /// wait: queued packets must check it before writing, even while waiting
365
+ /// for BLE credits. A timed-out stream must not replay during recovery.
366
+ @MainActor
367
+ func wait(streamId: UInt8, lenses: UInt8, timeoutNs: UInt64, send: (_ isPending: @escaping () -> Bool) -> Void) async -> Bool {
368
+ await withTaskCancellationHandler {
369
+ await withCheckedContinuation { cont in
370
+ let token = arm(streamId: streamId, lenses: lenses, cont: cont)
371
+ guard !Task.isCancelled else {
372
+ timeout(token: token)
373
+ return
374
+ }
375
+ let timer = Task.detached { [self] in
376
+ try? await Task.sleep(nanoseconds: timeoutNs)
377
+ guard !Task.isCancelled else { return }
378
+ timeout(token: token)
379
+ }
380
+ attachTimer(timer, token: token)
381
+ send { [self] in isPending(token: token) }
382
+ }
383
+ } onCancel: {
384
+ self.fail(reason: "cancelled")
385
+ }
386
+ }
387
+
388
+ private func isPending(token: UInt64) -> Bool {
389
+ lock.lock()
390
+ defer { lock.unlock() }
391
+ return self.token == token && cont != nil
392
+ }
393
+
394
+ func arm(streamId: UInt8, lenses: UInt8, cont: CheckedContinuation<Bool, Never>) -> UInt64 {
395
+ lock.lock()
396
+ token &+= 1
397
+ let t = token
398
+ self.streamId = streamId
399
+ expectedLenses = lenses & G2CfwTransport.lensBoth
400
+ ackedLenses = 0
401
+ self.cont = cont
402
+ lastOutcome = nil
403
+ replies.removeAll()
404
+ let stale = timer
405
+ timer = nil
406
+ lock.unlock()
407
+ stale?.cancel()
408
+ return t
409
+ }
410
+
411
+ func attachTimer(_ task: Task<Void, Never>, token: UInt64) {
412
+ lock.lock()
413
+ let current = self.token == token && cont != nil
414
+ if current {
415
+ timer = task
416
+ }
417
+ lock.unlock()
418
+ if !current {
419
+ task.cancel()
420
+ }
421
+ }
422
+
423
+ func timeout(token: UInt64) {
424
+ lock.lock()
425
+ guard self.token == token, let c = cont else {
426
+ lock.unlock()
427
+ return
428
+ }
429
+ clearLocked(reason: "timeout")
430
+ lock.unlock()
431
+ c.resume(returning: false)
432
+ }
433
+
434
+ private func fail(reason: String) {
435
+ lock.lock()
436
+ guard let c = cont else {
437
+ lock.unlock()
438
+ return
439
+ }
440
+ let t = timer
441
+ clearLocked(reason: reason)
442
+ lock.unlock()
443
+ t?.cancel()
444
+ c.resume(returning: false)
445
+ }
446
+
447
+ /// Feed one reply. Returns true when it resolved the in-flight message.
448
+ @discardableResult
449
+ func resolve(_ ack: G2CfwTransport.Ack) -> Bool {
450
+ lock.lock()
451
+ guard let armed = streamId, armed == ack.current.streamId, ack.current.ordinal == 0, let c = cont else {
452
+ lock.unlock()
453
+ return false
454
+ }
455
+ if !ack.success {
456
+ let t = timer
457
+ clearLocked(reason: "nack")
458
+ lock.unlock()
459
+ t?.cancel()
460
+ c.resume(returning: false)
461
+ return true
462
+ }
463
+ ackedLenses |= ack.lens & expectedLenses
464
+ guard ackedLenses == expectedLenses else {
465
+ lock.unlock()
466
+ return false
467
+ }
468
+ let t = timer
469
+ lastSuccessMs = Int64(Date().timeIntervalSince1970 * 1000)
470
+ clearLocked(reason: "ack")
471
+ lock.unlock()
472
+ t?.cancel()
473
+ c.resume(returning: true)
474
+ return true
475
+ }
476
+
477
+ private func clearLocked(reason: String) {
478
+ streamId = nil
479
+ expectedLenses = 0
480
+ ackedLenses = 0
481
+ cont = nil
482
+ timer = nil
483
+ lastOutcome = (ProcessInfo.processInfo.systemUptime, reason)
484
+ }
485
+ }
@@ -0,0 +1,154 @@
1
+ import Foundation
2
+
3
+ /// Bounded, main-actor counters. No payload text, per-packet logging, or sample arrays.
4
+ /// Durations use uptime; wall time is only for correlating exported reports.
5
+ /// Disabled by default; opt in explicitly when investigating display performance.
6
+ /// Lines stay small: a sample is logged only for a window with display work, a
7
+ /// backlog or a late tick; zero, false and empty values and sub-millisecond
8
+ /// timings are left out; the link's fixed facts go in a separate session line.
9
+ @MainActor
10
+ final class G2DisplayDiagnostics {
11
+ /// Link upkeep that runs whether or not anything is drawn: IMU reports,
12
+ /// touch debouncing and heartbeat writes alone leave a window idle.
13
+ private static let ambientCounters: Set<String> = [
14
+ "imuReceived", "imuSamples", "touchDebounced", "txBytes", "txPackets", "readyCallbacksL", "readyCallbacksR",
15
+ ]
16
+ private static let ambientTimings: Set<String> = ["notifyDispatchMs", "creditWaitMsL", "creditWaitMsR", "writeQueueMs"]
17
+ /// A tick this late means the main actor or the process was held up: logged even when idle.
18
+ static let stretchedWindow: TimeInterval = 2
19
+ /// The session line is repeated this often so a trimmed log excerpt still has it.
20
+ static let sessionRepeat: TimeInterval = 60
21
+
22
+ nonisolated static var now: TimeInterval {
23
+ ProcessInfo.processInfo.systemUptime
24
+ }
25
+
26
+ struct Timing {
27
+ var count = 0
28
+ var total = 0.0
29
+ var maximum = 0.0
30
+ mutating func add(_ ms: Double) {
31
+ count += 1
32
+ total += max(0, ms)
33
+ maximum = max(maximum, ms)
34
+ }
35
+ }
36
+
37
+ @MainActor final class Fragment {
38
+ let queuedAt = G2DisplayDiagnostics.now
39
+ var firstWrite: TimeInterval?
40
+ var lastWrite: TimeInterval?
41
+ func wrote() {
42
+ let now = G2DisplayDiagnostics.now
43
+ if firstWrite == nil {
44
+ firstWrite = now
45
+ }
46
+ lastWrite = now
47
+ }
48
+ }
49
+
50
+ private let enabled: Bool
51
+ private var started: TimeInterval
52
+ private var counters: [String: Int] = [:]
53
+ private var timings: [String: Timing] = [:]
54
+ /// Short: it is on every line, and only has to tell apart the runs in one log.
55
+ private let run = String(UUID().uuidString.prefix(8)).lowercased()
56
+ private var sequence = 0
57
+ private var lastStall: TimeInterval = -.infinity
58
+ private var sessionKey: String?
59
+ private var lastSession: TimeInterval = -.infinity
60
+
61
+ init(enabled: Bool = false, now: TimeInterval = G2DisplayDiagnostics.now) {
62
+ self.enabled = enabled
63
+ started = now
64
+ }
65
+
66
+ var isSampleDue: Bool {
67
+ enabled && Self.now - started >= 1
68
+ }
69
+
70
+ func count(_ name: String, _ amount: Int = 1) {
71
+ guard enabled else { return }
72
+ counters[name, default: 0] += amount
73
+ }
74
+
75
+ func timing(_ name: String, _ milliseconds: Double) {
76
+ guard enabled else { return }
77
+ timings[name, default: Timing()].add(milliseconds)
78
+ }
79
+
80
+ private func event(_ type: String, fields: [String: Any]) -> String {
81
+ sequence += 1
82
+ var record = fields
83
+ record["v"] = 1
84
+ record["run"] = run
85
+ record["id"] = sequence
86
+ record["t"] = Int64(Date().timeIntervalSince1970 * 1000)
87
+ record["type"] = type
88
+ let data = try? JSONSerialization.data(withJSONObject: record, options: [.sortedKeys])
89
+ return "G2PERF " + (data.flatMap { String(data: $0, encoding: .utf8) } ?? "{}")
90
+ }
91
+
92
+ /// At most one detail line/second; every failure still enters the summary counters.
93
+ func stall(fields: [String: Any], now: TimeInterval = G2DisplayDiagnostics.now) -> String? {
94
+ guard enabled, now - lastStall >= 1 else { return nil }
95
+ lastStall = now
96
+ return event("stall", fields: fields)
97
+ }
98
+
99
+ /// The link's fixed facts (firmware, MTUs, frame cap): logged when they
100
+ /// change and once a minute, instead of on every sample.
101
+ func session(_ fields: [String: Any], now: TimeInterval = G2DisplayDiagnostics.now) -> String? {
102
+ guard enabled else { return nil }
103
+ let key = fields.sorted { $0.key < $1.key }.map { "\($0.key)=\($0.value)" }.joined(separator: ",")
104
+ guard key != sessionKey || now - lastSession >= Self.sessionRepeat else { return nil }
105
+ sessionKey = key
106
+ lastSession = now
107
+ return event("session", fields: fields)
108
+ }
109
+
110
+ /// The window's summary, or nil when it is not due or was idle. `busy`
111
+ /// means something is queued for the glasses, which is never idle.
112
+ func sample(context: [String: Any], busy: Bool = false, now: TimeInterval = G2DisplayDiagnostics.now) -> String? {
113
+ guard enabled else { return nil }
114
+ let elapsed = now - started
115
+ guard elapsed >= 1 else { return nil }
116
+ started = now
117
+ let counters = counters
118
+ let timings = timings
119
+ self.counters.removeAll(keepingCapacity: true)
120
+ self.timings.removeAll(keepingCapacity: true)
121
+ let active = busy || elapsed >= Self.stretchedWindow
122
+ || counters.keys.contains { !Self.ambientCounters.contains($0) }
123
+ || timings.keys.contains { !Self.ambientTimings.contains($0) }
124
+ guard active else { return nil }
125
+ var fields = context
126
+ fields["windowMs"] = Int((elapsed * 1000).rounded())
127
+ for (key, value) in counters {
128
+ fields[key] = value
129
+ }
130
+ // A timing that never reached a millisecond shows no delay: left out.
131
+ for (key, value) in timings where value.maximum >= 1 {
132
+ fields[key + "Avg"] = Self.tenths(value.total / Double(value.count))
133
+ fields[key + "Max"] = Self.tenths(value.maximum)
134
+ }
135
+ return event("sample", fields: fields.filter { !Self.isEmpty($0.value) })
136
+ }
137
+
138
+ /// One decimal that serializes as written: a Double 7.2 prints as 7.2000000000000002.
139
+ private static func tenths(_ value: Double) -> NSDecimalNumber {
140
+ let scaled = Int((value * 10).rounded())
141
+ return NSDecimalNumber(mantissa: UInt64(abs(scaled)), exponent: -1, isNegative: scaled < 0)
142
+ }
143
+
144
+ private static func isEmpty(_ value: Any) -> Bool {
145
+ switch value {
146
+ case let value as NSDecimalNumber: return value == .zero
147
+ case let value as Bool: return !value
148
+ case let value as Int: return value == 0
149
+ case let value as Double: return value == 0
150
+ case let value as String: return value.isEmpty
151
+ default: return false
152
+ }
153
+ }
154
+ }
@@ -288,8 +288,8 @@ enum G2FlashProtocol {
288
288
  }
289
289
 
290
290
  static let goldenStock = GoldenVector(
291
- sha256: "a03fbea9f68a9de6bc271daabb9f3a41c59053d1086622c76a4e990f829cc561",
292
- ps: 3_697_284, progEnd: 0x007B_EA64, pass: true
291
+ sha256: "187ccf2bcc5c17a212106e8a376745511e8289c4232b634a7ea94b9bf25a0979",
292
+ ps: 3_758_720, progEnd: 0x007C_DA60, pass: true
293
293
  )
294
294
 
295
295
  /// Run the parse+guard on `img` and assert it reproduces the golden vector. Returns
@@ -27,18 +27,18 @@ enum G2FirmwareCatalog {
27
27
  G2FirmwareArtifact(
28
28
  id: "stock",
29
29
  displayName: "Stock firmware",
30
- version: "2.2.9.22",
31
- fileName: "g2-stock-2.2.9.22.evenota.bin",
32
- sha256: "a03fbea9f68a9de6bc271daabb9f3a41c59053d1086622c76a4e990f829cc561",
33
- size: 4_476_518
30
+ version: "2.3.0.24",
31
+ fileName: "g2-stock-2.3.0.24.evenota.bin",
32
+ sha256: "187ccf2bcc5c17a212106e8a376745511e8289c4232b634a7ea94b9bf25a0979",
33
+ size: 4_537_963
34
34
  ),
35
35
  G2FirmwareArtifact(
36
36
  id: "glassly-cfw",
37
- displayName: "Glassly CFW",
38
- version: "2.2.9.22 CFW",
39
- fileName: "g2-glassly-cfw-2.2.9.22.evenota.bin",
40
- sha256: "ceeede999529505ebc0a1369f57ab3c941273379a90b16669ccd39cc6bd99135",
41
- size: 4_515_846
37
+ displayName: "Glassly CFW v39",
38
+ version: "2.3.0.24",
39
+ fileName: "g2-glassly-cfw-2.3.0.24.evenota.bin",
40
+ sha256: "4c7bf9a552df1a42f26b3378425c7c2121ca4058fed7283ec863c3f8d2cd13fe",
41
+ size: 4_661_963
42
42
  ),
43
43
  ]
44
44