feat: make gb32960 archive history query production ready
This commit is contained in:
@@ -0,0 +1,172 @@
|
||||
package com.lingniu.ingest.protocol.gb32960.codec;
|
||||
|
||||
import com.lingniu.ingest.api.spi.DecodeException;
|
||||
import com.lingniu.ingest.protocol.gb32960.codec.parser.v2016.PositionV2016BlockParser;
|
||||
import com.lingniu.ingest.protocol.gb32960.codec.parser.v2016.VehicleV2016BlockParser;
|
||||
import com.lingniu.ingest.protocol.gb32960.model.InfoBlock;
|
||||
import com.lingniu.ingest.protocol.gb32960.model.InfoBlockType;
|
||||
import com.lingniu.ingest.protocol.gb32960.model.ProtocolVersion;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.util.List;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
/**
|
||||
* 验证 {@link Gb32960BodyParser} 单块异常隔离行为。
|
||||
*
|
||||
* <p>三个隔离场景:
|
||||
* <ol>
|
||||
* <li>固定长度块 parser 抛异常 → 失败块兜 Raw(按 fixedLen 截取)+ 后续块继续解析
|
||||
* <li>固定长度块剩余字节不足(截断帧) → 兜 Raw(剩余) + break 循环
|
||||
* <li>变长块 parser 抛异常 → 兜 Raw(从失败块起剩余全部) + break 循环
|
||||
* </ol>
|
||||
*
|
||||
* <p>外加一个严格模式回退测试:{@code lenientBlockFailure=false} 时异常应抛 DecodeException。
|
||||
*/
|
||||
class Gb32960BodyParserIsolationTest {
|
||||
|
||||
/** 模拟一个固定长度的 Position parser,声明 fixedLength=9,parse 时主动抛异常。 */
|
||||
private static final InfoBlockParser EXPLODING_FIXED_LEN_POSITION = new InfoBlockParser() {
|
||||
@Override public ProtocolVersion version() { return ProtocolVersion.V2016; }
|
||||
@Override public int typeCode() { return 0x05; }
|
||||
@Override public int fixedLength() { return 9; }
|
||||
@Override public InfoBlock parse(ByteBuffer buffer) {
|
||||
buffer.get();
|
||||
buffer.get();
|
||||
throw new DecodeException("simulated parser failure in Position");
|
||||
}
|
||||
};
|
||||
|
||||
/** 模拟一个变长 Alarm parser(fixedLength=-1),消费若干字节后抛。 */
|
||||
private static final InfoBlockParser EXPLODING_VAR_LEN_ALARM = new InfoBlockParser() {
|
||||
@Override public ProtocolVersion version() { return ProtocolVersion.V2016; }
|
||||
@Override public int typeCode() { return 0x07; }
|
||||
@Override public int fixedLength() { return -1; }
|
||||
@Override public InfoBlock parse(ByteBuffer buffer) {
|
||||
buffer.get();
|
||||
throw new DecodeException("simulated parser failure in Alarm list-length read");
|
||||
}
|
||||
};
|
||||
|
||||
@Test
|
||||
void fixedLengthBlockFailure_isIsolated_subsequentBlocksStillParsed() {
|
||||
InfoBlockParserRegistry registry = new InfoBlockParserRegistry(List.of(
|
||||
new VehicleV2016BlockParser(),
|
||||
EXPLODING_FIXED_LEN_POSITION));
|
||||
|
||||
Gb32960BodyParser parser = new Gb32960BodyParser(registry);
|
||||
|
||||
ByteArrayOutputStream os = new ByteArrayOutputStream();
|
||||
writeValidVehicle(os); // 1 + 20 = 21B
|
||||
writePositionTypeAnd9ByteBody(os); // 1 + 9 = 10B(parser 会爆)
|
||||
writeValidVehicle(os); // 1 + 20 = 21B
|
||||
ByteBuffer body = ByteBuffer.wrap(os.toByteArray());
|
||||
|
||||
var result = parser.parse(ProtocolVersion.V2016, body);
|
||||
|
||||
assertThat(result.blocks()).hasSize(3);
|
||||
assertThat(result.blocks().get(0)).isInstanceOf(InfoBlock.Gb32960V2016.Vehicle.class);
|
||||
assertThat(result.blocks().get(1)).isInstanceOfSatisfying(InfoBlock.Raw.class, raw -> {
|
||||
assertThat(raw.typeCode()).isEqualTo(0x05);
|
||||
assertThat(raw.type()).isEqualTo(InfoBlockType.RAW);
|
||||
assertThat(raw.bytes()).hasSize(9);
|
||||
});
|
||||
assertThat(result.blocks().get(2)).isInstanceOf(InfoBlock.Gb32960V2016.Vehicle.class);
|
||||
assertThat(body.hasRemaining()).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void truncatedFixedLengthBlock_isWrappedAsRaw_loopTerminates() {
|
||||
InfoBlockParserRegistry registry = new InfoBlockParserRegistry(List.of(
|
||||
new VehicleV2016BlockParser(),
|
||||
new PositionV2016BlockParser()));
|
||||
|
||||
Gb32960BodyParser parser = new Gb32960BodyParser(registry);
|
||||
|
||||
ByteArrayOutputStream os = new ByteArrayOutputStream();
|
||||
writeValidVehicle(os);
|
||||
os.write(0x05);
|
||||
os.write(0);
|
||||
os.write(0);
|
||||
os.write(0);
|
||||
ByteBuffer body = ByteBuffer.wrap(os.toByteArray());
|
||||
|
||||
var result = parser.parse(ProtocolVersion.V2016, body);
|
||||
|
||||
assertThat(result.blocks()).hasSize(2);
|
||||
assertThat(result.blocks().get(0)).isInstanceOf(InfoBlock.Gb32960V2016.Vehicle.class);
|
||||
assertThat(result.blocks().get(1)).isInstanceOfSatisfying(InfoBlock.Raw.class, raw -> {
|
||||
assertThat(raw.typeCode()).isEqualTo(0x05);
|
||||
assertThat(raw.bytes()).hasSize(3);
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void variableLengthBlockFailure_swallowsRemainderAsRaw_loopBreaks() {
|
||||
InfoBlockParserRegistry registry = new InfoBlockParserRegistry(List.of(
|
||||
new VehicleV2016BlockParser(),
|
||||
EXPLODING_VAR_LEN_ALARM));
|
||||
|
||||
Gb32960BodyParser parser = new Gb32960BodyParser(registry);
|
||||
|
||||
ByteArrayOutputStream os = new ByteArrayOutputStream();
|
||||
writeValidVehicle(os);
|
||||
os.write(0x07);
|
||||
for (int i = 0; i < 10; i++) os.write(0xAA);
|
||||
writeValidVehicle(os);
|
||||
ByteBuffer body = ByteBuffer.wrap(os.toByteArray());
|
||||
|
||||
var result = parser.parse(ProtocolVersion.V2016, body);
|
||||
|
||||
assertThat(result.blocks()).hasSize(2);
|
||||
assertThat(result.blocks().get(0)).isInstanceOf(InfoBlock.Gb32960V2016.Vehicle.class);
|
||||
assertThat(result.blocks().get(1)).isInstanceOfSatisfying(InfoBlock.Raw.class, raw -> {
|
||||
assertThat(raw.typeCode()).isEqualTo(0x07);
|
||||
assertThat(raw.bytes()).hasSize(31);
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void strictMode_throwsOnAnyBlockFailure() {
|
||||
InfoBlockParserRegistry registry = new InfoBlockParserRegistry(List.of(
|
||||
new VehicleV2016BlockParser(),
|
||||
EXPLODING_FIXED_LEN_POSITION));
|
||||
|
||||
Gb32960BodyParser parser = new Gb32960BodyParser(registry);
|
||||
parser.setLenientBlockFailure(false);
|
||||
|
||||
ByteArrayOutputStream os = new ByteArrayOutputStream();
|
||||
writeValidVehicle(os);
|
||||
writePositionTypeAnd9ByteBody(os);
|
||||
ByteBuffer body = ByteBuffer.wrap(os.toByteArray());
|
||||
|
||||
assertThatThrownBy(() -> parser.parse(ProtocolVersion.V2016, body))
|
||||
.isInstanceOf(DecodeException.class);
|
||||
}
|
||||
|
||||
private static void writeValidVehicle(ByteArrayOutputStream os) {
|
||||
os.write(0x01);
|
||||
os.write(0x01);
|
||||
os.write(0x01);
|
||||
os.write(0x01);
|
||||
os.write(0); os.write(0);
|
||||
os.write(0); os.write(0); os.write(0); os.write(0);
|
||||
os.write(0); os.write(0);
|
||||
os.write(0); os.write(0);
|
||||
os.write(50);
|
||||
os.write(0x01);
|
||||
os.write(0);
|
||||
os.write(0); os.write(0);
|
||||
os.write(0);
|
||||
os.write(0);
|
||||
}
|
||||
|
||||
private static void writePositionTypeAnd9ByteBody(ByteArrayOutputStream os) {
|
||||
os.write(0x05);
|
||||
for (int i = 0; i < 9; i++) os.write(0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package com.lingniu.ingest.protocol.gb32960.codec;
|
||||
|
||||
import com.lingniu.ingest.protocol.gb32960.codec.parser.v2016.AlarmV2016BlockParser;
|
||||
import com.lingniu.ingest.protocol.gb32960.codec.parser.v2016.DriveMotorV2016BlockParser;
|
||||
import com.lingniu.ingest.protocol.gb32960.codec.parser.v2016.EngineV2016BlockParser;
|
||||
import com.lingniu.ingest.protocol.gb32960.codec.parser.v2016.ExtremeValueV2016BlockParser;
|
||||
import com.lingniu.ingest.protocol.gb32960.codec.parser.v2016.FuelCellV2016BlockParser;
|
||||
import com.lingniu.ingest.protocol.gb32960.codec.parser.v2016.PositionV2016BlockParser;
|
||||
import com.lingniu.ingest.protocol.gb32960.codec.parser.v2016.TemperatureV2016BlockParser;
|
||||
import com.lingniu.ingest.protocol.gb32960.codec.parser.v2016.VehicleV2016BlockParser;
|
||||
import com.lingniu.ingest.protocol.gb32960.codec.parser.v2016.VoltageV2016BlockParser;
|
||||
import com.lingniu.ingest.protocol.gb32960.model.Gb32960Message;
|
||||
import com.lingniu.ingest.protocol.gb32960.model.InfoBlock;
|
||||
import org.junit.jupiter.api.DynamicTest;
|
||||
import org.junit.jupiter.api.TestFactory;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.URISyntaxException;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/**
|
||||
* 黄金样本集回放测试:遍历 {@code src/test/resources/samples/gb32960/*.hex},
|
||||
* 逐帧解码并断言命令类型来自 {@link com.lingniu.ingest.protocol.gb32960.model.CommandType} 有效值。
|
||||
*
|
||||
* <p>样本文件目前为空,该测试会优雅地跳过。加入样本后会自动变为 N 条动态用例。
|
||||
*/
|
||||
class Gb32960DecoderGoldenTest {
|
||||
|
||||
private final Gb32960MessageDecoder decoder = new Gb32960MessageDecoder(
|
||||
new Gb32960BodyParser(new InfoBlockParserRegistry(List.of(
|
||||
new VehicleV2016BlockParser(),
|
||||
new PositionV2016BlockParser(),
|
||||
new DriveMotorV2016BlockParser(),
|
||||
new FuelCellV2016BlockParser(),
|
||||
new EngineV2016BlockParser(),
|
||||
new ExtremeValueV2016BlockParser(),
|
||||
new AlarmV2016BlockParser(),
|
||||
new VoltageV2016BlockParser(),
|
||||
new TemperatureV2016BlockParser()))));
|
||||
|
||||
@TestFactory
|
||||
Collection<DynamicTest> replaySamples() throws URISyntaxException, IOException {
|
||||
var url = getClass().getClassLoader().getResource("samples/gb32960");
|
||||
if (url == null) return List.of();
|
||||
Path dir = Paths.get(url.toURI());
|
||||
try (Stream<Path> s = Files.list(dir)) {
|
||||
return s.filter(p -> p.toString().endsWith(".hex"))
|
||||
.sorted()
|
||||
.map(this::toTest)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
}
|
||||
|
||||
private DynamicTest toTest(Path sample) {
|
||||
return DynamicTest.dynamicTest(sample.getFileName().toString(), () -> {
|
||||
byte[] frame = readHex(sample);
|
||||
Gb32960Message msg = decoder.decode(ByteBuffer.wrap(frame));
|
||||
assertThat(msg.header().vin()).hasSize(17);
|
||||
assertThat(msg.header().command()).isNotNull();
|
||||
// 749 字节真实生产帧(realtime_002/003/010/200):peer 在 V2016 帧里下发了
|
||||
// 0x30/0x31/0x32 等 typeCode,但这些在 GB/T 32960.3-2016 附录 B 表 B.3 是"预留"区,
|
||||
// 没有任何字段定义。因此**期望产生 Raw 兜底块**——这是符合规范的正确行为。
|
||||
// 若未来对端切换到 V2025 (2424 起始) 或者迁移到 0x80~0xFE 用户自定义区,可再调整断言。
|
||||
String name = sample.getFileName().toString();
|
||||
boolean isRealProductionFrame = frame.length == 774;
|
||||
if (isRealProductionFrame) {
|
||||
assertThat(msg.findBlock(InfoBlock.Raw.class))
|
||||
.as("[%s] 749字节真实生产帧应有 Raw 兜底块(peer 越界使用 0x30+ 预留 typeCode)", name)
|
||||
.isPresent();
|
||||
}
|
||||
// 总电流必须落在协议规定的 -1000~+1000 A 区间,防回归到 -3000 偏移
|
||||
msg.findBlock(InfoBlock.Gb32960V2016.Vehicle.class).ifPresent(v -> {
|
||||
if (v.totalCurrentA() != null) {
|
||||
assertThat(v.totalCurrentA())
|
||||
.as("[%s] vehicle.totalCurrentA 越界,疑似偏移常量回归", name)
|
||||
.isBetween(-1000.0, 1000.0);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private static byte[] readHex(Path path) throws IOException {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (String line : Files.readAllLines(path)) {
|
||||
String trimmed = line.trim();
|
||||
if (trimmed.isEmpty() || trimmed.startsWith("#")) continue;
|
||||
sb.append(trimmed);
|
||||
}
|
||||
String hex = sb.toString();
|
||||
int len = hex.length() / 2;
|
||||
byte[] out = new byte[len];
|
||||
for (int i = 0; i < len; i++) {
|
||||
out[i] = (byte) Integer.parseInt(hex.substring(i * 2, i * 2 + 2), 16);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
package com.lingniu.ingest.protocol.gb32960.codec;
|
||||
|
||||
import com.lingniu.ingest.codec.BccChecksum;
|
||||
import com.lingniu.ingest.protocol.gb32960.codec.parser.v2016.PositionV2016BlockParser;
|
||||
import com.lingniu.ingest.protocol.gb32960.codec.parser.v2016.VehicleV2016BlockParser;
|
||||
import com.lingniu.ingest.protocol.gb32960.model.CommandBody;
|
||||
import com.lingniu.ingest.protocol.gb32960.model.CommandType;
|
||||
import com.lingniu.ingest.protocol.gb32960.model.Gb32960Message;
|
||||
import com.lingniu.ingest.protocol.gb32960.model.InfoBlock;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.List;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/**
|
||||
* 构造一条合成 32960 实时上报帧,跑通 Frame 解码 → Body 解析 → InfoBlock 的全链路。
|
||||
*
|
||||
* <p>样本无需外部文件:本测试既验证解码正确性,也作为 {@code samples/} 黄金样本的期望值参考实现。
|
||||
*/
|
||||
public class Gb32960DecoderTest {
|
||||
|
||||
@Test
|
||||
void decodesSyntheticRealtimeReport() {
|
||||
byte[] frame = buildRealtimeFrame("LTEST000000000001");
|
||||
|
||||
InfoBlockParserRegistry registry = new InfoBlockParserRegistry(
|
||||
List.of(new VehicleV2016BlockParser(), new PositionV2016BlockParser()));
|
||||
Gb32960BodyParser bodyParser = new Gb32960BodyParser(registry);
|
||||
Gb32960MessageDecoder decoder = new Gb32960MessageDecoder(bodyParser);
|
||||
|
||||
Gb32960Message msg = decoder.decode(ByteBuffer.wrap(frame));
|
||||
|
||||
assertThat(msg.header().command()).isEqualTo(CommandType.REALTIME_REPORT);
|
||||
assertThat(msg.header().vin()).isEqualTo("LTEST000000000001");
|
||||
assertThat(msg.header().eventTime()).isNotNull();
|
||||
|
||||
InfoBlock.Gb32960V2016.Vehicle v = msg.findBlock(InfoBlock.Gb32960V2016.Vehicle.class).orElseThrow();
|
||||
assertThat(v.socPercent()).isEqualTo(70);
|
||||
assertThat(v.speedKmh()).isEqualTo(52.3, org.assertj.core.data.Offset.offset(0.01));
|
||||
assertThat(v.gearRaw()).isEqualTo(0x0F);
|
||||
|
||||
InfoBlock.Gb32960V2016.Position p = msg.findBlock(InfoBlock.Gb32960V2016.Position.class).orElseThrow();
|
||||
assertThat(p.longitude()).isEqualTo(116.397128, org.assertj.core.data.Offset.offset(0.000001));
|
||||
assertThat(p.latitude()).isEqualTo(39.916527, org.assertj.core.data.Offset.offset(0.000001));
|
||||
}
|
||||
|
||||
@Test
|
||||
void decodesPlatformLoginWithMd5HexPasswordLongerThanDeclaredGbField() {
|
||||
byte[] frame = buildPlatformLoginFrameWithExtendedPassword(
|
||||
"Hyundai",
|
||||
"f2e3445d7cda409fb4f278f6fb890734");
|
||||
|
||||
Gb32960MessageDecoder decoder = new Gb32960MessageDecoder(new Gb32960BodyParser(
|
||||
new InfoBlockParserRegistry(List.of())));
|
||||
|
||||
Gb32960Message msg = decoder.decode(ByteBuffer.wrap(frame));
|
||||
|
||||
assertThat(msg.header().command()).isEqualTo(CommandType.PLATFORM_LOGIN);
|
||||
assertThat(msg.commandBody()).isInstanceOf(CommandBody.PlatformLogin.class);
|
||||
CommandBody.PlatformLogin login = (CommandBody.PlatformLogin) msg.commandBody();
|
||||
assertThat(login.username()).isEqualTo("Hyundai");
|
||||
assertThat(login.password()).isEqualTo("f2e3445d7cda409fb4f278f6fb890734");
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造一条合法的 32960 实时上报帧:
|
||||
* header + 6B 时间戳(2024-01-02 03:04:05) + 0x01 整车 + 0x05 位置 + BCC
|
||||
*/
|
||||
public static byte[] buildRealtimeFrame(String vin) {
|
||||
ByteArrayOutputStream body = new ByteArrayOutputStream();
|
||||
// 时间戳
|
||||
body.write(24); body.write(1); body.write(2); body.write(3); body.write(4); body.write(5);
|
||||
|
||||
// 0x01 整车 20 字节
|
||||
body.write(0x01);
|
||||
body.write(0x01); // vehicle state = 启动
|
||||
body.write(0x03); // charging = 未充电
|
||||
body.write(0x01); // 纯电
|
||||
writeU16(body, 523); // 车速 52.3 km/h
|
||||
writeU32(body, 1234567); // 里程 123456.7 km
|
||||
writeU16(body, 6000); // 600.0 V
|
||||
writeU16(body, 1100); // 110.0 A (110 - (1000-1000) = 10) 实际计算:1100*0.1 - 1000 = -890;此处只为结构测试
|
||||
body.write(70); // SOC
|
||||
body.write(0x01);
|
||||
body.write(0x0F);
|
||||
writeU16(body, 500);
|
||||
body.write(30);
|
||||
body.write(0);
|
||||
|
||||
// 0x05 位置 9 字节
|
||||
body.write(0x05);
|
||||
body.write(0x00); // 有效 + 北纬 + 东经
|
||||
writeU32(body, 116_397_128L);
|
||||
writeU32(body, 39_916_527L);
|
||||
|
||||
byte[] bodyBytes = body.toByteArray();
|
||||
|
||||
ByteArrayOutputStream out = new ByteArrayOutputStream();
|
||||
out.write(0x23); out.write(0x23);
|
||||
out.write(0x02); // 实时上报
|
||||
out.write(0xFE); // 应答 / 命令
|
||||
byte[] vinBytes = vin.getBytes(StandardCharsets.US_ASCII);
|
||||
out.write(vinBytes, 0, 17);
|
||||
out.write(0x01); // 不加密
|
||||
writeU16(out, bodyBytes.length);
|
||||
out.write(bodyBytes, 0, bodyBytes.length);
|
||||
|
||||
byte[] almost = out.toByteArray();
|
||||
byte bcc = BccChecksum.compute(almost, 2, almost.length - 2);
|
||||
out.write(bcc & 0xFF);
|
||||
return out.toByteArray();
|
||||
}
|
||||
|
||||
private static void writeU16(ByteArrayOutputStream os, int v) {
|
||||
os.write((v >> 8) & 0xFF);
|
||||
os.write(v & 0xFF);
|
||||
}
|
||||
|
||||
private static void writeU32(ByteArrayOutputStream os, long v) {
|
||||
os.write((int) ((v >> 24) & 0xFF));
|
||||
os.write((int) ((v >> 16) & 0xFF));
|
||||
os.write((int) ((v >> 8) & 0xFF));
|
||||
os.write((int) (v & 0xFF));
|
||||
}
|
||||
|
||||
private static byte[] buildPlatformLoginFrameWithExtendedPassword(String username, String password) {
|
||||
ByteArrayOutputStream body = new ByteArrayOutputStream();
|
||||
body.write(26); body.write(6); body.write(22); body.write(20); body.write(40); body.write(45);
|
||||
writeU16(body, 1);
|
||||
writePaddedAscii(body, username, 12);
|
||||
writePaddedAscii(body, password, password.length());
|
||||
body.write(0x01);
|
||||
|
||||
byte[] bodyBytes = body.toByteArray();
|
||||
ByteArrayOutputStream out = new ByteArrayOutputStream();
|
||||
out.write(0x23); out.write(0x23);
|
||||
out.write(0x05);
|
||||
out.write(0xFE);
|
||||
for (int i = 0; i < 17; i++) out.write(0);
|
||||
out.write(0x01);
|
||||
writeU16(out, 41);
|
||||
out.write(bodyBytes, 0, bodyBytes.length);
|
||||
byte[] almost = out.toByteArray();
|
||||
out.write(BccChecksum.compute(almost, 2, almost.length - 2) & 0xFF);
|
||||
return out.toByteArray();
|
||||
}
|
||||
|
||||
private static void writePaddedAscii(ByteArrayOutputStream os, String value, int len) {
|
||||
byte[] bytes = value.getBytes(StandardCharsets.US_ASCII);
|
||||
int copy = Math.min(bytes.length, len);
|
||||
os.write(bytes, 0, copy);
|
||||
for (int i = copy; i < len; i++) os.write(0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package com.lingniu.ingest.protocol.gb32960.codec;
|
||||
|
||||
import com.lingniu.ingest.codec.BccChecksum;
|
||||
import io.netty.channel.embedded.EmbeddedChannel;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class Gb32960FrameDecoderTest {
|
||||
|
||||
@Test
|
||||
void keepsPlatformLoginWithMd5HexPasswordAsOneFrame() {
|
||||
byte[] frame = buildPlatformLoginFrame("Hyundai", "f2e3445d7cda409fb4f278f6fb890734");
|
||||
EmbeddedChannel channel = new EmbeddedChannel(new Gb32960FrameDecoder());
|
||||
|
||||
assertThat(channel.writeInbound(frame)).isTrue();
|
||||
|
||||
byte[] decoded = channel.readInbound();
|
||||
assertThat(decoded).isEqualTo(frame);
|
||||
assertThat((Object) channel.readInbound()).isNull();
|
||||
}
|
||||
|
||||
private static byte[] buildPlatformLoginFrame(String username, String password) {
|
||||
ByteArrayOutputStream body = new ByteArrayOutputStream();
|
||||
body.write(26); body.write(6); body.write(22); body.write(20); body.write(40); body.write(45);
|
||||
writeU16(body, 1);
|
||||
writePaddedAscii(body, username, 12);
|
||||
writePaddedAscii(body, password, password.length());
|
||||
body.write(0x01);
|
||||
|
||||
ByteArrayOutputStream out = new ByteArrayOutputStream();
|
||||
out.write(0x23); out.write(0x23);
|
||||
out.write(0x05);
|
||||
out.write(0xFE);
|
||||
for (int i = 0; i < 17; i++) out.write(0);
|
||||
out.write(0x01);
|
||||
writeU16(out, 41);
|
||||
byte[] bodyBytes = body.toByteArray();
|
||||
out.write(bodyBytes, 0, bodyBytes.length);
|
||||
byte[] almost = out.toByteArray();
|
||||
out.write(BccChecksum.compute(almost, 2, almost.length - 2) & 0xFF);
|
||||
return out.toByteArray();
|
||||
}
|
||||
|
||||
private static void writeU16(ByteArrayOutputStream os, int v) {
|
||||
os.write((v >> 8) & 0xFF);
|
||||
os.write(v & 0xFF);
|
||||
}
|
||||
|
||||
private static void writePaddedAscii(ByteArrayOutputStream os, String value, int len) {
|
||||
byte[] bytes = value.getBytes(StandardCharsets.US_ASCII);
|
||||
int copy = Math.min(bytes.length, len);
|
||||
os.write(bytes, 0, copy);
|
||||
for (int i = copy; i < len; i++) os.write(0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
package com.lingniu.ingest.protocol.gb32960.codec;
|
||||
|
||||
import com.lingniu.ingest.codec.BccChecksum;
|
||||
import com.lingniu.ingest.protocol.gb32960.codec.parser.v2016.AlarmV2016BlockParser;
|
||||
import com.lingniu.ingest.protocol.gb32960.codec.parser.v2016.DriveMotorV2016BlockParser;
|
||||
import com.lingniu.ingest.protocol.gb32960.codec.parser.v2016.PositionV2016BlockParser;
|
||||
import com.lingniu.ingest.protocol.gb32960.codec.parser.v2016.TemperatureV2016BlockParser;
|
||||
import com.lingniu.ingest.protocol.gb32960.codec.parser.v2016.VehicleV2016BlockParser;
|
||||
import com.lingniu.ingest.protocol.gb32960.codec.parser.v2016.VoltageV2016BlockParser;
|
||||
import com.lingniu.ingest.protocol.gb32960.model.Gb32960Message;
|
||||
import com.lingniu.ingest.protocol.gb32960.model.InfoBlock;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.List;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/**
|
||||
* 验证新增的变长信息体 Parser(0x02 驱动电机 / 0x07 报警 / 0x08 电压 / 0x09 温度)
|
||||
* 与主解码器协同工作。
|
||||
*/
|
||||
class Gb32960FullBlocksTest {
|
||||
|
||||
private final Gb32960MessageDecoder decoder = new Gb32960MessageDecoder(
|
||||
new Gb32960BodyParser(new InfoBlockParserRegistry(List.of(
|
||||
new VehicleV2016BlockParser(),
|
||||
new PositionV2016BlockParser(),
|
||||
new DriveMotorV2016BlockParser(),
|
||||
new AlarmV2016BlockParser(),
|
||||
new VoltageV2016BlockParser(),
|
||||
new TemperatureV2016BlockParser()))));
|
||||
|
||||
@Test
|
||||
void parsesDriveMotorBlock() {
|
||||
byte[] frame = buildFrame(os -> {
|
||||
os.write(0x02); // drive motor
|
||||
os.write(1); // 1 motor
|
||||
os.write(1); // serial
|
||||
os.write(0x01); // state
|
||||
os.write(100); // controllerTemp = 60
|
||||
writeU16(os, 23_000); // rpm = 3000
|
||||
writeU16(os, 21_000); // torque raw, actual = 100 Nm
|
||||
os.write(90); // motorTemp = 50
|
||||
writeU16(os, 5400); // voltage 540.0V
|
||||
writeU16(os, 11000); // current raw → 100 A
|
||||
});
|
||||
Gb32960Message msg = decoder.decode(ByteBuffer.wrap(frame));
|
||||
InfoBlock.Gb32960V2016.DriveMotor dm = msg.findBlock(InfoBlock.Gb32960V2016.DriveMotor.class).orElseThrow();
|
||||
assertThat(dm.motors()).hasSize(1);
|
||||
var m = dm.motors().get(0);
|
||||
assertThat(m.rpm()).isEqualTo(3000);
|
||||
assertThat(m.torqueNm()).isEqualTo(100.0);
|
||||
assertThat(m.controllerInputVoltageV()).isEqualTo(540.0);
|
||||
}
|
||||
|
||||
@Test
|
||||
void parsesAlarmBlock() {
|
||||
byte[] frame = buildFrame(os -> {
|
||||
os.write(0x07); // alarm
|
||||
os.write(2); // max level
|
||||
writeU32(os, 0x0000_0003L);// general flag
|
||||
os.write(1); writeU32(os, 0xDEAD_BEEFL); // battery faults 1
|
||||
os.write(0); // motor faults 0
|
||||
os.write(0); // engine faults 0
|
||||
os.write(1); writeU32(os, 0xCAFE_BABEL); // other faults 1
|
||||
});
|
||||
Gb32960Message msg = decoder.decode(ByteBuffer.wrap(frame));
|
||||
InfoBlock.Gb32960V2016.Alarm a = msg.findBlock(InfoBlock.Gb32960V2016.Alarm.class).orElseThrow();
|
||||
assertThat(a.maxLevel()).isEqualTo(2);
|
||||
assertThat(a.batteryFaults()).hasSize(1);
|
||||
assertThat(a.otherFaults()).hasSize(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void parsesVoltageBlock() {
|
||||
byte[] frame = buildFrame(os -> {
|
||||
os.write(0x08); // voltage
|
||||
os.write(1); // 1 subsystem
|
||||
os.write(1); // battery index
|
||||
writeU16(os, 3800); // 380.0 V
|
||||
writeU16(os, 11_000); // current raw
|
||||
writeU16(os, 96); // total cells
|
||||
writeU16(os, 1); // start
|
||||
os.write(3); // frame cells
|
||||
writeU16(os, 3500); // 3.5V
|
||||
writeU16(os, 3600); // 3.6V
|
||||
writeU16(os, 3300); // 3.3V
|
||||
});
|
||||
Gb32960Message msg = decoder.decode(ByteBuffer.wrap(frame));
|
||||
InfoBlock.Gb32960V2016.Voltage v = msg.findBlock(InfoBlock.Gb32960V2016.Voltage.class).orElseThrow();
|
||||
assertThat(v.subSystemCount()).isEqualTo(1);
|
||||
assertThat(v.maxCellVoltageV()).isEqualTo(3.6, org.assertj.core.data.Offset.offset(0.001));
|
||||
assertThat(v.minCellVoltageV()).isEqualTo(3.3, org.assertj.core.data.Offset.offset(0.001));
|
||||
}
|
||||
|
||||
@Test
|
||||
void parsesTemperatureBlock() {
|
||||
byte[] frame = buildFrame(os -> {
|
||||
os.write(0x09); // temperature
|
||||
os.write(1); // subsystems
|
||||
os.write(1); // battery index
|
||||
writeU16(os, 4); // probes
|
||||
os.write(60); // actual 20
|
||||
os.write(65); // actual 25
|
||||
os.write(70); // actual 30
|
||||
os.write(55); // actual 15
|
||||
});
|
||||
Gb32960Message msg = decoder.decode(ByteBuffer.wrap(frame));
|
||||
InfoBlock.Gb32960V2016.Temperature t = msg.findBlock(InfoBlock.Gb32960V2016.Temperature.class).orElseThrow();
|
||||
assertThat(t.maxTempC()).isEqualTo(30);
|
||||
assertThat(t.minTempC()).isEqualTo(15);
|
||||
}
|
||||
|
||||
// ===== frame builder helpers =====
|
||||
|
||||
private static byte[] buildFrame(java.util.function.Consumer<ByteArrayOutputStream> bodyWriter) {
|
||||
ByteArrayOutputStream body = new ByteArrayOutputStream();
|
||||
// 时间戳
|
||||
body.write(24); body.write(1); body.write(2); body.write(3); body.write(4); body.write(5);
|
||||
bodyWriter.accept(body);
|
||||
|
||||
byte[] bodyBytes = body.toByteArray();
|
||||
ByteArrayOutputStream out = new ByteArrayOutputStream();
|
||||
out.write(0x23); out.write(0x23);
|
||||
out.write(0x02); // cmd: realtime
|
||||
out.write(0xFE); // response flag
|
||||
byte[] vin = "LTEST000000000010".getBytes(StandardCharsets.US_ASCII);
|
||||
out.write(vin, 0, 17);
|
||||
out.write(0x01); // not encrypted
|
||||
writeU16(out, bodyBytes.length);
|
||||
out.write(bodyBytes, 0, bodyBytes.length);
|
||||
byte[] almost = out.toByteArray();
|
||||
byte bcc = BccChecksum.compute(almost, 2, almost.length - 2);
|
||||
out.write(bcc & 0xFF);
|
||||
return out.toByteArray();
|
||||
}
|
||||
|
||||
private static void writeU16(ByteArrayOutputStream os, int v) {
|
||||
os.write((v >> 8) & 0xFF);
|
||||
os.write(v & 0xFF);
|
||||
}
|
||||
|
||||
private static void writeU32(ByteArrayOutputStream os, long v) {
|
||||
os.write((int) ((v >> 24) & 0xFF));
|
||||
os.write((int) ((v >> 16) & 0xFF));
|
||||
os.write((int) ((v >> 8) & 0xFF));
|
||||
os.write((int) (v & 0xFF));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
package com.lingniu.ingest.protocol.gb32960.codec;
|
||||
|
||||
import com.lingniu.ingest.codec.BccChecksum;
|
||||
import com.lingniu.ingest.protocol.gb32960.codec.parser.v2016.AlarmV2016BlockParser;
|
||||
import com.lingniu.ingest.protocol.gb32960.codec.parser.v2016.DriveMotorV2016BlockParser;
|
||||
import com.lingniu.ingest.protocol.gb32960.codec.parser.v2016.PositionV2016BlockParser;
|
||||
import com.lingniu.ingest.protocol.gb32960.codec.parser.v2016.TemperatureV2016BlockParser;
|
||||
import com.lingniu.ingest.protocol.gb32960.codec.parser.v2016.VehicleV2016BlockParser;
|
||||
import com.lingniu.ingest.protocol.gb32960.codec.parser.v2016.VoltageV2016BlockParser;
|
||||
import com.lingniu.ingest.protocol.gb32960.codec.parser.vendor.guangdong.GdFcAirConditionerBlockParser;
|
||||
import com.lingniu.ingest.protocol.gb32960.codec.parser.vendor.guangdong.GdFcAuxiliaryBlockParser;
|
||||
import com.lingniu.ingest.protocol.gb32960.codec.parser.vendor.guangdong.GdFcDcDcBlockParser;
|
||||
import com.lingniu.ingest.protocol.gb32960.codec.parser.vendor.guangdong.GdFcDemoExtensionBlockParser;
|
||||
import com.lingniu.ingest.protocol.gb32960.codec.parser.vendor.guangdong.GdFcStackBlockParser;
|
||||
import com.lingniu.ingest.protocol.gb32960.codec.parser.vendor.guangdong.GdFcVehicleInfoBlockParser;
|
||||
import com.lingniu.ingest.protocol.gb32960.codec.profile.Gb32960ProfileRegistry;
|
||||
import com.lingniu.ingest.protocol.gb32960.codec.profile.RuleBasedVendorExtensionSelector;
|
||||
import com.lingniu.ingest.protocol.gb32960.codec.profile.VendorExtensionCatalog;
|
||||
import com.lingniu.ingest.protocol.gb32960.config.Gb32960Properties;
|
||||
import com.lingniu.ingest.protocol.gb32960.inbound.Gb32960ChannelHandler;
|
||||
import com.lingniu.ingest.protocol.gb32960.model.Gb32960Message;
|
||||
import com.lingniu.ingest.protocol.gb32960.model.InfoBlock;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.within;
|
||||
|
||||
/**
|
||||
* 端到端集成测试:构造一帧带广东燃料电池规范扩展块的 V2016 帧,验证
|
||||
* vendor profile selector 路由 + GuangdongFc 系列 parser 全链路解码。
|
||||
*
|
||||
* <p>覆盖:
|
||||
* <ul>
|
||||
* <li>9 个 GB/T 32960 标准块(0x01~0x09)原生解析
|
||||
* <li>0x30 GuangdongFc.Stack(含 1 个电堆、4 个单体电压样本)
|
||||
* <li>0x32 GuangdongFc.DcDc
|
||||
* <li>0x33 GuangdongFc.AirConditioner
|
||||
* <li>0x34 GuangdongFc.VehicleInfo
|
||||
* <li>0x80 GuangdongFc.DemoExtension
|
||||
* <li>VendorExtensionSelector 按 platformAccount=lingniu 命中
|
||||
* </ul>
|
||||
*
|
||||
* <p>0x31 Auxiliary 没有放进帧里(变长且字段相对琐碎),单独由 unit test 覆盖。
|
||||
*/
|
||||
class GuangdongFcEndToEndTest {
|
||||
|
||||
private final Gb32960MessageDecoder decoder = buildDecoder();
|
||||
|
||||
@Test
|
||||
void parsesRealtimeFrameWithGuangdongFcExtensions() {
|
||||
byte[] frame = buildFrame();
|
||||
// 用 platformAccount=lingniu 触发 vendor profile
|
||||
Gb32960Message msg = decoder.decode(ByteBuffer.wrap(frame), "lingniu");
|
||||
|
||||
// ---- 标准块 ----
|
||||
var v = msg.findBlock(InfoBlock.Gb32960V2016.Vehicle.class).orElseThrow();
|
||||
assertThat(v.socPercent()).isEqualTo(85);
|
||||
|
||||
var pos = msg.findBlock(InfoBlock.Gb32960V2016.Position.class).orElseThrow();
|
||||
assertThat(pos.longitude()).isCloseTo(120.848158, within(1e-6));
|
||||
|
||||
// ---- vendor 块:0x30 Stack ----
|
||||
var stack = msg.findBlock(InfoBlock.GuangdongFc.Stack.class).orElseThrow();
|
||||
assertThat(stack.stackCount()).isEqualTo(1);
|
||||
var s0 = stack.stacks().get(0);
|
||||
assertThat(s0.engineWorkState()).isEqualTo(1);
|
||||
assertThat(s0.stackWaterOutletTempC()).isEqualTo(50); // 90-40
|
||||
assertThat(s0.maxCellVoltageV()).isCloseTo(0.745, within(1e-6)); // 745 mV
|
||||
assertThat(s0.minCellVoltageV()).isCloseTo(0.730, within(1e-6));
|
||||
assertThat(s0.avgCellVoltageV()).isCloseTo(0.738, within(1e-6));
|
||||
assertThat(s0.cellCount()).isEqualTo(400);
|
||||
assertThat(s0.frameCellCount()).isEqualTo(4);
|
||||
assertThat(s0.frameCellVoltagesV()).hasSize(4)
|
||||
.allSatisfy(c -> assertThat(c).isBetween(0.7, 0.8));
|
||||
|
||||
// ---- vendor 块:0x32 DcDc ----
|
||||
var dcdc = msg.findBlock(InfoBlock.GuangdongFc.DcDc.class).orElseThrow();
|
||||
assertThat(dcdc.inputVoltageV()).isCloseTo(300.0, within(1e-6));
|
||||
assertThat(dcdc.outputCurrentA()).isCloseTo(110.0, within(1e-6));
|
||||
|
||||
// ---- vendor 块:0x33 AirConditioner ----
|
||||
var ac = msg.findBlock(InfoBlock.GuangdongFc.AirConditioner.class).orElseThrow();
|
||||
assertThat(ac.status()).isEqualTo(1);
|
||||
assertThat(ac.powerKw()).isEqualTo(15);
|
||||
|
||||
// ---- vendor 块:0x34 VehicleInfo ----
|
||||
var info = msg.findBlock(InfoBlock.GuangdongFc.VehicleInfo.class).orElseThrow();
|
||||
assertThat(info.collisionAlarm()).isEqualTo(0);
|
||||
assertThat(info.ambientTempC()).isEqualTo(30);
|
||||
assertThat(info.hydrogenMassKg()).isCloseTo(8.0, within(1e-6));
|
||||
|
||||
// ---- vendor 块:0x80 DemoExtension ----
|
||||
var demo = msg.findBlock(InfoBlock.GuangdongFc.DemoExtension.class).orElseThrow();
|
||||
assertThat(demo.stackTempC()).isEqualTo(50);
|
||||
assertThat(demo.airCompressorVoltageV()).isCloseTo(372.0, within(1e-6));
|
||||
|
||||
// 不应有 Raw 兜底:所有块都被结构化解析
|
||||
assertThat(msg.findBlock(InfoBlock.Raw.class)).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void unmatchedAccountFallsBackToDefaultAndProducesRawForVendorBlocks() {
|
||||
byte[] frame = buildFrame();
|
||||
// 不传 account → selector 返回 null → default profile(无 vendor parser)
|
||||
Gb32960Message msg = decoder.decode(ByteBuffer.wrap(frame), null);
|
||||
|
||||
// 标准块仍能解析
|
||||
assertThat(msg.findBlock(InfoBlock.Gb32960V2016.Vehicle.class)).isPresent();
|
||||
// 但 0x30 应触发 unknown → Raw 兜底
|
||||
assertThat(msg.findBlock(InfoBlock.Raw.class)).isPresent();
|
||||
assertThat(msg.findBlock(InfoBlock.GuangdongFc.Stack.class)).isEmpty();
|
||||
}
|
||||
|
||||
// ====================================================================
|
||||
// 帧构造:标准块 0x01/0x05/0x07/0x08/0x09 + vendor 块 0x30/0x32/0x33/0x34/0x80
|
||||
// 不带 0x02/0x04/0x06/0x31,纯粹为了控制帧长和测试焦点。
|
||||
// ====================================================================
|
||||
private static byte[] buildFrame() {
|
||||
ByteArrayOutputStream body = new ByteArrayOutputStream();
|
||||
// 时间戳 6B
|
||||
body.write(24); body.write(4); body.write(15); body.write(11); body.write(0); body.write(0);
|
||||
|
||||
// 0x01 整车数据 20B
|
||||
body.write(0x01);
|
||||
body.write(0x01); // vehicleState 启动
|
||||
body.write(0x03); // chargingState 未充电
|
||||
body.write(0x02); // runningMode 混动
|
||||
writeU16(body, 0); // 速度
|
||||
writeU32(body, 100_000L); // 累计里程 = 10000 km
|
||||
writeU16(body, 5605); // totalVoltage 560.5 V
|
||||
writeU16(body, 10000); // totalCurrent 0 A (after -1000 offset)
|
||||
body.write(85); // SOC
|
||||
body.write(0x01); // DC-DC 工作
|
||||
body.write(0x00); // 挡位
|
||||
writeU16(body, 10000); // 绝缘电阻
|
||||
body.write(0x00); // accelPedal
|
||||
body.write(0x00); // brakePedal
|
||||
|
||||
// 0x05 位置数据 9B
|
||||
body.write(0x05);
|
||||
body.write(0x00); // 状态:有效定位
|
||||
writeU32(body, 120_848_158L); // 经度 120.848158
|
||||
writeU32(body, 31_497_236L); // 纬度 31.497236
|
||||
|
||||
// 0x07 报警数据 10B:level=0 + flag=0 + 4 个空 fault list
|
||||
body.write(0x07);
|
||||
body.write(0x00); // maxLevel
|
||||
writeU32(body, 0); // generalFlag
|
||||
body.write(0x00); // batteryFaultCount
|
||||
body.write(0x00); // motorFaultCount
|
||||
body.write(0x00); // engineFaultCount
|
||||
body.write(0x00); // otherFaultCount
|
||||
|
||||
// 0x08 储能电压:1 sub × 0 cell(保持简洁)
|
||||
body.write(0x08);
|
||||
body.write(0x01); // subCount
|
||||
body.write(0x01); // batteryIndex
|
||||
writeU16(body, 5605); // voltage
|
||||
writeU16(body, 10000); // current
|
||||
writeU16(body, 0); // cellCount
|
||||
writeU16(body, 1); // frameCellStart
|
||||
body.write(0x00); // frameCellCount = 0
|
||||
|
||||
// 0x09 储能温度:1 sub × 0 probe
|
||||
body.write(0x09);
|
||||
body.write(0x01); // subCount
|
||||
body.write(0x01); // batteryIndex
|
||||
writeU16(body, 0); // probeCount = 0
|
||||
|
||||
// 0x30 GuangdongFc.Stack:1 stack + 4 单体电压
|
||||
body.write(0x30);
|
||||
body.write(0x01); // stackCount
|
||||
body.write(0x01); // engineWorkState 打开
|
||||
body.write(90); // 水温 90-40 = 50°C
|
||||
writeU16(body, 1850); // h2 入口压力 raw=1850 → 85 kPa
|
||||
writeU16(body, 1500); // air 入口压力 raw=1500 → 50 kPa
|
||||
body.write(70); // air 入口温度 70-40 = 30°C
|
||||
writeU16(body, 12); // maxCellId
|
||||
writeU16(body, 88); // minCellId
|
||||
writeU16(body, 745); // maxCellV 745 mV
|
||||
writeU16(body, 730); // minCellV 730 mV
|
||||
writeU16(body, 738); // avgCellV 738 mV
|
||||
writeU16(body, 400); // cellCount
|
||||
writeU16(body, 1); // frameCellStart
|
||||
body.write(0x04); // frameCellCount = 4
|
||||
writeU16(body, 745);
|
||||
writeU16(body, 740);
|
||||
writeU16(body, 735);
|
||||
writeU16(body, 730);
|
||||
|
||||
// 0x32 GuangdongFc.DcDc 9B
|
||||
body.write(0x32);
|
||||
writeU16(body, 3000); // inputV 300.0
|
||||
writeU16(body, 1200); // inputC 120.0
|
||||
writeU16(body, 5600); // outputV 560.0
|
||||
writeU16(body, 1100); // outputC 110.0
|
||||
body.write(105); // ctrlTemp 105-40 = 65°C
|
||||
|
||||
// 0x33 GuangdongFc.AirConditioner 5B
|
||||
body.write(0x33);
|
||||
body.write(0x01); // status 启动
|
||||
writeU16(body, 20); // power 20-5 = 15 kw
|
||||
writeU16(body, 5400); // compressorInputV 540.0
|
||||
|
||||
// 0x34 GuangdongFc.VehicleInfo 7B
|
||||
body.write(0x34);
|
||||
body.write(0x00); // collision 无
|
||||
writeU16(body, 70); // ambientTemp 70-40 = 30°C
|
||||
writeU16(body, 1100); // ambientPressure raw=1100 → 10.0 kPa
|
||||
writeU16(body, 80); // h2Mass 80*0.1 = 8.0 kg
|
||||
|
||||
// 0x80 GuangdongFc.DemoExtension 12B(含 2B 内层 length=9)
|
||||
body.write(0x80);
|
||||
writeU16(body, 9); // declared length
|
||||
body.write(90); // stackTemp 50°C
|
||||
writeU16(body, 3720); // ac voltage 372.0
|
||||
writeU16(body, 10000); // ac current 0 A
|
||||
writeU16(body, 13); // h2 pump voltage 1.3
|
||||
writeU16(body, 10000); // h2 pump current 0 A
|
||||
|
||||
byte[] bodyBytes = body.toByteArray();
|
||||
|
||||
// 帧封装
|
||||
ByteArrayOutputStream out = new ByteArrayOutputStream();
|
||||
out.write(0x23); out.write(0x23);
|
||||
out.write(0x02); // cmd realtime
|
||||
out.write(0xFE); // 命令包
|
||||
byte[] vin = "LNVFC000000000001".getBytes(StandardCharsets.US_ASCII);
|
||||
out.write(vin, 0, 17);
|
||||
out.write(0x01); // 不加密
|
||||
writeU16(out, bodyBytes.length);
|
||||
out.write(bodyBytes, 0, bodyBytes.length);
|
||||
byte[] almost = out.toByteArray();
|
||||
byte bcc = BccChecksum.compute(almost, 2, almost.length - 2);
|
||||
out.write(bcc & 0xFF);
|
||||
return out.toByteArray();
|
||||
}
|
||||
|
||||
private static void writeU16(ByteArrayOutputStream os, int v) {
|
||||
os.write((v >> 8) & 0xFF);
|
||||
os.write(v & 0xFF);
|
||||
}
|
||||
|
||||
private static void writeU32(ByteArrayOutputStream os, long v) {
|
||||
os.write((int) ((v >> 24) & 0xFF));
|
||||
os.write((int) ((v >> 16) & 0xFF));
|
||||
os.write((int) ((v >> 8) & 0xFF));
|
||||
os.write((int) (v & 0xFF));
|
||||
}
|
||||
|
||||
// ====================================================================
|
||||
// Decoder 装配:手动构造一个挂着 guangdong-fc 路由的 BodyParser
|
||||
// ====================================================================
|
||||
private static Gb32960MessageDecoder buildDecoder() {
|
||||
var standardParsers = List.of(
|
||||
(com.lingniu.ingest.protocol.gb32960.codec.InfoBlockParser) new VehicleV2016BlockParser(),
|
||||
new DriveMotorV2016BlockParser(),
|
||||
new PositionV2016BlockParser(),
|
||||
new AlarmV2016BlockParser(),
|
||||
new VoltageV2016BlockParser(),
|
||||
new TemperatureV2016BlockParser());
|
||||
var catalog = new VendorExtensionCatalog(java.util.Map.of(
|
||||
"guangdong-fc", List.of(
|
||||
new GdFcStackBlockParser(),
|
||||
new GdFcAuxiliaryBlockParser(),
|
||||
new GdFcDcDcBlockParser(),
|
||||
new GdFcAirConditionerBlockParser(),
|
||||
new GdFcVehicleInfoBlockParser(),
|
||||
new GdFcDemoExtensionBlockParser())));
|
||||
var profileRegistry = new Gb32960ProfileRegistry(
|
||||
standardParsers, catalog, Set.of("guangdong-fc"));
|
||||
|
||||
// selector:lingniu 账号 → guangdong-fc
|
||||
var entry = new Gb32960Properties.VendorExtension();
|
||||
entry.setName("guangdong-fc");
|
||||
var match = new Gb32960Properties.VendorExtension.Match();
|
||||
match.setPlatformAccounts(List.of("lingniu"));
|
||||
entry.setMatch(match);
|
||||
var selector = new RuleBasedVendorExtensionSelector(
|
||||
List.of(entry), Set.of("guangdong-fc"));
|
||||
|
||||
var bodyParser = new Gb32960BodyParser(profileRegistry, selector);
|
||||
// PLATFORM_ACCOUNT_ATTR 仅供 handler 使用,这里不需要
|
||||
return new Gb32960MessageDecoder(bodyParser);
|
||||
}
|
||||
|
||||
// suppress unused-import warning for the channel handler attr key
|
||||
@SuppressWarnings("unused")
|
||||
private static final Object KEEP_ATTR_REF = Gb32960ChannelHandler.PLATFORM_ACCOUNT_ATTR;
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package com.lingniu.ingest.protocol.gb32960.codec.parser.vendor.guangdong;
|
||||
|
||||
import com.lingniu.ingest.protocol.gb32960.model.InfoBlock;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.nio.ByteBuffer;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.within;
|
||||
|
||||
/**
|
||||
* 单元测试:广东燃料电池规范 fixed-length parser(0x32 / 0x33 / 0x34 / 0x80)逐字段还原。
|
||||
* 0x30/0x31 是变长,集成层会在 BodyParser 全链路测试里覆盖。
|
||||
*/
|
||||
class GdFcParserTest {
|
||||
|
||||
@Test
|
||||
void dcDcBlockParsesAllFields() {
|
||||
// inV=300.0 inC=120.0 outV=560.0 outC=110.5 ctrlTemp=65°C
|
||||
ByteBuffer buf = ByteBuffer.wrap(new byte[]{
|
||||
0x0B, (byte) 0xB8, // 0x0BB8 = 3000 → 300.0 V
|
||||
0x04, (byte) 0xB0, // 0x04B0 = 1200 → 120.0 A
|
||||
0x15, (byte) 0xE0, // 0x15E0 = 5600 → 560.0 V
|
||||
0x04, 0x52, // 0x0452 = 1106 → 110.6 A
|
||||
0x69 // 0x69 = 105 - 40 = 65 °C
|
||||
});
|
||||
InfoBlock.GuangdongFc.DcDc b =
|
||||
(InfoBlock.GuangdongFc.DcDc) new GdFcDcDcBlockParser().parse(buf);
|
||||
assertThat(b.inputVoltageV()).isCloseTo(300.0, within(1e-6));
|
||||
assertThat(b.inputCurrentA()).isCloseTo(120.0, within(1e-6));
|
||||
assertThat(b.outputVoltageV()).isCloseTo(560.0, within(1e-6));
|
||||
assertThat(b.outputCurrentA()).isCloseTo(110.6, within(1e-6));
|
||||
assertThat(b.controllerTempC()).isEqualTo(65);
|
||||
}
|
||||
|
||||
@Test
|
||||
void airConditionerParsesPowerOffsetAndStatus() {
|
||||
// status=01 power raw=20 (= 15 kw after -5 offset) inputV=540.0
|
||||
ByteBuffer buf = ByteBuffer.wrap(new byte[]{
|
||||
0x01,
|
||||
0x00, 0x14, // 20 - 5 = 15 kw
|
||||
0x15, (byte) 0x18 // 0x1518 = 5400 → 540.0 V
|
||||
});
|
||||
InfoBlock.GuangdongFc.AirConditioner b =
|
||||
(InfoBlock.GuangdongFc.AirConditioner) new GdFcAirConditionerBlockParser().parse(buf);
|
||||
assertThat(b.status()).isEqualTo(1);
|
||||
assertThat(b.powerKw()).isEqualTo(15);
|
||||
assertThat(b.compressorInputVoltageV()).isCloseTo(540.0, within(1e-6));
|
||||
}
|
||||
|
||||
@Test
|
||||
void vehicleInfoParsesAmbientFields() {
|
||||
// collision=00 ambientTemp raw=70 (=30°C) ambientPressure raw=1100 (10kPa) h2Mass raw=80 (8.0 kg)
|
||||
ByteBuffer buf = ByteBuffer.wrap(new byte[]{
|
||||
0x00,
|
||||
0x00, 0x46, // 70 - 40 = 30°C
|
||||
0x04, 0x4C, // 0x044C = 1100 → 110*0.1 - 100 = 10.0 kPa
|
||||
0x00, 0x50 // 0x0050 = 80 → 8.0 kg
|
||||
});
|
||||
InfoBlock.GuangdongFc.VehicleInfo b =
|
||||
(InfoBlock.GuangdongFc.VehicleInfo) new GdFcVehicleInfoBlockParser().parse(buf);
|
||||
assertThat(b.collisionAlarm()).isEqualTo(0);
|
||||
assertThat(b.ambientTempC()).isEqualTo(30);
|
||||
assertThat(b.ambientPressureKpa()).isCloseTo(10.0, within(1e-6));
|
||||
assertThat(b.hydrogenMassKg()).isCloseTo(8.0, within(1e-6));
|
||||
}
|
||||
|
||||
@Test
|
||||
void demoExtensionParsesInnerLengthAndFields() {
|
||||
// length=9, stackTemp=50, acV=372.0, acC=0, h2V=1.3V, h2C=0
|
||||
ByteBuffer buf = ByteBuffer.wrap(new byte[]{
|
||||
0x00, 0x09, // declared length
|
||||
0x5A, // 90 - 40 = 50°C
|
||||
0x0E, (byte) 0x88, // 0x0E88 = 3720 → 372.0V
|
||||
0x27, 0x10, // 0x2710 = 10000 → 0 A (after -1000)
|
||||
0x00, 0x0D, // 0x000D = 13 → 1.3V
|
||||
0x27, 0x10 // 0 A
|
||||
});
|
||||
InfoBlock.GuangdongFc.DemoExtension b =
|
||||
(InfoBlock.GuangdongFc.DemoExtension) new GdFcDemoExtensionBlockParser().parse(buf);
|
||||
assertThat(b.stackTempC()).isEqualTo(50);
|
||||
assertThat(b.airCompressorVoltageV()).isCloseTo(372.0, within(1e-6));
|
||||
assertThat(b.airCompressorCurrentA()).isCloseTo(0.0, within(1e-6));
|
||||
assertThat(b.hydrogenPumpVoltageV()).isCloseTo(1.3, within(1e-6));
|
||||
assertThat(b.hydrogenPumpCurrentA()).isCloseTo(0.0, within(1e-6));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package com.lingniu.ingest.protocol.gb32960.codec.parser.vendor.guangdong;
|
||||
|
||||
import com.lingniu.ingest.protocol.gb32960.codec.Gb32960BodyParser;
|
||||
import com.lingniu.ingest.protocol.gb32960.codec.Gb32960MessageDecoder;
|
||||
import com.lingniu.ingest.protocol.gb32960.codec.InfoBlockParser;
|
||||
import com.lingniu.ingest.protocol.gb32960.codec.InfoBlockParserRegistry;
|
||||
import com.lingniu.ingest.protocol.gb32960.codec.parser.v2016.VehicleV2016BlockParser;
|
||||
import com.lingniu.ingest.protocol.gb32960.model.InfoBlock;
|
||||
import com.lingniu.ingest.protocol.gb32960.model.ProtocolVersion;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.util.List;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/**
|
||||
* 验证 {@link GdFcVendorTlvBlockParser} 严格按 length 消费字节,不会贪心吞光后续信息体。
|
||||
*
|
||||
* <p>这是关键回归 case:用户问"加 0x83 兜底会不会把后面其它块也吃掉?"——通过构造
|
||||
* 一个 {@code 0x83 TLV + 0x01 Vehicle} 串联的 body,断言 BodyParser 解出**两个块**而非一个。
|
||||
*/
|
||||
class GdFcVendorTlvBlockParserTest {
|
||||
|
||||
@Test
|
||||
void parsesPayloadOfDeclaredLengthExactly() {
|
||||
// 0x83 typeCode 已被外层消费,buffer 从 length 字段开始
|
||||
byte[] data = bytes(
|
||||
0x00, 0x05, // length = 5
|
||||
0xAA, 0xBB, 0xCC, 0xDD, 0xEE); // 5 bytes payload
|
||||
ByteBuffer buf = ByteBuffer.wrap(data);
|
||||
|
||||
InfoBlock.GuangdongFc.VendorTlv tlv =
|
||||
(InfoBlock.GuangdongFc.VendorTlv) new GdFcVendorTlvBlockParser(0x83).parse(buf);
|
||||
|
||||
assertThat(tlv.typeCode()).isEqualTo(0x83);
|
||||
assertThat(tlv.declaredLength()).isEqualTo(5);
|
||||
assertThat(tlv.payload()).containsExactly(0xAA, 0xBB, 0xCC, 0xDD, 0xEE);
|
||||
assertThat(buf.remaining()).as("应正好消费 2+5=7 字节").isZero();
|
||||
}
|
||||
|
||||
@Test
|
||||
void doesNotSwallowSubsequentBlocksWhenChainedInBodyParser() {
|
||||
// 构造 body:0x83 TLV(length=4) + 0x01 Vehicle(20B 标准布局)
|
||||
ByteArrayOutputStream body = new ByteArrayOutputStream();
|
||||
// 0x83 TLV:3+4 = 7 字节
|
||||
body.write(0x83);
|
||||
body.write(0x00); body.write(0x04); // length = 4
|
||||
body.write(0x11); body.write(0x22); body.write(0x33); body.write(0x44);
|
||||
// 0x01 Vehicle: typeCode + 20 字节 body
|
||||
body.write(0x01);
|
||||
body.write(0x01); // vehicleState
|
||||
body.write(0x03); // chargingState
|
||||
body.write(0x02); // runningMode
|
||||
write16(body, 0); // speed
|
||||
write32(body, 100_000L); // mileage
|
||||
write16(body, 5605); // totalVoltage
|
||||
write16(body, 10000); // totalCurrent
|
||||
body.write(85); // SOC
|
||||
body.write(0x01); // dcDcStatus
|
||||
body.write(0x00); // gear
|
||||
write16(body, 10000); // insulation
|
||||
body.write(0x00); // accelPedal
|
||||
body.write(0x00); // brakePedal
|
||||
|
||||
// 装配 body parser:注册 0x83 vendor TLV + 0x01 标准 Vehicle
|
||||
InfoBlockParserRegistry registry = new InfoBlockParserRegistry(List.of(
|
||||
new GdFcVendorTlvBlockParser(0x83),
|
||||
new VehicleV2016BlockParser()));
|
||||
Gb32960BodyParser parser = new Gb32960BodyParser(registry);
|
||||
|
||||
ByteBuffer buf = ByteBuffer.wrap(body.toByteArray());
|
||||
Gb32960MessageDecoder.BodyParseResult result = parser.parse(ProtocolVersion.V2016, buf);
|
||||
|
||||
// 关键断言:两个块都解析出来了,0x01 没被 0x83 吞掉
|
||||
assertThat(result.blocks()).hasSize(2);
|
||||
assertThat(result.blocks().get(0)).isInstanceOf(InfoBlock.GuangdongFc.VendorTlv.class);
|
||||
assertThat(result.blocks().get(1)).isInstanceOf(InfoBlock.Gb32960V2016.Vehicle.class);
|
||||
|
||||
InfoBlock.GuangdongFc.VendorTlv tlv = (InfoBlock.GuangdongFc.VendorTlv) result.blocks().get(0);
|
||||
assertThat(tlv.declaredLength()).isEqualTo(4);
|
||||
assertThat(tlv.payload()).containsExactly(0x11, 0x22, 0x33, 0x44);
|
||||
|
||||
InfoBlock.Gb32960V2016.Vehicle v = (InfoBlock.Gb32960V2016.Vehicle) result.blocks().get(1);
|
||||
assertThat(v.socPercent()).isEqualTo(85);
|
||||
assertThat(v.totalVoltageV()).isEqualTo(560.5);
|
||||
}
|
||||
|
||||
@Test
|
||||
void truncatesPayloadIfDeclaredLengthExceedsRemaining() {
|
||||
// 防御场景:length 字段声称 100 字节但 buffer 只剩 3 字节
|
||||
byte[] data = bytes(0x00, 0x64, 0x01, 0x02, 0x03);
|
||||
ByteBuffer buf = ByteBuffer.wrap(data);
|
||||
InfoBlock.GuangdongFc.VendorTlv tlv =
|
||||
(InfoBlock.GuangdongFc.VendorTlv) new GdFcVendorTlvBlockParser(0x83).parse(buf);
|
||||
assertThat(tlv.declaredLength()).isEqualTo(100);
|
||||
assertThat(tlv.payload()).hasSize(3).containsExactly(0x01, 0x02, 0x03);
|
||||
}
|
||||
|
||||
private static byte[] bytes(int... values) {
|
||||
byte[] out = new byte[values.length];
|
||||
for (int i = 0; i < values.length; i++) out[i] = (byte) values[i];
|
||||
return out;
|
||||
}
|
||||
|
||||
private static void write16(ByteArrayOutputStream os, int v) {
|
||||
os.write((v >> 8) & 0xFF);
|
||||
os.write(v & 0xFF);
|
||||
}
|
||||
|
||||
private static void write32(ByteArrayOutputStream os, long v) {
|
||||
os.write((int) ((v >> 24) & 0xFF));
|
||||
os.write((int) ((v >> 16) & 0xFF));
|
||||
os.write((int) ((v >> 8) & 0xFF));
|
||||
os.write((int) (v & 0xFF));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package com.lingniu.ingest.protocol.gb32960.codec.profile;
|
||||
|
||||
import com.lingniu.ingest.protocol.gb32960.codec.Gb32960ParserContext;
|
||||
import com.lingniu.ingest.protocol.gb32960.config.Gb32960Properties;
|
||||
import com.lingniu.ingest.protocol.gb32960.model.ProtocolVersion;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
class RuleBasedVendorExtensionSelectorTest {
|
||||
|
||||
@Test
|
||||
void platformAccountMatchHits() {
|
||||
var ext = entry("guangdong-fc", List.of("lingniu"), List.of(), List.of());
|
||||
var selector = new RuleBasedVendorExtensionSelector(List.of(ext), Set.of("guangdong-fc"));
|
||||
var ctx = new Gb32960ParserContext(ProtocolVersion.V2016, "LNV1234567890ABCD", "lingniu");
|
||||
assertThat(selector.select(ctx)).isEqualTo("guangdong-fc");
|
||||
}
|
||||
|
||||
@Test
|
||||
void vinExactMatchHits() {
|
||||
var ext = entry("guangdong-fc", List.of(), List.of("LNV1234567890ABCD"), List.of());
|
||||
var selector = new RuleBasedVendorExtensionSelector(List.of(ext), Set.of("guangdong-fc"));
|
||||
var ctx = new Gb32960ParserContext(ProtocolVersion.V2016, "lnv1234567890abcd", null);
|
||||
assertThat(selector.select(ctx))
|
||||
.as("VIN match should be case-insensitive")
|
||||
.isEqualTo("guangdong-fc");
|
||||
}
|
||||
|
||||
@Test
|
||||
void vinPrefixMatchHits() {
|
||||
var ext = entry("guangdong-fc", List.of(), List.of(), List.of("LNVFC", "LZG"));
|
||||
var selector = new RuleBasedVendorExtensionSelector(List.of(ext), Set.of("guangdong-fc"));
|
||||
var ctx = new Gb32960ParserContext(ProtocolVersion.V2016, "LNVFC0001", null);
|
||||
assertThat(selector.select(ctx)).isEqualTo("guangdong-fc");
|
||||
}
|
||||
|
||||
@Test
|
||||
void noMatchReturnsNull() {
|
||||
var ext = entry("guangdong-fc", List.of("other"), List.of(), List.of());
|
||||
var selector = new RuleBasedVendorExtensionSelector(List.of(ext), Set.of("guangdong-fc"));
|
||||
var ctx = new Gb32960ParserContext(ProtocolVersion.V2016, "ZZZ", "lingniu");
|
||||
assertThat(selector.select(ctx)).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void firstMatchWinsTopDown() {
|
||||
var first = entry("guangdong-fc", List.of("lingniu"), List.of(), List.of());
|
||||
var second = entry("guangdong-fc", List.of(), List.of(), List.of("LNV"));
|
||||
var selector = new RuleBasedVendorExtensionSelector(
|
||||
List.of(first, second), Set.of("guangdong-fc"));
|
||||
// 即便两条都能命中,应返回第一条匹配 entry 的 name(这里凑巧一样)
|
||||
var ctx = new Gb32960ParserContext(ProtocolVersion.V2016, "LNV1", "lingniu");
|
||||
assertThat(selector.select(ctx)).isEqualTo("guangdong-fc");
|
||||
}
|
||||
|
||||
@Test
|
||||
void emptyConfigReturnsNullWithoutScanning() {
|
||||
var selector = new RuleBasedVendorExtensionSelector(List.of(), Set.of("guangdong-fc"));
|
||||
var ctx = new Gb32960ParserContext(ProtocolVersion.V2016, "ANY", "ANY");
|
||||
assertThat(selector.select(ctx)).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void unknownExtensionNameRejectedAtConstruction() {
|
||||
var ext = entry("not-in-catalog", List.of("x"), List.of(), List.of());
|
||||
assertThatThrownBy(() ->
|
||||
new RuleBasedVendorExtensionSelector(List.of(ext), Set.of("guangdong-fc")))
|
||||
.isInstanceOf(IllegalStateException.class)
|
||||
.hasMessageContaining("not-in-catalog");
|
||||
}
|
||||
|
||||
@Test
|
||||
void resultIsCachedPerAccountVinTuple() {
|
||||
var ext = entry("guangdong-fc", List.of("lingniu"), List.of(), List.of());
|
||||
var selector = new RuleBasedVendorExtensionSelector(List.of(ext), Set.of("guangdong-fc"));
|
||||
var ctx1 = new Gb32960ParserContext(ProtocolVersion.V2016, "VIN1", "lingniu");
|
||||
var ctx2 = new Gb32960ParserContext(ProtocolVersion.V2016, "VIN1", "lingniu");
|
||||
selector.select(ctx1);
|
||||
selector.select(ctx2);
|
||||
// 同一个 (account, vin) 应只产生一个 cache 条目
|
||||
assertThat(selector.cacheSize()).isEqualTo(1);
|
||||
|
||||
var ctx3 = new Gb32960ParserContext(ProtocolVersion.V2016, "VIN2", "lingniu");
|
||||
selector.select(ctx3);
|
||||
assertThat(selector.cacheSize()).isEqualTo(2);
|
||||
}
|
||||
|
||||
private static Gb32960Properties.VendorExtension entry(String name,
|
||||
List<String> accounts,
|
||||
List<String> vins,
|
||||
List<String> vinPrefixes) {
|
||||
var e = new Gb32960Properties.VendorExtension();
|
||||
e.setName(name);
|
||||
var m = new Gb32960Properties.VendorExtension.Match();
|
||||
m.setPlatformAccounts(accounts);
|
||||
m.setVins(vins);
|
||||
m.setVinPrefixes(vinPrefixes);
|
||||
e.setMatch(m);
|
||||
return e;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package com.lingniu.ingest.protocol.gb32960.handler;
|
||||
|
||||
import com.lingniu.ingest.api.event.VehicleEvent;
|
||||
import com.lingniu.ingest.protocol.gb32960.mapper.Gb32960EventMapper;
|
||||
import com.lingniu.ingest.protocol.gb32960.model.CommandBody;
|
||||
import com.lingniu.ingest.protocol.gb32960.model.CommandType;
|
||||
import com.lingniu.ingest.protocol.gb32960.model.EncryptType;
|
||||
import com.lingniu.ingest.protocol.gb32960.model.Gb32960Header;
|
||||
import com.lingniu.ingest.protocol.gb32960.model.Gb32960Message;
|
||||
import com.lingniu.ingest.protocol.gb32960.model.ProtocolVersion;
|
||||
import com.lingniu.ingest.protocol.gb32960.model.ResponseFlag;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/**
|
||||
* 保障 {@link Gb32960RealtimeHandler} 正确委托给 {@link Gb32960EventMapper},
|
||||
* 不吞掉任何需要上送下游的事件。
|
||||
*/
|
||||
class Gb32960RealtimeHandlerTest {
|
||||
|
||||
private final Gb32960RealtimeHandler handler = new Gb32960RealtimeHandler(new Gb32960EventMapper());
|
||||
|
||||
@Test
|
||||
void onPlatform_login_emitsLoginEventWithKindPlatform() {
|
||||
Gb32960Message msg = buildPlatformLoginMessage("lingniu");
|
||||
|
||||
List<VehicleEvent> events = handler.onPlatform(msg);
|
||||
|
||||
assertThat(events).hasSize(1);
|
||||
VehicleEvent first = events.get(0);
|
||||
assertThat(first).isInstanceOf(VehicleEvent.Login.class);
|
||||
assertThat(first.vin()).isEqualTo("platform:lingniu");
|
||||
assertThat(first.metadata())
|
||||
.containsEntry("kind", "platform")
|
||||
.containsEntry("username", "lingniu");
|
||||
}
|
||||
|
||||
@Test
|
||||
void onPlatform_logout_emitsLogoutEventWithKindPlatform() {
|
||||
Gb32960Message msg = new Gb32960Message(
|
||||
new Gb32960Header(
|
||||
ProtocolVersion.V2016,
|
||||
CommandType.PLATFORM_LOGOUT,
|
||||
ResponseFlag.COMMAND,
|
||||
"00000000000000000",
|
||||
EncryptType.UNENCRYPTED,
|
||||
0,
|
||||
null),
|
||||
new CommandBody.PlatformLogout(Instant.parse("2026-04-20T10:00:00Z"), 1));
|
||||
|
||||
List<VehicleEvent> events = handler.onPlatform(msg);
|
||||
|
||||
assertThat(events).hasSize(1);
|
||||
assertThat(events.get(0)).isInstanceOf(VehicleEvent.Logout.class);
|
||||
assertThat(events.get(0).metadata()).containsEntry("kind", "platform");
|
||||
}
|
||||
|
||||
private static Gb32960Message buildPlatformLoginMessage(String username) {
|
||||
return new Gb32960Message(
|
||||
new Gb32960Header(
|
||||
ProtocolVersion.V2016,
|
||||
CommandType.PLATFORM_LOGIN,
|
||||
ResponseFlag.COMMAND,
|
||||
"00000000000000000",
|
||||
EncryptType.UNENCRYPTED,
|
||||
0,
|
||||
null),
|
||||
new CommandBody.PlatformLogin(
|
||||
Instant.parse("2026-04-20T10:00:00Z"),
|
||||
1,
|
||||
username,
|
||||
"secret",
|
||||
EncryptType.UNENCRYPTED));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package com.lingniu.ingest.protocol.gb32960.inbound;
|
||||
|
||||
import com.lingniu.ingest.protocol.gb32960.auth.Gb32960PlatformAuthorizer;
|
||||
import com.lingniu.ingest.protocol.gb32960.auth.Gb32960VinAuthorizer;
|
||||
import com.lingniu.ingest.protocol.gb32960.config.Gb32960Properties;
|
||||
import io.netty.channel.embedded.EmbeddedChannel;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.net.InetSocketAddress;
|
||||
import java.util.List;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class Gb32960AccessServiceTest {
|
||||
|
||||
@Test
|
||||
void bindPlatformAccount_makesAccountAvailableForLaterFrames() {
|
||||
Gb32960AccessService service = newService(false);
|
||||
EmbeddedChannel channel = new EmbeddedChannel();
|
||||
|
||||
service.bindPlatformAccount(channel, "lingniu");
|
||||
|
||||
assertThat(service.platformAccount(channel)).isEqualTo("lingniu");
|
||||
}
|
||||
|
||||
@Test
|
||||
void authenticatePlatformLogin_rejectsUnknownPeerIpWhenPolicyRestrictsIt() {
|
||||
Gb32960Properties.Auth auth = new Gb32960Properties.Auth();
|
||||
Gb32960Properties.Auth.Platform platform = new Gb32960Properties.Auth.Platform();
|
||||
platform.setUsername("lingniu");
|
||||
platform.setPassword("secret");
|
||||
platform.setAllowedIps(List.of("10.0.0.1"));
|
||||
auth.setPlatforms(List.of(platform));
|
||||
|
||||
Gb32960AccessService service = new Gb32960AccessService(
|
||||
new Gb32960VinAuthorizer(auth),
|
||||
new Gb32960PlatformAuthorizer(auth.getPlatforms()));
|
||||
EmbeddedChannel channel = new EmbeddedChannel();
|
||||
channel.connect(new InetSocketAddress("10.0.0.2", 9001));
|
||||
|
||||
assertThat(service.authenticatePlatformLogin("lingniu", "secret", channel)
|
||||
.accepted()).isFalse();
|
||||
}
|
||||
|
||||
private static Gb32960AccessService newService(boolean vinAuthEnabled) {
|
||||
Gb32960Properties.Auth auth = new Gb32960Properties.Auth();
|
||||
auth.setEnabled(vinAuthEnabled);
|
||||
return new Gb32960AccessService(
|
||||
new Gb32960VinAuthorizer(auth),
|
||||
new Gb32960PlatformAuthorizer(auth.getPlatforms()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package com.lingniu.ingest.protocol.gb32960.inbound;
|
||||
|
||||
import com.lingniu.ingest.protocol.gb32960.model.CommandType;
|
||||
import com.lingniu.ingest.protocol.gb32960.model.ProtocolVersion;
|
||||
import com.lingniu.ingest.protocol.gb32960.model.ResponseFlag;
|
||||
import io.netty.buffer.ByteBuf;
|
||||
import io.netty.channel.embedded.EmbeddedChannel;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Instant;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class Gb32960AckServiceTest {
|
||||
|
||||
@Test
|
||||
void writeResponse_writesGb32960AckFrameToChannel() {
|
||||
Gb32960AckService service = new Gb32960AckService();
|
||||
EmbeddedChannel channel = new EmbeddedChannel();
|
||||
byte[] rawVin = "LTEST000000000001".getBytes(StandardCharsets.US_ASCII);
|
||||
|
||||
service.writeResponse(
|
||||
channel,
|
||||
ProtocolVersion.V2016,
|
||||
CommandType.VEHICLE_LOGIN,
|
||||
ResponseFlag.SUCCESS,
|
||||
rawVin,
|
||||
Instant.parse("2026-04-20T10:00:00Z"),
|
||||
null,
|
||||
"vehicle-login-ack");
|
||||
|
||||
ByteBuf out = channel.readOutbound();
|
||||
assertThat(out).isNotNull();
|
||||
byte[] bytes = new byte[out.readableBytes()];
|
||||
out.readBytes(bytes);
|
||||
assertThat(bytes[0]).isEqualTo((byte) 0x23);
|
||||
assertThat(bytes[1]).isEqualTo((byte) 0x23);
|
||||
assertThat(bytes[2]).isEqualTo((byte) CommandType.VEHICLE_LOGIN.code());
|
||||
assertThat(bytes[3]).isEqualTo((byte) ResponseFlag.SUCCESS.code());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package com.lingniu.ingest.protocol.gb32960.inbound;
|
||||
|
||||
import com.lingniu.ingest.protocol.gb32960.model.InfoBlock;
|
||||
import com.lingniu.ingest.protocol.gb32960.model.InfoBlockType;
|
||||
import io.netty.channel.embedded.EmbeddedChannel;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class Gb32960FrameDiagnosticsTest {
|
||||
|
||||
@Test
|
||||
void shouldLogFirstSeenRawSignatureOnlyOncePerChannel() {
|
||||
Gb32960FrameDiagnostics diagnostics = new Gb32960FrameDiagnostics(8);
|
||||
EmbeddedChannel channel = new EmbeddedChannel();
|
||||
|
||||
List<String> rawTypes = List.of("0x30", "0x83");
|
||||
|
||||
assertThat(diagnostics.markFirstSeen(channel, "VIN001", rawTypes)).isTrue();
|
||||
assertThat(diagnostics.markFirstSeen(channel, "VIN001", rawTypes)).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void markFirstSeen_keepsBoundedKeysPerChannel() {
|
||||
Gb32960FrameDiagnostics diagnostics = new Gb32960FrameDiagnostics(2);
|
||||
EmbeddedChannel channel = new EmbeddedChannel();
|
||||
|
||||
assertThat(diagnostics.markFirstSeen(channel, "VIN001", List.of("0x30"))).isTrue();
|
||||
assertThat(diagnostics.markFirstSeen(channel, "VIN001", List.of("0x31"))).isTrue();
|
||||
assertThat(diagnostics.markFirstSeen(channel, "VIN001", List.of("0x32"))).isTrue();
|
||||
|
||||
assertThat(diagnostics.seenKeyCount(channel)).isLessThanOrEqualTo(2);
|
||||
}
|
||||
|
||||
@Test
|
||||
void collectRawTypeHex_returnsOnlyRawBlocks() {
|
||||
List<String> rawTypes = Gb32960FrameDiagnostics.collectRawTypeHex(List.of(
|
||||
new InfoBlock.Raw(0x30, InfoBlockType.RAW, new byte[] {1}),
|
||||
new InfoBlock.Raw(0x83, InfoBlockType.RAW, new byte[] {2})));
|
||||
|
||||
assertThat(rawTypes).containsExactly("0x30", "0x83");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package com.lingniu.ingest.protocol.gb32960.mapper;
|
||||
|
||||
import com.lingniu.ingest.api.ProtocolId;
|
||||
import com.lingniu.ingest.api.event.AlarmPayload;
|
||||
import com.lingniu.ingest.api.event.VehicleEvent;
|
||||
import com.lingniu.ingest.protocol.gb32960.codec.Gb32960BodyParser;
|
||||
import com.lingniu.ingest.protocol.gb32960.codec.Gb32960DecoderTest;
|
||||
import com.lingniu.ingest.protocol.gb32960.codec.Gb32960MessageDecoder;
|
||||
import com.lingniu.ingest.protocol.gb32960.codec.InfoBlockParserRegistry;
|
||||
import com.lingniu.ingest.protocol.gb32960.codec.parser.v2016.PositionV2016BlockParser;
|
||||
import com.lingniu.ingest.protocol.gb32960.codec.parser.v2016.VehicleV2016BlockParser;
|
||||
import com.lingniu.ingest.protocol.gb32960.model.CommandType;
|
||||
import com.lingniu.ingest.protocol.gb32960.model.EncryptType;
|
||||
import com.lingniu.ingest.protocol.gb32960.model.Gb32960Header;
|
||||
import com.lingniu.ingest.protocol.gb32960.model.Gb32960Message;
|
||||
import com.lingniu.ingest.protocol.gb32960.model.GeneralAlarmFlagBit;
|
||||
import com.lingniu.ingest.protocol.gb32960.model.InfoBlock;
|
||||
import com.lingniu.ingest.protocol.gb32960.model.ProtocolVersion;
|
||||
import com.lingniu.ingest.protocol.gb32960.model.ResponseFlag;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.nio.ByteBuffer;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class Gb32960EventMapperTest {
|
||||
|
||||
@Test
|
||||
void realtimeReportProducesRealtimeAndLocationEvents() {
|
||||
byte[] frame = Gb32960DecoderTest.buildRealtimeFrame("LTEST000000000002");
|
||||
|
||||
Gb32960BodyParser body = new Gb32960BodyParser(new InfoBlockParserRegistry(
|
||||
List.of(new VehicleV2016BlockParser(), new PositionV2016BlockParser())));
|
||||
Gb32960Message msg = new Gb32960MessageDecoder(body).decode(ByteBuffer.wrap(frame));
|
||||
|
||||
List<VehicleEvent> events = new Gb32960EventMapper().toEvents(msg);
|
||||
|
||||
assertThat(events).hasSize(2);
|
||||
assertThat(events).anyMatch(e -> e instanceof VehicleEvent.Realtime);
|
||||
assertThat(events).anyMatch(e -> e instanceof VehicleEvent.Location);
|
||||
assertThat(events).allMatch(e -> e.source() == ProtocolId.GB32960);
|
||||
assertThat(events).allMatch(e -> e.vin().equals("LTEST000000000002"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void hydrogenLeakAlarmBitProducesCriticalSafetyAlarmEvent() {
|
||||
long hydrogenLeakFlag = 1L << GeneralAlarmFlagBit.HYDROGEN_LEAK.bitIndex();
|
||||
Gb32960Message msg = new Gb32960Message(
|
||||
new Gb32960Header(
|
||||
ProtocolVersion.V2025,
|
||||
CommandType.REALTIME_REPORT,
|
||||
ResponseFlag.COMMAND,
|
||||
"LTEST000000000003",
|
||||
EncryptType.UNENCRYPTED,
|
||||
0,
|
||||
Instant.parse("2026-06-22T01:00:00Z")),
|
||||
List.of(new InfoBlock.Gb32960V2025.Alarm(
|
||||
0,
|
||||
hydrogenLeakFlag,
|
||||
List.of(),
|
||||
List.of(),
|
||||
List.of(),
|
||||
List.of(),
|
||||
List.of())));
|
||||
|
||||
List<VehicleEvent> events = new Gb32960EventMapper().toEvents(msg);
|
||||
|
||||
VehicleEvent.Alarm alarm = events.stream()
|
||||
.filter(VehicleEvent.Alarm.class::isInstance)
|
||||
.map(VehicleEvent.Alarm.class::cast)
|
||||
.findFirst()
|
||||
.orElseThrow();
|
||||
assertThat(alarm.payload().level()).isEqualTo(AlarmPayload.AlarmLevel.CRITICAL);
|
||||
assertThat(alarm.payload().safetyCategory()).isEqualTo(AlarmPayload.SafetyCategory.HYDROGEN_LEAK);
|
||||
assertThat(alarm.payload().hydrogenLeakDetected()).isTrue();
|
||||
assertThat(alarm.payload().hydrogenLeakLevel()).isEqualTo(AlarmPayload.HydrogenLeakLevel.CRITICAL);
|
||||
assertThat(alarm.payload().hydrogenLeakActionRequired()).isTrue();
|
||||
assertThat(alarm.payload().activeBits()).contains("HYDROGEN_LEAK");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
# GB/T 32960 黄金样本集
|
||||
|
||||
> 本目录用于回放测试与双跑对账。所有样本从旧服务 `lingniu-vehicle-data-reception` 线上抓包后脱敏(VIN 替换为 `LTEST<序号>XXXXXXXX`)。
|
||||
|
||||
## 文件命名
|
||||
|
||||
```
|
||||
<命令类型>_<场景>_<序号>.hex
|
||||
```
|
||||
|
||||
命令类型取自 `CommandType` 枚举:
|
||||
- `vehicle_login` (0x01)
|
||||
- `realtime_report` (0x02)
|
||||
- `resend_report` (0x03)
|
||||
- `vehicle_logout` (0x04)
|
||||
- `heartbeat` (0x07)
|
||||
|
||||
## 文件格式
|
||||
|
||||
每个 `.hex` 文件是一行紧凑十六进制,字节之间**无分隔符**。
|
||||
行首允许以 `#` 开头写注释,测试代码忽略注释行与空行。
|
||||
|
||||
示例 `realtime_report_basic_001.hex`:
|
||||
```
|
||||
# 2017-05-21 10:00:00 宇通 YT01,SOC 70%,车速 52.3 km/h
|
||||
2323020000010203040506070809101112131415160100352016051510000001010012350000037102710000A064010505050A0005010A00102003E8...
|
||||
```
|
||||
|
||||
## 新增样本步骤
|
||||
|
||||
1. 从生产抓包工具(tcpdump / rg-samples)导出一帧完整字节(含 `0x23 0x23` 起始和 BCC 尾)
|
||||
2. VIN 替换为测试段:`LTEST0000<9位序号>`
|
||||
3. 放入本目录,文件名按规范
|
||||
4. 在 `Gb32960DecoderGoldenTest` 中添加断言(期望 VIN / 命令 / 关键字段)
|
||||
5. 运行 `mvn -pl protocol-gb32960 test`
|
||||
|
||||
## 验证目标
|
||||
|
||||
- 100% 解析一致性(新服务解析结果 === 旧服务解析结果)
|
||||
- BCC 校验通过
|
||||
- 事件映射字段单位一致(车速 km/h、里程 km、经纬度十进制度)
|
||||
@@ -0,0 +1 @@
|
||||
232307fe4c54455354303030303030303030303131010000a2
|
||||
@@ -0,0 +1 @@
|
||||
232307fe4c54455354303030303030303030303231010000a1
|
||||
@@ -0,0 +1 @@
|
||||
232302fe4c544553543030303030303030303030310102101a040d0c213901010302037a000531c2157825fb44012e27103400020101025575304eca6b157a288b030c82050a00aa0002686c02940200000100630101050006c4f414015eedea06018c0f0701310ebf01014a01054907000000000000000000080101157825fb00900001900eea0eea0eea0ee60ee60ee60ef00eef0ef10eeb0eec0eeb0eed0eee0eee0eeb0eed0eea0ed30ed20ed20ed30ed50ed40ecc0ecd0ecc0ece0ece0ece0ed40ed40ed40ed30ed10ed00ed20ed10ed00ece0ece0ecf0ed40ed40ed40eda0eda0edb0ebf0ed40ed30ed60ed60ed50ed40ed50ede0edf0ede0ede0eee0eed0eec0ee80ee80ee50ef00eef0eef0ee80ee90eea0ee40ee70ee80eeb0eeb0eea0eee0eef0eef0ef60ef60ef50ef80ef70ef50ef60ef50ef60eef0ef00ef10ef60ef80ef90efa0ef70ef90efa0ef80ef80efc0efc0efc0ef50ef50ef50ef20ef30ef40ef50ef50ef40ef50ef50ef70ef40ef40ef50ef80ef80ef70ef70ef50ef40ef70ef70ef70efa0efd0efc0ef70ef80ef70ef80ef60ef50f050f070f070ef00eed0ef009010100084a4a4a4a494a4a493001026c08fcffffff0003003a02ee02e402e5000000000031010c800006ffffffff0c80ffffffff0088320c8004c2159002c35333ffffffffff34ffffffffff001c8000096c0c802742ffffffff8300250019000e0b002300020000000000014f00ffffffffffff00000282ffffffff1ffe1fedff1fa0
|
||||
@@ -0,0 +1 @@
|
||||
232302fe4c544553543030303030303030303030320102ed1a040d0c213a01010302013600036758162826284c012e2710010002010101485bf84de44b165526ec030da9017400d200026a6b023a0200000100b101010402ffff0015050107373d6c01d2e6880601020f8701520efe010141010540070000000000000000000801011628262800900001900f860f870f860f6d0f6c0f6c0f870f860f870f6d0f700f6e0f7e0f7d0f710f640f630f650f6e0f800f7e0f260f250f250f810f810f820f620f660f670f810f820f830f670f680f670f780f770f770f670f660f670f640f630f620f1a0f1c0f1c0f500f500f4d0f690f6b0f690f540f530f5a0f420f440f440f6e0f6e0f6d0f460f450f450f720f6c0f6b0f650f640f670f500f4f0f4c0f150f140f150f780f760f780efe0eff0eff0f7b0f7c0f7d0f640f640f640f6e0f6d0f6e0f360f390f390f5c0f5e0f5c0f650f660f660f7e0f800f800f640f620f640f810f800f800f300f330f2f0f800f820f830f670f660f680f820f840f820f650f670f660f820f840f830f680f660f660f820f810f810f680f660f660f720f720f710f680f670f67090101000841414141404040403001026b092eff00ff000100730ca80c800ca4006c00016c0ca40ca80ca30ca30ca30ca30ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca40ca431010dc00005ffffffff0dc0ffffffff008a320dc000ff15e7009e4833ffffffffff34ffffffffff002f8000096b0dc02715000d000e83002402040100002700080000000000c900000000ffffffffffffffffffffffff1ffe15c8631f18
|
||||
@@ -0,0 +1 @@
|
||||
232302fe4c544553543030303030303030303030330102ed1a040d0c2200010203010000000418d914ed27103a020007d0000002010104454e204e204814be2710030006000000b400024a4902bc02000001006701000400ffff0012050006be89500161217106013d0e91012c0e860101470105460700000000000000000008010114ed271000900001900e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e8a0e870e870e880e880e880e880e880e880e880e880e880e870e860e860e880e880e8a0e880e880e880e880e880e880e880e880e880e880e880e880e910e900e900e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e880e88090101000847474747464746463001004907c6ff00ff00000039000000000004006c00016c00000004000400040004000400040004000400040004000400040004000400040004000400040004000400040004000400040004000400040004000400040004000400040004000400040004000400040004000400040004000400040004000400040004000400040004000400040004000400000004000400040004000400040004000400040004000400040004000400040004000400040004000400040004000400040004000400040004000400040004000400040004000400040004000400040004000400040005000500050005000500050005000531010eb00005ffffffff0eb0ffffffff0081320eb01b3b14e613234733ffffffffff34ffffffffff001d800009490eb02710000d000e83002402040100002700080000000000d500000000ffffffffffffffffffffffff1ffe167c021f7b
|
||||
@@ -0,0 +1 @@
|
||||
232302fe4c544553543030303030303030303031300102ed1a040d0c220501020301006400049992163027df4f012e27101e00020101015952da51365215f4279f030005000000000002625f02a80200000100a801000400ffff0000050106c3d5cf016240a406010b0f90015b0f4001014a01054907000000000000000000080101163027df00900001900f8c0f8d0f8d0f8d0f8e0f8e0f8f0f8f0f8e0f8f0f900f900f8f0f900f7a0f7c0f800f800f7f0f7c0f7c0f7c0f7e0f7e0f810f7f0f7f0f7e0f820f830f500f500f500f840f850f840f840f820f850f850f820f820f730f750f730f690f6a0f680f620f5f0f600f500f500f500f630f5a0f6d0f6c0f6c0f6d0f500f500f500f6b0f6b0f6c0f6a0f5d0f5f0f5c0f5f0f610f5c0f5e0f5a0f5c0f5c0f610f630f630f640f510f520f540f500f500f500f610f550f500f400f430f410f500f510f500f4e0f4f0f530f500f500f4f0f550f550f560f550f570f560f540f570f560f510f540f530f540f560f5a0f5a0f590f570f7e0f7e0f810f580f4e0f530f570f550f510f500f480f4d0f870f7e0f7e0f7c0f7e0f7f0f7c0f7b0f810f7d0f7e0f7f09010100084a4a4a4a494949493001005f07daff00ff00000038000000000004006c00016c0000000400040004000400040004000400040004000400040004000400040004000400040004000400040004000400040004000400040004000400040004000400040004000400040004000400040004000400040004000400040004000400040004000400040004000400040004000400000004000400040004000400040004000400040004000400040004000400040004000400040004000400040004000400040004000400040004000400040004000400040004000400040004000400040004000400040004000500050005000500050005000500053101003c0005ffffffff003cffffffff008a32003c00000000ffff4f33ffffffffff34ffffffffff002c8000095f003c2710000d000e830024020401000027000800000000000000000000ffffffffffffffffffffffff1ffe17ae631ff9
|
||||
@@ -0,0 +1 @@
|
||||
232302fe4c544553543030303030303030303032330101d61a040d0c22070101030203010004c1ee17fc261337012e271032000201010157705a4fe26e17f2260c0309d50bba021a00027f6e022b030000010106010105000733a7d401d832920601840eda011c0ebf01044b0101480700000000000000000008010117fc261300a20001a20ecc0eca0ec30ecb0ec90ec70eca0ec90ec20ec20ec40ec20ec70eca0eca0ec90ece0ecd0ec80ecd0eca0ecc0ec90ec80ece0ec70ec50ebf0ed40ed20ed40ed30ed40ed30ed50ed40ed30ed50ed20ed50ed50ed30ecc0ecb0ec80ecf0ec90ed00ec70ecd0ec70ec50ec70ece0eca0eca0ec90eca0ed10ec90ed00ecf0eca0ed10ed30ed10ecf0ed20ed00ed20ed40ed50ed20ed20ed60ed20ed30ed20ed50ed50ed70ed40ed10ed40ed30ed20ed30ed20ecc0ed00ed10ecc0ed30ed10ed00ecb0ec90ecd0ec80ed10ec50ec80ed20ec10ec90ec50ec90ec30ec50ed40ed40ed40ed40ed40ed40ed60ed50ed30ed50ed50ed50ed50ed40ed50ed40ed30ed20ed20ed30ed60ed50eda0ed00ed70ed80ed60ed70ed50ed60ece0ed50ed40ed60ed40ed10ed30ed20ed40ed40ed40ecf0ed80ed60ed50ed60ed70ed60ed60ed40ed50ed70eda0901010020484a4a4b4b4b4b4a484b4b4b4b4b4b4a484a4b4b4b4b4b4a484a4b4b4b4b4b4acc
|
||||
@@ -0,0 +1 @@
|
||||
232302fe4c544553543030303030303030303130370101ae1a040d0c39263001016908fc09c4410003007403200320032001b000c9c803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e8cb
|
||||
@@ -0,0 +1 @@
|
||||
232302fe4c544553543030303030303030303231310102ed1a040d0c3a120101030200000002533c150727203e01002710000002010104464e204e204814fc2710030004000200be00025353023a01000001010201010402ffff00130500072eb49401de012e06013f0ea7012a0e9301014001033f070000000000000000000801011507272000900001900ea60ea60ea60ea60ea60ea60ea50ea60ea60ea60ea60ea60ea60ea50e960e990e960e980e980e990e970e990e970e960e980e980e990e980e950e970e950e940e950e960e970e940e990e960e960e950e950e930e960e9b0e9c0e9c0e990e9b0e940e950e930e980e9a0e9a0e940e950ea50ea50ea50ea50ea50ea60ea70ea60ea50ea50ea50e9d0e9e0e9e0e9e0e990e970e970e990e980e9a0e9d0e9a0e9c0e9a0e990e9a0e9a0e9b0e9c0e9c0e980e950e980e990e990e990e980e970e970e980e970e970e990e990e9a0e990e990e990e9a0e980e960e960e990e970e970e970e970e980e950e930e9b0e980e9a0e990e9a0e990e940e940e950e940e950e950e990e990e9b0e9a0e9b0e9b0e960e950e960e950e950e950e950e940e99090101000840403f403f3f3f3f300102530708ff00ff00000037000000000000006c00016c00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000031010e880005ffffffff0e88ffffffff008a320e880002150e00014733ffffffffff34ffffffffff0044800009530e882710000d000e830024020401000027000800000000005401000000ffffffffffffffffffffffff1ffe1d7b011fdd
|
||||
@@ -0,0 +1 @@
|
||||
232303fe4c544553543030303030303030303032320102101a040d0d0b0a010203010000000532ad156d271c45011007d0006502010103514e204e205c003f27100300070000000000025e5c03b601000001014d0100050106c3aa530160540f0601010ee301120ede01014801054707000000000000000000080101156d271c00900001900ee30ee30ee30ee00ee20ee20ee20ee10ee20ee30ee20ee20ee20ee10ee30ee10ee10ede0ee10ee10ee10ee00ee10ee10ee10ee00ee00ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee10ee1090101000848484848474848473001005c0816ffffff0000003a000a0000000100000000003101003c0005ffffffff003cffffffff008b32003c00000000ffff5233ffffffffff34ffffffffff004c8000095c003c2710ffffffff8300250019000e0b002300020000000000000000ffffffffffff0000000affffffff1ffe1fedff0089
|
||||
@@ -0,0 +1 @@
|
||||
232303fe4c544553543030303030303030303230390101ae1a040d0c391b3001005307ee09c4410037000100000000000001b000c9c803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e803e8be
|
||||
@@ -0,0 +1 @@
|
||||
232301fe4c5445535430303030303030303030313901001e1a040d0d0b1a000c38393836303332313435323039303735363530390100bd
|
||||
Reference in New Issue
Block a user