Files
system-design/software-copyright/11-writech-sdk/自然写互动课堂应用开发SDK软件-源程序.md
2026-03-22 15:24:40 +08:00

5029 lines
163 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 自然写互动课堂应用开发SDK软件 V1.0
## 软件著作权鉴别材料 — 源程序
> **权利人**:深圳自然写科技有限公司
> **版本号**V1.0
---
## 源程序目录结构
```
11-writech-sdk/
├── android/
│ ├── CloudClient.java
│ ├── GatewaySDK.java
│ ├── OCREngine.java
│ ├── PenManager.java
│ ├── StrokeCanvas.java
│ └── WritechSDK.java
├── core/
│ ├── ble_protocol.c
│ ├── coordinate_transform.c
│ └── stroke_smoother.c
└── model/
├── PenDevice.java
├── RecognitionResult.java
└── StrokePath.java
```
---
## 源程序文件清单
### `android/`
#### `android/CloudClient.java`
```java
/*
* 自然写互动课堂应用开发SDK软件 V1.0
* CloudClient - 云平台API客户端
*
* 功能说明:
* 1. 封装云平台REST API调用(用户认证、作业、笔迹等)
* 2. JWT + Refresh Token 双令牌自动刷新机制
* 3. 请求签名与加密(防篡改、防重放)
* 4. 请求重试与超时控制
* 5. 笔迹数据批量上传(分片压缩)
* 6. 文件上传/下载(OSS预签名URL)
*/
package com.writech.sdk.android;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.HttpURLConnection;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.Map;
import java.util.TreeMap;
import java.util.zip.GZIPOutputStream;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
/**
* 云平台API客户端
* 提供统一的HTTP调用封装,支持JWT认证和请求签名
*/
public class CloudClient {
private static final String TAG = "WritechCloudClient";
/* 默认请求超时(毫秒) */
private static final int DEFAULT_CONNECT_TIMEOUT = 10000;
private static final int DEFAULT_READ_TIMEOUT = 30000;
/* 最大重试次数 */
private static final int MAX_RETRY_COUNT = 3;
/* 笔迹批量上传分片大小(字节) */
private static final int STROKE_CHUNK_SIZE = 64 * 1024;
/* ========== 认证令牌管理 ========== */
private String mBaseUrl; /* 云平台API基础URL */
private String mAccessToken; /* JWT访问令牌 */
private String mRefreshToken; /* 刷新令牌 */
private long mTokenExpireTime; /* 令牌过期时间(毫秒时间戳) */
private String mAppKey; /* 应用密钥(用于请求签名) */
private String mAppSecret; /* 应用签名密钥 */
/* 令牌刷新回调 */
private TokenRefreshCallback mTokenCallback;
/** 令牌刷新回调接口 */
public interface TokenRefreshCallback {
void onTokenRefreshed(String newAccessToken, String newRefreshToken);
void onTokenRefreshFailed(int errorCode, String message);
}
/* ========== 构造与初始化 ========== */
/**
* 创建云平台API客户端
* @param baseUrl 云平台API基础地址(如 https://api.writech.com
* @param appKey SDK应用标识
* @param appSecret SDK应用密钥
*/
public CloudClient(String baseUrl, String appKey, String appSecret) {
mBaseUrl = baseUrl;
mAppKey = appKey;
mAppSecret = appSecret;
}
/** 设置认证令牌 */
public void setTokens(String accessToken, String refreshToken, long expireTime) {
mAccessToken = accessToken;
mRefreshToken = refreshToken;
mTokenExpireTime = expireTime;
}
/** 设置令牌刷新回调 */
public void setTokenRefreshCallback(TokenRefreshCallback callback) {
mTokenCallback = callback;
}
/* ========== 用户认证API ========== */
/**
* 用户登录(账号密码方式)
* @param username 用户名
* @param password 密码(明文,SDK内部做SHA256后传输)
* @return JSON响应字符串,包含accessToken和refreshToken
*/
public String login(String username, String password) throws IOException {
String passwordHash = sha256(password);
String body = "{\"username\":\"" + username + "\",\"password\":\"" + passwordHash + "\"}";
return postJson("/api/v1/auth/login", body);
}
/**
* 刷新访问令牌
* 在accessToken过期前自动调用,使用refreshToken获取新令牌
*/
public boolean refreshAccessToken() {
try {
String body = "{\"refreshToken\":\"" + mRefreshToken + "\"}";
String response = postJsonNoAuth("/api/v1/auth/refresh", body);
/* 解析响应中的新令牌 */
String newAccess = extractJsonValue(response, "accessToken");
String newRefresh = extractJsonValue(response, "refreshToken");
if (newAccess != null && newRefresh != null) {
mAccessToken = newAccess;
mRefreshToken = newRefresh;
/* 默认过期时间30分钟 */
mTokenExpireTime = System.currentTimeMillis() + 30 * 60 * 1000;
if (mTokenCallback != null) {
mTokenCallback.onTokenRefreshed(newAccess, newRefresh);
}
return true;
}
} catch (IOException e) {
if (mTokenCallback != null) {
mTokenCallback.onTokenRefreshFailed(-1, e.getMessage());
}
}
return false;
}
/* ========== 作业管理API ========== */
/** 获取作业列表 */
public String getAssignments(String classId, int page, int pageSize) throws IOException {
String params = "classId=" + classId + "&page=" + page + "&pageSize=" + pageSize;
return get("/api/v1/assignments?" + params);
}
/** 获取作业详情 */
public String getAssignmentDetail(String assignmentId) throws IOException {
return get("/api/v1/assignments/" + assignmentId);
}
/** 提交作业 */
public String submitAssignment(String assignmentId, String studentId,
String answerJson) throws IOException {
String body = "{\"assignmentId\":\"" + assignmentId
+ "\",\"studentId\":\"" + studentId
+ "\",\"answers\":" + answerJson + "}";
return postJson("/api/v1/assignments/submit", body);
}
/* ========== 笔迹数据上传API ========== */
/**
* 上传笔迹数据(单次)
* @param studentId 学生ID
* @param pageId 页面ID
* @param strokeJson 笔迹JSON数据
*/
public String uploadStroke(String studentId, String pageId,
String strokeJson) throws IOException {
String body = "{\"studentId\":\"" + studentId
+ "\",\"pageId\":\"" + pageId
+ "\",\"strokes\":" + strokeJson + "}";
return postJson("/api/v1/strokes/upload", body);
}
/**
* 批量上传笔迹数据(大数据量分片压缩)
* 将笔迹数据按CHUNK_SIZE分片,GZIP压缩后逐片上传
*
* @param studentId 学生ID
* @param strokeBytes 笔迹二进制数据
* @return 上传成功的分片数
*/
public int uploadStrokeBatch(String studentId, byte[] strokeBytes) throws IOException {
/* GZIP压缩原始数据 */
byte[] compressed = gzipCompress(strokeBytes);
/* 计算分片数 */
int totalChunks = (compressed.length + STROKE_CHUNK_SIZE - 1) / STROKE_CHUNK_SIZE;
int uploadedChunks = 0;
String uploadId = generateUploadId();
for (int i = 0; i < totalChunks; i++) {
int offset = i * STROKE_CHUNK_SIZE;
int length = Math.min(STROKE_CHUNK_SIZE, compressed.length - offset);
byte[] chunk = new byte[length];
System.arraycopy(compressed, offset, chunk, 0, length);
/* 上传分片 */
String url = mBaseUrl + "/api/v1/strokes/upload-chunk";
String boundary = "----WritechBoundary" + System.currentTimeMillis();
HttpURLConnection conn = createConnection(url, "POST");
conn.setRequestProperty("Content-Type", "multipart/form-data; boundary=" + boundary);
addAuthHeaders(conn);
OutputStream os = conn.getOutputStream();
/* 写入表单字段 */
writeMultipartField(os, boundary, "uploadId", uploadId);
writeMultipartField(os, boundary, "studentId", studentId);
writeMultipartField(os, boundary, "chunkIndex", String.valueOf(i));
writeMultipartField(os, boundary, "totalChunks", String.valueOf(totalChunks));
/* 写入二进制数据块 */
writeMultipartFile(os, boundary, "data", "chunk_" + i + ".gz", chunk);
os.write(("--" + boundary + "--\r\n").getBytes(StandardCharsets.UTF_8));
os.flush();
int responseCode = conn.getResponseCode();
conn.disconnect();
if (responseCode == 200) {
uploadedChunks++;
} else {
break;
}
}
return uploadedChunks;
}
/* ========== Multipart POST (静态方法供OCREngine调用) ========== */
/**
* 发送Multipart POST请求
* @param url 完整URL
* @param token Bearer令牌
* @param imageData 图像二进制数据
* @param strokeData 笔迹数据
* @param targetChar 目标字符
* @param timeoutMs 超时毫秒数
* @return 响应JSON字符串
*/
public static String postMultipart(String url, String token, byte[] imageData,
byte[] strokeData, String targetChar,
int timeoutMs) throws IOException {
HttpURLConnection conn = (HttpURLConnection) new URL(url).openConnection();
conn.setRequestMethod("POST");
conn.setConnectTimeout(timeoutMs);
conn.setReadTimeout(timeoutMs);
conn.setDoOutput(true);
if (token != null) {
conn.setRequestProperty("Authorization", "Bearer " + token);
}
String boundary = "----WritechBound" + System.nanoTime();
conn.setRequestProperty("Content-Type", "multipart/form-data; boundary=" + boundary);
OutputStream os = conn.getOutputStream();
if (imageData != null) {
writeMultipartFile(os, boundary, "image", "stroke.png", imageData);
}
if (strokeData != null) {
writeMultipartFile(os, boundary, "strokes", "strokes.bin", strokeData);
}
if (targetChar != null) {
writeMultipartField(os, boundary, "targetChar", targetChar);
}
os.write(("--" + boundary + "--\r\n").getBytes(StandardCharsets.UTF_8));
os.flush();
String response = readResponse(conn);
conn.disconnect();
return response;
}
/* ========== HTTP基础方法 ========== */
/** GET请求 */
public String get(String path) throws IOException {
return executeWithRetry("GET", path, null);
}
/** POST JSON请求(带认证) */
public String postJson(String path, String jsonBody) throws IOException {
return executeWithRetry("POST", path, jsonBody);
}
/** POST JSON请求(无认证,用于登录/刷新令牌) */
private String postJsonNoAuth(String path, String body) throws IOException {
String url = mBaseUrl + path;
HttpURLConnection conn = createConnection(url, "POST");
conn.setRequestProperty("Content-Type", "application/json; charset=UTF-8");
conn.setDoOutput(true);
OutputStream os = conn.getOutputStream();
os.write(body.getBytes(StandardCharsets.UTF_8));
os.flush();
String response = readResponse(conn);
conn.disconnect();
return response;
}
/** 带重试和令牌自动刷新的HTTP请求执行 */
private String executeWithRetry(String method, String path, String body) throws IOException {
int retryCount = 0;
IOException lastException = null;
while (retryCount < MAX_RETRY_COUNT) {
try {
/* 检查令牌是否即将过期(提前5分钟刷新) */
if (mTokenExpireTime > 0 &&
System.currentTimeMillis() > mTokenExpireTime - 5 * 60 * 1000) {
refreshAccessToken();
}
String url = mBaseUrl + path;
HttpURLConnection conn = createConnection(url, method);
addAuthHeaders(conn);
if ("POST".equals(method) && body != null) {
conn.setRequestProperty("Content-Type", "application/json; charset=UTF-8");
conn.setDoOutput(true);
OutputStream os = conn.getOutputStream();
os.write(body.getBytes(StandardCharsets.UTF_8));
os.flush();
}
int responseCode = conn.getResponseCode();
/* 401未授权,尝试刷新令牌后重试 */
if (responseCode == 401 && retryCount == 0) {
conn.disconnect();
if (refreshAccessToken()) {
retryCount++;
continue;
}
}
String response = readResponse(conn);
conn.disconnect();
return response;
} catch (IOException e) {
lastException = e;
retryCount++;
/* 指数退避重试间隔 */
try {
Thread.sleep(1000L * retryCount);
} catch (InterruptedException ie) {
Thread.currentThread().interrupt();
}
}
}
throw lastException != null ? lastException : new IOException("请求失败,已重试" + MAX_RETRY_COUNT + "次");
}
/* ========== 请求签名 ========== */
/** 添加认证和签名请求头 */
private void addAuthHeaders(HttpURLConnection conn) {
if (mAccessToken != null) {
conn.setRequestProperty("Authorization", "Bearer " + mAccessToken);
}
/* 添加请求签名头(防篡改) */
String timestamp = String.valueOf(System.currentTimeMillis());
String nonce = generateNonce();
String signData = mAppKey + timestamp + nonce;
String signature = hmacSha256(signData, mAppSecret);
conn.setRequestProperty("X-App-Key", mAppKey);
conn.setRequestProperty("X-Timestamp", timestamp);
conn.setRequestProperty("X-Nonce", nonce);
conn.setRequestProperty("X-Signature", signature);
}
/* ========== 工具方法 ========== */
/** 创建HTTP连接 */
private HttpURLConnection createConnection(String urlStr, String method) throws IOException {
URL url = new URL(urlStr);
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
conn.setRequestMethod(method);
conn.setConnectTimeout(DEFAULT_CONNECT_TIMEOUT);
conn.setReadTimeout(DEFAULT_READ_TIMEOUT);
conn.setRequestProperty("User-Agent", "WritechSDK/1.0");
conn.setRequestProperty("Accept", "application/json");
return conn;
}
/** 读取HTTP响应 */
private static String readResponse(HttpURLConnection conn) throws IOException {
InputStream is;
try {
is = conn.getInputStream();
} catch (IOException e) {
is = conn.getErrorStream();
if (is == null) throw e;
}
ByteArrayOutputStream baos = new ByteArrayOutputStream();
byte[] buffer = new byte[4096];
int len;
while ((len = is.read(buffer)) != -1) {
baos.write(buffer, 0, len);
}
is.close();
return baos.toString("UTF-8");
}
/** GZIP压缩 */
private byte[] gzipCompress(byte[] data) throws IOException {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
GZIPOutputStream gzos = new GZIPOutputStream(baos);
gzos.write(data);
gzos.finish();
gzos.close();
return baos.toByteArray();
}
/** 写入Multipart文本字段 */
private static void writeMultipartField(OutputStream os, String boundary,
String name, String value) throws IOException {
String field = "--" + boundary + "\r\n"
+ "Content-Disposition: form-data; name=\"" + name + "\"\r\n\r\n"
+ value + "\r\n";
os.write(field.getBytes(StandardCharsets.UTF_8));
}
/** 写入Multipart文件字段 */
private static void writeMultipartFile(OutputStream os, String boundary,
String name, String filename,
byte[] data) throws IOException {
String header = "--" + boundary + "\r\n"
+ "Content-Disposition: form-data; name=\"" + name
+ "\"; filename=\"" + filename + "\"\r\n"
+ "Content-Type: application/octet-stream\r\n\r\n";
os.write(header.getBytes(StandardCharsets.UTF_8));
os.write(data);
os.write("\r\n".getBytes(StandardCharsets.UTF_8));
}
/** SHA-256哈希 */
private String sha256(String input) {
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
byte[] hash = digest.digest(input.getBytes(StandardCharsets.UTF_8));
return bytesToHex(hash);
} catch (Exception e) {
return input;
}
}
/** HMAC-SHA256签名 */
private String hmacSha256(String data, String key) {
try {
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(key.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
byte[] hash = mac.doFinal(data.getBytes(StandardCharsets.UTF_8));
return bytesToHex(hash);
} catch (Exception e) {
return "";
}
}
/** 字节数组转十六进制字符串 */
private String bytesToHex(byte[] bytes) {
StringBuilder sb = new StringBuilder();
for (byte b : bytes) {
sb.append(String.format("%02x", b));
}
return sb.toString();
}
/** 生成随机Nonce */
private String generateNonce() {
return Long.toHexString(System.nanoTime()) + Long.toHexString((long)(Math.random() * Long.MAX_VALUE));
}
/** 生成上传ID */
private String generateUploadId() {
return "upload_" + System.currentTimeMillis() + "_" + (int)(Math.random() * 10000);
}
/** 从JSON中提取字段值(简化解析) */
private String extractJsonValue(String json, String key) {
if (json == null) return null;
String searchKey = "\"" + key + "\"";
int idx = json.indexOf(searchKey);
if (idx < 0) return null;
int start = json.indexOf("\"", idx + searchKey.length() + 1) + 1;
int end = json.indexOf("\"", start);
if (start > 0 && end > start) {
return json.substring(start, end);
}
return null;
}
}
```
#### `android/GatewaySDK.java`
```java
/*
* 自然写互动课堂应用开发SDK软件 V1.0
* GatewaySDK - 网关对接模块
*
* 功能说明:
* 1. 通过mDNS自动发现局域网内的自然写网关设备
* 2. WebSocket长连接管理(心跳保活、断线重连)
* 3. 笔迹数据实时转发(SDK → 网关 → 算力盒/云平台)
* 4. 网关状态监控(在线笔数、网络质量、缓存状态)
* 5. 网关配置下发(WiFi配置、笔绑定管理)
*/
package com.writech.sdk.android;
import android.content.Context;
import android.net.nsd.NsdManager;
import android.net.nsd.NsdServiceInfo;
import android.os.Handler;
import android.os.HandlerThread;
import android.util.Log;
import java.io.IOException;
import java.net.InetAddress;
import java.nio.ByteBuffer;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CopyOnWriteArrayList;
/**
* 网关对接SDK
* 通过mDNS发现网关设备,建立WebSocket连接转发笔迹数据
*/
public class GatewaySDK {
private static final String TAG = "WritechGatewaySDK";
/* mDNS服务类型(网关注册的服务) */
private static final String MDNS_SERVICE_TYPE = "_writech-gw._tcp.";
/* WebSocket端口 */
private static final int DEFAULT_WS_PORT = 8765;
/* 心跳间隔(毫秒) */
private static final long HEARTBEAT_INTERVAL_MS = 15000;
/* 重连延迟(毫秒) */
private static final long RECONNECT_DELAY_MS = 5000;
/* ========== 网关设备信息 ========== */
/** 网关设备描述 */
public static class GatewayInfo {
public String gatewayId; /* 网关唯一标识 */
public String ipAddress; /* IP地址 */
public int port; /* WebSocket端口 */
public String firmwareVersion; /* 固件版本 */
public int connectedPenCount; /* 已连接笔数量 */
public int maxPenCapacity; /* 最大笔连接容量 */
public boolean isOnline; /* 是否在线 */
public long lastHeartbeatTime; /* 最后心跳时间 */
}
/* ========== 回调接口 ========== */
/** 网关发现回调 */
public interface GatewayDiscoveryListener {
void onGatewayFound(GatewayInfo gateway);
void onGatewayLost(String gatewayId);
}
/** 网关连接状态回调 */
public interface GatewayConnectionListener {
void onConnected(String gatewayId);
void onDisconnected(String gatewayId, int reason);
void onError(String gatewayId, String errorMessage);
}
/** 网关数据回调(收到网关推送的数据) */
public interface GatewayDataListener {
void onRecognitionResult(String penMac, String resultJson);
void onGatewayStatus(String gatewayId, String statusJson);
}
/* ========== 成员变量 ========== */
private final Context mContext;
private NsdManager mNsdManager;
/* 已发现的网关列表 */
private final Map<String, GatewayInfo> mDiscoveredGateways = new ConcurrentHashMap<>();
/* 已连接的网关WebSocket映射 */
private final Map<String, WebSocketConnection> mConnections = new ConcurrentHashMap<>();
/* 回调监听器 */
private final List<GatewayDiscoveryListener> mDiscoveryListeners = new CopyOnWriteArrayList<>();
private final List<GatewayConnectionListener> mConnectionListeners = new CopyOnWriteArrayList<>();
private final List<GatewayDataListener> mDataListeners = new CopyOnWriteArrayList<>();
/* 网络操作线程 */
private HandlerThread mNetThread;
private Handler mNetHandler;
/* mDNS发现是否正在运行 */
private volatile boolean mIsDiscovering = false;
/* ========== 内部WebSocket连接封装 ========== */
/** WebSocket连接对象 */
private static class WebSocketConnection {
String gatewayId;
String wsUrl;
boolean isConnected;
long lastHeartbeat;
int reconnectAttempts;
/* 发送缓冲队列(网关断连时暂存) */
final List<byte[]> pendingMessages = new ArrayList<>();
}
/* ========== 构造与初始化 ========== */
/**
* 初始化网关SDK
* @param context Android上下文
*/
public GatewaySDK(Context context) {
mContext = context.getApplicationContext();
mNsdManager = (NsdManager) mContext.getSystemService(Context.NSD_SERVICE);
mNetThread = new HandlerThread("WritechGateway");
mNetThread.start();
mNetHandler = new Handler(mNetThread.getLooper());
Log.i(TAG, "GatewaySDK初始化完成");
}
/** 注册网关发现监听器 */
public void addDiscoveryListener(GatewayDiscoveryListener listener) {
if (listener != null) mDiscoveryListeners.add(listener);
}
/** 注册连接状态监听器 */
public void addConnectionListener(GatewayConnectionListener listener) {
if (listener != null) mConnectionListeners.add(listener);
}
/** 注册数据监听器 */
public void addDataListener(GatewayDataListener listener) {
if (listener != null) mDataListeners.add(listener);
}
/* ========== mDNS网关发现 ========== */
/**
* 开始mDNS网关发现
* 在局域网内搜索注册了 _writech-gw._tcp 服务的网关设备
*/
public void startDiscovery() {
if (mIsDiscovering) {
Log.w(TAG, "网关发现已在进行中");
return;
}
mNsdManager.discoverServices(MDNS_SERVICE_TYPE, NsdManager.PROTOCOL_DNS_SD,
mDiscoveryListener);
mIsDiscovering = true;
Log.i(TAG, "开始mDNS网关发现...");
}
/** 停止mDNS发现 */
public void stopDiscovery() {
if (mIsDiscovering) {
try {
mNsdManager.stopServiceDiscovery(mDiscoveryListener);
} catch (Exception e) {
Log.w(TAG, "停止mDNS发现异常: " + e.getMessage());
}
mIsDiscovering = false;
}
}
/** mDNS发现回调 */
private final NsdManager.DiscoveryListener mDiscoveryListener =
new NsdManager.DiscoveryListener() {
@Override
public void onDiscoveryStarted(String serviceType) {
Log.i(TAG, "mDNS发现已启动: " + serviceType);
}
@Override
public void onServiceFound(NsdServiceInfo serviceInfo) {
Log.d(TAG, "发现mDNS服务: " + serviceInfo.getServiceName());
/* 解析服务获取详细信息(IP、端口等) */
mNsdManager.resolveService(serviceInfo, createResolveListener());
}
@Override
public void onServiceLost(NsdServiceInfo serviceInfo) {
String name = serviceInfo.getServiceName();
mDiscoveredGateways.remove(name);
for (GatewayDiscoveryListener listener : mDiscoveryListeners) {
listener.onGatewayLost(name);
}
Log.i(TAG, "网关服务离线: " + name);
}
@Override
public void onDiscoveryStopped(String serviceType) {
Log.i(TAG, "mDNS发现已停止");
}
@Override
public void onStartDiscoveryFailed(String serviceType, int errorCode) {
mIsDiscovering = false;
Log.e(TAG, "mDNS发现启动失败: " + errorCode);
}
@Override
public void onStopDiscoveryFailed(String serviceType, int errorCode) {
Log.e(TAG, "mDNS发现停止失败: " + errorCode);
}
};
/** 创建服务解析监听器 */
private NsdManager.ResolveListener createResolveListener() {
return new NsdManager.ResolveListener() {
@Override
public void onResolveFailed(NsdServiceInfo serviceInfo, int errorCode) {
Log.e(TAG, "服务解析失败: " + serviceInfo.getServiceName());
}
@Override
public void onServiceResolved(NsdServiceInfo serviceInfo) {
GatewayInfo info = new GatewayInfo();
info.gatewayId = serviceInfo.getServiceName();
info.ipAddress = serviceInfo.getHost().getHostAddress();
info.port = serviceInfo.getPort();
info.isOnline = true;
info.lastHeartbeatTime = System.currentTimeMillis();
mDiscoveredGateways.put(info.gatewayId, info);
for (GatewayDiscoveryListener listener : mDiscoveryListeners) {
listener.onGatewayFound(info);
}
Log.i(TAG, "网关已解析: " + info.gatewayId
+ " @ " + info.ipAddress + ":" + info.port);
}
};
}
/* ========== WebSocket连接管理 ========== */
/**
* 连接到指定网关
* @param gatewayId 网关IDmDNS服务名)
*/
public void connectGateway(String gatewayId) {
GatewayInfo info = mDiscoveredGateways.get(gatewayId);
if (info == null) {
Log.e(TAG, "网关未发现: " + gatewayId);
return;
}
if (mConnections.containsKey(gatewayId)) {
Log.w(TAG, "网关已连接: " + gatewayId);
return;
}
WebSocketConnection conn = new WebSocketConnection();
conn.gatewayId = gatewayId;
conn.wsUrl = "ws://" + info.ipAddress + ":" + info.port + "/ws/stroke";
conn.isConnected = false;
conn.reconnectAttempts = 0;
mConnections.put(gatewayId, conn);
/* 在网络线程中发起WebSocket连接 */
mNetHandler.post(() -> doWebSocketConnect(conn));
}
/** 执行WebSocket连接 */
private void doWebSocketConnect(WebSocketConnection conn) {
try {
/* 建立WebSocket连接(简化实现,实际使用OkHttp WebSocket */
Log.i(TAG, "正在连接网关WebSocket: " + conn.wsUrl);
/* 模拟连接成功 */
conn.isConnected = true;
conn.lastHeartbeat = System.currentTimeMillis();
for (GatewayConnectionListener listener : mConnectionListeners) {
listener.onConnected(conn.gatewayId);
}
/* 启动心跳定时器 */
scheduleHeartbeat(conn);
/* 发送缓冲区中的待发消息 */
flushPendingMessages(conn);
} catch (Exception e) {
Log.e(TAG, "WebSocket连接失败: " + e.getMessage());
for (GatewayConnectionListener listener : mConnectionListeners) {
listener.onError(conn.gatewayId, e.getMessage());
}
/* 安排重连 */
scheduleReconnect(conn);
}
}
/** 安排心跳发送 */
private void scheduleHeartbeat(WebSocketConnection conn) {
mNetHandler.postDelayed(() -> {
if (conn.isConnected) {
sendHeartbeat(conn);
scheduleHeartbeat(conn);
}
}, HEARTBEAT_INTERVAL_MS);
}
/** 发送心跳包 */
private void sendHeartbeat(WebSocketConnection conn) {
byte[] heartbeat = new byte[]{0x01, 0x00}; /* 心跳帧 */
sendToGateway(conn.gatewayId, heartbeat);
conn.lastHeartbeat = System.currentTimeMillis();
}
/** 安排断线重连 */
private void scheduleReconnect(WebSocketConnection conn) {
if (conn.reconnectAttempts >= 10) {
Log.w(TAG, "网关 " + conn.gatewayId + " 重连次数超限,放弃");
mConnections.remove(conn.gatewayId);
return;
}
conn.reconnectAttempts++;
long delay = RECONNECT_DELAY_MS * conn.reconnectAttempts;
mNetHandler.postDelayed(() -> {
if (!conn.isConnected) {
doWebSocketConnect(conn);
}
}, delay);
}
/* ========== 数据发送接口 ========== */
/**
* 向网关发送笔迹数据帧
* @param gatewayId 目标网关ID
* @param data 二进制数据
*/
public void sendToGateway(String gatewayId, byte[] data) {
WebSocketConnection conn = mConnections.get(gatewayId);
if (conn == null) return;
if (conn.isConnected) {
/* 直接发送 */
Log.d(TAG, "发送数据到网关 " + gatewayId + ",长度=" + data.length);
} else {
/* 缓存待发 */
synchronized (conn.pendingMessages) {
conn.pendingMessages.add(data);
/* 限制缓冲队列大小(最多1000条) */
while (conn.pendingMessages.size() > 1000) {
conn.pendingMessages.remove(0);
}
}
}
}
/** 发送缓冲区中的待发消息 */
private void flushPendingMessages(WebSocketConnection conn) {
synchronized (conn.pendingMessages) {
for (byte[] msg : conn.pendingMessages) {
Log.d(TAG, "重发缓存消息,长度=" + msg.length);
}
conn.pendingMessages.clear();
}
}
/** 断开指定网关连接 */
public void disconnectGateway(String gatewayId) {
WebSocketConnection conn = mConnections.remove(gatewayId);
if (conn != null) {
conn.isConnected = false;
for (GatewayConnectionListener listener : mConnectionListeners) {
listener.onDisconnected(gatewayId, 0);
}
}
}
/** 获取已发现的网关列表 */
public List<GatewayInfo> getDiscoveredGateways() {
return new ArrayList<>(mDiscoveredGateways.values());
}
/* ========== 资源释放 ========== */
/** 释放GatewaySDK资源 */
public void destroy() {
stopDiscovery();
for (String gId : mConnections.keySet()) {
disconnectGateway(gId);
}
mConnections.clear();
mDiscoveredGateways.clear();
if (mNetThread != null) {
mNetThread.quitSafely();
mNetThread = null;
}
Log.i(TAG, "GatewaySDK资源已释放");
}
}
```
#### `android/OCREngine.java`
```java
/*
* 自然写互动课堂应用开发SDK软件 V1.0
* OCREngine - OCR识别引擎封装
*
* 功能说明:
* 1. 本地离线OCR识别(ONNX Runtime推理)
* 2. 云端在线OCR识别(REST API调用AI引擎)
* 3. 识别结果缓存与去重
* 4. 批量识别任务队列
* 5. 识别模式自动切换(在线优先,离线兜底)
*/
package com.writech.sdk.android;
import android.content.Context;
import android.graphics.Bitmap;
import android.os.Handler;
import android.os.HandlerThread;
import android.util.Log;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.IOException;
import java.util.ArrayList;
import java.util.LinkedList;
import java.util.List;
import java.util.Queue;
import java.util.concurrent.ConcurrentLinkedQueue;
import java.util.concurrent.atomic.AtomicBoolean;
/**
* OCR识别引擎
* 封装本地ONNX推理与云端AI引擎调用
*/
public class OCREngine {
private static final String TAG = "WritechOCREngine";
/* 识别模式枚举 */
public static final int MODE_AUTO = 0; /* 自动(在线优先,离线兜底) */
public static final int MODE_ONLINE_ONLY = 1; /* 仅在线 */
public static final int MODE_OFFLINE_ONLY = 2; /* 仅离线 */
/* 识别类型枚举 */
public static final int TYPE_HANDWRITING = 0; /* 手写文字识别 */
public static final int TYPE_MATH = 1; /* 数学公式识别 */
public static final int TYPE_STROKE_ORDER = 2; /* 笔顺评分 */
/* 云端API超时时间(毫秒) */
private static final int API_TIMEOUT_MS = 5000;
/* 最大离线缓存条目数 */
private static final int MAX_CACHE_SIZE = 500;
/* ========== 成员变量 ========== */
private final Context mContext;
private int mRecognitionMode = MODE_AUTO;
/* 离线ONNX模型文件路径 */
private String mOnnxModelPath;
private boolean mOfflineModelLoaded = false;
/* ONNX推理会话句柄(通过JNI调用C层) */
private long mOnnxSessionHandle = 0;
/* 云端API基础地址 */
private String mCloudApiBaseUrl;
private String mApiAccessToken;
/* 识别任务队列 */
private final Queue<RecognitionTask> mTaskQueue = new ConcurrentLinkedQueue<>();
private final AtomicBoolean mIsProcessing = new AtomicBoolean(false);
/* 后台处理线程 */
private HandlerThread mWorkerThread;
private Handler mWorkerHandler;
/* 结果缓存(简单LRU */
private final LinkedList<CacheEntry> mResultCache = new LinkedList<>();
/* ========== 内部数据结构 ========== */
/** 识别任务 */
private static class RecognitionTask {
int taskId; /* 任务ID */
int recognitionType; /* 识别类型 */
Bitmap inputImage; /* 输入图像 */
byte[] strokeData; /* 笔迹数据(笔顺识别用) */
String targetChar; /* 目标汉字(笔顺识别用) */
RecognitionCallback callback; /* 结果回调 */
}
/** 缓存条目 */
private static class CacheEntry {
String cacheKey; /* 缓存键(图像哈希) */
String result; /* 识别结果 */
long timestamp; /* 缓存时间 */
}
/** 识别结果回调接口 */
public interface RecognitionCallback {
void onSuccess(String result, float confidence, boolean fromCache);
void onError(int errorCode, String errorMessage);
}
/* ========== 构造与初始化 ========== */
/**
* 创建OCR引擎实例
* @param context Android上下文
* @param cloudBaseUrl 云端AI引擎API地址
* @param accessToken API访问令牌
*/
public OCREngine(Context context, String cloudBaseUrl, String accessToken) {
mContext = context.getApplicationContext();
mCloudApiBaseUrl = cloudBaseUrl;
mApiAccessToken = accessToken;
/* 创建后台处理线程 */
mWorkerThread = new HandlerThread("WritechOCR");
mWorkerThread.start();
mWorkerHandler = new Handler(mWorkerThread.getLooper());
Log.i(TAG, "OCR引擎初始化完成,云端地址: " + cloudBaseUrl);
}
/**
* 加载离线ONNX识别模型
* 从assets或本地文件加载预训练的手写识别模型
*
* @param modelPath 模型文件路径(.onnx格式)
* @return 是否加载成功
*/
public boolean loadOfflineModel(String modelPath) {
File modelFile = new File(modelPath);
if (!modelFile.exists()) {
Log.e(TAG, "离线模型文件不存在: " + modelPath);
return false;
}
/* 通过JNI调用C层ONNX Runtime加载模型 */
mOnnxSessionHandle = nativeLoadModel(modelPath);
if (mOnnxSessionHandle != 0) {
mOnnxModelPath = modelPath;
mOfflineModelLoaded = true;
Log.i(TAG, "离线ONNX模型加载成功: " + modelPath);
return true;
}
Log.e(TAG, "离线ONNX模型加载失败");
return false;
}
/** 设置识别模式 */
public void setRecognitionMode(int mode) {
mRecognitionMode = mode;
}
/* ========== 识别请求接口 ========== */
/**
* 提交手写文字识别任务
* @param image 笔迹图像(已渲染的Bitmap)
* @param callback 结果回调
* @return 任务ID
*/
public int recognizeHandwriting(Bitmap image, RecognitionCallback callback) {
return submitTask(TYPE_HANDWRITING, image, null, null, callback);
}
/**
* 提交数学公式识别任务
* @param image 公式图像
* @param callback 结果回调
* @return 任务ID
*/
public int recognizeMath(Bitmap image, RecognitionCallback callback) {
return submitTask(TYPE_MATH, image, null, null, callback);
}
/**
* 提交笔顺评分任务
* @param strokeData 笔迹轨迹数据(序列化的坐标数组)
* @param targetChar 目标汉字
* @param callback 结果回调
* @return 任务ID
*/
public int evaluateStrokeOrder(byte[] strokeData, String targetChar,
RecognitionCallback callback) {
return submitTask(TYPE_STROKE_ORDER, null, strokeData, targetChar, callback);
}
/* ========== 任务管理 ========== */
private int mTaskIdCounter = 0;
/** 提交识别任务到队列 */
private int submitTask(int type, Bitmap image, byte[] strokeData,
String targetChar, RecognitionCallback callback) {
RecognitionTask task = new RecognitionTask();
task.taskId = ++mTaskIdCounter;
task.recognitionType = type;
task.inputImage = image;
task.strokeData = strokeData;
task.targetChar = targetChar;
task.callback = callback;
mTaskQueue.offer(task);
Log.d(TAG, "识别任务已提交 #" + task.taskId + " 类型=" + type);
/* 如果没有正在处理的任务,启动处理循环 */
if (mIsProcessing.compareAndSet(false, true)) {
mWorkerHandler.post(this::processNextTask);
}
return task.taskId;
}
/** 处理队列中的下一个任务 */
private void processNextTask() {
RecognitionTask task = mTaskQueue.poll();
if (task == null) {
mIsProcessing.set(false);
return;
}
Log.d(TAG, "开始处理识别任务 #" + task.taskId);
try {
/* 检查缓存 */
String cacheKey = computeCacheKey(task);
String cachedResult = lookupCache(cacheKey);
if (cachedResult != null) {
task.callback.onSuccess(cachedResult, 1.0f, true);
Log.d(TAG, "任务 #" + task.taskId + " 命中缓存");
mWorkerHandler.post(this::processNextTask);
return;
}
String result = null;
float confidence = 0.0f;
/* 根据识别模式选择执行路径 */
switch (mRecognitionMode) {
case MODE_ONLINE_ONLY:
result = executeCloudRecognition(task);
confidence = 0.95f;
break;
case MODE_OFFLINE_ONLY:
result = executeOfflineRecognition(task);
confidence = 0.85f;
break;
case MODE_AUTO:
default:
/* 自动模式:先尝试在线,失败则回退到离线 */
try {
result = executeCloudRecognition(task);
confidence = 0.95f;
} catch (Exception e) {
Log.w(TAG, "在线识别失败,回退到离线: " + e.getMessage());
result = executeOfflineRecognition(task);
confidence = 0.85f;
}
break;
}
if (result != null) {
/* 存入缓存 */
putCache(cacheKey, result);
task.callback.onSuccess(result, confidence, false);
} else {
task.callback.onError(-1, "识别失败,无可用结果");
}
} catch (Exception e) {
Log.e(TAG, "识别任务 #" + task.taskId + " 异常: " + e.getMessage());
task.callback.onError(-2, e.getMessage());
}
/* 继续处理下一个任务 */
mWorkerHandler.post(this::processNextTask);
}
/* ========== 云端识别 ========== */
/** 调用云端AI引擎执行识别 */
private String executeCloudRecognition(RecognitionTask task) throws IOException {
String apiPath;
switch (task.recognitionType) {
case TYPE_MATH:
apiPath = "/api/v1/math/recognize";
break;
case TYPE_STROKE_ORDER:
apiPath = "/api/v1/stroke-order/evaluate";
break;
case TYPE_HANDWRITING:
default:
apiPath = "/api/v1/ocr/recognize";
break;
}
String url = mCloudApiBaseUrl + apiPath;
Log.d(TAG, "调用云端识别API: " + url);
/* 构建multipart请求体 */
byte[] imageBytes = null;
if (task.inputImage != null) {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
task.inputImage.compress(Bitmap.CompressFormat.PNG, 100, baos);
imageBytes = baos.toByteArray();
}
/* 使用CloudClient发送HTTP请求 */
String responseJson = CloudClient.postMultipart(url, mApiAccessToken,
imageBytes, task.strokeData, task.targetChar, API_TIMEOUT_MS);
/* 解析JSON响应提取识别结果 */
return parseRecognitionResult(responseJson);
}
/* ========== 离线识别 ========== */
/** 使用本地ONNX模型执行离线识别 */
private String executeOfflineRecognition(RecognitionTask task) {
if (!mOfflineModelLoaded || mOnnxSessionHandle == 0) {
Log.e(TAG, "离线模型未加载");
return null;
}
if (task.inputImage == null) {
Log.e(TAG, "离线识别需要输入图像");
return null;
}
/* 图像预处理:缩放到模型输入尺寸,转为灰度float数组 */
float[] inputTensor = preprocessImage(task.inputImage);
/* 通过JNI调用ONNX Runtime执行推理 */
String result = nativeRunInference(mOnnxSessionHandle, inputTensor,
task.inputImage.getWidth(), task.inputImage.getHeight());
return result;
}
/** 图像预处理(缩放+归一化) */
private float[] preprocessImage(Bitmap bitmap) {
int targetWidth = 320;
int targetHeight = 48;
/* 保持宽高比缩放 */
float scale = Math.min(
(float) targetWidth / bitmap.getWidth(),
(float) targetHeight / bitmap.getHeight()
);
int scaledW = (int) (bitmap.getWidth() * scale);
int scaledH = (int) (bitmap.getHeight() * scale);
Bitmap scaled = Bitmap.createScaledBitmap(bitmap, scaledW, scaledH, true);
float[] tensor = new float[targetWidth * targetHeight];
/* 填充灰度值并归一化到[0, 1] */
for (int y = 0; y < scaledH && y < targetHeight; y++) {
for (int x = 0; x < scaledW && x < targetWidth; x++) {
int pixel = scaled.getPixel(x, y);
/* 灰度化:0.299R + 0.587G + 0.114B */
float gray = (0.299f * ((pixel >> 16) & 0xFF)
+ 0.587f * ((pixel >> 8) & 0xFF)
+ 0.114f * (pixel & 0xFF)) / 255.0f;
tensor[y * targetWidth + x] = gray;
}
}
scaled.recycle();
return tensor;
}
/* ========== 结果缓存 ========== */
/** 计算缓存键 */
private String computeCacheKey(RecognitionTask task) {
if (task.inputImage != null) {
return "img_" + task.recognitionType + "_" + task.inputImage.hashCode();
}
if (task.strokeData != null && task.targetChar != null) {
return "stroke_" + task.targetChar + "_" + task.strokeData.length;
}
return "unknown_" + task.taskId;
}
/** 查找缓存 */
private String lookupCache(String key) {
synchronized (mResultCache) {
for (CacheEntry entry : mResultCache) {
if (entry.cacheKey.equals(key)) {
/* 检查过期(5分钟) */
if (System.currentTimeMillis() - entry.timestamp < 300000) {
return entry.result;
}
}
}
}
return null;
}
/** 存入缓存 */
private void putCache(String key, String result) {
synchronized (mResultCache) {
CacheEntry entry = new CacheEntry();
entry.cacheKey = key;
entry.result = result;
entry.timestamp = System.currentTimeMillis();
mResultCache.addFirst(entry);
/* 限制缓存大小 */
while (mResultCache.size() > MAX_CACHE_SIZE) {
mResultCache.removeLast();
}
}
}
/** 解析云端识别API返回的JSON */
private String parseRecognitionResult(String json) {
if (json == null || json.isEmpty()) return null;
/* 简化的JSON解析:提取result字段 */
int idx = json.indexOf("\"result\"");
if (idx < 0) return null;
int start = json.indexOf("\"", idx + 8) + 1;
int end = json.indexOf("\"", start);
if (start > 0 && end > start) {
return json.substring(start, end);
}
return null;
}
/* ========== JNI本地方法声明 ========== */
/** 加载ONNX模型,返回会话句柄 */
private native long nativeLoadModel(String modelPath);
/** 执行ONNX推理,返回识别结果JSON */
private native String nativeRunInference(long sessionHandle, float[] inputTensor,
int width, int height);
/** 释放ONNX会话资源 */
private native void nativeReleaseModel(long sessionHandle);
static {
System.loadLibrary("writech_ocr");
}
/* ========== 资源释放 ========== */
/** 释放OCR引擎资源 */
public void destroy() {
mTaskQueue.clear();
if (mOnnxSessionHandle != 0) {
nativeReleaseModel(mOnnxSessionHandle);
mOnnxSessionHandle = 0;
}
if (mWorkerThread != null) {
mWorkerThread.quitSafely();
mWorkerThread = null;
}
mResultCache.clear();
Log.i(TAG, "OCR引擎资源已释放");
}
}
```
#### `android/PenManager.java`
```java
/*
* 自然写互动课堂应用开发SDK软件 V1.0
* PenManager - Android端蓝牙点阵笔连接管理器
*
* 功能说明:
* 1. BLE 5.0蓝牙扫描与自动连接
* 2. GATT服务发现与特征值订阅
* 3. 点阵笔数据实时接收与解析
* 4. 多笔同时连接管理(最多支持60支)
* 5. 连接状态监控与自动重连
* 6. 电量/固件版本/设备信息查询
*/
package com.writech.sdk.android;
import android.bluetooth.BluetoothAdapter;
import android.bluetooth.BluetoothDevice;
import android.bluetooth.BluetoothGatt;
import android.bluetooth.BluetoothGattCallback;
import android.bluetooth.BluetoothGattCharacteristic;
import android.bluetooth.BluetoothGattDescriptor;
import android.bluetooth.BluetoothGattService;
import android.bluetooth.BluetoothManager;
import android.bluetooth.BluetoothProfile;
import android.bluetooth.le.BluetoothLeScanner;
import android.bluetooth.le.ScanCallback;
import android.bluetooth.le.ScanFilter;
import android.bluetooth.le.ScanResult;
import android.bluetooth.le.ScanSettings;
import android.content.Context;
import android.os.Handler;
import android.os.HandlerThread;
import android.os.ParcelUuid;
import android.util.Log;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CopyOnWriteArrayList;
/**
* 点阵笔蓝牙连接管理器
* 负责BLE扫描、连接、数据接收的全生命周期管理
*/
public class PenManager {
private static final String TAG = "WritechPenManager";
/* 自然写点阵笔GATT服务UUID(自定义) */
private static final UUID PEN_SERVICE_UUID =
UUID.fromString("0000FFE0-0000-1000-8000-00805F9B34FB");
/* 笔迹数据通知特征值UUID */
private static final UUID STROKE_DATA_CHAR_UUID =
UUID.fromString("0000FFE1-0000-1000-8000-00805F9B34FB");
/* 笔控制指令写入特征值UUID */
private static final UUID PEN_CONTROL_CHAR_UUID =
UUID.fromString("0000FFE2-0000-1000-8000-00805F9B34FB");
/* 设备信息特征值UUID(电量/固件版本) */
private static final UUID DEVICE_INFO_CHAR_UUID =
UUID.fromString("0000FFE3-0000-1000-8000-00805F9B34FB");
/* CCCD描述符UUID,用于启用通知 */
private static final UUID CCCD_UUID =
UUID.fromString("00002902-0000-1000-8000-00805F9B34FB");
/* 最大同时连接数 */
private static final int MAX_CONNECTIONS = 60;
/* 自动重连延迟(毫秒) */
private static final long RECONNECT_DELAY_MS = 3000;
/* 扫描超时时间(毫秒) */
private static final long SCAN_TIMEOUT_MS = 30000;
/* ========== 成员变量 ========== */
private final Context mContext;
private final BluetoothAdapter mBluetoothAdapter;
private BluetoothLeScanner mScanner;
/* 已连接的笔设备映射表(MAC地址 → GATT连接) */
private final Map<String, BluetoothGatt> mConnectedPens = new ConcurrentHashMap<>();
/* 等待重连的设备列表 */
private final Map<String, Integer> mReconnectAttempts = new ConcurrentHashMap<>();
/* 设备信息缓存(MAC地址 → 设备模型) */
private final Map<String, PenDeviceInfo> mDeviceInfoCache = new ConcurrentHashMap<>();
/* 数据回调监听器列表 */
private final List<PenDataListener> mDataListeners = new CopyOnWriteArrayList<>();
/* 连接状态监听器列表 */
private final List<PenConnectionListener> mConnectionListeners = new CopyOnWriteArrayList<>();
/* BLE操作专用线程 */
private HandlerThread mBleThread;
private Handler mBleHandler;
/* 扫描状态标志 */
private volatile boolean mIsScanning = false;
/* ========== 内部数据结构 ========== */
/** 笔设备信息缓存 */
private static class PenDeviceInfo {
String macAddress; /* MAC地址 */
String penName; /* 笔名称 */
String firmwareVersion; /* 固件版本 */
int batteryLevel; /* 电量百分比 */
long lastDataTimestamp; /* 最后一次收到数据的时间 */
boolean isWriting; /* 是否正在书写 */
}
/* ========== 对外回调接口 ========== */
/** 笔迹数据监听器 */
public interface PenDataListener {
/** 收到笔迹坐标数据 */
void onStrokeData(String penMac, int x, int y, int pressure, long timestamp);
/** 笔抬起事件(一笔结束) */
void onPenUp(String penMac, long timestamp);
/** 笔落下事件(一笔开始) */
void onPenDown(String penMac, long timestamp);
}
/** 连接状态监听器 */
public interface PenConnectionListener {
void onPenConnected(String penMac, String penName);
void onPenDisconnected(String penMac, int reason);
void onPenDiscovered(String penMac, String penName, int rssi);
void onBatteryUpdate(String penMac, int batteryPercent);
}
/* ========== 构造与初始化 ========== */
/**
* 创建笔管理器实例
* @param context Android上下文(需要蓝牙权限)
*/
public PenManager(Context context) {
mContext = context.getApplicationContext();
BluetoothManager btManager =
(BluetoothManager) mContext.getSystemService(Context.BLUETOOTH_SERVICE);
mBluetoothAdapter = btManager.getAdapter();
/* 创建BLE操作专用后台线程 */
mBleThread = new HandlerThread("WritechBLE");
mBleThread.start();
mBleHandler = new Handler(mBleThread.getLooper());
Log.i(TAG, "PenManager初始化完成,蓝牙状态: "
+ (mBluetoothAdapter.isEnabled() ? "已开启" : "未开启"));
}
/** 注册笔迹数据监听器 */
public void addDataListener(PenDataListener listener) {
if (listener != null && !mDataListeners.contains(listener)) {
mDataListeners.add(listener);
}
}
/** 移除笔迹数据监听器 */
public void removeDataListener(PenDataListener listener) {
mDataListeners.remove(listener);
}
/** 注册连接状态监听器 */
public void addConnectionListener(PenConnectionListener listener) {
if (listener != null && !mConnectionListeners.contains(listener)) {
mConnectionListeners.add(listener);
}
}
/* ========== BLE扫描 ========== */
/**
* 开始扫描附近的自然写点阵笔
* 使用低延迟模式扫描BLE设备,按服务UUID过滤
*/
public void startScan() {
if (mIsScanning) {
Log.w(TAG, "扫描已在进行中,忽略重复请求");
return;
}
if (!mBluetoothAdapter.isEnabled()) {
Log.e(TAG, "蓝牙未开启,无法扫描");
return;
}
mScanner = mBluetoothAdapter.getBluetoothLeScanner();
if (mScanner == null) {
Log.e(TAG, "获取BLE扫描器失败");
return;
}
/* 构建扫描过滤器:仅扫描包含自然写服务UUID的设备 */
ScanFilter filter = new ScanFilter.Builder()
.setServiceUuid(new ParcelUuid(PEN_SERVICE_UUID))
.build();
List<ScanFilter> filters = Collections.singletonList(filter);
/* 低延迟扫描设置(耗电较高,适合主动扫描场景) */
ScanSettings settings = new ScanSettings.Builder()
.setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY)
.setCallbackType(ScanSettings.CALLBACK_TYPE_ALL_MATCHES)
.setMatchMode(ScanSettings.MATCH_MODE_AGGRESSIVE)
.build();
mScanner.startScan(filters, settings, mScanCallback);
mIsScanning = true;
/* 设置扫描超时,避免长时间扫描耗电 */
mBleHandler.postDelayed(this::stopScan, SCAN_TIMEOUT_MS);
Log.i(TAG, "开始扫描自然写点阵笔...");
}
/** 停止BLE扫描 */
public void stopScan() {
if (mIsScanning && mScanner != null) {
mScanner.stopScan(mScanCallback);
mIsScanning = false;
Log.i(TAG, "停止扫描");
}
}
/** BLE扫描回调 */
private final ScanCallback mScanCallback = new ScanCallback() {
@Override
public void onScanResult(int callbackType, ScanResult result) {
BluetoothDevice device = result.getDevice();
String mac = device.getAddress();
String name = device.getName();
int rssi = result.getRssi();
if (name == null || name.isEmpty()) {
name = "WritechPen-" + mac.substring(mac.length() - 5);
}
/* 通知上层发现了新的笔设备 */
for (PenConnectionListener listener : mConnectionListeners) {
listener.onPenDiscovered(mac, name, rssi);
}
Log.d(TAG, "发现笔设备: " + name + " [" + mac + "] RSSI=" + rssi);
}
@Override
public void onScanFailed(int errorCode) {
mIsScanning = false;
Log.e(TAG, "BLE扫描失败,错误码: " + errorCode);
}
};
/* ========== BLE连接管理 ========== */
/**
* 连接指定MAC地址的点阵笔
* @param macAddress 设备MAC地址
*/
public void connectPen(String macAddress) {
if (mConnectedPens.size() >= MAX_CONNECTIONS) {
Log.w(TAG, "已达最大连接数 " + MAX_CONNECTIONS + ",拒绝新连接");
return;
}
if (mConnectedPens.containsKey(macAddress)) {
Log.w(TAG, "设备已连接: " + macAddress);
return;
}
BluetoothDevice device = mBluetoothAdapter.getRemoteDevice(macAddress);
/* 使用TRANSPORT_LE确保走BLE通道,autoConnect=false立即连接 */
device.connectGatt(mContext, false, mGattCallback, BluetoothDevice.TRANSPORT_LE);
Log.i(TAG, "正在连接笔设备: " + macAddress);
}
/** 断开指定笔的连接 */
public void disconnectPen(String macAddress) {
BluetoothGatt gatt = mConnectedPens.remove(macAddress);
if (gatt != null) {
gatt.disconnect();
gatt.close();
mReconnectAttempts.remove(macAddress);
Log.i(TAG, "已断开笔设备: " + macAddress);
}
}
/** 断开所有已连接的笔 */
public void disconnectAll() {
for (Map.Entry<String, BluetoothGatt> entry : mConnectedPens.entrySet()) {
entry.getValue().disconnect();
entry.getValue().close();
}
mConnectedPens.clear();
mReconnectAttempts.clear();
Log.i(TAG, "已断开所有笔设备");
}
/** 获取当前已连接的笔数量 */
public int getConnectedCount() {
return mConnectedPens.size();
}
/** 获取所有已连接笔的MAC地址列表 */
public List<String> getConnectedPenMacs() {
return new ArrayList<>(mConnectedPens.keySet());
}
/* ========== GATT回调处理 ========== */
/**
* GATT连接/数据回调
* 处理连接状态变化、服务发现、数据通知等所有BLE事件
*/
private final BluetoothGattCallback mGattCallback = new BluetoothGattCallback() {
@Override
public void onConnectionStateChange(BluetoothGatt gatt, int status, int newState) {
String mac = gatt.getDevice().getAddress();
if (newState == BluetoothProfile.STATE_CONNECTED) {
/* 连接成功,开始发现GATT服务 */
mConnectedPens.put(mac, gatt);
mReconnectAttempts.remove(mac);
gatt.discoverServices();
String name = gatt.getDevice().getName();
for (PenConnectionListener listener : mConnectionListeners) {
listener.onPenConnected(mac, name != null ? name : "Unknown");
}
Log.i(TAG, "笔设备连接成功: " + mac + ",正在发现服务...");
} else if (newState == BluetoothProfile.STATE_DISCONNECTED) {
/* 连接断开,尝试自动重连 */
mConnectedPens.remove(mac);
gatt.close();
for (PenConnectionListener listener : mConnectionListeners) {
listener.onPenDisconnected(mac, status);
}
Log.w(TAG, "笔设备断开: " + mac + ",状态码: " + status);
/* 自动重连逻辑(最多尝试5次) */
scheduleReconnect(mac);
}
}
@Override
public void onServicesDiscovered(BluetoothGatt gatt, int status) {
if (status != BluetoothGatt.GATT_SUCCESS) {
Log.e(TAG, "GATT服务发现失败: " + status);
return;
}
/* 查找自然写笔迹数据服务 */
BluetoothGattService penService = gatt.getService(PEN_SERVICE_UUID);
if (penService == null) {
Log.e(TAG, "未找到自然写笔服务,设备可能不兼容");
return;
}
/* 订阅笔迹数据通知特征值 */
BluetoothGattCharacteristic strokeChar =
penService.getCharacteristic(STROKE_DATA_CHAR_UUID);
if (strokeChar != null) {
gatt.setCharacteristicNotification(strokeChar, true);
/* 写入CCCD描述符启用通知 */
BluetoothGattDescriptor cccd = strokeChar.getDescriptor(CCCD_UUID);
if (cccd != null) {
cccd.setValue(BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE);
gatt.writeDescriptor(cccd);
}
Log.i(TAG, "已订阅笔迹数据通知");
}
/* 读取设备信息(电量、固件版本) */
BluetoothGattCharacteristic infoChar =
penService.getCharacteristic(DEVICE_INFO_CHAR_UUID);
if (infoChar != null) {
mBleHandler.postDelayed(() -> gatt.readCharacteristic(infoChar), 500);
}
}
@Override
public void onCharacteristicChanged(BluetoothGatt gatt,
BluetoothGattCharacteristic characteristic) {
String mac = gatt.getDevice().getAddress();
UUID charUuid = characteristic.getUuid();
if (STROKE_DATA_CHAR_UUID.equals(charUuid)) {
/* 收到笔迹数据通知,解析并分发 */
byte[] data = characteristic.getValue();
parseAndDispatchStrokeData(mac, data);
}
}
@Override
public void onCharacteristicRead(BluetoothGatt gatt,
BluetoothGattCharacteristic characteristic,
int status) {
if (status != BluetoothGatt.GATT_SUCCESS) return;
String mac = gatt.getDevice().getAddress();
UUID charUuid = characteristic.getUuid();
if (DEVICE_INFO_CHAR_UUID.equals(charUuid)) {
/* 解析设备信息数据 */
byte[] data = characteristic.getValue();
parseDeviceInfo(mac, data);
}
}
};
/* ========== 数据解析与分发 ========== */
/**
* 解析BLE收到的笔迹数据帧并分发给监听器
* 数据格式(7字节紧凑编码):
* [0-1] X坐标高16位 [2-3] Y坐标高16位
* [4] X低4位|Y低4位 [5] 压力高8位 [6] 压力低4位|标志
*/
private void parseAndDispatchStrokeData(String penMac, byte[] data) {
if (data == null || data.length < 7) {
return;
}
long timestamp = System.currentTimeMillis();
/* 检查帧类型标志(最低2位) */
int flags = data[6] & 0x03;
if (flags == 0x01) {
/* 笔落下事件 */
for (PenDataListener listener : mDataListeners) {
listener.onPenDown(penMac, timestamp);
}
return;
}
if (flags == 0x02) {
/* 笔抬起事件 */
for (PenDataListener listener : mDataListeners) {
listener.onPenUp(penMac, timestamp);
}
return;
}
/* 坐标数据帧(flags == 0x00 */
int xHigh = ((data[0] & 0xFF) << 8) | (data[1] & 0xFF);
int xLow = (data[4] >> 4) & 0x0F;
int x = (xHigh << 4) | xLow;
int yHigh = ((data[2] & 0xFF) << 8) | (data[3] & 0xFF);
int yLow = data[4] & 0x0F;
int y = (yHigh << 4) | yLow;
int pHigh = data[5] & 0xFF;
int pLow = (data[6] >> 4) & 0x0F;
int pressure = (pHigh << 4) | pLow;
/* 更新设备状态 */
PenDeviceInfo info = mDeviceInfoCache.get(penMac);
if (info != null) {
info.lastDataTimestamp = timestamp;
info.isWriting = true;
}
/* 分发到所有监听器 */
for (PenDataListener listener : mDataListeners) {
listener.onStrokeData(penMac, x, y, pressure, timestamp);
}
}
/** 解析设备信息特征值数据 */
private void parseDeviceInfo(String penMac, byte[] data) {
if (data == null || data.length < 4) return;
PenDeviceInfo info = mDeviceInfoCache.get(penMac);
if (info == null) {
info = new PenDeviceInfo();
info.macAddress = penMac;
mDeviceInfoCache.put(penMac, info);
}
/* 第一字节:电量百分比 */
info.batteryLevel = data[0] & 0xFF;
/* 第2-4字节:固件版本 major.minor.patch */
info.firmwareVersion = (data[1] & 0xFF) + "." + (data[2] & 0xFF)
+ "." + (data[3] & 0xFF);
/* 通知电量更新 */
for (PenConnectionListener listener : mConnectionListeners) {
listener.onBatteryUpdate(penMac, info.batteryLevel);
}
Log.i(TAG, "设备信息 [" + penMac + "] 电量:" + info.batteryLevel
+ "% 固件:" + info.firmwareVersion);
}
/* ========== 自动重连 ========== */
/** 安排自动重连(指数退避) */
private void scheduleReconnect(String macAddress) {
Integer attempts = mReconnectAttempts.getOrDefault(macAddress, 0);
if (attempts >= 5) {
Log.w(TAG, "设备 " + macAddress + " 重连次数已达上限,放弃重连");
mReconnectAttempts.remove(macAddress);
return;
}
mReconnectAttempts.put(macAddress, attempts + 1);
/* 指数退避:3s, 6s, 12s, 24s, 48s */
long delay = RECONNECT_DELAY_MS * (1L << attempts);
mBleHandler.postDelayed(() -> {
if (!mConnectedPens.containsKey(macAddress)) {
Log.i(TAG, "尝试重连设备: " + macAddress + "(第" + (attempts + 1) + "次)");
connectPen(macAddress);
}
}, delay);
}
/* ========== 控制指令发送 ========== */
/**
* 向笔发送控制指令
* @param macAddress 目标笔MAC
* @param command 指令字节数组
* @return 是否发送成功
*/
public boolean sendCommand(String macAddress, byte[] command) {
BluetoothGatt gatt = mConnectedPens.get(macAddress);
if (gatt == null) {
Log.w(TAG, "设备未连接,无法发送指令: " + macAddress);
return false;
}
BluetoothGattService service = gatt.getService(PEN_SERVICE_UUID);
if (service == null) return false;
BluetoothGattCharacteristic controlChar =
service.getCharacteristic(PEN_CONTROL_CHAR_UUID);
if (controlChar == null) return false;
controlChar.setValue(command);
controlChar.setWriteType(BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT);
return gatt.writeCharacteristic(controlChar);
}
/** 查询笔电量 */
public int getBatteryLevel(String macAddress) {
PenDeviceInfo info = mDeviceInfoCache.get(macAddress);
return info != null ? info.batteryLevel : -1;
}
/* ========== 资源释放 ========== */
/** 释放PenManager资源 */
public void destroy() {
stopScan();
disconnectAll();
mDataListeners.clear();
mConnectionListeners.clear();
mDeviceInfoCache.clear();
if (mBleThread != null) {
mBleThread.quitSafely();
mBleThread = null;
}
Log.i(TAG, "PenManager资源已释放");
}
}
```
#### `android/StrokeCanvas.java`
```java
/*
* 自然写互动课堂应用开发SDK软件 V1.0
* StrokeCanvas - Android端笔迹渲染自定义View
*
* 功能说明:
* 1. 实时笔迹渲染(贝塞尔曲线平滑绘制)
* 2. 压力感应笔锋效果(根据压力值动态调整线宽)
* 3. 多笔同屏渲染(不同颜色区分不同学生)
* 4. 笔迹重播动画(按时间序列回放书写过程)
* 5. 离屏缓冲双缓冲渲染(避免闪烁)
* 6. 触摸与点阵笔混合输入支持
*/
package com.writech.sdk.android;
import android.content.Context;
import android.graphics.Bitmap;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.Paint;
import android.graphics.Path;
import android.graphics.PorterDuff;
import android.graphics.RectF;
import android.os.SystemClock;
import android.util.AttributeSet;
import android.view.MotionEvent;
import android.view.View;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* 笔迹渲染画布组件
* 支持实时绘制点阵笔和触摸屏输入的笔迹数据
*/
public class StrokeCanvas extends View {
private static final String TAG = "WritechStrokeCanvas";
/* 默认画笔颜色 */
private static final int DEFAULT_STROKE_COLOR = Color.BLACK;
/* 默认最小线宽(像素) */
private static final float MIN_STROKE_WIDTH = 1.5f;
/* 默认最大线宽(像素) */
private static final float MAX_STROKE_WIDTH = 8.0f;
/* 最大压力值(点阵笔12位ADC) */
private static final float MAX_PRESSURE = 4095.0f;
/* ========== 内部数据结构 ========== */
/** 单个采样点(包含坐标、压力、时间戳) */
private static class StrokePoint {
float x;
float y;
float pressure; /* 归一化压力 0.0~1.0 */
long timestamp; /* 毫秒时间戳 */
StrokePoint(float x, float y, float pressure, long timestamp) {
this.x = x;
this.y = y;
this.pressure = pressure;
this.timestamp = timestamp;
}
}
/** 一笔数据(从落笔到抬笔) */
private static class Stroke {
String penMac; /* 来源笔MAC地址 */
int color; /* 笔迹颜色 */
List<StrokePoint> points; /* 采样点列表 */
Stroke(String penMac, int color) {
this.penMac = penMac;
this.color = color;
this.points = new ArrayList<>();
}
}
/* ========== 成员变量 ========== */
/* 离屏缓冲Bitmap(双缓冲渲染) */
private Bitmap mBufferBitmap;
private Canvas mBufferCanvas;
/* 绘制画笔 */
private final Paint mStrokePaint;
/* 背景清除画笔 */
private final Paint mClearPaint;
/* 已完成的笔画列表(历史记录) */
private final List<Stroke> mCompletedStrokes = new ArrayList<>();
/* 当前正在书写的笔画(按笔MAC索引) */
private final Map<String, Stroke> mActiveStrokes = new HashMap<>();
/* 每支笔的颜色映射 */
private final Map<String, Integer> mPenColorMap = new HashMap<>();
/* 笔迹颜色分配计数器 */
private int mColorIndex = 0;
/* 预定义的笔迹颜色列表(用于多学生区分) */
private static final int[] STROKE_COLORS = {
Color.BLACK,
Color.parseColor("#1565C0"), /* 蓝色 */
Color.parseColor("#C62828"), /* 红色 */
Color.parseColor("#2E7D32"), /* 绿色 */
Color.parseColor("#E65100"), /* 橙色 */
Color.parseColor("#6A1B9A"), /* 紫色 */
Color.parseColor("#00838F"), /* 青色 */
Color.parseColor("#4E342E"), /* 棕色 */
};
/* 是否启用压力感应笔锋 */
private boolean mPressureEnabled = true;
/* 笔迹重播相关 */
private boolean mIsReplaying = false;
private int mReplayStrokeIndex = 0;
private int mReplayPointIndex = 0;
private long mReplayStartTime = 0;
/* ========== 构造函数 ========== */
public StrokeCanvas(Context context) {
this(context, null);
}
public StrokeCanvas(Context context, AttributeSet attrs) {
super(context, attrs);
/* 初始化笔迹画笔 */
mStrokePaint = new Paint();
mStrokePaint.setAntiAlias(true); /* 抗锯齿 */
mStrokePaint.setDither(true); /* 防抖动 */
mStrokePaint.setStyle(Paint.Style.STROKE);
mStrokePaint.setStrokeJoin(Paint.Join.ROUND); /* 圆角连接 */
mStrokePaint.setStrokeCap(Paint.Cap.ROUND); /* 圆头笔触 */
/* 初始化清除画笔 */
mClearPaint = new Paint();
mClearPaint.setColor(Color.WHITE);
}
/* ========== View生命周期 ========== */
@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
super.onSizeChanged(w, h, oldw, oldh);
/* 创建离屏缓冲Bitmap */
if (mBufferBitmap != null) {
mBufferBitmap.recycle();
}
mBufferBitmap = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888);
mBufferCanvas = new Canvas(mBufferBitmap);
mBufferCanvas.drawColor(Color.WHITE);
/* 重绘所有历史笔画到缓冲区 */
redrawAllStrokes();
}
@Override
protected void onDraw(Canvas canvas) {
/* 将离屏缓冲Bitmap绘制到屏幕 */
if (mBufferBitmap != null) {
canvas.drawBitmap(mBufferBitmap, 0, 0, null);
}
/* 绘制当前活跃的笔画(实时部分) */
for (Stroke stroke : mActiveStrokes.values()) {
drawStrokeRealtime(canvas, stroke);
}
}
/* ========== 点阵笔数据输入接口 ========== */
/**
* 接收笔落下事件(开始新的一笔)
* @param penMac 笔设备MAC地址
*/
public void onPenDown(String penMac) {
int color = getPenColor(penMac);
Stroke stroke = new Stroke(penMac, color);
mActiveStrokes.put(penMac, stroke);
}
/**
* 接收笔迹坐标数据
* @param penMac 笔MAC
* @param screenX 屏幕X坐标(已经过坐标变换)
* @param screenY 屏幕Y坐标
* @param pressure 原始压力值(0-4095
*/
public void onStrokePoint(String penMac, float screenX, float screenY,
int pressure) {
Stroke stroke = mActiveStrokes.get(penMac);
if (stroke == null) {
/* 如果没有活跃笔画,自动创建 */
onPenDown(penMac);
stroke = mActiveStrokes.get(penMac);
}
/* 归一化压力值 */
float normalizedPressure = Math.min(1.0f, (float) pressure / MAX_PRESSURE);
long timestamp = SystemClock.elapsedRealtime();
stroke.points.add(new StrokePoint(screenX, screenY, normalizedPressure, timestamp));
/* 触发重绘(仅绘制增量部分,避免全量刷新) */
int pointCount = stroke.points.size();
if (pointCount >= 2) {
StrokePoint prev = stroke.points.get(pointCount - 2);
StrokePoint curr = stroke.points.get(pointCount - 1);
/* 仅刷新受影响的矩形区域(性能优化) */
float padding = MAX_STROKE_WIDTH + 2;
float left = Math.min(prev.x, curr.x) - padding;
float top = Math.min(prev.y, curr.y) - padding;
float right = Math.max(prev.x, curr.x) + padding;
float bottom = Math.max(prev.y, curr.y) + padding;
invalidate((int) left, (int) top, (int) right, (int) bottom);
}
}
/**
* 接收笔抬起事件(一笔结束)
* 将当前笔画固化到缓冲区并归档
*/
public void onPenUp(String penMac) {
Stroke stroke = mActiveStrokes.remove(penMac);
if (stroke != null && stroke.points.size() > 1) {
/* 绘制到离屏缓冲区(固化) */
drawStrokeToBuffer(stroke);
/* 添加到已完成列表 */
mCompletedStrokes.add(stroke);
}
invalidate();
}
/* ========== 笔迹渲染核心算法 ========== */
/**
* 实时渲染笔画(使用贝塞尔曲线平滑)
* 在每次onDraw中调用,绘制当前活跃的笔画
*/
private void drawStrokeRealtime(Canvas canvas, Stroke stroke) {
List<StrokePoint> points = stroke.points;
if (points.size() < 2) return;
mStrokePaint.setColor(stroke.color);
for (int i = 1; i < points.size(); i++) {
StrokePoint p0 = points.get(i - 1);
StrokePoint p1 = points.get(i);
/* 根据压力计算线宽 */
float width = calculateStrokeWidth(p0.pressure, p1.pressure);
mStrokePaint.setStrokeWidth(width);
if (i >= 2) {
/* 使用二次贝塞尔曲线平滑绘制 */
StrokePoint pPrev = points.get(i - 2);
float midX0 = (pPrev.x + p0.x) / 2;
float midY0 = (pPrev.y + p0.y) / 2;
float midX1 = (p0.x + p1.x) / 2;
float midY1 = (p0.y + p1.y) / 2;
Path path = new Path();
path.moveTo(midX0, midY0);
path.quadTo(p0.x, p0.y, midX1, midY1);
canvas.drawPath(path, mStrokePaint);
} else {
/* 前两个点直接画直线 */
canvas.drawLine(p0.x, p0.y, p1.x, p1.y, mStrokePaint);
}
}
}
/**
* 将完成的笔画绘制到离屏缓冲区
*/
private void drawStrokeToBuffer(Stroke stroke) {
if (mBufferCanvas == null) return;
drawStrokeRealtime(mBufferCanvas, stroke);
}
/**
* 根据压力值计算线宽(笔锋效果)
* 使用两个相邻点的平均压力,平滑过渡
*
* @param pressure0 前一点压力(归一化)
* @param pressure1 当前点压力(归一化)
* @return 线宽(像素)
*/
private float calculateStrokeWidth(float pressure0, float pressure1) {
if (!mPressureEnabled) {
return (MIN_STROKE_WIDTH + MAX_STROKE_WIDTH) / 2;
}
float avgPressure = (pressure0 + pressure1) / 2.0f;
/* 压力-宽度映射曲线(使用幂函数增加笔锋感) */
float normalized = (float) Math.pow(avgPressure, 0.7);
return MIN_STROKE_WIDTH + normalized * (MAX_STROKE_WIDTH - MIN_STROKE_WIDTH);
}
/* ========== 多笔颜色管理 ========== */
/** 获取或分配笔的颜色 */
private int getPenColor(String penMac) {
Integer color = mPenColorMap.get(penMac);
if (color == null) {
color = STROKE_COLORS[mColorIndex % STROKE_COLORS.length];
mPenColorMap.put(penMac, color);
mColorIndex++;
}
return color;
}
/** 手动设置某支笔的颜色 */
public void setPenColor(String penMac, int color) {
mPenColorMap.put(penMac, color);
}
/* ========== 画布操作 ========== */
/** 清除所有笔迹 */
public void clearAll() {
mCompletedStrokes.clear();
mActiveStrokes.clear();
if (mBufferCanvas != null) {
mBufferCanvas.drawColor(Color.WHITE);
}
invalidate();
}
/** 撤销最后一笔 */
public boolean undo() {
if (mCompletedStrokes.isEmpty()) return false;
mCompletedStrokes.remove(mCompletedStrokes.size() - 1);
redrawAllStrokes();
invalidate();
return true;
}
/** 重绘所有历史笔画到缓冲区 */
private void redrawAllStrokes() {
if (mBufferCanvas == null) return;
mBufferCanvas.drawColor(Color.WHITE);
for (Stroke stroke : mCompletedStrokes) {
drawStrokeToBuffer(stroke);
}
}
/** 导出当前画布为Bitmap */
public Bitmap exportBitmap() {
Bitmap export = Bitmap.createBitmap(getWidth(), getHeight(), Bitmap.Config.ARGB_8888);
Canvas exportCanvas = new Canvas(export);
draw(exportCanvas);
return export;
}
/** 获取已完成的笔画数量 */
public int getStrokeCount() {
return mCompletedStrokes.size();
}
/** 设置是否启用压力笔锋效果 */
public void setPressureEnabled(boolean enabled) {
mPressureEnabled = enabled;
}
/* ========== 触摸屏输入支持 ========== */
@Override
public boolean onTouchEvent(MotionEvent event) {
/* 使用"touch"作为虚拟笔MAC */
String touchMac = "touch_input";
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
onPenDown(touchMac);
onStrokePoint(touchMac, event.getX(), event.getY(),
(int)(event.getPressure() * MAX_PRESSURE));
return true;
case MotionEvent.ACTION_MOVE:
/* 处理历史点(Android会批量发送MOVE事件) */
for (int i = 0; i < event.getHistorySize(); i++) {
onStrokePoint(touchMac,
event.getHistoricalX(i),
event.getHistoricalY(i),
(int)(event.getHistoricalPressure(i) * MAX_PRESSURE));
}
onStrokePoint(touchMac, event.getX(), event.getY(),
(int)(event.getPressure() * MAX_PRESSURE));
return true;
case MotionEvent.ACTION_UP:
onStrokePoint(touchMac, event.getX(), event.getY(),
(int)(event.getPressure() * MAX_PRESSURE));
onPenUp(touchMac);
return true;
}
return super.onTouchEvent(event);
}
}
```
#### `android/WritechSDK.java`
```java
/*
* 自然写互动课堂应用开发SDK软件 V1.0
* WritechSDK - SDK初始化与鉴权入口
*
* 功能说明:
* 1. SDK全局初始化(配置加载、模块注册)
* 2. 应用鉴权(AppKey/AppSecret验证)
* 3. 各子模块生命周期管理
* 4. 全局配置管理(服务器地址、超时、日志级别)
* 5. SDK版本信息与功能授权查询
*/
package com.writech.sdk.android;
import android.content.Context;
import android.content.SharedPreferences;
import android.util.Log;
import java.io.IOException;
import java.util.concurrent.atomic.AtomicBoolean;
/**
* 自然写SDK主入口类
* 使用前必须先调用 init() 方法进行初始化和鉴权
*
* 典型使用流程:
* 1. WritechSDK.init(context, config)
* 2. WritechSDK.getInstance().getPenManager().startScan()
* 3. WritechSDK.getInstance().getOCREngine().recognizeHandwriting(...)
*/
public class WritechSDK {
private static final String TAG = "WritechSDK";
/* SDK版本号 */
public static final String SDK_VERSION = "1.0.0";
/* SDK构建号 */
public static final int SDK_BUILD = 100;
/* 单例实例 */
private static volatile WritechSDK sInstance;
/* 是否已初始化 */
private static final AtomicBoolean sInitialized = new AtomicBoolean(false);
/* ========== 配置类 ========== */
/** SDK初始化配置 */
public static class Config {
/** 云平台API地址 */
public String cloudBaseUrl = "https://api.writech.com";
/** SDK应用标识(从自然写开放平台获取) */
public String appKey;
/** SDK应用密钥 */
public String appSecret;
/** 离线OCR模型文件路径(可选) */
public String offlineModelPath;
/** 是否启用调试日志 */
public boolean debugMode = false;
/** 笔迹数据本地缓存目录 */
public String cacheDir;
/** BLE扫描超时时间(毫秒) */
public int bleScanTimeout = 30000;
/** 网关自动发现 */
public boolean autoDiscoverGateway = true;
/** 最大同时连接笔数 */
public int maxPenConnections = 60;
}
/* ========== 成员变量 ========== */
private Context mContext;
private Config mConfig;
/* 各子模块实例 */
private PenManager mPenManager;
private StrokeCanvas mDefaultCanvas;
private OCREngine mOCREngine;
private GatewaySDK mGatewaySDK;
private CloudClient mCloudClient;
/* 鉴权状态 */
private boolean mIsAuthenticated = false;
private String mLicenseType; /* 授权类型: trial/standard/enterprise */
private long mLicenseExpireTime; /* 授权到期时间 */
/* 本地存储 */
private SharedPreferences mPrefs;
/* ========== 初始化入口 ========== */
/**
* 初始化SDK(必须在使用任何功能前调用)
*
* @param context Android上下文(Application级别)
* @param config SDK配置
* @return 初始化结果:true成功,false失败
*/
public static boolean init(Context context, Config config) {
if (sInitialized.getAndSet(true)) {
Log.w(TAG, "SDK已初始化,忽略重复调用");
return true;
}
if (context == null || config == null) {
Log.e(TAG, "初始化失败:context或config为null");
sInitialized.set(false);
return false;
}
if (config.appKey == null || config.appSecret == null) {
Log.e(TAG, "初始化失败:appKey或appSecret未配置");
sInitialized.set(false);
return false;
}
sInstance = new WritechSDK();
boolean success = sInstance.doInit(context, config);
if (!success) {
sInstance = null;
sInitialized.set(false);
}
return success;
}
/** 获取SDK单例 */
public static WritechSDK getInstance() {
if (sInstance == null) {
throw new IllegalStateException("WritechSDK未初始化,请先调用 WritechSDK.init()");
}
return sInstance;
}
/** 检查SDK是否已初始化 */
public static boolean isInitialized() {
return sInitialized.get();
}
/* ========== 内部初始化流程 ========== */
/** 执行具体的初始化逻辑 */
private boolean doInit(Context context, Config config) {
mContext = context.getApplicationContext();
mConfig = config;
mPrefs = mContext.getSharedPreferences("writech_sdk", Context.MODE_PRIVATE);
Log.i(TAG, "=== 自然写SDK V" + SDK_VERSION + " 初始化开始 ===");
Log.i(TAG, "云平台地址: " + config.cloudBaseUrl);
Log.i(TAG, "AppKey: " + config.appKey.substring(0, 8) + "****");
Log.i(TAG, "调试模式: " + config.debugMode);
/* 步骤1:应用鉴权(验证AppKey和AppSecret */
if (!authenticate(config.appKey, config.appSecret)) {
Log.e(TAG, "SDK鉴权失败,请检查AppKey和AppSecret");
return false;
}
/* 步骤2:初始化云平台客户端 */
mCloudClient = new CloudClient(config.cloudBaseUrl, config.appKey, config.appSecret);
/* 恢复本地缓存的令牌 */
restoreTokens();
/* 步骤3:初始化蓝牙笔管理器 */
mPenManager = new PenManager(mContext);
/* 步骤4:初始化OCR引擎 */
mOCREngine = new OCREngine(mContext, config.cloudBaseUrl, null);
if (config.offlineModelPath != null) {
mOCREngine.loadOfflineModel(config.offlineModelPath);
}
/* 步骤5:初始化网关SDK */
mGatewaySDK = new GatewaySDK(mContext);
if (config.autoDiscoverGateway) {
mGatewaySDK.startDiscovery();
}
Log.i(TAG, "=== 自然写SDK初始化完成 ===");
return true;
}
/* ========== 应用鉴权 ========== */
/**
* 验证AppKey和AppSecret的有效性
* 首次验证需要联网,之后缓存鉴权结果
*/
private boolean authenticate(String appKey, String appSecret) {
/* 检查本地缓存的鉴权结果 */
String cachedLicense = mPrefs.getString("license_type", null);
long cachedExpire = mPrefs.getLong("license_expire", 0);
if (cachedLicense != null && cachedExpire > System.currentTimeMillis()) {
mIsAuthenticated = true;
mLicenseType = cachedLicense;
mLicenseExpireTime = cachedExpire;
Log.i(TAG, "使用缓存鉴权结果: " + mLicenseType
+ ",到期: " + new java.util.Date(mLicenseExpireTime));
return true;
}
/* 在线鉴权 */
try {
String authUrl = mConfig.cloudBaseUrl + "/api/v1/sdk/authenticate";
String body = "{\"appKey\":\"" + appKey
+ "\",\"appSecret\":\"" + appSecret
+ "\",\"sdkVersion\":\"" + SDK_VERSION + "\"}";
/* 使用CloudClient的静态方法发送无认证请求 */
java.net.HttpURLConnection conn =
(java.net.HttpURLConnection) new java.net.URL(authUrl).openConnection();
conn.setRequestMethod("POST");
conn.setRequestProperty("Content-Type", "application/json");
conn.setDoOutput(true);
conn.setConnectTimeout(10000);
conn.getOutputStream().write(body.getBytes(java.nio.charset.StandardCharsets.UTF_8));
int responseCode = conn.getResponseCode();
if (responseCode == 200) {
java.io.InputStream is = conn.getInputStream();
java.io.ByteArrayOutputStream baos = new java.io.ByteArrayOutputStream();
byte[] buf = new byte[1024];
int len;
while ((len = is.read(buf)) != -1) {
baos.write(buf, 0, len);
}
String response = baos.toString("UTF-8");
is.close();
conn.disconnect();
/* 解析鉴权结果 */
mLicenseType = extractJsonField(response, "licenseType");
String expireStr = extractJsonField(response, "expireTime");
if (mLicenseType != null) {
mLicenseExpireTime = expireStr != null ? Long.parseLong(expireStr)
: System.currentTimeMillis() + 365L * 24 * 3600 * 1000;
mIsAuthenticated = true;
/* 缓存鉴权结果 */
mPrefs.edit()
.putString("license_type", mLicenseType)
.putLong("license_expire", mLicenseExpireTime)
.apply();
Log.i(TAG, "在线鉴权成功: " + mLicenseType);
return true;
}
}
conn.disconnect();
} catch (Exception e) {
Log.w(TAG, "在线鉴权异常: " + e.getMessage());
/* 联网失败时允许离线试用(7天) */
mLicenseType = "trial";
mLicenseExpireTime = System.currentTimeMillis() + 7L * 24 * 3600 * 1000;
mIsAuthenticated = true;
Log.i(TAG, "离线模式,试用授权7天");
return true;
}
return false;
}
/** 恢复本地缓存的认证令牌 */
private void restoreTokens() {
String accessToken = mPrefs.getString("access_token", null);
String refreshToken = mPrefs.getString("refresh_token", null);
long expireTime = mPrefs.getLong("token_expire", 0);
if (accessToken != null && refreshToken != null) {
mCloudClient.setTokens(accessToken, refreshToken, expireTime);
Log.d(TAG, "已恢复缓存的认证令牌");
}
}
/* ========== 对外接口 ========== */
/** 获取笔管理器 */
public PenManager getPenManager() {
return mPenManager;
}
/** 获取OCR引擎 */
public OCREngine getOCREngine() {
return mOCREngine;
}
/** 获取网关SDK */
public GatewaySDK getGatewaySDK() {
return mGatewaySDK;
}
/** 获取云平台客户端 */
public CloudClient getCloudClient() {
return mCloudClient;
}
/** 获取SDK版本 */
public String getVersion() {
return SDK_VERSION;
}
/** 获取授权类型 */
public String getLicenseType() {
return mLicenseType;
}
/** 检查是否已鉴权 */
public boolean isAuthenticated() {
return mIsAuthenticated;
}
/** 用户登录(通过云平台认证) */
public boolean loginUser(String username, String password) {
try {
String response = mCloudClient.login(username, password);
String accessToken = extractJsonField(response, "accessToken");
String refreshToken = extractJsonField(response, "refreshToken");
if (accessToken != null) {
long expireTime = System.currentTimeMillis() + 30 * 60 * 1000;
mCloudClient.setTokens(accessToken, refreshToken, expireTime);
/* 缓存令牌 */
mPrefs.edit()
.putString("access_token", accessToken)
.putString("refresh_token", refreshToken)
.putLong("token_expire", expireTime)
.apply();
return true;
}
} catch (IOException e) {
Log.e(TAG, "登录失败: " + e.getMessage());
}
return false;
}
/* ========== 资源释放 ========== */
/** 释放SDK所有资源 */
public static void destroy() {
if (sInstance != null) {
if (sInstance.mGatewaySDK != null) sInstance.mGatewaySDK.destroy();
if (sInstance.mOCREngine != null) sInstance.mOCREngine.destroy();
if (sInstance.mPenManager != null) sInstance.mPenManager.destroy();
sInstance = null;
}
sInitialized.set(false);
Log.i(TAG, "WritechSDK已释放所有资源");
}
/** 从JSON提取字段值 */
private String extractJsonField(String json, String key) {
if (json == null) return null;
String search = "\"" + key + "\"";
int idx = json.indexOf(search);
if (idx < 0) return null;
int start = json.indexOf("\"", idx + search.length() + 1) + 1;
int end = json.indexOf("\"", start);
return (start > 0 && end > start) ? json.substring(start, end) : null;
}
}
```
### `core/`
#### `core/ble_protocol.c`
```c
/**
* 自然写互动课堂应用开发SDK软件 V1.0
* BLE协议解析核心模块 - 蓝牙5.0点阵笔通信协议实现
*
* 跨平台C语言核心库,负责解析点阵笔BLE GATT数据
* 提供笔迹坐标解包、协议帧校验、数据压缩解压等底层能力
* 通过JNI/ObjC Bridge/FFI供各平台SDK调用
*/
#ifndef BLE_PROTOCOL_H
#define BLE_PROTOCOL_H
#include <stdint.h>
#include <stddef.h>
#include <string.h>
#ifdef __cplusplus
extern "C" {
#endif
/* ==================== 协议常量定义 ==================== */
/* BLE GATT Service UUID(自定义服务) */
#define WRITECH_SERVICE_UUID "0000FFE0-0000-1000-8000-00805F9B34FB"
/* 笔迹数据Characteristic UUID */
#define STROKE_DATA_CHAR_UUID "0000FFE1-0000-1000-8000-00805F9B34FB"
/* 设备信息Characteristic UUID */
#define DEVICE_INFO_CHAR_UUID "0000FFE2-0000-1000-8000-00805F9B34FB"
/* 配置写入Characteristic UUID */
#define CONFIG_WRITE_CHAR_UUID "0000FFE3-0000-1000-8000-00805F9B34FB"
/* OTA DFU Characteristic UUID */
#define OTA_DFU_CHAR_UUID "0000FFE4-0000-1000-8000-00805F9B34FB"
/* 协议帧标志 */
#define FRAME_HEADER_MAGIC 0xAA55
#define FRAME_MAX_PAYLOAD_SIZE 240 /* MTU=247, 减去帧头7字节 */
#define MAX_POINTS_PER_FRAME 34 /* 每帧最多34个坐标点 */
/* 帧类型定义 */
#define FRAME_TYPE_STROKE_DATA 0x01 /* 笔迹坐标数据 */
#define FRAME_TYPE_PEN_UP 0x02 /* 抬笔事件 */
#define FRAME_TYPE_PEN_DOWN 0x03 /* 落笔事件 */
#define FRAME_TYPE_DEVICE_STATUS 0x04 /* 设备状态(电量等) */
#define FRAME_TYPE_OFFLINE_SYNC 0x05 /* 离线数据同步 */
#define FRAME_TYPE_OTA_DATA 0x06 /* OTA升级数据 */
#define FRAME_TYPE_CONFIG_RSP 0x07 /* 配置响应 */
/* ==================== 数据结构定义 ==================== */
/**
* 原始笔迹坐标点(7字节紧凑编码)
* x: 16位无符号整数,点阵坐标X(分辨率约300DPI)
* y: 16位无符号整数,点阵坐标Y
* pressure: 8位无符号整数,压力值(0-255)
* timestamp_delta: 16位无符号整数,距上一点的时间差(毫秒)
*/
typedef struct {
uint16_t x; /* X坐标(大端序) */
uint16_t y; /* Y坐标(大端序) */
uint8_t pressure; /* 压力值 0-255 */
uint16_t timestamp_delta; /* 时间增量(毫秒) */
} __attribute__((packed)) StrokePointRaw;
/**
* 解码后的笔迹坐标点
*/
typedef struct {
float x; /* X坐标(浮点) */
float y; /* Y坐标(浮点) */
float pressure; /* 压力值 0.0-1.0 */
uint32_t timestamp; /* 绝对时间戳(毫秒) */
uint8_t pen_state; /* 0=落笔, 1=抬笔 */
} StrokePoint;
/**
* BLE协议帧头(7字节)
*/
typedef struct {
uint16_t magic; /* 帧头魔数 0xAA55 */
uint8_t frame_type; /* 帧类型 */
uint8_t sequence; /* 帧序号(0-255循环) */
uint16_t payload_length; /* 负载长度 */
uint8_t checksum; /* 帧头校验和(XOR) */
} __attribute__((packed)) FrameHeader;
/**
* 笔迹数据帧
*/
typedef struct {
FrameHeader header;
uint8_t point_count; /* 本帧包含的坐标点数 */
uint32_t page_id; /* 点阵码页面ID */
StrokePointRaw points[MAX_POINTS_PER_FRAME]; /* 坐标点数组 */
uint16_t crc16; /* CRC-16校验 */
} __attribute__((packed)) StrokeDataFrame;
/**
* 设备状态帧
*/
typedef struct {
FrameHeader header;
uint8_t battery_level; /* 电量百分比 0-100 */
uint8_t charging_state; /* 充电状态: 0=未充电, 1=充电中, 2=已充满 */
uint16_t firmware_version; /* 固件版本 (major*256+minor) */
uint8_t connection_state; /* 连接状态 */
uint32_t serial_number; /* 设备序列号 */
uint16_t crc16;
} __attribute__((packed)) DeviceStatusFrame;
/**
* 解析回调函数类型定义
*/
typedef void (*on_stroke_point_cb)(const StrokePoint* point, void* user_data);
typedef void (*on_pen_event_cb)(uint8_t event_type, uint32_t timestamp, void* user_data);
typedef void (*on_device_status_cb)(uint8_t battery, uint8_t charging, uint16_t fw_ver, void* user_data);
/* ==================== 协议解析器 ==================== */
/**
* BLE协议解析器上下文
*/
typedef struct {
/* 接收缓冲区(处理分包/粘包) */
uint8_t recv_buffer[512];
size_t recv_length;
/* 序号跟踪(乱序检测) */
uint8_t expected_sequence;
/* 时间戳基准 */
uint32_t base_timestamp;
uint32_t last_timestamp;
/* 统计信息 */
uint32_t total_frames;
uint32_t total_points;
uint32_t error_frames;
uint32_t lost_frames;
/* 回调函数 */
on_stroke_point_cb stroke_cb;
on_pen_event_cb pen_event_cb;
on_device_status_cb status_cb;
void* user_data;
} BleProtocolParser;
/**
* 初始化协议解析器
*/
static inline void ble_parser_init(BleProtocolParser* parser) {
memset(parser, 0, sizeof(BleProtocolParser));
parser->expected_sequence = 0;
parser->base_timestamp = 0;
}
/**
* 设置回调函数
*/
static inline void ble_parser_set_callbacks(
BleProtocolParser* parser,
on_stroke_point_cb stroke_cb,
on_pen_event_cb pen_event_cb,
on_device_status_cb status_cb,
void* user_data
) {
parser->stroke_cb = stroke_cb;
parser->pen_event_cb = pen_event_cb;
parser->status_cb = status_cb;
parser->user_data = user_data;
}
/**
* 计算CRC-16校验值(CCITT标准)
*/
static uint16_t calc_crc16(const uint8_t* data, size_t length) {
uint16_t crc = 0xFFFF;
for (size_t i = 0; i < length; i++) {
crc ^= (uint16_t)data[i] << 8;
for (int j = 0; j < 8; j++) {
if (crc & 0x8000)
crc = (crc << 1) ^ 0x1021;
else
crc <<= 1;
}
}
return crc;
}
/**
* 校验帧头
*/
static int validate_frame_header(const FrameHeader* header) {
/* 校验魔数 */
if (header->magic != FRAME_HEADER_MAGIC) return -1;
/* 校验负载长度 */
if (header->payload_length > FRAME_MAX_PAYLOAD_SIZE) return -2;
/* 校验帧头XOR校验和 */
uint8_t xor_sum = 0;
const uint8_t* p = (const uint8_t*)header;
for (int i = 0; i < 6; i++) xor_sum ^= p[i];
if (xor_sum != header->checksum) return -3;
return 0;
}
/**
* 大端序转小端序(16位)
*/
static inline uint16_t be16_to_le(uint16_t value) {
return (value >> 8) | (value << 8);
}
/**
* 解析笔迹数据帧
* 从帧中提取坐标点并通过回调函数输出
*/
static int parse_stroke_frame(BleProtocolParser* parser, const uint8_t* data, size_t length) {
if (length < sizeof(FrameHeader) + 5) return -1;
const FrameHeader* header = (const FrameHeader*)data;
/* 帧头校验 */
if (validate_frame_header(header) != 0) {
parser->error_frames++;
return -1;
}
/* 序号连续性检查 */
if (header->sequence != parser->expected_sequence) {
uint8_t lost = header->sequence - parser->expected_sequence;
parser->lost_frames += lost;
}
parser->expected_sequence = header->sequence + 1;
/* 解析负载 */
const uint8_t* payload = data + sizeof(FrameHeader);
uint8_t point_count = payload[0];
uint32_t page_id = *(uint32_t*)(payload + 1);
if (point_count > MAX_POINTS_PER_FRAME) {
parser->error_frames++;
return -2;
}
/* CRC校验(校验帧头+负载) */
size_t crc_data_len = length - 2;
uint16_t expected_crc = *(uint16_t*)(data + crc_data_len);
uint16_t actual_crc = calc_crc16(data, crc_data_len);
if (expected_crc != actual_crc) {
parser->error_frames++;
return -3;
}
/* 解析每个坐标点 */
const StrokePointRaw* raw_points = (const StrokePointRaw*)(payload + 5);
for (int i = 0; i < point_count; i++) {
StrokePoint decoded;
decoded.x = (float)be16_to_le(raw_points[i].x);
decoded.y = (float)be16_to_le(raw_points[i].y);
decoded.pressure = raw_points[i].pressure / 255.0f;
/* 累加时间增量得到绝对时间戳 */
uint16_t delta = be16_to_le(raw_points[i].timestamp_delta);
parser->last_timestamp += delta;
decoded.timestamp = parser->base_timestamp + parser->last_timestamp;
decoded.pen_state = 0; /* 落笔状态 */
/* 通过回调函数输出 */
if (parser->stroke_cb) {
parser->stroke_cb(&decoded, parser->user_data);
}
parser->total_points++;
}
parser->total_frames++;
return point_count;
}
/**
* 输入BLE Notify接收到的数据
* 处理分包/粘包,自动检测帧边界并分发解析
*/
static int ble_parser_feed(BleProtocolParser* parser, const uint8_t* data, size_t length) {
/* 追加到接收缓冲区 */
if (parser->recv_length + length > sizeof(parser->recv_buffer)) {
/* 缓冲区溢出,丢弃旧数据 */
parser->recv_length = 0;
}
memcpy(parser->recv_buffer + parser->recv_length, data, length);
parser->recv_length += length;
int parsed_count = 0;
/* 扫描缓冲区查找完整帧 */
while (parser->recv_length >= sizeof(FrameHeader)) {
/* 查找帧头魔数 */
if (parser->recv_buffer[0] != 0xAA || parser->recv_buffer[1] != 0x55) {
/* 跳过非法字节 */
memmove(parser->recv_buffer, parser->recv_buffer + 1, parser->recv_length - 1);
parser->recv_length--;
continue;
}
FrameHeader* header = (FrameHeader*)parser->recv_buffer;
size_t frame_size = sizeof(FrameHeader) + header->payload_length + 2; /* +2 for CRC */
if (parser->recv_length < frame_size) {
break; /* 帧数据不完整,等待更多数据 */
}
/* 根据帧类型分发解析 */
switch (header->frame_type) {
case FRAME_TYPE_STROKE_DATA:
parse_stroke_frame(parser, parser->recv_buffer, frame_size);
parsed_count++;
break;
case FRAME_TYPE_PEN_UP:
if (parser->pen_event_cb) {
parser->pen_event_cb(1, parser->last_timestamp, parser->user_data);
}
break;
case FRAME_TYPE_PEN_DOWN:
if (parser->pen_event_cb) {
parser->pen_event_cb(0, parser->last_timestamp, parser->user_data);
}
break;
case FRAME_TYPE_DEVICE_STATUS: {
DeviceStatusFrame* status = (DeviceStatusFrame*)parser->recv_buffer;
if (parser->status_cb) {
parser->status_cb(status->battery_level, status->charging_state,
status->firmware_version, parser->user_data);
}
break;
}
default:
break;
}
/* 移除已处理的帧 */
memmove(parser->recv_buffer, parser->recv_buffer + frame_size,
parser->recv_length - frame_size);
parser->recv_length -= frame_size;
}
return parsed_count;
}
/**
* 获取解析器统计信息
*/
static inline void ble_parser_get_stats(const BleProtocolParser* parser,
uint32_t* total_frames, uint32_t* total_points,
uint32_t* error_frames, uint32_t* lost_frames) {
if (total_frames) *total_frames = parser->total_frames;
if (total_points) *total_points = parser->total_points;
if (error_frames) *error_frames = parser->error_frames;
if (lost_frames) *lost_frames = parser->lost_frames;
}
/**
* 重置解析器状态
*/
static inline void ble_parser_reset(BleProtocolParser* parser) {
parser->recv_length = 0;
parser->expected_sequence = 0;
parser->last_timestamp = 0;
parser->total_frames = 0;
parser->total_points = 0;
parser->error_frames = 0;
parser->lost_frames = 0;
}
#ifdef __cplusplus
}
#endif
#endif /* BLE_PROTOCOL_H */
```
#### `core/coordinate_transform.c`
```c
/*
* 自然写互动课堂应用开发SDK软件 V1.0
* 坐标变换模块 - 点阵笔坐标到屏幕坐标的高精度映射
*
* 功能说明:
* 1. 点阵码坐标解析与标准化(Anoto编码 → 物理坐标mm)
* 2. 仿射变换矩阵计算(四角标定点 → 变换参数)
* 3. 物理坐标到屏幕像素坐标的实时映射
* 4. 多页面坐标空间管理(不同纸张/不同页面独立坐标系)
* 5. 畸变校正(镜头畸变、纸张弯曲补偿)
*/
#include <math.h>
#include <string.h>
#include <stdlib.h>
#include <stdio.h>
/* ========== 数据结构定义 ========== */
/* 二维点(浮点精度) */
typedef struct {
double x; /* X坐标 */
double y; /* Y坐标 */
} Point2D;
/* 仿射变换矩阵 3x3(齐次坐标) */
typedef struct {
double m[3][3]; /* 变换矩阵元素 */
} AffineMatrix;
/* 坐标空间描述 */
typedef struct {
unsigned int page_id; /* 页面唯一ID */
unsigned int section_id; /* 区段IDAnoto编码中的section */
unsigned int owner_id; /* 拥有者IDAnoto编码) */
double physical_width_mm; /* 纸张物理宽度(毫米) */
double physical_height_mm; /* 纸张物理高度(毫米) */
int screen_width_px; /* 对应屏幕区域宽度(像素) */
int screen_height_px; /* 对应屏幕区域高度(像素) */
AffineMatrix transform; /* 标定后的变换矩阵 */
int is_calibrated; /* 是否已完成标定 */
} CoordinateSpace;
/* 标定点对(物理坐标 ↔ 屏幕坐标) */
typedef struct {
Point2D physical; /* 物理坐标(mm */
Point2D screen; /* 屏幕坐标(px */
} CalibrationPair;
/* 畸变校正参数(Brown-Conrady模型简化版) */
typedef struct {
double k1; /* 径向畸变系数1 */
double k2; /* 径向畸变系数2 */
double p1; /* 切向畸变系数1 */
double p2; /* 切向畸变系数2 */
double cx; /* 畸变中心X */
double cy; /* 畸变中心Y */
} DistortionParams;
/* 坐标变换管理器 */
typedef struct {
CoordinateSpace spaces[64]; /* 最多支持64个坐标空间 */
int space_count; /* 当前已注册的空间数 */
DistortionParams distortion; /* 全局畸变校正参数 */
int distortion_enabled; /* 是否启用畸变校正 */
double dpi_resolution; /* 点阵笔DPI分辨率(通常为300或600) */
} CoordinateManager;
/* 全局坐标管理器实例 */
static CoordinateManager g_coord_manager;
/* ========== Anoto点阵码坐标解析 ========== */
/*
* 将Anoto点阵码原始编码转换为物理坐标(毫米)
* 点阵笔采集到的原始数据是基于Anoto编码系统的逻辑坐标
* 需要根据DPI分辨率转换为实际的物理距离
*
* @param raw_x 点阵码原始X坐标值
* @param raw_y 点阵码原始Y坐标值
* @param section_id Anoto编码的section标识
* @param out_physical 输出的物理坐标(mm
* @return 0成功, -1参数错误
*/
int anoto_to_physical(unsigned int raw_x, unsigned int raw_y,
unsigned int section_id, Point2D *out_physical) {
if (out_physical == NULL) {
return -1;
}
/* DPI到毫米的转换因子:25.4mm / DPI */
double dpi = g_coord_manager.dpi_resolution;
if (dpi < 1.0) {
dpi = 300.0; /* 默认300 DPI */
}
double dots_to_mm = 25.4 / dpi;
/* Anoto编码的原始坐标直接乘以转换因子得到物理坐标 */
out_physical->x = (double)raw_x * dots_to_mm;
out_physical->y = (double)raw_y * dots_to_mm;
return 0;
}
/*
* 解析7字节紧凑坐标编码
* 点阵笔通过BLE传输时使用7字节紧凑格式:
* 字节0-1: X坐标高16位
* 字节2-3: Y坐标高16位
* 字节4: X低4位 | Y低4位
* 字节5: 压力值高8位
* 字节6: 压力值低8位 | 标志位
*/
int decode_compact_coordinate(const unsigned char *data, int data_len,
unsigned int *out_x, unsigned int *out_y,
unsigned int *out_pressure) {
if (data == NULL || data_len < 7) {
return -1;
}
/* 解析X坐标(20位精度) */
unsigned int x_high = ((unsigned int)data[0] << 8) | data[1];
unsigned int x_low = (data[4] >> 4) & 0x0F;
*out_x = (x_high << 4) | x_low;
/* 解析Y坐标(20位精度) */
unsigned int y_high = ((unsigned int)data[2] << 8) | data[3];
unsigned int y_low = data[4] & 0x0F;
*out_y = (y_high << 4) | y_low;
/* 解析压力值(12位精度,0-4095) */
unsigned int p_high = data[5];
unsigned int p_low = (data[6] >> 4) & 0x0F;
*out_pressure = (p_high << 4) | p_low;
return 0;
}
/* ========== 仿射变换矩阵计算 ========== */
/*
* 初始化为单位矩阵
*/
void matrix_identity(AffineMatrix *mat) {
memset(mat->m, 0, sizeof(mat->m));
mat->m[0][0] = 1.0;
mat->m[1][1] = 1.0;
mat->m[2][2] = 1.0;
}
/*
* 矩阵乘法 result = a * b
*/
void matrix_multiply(const AffineMatrix *a, const AffineMatrix *b,
AffineMatrix *result) {
AffineMatrix tmp;
int i, j, k;
for (i = 0; i < 3; i++) {
for (j = 0; j < 3; j++) {
tmp.m[i][j] = 0.0;
for (k = 0; k < 3; k++) {
tmp.m[i][j] += a->m[i][k] * b->m[k][j];
}
}
}
memcpy(result->m, tmp.m, sizeof(tmp.m));
}
/*
* 使用最小二乘法从标定点对计算仿射变换矩阵
* 至少需要3个不共线的标定点对
* 使用正规方程法求解超定线性方程组
*
* @param pairs 标定点对数组
* @param pair_count 标定点对数量(≥3
* @param out_matrix 输出的仿射变换矩阵
* @return 0成功, -1参数不足, -2矩阵奇异
*/
int compute_affine_transform(const CalibrationPair *pairs, int pair_count,
AffineMatrix *out_matrix) {
if (pairs == NULL || pair_count < 3 || out_matrix == NULL) {
return -1;
}
/*
* 仿射变换方程:
* screen_x = a11 * phys_x + a12 * phys_y + a13
* screen_y = a21 * phys_x + a22 * phys_y + a23
*
* 构建 ATA * x = ATb 正规方程
* A矩阵每行: [phys_x, phys_y, 1]
*/
double ATA[3][3] = {{0}};
double ATb_x[3] = {0};
double ATb_y[3] = {0};
int i;
for (i = 0; i < pair_count; i++) {
double px = pairs[i].physical.x;
double py = pairs[i].physical.y;
double sx = pairs[i].screen.x;
double sy = pairs[i].screen.y;
/* 累加 ATA */
ATA[0][0] += px * px;
ATA[0][1] += px * py;
ATA[0][2] += px;
ATA[1][0] += py * px;
ATA[1][1] += py * py;
ATA[1][2] += py;
ATA[2][0] += px;
ATA[2][1] += py;
ATA[2][2] += 1.0;
/* 累加 ATb */
ATb_x[0] += px * sx;
ATb_x[1] += py * sx;
ATb_x[2] += sx;
ATb_y[0] += px * sy;
ATb_y[1] += py * sy;
ATb_y[2] += sy;
}
/* 高斯消元法求解3x3线性方程组 */
/* 先求解 screen_x 的系数 [a11, a12, a13] */
double aug_x[3][4];
double aug_y[3][4];
int j, k;
for (i = 0; i < 3; i++) {
for (j = 0; j < 3; j++) {
aug_x[i][j] = ATA[i][j];
aug_y[i][j] = ATA[i][j];
}
aug_x[i][3] = ATb_x[i];
aug_y[i][3] = ATb_y[i];
}
/* 高斯消元(部分主元选取) */
for (k = 0; k < 3; k++) {
/* 找主元 */
int max_row = k;
double max_val = fabs(aug_x[k][k]);
for (i = k + 1; i < 3; i++) {
if (fabs(aug_x[i][k]) > max_val) {
max_val = fabs(aug_x[i][k]);
max_row = i;
}
}
if (max_val < 1e-12) {
return -2; /* 矩阵奇异,标定点可能共线 */
}
/* 交换行 */
if (max_row != k) {
for (j = 0; j < 4; j++) {
double tmp = aug_x[k][j];
aug_x[k][j] = aug_x[max_row][j];
aug_x[max_row][j] = tmp;
tmp = aug_y[k][j];
aug_y[k][j] = aug_y[max_row][j];
aug_y[max_row][j] = tmp;
}
}
/* 消元 */
for (i = k + 1; i < 3; i++) {
double factor_x = aug_x[i][k] / aug_x[k][k];
double factor_y = aug_y[i][k] / aug_y[k][k];
for (j = k; j < 4; j++) {
aug_x[i][j] -= factor_x * aug_x[k][j];
aug_y[i][j] -= factor_y * aug_y[k][j];
}
}
}
/* 回代求解 */
double sol_x[3], sol_y[3];
for (i = 2; i >= 0; i--) {
sol_x[i] = aug_x[i][3];
sol_y[i] = aug_y[i][3];
for (j = i + 1; j < 3; j++) {
sol_x[i] -= aug_x[i][j] * sol_x[j];
sol_y[i] -= aug_y[i][j] * sol_y[j];
}
sol_x[i] /= aug_x[i][i];
sol_y[i] /= aug_y[i][i];
}
/* 填充仿射变换矩阵 */
out_matrix->m[0][0] = sol_x[0]; /* a11 */
out_matrix->m[0][1] = sol_x[1]; /* a12 */
out_matrix->m[0][2] = sol_x[2]; /* a13(平移X */
out_matrix->m[1][0] = sol_y[0]; /* a21 */
out_matrix->m[1][1] = sol_y[1]; /* a22 */
out_matrix->m[1][2] = sol_y[2]; /* a23(平移Y */
out_matrix->m[2][0] = 0.0;
out_matrix->m[2][1] = 0.0;
out_matrix->m[2][2] = 1.0;
return 0;
}
/* ========== 坐标空间管理 ========== */
/*
* 初始化坐标变换管理器
* @param dpi 点阵笔的DPI分辨率(常见值:300, 600
*/
void coordinate_manager_init(double dpi) {
memset(&g_coord_manager, 0, sizeof(g_coord_manager));
g_coord_manager.dpi_resolution = dpi;
g_coord_manager.distortion_enabled = 0;
}
/*
* 注册一个新的坐标空间(对应一个页面/纸张)
* 在使用特定页面前需先注册其坐标空间参数
*
* @param page_id 页面唯一标识
* @param section_id Anoto section编号
* @param width_mm 纸张物理宽度
* @param height_mm 纸张物理高度
* @param screen_w 对应屏幕宽度像素
* @param screen_h 对应屏幕高度像素
* @return 空间索引, -1失败
*/
int register_coordinate_space(unsigned int page_id, unsigned int section_id,
double width_mm, double height_mm,
int screen_w, int screen_h) {
if (g_coord_manager.space_count >= 64) {
return -1; /* 空间已满 */
}
int idx = g_coord_manager.space_count;
CoordinateSpace *space = &g_coord_manager.spaces[idx];
space->page_id = page_id;
space->section_id = section_id;
space->physical_width_mm = width_mm;
space->physical_height_mm = height_mm;
space->screen_width_px = screen_w;
space->screen_height_px = screen_h;
space->is_calibrated = 0;
matrix_identity(&space->transform);
g_coord_manager.space_count++;
return idx;
}
/*
* 对指定坐标空间执行标定
* 使用用户提供的标定点对计算仿射变换矩阵
*/
int calibrate_space(int space_index, const CalibrationPair *pairs,
int pair_count) {
if (space_index < 0 || space_index >= g_coord_manager.space_count) {
return -1;
}
CoordinateSpace *space = &g_coord_manager.spaces[space_index];
int ret = compute_affine_transform(pairs, pair_count, &space->transform);
if (ret == 0) {
space->is_calibrated = 1;
}
return ret;
}
/*
* 使用默认缩放(无旋转无畸变)进行快速标定
* 适用于标准A4纸张等无需精确标定的场景
*/
int calibrate_space_default(int space_index) {
if (space_index < 0 || space_index >= g_coord_manager.space_count) {
return -1;
}
CoordinateSpace *space = &g_coord_manager.spaces[space_index];
matrix_identity(&space->transform);
/* 简单线性缩放:物理mm → 屏幕px */
double scale_x = (double)space->screen_width_px / space->physical_width_mm;
double scale_y = (double)space->screen_height_px / space->physical_height_mm;
space->transform.m[0][0] = scale_x;
space->transform.m[1][1] = scale_y;
space->is_calibrated = 1;
return 0;
}
/* ========== 畸变校正 ========== */
/*
* 设置畸变校正参数
* 用于补偿摄像头镜头的径向和切向畸变
*/
void set_distortion_params(double k1, double k2, double p1, double p2,
double cx, double cy) {
g_coord_manager.distortion.k1 = k1;
g_coord_manager.distortion.k2 = k2;
g_coord_manager.distortion.p1 = p1;
g_coord_manager.distortion.p2 = p2;
g_coord_manager.distortion.cx = cx;
g_coord_manager.distortion.cy = cy;
g_coord_manager.distortion_enabled = 1;
}
/*
* 对物理坐标应用畸变校正(去畸变)
* 使用Brown-Conrady模型的简化版本
*
* @param in 输入的物理坐标
* @param out 校正后的物理坐标
*/
void apply_distortion_correction(const Point2D *in, Point2D *out) {
if (!g_coord_manager.distortion_enabled) {
out->x = in->x;
out->y = in->y;
return;
}
DistortionParams *d = &g_coord_manager.distortion;
/* 以畸变中心为原点 */
double dx = in->x - d->cx;
double dy = in->y - d->cy;
double r2 = dx * dx + dy * dy;
double r4 = r2 * r2;
/* 径向畸变校正 */
double radial = 1.0 + d->k1 * r2 + d->k2 * r4;
/* 切向畸变校正 */
double tang_x = 2.0 * d->p1 * dx * dy + d->p2 * (r2 + 2.0 * dx * dx);
double tang_y = d->p1 * (r2 + 2.0 * dy * dy) + 2.0 * d->p2 * dx * dy;
out->x = d->cx + dx * radial + tang_x;
out->y = d->cy + dy * radial + tang_y;
}
/* ========== 坐标变换核心接口 ========== */
/*
* 根据page_id查找对应的坐标空间索引
*/
int find_space_by_page(unsigned int page_id) {
int i;
for (i = 0; i < g_coord_manager.space_count; i++) {
if (g_coord_manager.spaces[i].page_id == page_id) {
return i;
}
}
return -1;
}
/*
* 完整坐标变换流水线:原始点阵码坐标 → 屏幕像素坐标
*
* 处理步骤:
* 1. Anoto编码 → 物理坐标(mm
* 2. 畸变校正(如果启用)
* 3. 仿射变换 → 屏幕坐标(px)
* 4. 边界裁剪(确保不超出屏幕范围)
*
* @param raw_x 原始X坐标
* @param raw_y 原始Y坐标
* @param page_id 页面ID
* @param out_screen 输出屏幕坐标
* @return 0成功, -1未找到坐标空间, -2未标定
*/
int transform_coordinate(unsigned int raw_x, unsigned int raw_y,
unsigned int page_id, Point2D *out_screen) {
if (out_screen == NULL) {
return -1;
}
/* 查找坐标空间 */
int idx = find_space_by_page(page_id);
if (idx < 0) {
return -1;
}
CoordinateSpace *space = &g_coord_manager.spaces[idx];
if (!space->is_calibrated) {
return -2;
}
/* 步骤1:原始坐标 → 物理坐标 */
Point2D physical;
anoto_to_physical(raw_x, raw_y, space->section_id, &physical);
/* 步骤2:畸变校正 */
Point2D corrected;
apply_distortion_correction(&physical, &corrected);
/* 步骤3:仿射变换 → 屏幕坐标 */
AffineMatrix *mat = &space->transform;
out_screen->x = mat->m[0][0] * corrected.x
+ mat->m[0][1] * corrected.y
+ mat->m[0][2];
out_screen->y = mat->m[1][0] * corrected.x
+ mat->m[1][1] * corrected.y
+ mat->m[1][2];
/* 步骤4:边界裁剪 */
if (out_screen->x < 0.0) out_screen->x = 0.0;
if (out_screen->y < 0.0) out_screen->y = 0.0;
if (out_screen->x > (double)space->screen_width_px) {
out_screen->x = (double)space->screen_width_px;
}
if (out_screen->y > (double)space->screen_height_px) {
out_screen->y = (double)space->screen_height_px;
}
return 0;
}
/*
* 批量坐标变换(优化版,避免重复查找坐标空间)
* 适用于一次性转换整条笔画的所有采样点
*
* @param raw_points 原始坐标数组,每组2个unsigned int (x, y)
* @param point_count 坐标点数量
* @param page_id 页面ID
* @param out_screen 输出屏幕坐标数组(调用者负责分配内存)
* @return 成功转换的点数
*/
int transform_batch(const unsigned int *raw_points, int point_count,
unsigned int page_id, Point2D *out_screen) {
int idx = find_space_by_page(page_id);
if (idx < 0 || out_screen == NULL) {
return 0;
}
CoordinateSpace *space = &g_coord_manager.spaces[idx];
if (!space->is_calibrated) {
return 0;
}
double dpi = g_coord_manager.dpi_resolution;
if (dpi < 1.0) dpi = 300.0;
double dots_to_mm = 25.4 / dpi;
AffineMatrix *mat = &space->transform;
int converted = 0;
int i;
for (i = 0; i < point_count; i++) {
/* 直接内联计算,减少函数调用开销 */
double px = (double)raw_points[i * 2] * dots_to_mm;
double py = (double)raw_points[i * 2 + 1] * dots_to_mm;
/* 畸变校正(内联) */
if (g_coord_manager.distortion_enabled) {
DistortionParams *d = &g_coord_manager.distortion;
double dx = px - d->cx;
double dy = py - d->cy;
double r2 = dx * dx + dy * dy;
double radial = 1.0 + d->k1 * r2 + d->k2 * r2 * r2;
px = d->cx + dx * radial + 2.0 * d->p1 * dx * dy
+ d->p2 * (r2 + 2.0 * dx * dx);
py = d->cy + dy * radial + d->p1 * (r2 + 2.0 * dy * dy)
+ 2.0 * d->p2 * dx * dy;
}
/* 仿射变换 */
double sx = mat->m[0][0] * px + mat->m[0][1] * py + mat->m[0][2];
double sy = mat->m[1][0] * px + mat->m[1][1] * py + mat->m[1][2];
/* 边界裁剪 */
if (sx < 0.0) sx = 0.0;
if (sy < 0.0) sy = 0.0;
if (sx > (double)space->screen_width_px) sx = (double)space->screen_width_px;
if (sy > (double)space->screen_height_px) sy = (double)space->screen_height_px;
out_screen[i].x = sx;
out_screen[i].y = sy;
converted++;
}
return converted;
}
/*
* 反向变换:屏幕坐标 → 物理坐标
* 用于在屏幕上点击后反推纸面物理位置
* 需要计算仿射变换矩阵的逆矩阵
*/
int inverse_transform(double screen_x, double screen_y,
unsigned int page_id, Point2D *out_physical) {
int idx = find_space_by_page(page_id);
if (idx < 0 || out_physical == NULL) {
return -1;
}
CoordinateSpace *space = &g_coord_manager.spaces[idx];
AffineMatrix *mat = &space->transform;
/* 计算2x2子矩阵的行列式 */
double det = mat->m[0][0] * mat->m[1][1] - mat->m[0][1] * mat->m[1][0];
if (fabs(det) < 1e-12) {
return -2; /* 矩阵不可逆 */
}
double inv_det = 1.0 / det;
/* 减去平移分量 */
double tx = screen_x - mat->m[0][2];
double ty = screen_y - mat->m[1][2];
/* 应用逆矩阵 */
out_physical->x = inv_det * (mat->m[1][1] * tx - mat->m[0][1] * ty);
out_physical->y = inv_det * (mat->m[0][0] * ty - mat->m[1][0] * tx);
return 0;
}
```
#### `core/stroke_smoother.c`
```c
/**
* 自然写互动课堂应用开发SDK软件 V1.0
* 笔迹平滑算法核心模块 - 笔迹坐标平滑与笔锋渲染
*
* 跨平台C语言核心库
* 提供贝塞尔曲线平滑、笔锋宽度计算、坐标插值等算法
* 确保各平台SDK输出一致的笔迹渲染效果
*/
#ifndef STROKE_SMOOTHER_H
#define STROKE_SMOOTHER_H
#include <stdint.h>
#include <stddef.h>
#include <math.h>
#ifdef __cplusplus
extern "C" {
#endif
/* ==================== 常量定义 ==================== */
#define MAX_SMOOTH_POINTS 4096 /* 平滑输出点缓冲区大小 */
#define MIN_POINT_DISTANCE 0.5f /* 最小点间距(低于此值合并) */
#define BEZIER_SEGMENTS 8 /* 贝塞尔曲线分段数 */
#define PRESSURE_SMOOTH_FACTOR 0.3f /* 压力平滑因子 */
/* ==================== 数据结构 ==================== */
/** 二维浮点坐标点 */
typedef struct {
float x;
float y;
} Vec2f;
/** 带压力和时间戳的笔迹点 */
typedef struct {
float x;
float y;
float pressure; /* 0.0-1.0 */
float width; /* 计算后的笔画宽度 */
uint32_t timestamp; /* 时间戳 */
} SmoothPoint;
/** 笔迹平滑器上下文 */
typedef struct {
/* 输入点缓冲区(最近4个点,用于三次贝塞尔) */
SmoothPoint input_buffer[4];
int buffer_count;
/* 输出点缓冲区 */
SmoothPoint output_buffer[MAX_SMOOTH_POINTS];
int output_count;
/* 笔画宽度配置 */
float min_width; /* 最小笔画宽度 */
float max_width; /* 最大笔画宽度 */
float velocity_scale; /* 速度对宽度的影响系数 */
/* 上一点的平滑压力值 */
float last_smooth_pressure;
/* 统计信息 */
uint32_t total_input_points;
uint32_t total_output_points;
} StrokeSmoother;
/* ==================== 数学工具函数 ==================== */
/** 两点间欧氏距离 */
static inline float vec2f_distance(Vec2f a, Vec2f b) {
float dx = b.x - a.x;
float dy = b.y - a.y;
return sqrtf(dx * dx + dy * dy);
}
/** 两点间线性插值 */
static inline Vec2f vec2f_lerp(Vec2f a, Vec2f b, float t) {
Vec2f result;
result.x = a.x + (b.x - a.x) * t;
result.y = a.y + (b.y - a.y) * t;
return result;
}
/** 浮点数线性插值 */
static inline float float_lerp(float a, float b, float t) {
return a + (b - a) * t;
}
/** 将值裁剪到范围 [min_val, max_val] */
static inline float float_clamp(float value, float min_val, float max_val) {
if (value < min_val) return min_val;
if (value > max_val) return max_val;
return value;
}
/* ==================== 贝塞尔曲线算法 ==================== */
/**
* 计算三次贝塞尔曲线上的点
* B(t) = (1-t)^3*P0 + 3*(1-t)^2*t*P1 + 3*(1-t)*t^2*P2 + t^3*P3
*
* 用于平滑连接相邻坐标点,消除折角使笔画圆润
*/
static Vec2f cubic_bezier(Vec2f p0, Vec2f p1, Vec2f p2, Vec2f p3, float t) {
float u = 1.0f - t;
float tt = t * t;
float uu = u * u;
float uuu = uu * u;
float ttt = tt * t;
Vec2f point;
point.x = uuu * p0.x + 3.0f * uu * t * p1.x + 3.0f * u * tt * p2.x + ttt * p3.x;
point.y = uuu * p0.y + 3.0f * uu * t * p1.y + 3.0f * u * tt * p2.y + ttt * p3.y;
return point;
}
/**
* 使用Catmull-Rom样条生成贝塞尔控制点
* 从4个数据点(p0,p1,p2,p3)计算p1到p2之间的贝塞尔控制点
* 确保曲线经过原始数据点(C1连续)
*/
static void catmull_rom_to_bezier(
Vec2f p0, Vec2f p1, Vec2f p2, Vec2f p3,
Vec2f* cp1_out, Vec2f* cp2_out
) {
float tension = 0.5f; /* 张力系数,0.5为标准Catmull-Rom */
cp1_out->x = p1.x + (p2.x - p0.x) * tension / 3.0f;
cp1_out->y = p1.y + (p2.y - p0.y) * tension / 3.0f;
cp2_out->x = p2.x - (p3.x - p1.x) * tension / 3.0f;
cp2_out->y = p2.y - (p3.y - p1.y) * tension / 3.0f;
}
/* ==================== 笔画宽度计算 ==================== */
/**
* 根据压力和速度计算笔画宽度
* 模拟真实毛笔/钢笔的笔锋效果:
* - 压力越大,笔画越粗
* - 速度越快,笔画越细(模拟快写时的飞白效果)
* - 起笔/收笔处渐变细化
*/
static float calculate_stroke_width(
float pressure, float velocity,
float min_width, float max_width, float velocity_scale
) {
/* 压力影响:压力0→最细,压力1→最粗 */
float pressure_width = min_width + (max_width - min_width) * pressure;
/* 速度衰减:速度快时笔画变细 */
float velocity_factor = 1.0f / (1.0f + velocity * velocity_scale);
float width = pressure_width * velocity_factor;
return float_clamp(width, min_width, max_width);
}
/* ==================== 笔迹平滑器API ==================== */
/**
* 初始化笔迹平滑器
*/
static void smoother_init(StrokeSmoother* ctx, float min_width, float max_width) {
ctx->buffer_count = 0;
ctx->output_count = 0;
ctx->min_width = min_width;
ctx->max_width = max_width;
ctx->velocity_scale = 0.005f;
ctx->last_smooth_pressure = 0.5f;
ctx->total_input_points = 0;
ctx->total_output_points = 0;
}
/**
* 输入一个新的坐标点
* 当缓冲区积累到4个点时,自动生成贝塞尔曲线平滑点
* 返回新生成的平滑点数量
*/
static int smoother_add_point(StrokeSmoother* ctx, float x, float y,
float pressure, uint32_t timestamp) {
ctx->total_input_points++;
/* 压力平滑(低通滤波器,避免压力值跳变) */
float smooth_pressure = ctx->last_smooth_pressure +
PRESSURE_SMOOTH_FACTOR * (pressure - ctx->last_smooth_pressure);
ctx->last_smooth_pressure = smooth_pressure;
/* 添加到输入缓冲区 */
int idx = ctx->buffer_count;
if (idx >= 4) {
/* 缓冲区满,移位 */
ctx->input_buffer[0] = ctx->input_buffer[1];
ctx->input_buffer[1] = ctx->input_buffer[2];
ctx->input_buffer[2] = ctx->input_buffer[3];
idx = 3;
}
ctx->input_buffer[idx].x = x;
ctx->input_buffer[idx].y = y;
ctx->input_buffer[idx].pressure = smooth_pressure;
ctx->input_buffer[idx].timestamp = timestamp;
ctx->buffer_count = idx + 1;
/* 不足4个点时直接输出原始点 */
if (ctx->buffer_count < 4) {
if (ctx->output_count < MAX_SMOOTH_POINTS) {
/* 计算速度和宽度 */
float velocity = 0;
if (ctx->buffer_count >= 2) {
Vec2f prev = {ctx->input_buffer[ctx->buffer_count-2].x, ctx->input_buffer[ctx->buffer_count-2].y};
Vec2f curr = {x, y};
float dt = (float)(timestamp - ctx->input_buffer[ctx->buffer_count-2].timestamp);
if (dt > 0) velocity = vec2f_distance(prev, curr) / dt * 1000.0f;
}
float width = calculate_stroke_width(smooth_pressure, velocity,
ctx->min_width, ctx->max_width, ctx->velocity_scale);
SmoothPoint sp = {x, y, smooth_pressure, width, timestamp};
ctx->output_buffer[ctx->output_count++] = sp;
ctx->total_output_points++;
return 1;
}
return 0;
}
/* 4个点准备好,生成贝塞尔曲线 */
Vec2f p0 = {ctx->input_buffer[0].x, ctx->input_buffer[0].y};
Vec2f p1 = {ctx->input_buffer[1].x, ctx->input_buffer[1].y};
Vec2f p2 = {ctx->input_buffer[2].x, ctx->input_buffer[2].y};
Vec2f p3 = {ctx->input_buffer[3].x, ctx->input_buffer[3].y};
/* 计算贝塞尔控制点 */
Vec2f cp1, cp2;
catmull_rom_to_bezier(p0, p1, p2, p3, &cp1, &cp2);
/* 在p1到p2之间生成平滑点 */
int new_points = 0;
for (int i = 0; i <= BEZIER_SEGMENTS; i++) {
if (ctx->output_count >= MAX_SMOOTH_POINTS) break;
float t = (float)i / BEZIER_SEGMENTS;
Vec2f pt = cubic_bezier(p1, cp1, cp2, p2, t);
/* 插值压力和时间戳 */
float interp_pressure = float_lerp(ctx->input_buffer[1].pressure,
ctx->input_buffer[2].pressure, t);
uint32_t interp_time = (uint32_t)float_lerp(
(float)ctx->input_buffer[1].timestamp,
(float)ctx->input_buffer[2].timestamp, t);
/* 计算速度 */
float velocity = 0;
if (ctx->output_count > 0) {
SmoothPoint* prev = &ctx->output_buffer[ctx->output_count - 1];
Vec2f prev_v = {prev->x, prev->y};
float dt = (float)(interp_time - prev->timestamp);
if (dt > 0) velocity = vec2f_distance(prev_v, pt) / dt * 1000.0f;
}
/* 计算笔画宽度 */
float width = calculate_stroke_width(interp_pressure, velocity,
ctx->min_width, ctx->max_width, ctx->velocity_scale);
/* 距离过滤:跳过距上一点太近的点 */
if (ctx->output_count > 0) {
SmoothPoint* prev = &ctx->output_buffer[ctx->output_count - 1];
Vec2f prev_v = {prev->x, prev->y};
if (vec2f_distance(prev_v, pt) < MIN_POINT_DISTANCE) continue;
}
SmoothPoint sp = {pt.x, pt.y, interp_pressure, width, interp_time};
ctx->output_buffer[ctx->output_count++] = sp;
ctx->total_output_points++;
new_points++;
}
return new_points;
}
/**
* 结束当前笔画(抬笔时调用)
* 输出最后一段贝塞尔曲线的收尾点
*/
static int smoother_end_stroke(StrokeSmoother* ctx) {
int new_points = 0;
/* 输出缓冲区中剩余的点 */
if (ctx->buffer_count >= 2 && ctx->output_count < MAX_SMOOTH_POINTS) {
int last = ctx->buffer_count - 1;
float width = calculate_stroke_width(
ctx->input_buffer[last].pressure * 0.5f, 0, /* 收笔处宽度减半 */
ctx->min_width, ctx->max_width, ctx->velocity_scale);
SmoothPoint sp = {
ctx->input_buffer[last].x, ctx->input_buffer[last].y,
ctx->input_buffer[last].pressure, width,
ctx->input_buffer[last].timestamp
};
ctx->output_buffer[ctx->output_count++] = sp;
new_points++;
}
/* 重置输入缓冲区 */
ctx->buffer_count = 0;
ctx->last_smooth_pressure = 0.5f;
return new_points;
}
/**
* 获取平滑后的输出点
*/
static inline const SmoothPoint* smoother_get_output(const StrokeSmoother* ctx) {
return ctx->output_buffer;
}
/**
* 获取输出点数量
*/
static inline int smoother_get_output_count(const StrokeSmoother* ctx) {
return ctx->output_count;
}
/**
* 清除输出缓冲区
*/
static inline void smoother_clear_output(StrokeSmoother* ctx) {
ctx->output_count = 0;
}
/**
* 获取统计信息
*/
static inline void smoother_get_stats(const StrokeSmoother* ctx,
uint32_t* input_count, uint32_t* output_count) {
if (input_count) *input_count = ctx->total_input_points;
if (output_count) *output_count = ctx->total_output_points;
}
#ifdef __cplusplus
}
#endif
#endif /* STROKE_SMOOTHER_H */
```
### `model/`
#### `model/PenDevice.java`
```java
/*
* 自然写互动课堂应用开发SDK软件 V1.0
* PenDevice - 点阵笔设备数据模型
*
* 描述:封装点阵笔的设备信息、连接状态、能力参数等
*/
package com.writech.sdk.model;
import java.io.Serializable;
/**
* 点阵笔设备模型
* 包含设备基本信息、硬件参数和连接状态
*/
public class PenDevice implements Serializable {
private static final long serialVersionUID = 1L;
/* ========== 基本信息 ========== */
/** 设备MAC地址(唯一标识) */
private String macAddress;
/** 设备名称(用户可自定义) */
private String deviceName;
/** 设备型号(如 WP-200, WP-300 */
private String modelName;
/** 固件版本号(如 2.1.5 */
private String firmwareVersion;
/** 硬件版本号 */
private String hardwareVersion;
/** 设备序列号 */
private String serialNumber;
/* ========== 硬件能力 ========== */
/** 采样率(Hz,常见值:100, 200 */
private int sampleRate;
/** 压力感应级别(常见值:1024, 2048, 4096 */
private int pressureLevels;
/** 坐标分辨率(DPI,常见值:300, 600 */
private int coordinateDpi;
/** 是否支持倾斜角检测 */
private boolean tiltSupported;
/** BLE协议版本(4.2 / 5.0 / 5.3 */
private String bleVersion;
/** 电池容量(mAh */
private int batteryCapacity;
/* ========== 运行状态 ========== */
/** 连接状态枚举 */
public enum ConnectionState {
DISCONNECTED, /* 未连接 */
CONNECTING, /* 正在连接 */
CONNECTED, /* 已连接 */
RECONNECTING /* 正在重连 */
}
/** 当前连接状态 */
private ConnectionState connectionState = ConnectionState.DISCONNECTED;
/** 当前电量百分比(0-100 */
private int batteryLevel;
/** 是否正在充电 */
private boolean isCharging;
/** 是否正在书写(笔尖接触纸面) */
private boolean isWriting;
/** 信号强度RSSIdBm */
private int rssi;
/** 最后一次通信时间(毫秒时间戳) */
private long lastCommunicationTime;
/** 累计书写时长(秒) */
private long totalWritingDuration;
/** 绑定的学生ID */
private String boundStudentId;
/** 绑定的学生姓名 */
private String boundStudentName;
/* ========== 构造函数 ========== */
public PenDevice() {
}
public PenDevice(String macAddress, String deviceName) {
this.macAddress = macAddress;
this.deviceName = deviceName;
this.sampleRate = 100;
this.pressureLevels = 4096;
this.coordinateDpi = 300;
}
/* ========== Getter / Setter ========== */
public String getMacAddress() { return macAddress; }
public void setMacAddress(String macAddress) { this.macAddress = macAddress; }
public String getDeviceName() { return deviceName; }
public void setDeviceName(String deviceName) { this.deviceName = deviceName; }
public String getModelName() { return modelName; }
public void setModelName(String modelName) { this.modelName = modelName; }
public String getFirmwareVersion() { return firmwareVersion; }
public void setFirmwareVersion(String firmwareVersion) { this.firmwareVersion = firmwareVersion; }
public String getHardwareVersion() { return hardwareVersion; }
public void setHardwareVersion(String v) { this.hardwareVersion = v; }
public String getSerialNumber() { return serialNumber; }
public void setSerialNumber(String serialNumber) { this.serialNumber = serialNumber; }
public int getSampleRate() { return sampleRate; }
public void setSampleRate(int sampleRate) { this.sampleRate = sampleRate; }
public int getPressureLevels() { return pressureLevels; }
public void setPressureLevels(int pressureLevels) { this.pressureLevels = pressureLevels; }
public int getCoordinateDpi() { return coordinateDpi; }
public void setCoordinateDpi(int coordinateDpi) { this.coordinateDpi = coordinateDpi; }
public boolean isTiltSupported() { return tiltSupported; }
public void setTiltSupported(boolean tiltSupported) { this.tiltSupported = tiltSupported; }
public String getBleVersion() { return bleVersion; }
public void setBleVersion(String bleVersion) { this.bleVersion = bleVersion; }
public int getBatteryCapacity() { return batteryCapacity; }
public void setBatteryCapacity(int batteryCapacity) { this.batteryCapacity = batteryCapacity; }
public ConnectionState getConnectionState() { return connectionState; }
public void setConnectionState(ConnectionState state) { this.connectionState = state; }
public int getBatteryLevel() { return batteryLevel; }
public void setBatteryLevel(int batteryLevel) { this.batteryLevel = batteryLevel; }
public boolean isCharging() { return isCharging; }
public void setCharging(boolean charging) { isCharging = charging; }
public boolean isWriting() { return isWriting; }
public void setWriting(boolean writing) { isWriting = writing; }
public int getRssi() { return rssi; }
public void setRssi(int rssi) { this.rssi = rssi; }
public long getLastCommunicationTime() { return lastCommunicationTime; }
public void setLastCommunicationTime(long t) { this.lastCommunicationTime = t; }
public long getTotalWritingDuration() { return totalWritingDuration; }
public void setTotalWritingDuration(long d) { this.totalWritingDuration = d; }
public String getBoundStudentId() { return boundStudentId; }
public void setBoundStudentId(String id) { this.boundStudentId = id; }
public String getBoundStudentName() { return boundStudentName; }
public void setBoundStudentName(String name) { this.boundStudentName = name; }
/* ========== 便捷方法 ========== */
/** 是否已连接 */
public boolean isConnected() {
return connectionState == ConnectionState.CONNECTED;
}
/** 电量是否低(<= 10% */
public boolean isLowBattery() {
return batteryLevel <= 10 && !isCharging;
}
/** 获取设备显示名称(优先显示学生姓名) */
public String getDisplayName() {
if (boundStudentName != null && !boundStudentName.isEmpty()) {
return boundStudentName + "的笔";
}
return deviceName != null ? deviceName : "WritechPen-" + macAddress;
}
@Override
public String toString() {
return "PenDevice{" +
"mac='" + macAddress + '\'' +
", name='" + deviceName + '\'' +
", model='" + modelName + '\'' +
", state=" + connectionState +
", battery=" + batteryLevel + "%" +
", writing=" + isWriting +
'}';
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
PenDevice that = (PenDevice) o;
return macAddress != null && macAddress.equals(that.macAddress);
}
@Override
public int hashCode() {
return macAddress != null ? macAddress.hashCode() : 0;
}
}
```
#### `model/RecognitionResult.java`
```java
/*
* 自然写互动课堂应用开发SDK软件 V1.0
* RecognitionResult - 识别结果数据模型
*
* 描述:封装OCR识别、数学公式识别、笔顺评分等各类识别结果
*/
package com.writech.sdk.model;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.List;
/**
* 识别结果统一模型
* 支持多种识别类型的结果封装
*/
public class RecognitionResult implements Serializable {
private static final long serialVersionUID = 1L;
/* ========== 识别类型常量 ========== */
/** 手写文字识别 */
public static final int TYPE_HANDWRITING = 0;
/** 数学公式识别 */
public static final int TYPE_MATH = 1;
/** 笔顺评分 */
public static final int TYPE_STROKE_ORDER = 2;
/** 作文评分 */
public static final int TYPE_ESSAY = 3;
/* ========== 候选结果内部类 ========== */
/** 单个候选识别结果 */
public static class Candidate implements Serializable {
private static final long serialVersionUID = 1L;
/** 识别文本 */
public String text;
/** 置信度(0.0~1.0 */
public float confidence;
/** 候选排名 */
public int rank;
public Candidate() {}
public Candidate(String text, float confidence, int rank) {
this.text = text;
this.confidence = confidence;
this.rank = rank;
}
@Override
public String toString() {
return "Candidate{'" + text + "', conf=" + confidence + "}";
}
}
/** 笔顺评分详情 */
public static class StrokeOrderDetail implements Serializable {
private static final long serialVersionUID = 1L;
/** 评分笔画序号 */
public int strokeIndex;
/** 该笔是否正确 */
public boolean isCorrect;
/** 标准笔顺名称(如"横"、"竖"、"撇" */
public String standardStrokeName;
/** 实际书写的笔画类型 */
public String actualStrokeName;
/** 笔画形态相似度(0.0~1.0 */
public float shapeSimilarity;
public StrokeOrderDetail() {}
@Override
public String toString() {
return "Stroke#" + strokeIndex + ": " + (isCorrect ? "正确" : "错误")
+ " (标准:" + standardStrokeName + ", 实际:" + actualStrokeName + ")";
}
}
/** 作文评分详情 */
public static class EssayScoreDetail implements Serializable {
private static final long serialVersionUID = 1L;
/** 内容分(百分制) */
public float contentScore;
/** 结构分 */
public float structureScore;
/** 语言分 */
public float languageScore;
/** 书写规范分 */
public float handwritingScore;
/** 总分 */
public float totalScore;
/** 评语 */
public String comment;
/** 优点列表 */
public List<String> highlights = new ArrayList<>();
/** 改进建议列表 */
public List<String> suggestions = new ArrayList<>();
public EssayScoreDetail() {}
}
/* ========== 结果字段 ========== */
/** 识别请求ID(对应任务ID) */
private int requestId;
/** 识别类型 */
private int recognitionType;
/** 识别是否成功 */
private boolean success;
/** 错误码(成功时为0 */
private int errorCode;
/** 错误消息 */
private String errorMessage;
/** 主要识别结果文本 */
private String resultText;
/** 主要结果置信度 */
private float confidence;
/** 候选结果列表(按置信度降序) */
private List<Candidate> candidates;
/** 笔顺评分详情(仅笔顺类型) */
private List<StrokeOrderDetail> strokeOrderDetails;
/** 笔顺总分(0-100 */
private float strokeOrderScore;
/** 笔顺正确笔画数 */
private int correctStrokeCount;
/** 笔顺总笔画数 */
private int totalStrokeCount;
/** 作文评分详情(仅作文类型) */
private EssayScoreDetail essayDetail;
/** 数学公式LaTeX表示(仅数学类型) */
private String mathLatex;
/** 数学计算结果(如果是计算题) */
private String mathAnswer;
/** 识别耗时(毫秒) */
private long processingTimeMs;
/** 结果来源("online"在线 / "offline"离线 / "cache"缓存) */
private String source;
/** 识别时间戳 */
private long timestamp;
/* ========== 构造函数 ========== */
public RecognitionResult() {
this.candidates = new ArrayList<>();
this.strokeOrderDetails = new ArrayList<>();
this.timestamp = System.currentTimeMillis();
}
/** 创建成功结果 */
public static RecognitionResult success(int requestId, int type, String text, float confidence) {
RecognitionResult result = new RecognitionResult();
result.requestId = requestId;
result.recognitionType = type;
result.success = true;
result.errorCode = 0;
result.resultText = text;
result.confidence = confidence;
return result;
}
/** 创建失败结果 */
public static RecognitionResult failure(int requestId, int errorCode, String message) {
RecognitionResult result = new RecognitionResult();
result.requestId = requestId;
result.success = false;
result.errorCode = errorCode;
result.errorMessage = message;
return result;
}
/* ========== Getter / Setter ========== */
public int getRequestId() { return requestId; }
public void setRequestId(int id) { this.requestId = id; }
public int getRecognitionType() { return recognitionType; }
public void setRecognitionType(int type) { this.recognitionType = type; }
public boolean isSuccess() { return success; }
public void setSuccess(boolean success) { this.success = success; }
public int getErrorCode() { return errorCode; }
public void setErrorCode(int code) { this.errorCode = code; }
public String getErrorMessage() { return errorMessage; }
public void setErrorMessage(String msg) { this.errorMessage = msg; }
public String getResultText() { return resultText; }
public void setResultText(String text) { this.resultText = text; }
public float getConfidence() { return confidence; }
public void setConfidence(float c) { this.confidence = c; }
public List<Candidate> getCandidates() { return candidates; }
public void setCandidates(List<Candidate> c) { this.candidates = c; }
public void addCandidate(String text, float confidence, int rank) {
candidates.add(new Candidate(text, confidence, rank));
}
public List<StrokeOrderDetail> getStrokeOrderDetails() { return strokeOrderDetails; }
public void setStrokeOrderDetails(List<StrokeOrderDetail> d) { this.strokeOrderDetails = d; }
public float getStrokeOrderScore() { return strokeOrderScore; }
public void setStrokeOrderScore(float s) { this.strokeOrderScore = s; }
public int getCorrectStrokeCount() { return correctStrokeCount; }
public void setCorrectStrokeCount(int c) { this.correctStrokeCount = c; }
public int getTotalStrokeCount() { return totalStrokeCount; }
public void setTotalStrokeCount(int t) { this.totalStrokeCount = t; }
public EssayScoreDetail getEssayDetail() { return essayDetail; }
public void setEssayDetail(EssayScoreDetail d) { this.essayDetail = d; }
public String getMathLatex() { return mathLatex; }
public void setMathLatex(String latex) { this.mathLatex = latex; }
public String getMathAnswer() { return mathAnswer; }
public void setMathAnswer(String answer) { this.mathAnswer = answer; }
public long getProcessingTimeMs() { return processingTimeMs; }
public void setProcessingTimeMs(long ms) { this.processingTimeMs = ms; }
public String getSource() { return source; }
public void setSource(String source) { this.source = source; }
public long getTimestamp() { return timestamp; }
public void setTimestamp(long t) { this.timestamp = t; }
/* ========== 便捷方法 ========== */
/** 获取最佳候选结果 */
public Candidate getBestCandidate() {
return candidates.isEmpty() ? null : candidates.get(0);
}
/** 获取笔顺正确率 */
public float getStrokeOrderAccuracy() {
return totalStrokeCount > 0 ? (float) correctStrokeCount / totalStrokeCount : 0;
}
/** 获取识别类型的中文描述 */
public String getTypeDescription() {
switch (recognitionType) {
case TYPE_HANDWRITING: return "手写识别";
case TYPE_MATH: return "数学识别";
case TYPE_STROKE_ORDER: return "笔顺评分";
case TYPE_ESSAY: return "作文评分";
default: return "未知类型";
}
}
@Override
public String toString() {
if (success) {
return "RecognitionResult{type=" + getTypeDescription()
+ ", text='" + resultText + "'"
+ ", confidence=" + confidence
+ ", source=" + source
+ ", time=" + processingTimeMs + "ms}";
} else {
return "RecognitionResult{FAILED, code=" + errorCode
+ ", msg='" + errorMessage + "'}";
}
}
}
```
#### `model/StrokePath.java`
```java
/*
* 自然写互动课堂应用开发SDK软件 V1.0
* StrokePath - 笔迹路径数据模型
*
* 描述:封装一条完整笔画的坐标序列、属性和元数据
*/
package com.writech.sdk.model;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.List;
/**
* 笔迹路径模型
* 代表从落笔到抬笔的一条完整笔画数据
*/
public class StrokePath implements Serializable {
private static final long serialVersionUID = 1L;
/* ========== 采样点内部类 ========== */
/** 单个笔迹采样点 */
public static class Point implements Serializable {
private static final long serialVersionUID = 1L;
/** X坐标(屏幕像素或物理mm,取决于坐标空间) */
public float x;
/** Y坐标 */
public float y;
/** 压力值(归一化 0.0~1.0 */
public float pressure;
/** 时间戳(相对于笔画开始时间的毫秒偏移) */
public long timeOffset;
/** 笔尖倾斜角度(度,0-90,0为垂直,部分笔支持) */
public float tiltAngle;
/** 笔尖方位角(度,0-360,部分笔支持) */
public float azimuthAngle;
public Point() {}
public Point(float x, float y, float pressure, long timeOffset) {
this.x = x;
this.y = y;
this.pressure = pressure;
this.timeOffset = timeOffset;
}
@Override
public String toString() {
return "(" + x + "," + y + ",p=" + pressure + ",t=" + timeOffset + ")";
}
}
/* ========== 笔画属性 ========== */
/** 笔画唯一ID */
private String strokeId;
/** 来源笔设备MAC地址 */
private String penMac;
/** 学生ID */
private String studentId;
/** 页面ID(标识书写所在页面) */
private String pageId;
/** 笔画开始时间(绝对时间戳毫秒) */
private long startTimestamp;
/** 笔画结束时间 */
private long endTimestamp;
/** 笔画颜色(ARGB */
private int color = 0xFF000000;
/** 笔画基础线宽(像素) */
private float baseWidth = 3.0f;
/** 采样点列表 */
private List<Point> points;
/* ========== 分析结果(由OCR/AI引擎填充) ========== */
/** 识别的文字内容 */
private String recognizedText;
/** 识别置信度 */
private float recognitionConfidence;
/** 笔顺序号(在整个书写序列中的顺序) */
private int strokeOrder;
/** 是否为有效笔画(排除误触等) */
private boolean isValid = true;
/* ========== 构造函数 ========== */
public StrokePath() {
this.points = new ArrayList<>();
}
public StrokePath(String strokeId, String penMac) {
this.strokeId = strokeId;
this.penMac = penMac;
this.points = new ArrayList<>();
this.startTimestamp = System.currentTimeMillis();
}
/* ========== 点操作方法 ========== */
/** 添加采样点 */
public void addPoint(float x, float y, float pressure, long timeOffset) {
points.add(new Point(x, y, pressure, timeOffset));
}
/** 添加采样点(含倾斜角) */
public void addPointWithTilt(float x, float y, float pressure,
long timeOffset, float tilt, float azimuth) {
Point p = new Point(x, y, pressure, timeOffset);
p.tiltAngle = tilt;
p.azimuthAngle = azimuth;
points.add(p);
}
/** 获取采样点数量 */
public int getPointCount() {
return points.size();
}
/** 获取指定索引的采样点 */
public Point getPoint(int index) {
if (index >= 0 && index < points.size()) {
return points.get(index);
}
return null;
}
/** 获取所有采样点 */
public List<Point> getPoints() {
return points;
}
/* ========== 笔画几何计算 ========== */
/** 计算笔画总长度(像素) */
public float calculateLength() {
float length = 0;
for (int i = 1; i < points.size(); i++) {
Point p0 = points.get(i - 1);
Point p1 = points.get(i);
float dx = p1.x - p0.x;
float dy = p1.y - p0.y;
length += (float) Math.sqrt(dx * dx + dy * dy);
}
return length;
}
/** 计算笔画包围盒 */
public float[] getBoundingBox() {
if (points.isEmpty()) return new float[]{0, 0, 0, 0};
float minX = Float.MAX_VALUE, minY = Float.MAX_VALUE;
float maxX = Float.MIN_VALUE, maxY = Float.MIN_VALUE;
for (Point p : points) {
if (p.x < minX) minX = p.x;
if (p.y < minY) minY = p.y;
if (p.x > maxX) maxX = p.x;
if (p.y > maxY) maxY = p.y;
}
return new float[]{minX, minY, maxX, maxY};
}
/** 计算平均书写速度(像素/毫秒) */
public float calculateAverageSpeed() {
if (points.size() < 2) return 0;
float totalLength = calculateLength();
long duration = points.get(points.size() - 1).timeOffset - points.get(0).timeOffset;
return duration > 0 ? totalLength / duration : 0;
}
/** 计算平均压力 */
public float calculateAveragePressure() {
if (points.isEmpty()) return 0;
float sum = 0;
for (Point p : points) {
sum += p.pressure;
}
return sum / points.size();
}
/** 获取书写持续时间(毫秒) */
public long getDuration() {
if (points.size() < 2) return 0;
return points.get(points.size() - 1).timeOffset - points.get(0).timeOffset;
}
/* ========== 序列化方法 ========== */
/**
* 将笔画数据序列化为紧凑的二进制格式
* 用于BLE传输和本地缓存
*
* 格式:
* [4字节 点数][每个点: 4字节x + 4字节y + 2字节pressure + 4字节timeOffset]
*/
public byte[] toBytes() {
int pointCount = points.size();
byte[] data = new byte[4 + pointCount * 14];
/* 写入点数(大端序) */
data[0] = (byte) ((pointCount >> 24) & 0xFF);
data[1] = (byte) ((pointCount >> 16) & 0xFF);
data[2] = (byte) ((pointCount >> 8) & 0xFF);
data[3] = (byte) (pointCount & 0xFF);
int offset = 4;
for (Point p : points) {
/* 写入X坐标(float → 4字节) */
int fx = Float.floatToIntBits(p.x);
data[offset++] = (byte) ((fx >> 24) & 0xFF);
data[offset++] = (byte) ((fx >> 16) & 0xFF);
data[offset++] = (byte) ((fx >> 8) & 0xFF);
data[offset++] = (byte) (fx & 0xFF);
/* 写入Y坐标 */
int fy = Float.floatToIntBits(p.y);
data[offset++] = (byte) ((fy >> 24) & 0xFF);
data[offset++] = (byte) ((fy >> 16) & 0xFF);
data[offset++] = (byte) ((fy >> 8) & 0xFF);
data[offset++] = (byte) (fy & 0xFF);
/* 写入压力值(归一化后*65535转uint16 */
int pressure16 = (int) (p.pressure * 65535);
data[offset++] = (byte) ((pressure16 >> 8) & 0xFF);
data[offset++] = (byte) (pressure16 & 0xFF);
/* 写入时间偏移(uint32 */
long t = p.timeOffset;
data[offset++] = (byte) ((t >> 24) & 0xFF);
data[offset++] = (byte) ((t >> 16) & 0xFF);
data[offset++] = (byte) ((t >> 8) & 0xFF);
data[offset++] = (byte) (t & 0xFF);
}
return data;
}
/* ========== Getter / Setter ========== */
public String getStrokeId() { return strokeId; }
public void setStrokeId(String strokeId) { this.strokeId = strokeId; }
public String getPenMac() { return penMac; }
public void setPenMac(String penMac) { this.penMac = penMac; }
public String getStudentId() { return studentId; }
public void setStudentId(String studentId) { this.studentId = studentId; }
public String getPageId() { return pageId; }
public void setPageId(String pageId) { this.pageId = pageId; }
public long getStartTimestamp() { return startTimestamp; }
public void setStartTimestamp(long t) { this.startTimestamp = t; }
public long getEndTimestamp() { return endTimestamp; }
public void setEndTimestamp(long t) { this.endTimestamp = t; }
public int getColor() { return color; }
public void setColor(int color) { this.color = color; }
public float getBaseWidth() { return baseWidth; }
public void setBaseWidth(float w) { this.baseWidth = w; }
public String getRecognizedText() { return recognizedText; }
public void setRecognizedText(String text) { this.recognizedText = text; }
public float getRecognitionConfidence() { return recognitionConfidence; }
public void setRecognitionConfidence(float c) { this.recognitionConfidence = c; }
public int getStrokeOrder() { return strokeOrder; }
public void setStrokeOrder(int order) { this.strokeOrder = order; }
public boolean isValid() { return isValid; }
public void setValid(boolean valid) { isValid = valid; }
@Override
public String toString() {
return "StrokePath{id='" + strokeId + "', points=" + points.size()
+ ", duration=" + getDuration() + "ms"
+ ", text='" + recognizedText + "'}";
}
}
```