*
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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 位表示。
|
||||
* 例:设置 100(0x0064)→ 整条命令 `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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
// 预期路径
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user