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,1049 @@
1
+ """
2
+ Modbus Tool Web GUI Server.
3
+
4
+ Launches a local HTTP server with a web-based UI for controlling
5
+ all Modbus Master/Slave/Scan operations through a browser.
6
+ Zero extra dependencies - uses only Python stdlib + pymodbus.
7
+ """
8
+
9
+ import csv
10
+ import io
11
+ import json
12
+ import logging
13
+ import threading
14
+ import time
15
+ import uuid
16
+ from http.server import HTTPServer, BaseHTTPRequestHandler
17
+ from typing import Optional
18
+ from urllib.parse import urlparse, parse_qs
19
+
20
+ from .master import ModbusMaster
21
+ from .registers import ModbusRegisterMap
22
+ from .tcp_server import ModbusTCPServer
23
+ from .rtu_server import ModbusSerialServer
24
+ from .simulation import SimulationEngine
25
+ from .datatypes import (
26
+ ByteOrder, DataType, parse_data_type, parse_byte_order, format_value,
27
+ )
28
+
29
+ logger = logging.getLogger(__name__)
30
+
31
+ # ── Global state ──
32
+
33
+ _poll_threads: dict = {}
34
+ _poll_buffers: dict = {}
35
+ _scan_progress: dict = {}
36
+ _slave_server = {"instance": None, "engine": None, "config": {}}
37
+
38
+
39
+ # ── Helper functions ──
40
+
41
+ def _make_response(data: dict, status=200) -> tuple:
42
+ body = json.dumps(data, ensure_ascii=False).encode("utf-8")
43
+ return (status, {"Content-Type": "application/json; charset=utf-8"}, body)
44
+
45
+
46
+ def _read_body(handler) -> dict:
47
+ length = int(handler.headers.get("Content-Length", 0))
48
+ raw = handler.rfile.read(length) if length else b"{}"
49
+ return json.loads(raw)
50
+
51
+
52
+ def _build_master(args: dict) -> Optional[ModbusMaster]:
53
+ port = args.get("port", "502")
54
+ try:
55
+ port_int = int(port)
56
+ except ValueError:
57
+ port_int = port
58
+ master = ModbusMaster(
59
+ host=args.get("host", "127.0.0.1"),
60
+ port=port_int,
61
+ mode=args.get("mode", "tcp"),
62
+ slave_id=int(args.get("slave_id", 1)),
63
+ timeout=float(args.get("timeout", 3.0)),
64
+ baudrate=int(args.get("baudrate", 9600)),
65
+ parity=args.get("parity", "N"),
66
+ stopbits=int(args.get("stopbits", 1)),
67
+ bytesize=int(args.get("bytesize", 8)),
68
+ )
69
+ if not master.connect():
70
+ return None
71
+ return master
72
+
73
+
74
+ # ── API Handlers ──
75
+
76
+ def api_read(body: dict) -> tuple:
77
+ master = _build_master(body)
78
+ if not master:
79
+ return _make_response({"success": False, "error": "Connection failed"})
80
+ try:
81
+ data_type = parse_data_type(body.get("data_type", "int16"))
82
+ byte_order = parse_byte_order(body.get("byte_order", "ABCD"))
83
+ function_code = int(body.get("function_code", 3))
84
+ value = master.read_and_convert(
85
+ address=int(body.get("register", 0)),
86
+ count=int(body.get("count", 1)),
87
+ data_type=data_type,
88
+ function_code=function_code,
89
+ byte_order=byte_order,
90
+ )
91
+ if value is None:
92
+ return _make_response({"success": False, "error": "Read failed"})
93
+ if isinstance(value, list):
94
+ vals = [v if isinstance(v, (int, float)) else str(v) for v in value]
95
+ else:
96
+ vals = [value if isinstance(value, (int, float)) else str(value)]
97
+ return _make_response({"success": True, "values": vals})
98
+ except Exception as e:
99
+ return _make_response({"success": False, "error": str(e)})
100
+ finally:
101
+ master.disconnect()
102
+
103
+
104
+ def api_write(body: dict) -> tuple:
105
+ master = _build_master(body)
106
+ if not master:
107
+ return _make_response({"success": False, "error": "Connection failed"})
108
+ try:
109
+ data_type = parse_data_type(body.get("data_type", "int16"))
110
+ byte_order = parse_byte_order(body.get("byte_order", "ABCD"))
111
+ value_str = body.get("value", "0")
112
+ # batch write
113
+ parts = [v.strip() for v in value_str.split(",")]
114
+ if len(parts) > 1:
115
+ regs_per = {DataType.INT32: 2, DataType.UINT32: 2, DataType.FLOAT32: 2}.get(data_type, 1)
116
+ all_ok = True
117
+ for i, p in enumerate(parts):
118
+ val = float(p) if data_type == DataType.FLOAT32 else int(float(p))
119
+ addr = int(body.get("register", 0)) + i * regs_per
120
+ if not master.write_converted(addr, val, data_type, byte_order=byte_order):
121
+ all_ok = False
122
+ return _make_response({"success": all_ok})
123
+ else:
124
+ val = float(parts[0]) if data_type == DataType.FLOAT32 else int(float(parts[0]))
125
+ ok = master.write_converted(
126
+ int(body.get("register", 0)), val, data_type, byte_order=byte_order,
127
+ )
128
+ return _make_response({"success": ok})
129
+ except Exception as e:
130
+ return _make_response({"success": False, "error": str(e)})
131
+ finally:
132
+ master.disconnect()
133
+
134
+
135
+ def api_write_coil(body: dict) -> tuple:
136
+ master = _build_master(body)
137
+ if not master:
138
+ return _make_response({"success": False, "error": "Connection failed"})
139
+ try:
140
+ ok = master.write_single_coil(
141
+ int(body.get("register", 0)),
142
+ bool(int(body.get("coil_state", 1))),
143
+ )
144
+ return _make_response({"success": ok})
145
+ except Exception as e:
146
+ return _make_response({"success": False, "error": str(e)})
147
+ finally:
148
+ master.disconnect()
149
+
150
+
151
+ def api_poll_start(body: dict) -> tuple:
152
+ poll_id = uuid.uuid4().hex[:8]
153
+ _poll_buffers[poll_id] = []
154
+ stop_event = threading.Event()
155
+ _poll_threads[poll_id] = stop_event
156
+
157
+ def _run():
158
+ master = _build_master(body)
159
+ if not master:
160
+ _poll_buffers[poll_id].append({"error": "Connection failed"})
161
+ return
162
+ try:
163
+ data_type = parse_data_type(body.get("data_type", "int16"))
164
+ byte_order = parse_byte_order(body.get("byte_order", "ABCD"))
165
+ function_code = int(body.get("function_code", 3))
166
+ register = int(body.get("register", 0))
167
+ count = int(body.get("count", 1))
168
+ interval = float(body.get("interval", 1.0))
169
+ iteration = 0
170
+ while not stop_event.is_set():
171
+ iteration += 1
172
+ value = master.read_and_convert(
173
+ address=register, count=count, data_type=data_type,
174
+ function_code=function_code, byte_order=byte_order,
175
+ )
176
+ entry = {"iteration": iteration}
177
+ if value is not None:
178
+ if isinstance(value, list):
179
+ entry["values"] = [v if isinstance(v, (int, float)) else str(v) for v in value]
180
+ else:
181
+ entry["values"] = [value if isinstance(value, (int, float)) else str(value)]
182
+ else:
183
+ entry["error"] = "Read failed"
184
+ _poll_buffers[poll_id].append(entry)
185
+ # keep buffer limited
186
+ if len(_poll_buffers[poll_id]) > 200:
187
+ _poll_buffers[poll_id] = _poll_buffers[poll_id][-100:]
188
+ time.sleep(interval)
189
+ except Exception as e:
190
+ _poll_buffers[poll_id].append({"error": str(e)})
191
+ finally:
192
+ master.disconnect()
193
+
194
+ t = threading.Thread(target=_run, daemon=True)
195
+ t.start()
196
+ return _make_response({"success": True, "poll_id": poll_id})
197
+
198
+
199
+ def api_poll_data(poll_id: str) -> tuple:
200
+ if poll_id not in _poll_buffers:
201
+ return _make_response({"success": False, "error": "Poll not found"})
202
+ data = list(_poll_buffers[poll_id])
203
+ running = poll_id in _poll_threads and not _poll_threads[poll_id].is_set()
204
+ return _make_response({"success": True, "data": data, "running": running})
205
+
206
+
207
+ def api_poll_stop(poll_id: str) -> tuple:
208
+ if poll_id in _poll_threads:
209
+ _poll_threads[poll_id].set()
210
+ _poll_buffers.pop(poll_id, None)
211
+ return _make_response({"success": True})
212
+
213
+
214
+ def api_slave_start(body: dict) -> tuple:
215
+ global _slave_server
216
+ if _slave_server["instance"] and _slave_server["instance"].is_running():
217
+ return _make_response({"success": False, "error": "Slave already running"})
218
+
219
+ register_map = ModbusRegisterMap()
220
+ register_map.create_default_map()
221
+ sim = None
222
+ server = None
223
+ try:
224
+ mode = body.get("mode", "tcp")
225
+ port = body.get("port", "502")
226
+ slave_id = int(body.get("slave_id", 1))
227
+
228
+ if mode == "tcp":
229
+ server = ModbusTCPServer(
230
+ register_map=register_map,
231
+ host=body.get("host", "0.0.0.0"),
232
+ port=int(port),
233
+ slave_id=slave_id,
234
+ )
235
+ else:
236
+ framer = __import__("pymodbus.framer", fromlist=[""]).FramerType
237
+ from pymodbus.framer import FramerType
238
+ server = ModbusSerialServer(
239
+ register_map=register_map,
240
+ port=str(port),
241
+ baudrate=int(body.get("baudrate", 9600)),
242
+ parity=body.get("parity", "N"),
243
+ stopbits=int(body.get("stopbits", 1)),
244
+ bytesize=int(body.get("bytesize", 8)),
245
+ timeout=float(body.get("timeout", 1.0)),
246
+ slave_id=slave_id,
247
+ framer=FramerType.ASCII if mode == "ascii" else FramerType.RTU,
248
+ )
249
+
250
+ if body.get("simulate"):
251
+ sim = SimulationEngine(register_map)
252
+ sim.interval = float(body.get("sim_interval", 0.1))
253
+ sim.setup_default_simulations()
254
+
255
+ server.start()
256
+ if sim:
257
+ sim.start()
258
+
259
+ _slave_server = {
260
+ "instance": server,
261
+ "engine": sim,
262
+ "config": {"mode": mode, "port": port, "slave_id": slave_id, "simulate": body.get("simulate", False)},
263
+ }
264
+ return _make_response({"success": True})
265
+ except Exception as e:
266
+ return _make_response({"success": False, "error": str(e)})
267
+
268
+
269
+ def api_slave_stop() -> tuple:
270
+ global _slave_server
271
+ if _slave_server["engine"]:
272
+ _slave_server["engine"].stop()
273
+ if _slave_server["instance"]:
274
+ _slave_server["instance"].stop()
275
+ _slave_server = {"instance": None, "engine": None, "config": {}}
276
+ return _make_response({"success": True})
277
+
278
+
279
+ def api_slave_status() -> tuple:
280
+ s = _slave_server
281
+ running = s["instance"] is not None and s["instance"].is_running()
282
+ return _make_response({
283
+ "success": True,
284
+ "running": running,
285
+ "config": s["config"],
286
+ })
287
+
288
+
289
+ def api_scan(body: dict) -> tuple:
290
+ scan_id = uuid.uuid4().hex[:8]
291
+ _scan_progress[scan_id] = {"found": [], "current": 0, "total": 247, "done": False}
292
+
293
+ def _run():
294
+ master = _build_master(body)
295
+ if not master:
296
+ _scan_progress[scan_id] = {"found": [], "current": 0, "total": 0, "done": True, "error": "Connection failed"}
297
+ return
298
+ try:
299
+ found = master.scan_slaves(
300
+ start=1, end=247,
301
+ timeout_per=min(float(body.get("timeout", 3.0)), 0.5),
302
+ )
303
+ _scan_progress[scan_id] = {"found": found, "current": 247, "total": 247, "done": True}
304
+ except Exception as e:
305
+ _scan_progress[scan_id]["error"] = str(e)
306
+ _scan_progress[scan_id]["done"] = True
307
+ finally:
308
+ master.disconnect()
309
+
310
+ t = threading.Thread(target=_run, daemon=True)
311
+ t.start()
312
+ return _make_response({"success": True, "scan_id": scan_id})
313
+
314
+
315
+ def api_scan_status(scan_id: str) -> tuple:
316
+ data = _scan_progress.get(scan_id, {"done": False, "found": [], "current": 0, "total": 247})
317
+ return _make_response({"success": True, **data})
318
+
319
+
320
+ def api_register_map_get(body: dict) -> tuple:
321
+ """Return default register map for display in the UI."""
322
+ rm = ModbusRegisterMap()
323
+ if body.get("filepath"):
324
+ try:
325
+ rm = ModbusRegisterMap.load_from_json(body["filepath"])
326
+ except Exception as e:
327
+ return _make_response({"success": False, "error": str(e)})
328
+ else:
329
+ rm.create_default_map()
330
+ sections = {}
331
+ for key in ["coils", "discrete_inputs", "input_registers", "holding_registers"]:
332
+ regs = getattr(rm, key, {})
333
+ sections[key] = [
334
+ {"address": addr, "value": reg.value, "description": reg.description}
335
+ for addr, reg in sorted(regs.items())
336
+ ]
337
+ return _make_response({"success": True, "sections": sections})
338
+
339
+
340
+ # ── HTTP Router ──
341
+
342
+ ROUTES = {
343
+ "/api/master/read": ("POST", api_read),
344
+ "/api/master/write": ("POST", api_write),
345
+ "/api/master/write-coil": ("POST", api_write_coil),
346
+ "/api/master/poll/start": ("POST", api_poll_start),
347
+ "/api/master/poll/stop": ("POST", lambda b: _not_found),
348
+ "/api/slave/start": ("POST", api_slave_start),
349
+ "/api/slave/stop": ("POST", lambda b: api_slave_stop()),
350
+ "/api/slave/status": ("GET", lambda b: api_slave_status()),
351
+ "/api/scan": ("POST", api_scan),
352
+ "/api/register-map": ("POST", api_register_map_get),
353
+ }
354
+
355
+
356
+ def _not_found() -> tuple:
357
+ return _make_response({"success": False, "error": "Not found"}, 404)
358
+
359
+
360
+ class _Handler(BaseHTTPRequestHandler):
361
+ def log_message(self, fmt, *args):
362
+ pass # suppress default logging
363
+
364
+ def _send(self, status, headers, body):
365
+ self.send_response(status)
366
+ for k, v in headers.items():
367
+ self.send_header(k, v)
368
+ self.send_header("Access-Control-Allow-Origin", "*")
369
+ self.end_headers()
370
+ if body:
371
+ self.wfile.write(body)
372
+
373
+ def do_GET(self):
374
+ parsed = urlparse(self.path)
375
+ path = parsed.path
376
+
377
+ # SSE for poll data
378
+ if path.startswith("/api/master/poll/") and path.endswith("/data"):
379
+ poll_id = path.split("/")[4]
380
+ self.send_response(200)
381
+ self.send_header("Content-Type", "text/event-stream")
382
+ self.send_header("Cache-Control", "no-cache")
383
+ self.send_header("Access-Control-Allow-Origin", "*")
384
+ self.end_headers()
385
+ try:
386
+ while True:
387
+ data = _poll_buffers.get(poll_id, [])
388
+ running = poll_id in _poll_threads and not _poll_threads[poll_id].is_set()
389
+ payload = json.dumps({"data": data[-1] if data else None, "running": running})
390
+ self.wfile.write(f"data: {payload}\n\n".encode())
391
+ if not running:
392
+ break
393
+ time.sleep(0.3)
394
+ except (BrokenPipeError, ConnectionResetError):
395
+ pass
396
+ return
397
+
398
+ # Scan status SSE
399
+ if path.startswith("/api/scan/") and path.endswith("/status"):
400
+ scan_id = path.split("/")[3]
401
+ self.send_response(200)
402
+ self.send_header("Content-Type", "text/event-stream")
403
+ self.send_header("Cache-Control", "no-cache")
404
+ self.send_header("Access-Control-Allow-Origin", "*")
405
+ self.end_headers()
406
+ try:
407
+ while True:
408
+ data = _scan_progress.get(scan_id, {})
409
+ payload = json.dumps(data)
410
+ self.wfile.write(f"data: {payload}\n\n".encode())
411
+ if data.get("done"):
412
+ break
413
+ time.sleep(0.5)
414
+ except (BrokenPipeError, ConnectionResetError):
415
+ pass
416
+ return
417
+
418
+ # API routes
419
+ if path in ROUTES:
420
+ method, handler = ROUTES[path]
421
+ if method == "GET":
422
+ status, headers, body = handler({})
423
+ self._send(status, headers, body)
424
+ return
425
+
426
+ self._send(*_make_response({"error": "Not found"}, 404))
427
+
428
+ def do_POST(self):
429
+ parsed = urlparse(self.path)
430
+ path = parsed.path
431
+ body = _read_body(self)
432
+
433
+ # dynamic routes: /api/master/poll/<id>/stop
434
+ if path.startswith("/api/master/poll/") and path.endswith("/stop"):
435
+ poll_id = path.split("/")[4]
436
+ status, headers, body = api_poll_stop(poll_id)
437
+ self._send(status, headers, body)
438
+ return
439
+
440
+ if path.startswith("/api/master/poll/") and path.endswith("/data"):
441
+ poll_id = path.split("/")[4]
442
+ status, headers, body = api_poll_data(poll_id)
443
+ self._send(status, headers, body)
444
+ return
445
+
446
+ if path.startswith("/api/scan/"):
447
+ scan_id = path.split("/")[3]
448
+ status, headers, body = api_scan_status(scan_id)
449
+ self._send(status, headers, body)
450
+ return
451
+
452
+ if path in ROUTES:
453
+ method, handler = ROUTES[path]
454
+ if method == "POST":
455
+ status, headers, body = handler(body)
456
+ self._send(status, headers, body)
457
+ return
458
+
459
+ self._send(*_make_response({"error": "Not found"}, 404))
460
+
461
+ def do_OPTIONS(self):
462
+ self.send_response(204)
463
+ self.send_header("Access-Control-Allow-Origin", "*")
464
+ self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
465
+ self.send_header("Access-Control-Allow-Headers", "Content-Type")
466
+ self.end_headers()
467
+
468
+ def do_HEAD(self):
469
+ self.do_GET()
470
+
471
+ # ── Serve the embedded frontend ──
472
+
473
+ def _serve_frontend(self):
474
+ html = FRONTEND_HTML
475
+ self.send_response(200)
476
+ self.send_header("Content-Type", "text/html; charset=utf-8")
477
+ self.send_header("Access-Control-Allow-Origin", "*")
478
+ self.end_headers()
479
+ self.wfile.write(html.encode("utf-8"))
480
+
481
+ def _route_get(self):
482
+ """Route GET requests - either serve frontend or API."""
483
+ parsed = urlparse(self.path)
484
+ path = parsed.path
485
+
486
+ # Serve frontend for root paths
487
+ if path in ("/", "/ui"):
488
+ self._serve_frontend()
489
+ return
490
+
491
+ # SSE for poll data
492
+ if path.startswith("/api/master/poll/") and path.endswith("/data"):
493
+ poll_id = path.split("/")[4]
494
+ self.send_response(200)
495
+ self.send_header("Content-Type", "text/event-stream")
496
+ self.send_header("Cache-Control", "no-cache")
497
+ self.send_header("Access-Control-Allow-Origin", "*")
498
+ self.end_headers()
499
+ try:
500
+ while True:
501
+ data = _poll_buffers.get(poll_id, [])
502
+ running = poll_id in _poll_threads and not _poll_threads[poll_id].is_set()
503
+ payload = json.dumps({"data": data[-1] if data else None, "running": running})
504
+ self.wfile.write(f"data: {payload}\n\n".encode())
505
+ if not running:
506
+ break
507
+ time.sleep(0.3)
508
+ except (BrokenPipeError, ConnectionResetError):
509
+ pass
510
+ return
511
+
512
+ # Scan status SSE
513
+ if path.startswith("/api/scan/") and path.endswith("/status"):
514
+ scan_id = path.split("/")[3]
515
+ self.send_response(200)
516
+ self.send_header("Content-Type", "text/event-stream")
517
+ self.send_header("Cache-Control", "no-cache")
518
+ self.send_header("Access-Control-Allow-Origin", "*")
519
+ self.end_headers()
520
+ try:
521
+ while True:
522
+ data = _scan_progress.get(scan_id, {})
523
+ payload = json.dumps(data)
524
+ self.wfile.write(f"data: {payload}\n\n".encode())
525
+ if data.get("done"):
526
+ break
527
+ time.sleep(0.5)
528
+ except (BrokenPipeError, ConnectionResetError):
529
+ pass
530
+ return
531
+
532
+ # API routes
533
+ if path in ROUTES:
534
+ method, handler = ROUTES[path]
535
+ if method == "GET":
536
+ status, headers, body = handler({})
537
+ self._send(status, headers, body)
538
+ return
539
+
540
+ self._send(*_make_response({"error": "Not found"}, 404))
541
+
542
+ # Override do_GET to route properly
543
+ def do_GET(self):
544
+ self._route_get()
545
+
546
+
547
+ # Monkey-patch: save original do_GET for backward compat
548
+ _Handler.do_GET_original = _Handler.do_GET
549
+
550
+ # ── Embedded Frontend HTML ──
551
+
552
+ FRONTEND_HTML = r"""<!DOCTYPE html>
553
+ <html lang="zh-CN">
554
+ <head>
555
+ <meta charset="UTF-8">
556
+ <meta name="viewport" content="width=device-width,initial-scale=1">
557
+ <title>Modbus Tool Web UI</title>
558
+ <style>
559
+ *{margin:0;padding:0;box-sizing:border-box}
560
+ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background:#0f1117;color:#e1e4e8;min-height:100vh}
561
+ .header{background:#161b22;border-bottom:1px solid #30363d;padding:16px 24px;display:flex;align-items:center;gap:12px}
562
+ .header h1{font-size:20px;color:#58a6ff;font-weight:600}
563
+ .header span{color:#8b949e;font-size:13px}
564
+ .tabs{display:flex;background:#161b22;border-bottom:1px solid #30363d;padding:0 16px;gap:0}
565
+ .tab{padding:12px 20px;cursor:pointer;color:#8b949e;font-size:14px;border-bottom:2px solid transparent;transition:.2s}
566
+ .tab:hover{color:#e1e4e8;background:#1c2128}
567
+ .tab.active{color:#58a6ff;border-bottom-color:#58a6ff}
568
+ .panels{display:none;padding:20px 24px}
569
+ .panels.active{display:block}
570
+ .card{background:#161b22;border:1px solid #30363d;border-radius:8px;padding:16px;margin-bottom:16px}
571
+ .card h3{font-size:14px;color:#8b949e;margin-bottom:12px;text-transform:uppercase;letter-spacing:.5px}
572
+ .row{display:flex;gap:12px;flex-wrap:wrap;align-items:end;margin-bottom:12px}
573
+ .row:last-child{margin-bottom:0}
574
+ .field{display:flex;flex-direction:column;gap:4px}
575
+ .field label{font-size:12px;color:#8b949e}
576
+ .field input,.field select{background:#0d1117;border:1px solid #30363d;border-radius:6px;padding:8px 12px;color:#e1e4e8;font-size:13px;outline:none;min-width:80px}
577
+ .field input:focus,.field select:focus{border-color:#58a6ff}
578
+ .btn{background:#238636;color:#fff;border:none;border-radius:6px;padding:8px 20px;cursor:pointer;font-size:13px;font-weight:500;transition:.2s}
579
+ .btn:hover{background:#2ea043}
580
+ .btn.danger{background:#da3633}
581
+ .btn.danger:hover{background:#f85149}
582
+ .btn.secondary{background:#21262d;border:1px solid #30363d;color:#e1e4e8}
583
+ .btn.secondary:hover{background:#30363d}
584
+ .btn:disabled{opacity:.6;cursor:not-allowed}
585
+ table{width:100%;border-collapse:collapse;font-size:13px}
586
+ th,td{padding:8px 12px;text-align:left;border-bottom:1px solid #21262d}
587
+ th{color:#8b949e;font-weight:500}
588
+ td{font-family:monospace}
589
+ .status-dot{display:inline-block;width:10px;height:10px;border-radius:50%;margin-right:6px}
590
+ .status-dot.green{background:#3fb950}
591
+ .status-dot.red{background:#f85149}
592
+ .status-dot.yellow{background:#d29922}
593
+ #chart-canvas{width:100%;height:200px;background:#0d1117;border:1px solid #30363d;border-radius:6px;margin-top:8px}
594
+ .poll-controls{display:flex;gap:8px;align-items:center}
595
+ .result-msg{padding:8px 12px;border-radius:6px;margin-top:8px;font-size:13px}
596
+ .result-msg.ok{background:#1b3a1f;color:#3fb950;border:1px solid #238636}
597
+ .result-msg.err{background:#3a1b1b;color:#f85149;border:1px solid #da3633}
598
+ @media(max-width:768px){.row{flex-direction:column}.field{width:100%}.field input,.field select{width:100%}}
599
+ </style>
600
+ </head>
601
+ <body>
602
+
603
+ <div class="header">
604
+ <h1>⚡ Modbus Tool</h1>
605
+ <span>Web UI · v0.2.0</span>
606
+ </div>
607
+
608
+ <div class="tabs">
609
+ <div class="tab active" onclick="switchTab('master')">📡 主站 (Master)</div>
610
+ <div class="tab" onclick="switchTab('slave')">🖥️ 从站 (Slave)</div>
611
+ <div class="tab" onclick="switchTab('scan')">🔍 扫描 (Scan)</div>
612
+ </div>
613
+
614
+ <!-- ── MASTER PANEL ── -->
615
+ <div id="panel-master" class="panels active">
616
+
617
+ <div class="card">
618
+ <h3>连接配置</h3>
619
+ <div class="row">
620
+ <div class="field"><label>协议</label><select id="m-proto"><option value="tcp">TCP</option><option value="rtu">RTU</option><option value="ascii">ASCII</option></select></div>
621
+ <div class="field" id="m-host-field"><label>主机</label><input id="m-host" value="127.0.0.1"></div>
622
+ <div class="field" id="m-port-field"><label>端口/串口</label><input id="m-port" value="502"></div>
623
+ <div class="field"><label>从站ID</label><input id="m-sid" value="1"></div>
624
+ <div class="field"><label>超时(s)</label><input id="m-timeout" value="3.0"></div>
625
+ </div>
626
+ <div class="row" id="m-serial-opts" style="display:none">
627
+ <div class="field"><label>波特率</label><select id="m-baud"><option>9600</option><option>19200</option><option>38400</option><option>57600</option><option>115200</option></select></div>
628
+ <div class="field"><label>校验</label><select id="m-parity"><option value="N">N</option><option value="E">E</option><option value="O">O</option></select></div>
629
+ <div class="field"><label>停止位</label><select id="m-stop"><option value="1">1</option><option value="2">2</option></select></div>
630
+ </div>
631
+ </div>
632
+
633
+ <div style="display:flex;gap:16px;flex-wrap:wrap">
634
+ <!-- Read -->
635
+ <div class="card" style="flex:1;min-width:320px">
636
+ <h3>读取寄存器</h3>
637
+ <div class="row">
638
+ <div class="field"><label>地址</label><input id="mr-addr" value="0"></div>
639
+ <div class="field"><label>数量</label><input id="mr-count" value="10"></div>
640
+ <div class="field"><label>数据类型</label><select id="mr-type"><option>int16</option><option>uint16</option><option>int32</option><option>uint32</option><option>float</option><option>hex</option></select></div>
641
+ <div class="field"><label>功能码</label><select id="mr-fc"><option value="3">FC03-保持寄存器</option><option value="1">FC01-线圈</option><option value="2">FC02-离散输入</option><option value="4">FC04-输入寄存器</option></select></div>
642
+ <div class="field"><label>字节序</label><select id="mr-bo"><option value="ABCD">ABCD</option><option value="CDBA">CDBA</option><option value="BADC">BADC</option><option value="DCBA">DCBA</option></select></div>
643
+ </div>
644
+ <button class="btn secondary" onclick="masterRead()">📖 读取</button>
645
+ <div id="mr-result"></div>
646
+ </div>
647
+
648
+ <!-- Write -->
649
+ <div class="card" style="flex:1;min-width:320px">
650
+ <h3>写入寄存器</h3>
651
+ <div class="row">
652
+ <div class="field"><label>地址</label><input id="mw-addr" value="0"></div>
653
+ <div class="field"><label>值(逗号分隔=批量)</label><input id="mw-val" value="100"></div>
654
+ <div class="field"><label>数据类型</label><select id="mw-type"><option>int16</option><option>uint16</option><option>int32</option><option>uint32</option><option>float</option></select></div>
655
+ </div>
656
+ <button class="btn" onclick="masterWrite()">✏️ 写入</button>
657
+ <div id="mw-result"></div>
658
+ </div>
659
+
660
+ <!-- Write Coil -->
661
+ <div class="card" style="flex:1;min-width:320px">
662
+ <h3>写线圈</h3>
663
+ <div class="row">
664
+ <div class="field"><label>线圈地址</label><input id="mwc-addr" value="0"></div>
665
+ <div class="field"><label>状态</label><select id="mwc-state"><option value="1">ON (闭合)</option><option value="0">OFF (断开)</option></select></div>
666
+ </div>
667
+ <button class="btn" onclick="masterWriteCoil()">✏️ 写线圈</button>
668
+ <div id="mwc-result"></div>
669
+ </div>
670
+ </div>
671
+
672
+ <!-- Poll -->
673
+ <div class="card">
674
+ <h3>连续轮询</h3>
675
+ <div class="row">
676
+ <div class="field"><label>地址</label><input id="mp-addr" value="0"></div>
677
+ <div class="field"><label>数量</label><input id="mp-count" value="5"></div>
678
+ <div class="field"><label>数据类型</label><select id="mp-type"><option>int16</option><option>uint16</option><option>int32</option><option>uint32</option><option>float</option><option>hex</option></select></div>
679
+ <div class="field"><label>间隔(s)</label><input id="mp-interval" value="1.0"></div>
680
+ <div class="field"><label>告警</label><input id="mp-alarm" placeholder="0>1000" style="width:100px"></div>
681
+ <div class="poll-controls">
682
+ <button class="btn" id="poll-start-btn" onclick="pollStart()">▶ 启动</button>
683
+ <button class="btn danger" id="poll-stop-btn" onclick="pollStop()" disabled>⏹ 停止</button>
684
+ </div>
685
+ </div>
686
+ <canvas id="chart-canvas"></canvas>
687
+ <div id="mp-result" style="margin-top:8px;max-height:150px;overflow-y:auto;font-family:monospace;font-size:12px"></div>
688
+ <div id="poll-id" style="display:none"></div>
689
+ </div>
690
+ </div>
691
+
692
+ <!-- ── SLAVE PANEL ── -->
693
+ <div id="panel-slave" class="panels">
694
+ <div class="card">
695
+ <h3>从站配置</h3>
696
+ <div class="row">
697
+ <div class="field"><label>协议</label><select id="s-proto"><option value="tcp">TCP</option><option value="rtu">RTU</option><option value="ascii">ASCII</option></select></div>
698
+ <div class="field"><label>端口/串口</label><input id="s-port" value="502"></div>
699
+ <div class="field"><label>从站ID</label><input id="s-sid" value="1"></div>
700
+ <div class="field"><label><input type="checkbox" id="s-sim"> 启用模拟</label></div>
701
+ </div>
702
+ <div class="row" id="s-serial-opts" style="display:none">
703
+ <div class="field"><label>波特率</label><select id="s-baud"><option>9600</option><option>19200</option></select></div>
704
+ <div class="field"><label>校验</label><select id="s-parity"><option value="N">N</option><option value="E">E</option><option value="O">O</option></select></div>
705
+ </div>
706
+ <div style="display:flex;gap:12px;align-items:center">
707
+ <button class="btn" id="s-start-btn" onclick="slaveStart()">▶ 启动从站</button>
708
+ <button class="btn danger" id="s-stop-btn" onclick="slaveStop()" disabled>⏹ 停止从站</button>
709
+ <span id="s-status" style="font-size:13px;color:#8b949e">已停止</span>
710
+ </div>
711
+ </div>
712
+ <div class="card">
713
+ <h3>寄存器状态</h3>
714
+ <div id="s-registers"><p style="color:#8b949e;font-size:13px">启动从站后自动加载</p></div>
715
+ </div>
716
+ </div>
717
+
718
+ <!-- ── SCAN PANEL ── -->
719
+ <div id="panel-scan" class="panels">
720
+ <div class="card">
721
+ <h3>扫描配置</h3>
722
+ <div class="row">
723
+ <div class="field"><label>协议</label><select id="sc-proto"><option value="tcp">TCP</option><option value="rtu">RTU</option></select></div>
724
+ <div class="field"><label>主机</label><input id="sc-host" value="127.0.0.1"></div>
725
+ <div class="field"><label>端口/串口</label><input id="sc-port" value="502"></div>
726
+ </div>
727
+ <button class="btn" id="sc-start-btn" onclick="scanStart()">🔍 开始扫描</button>
728
+ <div id="sc-progress" style="margin-top:8px;font-size:13px;color:#8b949e"></div>
729
+ </div>
730
+ <div class="card">
731
+ <h3>扫描结果</h3>
732
+ <div id="sc-result"><p style="color:#8b949e;font-size:13px">点击"开始扫描"发现活动设备</p></div>
733
+ </div>
734
+ </div>
735
+
736
+ <script>
737
+ // ── Tab switching ──
738
+ function switchTab(name) {
739
+ document.querySelectorAll('.tab').forEach(t=>t.classList.remove('active'));
740
+ document.querySelectorAll('.panels').forEach(p=>p.classList.remove('active'));
741
+ document.querySelector(`.tab[onclick*='${name}']`).classList.add('active');
742
+ document.getElementById(`panel-${name}`).classList.add('active');
743
+ }
744
+
745
+ // ── Serial option toggles ──
746
+ function toggleSerialOpts() {
747
+ const isSerial = document.getElementById('m-proto').value !== 'tcp';
748
+ document.getElementById('m-serial-opts').style.display = isSerial ? 'flex' : 'none';
749
+ document.getElementById('m-host-field').style.display = isSerial?'none':'flex';
750
+ document.getElementById('m-port-field').querySelector('label').textContent = isSerial?'串口路径':'端口';
751
+ }
752
+ document.getElementById('m-proto').addEventListener('change', toggleSerialOpts);
753
+ document.getElementById('s-proto').addEventListener('change', ()=>{
754
+ const s = document.getElementById('s-proto').value !== 'tcp';
755
+ document.getElementById('s-serial-opts').style.display = s?'flex':'none';
756
+ });
757
+
758
+ // ── Connection config helper ──
759
+ function connConfig(prefix) {
760
+ const proto = document.getElementById(prefix+'-proto').value;
761
+ const cfg = {
762
+ mode: proto,
763
+ host: document.getElementById(prefix+'-host')?.value || '127.0.0.1',
764
+ port: document.getElementById(prefix+'-port')?.value || '502',
765
+ slave_id: document.getElementById(prefix+'-sid')?.value || '1',
766
+ timeout: document.getElementById(prefix+'-timeout')?.value || '3.0',
767
+ baudrate: document.getElementById(prefix+'-baud')?.value || '9600',
768
+ parity: document.getElementById(prefix+'-parity')?.value || 'N',
769
+ stopbits: document.getElementById(prefix+'-stop')?.value || '1',
770
+ bytesize: document.getElementById(prefix+'-bytesize')?.value || '8',
771
+ };
772
+ if (proto === 'tcp') { cfg.bytesize='8'; cfg.stopbits='1'; cfg.parity='N'; }
773
+ return cfg;
774
+ }
775
+
776
+ // ── API helper ──
777
+ async function api(path, body) {
778
+ const r = await fetch(path, {
779
+ method:'POST', headers:{'Content-Type':'application/json'},
780
+ body: JSON.stringify(body)
781
+ });
782
+ return r.json();
783
+ }
784
+
785
+ // ── Master Read ──
786
+ async function masterRead() {
787
+ const el = document.getElementById('mr-result');
788
+ el.innerHTML = '<span style="color:#8b949e">读取中...</span>';
789
+ const cfg = connConfig('m');
790
+ const r = await api('/api/master/read', {
791
+ ...cfg,
792
+ register: document.getElementById('mr-addr').value,
793
+ count: document.getElementById('mr-count').value,
794
+ data_type: document.getElementById('mr-type').value,
795
+ function_code: document.getElementById('mr-fc').value,
796
+ byte_order: document.getElementById('mr-bo').value,
797
+ });
798
+ if (r.success) {
799
+ let html = '<table><tr><th>#</th><th>值</th></tr>';
800
+ r.values.forEach((v,i) => {
801
+ html += `<tr><td>${i}</td><td style="color:#58a6ff;font-weight:600">${v}</td></tr>`;
802
+ });
803
+ html += '</table>';
804
+ el.innerHTML = html;
805
+ } else {
806
+ el.innerHTML = `<div class="result-msg err">${r.error}</div>`;
807
+ }
808
+ }
809
+
810
+ // ── Master Write ──
811
+ async function masterWrite() {
812
+ const el = document.getElementById('mw-result');
813
+ el.innerHTML = '<span style="color:#8b949e">写入中...</span>';
814
+ const cfg = connConfig('m');
815
+ const r = await api('/api/master/write', {
816
+ ...cfg,
817
+ register: document.getElementById('mw-addr').value,
818
+ value: document.getElementById('mw-val').value,
819
+ data_type: document.getElementById('mw-type').value,
820
+ });
821
+ el.innerHTML = r.success
822
+ ? `<div class="result-msg ok">✅ 写入成功</div>`
823
+ : `<div class="result-msg err">${r.error}</div>`;
824
+ }
825
+
826
+ // ── Master Write Coil ──
827
+ async function masterWriteCoil() {
828
+ const el = document.getElementById('mwc-result');
829
+ const cfg = connConfig('m');
830
+ const r = await api('/api/master/write-coil', {
831
+ ...cfg,
832
+ register: document.getElementById('mwc-addr').value,
833
+ coil_state: document.getElementById('mwc-state').value,
834
+ });
835
+ el.innerHTML = r.success
836
+ ? `<div class="result-msg ok">✅ 线圈写入成功</div>`
837
+ : `<div class="result-msg err">${r.error}</div>`;
838
+ }
839
+
840
+ // ── Poll ──
841
+ let _pollActive = false;
842
+ async function pollStart() {
843
+ if (_pollActive) return;
844
+ const cfg = connConfig('m');
845
+ const r = await api('/api/master/poll/start', {
846
+ ...cfg,
847
+ register: document.getElementById('mp-addr').value,
848
+ count: document.getElementById('mp-count').value,
849
+ data_type: document.getElementById('mp-type').value,
850
+ interval: document.getElementById('mp-interval').value,
851
+ });
852
+ if (!r.success) { alert(r.error); return; }
853
+ _pollActive = true;
854
+ document.getElementById('poll-start-btn').disabled = true;
855
+ document.getElementById('poll-stop-btn').disabled = false;
856
+ document.getElementById('poll-id').textContent = r.poll_id;
857
+
858
+ const resultEl = document.getElementById('mp-result');
859
+ const canvas = document.getElementById('chart-canvas');
860
+ const ctx = canvas.getContext('2d');
861
+ const chartData = [];
862
+
863
+ // SSE
864
+ const evt = new EventSource(`/api/master/poll/${r.poll_id}/data`);
865
+ evt.onmessage = (e) => {
866
+ const d = JSON.parse(e.data);
867
+ if (!d.data) return;
868
+ if (d.data.error) {
869
+ resultEl.innerHTML += `<div style="color:#f85149">${d.data.error}</div>`;
870
+ return;
871
+ }
872
+ const iterHtml = `<span style="color:#8b949e">[${d.data.iteration}]</span> ` +
873
+ (d.data.values||[]).map(v => `<span style="color:#58a6ff;font-weight:600">${v}</span>`).join(' ');
874
+ resultEl.innerHTML += `<div>${iterHtml}</div>`;
875
+ resultEl.scrollTop = resultEl.scrollHeight;
876
+
877
+ // Chart data
878
+ if (d.data.values && d.data.values.length > 0) {
879
+ chartData.push({iter: d.data.iteration, val: parseFloat(d.data.values[0])});
880
+ if (chartData.length > 100) chartData.shift();
881
+ drawChart(ctx, canvas, chartData);
882
+ }
883
+
884
+ if (!d.running) { pollCleanup(); evt.close(); }
885
+ };
886
+ document.getElementById('poll-stop-btn').onclick = () => {
887
+ fetch(`/api/master/poll/${r.poll_id}/stop`, {method:'POST'});
888
+ pollCleanup();
889
+ evt.close();
890
+ };
891
+ }
892
+
893
+ function pollCleanup() {
894
+ _pollActive = false;
895
+ document.getElementById('poll-start-btn').disabled = false;
896
+ document.getElementById('poll-stop-btn').disabled = true;
897
+ }
898
+
899
+ function drawChart(ctx, canvas, data) {
900
+ const w = canvas.width = canvas.clientWidth * (window.devicePixelRatio||1);
901
+ const h = canvas.height = canvas.clientHeight * (window.devicePixelRatio||1);
902
+ ctx.scale(window.devicePixelRatio||1, window.devicePixelRatio||1);
903
+ ctx.clearRect(0,0,canvas.width,canvas.height);
904
+ if (data.length < 2) return;
905
+ const vals = data.map(d=>d.val);
906
+ const min = Math.min(...vals), max = Math.max(...vals);
907
+ const range = max-min || 1;
908
+ const pad = 10;
909
+ const cw = canvas.clientWidth, ch = canvas.clientHeight;
910
+
911
+ ctx.strokeStyle = '#58a6ff';
912
+ ctx.lineWidth = 2;
913
+ ctx.beginPath();
914
+ data.forEach((d,i) => {
915
+ const x = pad + (i/(data.length-1))*(cw-2*pad);
916
+ const y = ch - pad - ((d.val-min)/range)*(ch-2*pad);
917
+ i===0 ? ctx.moveTo(x,y) : ctx.lineTo(x,y);
918
+ });
919
+ ctx.stroke();
920
+
921
+ // grid
922
+ ctx.strokeStyle = '#21262d';
923
+ ctx.lineWidth = 1;
924
+ for (let i=0; i<4; i++) {
925
+ const y = pad + (i/3)*(ch-2*pad);
926
+ ctx.beginPath(); ctx.moveTo(pad,y); ctx.lineTo(cw-pad,y); ctx.stroke();
927
+ ctx.fillStyle='#8b949e'; ctx.font='10px monospace';
928
+ ctx.fillText((max-(i/3)*range).toFixed(1), 2, y-2);
929
+ }
930
+ }
931
+
932
+ // ── Slave ──
933
+ async function slaveStart() {
934
+ const proto = document.getElementById('s-proto').value;
935
+ const body = {
936
+ mode: proto,
937
+ port: document.getElementById('s-port').value,
938
+ slave_id: document.getElementById('s-sid').value,
939
+ simulate: document.getElementById('s-sim').checked,
940
+ baudrate: document.getElementById('s-baud')?.value || '9600',
941
+ parity: document.getElementById('s-parity')?.value || 'N',
942
+ };
943
+ const r = await api('/api/slave/start', body);
944
+ if (r.success) {
945
+ document.getElementById('s-status').innerHTML = '<span class="status-dot green"></span>运行中';
946
+ document.getElementById('s-start-btn').disabled = true;
947
+ document.getElementById('s-stop-btn').disabled = false;
948
+ loadRegisterMap();
949
+ } else {
950
+ alert(r.error);
951
+ }
952
+ }
953
+
954
+ async function slaveStop() {
955
+ const r = await api('/api/slave/stop', {});
956
+ if (r.success) {
957
+ document.getElementById('s-status').innerHTML = '<span class="status-dot red"></span>已停止';
958
+ document.getElementById('s-start-btn').disabled = false;
959
+ document.getElementById('s-stop-btn').disabled = true;
960
+ }
961
+ }
962
+
963
+ async function loadRegisterMap() {
964
+ const r = await api('/api/register-map', {});
965
+ if (!r.success) return;
966
+ const el = document.getElementById('s-registers');
967
+ let html = '';
968
+ for (const [section, regs] of Object.entries(r.sections)) {
969
+ if (regs.length === 0) continue;
970
+ const label = {coils:'线圈', discrete_inputs:'离散输入', input_registers:'输入寄存器', holding_registers:'保持寄存器'}[section]||section;
971
+ html += `<div style="margin-bottom:12px"><strong style="color:#8b949e;font-size:13px">${label}</strong>`;
972
+ html += '<table><tr><th>地址</th><th>值</th><th>描述</th></tr>';
973
+ regs.forEach(r => {
974
+ const val = typeof r.value === 'boolean' ? (r.value?'<span style="color:#3fb950">ON</span>':'<span style="color:#f85149">OFF</span>') : `<span style="color:#58a6ff">${r.value}</span>`;
975
+ html += `<tr><td>${r.address}</td><td>${val}</td><td style="color:#8b949e">${r.description||''}</td></tr>`;
976
+ });
977
+ html += '</table></div>';
978
+ }
979
+ el.innerHTML = html;
980
+ }
981
+
982
+ // ── Scan ──
983
+ async function scanStart() {
984
+ const btn = document.getElementById('sc-start-btn');
985
+ const prog = document.getElementById('sc-progress');
986
+ const result = document.getElementById('sc-result');
987
+ btn.disabled = true;
988
+ prog.innerHTML = '连接中...';
989
+ result.innerHTML = '';
990
+
991
+ const proto = document.getElementById('sc-proto').value;
992
+ const body = {
993
+ mode: proto,
994
+ host: document.getElementById('sc-host').value,
995
+ port: document.getElementById('sc-port').value,
996
+ timeout: '3.0',
997
+ };
998
+ const r = await api('/api/scan', body);
999
+ if (!r.success) { alert(r.error); btn.disabled=false; return; }
1000
+
1001
+ const evt = new EventSource(`/api/scan/${r.scan_id}/status`);
1002
+ evt.onmessage = (e) => {
1003
+ const d = JSON.parse(e.data);
1004
+ if (d.total) {
1005
+ prog.innerHTML = `扫描进度: ${d.current}/${d.total}`;
1006
+ }
1007
+ if (d.done) {
1008
+ evt.close();
1009
+ btn.disabled = false;
1010
+ if (d.error) {
1011
+ prog.innerHTML = `<span style="color:#f85149">${d.error}</span>`;
1012
+ return;
1013
+ }
1014
+ if (d.found && d.found.length > 0) {
1015
+ prog.innerHTML = `<span style="color:#3fb950">✅ 发现 ${d.found.length} 个活动设备</span>`;
1016
+ result.innerHTML = '<table><tr><th>从站ID</th></tr>' +
1017
+ d.found.map(id => `<tr><td style="color:#58a6ff;font-weight:600">${id}</td></tr>`).join('') +
1018
+ '</table>';
1019
+ } else {
1020
+ prog.innerHTML = '<span style="color:#d29922">未发现活动设备</span>';
1021
+ result.innerHTML = '<p style="color:#8b949e">没有从站响应。检查连接和网络配置。</p>';
1022
+ }
1023
+ }
1024
+ };
1025
+ }
1026
+
1027
+ // ── Init ──
1028
+ toggleSerialOpts();
1029
+ </script>
1030
+ </body>
1031
+ </html>"""
1032
+
1033
+ _Handler.HTML = FRONTEND_HTML
1034
+
1035
+
1036
+ # ── Server launcher ──
1037
+
1038
+ def run_gui(host: str = "127.0.0.1", port: int = 8080):
1039
+ """Start the Web GUI server."""
1040
+ server = HTTPServer((host, port), _Handler)
1041
+ url = f"http://{host}:{port}"
1042
+ print(f"\n == Modbus Tool Web UI ==")
1043
+ print(f" Open in browser: {url}")
1044
+ print(f" Press Ctrl+C to stop\n")
1045
+ try:
1046
+ server.serve_forever()
1047
+ except KeyboardInterrupt:
1048
+ print("\nShutting down GUI server...")
1049
+ server.shutdown()