diff --git a/src/main/java/com/iot/transport/jt808/common/Consts.java b/src/main/java/com/iot/transport/jt808/common/Consts.java index 1de3d43..4a3493e 100644 --- a/src/main/java/com/iot/transport/jt808/common/Consts.java +++ b/src/main/java/com/iot/transport/jt808/common/Consts.java @@ -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; } diff --git a/src/main/java/com/iot/transport/jt808/controller/DeviceController.java b/src/main/java/com/iot/transport/jt808/controller/DeviceController.java index 6fc3bb2..343234c 100644 --- a/src/main/java/com/iot/transport/jt808/controller/DeviceController.java +++ b/src/main/java/com/iot/transport/jt808/controller/DeviceController.java @@ -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 sendCommand(@RequestBody Map payload) { + String imei = (String) payload.get("imei"); + String cmdType = (String) payload.get("cmd"); + List params = (List) 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 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 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) diff --git a/src/main/java/com/iot/transport/jt808/entity/request/LocationInquiryPack.java b/src/main/java/com/iot/transport/jt808/entity/request/LocationInquiryPack.java new file mode 100644 index 0000000..d202bff --- /dev/null +++ b/src/main/java/com/iot/transport/jt808/entity/request/LocationInquiryPack.java @@ -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() { + } +} + diff --git a/src/main/java/com/iot/transport/jt808/entity/request/TextInfoDownPack.java b/src/main/java/com/iot/transport/jt808/entity/request/TextInfoDownPack.java new file mode 100644 index 0000000..61a86ea --- /dev/null +++ b/src/main/java/com/iot/transport/jt808/entity/request/TextInfoDownPack.java @@ -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/09:TTS文本播报下发)" + * 似乎这里的 "属性" 就是标志位。 + * 示例中 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; + } +} + diff --git a/src/main/java/com/iot/transport/jt808/server/SessionManager.java b/src/main/java/com/iot/transport/jt808/server/SessionManager.java index 93d3162..c438630 100644 --- a/src/main/java/com/iot/transport/jt808/server/SessionManager.java +++ b/src/main/java/com/iot/transport/jt808/server/SessionManager.java @@ -105,7 +105,7 @@ public class SessionManager { return sessionIdMap.entrySet(); } - public List toList() { + public List getValues() { return this.sessionIdMap.entrySet().stream().map(e -> e.getValue()).collect(Collectors.toList()); } diff --git a/src/main/java/com/iot/transport/jt808/service/codec/DataEncoder.java b/src/main/java/com/iot/transport/jt808/service/codec/DataEncoder.java index 1fa8f08..2d125c4 100644 --- a/src/main/java/com/iot/transport/jt808/service/codec/DataEncoder.java +++ b/src/main/java/com/iot/transport/jt808/service/codec/DataEncoder.java @@ -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); } } + diff --git a/src/main/java/com/iot/transport/jt808/util/CommandBuilder.java b/src/main/java/com/iot/transport/jt808/util/CommandBuilder.java new file mode 100644 index 0000000..4f5cfeb --- /dev/null +++ b/src/main/java/com/iot/transport/jt808/util/CommandBuilder.java @@ -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); + } +} diff --git a/src/main/resources/static/index.html b/src/main/resources/static/index.html index b9c5cee..fc58a19 100644 --- a/src/main/resources/static/index.html +++ b/src/main/resources/static/index.html @@ -143,6 +143,9 @@ +
@@ -195,6 +198,24 @@
+ +
+
+ + +
+
+ + +
+ +
@@ -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) { diff --git a/target/classes/static/index.html b/target/classes/static/index.html index b9c5cee..fc58a19 100644 --- a/target/classes/static/index.html +++ b/target/classes/static/index.html @@ -143,6 +143,9 @@ +
@@ -195,6 +198,24 @@
+ +
+
+ + +
+
+ + +
+ +
@@ -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) {