@glassly/bluetooth-sdk 0.3.1-dev.535 → 0.3.1-dev.538
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/android/src/main/java/com/glassly/bluetoothsdk/DeviceManager.kt +13 -9
- package/android/src/main/java/com/glassly/bluetoothsdk/DeviceStore.kt +10 -0
- package/android/src/main/java/com/glassly/bluetoothsdk/GeneratedReleaseMetadata.kt +3 -3
- package/android/src/main/java/com/glassly/bluetoothsdk/controllers/ControllerManager.kt +2 -0
- package/android/src/main/java/com/glassly/bluetoothsdk/controllers/R1.kt +134 -22
- package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2.kt +56 -32
- package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/SGCManager.kt +2 -0
- package/build/generated/releaseMetadata.js +3 -3
- package/build/generated/releaseMetadata.js.map +1 -1
- package/ios/Source/BluetoothSdkDefaults.swift +1 -1
- package/ios/Source/DeviceManager.swift +13 -5
- package/ios/Source/DeviceStore.swift +7 -0
- package/ios/Source/GeneratedReleaseMetadata.swift +3 -3
- package/ios/Source/controllers/ControllerManager.swift +2 -0
- package/ios/Source/controllers/R1.swift +123 -24
- package/ios/Source/sgcs/G2.swift +57 -39
- package/ios/Source/sgcs/SGCManager.swift +5 -0
- package/package.json +1 -1
- package/src/generated/releaseMetadata.ts +3 -3
|
@@ -2340,11 +2340,22 @@ class DeviceManager {
|
|
|
2340
2340
|
Bridge.log("MAN: connectDefaultController skipped — bluetooth runtime permissions not granted")
|
|
2341
2341
|
return
|
|
2342
2342
|
}
|
|
2343
|
+
// The ring link outlives glasses connects/disconnects, so every
|
|
2344
|
+
// reconnect path may find it already up.
|
|
2345
|
+
if (DeviceStore.get("glasses", "controllerConnected") as? Boolean == true) {
|
|
2346
|
+
Bridge.log("MAN: Controller already connected, returning")
|
|
2347
|
+
return
|
|
2348
|
+
}
|
|
2343
2349
|
initController(defaultController)
|
|
2344
2350
|
searchingController = true
|
|
2345
2351
|
controller?.connectById(controllerDeviceName)
|
|
2346
2352
|
}
|
|
2347
2353
|
|
|
2354
|
+
/** Debug `ring_via_glasses` flipped: re-route a connected ring live. */
|
|
2355
|
+
fun ringRouteToggled(viaGlasses: Boolean) {
|
|
2356
|
+
controller?.setRouteViaGlasses(viaGlasses)
|
|
2357
|
+
}
|
|
2358
|
+
|
|
2348
2359
|
fun connectByName(dName: String) {
|
|
2349
2360
|
Bridge.log("MAN: Connecting to wearable: $dName")
|
|
2350
2361
|
|
|
@@ -2435,11 +2446,8 @@ class DeviceManager {
|
|
|
2435
2446
|
"voiceActivityDetectionEnabled",
|
|
2436
2447
|
BluetoothSdkDefaults.VOICE_ACTIVITY_DETECTION_ENABLED
|
|
2437
2448
|
)
|
|
2438
|
-
//
|
|
2439
|
-
|
|
2440
|
-
DeviceStore.apply("glasses", "controllerConnected", false)
|
|
2441
|
-
controller?.disconnect()
|
|
2442
|
-
controller = null
|
|
2449
|
+
// The ring is its own link and stays up across glasses disconnects;
|
|
2450
|
+
// disconnectController()/forgetController() are the explicit ways down.
|
|
2443
2451
|
if (clearPendingIdentity) {
|
|
2444
2452
|
clearPendingConnection()
|
|
2445
2453
|
}
|
|
@@ -2489,10 +2497,6 @@ class DeviceManager {
|
|
|
2489
2497
|
"voiceActivityDetectionEnabled",
|
|
2490
2498
|
BluetoothSdkDefaults.VOICE_ACTIVITY_DETECTION_ENABLED,
|
|
2491
2499
|
)
|
|
2492
|
-
searchingController = false
|
|
2493
|
-
DeviceStore.apply("glasses", "controllerConnected", false)
|
|
2494
|
-
controller?.disconnect()
|
|
2495
|
-
controller = null
|
|
2496
2500
|
} else {
|
|
2497
2501
|
// Typical abandonAttempt path: disconnect() already destroyed SGC and nulled
|
|
2498
2502
|
// this.sgc, so MentraLive.forget() never ran and Classic bonds stayed up —
|
|
@@ -148,6 +148,8 @@ object DeviceStore {
|
|
|
148
148
|
store.set("bluetooth", "cfw_fast_ble", false)
|
|
149
149
|
// Debug: G2PERF display performance lines in the log (off = counted, not logged).
|
|
150
150
|
store.set("bluetooth", "g2_perf_logs", false)
|
|
151
|
+
// Debug: link the R1 ring to the glasses (off = ring on the phone link only).
|
|
152
|
+
store.set("bluetooth", "ring_via_glasses", false)
|
|
151
153
|
store.set("bluetooth", "button_photo_size", "max")
|
|
152
154
|
store.set("bluetooth", "button_max_recording_time", 10)
|
|
153
155
|
store.set("bluetooth", "camera_fov", mapOf("fov" to 118, "roi_position" to 0))
|
|
@@ -264,6 +266,14 @@ object DeviceStore {
|
|
|
264
266
|
}
|
|
265
267
|
}
|
|
266
268
|
}
|
|
269
|
+
"bluetooth" to "ring_via_glasses" -> {
|
|
270
|
+
val viaGlasses = value as? Boolean
|
|
271
|
+
if (viaGlasses != null && (oldValue as? Boolean) != viaGlasses) {
|
|
272
|
+
CoroutineScope(Dispatchers.Main).launch {
|
|
273
|
+
DeviceManager.getInstance().ringRouteToggled(viaGlasses)
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
}
|
|
267
277
|
"bluetooth" to "display_max_fps" -> {
|
|
268
278
|
(value as? Number)?.let { fps ->
|
|
269
279
|
DeviceManager.getInstance().setDisplayMaxFps(fps.toInt())
|
|
@@ -3,9 +3,9 @@ package com.glassly.bluetoothsdk
|
|
|
3
3
|
/** Generated by release CI. Do not edit in a release checkout. */
|
|
4
4
|
internal object GeneratedReleaseMetadata {
|
|
5
5
|
const val FAMILY_BASE_VERSION: String = "0.3.1"
|
|
6
|
-
const val RELEASE_IDENTITY: String = "0.3.1-dev.
|
|
7
|
-
const val RELEASE_SET_ID: String = "glassly-0.3.1-dev.
|
|
8
|
-
const val SOURCE_COMMIT: String = "
|
|
6
|
+
const val RELEASE_IDENTITY: String = "0.3.1-dev.538"
|
|
7
|
+
const val RELEASE_SET_ID: String = "glassly-0.3.1-dev.538"
|
|
8
|
+
const val SOURCE_COMMIT: String = "3c004fd397951d67d68c6a6dcfda389afb5a2bc0"
|
|
9
9
|
const val OTA_MANIFEST_URL: String = ""
|
|
10
10
|
const val OTA_MANIFEST_SHA256: String = ""
|
|
11
11
|
}
|
|
@@ -63,6 +63,8 @@ abstract class ControllerManager {
|
|
|
63
63
|
// Connection Management
|
|
64
64
|
abstract fun disconnect()
|
|
65
65
|
abstract fun forget()
|
|
66
|
+
/** Route input through the glasses (debug) or the phone link (default). */
|
|
67
|
+
open fun setRouteViaGlasses(viaGlasses: Boolean) {}
|
|
66
68
|
abstract fun findCompatibleDevices()
|
|
67
69
|
abstract fun stopScan()
|
|
68
70
|
abstract fun connectById(id: String)
|
|
@@ -45,6 +45,70 @@ private object R1BLE {
|
|
|
45
45
|
val NAME_FILTERS = arrayOf("EVEN R1", "BCL60")
|
|
46
46
|
|
|
47
47
|
const val GESTURE_MARKER: Byte = 0xFF.toByte()
|
|
48
|
+
|
|
49
|
+
/** `source` on emitted touch events; the same value the G2 CFW tags
|
|
50
|
+
* ring input it relays with, so JS treats both paths alike. */
|
|
51
|
+
const val RING_SOURCE = 2
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Scan-response manufacturer data (openCFW: `0x5245` company id, the six
|
|
55
|
+
* address bytes in SoftDevice order, then an optional 15-byte serial).
|
|
56
|
+
* Android hands us the payload with the company id already stripped. The
|
|
57
|
+
* GAP name is `EVEN R1_` + address bytes 3,2,1, which pins the byte order;
|
|
58
|
+
* the returned string keeps SoftDevice order, the form the glasses have
|
|
59
|
+
* accepted in RING_CONNECT_INFO. Taking the LAST six bytes was wrong for
|
|
60
|
+
* rings with a serial (it returned ASCII digits).
|
|
61
|
+
*/
|
|
62
|
+
fun macAddress(mfgBytes: ByteArray, ringId: String?): String? {
|
|
63
|
+
if (mfgBytes.size < 6) return null
|
|
64
|
+
val addr = mfgBytes.copyOfRange(0, 6)
|
|
65
|
+
val hex = { b: ByteArray -> b.joinToString("") { String.format("%02X", it) } }
|
|
66
|
+
val id = ringId?.uppercase()
|
|
67
|
+
val chosen = when {
|
|
68
|
+
id.isNullOrEmpty() -> addr
|
|
69
|
+
hex(byteArrayOf(addr[3], addr[2], addr[1])) == id -> addr
|
|
70
|
+
else -> {
|
|
71
|
+
val flipped = addr.reversedArray()
|
|
72
|
+
if (hex(byteArrayOf(flipped[3], flipped[2], flipped[1])) == id) flipped else return null
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return chosen.joinToString(":") { String.format("%02X", it) }
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// MARK: - Ring → phone touch report (channel 1)
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The ring reports touch on the legacy channel-1 transport as 11 bytes
|
|
83
|
+
* `[00][09][61][00][wireType][aux][speed][00][tick LE32]` — the same report
|
|
84
|
+
* the Glassly CFW relays from the glasses as the `RI` SysEvent extension
|
|
85
|
+
* (g2flash gesture_fwd.c `faceclaw_ring_report`). Wire types map onto the
|
|
86
|
+
* gesture names the G2 path emits so miniapps see one vocabulary.
|
|
87
|
+
*/
|
|
88
|
+
private object R1TouchReport {
|
|
89
|
+
const val LENGTH = 11
|
|
90
|
+
const val OPCODE: Byte = 0x61
|
|
91
|
+
|
|
92
|
+
fun gestureName(wireType: Int): String? = when (wireType) {
|
|
93
|
+
0 -> "tap_start"
|
|
94
|
+
1 -> "single_tap"
|
|
95
|
+
2 -> "double_tap"
|
|
96
|
+
4 -> "swipe_up"
|
|
97
|
+
5 -> "swipe_down"
|
|
98
|
+
8 -> "tap_end"
|
|
99
|
+
9 -> "tap_then_hold"
|
|
100
|
+
10 -> "touch_down"
|
|
101
|
+
else -> null
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** null when `data` is not a touch report; `.first` null for an unknown wire type. */
|
|
105
|
+
fun parse(data: ByteArray): Pair<String?, Int>? {
|
|
106
|
+
if (data.size != LENGTH || data[0] != 0.toByte() || data[1] != 9.toByte() ||
|
|
107
|
+
data[2] != OPCODE || data[3] != 0.toByte()
|
|
108
|
+
) return null
|
|
109
|
+
val wireType = data[4].toInt() and 0xFF
|
|
110
|
+
return gestureName(wireType) to wireType
|
|
111
|
+
}
|
|
48
112
|
}
|
|
49
113
|
|
|
50
114
|
// MARK: - EUS transport encoder (phone → ring, channel 2)
|
|
@@ -75,6 +139,12 @@ private object R1Eus {
|
|
|
75
139
|
/** system subcommand 0x0A: 12-byte payload = two 6-byte glasses arm MACs;
|
|
76
140
|
* the ring stores both targets and fast-advertises for them */
|
|
77
141
|
const val SUBCMD_ADV_START = 0x0A
|
|
142
|
+
/** system subcommand 0x82 (removeRingNotify): one-byte SET erases both
|
|
143
|
+
* stored glasses targets and the "glasses paired" flag. While the ring
|
|
144
|
+
* holds glasses targets its touch reports go to the glasses and the
|
|
145
|
+
* phone link carries telemetry only, so this is what puts input on the
|
|
146
|
+
* phone link. */
|
|
147
|
+
const val SUBCMD_REMOVE_RING = 0x82
|
|
78
148
|
|
|
79
149
|
private fun crc16Step(crc: Int, byte: Int): Int {
|
|
80
150
|
var c = ((crc shr 8) or (crc shl 8)) and 0xFFFF
|
|
@@ -144,7 +214,7 @@ private object R1Eus {
|
|
|
144
214
|
// MARK: - R1 Gesture Types
|
|
145
215
|
|
|
146
216
|
private enum class R1Gesture(val rawValue: String) {
|
|
147
|
-
HOLD("
|
|
217
|
+
HOLD("tap_then_hold"),
|
|
148
218
|
SINGLE_TAP("single_tap"),
|
|
149
219
|
DOUBLE_TAP("double_tap"),
|
|
150
220
|
SWIPE_UP("swipe_up"),
|
|
@@ -438,14 +508,14 @@ class R1 : ControllerManager() {
|
|
|
438
508
|
val mfgHex = mfgBytes?.joinToString(" ") { String.format("%02X", it) } ?: "none"
|
|
439
509
|
Bridge.log("R1: Discovered: ${deviceName ?: "?"} (RSSI: ${result.rssi}) mfgData: $mfgHex")
|
|
440
510
|
|
|
441
|
-
// Extract ring MAC from manufacturer data
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
511
|
+
// Extract the ring MAC from manufacturer data and store name->MAC map.
|
|
512
|
+
if (deviceName != null && mfgBytes != null) {
|
|
513
|
+
val macStr = R1BLE.macAddress(mfgBytes, extractRingId(deviceName))
|
|
514
|
+
if (macStr != null) {
|
|
515
|
+
putRingMacInMap(deviceName, macStr)
|
|
516
|
+
} else {
|
|
517
|
+
Bridge.log("R1: mfgData carries no address matching $deviceName")
|
|
518
|
+
}
|
|
449
519
|
}
|
|
450
520
|
|
|
451
521
|
// Emit discovered device
|
|
@@ -554,20 +624,54 @@ class R1 : ControllerManager() {
|
|
|
554
624
|
DeviceStore.apply("glasses", "controllerMacAddress", mac)
|
|
555
625
|
DeviceStore.apply("glasses", "controllerConnected", true)
|
|
556
626
|
// The phone link owns these flags: a ready phone link IS pairing
|
|
557
|
-
// success (fires handleControllerReady).
|
|
558
|
-
// on afterwards
|
|
627
|
+
// success (fires handleControllerReady). A glasses link is only
|
|
628
|
+
// layered on afterwards (advStart + RING_CONNECT_INFO) when debug-routed.
|
|
559
629
|
DeviceStore.apply("glasses", "controllerFullyBooted", true)
|
|
560
630
|
|
|
561
|
-
|
|
562
|
-
|
|
631
|
+
applyRoute(routeViaGlasses)
|
|
632
|
+
|
|
633
|
+
startHeartbeat()
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
/** Debug `ring_via_glasses` (default off): link the ring to the glasses and
|
|
637
|
+
* take its input through the CFW relay instead of this link. */
|
|
638
|
+
private val routeViaGlasses: Boolean
|
|
639
|
+
get() = DeviceStore.get("bluetooth", "ring_via_glasses") as? Boolean ?: false
|
|
563
640
|
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
641
|
+
/**
|
|
642
|
+
* Phone route (default): erase the ring's stored glasses targets so its
|
|
643
|
+
* touch reports come to this link, and have the glasses drop any ring
|
|
644
|
+
* link they still hold. Glasses route (debug): hand the ring the glasses
|
|
645
|
+
* MACs, then ask the glasses to connect.
|
|
646
|
+
*/
|
|
647
|
+
private fun applyRoute(viaGlasses: Boolean) {
|
|
648
|
+
if (viaGlasses) {
|
|
649
|
+
connectToGlasses()
|
|
650
|
+
// after a second, connect the glasses to the controller if needed:
|
|
651
|
+
CoroutineScope(Dispatchers.Main).launch {
|
|
652
|
+
delay(1000)
|
|
653
|
+
DeviceManager.getInstance().sgc?.connectController()
|
|
654
|
+
}
|
|
655
|
+
} else {
|
|
656
|
+
sendEusCommand(R1Eus.SUBCMD_REMOVE_RING, byteArrayOf(0x01), "removeRingNotify")
|
|
657
|
+
DeviceManager.getInstance().sgc?.releaseController()
|
|
568
658
|
}
|
|
659
|
+
}
|
|
569
660
|
|
|
570
|
-
|
|
661
|
+
override fun setRouteViaGlasses(viaGlasses: Boolean) {
|
|
662
|
+
Bridge.log("R1: route via ${if (viaGlasses) "glasses" else "phone"}")
|
|
663
|
+
if (DeviceStore.get("glasses", "controllerConnected") as? Boolean != true) return
|
|
664
|
+
applyRoute(viaGlasses)
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
/**
|
|
668
|
+
* One emit path for both direct parsers. The ring reports touch to a
|
|
669
|
+
* single peer, so whatever arrives here is the only copy — forward it
|
|
670
|
+
* whichever route is configured.
|
|
671
|
+
*/
|
|
672
|
+
private fun emitGesture(gestureName: String, timestamp: Long, via: String) {
|
|
673
|
+
Bridge.log("R1: $via: $gestureName")
|
|
674
|
+
Bridge.sendTouchEvent(ControllerTypes.R1, gestureName, timestamp, R1BLE.RING_SOURCE)
|
|
571
675
|
}
|
|
572
676
|
|
|
573
677
|
/**
|
|
@@ -657,12 +761,21 @@ class R1 : ControllerManager() {
|
|
|
657
761
|
|
|
658
762
|
val timestamp = System.currentTimeMillis()
|
|
659
763
|
|
|
764
|
+
// Touch report: [00 09 61 00 wireType aux speed 00 tick4]
|
|
765
|
+
R1TouchReport.parse(data)?.let { (gesture, wireType) ->
|
|
766
|
+
if (gesture != null) {
|
|
767
|
+
emitGesture(gesture, timestamp, "Touch report")
|
|
768
|
+
} else {
|
|
769
|
+
Bridge.log("R1: Unknown touch report wireType=$wireType")
|
|
770
|
+
}
|
|
771
|
+
return
|
|
772
|
+
}
|
|
773
|
+
|
|
660
774
|
// Gesture: [0xFF, type, param]
|
|
661
775
|
if (data.size >= 3 && data[0] == R1BLE.GESTURE_MARKER) {
|
|
662
776
|
val gesture = R1Gesture.parse(data)
|
|
663
777
|
if (gesture != null) {
|
|
664
|
-
|
|
665
|
-
Bridge.sendTouchEvent(ControllerTypes.R1, gesture.rawValue, timestamp)
|
|
778
|
+
emitGesture(gesture.rawValue, timestamp, "Gesture")
|
|
666
779
|
} else {
|
|
667
780
|
Bridge.log(
|
|
668
781
|
"R1: Unknown gesture type=0x%02X param=0x%02X".format(data[1], data[2])
|
|
@@ -696,8 +809,7 @@ class R1 : ControllerManager() {
|
|
|
696
809
|
val slice = byteArrayOf(data[ffIndex], data[ffIndex + 1], data[ffIndex + 2])
|
|
697
810
|
val gesture = R1Gesture.parse(slice)
|
|
698
811
|
if (gesture != null) {
|
|
699
|
-
|
|
700
|
-
Bridge.sendTouchEvent(ControllerTypes.R1, gesture.rawValue, timestamp)
|
|
812
|
+
emitGesture(gesture.rawValue, timestamp, "Embedded gesture")
|
|
701
813
|
return
|
|
702
814
|
}
|
|
703
815
|
}
|
|
@@ -2298,7 +2298,7 @@ class G2 : SGCManager() {
|
|
|
2298
2298
|
|
|
2299
2299
|
setFullyConnected()
|
|
2300
2300
|
|
|
2301
|
-
//
|
|
2301
|
+
// Link a paired ring if it is routed via the glasses, else release it
|
|
2302
2302
|
connectController()
|
|
2303
2303
|
|
|
2304
2304
|
// Query version + battery info from glasses
|
|
@@ -5596,52 +5596,60 @@ class G2 : SGCManager() {
|
|
|
5596
5596
|
connectController()
|
|
5597
5597
|
}
|
|
5598
5598
|
|
|
5599
|
-
|
|
5599
|
+
/**
|
|
5600
|
+
* Debug `ring_via_glasses`: the ring is linked to the glasses and its
|
|
5601
|
+
* input arrives through the CFW. Off (default), the ring stays on the
|
|
5602
|
+
* phone link and the glasses must not hold it — the ring sends touch
|
|
5603
|
+
* reports to whichever glasses link it has and only telemetry to the phone.
|
|
5604
|
+
*/
|
|
5605
|
+
private val ringViaGlasses: Boolean
|
|
5606
|
+
get() = DeviceStore.get("bluetooth", "ring_via_glasses") as? Boolean ?: false
|
|
5607
|
+
|
|
5608
|
+
private var lastRingReleaseAt = 0L
|
|
5609
|
+
private val RING_RELEASE_INTERVAL_MS = 5_000L
|
|
5610
|
+
|
|
5611
|
+
/** RING_CONNECT_INFO for the paired ring. False when the glasses aren't
|
|
5612
|
+
* booted or no ring MAC is known. */
|
|
5613
|
+
private fun sendRingConnectInfo(connect: Boolean, tag: String): Boolean {
|
|
5600
5614
|
val isFullyBooted = DeviceStore.get("glasses", "fullyBooted") as? Boolean ?: false
|
|
5601
5615
|
if (!isFullyBooted) {
|
|
5602
|
-
Bridge.log("G2:
|
|
5603
|
-
return
|
|
5616
|
+
Bridge.log("G2: $tag - g2 not fully booted, ignoring")
|
|
5617
|
+
return false
|
|
5604
5618
|
}
|
|
5605
5619
|
val mac = DeviceStore.get("glasses", "controllerMacAddress") as? String
|
|
5606
5620
|
if (mac.isNullOrEmpty()) {
|
|
5607
|
-
Bridge.log("G2:
|
|
5608
|
-
return
|
|
5621
|
+
Bridge.log("G2: $tag - no MAC address found")
|
|
5622
|
+
return false
|
|
5609
5623
|
}
|
|
5610
5624
|
val hexParts = mac.split(":").mapNotNull { it.toIntOrNull(16)?.toByte() }
|
|
5611
5625
|
if (hexParts.size != 6) {
|
|
5612
|
-
Bridge.log("G2:
|
|
5613
|
-
return
|
|
5626
|
+
Bridge.log("G2: $tag - invalid MAC format: $mac")
|
|
5627
|
+
return false
|
|
5614
5628
|
}
|
|
5615
|
-
|
|
5616
|
-
val macData = hexParts.toByteArray()
|
|
5617
|
-
val msg = DevSettingsProto.ringConnectInfo(sendManager.nextMagicRandom(), true, macData)
|
|
5629
|
+
val msg = DevSettingsProto.ringConnectInfo(sendManager.nextMagicRandom(), connect, hexParts.toByteArray())
|
|
5618
5630
|
sendDevSettingsCommand(msg)
|
|
5619
|
-
Bridge.log("G2: Sent
|
|
5631
|
+
Bridge.log("G2: Sent RING_${if (connect) "CONNECT" else "DISCONNECT"}_INFO for MAC $mac")
|
|
5632
|
+
return true
|
|
5620
5633
|
}
|
|
5621
5634
|
|
|
5622
|
-
override fun
|
|
5623
|
-
|
|
5624
|
-
|
|
5625
|
-
|
|
5626
|
-
return
|
|
5627
|
-
}
|
|
5628
|
-
val mac = DeviceStore.get("glasses", "controllerMacAddress") as? String
|
|
5629
|
-
if (mac.isNullOrEmpty()) {
|
|
5630
|
-
Bridge.log("G2: disconnectController - no MAC address found")
|
|
5631
|
-
return
|
|
5632
|
-
}
|
|
5633
|
-
val hexParts = mac.split(":").mapNotNull { it.toIntOrNull(16)?.toByte() }
|
|
5634
|
-
if (hexParts.size != 6) {
|
|
5635
|
-
Bridge.log("G2: disconnectController - invalid MAC format: $mac")
|
|
5635
|
+
override fun connectController() {
|
|
5636
|
+
if (!ringViaGlasses) {
|
|
5637
|
+
// Phone-routed ring: make sure the glasses let go of it instead.
|
|
5638
|
+
releaseController()
|
|
5636
5639
|
return
|
|
5637
5640
|
}
|
|
5638
|
-
|
|
5639
|
-
|
|
5640
|
-
|
|
5641
|
+
sendRingConnectInfo(true, "connectController")
|
|
5642
|
+
}
|
|
5643
|
+
|
|
5644
|
+
override fun disconnectController() {
|
|
5645
|
+
if (!sendRingConnectInfo(false, "disconnectController")) return
|
|
5641
5646
|
// DeviceStore.apply("glasses", "controllerMacAddress", "")
|
|
5642
5647
|
DeviceStore.apply("glasses", "controllerConnected", false)
|
|
5643
5648
|
DeviceStore.apply("glasses", "controllerFullyBooted", false)
|
|
5644
|
-
|
|
5649
|
+
}
|
|
5650
|
+
|
|
5651
|
+
override fun releaseController() {
|
|
5652
|
+
sendRingConnectInfo(false, "releaseController")
|
|
5645
5653
|
}
|
|
5646
5654
|
|
|
5647
5655
|
// ---------- SGCManager: Device Control ----------
|
|
@@ -6779,9 +6787,24 @@ class G2 : SGCManager() {
|
|
|
6779
6787
|
return@let
|
|
6780
6788
|
}
|
|
6781
6789
|
|
|
6790
|
+
// Ring input relayed by the glasses. Older CFWs left event 14's
|
|
6791
|
+
// source unspecified, so the id alone also marks the ring.
|
|
6792
|
+
val fromRing = eventSource == 2 || eventType == OsEventType.RING_TOUCH_DOWN
|
|
6793
|
+
if (fromRing && !ringViaGlasses) {
|
|
6794
|
+
// The ring only reports touch to one peer, so this input is
|
|
6795
|
+
// real and must flow — but the glasses shouldn't hold the ring
|
|
6796
|
+
// while it is phone-routed. Ask them to let go (not per event).
|
|
6797
|
+
val now = System.currentTimeMillis()
|
|
6798
|
+
if (now - lastRingReleaseAt > RING_RELEASE_INTERVAL_MS) {
|
|
6799
|
+
lastRingReleaseAt = now
|
|
6800
|
+
Bridge.log("G2: ring $gestureName via glasses while phone-routed — releasing ring")
|
|
6801
|
+
releaseController()
|
|
6802
|
+
}
|
|
6803
|
+
}
|
|
6804
|
+
|
|
6782
6805
|
Bridge.sendTouchEvent(DeviceTypes.G2, gestureName, timestamp, eventSource)
|
|
6783
6806
|
|
|
6784
|
-
if (
|
|
6807
|
+
if (fromRing) {
|
|
6785
6808
|
// controller must be connected and fully booted:
|
|
6786
6809
|
setControllerFullyConnected()
|
|
6787
6810
|
}
|
|
@@ -6905,7 +6928,8 @@ class G2 : SGCManager() {
|
|
|
6905
6928
|
if (connStatus == 22) {
|
|
6906
6929
|
// The ring↔glasses link dropped. The phone↔ring link owns
|
|
6907
6930
|
// controllerConnected/FullyBooted, so don't clear them here
|
|
6908
|
-
// (that would flap pairing state) — just re-request the link
|
|
6931
|
+
// (that would flap pairing state) — just re-request the link
|
|
6932
|
+
// (a no-op while the ring is routed via the phone).
|
|
6909
6933
|
Bridge.log("G2: Ring-glasses link dropped, re-requesting")
|
|
6910
6934
|
reconnectController()
|
|
6911
6935
|
}
|
|
@@ -355,6 +355,8 @@ abstract class SGCManager {
|
|
|
355
355
|
// Controller bridging (default no-op — only G2 supports pairing with a ring controller)
|
|
356
356
|
open fun connectController() {}
|
|
357
357
|
open fun disconnectController() {}
|
|
358
|
+
/** Drop the glasses↔ring link without touching the phone↔ring state. */
|
|
359
|
+
open fun releaseController() {}
|
|
358
360
|
|
|
359
361
|
// Native navigation mode (glasses-rendered nav UI — G2 service 0x08 today).
|
|
360
362
|
// Default no-ops so devices without a firmware nav UI accept the calls; the JS layer
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
export const BLUETOOTH_SDK_RELEASE_METADATA = Object.freeze({
|
|
2
2
|
"schemaVersion": 1,
|
|
3
3
|
"familyBaseVersion": "0.3.1",
|
|
4
|
-
"releaseIdentity": "0.3.1-dev.
|
|
5
|
-
"releaseSetId": "glassly-0.3.1-dev.
|
|
6
|
-
"sourceCommit": "
|
|
4
|
+
"releaseIdentity": "0.3.1-dev.538",
|
|
5
|
+
"releaseSetId": "glassly-0.3.1-dev.538",
|
|
6
|
+
"sourceCommit": "3c004fd397951d67d68c6a6dcfda389afb5a2bc0",
|
|
7
7
|
"otaManifestUrl": null,
|
|
8
8
|
"otaManifestSha256": null
|
|
9
9
|
});
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"releaseMetadata.js","sourceRoot":"","sources":["../../src/generated/releaseMetadata.ts"],"names":[],"mappings":"AAWA,MAAM,CAAC,MAAM,8BAA8B,GAA0C,MAAM,CAAC,MAAM,CAAC;IACjG,eAAe,EAAE,CAAC;IAClB,mBAAmB,EAAE,OAAO;IAC5B,iBAAiB,EAAE,eAAe;IAClC,cAAc,EAAE,uBAAuB;IACvC,cAAc,EAAE,0CAA0C;IAC1D,gBAAgB,EAAE,IAAI;IACtB,mBAAmB,EAAE,IAAI;CAC1B,CAAC,CAAA","sourcesContent":["/** Generated by release CI. Do not edit in a release checkout. */\nexport interface BluetoothSdkReleaseMetadata {\n schemaVersion: 1\n familyBaseVersion: string | null\n releaseIdentity: string | null\n releaseSetId: string | null\n sourceCommit: string | null\n otaManifestUrl: string | null\n otaManifestSha256: string | null\n}\n\nexport const BLUETOOTH_SDK_RELEASE_METADATA: Readonly<BluetoothSdkReleaseMetadata> = Object.freeze({\n \"schemaVersion\": 1,\n \"familyBaseVersion\": \"0.3.1\",\n \"releaseIdentity\": \"0.3.1-dev.
|
|
1
|
+
{"version":3,"file":"releaseMetadata.js","sourceRoot":"","sources":["../../src/generated/releaseMetadata.ts"],"names":[],"mappings":"AAWA,MAAM,CAAC,MAAM,8BAA8B,GAA0C,MAAM,CAAC,MAAM,CAAC;IACjG,eAAe,EAAE,CAAC;IAClB,mBAAmB,EAAE,OAAO;IAC5B,iBAAiB,EAAE,eAAe;IAClC,cAAc,EAAE,uBAAuB;IACvC,cAAc,EAAE,0CAA0C;IAC1D,gBAAgB,EAAE,IAAI;IACtB,mBAAmB,EAAE,IAAI;CAC1B,CAAC,CAAA","sourcesContent":["/** Generated by release CI. Do not edit in a release checkout. */\nexport interface BluetoothSdkReleaseMetadata {\n schemaVersion: 1\n familyBaseVersion: string | null\n releaseIdentity: string | null\n releaseSetId: string | null\n sourceCommit: string | null\n otaManifestUrl: string | null\n otaManifestSha256: string | null\n}\n\nexport const BLUETOOTH_SDK_RELEASE_METADATA: Readonly<BluetoothSdkReleaseMetadata> = Object.freeze({\n \"schemaVersion\": 1,\n \"familyBaseVersion\": \"0.3.1\",\n \"releaseIdentity\": \"0.3.1-dev.538\",\n \"releaseSetId\": \"glassly-0.3.1-dev.538\",\n \"sourceCommit\": \"3c004fd397951d67d68c6a6dcfda389afb5a2bc0\",\n \"otaManifestUrl\": null,\n \"otaManifestSha256\": null\n})\n"]}
|
|
@@ -11,7 +11,7 @@ enum BluetoothSdkDefaults {
|
|
|
11
11
|
static let voiceActivityDetectionEnabled = false
|
|
12
12
|
static let loudnessGateEnabled = true
|
|
13
13
|
private static let infoSdkVersionKey = "BluetoothSdkVersion"
|
|
14
|
-
private static let swiftPackageSdkVersion = "0.3.1-dev.
|
|
14
|
+
private static let swiftPackageSdkVersion = "0.3.1-dev.538"
|
|
15
15
|
private static let swiftPackageSdkVersionPlaceholder = "__GLASSLY" + "_BLUETOOTH_SDK_VERSION__"
|
|
16
16
|
|
|
17
17
|
private static func normalizedSdkVersion(_ value: String?) -> String? {
|
|
@@ -1976,6 +1976,12 @@ struct ViewState {
|
|
|
1976
1976
|
Bridge.log("MAN: No controller device name, returning")
|
|
1977
1977
|
return
|
|
1978
1978
|
}
|
|
1979
|
+
// The ring link outlives glasses connects/disconnects, so every
|
|
1980
|
+
// reconnect path may find it already up.
|
|
1981
|
+
if DeviceStore.shared.get("glasses", "controllerConnected") as? Bool == true {
|
|
1982
|
+
Bridge.log("MAN: Controller already connected, returning")
|
|
1983
|
+
return
|
|
1984
|
+
}
|
|
1979
1985
|
initController(defaultController)
|
|
1980
1986
|
searchingController = true
|
|
1981
1987
|
controller?.connectById(controllerDeviceName)
|
|
@@ -2074,11 +2080,8 @@ struct ViewState {
|
|
|
2074
2080
|
DeviceStore.shared.apply("glasses", "connected", false)
|
|
2075
2081
|
DeviceStore.shared.apply("glasses", "connectionState", ConnTypes.DISCONNECTED)
|
|
2076
2082
|
DeviceStore.shared.apply("glasses", "voiceActivityDetectionEnabled", BluetoothSdkDefaults.voiceActivityDetectionEnabled)
|
|
2077
|
-
//
|
|
2078
|
-
|
|
2079
|
-
DeviceStore.shared.apply("glasses", "controllerConnected", false)
|
|
2080
|
-
controller?.disconnect()
|
|
2081
|
-
controller = nil // Clear the controller reference after disconnect
|
|
2083
|
+
// The ring is its own link and stays up across glasses disconnects;
|
|
2084
|
+
// disconnectController()/forgetController() are the explicit ways down.
|
|
2082
2085
|
if clearPendingConnection {
|
|
2083
2086
|
self.clearPendingConnection()
|
|
2084
2087
|
}
|
|
@@ -2098,6 +2101,11 @@ struct ViewState {
|
|
|
2098
2101
|
controller = nil // Clear the controller reference after disconnect
|
|
2099
2102
|
}
|
|
2100
2103
|
|
|
2104
|
+
/// Debug `ring_via_glasses` flipped: re-route a connected ring live.
|
|
2105
|
+
func ringRouteToggled(_ viaGlasses: Bool) {
|
|
2106
|
+
controller?.setRouteViaGlasses(viaGlasses)
|
|
2107
|
+
}
|
|
2108
|
+
|
|
2101
2109
|
func forget() {
|
|
2102
2110
|
Bridge.log("MAN: Forgetting smart glasses")
|
|
2103
2111
|
// Call forget first to stop timers/handlers/reconnect logic
|
|
@@ -129,6 +129,8 @@ class DeviceStore {
|
|
|
129
129
|
store.set("bluetooth", "cfw_fast_ble", false)
|
|
130
130
|
// Debug: G2PERF display performance lines in the log (off = counted, not logged).
|
|
131
131
|
store.set("bluetooth", "g2_perf_logs", false)
|
|
132
|
+
// Debug: link the R1 ring to the glasses (off = ring on the phone link only).
|
|
133
|
+
store.set("bluetooth", "ring_via_glasses", false)
|
|
132
134
|
store.set("bluetooth", "display_max_fps", DisplayPacer.defaultFps)
|
|
133
135
|
}
|
|
134
136
|
|
|
@@ -247,6 +249,11 @@ class DeviceStore {
|
|
|
247
249
|
Task { await DeviceManager.shared.sgc?.cfwFastBleToggled(enabled) }
|
|
248
250
|
}
|
|
249
251
|
|
|
252
|
+
case ("bluetooth", "ring_via_glasses"):
|
|
253
|
+
if let viaGlasses = value as? Bool, (oldValue as? Bool) != viaGlasses {
|
|
254
|
+
Task { await DeviceManager.shared.ringRouteToggled(viaGlasses) }
|
|
255
|
+
}
|
|
256
|
+
|
|
250
257
|
case ("bluetooth", "display_max_fps"):
|
|
251
258
|
if let fps = value as? NSNumber {
|
|
252
259
|
DeviceManager.shared.setDisplayMaxFps(fps.intValue)
|
|
@@ -3,9 +3,9 @@ import Foundation
|
|
|
3
3
|
/// Generated by release CI. Do not edit in a release checkout.
|
|
4
4
|
enum GeneratedReleaseMetadata {
|
|
5
5
|
static let familyBaseVersion = "0.3.1"
|
|
6
|
-
static let releaseIdentity = "0.3.1-dev.
|
|
7
|
-
static let releaseSetId = "glassly-0.3.1-dev.
|
|
8
|
-
static let sourceCommit = "
|
|
6
|
+
static let releaseIdentity = "0.3.1-dev.538"
|
|
7
|
+
static let releaseSetId = "glassly-0.3.1-dev.538"
|
|
8
|
+
static let sourceCommit = "3c004fd397951d67d68c6a6dcfda389afb5a2bc0"
|
|
9
9
|
static let otaManifestUrl = ""
|
|
10
10
|
static let otaManifestSha256 = ""
|
|
11
11
|
}
|
|
@@ -58,6 +58,8 @@ protocol ControllerManager {
|
|
|
58
58
|
|
|
59
59
|
func disconnect()
|
|
60
60
|
func forget()
|
|
61
|
+
/// Route input through the glasses (debug) or the phone link (default).
|
|
62
|
+
func setRouteViaGlasses(_ viaGlasses: Bool)
|
|
61
63
|
func findCompatibleDevices()
|
|
62
64
|
func stopScan()
|
|
63
65
|
func connectById(_ id: String)
|
|
@@ -12,6 +12,24 @@ import Foundation
|
|
|
12
12
|
// MARK: - R1 BLE Constants
|
|
13
13
|
|
|
14
14
|
private enum R1BLE {
|
|
15
|
+
/// Scan-response manufacturer data (openCFW: `0x5245` company id, the six
|
|
16
|
+
/// address bytes in SoftDevice order, then an optional 15-byte serial).
|
|
17
|
+
/// The GAP name is `EVEN R1_` + address bytes 3,2,1, which pins the byte
|
|
18
|
+
/// order; the stored string keeps SoftDevice order, the form the glasses
|
|
19
|
+
/// have accepted in RING_CONNECT_INFO. Taking the LAST six bytes was wrong
|
|
20
|
+
/// for rings with a serial (it returned ASCII digits).
|
|
21
|
+
static func macAddress(mfgData: Data, ringId: String?) -> Data? {
|
|
22
|
+
let bytes = [UInt8](mfgData)
|
|
23
|
+
guard bytes.count >= 8, bytes[0] == 0x45, bytes[1] == 0x52 else { return nil }
|
|
24
|
+
let addr = Array(bytes[2 ..< 8])
|
|
25
|
+
guard let ringId, !ringId.isEmpty else { return Data(addr) }
|
|
26
|
+
let hex = { (b: [UInt8]) in b.map { String(format: "%02X", $0) }.joined() }
|
|
27
|
+
if hex([addr[3], addr[2], addr[1]]) == ringId.uppercased() { return Data(addr) }
|
|
28
|
+
let flipped = Array(addr.reversed())
|
|
29
|
+
if hex([flipped[3], flipped[2], flipped[1]]) == ringId.uppercased() { return Data(flipped) }
|
|
30
|
+
return nil
|
|
31
|
+
}
|
|
32
|
+
|
|
15
33
|
/// Ring custom service
|
|
16
34
|
static let SERVICE_UUID = CBUUID(string: "BAE80001-4F05-4503-8E65-3AF1F7329D1F")
|
|
17
35
|
|
|
@@ -33,9 +51,46 @@ private enum R1BLE {
|
|
|
33
51
|
/// Gesture protocol marker
|
|
34
52
|
static let GESTURE_MARKER: UInt8 = 0xFF
|
|
35
53
|
|
|
54
|
+
/// `source` on emitted touch events; the same value the G2 CFW tags
|
|
55
|
+
/// ring input it relays with, so JS treats both paths alike.
|
|
56
|
+
static let RING_SOURCE: Int32 = 2
|
|
57
|
+
|
|
36
58
|
static let SCAN_TIMEOUT: TimeInterval = 15.0
|
|
37
59
|
}
|
|
38
60
|
|
|
61
|
+
// MARK: - Ring → phone touch report (channel 1)
|
|
62
|
+
|
|
63
|
+
/// The ring reports touch on the legacy channel-1 transport as 11 bytes
|
|
64
|
+
/// `[00][09][61][00][wireType][aux][speed][00][tick LE32]` — the same report
|
|
65
|
+
/// the Glassly CFW relays from the glasses as the `RI` SysEvent extension
|
|
66
|
+
/// (g2flash gesture_fwd.c `faceclaw_ring_report`). Wire types map onto the
|
|
67
|
+
/// gesture names the G2 path emits so miniapps see one vocabulary.
|
|
68
|
+
private enum R1TouchReport {
|
|
69
|
+
static let LENGTH = 11
|
|
70
|
+
static let OPCODE: UInt8 = 0x61
|
|
71
|
+
|
|
72
|
+
static func gestureName(wireType: UInt8) -> String? {
|
|
73
|
+
switch wireType {
|
|
74
|
+
case 0: return "tap_start"
|
|
75
|
+
case 1: return "single_tap"
|
|
76
|
+
case 2: return "double_tap"
|
|
77
|
+
case 4: return "swipe_up"
|
|
78
|
+
case 5: return "swipe_down"
|
|
79
|
+
case 8: return "tap_end"
|
|
80
|
+
case 9: return "tap_then_hold"
|
|
81
|
+
case 10: return "touch_down"
|
|
82
|
+
default: return nil
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/// nil when `data` is not a touch report; `.gesture` nil for an unknown wire type.
|
|
87
|
+
static func parse(_ data: Data) -> (gesture: String?, wireType: UInt8)? {
|
|
88
|
+
let b = [UInt8](data)
|
|
89
|
+
guard b.count == LENGTH, b[0] == 0, b[1] == 9, b[2] == OPCODE, b[3] == 0 else { return nil }
|
|
90
|
+
return (gestureName(wireType: b[4]), b[4])
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
39
94
|
// MARK: - EUS transport encoder (phone → ring, channel 2)
|
|
40
95
|
|
|
41
96
|
/// Recovered firmware contract (openCFW r1/ research): every phone→ring packet
|
|
@@ -61,6 +116,12 @@ private enum R1Eus {
|
|
|
61
116
|
/// system subcommand 0x0A: 12-byte payload = two 6-byte glasses arm MACs;
|
|
62
117
|
/// the ring stores both targets and fast-advertises for them
|
|
63
118
|
static let SUBCMD_ADV_START: UInt8 = 0x0A
|
|
119
|
+
/// system subcommand 0x82 (removeRingNotify): one-byte SET erases both
|
|
120
|
+
/// stored glasses targets and the "glasses paired" flag. While the ring
|
|
121
|
+
/// holds glasses targets its touch reports go to the glasses and the
|
|
122
|
+
/// phone link carries telemetry only, so this is what puts input on the
|
|
123
|
+
/// phone link.
|
|
124
|
+
static let SUBCMD_REMOVE_RING: UInt8 = 0x82
|
|
64
125
|
|
|
65
126
|
private static func crc16Step(_ crc: UInt16, _ byte: UInt8) -> UInt16 {
|
|
66
127
|
var c = (crc >> 8) | (crc << 8)
|
|
@@ -124,7 +185,7 @@ private enum R1Eus {
|
|
|
124
185
|
// MARK: - R1 Gesture Types
|
|
125
186
|
|
|
126
187
|
private enum R1Gesture: String {
|
|
127
|
-
case hold
|
|
188
|
+
case hold = "tap_then_hold"
|
|
128
189
|
case singleTap = "single_tap"
|
|
129
190
|
case doubleTap = "double_tap"
|
|
130
191
|
case swipeUp = "swipe_up"
|
|
@@ -168,6 +229,12 @@ class R1: NSObject, ControllerManager {
|
|
|
168
229
|
private var initSequenceRun = false
|
|
169
230
|
private var eusSerial: UInt16 = 0
|
|
170
231
|
|
|
232
|
+
/// Debug `ring_via_glasses` (default off): link the ring to the glasses and
|
|
233
|
+
/// take its input through the CFW relay instead of this link.
|
|
234
|
+
private var routeViaGlasses: Bool {
|
|
235
|
+
DeviceStore.shared.get("bluetooth", "ring_via_glasses") as? Bool ?? false
|
|
236
|
+
}
|
|
237
|
+
|
|
171
238
|
/// Device search
|
|
172
239
|
var DEVICE_SEARCH_ID = "NOT_SET"
|
|
173
240
|
|
|
@@ -409,20 +476,50 @@ class R1: NSObject, ControllerManager {
|
|
|
409
476
|
|
|
410
477
|
DeviceStore.shared.apply("glasses", "controllerConnected", true)
|
|
411
478
|
// The phone link owns these flags: a ready phone link IS pairing
|
|
412
|
-
// success (fires handleControllerReady).
|
|
413
|
-
// on afterwards
|
|
479
|
+
// success (fires handleControllerReady). A glasses link is only
|
|
480
|
+
// layered on afterwards (advStart + RING_CONNECT_INFO) when debug-routed.
|
|
414
481
|
DeviceStore.shared.apply("glasses", "controllerFullyBooted", true)
|
|
415
482
|
|
|
416
|
-
|
|
417
|
-
connectToGlasses()
|
|
483
|
+
applyRoute(viaGlasses: routeViaGlasses)
|
|
418
484
|
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
485
|
+
startHeartbeat()
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/// Phone route (default): erase the ring's stored glasses targets so its
|
|
489
|
+
/// touch reports come to this link, and have the glasses drop any ring
|
|
490
|
+
/// link they still hold. Glasses route (debug): hand the ring the glasses
|
|
491
|
+
/// MACs, then ask the glasses to connect.
|
|
492
|
+
private func applyRoute(viaGlasses: Bool) {
|
|
493
|
+
if viaGlasses {
|
|
494
|
+
connectToGlasses()
|
|
495
|
+
// after a second, connect the glasses to the controller if needed:
|
|
496
|
+
Task {
|
|
497
|
+
try? await Task.sleep(nanoseconds: 1_000_000_000)
|
|
498
|
+
await DeviceManager.shared.sgc?.connectController()
|
|
499
|
+
}
|
|
500
|
+
} else {
|
|
501
|
+
sendEusCommand(R1Eus.SUBCMD_REMOVE_RING, payload: Data([0x01]), log: "removeRingNotify")
|
|
502
|
+
Task { await DeviceManager.shared.sgc?.releaseController() }
|
|
423
503
|
}
|
|
504
|
+
}
|
|
424
505
|
|
|
425
|
-
|
|
506
|
+
func setRouteViaGlasses(_ viaGlasses: Bool) {
|
|
507
|
+
Bridge.log("R1: route via \(viaGlasses ? "glasses" : "phone")")
|
|
508
|
+
guard DeviceStore.shared.get("glasses", "controllerConnected") as? Bool ?? false else { return }
|
|
509
|
+
applyRoute(viaGlasses: viaGlasses)
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
/// One emit path for both direct parsers. The ring reports touch to a
|
|
513
|
+
/// single peer, so whatever arrives here is the only copy — forward it
|
|
514
|
+
/// whichever route is configured.
|
|
515
|
+
private func emitGesture(_ gestureName: String, timestamp: Int64, via: String) {
|
|
516
|
+
Bridge.log("R1: \(via): \(gestureName)")
|
|
517
|
+
Bridge.sendTouchEvent(
|
|
518
|
+
deviceModel: ControllerTypes.R1,
|
|
519
|
+
gestureName: gestureName,
|
|
520
|
+
timestamp: timestamp,
|
|
521
|
+
source: R1BLE.RING_SOURCE
|
|
522
|
+
)
|
|
426
523
|
}
|
|
427
524
|
|
|
428
525
|
// MARK: - Heartbeat
|
|
@@ -455,15 +552,20 @@ class R1: NSObject, ControllerManager {
|
|
|
455
552
|
|
|
456
553
|
let timestamp = Int64(Date().timeIntervalSince1970 * 1000)
|
|
457
554
|
|
|
555
|
+
// Touch report: [00 09 61 00 wireType aux speed 00 tick4]
|
|
556
|
+
if let report = R1TouchReport.parse(data) {
|
|
557
|
+
if let gesture = report.gesture {
|
|
558
|
+
emitGesture(gesture, timestamp: timestamp, via: "Touch report")
|
|
559
|
+
} else {
|
|
560
|
+
Bridge.log("R1: Unknown touch report wireType=\(report.wireType)")
|
|
561
|
+
}
|
|
562
|
+
return
|
|
563
|
+
}
|
|
564
|
+
|
|
458
565
|
// Gesture: [0xFF, type, param]
|
|
459
566
|
if data.count >= 3, data[0] == R1BLE.GESTURE_MARKER {
|
|
460
567
|
if let gesture = R1Gesture.parse(from: data) {
|
|
461
|
-
|
|
462
|
-
Bridge.sendTouchEvent(
|
|
463
|
-
deviceModel: ControllerTypes.R1,
|
|
464
|
-
gestureName: gesture.rawValue,
|
|
465
|
-
timestamp: timestamp
|
|
466
|
-
)
|
|
568
|
+
emitGesture(gesture.rawValue, timestamp: timestamp, via: "Gesture")
|
|
467
569
|
} else {
|
|
468
570
|
Bridge.log(
|
|
469
571
|
"R1: Unknown gesture type=0x\(String(format: "%02X", data[1])) param=0x\(String(format: "%02X", data[2]))"
|
|
@@ -492,12 +594,7 @@ class R1: NSObject, ControllerManager {
|
|
|
492
594
|
{
|
|
493
595
|
let gestureData = Data(data[ffIndex ... ffIndex + 2])
|
|
494
596
|
if let gesture = R1Gesture.parse(from: gestureData) {
|
|
495
|
-
|
|
496
|
-
Bridge.sendTouchEvent(
|
|
497
|
-
deviceModel: ControllerTypes.R1,
|
|
498
|
-
gestureName: gesture.rawValue,
|
|
499
|
-
timestamp: timestamp
|
|
500
|
-
)
|
|
597
|
+
emitGesture(gesture.rawValue, timestamp: timestamp, via: "Embedded gesture")
|
|
501
598
|
return
|
|
502
599
|
}
|
|
503
600
|
}
|
|
@@ -678,8 +775,10 @@ extension R1: CBCentralManagerDelegate {
|
|
|
678
775
|
Bridge.log(
|
|
679
776
|
"R1: mfgData: \(mfgData.map { String(format: "%02X", $0) }.joined(separator: " "))"
|
|
680
777
|
)
|
|
681
|
-
if mfgData
|
|
682
|
-
self.ringMacAddressMap[name ?? ""] =
|
|
778
|
+
if let mac = R1BLE.macAddress(mfgData: mfgData, ringId: name.flatMap(self.extractRingId)) {
|
|
779
|
+
self.ringMacAddressMap[name ?? ""] = mac
|
|
780
|
+
} else {
|
|
781
|
+
Bridge.log("R1: mfgData carries no address matching \(name ?? "?")")
|
|
683
782
|
}
|
|
684
783
|
}
|
|
685
784
|
|
package/ios/Source/sgcs/G2.swift
CHANGED
|
@@ -2674,7 +2674,7 @@ final class G2: NSObject, SGCManager {
|
|
|
2674
2674
|
|
|
2675
2675
|
setFullyConnected()
|
|
2676
2676
|
|
|
2677
|
-
//
|
|
2677
|
+
// Link a paired ring if it is routed via the glasses, else release it:
|
|
2678
2678
|
connectController()
|
|
2679
2679
|
|
|
2680
2680
|
// Query version + battery info from glasses
|
|
@@ -5812,66 +5812,68 @@ final class G2: NSObject, SGCManager {
|
|
|
5812
5812
|
sendEvenHubHeartbeat()
|
|
5813
5813
|
}
|
|
5814
5814
|
|
|
5815
|
-
|
|
5815
|
+
/// Debug `ring_via_glasses`: the ring is linked to the glasses and its
|
|
5816
|
+
/// input arrives through the CFW. Off (default), the ring stays on the
|
|
5817
|
+
/// phone link and the glasses must not hold it — the ring sends touch
|
|
5818
|
+
/// reports to whichever glasses link it has and only telemetry to the phone.
|
|
5819
|
+
private var ringViaGlasses: Bool {
|
|
5820
|
+
DeviceStore.shared.get("bluetooth", "ring_via_glasses") as? Bool ?? false
|
|
5821
|
+
}
|
|
5822
|
+
|
|
5823
|
+
private var lastRingReleaseAt: TimeInterval = 0
|
|
5824
|
+
private let RING_RELEASE_INTERVAL_S: TimeInterval = 5
|
|
5825
|
+
|
|
5826
|
+
/// RING_CONNECT_INFO for the paired ring. False when the glasses aren't
|
|
5827
|
+
/// booted or no ring MAC is known.
|
|
5828
|
+
@discardableResult
|
|
5829
|
+
private func sendRingConnectInfo(connect: Bool, tag: String) -> Bool {
|
|
5816
5830
|
let isFullyBooted = DeviceStore.shared.get("glasses", "fullyBooted") as? Bool ?? false
|
|
5817
5831
|
guard isFullyBooted else {
|
|
5818
|
-
Bridge.log("G2:
|
|
5819
|
-
return
|
|
5832
|
+
Bridge.log("G2: \(tag) - g2 not fully booted, ignoring")
|
|
5833
|
+
return false
|
|
5820
5834
|
}
|
|
5821
5835
|
|
|
5822
|
-
guard let mac = DeviceStore.shared.get("glasses", "controllerMacAddress") as? String else {
|
|
5823
|
-
Bridge.log("G2:
|
|
5824
|
-
return
|
|
5836
|
+
guard let mac = DeviceStore.shared.get("glasses", "controllerMacAddress") as? String, !mac.isEmpty else {
|
|
5837
|
+
Bridge.log("G2: \(tag) - no MAC address found")
|
|
5838
|
+
return false
|
|
5825
5839
|
}
|
|
5826
5840
|
|
|
5827
5841
|
// Parse "AA:BB:CC:DD:EE:FF" into 6-byte Data
|
|
5828
5842
|
let hexParts = mac.split(separator: ":").compactMap { UInt8($0, radix: 16) }
|
|
5829
5843
|
guard hexParts.count == 6 else {
|
|
5830
|
-
Bridge.log("G2:
|
|
5831
|
-
return
|
|
5844
|
+
Bridge.log("G2: \(tag) - invalid MAC format: \(mac)")
|
|
5845
|
+
return false
|
|
5832
5846
|
}
|
|
5833
|
-
let macData = Data(hexParts)
|
|
5834
5847
|
|
|
5835
5848
|
let msg = DevSettingsProto.ringConnectInfo(
|
|
5836
5849
|
magicRandom: sendManager.nextMagicRandom(),
|
|
5837
|
-
connect:
|
|
5838
|
-
ringMac:
|
|
5850
|
+
connect: connect,
|
|
5851
|
+
ringMac: Data(hexParts)
|
|
5839
5852
|
)
|
|
5840
5853
|
sendDevSettingsCommand(msg)
|
|
5841
|
-
Bridge.log("G2: Sent
|
|
5854
|
+
Bridge.log("G2: Sent RING_\(connect ? "CONNECT" : "DISCONNECT")_INFO for MAC \(mac)")
|
|
5855
|
+
return true
|
|
5842
5856
|
}
|
|
5843
5857
|
|
|
5844
|
-
func
|
|
5845
|
-
|
|
5846
|
-
|
|
5847
|
-
|
|
5848
|
-
return
|
|
5849
|
-
}
|
|
5850
|
-
|
|
5851
|
-
guard let mac = DeviceStore.shared.get("glasses", "controllerMacAddress") as? String else {
|
|
5852
|
-
Bridge.log("G2: disconnectController - no MAC address found")
|
|
5853
|
-
return
|
|
5854
|
-
}
|
|
5855
|
-
|
|
5856
|
-
// Parse "AA:BB:CC:DD:EE:FF" into 6-byte Data
|
|
5857
|
-
let hexParts = mac.split(separator: ":").compactMap { UInt8($0, radix: 16) }
|
|
5858
|
-
guard hexParts.count == 6 else {
|
|
5859
|
-
Bridge.log("G2: disconnectController - invalid MAC format: \(mac)")
|
|
5858
|
+
func connectController() {
|
|
5859
|
+
guard ringViaGlasses else {
|
|
5860
|
+
// Phone-routed ring: make sure the glasses let go of it instead.
|
|
5861
|
+
releaseController()
|
|
5860
5862
|
return
|
|
5861
5863
|
}
|
|
5862
|
-
|
|
5864
|
+
sendRingConnectInfo(connect: true, tag: "connectController")
|
|
5865
|
+
}
|
|
5863
5866
|
|
|
5864
|
-
|
|
5865
|
-
|
|
5866
|
-
connect: false,
|
|
5867
|
-
ringMac: macData
|
|
5868
|
-
)
|
|
5869
|
-
sendDevSettingsCommand(msg)
|
|
5867
|
+
func disconnectController() {
|
|
5868
|
+
guard sendRingConnectInfo(connect: false, tag: "disconnectController") else { return }
|
|
5870
5869
|
|
|
5871
5870
|
// DeviceStore.shared.apply("glasses", "controllerMacAddress", "")
|
|
5872
5871
|
DeviceStore.shared.apply("glasses", "controllerConnected", false)
|
|
5873
5872
|
DeviceStore.shared.apply("glasses", "controllerFullyBooted", false)
|
|
5874
|
-
|
|
5873
|
+
}
|
|
5874
|
+
|
|
5875
|
+
func releaseController() {
|
|
5876
|
+
sendRingConnectInfo(connect: false, tag: "releaseController")
|
|
5875
5877
|
}
|
|
5876
5878
|
|
|
5877
5879
|
/// Fire an EvenAI skill — the same path "Hey Even, show X" uses. Triggers a
|
|
@@ -7092,13 +7094,28 @@ final class G2: NSObject, SGCManager {
|
|
|
7092
7094
|
return
|
|
7093
7095
|
}
|
|
7094
7096
|
|
|
7097
|
+
// Ring input relayed by the glasses. Older CFWs left event 14's
|
|
7098
|
+
// source unspecified, so the id alone also marks the ring.
|
|
7099
|
+
let fromRing = eventSource == 2 || eventType == .ringTouchDown
|
|
7100
|
+
if fromRing, !ringViaGlasses {
|
|
7101
|
+
// The ring only reports touch to one peer, so this input is
|
|
7102
|
+
// real and must flow — but the glasses shouldn't hold the ring
|
|
7103
|
+
// while it is phone-routed. Ask them to let go (not per event).
|
|
7104
|
+
let now = Date().timeIntervalSince1970
|
|
7105
|
+
if now - lastRingReleaseAt > RING_RELEASE_INTERVAL_S {
|
|
7106
|
+
lastRingReleaseAt = now
|
|
7107
|
+
Bridge.log("G2: ring \(gestureName) via glasses while phone-routed — releasing ring")
|
|
7108
|
+
releaseController()
|
|
7109
|
+
}
|
|
7110
|
+
}
|
|
7111
|
+
|
|
7095
7112
|
Bridge.sendTouchEvent(
|
|
7096
7113
|
deviceModel: type, gestureName: gestureName,
|
|
7097
7114
|
timestamp: timestamp,
|
|
7098
7115
|
source: eventSource
|
|
7099
7116
|
)
|
|
7100
7117
|
|
|
7101
|
-
if
|
|
7118
|
+
if fromRing {
|
|
7102
7119
|
// controller must be connected and fully booted:
|
|
7103
7120
|
setControllerFullyConnected()
|
|
7104
7121
|
}
|
|
@@ -7259,7 +7276,8 @@ final class G2: NSObject, SGCManager {
|
|
|
7259
7276
|
if connStatus == 22 {
|
|
7260
7277
|
// The ring↔glasses link dropped. The phone↔ring link owns
|
|
7261
7278
|
// controllerConnected/FullyBooted, so don't clear them here
|
|
7262
|
-
// (that would flap pairing state) — just re-request the link
|
|
7279
|
+
// (that would flap pairing state) — just re-request the link
|
|
7280
|
+
// (a no-op while the ring is routed via the phone).
|
|
7263
7281
|
Bridge.log("G2: Ring-glasses link dropped, re-requesting")
|
|
7264
7282
|
reconnectController()
|
|
7265
7283
|
}
|
|
@@ -343,6 +343,8 @@ protocol SGCManager {
|
|
|
343
343
|
func getConnectedBluetoothName() -> String?
|
|
344
344
|
func connectController()
|
|
345
345
|
func disconnectController()
|
|
346
|
+
/// Drop the glasses↔ring link without touching the phone↔ring state.
|
|
347
|
+
func releaseController()
|
|
346
348
|
func cleanup()
|
|
347
349
|
func ping()
|
|
348
350
|
func dbg1()
|
|
@@ -397,6 +399,9 @@ extension SGCManager {
|
|
|
397
399
|
/// Default: no-op. Only G2 distinguishes stock from Glassly CFW firmware.
|
|
398
400
|
func awaitFirmwareClassification() async {}
|
|
399
401
|
|
|
402
|
+
/// Default: no-op. Only G2 can hold a ring link.
|
|
403
|
+
func releaseController() {}
|
|
404
|
+
|
|
400
405
|
/// Default: no-op debug hooks; only G2 wires them.
|
|
401
406
|
func dbg3() {}
|
|
402
407
|
func dbg4() {}
|
package/package.json
CHANGED
|
@@ -12,9 +12,9 @@ export interface BluetoothSdkReleaseMetadata {
|
|
|
12
12
|
export const BLUETOOTH_SDK_RELEASE_METADATA: Readonly<BluetoothSdkReleaseMetadata> = Object.freeze({
|
|
13
13
|
"schemaVersion": 1,
|
|
14
14
|
"familyBaseVersion": "0.3.1",
|
|
15
|
-
"releaseIdentity": "0.3.1-dev.
|
|
16
|
-
"releaseSetId": "glassly-0.3.1-dev.
|
|
17
|
-
"sourceCommit": "
|
|
15
|
+
"releaseIdentity": "0.3.1-dev.538",
|
|
16
|
+
"releaseSetId": "glassly-0.3.1-dev.538",
|
|
17
|
+
"sourceCommit": "3c004fd397951d67d68c6a6dcfda389afb5a2bc0",
|
|
18
18
|
"otaManifestUrl": null,
|
|
19
19
|
"otaManifestSha256": null
|
|
20
20
|
})
|