[背景]
化工园区封闭化建设,出入需要称重,所以加装了地磅。现有两个卡口,每个卡口进出分别各有一个地磅,每个卡口配一台主机,两个地磅都直接连接到电脑主机上,仪表232接电脑串口(仪表15芯7、8接电脑九芯2、5)。业务需求是提供一个接口能够读取指定地磅的实时数据,所以就开发一个程序部署在这台主机上读取串口数据并往外提供最新数据。

准备工作

因为本身并没有出差去现场,加上他们不知道应该怎么把地磅和主机接起来,所以远程跟运维沟通了下,现场发来卡口主机接口如下:
8f5dfe76d10b738f7a156096e2afb1b.jpg

我一看,以我贫瘠的硬件知识来说貌似也没太多选择,买俩usb转485线应该就可以了。然后打开京东,搜索485转USB打开第一家,转发物品链接,让运维去采购好了。(其实我觉得这线绝对贵了很多,不过也不用我掏钱,也懒的再去找了😂)
63f8d36c201bb7df5bec9ff57959540.jpg

过了一个周末,线到了。开始配合现场调试数据,听说现场调试地磅的还是一个60多的老前辈,指着串口返回的一堆二进制的00数据跟我说地磅调试正常了。我是一脸懵逼,这和报文协议咋不一样,而且这二进制的00能解析出啥?这个时候我是有点开始怀疑自己了,难道是报文协议看漏了,还是我用的串口工具有问题啊,还没等我安装上另外的串口工具,结果老前辈发消息来说是线没接上🥴。我估摸着有点扯淡的,线没接也打不开串口啊,当然也有可能是其他部件的线没接上导致数据不是正常数据。反正是经过一通折腾,终于有正经的数据返回了。
b75a52bb34ed8882870b158217f9cf8.png

报文协议

文档的链接为:
https://mf.alllf.com/cloudpic/2024/07/c530a6c79d0d846679f9e3a4aade3a88.pdf

主要核心内容包含如下:
image.png

image.png
image.png

源码说明

因为只会java所以就用java写的,其实要是用go的话代码应该会简略很多,想了想以后再学吧,现在用java写写得了。

引入核心串口通讯依赖

1
2
3
4
5
<dependency>  
<groupId>com.fazecast</groupId>
<artifactId>jSerialComm</artifactId>
<version>2.11.0</version>
</dependency>

串口打开、关闭、发送数据、接收数据的类开发

每个方法的作用详细见方法注释,初始化时会遍历所有可以打开的串口,并将串口储存在map中便于后续的使用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
package cn.allbs.weightscale.config;  

import cn.allbs.weightscale.exception.BhudyException;
import cn.allbs.weightscale.handler.SerialPortListener;
import cn.allbs.weightscale.util.SerialPortUtil;
import com.fazecast.jSerialComm.SerialPort;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;

import java.io.ByteArrayOutputStream;
import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;

/**
* 类 SerialPortManager
* * @date 2024/6/27
*/
@Slf4j
@Component
public class SerialPortManager {

private final Map<String, SerialPort> serialPorts;
private final Map<String, SerialPortListener> listeners;

@Resource
private RedisTemplate redisTemplate;

@Resource
private SerialPortConfig serialPortConfig;

public SerialPortManager() {
serialPorts = new HashMap<>();
listeners = new HashMap<>();
}

@PostConstruct
public void init() {
log.info("\nUsing Library Version v{}", SerialPort.getVersion());
SerialPort[] ports = SerialPort.getCommPorts();
log.info("\nAvailable Ports:\n");
Map<String, String> portMappings = serialPortConfig.getPortMappings();
for (SerialPort port : ports) {
log.info("{}: {} - {}", port.getSystemPortName(), port.getDescriptivePortName(), port.getPortDescription());
serialPorts.put(port.getSystemPortName(), port);
openPort(port.getSystemPortName());
}
}

public boolean addPort(String portName) {
SerialPort[] ports = SerialPort.getCommPorts();
for (SerialPort port : ports) {
if (port.getSystemPortName().equals(portName)) {
serialPorts.put(portName, port);
log.info("Added Port: {} - {}", port.getSystemPortName(), port.getDescriptivePortName());
return true;
}
}
log.info("Port {} not found!", portName);
return false;
}

/**
* 打开指定com口
*/
public void openPort(String portName) {
SerialPort port = serialPorts.get(portName);
if (port == null) {
log.info("Port {} is not managed!", portName);
return;
}
if (!port.isOpen()) {
log.info("\nPre-setting RTS: {}", port.setRTS() ? "Success" : "Failure");
if (!port.openPort()) {
log.info("Open serial port {} error!", portName);
return;
}
log.info("\nOpening {}: {} - {}", port.getSystemPortName(), port.getDescriptivePortName(), port.getPortDescription());
port.setFlowControl(SerialPort.FLOW_CONTROL_DISABLED);
port.setComPortParameters(9600, 8, SerialPort.ONE_STOP_BIT, SerialPort.NO_PARITY);
port.setComPortTimeouts(SerialPort.TIMEOUT_READ_BLOCKING | SerialPort.TIMEOUT_WRITE_BLOCKING, 1000, 1000);
// 获取 Redis 键名
String redisKey = Optional.ofNullable(serialPortConfig.getPortMappings()).map(a -> a.get(portName)).orElse("pc:weight:unknown");

// 直接开始解析
SerialPortListener listener = new SerialPortListener(port, portName, redisTemplate, redisKey);
listeners.put(portName, listener);
Thread listenerThread = new Thread(listener);
listenerThread.start();
}
}

/**
* 判断指定com口是否打开
*/
public boolean isPortOpen(String portName) {
SerialPort port = serialPorts.get(portName);
return port != null && port.isOpen();
}

/**
* 关闭指定com口
*/
public void closePort(String portName) {
SerialPort port = serialPorts.get(portName);
SerialPortListener listener = listeners.get(portName);
if (port != null && port.isOpen()) {
if (listener != null) {
listener.stop();
listeners.remove(portName);
}
port.closePort();
log.info("Closed Port: {}", portName);
}
}

/**
* 向指定com口发送数据
*/
public int write(String portName, byte[] data) {
SerialPort port = serialPorts.get(portName);
if (port == null || !port.isOpen()) {
return 0;
}
return port.writeBytes(data, data.length);
}

/**
* 从指定com口读取数据
*/
public int read(String portName, byte[] data) {
SerialPort port = serialPorts.get(portName);
if (port == null || !port.isOpen()) {
return 0;
}
return port.readBytes(data, data.length);
}

/**
* 向指定com口发送数据并且读取数据
*/
public byte[] writeAndRead(String portName, byte[] bytes) {
byte[] resultData = null;
try (ByteArrayOutputStream bao = new ByteArrayOutputStream()) {
SerialPort port = serialPorts.get(portName);
if (port == null || !port.isOpen()) throw new BhudyException("Port not open or not found: " + portName);
int numWrite = port.writeBytes(bytes, bytes.length);
if (numWrite > 0) {
Thread.sleep(100); // 休眠0.1秒,等待下位机返回数据。如果不休眠直接读取,有可能无法成功读到数据
while (port.bytesAvailable() > 0) {
byte[] newData = new byte[port.bytesAvailable()];
int numRead = port.readBytes(newData, newData.length);
if (numRead > 0) {
bao.write(newData);
}
}
resultData = bao.toByteArray();
}
} catch (Exception e) {
throw new BhudyException(e.getMessage());
}
return resultData;
}

/**
* 向指定com口发送数据并且读取数据
*/
public String readWeightOnce(String portName) {
try {
// 固定读取12位
byte[] readBuffer = new byte[12];
SerialPort port = serialPorts.get(portName);
int numRead = port.readBytes(readBuffer, readBuffer.length);
if (numRead > 0) {
String result = SerialPortUtil.parseWeightData(readBuffer);
log.info("{}读取只读取一次串口{}的数据为:{}", LocalDateTime.now(), portName, result);
return result;
}
} catch (Exception e) {
throw new BhudyException(e.getMessage());
}
return null;
}

/**
* 关闭所有com口
*/
public void closeAllPorts() {
for (String portName : serialPorts.keySet()) {
closePort(portName);
}
}
}

报文解析工具类

包含指令生成和报文解析方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
package cn.allbs.weightscale.util;  

import cn.allbs.weightscale.exception.BhudyException;
import lombok.experimental.UtilityClass;
import lombok.extern.slf4j.Slf4j;

import java.util.Arrays;

/**
* 类 SerialPortUtil
* * @author ChenQi
* @date 2024/6/11
*/
@Slf4j
@UtilityClass
public class SerialPortUtil {

/**
* 十六进制数的字符串转换为对应的字节数组
*
* @param s 十六进制字符串
* @return 字节数组
*/
public static byte[] hexStringToByteArray(String s) {
s = s.replaceAll("\\s", ""); // 去除所有空格
int len = s.length();

// 确保字符串长度为偶数
if (len % 2 != 0) {
throw new IllegalArgumentException("Invalid hex string: " + s);
}

byte[] data = new byte[len / 2];
for (int i = 0; i < len; i += 2) {
int high = Character.digit(s.charAt(i), 16);
int low = Character.digit(s.charAt(i + 1), 16);
if (high == -1 || low == -1) {
throw new IllegalArgumentException("Invalid hex character in string: " + s);
}
data[i / 2] = (byte) ((high << 4) + low);
}
return data;
}


/**
* 字节数组转换为十六进制字符串
*
* @param byteArray 字节数组
* @return 十六进制字符串
*/
public static String byteArrayToHexString(byte[] byteArray) {
StringBuilder hexString = new StringBuilder();
for (byte b : byteArray) {
hexString.append(String.format("%02X ", b));
}
// 去掉最后一个空格
return hexString.toString().trim();
}

/**
* 解析重量数据(主要是校验)
*
* @param data 数据
* @return 重量
*/
public static String parseWeightData(byte[] data) {
if (data.length < 12) {
throw new BhudyException("串口未接收到数据或者数据长度不够,返回数据内容为" + Arrays.toString(data));
}
if (data[0] != 0x02 || data[data.length - 1] != 0x03) {
throw new IllegalArgumentException("Invalid data format");
}

byte[] payload = Arrays.copyOfRange(data, 1, data.length - 3);
byte xorHigh = data[data.length - 3];
byte xorLow = data[data.length - 2];
byte end = data[data.length - 1];

// 校验异或
byte calculatedXor = 0;
for (int i = 1; i < data.length - 3; i++) { // 从第2位到异或校验前一位
calculatedXor ^= data[i];
}

byte expectedXorHigh = (byte) ((calculatedXor >> 4) & 0x0F);
byte expectedXorLow = (byte) (calculatedXor & 0x0F);

// Convert XOR values to ASCII
expectedXorHigh += (byte) ((expectedXorHigh <= 9) ? 0x30 : 0x37);
expectedXorLow += (byte) ((expectedXorLow <= 9) ? 0x30 : 0x37);

if (expectedXorHigh != xorHigh || expectedXorLow != xorLow) {
log.info("校验失败,报文中高四位{},低四位{};主动校验后的高四位:{},低四位{};", xorHigh, xorLow, expectedXorHigh, expectedXorLow);
}

return parseWeight(payload);
}

/**
* 解析重量数据
*
* @param payload 数据
* @return 重量
*/
private static String parseWeight(byte[] payload) {
if (payload.length != 8) {
throw new IllegalArgumentException("Invalid payload length for weight data");
}

char sign = (char) payload[0];
String weightValue = new String(Arrays.copyOfRange(payload, 1, 7)).trim();
int decimalPointPosition = payload[7] - '0';

// 插入小数点
StringBuilder weight = new StringBuilder(weightValue);
if (decimalPointPosition > 0 && decimalPointPosition < weight.length()) {
weight.insert(weight.length() - decimalPointPosition, '.');
}

// 转换为实际数值
String weightString = sign + weight.toString();

// 去掉小数位为0的部分
double weightNumber = Double.parseDouble(weightString);
if (decimalPointPosition == 0) {
return String.valueOf((int) weightNumber); // 如果小数位为0,返回整数
} else {
return String.valueOf(weightNumber);
}
}

/**
* 计算异或校验和
*
* @param data 数据
* @return 校验和
*/
public static byte calculateXorChecksum(byte[] data) {
byte xor = 0;
for (byte b : data) {
xor ^= b;
}
return xor;
}

/**
* 生成指令
*
* @param address 地址
* @param command 指令
* @return 指令字节数组
*/
public byte[] generateCommand(String address, char command) {
byte start = 0x02; // 开始符
byte end = 0x03; // 结束符

byte addressByte = (byte) address.charAt(0);
byte commandByte = (byte) command;

// 计算异或校验
byte xor = (byte) (addressByte ^ commandByte);
byte xorHigh = (byte) ((xor >> 4) & 0x0F);
byte xorLow = (byte) (xor & 0x0F);

// 高四位和低四位的ASCII码转换
xorHigh += (byte) ((xorHigh <= 9) ? 0x30 : 0x37);
xorLow += (byte) ((xorLow <= 9) ? 0x30 : 0x37);

// 生成字节数组
return new byte[]{start, addressByte, commandByte, xorHigh, xorLow, end};
}

public static void main(String[] args) {
byte[] data = {
0x02, // Start byte
0x2B, // '+'
0x30, 0x30, 0x31, 0x35, 0x30, 0x30, 0x30, 0x31, // '00150001'
0x46, // XOR checksum
0x03 // End byte
};

String result = parseWeightData(data);
System.out.println(result);
}
}

串口监听器

考虑到串口持续输出数据,所以在串口初始化时,起一个线程专门监听串口数据并解析存储数据。为了减轻cpu压力,设置每100毫秒接收解析一次数据,实际上1秒一次也绝对够了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
package cn.allbs.weightscale.handler;  

import cn.allbs.weightscale.constants.CommonConstants;
import cn.allbs.weightscale.util.SerialPortUtil;
import com.fazecast.jSerialComm.SerialPort;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.RedisTemplate;

import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;

@Slf4j
public class SerialPortListener implements Runnable {

private final SerialPort serialPort;
private final String portName;
private final RedisTemplate<String, Object> redisTemplate;
private final ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1);
private final String redisKey;

public SerialPortListener(SerialPort serialPort, String portName, RedisTemplate<String, Object> redisTemplate, String redisKey) {
this.serialPort = serialPort;
this.portName = portName;
this.redisTemplate = redisTemplate;
this.redisKey = redisKey;
}

@Override
public void run() {
scheduler.scheduleAtFixedRate(this::readFromPort, 0, 100, TimeUnit.MILLISECONDS);
}

private void readFromPort() {
try {
if (serialPort.bytesAvailable() > 0) {
byte[] readBuffer = new byte[12]; // 根据实际数据长度调整缓冲区大小
int numRead = serialPort.readBytes(readBuffer, readBuffer.length);
if (numRead > 0) {
String result = SerialPortUtil.parseWeightData(readBuffer);
log.info("{}读取到串口{}的数据为:{}", LocalDateTime.now().format(DateTimeFormatter.ofPattern(CommonConstants.DATETIME_PATTERN)), portName, result);
// 存入Redis
redisTemplate.opsForValue().set(redisKey, result);
}
}
} catch (Exception e) {
log.error("Error reading from serial port", e);
}
}

// 停止监听器的方法
public void stop() {
scheduler.shutdown();
}
}

其他相关说明

因为本来根据业务需求,指令应答方式的形式获取数据肯定是够了,所以一开始就偷了个懒没做连续发送方式的解析代码。但是老师傅在现场一句指令应答做不了给断送了。。。。只能使用连续发送的方式来获取数据,也就是项目中后续加了启动线程监听相关代码的原因。
image.png

接口说明

本项目目的是给其他服务提供地磅最新数据,所以加了接口供其他服务调用。接口文档路径为{ip}:{port}/dic.html,本项目默认设置端口为7878

连续发送方式

顾名思义项目启动后会一直接收串口数据,本项目中考虑到连续发送间隔时间太短,一是无意义数据较多,二是cpu负荷较大,所以额外起了线程,每100毫秒接收并解析一次,根据自己需要设置。
调整方式为修改SerialPortListener类中的scheduler.scheduleAtFixedRate(this::readFromPort, 0, 100, TimeUnit.MILLISECONDS);period即可,现在设置的时100,单位为毫秒
获取该数据有两种方式:

  • 一种是获取缓存到redis中的pc:weight:*,这个*代表的是不同地磅缓存的数据,具体定义见application.yml的active和SerialPortConfiggetPortMappings方法获取的rediskey值
  • 第二种是通过接口,/weight,这个接口是获取最新的一次称重数据,如果没有称重数据则返回null,参数需要传指定的串口名称,比如我当前项目两个串口分别为COM3,COM4一个进的地磅一个出的地磅

指令应答方式

根据指令获取具体数据
只能通过接口获取
接口: /scale
传的参数有

  • address地址,从A~Z
  • operationCode操作方式,A握手,B读毛重,C读皮重,D读净重
  • portName串口,比如当前项目的两个串口COM3,COM4,其他项目可能是COM1,COM2之类的。

项目运行效果

部署

考虑到是在windows中运行, 万一主机关机啥的得弄个开机自启,所以使用的是Windows Service Wrapper 这个工具

下载地址

http://repo.jenkins-ci.org/releases/com/sun/winsw/winsw

使用方式

将下载下来的exe名称修改为jar包相同的名称

然后新增一个和jar相同名称的xml,内置内容如下:

1
2
3
4
5
6
7
8
9
10
<service>
<id>weight-scale</id>
<name>Weight Scale Service</name>
<description>卡口进出地磅称重数据</description>
<executable>java</executable>
<arguments>-jar "D:\weight-scale\weight-scale-0.0.1.jar"</arguments>
<logmode>none</logmode>
<startmode>Automatic</startmode>
<stoptimeout>30sec</stoptimeout>
</service>

需要注意其中的logmod我这边设置的是none,因为内置了logback,有对应的日志文件在logs中所以不需要额外的logmod。

image-20240702112931433

注册jar为系统服务并启动

1
2
3
4
5
6
7
8
# 注册系统服务
./weight-scale-0.0.1.exe install
# 启动服务
./weight-scale-0.0.1.exe start
# 停止服务
./weight-scale-0.0.1.exe stop
# 注销系统服务
./weight-scale-0.0.1.exe uninstall

最后附上项目源码

如果对你有帮助,给个star哦😘
https://github.com/chenqi92/weight-scale.git