|
|
@@ -0,0 +1,474 @@
|
|
|
+package com.usky.ems.protocol;
|
|
|
+
|
|
|
+import com.baomidou.mybatisplus.core.toolkit.StringUtils;
|
|
|
+import com.baomidou.mybatisplus.core.toolkit.Wrappers;
|
|
|
+import com.fasterxml.jackson.databind.ObjectMapper;
|
|
|
+import com.usky.ems.util.AesUtil;
|
|
|
+import org.dom4j.Document;
|
|
|
+import org.dom4j.DocumentException;
|
|
|
+import org.dom4j.DocumentHelper;
|
|
|
+import org.dom4j.Element;
|
|
|
+import org.slf4j.Logger;
|
|
|
+import org.slf4j.LoggerFactory;
|
|
|
+
|
|
|
+import java.io.IOException;
|
|
|
+import java.io.InputStream;
|
|
|
+import java.io.OutputStream;
|
|
|
+import java.net.Socket;
|
|
|
+import java.time.LocalDateTime;
|
|
|
+import java.time.ZoneOffset;
|
|
|
+import java.util.Objects;
|
|
|
+import java.util.concurrent.atomic.AtomicBoolean;
|
|
|
+
|
|
|
+/**
|
|
|
+ * TCP客户端
|
|
|
+ * 用于与能耗监管系统建立连接并发送数据
|
|
|
+ *
|
|
|
+ * @author system
|
|
|
+ * @since 2024-01-01
|
|
|
+ */
|
|
|
+public class TcpClient {
|
|
|
+
|
|
|
+ private static final Logger logger = LoggerFactory.getLogger(TcpClient.class);
|
|
|
+
|
|
|
+ private String host;
|
|
|
+ private int port;
|
|
|
+ private String authKey;
|
|
|
+ private Socket socket;
|
|
|
+ private InputStream inputStream;
|
|
|
+ private OutputStream outputStream;
|
|
|
+ private AtomicBoolean connected = new AtomicBoolean(false);
|
|
|
+ private AtomicBoolean authenticated = new AtomicBoolean(false);
|
|
|
+
|
|
|
+ public TcpClient(String host, int port, String authKey) {
|
|
|
+ this.host = host;
|
|
|
+ this.port = port;
|
|
|
+ this.authKey = authKey;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 建立TCP连接
|
|
|
+ *
|
|
|
+ * @return 是否连接成功
|
|
|
+ */
|
|
|
+ public boolean connect() {
|
|
|
+ try {
|
|
|
+ if (socket != null && !socket.isClosed()) {
|
|
|
+ socket.close();
|
|
|
+ }
|
|
|
+
|
|
|
+ socket = new Socket(host, port);
|
|
|
+ socket.setSoTimeout(30000); // 30秒超时
|
|
|
+ inputStream = socket.getInputStream();
|
|
|
+ outputStream = socket.getOutputStream();
|
|
|
+ connected.set(true);
|
|
|
+ authenticated.set(false);
|
|
|
+
|
|
|
+ logger.info("TCP连接成功: {}:{}", host, port);
|
|
|
+ return true;
|
|
|
+ } catch (IOException e) {
|
|
|
+ logger.error("TCP连接失败: {}:{}", host, port, e);
|
|
|
+ connected.set(false);
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 关闭连接
|
|
|
+ */
|
|
|
+ public void close() {
|
|
|
+ try {
|
|
|
+ if (inputStream != null) {
|
|
|
+ inputStream.close();
|
|
|
+ }
|
|
|
+ if (outputStream != null) {
|
|
|
+ outputStream.close();
|
|
|
+ }
|
|
|
+ if (socket != null && !socket.isClosed()) {
|
|
|
+ socket.close();
|
|
|
+ }
|
|
|
+ connected.set(false);
|
|
|
+ authenticated.set(false);
|
|
|
+ logger.info("TCP连接已关闭");
|
|
|
+ } catch (IOException e) {
|
|
|
+ logger.error("关闭TCP连接失败", e);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 身份认证
|
|
|
+ *
|
|
|
+ * @param buildingId 建筑ID
|
|
|
+ * @param gatewayId 网关ID
|
|
|
+ * @return 是否认证成功
|
|
|
+ */
|
|
|
+ public boolean authenticate(String buildingId, String gatewayId) {
|
|
|
+ if (!connected.get()) {
|
|
|
+ logger.error("未建立TCP连接,无法进行身份认证");
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+
|
|
|
+ try {
|
|
|
+ // 1. 发送身份认证请求
|
|
|
+ String requestXml = com.usky.ems.util.XmlBuilder.buildAuthRequest(buildingId, gatewayId);
|
|
|
+ sendPacket(NetworkPacket.TYPE_AUTH, requestXml, false);
|
|
|
+
|
|
|
+ // 2. 接收服务端返回的随机序列
|
|
|
+ NetworkPacket response = receivePacket();
|
|
|
+ if (response == null || response.getType() != NetworkPacket.TYPE_AUTH) {
|
|
|
+ logger.error("接收身份认证响应失败");
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 认证响应不加密
|
|
|
+ String responseXml = new String(response.getData(), java.nio.charset.StandardCharsets.UTF_8);
|
|
|
+ String sequence = parseSequenceFromXml(responseXml);
|
|
|
+ if (sequence == null || sequence.isEmpty()) {
|
|
|
+ logger.error("解析随机序列失败");
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 3. 计算MD5:密钥 + 随机序列
|
|
|
+ String md5 = com.usky.ems.util.Md5Util.md5(authKey + sequence);
|
|
|
+
|
|
|
+ // 4. 发送MD5值
|
|
|
+ String md5Xml = com.usky.ems.util.XmlBuilder.buildAuthMd5Request(buildingId, gatewayId, md5);
|
|
|
+ sendPacket(NetworkPacket.TYPE_AUTH, md5Xml, false);
|
|
|
+
|
|
|
+ // 5. 接收认证结果
|
|
|
+ NetworkPacket result = receivePacket();
|
|
|
+ if (result == null || result.getType() != NetworkPacket.TYPE_AUTH) {
|
|
|
+ logger.error("接收认证结果失败");
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 认证响应不加密
|
|
|
+ String resultXml = new String(result.getData(), java.nio.charset.StandardCharsets.UTF_8);
|
|
|
+ boolean success = parseAuthResult(resultXml);
|
|
|
+
|
|
|
+ if (success) {
|
|
|
+ authenticated.set(true);
|
|
|
+ logger.info("身份认证成功");
|
|
|
+ } else {
|
|
|
+ logger.error("身份认证失败");
|
|
|
+ }
|
|
|
+
|
|
|
+ return success;
|
|
|
+ } catch (Exception e) {
|
|
|
+ logger.error("身份认证过程异常", e);
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 发送心跳
|
|
|
+ *
|
|
|
+ * @param buildingId 建筑ID
|
|
|
+ * @param gatewayId 网关ID
|
|
|
+ * @return 是否发送成功
|
|
|
+ */
|
|
|
+ public boolean sendHeartbeat(String buildingId, String gatewayId) {
|
|
|
+ if (!connected.get() || !authenticated.get()) {
|
|
|
+ logger.warn("连接未建立或未认证,无法发送心跳");
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+
|
|
|
+ try {
|
|
|
+ String heartbeatXml = com.usky.ems.util.XmlBuilder.buildHeartbeatRequest(buildingId, gatewayId);
|
|
|
+ sendPacket(NetworkPacket.TYPE_HEARTBEAT, heartbeatXml, false);
|
|
|
+
|
|
|
+ NetworkPacket response1 = receivePacket();
|
|
|
+ if (response1 != null && response1.getType() == NetworkPacket.TYPE_HEARTBEAT) {
|
|
|
+ // 直接转换为可读XML(无需解密)
|
|
|
+ String heartbeatResponseXml = new String(response1.getData(), java.nio.charset.StandardCharsets.UTF_8);
|
|
|
+ System.out.println("可读报文:\n" + heartbeatResponseXml);
|
|
|
+ boolean success = this.parseHeartbeatResult(heartbeatResponseXml);
|
|
|
+ if (success) {
|
|
|
+ logger.info("发送心跳成功");
|
|
|
+ return true;
|
|
|
+ }else{
|
|
|
+ logger.info("发送心跳失败");
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return false;
|
|
|
+ } catch (Exception e) {
|
|
|
+ logger.error("发送心跳失败", e);
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 发送能耗数据
|
|
|
+ *
|
|
|
+ * @param xmlData XML数据(未加密)
|
|
|
+ * @return 是否发送成功
|
|
|
+ */
|
|
|
+ public boolean sendEnergyData(String xmlData) {
|
|
|
+ if (!connected.get() || !authenticated.get()) {
|
|
|
+ logger.warn("连接未建立或未认证,无法发送能耗数据");
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+
|
|
|
+ try {
|
|
|
+ sendPacket(NetworkPacket.TYPE_ENERGY_DATA, xmlData, true);
|
|
|
+
|
|
|
+ NetworkPacket response = receivePacket();
|
|
|
+ if (response != null && response.getType() == NetworkPacket.TYPE_ENERGY_DATA) {
|
|
|
+ // 直接转换为可读XML(无需解密)
|
|
|
+ String readable = new String(response.getData(), java.nio.charset.StandardCharsets.UTF_8);
|
|
|
+ System.out.println("可读报文:\n" + readable);
|
|
|
+ boolean success = this.parseEnergyDataResult(readable);
|
|
|
+ if (success) {
|
|
|
+ logger.info("能耗数据发送成功");
|
|
|
+ return true;
|
|
|
+ }else{
|
|
|
+ logger.info("能耗数据发送失败");
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return false;
|
|
|
+
|
|
|
+ } catch (Exception e) {
|
|
|
+ logger.error("发送能耗数据失败", e);
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 将TCP报文字节数组直接转换为可读格式(无需解密)
|
|
|
+ * @param dataBytes 原始报文字节数组
|
|
|
+ * @param charset 报文字符编码(默认UTF-8)
|
|
|
+ * @param format 报文格式(xml/json/plain,plain为普通文本)
|
|
|
+ * @return 可读的报文字符串
|
|
|
+ */
|
|
|
+ public String convertToReadablePacket(byte[] dataBytes, String charset, String format) {
|
|
|
+ if (dataBytes == null || dataBytes.length == 0) {
|
|
|
+ return "空报文";
|
|
|
+ }
|
|
|
+
|
|
|
+ // 兜底处理字符编码,默认用UTF-8
|
|
|
+ String actualCharset = (charset == null || charset.isEmpty()) ? "UTF-8" : charset;
|
|
|
+
|
|
|
+ try {
|
|
|
+ // 第一步:直接将字节数组转字符串(核心,无需解密)
|
|
|
+ String rawText = new String(dataBytes, actualCharset);
|
|
|
+ logger.debug("原始文本报文:{}", rawText);
|
|
|
+
|
|
|
+ // 第二步:根据格式美化
|
|
|
+ switch (format.toLowerCase()) {
|
|
|
+ case "xml":
|
|
|
+ return formatXml(rawText); // XML格式化(带缩进)
|
|
|
+ case "json":
|
|
|
+ return formatJson(rawText); // JSON格式化(带缩进)
|
|
|
+ case "plain":
|
|
|
+ default:
|
|
|
+ return rawText; // 普通文本直接返回
|
|
|
+ }
|
|
|
+ } catch (Exception e) {
|
|
|
+ logger.warn("转换为文本格式失败,返回十六进制格式:{}", e.getMessage());
|
|
|
+ // 转换失败时,返回十六进制(所有二进制数据都能读)
|
|
|
+ return bytesToHex(dataBytes);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * XML格式化(美化缩进)
|
|
|
+ */
|
|
|
+ private String formatXml(String xmlText) throws DocumentException {
|
|
|
+ Document document = DocumentHelper.parseText(xmlText);
|
|
|
+ return document.asXML();
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * JSON格式化(美化缩进)
|
|
|
+ */
|
|
|
+ private String formatJson(String jsonText) throws Exception {
|
|
|
+ ObjectMapper mapper = new ObjectMapper();
|
|
|
+ Object jsonObj = mapper.readValue(jsonText, Object.class);
|
|
|
+ return mapper.writerWithDefaultPrettyPrinter().writeValueAsString(jsonObj);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 字节数组转十六进制字符串(兜底方案)
|
|
|
+ * 格式:00 1A 2B ... (便于排查二进制数据问题)
|
|
|
+ */
|
|
|
+ private String bytesToHex(byte[] bytes) {
|
|
|
+ StringBuilder sb = new StringBuilder();
|
|
|
+ for (byte b : bytes) {
|
|
|
+ sb.append(String.format("%02X ", b));
|
|
|
+ }
|
|
|
+ return "十六进制格式:" + sb.toString().trim();
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 发送数据包
|
|
|
+ *
|
|
|
+ * @param type 消息类型
|
|
|
+ * @param xmlData XML数据
|
|
|
+ * @param encrypt 是否加密
|
|
|
+ */
|
|
|
+ private void sendPacket(byte type, String xmlData, boolean encrypt) throws IOException {
|
|
|
+ byte[] data;
|
|
|
+ if (encrypt) {
|
|
|
+ // 能耗数据需要AES加密
|
|
|
+ try {
|
|
|
+ data = AesUtil.encryptEnergyXml(xmlData, authKey);
|
|
|
+ } catch (Exception e) {
|
|
|
+ throw new IOException("加密能耗数据失败", e);
|
|
|
+ }
|
|
|
+ } else {
|
|
|
+ // 身份认证和心跳不加密
|
|
|
+ data = xmlData.getBytes(java.nio.charset.StandardCharsets.UTF_8);
|
|
|
+ }
|
|
|
+
|
|
|
+ NetworkPacket packet = new NetworkPacket();
|
|
|
+ packet.setType(type);
|
|
|
+ packet.setData(data);
|
|
|
+
|
|
|
+ byte[] packetBytes = packet.encode();
|
|
|
+ outputStream.write(packetBytes);
|
|
|
+ outputStream.flush();
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 接收数据包
|
|
|
+ *
|
|
|
+ * @return 接收到的数据包
|
|
|
+ */
|
|
|
+ private NetworkPacket receivePacket() throws IOException {
|
|
|
+ // 先读取7字节(Head + Type + Length)
|
|
|
+ byte[] header = new byte[7];
|
|
|
+ int bytesRead = 0;
|
|
|
+ while (bytesRead < 7) {
|
|
|
+ int n = inputStream.read(header, bytesRead, 7 - bytesRead);
|
|
|
+ if (n == -1) {
|
|
|
+ throw new IOException("连接已关闭");
|
|
|
+ }
|
|
|
+ bytesRead += n;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 解析Length
|
|
|
+ int length = ((header[3] & 0xFF) << 24) |
|
|
|
+ ((header[4] & 0xFF) << 16) |
|
|
|
+ ((header[5] & 0xFF) << 8) |
|
|
|
+ (header[6] & 0xFF);
|
|
|
+
|
|
|
+ // 读取Data
|
|
|
+ byte[] data = new byte[length];
|
|
|
+ bytesRead = 0;
|
|
|
+ while (bytesRead < length) {
|
|
|
+ int n = inputStream.read(data, bytesRead, length - bytesRead);
|
|
|
+ if (n == -1) {
|
|
|
+ throw new IOException("连接已关闭");
|
|
|
+ }
|
|
|
+ bytesRead += n;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 组装完整数据包
|
|
|
+ byte[] packetBytes = new byte[7 + length];
|
|
|
+ System.arraycopy(header, 0, packetBytes, 0, 7);
|
|
|
+ System.arraycopy(data, 0, packetBytes, 7, length);
|
|
|
+
|
|
|
+ return NetworkPacket.decode(packetBytes);
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 从XML中解析随机序列
|
|
|
+ */
|
|
|
+ private String parseSequenceFromXml(String xml) {
|
|
|
+ try {
|
|
|
+ Document document = DocumentHelper.parseText(xml);
|
|
|
+ Element root = document.getRootElement();
|
|
|
+ Element idValidate = root.element("id_validate");
|
|
|
+ if (idValidate != null) {
|
|
|
+ Element sequence = idValidate.element("sequence");
|
|
|
+ if (sequence != null) {
|
|
|
+ return sequence.getTextTrim();
|
|
|
+ }
|
|
|
+ }
|
|
|
+ } catch (Exception e) {
|
|
|
+ logger.error("解析随机序列失败", e);
|
|
|
+ }
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 从XML中解析认证结果
|
|
|
+ */
|
|
|
+ private boolean parseAuthResult(String xml) {
|
|
|
+ try {
|
|
|
+ Document document = DocumentHelper.parseText(xml);
|
|
|
+ Element root = document.getRootElement();
|
|
|
+ Element idValidate = root.element("id_validate");
|
|
|
+ if (idValidate != null && "result".equals(idValidate.attributeValue("operation"))) {
|
|
|
+ Element result = idValidate.element("result");
|
|
|
+ if (result != null) {
|
|
|
+ return "pass".equals(result.getTextTrim());
|
|
|
+ }
|
|
|
+ }
|
|
|
+ } catch (Exception e) {
|
|
|
+ logger.error("解析认证结果失败", e);
|
|
|
+ }
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 从XML中解析推送能耗数据响应结果
|
|
|
+ */
|
|
|
+ private boolean parseEnergyDataResult(String xml) {
|
|
|
+ try {
|
|
|
+ Document document = DocumentHelper.parseText(xml);
|
|
|
+ Element root = document.getRootElement();
|
|
|
+ Element dataElement = root.element("data");
|
|
|
+ if (dataElement == null) {
|
|
|
+ logger.warn("XML中未找到<data>节点");
|
|
|
+ }
|
|
|
+
|
|
|
+ // 4. 获取<ack>子节点的文本值
|
|
|
+ Element ackElement = dataElement.element("ack");
|
|
|
+ if (ackElement == null) {
|
|
|
+ logger.warn("XML中未找到<ack>节点");
|
|
|
+ }
|
|
|
+
|
|
|
+ return "OK".equals(ackElement.getTextTrim());
|
|
|
+ } catch (Exception e) {
|
|
|
+ logger.error("解析认证结果失败", e);
|
|
|
+ }
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 从XML中解析推送能耗数据响应结果
|
|
|
+ */
|
|
|
+ private boolean parseHeartbeatResult(String xml) {
|
|
|
+ try {
|
|
|
+ Document document = DocumentHelper.parseText(xml);
|
|
|
+ Element root = document.getRootElement();
|
|
|
+ Element dataElement = root.element("heart_beat");
|
|
|
+ if (dataElement == null) {
|
|
|
+ logger.warn("XML中未找到<heart_beat>节点");
|
|
|
+ }
|
|
|
+
|
|
|
+ // 4. 获取<ack>子节点的文本值
|
|
|
+ Element ackElement = dataElement.element("time");
|
|
|
+ if (ackElement == null) {
|
|
|
+ logger.warn("XML中未找到<time>节点");
|
|
|
+ }
|
|
|
+
|
|
|
+ return StringUtils.isNotBlank(ackElement.getTextTrim());
|
|
|
+ } catch (Exception e) {
|
|
|
+ logger.error("解析认证结果失败", e);
|
|
|
+ }
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+
|
|
|
+ public boolean isConnected() {
|
|
|
+ return connected.get() && socket != null && !socket.isClosed();
|
|
|
+ }
|
|
|
+
|
|
|
+ public boolean isAuthenticated() {
|
|
|
+ return authenticated.get();
|
|
|
+ }
|
|
|
+}
|