Browse Source

提交圣昌9900端口TCP服务端

15044148858 6 ngày trước cách đây
mục cha
commit
49486f31fa

+ 2 - 0
src/main/java/com/sooka/sponest/SookaDigitalConstructionApplication.java

@@ -7,6 +7,7 @@ import org.springframework.boot.SpringApplication;
 import org.springframework.boot.autoconfigure.SpringBootApplication;
 import org.springframework.boot.web.servlet.MultipartConfigFactory;
 import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.ComponentScan;
 import org.springframework.scheduling.annotation.EnableAsync;
 import org.springframework.util.unit.DataSize;
 
@@ -17,6 +18,7 @@ import javax.servlet.MultipartConfigElement;
 @EnableCustomSwagger2
 @EnableRyFeignClients
 @SpringBootApplication
+@ComponentScan("com.sooka.sponest")
 public class SookaDigitalConstructionApplication {
     public static void main(String[] args) {
         SpringApplication.run(SookaDigitalConstructionApplication.class, args);

+ 5 - 1
src/main/java/com/sooka/sponest/construction/sentinel/service/impl/DeviceInformationServiceImpl.java

@@ -5,6 +5,7 @@ import com.ruoyi.common.core.web.domain.AjaxResult;
 import com.sooka.sponest.construction.sentinel.domain.DeviceInformation;
 import com.sooka.sponest.construction.sentinel.mapper.DeviceInformationMapper;
 import com.sooka.sponest.construction.sentinel.service.IDeviceInformationService;
+import com.sooka.sponest.construction.shengchang.ShengChangService;
 import com.sooka.sponest.remoteapi.service.middleground.RemoteMiddleGroundBaseService;
 import org.springframework.stereotype.Service;
 
@@ -25,10 +26,13 @@ public class DeviceInformationServiceImpl implements IDeviceInformationService {
     @Resource
     private RemoteMiddleGroundBaseService remoteMiddleGroundBaseService;
 
+    @Resource
+    private ShengChangService shengChangService;
+
     @Override
     public AjaxResult updataValveOpening(Long valveNumber, Integer valveOpening) {
         DeviceInformation information = deviceInformationMapper.selectDeviceInformationById(valveNumber);
-        AjaxResult result = remoteMiddleGroundBaseService.controlValve(information.getSimCardNumber(), valveOpening);
+        AjaxResult result = shengChangService.controlValve(information.getSimCardNumber(), valveOpening);
         System.out.println(result);
         if (result.isSuccess()) {
             information.setValveOpening(valveOpening);

+ 97 - 0
src/main/java/com/sooka/sponest/construction/shengchang/MbusResponse.java

@@ -0,0 +1,97 @@
+package com.sooka.sponest.construction.shengchang;
+
+import lombok.Data;
+
+import java.io.Serializable;
+import java.util.Date;
+
+/**
+ * MBUS响应数据实体
+ */
+@Data
+public class MbusResponse implements Serializable {
+
+    private static final long serialVersionUID = 1L;
+
+    /**
+     * 表号
+     */
+    private String meterNo;
+
+    /**
+     * 命令字
+     */
+    private Integer command;
+
+    /**
+     * 数据长度
+     */
+    private Integer dataLength;
+
+    /**
+     * 阀门开度(百分比 0-100)
+     */
+    private Integer valveOpening;
+
+    /**
+     * 阀门入口压力(KPa)
+     */
+    private Integer inletPressure;
+
+    /**
+     * 阀门出口压力(KPa)
+     */
+    private Integer outletPressure;
+
+    /**
+     * 供电电压(伏特)
+     */
+    private Double voltage;
+
+    /**
+     * 状态位
+     */
+    private Integer status;
+
+    /**
+     * 是否自动模式(true-自动,false-手动)
+     */
+    private Boolean autoMode;
+
+    /**
+     * 是否低电压
+     */
+    private Boolean lowVoltage;
+
+    /**
+     * 是否电流欠载
+     */
+    private Boolean currentUnderload;
+
+    /**
+     * 是否电流过载
+     */
+    private Boolean currentOverload;
+
+    /**
+     * 接收时间
+     */
+    private Date receiveTime = new Date();
+
+    @Override
+    public String toString() {
+        return "MbusResponse{" +
+                "meterNo='" + meterNo + '\'' +
+                ", command=" + command +
+                ", valveOpening=" + valveOpening + "%" +
+                ", inletPressure=" + inletPressure + "KPa" +
+                ", outletPressure=" + outletPressure + "KPa" +
+                ", voltage=" + voltage + "V" +
+                ", autoMode=" + autoMode +
+                ", lowVoltage=" + lowVoltage +
+                ", currentUnderload=" + currentUnderload +
+                ", currentOverload=" + currentOverload +
+                ", receiveTime=" + receiveTime +
+                '}';
+    }
+}

+ 55 - 0
src/main/java/com/sooka/sponest/construction/shengchang/ShengChangConfig.java

@@ -0,0 +1,55 @@
+package com.sooka.sponest.construction.shengchang;
+
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.context.annotation.Configuration;
+
+import javax.annotation.PostConstruct;
+import javax.annotation.Resource;
+
+/**
+ * 盛昌设备配置
+ * 硬编码设备映射关系(后期可改为从配置文件或数据库读取)
+ */
+@Slf4j
+@Configuration
+public class ShengChangConfig {
+
+    @Resource
+    private ShengChangDeviceManager deviceManager;
+
+    // ========================================
+    // 设备配置区域 - 在这里修改你的设备信息
+    // ========================================
+    
+    /**
+     * 设备1 - SIM卡号
+     */
+    private static final String DEVICE_1_SIM = "18646305937";
+    
+    /**
+     * 设备1 - 表号
+     */
+    private static final String DEVICE_1_METER_NO = "00000021080364";
+    
+    // 继续添加更多设备...
+    // private static final String DEVICE_3_SIM = "13900139000";
+    // private static final String DEVICE_3_METER_NO = "33445566778899";
+
+    /**
+     * 初始化设备映射
+     */
+    @PostConstruct
+    public void init() {
+        log.info("开始加载盛昌设备配置(硬编码方式)");
+        
+        // 配置设备1
+        deviceManager.preConfigureMeterNo(DEVICE_1_SIM, DEVICE_1_METER_NO);
+        log.info("已配置设备1:SIM={}, 表号={}", DEVICE_1_SIM, DEVICE_1_METER_NO);
+        
+        // 继续添加更多设备...
+        // deviceManager.preConfigureMeterNo(DEVICE_3_SIM, DEVICE_3_METER_NO);
+        // log.info("已配置设备3:SIM={}, 表号={}", DEVICE_3_SIM, DEVICE_3_METER_NO);
+        
+        log.info("盛昌设备配置加载完成");
+    }
+}

+ 238 - 0
src/main/java/com/sooka/sponest/construction/shengchang/ShengChangDeviceManager.java

@@ -0,0 +1,238 @@
+package com.sooka.sponest.construction.shengchang;
+
+import io.netty.channel.Channel;
+import lombok.Data;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Component;
+
+import java.util.Date;
+import java.util.Map;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.ConcurrentHashMap;
+
+/**
+ * 盛昌设备管理器
+ * 管理所有已连接的设备及其通道
+ */
+@Slf4j
+@Component
+public class ShengChangDeviceManager {
+
+    /**
+     * 设备映射表:SIM卡号 -> 设备信息
+     */
+    private final Map<String, DeviceInfo> deviceMap = new ConcurrentHashMap<>();
+
+    /**
+     * 通道映射表:Channel -> SIM卡号
+     */
+    private final Map<Channel, String> channelMap = new ConcurrentHashMap<>();
+
+    /**
+     * 表号映射表:表号 -> SIM卡号
+     */
+    private final Map<String, String> meterNoMap = new ConcurrentHashMap<>();
+
+    /**
+     * SIM卡号到表号的预配置映射(用于自动绑定)
+     * 可以通过配置文件或数据库初始化
+     */
+    private final Map<String, String> simToMeterMap = new ConcurrentHashMap<>();
+
+    /**
+     * 等待控制确认的Future映射
+     * SIM卡号 -> CompletableFuture<Boolean>(true=收到确认,false=超时)
+     */
+    private final Map<String, CompletableFuture<Boolean>> pendingControlFutures = new ConcurrentHashMap<>();
+
+    /**
+     * 注册设备
+     */
+    public void registerDevice(String simNumber, Channel channel) {
+        // 如果设备已存在,先关闭旧连接(处理重连场景)
+        DeviceInfo oldDevice = deviceMap.get(simNumber);
+        if (oldDevice != null && oldDevice.getChannel() != channel) {
+            Channel oldChannel = oldDevice.getChannel();
+            if (oldChannel != null && oldChannel.isActive()) {
+                log.info("设备重连,关闭旧连接:{}", simNumber);
+                oldChannel.close();
+            }
+            // 清理旧的 channelMap
+            channelMap.remove(oldChannel);
+        }
+        
+        DeviceInfo deviceInfo = new DeviceInfo();
+        deviceInfo.setSimNumber(simNumber);
+        deviceInfo.setChannel(channel);
+        deviceInfo.setConnectTime(new Date());
+        deviceInfo.setLastActiveTime(new Date());
+
+        // 检查是否有预配置的表号,自动绑定
+        String preMeterNo = simToMeterMap.get(simNumber);
+        if (preMeterNo != null) {
+            deviceInfo.setMeterNo(preMeterNo);
+            meterNoMap.put(preMeterNo, simNumber);
+            log.info("设备注册成功:{},自动绑定表号:{}", simNumber, preMeterNo);
+        } else {
+            log.info("设备注册成功:{}", simNumber);
+        }
+
+        deviceMap.put(simNumber, deviceInfo);
+        channelMap.put(channel, simNumber);
+    }
+
+    /**
+     * 移除设备
+     */
+    public void removeDevice(Channel channel) {
+        String simNumber = channelMap.remove(channel);
+        if (simNumber != null) {
+            DeviceInfo deviceInfo = deviceMap.get(simNumber);
+            // 只有当前设备的 channel 与要删除的 channel 一致时才删除
+            // 防止设备重连后,旧连接的 channelInactive 误删新连接的设备信息
+            if (deviceInfo != null && deviceInfo.getChannel() == channel) {
+                deviceMap.remove(simNumber);
+                // 移除表号映射
+                if (deviceInfo.getMeterNo() != null) {
+                    meterNoMap.remove(deviceInfo.getMeterNo());
+                }
+                log.info("设备已移除:{}", simNumber);
+            } else {
+                log.info("旧连接关闭,设备已重连:{}", simNumber);
+            }
+        }
+    }
+
+    /**
+     * 更新设备数据
+     */
+    public void updateDeviceData(Channel channel, MbusResponse response) {
+        String simNumber = channelMap.get(channel);
+        if (simNumber != null) {
+            DeviceInfo deviceInfo = deviceMap.get(simNumber);
+            if (deviceInfo != null) {
+                deviceInfo.setLastResponse(response);
+                deviceInfo.setLastActiveTime(new Date());
+                
+                // 不要更新表号!保持使用配置的原始表号
+                // 设备响应中的表号是反转后的格式,会导致重复反转错误
+            }
+        }
+    }
+
+    /**
+     * 根据SIM卡号获取设备通道
+     */
+    public Channel getChannelBySimNumber(String simNumber) {
+        DeviceInfo deviceInfo = deviceMap.get(simNumber);
+        return deviceInfo != null ? deviceInfo.getChannel() : null;
+    }
+
+    /**
+     * 根据Channel获取SIM卡号
+     */
+    public String getSimNumberByChannel(Channel channel) {
+        return channelMap.get(channel);
+    }
+
+
+    /**
+     * 获取所有在线设备
+     */
+    public Map<String, DeviceInfo> getAllDevices() {
+        return new ConcurrentHashMap<>(deviceMap);
+    }
+
+    /**
+     * 预配置SIM卡号和表号的映射(用于自动绑定)
+     */
+    public void preConfigureMeterNo(String simNumber, String meterNo) {
+        simToMeterMap.put(simNumber, meterNo);
+        log.info("预配置表号映射:SIM {} -> 表号 {}", simNumber, meterNo);
+        
+        // 如果设备已经在线,立即绑定
+        DeviceInfo deviceInfo = deviceMap.get(simNumber);
+        if (deviceInfo != null) {
+            deviceInfo.setMeterNo(meterNo);
+            meterNoMap.put(meterNo, simNumber);
+            log.info("设备已在线,立即绑定表号:{}", meterNo);
+        }
+    }
+
+    /**
+     * 根据SIM卡号获取表号
+     */
+    public String getMeterNoBySimNumber(String simNumber) {
+        DeviceInfo deviceInfo = deviceMap.get(simNumber);
+        if (deviceInfo != null && deviceInfo.getMeterNo() != null) {
+            return deviceInfo.getMeterNo();
+        }
+        // 如果没有表号,返回预配置的表号
+        return simToMeterMap.get(simNumber);
+    }
+
+    /**
+     * 创建等待控制确认的Future
+     */
+    public CompletableFuture<Boolean> createControlFuture(String simNumber) {
+        CompletableFuture<Boolean> future = new CompletableFuture<>();
+        pendingControlFutures.put(simNumber, future);
+        
+        // 5秒超时(Java 8 兼容方式)
+        java.util.concurrent.Executors.newScheduledThreadPool(1).schedule(() -> {
+            if (!future.isDone()) {
+                pendingControlFutures.remove(simNumber);
+                future.complete(false); // 超时返回false
+                log.warn("等待控制确认超时:{}", simNumber);
+            }
+        }, 5, java.util.concurrent.TimeUnit.SECONDS);
+        
+        return future;
+    }
+
+    /**
+     * 完成控制确认(当收到0x97确认响应时调用)
+     */
+    public void completeControlFuture(String simNumber, boolean success) {
+        CompletableFuture<Boolean> future = pendingControlFutures.remove(simNumber);
+        if (future != null && !future.isDone()) {
+            future.complete(success);
+        }
+    }
+
+    /**
+     * 设备信息
+     */
+    @Data
+    public static class DeviceInfo {
+        /**
+         * SIM卡号
+         */
+        private String simNumber;
+
+        /**
+         * 表号
+         */
+        private String meterNo;
+
+        /**
+         * 通信通道
+         */
+        private Channel channel;
+
+        /**
+         * 连接时间
+         */
+        private Date connectTime;
+
+        /**
+         * 最后活动时间
+         */
+        private Date lastActiveTime;
+
+        /**
+         * 最后一次响应数据
+         */
+        private MbusResponse lastResponse;
+    }
+}

+ 317 - 0
src/main/java/com/sooka/sponest/construction/shengchang/ShengChangProtocolParser.java

@@ -0,0 +1,317 @@
+package com.sooka.sponest.construction.shengchang;
+
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Component;
+
+/**
+ * 盛昌协议解析器
+ * 解析注册包、MBUS数据包,构建控制命令
+ */
+@Slf4j
+@Component
+public class ShengChangProtocolParser {
+
+    // 注册包固定头
+    private static final byte REGISTER_START = 0x68;
+    private static final byte REGISTER_TYPE = (byte) 0x80;
+    private static final byte REGISTER_END = 0x16;
+
+    // MBUS协议固定值
+    private static final byte MBUS_START = 0x68;
+    private static final byte MBUS_END = 0x16;
+    private static final byte MBUS_SYNC = (byte) 0xFE;
+    private static final byte VALVE_TYPE = 0x42; // 大阀类型
+
+    /**
+     * 判断是否为注册包
+     */
+    public boolean isRegisterPacket(byte[] data) {
+        if (data.length < 28) {
+            return false;
+        }
+        // 检查注册包特征:68 80 ... 80 00 0E 00 0B
+        return data[0] == REGISTER_START &&
+                data[1] == REGISTER_TYPE &&
+                data[9] == REGISTER_TYPE &&
+                data[10] == 0x00 &&
+                data[11] == 0x0E &&
+                data[12] == 0x00 &&
+                data[13] == 0x0B &&
+                data[data.length - 1] == REGISTER_END;
+    }
+
+    /**
+     * 判断是否为MBUS数据包
+     */
+    public boolean isMbusPacket(byte[] data) {
+        if (data.length < 10) {
+            return false;
+        }
+        // 查找起始码68和结束码16
+        int startIndex = -1;
+        for (int i = 0; i < data.length - 1; i++) {
+            if (data[i] == MBUS_START) {
+                startIndex = i;
+                break;
+            }
+        }
+        return startIndex >= 0 && data[data.length - 1] == MBUS_END;
+    }
+
+    /**
+     * 解析注册包,提取SIM卡号
+     */
+    public String parseRegisterPacket(byte[] data) {
+        if (!isRegisterPacket(data)) {
+            return null;
+        }
+
+        try {
+            // SIM卡号从索引15开始,到倒数第3个字节(不包括校验和和结束符)
+            int simStart = 15;
+            int simEnd = data.length - 2; // 排除校验和和结束符
+            StringBuilder simNumber = new StringBuilder();
+            for (int i = simStart; i < simEnd; i++) {
+                simNumber.append((char) data[i]);
+            }
+            return simNumber.toString();
+        } catch (Exception e) {
+            log.error("解析注册包失败", e);
+            return null;
+        }
+    }
+
+    /**
+     * 解析MBUS响应数据
+     */
+    public MbusResponse parseMbusResponse(byte[] data) {
+        if (!isMbusPacket(data)) {
+            return null;
+        }
+
+        try {
+            // 查找起始码位置
+            int startIndex = -1;
+            for (int i = 0; i < data.length; i++) {
+                if (data[i] == MBUS_START) {
+                    startIndex = i;
+                    break;
+                }
+            }
+
+            if (startIndex < 0 || data.length - startIndex < 13) {
+                log.warn("MBUS数据包太短或找不到起始码");
+                return null;
+            }
+
+            MbusResponse response = new MbusResponse();
+
+            // 解析表号(7字节)
+            byte[] meterNo = new byte[7];
+            System.arraycopy(data, startIndex + 2, meterNo, 0, 7);
+            response.setMeterNo(bytesToHex(meterNo));
+
+            // 解析命令字
+            byte command = data[startIndex + 9];
+            response.setCommand(command & 0xFF);
+
+            // 解析长度
+            int length = data[startIndex + 10] & 0xFF;
+            response.setDataLength(length);
+
+            // 解析数据域(根据命令字判断)
+            int cmdType = command & 0xFF;
+            
+            // 0x17: 设备主动上报控制状态(只有开度数据)
+            if (cmdType == 0x17 && length >= 4) {
+                int dataStart = startIndex + 14;
+                if (data.length > dataStart) {
+                    int opening = data[dataStart] & 0xFF;
+                    response.setValveOpening(opening);
+                }
+            }
+            // 0x81: 读取数据响应,0x97: 阀门控制响应(完整数据)
+            else if ((cmdType == 0x81 || cmdType == 0x97) && length >= 11) {
+                int dataStart = startIndex + 14;
+
+                if (data.length > dataStart) {
+                    // 解析阀门开度
+                    int opening = data[dataStart] & 0xFF;
+                    response.setValveOpening(opening);
+
+                    // 解析阀门入口压力(KPa)
+                    if (data.length > dataStart + 2) {
+                        int inletPressure = (data[dataStart + 1] & 0xFF) | ((data[dataStart + 2] & 0xFF) << 8);
+                        response.setInletPressure(inletPressure);
+                    }
+
+                    // 解析阀门出口压力(KPa)
+                    if (data.length > dataStart + 4) {
+                        int outletPressure = (data[dataStart + 3] & 0xFF) | ((data[dataStart + 4] & 0xFF) << 8);
+                        response.setOutletPressure(outletPressure);
+                    }
+
+                    // 解析供电电压(单位:百毫伏)
+                    if (data.length > dataStart + 6) {
+                        int voltage = (data[dataStart + 5] & 0xFF) | ((data[dataStart + 6] & 0xFF) << 8);
+                        response.setVoltage(voltage / 10.0);
+                    }
+
+                    // 解析状态位
+                    if (data.length > dataStart + 7) {
+                        int status = data[dataStart + 7] & 0xFF;
+                        response.setStatus(status);
+                        response.setAutoMode((status & 0x80) != 0);
+                        response.setLowVoltage((status & 0x40) != 0);
+                        response.setCurrentUnderload((status & 0x02) != 0);
+                        response.setCurrentOverload((status & 0x01) != 0);
+                    }
+                }
+            }
+
+            return response;
+        } catch (Exception e) {
+            log.error("解析MBUS响应失败", e);
+            return null;
+        }
+    }
+
+    /**
+     * 构建阀门控制命令
+     *
+     * @param meterNo      表号(7字节十六进制字符串)
+     * @param openingValue 期望阀门开度(0-100)
+     * @return 命令字节数组
+     */
+    public byte[] buildValveControlCommand(String meterNo, int openingValue) {
+        try {
+            if (openingValue < 0 || openingValue > 100) {
+                throw new IllegalArgumentException("阀门开度必须在0-100之间");
+            }
+
+            // 根据实际测试:设备需要前导码
+            // 虽然设备上报数据不带前导码,但下发控制命令必须带前导码
+            boolean useSyncCode = true; // 启用前导码
+            
+            if (useSyncCode) {
+                // 带前导码格式(协议标准):FE FE 68 42 12 34 56 78 00 11 11 17 04 A0 17 00 32 93 16
+                byte[] command = new byte[19];
+                
+                command[0] = MBUS_SYNC;
+                command[1] = MBUS_SYNC;
+                command[2] = MBUS_START;
+                command[3] = VALVE_TYPE;
+                
+                byte[] meterBytes = hexStringToBytes(meterNo);
+                System.arraycopy(meterBytes, 0, command, 4, 7);
+                
+                command[11] = 0x17;      // 命令字
+                command[12] = 0x04;      // 长度
+                command[13] = (byte) 0xA0; // 控制域
+                command[14] = 0x17;
+                command[15] = 0x00;
+                command[16] = (byte) (openingValue & 0xFF); // 开度
+                command[17] = calculateChecksum(command, 2, 17); // 校验和
+                command[18] = MBUS_END;  // 结束符
+                
+                return command;
+            } else {
+                // 无前导码格式(参考设备上报):68 42 64 03 08 21 00 00 00 17 04 A0 17 00 64 70 16
+                byte[] command = new byte[17];
+                
+                command[0] = MBUS_START;
+                command[1] = VALVE_TYPE;
+                
+                byte[] meterBytes = hexStringToBytes(meterNo);
+                System.arraycopy(meterBytes, 0, command, 2, 7);
+                
+                command[9] = 0x17;       // 命令字
+                command[10] = 0x04;      // 长度
+                command[11] = (byte) 0xA0; // 控制域
+                command[12] = 0x17;
+                command[13] = 0x00;
+                command[14] = (byte) (openingValue & 0xFF); // 开度
+                command[15] = calculateChecksum(command, 0, 15); // 校验和(从0x68开始)
+                command[16] = MBUS_END;  // 结束符
+                
+                log.info("构建命令 - TCP格式(无前导码,参考设备上报)");
+                return command;
+            }
+        } catch (Exception e) {
+            log.error("构建阀门控制命令失败", e);
+            return null;
+        }
+    }
+
+    /**
+     * 构建读取数据命令
+     * 
+     * @param meterNo 表号(7字节十六进制字符串)
+     * @return 命令字节数组
+     */
+    public byte[] buildReadCommand(String meterNo) {
+        try {
+            // 读取命令格式:FE FE 68 42 12 34 56 78 00 11 11 01 03 90 1F 00 93 16
+            byte[] command = new byte[18];
+            
+            command[0] = MBUS_SYNC;
+            command[1] = MBUS_SYNC;
+            command[2] = MBUS_START;
+            command[3] = VALVE_TYPE;
+            
+            byte[] meterBytes = hexStringToBytes(meterNo);
+            System.arraycopy(meterBytes, 0, command, 4, 7);
+            
+            command[11] = 0x01;      // 命令字:读取
+            command[12] = 0x03;      // 长度
+            command[13] = (byte) 0x90; // 控制域
+            command[14] = 0x1F;
+            command[15] = 0x00;
+            command[16] = calculateChecksum(command, 2, 16); // 校验和
+            command[17] = MBUS_END;  // 结束符
+            
+            log.info("构建读取命令 - 18字节");
+            return command;
+        } catch (Exception e) {
+            log.error("构建读取命令失败", e);
+            return null;
+        }
+    }
+
+    /**
+     * 计算校验和
+     */
+    private byte calculateChecksum(byte[] data, int start, int end) {
+        int sum = 0;
+        for (int i = start; i < end; i++) {
+            sum += data[i] & 0xFF;
+        }
+        return (byte) (sum & 0xFF);
+    }
+
+    /**
+     * 十六进制字符串转字节数组
+     */
+    private byte[] hexStringToBytes(String hexString) {
+        // 移除空格
+        hexString = hexString.replace(" ", "");
+        int len = hexString.length();
+        byte[] data = new byte[len / 2];
+        for (int i = 0; i < len; i += 2) {
+            data[i / 2] = (byte) ((Character.digit(hexString.charAt(i), 16) << 4)
+                    + Character.digit(hexString.charAt(i + 1), 16));
+        }
+        return data;
+    }
+
+    /**
+     * 字节数组转十六进制字符串
+     */
+    private String bytesToHex(byte[] bytes) {
+        StringBuilder sb = new StringBuilder();
+        for (byte b : bytes) {
+            sb.append(String.format("%02X", b & 0xFF));
+        }
+        return sb.toString();
+    }
+}

+ 185 - 0
src/main/java/com/sooka/sponest/construction/shengchang/ShengChangServerHandler.java

@@ -0,0 +1,185 @@
+package com.sooka.sponest.construction.shengchang;
+
+import io.netty.buffer.Unpooled;
+import io.netty.channel.Channel;
+import io.netty.channel.ChannelHandler;
+import io.netty.channel.ChannelHandlerContext;
+import io.netty.channel.SimpleChannelInboundHandler;
+import io.netty.handler.timeout.IdleState;
+import io.netty.handler.timeout.IdleStateEvent;
+import io.netty.util.AttributeKey;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Component;
+
+import javax.annotation.Resource;
+import java.net.InetSocketAddress;
+
+/**
+ * 盛昌TCP服务器处理器
+ * 处理注册包、心跳包和数据包
+ */
+@Slf4j
+@Component
+@ChannelHandler.Sharable
+public class ShengChangServerHandler extends SimpleChannelInboundHandler<byte[]> {
+
+    // 用于存储连接时间的属性键
+    private static final AttributeKey<Long> CONNECT_TIME = AttributeKey.valueOf("connectTime");
+
+    @Resource
+    private ShengChangProtocolParser protocolParser;
+
+    @Resource
+    private ShengChangDeviceManager deviceManager;
+
+    @Override
+    public void channelActive(ChannelHandlerContext ctx) throws Exception {
+        // 记录连接时间
+        ctx.channel().attr(CONNECT_TIME).set(System.currentTimeMillis());
+        InetSocketAddress address = (InetSocketAddress) ctx.channel().remoteAddress();
+        log.info("新设备连接:{}:{}", address.getHostString(), address.getPort());
+        super.channelActive(ctx);
+    }
+
+    @Override
+    public void channelInactive(ChannelHandlerContext ctx) throws Exception {
+        // 计算连接时长
+        Long connectTime = ctx.channel().attr(CONNECT_TIME).get();
+        String duration = "";
+        if (connectTime != null) {
+            long durationMs = System.currentTimeMillis() - connectTime;
+            duration = String.format(",连接时长:%.1f秒", durationMs / 1000.0);
+        }
+        
+        InetSocketAddress address = (InetSocketAddress) ctx.channel().remoteAddress();
+        log.info("设备断开连接:{}:{}{}", address.getHostString(), address.getPort(), duration);
+        // 从设备管理器中移除
+        deviceManager.removeDevice(ctx.channel());
+        super.channelInactive(ctx);
+    }
+
+    @Override
+    protected void channelRead0(ChannelHandlerContext ctx, byte[] data) throws Exception {
+        if (data == null || data.length == 0) {
+            return;
+        }
+
+        // 打印接收到的原始数据
+        log.debug("接收到数据:{}", bytesToHex(data));
+
+        // 处理心跳包
+        if (data.length == 1 && (data[0] & 0xFF) == 0xCC) {
+            return;
+        }
+
+        // 处理注册包
+        if (protocolParser.isRegisterPacket(data)) {
+            handleRegisterPacket(ctx, data);
+            return;
+        }
+
+        // 处理MBUS数据包
+        if (protocolParser.isMbusPacket(data)) {
+            handleMbusPacket(ctx, data);
+            return;
+        }
+
+        log.warn("未知数据包:{}", bytesToHex(data));
+    }
+
+    /**
+     * 处理注册包
+     */
+    private void handleRegisterPacket(ChannelHandlerContext ctx, byte[] data) {
+        try {
+            String simNumber = protocolParser.parseRegisterPacket(data);
+            if (simNumber != null) {
+                log.info("设备注册成功,SIM卡号:{}", simNumber);
+                // 注册设备到管理器
+                deviceManager.registerDevice(simNumber, ctx.channel());
+            } else {
+                log.error("注册包解析失败");
+            }
+        } catch (Exception e) {
+            log.error("处理注册包异常", e);
+        }
+    }
+
+    /**
+     * 处理MBUS数据包
+     */
+    private void handleMbusPacket(ChannelHandlerContext ctx, byte[] data) {
+        try {
+            // 解析MBUS响应数据
+            MbusResponse response = protocolParser.parseMbusResponse(data);
+            if (response != null) {
+                // 如果是设备主动上报(命令字0x17),发送应答包
+                if (response.getCommand() == 0x17) {
+                    sendAckPacket(ctx, data);
+                }
+                
+                // 如果是控制确认响应(命令字0x97),完成等待的Future
+                if (response.getCommand() == 0x97) {
+                    String simNumber = deviceManager.getSimNumberByChannel(ctx.channel());
+                    if (simNumber != null) {
+                        deviceManager.completeControlFuture(simNumber, true);
+                    }
+                }
+                
+                // 保存数据到缓存
+                Channel channel = ctx.channel();
+                deviceManager.updateDeviceData(channel, response);
+            }
+        } catch (Exception e) {
+            log.error("处理MBUS数据包异常", e);
+        }
+    }
+    
+    /**
+     * 发送应答包(原样返回接收到的数据)
+     */
+    private void sendAckPacket(ChannelHandlerContext ctx, byte[] data) {
+        try {
+            ctx.writeAndFlush(Unpooled.copiedBuffer(data));
+        } catch (Exception e) {
+            log.error("发送应答包失败", e);
+        }
+    }
+
+    @Override
+    public void userEventTriggered(ChannelHandlerContext ctx, Object evt) throws Exception {
+        if (evt instanceof IdleStateEvent) {
+            IdleStateEvent event = (IdleStateEvent) evt;
+            if (event.state() == IdleState.READER_IDLE) {
+                // 计算连接时长
+                Long connectTime = ctx.channel().attr(CONNECT_TIME).get();
+                if (connectTime != null) {
+                    long durationMs = System.currentTimeMillis() - connectTime;
+                    log.warn("设备长时间未发送数据(超时{}秒),关闭连接", String.format("%.1f", durationMs / 1000.0));
+                } else {
+                    log.warn("设备长时间未发送数据,关闭连接");
+                }
+                ctx.close();
+            }
+        } else {
+            super.userEventTriggered(ctx, evt);
+        }
+    }
+
+    @Override
+    public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception {
+        log.error("连接异常", cause);
+        ctx.close();
+    }
+
+    /**
+     * 字节数组转十六进制字符串
+     */
+    private String bytesToHex(byte[] bytes) {
+        StringBuilder sb = new StringBuilder();
+        for (byte b : bytes) {
+            sb.append(String.format("%02X ", b & 0xFF));
+        }
+        return sb.toString().trim();
+    }
+}

+ 180 - 0
src/main/java/com/sooka/sponest/construction/shengchang/ShengChangService.java

@@ -0,0 +1,180 @@
+package com.sooka.sponest.construction.shengchang;
+
+import com.ruoyi.common.core.web.domain.AjaxResult;
+import io.netty.buffer.Unpooled;
+import io.netty.channel.Channel;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+
+import javax.annotation.Resource;
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * 盛昌阀门控制服务
+ * 核心功能:通过SIM卡号控制阀门
+ */
+@Slf4j
+@Service
+public class ShengChangService {
+
+    @Resource
+    private ShengChangDeviceManager deviceManager;
+
+    @Resource
+    private ShengChangProtocolParser protocolParser;
+
+    /**
+     * 控制阀门开度
+     *
+     * @param simNumber    SIM卡号
+     * @param openingValue 期望阀门开度(0-100)
+     * @return 控制结果
+     */
+    public AjaxResult controlValve(String simNumber, Integer openingValue) {
+        try {
+            log.info("控制阀门 - SIM:{},开度:{}%", simNumber, openingValue);
+
+            // 参数校验
+            if (simNumber == null || simNumber.trim().isEmpty()) {
+                return AjaxResult.error("SIM卡号不能为空");
+            }
+
+            if (openingValue == null || openingValue < 0 || openingValue > 100) {
+                return AjaxResult.error("阀门开度必须在0-100之间");
+            }
+
+            // 查找设备
+            Channel channel = deviceManager.getChannelBySimNumber(simNumber);
+            if (channel == null || !channel.isActive()) {
+                return AjaxResult.error("设备未连接或已离线");
+            }
+
+            // 获取表号
+            String meterNo = deviceManager.getMeterNoBySimNumber(simNumber);
+            if (meterNo == null) {
+                return AjaxResult.error("未配置表号,请在配置文件中添加此设备的表号映射");
+            }
+
+            // 格式化表号
+            String formattedMeterNo = formatMeterNo(meterNo);
+
+            // 构建控制命令
+            byte[] command = protocolParser.buildValveControlCommand(formattedMeterNo, openingValue);
+            if (command == null) {
+                return AjaxResult.error("构建控制命令失败");
+            }
+
+            // 创建等待控制确认的Future
+            java.util.concurrent.CompletableFuture<Boolean> controlFuture = deviceManager.createControlFuture(simNumber);
+
+            // 发送控制命令
+            channel.writeAndFlush(Unpooled.copiedBuffer(command)).addListener(future -> {
+                if (!future.isSuccess()) {
+                    log.error("✗ 控制命令发送失败 - SIM:{},原因:{}", simNumber, future.cause().getMessage());
+                    controlFuture.complete(false);
+                }
+            });
+
+            // 同步等待设备确认(最多5秒)
+            try {
+                Boolean confirmed = controlFuture.get(5, java.util.concurrent.TimeUnit.SECONDS);
+
+                if (confirmed != null && confirmed) {
+                    // 设备确认收到,返回成功
+                    log.info("✓ 控制成功 - SIM:{},开度:{}%", simNumber, openingValue);
+
+                    Map<String, Object> result = new HashMap<>();
+                    result.put("simNumber", simNumber);
+                    result.put("openingValue", openingValue);
+
+                    return AjaxResult.success("操作成功", result);
+                } else {
+                    // 设备未确认,返回500错误
+                    log.error("✗ 控制失败 - SIM:{},设备未确认", simNumber);
+                    return AjaxResult.error(500, "设备未确认收到指令");
+                }
+
+            } catch (java.util.concurrent.TimeoutException e) {
+                log.error("✗ 控制超时 - SIM:{}", simNumber);
+                return AjaxResult.error(500, "等待设备确认超时");
+            } catch (Exception e) {
+                log.error("✗ 控制异常 - SIM:{},错误:{}", simNumber, e.getMessage());
+                return AjaxResult.error(500, "控制异常:" + e.getMessage());
+            }
+
+        } catch (Exception e) {
+            log.error("控制阀门异常", e);
+            return AjaxResult.error("控制阀门失败:" + e.getMessage());
+        }
+    }
+
+    /**
+     * 获取所有在线设备
+     *
+     * @return 设备列表
+     */
+    public AjaxResult getDevices() {
+        try {
+            Map<String, ShengChangDeviceManager.DeviceInfo> devices = deviceManager.getAllDevices();
+            Map<String, Object> result = new HashMap<>();
+
+            for (Map.Entry<String, ShengChangDeviceManager.DeviceInfo> entry : devices.entrySet()) {
+                ShengChangDeviceManager.DeviceInfo deviceInfo = entry.getValue();
+                Map<String, Object> info = new HashMap<>();
+                info.put("simNumber", deviceInfo.getSimNumber());
+                info.put("meterNo", deviceInfo.getMeterNo());
+                info.put("online", deviceInfo.getChannel() != null && deviceInfo.getChannel().isActive());
+                info.put("connectTime", deviceInfo.getConnectTime());
+                info.put("lastActiveTime", deviceInfo.getLastActiveTime());
+                if (deviceInfo.getLastResponse() != null) {
+                    info.put("lastData", deviceInfo.getLastResponse());
+                }
+                result.put(entry.getKey(), info);
+            }
+
+            return AjaxResult.success(result);
+
+        } catch (Exception e) {
+            log.error("获取设备列表异常", e);
+            return AjaxResult.error("获取设备列表失败:" + e.getMessage());
+        }
+    }
+
+    /**
+     * 格式化表号(添加空格,并反转字节序)
+     * 输入:00000021080364
+     * 输出:64 03 08 21 00 00 00(低位在前)
+     * <p>
+     * 根据MBUS协议,表号采用低位在前的字节序
+     * 例如:表号 00000012345678 在协议中为 78 56 34 12 00 00 00
+     */
+    private String formatMeterNo(String meterNo) {
+        // 移除所有空格
+        meterNo = meterNo.replace(" ", "");
+
+        // 如果长度不是14,在开头补0
+        while (meterNo.length() < 14) {
+            meterNo = "0" + meterNo;
+        }
+
+        // 每两位分组
+        String[] bytes = new String[7];
+        for (int i = 0; i < 7; i++) {
+            bytes[i] = meterNo.substring(i * 2, i * 2 + 2);
+        }
+
+        // 反转字节序(低位在前)
+        StringBuilder reversed = new StringBuilder();
+        for (int i = 6; i >= 0; i--) {
+            if (reversed.length() > 0) {
+                reversed.append(" ");
+            }
+            reversed.append(bytes[i]);
+        }
+
+        String result = reversed.toString();
+        log.info("表号转换:输入={}, 输出={}", meterNo, result);
+        return result;
+    }
+}

+ 97 - 0
src/main/java/com/sooka/sponest/construction/shengchang/ShengChangTcpServer.java

@@ -0,0 +1,97 @@
+package com.sooka.sponest.construction.shengchang;
+
+import io.netty.bootstrap.ServerBootstrap;
+import io.netty.channel.*;
+import io.netty.channel.nio.NioEventLoopGroup;
+import io.netty.channel.socket.SocketChannel;
+import io.netty.channel.socket.nio.NioServerSocketChannel;
+import io.netty.handler.codec.bytes.ByteArrayDecoder;
+import io.netty.handler.codec.bytes.ByteArrayEncoder;
+import io.netty.handler.timeout.IdleStateHandler;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.boot.CommandLineRunner;
+import org.springframework.stereotype.Component;
+
+import javax.annotation.PreDestroy;
+import javax.annotation.Resource;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * 盛昌采集器TCP服务器
+ * 负责接收采集器连接、处理注册包和心跳包
+ */
+@Slf4j
+@Component
+public class ShengChangTcpServer implements CommandLineRunner {
+
+    @Resource
+    private ShengChangServerHandler serverHandler;
+
+    private EventLoopGroup bossGroup;
+    private EventLoopGroup workerGroup;
+
+    @Override
+    public void run(String... args) throws Exception {
+        start();
+    }
+
+    /**
+     * 启动TCP服务器
+     */
+    public void start() {
+        bossGroup = new NioEventLoopGroup(1);
+        workerGroup = new NioEventLoopGroup();
+
+        try {
+            ServerBootstrap bootstrap = new ServerBootstrap();
+            bootstrap.group(bossGroup, workerGroup)
+                    .channel(NioServerSocketChannel.class)
+                    .option(ChannelOption.SO_BACKLOG, 128)
+                    .option(ChannelOption.SO_REUSEADDR, true)
+                    .childOption(ChannelOption.SO_KEEPALIVE, true)
+                    .childOption(ChannelOption.TCP_NODELAY, true)
+                    .childOption(ChannelOption.SO_REUSEADDR, true)
+                    .childHandler(new ChannelInitializer<SocketChannel>() {
+                        @Override
+                        protected void initChannel(SocketChannel ch) throws Exception {
+                            ChannelPipeline pipeline = ch.pipeline();
+                            // 添加空闲检测处理器(90秒未收到数据则触发空闲事件)
+                            // 设备心跳间隔约30-60秒,90秒足以检测掉线且不会误判
+                            pipeline.addLast(new IdleStateHandler(90, 0, 0, TimeUnit.SECONDS));
+                            // 字节数组编解码器
+                            pipeline.addLast(new ByteArrayDecoder());
+                            pipeline.addLast(new ByteArrayEncoder());
+                            // 业务处理器
+                            pipeline.addLast(serverHandler);
+                        }
+                    });
+
+            int port = 9900; // 监听端口
+            ChannelFuture future = bootstrap.bind(port).sync();
+            log.info("盛昌TCP服务器启动成功,监听端口:{}", port);
+            
+            // 等待服务器关闭
+            future.channel().closeFuture().addListener((ChannelFutureListener) channelFuture -> {
+                log.info("盛昌TCP服务器已关闭");
+            });
+            
+        } catch (Exception e) {
+            log.error("盛昌TCP服务器启动失败", e);
+            shutdown();
+        }
+    }
+
+    /**
+     * 关闭TCP服务器
+     */
+    @PreDestroy
+    public void shutdown() {
+        if (workerGroup != null) {
+            workerGroup.shutdownGracefully();
+        }
+        if (bossGroup != null) {
+            bossGroup.shutdownGracefully();
+        }
+        log.info("盛昌TCP服务器已停止");
+    }
+}