@glassly/bluetooth-sdk 0.1.0-dev.62 → 0.1.0-dev.90

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/README.md +4 -4
  2. package/android/src/main/assets/G2Firmware/README.md +3 -3
  3. package/android/src/main/assets/G2Firmware/g2-glassly-cfw-2.2.9.22.evenota.bin +0 -0
  4. package/android/src/main/assets/G2Firmware/g2-stock-2.2.9.22.evenota.bin +0 -0
  5. package/android/src/main/java/com/glassly/bluetoothsdk/BluetoothSdk.kt +0 -4
  6. package/android/src/main/java/com/glassly/bluetoothsdk/BluetoothSdkModule.kt +0 -1
  7. package/android/src/main/java/com/glassly/bluetoothsdk/DeviceManager.kt +30 -86
  8. package/android/src/main/java/com/glassly/bluetoothsdk/DeviceStore.kt +1 -2
  9. package/android/src/main/java/com/glassly/bluetoothsdk/GeneratedReleaseMetadata.kt +3 -3
  10. package/android/src/main/java/com/glassly/bluetoothsdk/controllers/R1.kt +171 -51
  11. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2.kt +23 -3
  12. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2CfwRenderer.kt +18 -6
  13. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2FlashProtocol.kt +3 -3
  14. package/android/src/main/java/com/glassly/bluetoothsdk/sgcs/G2Flasher.kt +8 -8
  15. package/build/BluetoothSdk.types.d.ts +37 -1
  16. package/build/BluetoothSdk.types.d.ts.map +1 -1
  17. package/build/BluetoothSdk.types.js.map +1 -1
  18. package/build/_private/BluetoothSdkModule.d.ts +0 -1
  19. package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
  20. package/build/_private/BluetoothSdkModule.js.map +1 -1
  21. package/build/generated/releaseMetadata.js +3 -3
  22. package/build/generated/releaseMetadata.js.map +1 -1
  23. package/build/index.d.ts.map +1 -1
  24. package/build/index.js +0 -1
  25. package/build/index.js.map +1 -1
  26. package/ios/BluetoothSdkModule.swift +0 -6
  27. package/ios/Source/BluetoothSDK.swift +0 -4
  28. package/ios/Source/BluetoothSdkDefaults.swift +1 -1
  29. package/ios/Source/DeviceManager.swift +35 -88
  30. package/ios/Source/DeviceStore.swift +16 -2
  31. package/ios/Source/GeneratedReleaseMetadata.swift +3 -3
  32. package/ios/Source/Resources/G2Firmware/README.md +3 -3
  33. package/ios/Source/Resources/G2Firmware/g2-glassly-cfw-2.2.9.22.evenota.bin +0 -0
  34. package/ios/Source/Resources/G2Firmware/g2-stock-2.2.9.22.evenota.bin +0 -0
  35. package/ios/Source/controllers/R1.swift +145 -49
  36. package/ios/Source/sgcs/G2.swift +311 -54
  37. package/ios/Source/sgcs/G2CfwRenderer.swift +24 -8
  38. package/ios/Source/sgcs/G2FlashProtocol.swift +2 -2
  39. package/ios/Source/sgcs/G2Flasher.swift +8 -8
  40. package/ios/Source/sgcs/SGCManager.swift +13 -0
  41. package/ios/Source/utils/G2NotifyFile.swift +116 -0
  42. package/ios/Tests/BluetoothSDKTests/G2CfwRendererTests.swift +2 -2
  43. package/ios/Tests/BluetoothSDKTests/G2NotifyFileTests.swift +91 -0
  44. package/package.json +1 -1
  45. package/src/BluetoothSdk.types.ts +37 -1
  46. package/src/_private/BluetoothSdkModule.ts +0 -1
  47. package/src/generated/releaseMetadata.ts +3 -3
  48. package/src/index.ts +0 -1
package/README.md CHANGED
@@ -352,7 +352,7 @@ Android and iOS async APIs use `BluetoothSdkException` / `BluetoothSdkError` for
352
352
  React Native apps should use `MentraLiveOtaFlow` or `useMentraLiveOta` from
353
353
  `@glassly/engine/ota`. They provide the same tested Wi-Fi/hotspot,
354
354
  APK/MTK/BES, restart, retry, and verification flow as the Glassly App. See
355
- [Update Mentra Live](https://docs.glassly.com/mentra-live/software-update).
355
+ [Update Mentra Live](https://docs.glassly.com/bluetooth-sdk/software-update).
356
356
 
357
357
  The Bluetooth SDK exposes the lower-level commands and transport capabilities
358
358
  that Glassly Engine and native apps build on. They are not a replacement for the
@@ -371,7 +371,7 @@ Engine coordinator:
371
371
  Release CI embeds an immutable manifest URL and checksum into every published
372
372
  SDK distribution. The completed coordinated release record correlates the SDK
373
373
  package with its exact manifest and OTA bundle. A completed release is available at
374
- `https://github.com/Glassly-Community/GlasslyOS/releases/tag/glassly-v<releaseIdentity>`
374
+ `https://github.com/tetramo-labs/glassly/releases/tag/glassly-v<releaseIdentity>`
375
375
  and contains `glassly-release-<releaseIdentity>.json`, which records the exact
376
376
  package coordinates, asset URLs, sizes, checksums, and build provenance.
377
377
 
@@ -409,7 +409,7 @@ OTA requires Mentra Live glasses firmware that supports the ASG OTA protocol and
409
409
 
410
410
  For release selection, bundle verification, analytics opt-out, internal server
411
411
  requirements, and disconnected validation, see the
412
- [air-gapped deployment guide](https://docs.glassly.com/mentra-live/air-gapped-deployment).
412
+ [air-gapped deployment guide](https://docs.glassly.com/bluetooth-sdk/air-gapped-deployment).
413
413
 
414
414
  Mentra Live also rejects `ota_start` before acknowledgement when its known battery level is below 5%, emitting a failed `ota_status` with `error_message: "battery_low"`. Unknown battery state remains fail-open, so apps should keep their own (typically stricter) user-facing battery policy.
415
415
 
@@ -560,4 +560,4 @@ secrets, not in the repository.
560
560
 
561
561
  ## Starter Example App
562
562
 
563
- The [Glassly Bluetooth SDK Starter Kit](https://github.com/Glassly-Community/Glassly-Bluetooth-SDK-Starter-Kit) includes starter example apps for Android, iOS, and React Native / Expo. The React Native starter demonstrates scan/connect, display, camera photo upload, RTMP/SRT/WebRTC streaming, Wi-Fi/hotspot, microphone PCM, RGB LED, gallery mode, and console event inspection.
563
+ The [Glassly Bluetooth SDK Starter Kit](https://github.com/tetramo-labs/glassly-starter-kit) includes starter example apps for Android, iOS, and React Native / Expo. The React Native starter demonstrates scan/connect, display, camera photo upload, RTMP/SRT/WebRTC streaming, Wi-Fi/hotspot, microphone PCM, RGB LED, gallery mode, and console event inspection.
@@ -3,9 +3,9 @@
3
3
  The Android library bundles the stock and Glassly CFW EVENOTA images stored in
4
4
  this directory. They are copied from the sibling `g2flash` checkout:
5
5
 
6
- - `g2_2.2.6.10.bin` -> `g2-stock-2.2.6.10.evenota.bin`
7
- - `g2_2.2.6.10_cfw.bin` -> `g2-glassly-cfw-2.2.6.10.evenota.bin`
6
+ - `g2_2.2.9.22.bin` -> `g2-stock-2.2.9.22.evenota.bin`
7
+ - `g2_2.2.9.22_cfw.bin` -> `g2-glassly-cfw-2.2.9.22.evenota.bin`
8
8
 
9
9
  Run `./build_cfw.sh` in `g2flash` before manually replacing the CFW image. The
10
- firmware binaries are intentionally ignored by Git and verified by size,
10
+ firmware binaries are committed so CI builds bundle them, and verified by size,
11
11
  SHA-256, per-component CRC32C, and the MRAM guard before flashing.
@@ -501,10 +501,6 @@ class BluetoothSdk private constructor(
501
501
  deviceManager.showDashboard()
502
502
  }
503
503
 
504
- fun toggleHeadUp() {
505
- deviceManager.headUp = !deviceManager.headUp
506
- }
507
-
508
504
  fun showNotificationsPanel() {
509
505
  deviceManager.showNotificationsPanel()
510
506
  }
@@ -550,7 +550,6 @@ class BluetoothSdkModule : Module() {
550
550
 
551
551
  SdkAsyncFunction("showDashboard") { -> sdk?.showDashboard() }
552
552
 
553
- SdkAsyncFunction("toggleHeadUp") { -> sdk?.toggleHeadUp() }
554
553
 
555
554
  SdkAsyncFunction("showNativeNotificationPanel") { -> sdk?.showNotificationsPanel() }
556
555
 
@@ -176,10 +176,6 @@ class DeviceManager {
176
176
  get() = DeviceStore.store.get("bluetooth", "should_send_transcript") as? Boolean ?: false
177
177
  set(value) = DeviceStore.apply("bluetooth", "should_send_transcript", value)
178
178
 
179
- private var contextualDashboard: Boolean
180
- get() = DeviceStore.store.get("bluetooth", "contextual_dashboard") as? Boolean ?: true
181
- set(value) = DeviceStore.apply("bluetooth", "contextual_dashboard", value)
182
-
183
179
  private var dashboardHeight: Int
184
180
  get() = (DeviceStore.store.get("bluetooth", "dashboard_height") as? Number)?.toInt() ?: 4
185
181
  set(value) = DeviceStore.apply("bluetooth", "dashboard_height", value)
@@ -223,10 +219,6 @@ class DeviceManager {
223
219
  get() = DeviceStore.store.get("bluetooth", "systemMicUnavailable") as? Boolean ?: false
224
220
  set(value) = DeviceStore.apply("bluetooth", "systemMicUnavailable", value)
225
221
 
226
- public var headUp: Boolean
227
- get() = DeviceStore.store.get("glasses", "headUp") as? Boolean ?: false
228
- set(value) = DeviceStore.apply("glasses", "headUp", value)
229
-
230
222
  private var micEnabled: Boolean
231
223
  get() = DeviceStore.store.get("bluetooth", "micEnabled") as? Boolean ?: false
232
224
  set(value) = DeviceStore.apply("bluetooth", "micEnabled", value)
@@ -280,12 +272,12 @@ class DeviceManager {
280
272
  // STT
281
273
  private var transcriber: SherpaOnnxTranscriber? = null
282
274
 
283
- // View states
284
- private val viewStates = mutableListOf<ViewState>()
275
+ // Single retained display state. The JS engine owns view switching
276
+ // (dashboard vs main); native just paints and repaints the last frame.
277
+ private var viewState = ViewState(" ", " ", " ", "text_wall", "", null, null)
285
278
 
286
279
  init {
287
280
  Bridge.log("DeviceManager: init()")
288
- initializeViewStates()
289
281
  startForegroundService()
290
282
  // setupPermissionMonitoring()
291
283
  setupBluetoothStateMonitoring()
@@ -673,36 +665,6 @@ class DeviceManager {
673
665
  }
674
666
  }
675
667
 
676
- private fun initializeViewStates() {
677
- viewStates.clear()
678
-
679
- // Matching Swift's 4 view states exactly
680
- viewStates.add(ViewState(" ", " ", " ", "text_wall", "", null, null))
681
- viewStates.add(
682
- ViewState(
683
- " ",
684
- " ",
685
- " ",
686
- "text_wall",
687
- "\$TIME12$ \$DATE$ \$GBATT$ \$CONNECTION_STATUS$",
688
- null,
689
- null
690
- )
691
- )
692
- viewStates.add(ViewState(" ", " ", " ", "text_wall", "", null, null))
693
- viewStates.add(
694
- ViewState(
695
- " ",
696
- " ",
697
- " ",
698
- "text_wall",
699
- "\$TIME12$ \$DATE$ \$GBATT$ \$CONNECTION_STATUS$",
700
- null,
701
- null
702
- )
703
- )
704
- }
705
-
706
668
  private fun statesEqual(s1: ViewState, s2: ViewState): Boolean {
707
669
  val state1 =
708
670
  "${s1.layoutType}${s1.text}${s1.topText}${s1.bottomText}${s1.title}${s1.data ?: ""}"
@@ -736,12 +698,11 @@ class DeviceManager {
736
698
  var borderRadius: Int? = null
737
699
  )
738
700
 
739
- // Scene slots — one whole SceneFrame per view (main/dashboard), parallel to
740
- // viewStates. When a slot holds a scene, viewStates carries a "scene"
701
+ // Retained SceneFrame. When it holds a scene, viewState carries a "scene"
741
702
  // sentinel so sendCurrentState routes here. Holding the WHOLE frame keeps
742
- // native re-dispatch coherent (dashboard exit re-applies a complete scene,
743
- // not whatever element happened to arrive last).
744
- private val sceneStates = arrayOfNulls<SceneFrame>(2)
703
+ // native re-dispatch coherent (nav exit re-applies a complete scene, not
704
+ // whatever element happened to arrive last).
705
+ private var sceneState: SceneFrame? = null
745
706
  // MARK: - End Unique
746
707
 
747
708
  // MARK: - Voice Data Handling
@@ -1021,15 +982,12 @@ class DeviceManager {
1021
982
  }
1022
983
 
1023
984
  fun sendCurrentState() {
1024
- val hUp = DeviceStore.get("glasses", "headUp") as? Boolean ?: false
1025
- // Bridge.log("MAN: sendCurrentState(): $isHeadUp")
1026
985
  if (screenDisabled) {
1027
986
  return
1028
987
  }
1029
988
 
1030
989
  // executor.execute {
1031
- val currentStateIndex = if (hUp && contextualDashboard) 1 else 0
1032
- val currentViewState: ViewState = viewStates[currentStateIndex]
990
+ val currentViewState: ViewState = viewState
1033
991
 
1034
992
  if (sgc?.type?.contains(DeviceTypes.SIMULATED) == true) {
1035
993
  // dont send the event to glasses that aren't there:
@@ -1093,7 +1051,7 @@ class DeviceManager {
1093
1051
  }
1094
1052
 
1095
1053
  "scene" -> {
1096
- sceneStates[currentStateIndex]?.let { sgc?.applySceneFrame(it) }
1054
+ sceneState?.let { sgc?.applySceneFrame(it) }
1097
1055
  }
1098
1056
 
1099
1057
  "clear_view" -> sgc?.clearDisplay()
@@ -1529,6 +1487,12 @@ class DeviceManager {
1529
1487
  val projectName = (DeviceStore.store.get("bluetooth", "project_name") as? String)?.trim().orEmpty()
1530
1488
  Bridge.saveSetting("project_name", projectName)
1531
1489
  }
1490
+
1491
+ // Restore the ring: connecting glasses tears the controller down
1492
+ // (disconnect() nulls it), so bring a paired default controller back.
1493
+ if (DeviceStore.get("glasses", "controllerConnected") as? Boolean != true) {
1494
+ connectDefaultController()
1495
+ }
1532
1496
  }
1533
1497
 
1534
1498
  private fun syncSystemTimeOnceForConnection(connectionKey: String) {
@@ -1620,22 +1584,13 @@ class DeviceManager {
1620
1584
  }
1621
1585
 
1622
1586
  fun displayEvent(event: Map<String, Any>) {
1623
- val view = event["view"] as? String
1624
- if (view == null) {
1625
- Bridge.log("MAN: Invalid view")
1626
- return
1627
- }
1628
-
1629
- val isDashboard = view == "dashboard"
1630
- val stateIndex = if (isDashboard) 1 else 0
1631
-
1632
1587
  // Scene frames (display.render() pipeline) take their own path: the
1633
1588
  // whole frame is the unit, not a layout, and the host's per-element
1634
1589
  // annotations make redundant frames self-deduping (all-"unchanged"
1635
1590
  // frames no-op in the SGC base handler).
1636
1591
  @Suppress("UNCHECKED_CAST") val sceneMap = event["scene"] as? Map<String, Any>
1637
1592
  if (sceneMap != null) {
1638
- handleSceneEvent(stateIndex, sceneMap)
1593
+ handleSceneEvent(sceneMap)
1639
1594
  return
1640
1595
  }
1641
1596
 
@@ -1647,8 +1602,8 @@ class DeviceManager {
1647
1602
  // (e.g. a cloud app taking the view from a miniapp). Sweep the scene's
1648
1603
  // elements first so they don't linger under the new content; clear_view
1649
1604
  // wipes everything anyway.
1650
- sceneStates[stateIndex]?.let { prevFrame ->
1651
- sceneStates[stateIndex] = null
1605
+ sceneState?.let { prevFrame ->
1606
+ sceneState = null
1652
1607
  if (layoutType != "clear_view") {
1653
1608
  sgc?.clearSceneElements(prevFrame.elements.map { it.id })
1654
1609
  }
@@ -1687,33 +1642,25 @@ class DeviceManager {
1687
1642
  borderRadius
1688
1643
  )
1689
1644
 
1690
- val currentState = viewStates[stateIndex]
1691
-
1692
- if (statesEqual(currentState, newViewState)) {
1645
+ if (statesEqual(viewState, newViewState)) {
1693
1646
  return
1694
1647
  }
1695
1648
 
1696
- viewStates[stateIndex] = newViewState
1697
- val hUp = headUp && contextualDashboard
1698
- // send the state we just received if the user is currently in that state:
1699
- if (stateIndex == 0 && !hUp) {
1700
- sendCurrentState()
1701
- } else if (stateIndex == 1 && hUp) {
1702
- sendCurrentState()
1703
- }
1649
+ viewState = newViewState
1650
+ sendCurrentState()
1704
1651
  }
1705
1652
 
1706
- /** Parse + store a scene frame, then dispatch it if its view is visible. */
1707
- private fun handleSceneEvent(stateIndex: Int, sceneMap: Map<String, Any>) {
1653
+ /** Parse + store a scene frame, then dispatch it. */
1654
+ private fun handleSceneEvent(sceneMap: Map<String, Any>) {
1708
1655
  var frame = parseSceneFrame(sceneMap) ?: return
1709
- val prevFrame = sceneStates[stateIndex]
1656
+ val prevFrame = sceneState
1710
1657
 
1711
1658
  if (prevFrame == null) {
1712
1659
  // Legacy→scene handoff: stale legacy content (e.g. a cloud app's
1713
1660
  // text wall) must not linger under the scene's elements.
1714
1661
  // clearDisplay is the per-device "wipe what's there" (blank-in-place
1715
1662
  // on G2 — no page rebuild).
1716
- val prevLegacyType = viewStates[stateIndex].layoutType
1663
+ val prevLegacyType = viewState.layoutType
1717
1664
  if (prevLegacyType.isNotEmpty() && prevLegacyType != "clear_view" && prevLegacyType != "scene") {
1718
1665
  sgc?.clearDisplay()
1719
1666
  }
@@ -1728,17 +1675,14 @@ class DeviceManager {
1728
1675
  frame = frame.copy(replay = true, elements = frame.elements.map { it.copy(change = "created") })
1729
1676
  }
1730
1677
 
1731
- // Store the REDISPATCH form: any later sendCurrentState (dashboard
1732
- // exit, head-up return) must repaint the whole frame — the original
1678
+ // Store the REDISPATCH form: any later sendCurrentState (nav exit,
1679
+ // screen re-enable) must repaint the whole frame — the original
1733
1680
  // annotations are only valid for the first dispatch right now.
1734
- sceneStates[stateIndex] =
1681
+ sceneState =
1735
1682
  frame.copy(replay = true, elements = frame.elements.map { it.copy(change = "created") })
1736
- viewStates[stateIndex] = ViewState(" ", " ", " ", "scene", " ", null, null)
1683
+ viewState = ViewState(" ", " ", " ", "scene", " ", null, null)
1737
1684
 
1738
- val hUp = headUp && contextualDashboard
1739
- if ((stateIndex == 0 && !hUp) || (stateIndex == 1 && hUp)) {
1740
- dispatchSceneFrame(frame)
1741
- }
1685
+ dispatchSceneFrame(frame)
1742
1686
  }
1743
1687
 
1744
1688
  /** Guarded scene dispatch — mirrors sendCurrentState's send conditions. */
@@ -128,7 +128,6 @@ object DeviceStore {
128
128
  store.set("bluetooth", "dashboard_depth", 2)
129
129
  store.set("bluetooth", "head_up_angle", 30)
130
130
  store.set("bluetooth", "imu_enabled", false)
131
- store.set("bluetooth", "contextual_dashboard", true)
132
131
  store.set("bluetooth", "gallery_mode", true)
133
132
  store.set("bluetooth", "voice_activity_detection_enabled", BluetoothSdkDefaults.VOICE_ACTIVITY_DETECTION_ENABLED)
134
133
  store.set("bluetooth", "loudness_gate_enabled", BluetoothSdkDefaults.LOUDNESS_GATE_ENABLED)
@@ -198,9 +197,9 @@ object DeviceStore {
198
197
  }
199
198
  }
200
199
  }
200
+ // Head position only reaches JS now; the engine owns dashboard switching.
201
201
  "glasses" to "headUp" -> {
202
202
  if (value is Boolean) {
203
- DeviceManager.getInstance().sendCurrentState()
204
203
  Bridge.sendHeadUp(value)
205
204
  }
206
205
  }
@@ -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.1.0"
6
- const val RELEASE_IDENTITY: String = "0.1.0-dev.62"
7
- const val RELEASE_SET_ID: String = "glassly-0.1.0-dev.62"
8
- const val SOURCE_COMMIT: String = "69eee438b882fca1c9b0ad466d71fc2c24053662"
6
+ const val RELEASE_IDENTITY: String = "0.1.0-dev.90"
7
+ const val RELEASE_SET_ID: String = "glassly-0.1.0-dev.90"
8
+ const val SOURCE_COMMIT: String = "1140c7490313353cb1d8da0782bf74c87d9119eb"
9
9
  const val OTA_MANIFEST_URL: String = ""
10
10
  const val OTA_MANIFEST_SHA256: String = ""
11
11
  }
@@ -44,16 +44,101 @@ private object R1BLE {
44
44
 
45
45
  val NAME_FILTERS = arrayOf("EVEN R1", "BCL60")
46
46
 
47
- val CONFIG_FC = byteArrayOf(0xFC.toByte())
48
- val CONFIG_11 = byteArrayOf(0x11.toByte())
47
+ const val GESTURE_MARKER: Byte = 0xFF.toByte()
48
+ }
49
49
 
50
- // BleRing1 command header (cmd, module, subCmd) for advStart.
51
- // From RE: BleRing1Cmd_system=0, BleRing1Module_system=0, BleRing1SubCmd_advStart=9
52
- const val CMD_SYSTEM: Byte = 0x00
53
- const val MODULE_SYSTEM: Byte = 0x00
54
- const val SUBCMD_ADV_START: Byte = 0x09
50
+ // MARK: - EUS transport encoder (phone → ring, channel 2)
51
+
52
+ /**
53
+ * Recovered firmware contract (openCFW r1/ research): every phone→ring packet
54
+ * is a 12-byte "model" header
55
+ * [version, module, moduleVersion, serial LE16, status, command, subcommand,
56
+ * totalLength LE16, crc16 LE16]
57
+ * followed by the payload, where the phone-direction CRC-16 (CCITT rounds,
58
+ * init 0xFFFF) runs over the compact bytes [0,1,2,3,5,6,7,8,0x00] and then
59
+ * continues over the payload bytes. The model is carried on channel 2 inside a
60
+ * fragment train [sequence, crc32 LE32, ≤239 bytes]; the CRC-32 (poly
61
+ * 0x1EDC6F41, MSB-first, zero seed, no final xor) covers the whole model and
62
+ * sequence 0 terminates the train — all our packets fit a single fragment.
63
+ * Mirrors iOS R1.swift's R1Eus.
64
+ */
65
+ private object R1Eus {
66
+ const val VERSION = 100
67
+ const val MODULE_SYSTEM = 0x01
68
+ const val MODULE_VERSION = 100
69
+ /** status bit 0x02 marks a SET; advStart requires it */
70
+ const val STATUS_SET = 0x02
71
+
72
+ /** system subcommand 0x08: payload [0x01] claims the phone role; the ring
73
+ * requests link encryption/bonding in response */
74
+ const val SUBCMD_PAIR_AUTH = 0x08
75
+ /** system subcommand 0x0A: 12-byte payload = two 6-byte glasses arm MACs;
76
+ * the ring stores both targets and fast-advertises for them */
77
+ const val SUBCMD_ADV_START = 0x0A
78
+
79
+ private fun crc16Step(crc: Int, byte: Int): Int {
80
+ var c = ((crc shr 8) or (crc shl 8)) and 0xFFFF
81
+ c = c xor (byte and 0xFF)
82
+ c = c xor ((c and 0x00FF) shr 4)
83
+ c = (c xor (c shl 12)) and 0xFFFF
84
+ c = (c xor ((c and 0x00FF) shl 5)) and 0xFFFF
85
+ return c
86
+ }
55
87
 
56
- const val GESTURE_MARKER: Byte = 0xFF.toByte()
88
+ private fun phoneCrc16(header: ByteArray, payload: ByteArray): Int {
89
+ var crc = 0xFFFF
90
+ // compact header: bytes 0-3 and 5-8, then a zero placeholder
91
+ for (b in intArrayOf(
92
+ header[0].toInt(), header[1].toInt(), header[2].toInt(), header[3].toInt(),
93
+ header[5].toInt(), header[6].toInt(), header[7].toInt(), header[8].toInt(), 0
94
+ )) {
95
+ crc = crc16Step(crc, b)
96
+ }
97
+ for (b in payload) {
98
+ crc = crc16Step(crc, b.toInt())
99
+ }
100
+ return crc
101
+ }
102
+
103
+ private fun crc32(data: ByteArray): Long {
104
+ var crc = 0L
105
+ for (byte in data) {
106
+ crc = crc xor ((byte.toLong() and 0xFF) shl 24)
107
+ repeat(8) {
108
+ crc = if ((crc and 0x8000_0000L) != 0L) {
109
+ ((crc shl 1) xor 0x1EDC6F41L) and 0xFFFF_FFFFL
110
+ } else {
111
+ (crc shl 1) and 0xFFFF_FFFFL
112
+ }
113
+ }
114
+ }
115
+ return crc
116
+ }
117
+
118
+ /** Build one single-fragment system-module command packet. */
119
+ fun frame(subcommand: Int, status: Int, serial: Int, payload: ByteArray): ByteArray {
120
+ val length = 12 + payload.size
121
+ val header = byteArrayOf(
122
+ VERSION.toByte(), MODULE_SYSTEM.toByte(), MODULE_VERSION.toByte(),
123
+ (serial and 0xFF).toByte(), ((serial shr 8) and 0xFF).toByte(),
124
+ status.toByte(), 0x00, subcommand.toByte(),
125
+ (length and 0xFF).toByte(), ((length shr 8) and 0xFF).toByte(),
126
+ 0, 0,
127
+ )
128
+ val crc16 = phoneCrc16(header, payload)
129
+ header[10] = (crc16 and 0xFF).toByte()
130
+ header[11] = ((crc16 shr 8) and 0xFF).toByte()
131
+ val model = header + payload
132
+
133
+ val outer = crc32(model)
134
+ return byteArrayOf(
135
+ 0x00, // sequence 0: single-fragment train
136
+ (outer and 0xFF).toByte(),
137
+ ((outer shr 8) and 0xFF).toByte(),
138
+ ((outer shr 16) and 0xFF).toByte(),
139
+ ((outer shr 24) and 0xFF).toByte(),
140
+ ) + model
141
+ }
57
142
  }
58
143
 
59
144
  // MARK: - R1 Gesture Types
@@ -166,9 +251,22 @@ class R1 : ControllerManager() {
166
251
 
167
252
  private fun getRingMacFromMap(name: String): String? = loadRingMacAddressMap()[name]
168
253
 
169
- // Reconnection (defined but currently unwired — matches iOS which leaves it commented)
254
+ // Reconnection
170
255
  private val reconnectionManager = R1ReconnectionManager()
171
256
 
257
+ private fun startReconnectionTimer() {
258
+ reconnectionManager.start {
259
+ val connected = DeviceStore.get("glasses", "controllerConnected") as? Boolean ?: false
260
+ if (connected) {
261
+ true // already connected — stop retrying
262
+ } else {
263
+ Bridge.log("R1: Attempting reconnection...")
264
+ mainHandler.post { startScan() }
265
+ false // keep trying
266
+ }
267
+ }
268
+ }
269
+
172
270
  // Battery
173
271
  private var _batteryLevel: Int = -1
174
272
  set(value) {
@@ -378,27 +476,52 @@ class R1 : ControllerManager() {
378
476
  }
379
477
  }
380
478
 
479
+ // MARK: - EUS commands
480
+
481
+ private var eusSerial = 0
482
+
483
+ private fun nextEusSerial(): Int {
484
+ eusSerial = (eusSerial + 1) and 0xFFFF
485
+ if (eusSerial == 0) eusSerial = 1
486
+ return eusSerial
487
+ }
488
+
489
+ /**
490
+ * EUS system commands go to WRITE_CHAR_2 only — channel 2 is the framed
491
+ * transport; channel 1 speaks the separate legacy protocol.
492
+ */
493
+ private fun sendEusCommand(subcommand: Int, payload: ByteArray, log: String) {
494
+ val wc = writeChar2
495
+ if (wc == null) {
496
+ Bridge.log("R1: $log: no channel-2 write characteristic")
497
+ return
498
+ }
499
+ val packet = R1Eus.frame(subcommand, R1Eus.STATUS_SET, nextEusSerial(), payload)
500
+ writeNoResponse(wc, packet)
501
+ Bridge.log("R1: $log sent (${packet.size} bytes)")
502
+ }
503
+
381
504
  // MARK: - Init Sequence
382
505
 
506
+ /**
507
+ * Runs after notify subscriptions are confirmed: claim the phone role,
508
+ * then mark the phone link ready.
509
+ */
383
510
  private fun runInitSequence() {
384
511
  if (initSequenceRun) return
385
512
  initSequenceRun = true
386
513
 
387
514
  Bridge.log("R1: Running init sequence")
388
515
 
389
- val writeChars = listOfNotNull(writeChar1, writeChar2)
390
- if (writeChars.isEmpty()) {
391
- Bridge.log("R1: No write characteristics found, skipping init")
516
+ if (writeChar2 == null) {
517
+ Bridge.log("R1: No channel-2 write characteristic, skipping init")
392
518
  markConnected()
393
519
  return
394
520
  }
395
521
 
396
- writeChars.forEach { writeNoResponse(it, R1BLE.CONFIG_FC) }
522
+ sendEusCommand(R1Eus.SUBCMD_PAIR_AUTH, byteArrayOf(0x01), "pairAuth")
397
523
 
398
- mainHandler.postDelayed({
399
- writeChars.forEach { writeNoResponse(it, R1BLE.CONFIG_11) }
400
- markConnected()
401
- }, 200)
524
+ mainHandler.postDelayed({ markConnected() }, 300)
402
525
  }
403
526
 
404
527
  private fun writeNoResponse(char: BluetoothGattCharacteristic, data: ByteArray) {
@@ -430,7 +553,10 @@ class R1 : ControllerManager() {
430
553
  }
431
554
  DeviceStore.apply("glasses", "controllerMacAddress", mac)
432
555
  DeviceStore.apply("glasses", "controllerConnected", true)
433
- // DeviceStore.apply("glasses", "controllerFullyBooted", true)
556
+ // 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.
559
+ DeviceStore.apply("glasses", "controllerFullyBooted", true)
434
560
 
435
561
  // tell the ring to connect to the glasses if we have its mac address:
436
562
  connectToGlasses()
@@ -445,46 +571,41 @@ class R1 : ControllerManager() {
445
571
  }
446
572
 
447
573
  /**
448
- * Tells the ring to start advertising / connect to the glasses.
449
- * Sends BleRing1 advStart (cmd=0, module=0, subCmd=9) with the 6-byte glasses MAC as payload
450
- * to WRITE_CHAR_2 (BAE80012-…). Reverse-engineered from the Even Realities mobile app
451
- * (BleRing1CmdProto::advStart -> BleRing1CmdPublicExt.sendCmd).
452
- *
453
- * TODO: BleRing1CmdPublicExt.sendCmd may add additional outer framing (length/seq/CRC) around
454
- * the 3-byte header + MAC. If the ring rejects this raw payload, decode the wrapper.
574
+ * Tells the ring which glasses may connect to it: EUS advStart whose
575
+ * 12-byte payload is the two glasses arm MACs. The ring stores both
576
+ * targets, fast-advertises while a glasses role is open, and accepts a
577
+ * connection from either arm. With only one arm MAC known it is sent for
578
+ * both targets — the ring matches against either slot.
455
579
  */
456
580
  private fun connectToGlasses() {
457
- // Try DeviceStore first; fall back to cached value in SharedPreferences.
458
- val glassesMac = (DeviceStore.get("glasses", "bluetoothMacAddress") as? String)
581
+ fun stored(key: String): String? =
582
+ (DeviceStore.get("glasses", key) as? String)?.takeIf { it.isNotEmpty() }
583
+
584
+ val left = stored("leftMacAddress")
585
+ ?: stored("bluetoothMacAddress")
459
586
  ?: prefs.getString("glasses_btMacAddress", null)
460
- if (glassesMac == null) {
587
+ val right = stored("rightMacAddress")
588
+ ?: prefs.getString("glasses_btMacAddressRight", null)
589
+
590
+ if (left == null) {
461
591
  Bridge.log("R1: connectToGlasses: no glasses MAC")
462
592
  return
463
593
  }
464
- // Cache so we can reconnect even before the glasses are scanned.
465
- prefs.edit().putString("glasses_btMacAddress", glassesMac).apply()
466
-
467
- val macBytes = parseMac(glassesMac)
468
- if (macBytes == null) {
594
+ val leftBytes = parseMac(left)
595
+ if (leftBytes == null) {
469
596
  Bridge.log("R1: connectToGlasses: could not parse glasses MAC")
470
597
  return
471
598
  }
472
- val wc = writeChar2 ?: writeChar1
473
- if (wc == null) {
474
- Bridge.log("R1: connectToGlasses: no write characteristic")
475
- return
476
- }
477
-
478
- val payload = byteArrayOf(R1BLE.CMD_SYSTEM, R1BLE.MODULE_SYSTEM, R1BLE.SUBCMD_ADV_START) + macBytes
479
- Bridge.log("R1: advStart sent")
599
+ val rightBytes = right?.let { parseMac(it) } ?: leftBytes
480
600
 
481
- wc.writeType = BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE
482
- wc.value = payload
483
- try {
484
- ringGatt?.writeCharacteristic(wc)
485
- } catch (e: SecurityException) {
486
- Bridge.log("R1: connectToGlasses writeCharacteristic SecurityException: ${e.message}")
601
+ // Cache so we can re-send even before the glasses are next scanned.
602
+ prefs.edit().apply {
603
+ putString("glasses_btMacAddress", left)
604
+ if (right != null) putString("glasses_btMacAddressRight", right)
605
+ apply()
487
606
  }
607
+
608
+ sendEusCommand(R1Eus.SUBCMD_ADV_START, leftBytes + rightBytes, "advStart")
488
609
  }
489
610
 
490
611
  /** Parse "AA:BB:CC:DD:EE:FF" or "AABBCCDDEEFF" into 6 raw bytes. */
@@ -598,8 +719,8 @@ class R1 : ControllerManager() {
598
719
  pendingReads.clear()
599
720
  descriptorWriteInFlight = false
600
721
  readInFlight = false
601
- ringMacAddress = null
602
- ringBleAddress = null
722
+ // ringMacAddress/ringBleAddress persist across disconnects so
723
+ // reconnection can find the ring again; only forget() clears them.
603
724
  DeviceStore.apply("glasses", "controllerConnected", false)
604
725
  DeviceStore.apply("glasses", "controllerFullyBooted", false)
605
726
  }
@@ -645,7 +766,7 @@ class R1 : ControllerManager() {
645
766
  try { gatt.close() } catch (e: SecurityException) {}
646
767
  if (isDisconnecting) return@post
647
768
  resetConnectionState()
648
- // iOS leaves reconnection timer commented; match that behavior
769
+ startReconnectionTimer()
649
770
  }
650
771
  }
651
772
  }
@@ -906,7 +1027,6 @@ class R1 : ControllerManager() {
906
1027
 
907
1028
  // MARK: - R1 Reconnection Manager
908
1029
  // Coroutine-based port of Swift's R1ReconnectionManager actor.
909
- // Currently unwired — matches iOS which leaves the reconnection timer commented out.
910
1030
 
911
1031
  private class R1ReconnectionManager(
912
1032
  private val intervalSeconds: Long = 30L,