699 lines
21 KiB
Dart
699 lines
21 KiB
Dart
import 'dart:async';
|
|
import 'dart:convert';
|
|
|
|
import 'package:bluetooth_low_energy/bluetooth_low_energy.dart';
|
|
import 'package:convert/convert.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter/services.dart';
|
|
|
|
CentralManager get central => CentralManager.instance;
|
|
|
|
void main() {
|
|
const app = MyApp();
|
|
runApp(app);
|
|
}
|
|
|
|
class MyApp extends StatelessWidget {
|
|
const MyApp({super.key});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return MaterialApp(
|
|
home: const HomeView(),
|
|
routes: {
|
|
'device': (context) {
|
|
final peripheral =
|
|
ModalRoute.of(context)?.settings.arguments as Peripheral;
|
|
return DeviceView(
|
|
peripheral: peripheral,
|
|
);
|
|
},
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
class HomeView extends StatefulWidget {
|
|
const HomeView({Key? key}) : super(key: key);
|
|
|
|
@override
|
|
State<HomeView> createState() => _HomeViewState();
|
|
}
|
|
|
|
class _HomeViewState extends State<HomeView> {
|
|
late ValueNotifier<bool> state;
|
|
late ValueNotifier<bool> discovering;
|
|
late ValueNotifier<List<Broadcast>> broadcasts;
|
|
late StreamSubscription<BluetoothState> stateStreamSubscription;
|
|
late StreamSubscription<Broadcast> broadcastStreamSubscription;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
|
|
state = ValueNotifier(false);
|
|
discovering = ValueNotifier(false);
|
|
broadcasts = ValueNotifier([]);
|
|
|
|
state.addListener(onStateChanged);
|
|
stateStreamSubscription = central.stateChanged.listen(
|
|
(state) => this.state.value = state == BluetoothState.poweredOn,
|
|
);
|
|
broadcastStreamSubscription = central.scanned.listen(
|
|
(broadcast) {
|
|
final broadcasts = this.broadcasts.value;
|
|
final i = broadcasts.indexWhere(
|
|
(element) => element.peripheral.uuid == broadcast.peripheral.uuid,
|
|
);
|
|
if (i < 0) {
|
|
this.broadcasts.value = [...broadcasts, broadcast];
|
|
} else {
|
|
broadcasts[i] = broadcast;
|
|
this.broadcasts.value = [...broadcasts];
|
|
}
|
|
},
|
|
);
|
|
setup();
|
|
}
|
|
|
|
void setup() async {
|
|
final authorized = await central.authorize();
|
|
if (!authorized) {
|
|
throw UnimplementedError();
|
|
}
|
|
final state = await central.state;
|
|
this.state.value = state == BluetoothState.poweredOn;
|
|
}
|
|
|
|
void onStateChanged() {
|
|
final route = ModalRoute.of(context);
|
|
if (route == null || !route.isCurrent) {
|
|
return;
|
|
}
|
|
if (state.value) {
|
|
startScan();
|
|
} else {
|
|
discovering.value = false;
|
|
}
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
stopScan();
|
|
state.removeListener(onStateChanged);
|
|
stateStreamSubscription.cancel();
|
|
broadcastStreamSubscription.cancel();
|
|
|
|
state.dispose();
|
|
discovering.dispose();
|
|
broadcasts.dispose();
|
|
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Scaffold(
|
|
appBar: AppBar(
|
|
title: const Text('Home'),
|
|
),
|
|
body: buildBody(context),
|
|
);
|
|
}
|
|
|
|
void startScan() async {
|
|
if (discovering.value || !state.value) {
|
|
return;
|
|
}
|
|
await central.startScan();
|
|
discovering.value = true;
|
|
}
|
|
|
|
void stopScan() async {
|
|
if (!discovering.value || !state.value) {
|
|
return;
|
|
}
|
|
await central.stopScan();
|
|
broadcasts.value = [];
|
|
discovering.value = false;
|
|
}
|
|
|
|
void showBroadcast(Broadcast advertisement) {
|
|
showModalBottomSheet(
|
|
context: context,
|
|
backgroundColor: Colors.transparent,
|
|
elevation: 0.0,
|
|
builder: (context) => buildBroadcastView(advertisement),
|
|
);
|
|
}
|
|
|
|
void showDeviceView(Peripheral peripheral) async {
|
|
stopScan();
|
|
await Navigator.of(context).pushNamed(
|
|
'device',
|
|
arguments: peripheral,
|
|
);
|
|
startScan();
|
|
}
|
|
}
|
|
|
|
extension on _HomeViewState {
|
|
Widget buildBody(BuildContext context) {
|
|
return ValueListenableBuilder(
|
|
valueListenable: state,
|
|
builder: (context, bool state, child) {
|
|
return state ? buildBroadcastsView(context) : buildClosedView(context);
|
|
},
|
|
);
|
|
}
|
|
|
|
Widget buildClosedView(BuildContext context) {
|
|
return const Center(
|
|
child: Text('蓝牙未开启'),
|
|
);
|
|
}
|
|
|
|
Widget buildBroadcastsView(BuildContext context) {
|
|
return RefreshIndicator(
|
|
onRefresh: () async => broadcasts.value = [],
|
|
child: ValueListenableBuilder(
|
|
valueListenable: broadcasts,
|
|
builder: (context, List<Broadcast> broadcasts, child) {
|
|
return ListView.builder(
|
|
padding: const EdgeInsets.all(6.0),
|
|
itemCount: broadcasts.length,
|
|
itemBuilder: (context, i) {
|
|
final broadcast = broadcasts.elementAt(i);
|
|
final connectable = broadcast.connectable ?? true;
|
|
return Card(
|
|
color: connectable ? Colors.amber : Colors.grey,
|
|
clipBehavior: Clip.antiAlias,
|
|
shape: const BeveledRectangleBorder(
|
|
borderRadius: BorderRadius.only(
|
|
topRight: Radius.circular(12.0),
|
|
bottomLeft: Radius.circular(12.0),
|
|
),
|
|
),
|
|
margin: const EdgeInsets.all(6.0),
|
|
key: Key(broadcast.peripheral.uuid.value),
|
|
child: InkWell(
|
|
splashColor: Colors.purple,
|
|
onTap: connectable
|
|
? () => showDeviceView(broadcast.peripheral)
|
|
: null,
|
|
onLongPress: () => showBroadcast(broadcast),
|
|
child: Container(
|
|
height: 100.0,
|
|
padding: const EdgeInsets.all(12.0),
|
|
child: Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
|
children: [
|
|
Expanded(
|
|
flex: 3,
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
Text(broadcast.localName ?? 'UNKNOWN'),
|
|
Text(
|
|
broadcast.peripheral.uuid.value,
|
|
softWrap: true,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
Expanded(
|
|
flex: 1,
|
|
child: Text(
|
|
broadcast.rssi.toString(),
|
|
textAlign: TextAlign.center,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
},
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget buildBroadcastView(Broadcast advertisement) {
|
|
final widgets = <Widget>[
|
|
Row(
|
|
children: const [
|
|
Text('Type'),
|
|
Expanded(
|
|
child: Center(
|
|
child: Text('Value'),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const Divider(),
|
|
];
|
|
// for (final entry in advertisement.data.entries) {
|
|
// final type = '0x${entry.key.toRadixString(16).padLeft(2, '0')}';
|
|
// final value = hex.encode(entry.value);
|
|
// final widget = Row(
|
|
// children: [
|
|
// Text(type),
|
|
// Container(width: 12.0),
|
|
// Expanded(
|
|
// child: Text(
|
|
// value,
|
|
// softWrap: true,
|
|
// ),
|
|
// ),
|
|
// ],
|
|
// );
|
|
// widgets.add(widget);
|
|
// if (entry.key != advertisement.data.entries.last.key) {
|
|
// const divider = Divider();
|
|
// widgets.add(divider);
|
|
// }
|
|
// }
|
|
return Container(
|
|
margin: const EdgeInsets.all(12.0),
|
|
child: Material(
|
|
elevation: 1.0,
|
|
shape: const BeveledRectangleBorder(
|
|
borderRadius: BorderRadius.only(
|
|
topLeft: Radius.circular(12.0),
|
|
bottomRight: Radius.circular(12.0),
|
|
),
|
|
),
|
|
clipBehavior: Clip.antiAlias,
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(12.0),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: widgets,
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class DeviceView extends StatefulWidget {
|
|
final Peripheral peripheral;
|
|
|
|
const DeviceView({
|
|
Key? key,
|
|
required this.peripheral,
|
|
}) : super(key: key);
|
|
|
|
@override
|
|
State<DeviceView> createState() => _DeviceViewState();
|
|
}
|
|
|
|
class _DeviceViewState extends State<DeviceView> {
|
|
late StreamSubscription<Exception> connectionLostStreamSubscription;
|
|
late Map<GattService, List<GattCharacteristic>> services;
|
|
late ValueNotifier<ConnectionState> state;
|
|
late ValueNotifier<GattService?> service;
|
|
late ValueNotifier<GattCharacteristic?> characteristic;
|
|
late TextEditingController writeController;
|
|
late ValueNotifier<Map<GattCharacteristic, StreamSubscription>> notifies;
|
|
late ValueNotifier<List<String>> logs;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
|
|
connectionLostStreamSubscription = widget.peripheral.connectionLost.listen(
|
|
(error) {
|
|
for (var subscription in notifies.value.values) {
|
|
subscription.cancel();
|
|
}
|
|
service.value = null;
|
|
characteristic.value = null;
|
|
notifies.value.clear();
|
|
logs.value.clear();
|
|
state.value = ConnectionState.disconnected;
|
|
},
|
|
);
|
|
services = {};
|
|
state = ValueNotifier(ConnectionState.disconnected);
|
|
service = ValueNotifier(null);
|
|
characteristic = ValueNotifier(null);
|
|
writeController = TextEditingController();
|
|
notifies = ValueNotifier({});
|
|
logs = ValueNotifier([]);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Scaffold(
|
|
appBar: AppBar(
|
|
title: Text(widget.peripheral.uuid.value),
|
|
actions: [
|
|
buildConnectionState(context),
|
|
],
|
|
),
|
|
body: buildBody(context),
|
|
);
|
|
}
|
|
|
|
void Function()? disposeListener;
|
|
|
|
@override
|
|
void dispose() {
|
|
super.dispose();
|
|
|
|
if (state.value != ConnectionState.disconnected) {
|
|
disposeListener ??= () => disposeGATT();
|
|
state.addListener(disposeListener!);
|
|
if (state.value == ConnectionState.connected) {
|
|
disconnect();
|
|
}
|
|
} else {
|
|
connectionLostStreamSubscription.cancel();
|
|
state.dispose();
|
|
service.dispose();
|
|
characteristic.dispose();
|
|
notifies.dispose();
|
|
logs.dispose();
|
|
}
|
|
}
|
|
|
|
void disposeGATT() {
|
|
switch (state.value) {
|
|
case ConnectionState.connected:
|
|
disconnect();
|
|
break;
|
|
case ConnectionState.disconnected:
|
|
state.removeListener(disposeListener!);
|
|
connectionLostStreamSubscription.cancel();
|
|
state.dispose();
|
|
service.dispose();
|
|
characteristic.dispose();
|
|
notifies.dispose();
|
|
logs.dispose();
|
|
break;
|
|
default:
|
|
break;
|
|
}
|
|
}
|
|
|
|
void connect() async {
|
|
try {
|
|
state.value = ConnectionState.connecting;
|
|
await widget.peripheral.connect();
|
|
try {
|
|
final items0 = <GattService, List<GattCharacteristic>>{};
|
|
final services = await widget.peripheral.discoverServices();
|
|
for (var service in services) {
|
|
final items1 = <GattCharacteristic>[];
|
|
final characteristics = await service.discoverCharacteristics();
|
|
for (var characteristic in characteristics) {
|
|
items1.add(characteristic);
|
|
}
|
|
items0[service] = items1;
|
|
}
|
|
this.services = items0;
|
|
} catch (e) {
|
|
widget.peripheral.disconnect();
|
|
rethrow;
|
|
}
|
|
state.value = ConnectionState.connected;
|
|
} catch (error) {
|
|
state.value = ConnectionState.disconnected;
|
|
}
|
|
}
|
|
|
|
void disconnect() async {
|
|
try {
|
|
state.value = ConnectionState.disconnecting;
|
|
for (var subscription in notifies.value.values) {
|
|
subscription.cancel();
|
|
}
|
|
await widget.peripheral.disconnect();
|
|
services = {};
|
|
service.value = null;
|
|
characteristic.value = null;
|
|
notifies.value.clear();
|
|
logs.value.clear();
|
|
state.value = ConnectionState.disconnected;
|
|
} catch (e) {
|
|
state.value = ConnectionState.connected;
|
|
}
|
|
}
|
|
}
|
|
|
|
extension on _DeviceViewState {
|
|
Widget buildConnectionState(BuildContext context) {
|
|
return ValueListenableBuilder(
|
|
valueListenable: state,
|
|
builder: (context, ConnectionState state, child) {
|
|
void Function()? onPressed;
|
|
String data;
|
|
switch (state) {
|
|
case ConnectionState.disconnected:
|
|
onPressed = connect;
|
|
data = '连接';
|
|
break;
|
|
case ConnectionState.connecting:
|
|
data = '连接';
|
|
break;
|
|
case ConnectionState.connected:
|
|
onPressed = disconnect;
|
|
data = '断开';
|
|
break;
|
|
case ConnectionState.disconnecting:
|
|
data = '断开';
|
|
break;
|
|
default:
|
|
data = '';
|
|
break;
|
|
}
|
|
return TextButton(
|
|
onPressed: onPressed,
|
|
style: TextButton.styleFrom(
|
|
foregroundColor: Colors.white,
|
|
),
|
|
child: Text(data),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
Widget buildBody(BuildContext context) {
|
|
return ValueListenableBuilder(
|
|
valueListenable: state,
|
|
builder: (context, ConnectionState stateValue, child) {
|
|
switch (stateValue) {
|
|
case ConnectionState.disconnected:
|
|
return buildDisconnectedView(context);
|
|
case ConnectionState.connecting:
|
|
return buildConnectingView(context);
|
|
case ConnectionState.connected:
|
|
return buildConnectedView(context);
|
|
case ConnectionState.disconnecting:
|
|
return buildDisconnectingView(context);
|
|
default:
|
|
throw UnimplementedError();
|
|
}
|
|
},
|
|
);
|
|
}
|
|
|
|
Widget buildDisconnectedView(BuildContext context) {
|
|
return const Center(
|
|
child: Text('Disconnected'),
|
|
);
|
|
}
|
|
|
|
Widget buildConnectingView(BuildContext context) {
|
|
return const Center(
|
|
child: Text('Connecting'),
|
|
);
|
|
}
|
|
|
|
Widget buildConnectedView(BuildContext context) {
|
|
return ValueListenableBuilder(
|
|
valueListenable: service,
|
|
builder: (context, GattService? service, child) {
|
|
final services = this.services.keys.map((service) {
|
|
return DropdownMenuItem<GattService>(
|
|
value: service,
|
|
child: Text(
|
|
service.uuid.toString(),
|
|
softWrap: false,
|
|
),
|
|
);
|
|
}).toList();
|
|
final serviceView = DropdownButton<GattService>(
|
|
isExpanded: true,
|
|
hint: const Text('Choose a service'),
|
|
value: service,
|
|
items: services,
|
|
onChanged: (service) {
|
|
this.service.value = service;
|
|
characteristic.value = null;
|
|
},
|
|
);
|
|
final views = <Widget>[serviceView];
|
|
if (service != null) {
|
|
final characteristics = this.services[service]?.map((characteristic) {
|
|
return DropdownMenuItem(
|
|
value: characteristic,
|
|
child: Text(
|
|
characteristic.uuid.toString(),
|
|
softWrap: false,
|
|
),
|
|
);
|
|
}).toList();
|
|
final characteristicView = ValueListenableBuilder(
|
|
valueListenable: characteristic,
|
|
builder: (context, GattCharacteristic? characteristic, child) {
|
|
final canWrite = characteristic != null &&
|
|
(characteristic.canWrite ||
|
|
characteristic.canWriteWithoutResponse);
|
|
final canRead = characteristic != null && characteristic.canRead;
|
|
final canNotify =
|
|
characteristic != null && characteristic.canNotify;
|
|
final readAndNotifyView = Row(
|
|
mainAxisAlignment: MainAxisAlignment.end,
|
|
children: [
|
|
IconButton(
|
|
onPressed: canRead
|
|
? () async {
|
|
final value = await characteristic.read();
|
|
final time = DateTime.now().display;
|
|
final log = '[$time][READ] ${hex.encode(value)}';
|
|
logs.value = [...logs.value, log];
|
|
}
|
|
: null,
|
|
icon: const Icon(Icons.archive),
|
|
),
|
|
ValueListenableBuilder(
|
|
valueListenable: notifies,
|
|
builder: (context,
|
|
Map<GattCharacteristic, StreamSubscription>
|
|
notifiesValue,
|
|
child) {
|
|
final notifying =
|
|
notifiesValue.containsKey(characteristic);
|
|
return IconButton(
|
|
onPressed: canNotify
|
|
? () async {
|
|
if (notifying) {
|
|
await notifiesValue
|
|
.remove(characteristic)!
|
|
.cancel();
|
|
await characteristic.setNotify(false);
|
|
} else {
|
|
notifiesValue[characteristic] =
|
|
characteristic.valueChanged
|
|
.listen((value) {
|
|
final time = DateTime.now().display;
|
|
final log =
|
|
'[$time][NOTIFY] ${hex.encode(value)}';
|
|
logs.value = [...logs.value, log];
|
|
});
|
|
await characteristic.setNotify(true);
|
|
}
|
|
notifies.value = {...notifiesValue};
|
|
}
|
|
: null,
|
|
icon: Icon(
|
|
Icons.notifications,
|
|
color: notifying ? Colors.blue : null,
|
|
),
|
|
);
|
|
}),
|
|
],
|
|
);
|
|
final controllerView = TextField(
|
|
controller: writeController,
|
|
decoration: InputDecoration(
|
|
// hintText: 'MTU: ${peripheral.maximumWriteLength}',
|
|
suffixIcon: IconButton(
|
|
onPressed: canWrite
|
|
? () {
|
|
final elements = utf8.encode(writeController.text);
|
|
final value = Uint8List.fromList(elements);
|
|
characteristic.write(
|
|
value,
|
|
withoutResponse: !characteristic.canWrite,
|
|
);
|
|
}
|
|
: null,
|
|
icon: const Icon(Icons.send),
|
|
),
|
|
),
|
|
);
|
|
return Column(
|
|
children: [
|
|
DropdownButton<GattCharacteristic>(
|
|
isExpanded: true,
|
|
hint: const Text('Choose a characteristic'),
|
|
value: characteristic,
|
|
items: characteristics,
|
|
onChanged: (characteristic) {
|
|
this.characteristic.value = characteristic;
|
|
},
|
|
),
|
|
readAndNotifyView,
|
|
controllerView,
|
|
],
|
|
);
|
|
},
|
|
);
|
|
views.add(characteristicView);
|
|
}
|
|
final loggerView = Expanded(
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: 12.0),
|
|
child: ValueListenableBuilder(
|
|
valueListenable: logs,
|
|
builder: (context, List<String> logsValue, child) {
|
|
return ListView.builder(
|
|
itemCount: logsValue.length,
|
|
itemBuilder: (context, i) {
|
|
final log = logsValue[i];
|
|
return Text(log);
|
|
},
|
|
);
|
|
}),
|
|
),
|
|
);
|
|
views.add(loggerView);
|
|
return Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 12.0),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: views,
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
Widget buildDisconnectingView(BuildContext context) {
|
|
return const Center(
|
|
child: Text('Disconnecting'),
|
|
);
|
|
}
|
|
}
|
|
|
|
extension on DateTime {
|
|
String get display {
|
|
final hh = hour.toString().padLeft(2, '0');
|
|
final mm = minute.toString().padLeft(2, '0');
|
|
final ss = second.toString().padLeft(2, '0');
|
|
return '$hh:$mm:$ss';
|
|
}
|
|
}
|
|
|
|
enum ConnectionState {
|
|
disconnected,
|
|
connecting,
|
|
connected,
|
|
disconnecting,
|
|
}
|