-
Notifications
You must be signed in to change notification settings - Fork 326
Two-phase pairing identity: hydration contract, non-destructive back-outs, guarded connect paths #3346
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Two-phase pairing identity: hydration contract, non-destructive back-outs, guarded connect paths #3346
Changes from all commits
4d1fac6
4b2ef78
18bd89f
51b4199
fafc89f
0bb2df0
5404c52
a52d382
6c12d9d
4aac1bf
b5c250c
e3e8125
ae2cac9
8b4fdf7
1d6955e
be7017b
89a270f
c0b85c0
a839611
a90d918
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -6,11 +6,26 @@ | |
| * (Connect-the-already-paired-default is `toolkit.glasses.connectDefault()`; this | ||
| * facade is the first-time discovery + pair flow.) | ||
| */ | ||
| import BluetoothSdk from "@mentra/bluetooth-sdk" | ||
| import type {PairFailureEvent, GlassesNotReadyEvent} from "@mentra/bluetooth-sdk" | ||
| // Internal btsdk surface — updateBluetoothSettings (the Bluetooth Classic | ||
| // target hint) lives on the full surface, not the public entry (same reason | ||
| // the glasses facade imports internal). | ||
| import BluetoothSdk from "@mentra/bluetooth-sdk/internal" | ||
| import type {ConnectOptions, Device, PairFailureEvent, GlassesNotReadyEvent} from "@mentra/bluetooth-sdk" | ||
| import {useCoreStore} from "../stores/core" | ||
| import {useGlassesStore} from "../stores/glasses" | ||
| import {isGlassesLinkLayerBusy, isGlassesReady, waitForGlassesReady} from "../services/GlassesReadiness" | ||
| import {hasDefaultDevice} from "../services/DeviceStoreHydration" | ||
| import { | ||
| markPendingSelection, | ||
| projectPairingIdentity, | ||
| subscribePairingIdentity, | ||
| type IdentitySnapshot, | ||
| } from "../services/PairingIdentity" | ||
| import { | ||
| isGlassesConnected, | ||
| isGlassesLinkLayerBusy, | ||
| isGlassesReady, | ||
| waitForGlassesReady, | ||
| } from "../services/GlassesReadiness" | ||
| import { | ||
| logAutomaticReportSubmissionStatus, | ||
| toAutomaticReportSubmissionStatus, | ||
|
|
@@ -19,6 +34,8 @@ import { | |
| import {pushAllBluetoothSettings} from "../services/GlassesSettingsSync" | ||
| import {submitAutomaticReport} from "./reports" | ||
|
|
||
| export type {IdentitySnapshot} from "../services/PairingIdentity" | ||
|
|
||
| export interface PairingReadyWaitOptions { | ||
| deviceModel?: string | ||
| deviceName?: string | ||
|
|
@@ -177,13 +194,23 @@ export const pairing = { | |
| }) | ||
| }, | ||
|
|
||
| // --- pairing-identity lifecycle (the PairingIdentity read-model + the JS-owned | ||
| // identity writes; promotion to `paired` only ever happens natively) --- | ||
| /** The identity lifecycle snapshot: none | pending (chosen, never paired) | paired. */ | ||
| identity: (): IdentitySnapshot => projectPairingIdentity(), | ||
| /** Subscribe to identity changes; fires only when the projected snapshot changes. | ||
| * Returns an unsubscribe. */ | ||
| onIdentity: (cb: (identity: IdentitySnapshot) => void): (() => void) => subscribePairingIdentity(cb), | ||
| /** Mark the chosen model as the pending selection (the scan-entry write); the | ||
| * host renders it as a finish-pairing affordance until pairing succeeds. */ | ||
| markPendingSelection: (model: string) => markPendingSelection(model), | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: The Prompt for AI agents |
||
|
|
||
| /** Start scanning for nearby glasses. Results land on `searchResults()`/`onFound()`. */ | ||
| scan: (...args: Parameters<typeof BluetoothSdk.startScan>) => BluetoothSdk.startScan(...args), | ||
| /** Whether a scan is currently in progress. */ | ||
| scanning: (): boolean => useCoreStore.getState().searching, | ||
| /** Subscribe to scan-in-progress changes; fires only when it changes. Returns an unsubscribe. */ | ||
| onScanning: (cb: (scanning: boolean) => void): (() => void) => | ||
| useCoreStore.subscribe((s) => s.searching, cb), | ||
| onScanning: (cb: (scanning: boolean) => void): (() => void) => useCoreStore.subscribe((s) => s.searching, cb), | ||
| /** Whether a controller scan is currently in progress. */ | ||
| scanningController: (): boolean => useCoreStore.getState().searchingController, | ||
| /** Subscribe to controller-scan-in-progress changes; fires only when it changes. Returns an unsubscribe. */ | ||
|
|
@@ -211,13 +238,79 @@ export const pairing = { | |
| }) | ||
| }, | ||
|
|
||
| /** Pair with (connect to) a discovered device. */ | ||
| pair: async (...args: Parameters<typeof BluetoothSdk.connect>): Promise<void> => { | ||
| /** | ||
| * Pair with (connect to) a discovered device. Two-phase identity: the connect | ||
| * attempt only marks the device as pending (`saveAsDefault: false` — no eager | ||
| * default-device write); the native layer promotes it to the default wearable | ||
| * when pairing actually succeeds (handleDeviceReady), so an abandoned or | ||
| * failed attempt can't leave a default identity with no paired device behind. | ||
| */ | ||
| pair: async (device: Device, options?: ConnectOptions): Promise<void> => { | ||
| await pushAllBluetoothSettings() | ||
| return BluetoothSdk.connect(...args) | ||
| return BluetoothSdk.connect(device, {...options, saveAsDefault: false}) | ||
| }, | ||
| /** Set a device as the default for subsequent `glasses.connectDefault()`. */ | ||
| setDefault: (...args: Parameters<typeof BluetoothSdk.setDefaultDevice>) => BluetoothSdk.setDefaultDevice(...args), | ||
| /** | ||
| * Prime the native Bluetooth Classic audio watcher with the picked device. | ||
| * The iOS Mentra Live flow pairs Classic audio BEFORE any BLE connect | ||
| * exists, and native detects the pairing by matching the connected audio | ||
| * route against its device_name — which two-phase identity no longer sets | ||
| * at selection time. This is native-only routing state (device_name has no | ||
| * native→JS echo): the phone's persisted identity is untouched, and | ||
| * promotion still happens only at pairing success. | ||
| */ | ||
| setBluetoothClassicTarget: (device: Device): Promise<void> => | ||
| BluetoothSdk.updateBluetoothSettings({device_name: device.name}), | ||
| /** | ||
| * Abandon an in-flight pairing attempt (back-out, failure retry, conflict | ||
| * retry) without destroying an existing pairing. The decision comes from the | ||
| * LIVE hydrated default-device read — never from flow-entry state, because a | ||
| * pairing can PROMOTE while the flow is open (the glasses finish pairing | ||
| * even as the user backs out of the UI), and an entry snapshot would forget | ||
| * that brand-new pairing: | ||
| * - Default device exists and glasses are connected (nothing in flight — an | ||
| * attempt drops the link first): stop the scan, touch nothing else. | ||
| * - Default device exists with an attempt in flight: cancel it, then re-seed | ||
| * the native identity from the phone's persisted settings — the attempt's | ||
| * connect-by-name overwrote the native device_name, so a later | ||
| * connectDefault() would otherwise target the abandoned device. | ||
| * - No default device (genuinely unpaired attempt): also forget, clearing | ||
| * the partial native pairing state. | ||
| * The read fails OPEN to "preserve" — a transient failure must never wipe a | ||
| * real pairing. The `pending_wearable` marker is deliberately left alone — | ||
| * the host renders it as a finish-pairing affordance. | ||
| */ | ||
| abandonAttempt: async (): Promise<void> => { | ||
| const nativeHasDefault = await hasDefaultDevice().catch(() => true) | ||
| if (nativeHasDefault && isGlassesConnected(useGlassesStore.getState().connection)) { | ||
| // Still connected means no connect attempt is in flight (an attempt drops | ||
| // the existing link first): the user browsed the scan and backed out. | ||
| // Stop the scan and leave the live pairing untouched. | ||
| console.log("PairingIdentity: abandonAttempt — pairing intact and connected; stopping scan only") | ||
| await BluetoothSdk.stopScan() | ||
| return | ||
| } | ||
| await BluetoothSdk.disconnect() | ||
| if (projectPairingIdentity().kind === "paired") { | ||
| // The persisted settings describe a COMPLETE pairing: restore it to | ||
| // native (the attempt's connect-by-name overwrote the native | ||
| // device_name; and if native somehow lost its default entirely, this | ||
| // repairs the divergence instead of forgetting a real pairing). | ||
| console.log("PairingIdentity: abandonAttempt — preserving pairing; attempt cancelled, native identity re-seeded") | ||
| await pushAllBluetoothSettings() | ||
| } else if (nativeHasDefault) { | ||
| // Mid-relay: native promoted and its echoes are still landing — the | ||
| // incomplete JS snapshot must not be pushed over the fresher native | ||
| // identity (the on-connect replay's race). Native holds the truth. | ||
| console.log("PairingIdentity: abandonAttempt — preserving pairing; JS identity mid-relay, native kept as-is") | ||
| } else { | ||
| // Forgetting requires CONSENSUS: no native default AND no complete | ||
| // persisted pairing — a genuinely partial attempt. | ||
| console.log("PairingIdentity: abandonAttempt — no pairing on either layer; forgetting the partial attempt") | ||
| await BluetoothSdk.forget() | ||
|
cursor[bot] marked this conversation as resolved.
|
||
| } | ||
|
cursor[bot] marked this conversation as resolved.
|
||
| }, | ||
|
|
||
| /** Subscribe to pairing failures; returns an unsubscribe. */ | ||
| onPairFailure: (cb: (event: PairFailureEvent) => void): (() => void) => { | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.