20260322:手动下发指令保存Device表

This commit is contained in:
George 2026-03-22 11:12:16 +08:00
parent df0c9890e8
commit 629d361ca8
6 changed files with 347 additions and 108 deletions

View File

@ -18,9 +18,9 @@ spring:
master:
type: com.alibaba.druid.pool.DruidDataSource
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://47.121.180.229:3306/fastbee?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8
url: jdbc:mysql://localhost:3306/fastbee?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8
username: root
password: Yan@ancekong-mysql
password: fastbee
druid:
filters: stat,wall
stat:
@ -56,7 +56,7 @@ spring:
# ds0: # 配置 mysql 数据源
# type: com.alibaba.druid.pool.DruidDataSource
# driver-class-name: com.mysql.cj.jdbc.Driver
# url: jdbc:mysql://47.121.180.229:3306/fastbee?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8
# url: jdbc:mysql://localhost:3306/fastbee?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8
# username: root
# password: fastbee
# filters: stat,wall
@ -100,7 +100,7 @@ spring:
# redis 配置
redis:
host: 47.121.180.229 # 地址
host: localhost # 地址
port: 6379 # 端口,默认为6379
database: 0 # 数据库索引
password: fastbee # 密码
@ -115,7 +115,7 @@ spring:
mqtt:
username: fastbee # 账号
password: fastbee # 密码
host-url: tcp://47.121.180.229:1883 # mqtt连接tcp地址
host-url: tcp://localhost:1883 # mqtt连接tcp地址
client-id: ${random.int} # 客户端Id,不能相同,采用随机数 ${random.value}
default-topic: test # 默认主题
timeout: 30 # 超时时间
@ -123,7 +123,7 @@ spring:
clearSession: true # 清除会话(设置为false,断开连接,重连后使用原来的会话 保留订阅的主题,能接收离线期间的消息)
# EMQX API配置需要在运行emqx之后去EMQX管理后台手动创建API Key和Secret,并配置到application.yml中
emqx:
host: 47.121.180.229 # EMQX服务器地址
host: localhost # EMQX服务器地址
port: 18083
ApiKey: emqx.key.123 # EMQX API Key
ApiSecret: emqx.api.123

View File

@ -31,7 +31,7 @@ spring:
wall:
none-base-statement-allow: true
taos: # 配置 taos 数据源
enabled: true
enabled: false
type: com.alibaba.druid.pool.DruidDataSource
driver-class-name: com.taosdata.jdbc.TSDBDriver
url: jdbc:TAOS://fastbee:6030/fastbee_log?timezone=UTC-8&charset=utf-8
@ -50,7 +50,7 @@ spring:
host: 47.121.180.229 # 地址
port: 6379 # 端口,默认为6379
database: 0 # 数据库索引
password: Yan@ancekong-redis # 密码
password: fastbee # 密码
timeout: 10s # 连接超时时间
lettuce:
pool:

View File

@ -0,0 +1,306 @@
package com.fastbee.common.utils.dtu;
import java.nio.charset.StandardCharsets;
import java.util.HashMap;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* AT指令响应解析工具类
* 用于解析AT指令返回的数据,提取key-value信息
*/
/**
* @description: AT指令解析工具类
* 用于解析AT指令返回的数据,提取key-value信息
* @author Sumuxi
* @date 2026/3/22
*/
public class ATCommandParserUtils {
/**
* 从十六进制字符串解析AT响应并提取键值对
*
* @param hexString 十六进制字符串,如 "0a 2b 49 4d 45 49 3a 22 38 36..."
* @return 解析结果Map,key为字段名,value为字段值
*/
public static Map<String, String> parseATResponseFromHex(String hexString) {
String cleanHex = hexString.replaceAll("\\s+", "");
byte[] data = hexStringToBytes(cleanHex);
String response = new String(data, StandardCharsets.UTF_8);
return parseATResponse(response);
}
/**
* 从AT响应字符串解析键值对
* 支持格式:
* - +KEY: "value"
* - +KEY: value
* - +KEY="value"
* - KEY: "value"
*
* @param response AT响应字符串
* @return 解析结果Map
*/
public static Map<String, String> parseATResponse(String response) {
Map<String, String> result = new HashMap<>();
response = response.trim();
String[] lines = response.split("\\r?\\n");
for (String line : lines) {
line = line.trim();
if (line.isEmpty() || line.equals("OK") || line.equals("ERROR")) {
continue;
}
// 支持冒号或等号分隔,支持带引号或不带引号的值
Pattern pattern = Pattern.compile("^\\+?([A-Z_]+)[:=]\\s*(?:\"([^\"]*)\"|([^\\r\\n]*))$");
Matcher matcher = pattern.matcher(line);
if (matcher.find()) {
String key = matcher.group(1);
String value = matcher.group(2);
if (value == null) {
value = matcher.group(3);
}
if (value != null) {
value = value.trim();
}
result.put(key, value);
}
}
return result;
}
/**
* 解析IMEI响应
* 示例输入: 0a 2b 49 4d 45 49 3a 22 38 36 31 38 37 37 30 37 38 32 35 32 35 33 32 22 0a 0a 4f 4b 0a
* 对应文本: +IMEI:"861877078252532"\n\nOK\n
*
* @param hexString 十六进制字符串
* @return IMEI号,解析失败返回null
*/
public static String parseIMEI(String hexString) {
Map<String, String> result = parseATResponseFromHex(hexString);
return result.get("IMEI");
}
/**
* 解析ICCID响应
* 示例输入: 0d0a2b49434349443a223839383630383538313132354432373832343033220d0a4f4b0d0a
* 对应文本: \r\n+ICCID:"898608581125D2782403"\r\nOK\r\n
*
* @param hexString 十六进制字符串
* @return ICCID号,解析失败返回null
*/
public static String parseICCID(String hexString) {
Map<String, String> result = parseATResponseFromHex(hexString);
return result.get("ICCID");
}
/**
* 解析CSQ信号强度响应
* 示例输入: 2b4353513a32332c39390d0a4f4b0d0a
* 对应文本: +CSQ:23,99\r\nOK\r\n
*
* @param hexString 十六进制字符串
* @return 信号强度值,如23,解析失败返回null
*/
public static Integer parseCSQ(String hexString) {
Map<String, String> result = parseATResponseFromHex(hexString);
String csqValue = result.get("CSQ");
if (csqValue != null && csqValue.contains(",")) {
String[] parts = csqValue.split(",");
try {
return Integer.parseInt(parts[0]);
} catch (NumberFormatException e) {
return null;
}
}
return null;
}
/**
* 解析LBS基站定位响应
* 示例输入: 0d0a2b4c42533d2232382e3132353833382c3131322e383632363339220d0a4f4b0d0a
* 对应文本: \r\n+LBS="28.125838,112.862639"\r\nOK\r\n
*
* @param hexString 十六进制字符串
* @return 经纬度数组 [经度, 纬度],解析失败返回null
*/
public static double[] parseLBS(String hexString) {
Map<String, String> result = parseATResponseFromHex(hexString);
String lbsValue = result.get("LBS");
if (lbsValue != null && lbsValue.contains(",")) {
String[] parts = lbsValue.split(",");
try {
// 注意:实际顺序可能是 [纬度,经度] 或 [经度,纬度],根据文档通常为经度,纬度
double latitude = Double.parseDouble(parts[0]);
double longitude = Double.parseDouble(parts[1]);
return new double[]{latitude, longitude};
} catch (NumberFormatException e) {
return null;
}
}
return null;
}
/**
* 解析LBS响应,返回经纬度字符串
*
* @param hexString 十六进制字符串
* @return 经纬度字符串,如 "28.125838,112.862639"
*/
public static String parseLBSString(String hexString) {
Map<String, String> result = parseATResponseFromHex(hexString);
return result.get("LBS");
}
/**
* 解析SIM卡信息响应
*
* @param hexString 十六进制字符串
* @return SIM卡信息Map
*/
public static Map<String, String> parseSIMInfo(String hexString) {
Map<String, String> result = new HashMap<>();
String cleanHex = hexString.replaceAll("\\s+", "");
byte[] data = hexStringToBytes(cleanHex);
String response = new String(data, StandardCharsets.UTF_8);
String[] lines = response.split("\\r?\\n");
for (String line : lines) {
line = line.trim();
if (line.isEmpty() || line.equals("OK")) {
continue;
}
// 格式: field: value
Pattern pattern = Pattern.compile("^([a-z_]+):\\s*(.+)$");
Matcher matcher = pattern.matcher(line);
if (matcher.find()) {
result.put(matcher.group(1), matcher.group(2));
}
}
return result;
}
/**
* 解析UTC时间响应
* 示例输入: 2b5554433a2231373534343633353332220d0a4f4b0d0a
* 对应文本: +UTC:"1754463532"\r\nOK\r\n
*
* @param hexString 十六进制字符串
* @return UTC时间戳(秒),解析失败返回null
*/
public static Long parseUTC(String hexString) {
Map<String, String> result = parseATResponseFromHex(hexString);
String utcValue = result.get("UTC");
if (utcValue != null) {
try {
return Long.parseLong(utcValue);
} catch (NumberFormatException e) {
return null;
}
}
return null;
}
/**
* 解析时间响应
* 示例输入: 2b54494d453a22323032352d30382d30362031343a35393a3436220d0a4f4b0d0a
* 对应文本: +TIME:"2025-08-06 14:59:46"\r\nOK\r\n
*
* @param hexString 十六进制字符串
* @return 时间字符串,如 "2025-08-06 14:59:46"
*/
public static String parseTime(String hexString) {
Map<String, String> result = parseATResponseFromHex(hexString);
return result.get("TIME");
}
/**
* 解析网络注册状态响应
* 示例输入: 2b43455245473a310d0a4f4b0d0a
* 对应文本: +CEREG:1\r\nOK\r\n
*
* @param hexString 十六进制字符串
* @return 注册状态码 (0-5),解析失败返回null
*/
public static Integer parseCEREG(String hexString) {
Map<String, String> result = parseATResponseFromHex(hexString);
String ceregValue = result.get("CEREG");
if (ceregValue != null) {
try {
return Integer.parseInt(ceregValue);
} catch (NumberFormatException e) {
return null;
}
}
return null;
}
/**
* 十六进制字符串转字节数组
*
* @param hex 十六进制字符串(不含空格)
* @return 字节数组
*/
private static byte[] hexStringToBytes(String hex) {
if (hex == null || hex.isEmpty()) {
return new byte[0];
}
int len = hex.length();
if (len % 2 != 0) {
throw new IllegalArgumentException("十六进制字符串长度必须为偶数");
}
byte[] data = new byte[len / 2];
for (int i = 0; i < len; i += 2) {
data[i / 2] = (byte) ((Character.digit(hex.charAt(i), 16) << 4)
+ Character.digit(hex.charAt(i + 1), 16));
}
return data;
}
/**
* 通用解析方法,返回完整的响应文本
*
* @param hexString 十六进制字符串
* @return 解码后的文本响应
*/
public static String decodeHexResponse(String hexString) {
String cleanHex = hexString.replaceAll("\\s+", "");
byte[] data = hexStringToBytes(cleanHex);
return new String(data, StandardCharsets.UTF_8);
}
// 测试方法
public static void main(String[] args) {
// 测试IMEI解析
String imeiHex = "0d0a2b494d45493a22383631383737303738323532353332220d0a0d0a4f4b0d0a";
System.out.println("IMEI解析结果: " + parseIMEI(imeiHex));
// 测试ICCID解析
String iccidHex = "0d0a2b49434349443a223839383630383538313132354432373832343033220d0a4f4b0d0a";
System.out.println("ICCID解析结果: " + parseICCID(iccidHex));
// 测试LBS解析(修正后应能正常解析)
String lbsHex = "0d0a2b4c42533d2232382e3132353833382c3131322e383632363339220d0a4f4b0d0a";
System.out.println("LBS解析结果: " + parseLBSString(lbsHex));
double[] lbs = parseLBS(lbsHex);
if (lbs != null) {
System.out.println("纬度: " + lbs[0] + ", 经度: " + lbs[1]);
}
// 测试通用解析
String response = decodeHexResponse(imeiHex);
System.out.println("\n解码后的响应: " + response);
// 测试解析所有字段
Map<String, String> allFields = parseATResponseFromHex(imeiHex);
System.out.println("解析结果: " + allFields);
}
}

View File

@ -6,18 +6,20 @@ import com.fastbee.common.core.device.DeviceAndProtocol;
import com.fastbee.common.core.mq.DeviceReportBo;
import com.fastbee.common.core.mq.message.DeviceData;
import com.fastbee.common.core.mq.message.DeviceMessage;
import com.fastbee.common.core.redis.RedisCache;
import com.fastbee.common.core.thingsModel.ThingsModelSimpleItem;
import com.fastbee.common.enums.DeviceLogTypeEnum;
import com.fastbee.common.enums.TopicType;
import com.fastbee.common.utils.DateUtils;
import com.fastbee.common.utils.StringUtils;
import com.fastbee.common.utils.dtu.ATCommandParserUtils;
import com.fastbee.common.utils.gateway.mq.TopicsUtils;
import com.fastbee.iot.domain.*;
import com.fastbee.iot.domain.Device;
import com.fastbee.iot.domain.Product;
import com.fastbee.iot.domain.ProductAuthorize;
import com.fastbee.iot.mapper.DeviceMapper;
import com.fastbee.iot.service.IDeviceService;
import com.fastbee.iot.service.IDeviceUserService;
import com.fastbee.iot.service.IProductAuthorizeService;
import com.fastbee.iot.service.IProductService;
import com.fastbee.iot.tdengine.service.ILogService;
import com.fastbee.mqtt.manager.MqttRemoteManager;
import com.fastbee.mqtt.model.PushMessageBo;
import io.netty.buffer.ByteBuf;
@ -31,10 +33,7 @@ import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import javax.annotation.Resource;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import java.util.Objects;
import java.util.*;
import java.util.function.Function;
/**
@ -57,9 +56,6 @@ import java.util.function.Function;
@Slf4j
public class DeviceDebugLogAspect {
@Resource
private ILogService logService;
@Resource
private IDeviceService deviceService;
@ -76,7 +72,7 @@ public class DeviceDebugLogAspect {
private TopicsUtils topicsUtils;
@Resource
private RedisCache redisCache;
private DeviceMapper deviceMapper;
/**
* 拦截数据解析方法
@ -273,9 +269,6 @@ public class DeviceDebugLogAspect {
String errorMsg = e.getMessage();
return StringUtils.isEmpty(errorMsg) ? e.getClass().getName() : errorMsg;
}
@Resource
private IDeviceUserService deviceUserService;
/**
* 检查设备是否已授权
@ -319,6 +312,22 @@ public class DeviceDebugLogAspect {
return;
}
// 跟新IMEI、ICCID
if (StringUtils.isEmpty(device.getImei()) || StringUtils.isEmpty(device.getIccid())) {
Map<String, String> atCommandMap = ATCommandParserUtils.parseATResponseFromHex(context.sourceData);
Device updateDevice = new Device();
if (StringUtils.isEmpty(device.getImei())) {
updateDevice.setImei(atCommandMap.get("IMEI"));
}
if (StringUtils.isEmpty(device.getIccid())) {
updateDevice.setIccid(atCommandMap.get("ICCID"));
}
updateDevice.setDeviceId(device.getDeviceId());
updateDevice.setUpdateBy("DTU");
updateDevice.setUpdateTime(DateUtils.getNowDate());
deviceMapper.updateDevice(updateDevice);
}
// 获取产品ID
Long productId = context.getProductId();
if (productId == null) {

View File

@ -7,19 +7,14 @@ import com.fastbee.common.core.mq.DeviceStatusBo;
import com.fastbee.common.core.mq.MQSendMessageBo;
import com.fastbee.common.core.mq.SubDeviceBo;
import com.fastbee.common.core.mq.message.ReportDataBo;
import com.fastbee.common.core.mq.message.DeviceMessage;
import com.fastbee.common.core.thingsModel.ThingsModelSimpleItem;
import com.fastbee.common.enums.DeviceStatus;
import com.fastbee.common.core.redis.RedisCache;
import com.fastbee.common.utils.StringUtils;
import com.fastbee.common.enums.TopicType;
import com.fastbee.iot.cache.IDeviceCache;
import com.fastbee.iot.data.service.IMqttMessagePublish;
import com.fastbee.iot.data.service.IRuleEngine;
import com.fastbee.iot.domain.Device;
import com.fastbee.iot.domain.Product;
import com.fastbee.iot.model.ThingsModels.ThingsModelShadow;
import com.fastbee.iot.data.service.IDeviceMessageService;
import com.fastbee.iot.service.IDeviceService;
import com.fastbee.iot.service.IProductService;
import com.fastbee.iot.service.ISubGatewayService;
@ -35,7 +30,6 @@ import org.springframework.util.CollectionUtils;
import javax.annotation.Resource;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.TimeUnit;
/**
* 设备状态消息处理类
@ -63,20 +57,8 @@ public class DeviceStatusConsumer {
private Boolean enabled;
@Resource
private ISubGatewayService subGatewayService;
@Resource
private IDeviceMessageService deviceMessageService;
@Resource
private RedisCache redisCache;
private SnowflakeIdWorker snowflakeIdWorker = new SnowflakeIdWorker(2);
private static final String IMEI_CMD = "41542b494d45495c525c6e"; // AT+IMEI\r\n
private static final String ICCID_CMD = "41542b49434349445c525c6e"; // AT+ICCID\r\n
private static final int AUTO_FETCH_COOLDOWN_SECONDS = 300;
private static final String AUTO_FETCH_KEY_PREFIX = "iot:autoFetch:imeiIccid:";
public synchronized void consume(DeviceStatusBo bo) {
try {
List<DeviceStatusBo> list = new ArrayList<>();
@ -108,10 +90,6 @@ public class DeviceStatusConsumer {
}
/*更新设备状态*/
deviceCache.updateDeviceStatusCache(statusBo, device);
// 设备首次上线,自动下发 AT 指令获取 IMEI/ICCID(字段为空才下发,并做简单限流)
if (status.equals(DeviceStatus.ONLINE)) {
autoFetchImeiIccidIfNeeded(device);
}
//处理影子模式值
this.handlerShadow(device, status);
//设备上下线执行规则引擎
@ -129,49 +107,6 @@ public class DeviceStatusConsumer {
}
}
private void autoFetchImeiIccidIfNeeded(Device device) {
if (device == null || StringUtils.isEmpty(device.getSerialNumber())) {
log.info("=>设备首次上线,设备为空{}", device);
return;
}
String serialNumber = device.getSerialNumber();
boolean needImei = StringUtils.isEmpty(device.getImei());
boolean needIccid = StringUtils.isEmpty(device.getIccid());
if (!needImei && !needIccid) {
log.info("=>设备首次上线,设备已存在IMEI/ICCID,无需获取{}", device);
return;
}
if (needImei) {
String key = AUTO_FETCH_KEY_PREFIX + serialNumber + ":imei";
if (!redisCache.containsKey(key)) {
redisCache.setCacheObject(key, "1", AUTO_FETCH_COOLDOWN_SECONDS, TimeUnit.SECONDS);
log.info("=>设备首次上线,下发AT指令获取IMEI,{}", device);
sendAtCommand(serialNumber, IMEI_CMD);
}
}
if (needIccid) {
String key = AUTO_FETCH_KEY_PREFIX + serialNumber + ":iccid";
if (!redisCache.containsKey(key)) {
redisCache.setCacheObject(key, "1", AUTO_FETCH_COOLDOWN_SECONDS, TimeUnit.SECONDS);
log.info("=>设备首次上线,下发AT指令获取ICCID,{}", device);
sendAtCommand(serialNumber, ICCID_CMD);
}
}
}
private void sendAtCommand(String serialNumber, String command) {
DeviceMessage msg = new DeviceMessage();
msg.setSerialNumber(serialNumber);
msg.setTopicName(TopicType.FUNCTION_GET.getTopicSuffix());
msg.setMessage(command);
msg.setDataType("ascii");
deviceMessageService.messagePost(msg);
log.info("自动下发 AT 指令获取信息, serialNumber={}, cmd={}", serialNumber, command);
}
private void handlerShadow(Device device, DeviceStatus status) {
//获取设备协议编码
DeviceAndProtocol dp = deviceService.selectProtocolBySerialNumber(device.getSerialNumber());

View File

@ -645,9 +645,9 @@ export default {
if (!this.deviceInfo || !this.deviceInfo.deviceId) return;
const res = await getDevice(this.deviceInfo.deviceId);
if (res && res.code === 200 && res.data) {
// 只刷新这两个字段,避免影响运行态数据
this.deviceInfo.imei = res.data.imei;
this.deviceInfo.iccid = res.data.iccid;
// 只刷新这两个字段,避免影响运行态数据/使用 $set 确保响应式更新
this.$set(this.deviceInfo, 'imei', res.data.imei);
this.$set(this.deviceInfo, 'iccid', res.data.iccid);
}
},
@ -669,7 +669,7 @@ export default {
const trimmed = String(cmd).trim();
const hexMessage = this.isHexLike(trimmed)
? this.normalizeHexString(trimmed)
: this.asciiCommandToHex(this.normalizeAtEscapes(trimmed));
: this.asciiCommandToHex(trimmed);
await messagePost({
message: hexMessage,
@ -679,9 +679,9 @@ export default {
});
this.$message({ type: 'success', message: '已下发指令,等待设备回复解析…' });
// 后端收到回复后会自动解析并写入数据库,这里做一次轻量轮询刷新
setTimeout(() => this.refreshDeviceBaseInfo(), 1200);
setTimeout(() => this.refreshDeviceBaseInfo(), 2500);
// 后端收到回复后会自动解析并写入数据库,这里做一次轻量轮询刷新3秒、5秒各一次
setTimeout(() => this.refreshDeviceBaseInfo(), 3000);
setTimeout(() => this.refreshDeviceBaseInfo(), 8000);
} catch (e) {
this.$message({ type: 'error', message: e && e.message ? e.message : '指令下发失败' });
} finally {
@ -690,17 +690,6 @@ export default {
}
},
normalizeAtEscapes(str) {
// 输入框里用户通常会键入:AT+IMEI\\r\\n
// 但你当前的设备/链路期望的不是“真实 CR/LF(0d0a)”,而是把转义串当作占位符:
// 把 \\r\\n 期望转成 "/r/n"(即字符 '/','r','/','n',十六进制为 2f 72 2f 6e)。
if (!str) return '';
return String(str)
.replace(/\\r\\n/g, '/r/n')
.replace(/\\r/g, '/r')
.replace(/\\n/g, '/n');
},
isHexLike(hexStr) {
if (!hexStr) return false;
const cleaned = this.normalizeHexString(hexStr);