@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.
- package/README.md +47 -6
- package/android/build.gradle +7 -7
- package/android/lc3Lib/build.gradle +2 -2
- package/android/src/main/assets/G2Firmware/README.md +27 -2
- 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
- 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
- package/android/src/main/java/com/glassly/bluetoothsdk/BluetoothSdk.kt +38 -0
- package/android/src/main/java/com/glassly/bluetoothsdk/BluetoothSdkModule.kt +50 -14
- package/android/src/main/java/com/glassly/bluetoothsdk/Bridge.kt +18 -0
- package/android/src/main/java/com/glassly/bluetoothsdk/DeviceManager.kt +139 -10
- package/android/src/main/java/com/glassly/bluetoothsdk/DeviceStore.kt +28 -1
- package/android/src/main/java/com/glassly/bluetoothsdk/DisplayPacer.kt +199 -0
- package/android/src/main/java/com/glassly/bluetoothsdk/GeneratedChangelogCatalog.kt +10 -1
- package/android/src/main/java/com/glassly/bluetoothsdk/GeneratedReleaseMetadata.kt +4 -4
- package/android/src/main/java/com/glassly/bluetoothsdk/GlassesNotifications.kt +155 -0
- package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2.kt +1075 -257
- package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2BleLink.kt +119 -0
- package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2CfwClassification.kt +29 -28
- package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2CfwRenderer.kt +138 -33
- package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2CfwScene.kt +673 -0
- package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2CfwTextureCache.kt +235 -162
- package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2CfwTransport.kt +402 -0
- package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2DisplayDiagnostics.kt +84 -0
- package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2FlashProtocol.kt +3 -3
- package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2Flasher.kt +9 -9
- package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2Keyboard.kt +689 -0
- package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2KeyboardBridge.kt +452 -0
- package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2KeyboardHid.kt +208 -0
- package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2NotificationFile.kt +181 -0
- package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2RingBattery.kt +20 -0
- package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2Sensors.kt +67 -0
- package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2TouchInput.kt +39 -0
- package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/MentraLive.kt +5 -5
- package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/MentraNex.kt +14 -14
- package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/{GlasslyBle.java → NexProto.java} +6462 -7618
- package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/NotificationMessageIds.kt +58 -0
- package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/SGCManager.kt +77 -2
- package/android/src/main/java/com/glassly/bluetoothsdk/speakerid/SpeakerIdEngine.kt +1 -1
- package/android/src/main/java/com/glassly/bluetoothsdk/status/DeviceStatus.kt +55 -0
- package/android/src/main/java/com/glassly/bluetoothsdk/stt/STTTools.kt +1 -1
- package/android/src/main/java/com/glassly/bluetoothsdk/stt/SherpaOnnxTranscriber.kt +1 -1
- package/android/src/main/java/com/glassly/bluetoothsdk/tts/TTSTools.kt +1 -1
- package/android/src/main/java/com/glassly/bluetoothsdk/utils/NexSGCUtils.kt +24 -149
- package/android/src/test/java/com/glassly/bluetoothsdk/DisplayPacerTest.kt +277 -0
- package/android/src/test/java/com/glassly/bluetoothsdk/G2BleLinkTest.kt +70 -0
- package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/G2CfwClassificationTest.kt +45 -56
- package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/G2CfwRendererTest.kt +78 -5
- package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/G2CfwSceneTest.kt +489 -0
- package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/G2CfwTextureCacheTest.kt +100 -43
- package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/G2CfwTransportTest.kt +252 -0
- package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/G2DisplayDiagnosticsTest.kt +64 -0
- package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/G2KeyboardTest.kt +271 -0
- package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/G2NotificationFileTest.kt +204 -0
- package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/G2RingBatteryTest.kt +56 -0
- package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/G2SensorsTest.kt +56 -0
- package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/G2TouchInputTest.kt +60 -0
- package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/NexProtoDescriptorTest.kt +32 -0
- package/android/src/test/java/com/glassly/bluetoothsdk/sgcs/NotificationMessageIdsTest.kt +58 -0
- package/build/BluetoothSdk.types.d.ts +209 -20
- package/build/BluetoothSdk.types.d.ts.map +1 -1
- package/build/BluetoothSdk.types.js.map +1 -1
- package/build/_internal.d.ts +1 -1
- package/build/_internal.d.ts.map +1 -1
- package/build/_internal.js.map +1 -1
- package/build/_private/BluetoothSdkModule.d.ts +30 -1
- package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
- package/build/_private/BluetoothSdkModule.js.map +1 -1
- package/build/generated/changelogCatalog.d.ts +28 -1
- package/build/generated/changelogCatalog.d.ts.map +1 -1
- package/build/generated/changelogCatalog.js +37 -1
- package/build/generated/changelogCatalog.js.map +1 -1
- package/build/generated/releaseMetadata.js +4 -4
- package/build/generated/releaseMetadata.js.map +1 -1
- package/build/index.d.ts +1 -1
- package/build/index.d.ts.map +1 -1
- package/build/index.js +14 -0
- package/build/index.js.map +1 -1
- package/ios/BluetoothSdkModule.swift +102 -13
- package/ios/GlasslyBluetoothSDK.podspec +1 -1
- package/ios/Packages/SherpaOnnx/SherpaOnnx.swift +8 -3
- package/ios/Source/BluetoothSDK.swift +53 -0
- package/ios/Source/BluetoothSdkDefaults.swift +1 -1
- package/ios/Source/Bridge.swift +13 -0
- package/ios/Source/DeviceManager.swift +151 -33
- package/ios/Source/DeviceStore.swift +28 -14
- package/ios/Source/DisplayPacer.swift +180 -0
- package/ios/Source/GeneratedChangelogCatalog.swift +10 -1
- package/ios/Source/GeneratedReleaseMetadata.swift +4 -4
- package/ios/Source/GlassesNotifications.swift +181 -0
- package/ios/Source/ObservableStore.swift +14 -2
- package/ios/Source/Resources/G2Firmware/README.md +27 -2
- package/ios/Source/Resources/G2Firmware/{g2-stock-2.2.9.22.evenota.bin → g2-glassly-cfw-2.3.0.24.evenota.bin} +0 -0
- package/ios/Source/Resources/G2Firmware/{g2-glassly-cfw-2.2.9.22.evenota.bin → g2-stock-2.3.0.24.evenota.bin} +0 -0
- package/ios/Source/controllers/R1.swift +0 -1
- package/ios/Source/internal/BluetoothAvailability.swift +31 -0
- package/ios/Source/sgcs/G2.swift +1434 -504
- package/ios/Source/sgcs/G2AncsRelay.swift +268 -0
- package/ios/Source/sgcs/G2BleLink.swift +102 -0
- package/ios/Source/sgcs/G2CfwClassification.swift +45 -27
- package/ios/Source/sgcs/G2CfwRenderer.swift +166 -33
- package/ios/Source/sgcs/G2CfwScene.swift +779 -0
- package/ios/Source/sgcs/G2CfwTextureCache.swift +244 -117
- package/ios/Source/sgcs/G2CfwTransport.swift +485 -0
- package/ios/Source/sgcs/G2DisplayDiagnostics.swift +154 -0
- package/ios/Source/sgcs/G2FlashProtocol.swift +2 -2
- package/ios/Source/sgcs/G2Flasher.swift +9 -9
- package/ios/Source/sgcs/G2Keyboard.swift +780 -0
- package/ios/Source/sgcs/G2KeyboardBridge.swift +438 -0
- package/ios/Source/sgcs/G2KeyboardHid.swift +290 -0
- package/ios/Source/sgcs/G2NotificationTuning.swift +24 -0
- package/ios/Source/sgcs/G2RingBattery.swift +22 -0
- package/ios/Source/sgcs/G2Sensors.swift +75 -0
- package/ios/Source/sgcs/G2TouchInput.swift +51 -0
- package/ios/Source/sgcs/G2WritePacing.swift +18 -0
- package/ios/Source/sgcs/MentraNex.swift +44 -44
- package/ios/Source/sgcs/SGCManager.swift +120 -13
- package/ios/Source/sgcs/{glassly_ble.pb.swift → nex_ble.pb.swift} +405 -465
- package/ios/Source/speakerid/SpeakerIdEngine.swift +2 -2
- package/ios/Source/status/DeviceStatus.swift +27 -0
- package/ios/Source/stt/SherpaOnnxTranscriber.swift +15 -14
- package/ios/Source/utils/Enums.swift +0 -1
- package/ios/Source/utils/G1Text.swift +0 -1
- package/ios/Source/utils/G2NotifyFile.swift +20 -10
- package/ios/Source/utils/JSCExperiment.swift +6 -6
- package/ios/Source/utils/Models.swift +0 -1
- package/ios/Tests/BluetoothSDKTests/BluetoothAvailabilityTests.swift +29 -0
- package/ios/Tests/BluetoothSDKTests/DisplayPacerTests.swift +255 -0
- package/ios/Tests/BluetoothSDKTests/G2AncsRelayTests.swift +186 -0
- package/ios/Tests/BluetoothSDKTests/G2BleLinkTests.swift +52 -0
- package/ios/Tests/BluetoothSDKTests/G2CfwClassificationTests.swift +46 -51
- package/ios/Tests/BluetoothSDKTests/G2CfwRendererTests.swift +164 -5
- package/ios/Tests/BluetoothSDKTests/G2CfwSceneTests.swift +497 -0
- package/ios/Tests/BluetoothSDKTests/G2CfwTextureCacheTests.swift +108 -44
- package/ios/Tests/BluetoothSDKTests/G2CfwTransportTests.swift +259 -0
- package/ios/Tests/BluetoothSDKTests/G2DisplayDiagnosticsTests.swift +89 -0
- package/ios/Tests/BluetoothSDKTests/G2FlashProtocolTests.swift +3 -2
- package/ios/Tests/BluetoothSDKTests/G2ImageAckTests.swift +104 -0
- package/ios/Tests/BluetoothSDKTests/G2KeyboardTests.swift +274 -0
- package/ios/Tests/BluetoothSDKTests/G2NotifyFileTests.swift +21 -5
- package/ios/Tests/BluetoothSDKTests/G2RingBatteryTests.swift +56 -0
- package/ios/Tests/BluetoothSDKTests/G2SensorsTests.swift +56 -0
- package/ios/Tests/BluetoothSDKTests/G2TouchInputTests.swift +71 -0
- package/ios/Tests/BluetoothSDKTests/G2WritePacingTests.swift +35 -0
- package/ios/Tests/BluetoothSDKTests/GlassesNotificationTests.swift +95 -0
- package/package.json +1 -1
- package/plugin/build/withAndroid.js +2 -2
- package/plugin/build/withIos.js +1 -1
- package/scripts/gen-nex-proto.sh +12 -0
- package/scripts/inject-ios-sdk-version.mjs +1 -1
- package/src/BluetoothSdk.types.ts +214 -19
- package/src/_internal.ts +1 -1
- package/src/_private/BluetoothSdkModule.ts +40 -0
- package/src/generated/changelogCatalog.ts +37 -1
- package/src/generated/releaseMetadata.ts +4 -4
- 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
|
-
///
|
|
4
|
-
///
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
///
|
|
22
|
-
///
|
|
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
|
|
48
|
+
if let capability = data.flatMap({ String(data: $0, encoding: .utf8) })?
|
|
31
49
|
.trimmingCharacters(in: CharacterSet(charactersIn: "\0"))
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|