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.
- mbtoolcli-0.3.1.dist-info/METADATA +473 -0
- mbtoolcli-0.3.1.dist-info/RECORD +16 -0
- mbtoolcli-0.3.1.dist-info/WHEEL +5 -0
- mbtoolcli-0.3.1.dist-info/entry_points.txt +4 -0
- mbtoolcli-0.3.1.dist-info/licenses/LICENSE +21 -0
- mbtoolcli-0.3.1.dist-info/top_level.txt +1 -0
- modbus_slave_sim/__init__.py +42 -0
- modbus_slave_sim/cli.py +903 -0
- modbus_slave_sim/colors.py +57 -0
- modbus_slave_sim/datatypes.py +386 -0
- modbus_slave_sim/gui_server.py +1049 -0
- modbus_slave_sim/master.py +647 -0
- modbus_slave_sim/registers.py +288 -0
- modbus_slave_sim/rtu_server.py +158 -0
- modbus_slave_sim/simulation.py +318 -0
- modbus_slave_sim/tcp_server.py +178 -0
|
@@ -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()
|