Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,9 @@ Communication:
| `PhotoCaptureFormat` | `heic('heic')`, `jpeg('jpeg')` |
| `FrameFormat` | `rawRgba`, `rawStraightRgba`, `png` |
| `ThermalLevel` | `unknown(0)`, `none(1)`, `light(2)`, `moderate(3)`, `severe(4)`, `critical(5)`, `emergency(6)`, `shutdown(7)` |
| `WearableDeviceType` | `rayBanMeta`, `oakleyMetaHSTN`, `oakleyMetaVanguard`, `metaRayBanDisplay`, `rayBanMetaOptics`, `unknown` |
| `WearableLinkState` | `disconnected`, `connecting`, `connected`, `unknown` |
| `WearableCompatibility` | `undefined`, `compatible`, `deviceUpdateRequired`, `sdkUpdateRequired` |

### Classes

Expand All @@ -59,6 +62,7 @@ Communication:
| `VideoFrame` | `codec` (VideoCodec), `bytes` (Uint8List), `width`, `height`, `presentationTimestampUs`, `isKeyframe` |
| `BackgroundNotification` | `title`, `text`, `channelId`, `channelName`, `iconResourceName?` (Android only) |
| `DeviceState` | `thermalLevel` (ThermalLevel) |
| `WearableDevice` | `id`, `name`, `type` (WearableDeviceType), `linkState` (WearableLinkState), `compatibility` (WearableCompatibility), `supportsDisplay`, `isActive`, `isStreamingDevice`, `firmwareInfo?` |

### Error codes (StreamSessionError)

Expand Down Expand Up @@ -96,6 +100,7 @@ static Future<bool> getCameraPermissionStatus()

// Device monitoring
static Stream<bool> activeDeviceStream()
static Future<List<WearableDevice>> getDevices() // paired glasses; isStreamingDevice/isActive flag the streaming/selected pair. Android throws NOT_INITIALIZED before BT permission
static Future<bool> restartActiveDeviceMonitoring() // No-op on iOS

// Streaming
Expand Down
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
## 0.6.0
* New `getDevices()` returning `List<WearableDevice>` — enumerate the paired glasses with their `name`, model (`WearableDeviceType`), link state, compatibility, and display capability. `isStreamingDevice` flags the pair the current stream is actually using; `isActive` flags the pair the shared auto-selector would bind a *new* stream to (the two differ when more than one pair is connected). Use it to tell two pairs of the same model apart and see which one you're streaming from. Read-only — *selecting* (pinning) a specific pair is planned for a follow-up.
* New types: `WearableDevice`, `WearableDeviceType`, `WearableLinkState`, `WearableCompatibility`. Non-breaking, drop-in addition.
* Example app: a "Paired devices" sheet (devices button, top-right) lists every paired pair and marks the streaming / selected one.

## 0.5.3
* iOS: Fix first-registration device discovery and a `startStreamSession` hang after a failed start; surface genuine session errors.

Expand Down
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,7 @@ The plugin follows Meta's integration lifecycle as documented in the [Meta Weara
- Handle the callback URL with `MetaWearablesDat.handleUrl(url)` to complete registration
- Monitor registration state via `MetaWearablesDat.registrationStateStream()`
- Monitor active device availability via `MetaWearablesDat.activeDeviceStream()`
- List the paired glasses (name, model, and which pair is streaming) via `MetaWearablesDat.getDevices()` — useful when more than one pair is paired

### 2. Permissions (First-time camera access)
- When your app first attempts to access the camera, request permission
Expand Down Expand Up @@ -302,6 +303,14 @@ MetaWearablesDat.deviceStateStream().listen((state) {
print('Thermal: ${state.thermalLevel}');
});

// (Optional) List the paired glasses — names, models, and which pair is
// streaming. Handy when two pairs of the same model are paired.
final devices = await MetaWearablesDat.getDevices();
for (final d in devices) {
print('${d.name} (${d.type.code})'
'${d.isStreamingDevice ? ' — streaming' : ''}');
}

// Capture a photo during streaming
final photo = await MetaWearablesDat.capturePhoto(
deviceUUID,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,13 @@ class MetaWearablesDatPlugin :
private var session: DeviceSession? = null
private var stream: Stream? = null
private var sessionKey: String? = null

// Device the current (reusable) DeviceSession is bound to. Captured at
// session creation — from the auto-selector's pick *before* createSession,
// so a later A→B switch can't misattribute it — and cleared on session
// teardown. Combined with a live STREAMING check in `getDevices`, it tells
// which device is actually streaming.
@Volatile private var sessionDeviceId: String? = null
private var videoJob: Job? = null
private var streamErrorJob: Job? = null
private var deviceAvailabilityJob: Job? = null
Expand Down Expand Up @@ -235,6 +242,7 @@ class MetaWearablesDatPlugin :
"startRegistration" -> startRegistration(result)
"disconnect" -> disconnect(result)
"getRegistrationState" -> getRegistrationState(result)
"getDevices" -> getDevices(result)
"getCameraPermissionStatus" -> getCameraPermissionStatus(result)
"requestCameraPermission" -> requestCameraPermission(result)
"handleUrl" -> handleUrl(call, result)
Expand Down Expand Up @@ -605,6 +613,121 @@ class MetaWearablesDatPlugin :
}
}

/**
* Returns a snapshot of all paired devices as a list of maps, decoded by
* `WearableDevice.fromMap` on the Dart side. Requires Bluetooth permissions
* (the SDK can't enumerate devices until initialized) — returns a
* `NOT_INITIALIZED` error otherwise rather than a misleading empty list.
*
* `isActive` = the shared auto-selector's current pick (what a new stream
* would bind to). `isStreamingDevice` = the device the *live* stream uses,
* gated on the stream's actual STREAMING state so a stale reference after
* an SDK-driven stop doesn't report a false positive.
*/
private fun getDevices(result: Result) {
if (!btPermissionsGranted || application == null) {
result.error(
"NOT_INITIALIZED",
"Bluetooth permissions not granted; call requestAndroidPermissions() first.",
null,
)
return
}
scope.launch {
try {
ensureWearablesInitialized()
val active = deviceSelector.activeDevice()?.identifier
val streaming =
stream?.state?.value ==
com.meta.wearable.dat.camera.types.StreamState.STREAMING
val sessionId = sessionDeviceId
val devices =
Wearables.devices.value.mapNotNull { id ->
val idStr = id.identifier
if (idStr.isEmpty()) return@mapNotNull null
val device = Wearables.devicesMetadata[id]?.value
val isActive = idStr == active
val isStreamingDevice = streaming && idStr == sessionId
if (device != null) {
mapOf<String, Any?>(
"id" to idStr,
"name" to device.name,
"deviceType" to deviceTypeCode(device.deviceType),
"linkState" to linkStateCode(device.linkState),
"compatibility" to
compatibilityCode(device.compatibility),
"supportsDisplay" to device.isDisplayCapable(),
"isActive" to isActive,
"isStreamingDevice" to isStreamingDevice,
"firmwareInfo" to device.firmwareInfo,
)
} else {
// Metadata unavailable — complete fallback so the
// count still matches Wearables.devices.
mapOf<String, Any?>(
"id" to idStr,
"name" to idStr,
"deviceType" to "unknown",
"linkState" to "unknown",
"compatibility" to "undefined",
"supportsDisplay" to false,
"isActive" to isActive,
"isStreamingDevice" to isStreamingDevice,
"firmwareInfo" to null,
)
}
}
result.success(devices)
} catch (e: Exception) {
result.error(
"GET_DEVICES_ERROR",
e.message ?: "Failed to list devices.",
null,
)
}
}
}

/** Canonical device-type code, kept identical to the iOS side. */
private fun deviceTypeCode(type: com.meta.wearable.dat.core.types.DeviceType): String {
return when (type) {
com.meta.wearable.dat.core.types.DeviceType.RAYBAN_META -> "rayBanMeta"
com.meta.wearable.dat.core.types.DeviceType.OAKLEY_META_HSTN -> "oakleyMetaHSTN"
com.meta.wearable.dat.core.types.DeviceType.OAKLEY_META_VANGUARD ->
"oakleyMetaVanguard"
com.meta.wearable.dat.core.types.DeviceType.META_RAYBAN_DISPLAY ->
"metaRayBanDisplay"
com.meta.wearable.dat.core.types.DeviceType.RAYBAN_META_OPTICS -> "rayBanMetaOptics"
com.meta.wearable.dat.core.types.DeviceType.UNKNOWN -> "unknown"
else -> "unknown"
}
}

/** Canonical link-state code, kept identical to the iOS side. */
private fun linkStateCode(state: com.meta.wearable.dat.core.types.LinkState): String {
return when (state) {
com.meta.wearable.dat.core.types.LinkState.DISCONNECTED -> "disconnected"
com.meta.wearable.dat.core.types.LinkState.CONNECTING -> "connecting"
com.meta.wearable.dat.core.types.LinkState.CONNECTED -> "connected"
else -> "unknown"
}
}

/** Canonical compatibility code, kept identical to the iOS side. */
private fun compatibilityCode(
compatibility: com.meta.wearable.dat.core.types.DeviceCompatibility
): String {
return when (compatibility) {
com.meta.wearable.dat.core.types.DeviceCompatibility.UNDEFINED -> "undefined"
com.meta.wearable.dat.core.types.DeviceCompatibility.COMPATIBLE -> "compatible"
com.meta.wearable.dat.core.types.DeviceCompatibility.DEVICE_UPDATE_REQUIRED ->
"deviceUpdateRequired"
com.meta.wearable.dat.core.types.DeviceCompatibility.SDK_UPDATE_REQUIRED ->
"sdkUpdateRequired"
else -> "undefined"
}
}

private fun getRegistrationState(result: Result) {
try {
ensureWearablesInitialized()
Expand Down Expand Up @@ -964,10 +1087,17 @@ class MetaWearablesDatPlugin :
}
}

// Capture the auto-selector's current pick *before* createSession so a
// later A→B switch can't misattribute which device this session bound.
val candidate = deviceSelector.activeDevice()?.identifier
var created: DeviceSession? = null
Wearables.createSession(deviceSelector)
.onSuccess { created = it }
.onSuccess {
created = it
sessionDeviceId = candidate
}
.onFailure { error, _ ->
sessionDeviceId = null
val identifier = error.description.lowercase()
val code =
when {
Expand Down Expand Up @@ -1179,6 +1309,7 @@ class MetaWearablesDatPlugin :
Log.w(TAG, "Error stopping session: ${e.message}")
}
session = null
sessionDeviceId = null
}

// endregion
Expand Down
126 changes: 75 additions & 51 deletions example/lib/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import 'package:flutter_meta_wearables_dat_example/providers/stream_provider.dar
import 'package:flutter_meta_wearables_dat_example/screens/home/home_screen.dart';
import 'package:flutter_meta_wearables_dat_example/screens/mock_device/mock_device_sheet.dart';
import 'package:flutter_meta_wearables_dat_example/screens/settings/settings_sheet.dart';
import 'package:flutter_meta_wearables_dat_example/screens/stream/paired_devices_sheet.dart';
import 'package:flutter_meta_wearables_dat_example/screens/stream/stream_screen.dart';
import 'package:provider/provider.dart';

Expand Down Expand Up @@ -114,60 +115,83 @@ class _MyAppState extends State<MyApp> with WidgetsBindingObserver {
child: MaterialApp(
navigatorKey: navigatorKey,
home: Builder(
builder: (context) =>
Consumer2<DeviceProvider, StreamSessionProvider>(
builder: (context) => Consumer2<DeviceProvider, StreamSessionProvider>(
builder: (context, deviceProvider, streamProvider, _) => Scaffold(
floatingActionButtonLocation: FloatingActionButtonLocation.endTop,
floatingActionButton: streamProvider.isStreaming
? null
: Padding(
padding: const EdgeInsets.only(top: 8, right: 5),
child: Row(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.end,
children: [
if (deviceProvider.isRegistered)
Padding(
padding: const EdgeInsets.only(right: 5),
child: FloatingActionButton.small(
heroTag: 'settings',
backgroundColor: Colors.blueAccent,
tooltip: 'Settings',
onPressed: () {
HapticFeedback.lightImpact();
showModalBottomSheet<void>(
isScrollControlled: true,
context: context,
builder: (ctx) => const SettingsSheet(),
);
},
child: const Icon(
Icons.settings,
color: Colors.white,
),
),
),
if (!deviceProvider.isRegistered)
FloatingActionButton.small(
heroTag: 'mock_device',
backgroundColor: Colors.blueAccent,
tooltip: 'Mock device',
onPressed: () {
HapticFeedback.lightImpact();
showModalBottomSheet<void>(
isScrollControlled: true,
context: context,
builder: (ctx) => const MockDeviceSheet(),
);
},
child: const Icon(
Icons.bug_report,
color: Colors.white,
),
),
],
floatingActionButton: Padding(
padding: const EdgeInsets.only(top: 8, right: 5),
child: Row(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.end,
children: [
// Paired devices — visible whenever registered, including
// while streaming, so the "Streaming" badge stays reachable.
if (deviceProvider.isRegistered)
Padding(
padding: const EdgeInsets.only(right: 5),
child: FloatingActionButton.small(
heroTag: 'devices',
backgroundColor: Colors.blueAccent,
tooltip: 'Paired devices',
onPressed: () {
HapticFeedback.lightImpact();
showModalBottomSheet<void>(
isScrollControlled: true,
context: context,
builder: (ctx) => const PairedDevicesSheet(),
);
},
child: const Icon(
Icons.devices,
color: Colors.white,
),
),
),
),
// Settings — hidden while streaming to keep the video clear.
if (!streamProvider.isStreaming &&
deviceProvider.isRegistered)
Padding(
padding: const EdgeInsets.only(right: 5),
child: FloatingActionButton.small(
heroTag: 'settings',
backgroundColor: Colors.blueAccent,
tooltip: 'Settings',
onPressed: () {
HapticFeedback.lightImpact();
showModalBottomSheet<void>(
isScrollControlled: true,
context: context,
builder: (ctx) => const SettingsSheet(),
);
},
child: const Icon(
Icons.settings,
color: Colors.white,
),
),
),
if (!streamProvider.isStreaming &&
!deviceProvider.isRegistered)
FloatingActionButton.small(
heroTag: 'mock_device',
backgroundColor: Colors.blueAccent,
tooltip: 'Mock device',
onPressed: () {
HapticFeedback.lightImpact();
showModalBottomSheet<void>(
isScrollControlled: true,
context: context,
builder: (ctx) => const MockDeviceSheet(),
);
},
child: const Icon(
Icons.bug_report,
color: Colors.white,
),
),
],
),
),
body:
Consumer3<
DeviceProvider,
Expand Down
Loading
Loading