跳转至

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
}