mbtoolcli 0.3.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,903 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Modbus Tool - A comprehensive Modbus Master/Slave testing tool.
4
+
5
+ A combined Modbus testing tool supporting both Master and Slave modes.
6
+ Similar to modpoll + diagslave, this tool allows you to:
7
+ - Act as a Master to read/write Modbus devices
8
+ - Act as a Slave to simulate Modbus devices
9
+
10
+ Usage:
11
+ mbtool [mode] [options]
12
+
13
+ Examples:
14
+ # Master mode - read 10 holding registers
15
+ mbtool --master -a 1 -c 10 -r 0
16
+
17
+ # Slave mode - start TCP server
18
+ mbtool --slave -p 502
19
+
20
+ # Master mode - write float value
21
+ mbtool --master --write -t float -a 1 -r 0 -v 3.14
22
+ """
23
+
24
+ import argparse
25
+ import csv
26
+ import logging
27
+ import signal
28
+ import sys
29
+ import time
30
+ from typing import Optional
31
+
32
+ from pymodbus.framer import Framer # noqa: F401 - used by rtu_server
33
+
34
+ from . import __version__
35
+ from .registers import ModbusRegisterMap
36
+ from .simulation import SimulationEngine, SimulationType
37
+ from .tcp_server import ModbusTCPServer
38
+ from .rtu_server import ModbusSerialServer
39
+ from .master import ModbusMaster
40
+ from .datatypes import DataType, ByteOrder, parse_data_type, parse_byte_order, format_value
41
+ from .colors import (
42
+ c, RST, BOLD, DIM, RED, RED_BOLD, GREEN, GREEN_BOLD,
43
+ YELLOW, YELLOW_BOLD, CYAN, CYAN_BOLD, MAGENTA, MAGENTA_BOLD, WHITE_BOLD,
44
+ )
45
+
46
+
47
+ def setup_logging(verbose: bool = False):
48
+ """Configure logging based on verbosity level."""
49
+ level = logging.DEBUG if verbose else logging.INFO
50
+ logging.basicConfig(
51
+ level=level,
52
+ format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
53
+ datefmt="%Y-%m-%d %H:%M:%S",
54
+ )
55
+
56
+
57
+ def enable_debug_frame():
58
+ """Enable raw Modbus frame logging via pymodbus debug loggers."""
59
+ for name in ["pymodbus", "pymodbus.client", "pymodbus.server",
60
+ "pymodbus.transport", "pymodbus.framer", "pymodbus.logging"]:
61
+ logging.getLogger(name).setLevel(logging.DEBUG)
62
+ logging.getLogger("pymodbus").addHandler(logging.StreamHandler())
63
+ print(c("Raw frame debug logging enabled", DIM))
64
+
65
+
66
+ def parse_alarm(expr: str) -> dict:
67
+ """Parse alarm expression like '0>100' or 'addr>threshold'."""
68
+ import re
69
+ m = re.match(r"(\d+)\s*(>|>=|<|<=|==|!=)\s*(-?\d+\.?\d*)", expr)
70
+ if not m:
71
+ print(c(f"Error: Invalid alarm expression '{expr}'. Format: ADDR>VALUE (e.g. 0>100)", RED_BOLD))
72
+ sys.exit(1)
73
+ addr = int(m.group(1))
74
+ op = m.group(2)
75
+ val_str = m.group(3)
76
+ return {"addr": addr, "op": op, "value": float(val_str) if "." in val_str else int(val_str)}
77
+
78
+
79
+ def parse_args(args=None):
80
+ """Parse command line arguments."""
81
+ parser = argparse.ArgumentParser(
82
+ description="Modbus Tool - A comprehensive Modbus Master/Slave testing tool",
83
+ formatter_class=argparse.RawDescriptionHelpFormatter,
84
+ epilog=f"""
85
+ {c('Usage Examples:', BOLD)}
86
+ {c('─'*60, DIM)}
87
+
88
+ {c('SLAVE MODE', CYAN_BOLD)}
89
+ %(prog)s --slave Start TCP server on port 502
90
+ %(prog)s --slave -p 5020 Start TCP on port 5020
91
+ %(prog)s --slave --simulate Enable default simulation
92
+ %(prog)s --slave -m rtu -p COM3 Start RTU server on COM3
93
+ %(prog)s --slave -m ascii -p COM3 Start ASCII server on COM3
94
+ %(prog)s --slave --register-map dev.json Load custom register map
95
+
96
+ {c('MASTER MODE - Read', CYAN_BOLD)}
97
+ %(prog)s --master -a 1 -r 0 -c 10 Read 10 holding registers (FC03)
98
+ %(prog)s --master -a 1 -r 0 -c 5 -t float Read 5 float values (2 regs each)
99
+ %(prog)s --master -a 1 -r 0 -c 10 -t hex Read in hex format
100
+ %(prog)s --master -f 1 -a 1 -r 0 -c 8 Read 8 coils (FC01)
101
+ %(prog)s --master -f 2 -a 1 -r 0 -c 8 Read 8 discrete inputs (FC02)
102
+ %(prog)s --master -f 4 -a 1 -r 0 -c 5 Read 5 input registers (FC04)
103
+
104
+ {c('MASTER MODE - Write', CYAN_BOLD)}
105
+ %(prog)s --master --write -a 1 -r 0 -v 100 Write 100 to register
106
+ %(prog)s --master --write -t float -a 1 -r 0 -v 3.14 Write float value
107
+ %(prog)s --master --write -a 0 -v "100,200,300" Batch write 3 values
108
+ %(prog)s --master --write-coil -r 0 --coil-state 1 Write coil ON (FC05)
109
+
110
+ {c('MASTER MODE - Poll', CYAN_BOLD)}
111
+ %(prog)s --master --poll -a 1 -r 0 -c 5 -i 2 Poll every 2s
112
+ %(prog)s --master --poll -i 1 -n 10 Poll 10 times then stop
113
+ %(prog)s --master --poll --alarm "0>100" Alarm on threshold
114
+ %(prog)s --master --poll --output data.csv Log results to CSV
115
+
116
+ {c('ADVANCED', CYAN_BOLD)}
117
+ %(prog)s --master -t float32 --byte-order CDBA Use word-swap byte order
118
+ %(prog)s --master --debug-frame Show raw Modbus frames
119
+ %(prog)s --scan -H 192.168.1.100 -p 502 Scan slave IDs 1-247
120
+
121
+ {c('Examples with output:', BOLD)}
122
+ {c('─'*60, DIM)}
123
+
124
+ # Read 10 holding registers (simplest read command)
125
+ %(prog)s --master -a 1 -r 0 -c 10
126
+ Output:
127
+ Reading 10 register(s) from address 0...
128
+ Results (10 values):
129
+ [0] = 100
130
+ [1] = 200
131
+ ...
132
+
133
+ # Continuous monitor with CSV logging and alarm
134
+ %(prog)s --master --poll -a 1 -r 0 -c 5 -t int16 -i 1 --output log.csv --alarm "0>1000"
135
+ Output:
136
+ Polling register 0, count=5, type=int16
137
+ Interval: 1.0s, Iterations: infinite
138
+ Output: log.csv
139
+ Alarm: register[0] > 1000
140
+ Press Ctrl+C to stop
141
+
142
+ [1] 150 !!! ALARM: register[0] > 1000 (current: 150) !!!
143
+ [2] 45
144
+ ...
145
+
146
+ {c('Function Codes:', BOLD)}
147
+ 1 Read Coils FC01 Read/write boolean coils
148
+ 2 Read Discrete Inputs FC02 Read-only boolean inputs
149
+ 3 Read Holding Registers FC03 Read/write 16-bit registers (default)
150
+ 4 Read Input Registers FC04 Read-only 16-bit registers
151
+
152
+ {c('Data Types:', BOLD)}
153
+ int16 Signed 16-bit integer 1 register Range: -32768 to 32767
154
+ uint16 Unsigned 16-bit integer 1 register Range: 0 to 65535
155
+ int32 Signed 32-bit integer 2 registers Range: -2^31 to 2^31-1
156
+ uint32 Unsigned 32-bit integer 2 registers Range: 0 to 2^32-1
157
+ float 32-bit IEEE 754 float 2 registers ~7 decimal digits precision
158
+ hex Hexadecimal view 1+ registers e.g. 0xABCD
159
+
160
+ {c('Byte Order Options (for 32-bit types):', BOLD)}
161
+ ABCD Big-Endian (standard) Reg0=High, Reg1=Low
162
+ CDBA Word swap (Siemens) Reg0=Low, Reg1=High
163
+ BADC Byte swap Bytes swapped within each register
164
+ DCBA Little-Endian Full little-endian
165
+
166
+ {c('More info:', DIM)}
167
+ Documentation: https://github.com/yourusername/modbus-tool#readme
168
+ Report issues: https://github.com/yourusername/modbus-tool/issues
169
+ """,
170
+ )
171
+
172
+ # ── Mode selection ──
173
+ mode_group = parser.add_mutually_exclusive_group()
174
+ mode_group.add_argument(
175
+ "--master",
176
+ action="store_true",
177
+ help="作为主站(客户端)读写从站设备",
178
+ )
179
+ mode_group.add_argument(
180
+ "--slave",
181
+ action="store_true",
182
+ help="作为从站(服务端)模拟一个 Modbus 设备",
183
+ )
184
+ mode_group.add_argument(
185
+ "--scan",
186
+ action="store_true",
187
+ help="扫描模式:探测 1-247 号从站 ID,发现网络中活动的设备",
188
+ )
189
+ mode_group.add_argument(
190
+ "--gui",
191
+ action="store_true",
192
+ help="启动 Web 图形界面(GUI),在浏览器中操作所有功能",
193
+ )
194
+
195
+ # ── Connection options ──
196
+ conn = parser.add_argument_group("Connection Options (连接选项)")
197
+ conn.add_argument(
198
+ "-m", "--mode",
199
+ metavar="PROTOCOL",
200
+ choices=["tcp", "rtu", "ascii", "rtu-tcp", "ascii-tcp"],
201
+ default="tcp",
202
+ help="通信协议:tcp=Modbus TCP标准协议, rtu=串口RTU(需真实串口), ascii=串口ASCII(需真实串口),\n"
203
+ " rtu-tcp=RTU over TCP(虚拟串口, 无需物理串口),\n"
204
+ " ascii-tcp=ASCII over TCP(虚拟串口, 无需物理串口) (默认: tcp)",
205
+ )
206
+ conn.add_argument(
207
+ "-H", "--host",
208
+ metavar="IP",
209
+ default="127.0.0.1",
210
+ help="TCP 模式下目标主机的 IP 地址 (默认: 127.0.0.1)",
211
+ )
212
+ conn.add_argument(
213
+ "-p", "--port",
214
+ metavar="PORT/PATH",
215
+ type=str,
216
+ default="502",
217
+ help="TCP 端口号(如 502) 或串口设备路径(如 COM3, /dev/ttyUSB0)。\n"
218
+ " TCP 模式填端口号,RTU/ASCII 模式填串口路径 (默认: 502)",
219
+ )
220
+ conn.add_argument(
221
+ "-a", "--slave-id",
222
+ metavar="ID",
223
+ type=str,
224
+ default="1",
225
+ help="从站设备 ID 号(1-247)。单个: 1, 多个逗号分隔: 1,2,3 (默认: 1)",
226
+ )
227
+ conn.add_argument(
228
+ "--timeout",
229
+ metavar="SEC",
230
+ type=float,
231
+ default=3.0,
232
+ help="等待从站响应的超时时长(秒),超时视为通信失败 (默认: 3.0)",
233
+ )
234
+
235
+ # ── Serial options ──
236
+ serial = parser.add_argument_group("Serial Port Options (串口参数, RTU/ASCII 模式)")
237
+ serial.add_argument(
238
+ "-b", "--baudrate",
239
+ metavar="RATE",
240
+ type=int,
241
+ default=9600,
242
+ help="串口波特率:300, 600, 1200, 2400, 4800, 9600, 19200, 38400, 57600, 115200 (默认: 9600)",
243
+ )
244
+ serial.add_argument(
245
+ "--parity",
246
+ metavar="P",
247
+ choices=["N", "E", "O"],
248
+ default="N",
249
+ help="串口校验位:N=无校验, E=偶校验, O=奇校验 (默认: N)",
250
+ )
251
+ serial.add_argument(
252
+ "--stopbits",
253
+ metavar="BITS",
254
+ type=int,
255
+ choices=[1, 2],
256
+ default=1,
257
+ help="停止位个数:1 或 2 (默认: 1)",
258
+ )
259
+ serial.add_argument(
260
+ "--bytesize",
261
+ metavar="BITS",
262
+ type=int,
263
+ choices=[7, 8],
264
+ default=8,
265
+ help="每字节数据位:7 或 8 (默认: 8)",
266
+ )
267
+ serial.add_argument(
268
+ "--inter-char-delay",
269
+ metavar="SEC",
270
+ type=float,
271
+ default=None,
272
+ help="RTU 模式下字符间额外延迟(秒),用于解决部分设备的通信时序问题",
273
+ )
274
+
275
+ # ── Master read options ──
276
+ rd = parser.add_argument_group("Master Read Options (主站读取参数)")
277
+ rd.add_argument(
278
+ "-r", "--register",
279
+ metavar="ADDR",
280
+ type=int,
281
+ default=0,
282
+ help="要读取的起始寄存器/线圈地址编号 (默认: 0)",
283
+ )
284
+ rd.add_argument(
285
+ "-c", "--count",
286
+ metavar="N",
287
+ type=int,
288
+ default=1,
289
+ help="连续读取的寄存器/线圈/离散输入的数量 (默认: 1)",
290
+ )
291
+ rd.add_argument(
292
+ "-t", "--data-type",
293
+ metavar="TYPE",
294
+ type=str,
295
+ default="int16",
296
+ help="寄存器数值的解释格式:int16, uint16, int32, uint32, float, hex\n"
297
+ " int16=有符号16位整数, uint16=无符号16位整数,\n"
298
+ " int32=有符号32位(占2寄存器), float=32位浮点数(占2寄存器),\n"
299
+ " hex=十六进制显示 (默认: int16)",
300
+ )
301
+ rd.add_argument(
302
+ "-f", "--function-code",
303
+ metavar="FC",
304
+ type=int,
305
+ choices=[1, 2, 3, 4],
306
+ default=3,
307
+ help="Modbus 功能码:\n"
308
+ " 1=读线圈(FC01, 布尔值, 可读写)\n"
309
+ " 2=读离散输入(FC02, 布尔值, 只读)\n"
310
+ " 3=读保持寄存器(FC03, 16位值, 可读写) ←默认\n"
311
+ " 4=读输入寄存器(FC04, 16位值, 只读)",
312
+ )
313
+ rd.add_argument(
314
+ "--hex",
315
+ action="store_true",
316
+ help="同时以十六进制(0xABCD)格式显示数值",
317
+ )
318
+ rd.add_argument(
319
+ "--byte-order",
320
+ metavar="ORDER",
321
+ type=str,
322
+ default="ABCD",
323
+ help="32位数据(int32/uint32/float)的字节排列顺序:\n"
324
+ " ABCD=标准大端序(寄存器0=高16位, 1=低16位)\n"
325
+ " CDBA=西门子字交换(寄存器0=低16位, 1=高16位)\n"
326
+ " BADC=字节交换\n"
327
+ " DCBA=小端序(完整小端) (默认: ABCD)",
328
+ )
329
+
330
+ # ── Master write options ──
331
+ wr = parser.add_argument_group("Master Write Options (主站写入参数)")
332
+ wr.add_argument(
333
+ "--write",
334
+ action="store_true",
335
+ help="写入寄存器模式,配合 --value 指定要写入的值。\n"
336
+ " 示例:--write -r 0 --value 100 写入单值\n"
337
+ " 示例:--write -r 0 --value \"100,200,300\" 批量写入",
338
+ )
339
+ wr.add_argument(
340
+ "--write-coil",
341
+ action="store_true",
342
+ help="写入线圈模式(FC05),配合 --coil-state 指定 ON/OFF",
343
+ )
344
+ wr.add_argument(
345
+ "--coil-state",
346
+ metavar="0/1",
347
+ type=int,
348
+ choices=[0, 1],
349
+ default=1,
350
+ help="线圈目标状态:0=断开(OFF), 1=闭合(ON) (默认: 1)",
351
+ )
352
+ wr.add_argument(
353
+ "--value",
354
+ "-v",
355
+ metavar="VAL",
356
+ type=str,
357
+ help="要写入的值:\n"
358
+ " 单个值如 \"100\" 或 \"3.14\"\n"
359
+ " 批量值用逗号分隔如 \"100,200,300\" 会依次写入连续地址",
360
+ )
361
+
362
+ # ── Poll options ──
363
+ pl = parser.add_argument_group("Polling Options (轮询参数)")
364
+ pl.add_argument(
365
+ "--poll", "--continuous",
366
+ action="store_true",
367
+ help="连续轮询模式:按固定间隔反复读取寄存器值,按 Ctrl+C 停止",
368
+ )
369
+ pl.add_argument(
370
+ "-i", "--interval",
371
+ metavar="SEC",
372
+ type=float,
373
+ default=1.0,
374
+ help="轮询的时间间隔(秒)。设为 0.5 表示每秒读 2 次 (默认: 1.0)",
375
+ )
376
+ pl.add_argument(
377
+ "-n", "--iterations",
378
+ metavar="N",
379
+ type=int,
380
+ default=0,
381
+ help="轮询总次数,达到后自动停止。0 表示无限轮询直到 Ctrl+C (默认: 0)",
382
+ )
383
+ pl.add_argument(
384
+ "--alarm",
385
+ metavar="EXPR",
386
+ type=str,
387
+ default=None,
388
+ help="数值越限告警。格式:寄存器号>阈值(如 0>1000 表示寄存器0超过1000时告警)\n"
389
+ " 支持的运算符: >, <, >=, <=, ==, !=\n"
390
+ " 示例: '0>100' '2<=50' '1==0'",
391
+ )
392
+ pl.add_argument(
393
+ "--output", "-o",
394
+ metavar="FILE",
395
+ type=str,
396
+ default=None,
397
+ help="将轮询或读取结果导出到 CSV 文件,方便后用 Excel 分析",
398
+ )
399
+
400
+ # ── Slave options ──
401
+ sv = parser.add_argument_group("Slave Server Options (从站服务器参数)")
402
+ sv.add_argument(
403
+ "--register-map",
404
+ metavar="FILE.json",
405
+ type=str,
406
+ default=None,
407
+ help="从 JSON 文件加载自定义寄存器表,代替默认的寄存器映射。\n"
408
+ " 文件格式见 README.md 中的 JSON 示例",
409
+ )
410
+ sv.add_argument(
411
+ "--simulate",
412
+ action="store_true",
413
+ help="启动动态数值模拟(正弦波、计数器、线性斜坡等),让从站数据不断变化",
414
+ )
415
+ sv.add_argument(
416
+ "--sim-interval",
417
+ metavar="SEC",
418
+ type=float,
419
+ default=0.1,
420
+ help="模拟值的更新间隔(秒),越小变化越平滑 (默认: 0.1)",
421
+ )
422
+ sv.add_argument(
423
+ "--status",
424
+ action="store_true",
425
+ help="查看当前所有寄存器的数值和状态,查看后立即退出",
426
+ )
427
+
428
+ # ── Display options ──
429
+ disp = parser.add_argument_group("Display / Debug Options (显示与调试)")
430
+ disp.add_argument(
431
+ "--gui-port",
432
+ type=int,
433
+ default=8080,
434
+ help="Web GUI 的端口号 (默认: 8080)",
435
+ )
436
+ disp.add_argument(
437
+ "-V", "--verbose",
438
+ action="store_true",
439
+ help="显示详细的调试日志信息",
440
+ )
441
+ disp.add_argument(
442
+ "--debug-frame",
443
+ action="store_true",
444
+ help="启用 Modbus 协议原始帧日志,显示收发的十六进制报文",
445
+ )
446
+ disp.add_argument(
447
+ "--version",
448
+ action="version",
449
+ version=f"%(prog)s {__version__}",
450
+ )
451
+
452
+ # RTU port as positional argument (hidden, for backward compatibility)
453
+ parser.add_argument(
454
+ "port_positional",
455
+ nargs="?",
456
+ default=None,
457
+ help=argparse.SUPPRESS,
458
+ )
459
+
460
+ return parser.parse_args(args)
461
+
462
+
463
+ def resolve_port(args):
464
+ """Resolve port argument."""
465
+ port = args.port
466
+ is_serial = args.mode in ("rtu", "ascii")
467
+ if args.port_positional is not None:
468
+ try:
469
+ port = int(args.port_positional)
470
+ except ValueError:
471
+ if is_serial:
472
+ port = args.port_positional
473
+ else:
474
+ try:
475
+ port = int(args.port)
476
+ except ValueError:
477
+ if is_serial:
478
+ pass # serial port path
479
+ else:
480
+ port = 502 # fallback for TCP
481
+ return port
482
+
483
+
484
+ def parse_slave_ids(raw: str) -> list[int]:
485
+ """Parse slave ID string. Returns list of ints."""
486
+ raw = raw.strip()
487
+ if "," in raw:
488
+ return [int(x.strip()) for x in raw.split(",")]
489
+ return [int(raw)]
490
+
491
+
492
+ def run_scan_mode(args):
493
+ """Run the tool in Scan mode."""
494
+ port = resolve_port(args)
495
+
496
+ master = ModbusMaster(
497
+ host=args.host,
498
+ port=int(port) if isinstance(port, (int, str)) and str(port).isdigit() else 502,
499
+ mode=args.mode,
500
+ slave_id=1,
501
+ timeout=args.timeout,
502
+ baudrate=args.baudrate,
503
+ parity=args.parity,
504
+ stopbits=args.stopbits,
505
+ bytesize=args.bytesize,
506
+ )
507
+
508
+ addr_str = f"{args.mode.upper()} {args.host}:{port}"
509
+ print(f"\n{c('Scanning', YELLOW)} {c(addr_str, CYAN_BOLD)} {c('for active slave devices...', YELLOW)}")
510
+
511
+ if args.mode == "tcp":
512
+ port_num = int(port)
513
+ print(f" {c('Range:', DIM)} Slave ID 1-247")
514
+ print(f" {c('Target:', DIM)} {args.host}:{port_num}")
515
+ else:
516
+ print(f" {c('Range:', DIM)} Slave ID 1-247")
517
+ print(f" {c('Target:', DIM)} {port}")
518
+
519
+ if not master.connect():
520
+ print(c("Failed to connect!", RED_BOLD))
521
+ sys.exit(1)
522
+
523
+ print()
524
+ found = master.scan_slaves(start=1, end=247, timeout_per=min(args.timeout, 0.5))
525
+ master.disconnect()
526
+
527
+ print()
528
+ if found:
529
+ print(f" {c('Found', GREEN_BOLD)} {c(str(len(found)), CYAN_BOLD)} {c('active slave(s):', GREEN)} {c(', '.join(str(s) for s in found), CYAN_BOLD)}")
530
+ else:
531
+ print(f" {c('No active slaves found.', YELLOW)}")
532
+
533
+
534
+ def run_master_mode(args):
535
+ """Run the tool in Master mode."""
536
+ logger = logging.getLogger(__name__)
537
+ byte_order = parse_byte_order(args.byte_order)
538
+
539
+ # Handle port argument compatibility
540
+ port = resolve_port(args)
541
+
542
+ # Create master client
543
+ master = ModbusMaster(
544
+ host=args.host,
545
+ port=int(port) if isinstance(port, (int, str)) and str(port).isdigit() else port,
546
+ mode=args.mode,
547
+ slave_id=parse_slave_ids(args.slave_id)[0],
548
+ timeout=args.timeout,
549
+ baudrate=args.baudrate,
550
+ parity=args.parity,
551
+ stopbits=args.stopbits,
552
+ bytesize=args.bytesize,
553
+ )
554
+
555
+ # Connect
556
+ addr_str = f"{args.mode.upper()} {args.host}:{port}"
557
+ print(f"\n{c('Connecting to', YELLOW)} {c(addr_str, CYAN_BOLD)}...")
558
+ if not master.connect():
559
+ print(c("Failed to connect!", RED_BOLD))
560
+ sys.exit(1)
561
+
562
+ print(f"{c('Connected!', GREEN_BOLD)}\n")
563
+
564
+ try:
565
+ # Parse data type
566
+ data_type = parse_data_type(args.data_type)
567
+
568
+ if args.write_coil:
569
+ # Write coil mode
570
+ if args.register is None:
571
+ print(c("Error: -r/--register is required for coil write", RED_BOLD))
572
+ sys.exit(1)
573
+ value = bool(args.coil_state)
574
+ print(f"{c('Writing coil', YELLOW)} {c(str(args.register), CYAN_BOLD)} {c('=', YELLOW)} {c(str(value), CYAN_BOLD)}...")
575
+ success = master.write_single_coil(args.register, value)
576
+ if success:
577
+ print(c("Coil write successful!", GREEN_BOLD))
578
+ else:
579
+ print(c("Coil write failed!", RED_BOLD))
580
+ sys.exit(1)
581
+
582
+ elif args.write:
583
+ # Write mode
584
+ if args.value is None:
585
+ print(c("Error: --value is required for write operations", RED_BOLD))
586
+ sys.exit(1)
587
+
588
+ # Support batch write: comma-separated values
589
+ raw_values = [v.strip() for v in args.value.split(",")]
590
+ batch_mode = len(raw_values) > 1
591
+
592
+ parsed_values = []
593
+ for raw_val in raw_values:
594
+ try:
595
+ if data_type in (DataType.FLOAT32,):
596
+ parsed_values.append(float(raw_val))
597
+ else:
598
+ parsed_values.append(int(raw_val))
599
+ except ValueError:
600
+ print(f"{c('Error:', RED_BOLD)} {c(f'Invalid value \'{raw_val}\' for type {args.data_type}', RED)}")
601
+ sys.exit(1)
602
+
603
+ if batch_mode:
604
+ # Write multiple registers in sequence
605
+ print(f"{c('Batch writing', YELLOW)} {c(str(len(parsed_values)), CYAN_BOLD)} {c('value(s) starting at register', YELLOW)} {c(str(args.register), CYAN_BOLD)}...")
606
+ all_ok = True
607
+ for i, val in enumerate(parsed_values):
608
+ regs_per = DATA_TYPE_REGISTERS.get(data_type, 1)
609
+ ok = master.write_converted(args.register + i * regs_per, val, data_type, byte_order=byte_order)
610
+ if ok:
611
+ print(f" [{c(str(i), MAGENTA)}] = {c(str(val), CYAN_BOLD)} {c('OK', GREEN_BOLD)}")
612
+ else:
613
+ print(f" [{c(str(i), MAGENTA)}] = {c(str(val), CYAN_BOLD)} {c('FAILED', RED_BOLD)}")
614
+ all_ok = False
615
+ if all_ok:
616
+ print(c("Batch write successful!", GREEN_BOLD))
617
+ else:
618
+ print(c("Some writes failed!", RED_BOLD))
619
+ sys.exit(1)
620
+ else:
621
+ # Single value write
622
+ value = parsed_values[0]
623
+ print(f"{c('Writing', YELLOW)} {c(str(value), CYAN_BOLD)} {c('to register', YELLOW)} {c(str(args.register), CYAN_BOLD)}...")
624
+ success = master.write_converted(args.register, value, data_type, byte_order=byte_order)
625
+ if success:
626
+ print(c("Write successful!", GREEN_BOLD))
627
+ else:
628
+ print(c("Write failed!", RED_BOLD))
629
+ sys.exit(1)
630
+
631
+ elif args.poll:
632
+ # Polling mode
633
+ print(f"{c('Polling register', YELLOW)} {c(str(args.register), CYAN_BOLD)}, "
634
+ f"{c('count', YELLOW)}={c(str(args.count), CYAN_BOLD)}, "
635
+ f"{c('type', YELLOW)}={c(args.data_type, CYAN_BOLD)}")
636
+ print(f"{c('Interval', YELLOW)}: {c(f'{args.interval}s', CYAN_BOLD)}, "
637
+ f"{c('Iterations', YELLOW)}: {c(str(args.iterations or 'infinite'), CYAN_BOLD)}")
638
+ if byte_order != ByteOrder.ABCD:
639
+ print(f"{c('Byte Order', YELLOW)}: {c(byte_order.value, CYAN_BOLD)}")
640
+ if args.output:
641
+ print(f"{c('Output', YELLOW)}: {c(args.output, CYAN_BOLD)}")
642
+ print(f"{c('Press Ctrl+C to stop', DIM)}\n")
643
+
644
+ # Parse alarm if specified
645
+ alarm = None
646
+ if args.alarm:
647
+ alarm = parse_alarm(args.alarm)
648
+ print(f" {c('Alarm:', YELLOW)} register[{alarm['addr']}] {alarm['op']} {alarm['value']}\n")
649
+
650
+ from .datatypes import DATA_TYPE_REGISTERS
651
+ master.poll(
652
+ address=args.register,
653
+ count=args.count,
654
+ data_type=data_type,
655
+ function_code=args.function_code,
656
+ interval=args.interval,
657
+ iterations=args.iterations,
658
+ hex_format=args.hex,
659
+ byte_order=byte_order,
660
+ alarm=alarm,
661
+ output_file=args.output,
662
+ )
663
+
664
+ else:
665
+ # Read mode
666
+ print(f"{c('Reading', YELLOW)} {c(str(args.count), CYAN_BOLD)} {c('register(s) from address', YELLOW)} {c(str(args.register), CYAN_BOLD)}...")
667
+
668
+ value = master.read_and_convert(
669
+ address=args.register,
670
+ count=args.count,
671
+ data_type=data_type,
672
+ function_code=args.function_code,
673
+ byte_order=byte_order,
674
+ )
675
+
676
+ if value is not None:
677
+ # Export to CSV if requested
678
+ if args.output:
679
+ with open(args.output, "w", newline="") as f:
680
+ writer = csv.writer(f)
681
+ writer.writerow(["address", "value"])
682
+ if isinstance(value, list):
683
+ for i, v in enumerate(value):
684
+ writer.writerow([args.register + i, v])
685
+ else:
686
+ writer.writerow([args.register, value])
687
+ print(f"\n{c('Results exported to', DIM)} {c(args.output, CYAN_BOLD)}")
688
+
689
+ if isinstance(value, list):
690
+ print(f"\n{c(f'Results ({len(value)} values):', BOLD)}")
691
+ for i, v in enumerate(value):
692
+ formatted = format_value(v, data_type, args.hex)
693
+ print(f" [{c(str(args.register + i), MAGENTA)}] = {c(formatted, CYAN_BOLD)}")
694
+ else:
695
+ formatted = format_value(value, data_type, args.hex)
696
+ print(f"\n{c('Result:', BOLD)} {c(formatted, CYAN_BOLD)}")
697
+ else:
698
+ print(f"\n{c('Read failed!', RED_BOLD)}")
699
+ sys.exit(1)
700
+
701
+ finally:
702
+ master.disconnect()
703
+
704
+
705
+ def run_slave_mode(args):
706
+ """Run the tool in Slave mode."""
707
+ logger = logging.getLogger(__name__)
708
+
709
+ # Handle port argument compatibility
710
+ port = resolve_port(args)
711
+
712
+ # Create register map
713
+ if args.register_map:
714
+ try:
715
+ register_map = ModbusRegisterMap.load_from_json(args.register_map)
716
+ print(f"{c('Loaded register map from', DIM)} {c(args.register_map, CYAN_BOLD)}")
717
+ except Exception as e:
718
+ print(f"{c('Error loading register map:', RED_BOLD)} {c(str(e), RED)}")
719
+ sys.exit(1)
720
+ else:
721
+ register_map = ModbusRegisterMap()
722
+ register_map.create_default_map()
723
+
724
+ # Show status and exit
725
+ if args.status:
726
+ register_map.print_status()
727
+ sys.exit(0)
728
+
729
+ # Create and start server
730
+ server = None
731
+ simulation_engine = None
732
+
733
+ try:
734
+ if args.mode == "tcp":
735
+ server = ModbusTCPServer(
736
+ register_map=register_map,
737
+ host=args.host,
738
+ port=int(port),
739
+ slave_id=parse_slave_ids(args.slave_id)[0],
740
+ )
741
+ logger.info(f"Starting Modbus TCP server on {args.host}:{port}")
742
+ elif args.mode == "rtu-tcp":
743
+ server = ModbusTCPServer(
744
+ register_map=register_map,
745
+ host=args.host,
746
+ port=int(port),
747
+ slave_id=parse_slave_ids(args.slave_id)[0],
748
+ framer="rtu-tcp",
749
+ )
750
+ logger.info(f"Starting Modbus RTU over TCP server on {args.host}:{port}")
751
+ elif args.mode == "ascii-tcp":
752
+ server = ModbusTCPServer(
753
+ register_map=register_map,
754
+ host=args.host,
755
+ port=int(port),
756
+ slave_id=parse_slave_ids(args.slave_id)[0],
757
+ framer="ascii-tcp",
758
+ )
759
+ logger.info(f"Starting Modbus ASCII over TCP server on {args.host}:{port}")
760
+ else:
761
+ framer = "rtu" if args.mode == "rtu" else "ascii"
762
+ protocol_name = "ASCII" if args.mode == "ascii" else "RTU"
763
+ server = ModbusSerialServer(
764
+ register_map=register_map,
765
+ port=str(port),
766
+ baudrate=args.baudrate,
767
+ parity=args.parity,
768
+ stopbits=args.stopbits,
769
+ bytesize=args.bytesize,
770
+ timeout=args.timeout,
771
+ slave_id=parse_slave_ids(args.slave_id)[0],
772
+ framer=framer,
773
+ inter_char_delay=args.inter_char_delay,
774
+ )
775
+ logger.info(f"Starting Modbus {protocol_name} server on {port}")
776
+
777
+ # Setup simulation if requested
778
+ if args.simulate:
779
+ simulation_engine = SimulationEngine(register_map)
780
+ simulation_engine.interval = args.sim_interval
781
+ simulation_engine.setup_default_simulations()
782
+ logger.info("Simulation enabled with default patterns")
783
+
784
+ # Start the server
785
+ server.start()
786
+
787
+ # Start simulation engine if enabled
788
+ if simulation_engine:
789
+ simulation_engine.start()
790
+
791
+ # Print status
792
+ sim_label = c("Enabled", GREEN_BOLD) if args.simulate else c("Disabled", DIM)
793
+
794
+ # Determine protocol display name
795
+ proto_display = {
796
+ "tcp": "TCP",
797
+ "rtu": "RTU (Serial)",
798
+ "ascii": "ASCII (Serial)",
799
+ "rtu-tcp": "RTU over TCP",
800
+ "ascii-tcp": "ASCII over TCP",
801
+ }.get(args.mode, args.mode.upper())
802
+
803
+ is_real_serial = args.mode in ("rtu", "ascii")
804
+ is_tcp_mode = args.mode in ("tcp", "rtu-tcp", "ascii-tcp")
805
+
806
+ print(f"\n{c('='*60, DIM)}")
807
+ print(f"{c('Modbus Slave Server Status:', CYAN_BOLD)}")
808
+ print(f" {c('Protocol:', WHITE_BOLD)} {c(proto_display, YELLOW_BOLD)}")
809
+ if is_tcp_mode:
810
+ print(f" {c('Address:', WHITE_BOLD)} {c(f'{args.host}:{port}', YELLOW_BOLD)}")
811
+ else:
812
+ print(f" {c('Port:', WHITE_BOLD)} {c(str(port), YELLOW_BOLD)}")
813
+ print(f" {c('Baudrate:', WHITE_BOLD)} {c(str(args.baudrate), YELLOW_BOLD)}")
814
+ print(f" {c('Parity:', WHITE_BOLD)} {c(args.parity, YELLOW_BOLD)}")
815
+ print(f" {c('Stopbits:', WHITE_BOLD)} {c(str(args.stopbits), YELLOW_BOLD)}")
816
+ print(f" {c('Slave ID:', WHITE_BOLD)} {c(args.slave_id, YELLOW_BOLD)}")
817
+ if args.register_map:
818
+ print(f" {c('Reg Map:', WHITE_BOLD)} {c(args.register_map, YELLOW_BOLD)}")
819
+ print(f" {c('Simulation:', WHITE_BOLD)} {sim_label}")
820
+ print(f"{c('='*60, DIM)}\n")
821
+
822
+ print(f"{c('Server is running.', GREEN_BOLD)} {c('Press Ctrl+C to stop.', DIM)}\n")
823
+
824
+ # Setup signal handlers
825
+ def signal_handler(signum, frame):
826
+ print(f"\n\n{c('Shutting down server...', YELLOW_BOLD)}")
827
+ raise KeyboardInterrupt
828
+
829
+ signal.signal(signal.SIGINT, signal_handler)
830
+ signal.signal(signal.SIGTERM, signal_handler)
831
+
832
+ # Keep running until interrupted
833
+ import math
834
+ _start = time.time()
835
+ while True:
836
+ if simulation_engine and simulation_engine.is_running():
837
+ elapsed = time.time() - _start
838
+ # Holding registers 0-1: Lasheng encoder (sine wave)
839
+ enc_val = int(500000 + 300000 * math.sin(2 * math.pi * 0.05 * elapsed))
840
+ server.set_registers(0x03, 0, [(enc_val >> 16) & 0xFFFF, enc_val & 0xFFFF])
841
+ # Input registers 0-1: LingGuang distance (sine wave)
842
+ dist_val = int(150000000 + 100000000 * math.sin(2 * math.pi * 0.03 * elapsed))
843
+ server.set_registers(0x04, 0, [(dist_val >> 16) & 0xFFFF, dist_val & 0xFFFF])
844
+ # Holding registers 61-63: Qingjiao Roll/Pitch/Yaw
845
+ roll_val = int(3000 * math.sin(2 * math.pi * 0.02 * elapsed))
846
+ pitch_val = int(2000 * math.sin(2 * math.pi * 0.025 * elapsed + 1))
847
+ yaw_val = int(1000 * math.sin(2 * math.pi * 0.015 * elapsed + 2))
848
+ server.set_registers(0x03, 61, [roll_val & 0xFFFF, pitch_val & 0xFFFF, yaw_val & 0xFFFF])
849
+ time.sleep(0.2)
850
+
851
+ except KeyboardInterrupt:
852
+ print(f"\n\n{c('Stopping server...', YELLOW_BOLD)}")
853
+ except Exception as e:
854
+ logger.error(f"Server error: {e}")
855
+ sys.exit(1)
856
+ finally:
857
+ # Cleanup
858
+ if simulation_engine:
859
+ simulation_engine.stop()
860
+ if server:
861
+ server.stop()
862
+ print(c("Server stopped.", DIM))
863
+
864
+
865
+ def main(args=None):
866
+ """Main entry point for the Modbus Tool."""
867
+ parsed = parse_args(args)
868
+ setup_logging(parsed.verbose)
869
+
870
+ # Launch Web GUI mode
871
+ if parsed.gui:
872
+ from .gui_server import run_gui
873
+ run_gui(host="127.0.0.1", port=parsed.gui_port)
874
+ return
875
+
876
+ if not (parsed.scan or parsed.master or parsed.slave):
877
+ print(c("Error: 请指定运行模式 --master, --slave, --scan 或 --gui", RED_BOLD))
878
+ print(c("提示:运行 --help 查看帮助", DIM))
879
+ sys.exit(1)
880
+
881
+ # Enable debug frame logging if requested
882
+ if parsed.debug_frame:
883
+ enable_debug_frame()
884
+
885
+ # Print banner
886
+ ver = f"v{__version__}"
887
+ print(f"""
888
+ {c('╔══════════════════════════════════════════════════════════════╗', CYAN_BOLD)}
889
+ {c('║', CYAN_BOLD)} {c(f'Modbus Tool {ver}', GREEN_BOLD)}{c(f'{" "*(25-len(ver))}║', CYAN_BOLD)}
890
+ {c('║', CYAN_BOLD)} {c('Master + Slave Testing Tool Combined', YELLOW_BOLD)}{c(' ║', CYAN_BOLD)}
891
+ {c('╚══════════════════════════════════════════════════════════════╝', CYAN_BOLD)}
892
+ """)
893
+
894
+ if parsed.scan:
895
+ run_scan_mode(parsed)
896
+ elif parsed.master:
897
+ run_master_mode(parsed)
898
+ else:
899
+ run_slave_mode(parsed)
900
+
901
+
902
+ if __name__ == "__main__":
903
+ main()