上海郢昱
9 小时以前 0d9956362ef415d0b49318746e9a2ef955dcfebd
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
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
package com.hwtd.mes.collect.handler;
 
import com.hwtd.mes.collect.dto.SerialPortDTO;
import com.fazecast.jSerialComm.SerialPort;
import com.fazecast.jSerialComm.SerialPortDataListener;
import com.fazecast.jSerialComm.SerialPortEvent;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
 
import javax.annotation.PostConstruct;
import javax.annotation.PreDestroy;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Locale;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.ReentrantLock;
 
/**
 * 串口监听(手动模式)
 * <p>
 * 与原来“项目启动就开始监听”的方式不同,现在改为手动监听:
 * <ul>
 *     <li>调用采集接口(/lims/getFile 且 fileExtension=.serialPort)时开启监听,串口没有数据时接口返回空集合,由前端轮询取数;</li>
 *     <li>也可以调用 /lims/openSerialPort 手动开启监听;</li>
 *     <li>采集结束后调用 /lims/closeSerialPort 手动关闭监听(幂等,未开启时调用也不会报错);</li>
 *     <li>项目停止时自动关闭监听。</li>
 * </ul>
 * 串口参数(串口名、波特率、数据位、停止位、校验位、流控、读取超时、结束标志、单轮条数上限)全部由接口传入,
 * 不传时使用 {@link SerialPortDTO} 里的默认值;参数和当前生效的参数不一致时会自动按新参数重新打开串口,
 * 参数一致则复用已经打开的串口,不会重复打开。
 * <p>
 * 并发说明:
 * <ul>
 *     <li>开启/关闭串口统一由 {@link #portLock} 保证互斥,重复调用只会真正打开一次串口,
 *         多线程并发调用采集接口也不会重复打开、重复注册监听器;</li>
 *     <li>采集到的数据放在同步集合中,多线程同时取数时每条数据只会被一个线程取走,不会重复、不会丢失;</li>
 *     <li>监听状态使用 volatile 标记,关闭串口后回调线程不会再处理数据。</li>
 * </ul>
 * 数据缓存说明:采集到的数据按队列缓存,插入最新的一条,超出 maxCount 条(默认 6 条)时移除最旧的一条,
 * 因此接口每次最多返回 maxCount 条最新的数据(按接收先后顺序);数据被取走后队列清空,重新开始缓存。
 * <p>
 * 帧切分说明:默认按结束标志 endMark 切分数据帧;如果设备最后一帧不带结束标志(例如每帧以 * 开头而不是结尾),
 * 串口空闲 frameIdleMillis 毫秒(默认 200ms)后会把接收缓冲区里剩下的数据作为一帧取出,避免漏掉最后一条。
 *
 * @author data-acquisition
 */
@Slf4j
@Component
public class SerialPortListener {
 
    /** 一直收不到结束标志时接收缓冲区的最大长度,超过则丢弃,防止内存无限增长 */
    private static final int MAX_RECEIVE_BUFFER_LENGTH = 4096;
 
    /**
     * 串口开关锁:开启 / 关闭串口互斥。
     * 多线程并发调用采集接口时,只有一个线程能真正打开串口,其它线程复用已经打开的串口。
     */
    private final ReentrantLock portLock = new ReentrantLock();
 
    /** 串口对象,只在持有 {@link #portLock} 时创建和关闭;外部只做状态判断 */
    private volatile SerialPort serialPort;
 
    /** 是否正在监听(volatile:串口回调线程与业务线程之间立即可见) */
    private volatile boolean listening = false;
 
    /** 当前生效的串口参数,只在持有 {@link #portLock} 时变更 */
    private volatile ActiveSetting activeSetting;
 
    /** 最后一次收到串口数据的时间,用于判断串口是否已经空闲(空闲补帧) */
    private volatile long lastDataTime = 0L;
 
    /** 空闲补帧任务:设备最后一帧不带结束标志时,靠它把剩余数据取出来 */
    private final ScheduledExecutorService frameFlushExecutor = Executors.newSingleThreadScheduledExecutor(runnable -> {
        Thread thread = new Thread(runnable, "serial-port-frame-flush");
        thread.setDaemon(true);
        return thread;
    });
 
    /** 接收缓冲区,由串口回调线程写入,用自身作为锁保护 */
    private final StringBuilder receiveBuffer = new StringBuilder();
 
    /**
     * 采集到的数据:串口回调线程写入,业务线程读取并清空,使用同步集合保证线程安全。
     * 队列式缓存:从尾部插入最新的一条,超出 maxCount 时从头部移除最旧的一条,最多保留 maxCount 条数据。
     */
    private final List<String> dataList = Collections.synchronizedList(new ArrayList<>());
 
    /**
     * 开启串口监听(幂等操作)
     * <p>
     * 多线程同时调用时:第一个线程真正打开串口并注册监听器,其它线程直接复用,不会重复打开串口。
     * 串口参数与当前生效参数不一致时会重新打开串口;串口被拔出导致异常断开时,下次调用会自动重新打开。
     *
     * @param setting 接口传入的串口参数,为 null 时全部使用默认值
     * @return true=监听已开启(包含本次调用之前就已开启的情况);false=开启失败(串口不存在、被占用,或串口功能未启用)
     * @throws IllegalArgumentException 串口参数非法(由 GlobalExceptionHandler 返回给前端)
     */
    public boolean startListening(SerialPortDTO setting) {
        // 参数校验、补默认值放在加锁之前:参数不对直接抛异常,不占用串口
        ActiveSetting requested = toActiveSetting(setting);
        portLock.lock();
        try {
            // 已经在监听、串口正常且参数没有变化:直接复用(重复开启的唯一判定点)
            if (listening && isPortOpen() && requested.equals(activeSetting)) {
                log.debug("串口 {} 监听已开启且参数未变化,本次调用直接复用", requested.getSerialPortName());
                return true;
            }
            if (activeSetting != null && !requested.equals(activeSetting)) {
                log.info("串口参数发生变化,按新参数重新打开串口:{} -> {}", activeSetting, requested);
            }
            // 首次开启、参数变化、上次打开失败、或串口异常断开时,先清理残留状态再重新打开
            closePortInternal();
            if (!openPort(requested)) {
                return false;
            }
            activeSetting = requested;
            log.info("串口 {} 监听已开启({})", requested.getSerialPortName(), requested.describe());
            return true;
        } finally {
            portLock.unlock();
        }
    }
 
    /**
     * 关闭串口监听(幂等操作)
     *
     * @return true=已关闭(包含本次调用之前就未开启的情况);false=关闭过程中出现异常
     */
    public boolean closeListening() {
        portLock.lock();
        try {
            boolean success = closePortInternal();
            if (success) {
                log.info("串口 {} 监听已关闭", getListenName());
            } else {
                log.warn("串口 {} 监听关闭失败,请检查串口状态", getListenName());
            }
            return success;
        } finally {
            portLock.unlock();
        }
    }
 
    /**
     * 取走当前采集到的数据(取走后清空缓冲区),供采集接口调用。
     * 加锁 + 快照 + 清空是一个原子操作,多线程并发取数时同一条数据只会被一个线程取走。
     *
     * @return 本次取走的数据,没有数据时返回空集合
     */
    public List<String> drainData() {
        synchronized (dataList) {
            if (dataList.isEmpty()) {
                return new ArrayList<>();
            }
            List<String> snapshot = new ArrayList<>(dataList);
            dataList.clear();
            log.info("串口 {} 本次取走 {} 条数据", getListenName(), snapshot.size());
            return snapshot;
        }
    }
 
    /**
     * @return 是否正在监听
     */
    public boolean isListening() {
        return listening && isPortOpen();
    }
 
    /**
     * @return 串口是否已经打开
     */
    public boolean isPortOpen() {
        SerialPort port = serialPort;
        return port != null && port.isOpen();
    }
 
    /**
     * @return 当前缓冲区中还未被取走的数据条数
     */
    public int dataSize() {
        synchronized (dataList) {
            return dataList.size();
        }
    }
 
    /**
     * @return 当前生效的串口名称;还没开启过监听时返回配置的默认串口名称
     */
    public String getListenName() {
        ActiveSetting current = activeSetting;
        return current != null ? current.getSerialPortName() : "";
    }
 
    /**
     * 项目停止时关闭串口
     */
    @PreDestroy
    public void destroy() {
        log.info("项目停止,关闭串口 {} 监听", getListenName());
        closeListening();
        frameFlushExecutor.shutdownNow();
    }
 
    /**
     * 把接口传入的串口参数补齐默认值并校验,转换成当前会话使用的参数快照
     *
     * @param setting 接口传入的参数,为 null 时全部使用默认值
     * @return 归一化后的串口参数
     * @throws IllegalArgumentException 参数非法
     */
    private ActiveSetting toActiveSetting(SerialPortDTO setting) {
        SerialPortDTO source = setting == null ? new SerialPortDTO() : setting;
        String portName = isBlank(source.getSerialPortName()) ? "" : source.getSerialPortName().trim();
        if (isBlank(portName)) {
            throw new IllegalArgumentException("串口名称不能为空:请通过 listenName 参数传入");
        }
        int baudRate = intOrDefault(source.getBaudRate(), SerialPortDTO.DEFAULT_BAUD_RATE);
        if (baudRate <= 0) {
            throw new IllegalArgumentException("串口参数 baudRate 非法:" + baudRate + ",必须大于 0");
        }
        int dataBits = checkRange("dataBits", intOrDefault(source.getDataBits(), SerialPortDTO.DEFAULT_DATA_BITS), 5, 8);
        int stopBits = checkRange("stopBits", intOrDefault(source.getStopBits(), SerialPortDTO.DEFAULT_STOP_BITS), 1, 3);
        int readTimeout = intOrDefault(source.getReadTimeout(), SerialPortDTO.DEFAULT_READ_TIMEOUT);
        if (readTimeout < 0) {
            throw new IllegalArgumentException("串口参数 readTimeout 非法:" + readTimeout + ",不能小于 0");
        }
        int maxCount = intOrDefault(source.getMaxCount(), SerialPortDTO.DEFAULT_MAX_COUNT);
        if (maxCount < 1) {
            throw new IllegalArgumentException("串口参数 maxCount 非法:" + maxCount + ",不能小于 1");
        }
        int frameIdleMillis = intOrDefault(source.getFrameIdleMillis(), SerialPortDTO.DEFAULT_FRAME_IDLE_MILLIS);
        if (frameIdleMillis < 0) {
            throw new IllegalArgumentException("串口参数 frameIdleMillis 非法:" + frameIdleMillis + ",不能小于 0");
        }
        String endMark = isBlank(source.getEndMark()) ? SerialPortDTO.DEFAULT_END_MARK : source.getEndMark();
        String parity = isBlank(source.getParity()) ? SerialPortDTO.DEFAULT_PARITY : source.getParity();
        String flowControl = isBlank(source.getFlowControl()) ? SerialPortDTO.DEFAULT_FLOW_CONTROL : source.getFlowControl();
        return new ActiveSetting(portName, baudRate, dataBits, stopBits,
                parseParity(parity), parseFlowControl(flowControl), readTimeout, endMark, maxCount, frameIdleMillis);
    }
 
    /**
     * 打开串口并注册数据监听器(调用方必须持有 {@link #portLock})
     *
     * @return 是否打开成功
     */
    private boolean openPort(ActiveSetting setting) {
        SerialPort port;
        try {
            port = SerialPort.getCommPort(setting.getSerialPortName());
            port.setBaudRate(setting.getBaudRate());        // 波特率
            port.setNumDataBits(setting.getDataBits());     // 数据位
            port.setNumStopBits(setting.getStopBits());     // 停止位
            port.setParity(setting.getParity());            // 校验位
            port.setFlowControl(setting.getFlowControl());  // 流控
            port.setComPortTimeouts(SerialPort.TIMEOUT_READ_SEMI_BLOCKING, setting.getReadTimeout(), 0); // 读取超时
 
            if (!port.openPort()) {
                log.error("串口 {} 打开失败,请检查串口是否存在、是否被其他程序占用", setting.getSerialPortName());
                return false;
            }
        } catch (Throwable t) {
            // 串口名称非法、参数不被驱动支持、驱动异常等情况统一返回失败,避免把异常抛给调用方(HTTP 线程)
            log.error("串口 {} 打开异常:{}", setting.getSerialPortName(), t.getMessage(), t);
            return false;
        }
        serialPort = port;
        // 新一轮采集开始:清掉上一轮未接收完整的半包和没被取走的数据,避免上一轮的数据混入本轮结果
        synchronized (receiveBuffer) {
            receiveBuffer.setLength(0);
        }
        discardCollectedData();
        // 先置为监听中再注册监听器,避免注册完成后第一批数据因状态未就绪被丢弃
        listening = true;
        try {
            port.addDataListener(buildDataListener(port, setting));
        } catch (Exception e) {
            log.error("串口 {} 注册数据监听器失败", setting.getSerialPortName(), e);
            // 注册失败时回滚,避免串口被占用却读不到数据
            closePortInternal();
            return false;
        }
        return true;
    }
 
    /**
     * 关闭串口并释放资源(调用方必须持有 {@link #portLock},本方法不会抛出异常)
     *
     * @return 是否正常关闭
     */
    private boolean closePortInternal() {
        SerialPort port = serialPort;
        serialPort = null;
        // 先置为未监听:串口回调线程不再处理后续数据
        listening = false;
        boolean success = true;
        if (port != null) {
            try {
                port.removeDataListener(); // 移除监听,避免关闭过程中回调还在触发
                if (port.isOpen() && !port.closePort()) {
                    log.warn("串口 {} 关闭返回失败,请确认串口是否已被拔出", getListenName());
                    success = false;
                }
            } catch (Exception e) {
                log.error("关闭串口 {} 出现异常", getListenName(), e);
                success = false;
            }
        }
        // 丢弃未接收完整的半包数据,避免下次开启时拼出错误数据
        synchronized (receiveBuffer) {
            receiveBuffer.setLength(0);
        }
        return success;
    }
 
    /**
     * 丢弃上一轮没有取走的数据(调用方必须持有 {@link #portLock})
     */
    private void discardCollectedData() {
        List<String> abandoned = drainData();
        if (!abandoned.isEmpty()) {
            log.warn("串口 {} 开启新一轮监听,丢弃上一轮未被取走的 {} 条数据:{}", getListenName(), abandoned.size(), abandoned);
        }
    }
 
    /**
     * 构建串口数据监听器,数据到达时在 jSerialComm 的监听线程中回调
     * <p>
     * 本轮使用的参数(结束标志、条数上限)直接固化在监听器里,避免解析时读到下一轮的参数。
     */
    private SerialPortDataListener buildDataListener(final SerialPort port, final ActiveSetting setting) {
        return new SerialPortDataListener() {
            /** 指定监听的事件类型:数据可用时触发 */
            @Override
            public int getListeningEvents() {
                return SerialPort.LISTENING_EVENT_DATA_AVAILABLE;
            }
 
            /** 数据可用时的处理逻辑 */
            @Override
            public void serialEvent(SerialPortEvent event) {
                if (event.getEventType() != SerialPort.LISTENING_EVENT_DATA_AVAILABLE) {
                    return;
                }
                // 关闭串口后可能还有一次回调在路上,这里再兜底判断一次
                if (!listening || !setting.equals(activeSetting)) {
                    return;
                }
                try {
                    int available = port.bytesAvailable();
                    if (available <= 0) {
                        return;
                    }
                    // 读取缓冲区中的所有字节
                    byte[] buffer = new byte[available];
                    int readBytes = port.readBytes(buffer, buffer.length);
                    if (readBytes > 0) {
                        // 把本次收到的零散数据转成字符串,追加到缓冲区
                        parseAndCollect(setting, new String(buffer, 0, readBytes, StandardCharsets.UTF_8));
                    }
                } catch (Exception e) {
                    // 单次读取异常不影响后续监听
                    log.error("串口 {} 读取数据异常:{}", setting.getSerialPortName(), e.getMessage(), e);
                }
            }
        };
    }
 
    /**
     * 解析接收到的数据:按结束标志切分,解析成数值后放入采集结果
     *
     * @param setting  本轮采集使用的串口参数
     * @param received 本次从串口读到的字符串
     */
    private void parseAndCollect(ActiveSetting setting, String received) {
        List<String> completeValues = new ArrayList<>();
        synchronized (receiveBuffer) {
            // 记录收到数据的时间,空闲补帧靠它判断串口是否已经没有新数据
            lastDataTime = System.currentTimeMillis();
            receiveBuffer.append(received);
            // 检查缓冲区是否包含“结束标志”,循环处理所有完整数据
            int endIndex = receiveBuffer.indexOf(setting.getEndMark());
            while (endIndex != -1) {
                // 提取“开头到结束标志”的完整数据
                String completeData = normalizeFrame(receiveBuffer.substring(0, endIndex));
                // 移除缓冲区中已处理的部分(保留剩余未完成的内容)
                receiveBuffer.delete(0, endIndex + setting.getEndMark().length());
                if (!completeData.isEmpty()) {
                    try {
                        completeValues.add(completeData);
                    } catch (NumberFormatException e) {
                        // 干扰数据只忽略当前这一条,不影响后续数据解析
                        log.warn("串口 {} 收到无法解析的数据,已忽略:[{}]", setting.getSerialPortName(), completeData);
                    }
                }
                endIndex = receiveBuffer.indexOf(setting.getEndMark());
            }
            // 一直没有结束标志时缓冲区会持续增长,超过上限直接丢弃,防止内存泄漏
            if (receiveBuffer.length() > MAX_RECEIVE_BUFFER_LENGTH) {
                log.warn("串口 {} 接收缓冲区超过 {} 个字符仍未收到结束标志,已丢弃缓存数据",
                        setting.getSerialPortName(), MAX_RECEIVE_BUFFER_LENGTH);
                receiveBuffer.setLength(0);
            }
        }
        if (completeValues.isEmpty()) {
            return;
        }
        enqueue(setting, completeValues);
    }
 
    /**
     * 把数据放入缓存队列:从尾部插入最新的一条,超出 maxCount 时从头部移除最旧的一条
     */
    private void enqueue(ActiveSetting setting, List<String> values) {
        synchronized (dataList) {
            for (String value : values) {
                dataList.add(value);
                while (dataList.size() > setting.getMaxCount()) {
                    String removed = dataList.remove(0);
                    log.debug("串口 {} 缓存已满 {} 条,移除最旧的一条数据 {}",
                            setting.getSerialPortName(), setting.getMaxCount(), removed);
                }
                log.info("串口 {} 接收到数据-->{}", setting.getSerialPortName(), value);
            }
        }
    }
 
    /**
     * 启动空闲补帧任务:设备最后一帧不带结束标志时,靠它把剩余数据取出来
     */
    @PostConstruct
    void startFrameFlushTask() {
        frameFlushExecutor.scheduleWithFixedDelay(this::flushIdleFrame, 100, 100, TimeUnit.MILLISECONDS);
    }
 
    /**
     * 空闲补帧:串口超过 frameIdleMillis 没有新数据时,把接收缓冲区里剩下的数据当作一帧取出来。
     * <p>
     * 典型场景:设备每帧以某个字符“开头”而不是“结尾”(如 *0 1 0),
     * 这时最后一帧不会有后续数据来触发切分,只靠结束标志永远取不出来。
     * 如果设备每帧都带结束标志,缓冲区在每次切分后都是空的,这里不会做任何事。
     */
    private void flushIdleFrame() {
        try {
            ActiveSetting setting = activeSetting;
            if (!listening || setting == null || setting.getFrameIdleMillis() <= 0) {
                return;
            }
            String pending;
            synchronized (receiveBuffer) {
                if (receiveBuffer.length() == 0) {
                    return;
                }
                long idle = System.currentTimeMillis() - lastDataTime;
                if (idle < setting.getFrameIdleMillis()) {
                    return;
                }
                pending = normalizeFrame(receiveBuffer.toString());
                receiveBuffer.setLength(0);
                log.info("串口 {} 已空闲 {}ms 没有收到新的结束标志,把缓冲区剩余数据 [{}] 作为一帧取出",
                        setting.getSerialPortName(), idle, pending);
            }
            if (!pending.isEmpty()) {
                enqueue(setting, Collections.singletonList(pending));
            }
        } catch (Exception e) {
            // 补帧异常不能影响后续补帧
            log.error("串口空闲补帧异常:{}", e.getMessage(), e);
        }
    }
 
    /**
     * 去掉数据里的换行和首尾空白
     */
    private static String normalizeFrame(String value) {
        return value.replace("\n", "").replace("\r", "").trim();
    }
 
    /**
     * 解析校验位参数:支持 NONE/ODD/EVEN/MARK/SPACE(不区分大小写),也兼容 0~4 的数字
     */
    private static int parseParity(String parity) {
        String value = parity.trim().toUpperCase(Locale.ROOT);
        switch (value) {
            case "NONE":
            case "N":
            case "NO":
                return SerialPort.NO_PARITY;
            case "ODD":
            case "O":
                return SerialPort.ODD_PARITY;
            case "EVEN":
            case "E":
                return SerialPort.EVEN_PARITY;
            case "MARK":
            case "M":
                return SerialPort.MARK_PARITY;
            case "SPACE":
            case "S":
                return SerialPort.SPACE_PARITY;
            default:
                int number = parseInt(value, -1);
                if (number >= SerialPort.NO_PARITY && number <= SerialPort.SPACE_PARITY) {
                    return number;
                }
                throw new IllegalArgumentException("串口参数 parity 非法:" + parity + ",可选 NONE/ODD/EVEN/MARK/SPACE,或 0~4 的数字");
        }
    }
 
    /**
     * 解析流控参数:支持 NONE/RTS_CTS/XON_XOFF(不区分大小写),也兼容 jSerialComm 的数字常量
     */
    private static int parseFlowControl(String flowControl) {
        String value = flowControl.trim().toUpperCase(Locale.ROOT).replace('-', '_');
        switch (value) {
            case "NONE":
            case "OFF":
            case "DISABLED":
                return SerialPort.FLOW_CONTROL_DISABLED;
            case "RTS_CTS":
            case "RTSCTS":
            case "HARDWARE":
                return SerialPort.FLOW_CONTROL_RTS_ENABLED | SerialPort.FLOW_CONTROL_CTS_ENABLED;
            case "XON_XOFF":
            case "XONXOFF":
            case "SOFTWARE":
                return SerialPort.FLOW_CONTROL_XONXOFF_IN_ENABLED | SerialPort.FLOW_CONTROL_XONXOFF_OUT_ENABLED;
            default:
                int number = parseInt(value, -1);
                if (number >= 0) {
                    return number;
                }
                throw new IllegalArgumentException("串口参数 flowControl 非法:" + flowControl + ",可选 NONE/RTS_CTS/XON_XOFF,或数字常量");
        }
    }
 
    /**
     * 校验取值范围
     */
    private static int checkRange(String name, int value, int min, int max) {
        if (value < min || value > max) {
            throw new IllegalArgumentException("串口参数 " + name + " 非法:" + value + ",取值范围 " + min + "~" + max);
        }
        return value;
    }
 
    /**
     * 接口没传(null)时使用默认值
     */
    private static int intOrDefault(Integer value, int defaultValue) {
        return value == null ? defaultValue : value;
    }
 
    /**
     * 解析数字,解析不了返回默认值
     */
    private static int parseInt(String value, int defaultValue) {
        try {
            return Integer.parseInt(value);
        } catch (NumberFormatException e) {
            return defaultValue;
        }
    }
 
    private static boolean isBlank(String value) {
        return value == null || value.trim().isEmpty();
    }
 
    /**
     * 当前生效的串口参数(接口参数补默认值、校验、归一化之后的快照,用于判断参数是否发生变化)
     */
    @Data
    private static final class ActiveSetting {
 
        /** 串口名称 */
        private final String serialPortName;
        /** 波特率 */
        private final int baudRate;
        /** 数据位 */
        private final int dataBits;
        /** 停止位 */
        private final int stopBits;
        /** 校验位(jSerialComm 常量) */
        private final int parity;
        /** 流控(jSerialComm 常量) */
        private final int flowControl;
        /** 读取超时(毫秒) */
        private final int readTimeout;
        /** 一帧数据的结束标志 */
        private final String endMark;
        /** 单轮采集最多保留的数据条数 */
        private final int maxCount;
        /** 帧空闲补帧时间(毫秒),0 表示关闭 */
        private final int frameIdleMillis;
 
        /**
         * 日志用的参数描述
         */
        String describe() {
            return "波特率 " + baudRate
                    + ",数据位 " + dataBits
                    + ",停止位 " + stopBits
                    + ",校验位 " + parityName()
                    + ",流控 " + flowControlName()
                    + ",读取超时 " + readTimeout + "ms"
                    + ",结束标志 [" + endMark + "]"
                    + ",单轮最多 " + maxCount + " 条"
                    + ",空闲补帧 " + (frameIdleMillis > 0 ? frameIdleMillis + "ms" : "关闭");
        }
 
        private String parityName() {
            switch (parity) {
                case SerialPort.ODD_PARITY:
                    return "奇校验";
                case SerialPort.EVEN_PARITY:
                    return "偶校验";
                case SerialPort.MARK_PARITY:
                    return "MARK";
                case SerialPort.SPACE_PARITY:
                    return "SPACE";
                default:
                    return "无校验";
            }
        }
 
        private String flowControlName() {
            if (flowControl == SerialPort.FLOW_CONTROL_DISABLED) {
                return "无";
            }
            if (flowControl == (SerialPort.FLOW_CONTROL_RTS_ENABLED | SerialPort.FLOW_CONTROL_CTS_ENABLED)) {
                return "RTS/CTS";
            }
            if (flowControl == (SerialPort.FLOW_CONTROL_XONXOFF_IN_ENABLED | SerialPort.FLOW_CONTROL_XONXOFF_OUT_ENABLED)) {
                return "XON/XOFF";
            }
            return String.valueOf(flowControl);
        }
    }
}