Flutter读表测试 - 通过蓝牙光电头读取支持IEC62056-21规约的电表测试程序
1.环境准备
- 安装[Flutter SDK]环境(https://flutter.dev/docs/get-started/install)
2.程序说明
概述
本程序演示了如何使用 Flutter 和 BLE 技术与 OPBT 设备进行 IEC 协议通信。程序实现了设备扫描、连接和数据交换的完整流程。
主要功能
- 扫描附近的蓝牙设备
- 连接到选定的 OPBT 设备
- 使用 IEC 协议与设备通信
- 实时显示接收到的数据
- 支持数据清除和持续监控
技术要点
- 蓝牙通信:使用 flutter_blue_plus 库实现
- 权限管理:使用 permission_handler 库处理
- 数据协议:实现 IEC 协议的电表读取
- 异步编程:使用 async/await 处理蓝牙操作
- 实时数据显示:持续监控和显示数据
通信协议
程序遵循 IEC 协议进行电表读取:
- 握手命令:
/?!\r\n
- 设备响应例如:
/LGZ5yyyyyyyyyyyyyyyyyyy\r\n
(其中'5’为标识符) - 数据请求命令:
0x06 0x30 [标识符] 0x30 0x0D 0x0A
- 数据响应:来自电表的连续数据流
开发注意事项
-
UUID 配置:
- 服务 UUID:“18F0”
- 通知特征 UUID:“2AF0”
- 写入特征 UUID:“2AF1”
-
通信流程:
- 建立连接
- 发送握手命令
- 接收并验证响应
- 发送数据请求命令
- 持续接收和显示数据
-
重要考虑事项:
- 命令之间的适当时间间隔
- 数据格式验证
- 通信失败的错误处理
- 连续数据的缓冲区管理
-
Android 要求:
- AndroidManifest.xml 中的蓝牙权限
- 蓝牙扫描的位置权限
- Android 12+ 特定权限
扩展开发
本程序可以扩展用于:
- 读取额外的电表参数
- 数据记录和分析
- 多设备管理
- 自定义命令实现
- 数据导出功能
界面组件
-
设备列表区域:
- 设备扫描控制
- 设备列表显示
- 连接状态
-
数据显示区域:
- 实时数据显示
- 数据清除选项
- 自动滚动到最新数据
错误处理
程序包含全面的错误处理,包括:
- 连接失败
- 通信超时
- 无效数据格式
- 权限问题
- 设备断开连接
程序部分代码说明
- 与蓝牙有关的服务
// UUIDs for Bluetooth service and characteristics
final String SERVICE_UUID = "18F0"; // Service UUID
final String CHARACTERISTIC_UUID_NOTIFY = "2AF0"; // Notification characteristic UUID
final String CHARACTERISTIC_UUID_WRITE = "2AF1"; // Write characteristic UUID
- 程序运行后,首先要申请蓝牙服务相关的权限
/// Request Bluetooth and location permissions
/// These permissions are required for Bluetooth scanning and connection
Future<void> _requestPermissions() async {
await Permission.bluetooth.request();
await Permission.bluetoothScan.request();
await Permission.bluetoothConnect.request();
await Permission.location.request();
}
- 蓝牙扫描相关代码
/// Start scanning for Bluetooth devices
/// Use flutter_blue_plus library to scan for nearby Bluetooth devices
void startScan() async {
setState(() {
scanResults.clear();
isScanning = true;
});
// Ensure Bluetooth adapter is ready
await FlutterBluePlus.adapterState.first;
// Start scanning with 4 second timeout
await FlutterBluePlus.startScan(timeout: const Duration(seconds: 4));
// Listen for scan results
FlutterBluePlus.scanResults.listen((results) {
setState(() {
scanResults = results;
});
});
// Listen for scan status
FlutterBluePlus.isScanning.listen((scanning) {
setState(() {
isScanning = scanning;
});
});
}
- 蓝牙设备连接相关代码,并在蓝牙设备连接上后,扫描相应的服务,并连接和监听
/// Connect to a Bluetooth device
/// [device] The device to connect to
/// Sets up the connection and starts the communication process
Future<void> connectToDevice(BluetoothDevice device) async {
try {
// Stop any ongoing data reception
await stopReceiving();
// Connect to the device
await device.connect();
// Discover services
List<BluetoothService> services = await device.discoverServices();
var service = services.firstWhere(
(s) => s.uuid.toString().toUpperCase().contains(SERVICE_UUID),
);
// Get characteristics for communication
writeCharacteristic = service.characteristics.firstWhere(
(c) =>
c.uuid.toString().toUpperCase().contains(CHARACTERISTIC_UUID_WRITE),
);
notifyCharacteristic = service.characteristics.firstWhere(
(c) => c.uuid.toString().toUpperCase().contains(
CHARACTERISTIC_UUID_NOTIFY,
),
);
// Enable notifications and start listening
await notifyCharacteristic!.setNotifyValue(true);
// Set up continuous notification listening
dataSubscription = notifyCharacteristic!.value.listen((value) {
if (value.isNotEmpty) {
String data = String.fromCharCodes(value);
// Save received data to buffer
dataBuffer.write(data);
}
});
setState(() {
connectedDevice = device;
receivedData = "Device connected";
});
// Start the communication process
await startCommunication();
} catch (e) {
setState(() {
receivedData = "Connection failed: $e";
});
}
}
- 当已经连接上相应的服务后,发送相应的命令给蓝牙设备,获取当前电池的电压
/// Start communication with the device
/// Implements the IEC protocol communication sequence:
/// 1. Send handshake command
/// 2. Receive and validate response
/// 3. Send data request command
/// 4. Continuously receive data
Future<void> startCommunication() async {
try {
// Send handshake command
bool sent = await sendData(utf8.encode('/?!\r\n'));
if (!sent) {
throw Exception('Failed to send handshake command');
}
await Future.delayed(const Duration(milliseconds: 1000));
// Receive handshake response
String? response = await receiveData();
// Check received data
if (response != null && response.isNotEmpty) {
// Check if first character is '/'
if (response.startsWith('/')) {
// Ensure response length is sufficient to extract 5th character
if (response.length >= 5) {
// Extract 5th character as identifier
String fifthChar = response[4];
// Build and send data request command
List<int> command = [
0x06, // Start of frame
0x30, // Data request
fifthChar.codeUnitAt(0), // Identifier
0x30, // Data request
0x0D, // Carriage return
0x0A, // Line feed
];
bool sent = await sendData(command);
if (!sent) {
throw Exception('Failed to send data request command');
}
// Loop to receive data
for (int i = 0; i < 20000; i++) {
// Receive data with 5 second timeout
String? receivedResponse = await receiveData(
timeout: const Duration(milliseconds: 5000),
);
if (receivedResponse != null && receivedResponse.isNotEmpty) {
// Update UI with received data
setState(() {
receivedData = '$receivedData$receivedResponse';
});
// Scroll to show latest data
_scrollToBottom();
} else {
break;
}
}
} else {
throw Exception('Response length insufficient');
}
} else {
throw Exception('Invalid data format');
}
} else {
throw Exception('No data received');
}
setState(() {
isReceiving = true;
});
} catch (e) {
setState(() {
receivedData = "Communication error: $e";
});
}
}
- 发送数据模块
/// Send data to the connected device
/// [data] The data to send as a list of integers
/// Returns true if the data was sent successfully, false otherwise
Future<bool> sendData(List<int> data) async {
try {
if (writeCharacteristic == null) {
return false;
}
await writeCharacteristic!.write(data);
return true;
} catch (e) {
return false;
}
}
- 接收数据模块
/// Receive data from the device
/// [timeout] Maximum time to wait for data, defaults to 5 seconds
/// Returns the received data as a string, or null if timeout or error occurs
Future<String?> receiveData({
Duration timeout = const Duration(seconds: 5),
}) async {
try {
if (notifyCharacteristic == null) {
return null;
}
Completer<String?> completer = Completer<String?>();
isWaitingForData = true;
// Set up timeout
Timer(timeout, () {
if (!completer.isCompleted) {
isWaitingForData = false;
completer.complete(null);
}
});
// Periodically check for new data
Timer.periodic(const Duration(milliseconds: 100), (timer) {
if (dataBuffer.isNotEmpty) {
String data = dataBuffer.toString();
dataBuffer.clear();
isWaitingForData = false;
timer.cancel();
completer.complete(data);
}
});
return await completer.future;
} catch (e) {
isWaitingForData = false;
return null;
}
}
3.程序代码安装和运行
首先创建一个新的 Flutter 项目:
flutter create opbt_iec_meter_read
cd opbt_iec_meter_read
然后在 opbt_iec_meter_read/pubspec.yaml 中添加依赖:
dependencies:
flutter:
sdk: flutter
flutter_blue_plus: ^1.31.13
permission_handler: ^11.3.0
修改 Android 权限配置: opbt_iec_meter_read/android/app/src/main/AndroidManifest.xml
<manifest xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools">
<!-- 添加蓝牙权限 -->
<uses-permission android:name="android.permission.BLUETOOTH" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/>
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"/>
<!-- Android 12+ 新增蓝牙权限 -->
<uses-permission android:name="android.permission.BLUETOOTH_SCAN"
android:usesPermissionFlags="neverForLocation"
tools:targetApi="S" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<uses-permission android:name="android.permission.BLUETOOTH_ADVERTISE" />
<!-- 声明蓝牙功能 -->
<uses-feature
android:name="android.hardware.bluetooth"
android:required="true" />
<uses-feature
android:name="android.hardware.bluetooth_le"
android:required="true" />
<application
android:label="opbt_iec_meter_read"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:taskAffinity=""
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
<!-- Specifies an Android theme to apply to this Activity as soon as
the Android process has started. This theme is visible to the user
while the Flutter UI initializes. After that, this theme continues
to determine the Window background behind the Flutter UI. -->
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme"
/>
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data
android:name="flutterEmbedding"
android:value="2" />
</application>
<!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
<queries>
<intent>
<action android:name="android.intent.action.PROCESS_TEXT"/>
<data android:mimeType="text/plain"/>
</intent>
</queries>
</manifest>
主程序代码: opbt_iec_meter_read/lib/main.dart
import 'dart:async';
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter_blue_plus/flutter_blue_plus.dart';
import 'package:permission_handler/permission_handler.dart';
/// Application entry point
/// Initializes and runs the Flutter application
void main() {
runApp(const MyApp());
}
/// Root widget of the application
/// Configures the application theme and sets up the home screen
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'OPBT Meter(IEC Protocol) Reader',
theme: ThemeData(primarySwatch: Colors.blue),
home: const DataReadScreen(),
);
}
}
/// Main screen for data reading
/// Handles device scanning, connection, and data display
class DataReadScreen extends StatefulWidget {
const DataReadScreen({super.key});
@override
State<DataReadScreen> createState() => _DataReadScreenState();
}
/// State class for DataReadScreen
/// Manages the application state and implements the main functionality
class _DataReadScreenState extends State<DataReadScreen> {
// List to store discovered Bluetooth devices
List<ScanResult> scanResults = [];
// Currently connected Bluetooth device
BluetoothDevice? connectedDevice;
// String to store and display received data
String receivedData = "Waiting for data...";
// Flag to track scanning status
bool isScanning = false;
// Flag to track data receiving status
bool isReceiving = false;
// Subscription for data notifications
StreamSubscription? dataSubscription;
// Controller for scrolling the data display
final ScrollController _scrollController = ScrollController();
// Bluetooth communication related variables
BluetoothCharacteristic?
writeCharacteristic; // Characteristic for sending data
BluetoothCharacteristic?
notifyCharacteristic; // Characteristic for receiving notifications
// UUIDs for Bluetooth service and characteristics
final String SERVICE_UUID = "18F0"; // Service UUID
final String CHARACTERISTIC_UUID_NOTIFY = "2AF0"; // Notification characteristic UUID
final String CHARACTERISTIC_UUID_WRITE = "2AF1"; // Write characteristic UUID
// Data buffer for storing received data
StringBuffer dataBuffer = StringBuffer();
// Variable to store the last received data
String? lastReceivedData;
// Flag to track if we're waiting for data
bool isWaitingForData = false;
@override
void initState() {
super.initState();
_requestPermissions();
}
/// Request necessary permissions for Bluetooth and location
/// Required for scanning and connecting to Bluetooth devices
Future<void> _requestPermissions() async {
await Permission.bluetooth.request();
await Permission.bluetoothScan.request();
await Permission.bluetoothConnect.request();
await Permission.location.request();
}
/// Start scanning for nearby Bluetooth devices
/// Updates the UI with discovered devices
void startScan() async {
setState(() {
scanResults.clear();
isScanning = true;
});
// Ensure Bluetooth adapter is ready
await FlutterBluePlus.adapterState.first;
// Start scanning with 4 second timeout
await FlutterBluePlus.startScan(timeout: const Duration(seconds: 4));
// Listen for scan results
FlutterBluePlus.scanResults.listen((results) {
setState(() {
scanResults = results;
});
});
// Listen for scan status
FlutterBluePlus.isScanning.listen((scanning) {
setState(() {
isScanning = scanning;
});
});
}
/// Send data to the connected device
/// [data] The data to send as a list of integers
/// Returns true if the data was sent successfully, false otherwise
Future<bool> sendData(List<int> data) async {
try {
if (writeCharacteristic == null) {
return false;
}
await writeCharacteristic!.write(data);
return true;
} catch (e) {
return false;
}
}
/// Receive data from the device
/// [timeout] Maximum time to wait for data, defaults to 5 seconds
/// Returns the received data as a string, or null if timeout or error occurs
Future<String?> receiveData({
Duration timeout = const Duration(seconds: 5),
}) async {
try {
if (notifyCharacteristic == null) {
return null;
}
Completer<String?> completer = Completer<String?>();
isWaitingForData = true;
// Set up timeout
Timer(timeout, () {
if (!completer.isCompleted) {
isWaitingForData = false;
completer.complete(null);
}
});
// Periodically check for new data
Timer.periodic(const Duration(milliseconds: 100), (timer) {
if (dataBuffer.isNotEmpty) {
String data = dataBuffer.toString();
dataBuffer.clear();
isWaitingForData = false;
timer.cancel();
completer.complete(data);
}
});
return await completer.future;
} catch (e) {
isWaitingForData = false;
return null;
}
}
/// Connect to a Bluetooth device
/// [device] The device to connect to
/// Sets up the connection and starts the communication process
Future<void> connectToDevice(BluetoothDevice device) async {
try {
// Stop any ongoing data reception
await stopReceiving();
// Connect to the device
await device.connect();
// Discover services
List<BluetoothService> services = await device.discoverServices();
var service = services.firstWhere(
(s) => s.uuid.toString().toUpperCase().contains(SERVICE_UUID),
);
// Get characteristics for communication
writeCharacteristic = service.characteristics.firstWhere(
(c) =>
c.uuid.toString().toUpperCase().contains(CHARACTERISTIC_UUID_WRITE),
);
notifyCharacteristic = service.characteristics.firstWhere(
(c) => c.uuid.toString().toUpperCase().contains(
CHARACTERISTIC_UUID_NOTIFY,
),
);
// Enable notifications and start listening
await notifyCharacteristic!.setNotifyValue(true);
// Set up continuous notification listening
dataSubscription = notifyCharacteristic!.value.listen((value) {
if (value.isNotEmpty) {
String data = String.fromCharCodes(value);
// Save received data to buffer
dataBuffer.write(data);
}
});
setState(() {
connectedDevice = device;
receivedData = "Device connected";
});
// Start the communication process
await startCommunication();
} catch (e) {
setState(() {
receivedData = "Connection failed: $e";
});
}
}
/// Start communication with the device
/// Implements the IEC protocol communication sequence:
/// 1. Send handshake command
/// 2. Receive and validate response
/// 3. Send data request command
/// 4. Continuously receive data
Future<void> startCommunication() async {
try {
// Send handshake command
bool sent = await sendData(utf8.encode('/?!\r\n'));
if (!sent) {
throw Exception('Failed to send handshake command');
}
await Future.delayed(const Duration(milliseconds: 1000));
// Receive handshake response
String? response = await receiveData();
// Check received data
if (response != null && response.isNotEmpty) {
// Check if first character is '/'
if (response.startsWith('/')) {
// Ensure response length is sufficient to extract 5th character
if (response.length >= 5) {
// Extract 5th character as identifier
String fifthChar = response[4];
// Build and send data request command
List<int> command = [
0x06, // Start of frame
0x30, // Data request
fifthChar.codeUnitAt(0), // Identifier
0x30, // Data request
0x0D, // Carriage return
0x0A, // Line feed
];
bool sent = await sendData(command);
if (!sent) {
throw Exception('Failed to send data request command');
}
// Loop to receive data
for (int i = 0; i < 20000; i++) {
// Receive data with 5 second timeout
String? receivedResponse = await receiveData(
timeout: const Duration(milliseconds: 5000),
);
if (receivedResponse != null && receivedResponse.isNotEmpty) {
// Update UI with received data
setState(() {
receivedData = '$receivedData$receivedResponse';
});
// Scroll to show latest data
_scrollToBottom();
} else {
break;
}
}
} else {
throw Exception('Response length insufficient');
}
} else {
throw Exception('Invalid data format');
}
} else {
throw Exception('No data received');
}
setState(() {
isReceiving = true;
});
} catch (e) {
setState(() {
receivedData = "Communication error: $e";
});
}
}
/// Stop receiving data and clean up resources
Future<void> stopReceiving() async {
if (dataSubscription != null) {
await dataSubscription!.cancel();
dataSubscription = null;
}
dataBuffer.clear();
lastReceivedData = null;
isWaitingForData = false;
setState(() {
isReceiving = false;
});
}
/// Scroll the data display to show the latest content
void _scrollToBottom() {
if (_scrollController.hasClients) {
_scrollController.animateTo(
_scrollController.position.maxScrollExtent,
duration: const Duration(milliseconds: 300),
curve: Curves.easeOut,
);
}
}
@override
void dispose() {
_scrollController.dispose();
stopReceiving();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('OPBT Meter(IEC Protocol) Reader'),
backgroundColor: Colors.blue,
),
body: Column(
children: [
// Upper part: Device list and control area
Expanded(
flex: 1,
child: Container(
width: double.infinity,
margin: const EdgeInsets.all(8),
decoration: BoxDecoration(
border: Border.all(color: Colors.blue.shade200),
borderRadius: BorderRadius.circular(8),
color: Colors.blue.shade50,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Display connection status
Padding(
padding: const EdgeInsets.all(8.0),
child: Text(
connectedDevice != null
? 'Connected device: ${connectedDevice!.name}'
: 'No device connected',
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: Colors.blue,
),
),
),
// Scan button
Padding(
padding: const EdgeInsets.all(8.0),
child: ElevatedButton(
onPressed: isScanning ? null : startScan,
style: ElevatedButton.styleFrom(
backgroundColor: Colors.blue,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(
horizontal: 32,
vertical: 12,
),
),
child: Text(isScanning ? 'Scanning...' : 'Scan Devices'),
),
),
// Device list
Expanded(
child: Container(
width: double.infinity,
margin: const EdgeInsets.all(8),
decoration: BoxDecoration(
border: Border.all(color: Colors.blue.shade200),
borderRadius: BorderRadius.circular(8),
color: Colors.white,
),
child: ListView.builder(
itemCount: scanResults.length,
itemBuilder: (context, index) {
ScanResult result = scanResults[index];
return ListTile(
title: Text(
result.device.name.isEmpty
? 'Unknown Device'
: result.device.name,
style: const TextStyle(
fontWeight: FontWeight.bold,
),
),
subtitle: Text(
result.device.id.toString(),
style: TextStyle(color: Colors.grey.shade600),
),
onTap: () => connectToDevice(result.device),
tileColor: Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
);
},
),
),
),
],
),
),
),
// Lower part: Receive information display area
Expanded(
flex: 2,
child: Container(
width: double.infinity,
margin: const EdgeInsets.all(8),
decoration: BoxDecoration(
border: Border.all(color: Colors.blue.shade200),
borderRadius: BorderRadius.circular(8),
color: Colors.blue.shade50,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Padding(
padding: EdgeInsets.all(8.0),
child: Text(
'Received Data',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: Colors.blue,
),
),
),
Padding(
padding: const EdgeInsets.all(8.0),
child: ElevatedButton(
onPressed: () {
setState(() {
receivedData = "Waiting for data...";
});
},
style: ElevatedButton.styleFrom(
backgroundColor: Colors.blue,
foregroundColor: Colors.white,
),
child: const Text('Clear'),
),
),
],
),
Expanded(
child: Container(
width: double.infinity,
margin: const EdgeInsets.all(8),
decoration: BoxDecoration(
border: Border.all(color: Colors.blue.shade200),
borderRadius: BorderRadius.circular(8),
color: Colors.white,
),
child: SingleChildScrollView(
controller: _scrollController,
child: Padding(
padding: const EdgeInsets.all(12.0),
child: Text(
receivedData,
style: const TextStyle(fontSize: 16, height: 1.5),
textAlign: TextAlign.left,
),
),
),
),
),
],
),
),
),
],
),
);
}
}