apps\iot\src\main\java\cn\nfarms\iot\protocol\Frame.kt¶
源码路径:
apps\iot\src\main\java\cn\nfarms\iot\protocol\Frame.kt· 166 行
package cn.nfarms.iot.protocol
import kotlinx.serialization.Serializable
/**
* 设备协议的帧格式。
*
* ```text
* 0 1 2 3 4..N-2 N-1
* +-------+-------+-------+-------+-----------+-------+
* | 0xAA | 0x55 | CMD | LEN | PAYLOAD | CRC8 |
* +-------+-------+-------+-------+-----------+-------+
* ```
*
* 几条必须写死的约定(都是实机调试里最容易出分歧的地方):
*
* * **字节序统一小端**。混合大小端是跨平台协议的第一大坑;
* * **LEN 只数 payload**,不含帧头与校验 —— 否则改帧头长度就要全线改一遍;
* * **CRC 覆盖 CMD + LEN + PAYLOAD**,不覆盖帧头魔数。魔数错说明根本不是一帧,
* 那种情况应当直接丢字节重同步,而不是报"校验错";
* * **最大 250 字节**。BLE 的 MTU 常见 23(默认)/ 185 / 247,
* 单帧超过 MTU 就必须分片,而分片是另一个协议层的事 —— 这里先把上限卡死。
*/
object Frame {
const val MAGIC0: Int = 0xAA
const val MAGIC1: Int = 0x55
const val MAX_PAYLOAD: Int = 250
fun encode(cmd: Cmd, payload: ByteArray = ByteArray(0)): ByteArray {
require(payload.size <= MAX_PAYLOAD) { "payload 超过 $MAX_PAYLOAD 字节,需要分片" }
val out = ByteArray(5 + payload.size)
out[0] = MAGIC0.toByte()
out[1] = MAGIC1.toByte()
out[2] = cmd.code.toByte()
out[3] = payload.size.toByte()
payload.copyInto(out, 4)
out[out.size - 1] = crc8(out, 2, out.size - 1).toByte()
return out
}
/**
* 增量解码:喂一段字节流,吐出所有完整的帧。
*
* 为什么不是「一帧一行」的简单解析:BLE 的回包会被拆成多个 notification,
* 也可能一次塞进两帧。解码器必须自己缓冲、找帧头、按 LEN 等够数据。
*/
fun decode(buf: ByteArray, len: Int = buf.size): DecodeResult {
val frames = mutableListOf<Decoded>()
var i = 0
scan@ while (i + 5 <= len) {
// 找帧头;找不到就丢掉前面所有字节
if ((buf[i].toInt() and 0xFF) != MAGIC0 || (buf[i + 1].toInt() and 0xFF) != MAGIC1) {
i++
continue@scan
}
val cmd = Cmd.fromCode(buf[i + 2].toInt() and 0xFF)
val payloadLen = buf[i + 3].toInt() and 0xFF
if (payloadLen > MAX_PAYLOAD) { i++; continue@scan }
val total = 5 + payloadLen
if (i + total > len) break // 还没收够,等下一次
val crcOk = (buf[i + total - 1].toInt() and 0xFF) == crc8(buf, i + 2, i + total - 1)
if (crcOk && cmd != null) {
val payload = buf.copyOfRange(i + 4, i + 4 + payloadLen)
frames += Decoded(cmd, payload, ok = true)
i += total
} else {
// 校验错:只丢一个字节再找帧头 —— 整帧丢弃会连带丢掉紧跟着的合法帧
frames += Decoded(cmd ?: Cmd.Unknown, ByteArray(0), ok = false)
i++
}
}
return DecodeResult(frames, buf.copyOfRange(i, len))
}
/** 覆盖 [from, to) 的 CRC-8/ATM(poly 0x07, init 0x00)。 */
fun crc8(data: ByteArray, from: Int, to: Int): Int {
var crc = 0
for (k in from until to) {
crc = crc xor (data[k].toInt() and 0xFF)
repeat(8) {
crc = if (crc and 0x80 != 0) ((crc shl 1) xor 0x07) and 0xFF else (crc shl 1) and 0xFF
}
}
return crc
}
}
data class DecodeResult(val frames: List<Decoded>, val rest: ByteArray)
data class Decoded(val cmd: Cmd, val payload: ByteArray, val ok: Boolean)
enum class Cmd(val code: Int, val label: String) {
Hello(0x01, "握手"),
HelloAck(0x02, "握手应答"),
ReadStatus(0x10, "读状态"),
Status(0x11, "状态上报"),
SetPower(0x20, "开关"),
SetMode(0x21, "设模式"),
OtaBegin(0x30, "升级开始"),
OtaChunk(0x31, "升级分片"),
OtaEnd(0x32, "升级结束"),
OtaAck(0x33, "升级应答"),
Error(0x7F, "错误"),
Unknown(0xFF, "未知"),
;
companion object {
fun fromCode(c: Int): Cmd? = entries.firstOrNull { it.code == c }
}
}
/**
* 载荷编解码。
*
* 手写字节操作而不是用序列化框架:协议是**跨语言**的(固件是 C),
* 用 kotlinx.serialization 生成一份只有 Kotlin 认得的格式没有意义。
*/
object Codec {
fun statusPayload(temperatureX10: Int, power: Boolean, mode: Int, version: String): ByteArray {
val raw = version.toByteArray(Charsets.US_ASCII)
val v = if (raw.size <= 15) raw else raw.copyOf(15)
val out = ByteArray(4 + v.size)
out[0] = (temperatureX10 and 0xFF).toByte()
out[1] = ((temperatureX10 shr 8) and 0xFF).toByte()
out[2] = (if (power) 1 else 0).toByte()
out[3] = mode.toByte()
v.copyInto(out, 4)
return out
}
/** 解析状态帧;字段不足时返回 null 而不是抛异常 —— 坏包不该把会话打断。 */
fun parseStatus(payload: ByteArray): DeviceStatus? {
if (payload.size < 4) return null
val t = (payload[0].toInt() and 0xFF) or ((payload[1].toInt() and 0xFF) shl 8)
val power = payload[2].toInt() != 0
val mode = payload[3].toInt() and 0xFF
val ver = if (payload.size > 4) String(payload, 4, payload.size - 4, Charsets.US_ASCII) else ""
return DeviceStatus(t, power, mode, ver)
}
fun powerPayload(on: Boolean): ByteArray = byteArrayOf(if (on) 1 else 0)
fun modePayload(mode: Int): ByteArray = byteArrayOf(mode.toByte())
/** OTA 分片:序号(4B 小端) + 数据。 */
fun chunkPayload(index: Int, data: ByteArray): ByteArray {
val out = ByteArray(4 + data.size)
out[0] = (index and 0xFF).toByte()
out[1] = ((index shr 8) and 0xFF).toByte()
out[2] = ((index shr 16) and 0xFF).toByte()
out[3] = ((index shr 24) and 0xFF).toByte()
data.copyInto(out, 4)
return out
}
}
@Serializable
data class DeviceStatus(
val temperatureX10: Int = 0,
val power: Boolean = false,
val mode: Int = 0,
val version: String = "",
) {
val temperatureC: Double get() = temperatureX10 / 10.0
}