Skip to content

feat: device pinning — stream from a chosen pair (SpecificDeviceSelector)#13

Merged
gdelataillade merged 5 commits into
mainfrom
feat/device-pinning
Jun 20, 2026
Merged

feat: device pinning — stream from a chosen pair (SpecificDeviceSelector)#13
gdelataillade merged 5 commits into
mainfrom
feat/device-pinning

Conversation

@gdelataillade

Copy link
Copy Markdown
Collaborator

Stacked on #12 (feat/get-devices-api) — review/merge that first; this PR's diff is pinning-only.

Summary

Step 2 of the device-selection work: startStreamSession(deviceId) now streams from a specific paired pair instead of whatever the shared AutoDeviceSelector happens to pick. Pass a WearableDevice.id (from the getDevices() added in #12) to pin it via the SDK's SpecificDeviceSelector; pass null to auto-select. This fixes the "connects to a random pair" problem with two pairs.

How it works

  • The pin is state (pinnedDeviceId) applied at startStreamSession, routed through a single pin-aware selector factory used at every construction site — so the load-bearing blind-rebuild recovery path keeps the pin instead of silently dropping it.
  • Both platforms keep one shared selector (the SpecificDeviceSelector when pinned), so the existing availability-watchdog / thermal / getDevices().isActive stack stays correct — we never run a specific selector beside the auto one.
  • A different deviceId while a stream is already active returns STREAM_ACTIVE (PlatformException) rather than silently returning the old device's texture — stop first.

Notable details

  • Android Device exposes no identifier, so an AutoDeviceSelector filter can't match by id — SpecificDeviceSelector(DeviceIdentifier) is the only id-based option, hence the uniform approach.
  • Android deferred init: selector is built through a nullable holder + accessor (never before Wearables.initialize()), and startStreamSession guards with NOT_INITIALIZED before any selector/texture work.
  • On a pin change: tear down the session, rebuild the selector, relaunch the iOS watchdog, and restartMonitoring(force: true) on both event handlers (added a force path on Android) so nothing keeps observing the old selector.
  • API: the positional deviceUUID param on startStreamSession/stopStreamSession/capturePhoto is renamed to deviceId (positional → non-breaking).

Example app

Tap a pair (or Automatic) in the Paired-devices sheet to choose which one streams next (takes effect on the next Start, per the chosen UX). Selection is the sole source of truth (null = Automatic); the mock is pinned explicitly on pair and cleared on unpair only when the selection still points at it.

Verification

  • dart analyze clean (root + example); flutter test green in both (startStreamSession wire forwarding / STREAM_ACTIVE; provider selection policy incl. unpair-preserves-real-pair).
  • flutter build apk ✓ and flutter build ios --no-codesign ✓.
  • Pending (needs hardware — yours): the two-real-pairs manual checks from the plan — pick B → Start → confirm B streams; switch via Stop→pick A→Start; STREAM_ACTIVE on switch-while-streaming; pinned-device disconnect/reconnect recovery.

🤖 Generated with Claude Code

gdelataillade and others added 2 commits June 19, 2026 09:58
…nning)

startStreamSession(deviceId) now binds the session to a specific paired
pair — a WearableDevice.id from getDevices() — via the SDK's
SpecificDeviceSelector, instead of always using the shared
AutoDeviceSelector. Pass null to auto-select.

- Dart: rename the positional deviceUUID -> deviceId on startStreamSession /
  stopStreamSession / capturePhoto (+ the wire key); positional, non-breaking.
- The pin is state (pinnedDeviceId) applied at startStreamSession through a
  single pin-aware selector factory used at EVERY construction site, so the
  blind-rebuild recovery path can't silently drop it.
- Requesting a different deviceId while a stream is active returns
  STREAM_ACTIVE (PlatformException) — stop first; same id returns the
  existing texture.
- iOS: selector widened to `any DeviceSelector`; on a pin change tear down
  the session, rebuild the selector, relaunch the availability watchdog,
  restart both event handlers (force: true), bump lastSelectorRebuild.
- Android: deferred nullable selector holder + accessor (never constructed
  before Wearables.initialize); NOT_INITIALIZED guard on startStreamSession;
  both handlers gained restartMonitoring(force) to rebind after a swap.
- Example: tap a pair (or "Automatic") in the Paired-devices sheet to choose
  which one streams next. Selection is the sole source of truth (null =
  Automatic); the mock is pinned explicitly on pair and cleared on unpair
  only when the selection still points at it.
- Tests: plugin wire (deviceId forwarding / STREAM_ACTIVE) + example provider
  selection policy. Docs: CHANGELOG 0.6.0, README, AGENTS, agent rules.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…zation, iOS selector monitoring)

- [P1] Conflict guard now checks the actual Stream state: a non-nil but
  STOPPED/STOPPING stream counts as inactive (torn down and recreated), so
  restarting the same device no longer returns a stale texture and switching
  no longer false-positives STREAM_ACTIVE. Both platforms.
- [P1] Serialize concurrent starts: reject a start while another is in flight
  (isStartingSession / startInProgress). Android ensureSessionStarted also
  bails if the session reaches STOPPED instead of STARTED, so a session torn
  down mid-start can't hang the awaiter.
- [P2] iOS pins via a filtered AutoDeviceSelector (filter: identifier == id)
  instead of SpecificDeviceSelector — the latter's iOS activeDeviceStream()
  emits once then completes, which would stop the availability watchdog from
  observing the pinned device disconnecting. The filtered AutoDeviceSelector
  keeps a continuous stream (emits nil on disconnect). iOS selector field +
  handler provider types reverted from `any DeviceSelector` to concrete
  AutoDeviceSelector. Android keeps SpecificDeviceSelector (its Device exposes
  no id to filter on).

Verified: dart analyze + flutter test (root + example) green; flutter build
apk + ios --no-codesign green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@gdelataillade

Copy link
Copy Markdown
Collaborator Author

Addressed all three review findings in 8b99d62:

  • [P1] Stopped streams treated as active — the conflict guard now checks the actual Stream state (iOS streamSession.state, Android stream.state.value). A non-nil but STOPPED/STOPPING/CLOSED stream is treated as inactive: it's torn down (teardownStreamOnly) and recreated, so restarting the same device no longer returns a stale texture and switching no longer false-positives STREAM_ACTIVE.
  • [P1] Concurrent starts can hang Android — added an in-flight guard (isStartingSession / startInProgress) that rejects a second start with STREAM_ACTIVE while one is in progress, so a second start can't tear down the first's in-flight session. Belt-and-suspenders: Android ensureSessionStarted now awaits STARTED || STOPPED and returns null on STOPPED, so a session torn down mid-start can't hang the awaiter.
  • [P2] Specific selector can't monitor iOS availability — confirmed via Meta's docs that iOS SpecificDeviceSelector.activeDeviceStream() emits once then completes. iOS now pins via a filtered AutoDeviceSelector (filter: { $0.identifier == id }) instead, which keeps a continuous stream that emits nil when the pinned device disconnects — so the availability watchdog and the active-device/thermal handlers keep working. The iOS selector field + handler provider types reverted from any DeviceSelector back to concrete AutoDeviceSelector.

Correction to the PR description: the approach is no longer a uniform SpecificDeviceSelector. iOS uses a filtered AutoDeviceSelector (continuous stream); Android keeps SpecificDeviceSelector (its Device exposes no identifier to filter on, and its activeDeviceFlow() wasn't flagged for the emit-once behavior).

Re-verified: dart analyze clean (root + example), flutter test green in both (19 + 4), flutter build apk ✓ and flutter build ios --no-codesign ✓. The two-real-pairs + disconnect-recovery manual checks still need hardware.

… selector doc

- [P1] Gate the example Start button on the *selected* pair's connectivity
  (new `canStartSelected`), not the shared selector's activeDevice. With a pin
  to a disconnected pair, `hasActiveDevice` stays false so Start was disabled —
  and since selecting a different (connected) pair only updates the Dart
  selection until the next startStreamSession, that switching call could never
  fire (deadlock). `canStartSelected` reflects the selected pair's link state
  from the last getDevices snapshot (falling back to hasActiveDevice in
  Automatic mode), so a connected pair can be picked to switch to — the native
  selector re-pins on start. Added a provider test.
- [P3] dat-streaming.mdc no longer claims pinning always uses
  SpecificDeviceSelector; now notes SpecificDeviceSelector on Android and a
  filtered AutoDeviceSelector on iOS, matching the CHANGELOG.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@gdelataillade

Copy link
Copy Markdown
Collaborator Author

Both remaining findings addressed in 5fd8249:

  • [P1] New selection can't escape a disconnected pin — the example's Start button is now gated on a new StreamSessionProvider.canStartSelected, which reflects the selected pair's link state (from the last getDevices() snapshot) rather than the shared selector's activeDevice. In Automatic mode it still mirrors hasActiveDevice. So with a pin to a now-disconnected pair, you can pick a connected pair and Start becomes enabled — the native selector re-pins on startStreamSession. Added a provider test (canStartSelected reflects the selected pair connectivity).
  • [P3] Selector docagent/cursor/rules/dat-streaming.mdc no longer says pinning always uses SpecificDeviceSelector; it now notes SpecificDeviceSelector on Android and a filtered AutoDeviceSelector on iOS, matching the CHANGELOG.

No native changes this round, so the prior green flutter build apk / build ios still hold. dart analyze clean, example tests now 5 (root 19) — all green.

@gdelataillade gdelataillade changed the base branch from feat/get-devices-api to main June 19, 2026 11:59
gdelataillade and others added 2 commits June 19, 2026 17:59
…Session

On a device-pin change the shared selector is rebuilt, and a freshly-built
selector resolves its active device asynchronously — so createSession ran
against an unresolved selector and failed with noEligibleDevice. (Seen on
hardware: the pinned device's `activeDevice -> true` arrived a beat *after*
the failure, confirming the device was found, just too late.)

startStreamSession now waits — after a pin change to a specific device — for
the selector's activeDevice to resolve (up to ~8s) before creating the
session. iOS polls deviceSelector.activeDevice; Android awaits
activeDeviceFlow().first { it != null } under withTimeoutOrNull. Automatic
(no pin) is unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ces, dep hints

- README: streaming snippets pass null (auto) with a pinning comment instead of
  an undefined `deviceId` variable.
- Copilot instructions: add getDevices() / pinning / STREAM_ACTIVE and rename
  the streaming param deviceUUID -> deviceId (it was behind on both 0.6.0
  features).
- Bump core + mock dependency hints from ^0.5.x to ^0.6.0 across README,
  AGENTS, and the agent rules/skills (^0.5.x would exclude the 0.6.0 packages).
- Mock-device examples keep `deviceUUID` (the mock's own pair UUID) — passing
  it now correctly pins the mock.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@gdelataillade gdelataillade merged commit 65dd3f2 into main Jun 20, 2026
9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant