@glassly/bluetooth-sdk 0.3.1-dev.533 → 0.3.1-dev.537

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.533"
7
- const val RELEASE_SET_ID: String = "glassly-0.3.1-dev.533"
8
- const val SOURCE_COMMIT: String = "1b6fc6874ea0d711351c15650221afae50fc857d"
6
+ const val RELEASE_IDENTITY: String = "0.3.1-dev.537"
7
+ const val RELEASE_SET_ID: String = "glassly-0.3.1-dev.537"
8
+ const val SOURCE_COMMIT: String = "2cb6ba96b1141a7d23dc38cb1ce44e6ede4f305a"
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,45 @@ 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
+ // MARK: - Ring → phone touch report (channel 1)
55
+
56
+ /**
57
+ * The ring reports touch on the legacy channel-1 transport as 11 bytes
58
+ * `[00][09][61][00][wireType][aux][speed][00][tick LE32]` — the same report
59
+ * the Glassly CFW relays from the glasses as the `RI` SysEvent extension
60
+ * (g2flash gesture_fwd.c `faceclaw_ring_report`). Wire types map onto the
61
+ * gesture names the G2 path emits so miniapps see one vocabulary.
62
+ */
63
+ private object R1TouchReport {
64
+ const val LENGTH = 11
65
+ const val OPCODE: Byte = 0x61
66
+
67
+ fun gestureName(wireType: Int): String? = when (wireType) {
68
+ 0 -> "tap_start"
69
+ 1 -> "single_tap"
70
+ 2 -> "double_tap"
71
+ 4 -> "swipe_up"
72
+ 5 -> "swipe_down"
73
+ 8 -> "tap_end"
74
+ 9 -> "tap_then_hold"
75
+ 10 -> "touch_down"
76
+ else -> null
77
+ }
78
+
79
+ /** null when `data` is not a touch report; `.first` null for an unknown wire type. */
80
+ fun parse(data: ByteArray): Pair<String?, Int>? {
81
+ if (data.size != LENGTH || data[0] != 0.toByte() || data[1] != 9.toByte() ||
82
+ data[2] != OPCODE || data[3] != 0.toByte()
83
+ ) return null
84
+ val wireType = data[4].toInt() and 0xFF
85
+ return gestureName(wireType) to wireType
86
+ }
48
87
  }
49
88
 
50
89
  // MARK: - EUS transport encoder (phone → ring, channel 2)
@@ -75,6 +114,12 @@ private object R1Eus {
75
114
  /** system subcommand 0x0A: 12-byte payload = two 6-byte glasses arm MACs;
76
115
  * the ring stores both targets and fast-advertises for them */
77
116
  const val SUBCMD_ADV_START = 0x0A
117
+ /** system subcommand 0x82 (removeRingNotify): one-byte SET erases both
118
+ * stored glasses targets and the "glasses paired" flag. While the ring
119
+ * holds glasses targets its touch reports go to the glasses and the
120
+ * phone link carries telemetry only, so this is what puts input on the
121
+ * phone link. */
122
+ const val SUBCMD_REMOVE_RING = 0x82
78
123
 
79
124
  private fun crc16Step(crc: Int, byte: Int): Int {
80
125
  var c = ((crc shr 8) or (crc shl 8)) and 0xFFFF
@@ -144,7 +189,7 @@ private object R1Eus {
144
189
  // MARK: - R1 Gesture Types
145
190
 
146
191
  private enum class R1Gesture(val rawValue: String) {
147
- HOLD("hold"),
192
+ HOLD("tap_then_hold"),
148
193
  SINGLE_TAP("single_tap"),
149
194
  DOUBLE_TAP("double_tap"),
150
195
  SWIPE_UP("swipe_up"),
@@ -554,20 +599,58 @@ class R1 : ControllerManager() {
554
599
  DeviceStore.apply("glasses", "controllerMacAddress", mac)
555
600
  DeviceStore.apply("glasses", "controllerConnected", true)
556
601
  // 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.
602
+ // success (fires handleControllerReady). A glasses link is only
603
+ // layered on afterwards (advStart + RING_CONNECT_INFO) when debug-routed.
559
604
  DeviceStore.apply("glasses", "controllerFullyBooted", true)
560
605
 
561
- // tell the ring to connect to the glasses if we have its mac address:
562
- connectToGlasses()
606
+ applyRoute(routeViaGlasses)
607
+
608
+ startHeartbeat()
609
+ }
563
610
 
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()
611
+ /** Debug `ring_via_glasses` (default off): link the ring to the glasses and
612
+ * take its input through the CFW relay instead of this link. */
613
+ private val routeViaGlasses: Boolean
614
+ get() = DeviceStore.get("bluetooth", "ring_via_glasses") as? Boolean ?: false
615
+
616
+ /**
617
+ * Phone route (default): erase the ring's stored glasses targets so its
618
+ * touch reports come to this link, and have the glasses drop any ring
619
+ * link they still hold. Glasses route (debug): hand the ring the glasses
620
+ * MACs, then ask the glasses to connect.
621
+ */
622
+ private fun applyRoute(viaGlasses: Boolean) {
623
+ if (viaGlasses) {
624
+ connectToGlasses()
625
+ // after a second, connect the glasses to the controller if needed:
626
+ CoroutineScope(Dispatchers.Main).launch {
627
+ delay(1000)
628
+ DeviceManager.getInstance().sgc?.connectController()
629
+ }
630
+ } else {
631
+ sendEusCommand(R1Eus.SUBCMD_REMOVE_RING, byteArrayOf(0x01), "removeRingNotify")
632
+ DeviceManager.getInstance().sgc?.releaseController()
568
633
  }
634
+ }
569
635
 
570
- startHeartbeat()
636
+ override fun setRouteViaGlasses(viaGlasses: Boolean) {
637
+ Bridge.log("R1: route via ${if (viaGlasses) "glasses" else "phone"}")
638
+ if (DeviceStore.get("glasses", "controllerConnected") as? Boolean != true) return
639
+ applyRoute(viaGlasses)
640
+ }
641
+
642
+ /**
643
+ * One emit path for both direct parsers: dropped while the ring is
644
+ * debug-routed via the glasses so a lingering phone-side report can't
645
+ * double up with the CFW relay.
646
+ */
647
+ private fun emitGesture(gestureName: String, timestamp: Long, via: String) {
648
+ if (routeViaGlasses) {
649
+ Bridge.log("R1: $via $gestureName ignored (ring routed via glasses)")
650
+ return
651
+ }
652
+ Bridge.log("R1: $via: $gestureName")
653
+ Bridge.sendTouchEvent(ControllerTypes.R1, gestureName, timestamp, R1BLE.RING_SOURCE)
571
654
  }
572
655
 
573
656
  /**
@@ -657,12 +740,21 @@ class R1 : ControllerManager() {
657
740
 
658
741
  val timestamp = System.currentTimeMillis()
659
742
 
743
+ // Touch report: [00 09 61 00 wireType aux speed 00 tick4]
744
+ R1TouchReport.parse(data)?.let { (gesture, wireType) ->
745
+ if (gesture != null) {
746
+ emitGesture(gesture, timestamp, "Touch report")
747
+ } else {
748
+ Bridge.log("R1: Unknown touch report wireType=$wireType")
749
+ }
750
+ return
751
+ }
752
+
660
753
  // Gesture: [0xFF, type, param]
661
754
  if (data.size >= 3 && data[0] == R1BLE.GESTURE_MARKER) {
662
755
  val gesture = R1Gesture.parse(data)
663
756
  if (gesture != null) {
664
- Bridge.log("R1: Gesture: ${gesture.rawValue}")
665
- Bridge.sendTouchEvent(ControllerTypes.R1, gesture.rawValue, timestamp)
757
+ emitGesture(gesture.rawValue, timestamp, "Gesture")
666
758
  } else {
667
759
  Bridge.log(
668
760
  "R1: Unknown gesture type=0x%02X param=0x%02X".format(data[1], data[2])
@@ -696,8 +788,7 @@ class R1 : ControllerManager() {
696
788
  val slice = byteArrayOf(data[ffIndex], data[ffIndex + 1], data[ffIndex + 2])
697
789
  val gesture = R1Gesture.parse(slice)
698
790
  if (gesture != null) {
699
- Bridge.log("R1: Embedded gesture: ${gesture.rawValue}")
700
- Bridge.sendTouchEvent(ControllerTypes.R1, gesture.rawValue, timestamp)
791
+ emitGesture(gesture.rawValue, timestamp, "Embedded gesture")
701
792
  return
702
793
  }
703
794
  }
@@ -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,57 @@ 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
+ /** RING_CONNECT_INFO for the paired ring. False when the glasses aren't
5609
+ * booted or no ring MAC is known. */
5610
+ private fun sendRingConnectInfo(connect: Boolean, tag: String): Boolean {
5600
5611
  val isFullyBooted = DeviceStore.get("glasses", "fullyBooted") as? Boolean ?: false
5601
5612
  if (!isFullyBooted) {
5602
- Bridge.log("G2: connectController - g2 not fully booted, ignoring")
5603
- return
5613
+ Bridge.log("G2: $tag - g2 not fully booted, ignoring")
5614
+ return false
5604
5615
  }
5605
5616
  val mac = DeviceStore.get("glasses", "controllerMacAddress") as? String
5606
5617
  if (mac.isNullOrEmpty()) {
5607
- Bridge.log("G2: connectController - no MAC address found")
5608
- return
5618
+ Bridge.log("G2: $tag - no MAC address found")
5619
+ return false
5609
5620
  }
5610
5621
  val hexParts = mac.split(":").mapNotNull { it.toIntOrNull(16)?.toByte() }
5611
5622
  if (hexParts.size != 6) {
5612
- Bridge.log("G2: connectController - invalid MAC format: $mac")
5613
- return
5623
+ Bridge.log("G2: $tag - invalid MAC format: $mac")
5624
+ return false
5614
5625
  }
5615
- Bridge.log("G2: connectController() - MAC: $mac")
5616
- val macData = hexParts.toByteArray()
5617
- val msg = DevSettingsProto.ringConnectInfo(sendManager.nextMagicRandom(), true, macData)
5626
+ val msg = DevSettingsProto.ringConnectInfo(sendManager.nextMagicRandom(), connect, hexParts.toByteArray())
5618
5627
  sendDevSettingsCommand(msg)
5619
- Bridge.log("G2: Sent RING_CONNECT_INFO for MAC $mac")
5628
+ Bridge.log("G2: Sent RING_${if (connect) "CONNECT" else "DISCONNECT"}_INFO for MAC $mac")
5629
+ return true
5620
5630
  }
5621
5631
 
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")
5632
+ override fun connectController() {
5633
+ if (!ringViaGlasses) {
5634
+ // Phone-routed ring: make sure the glasses let go of it instead.
5635
+ releaseController()
5636
5636
  return
5637
5637
  }
5638
- val macData = hexParts.toByteArray()
5639
- val msg = DevSettingsProto.ringConnectInfo(sendManager.nextMagicRandom(), false, macData)
5640
- sendDevSettingsCommand(msg)
5638
+ sendRingConnectInfo(true, "connectController")
5639
+ }
5640
+
5641
+ override fun disconnectController() {
5642
+ if (!sendRingConnectInfo(false, "disconnectController")) return
5641
5643
  // DeviceStore.apply("glasses", "controllerMacAddress", "")
5642
5644
  DeviceStore.apply("glasses", "controllerConnected", false)
5643
5645
  DeviceStore.apply("glasses", "controllerFullyBooted", false)
5644
- Bridge.log("G2: Sent RING_DISCONNECT_INFO for MAC $mac")
5646
+ }
5647
+
5648
+ override fun releaseController() {
5649
+ sendRingConnectInfo(false, "releaseController")
5645
5650
  }
5646
5651
 
5647
5652
  // ---------- SGCManager: Device Control ----------
@@ -6779,9 +6784,20 @@ class G2 : SGCManager() {
6779
6784
  return@let
6780
6785
  }
6781
6786
 
6787
+ // Ring input relayed by the glasses. Older CFWs left event 14's
6788
+ // source unspecified, so the id alone also marks the ring.
6789
+ val fromRing = eventSource == 2 || eventType == OsEventType.RING_TOUCH_DOWN
6790
+ if (fromRing && !ringViaGlasses) {
6791
+ // The phone link owns ring input; the glasses shouldn't hold
6792
+ // the ring at all, so this is a stale link — drop it again.
6793
+ Bridge.log("G2: ring $gestureName via glasses ignored (ring routed via phone)")
6794
+ releaseController()
6795
+ return@let
6796
+ }
6797
+
6782
6798
  Bridge.sendTouchEvent(DeviceTypes.G2, gestureName, timestamp, eventSource)
6783
6799
 
6784
- if (eventSource == 2) {
6800
+ if (fromRing) {
6785
6801
  // controller must be connected and fully booted:
6786
6802
  setControllerFullyConnected()
6787
6803
  }
@@ -6905,7 +6921,8 @@ class G2 : SGCManager() {
6905
6921
  if (connStatus == 22) {
6906
6922
  // The ring↔glasses link dropped. The phone↔ring link owns
6907
6923
  // controllerConnected/FullyBooted, so don't clear them here
6908
- // (that would flap pairing state) — just re-request the link.
6924
+ // (that would flap pairing state) — just re-request the link
6925
+ // (a no-op while the ring is routed via the phone).
6909
6926
  Bridge.log("G2: Ring-glasses link dropped, re-requesting")
6910
6927
  reconnectController()
6911
6928
  }
@@ -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.533",
5
- "releaseSetId": "glassly-0.3.1-dev.533",
6
- "sourceCommit": "1b6fc6874ea0d711351c15650221afae50fc857d",
4
+ "releaseIdentity": "0.3.1-dev.537",
5
+ "releaseSetId": "glassly-0.3.1-dev.537",
6
+ "sourceCommit": "2cb6ba96b1141a7d23dc38cb1ce44e6ede4f305a",
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.533\",\n \"releaseSetId\": \"glassly-0.3.1-dev.533\",\n \"sourceCommit\": \"1b6fc6874ea0d711351c15650221afae50fc857d\",\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.537\",\n \"releaseSetId\": \"glassly-0.3.1-dev.537\",\n \"sourceCommit\": \"2cb6ba96b1141a7d23dc38cb1ce44e6ede4f305a\",\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.533"
14
+ private static let swiftPackageSdkVersion = "0.3.1-dev.537"
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.533"
7
- static let releaseSetId = "glassly-0.3.1-dev.533"
8
- static let sourceCommit = "1b6fc6874ea0d711351c15650221afae50fc857d"
6
+ static let releaseIdentity = "0.3.1-dev.537"
7
+ static let releaseSetId = "glassly-0.3.1-dev.537"
8
+ static let sourceCommit = "2cb6ba96b1141a7d23dc38cb1ce44e6ede4f305a"
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)
@@ -33,9 +33,46 @@ private enum R1BLE {
33
33
  /// Gesture protocol marker
34
34
  static let GESTURE_MARKER: UInt8 = 0xFF
35
35
 
36
+ /// `source` on emitted touch events; the same value the G2 CFW tags
37
+ /// ring input it relays with, so JS treats both paths alike.
38
+ static let RING_SOURCE: Int32 = 2
39
+
36
40
  static let SCAN_TIMEOUT: TimeInterval = 15.0
37
41
  }
38
42
 
43
+ // MARK: - Ring → phone touch report (channel 1)
44
+
45
+ /// The ring reports touch on the legacy channel-1 transport as 11 bytes
46
+ /// `[00][09][61][00][wireType][aux][speed][00][tick LE32]` — the same report
47
+ /// the Glassly CFW relays from the glasses as the `RI` SysEvent extension
48
+ /// (g2flash gesture_fwd.c `faceclaw_ring_report`). Wire types map onto the
49
+ /// gesture names the G2 path emits so miniapps see one vocabulary.
50
+ private enum R1TouchReport {
51
+ static let LENGTH = 11
52
+ static let OPCODE: UInt8 = 0x61
53
+
54
+ static func gestureName(wireType: UInt8) -> String? {
55
+ switch wireType {
56
+ case 0: return "tap_start"
57
+ case 1: return "single_tap"
58
+ case 2: return "double_tap"
59
+ case 4: return "swipe_up"
60
+ case 5: return "swipe_down"
61
+ case 8: return "tap_end"
62
+ case 9: return "tap_then_hold"
63
+ case 10: return "touch_down"
64
+ default: return nil
65
+ }
66
+ }
67
+
68
+ /// nil when `data` is not a touch report; `.gesture` nil for an unknown wire type.
69
+ static func parse(_ data: Data) -> (gesture: String?, wireType: UInt8)? {
70
+ let b = [UInt8](data)
71
+ guard b.count == LENGTH, b[0] == 0, b[1] == 9, b[2] == OPCODE, b[3] == 0 else { return nil }
72
+ return (gestureName(wireType: b[4]), b[4])
73
+ }
74
+ }
75
+
39
76
  // MARK: - EUS transport encoder (phone → ring, channel 2)
40
77
 
41
78
  /// Recovered firmware contract (openCFW r1/ research): every phone→ring packet
@@ -61,6 +98,12 @@ private enum R1Eus {
61
98
  /// system subcommand 0x0A: 12-byte payload = two 6-byte glasses arm MACs;
62
99
  /// the ring stores both targets and fast-advertises for them
63
100
  static let SUBCMD_ADV_START: UInt8 = 0x0A
101
+ /// system subcommand 0x82 (removeRingNotify): one-byte SET erases both
102
+ /// stored glasses targets and the "glasses paired" flag. While the ring
103
+ /// holds glasses targets its touch reports go to the glasses and the
104
+ /// phone link carries telemetry only, so this is what puts input on the
105
+ /// phone link.
106
+ static let SUBCMD_REMOVE_RING: UInt8 = 0x82
64
107
 
65
108
  private static func crc16Step(_ crc: UInt16, _ byte: UInt8) -> UInt16 {
66
109
  var c = (crc >> 8) | (crc << 8)
@@ -124,7 +167,7 @@ private enum R1Eus {
124
167
  // MARK: - R1 Gesture Types
125
168
 
126
169
  private enum R1Gesture: String {
127
- case hold
170
+ case hold = "tap_then_hold"
128
171
  case singleTap = "single_tap"
129
172
  case doubleTap = "double_tap"
130
173
  case swipeUp = "swipe_up"
@@ -168,6 +211,12 @@ class R1: NSObject, ControllerManager {
168
211
  private var initSequenceRun = false
169
212
  private var eusSerial: UInt16 = 0
170
213
 
214
+ /// Debug `ring_via_glasses` (default off): link the ring to the glasses and
215
+ /// take its input through the CFW relay instead of this link.
216
+ private var routeViaGlasses: Bool {
217
+ DeviceStore.shared.get("bluetooth", "ring_via_glasses") as? Bool ?? false
218
+ }
219
+
171
220
  /// Device search
172
221
  var DEVICE_SEARCH_ID = "NOT_SET"
173
222
 
@@ -409,20 +458,54 @@ class R1: NSObject, ControllerManager {
409
458
 
410
459
  DeviceStore.shared.apply("glasses", "controllerConnected", true)
411
460
  // 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.
461
+ // success (fires handleControllerReady). A glasses link is only
462
+ // layered on afterwards (advStart + RING_CONNECT_INFO) when debug-routed.
414
463
  DeviceStore.shared.apply("glasses", "controllerFullyBooted", true)
415
464
 
416
- // tell the ring to connect to the glasses if we have it's mac address:
417
- connectToGlasses()
465
+ applyRoute(viaGlasses: routeViaGlasses)
418
466
 
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()
467
+ startHeartbeat()
468
+ }
469
+
470
+ /// Phone route (default): erase the ring's stored glasses targets so its
471
+ /// touch reports come to this link, and have the glasses drop any ring
472
+ /// link they still hold. Glasses route (debug): hand the ring the glasses
473
+ /// MACs, then ask the glasses to connect.
474
+ private func applyRoute(viaGlasses: Bool) {
475
+ if viaGlasses {
476
+ connectToGlasses()
477
+ // after a second, connect the glasses to the controller if needed:
478
+ Task {
479
+ try? await Task.sleep(nanoseconds: 1_000_000_000)
480
+ await DeviceManager.shared.sgc?.connectController()
481
+ }
482
+ } else {
483
+ sendEusCommand(R1Eus.SUBCMD_REMOVE_RING, payload: Data([0x01]), log: "removeRingNotify")
484
+ Task { await DeviceManager.shared.sgc?.releaseController() }
423
485
  }
486
+ }
424
487
 
425
- startHeartbeat()
488
+ func setRouteViaGlasses(_ viaGlasses: Bool) {
489
+ Bridge.log("R1: route via \(viaGlasses ? "glasses" : "phone")")
490
+ guard DeviceStore.shared.get("glasses", "controllerConnected") as? Bool ?? false else { return }
491
+ applyRoute(viaGlasses: viaGlasses)
492
+ }
493
+
494
+ /// One emit path for both direct parsers: dropped while the ring is
495
+ /// debug-routed via the glasses so a lingering phone-side report can't
496
+ /// double up with the CFW relay.
497
+ private func emitGesture(_ gestureName: String, timestamp: Int64, via: String) {
498
+ guard !routeViaGlasses else {
499
+ Bridge.log("R1: \(via) \(gestureName) ignored (ring routed via glasses)")
500
+ return
501
+ }
502
+ Bridge.log("R1: \(via): \(gestureName)")
503
+ Bridge.sendTouchEvent(
504
+ deviceModel: ControllerTypes.R1,
505
+ gestureName: gestureName,
506
+ timestamp: timestamp,
507
+ source: R1BLE.RING_SOURCE
508
+ )
426
509
  }
427
510
 
428
511
  // MARK: - Heartbeat
@@ -455,15 +538,20 @@ class R1: NSObject, ControllerManager {
455
538
 
456
539
  let timestamp = Int64(Date().timeIntervalSince1970 * 1000)
457
540
 
541
+ // Touch report: [00 09 61 00 wireType aux speed 00 tick4]
542
+ if let report = R1TouchReport.parse(data) {
543
+ if let gesture = report.gesture {
544
+ emitGesture(gesture, timestamp: timestamp, via: "Touch report")
545
+ } else {
546
+ Bridge.log("R1: Unknown touch report wireType=\(report.wireType)")
547
+ }
548
+ return
549
+ }
550
+
458
551
  // Gesture: [0xFF, type, param]
459
552
  if data.count >= 3, data[0] == R1BLE.GESTURE_MARKER {
460
553
  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
- )
554
+ emitGesture(gesture.rawValue, timestamp: timestamp, via: "Gesture")
467
555
  } else {
468
556
  Bridge.log(
469
557
  "R1: Unknown gesture type=0x\(String(format: "%02X", data[1])) param=0x\(String(format: "%02X", data[2]))"
@@ -492,12 +580,7 @@ class R1: NSObject, ControllerManager {
492
580
  {
493
581
  let gestureData = Data(data[ffIndex ... ffIndex + 2])
494
582
  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
- )
583
+ emitGesture(gesture.rawValue, timestamp: timestamp, via: "Embedded gesture")
501
584
  return
502
585
  }
503
586
  }
@@ -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,65 @@ 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
+ /// RING_CONNECT_INFO for the paired ring. False when the glasses aren't
5824
+ /// booted or no ring MAC is known.
5825
+ @discardableResult
5826
+ private func sendRingConnectInfo(connect: Bool, tag: String) -> Bool {
5816
5827
  let isFullyBooted = DeviceStore.shared.get("glasses", "fullyBooted") as? Bool ?? false
5817
5828
  guard isFullyBooted else {
5818
- Bridge.log("G2: connectController - g2 not fully booted, ignoring")
5819
- return
5829
+ Bridge.log("G2: \(tag) - g2 not fully booted, ignoring")
5830
+ return false
5820
5831
  }
5821
5832
 
5822
- guard let mac = DeviceStore.shared.get("glasses", "controllerMacAddress") as? String else {
5823
- Bridge.log("G2: connectController - no MAC address found")
5824
- return
5833
+ guard let mac = DeviceStore.shared.get("glasses", "controllerMacAddress") as? String, !mac.isEmpty else {
5834
+ Bridge.log("G2: \(tag) - no MAC address found")
5835
+ return false
5825
5836
  }
5826
5837
 
5827
5838
  // Parse "AA:BB:CC:DD:EE:FF" into 6-byte Data
5828
5839
  let hexParts = mac.split(separator: ":").compactMap { UInt8($0, radix: 16) }
5829
5840
  guard hexParts.count == 6 else {
5830
- Bridge.log("G2: connectController - invalid MAC format: \(mac)")
5831
- return
5841
+ Bridge.log("G2: \(tag) - invalid MAC format: \(mac)")
5842
+ return false
5832
5843
  }
5833
- let macData = Data(hexParts)
5834
5844
 
5835
5845
  let msg = DevSettingsProto.ringConnectInfo(
5836
5846
  magicRandom: sendManager.nextMagicRandom(),
5837
- connect: true,
5838
- ringMac: macData
5847
+ connect: connect,
5848
+ ringMac: Data(hexParts)
5839
5849
  )
5840
5850
  sendDevSettingsCommand(msg)
5841
- Bridge.log("G2: Sent RING_CONNECT_INFO for MAC \(mac)")
5851
+ Bridge.log("G2: Sent RING_\(connect ? "CONNECT" : "DISCONNECT")_INFO for MAC \(mac)")
5852
+ return true
5842
5853
  }
5843
5854
 
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)")
5855
+ func connectController() {
5856
+ guard ringViaGlasses else {
5857
+ // Phone-routed ring: make sure the glasses let go of it instead.
5858
+ releaseController()
5860
5859
  return
5861
5860
  }
5862
- let macData = Data(hexParts)
5861
+ sendRingConnectInfo(connect: true, tag: "connectController")
5862
+ }
5863
5863
 
5864
- let msg = DevSettingsProto.ringConnectInfo(
5865
- magicRandom: sendManager.nextMagicRandom(),
5866
- connect: false,
5867
- ringMac: macData
5868
- )
5869
- sendDevSettingsCommand(msg)
5864
+ func disconnectController() {
5865
+ guard sendRingConnectInfo(connect: false, tag: "disconnectController") else { return }
5870
5866
 
5871
5867
  // DeviceStore.shared.apply("glasses", "controllerMacAddress", "")
5872
5868
  DeviceStore.shared.apply("glasses", "controllerConnected", false)
5873
5869
  DeviceStore.shared.apply("glasses", "controllerFullyBooted", false)
5874
- Bridge.log("G2: Sent RING_DISCONNECT_INFO for MAC \(mac)")
5870
+ }
5871
+
5872
+ func releaseController() {
5873
+ sendRingConnectInfo(connect: false, tag: "releaseController")
5875
5874
  }
5876
5875
 
5877
5876
  /// Fire an EvenAI skill — the same path "Hey Even, show X" uses. Triggers a
@@ -7092,13 +7091,24 @@ final class G2: NSObject, SGCManager {
7092
7091
  return
7093
7092
  }
7094
7093
 
7094
+ // Ring input relayed by the glasses. Older CFWs left event 14's
7095
+ // source unspecified, so the id alone also marks the ring.
7096
+ let fromRing = eventSource == 2 || eventType == .ringTouchDown
7097
+ if fromRing, !ringViaGlasses {
7098
+ // The phone link owns ring input; the glasses shouldn't hold
7099
+ // the ring at all, so this is a stale link — drop it again.
7100
+ Bridge.log("G2: ring \(gestureName) via glasses ignored (ring routed via phone)")
7101
+ releaseController()
7102
+ return
7103
+ }
7104
+
7095
7105
  Bridge.sendTouchEvent(
7096
7106
  deviceModel: type, gestureName: gestureName,
7097
7107
  timestamp: timestamp,
7098
7108
  source: eventSource
7099
7109
  )
7100
7110
 
7101
- if eventSource == 2 {
7111
+ if fromRing {
7102
7112
  // controller must be connected and fully booted:
7103
7113
  setControllerFullyConnected()
7104
7114
  }
@@ -7259,7 +7269,8 @@ final class G2: NSObject, SGCManager {
7259
7269
  if connStatus == 22 {
7260
7270
  // The ring↔glasses link dropped. The phone↔ring link owns
7261
7271
  // controllerConnected/FullyBooted, so don't clear them here
7262
- // (that would flap pairing state) — just re-request the link.
7272
+ // (that would flap pairing state) — just re-request the link
7273
+ // (a no-op while the ring is routed via the phone).
7263
7274
  Bridge.log("G2: Ring-glasses link dropped, re-requesting")
7264
7275
  reconnectController()
7265
7276
  }
@@ -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.533",
3
+ "version": "0.3.1-dev.537",
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.533",
16
- "releaseSetId": "glassly-0.3.1-dev.533",
17
- "sourceCommit": "1b6fc6874ea0d711351c15650221afae50fc857d",
15
+ "releaseIdentity": "0.3.1-dev.537",
16
+ "releaseSetId": "glassly-0.3.1-dev.537",
17
+ "sourceCommit": "2cb6ba96b1141a7d23dc38cb1ce44e6ede4f305a",
18
18
  "otaManifestUrl": null,
19
19
  "otaManifestSha256": null
20
20
  })