diff --git a/README.md b/README.md index 6ea7f14..c2798fe 100644 --- a/README.md +++ b/README.md @@ -216,6 +216,29 @@ Connection failures are surfaced as typed `MidiConnectionException` subclasses w Pass `awaitConnectionTimeout: null` only if you explicitly want to let the required readiness flow wait indefinitely. The optional Apple CoreMIDI handoff remains bounded. +### Filtering discovered BLE devices + +BLE scanning is already constrained to the BLE MIDI service, so non-MIDI +peripherals never reach `devices`. To narrow discovery further — for example to +one vendor's hardware — use the UUIDs the peripheral advertised alongside the +MIDI service: + +```dart +const vendorService = '0000fe59-0000-1000-8000-00805f9b34fb'; + +final devices = await midi.devices ?? const []; +final vendorDevices = + devices.where((d) => d.serviceUUIDs.contains(vendorService)); +``` + +`MidiDevice.serviceUUIDs` holds lowercase 128-bit UUID strings. It is populated +only for devices discovered over the Dart BLE transport, and is empty for +host-native devices — including Bluetooth devices routed through host MIDI APIs +such as CoreMIDI (see "Host-paired Bluetooth MIDI devices may be native-routed"). +Some peripherals split their advertisement and scan response, so a device may be +reported before its full UUID list is known; treat the value as best-effort and +re-read it on `MidiSetupChange` events. + ### Setup change events Listen to `onMidiSetupChanged` to refresh your device list when the host MIDI topology changes. Native desktop/mobile transports monitor platform setup notifications and emit `MidiSetupChange` values: diff --git a/packages/flutter_midi_command_ble/lib/flutter_midi_command_ble.dart b/packages/flutter_midi_command_ble/lib/flutter_midi_command_ble.dart index 5dd6563..8bca639 100644 --- a/packages/flutter_midi_command_ble/lib/flutter_midi_command_ble.dart +++ b/packages/flutter_midi_command_ble/lib/flutter_midi_command_ble.dart @@ -20,6 +20,15 @@ bool _isPairingInfoRemoved(Object error) => error is UniversalBleException && error.message.toLowerCase().contains('pairing information'); +/// Advertised service UUIDs as lowercase 128-bit strings, dropping any entry +/// that is not a valid UUID. The pigeon-backed platforms already normalize, but +/// the Linux and Web implementations pass advertisement UUIDs through untouched, +/// so do it here to keep [MidiDevice.serviceUUIDs] comparable everywhere. +List _normalizeServiceUuids(List uuids) => uuids + .map(BleUuidParser.stringOrNull) + .whereType() + .toList(growable: false); + enum _BleHandlerState { header, timestamp, @@ -67,9 +76,16 @@ class UniversalBleMidiTransport implements MidiBleTransport { if (result.name == null) { return; } + final advertisedServices = _normalizeServiceUuids(result.services); final existing = _devices[result.deviceId]; if (existing != null) { existing.name = result.name!; + // Advertisement and scan-response packets arrive separately and only one + // of them carries the service list, so an empty list means "not in this + // packet" rather than "no longer advertised". Keep what we already saw. + if (advertisedServices.isNotEmpty) { + existing.serviceUUIDs = advertisedServices; + } if (!existing.visible) { existing.visible = true; _setupStreamController.add(MidiSetupChange.deviceAppeared); @@ -80,7 +96,7 @@ class UniversalBleMidiTransport implements MidiBleTransport { deviceId: result.deviceId, name: result.name!, rxStream: _rxStreamController, - ); + )..serviceUUIDs = advertisedServices; _setupStreamController.add(MidiSetupChange.deviceAppeared); }; diff --git a/packages/flutter_midi_command_ble/test/flutter_midi_command_ble_test.dart b/packages/flutter_midi_command_ble/test/flutter_midi_command_ble_test.dart index 1f531c3..644d8d1 100644 --- a/packages/flutter_midi_command_ble/test/flutter_midi_command_ble_test.dart +++ b/packages/flutter_midi_command_ble/test/flutter_midi_command_ble_test.dart @@ -543,6 +543,85 @@ void main() { expect(device.connected, isFalse); }); + group('advertised service UUIDs', () { + const vendorService = '0000fe59-0000-1000-8000-00805f9b34fb'; + + test('are exposed on the discovered device', () async { + fakePlatform.emitScanDevice( + BleDevice( + deviceId: 'ble-uuid', + name: 'UUID Device', + services: [midiServiceId, vendorService], + ), + ); + + final device = (await transport.devices).single; + + expect(device.serviceUUIDs, [ + midiServiceId.toLowerCase(), + vendorService, + ]); + }); + + test('are normalized to lowercase 128-bit form', () async { + fakePlatform.emitScanDevice( + BleDevice( + deviceId: 'ble-short-uuid', + name: 'Short UUID Device', + services: ['180D', 'not-a-uuid'], + ), + ); + + final device = (await transport.devices).single; + + expect(device.serviceUUIDs, [ + '0000180d-0000-1000-8000-00805f9b34fb', + ]); + }); + + test('survive an advertisement that carries no service list', () async { + fakePlatform.emitScanDevice( + BleDevice( + deviceId: 'ble-uuid', + name: 'UUID Device', + services: [vendorService], + ), + ); + fakePlatform.emitScanDevice( + BleDevice( + deviceId: 'ble-uuid', + name: 'UUID Device', + services: [], + ), + ); + + final device = (await transport.devices).single; + + expect(device.serviceUUIDs, [vendorService]); + }); + + test('are replaced when a later advertisement lists others', () async { + fakePlatform.emitScanDevice( + BleDevice( + deviceId: 'ble-uuid', + name: 'UUID Device', + services: [vendorService], + ), + ); + fakePlatform.emitScanDevice( + BleDevice( + deviceId: 'ble-uuid', + name: 'UUID Device', + services: [midiServiceId], + ), + ); + + final device = (await transport.devices).single; + + expect(device.serviceUUIDs, [midiServiceId.toLowerCase()]); + }); + }); + test('teardown unregisters callbacks and can be reactivated', () async { expect(fakePlatform.onScanResultUpdate, isNotNull); expect(fakePlatform.onConnectionChange, isNotNull); diff --git a/packages/flutter_midi_command_platform_interface/lib/midi_device.dart b/packages/flutter_midi_command_platform_interface/lib/midi_device.dart index 2d97caf..4a7c2ca 100644 --- a/packages/flutter_midi_command_platform_interface/lib/midi_device.dart +++ b/packages/flutter_midi_command_platform_interface/lib/midi_device.dart @@ -57,6 +57,14 @@ class MidiDevice { /// Ports that send MIDI data out of this device. List outputPorts = []; + + /// Service UUIDs advertised by this device, as lowercase 128-bit strings. + /// + /// Populated for devices discovered over the Dart BLE transport, where it can + /// be used to narrow discovery to specific hardware. Empty for host-native + /// devices, including Bluetooth devices routed through host MIDI APIs such as + /// CoreMIDI. + List serviceUUIDs = const []; final StreamController _connectionStateController = StreamController.broadcast(); MidiConnectionState _connectionState; diff --git a/packages/flutter_midi_command_platform_interface/test/method_channel_midi_command_test.dart b/packages/flutter_midi_command_platform_interface/test/method_channel_midi_command_test.dart index e602678..2b98cbf 100644 --- a/packages/flutter_midi_command_platform_interface/test/method_channel_midi_command_test.dart +++ b/packages/flutter_midi_command_platform_interface/test/method_channel_midi_command_test.dart @@ -107,6 +107,9 @@ void main() { expect(devices.first.outputPorts.first.id, 1); expect(devices.first.outputPorts.first.type, MidiPortType.OUT); expect(devices.first.outputPorts.first.connected, isFalse); + // Service UUIDs come from the Dart BLE transport only; the host device + // channel carries none. + expect(devices.first.serviceUUIDs, isEmpty); }); test('devices prunes stale cached devices when host list changes', () async {