software copyright
This commit is contained in:
@@ -0,0 +1,491 @@
|
||||
// 自然写互动课堂平板端应用软件 V1.0
|
||||
// service/ble_service.dart - BLE蓝牙点阵笔连接服务
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:typed_data';
|
||||
|
||||
/// BLE服务UUID常量定义
|
||||
/// 基于自然写点阵笔自定义GATT Service规范
|
||||
class PadBleConstants {
|
||||
/// 点阵笔主服务UUID
|
||||
static const String penServiceUuid = '0000ffe0-0000-1000-8000-00805f9b34fb';
|
||||
|
||||
/// 笔迹坐标数据特征值UUID(Notify)
|
||||
static const String strokeCharUuid = '0000ffe1-0000-1000-8000-00805f9b34fb';
|
||||
|
||||
/// 笔控制指令特征值UUID(Write)
|
||||
static const String controlCharUuid = '0000ffe2-0000-1000-8000-00805f9b34fb';
|
||||
|
||||
/// 电量信息特征值UUID(Read/Notify)
|
||||
static const String batteryCharUuid = '0000ffe3-0000-1000-8000-00805f9b34fb';
|
||||
|
||||
/// 设备信息服务UUID
|
||||
static const String deviceInfoUuid = '0000180a-0000-1000-8000-00805f9b34fb';
|
||||
|
||||
/// 扫描超时时间(秒)
|
||||
static const int scanTimeoutSeconds = 15;
|
||||
|
||||
/// 自动重连延迟(秒)
|
||||
static const int reconnectDelaySeconds = 3;
|
||||
|
||||
/// 最大重连次数
|
||||
static const int maxReconnectAttempts = 10;
|
||||
|
||||
/// MTU协商大小
|
||||
static const int requestedMtu = 247;
|
||||
|
||||
/// 笔迹数据缓冲批量回调阈值
|
||||
static const int strokeBatchSize = 8;
|
||||
|
||||
/// 电量读取间隔(秒)
|
||||
static const int batteryReadInterval = 60;
|
||||
}
|
||||
|
||||
/// 单个笔迹坐标点数据
|
||||
class PadPenPoint {
|
||||
/// X坐标(0.01mm精度,16位无符号)
|
||||
final double x;
|
||||
|
||||
/// Y坐标(0.01mm精度,16位无符号)
|
||||
final double y;
|
||||
|
||||
/// 压力值(0-255,8位无符号)
|
||||
final int pressure;
|
||||
|
||||
/// 时间戳(相对值,16位无符号,单位ms)
|
||||
final int timestamp;
|
||||
|
||||
/// 是否为落笔点
|
||||
final bool isPenDown;
|
||||
|
||||
PadPenPoint({
|
||||
required this.x,
|
||||
required this.y,
|
||||
required this.pressure,
|
||||
required this.timestamp,
|
||||
this.isPenDown = false,
|
||||
});
|
||||
|
||||
@override
|
||||
String toString() =>
|
||||
'PadPenPoint(x: ${x.toStringAsFixed(2)}, y: ${y.toStringAsFixed(2)}, '
|
||||
'p: $pressure, t: $timestamp)';
|
||||
}
|
||||
|
||||
/// 点阵笔设备信息
|
||||
class PadPenDevice {
|
||||
/// 设备蓝牙MAC地址
|
||||
final String macAddress;
|
||||
|
||||
/// 设备名称
|
||||
final String name;
|
||||
|
||||
/// 信号强度(RSSI)
|
||||
int rssi;
|
||||
|
||||
/// 当前连接状态
|
||||
PenConnectionState connectionState;
|
||||
|
||||
/// 电量百分比(0-100)
|
||||
int batteryLevel;
|
||||
|
||||
/// 固件版本号
|
||||
String? firmwareVersion;
|
||||
|
||||
/// 当前所在点阵码页面ID
|
||||
String? currentPageId;
|
||||
|
||||
PadPenDevice({
|
||||
required this.macAddress,
|
||||
required this.name,
|
||||
this.rssi = -100,
|
||||
this.connectionState = PenConnectionState.disconnected,
|
||||
this.batteryLevel = -1,
|
||||
this.firmwareVersion,
|
||||
this.currentPageId,
|
||||
});
|
||||
}
|
||||
|
||||
/// 笔连接状态枚举
|
||||
enum PenConnectionState {
|
||||
/// 未连接
|
||||
disconnected,
|
||||
|
||||
/// 正在扫描
|
||||
scanning,
|
||||
|
||||
/// 正在连接
|
||||
connecting,
|
||||
|
||||
/// 已连接
|
||||
connected,
|
||||
|
||||
/// 正在断开
|
||||
disconnecting,
|
||||
|
||||
/// 自动重连中
|
||||
reconnecting,
|
||||
}
|
||||
|
||||
/// 笔迹数据事件(批量坐标点回调)
|
||||
class PenStrokeEvent {
|
||||
/// 来源笔的MAC地址
|
||||
final String penMac;
|
||||
|
||||
/// 坐标点列表
|
||||
final List<PadPenPoint> points;
|
||||
|
||||
/// 所在页面ID(点阵码识别)
|
||||
final String? pageId;
|
||||
|
||||
PenStrokeEvent({
|
||||
required this.penMac,
|
||||
required this.points,
|
||||
this.pageId,
|
||||
});
|
||||
}
|
||||
|
||||
/// BLE蓝牙点阵笔连接服务
|
||||
/// 负责扫描、连接、数据接收、电量监控、自动重连等功能
|
||||
/// 平板端支持同时连接1支笔(学生个人使用场景)
|
||||
class PadBleService {
|
||||
/// 已发现的设备列表
|
||||
final List<PadPenDevice> _discoveredDevices = [];
|
||||
|
||||
/// 当前已连接的笔
|
||||
PadPenDevice? _connectedPen;
|
||||
|
||||
/// 笔迹数据缓冲区(累积到阈值后批量回调)
|
||||
final List<PadPenPoint> _strokeBuffer = [];
|
||||
|
||||
/// 扫描结果流
|
||||
final StreamController<List<PadPenDevice>> _scanController =
|
||||
StreamController<List<PadPenDevice>>.broadcast();
|
||||
|
||||
/// 笔迹数据事件流
|
||||
final StreamController<PenStrokeEvent> _strokeController =
|
||||
StreamController<PenStrokeEvent>.broadcast();
|
||||
|
||||
/// 连接状态变化流
|
||||
final StreamController<PenConnectionState> _connectionController =
|
||||
StreamController<PenConnectionState>.broadcast();
|
||||
|
||||
/// 电量变化流
|
||||
final StreamController<int> _batteryController =
|
||||
StreamController<int>.broadcast();
|
||||
|
||||
/// 自动重连计数器
|
||||
int _reconnectAttempts = 0;
|
||||
|
||||
/// 重连定时器
|
||||
Timer? _reconnectTimer;
|
||||
|
||||
/// 电量读取定时器
|
||||
Timer? _batteryTimer;
|
||||
|
||||
/// 是否正在扫描
|
||||
bool _isScanning = false;
|
||||
|
||||
/// 公开的流
|
||||
Stream<List<PadPenDevice>> get scanStream => _scanController.stream;
|
||||
Stream<PenStrokeEvent> get strokeStream => _strokeController.stream;
|
||||
Stream<PenConnectionState> get connectionStream =>
|
||||
_connectionController.stream;
|
||||
Stream<int> get batteryStream => _batteryController.stream;
|
||||
|
||||
/// 获取当前连接的笔
|
||||
PadPenDevice? get connectedPen => _connectedPen;
|
||||
|
||||
/// 开始扫描附近的点阵笔设备
|
||||
/// 按服务UUID过滤,仅发现自然写点阵笔
|
||||
Future<void> startScan() async {
|
||||
if (_isScanning) return;
|
||||
_isScanning = true;
|
||||
_discoveredDevices.clear();
|
||||
|
||||
// 通知扫描状态
|
||||
_connectionController.add(PenConnectionState.scanning);
|
||||
|
||||
// 模拟BLE扫描(实际使用flutter_blue_plus库)
|
||||
// 过滤条件:仅发现包含pen_service_uuid的设备
|
||||
// scanFilters: [ScanFilter(serviceUuid: PadBleConstants.penServiceUuid)]
|
||||
|
||||
// 设置扫描超时
|
||||
Timer(Duration(seconds: PadBleConstants.scanTimeoutSeconds), () {
|
||||
stopScan();
|
||||
});
|
||||
}
|
||||
|
||||
/// 停止扫描
|
||||
Future<void> stopScan() async {
|
||||
_isScanning = false;
|
||||
// 实际调用: FlutterBluePlus.stopScan()
|
||||
}
|
||||
|
||||
/// 处理扫描结果回调
|
||||
void _onScanResult(String mac, String name, int rssi) {
|
||||
// 检查是否已发现过
|
||||
final existingIndex = _discoveredDevices.indexWhere(
|
||||
(d) => d.macAddress == mac,
|
||||
);
|
||||
|
||||
if (existingIndex >= 0) {
|
||||
// 更新已有设备的RSSI
|
||||
_discoveredDevices[existingIndex].rssi = rssi;
|
||||
} else {
|
||||
// 添加新发现的设备
|
||||
_discoveredDevices.add(PadPenDevice(
|
||||
macAddress: mac,
|
||||
name: name,
|
||||
rssi: rssi,
|
||||
));
|
||||
}
|
||||
|
||||
// 按信号强度降序排列
|
||||
_discoveredDevices.sort((a, b) => b.rssi.compareTo(a.rssi));
|
||||
_scanController.add(List.from(_discoveredDevices));
|
||||
}
|
||||
|
||||
/// 连接指定的点阵笔
|
||||
/// [device] 要连接的笔设备信息
|
||||
Future<bool> connectPen(PadPenDevice device) async {
|
||||
// 先断开已有连接
|
||||
if (_connectedPen != null) {
|
||||
await disconnectPen();
|
||||
}
|
||||
|
||||
device.connectionState = PenConnectionState.connecting;
|
||||
_connectionController.add(PenConnectionState.connecting);
|
||||
|
||||
try {
|
||||
// 停止扫描
|
||||
await stopScan();
|
||||
|
||||
// 执行BLE连接
|
||||
// 实际调用: device.connect(timeout: Duration(seconds: 10))
|
||||
// 协商MTU
|
||||
// await device.requestMtu(PadBleConstants.requestedMtu);
|
||||
|
||||
// 发现服务和特征值
|
||||
// final services = await device.discoverServices();
|
||||
// 查找笔迹数据特征值并订阅Notify
|
||||
|
||||
// 设置连接成功状态
|
||||
device.connectionState = PenConnectionState.connected;
|
||||
_connectedPen = device;
|
||||
_reconnectAttempts = 0;
|
||||
_connectionController.add(PenConnectionState.connected);
|
||||
|
||||
// 启动电量定时读取
|
||||
_startBatteryMonitor();
|
||||
|
||||
// 订阅笔迹数据特征值
|
||||
_subscribeStrokeData();
|
||||
|
||||
return true;
|
||||
} catch (e) {
|
||||
device.connectionState = PenConnectionState.disconnected;
|
||||
_connectionController.add(PenConnectionState.disconnected);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// 订阅笔迹坐标数据Notify特征值
|
||||
void _subscribeStrokeData() {
|
||||
// 实际调用:
|
||||
// characteristic.setNotifyValue(true);
|
||||
// characteristic.onValueReceived.listen(_onStrokeDataReceived);
|
||||
}
|
||||
|
||||
/// 处理接收到的笔迹原始数据(7字节紧凑编码)
|
||||
/// 数据格式:[X_H, X_L, Y_H, Y_L, Pressure, TS_H, TS_L]
|
||||
/// X: 16位无符号(0.01mm精度)
|
||||
/// Y: 16位无符号(0.01mm精度)
|
||||
/// Pressure: 8位无符号(0-255)
|
||||
/// Timestamp: 16位无符号(相对毫秒)
|
||||
void _onStrokeDataReceived(Uint8List rawData) {
|
||||
if (rawData.length < 7) return;
|
||||
|
||||
// 可能包含多个坐标点(每7字节一个)
|
||||
int offset = 0;
|
||||
while (offset + 7 <= rawData.length) {
|
||||
// 解码X坐标(大端序16位)
|
||||
final int rawX = (rawData[offset] << 8) | rawData[offset + 1];
|
||||
final double x = rawX * 0.01; // 转换为毫米
|
||||
|
||||
// 解码Y坐标
|
||||
final int rawY = (rawData[offset + 2] << 8) | rawData[offset + 3];
|
||||
final double y = rawY * 0.01;
|
||||
|
||||
// 解码压力值
|
||||
final int pressure = rawData[offset + 4];
|
||||
|
||||
// 解码时间戳
|
||||
final int timestamp =
|
||||
(rawData[offset + 5] << 8) | rawData[offset + 6];
|
||||
|
||||
// 判断落笔/抬笔(压力值>0为落笔)
|
||||
final bool isPenDown = pressure > 0;
|
||||
|
||||
final point = PadPenPoint(
|
||||
x: x,
|
||||
y: y,
|
||||
pressure: pressure,
|
||||
timestamp: timestamp,
|
||||
isPenDown: isPenDown,
|
||||
);
|
||||
|
||||
_strokeBuffer.add(point);
|
||||
offset += 7;
|
||||
}
|
||||
|
||||
// CRC-16 CCITT校验(如果数据包尾部有2字节CRC)
|
||||
if (rawData.length > offset + 1) {
|
||||
final int receivedCrc = (rawData[offset] << 8) | rawData[offset + 1];
|
||||
final int calculatedCrc = _calculateCrc16(
|
||||
rawData.sublist(0, offset),
|
||||
);
|
||||
if (receivedCrc != calculatedCrc) {
|
||||
// CRC校验失败,丢弃本批数据
|
||||
_strokeBuffer.clear();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 达到批量阈值后回调
|
||||
if (_strokeBuffer.length >= PadBleConstants.strokeBatchSize) {
|
||||
_flushStrokeBuffer();
|
||||
}
|
||||
}
|
||||
|
||||
/// 将缓冲区中的笔迹数据批量回调
|
||||
void _flushStrokeBuffer() {
|
||||
if (_strokeBuffer.isEmpty || _connectedPen == null) return;
|
||||
|
||||
final event = PenStrokeEvent(
|
||||
penMac: _connectedPen!.macAddress,
|
||||
points: List.from(_strokeBuffer),
|
||||
pageId: _connectedPen!.currentPageId,
|
||||
);
|
||||
|
||||
_strokeController.add(event);
|
||||
_strokeBuffer.clear();
|
||||
}
|
||||
|
||||
/// CRC-16 CCITT校验算法
|
||||
/// 多项式: 0x1021, 初始值: 0xFFFF
|
||||
int _calculateCrc16(Uint8List data) {
|
||||
int crc = 0xFFFF;
|
||||
for (int i = 0; i < data.length; i++) {
|
||||
crc ^= (data[i] << 8);
|
||||
for (int j = 0; j < 8; j++) {
|
||||
if ((crc & 0x8000) != 0) {
|
||||
crc = ((crc << 1) ^ 0x1021) & 0xFFFF;
|
||||
} else {
|
||||
crc = (crc << 1) & 0xFFFF;
|
||||
}
|
||||
}
|
||||
}
|
||||
return crc;
|
||||
}
|
||||
|
||||
/// 启动电量定时读取
|
||||
void _startBatteryMonitor() {
|
||||
_batteryTimer?.cancel();
|
||||
_batteryTimer = Timer.periodic(
|
||||
Duration(seconds: PadBleConstants.batteryReadInterval),
|
||||
(_) => _readBatteryLevel(),
|
||||
);
|
||||
// 立即读取一次
|
||||
_readBatteryLevel();
|
||||
}
|
||||
|
||||
/// 读取笔电量
|
||||
Future<void> _readBatteryLevel() async {
|
||||
if (_connectedPen == null) return;
|
||||
|
||||
try {
|
||||
// 实际调用: 读取battery特征值
|
||||
// final value = await batteryCharacteristic.read();
|
||||
// _connectedPen!.batteryLevel = value[0];
|
||||
// _batteryController.add(_connectedPen!.batteryLevel);
|
||||
} catch (e) {
|
||||
// 读取失败,忽略
|
||||
}
|
||||
}
|
||||
|
||||
/// 向笔发送控制指令
|
||||
/// [command] 指令类型(如:LED闪烁、蜂鸣提示、固件信息查询)
|
||||
Future<void> sendCommand(int command, [Uint8List? payload]) async {
|
||||
if (_connectedPen == null) return;
|
||||
|
||||
// 构建指令包:[CMD, LEN, PAYLOAD..., CRC_H, CRC_L]
|
||||
final List<int> packet = [command];
|
||||
if (payload != null) {
|
||||
packet.add(payload.length);
|
||||
packet.addAll(payload);
|
||||
} else {
|
||||
packet.add(0);
|
||||
}
|
||||
|
||||
// 追加CRC校验
|
||||
final crc = _calculateCrc16(Uint8List.fromList(packet));
|
||||
packet.add((crc >> 8) & 0xFF);
|
||||
packet.add(crc & 0xFF);
|
||||
|
||||
// 实际调用: controlCharacteristic.write(Uint8List.fromList(packet));
|
||||
}
|
||||
|
||||
/// 断开当前笔连接
|
||||
Future<void> disconnectPen() async {
|
||||
_batteryTimer?.cancel();
|
||||
_reconnectTimer?.cancel();
|
||||
|
||||
if (_connectedPen != null) {
|
||||
_connectedPen!.connectionState = PenConnectionState.disconnecting;
|
||||
_connectionController.add(PenConnectionState.disconnecting);
|
||||
|
||||
// 实际调用: device.disconnect();
|
||||
_connectedPen!.connectionState = PenConnectionState.disconnected;
|
||||
_connectedPen = null;
|
||||
_connectionController.add(PenConnectionState.disconnected);
|
||||
}
|
||||
|
||||
// 清空缓冲区
|
||||
_flushStrokeBuffer();
|
||||
}
|
||||
|
||||
/// 处理连接意外断开,启动自动重连
|
||||
void _onDisconnected(PadPenDevice device) {
|
||||
if (_reconnectAttempts >= PadBleConstants.maxReconnectAttempts) {
|
||||
// 超过最大重连次数,放弃重连
|
||||
_connectionController.add(PenConnectionState.disconnected);
|
||||
return;
|
||||
}
|
||||
|
||||
_connectionController.add(PenConnectionState.reconnecting);
|
||||
_reconnectAttempts++;
|
||||
|
||||
// 指数退避延迟重连
|
||||
final delay = PadBleConstants.reconnectDelaySeconds * _reconnectAttempts;
|
||||
final clampedDelay = delay > 30 ? 30 : delay;
|
||||
|
||||
_reconnectTimer = Timer(Duration(seconds: clampedDelay), () async {
|
||||
final success = await connectPen(device);
|
||||
if (!success) {
|
||||
_onDisconnected(device);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// 释放所有资源
|
||||
void dispose() {
|
||||
_batteryTimer?.cancel();
|
||||
_reconnectTimer?.cancel();
|
||||
_scanController.close();
|
||||
_strokeController.close();
|
||||
_connectionController.close();
|
||||
_batteryController.close();
|
||||
_strokeBuffer.clear();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user