This commit is contained in:
阿猫
2026-09-03 12:38:16 +08:00
parent 09669fe4a2
commit e96e92d7ab
68 changed files with 2810 additions and 0 deletions

View File

@@ -0,0 +1,13 @@
// ble-protocol纯 Kotlin JVM 模块,零第三方依赖(测试框架除外)
// 职责IE-1000 / VM208 蓝牙帧的解析与编码,脱离 Android 可独立单测
plugins {
alias(libs.plugins.kotlin.jvm)
}
kotlin {
jvmToolchain(17)
}
dependencies {
testImplementation(libs.junit)
}

View File

@@ -0,0 +1,85 @@
package com.stec.cmd.protocol.ie1000
/**
* IE-1000 Notify 帧解析器(协议原文见 docs/BLE-IE1000.txt
*
* 帧格式(固定 12 字节):
* ```
* 'Y'(0x59) P0 P1 P2 P3 'T'(0x54) T0 T1 T2 T3 0x0D 0x0A
* ```
* 其中压力 4 字节、温度 4 字节均为小端 IEEE-754 float。
* 协议示例:`59 D8 BE 50 41 54 00 00 E8 41 0D 0A` → 压力 13.04、温度 29.0。
*
* BLE Notify 每次回调的字节数不定,解析器内部维护字节缓冲,
* [feed] 可按任意分片反复调用,按 `0D 0A` 定界切帧;
* 切出但不合规格式的帧(长度/帧头不符)整体丢弃后继续重同步。
*/
class Ie1000FrameParser {
private val buffer = ArrayDeque<Byte>()
/** 追加收到的字节,返回本轮累计解析出的完整读数列表。 */
fun feed(data: ByteArray): List<Ie1000Reading> {
for (b in data) buffer.addLast(b)
val readings = mutableListOf<Ie1000Reading>()
while (true) {
val frame = nextFrame() ?: break
parseFrame(frame)?.let(readings::add)
}
return readings
}
/** 清空内部缓冲(断连或采集会话重置时调用)。 */
fun reset() {
buffer.clear()
}
/** 从缓冲头部取出一条以 `0D 0A` 结尾的帧(含结束符);不足一条返回 null。 */
private fun nextFrame(): List<Byte>? {
var index = 0
var end = -1
while (index + 1 < buffer.size) {
if (buffer.elementAt(index) == CR && buffer.elementAt(index + 1) == LF) {
end = index
break
}
index++
}
if (end == -1) return null
val frame = ArrayList<Byte>(end + 2)
repeat(end + 2) { frame.add(buffer.removeFirst()) }
return frame
}
private fun parseFrame(frame: List<Byte>): Ie1000Reading? {
if (frame.size != FRAME_LENGTH) return null
if (frame[0] != HEADER_PRESSURE || frame[5] != HEADER_TEMPERATURE) return null
val pressure = Float.fromBits(frame.leIntAt(offset = 1))
val temperature = Float.fromBits(frame.leIntAt(offset = 6))
return Ie1000Reading(
pressure = pressure,
temperature = temperature,
receivedAtMillis = System.currentTimeMillis(),
)
}
/** 小端序读取 4 字节为 Int位模式交由 [Float.fromBits] 解释)。 */
private fun List<Byte>.leIntAt(offset: Int): Int =
(this[offset].toInt() and 0xFF) or
((this[offset + 1].toInt() and 0xFF) shl 8) or
((this[offset + 2].toInt() and 0xFF) shl 16) or
((this[offset + 3].toInt() and 0xFF) shl 24)
companion object {
const val FRAME_LENGTH = 12
/** 压力段帧头 'Y'。 */
val HEADER_PRESSURE: Byte = 'Y'.code.toByte()
/** 温度段帧头 'T'。 */
val HEADER_TEMPERATURE: Byte = 'T'.code.toByte()
private const val CR: Byte = 0x0D
private const val LF: Byte = 0x0A
}
}

View File

@@ -0,0 +1,14 @@
package com.stec.cmd.protocol.ie1000
/**
* IE-1000 振弦式读数仪单帧采集读数。
*
* @property pressure 压力值float 原值,量程单位依设备标定)
* @property temperature 温度值(℃)
* @property receivedAtMillis 本机接收时刻epoch 毫秒)
*/
data class Ie1000Reading(
val pressure: Float,
val temperature: Float,
val receivedAtMillis: Long,
)

View File

@@ -0,0 +1,46 @@
package com.stec.cmd.protocol.vm208
/**
* VM208 下行命令编码器(写 UUID协议 6.1)。
*
* 激励设置命令格式:`0F 0F 06 0A XX XX 0D 0A`
* 其中 `XX XX` 为设置值的大端 16 位表示。
* 例:设置 1000x0064→ 整条命令 `0F 0F 06 0A 00 64 0D 0A`
* 设备通过 Notify 原样回显该命令作为应答。
*/
object Vm208CommandEncoder {
/**
* 编码激励设置命令。
*
* @param excitation 激励设置值,须在 0..65535
* @return 8 字节完整命令帧
* @throws IllegalArgumentException 超出 16 位范围
*/
fun encodeExcitation(excitation: Int): ByteArray {
require(excitation in 0..0xFFFF) { "激励值超出 16 位范围: $excitation" }
return byteArrayOf(
0x0F, 0x0F, 0x06, 0x0A,
((excitation shr 8) and 0xFF).toByte(),
(excitation and 0xFF).toByte(),
0x0D, 0x0A,
)
}
/**
* 解析设备回显的激励命令应答帧。
*
* @return 应答中的激励值;帧长或帧头不符返回 null
*/
fun decodeExcitationAck(frame: ByteArray): Int? {
if (frame.size != COMMAND_LENGTH) return null
if (frame[0] != 0x0F.toByte() || frame[1] != 0x0F.toByte()) return null
if (frame[2] != 0x06.toByte() || frame[3] != 0x0A.toByte()) return null
if (frame[6] != 0x0D.toByte() || frame[7] != 0x0A.toByte()) return null
val high = frame[4].toInt() and 0xFF
val low = frame[5].toInt() and 0xFF
return (high shl 8) or low
}
const val COMMAND_LENGTH = 8
}

View File

@@ -0,0 +1,114 @@
package com.stec.cmd.protocol.vm208
/**
* VM208 Notify 帧解析器(协议原文见 docs/BLE-IE1000.txt
*
* 振弦数据报文格式ASCII 字段 + 固定结束符):
* ```
* CC F12345 T334 E14039 SG AA 0x0D 0x0A
* ```
* - `CC` 报文头、`AA` 报文尾、`0D 0A` 结束符号;
* - `F12345`:频率原值 12345 → 12345/10 = 1234.5 Hz
* - `T334`:温度原值 334 → 334/10 = 33.4 ℃;
* - `E14039`:电压原值 14039 → 14039/100 = 140 V
* - `SG`S 后跟 1 位质量字母G 好 / Q 一般 / N 没元件)。
*
* 与 [com.stec.cmd.protocol.ie1000.Ie1000FrameParser] 相同的缓冲策略:
* [feed] 支持任意分片,按 `0D 0A` 切帧,字段缺失或非数字的脏帧整体丢弃。
*/
class Vm208FrameParser {
private val buffer = ArrayDeque<Byte>()
/** 追加收到的字节,返回本轮累计解析出的完整读数列表。 */
fun feed(data: ByteArray): List<Vm208Reading> {
for (b in data) buffer.addLast(b)
val readings = mutableListOf<Vm208Reading>()
while (true) {
val frame = nextFrame() ?: break
parseFrame(frame)?.let(readings::add)
}
return readings
}
/** 清空内部缓冲(断连或采集会话重置时调用)。 */
fun reset() {
buffer.clear()
}
private fun nextFrame(): List<Byte>? {
var index = 0
var end = -1
while (index + 1 < buffer.size) {
if (buffer.elementAt(index) == CR && buffer.elementAt(index + 1) == LF) {
end = index
break
}
index++
}
if (end == -1) return null
val frame = ArrayList<Byte>(end + 2)
repeat(end + 2) { frame.add(buffer.removeFirst()) }
return frame
}
private fun parseFrame(frame: List<Byte>): Vm208Reading? {
if (frame.size < MIN_FRAME_LENGTH) return null
if (frame[0] != HEADER_CC || frame[frame.size - 3] != TAIL_AA) return null
val text = frame.toByteArray().toString(Charsets.US_ASCII)
val frequency = fieldOf(text, FIELD_FREQUENCY)?.div(SCALE_FREQUENCY) ?: return null
val temperature = fieldOf(text, FIELD_TEMPERATURE)?.div(SCALE_TEMPERATURE) ?: return null
val voltage = fieldOf(text, FIELD_VOLTAGE)?.div(SCALE_VOLTAGE) ?: return null
val qualityCode = qualityCodeOf(text) ?: return null
return Vm208Reading(
frequency = frequency,
temperature = temperature,
voltage = voltage,
quality = Vm208Quality.fromCode(qualityCode),
receivedAtMillis = System.currentTimeMillis(),
)
}
/** 取 `X` 字段后直到下一字母或报文尾的 ASCII 内容并按整数解析;缺失/非法返回 null。 */
private fun fieldOf(text: String, field: Char): Double? =
fieldRaw(text, field)?.toDoubleOrNull()
private fun fieldRaw(text: String, field: Char): String? {
val start = text.indexOf(field)
if (start < 0) return null
var end = start + 1
while (end < text.length && text[end].isDigit()) end++
if (end == start + 1) return null
return text.substring(start + 1, end)
}
/** 取 S 质量字段后的 1 个字母G/Q/N其余交由 UNKNOWN 兜底);缺字段返回 null。 */
private fun qualityCodeOf(text: String): Char? {
val start = text.indexOf(FIELD_QUALITY)
if (start < 0 || start + 1 >= text.length) return null
return text[start + 1]
}
companion object {
/** 最短帧CC F1 T1 E1 Sx AA CR LF = 9 字节。 */
const val MIN_FRAME_LENGTH = 9
/** 报文头 'C''C' 的首字节。 */
val HEADER_CC: Byte = 'C'.code.toByte()
/** 报文尾 'A''A' 的首字节(其后为 0D 0A 结束符)。 */
val TAIL_AA: Byte = 'A'.code.toByte()
private const val FIELD_FREQUENCY = 'F'
private const val FIELD_TEMPERATURE = 'T'
private const val FIELD_VOLTAGE = 'E'
private const val FIELD_QUALITY = 'S'
private const val SCALE_FREQUENCY = 10.0
private const val SCALE_TEMPERATURE = 10.0
private const val SCALE_VOLTAGE = 100.0
private const val CR: Byte = 0x0D
private const val LF: Byte = 0x0A
}
}

View File

@@ -0,0 +1,38 @@
package com.stec.cmd.protocol.vm208
/** VM208 帧内 S 字段携带的元件质量状态。 */
enum class Vm208Quality(val code: Char) {
/** 元件质量好。 */
GOOD('G'),
/** 元件一般。 */
FAIR('Q'),
/** 没有元件。 */
NONE('N'),
/** 非约定字母的兜底,避免脏帧导致崩溃。 */
UNKNOWN('?');
companion object {
fun fromCode(code: Char): Vm208Quality =
entries.firstOrNull { it.code == code } ?: UNKNOWN
}
}
/**
* VM208 综合采集仪单帧采集读数。
*
* @property frequency 频率Hz帧内原值 / 10
* @property temperature 温度(℃,帧内原值 / 10
* @property voltage 电压V帧内原值 / 100
* @property quality 元件质量状态
* @property receivedAtMillis 本机接收时刻epoch 毫秒)
*/
data class Vm208Reading(
val frequency: Double,
val temperature: Double,
val voltage: Double,
val quality: Vm208Quality,
val receivedAtMillis: Long,
)

View File

@@ -0,0 +1,67 @@
package com.stec.cmd.protocol.ie1000
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
/** 用协议文档示例字节做冒烟验证。 */
class Ie1000FrameParserTest {
private val parser = Ie1000FrameParser()
/**
* 协议示例帧59 D8 BE 50 41 54 00 00 E8 41 0D 0A。
* 压力段小端 float 0x4150BED8 = 13.0465927…(文档标注 13.04 为截断写法);
* 温度段 0x41E80000 = 29.0 精确。
*/
private val sample = byteArrayOf(
0x59.toByte(), 0xD8.toByte(), 0xBE.toByte(), 0x50.toByte(), 0x41.toByte(),
0x54.toByte(), 0x00, 0x00, 0xE8.toByte(), 0x41.toByte(),
0x0D, 0x0A,
)
@Test
fun `整帧一次喂入解析出协议示例读数`() {
val readings = parser.feed(sample)
assertEquals(1, readings.size)
assertEquals(13.0466f, readings[0].pressure, 0.001f)
assertEquals(29.0f, readings[0].temperature, 0.001f)
}
@Test
fun `逐字节分片喂入仍可切帧(残包容错)`() {
val readings = sample.flatMap { listOf(it) }.map { parser.feed(byteArrayOf(it)) }
.reduce { acc, list -> acc + list }
assertEquals(1, readings.size)
assertEquals(13.0466f, readings[0].pressure, 0.001f)
}
@Test
fun `粘包两帧一次解析`() {
val doubled = sample + sample
val readings = parser.feed(doubled)
assertEquals(2, readings.size)
}
@Test
fun `脏帧被丢弃后继续重同步`() {
val dirty = byteArrayOf(0x01, 0x02, 0x0D, 0x0A) // 长度不足的垃圾帧
val readings = parser.feed(dirty + sample)
assertEquals(1, readings.size)
assertEquals(13.0466f, readings[0].pressure, 0.001f)
}
@Test
fun `帧头错误的完整帧被丢弃`() {
val badHeader = sample.copyOf().also { it[0] = 0x58 } // 'X' 而非 'Y'
val readings = parser.feed(badHeader)
assertTrue(readings.isEmpty())
}
}

View File

@@ -0,0 +1,90 @@
package com.stec.cmd.protocol.vm208
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
/** 用协议文档示例帧做冒烟验证。 */
class Vm208FrameParserTest {
private val parser = Vm208FrameParser()
/**
* 协议示例报文CC F12345 T334 E14039 SG AA 0D 0A
* → 频率 1234.5 Hz、温度 33.4 ℃、电压 140 V、质量 GOOD。
*/
private fun sampleFrame(): ByteArray =
"CC F12345 T334 E14039 SG AA".toByteArray(Charsets.US_ASCII) + byteArrayOf(0x0D, 0x0A)
@Test
fun `整帧解析出协议示例读数`() {
val readings = parser.feed(sampleFrame())
assertEquals(1, readings.size)
assertEquals(1234.5, readings[0].frequency, 1e-9)
assertEquals(33.4, readings[0].temperature, 1e-9)
assertEquals(140.0, readings[0].voltage, 1e-9)
assertEquals(Vm208Quality.GOOD, readings[0].quality)
}
@Test
fun `分片喂入仍可切帧`() {
val frame = sampleFrame()
val mid = frame.size / 2
val readings = parser.feed(frame.copyOfRange(0, mid)) + parser.feed(frame.copyOfRange(mid, frame.size))
assertEquals(1, readings.size)
assertEquals(Vm208Quality.GOOD, readings[0].quality)
}
@Test
fun `状态字母映射 Q 与 N`() {
val qFrame = "CC F12345 T334 E14039 SQ AA".toByteArray(Charsets.US_ASCII) + byteArrayOf(0x0D, 0x0A)
val nFrame = "CC F12345 T334 E14039 SN AA".toByteArray(Charsets.US_ASCII) + byteArrayOf(0x0D, 0x0A)
val readings = parser.feed(qFrame + nFrame)
assertEquals(2, readings.size)
assertEquals(Vm208Quality.FAIR, readings[0].quality)
assertEquals(Vm208Quality.NONE, readings[1].quality)
}
@Test
fun `未知状态字母映射 UNKNOWN 而非崩溃`() {
val xFrame = "CC F12345 T334 E14039 SX AA".toByteArray(Charsets.US_ASCII) + byteArrayOf(0x0D, 0x0A)
val readings = parser.feed(xFrame)
assertEquals(1, readings.size)
assertEquals(Vm208Quality.UNKNOWN, readings[0].quality)
}
@Test
fun `缺字段脏帧被丢弃`() {
val dirty = "CC F12345 AA".toByteArray(Charsets.US_ASCII) + byteArrayOf(0x0D, 0x0A)
val readings = parser.feed(dirty + sampleFrame())
assertEquals(1, readings.size)
}
@Test
fun `激励命令编码与应答回解析`() {
val command = Vm208CommandEncoder.encodeExcitation(100)
assertEquals(8, command.size)
assertEquals("0F0F060A00640D0A", command.joinToString("") { "%02X".format(it) })
assertEquals(100, Vm208CommandEncoder.decodeExcitationAck(command))
assertNull(Vm208CommandEncoder.decodeExcitationAck(command.copyOf(7)))
}
@Test
fun `激励值越界抛出`() {
try {
Vm208CommandEncoder.encodeExcitation(0x10000)
throw AssertionError("应抛出 IllegalArgumentException")
} catch (expected: IllegalArgumentException) {
// 预期路径
}
}
}