feat:测试平台2.1更新(测试指令-语音播报)
All checks were successful
iot-test-platform CI/CD / build-and-deploy (push) Successful in 17s

This commit is contained in:
lzh
2025-12-15 12:03:30 +08:00
parent 461f139731
commit a6c80811dc
9 changed files with 522 additions and 1 deletions

View File

@@ -42,5 +42,9 @@ public class Consts {
public static final int CMD_PARAM_SETTINGS = 0X8103;
/** 查询终端参数 **/
public static final int CMD_PARAM_QUERY = 0x8104;
/** 文本信息下发 **/
public static final int CMD_TEXT_INFO_DOWN = 0x8300;
/** 位置信息查询 **/
public static final int CMD_LOCATION_INQUIRY = 0x8201;
}

View File

@@ -12,6 +12,14 @@ import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import java.util.Date;
import java.util.Map;
import java.util.List;
import com.iot.transport.jt808.entity.Session;
import com.iot.transport.jt808.entity.request.LocationInquiryPack;
import com.iot.transport.jt808.entity.request.TextInfoDownPack;
import com.iot.transport.jt808.server.SessionManager;
import com.iot.transport.jt808.service.codec.DataEncoder;
import com.iot.transport.jt808.util.CommandBuilder;
@Slf4j
@RestController
@@ -23,6 +31,112 @@ public class DeviceController {
@Autowired
private ApiLogService apiLogService;
private final SessionManager sessionManager = SessionManager.getInstance();
private final DataEncoder dataEncoder = new DataEncoder();
/**
* 下发指令 (通用)
* e.g. {"imei": "...", "cmd": "reboot", "params": []}
* or {"imei": "...", "cmd": "ip", "params": ["1.1.1.1", "80"]}
*/
@PostMapping("/command/send")
public CommonResult<String> sendCommand(@RequestBody Map<String, Object> payload) {
String imei = (String) payload.get("imei");
String cmdType = (String) payload.get("cmd");
List<String> params = (List<String>) payload.get("params");
String commandStr = "";
if (cmdType == null) return CommonResult.failed("cmd is required");
switch (cmdType.toLowerCase()) {
case "reboot":
commandStr = CommandBuilder.reboot();
break;
case "ip":
if (params != null && params.size() >= 2) {
commandStr = CommandBuilder.setIp(params.get(0), Integer.parseInt(params.get(1)));
}
break;
case "workmode":
if (params != null && params.size() >= 2) {
commandStr = CommandBuilder.setWorkMode(Integer.parseInt(params.get(0)), Integer.parseInt(params.get(1)));
}
break;
case "realtime":
if (params != null && params.size() >= 2) {
commandStr = CommandBuilder.setRealtimeMode(Integer.parseInt(params.get(0)), Integer.parseInt(params.get(1)));
}
break;
case "custom": // 自定义指令内容
commandStr = (String) payload.get("raw");
break;
default:
return CommonResult.failed("Unknown command type: " + cmdType);
}
if (commandStr == null || commandStr.isEmpty()) {
return CommonResult.failed("Invalid command parameters");
}
// 调用 DeviceService 发送 8300 指令 (这里直接复用现有的 sendTextCommand 逻辑)
// 也可以直接在这里调用
return sendTextCommand(imei, commandStr, 1);
}
/**
* 下发文本信息指令 (0x8300)
*/
@PostMapping("/command/text")
public CommonResult<String> sendTextCommand(@RequestParam String phone,
@RequestParam String content,
@RequestParam(defaultValue = "1") int flag) { // 默认为1(紧急)
try {
Session session = findSessionByPhone(phone);
if (session == null) {
return CommonResult.failed("设备未连接: " + phone);
}
TextInfoDownPack pack = new TextInfoDownPack(flag, content);
byte[] bytes = dataEncoder.encode4TextInfoDown(pack, session);
session.getChannel().writeAndFlush(io.netty.buffer.Unpooled.copiedBuffer(bytes)).sync();
log.info("Send text command to {}: {}", phone, content);
return CommonResult.success("指令下发成功: " + content);
} catch (Exception e) {
log.error("Send text command failed", e);
return CommonResult.failed("指令下发失败: " + e.getMessage());
}
}
/**
* 下发位置查询指令 (0x8201)
*/
@PostMapping("/command/location")
public CommonResult<String> sendLocationInquiry(@RequestParam String phone) {
try {
Session session = findSessionByPhone(phone);
if (session == null) {
return CommonResult.failed("设备未连接: " + phone);
}
LocationInquiryPack pack = new LocationInquiryPack();
byte[] bytes = dataEncoder.encode4LocationInquiry(pack, session);
session.getChannel().writeAndFlush(io.netty.buffer.Unpooled.copiedBuffer(bytes)).sync();
log.info("Send location inquiry to {}", phone);
return CommonResult.success("指令下发成功");
} catch (Exception e) {
log.error("Send location inquiry failed", e);
return CommonResult.failed("指令下发失败: " + e.getMessage());
}
}
// Helper to find session by phone
private Session findSessionByPhone(String phone) {
return sessionManager.findByTerminalPhone(phone);
}
/**
* Standard Location Report (Typed)

View File

@@ -0,0 +1,19 @@
package com.iot.transport.jt808.entity.request;
import com.iot.transport.jt808.entity.DataPack;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.ToString;
/**
* 位置信息查询 (0x8201)
* 消息体为空
*/
@Data
@EqualsAndHashCode(callSuper = true)
@ToString(callSuper = true)
public class LocationInquiryPack extends DataPack {
public LocationInquiryPack() {
}
}

View File

@@ -0,0 +1,44 @@
package com.iot.transport.jt808.entity.request;
import com.iot.transport.jt808.entity.DataPack;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.ToString;
/**
* 文本信息下发消息 (0x8300)
*/
@Data
@EqualsAndHashCode(callSuper = true)
@ToString(callSuper = true)
public class TextInfoDownPack extends DataPack {
/**
* 标志位
* 0: 紧急
* 2: 终端显示器显示
* 3: 终端TTS播报
* 4: 广告屏显示
* 5: 0-中心导航信息1-CAN故障码信息
*
* 这里协议文档说 "8300指令下发时需将标志位的紧急位置1",且 "5.2.1文本数据下发 01属性01指令下发08/09TTS文本播报下发"
* 似乎这里的 "属性" 就是标志位。
* 示例中 01 对应二进制 0000 0001即 bit0=1 (紧急)。
* 示例 08/09 可能对应 bit3=1 (TTS) + bit0=0/1。
*/
private int flag;
/**
* 文本信息
* 协议文档示例23323031342a5345542a4d3a3223 -> #2014*SET*M:2#
*/
private String content;
public TextInfoDownPack() {
}
public TextInfoDownPack(int flag, String content) {
this.flag = flag;
this.content = content;
}
}

View File

@@ -105,7 +105,7 @@ public class SessionManager {
return sessionIdMap.entrySet();
}
public List<Session> toList() {
public List<Session> getValues() {
return this.sessionIdMap.entrySet().stream().map(e -> e.getValue()).collect(Collectors.toList());
}

View File

@@ -10,6 +10,8 @@ import com.iot.transport.jt808.entity.response.ServerBodyPack;
import com.iot.transport.jt808.entity.response.RegisterBodyPack;
import com.iot.transport.jt808.util.BitUtil;
import com.iot.transport.jt808.util.JT808Util;
import com.iot.transport.jt808.entity.request.LocationInquiryPack;
import com.iot.transport.jt808.entity.request.TextInfoDownPack;
/**
* 数据包编码器
@@ -25,6 +27,38 @@ public class DataEncoder {
this.jt808Util = new JT808Util();
}
public byte[] encode4TextInfoDown(TextInfoDownPack req, Session session) throws Exception {
byte[] contentBytes = req.getContent().getBytes(Consts.DEFAULT_CHARSET);
byte[] msgBody = this.bitUtil.concatAll(Arrays.asList(//
new byte[] { (byte) req.getFlag() }, // 标志
contentBytes // 文本信息
));
// 消息头
int msgBodyProps = this.jt808Util.generateMsgBodyProps(msgBody.length, 0b000, false, 0);
byte[] msgHeader = this.jt808Util.generateMsgHeader(session.getTerminalPhone(),
Consts.CMD_TEXT_INFO_DOWN, msgBody, msgBodyProps, session.currentFlowId());
byte[] headerAndBody = this.bitUtil.concatAll(msgHeader, msgBody);
// 校验码
int checkSum = this.bitUtil.getCheckSum4JT808(headerAndBody, 0, headerAndBody.length - 1);
return this.doEncode(headerAndBody, checkSum);
}
public byte[] encode4LocationInquiry(LocationInquiryPack req, Session session) throws Exception {
byte[] msgBody = new byte[0]; // 空消息体
// 消息头
int msgBodyProps = this.jt808Util.generateMsgBodyProps(msgBody.length, 0b000, false, 0);
byte[] msgHeader = this.jt808Util.generateMsgHeader(session.getTerminalPhone(),
Consts.CMD_LOCATION_INQUIRY, msgBody, msgBodyProps, session.currentFlowId());
byte[] headerAndBody = this.bitUtil.concatAll(msgHeader, msgBody);
// 校验码
int checkSum = this.bitUtil.getCheckSum4JT808(headerAndBody, 0, headerAndBody.length - 1);
return this.doEncode(headerAndBody, checkSum);
}
public byte[] encode4TerminalRegisterResp(RegisterPack req, RegisterBodyPack respMsgBody,
int flowId) throws Exception {
// 消息体字节数组
@@ -100,3 +134,4 @@ public class DataEncoder {
return jt808Util.doEscape4Send(noEscapedBytes, 1, noEscapedBytes.length - 2);
}
}

View File

@@ -0,0 +1,115 @@
package com.iot.transport.jt808.util;
/**
* 指令构建工具类
* 用于构建 #2014*SET*...# 格式的指令
*/
public class CommandBuilder {
private static final String PREFIX = "#2014*SET*";
private static final String SUFFIX = "#";
private static final String SEPARATOR = "*";
// 指令标识符
public static final String CMD_IP = "T";
public static final String CMD_WORK_MODE = "M"; // M0, M2...
public static final String CMD_APN = "A";
public static final String CMD_REBOOT = "R";
public static final String CMD_SOS_NUM = "D"; // D1, D2...
public static final String CMD_DEL_SOS = "DO";
public static final String CMD_ANGLE_COMP = "GD";
public static final String CMD_FACTORY_RESET = "V";
public static final String CMD_CLEAR_CACHE = "QC";
public static final String CMD_MODIFY_ID = "S";
public static final String CMD_REG_AUTH = "NS";
public static final String CMD_GPS_MODE = "GM";
public static final String CMD_WHITE_LIST = "WC";
public static final String CMD_BATCH_UPLOAD = "BUL";
public static final String CMD_DISABLE_POWER_KEY = "DP";
public static final String CMD_DEL_SMS = "DSMS";
public static final String CMD_WIFI_PRIORITY = "WD";
public static final String CMD_VOLUME = "SP";
public static final String CMD_STEP_COUNT = "STP";
public static final String CMD_BT_BROADCAST = "BA";
public static final String CMD_BT_SCAN = "BN";
public static final String CMD_BT_SCAN_TIME = "BST";
public static final String CMD_BT_FILTER_UUID = "BUID";
public static final String CMD_BT_PROTOCOL = "BSP";
public static final String CMD_BT_INTERACTIVE = "BS";
public static final String CMD_SUB_IP = "IP2";
// 查询指令
public static final String CHK_PREFIX = "#2014*CHK*";
/**
* 构建设置指令
* @param cmdKey 指令标识符 (e.g. "T", "M2")
* @param params 参数,可以是多个 (e.g. "58.61.154.247", "7018")
* @return 完整指令字符串 (e.g. "#2014*SET*T:58.61.154.247,7018#")
*/
public static String buildSetCmd(String cmdKey, String... params) {
StringBuilder sb = new StringBuilder(PREFIX);
sb.append(cmdKey).append(":");
if (params != null && params.length > 0) {
sb.append(String.join(",", params));
}
sb.append(SUFFIX);
return sb.toString();
}
/**
* 构建复合指令 (多个指令合并)
* e.g. #2014*SET*A:CMNET# #2014*SET*A:APN,USER,PASS#
* 文档示例:#2014*SET*D1:电话号码*D2:电话号码*D3:电话号码#
*/
public static String buildCompositeCmd(String... subCmds) {
// subCmds 格式应为 "D1:123", "D2:456"
StringBuilder sb = new StringBuilder(PREFIX);
if (subCmds != null && subCmds.length > 0) {
sb.append(String.join(SEPARATOR, subCmds));
}
sb.append(SUFFIX);
return sb.toString();
}
/**
* 构建查询指令
* @param params 参数,可为空
* @return 完整查询指令
*/
public static String buildCheckCmd(String... params) {
StringBuilder sb = new StringBuilder(CHK_PREFIX);
if (params != null && params.length > 0) {
sb.append(String.join(SEPARATOR, params));
} else {
// Remove last * if no params
sb.setLength(sb.length() - 1);
}
sb.append(SUFFIX);
return sb.toString();
}
// 便捷方法示例
public static String setIp(String ip, int port) {
return buildSetCmd(CMD_IP, ip, String.valueOf(port));
}
public static String setWorkMode(int mode, int interval) {
// #2014*SET*M0:600#
return buildSetCmd(CMD_WORK_MODE + mode, String.valueOf(interval));
}
public static String setRealtimeMode(int activeInterval, int staticInterval) {
// #2014*SET*M2:30,300#
return buildSetCmd(CMD_WORK_MODE + "2", String.valueOf(activeInterval), String.valueOf(staticInterval));
}
public static String reboot() {
return buildSetCmd(CMD_REBOOT);
}
public static String factoryReset() {
return buildSetCmd(CMD_FACTORY_RESET);
}
}

View File

@@ -143,6 +143,9 @@
<li class="nav-item">
<button class="nav-link py-2 small" @click="mode='custom'" :class="{active: mode==='custom'}">自定义</button>
</li>
<li class="nav-item">
<button class="nav-link py-2 small" @click="mode='command'" :class="{active: mode==='command'}">指令下发</button>
</li>
</ul>
<div v-if="mode === 'badge'">
@@ -195,6 +198,24 @@
</div>
<button @click="sendCustomJson" class="btn btn-secondary btn-sm w-100">发送自定义数据</button>
</div>
<div v-if="mode === 'command'">
<div class="mb-3">
<label class="form-label small text-muted">接口类型</label>
<select v-model="commandForm.apiType" class="form-select form-select-sm" @change="updateJsonTemplate">
<option value="location">位置查询 (8201)</option>
<option value="text">文本下发 (8300)</option>
<option value="general">通用指令 (API)</option>
</select>
</div>
<div class="mb-3">
<label class="form-label small text-muted">请求参数 (JSON)</label>
<textarea v-model="commandForm.jsonBody" class="form-control font-monospace form-control-sm" rows="8"></textarea>
</div>
<button @click="sendCommand" class="btn btn-primary btn-sm w-100">
<i class="fas fa-terminal me-2"></i>发送指令
</button>
</div>
</div>
</div>
</div>
@@ -380,6 +401,11 @@
},
customJson: '{\n "deviceType": "sensor",\n "temp": 24.5\n}',
commandForm: {
apiType: 'location',
jsonBody: '{\n "phone": "09207455611"\n}'
},
// 实时数据存储
badges: {}, // Map: id -> badge data
counters: {}, // Map: id -> counter data
@@ -493,6 +519,75 @@
alert('JSON 格式错误');
}
},
updateJsonTemplate() {
const type = this.commandForm.apiType;
if (type === 'location') {
this.commandForm.jsonBody = '{\n "phone": "09207455611"\n}';
} else if (type === 'text') {
this.commandForm.jsonBody = '{\n "phone": "09207455611",\n "content": "#2014*SET*R:#",\n "flag": 1\n}';
} else if (type === 'general') {
this.commandForm.jsonBody = '{\n "imei": "09207455611",\n "cmd": "workmode",\n "params": ["2", "300"]\n}';
}
},
async sendCommand() {
let payload;
try {
payload = JSON.parse(this.commandForm.jsonBody);
} catch (e) {
alert('JSON 格式错误');
return;
}
try {
if (this.commandForm.apiType === 'location') {
// 调用 /api/v1/device/command/location
const formData = new FormData();
if (!payload.phone) { alert('缺少 phone 字段'); return; }
formData.append('phone', payload.phone);
const res = await fetch('/api/v1/device/command/location', {
method: 'POST',
body: formData
});
const result = await res.json();
if (result.code === 200) alert('指令已发送');
else alert('失败: ' + result.message);
} else if (this.commandForm.apiType === 'text') {
// 调用 /api/v1/device/command/text
const formData = new FormData();
if (!payload.phone || !payload.content) { alert('缺少 phone 或 content 字段'); return; }
formData.append('phone', payload.phone);
formData.append('content', payload.content);
formData.append('flag', payload.flag || 1);
const res = await fetch('/api/v1/device/command/text', {
method: 'POST',
body: formData
});
const result = await res.json();
if (result.code === 200) alert('指令已发送');
else alert('失败: ' + result.message);
} else {
// 通用指令接口 /api/v1/device/command/send
if (!payload.imei || !payload.cmd) { alert('缺少 imei 或 cmd 字段'); return; }
const res = await fetch('/api/v1/device/command/send', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(payload)
});
const result = await res.json();
if (result.code === 200) alert('指令已发送');
else alert('失败: ' + result.message);
}
} catch (e) {
alert('发送异常: ' + e.message);
}
},
// 辅助函数
formatTime(date) {