@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.
@@ -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
- // disconnect the controller as well:
2439
- searchingController = false
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.535"
7
- const val RELEASE_SET_ID: String = "glassly-0.3.1-dev.535"
8
- const val SOURCE_COMMIT: String = "5efaf90cbc7e0103909d4f15c530076eb042deb5"
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("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 (last 6 bytes) and store name->MAC map.
442
- // Android's manufacturerSpecificData strips the 2-byte company ID; iOS keeps it. The
443
- // resulting "last 6 bytes" land in reversed byte order between platforms (verified in
444
- // the field), so reverse here to match iOS's stored string ("1B:08:26:8E:0E:E6").
445
- if (deviceName != null && mfgBytes != null && mfgBytes.size >= 6) {
446
- val tail = mfgBytes.copyOfRange(mfgBytes.size - 6, mfgBytes.size)
447
- val macStr = tail.joinToString(":") { String.format("%02X", it) }
448
- putRingMacInMap(deviceName, macStr)
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). The glasses link is layered
558
- // on afterwards via advStart + RING_CONNECT_INFO.
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
- // tell the ring to connect to the glasses if we have its mac address:
562
- connectToGlasses()
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
- // after a second, connect the glasses to the controller if needed:
565
- CoroutineScope(Dispatchers.Main).launch {
566
- delay(1000)
567
- DeviceManager.getInstance().sgc?.connectController()
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
- startHeartbeat()
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
- Bridge.log("R1: Gesture: ${gesture.rawValue}")
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
- Bridge.log("R1: Embedded gesture: ${gesture.rawValue}")
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
- // Connect a controller if we have one
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
- override fun connectController() {
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: connectController - g2 not fully booted, ignoring")
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: connectController - no MAC address found")
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: connectController - invalid MAC format: $mac")
5613
- return
5626
+ Bridge.log("G2: $tag - invalid MAC format: $mac")
5627
+ return false
5614
5628
  }
5615
- Bridge.log("G2: connectController() - MAC: $mac")
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 RING_CONNECT_INFO for MAC $mac")
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 disconnectController() {
5623
- val isFullyBooted = DeviceStore.get("glasses", "fullyBooted") as? Boolean ?: false
5624
- if (!isFullyBooted) {
5625
- Bridge.log("G2: disconnectController - g2 not fully booted, ignoring")
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
- val macData = hexParts.toByteArray()
5639
- val msg = DevSettingsProto.ringConnectInfo(sendManager.nextMagicRandom(), false, macData)
5640
- sendDevSettingsCommand(msg)
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
- Bridge.log("G2: Sent RING_DISCONNECT_INFO for MAC $mac")
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 (eventSource == 2) {
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.535",
5
- "releaseSetId": "glassly-0.3.1-dev.535",
6
- "sourceCommit": "5efaf90cbc7e0103909d4f15c530076eb042deb5",
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.535\",\n \"releaseSetId\": \"glassly-0.3.1-dev.535\",\n \"sourceCommit\": \"5efaf90cbc7e0103909d4f15c530076eb042deb5\",\n \"otaManifestUrl\": null,\n \"otaManifestSha256\": null\n})\n"]}
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.535"
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
- // disconnect the controller as well:
2078
- searchingController = false
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.535"
7
- static let releaseSetId = "glassly-0.3.1-dev.535"
8
- static let sourceCommit = "5efaf90cbc7e0103909d4f15c530076eb042deb5"
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). The glasses link is layered
413
- // on afterwards via advStart + RING_CONNECT_INFO.
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
- // tell the ring to connect to the glasses if we have it's mac address:
417
- connectToGlasses()
483
+ applyRoute(viaGlasses: routeViaGlasses)
418
484
 
419
- // after a second, connect the glasses to the controller if needed:
420
- Task {
421
- try? await Task.sleep(nanoseconds: 1_000_000_000)
422
- await DeviceManager.shared.sgc?.connectController()
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
- startHeartbeat()
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
- Bridge.log("R1: Gesture: \(gesture.rawValue)")
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
- Bridge.log("R1: Embedded gesture: \(gesture.rawValue)")
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.count >= 6 {
682
- self.ringMacAddressMap[name ?? ""] = Data(mfgData.suffix(6))
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
 
@@ -2674,7 +2674,7 @@ final class G2: NSObject, SGCManager {
2674
2674
 
2675
2675
  setFullyConnected()
2676
2676
 
2677
- // connnect a controller if we have one:
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
- func connectController() {
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: connectController - g2 not fully booted, ignoring")
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: connectController - no MAC address found")
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: connectController - invalid MAC format: \(mac)")
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: true,
5838
- ringMac: macData
5850
+ connect: connect,
5851
+ ringMac: Data(hexParts)
5839
5852
  )
5840
5853
  sendDevSettingsCommand(msg)
5841
- Bridge.log("G2: Sent RING_CONNECT_INFO for MAC \(mac)")
5854
+ Bridge.log("G2: Sent RING_\(connect ? "CONNECT" : "DISCONNECT")_INFO for MAC \(mac)")
5855
+ return true
5842
5856
  }
5843
5857
 
5844
- func disconnectController() {
5845
- let isFullyBooted = DeviceStore.shared.get("glasses", "fullyBooted") as? Bool ?? false
5846
- guard isFullyBooted else {
5847
- Bridge.log("G2: disconnectController - g2 not fully booted, ignoring")
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
- let macData = Data(hexParts)
5864
+ sendRingConnectInfo(connect: true, tag: "connectController")
5865
+ }
5863
5866
 
5864
- let msg = DevSettingsProto.ringConnectInfo(
5865
- magicRandom: sendManager.nextMagicRandom(),
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
- Bridge.log("G2: Sent RING_DISCONNECT_INFO for MAC \(mac)")
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 eventSource == 2 {
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@glassly/bluetooth-sdk",
3
- "version": "0.3.1-dev.535",
3
+ "version": "0.3.1-dev.538",
4
4
  "description": "SDK for communicating with smart glasses",
5
5
  "main": "build/index.js",
6
6
  "react-native": "src/index.ts",
@@ -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.535",
16
- "releaseSetId": "glassly-0.3.1-dev.535",
17
- "sourceCommit": "5efaf90cbc7e0103909d4f15c530076eb042deb5",
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
  })