@glassly/bluetooth-sdk 0.1.0-dev.98 → 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 +1075 -257
  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 +1434 -504
  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,402 @@
1
+ package com.glassly.bluetoothsdk.sgcs
2
+
3
+ import java.io.ByteArrayOutputStream
4
+ import java.util.zip.Deflater
5
+ import kotlinx.coroutines.CompletableDeferred
6
+
7
+ /**
8
+ * Private SID-0xf0 message-stream transport of the Glassly CFW (GLASSLYCFW/31,
9
+ * upstream Faceclaw/10+). Custom commands no longer ride an EvenHub image
10
+ * container: they are records in a byte stream that the firmware reconstructs
11
+ * before any TPL reassembly, on whichever lenses the options byte selects (the
12
+ * other lens receives them over the frame bridge, in arrival order), and each
13
+ * record is answered with an ACK or NACK notification through the BLE ingress
14
+ * lens.
15
+ *
16
+ * Wire format (see g2flash `patches/message_transport.c`):
17
+ *
18
+ * - packet: `[0xAA][0x21][seq][len+2][1][1][0xF0][0][options][stream bytes][crc16 LE]`
19
+ * where `options` = lens bits (1 left, 2 right) | 0x80 RESET on the first
20
+ * packet of a stream | 0x40 END on its last, `seq` increments per packet
21
+ * (uint8 wrap) and the CRC (CCITT-FALSE) covers `options` + stream bytes.
22
+ * The stream id is the RESET packet's `seq`.
23
+ * - record: `[flags][len:u16][crc16 of the plain message:u16][body]` where
24
+ * flags = lens bits | 4 COMPRESSED | 8 RESET_CONTEXT. A compressed body is
25
+ * the output of one persistent deflate context ending in a SYNC_FLUSH (never
26
+ * finished); RESET_CONTEXT tells the glasses to start a fresh inflater, which
27
+ * the phone must set whenever its own deflater is new.
28
+ * - ACK: `[1][stream][ordinal:u16][lens][size:u16][crc:u16]` + up to three
29
+ * 7-byte history entries of preceding successes; NACK is `[3]...` alone.
30
+ *
31
+ * This client sends one record per stream (RESET..END), so every ordinal is 0
32
+ * and a stream id identifies the message. After a NACK or a timeout the
33
+ * glasses have dropped their inflater, so the encoder starts a new context and
34
+ * the next record carries RESET_CONTEXT.
35
+ */
36
+ internal object G2CfwTransport {
37
+ const val SERVICE_ID: Byte = 0xF0.toByte()
38
+ const val LENS_LEFT = 1
39
+ const val LENS_RIGHT = 2
40
+ const val LENS_BOTH = 3
41
+ const val FLAG_COMPRESSED = 4
42
+ const val FLAG_RESET_CONTEXT = 8
43
+ const val PACKET_RESET = 0x80
44
+ const val PACKET_END = 0x40
45
+ const val ACK_KIND = 1
46
+ const val NACK_KIND = 3
47
+ /** GLASSLYCFW/38: an object-cache reply, sent by each lens before its ACK. */
48
+ const val REPLY_KIND = 5
49
+ const val MAX_MESSAGE_BYTES = 0xFFFF
50
+
51
+ /**
52
+ * The firmware caps a packet at 252 stream bytes; our writes stay within the
53
+ * driver's packet payload (`G2BLE.MAX_PACKET_PAYLOAD` = 236: options byte +
54
+ * chunk + CRC).
55
+ */
56
+ const val MAX_CHUNK_BYTES = 233
57
+
58
+ fun crc16(bytes: ByteArray, from: Int = 0, to: Int = bytes.size): Int {
59
+ var crc = 0xFFFF
60
+ for (i in from until to) {
61
+ crc = crc xor ((bytes[i].toInt() and 0xFF) shl 8)
62
+ repeat(8) {
63
+ crc = if (crc and 0x8000 != 0) ((crc shl 1) xor 0x1021) and 0xFFFF else (crc shl 1) and 0xFFFF
64
+ }
65
+ }
66
+ return crc
67
+ }
68
+
69
+ /**
70
+ * ACK deadline for a stream: a both-lens stream acks at ~1.1 KB/s once the
71
+ * right lens has it over the frame bridge (GLASSLYCFW/39), so allow 1 ms per
72
+ * byte on top of 3.5 s. A tighter deadline times out uploads that are still
73
+ * landing, and each timeout re-uploads more.
74
+ */
75
+ fun ackTimeoutMs(streamBytes: Int): Long = 3500L + streamBytes
76
+
77
+ /**
78
+ * Persistent deflate context: records share dictionary state across
79
+ * messages, so a keyframe after a delta compresses against it.
80
+ */
81
+ class Encoder {
82
+ private var deflater: Deflater? = null
83
+ var needsContextReset = true
84
+ private set
85
+
86
+ /** Drop the context: the next record carries RESET_CONTEXT. */
87
+ fun resetContext() {
88
+ deflater?.end()
89
+ deflater = null
90
+ needsContextReset = true
91
+ }
92
+
93
+ /**
94
+ * One stream record for `message`, or null when it cannot be framed.
95
+ * Falls back to an uncompressed record (still resetting the context)
96
+ * when deflate would not fit the two-byte length.
97
+ */
98
+ fun record(message: ByteArray, targets: Int): ByteArray? {
99
+ if (message.size > MAX_MESSAGE_BYTES || targets and LENS_BOTH.inv() != 0) return null
100
+ // Each lens inflates only the records addressed to it, so a record for one
101
+ // lens can neither continue the shared context (the other lens would fall
102
+ // out of sync) nor be continued from: deflate it from a fresh context and
103
+ // start fresh again afterwards.
104
+ val exclusive = targets != LENS_BOTH
105
+ if (exclusive) resetContext()
106
+ try {
107
+ return frame(message, targets, crc16(message))
108
+ } finally {
109
+ if (exclusive) resetContext()
110
+ }
111
+ }
112
+
113
+ private fun frame(message: ByteArray, targets: Int, crc: Int): ByteArray {
114
+ var flags = targets
115
+ val compressed = deflateSyncFlush(message)
116
+ val body: ByteArray
117
+ if (compressed != null && compressed.size <= MAX_MESSAGE_BYTES) {
118
+ body = compressed
119
+ flags = flags or FLAG_COMPRESSED
120
+ if (needsContextReset) {
121
+ flags = flags or FLAG_RESET_CONTEXT
122
+ needsContextReset = false
123
+ }
124
+ } else {
125
+ // The glasses cannot resume a context this record skipped.
126
+ resetContext()
127
+ body = message
128
+ flags = flags or FLAG_RESET_CONTEXT
129
+ }
130
+ val out = ByteArrayOutputStream(5 + body.size)
131
+ out.write(flags)
132
+ out.write(body.size and 0xFF)
133
+ out.write((body.size ushr 8) and 0xFF)
134
+ out.write(crc and 0xFF)
135
+ out.write((crc ushr 8) and 0xFF)
136
+ out.write(body)
137
+ return out.toByteArray()
138
+ }
139
+
140
+ private fun deflateSyncFlush(message: ByteArray): ByteArray? {
141
+ val z = deflater ?: Deflater(Deflater.DEFAULT_COMPRESSION).also {
142
+ deflater = it
143
+ needsContextReset = true
144
+ }
145
+ return try {
146
+ z.setInput(message)
147
+ val out = ByteArrayOutputStream(message.size / 2 + 64)
148
+ val buffer = ByteArray(8192)
149
+ do {
150
+ val n = z.deflate(buffer, 0, buffer.size, Deflater.SYNC_FLUSH)
151
+ out.write(buffer, 0, n)
152
+ } while (n == buffer.size || !z.needsInput())
153
+ val bytes = out.toByteArray()
154
+ if (bytes.size < 4) {
155
+ resetContext()
156
+ null
157
+ } else {
158
+ bytes
159
+ }
160
+ } catch (_: Exception) {
161
+ resetContext()
162
+ null
163
+ }
164
+ }
165
+ }
166
+
167
+ /**
168
+ * Split `stream` (one or more records) into SID-0xf0 packets. `sequence` is
169
+ * the first packet's `seq` and the stream id; packets count up from it.
170
+ */
171
+ fun packets(stream: ByteArray, sequence: Int, targets: Int, chunkBytes: Int = MAX_CHUNK_BYTES): List<ByteArray> {
172
+ val chunk = chunkBytes.coerceIn(1, 252)
173
+ val packets = mutableListOf<ByteArray>()
174
+ var offset = 0
175
+ var index = 0
176
+ do {
177
+ val end = minOf(offset + chunk, stream.size)
178
+ var options = targets and LENS_BOTH
179
+ if (offset == 0) options = options or PACKET_RESET
180
+ if (end >= stream.size) options = options or PACKET_END
181
+ val body = ByteArray(1 + end - offset)
182
+ body[0] = options.toByte()
183
+ System.arraycopy(stream, offset, body, 1, end - offset)
184
+ val crc = crc16(body)
185
+ val packet = ByteArray(10 + body.size)
186
+ packet[0] = 0xAA.toByte()
187
+ packet[1] = 0x21
188
+ packet[2] = ((sequence + index) and 0xFF).toByte()
189
+ packet[3] = (body.size + 2).toByte()
190
+ packet[4] = 1
191
+ packet[5] = 1
192
+ packet[6] = SERVICE_ID
193
+ packet[7] = 0
194
+ System.arraycopy(body, 0, packet, 8, body.size)
195
+ packet[8 + body.size] = (crc and 0xFF).toByte()
196
+ packet[9 + body.size] = ((crc ushr 8) and 0xFF).toByte()
197
+ packets.add(packet)
198
+ index += 1
199
+ offset = end
200
+ } while (offset < stream.size)
201
+ return packets
202
+ }
203
+
204
+ data class AckEntry(val streamId: Int, val ordinal: Int, val size: Int, val crc: Int)
205
+
206
+ /** The message this reply is about ([entries] first), then preceding successes. */
207
+ data class Ack(val success: Boolean, val lens: Int, val entries: List<AckEntry>) {
208
+ val current: AckEntry get() = entries[0]
209
+ }
210
+
211
+ private fun u16(b: ByteArray, i: Int): Int = (b[i].toInt() and 0xFF) or ((b[i + 1].toInt() and 0xFF) shl 8)
212
+
213
+ /** Parse a reply body (the packet payload with header and CRC stripped). */
214
+ fun parseAck(b: ByteArray): Ack? {
215
+ if (b.size < 9 || (b.size - 9) % 7 != 0 || b.size > 9 + 3 * 7) return null
216
+ val kind = b[0].toInt() and 0xFF
217
+ val lens = b[4].toInt() and 0xFF
218
+ if (kind != ACK_KIND && kind != NACK_KIND) return null
219
+ if (lens != LENS_LEFT && lens != LENS_RIGHT) return null
220
+ if (kind == NACK_KIND && b.size != 9) return null
221
+ val entries = mutableListOf(AckEntry(b[1].toInt() and 0xFF, u16(b, 2), u16(b, 5), u16(b, 7)))
222
+ var i = 9
223
+ while (i < b.size) {
224
+ entries.add(AckEntry(b[i].toInt() and 0xFF, u16(b, i + 1), u16(b, i + 3), u16(b, i + 5)))
225
+ i += 7
226
+ }
227
+ return Ack(kind == ACK_KIND, lens, entries)
228
+ }
229
+
230
+ /**
231
+ * Object-cache reply (GLASSLYCFW/38): `[5][stream][ordinal:u16][lens]` then
232
+ * `[mode][request:u16][status][epoch:u16][revision:u32][extra]`.
233
+ */
234
+ data class CacheReply(
235
+ val streamId: Int, val ordinal: Int, val lens: Int, val mode: Int, val request: Int,
236
+ val status: Int, val epoch: Int, val revision: Long, val extra: ByteArray,
237
+ ) {
238
+ companion object {
239
+ const val APPLIED = 0
240
+ const val STALE = 1
241
+ const val MISSING = 2
242
+ const val CAPACITY = 3
243
+ const val REFUSED = 4
244
+ const val UNCHANGED = 5
245
+ const val DELTA = 6
246
+ const val SNAPSHOT = 7
247
+ const val RESET = 8
248
+ private val NAMES = listOf("applied", "stale", "missing", "capacity", "refused", "unchanged", "delta", "snapshot", "reset")
249
+ }
250
+
251
+ /** A RESET reply's store size in bytes (0 when the reply did not carry it). */
252
+ val storeBytes: Int
253
+ get() = if (mode == 41 && status == APPLIED && extra.size >= 3) ((extra[1].toInt() and 0xFF) or ((extra[2].toInt() and 0xFF) shl 8)) * 1024 else 0
254
+
255
+ /** Ids a MISSING reply names (objects and assets share the field). */
256
+ val missingIds: List<Int>
257
+ get() {
258
+ if (status != MISSING || extra.size < 2) return emptyList()
259
+ val out = mutableListOf<Int>()
260
+ var i = 2
261
+ while (i + 4 <= extra.size) {
262
+ out.add((extra[i].toInt() and 0xFF) or ((extra[i + 1].toInt() and 0xFF) shl 8) or ((extra[i + 2].toInt() and 0xFF) shl 16) or ((extra[i + 3].toInt() and 0xFF) shl 24))
263
+ i += 4
264
+ }
265
+ return out
266
+ }
267
+
268
+ val summary: String
269
+ get() {
270
+ val name = NAMES.getOrNull(status) ?: "status $status"
271
+ return when (status) {
272
+ MISSING -> "$name $missingIds"
273
+ REFUSED -> "$name reason ${extra.firstOrNull()?.toInt() ?: 0}"
274
+ APPLIED, CAPACITY -> if ((extra.firstOrNull()?.toInt() ?: 0) > 0) "$name, evicted ${extra[0].toInt()}" else name
275
+ else -> name
276
+ }
277
+ }
278
+ }
279
+
280
+ fun parseCacheReply(b: ByteArray): CacheReply? {
281
+ if (b.size < 15 || (b[0].toInt() and 0xFF) != REPLY_KIND) return null
282
+ val lens = b[4].toInt() and 0xFF
283
+ if (lens != LENS_LEFT && lens != LENS_RIGHT) return null
284
+ val revision = (u16(b, 11).toLong() or (u16(b, 13).toLong() shl 16))
285
+ return CacheReply(b[1].toInt() and 0xFF, u16(b, 2), lens, b[5].toInt() and 0xFF, u16(b, 6), b[8].toInt() and 0xFF, u16(b, 9), revision, b.copyOfRange(15, b.size))
286
+ }
287
+
288
+ /** True for messages of the object cache (modes 37-41), which carry an epoch and a request id. */
289
+ fun isCacheMessage(message: ByteArray): Boolean = message.size >= 5 && (message[0].toInt() and 0xFF) in 37..41
290
+
291
+ /**
292
+ * Copy of a cache message with the session epoch filled in and, when the encoder left
293
+ * the request id 0 (never for STATE, whose bytes are the revision), `request` stamped:
294
+ * the firmware acknowledges a SHOW or HIDE repeating the last applied request id
295
+ * without applying it again, so every distinct message needs its own.
296
+ */
297
+ fun stamp(message: ByteArray, epoch: Int, request: Int): ByteArray {
298
+ if (!isCacheMessage(message)) return message
299
+ val out = message.copyOf()
300
+ out[1] = (epoch and 0xFF).toByte()
301
+ out[2] = ((epoch ushr 8) and 0xFF).toByte()
302
+ if ((out[0].toInt() and 0xFF) != 40 && out[3].toInt() == 0 && out[4].toInt() == 0 && request != 0) {
303
+ out[3] = (request and 0xFF).toByte()
304
+ out[4] = ((request ushr 8) and 0xFF).toByte()
305
+ }
306
+ return out
307
+ }
308
+
309
+ /**
310
+ * Read-only, single-packet parse of a raw notify frame: the reply body if
311
+ * this is a complete SID-0xf0 frame with a valid CRC.
312
+ */
313
+ fun replyBody(raw: ByteArray): ByteArray? {
314
+ if (raw.size < 10) return null
315
+ if (raw[0] != 0xAA.toByte() || (raw[3].toInt() and 0xFF) + 8 != raw.size) return null
316
+ if (raw[4].toInt() != 1 || raw[5].toInt() != 1 || raw[6] != SERVICE_ID) return null
317
+ if (((raw[7].toInt() and 0xFF) shr 1) and 0x0F != 0) return null
318
+ val body = raw.copyOfRange(8, raw.size - 2)
319
+ val crc = crc16(body)
320
+ if (raw[raw.size - 2] != (crc and 0xFF).toByte() || raw[raw.size - 1] != ((crc ushr 8) and 0xFF).toByte()) return null
321
+ return body
322
+ }
323
+ }
324
+
325
+ /**
326
+ * The single in-flight stream ACK, completed on the BLE callback thread. A
327
+ * message sent to both lenses completes when each lens has acked it; any
328
+ * NACK fails it. Completing a finished deferred is a no-op, so duplicate and
329
+ * stale replies are harmless.
330
+ */
331
+ internal class G2CfwAckBox {
332
+ @Volatile private var streamId: Int? = null
333
+ @Volatile private var expectedLenses = 0
334
+ @Volatile private var ackedLenses = 0
335
+ @Volatile private var pending: CompletableDeferred<Boolean>? = null
336
+ @Volatile var lastSuccessMs: Long = 0
337
+ private set
338
+ @Volatile var lastOutcome: String = ""
339
+ private set
340
+ private val replies = mutableMapOf<Int, G2CfwTransport.CacheReply>()
341
+
342
+ /** The cache replies the last message drew, by lens (1 left, 2 right). */
343
+ @Synchronized
344
+ fun cacheReplies(): Map<Int, G2CfwTransport.CacheReply> = replies.toMap()
345
+
346
+ /** Feed one cache reply; it is kept when it belongs to the armed stream. */
347
+ @Synchronized
348
+ fun record(reply: G2CfwTransport.CacheReply) {
349
+ val armed = streamId ?: return
350
+ if (armed == reply.streamId && reply.ordinal == 0) replies[reply.lens] = reply
351
+ }
352
+
353
+ /** Arm for `streamId`; returns the deferred to await after sending. */
354
+ @Synchronized
355
+ fun arm(streamId: Int, lenses: Int): CompletableDeferred<Boolean> {
356
+ val deferred = CompletableDeferred<Boolean>()
357
+ this.streamId = streamId
358
+ expectedLenses = lenses and G2CfwTransport.LENS_BOTH
359
+ ackedLenses = 0
360
+ pending = deferred
361
+ lastOutcome = ""
362
+ replies.clear()
363
+ return deferred
364
+ }
365
+
366
+ /** Queue guard scoped to this arm, even when an 8-bit stream ID is reused. */
367
+ @Synchronized
368
+ fun isPending(deferred: CompletableDeferred<Boolean>): Boolean = pending === deferred && deferred.isActive
369
+
370
+ /** Forget `deferred` if it is still the armed one (after its await ended). */
371
+ @Synchronized
372
+ fun disarm(deferred: CompletableDeferred<Boolean>, outcome: String) {
373
+ if (pending === deferred) {
374
+ pending = null
375
+ streamId = null
376
+ if (lastOutcome.isEmpty()) lastOutcome = outcome
377
+ }
378
+ }
379
+
380
+ /** Feed one reply. Returns true when it resolved the in-flight message. */
381
+ @Synchronized
382
+ fun resolve(ack: G2CfwTransport.Ack): Boolean {
383
+ val deferred = pending ?: return false
384
+ val armed = streamId ?: return false
385
+ if (armed != ack.current.streamId || ack.current.ordinal != 0) return false
386
+ if (!ack.success) {
387
+ lastOutcome = "nack"
388
+ pending = null
389
+ streamId = null
390
+ deferred.complete(false)
391
+ return true
392
+ }
393
+ ackedLenses = ackedLenses or (ack.lens and expectedLenses)
394
+ if (ackedLenses != expectedLenses) return false
395
+ lastOutcome = "ack"
396
+ lastSuccessMs = System.currentTimeMillis()
397
+ pending = null
398
+ streamId = null
399
+ deferred.complete(true)
400
+ return true
401
+ }
402
+ }
@@ -0,0 +1,84 @@
1
+ package com.glassly.bluetoothsdk.sgcs
2
+
3
+ import java.util.UUID
4
+
5
+ /**
6
+ * Bounded aggregates shared by the BLE writer and main thread; never retain payloads.
7
+ * Lines stay small: a sample is logged only for a window with display work, a
8
+ * backlog or a late tick; zero, false and empty values and sub-millisecond
9
+ * timings are left out; the link's fixed facts go in a separate session line.
10
+ */
11
+ internal class G2DisplayDiagnostics(private var startedAt: Long) {
12
+ companion object {
13
+ /** Link upkeep that runs whether or not anything is drawn: IMU reports, touch debouncing and heartbeat writes alone leave a window idle. */
14
+ private val AMBIENT_COUNTERS = setOf("imuReceived", "touchDebounced", "txBytes", "txPackets")
15
+ private val AMBIENT_TIMINGS = setOf("notifyDispatchMs", "writeQueueMs")
16
+ /** A tick this late means the main thread or the process was held up: logged even when idle. */
17
+ const val STRETCHED_WINDOW_MS = 2000L
18
+ /** The session line is repeated this often so a trimmed log excerpt still has it. */
19
+ const val SESSION_REPEAT_MS = 60_000L
20
+ }
21
+
22
+ private data class Timing(var count: Int = 0, var total: Long = 0, var maximum: Long = 0)
23
+ private val counters = mutableMapOf<String, Int>()
24
+ private val timings = mutableMapOf<String, Timing>()
25
+ /** Short: it is on every line, and only has to tell apart the runs in one log. */
26
+ private val run = UUID.randomUUID().toString().take(8)
27
+ private var sequence = 0
28
+ private var sessionKey: Map<String, Any>? = null
29
+ private var lastSession = 0L
30
+
31
+ @Synchronized fun count(name: String, amount: Int = 1) {
32
+ counters[name] = (counters[name] ?: 0) + amount
33
+ }
34
+
35
+ @Synchronized fun timing(name: String, milliseconds: Long) {
36
+ val value = milliseconds.coerceAtLeast(0)
37
+ val timing = timings.getOrPut(name) { Timing() }
38
+ timing.count++
39
+ timing.total += value
40
+ timing.maximum = maxOf(timing.maximum, value)
41
+ }
42
+
43
+ /** The link's fixed facts: logged when they change and once a minute, instead of on every sample. */
44
+ @Synchronized fun session(now: Long, timestamp: Long, facts: Map<String, Any>): Map<String, Any>? {
45
+ if (facts == sessionKey && now - lastSession < SESSION_REPEAT_MS) return null
46
+ sessionKey = facts
47
+ lastSession = now
48
+ return facts + record("session", timestamp)
49
+ }
50
+
51
+ /** The window's summary, or null when it is not due or was idle. `busy` means something is queued for the glasses. */
52
+ @Synchronized fun sample(now: Long, timestamp: Long, context: Map<String, Any>, busy: Boolean = false): Map<String, Any>? {
53
+ val elapsed = now - startedAt
54
+ if (elapsed < 1000) return null
55
+ startedAt = now
56
+ val counters = counters.toMap()
57
+ val timings = timings.toMap()
58
+ this.counters.clear()
59
+ this.timings.clear()
60
+ val active = busy || elapsed >= STRETCHED_WINDOW_MS ||
61
+ counters.keys.any { it !in AMBIENT_COUNTERS } || timings.keys.any { it !in AMBIENT_TIMINGS }
62
+ if (!active) return null
63
+ val fields = context.toMutableMap()
64
+ fields["windowMs"] = elapsed
65
+ fields.putAll(counters)
66
+ // A timing that never reached a millisecond shows no delay: left out.
67
+ for ((key, value) in timings) {
68
+ if (value.maximum < 1) continue
69
+ fields[key + "Avg"] = Math.round(value.total * 10.0 / value.count) / 10.0
70
+ fields[key + "Max"] = value.maximum
71
+ }
72
+ return fields.filterValues { !isEmpty(it) } + record("sample", timestamp)
73
+ }
74
+
75
+ private fun record(type: String, timestamp: Long): Map<String, Any> =
76
+ mapOf("v" to 1, "run" to run, "id" to ++sequence, "t" to timestamp, "type" to type)
77
+
78
+ private fun isEmpty(value: Any): Boolean = when (value) {
79
+ is Boolean -> !value
80
+ is Number -> value.toDouble() == 0.0
81
+ is String -> value.isEmpty()
82
+ else -> false
83
+ }
84
+ }
@@ -43,9 +43,9 @@ internal object G2FlashProtocol {
43
43
 
44
44
  val goldenStock =
45
45
  GoldenVector(
46
- sha256 = "a03fbea9f68a9de6bc271daabb9f3a41c59053d1086622c76a4e990f829cc561",
47
- ps = 3_697_284,
48
- progEnd = 0x007B_EA64,
46
+ sha256 = "187ccf2bcc5c17a212106e8a376745511e8289c4232b634a7ea94b9bf25a0979",
47
+ ps = 3_758_720,
48
+ progEnd = 0x007C_DA60,
49
49
  pass = true,
50
50
  )
51
51
 
@@ -40,18 +40,18 @@ internal object G2FirmwareCatalog {
40
40
  G2FirmwareArtifact(
41
41
  id = "stock",
42
42
  displayName = "Stock firmware",
43
- version = "2.2.9.22",
44
- fileName = "g2-stock-2.2.9.22.evenota.bin",
45
- sha256 = "a03fbea9f68a9de6bc271daabb9f3a41c59053d1086622c76a4e990f829cc561",
46
- size = 4_476_518,
43
+ version = "2.3.0.24",
44
+ fileName = "g2-stock-2.3.0.24.evenota.bin",
45
+ sha256 = "187ccf2bcc5c17a212106e8a376745511e8289c4232b634a7ea94b9bf25a0979",
46
+ size = 4_537_963,
47
47
  ),
48
48
  G2FirmwareArtifact(
49
49
  id = "glassly-cfw",
50
- displayName = "Glassly CFW",
51
- version = "2.2.9.22 CFW",
52
- fileName = "g2-glassly-cfw-2.2.9.22.evenota.bin",
53
- sha256 = "ceeede999529505ebc0a1369f57ab3c941273379a90b16669ccd39cc6bd99135",
54
- size = 4_515_846,
50
+ displayName = "Glassly CFW v39",
51
+ version = "2.3.0.24",
52
+ fileName = "g2-glassly-cfw-2.3.0.24.evenota.bin",
53
+ sha256 = "4c7bf9a552df1a42f26b3378425c7c2121ca4058fed7283ec863c3f8d2cd13fe",
54
+ size = 4_661_963,
55
55
  ),
56
56
  )
57
57