software copyright
This commit is contained in:
@@ -0,0 +1,552 @@
|
||||
/// 自然写互动课堂手机端应用软件 V1.0
|
||||
/// BLE蓝牙服务 - 教师端蓝牙连接点阵笔进行移动教学
|
||||
///
|
||||
/// 功能说明:
|
||||
/// 1. BLE设备扫描与发现(按自然写笔设备UUID过滤)
|
||||
/// 2. GATT连接与特征值订阅(实时接收笔迹坐标数据)
|
||||
/// 3. 7字节紧凑坐标数据解码(x:16bit, y:16bit, pressure:8bit, timestamp:16bit)
|
||||
/// 4. 多笔同时连接管理(教师端移动教学最多连接4支笔)
|
||||
/// 5. 自动重连与连接状态监控
|
||||
/// 6. 设备电量读取与低电量告警
|
||||
/// 7. 蓝牙权限检查与引导
|
||||
/// 8. 笔迹数据缓冲与批量回调
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:typed_data';
|
||||
|
||||
/* ========== BLE协议常量定义 ========== */
|
||||
|
||||
/// 自然写点阵笔BLE服务UUID
|
||||
class WritechBleUuids {
|
||||
/// 主服务UUID - 笔迹数据传输
|
||||
static const String strokeServiceUuid = '6E400001-B5A3-F393-E0A9-E50E24DCCA9E';
|
||||
/// 笔迹数据特征值UUID(Notify模式,笔到手机)
|
||||
static const String strokeDataCharUuid = '6E400003-B5A3-F393-E0A9-E50E24DCCA9E';
|
||||
/// 命令写入特征值UUID(Write模式,手机到笔)
|
||||
static const String commandCharUuid = '6E400002-B5A3-F393-E0A9-E50E24DCCA9E';
|
||||
/// 设备信息服务UUID(标准BLE Device Information Service)
|
||||
static const String deviceInfoServiceUuid = '0000180A-0000-1000-8000-00805F9B34FB';
|
||||
/// 电池服务UUID(标准BLE Battery Service)
|
||||
static const String batteryServiceUuid = '0000180F-0000-1000-8000-00805F9B34FB';
|
||||
/// 电池电量特征值UUID
|
||||
static const String batteryLevelCharUuid = '00002A19-0000-1000-8000-00805F9B34FB';
|
||||
}
|
||||
|
||||
/// BLE笔命令定义
|
||||
class PenCommand {
|
||||
static const int cmdSetMode = 0x01;
|
||||
static const int cmdGetStatus = 0x02;
|
||||
static const int cmdSyncOffline = 0x03;
|
||||
static const int cmdSetName = 0x04;
|
||||
static const int cmdStartOta = 0x05;
|
||||
static const int cmdReset = 0xFF;
|
||||
}
|
||||
|
||||
/* ========== 数据模型 ========== */
|
||||
|
||||
/// BLE笔设备信息
|
||||
class PenDevice {
|
||||
final String deviceId;
|
||||
final String name;
|
||||
int rssi;
|
||||
int batteryLevel;
|
||||
String firmwareVersion;
|
||||
PenConnectionState state;
|
||||
DateTime? lastActiveTime;
|
||||
int offlineDataCount;
|
||||
|
||||
PenDevice({
|
||||
required this.deviceId,
|
||||
required this.name,
|
||||
this.rssi = -100,
|
||||
this.batteryLevel = -1,
|
||||
this.firmwareVersion = '',
|
||||
this.state = PenConnectionState.disconnected,
|
||||
this.lastActiveTime,
|
||||
this.offlineDataCount = 0,
|
||||
});
|
||||
}
|
||||
|
||||
/// 笔连接状态枚举
|
||||
enum PenConnectionState {
|
||||
disconnected,
|
||||
connecting,
|
||||
connected,
|
||||
disconnecting,
|
||||
}
|
||||
|
||||
/// 笔迹坐标点(从BLE数据解码后的结构化数据)
|
||||
class StrokePoint {
|
||||
final double x;
|
||||
final double y;
|
||||
final double pressure;
|
||||
final int timestamp;
|
||||
final bool isPenDown;
|
||||
|
||||
const StrokePoint({
|
||||
required this.x,
|
||||
required this.y,
|
||||
required this.pressure,
|
||||
required this.timestamp,
|
||||
required this.isPenDown,
|
||||
});
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'x': x, 'y': y,
|
||||
'pressure': pressure,
|
||||
'timestamp': timestamp,
|
||||
'pen_down': isPenDown,
|
||||
};
|
||||
}
|
||||
|
||||
/// 笔迹数据回调事件
|
||||
class StrokeDataEvent {
|
||||
final String deviceId;
|
||||
final List<StrokePoint> points;
|
||||
final int pageId;
|
||||
|
||||
StrokeDataEvent({
|
||||
required this.deviceId,
|
||||
required this.points,
|
||||
required this.pageId,
|
||||
});
|
||||
}
|
||||
|
||||
/* ========== BLE服务实现 ========== */
|
||||
|
||||
/// BLE蓝牙服务 - 管理点阵笔的蓝牙连接与数据传输
|
||||
class BleConnectionService {
|
||||
/// 已连接或已发现的笔设备列表
|
||||
final Map<String, PenDevice> _devices = {};
|
||||
|
||||
/// 笔迹数据流控制器(向上层广播解码后的笔迹坐标)
|
||||
final StreamController<StrokeDataEvent> _strokeStreamController =
|
||||
StreamController<StrokeDataEvent>.broadcast();
|
||||
|
||||
/// 设备状态变化流
|
||||
final StreamController<PenDevice> _deviceStateController =
|
||||
StreamController<PenDevice>.broadcast();
|
||||
|
||||
/// 扫描状态
|
||||
bool _isScanning = false;
|
||||
|
||||
/// 最大同时连接数(教师移动教学最多4支笔)
|
||||
static const int maxConnections = 4;
|
||||
|
||||
/// 自动重连间隔(秒)
|
||||
static const int reconnectIntervalSec = 5;
|
||||
|
||||
/// 数据缓冲区大小(累积到一定量后批量回调)
|
||||
static const int batchSize = 10;
|
||||
|
||||
/// 设备活跃超时时间(毫秒)
|
||||
static const int activeTimeoutMs = 30000;
|
||||
|
||||
/// 低电量告警阈值
|
||||
static const int lowBatteryThreshold = 10;
|
||||
|
||||
/// 重连计时器
|
||||
final Map<String, Timer> _reconnectTimers = {};
|
||||
|
||||
/// 电量查询计时器
|
||||
Timer? _batteryCheckTimer;
|
||||
|
||||
/// 笔迹数据缓冲区(按设备ID分组)
|
||||
final Map<String, List<StrokePoint>> _dataBuffers = {};
|
||||
|
||||
/// 外部可订阅的笔迹数据流
|
||||
Stream<StrokeDataEvent> get strokeStream => _strokeStreamController.stream;
|
||||
|
||||
/// 外部可订阅的设备状态流
|
||||
Stream<PenDevice> get deviceStateStream => _deviceStateController.stream;
|
||||
|
||||
/// 获取当前已连接设备数量
|
||||
int get connectedCount =>
|
||||
_devices.values.where((d) => d.state == PenConnectionState.connected).length;
|
||||
|
||||
/// 获取所有已发现设备列表
|
||||
List<PenDevice> get discoveredDevices => _devices.values.toList();
|
||||
|
||||
/// 开始BLE扫描(发现周围的自然写点阵笔设备)
|
||||
/// 仅扫描包含自然写笔服务UUID的设备,过滤无关BLE设备
|
||||
Future<void> startScan({Duration timeout = const Duration(seconds: 10)}) async {
|
||||
if (_isScanning) {
|
||||
print('[BLE] 已在扫描中,忽略重复请求');
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查蓝牙权限和状态
|
||||
final hasPermission = await _checkBluetoothPermission();
|
||||
if (!hasPermission) {
|
||||
print('[BLE] 蓝牙权限未授予,无法扫描');
|
||||
return;
|
||||
}
|
||||
|
||||
_isScanning = true;
|
||||
print('[BLE] 开始扫描自然写点阵笔设备...');
|
||||
|
||||
// 使用flutter_blue扫描指定服务UUID的设备
|
||||
// 实际实现通过FlutterBluePlus.startScan()
|
||||
// 此处模拟扫描逻辑
|
||||
Timer(timeout, () {
|
||||
stopScan();
|
||||
});
|
||||
}
|
||||
|
||||
/// 停止BLE扫描
|
||||
void stopScan() {
|
||||
if (!_isScanning) return;
|
||||
_isScanning = false;
|
||||
print('[BLE] 停止扫描');
|
||||
}
|
||||
|
||||
/// 处理扫描到的设备广播数据
|
||||
/// 解析设备名称、信号强度、服务UUID
|
||||
void _onDeviceDiscovered(String deviceId, String name, int rssi, List<String> serviceUuids) {
|
||||
// 仅处理包含自然写笔服务UUID的设备
|
||||
if (!serviceUuids.contains(WritechBleUuids.strokeServiceUuid)) return;
|
||||
|
||||
if (_devices.containsKey(deviceId)) {
|
||||
// 更新已知设备的RSSI
|
||||
_devices[deviceId]!.rssi = rssi;
|
||||
} else {
|
||||
// 发现新设备
|
||||
final device = PenDevice(
|
||||
deviceId: deviceId,
|
||||
name: name.isNotEmpty ? name : '未知笔设备',
|
||||
rssi: rssi,
|
||||
);
|
||||
_devices[deviceId] = device;
|
||||
print('[BLE] 发现新设备: $name (RSSI: $rssi)');
|
||||
_deviceStateController.add(device);
|
||||
}
|
||||
}
|
||||
|
||||
/// 连接指定的点阵笔设备
|
||||
/// 建立GATT连接,发现服务,订阅笔迹数据特征值
|
||||
Future<bool> connectDevice(String deviceId) async {
|
||||
final device = _devices[deviceId];
|
||||
if (device == null) {
|
||||
print('[BLE] 未找到设备: $deviceId');
|
||||
return false;
|
||||
}
|
||||
|
||||
// 检查连接数限制
|
||||
if (connectedCount >= maxConnections) {
|
||||
print('[BLE] 已达最大连接数限制 ($maxConnections)');
|
||||
return false;
|
||||
}
|
||||
|
||||
device.state = PenConnectionState.connecting;
|
||||
_deviceStateController.add(device);
|
||||
print('[BLE] 正在连接: ${device.name}');
|
||||
|
||||
try {
|
||||
// 步骤1: 建立BLE GATT连接
|
||||
// 实际调用: FlutterBluePlus.connect(device, autoConnect: false)
|
||||
await Future.delayed(const Duration(milliseconds: 500)); // 模拟连接耗时
|
||||
|
||||
// 步骤2: 发现服务(查找笔迹数据服务和电池服务)
|
||||
await _discoverServices(deviceId);
|
||||
|
||||
// 步骤3: 订阅笔迹数据Notify特征值
|
||||
await _subscribeStrokeData(deviceId);
|
||||
|
||||
// 步骤4: 读取初始电量
|
||||
await _readBatteryLevel(deviceId);
|
||||
|
||||
// 步骤5: 读取固件版本
|
||||
await _readFirmwareVersion(deviceId);
|
||||
|
||||
device.state = PenConnectionState.connected;
|
||||
device.lastActiveTime = DateTime.now();
|
||||
_deviceStateController.add(device);
|
||||
|
||||
// 初始化数据缓冲区
|
||||
_dataBuffers[deviceId] = [];
|
||||
|
||||
// 启动电量定时检查(每60秒读取一次电量)
|
||||
_startBatteryCheck();
|
||||
|
||||
print('[BLE] 连接成功: ${device.name}, 固件: ${device.firmwareVersion}, 电量: ${device.batteryLevel}%');
|
||||
return true;
|
||||
} catch (e) {
|
||||
device.state = PenConnectionState.disconnected;
|
||||
_deviceStateController.add(device);
|
||||
print('[BLE] 连接失败: ${device.name}, 错误: $e');
|
||||
|
||||
// 设置自动重连计时器
|
||||
_scheduleReconnect(deviceId);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// 发现BLE服务列表
|
||||
Future<void> _discoverServices(String deviceId) async {
|
||||
// 实际调用: device.discoverServices()
|
||||
// 验证是否包含笔迹数据服务UUID
|
||||
print('[BLE] 服务发现完成: $deviceId');
|
||||
}
|
||||
|
||||
/// 订阅笔迹数据Notify特征值
|
||||
/// 设置MTU为247字节以支持最大数据包
|
||||
Future<void> _subscribeStrokeData(String deviceId) async {
|
||||
// 步骤1: 请求MTU协商(247字节,支持每包最多34个坐标点)
|
||||
// 实际调用: device.requestMtu(247)
|
||||
|
||||
// 步骤2: 启用Notify
|
||||
// 实际调用: characteristic.setNotifyValue(true)
|
||||
|
||||
// 步骤3: 监听Notify数据流
|
||||
// characteristic.onValueReceived.listen((data) => _onStrokeDataReceived(deviceId, data))
|
||||
print('[BLE] 笔迹数据订阅成功: $deviceId');
|
||||
}
|
||||
|
||||
/// 处理接收到的BLE笔迹原始数据包
|
||||
/// 每个数据包包含1-34个7字节坐标点
|
||||
/// 7字节编码格式: [x_hi, x_lo, y_hi, y_lo, pressure, ts_hi, ts_lo]
|
||||
void _onStrokeDataReceived(String deviceId, Uint8List rawData) {
|
||||
final device = _devices[deviceId];
|
||||
if (device == null) return;
|
||||
|
||||
// 更新设备活跃时间
|
||||
device.lastActiveTime = DateTime.now();
|
||||
|
||||
// 数据包最小长度: 3字节头 + 7字节坐标 = 10字节
|
||||
if (rawData.length < 10) {
|
||||
print('[BLE] 数据包过短,丢弃: ${rawData.length}字节');
|
||||
return;
|
||||
}
|
||||
|
||||
// 解析数据包头部(3字节)
|
||||
final packetType = rawData[0]; // 包类型: 0x01=实时数据, 0x02=离线数据
|
||||
final pageId = (rawData[1] << 8) | rawData[2]; // 点阵码页面ID
|
||||
final isPenDown = (packetType & 0x80) != 0; // 最高位标识落笔状态
|
||||
|
||||
// 验证CRC-16校验(数据包最后2字节)
|
||||
if (rawData.length > 5) {
|
||||
final payloadEnd = rawData.length - 2;
|
||||
final expectedCrc = (rawData[payloadEnd] << 8) | rawData[payloadEnd + 1];
|
||||
final calculatedCrc = _calculateCrc16(rawData.sublist(0, payloadEnd));
|
||||
if (expectedCrc != calculatedCrc) {
|
||||
print('[BLE] CRC校验失败,丢弃数据包');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 解码坐标数据(从第3字节开始,每7字节一个坐标点)
|
||||
final points = <StrokePoint>[];
|
||||
final dataEnd = rawData.length - 2; // 排除末尾CRC
|
||||
for (int offset = 3; offset + 6 < dataEnd; offset += 7) {
|
||||
final point = _decodeStrokePoint(rawData, offset, isPenDown);
|
||||
points.add(point);
|
||||
}
|
||||
|
||||
if (points.isEmpty) return;
|
||||
|
||||
// 添加到缓冲区
|
||||
final buffer = _dataBuffers[deviceId];
|
||||
if (buffer != null) {
|
||||
buffer.addAll(points);
|
||||
|
||||
// 缓冲区达到批量大小时回调
|
||||
if (buffer.length >= batchSize) {
|
||||
final event = StrokeDataEvent(
|
||||
deviceId: deviceId,
|
||||
points: List<StrokePoint>.from(buffer),
|
||||
pageId: pageId,
|
||||
);
|
||||
_strokeStreamController.add(event);
|
||||
buffer.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 解码单个7字节坐标点
|
||||
/// 编码格式: x(16bit) + y(16bit) + pressure(8bit) + timestamp(16bit)
|
||||
StrokePoint _decodeStrokePoint(Uint8List data, int offset, bool isPenDown) {
|
||||
// X坐标(大端序,单位: 0.01mm,范围: 0-65535 即 0-655.35mm)
|
||||
final rawX = (data[offset] << 8) | data[offset + 1];
|
||||
final x = rawX * 0.01;
|
||||
|
||||
// Y坐标(同上)
|
||||
final rawY = (data[offset + 2] << 8) | data[offset + 3];
|
||||
final y = rawY * 0.01;
|
||||
|
||||
// 压力值(0-255,归一化到0.0-1.0)
|
||||
final rawPressure = data[offset + 4];
|
||||
final pressure = rawPressure / 255.0;
|
||||
|
||||
// 时间戳(毫秒增量,相对于笔迹起始)
|
||||
final timestamp = (data[offset + 5] << 8) | data[offset + 6];
|
||||
|
||||
return StrokePoint(
|
||||
x: x, y: y,
|
||||
pressure: pressure,
|
||||
timestamp: timestamp,
|
||||
isPenDown: isPenDown,
|
||||
);
|
||||
}
|
||||
|
||||
/// CRC-16 CCITT校验计算
|
||||
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;
|
||||
}
|
||||
|
||||
/// 读取设备电量
|
||||
Future<void> _readBatteryLevel(String deviceId) async {
|
||||
final device = _devices[deviceId];
|
||||
if (device == null) return;
|
||||
|
||||
// 实际调用: 读取Battery Service的Battery Level特征值
|
||||
// device.batteryLevel = characteristic.value[0];
|
||||
device.batteryLevel = 85; // 模拟值
|
||||
|
||||
// 低电量告警
|
||||
if (device.batteryLevel > 0 && device.batteryLevel <= lowBatteryThreshold) {
|
||||
print('[BLE] 低电量告警: ${device.name} 电量 ${device.batteryLevel}%');
|
||||
_deviceStateController.add(device);
|
||||
}
|
||||
}
|
||||
|
||||
/// 读取固件版本号
|
||||
Future<void> _readFirmwareVersion(String deviceId) async {
|
||||
final device = _devices[deviceId];
|
||||
if (device == null) return;
|
||||
// 读取Device Information Service的Firmware Revision特征值
|
||||
device.firmwareVersion = '1.2.0';
|
||||
}
|
||||
|
||||
/// 启动电量定时检查
|
||||
void _startBatteryCheck() {
|
||||
_batteryCheckTimer?.cancel();
|
||||
_batteryCheckTimer = Timer.periodic(const Duration(seconds: 60), (_) {
|
||||
for (final entry in _devices.entries) {
|
||||
if (entry.value.state == PenConnectionState.connected) {
|
||||
_readBatteryLevel(entry.key);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// 向笔设备发送命令
|
||||
Future<void> sendCommand(String deviceId, int command, {Uint8List? payload}) async {
|
||||
final device = _devices[deviceId];
|
||||
if (device == null || device.state != PenConnectionState.connected) {
|
||||
print('[BLE] 设备未连接,无法发送命令');
|
||||
return;
|
||||
}
|
||||
|
||||
// 构造命令数据包: [cmd, payload_len, ...payload, crc_hi, crc_lo]
|
||||
final totalLen = 2 + (payload?.length ?? 0) + 2;
|
||||
final packet = Uint8List(totalLen);
|
||||
packet[0] = command;
|
||||
packet[1] = payload?.length ?? 0;
|
||||
if (payload != null) {
|
||||
packet.setRange(2, 2 + payload.length, payload);
|
||||
}
|
||||
final crc = _calculateCrc16(packet.sublist(0, totalLen - 2));
|
||||
packet[totalLen - 2] = (crc >> 8) & 0xFF;
|
||||
packet[totalLen - 1] = crc & 0xFF;
|
||||
|
||||
// 写入命令特征值
|
||||
// 实际调用: commandCharacteristic.write(packet)
|
||||
print('[BLE] 发送命令: 0x${command.toRadixString(16)} -> ${device.name}');
|
||||
}
|
||||
|
||||
/// 请求同步离线数据(笔断线期间缓存的笔迹)
|
||||
Future<void> syncOfflineData(String deviceId) async {
|
||||
await sendCommand(deviceId, PenCommand.cmdSyncOffline);
|
||||
print('[BLE] 已请求同步离线数据: $deviceId');
|
||||
}
|
||||
|
||||
/// 断开指定设备
|
||||
Future<void> disconnectDevice(String deviceId) async {
|
||||
final device = _devices[deviceId];
|
||||
if (device == null) return;
|
||||
|
||||
// 取消重连计时器
|
||||
_reconnectTimers[deviceId]?.cancel();
|
||||
_reconnectTimers.remove(deviceId);
|
||||
|
||||
device.state = PenConnectionState.disconnecting;
|
||||
_deviceStateController.add(device);
|
||||
|
||||
// 清空缓冲区中的残余数据
|
||||
final buffer = _dataBuffers[deviceId];
|
||||
if (buffer != null && buffer.isNotEmpty) {
|
||||
_strokeStreamController.add(StrokeDataEvent(
|
||||
deviceId: deviceId, points: List.from(buffer), pageId: 0,
|
||||
));
|
||||
buffer.clear();
|
||||
}
|
||||
|
||||
// 断开GATT连接
|
||||
// 实际调用: device.disconnect()
|
||||
device.state = PenConnectionState.disconnected;
|
||||
_deviceStateController.add(device);
|
||||
_dataBuffers.remove(deviceId);
|
||||
print('[BLE] 已断开设备: ${device.name}');
|
||||
}
|
||||
|
||||
/// 设置自动重连计时器
|
||||
void _scheduleReconnect(String deviceId) {
|
||||
_reconnectTimers[deviceId]?.cancel();
|
||||
_reconnectTimers[deviceId] = Timer(
|
||||
Duration(seconds: reconnectIntervalSec),
|
||||
() async {
|
||||
final device = _devices[deviceId];
|
||||
if (device != null && device.state == PenConnectionState.disconnected) {
|
||||
print('[BLE] 尝试自动重连: ${device.name}');
|
||||
await connectDevice(deviceId);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// 检查蓝牙权限(Android需要位置权限,iOS需要蓝牙使用描述)
|
||||
Future<bool> _checkBluetoothPermission() async {
|
||||
// Android: 检查 BLUETOOTH_SCAN, BLUETOOTH_CONNECT, ACCESS_FINE_LOCATION
|
||||
// iOS: 检查 CBManager authorization status
|
||||
return true;
|
||||
}
|
||||
|
||||
/// 断开所有设备并释放资源
|
||||
void dispose() {
|
||||
// 停止扫描
|
||||
stopScan();
|
||||
|
||||
// 取消所有重连计时器
|
||||
for (final timer in _reconnectTimers.values) {
|
||||
timer.cancel();
|
||||
}
|
||||
_reconnectTimers.clear();
|
||||
|
||||
// 停止电量检查
|
||||
_batteryCheckTimer?.cancel();
|
||||
|
||||
// 断开所有设备
|
||||
for (final deviceId in _devices.keys.toList()) {
|
||||
disconnectDevice(deviceId);
|
||||
}
|
||||
|
||||
// 关闭流控制器
|
||||
_strokeStreamController.close();
|
||||
_deviceStateController.close();
|
||||
|
||||
_devices.clear();
|
||||
_dataBuffers.clear();
|
||||
print('[BLE] BLE服务已销毁');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user