enode-host 0.1.0__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.
- enode_host/__init__.py +1 -0
- enode_host/async_socket.py +165 -0
- enode_host/backup/c-wing3.py +226 -0
- enode_host/backup/c.py +226 -0
- enode_host/backup/esp_mesh.py +136 -0
- enode_host/backup/gui_comps.py +113 -0
- enode_host/backup/gui_wx_bk.py +270 -0
- enode_host/backup/load_file.py +27 -0
- enode_host/backup/mesh.py +0 -0
- enode_host/backup/mesh_bk.py +0 -0
- enode_host/backup/s.py +151 -0
- enode_host/backup/sandbox.py +93 -0
- enode_host/backup/shm.py +262 -0
- enode_host/backup/shmtools.py +70 -0
- enode_host/backup/smarts.py +243 -0
- enode_host/backup/test_wxpython_choice.py +49 -0
- enode_host/backup/view-wing3.py +494 -0
- enode_host/backup/wx_example.py +55 -0
- enode_host/backup/wx_test01.py +43 -0
- enode_host/cli.py +192 -0
- enode_host/config.py +8 -0
- enode_host/constants.py +25 -0
- enode_host/framed_mesh.py +237 -0
- enode_host/gui_framed.py +207 -0
- enode_host/model.py +1415 -0
- enode_host/protocol.py +311 -0
- enode_host/psd_recursive.py +139 -0
- enode_host/queues.py +11 -0
- enode_host/resampling.py +206 -0
- enode_host/shm_sigproc.py +47 -0
- enode_host/storage.py +93 -0
- enode_host/timestamping.py +79 -0
- enode_host/types.py +38 -0
- enode_host/view.py +1233 -0
- enode_host-0.1.0.dist-info/METADATA +81 -0
- enode_host-0.1.0.dist-info/RECORD +39 -0
- enode_host-0.1.0.dist-info/WHEEL +5 -0
- enode_host-0.1.0.dist-info/entry_points.txt +2 -0
- enode_host-0.1.0.dist-info/top_level.txt +1 -0
enode_host/cli.py
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import asyncio
|
|
3
|
+
import contextlib
|
|
4
|
+
from typing import List, Optional
|
|
5
|
+
|
|
6
|
+
from .async_socket import run_client, run_server
|
|
7
|
+
from .protocol import (
|
|
8
|
+
Mode,
|
|
9
|
+
Toggle,
|
|
10
|
+
build_past_stream,
|
|
11
|
+
build_realtime_stream,
|
|
12
|
+
build_sd_stream_start,
|
|
13
|
+
build_sd_stream_stop,
|
|
14
|
+
build_sd_clear,
|
|
15
|
+
build_set_mode,
|
|
16
|
+
build_start_daq,
|
|
17
|
+
build_stop_daq,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def build_command_from_tokens(tokens: List[str]) -> Optional[bytes]:
|
|
22
|
+
if not tokens:
|
|
23
|
+
return None
|
|
24
|
+
cmd = tokens[0].lower()
|
|
25
|
+
if cmd == "start_daq":
|
|
26
|
+
return build_start_daq()
|
|
27
|
+
if cmd == "stop_daq":
|
|
28
|
+
return build_stop_daq()
|
|
29
|
+
if cmd == "set_mode":
|
|
30
|
+
if len(tokens) < 2:
|
|
31
|
+
raise ValueError("set_mode requires realtime|past")
|
|
32
|
+
mode_token = tokens[1].lower()
|
|
33
|
+
if mode_token == "realtime":
|
|
34
|
+
mode = Mode.REALTIME
|
|
35
|
+
elif mode_token == "past":
|
|
36
|
+
mode = Mode.PAST
|
|
37
|
+
else:
|
|
38
|
+
raise ValueError("set_mode requires realtime|past")
|
|
39
|
+
return build_set_mode(mode)
|
|
40
|
+
if cmd == "realtime_stream":
|
|
41
|
+
if len(tokens) < 2:
|
|
42
|
+
raise ValueError("realtime_stream requires start|stop")
|
|
43
|
+
toggle = Toggle.START if tokens[1].lower() == "start" else Toggle.STOP
|
|
44
|
+
return build_realtime_stream(toggle)
|
|
45
|
+
if cmd == "past_stream":
|
|
46
|
+
if len(tokens) < 2:
|
|
47
|
+
raise ValueError("past_stream requires start|stop")
|
|
48
|
+
toggle = Toggle.START if tokens[1].lower() == "start" else Toggle.STOP
|
|
49
|
+
if toggle == Toggle.START:
|
|
50
|
+
if len(tokens) < 4:
|
|
51
|
+
raise ValueError("past_stream start requires start_ms end_ms")
|
|
52
|
+
return build_past_stream(toggle, int(tokens[2]), int(tokens[3]))
|
|
53
|
+
return build_past_stream(toggle)
|
|
54
|
+
if cmd == "sd_stream":
|
|
55
|
+
if len(tokens) < 2:
|
|
56
|
+
raise ValueError("sd_stream requires start|stop")
|
|
57
|
+
if tokens[1].lower() == "start":
|
|
58
|
+
if len(tokens) < 3:
|
|
59
|
+
raise ValueError("sd_stream start requires hours")
|
|
60
|
+
return build_sd_stream_start(int(tokens[2]))
|
|
61
|
+
return build_sd_stream_stop()
|
|
62
|
+
if cmd == "sd_clear":
|
|
63
|
+
return build_sd_clear()
|
|
64
|
+
raise ValueError(f"unknown command: {cmd}")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
68
|
+
parser = argparse.ArgumentParser(
|
|
69
|
+
prog="enode-host",
|
|
70
|
+
description="Host-side tools for interacting with the ESP-IDF firmware.",
|
|
71
|
+
)
|
|
72
|
+
parser.add_argument(
|
|
73
|
+
"--ping",
|
|
74
|
+
action="store_true",
|
|
75
|
+
help="Basic sanity check that the CLI is wired up.",
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
subparsers = parser.add_subparsers(dest="command")
|
|
79
|
+
server_parser = subparsers.add_parser(
|
|
80
|
+
"server",
|
|
81
|
+
help="Run a TCP server for ESP-IDF nodes.",
|
|
82
|
+
)
|
|
83
|
+
server_parser.add_argument("--host", default="0.0.0.0")
|
|
84
|
+
server_parser.add_argument("--port", type=int, default=3333)
|
|
85
|
+
server_parser.add_argument(
|
|
86
|
+
"--on-connect",
|
|
87
|
+
nargs=argparse.REMAINDER,
|
|
88
|
+
help="Send a protocol command immediately after a client connects.",
|
|
89
|
+
)
|
|
90
|
+
server_parser.add_argument(
|
|
91
|
+
"--interactive",
|
|
92
|
+
action="store_true",
|
|
93
|
+
help="Read commands from stdin and broadcast to all clients.",
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
gui_parser = subparsers.add_parser(
|
|
97
|
+
"gui",
|
|
98
|
+
help="Run the wx GUI backed by the framed server.",
|
|
99
|
+
)
|
|
100
|
+
gui_parser.add_argument("--host", default="0.0.0.0")
|
|
101
|
+
gui_parser.add_argument("--port", type=int, default=3333)
|
|
102
|
+
|
|
103
|
+
socket_parser = subparsers.add_parser(
|
|
104
|
+
"socket",
|
|
105
|
+
help="Send a message over a TCP socket and print the response.",
|
|
106
|
+
)
|
|
107
|
+
socket_parser.add_argument("host")
|
|
108
|
+
socket_parser.add_argument("port", type=int)
|
|
109
|
+
socket_parser.add_argument("message")
|
|
110
|
+
socket_parser.add_argument("--timeout", type=float, default=5.0)
|
|
111
|
+
return parser
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def main() -> int:
|
|
115
|
+
parser = build_parser()
|
|
116
|
+
args = parser.parse_args()
|
|
117
|
+
|
|
118
|
+
if args.ping:
|
|
119
|
+
print("pong")
|
|
120
|
+
return 0
|
|
121
|
+
|
|
122
|
+
if args.command is None:
|
|
123
|
+
try:
|
|
124
|
+
from .gui_framed import run_gui
|
|
125
|
+
except Exception as exc:
|
|
126
|
+
import traceback
|
|
127
|
+
print(f"[gui] failed to start: {exc}")
|
|
128
|
+
traceback.print_exc()
|
|
129
|
+
return 2
|
|
130
|
+
run_gui(host="0.0.0.0", port=3333)
|
|
131
|
+
return 0
|
|
132
|
+
|
|
133
|
+
if args.command == "server":
|
|
134
|
+
on_connect_payload = None
|
|
135
|
+
if args.on_connect:
|
|
136
|
+
on_connect_payload = build_command_from_tokens(args.on_connect)
|
|
137
|
+
|
|
138
|
+
if args.interactive:
|
|
139
|
+
return asyncio.run(run_interactive_server(args.host, args.port, on_connect_payload))
|
|
140
|
+
|
|
141
|
+
asyncio.run(run_server(args.host, args.port, on_connect_payload))
|
|
142
|
+
return 0
|
|
143
|
+
|
|
144
|
+
if args.command == "gui":
|
|
145
|
+
try:
|
|
146
|
+
from .gui_framed import run_gui
|
|
147
|
+
except Exception as exc:
|
|
148
|
+
import traceback
|
|
149
|
+
print(f"[gui] failed to start: {exc}")
|
|
150
|
+
traceback.print_exc()
|
|
151
|
+
return 2
|
|
152
|
+
run_gui(host=args.host, port=args.port)
|
|
153
|
+
return 0
|
|
154
|
+
|
|
155
|
+
if args.command == "socket":
|
|
156
|
+
return run_client(args.host, args.port, args.message, args.timeout)
|
|
157
|
+
|
|
158
|
+
parser.print_help()
|
|
159
|
+
return 0
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
async def run_interactive_server(host: str, port: int, on_connect_payload: Optional[bytes]) -> int:
|
|
163
|
+
queue: asyncio.Queue[bytes] = asyncio.Queue()
|
|
164
|
+
server_task = asyncio.create_task(run_server(host, port, on_connect_payload, queue))
|
|
165
|
+
|
|
166
|
+
try:
|
|
167
|
+
while True:
|
|
168
|
+
line = await asyncio.to_thread(input, "cmd> ")
|
|
169
|
+
if not line:
|
|
170
|
+
continue
|
|
171
|
+
stripped = line.strip()
|
|
172
|
+
if stripped in ("quit", "exit"):
|
|
173
|
+
break
|
|
174
|
+
tokens = stripped.split()
|
|
175
|
+
try:
|
|
176
|
+
payload = build_command_from_tokens(tokens)
|
|
177
|
+
except ValueError as exc:
|
|
178
|
+
print(f"[server] {exc}")
|
|
179
|
+
continue
|
|
180
|
+
if payload is None:
|
|
181
|
+
continue
|
|
182
|
+
await queue.put(payload)
|
|
183
|
+
finally:
|
|
184
|
+
server_task.cancel()
|
|
185
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
186
|
+
await server_task
|
|
187
|
+
|
|
188
|
+
return 0
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
if __name__ == "__main__":
|
|
192
|
+
raise SystemExit(main())
|
enode_host/config.py
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
# CONFIGURATION
|
|
2
|
+
rawFileLength_sec = 60
|
|
3
|
+
port = 3333
|
|
4
|
+
target_dir = "/home/kykoo/esp/esp-idf-v5.1.1/workspace/22_lvgl_gps_acc_mesh/attempt8/validation/02_mesh_test/"
|
|
5
|
+
target_dir = "/home/kykoo/OneDrive/esp/24_acc_svr_app/shm_v1/validation/data"
|
|
6
|
+
target_dir = "validation/data"
|
|
7
|
+
NNODES = 14
|
|
8
|
+
TIME_WINDOW_LENGTH_SEC = 30
|
enode_host/constants.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Shared constants for enode_host."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
|
|
5
|
+
BASE_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
|
|
6
|
+
|
|
7
|
+
RT_STREAM_DIR = os.path.join(BASE_DIR, "rt-streamed")
|
|
8
|
+
RT_STREAM_HD5_DIR = os.path.join(RT_STREAM_DIR, "hd5")
|
|
9
|
+
RT_STREAM_MERGED_DIR = os.path.join(RT_STREAM_DIR, "merged")
|
|
10
|
+
|
|
11
|
+
SD_STREAM_DIR = os.path.join(BASE_DIR, "sd-streamed")
|
|
12
|
+
SD_STREAM_BIN_DIR = os.path.join(SD_STREAM_DIR, "bin")
|
|
13
|
+
SD_STREAM_HD5_DIR = os.path.join(SD_STREAM_DIR, "hd5")
|
|
14
|
+
|
|
15
|
+
PACKET_SIZE = 23
|
|
16
|
+
|
|
17
|
+
SPEED_COL = "Speed\n(kB/s)"
|
|
18
|
+
RSSI_COL = "RSSI\n(dB)"
|
|
19
|
+
PPS_COL = "PPS\nage"
|
|
20
|
+
LEVEL_COL = "Lv"
|
|
21
|
+
|
|
22
|
+
PPS_AGE_LIMIT_SEC = 10
|
|
23
|
+
DAQ_AGE_LIMIT_SEC = 2
|
|
24
|
+
PPS_AGE_UPDATE_MS = 1000
|
|
25
|
+
GUI_TIMER_MS = 100
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import threading
|
|
3
|
+
from typing import Dict, Optional
|
|
4
|
+
|
|
5
|
+
from .async_socket import read_frame
|
|
6
|
+
from .protocol import (
|
|
7
|
+
MsgType,
|
|
8
|
+
build_command,
|
|
9
|
+
format_mac,
|
|
10
|
+
parse_acc_batch,
|
|
11
|
+
parse_ack,
|
|
12
|
+
parse_pps,
|
|
13
|
+
parse_sd_chunk,
|
|
14
|
+
parse_sd_done,
|
|
15
|
+
parse_status,
|
|
16
|
+
parse_gnss,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class FramedMesh:
|
|
21
|
+
def __init__(self, model, host: str = "0.0.0.0", port: int = 3333):
|
|
22
|
+
self.model = model
|
|
23
|
+
self.host = host
|
|
24
|
+
self.port = port
|
|
25
|
+
self.loop = asyncio.new_event_loop()
|
|
26
|
+
self.thread = threading.Thread(target=self._run, daemon=True)
|
|
27
|
+
self.clients: Dict[str, asyncio.StreamWriter] = {}
|
|
28
|
+
self._last_cmd: Dict[str, str] = {}
|
|
29
|
+
self._sd_done_events: Dict[str, threading.Event] = {}
|
|
30
|
+
self._sd_done_lock = threading.Lock()
|
|
31
|
+
self._stat_lock = threading.Lock()
|
|
32
|
+
self._data_frames = 0
|
|
33
|
+
self._data_samples = 0
|
|
34
|
+
self._pps_frames = 0
|
|
35
|
+
self._last_stat = 0.0
|
|
36
|
+
self.thread.start()
|
|
37
|
+
|
|
38
|
+
def _run(self) -> None:
|
|
39
|
+
asyncio.set_event_loop(self.loop)
|
|
40
|
+
server = self.loop.run_until_complete(
|
|
41
|
+
asyncio.start_server(self._handle_client, host=self.host, port=self.port)
|
|
42
|
+
)
|
|
43
|
+
addrs = ", ".join(str(sock.getsockname()) for sock in server.sockets or [])
|
|
44
|
+
self._ui_log(f"[gui] framed server listening on {addrs}")
|
|
45
|
+
try:
|
|
46
|
+
self.loop.run_forever()
|
|
47
|
+
finally:
|
|
48
|
+
server.close()
|
|
49
|
+
self.loop.run_until_complete(server.wait_closed())
|
|
50
|
+
|
|
51
|
+
async def _handle_client(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
|
|
52
|
+
peer = writer.get_extra_info("peername")
|
|
53
|
+
peer_id = f"{peer[0]}:{peer[1]}" if peer else "unknown"
|
|
54
|
+
self._ui_log(f"[gui] connected {peer_id}")
|
|
55
|
+
node_id: Optional[int] = None
|
|
56
|
+
|
|
57
|
+
try:
|
|
58
|
+
while True:
|
|
59
|
+
msg_type, payload = await read_frame(reader)
|
|
60
|
+
if msg_type == MsgType.STATUS:
|
|
61
|
+
status = parse_status(payload)
|
|
62
|
+
node_id = self.model.make_node_key(status.node_type, status.node_number)
|
|
63
|
+
self.clients[node_id] = writer
|
|
64
|
+
self.model.enqueue_conn_report(
|
|
65
|
+
nodeID=node_id,
|
|
66
|
+
level=status.level,
|
|
67
|
+
parent_mac=format_mac(status.parent_mac),
|
|
68
|
+
self_mac=format_mac(status.self_mac),
|
|
69
|
+
rssi=status.rssi,
|
|
70
|
+
acc_model=status.acc_model,
|
|
71
|
+
daq_mode=status.daq_mode,
|
|
72
|
+
daq_on=status.daq_on,
|
|
73
|
+
stream_status=status.stream_status,
|
|
74
|
+
notify=False,
|
|
75
|
+
)
|
|
76
|
+
elif msg_type == MsgType.DATA:
|
|
77
|
+
batch = parse_acc_batch(payload)
|
|
78
|
+
node_id = self.model.make_node_key(batch.node_type, batch.node_number)
|
|
79
|
+
self.clients[node_id] = writer
|
|
80
|
+
with self._stat_lock:
|
|
81
|
+
self._data_frames += 1
|
|
82
|
+
self._data_samples += len(batch.samples)
|
|
83
|
+
self.model.record_rx_bytes(node_id, len(payload) + 3, kind="data")
|
|
84
|
+
self.model.enqueue_acc_batch(node_id, batch.samples)
|
|
85
|
+
elif msg_type == MsgType.PPS:
|
|
86
|
+
try:
|
|
87
|
+
pps = parse_pps(payload)
|
|
88
|
+
except Exception as exc:
|
|
89
|
+
preview = payload[:12].hex()
|
|
90
|
+
print(f"[pps] parse failed len={len(payload)} head={preview} err={exc}")
|
|
91
|
+
continue
|
|
92
|
+
node_id = self.model.make_node_key(pps.node_type, pps.node_number)
|
|
93
|
+
self.clients[node_id] = writer
|
|
94
|
+
with self._stat_lock:
|
|
95
|
+
self._pps_frames += 1
|
|
96
|
+
self.model.enqueue_pps(node_id, pps.cc, pps.epoch)
|
|
97
|
+
elif msg_type == MsgType.SD_STREAM:
|
|
98
|
+
chunk = parse_sd_chunk(payload)
|
|
99
|
+
node_id = self.model.make_node_key(chunk.node_type, chunk.node_number)
|
|
100
|
+
self.model.record_rx_bytes(node_id, len(payload) + 3, kind="sd")
|
|
101
|
+
self.model.handle_sd_chunk(node_id, chunk.file_time, chunk.offset, chunk.data)
|
|
102
|
+
elif msg_type == MsgType.SD_DONE:
|
|
103
|
+
done = parse_sd_done(payload)
|
|
104
|
+
node_id = self.model.make_node_key(done.node_type, done.node_number)
|
|
105
|
+
self.model.handle_sd_done(node_id, done.file_time, done.status)
|
|
106
|
+
if done.status == 1:
|
|
107
|
+
self._signal_sd_done(node_id)
|
|
108
|
+
elif msg_type == MsgType.GNSS:
|
|
109
|
+
gnss = parse_gnss(payload)
|
|
110
|
+
node_id = self.model.make_node_key(gnss.node_type, gnss.node_number)
|
|
111
|
+
self.model.update_gnss_position(
|
|
112
|
+
node_id, gnss.latitude, gnss.longitude, gnss.fix_mode, gnss.valid
|
|
113
|
+
)
|
|
114
|
+
elif msg_type == MsgType.ACK:
|
|
115
|
+
try:
|
|
116
|
+
ack = parse_ack(payload)
|
|
117
|
+
except Exception as exc:
|
|
118
|
+
self._ui_log(f"[ack] parse failed len={len(payload)} err={exc}")
|
|
119
|
+
else:
|
|
120
|
+
self._ui_log(
|
|
121
|
+
f"[ack] type=0x{ack.original_type:02X} status={ack.status} msg={ack.message}"
|
|
122
|
+
)
|
|
123
|
+
if node_id is not None:
|
|
124
|
+
label = self._last_cmd.get(node_id, "cmd")
|
|
125
|
+
msg = f"{label} ack:{ack.status}"
|
|
126
|
+
if ack.message:
|
|
127
|
+
msg = f"{msg} {ack.message}"
|
|
128
|
+
self.model._set_node_fields(node_id, **{"CMD": msg})
|
|
129
|
+
await self._maybe_log_stats()
|
|
130
|
+
except asyncio.IncompleteReadError:
|
|
131
|
+
pass
|
|
132
|
+
finally:
|
|
133
|
+
if node_id is not None and self.clients.get(node_id) is writer:
|
|
134
|
+
self.clients.pop(node_id, None)
|
|
135
|
+
writer.close()
|
|
136
|
+
await writer.wait_closed()
|
|
137
|
+
self._ui_log(f"[gui] disconnected {peer_id}")
|
|
138
|
+
|
|
139
|
+
async def _maybe_log_stats(self) -> None:
|
|
140
|
+
now = self.loop.time()
|
|
141
|
+
if self._last_stat == 0.0:
|
|
142
|
+
self._last_stat = now
|
|
143
|
+
return
|
|
144
|
+
if now - self._last_stat < 1.0:
|
|
145
|
+
return
|
|
146
|
+
with self._stat_lock:
|
|
147
|
+
data_frames = self._data_frames
|
|
148
|
+
data_samples = self._data_samples
|
|
149
|
+
pps_frames = self._pps_frames
|
|
150
|
+
self._data_frames = 0
|
|
151
|
+
self._data_samples = 0
|
|
152
|
+
self._pps_frames = 0
|
|
153
|
+
self._ui_log(f"[rate] data_frames/s={data_frames} samples/s={data_samples} pps/s={pps_frames}")
|
|
154
|
+
self._last_stat = now
|
|
155
|
+
|
|
156
|
+
def _ui_log(self, msg: str) -> None:
|
|
157
|
+
ui_log = getattr(self.model, "ui_log", None)
|
|
158
|
+
if callable(ui_log):
|
|
159
|
+
ui_log(msg)
|
|
160
|
+
else:
|
|
161
|
+
print(msg)
|
|
162
|
+
|
|
163
|
+
async def _send(self, writer: asyncio.StreamWriter, payload: bytes) -> None:
|
|
164
|
+
writer.write(payload)
|
|
165
|
+
await writer.drain()
|
|
166
|
+
|
|
167
|
+
def send_command(self, node_id: str, command_payload: bytes, label: str = "cmd") -> bool:
|
|
168
|
+
writer = self.clients.get(node_id)
|
|
169
|
+
if writer is None:
|
|
170
|
+
return False
|
|
171
|
+
try:
|
|
172
|
+
self._mark_cmd_sent(node_id, label)
|
|
173
|
+
future = asyncio.run_coroutine_threadsafe(
|
|
174
|
+
self._send(writer, command_payload), self.loop
|
|
175
|
+
)
|
|
176
|
+
future.result(timeout=2)
|
|
177
|
+
return True
|
|
178
|
+
except Exception:
|
|
179
|
+
self.clients.pop(node_id, None)
|
|
180
|
+
return False
|
|
181
|
+
|
|
182
|
+
def _mark_cmd_sent(self, node_id: str, label: str) -> None:
|
|
183
|
+
self._last_cmd[node_id] = label
|
|
184
|
+
self.model._set_node_fields(node_id, **{"CMD": f"{label} sent"})
|
|
185
|
+
|
|
186
|
+
def broadcast_command(self, command_payload: bytes, label: str = "cmd") -> int:
|
|
187
|
+
sent = 0
|
|
188
|
+
for node_id, writer in list(self.clients.items()):
|
|
189
|
+
try:
|
|
190
|
+
self._mark_cmd_sent(node_id, label)
|
|
191
|
+
future = asyncio.run_coroutine_threadsafe(
|
|
192
|
+
self._send(writer, command_payload), self.loop
|
|
193
|
+
)
|
|
194
|
+
future.result(timeout=2)
|
|
195
|
+
sent += 1
|
|
196
|
+
except Exception:
|
|
197
|
+
self.clients.pop(node_id, None)
|
|
198
|
+
return sent
|
|
199
|
+
|
|
200
|
+
def _get_sd_event(self, node_id: str) -> threading.Event:
|
|
201
|
+
with self._sd_done_lock:
|
|
202
|
+
ev = self._sd_done_events.get(node_id)
|
|
203
|
+
if ev is None:
|
|
204
|
+
ev = threading.Event()
|
|
205
|
+
self._sd_done_events[node_id] = ev
|
|
206
|
+
return ev
|
|
207
|
+
|
|
208
|
+
def _signal_sd_done(self, node_id: str) -> None:
|
|
209
|
+
ev = self._get_sd_event(node_id)
|
|
210
|
+
ev.set()
|
|
211
|
+
|
|
212
|
+
def stream_sd_sequential(self, command_payload: bytes, label: str = "sd_stream_start", timeout_s: float = 3600.0) -> None:
|
|
213
|
+
node_ids = sorted(self.clients.keys())
|
|
214
|
+
if not node_ids:
|
|
215
|
+
self._ui_log("[sd] no connected nodes for sequential stream")
|
|
216
|
+
return
|
|
217
|
+
self._ui_log(f"[sd] sequential start for {len(node_ids)} nodes")
|
|
218
|
+
for node_id in node_ids:
|
|
219
|
+
ev = self._get_sd_event(node_id)
|
|
220
|
+
ev.clear()
|
|
221
|
+
ok = self.send_command(node_id, command_payload)
|
|
222
|
+
if not ok:
|
|
223
|
+
self._ui_log(f"[sd] send failed: {node_id}")
|
|
224
|
+
self.model._set_node_fields(node_id, **{"CMD": "sd_stream send failed"})
|
|
225
|
+
continue
|
|
226
|
+
self._mark_cmd_sent(node_id, label)
|
|
227
|
+
self._ui_log(f"[sd] streaming {node_id}")
|
|
228
|
+
if ev.wait(timeout=timeout_s):
|
|
229
|
+
self._ui_log(f"[sd] done {node_id}")
|
|
230
|
+
else:
|
|
231
|
+
self._ui_log(f"[sd] timeout {node_id}")
|
|
232
|
+
self.model._set_node_fields(node_id, **{"CMD": "sd_stream timeout"})
|
|
233
|
+
self._ui_log("[sd] sequential complete")
|
|
234
|
+
|
|
235
|
+
@staticmethod
|
|
236
|
+
def build_command_payload(command_id: int, payload: bytes = b"") -> bytes:
|
|
237
|
+
return build_command(command_id, payload)
|
enode_host/gui_framed.py
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import threading
|
|
3
|
+
|
|
4
|
+
import wx
|
|
5
|
+
|
|
6
|
+
from .framed_mesh import FramedMesh
|
|
7
|
+
from .cli import build_command_from_tokens
|
|
8
|
+
from .protocol import (
|
|
9
|
+
Toggle,
|
|
10
|
+
build_realtime_stream,
|
|
11
|
+
build_sd_clear,
|
|
12
|
+
build_sd_stream_start,
|
|
13
|
+
build_sd_stream_stop,
|
|
14
|
+
build_set_daq_mode,
|
|
15
|
+
build_start_daq,
|
|
16
|
+
build_stop_daq,
|
|
17
|
+
)
|
|
18
|
+
from . import model
|
|
19
|
+
from . import view
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class Controller:
|
|
25
|
+
def __init__(self, host: str, port: int):
|
|
26
|
+
self.model = model.Model(self)
|
|
27
|
+
self.view = view.View(self)
|
|
28
|
+
self.model.ui_log = self.view.append_status_message
|
|
29
|
+
self.view.Show()
|
|
30
|
+
self.mesh = FramedMesh(self.model, host=host, port=port)
|
|
31
|
+
|
|
32
|
+
self.view.m_button_nodes_update.Bind(wx.EVT_BUTTON, self.on_change_node_nums)
|
|
33
|
+
self.view.Bind(wx.EVT_MENU, self.on_exit, self.view.m_menu_file_quit)
|
|
34
|
+
self.view.Bind(wx.EVT_MENU, self.on_merge_rt_files, self.view.m_menu_data_export)
|
|
35
|
+
self.view.Bind(wx.EVT_MENU, self.on_merge_sd_files, self.view.m_menu_data_merge_sd)
|
|
36
|
+
self.view.Bind(wx.EVT_MENU, self.on_plot_rt_merged, self.view.m_menu_data_plot_rt)
|
|
37
|
+
self.view.Bind(wx.EVT_MENU, self.on_plot_sd_merged, self.view.m_menu_data_plot_sd)
|
|
38
|
+
self.view.Bind(wx.EVT_MENU, self.on_clear_rt_files, self.view.m_menu_data_clear_rt)
|
|
39
|
+
self.view.Bind(wx.EVT_MENU, self.on_clear_sd_files, self.view.m_menu_data_clear_sd)
|
|
40
|
+
self.view.Bind(wx.EVT_MENU, self.on_view_clf, self.view.m_menu_view_clf)
|
|
41
|
+
self.view.m_button1.Bind(wx.EVT_BUTTON, self.on_command_choice_send)
|
|
42
|
+
self.view.m_textCtrl_cmd.Bind(wx.EVT_TEXT_ENTER, self.on_command_send)
|
|
43
|
+
|
|
44
|
+
def on_change_node_nums(self, event):
|
|
45
|
+
self.model.options["acc_nums_txt"] = self.view.m_textCtrl_acc.GetValue()
|
|
46
|
+
self.model.options["tmp_nums_txt"] = self.view.m_textCtrl_tmp.GetValue()
|
|
47
|
+
self.model.options["str_nums_txt"] = self.view.m_textCtrl_str.GetValue()
|
|
48
|
+
self.model.options["veh_nums_txt"] = self.view.m_textCtrl_veh.GetValue()
|
|
49
|
+
self.model.parse_node_nums_txt()
|
|
50
|
+
self.model.save_config()
|
|
51
|
+
self.model.init_mesh_status_data()
|
|
52
|
+
self.model.init_other_data()
|
|
53
|
+
self.view.mesh_status_data_view()
|
|
54
|
+
self.view.m_grid2.ForceRefresh()
|
|
55
|
+
self.view.init_plot()
|
|
56
|
+
|
|
57
|
+
def on_exit(self, event):
|
|
58
|
+
self.view.Close()
|
|
59
|
+
|
|
60
|
+
def on_merge_rt_files(self, event):
|
|
61
|
+
threading.Thread(target=self._run_merge_rt_files, daemon=True).start()
|
|
62
|
+
|
|
63
|
+
def on_merge_sd_files(self, event):
|
|
64
|
+
threading.Thread(target=self._run_merge_sd_files, daemon=True).start()
|
|
65
|
+
|
|
66
|
+
def on_clear_rt_files(self, event):
|
|
67
|
+
threading.Thread(target=self._run_clear_rt_files, daemon=True).start()
|
|
68
|
+
|
|
69
|
+
def on_clear_sd_files(self, event):
|
|
70
|
+
threading.Thread(target=self._run_clear_sd_files, daemon=True).start()
|
|
71
|
+
|
|
72
|
+
def on_plot_rt_merged(self, event):
|
|
73
|
+
threading.Thread(target=self._run_plot_rt_merged, daemon=True).start()
|
|
74
|
+
|
|
75
|
+
def on_plot_sd_merged(self, event):
|
|
76
|
+
threading.Thread(target=self._run_plot_sd_merged, daemon=True).start()
|
|
77
|
+
|
|
78
|
+
def on_view_clf(self, event):
|
|
79
|
+
self.model.init_other_data()
|
|
80
|
+
self.view.init_plot()
|
|
81
|
+
self.view.figure_update()
|
|
82
|
+
|
|
83
|
+
def _broadcast(self, payload: bytes, label: str) -> None:
|
|
84
|
+
sent = self.mesh.broadcast_command(payload, label=label)
|
|
85
|
+
logger.info("command sent: %s (to %d clients)", label, sent)
|
|
86
|
+
self.view.append_status_message(f"[cmd] sent {label} to {sent} clients")
|
|
87
|
+
|
|
88
|
+
def on_command_choice_send(self, event):
|
|
89
|
+
selection = self.view.m_choice_OperationMode.GetStringSelection()
|
|
90
|
+
if selection == "DAQ Start":
|
|
91
|
+
self._broadcast(build_start_daq(), "start_daq")
|
|
92
|
+
elif selection == "DAQ Stop":
|
|
93
|
+
self._broadcast(build_stop_daq(), "stop_daq")
|
|
94
|
+
elif selection == "Realtime Stream Start":
|
|
95
|
+
self._broadcast(build_realtime_stream(Toggle.START), "rt_stream_start")
|
|
96
|
+
elif selection == "Realtime Stream Stop":
|
|
97
|
+
self._broadcast(build_realtime_stream(Toggle.STOP), "rt_stream_stop")
|
|
98
|
+
elif selection == "SD Stream Start":
|
|
99
|
+
threading.Thread(
|
|
100
|
+
target=self.mesh.stream_sd_sequential,
|
|
101
|
+
args=(build_sd_stream_start(0), "sd_stream_start_all"),
|
|
102
|
+
daemon=True,
|
|
103
|
+
).start()
|
|
104
|
+
elif selection == "SD Stream Stop":
|
|
105
|
+
self._broadcast(build_sd_stream_stop(), "sd_stream_stop")
|
|
106
|
+
elif selection == "SD Clear All":
|
|
107
|
+
self._broadcast(build_sd_clear(), "sd_clear")
|
|
108
|
+
|
|
109
|
+
def set_daq_mode(self, node_id: str, mode: int) -> None:
|
|
110
|
+
payload = build_set_daq_mode(mode)
|
|
111
|
+
if self.mesh.send_command(node_id, payload, label=f"daq_mode={mode}"):
|
|
112
|
+
self.view.append_status_message(f"[cmd] daq_mode={mode} -> {node_id}")
|
|
113
|
+
else:
|
|
114
|
+
self.view.append_status_message(f"[cmd] failed daq_mode={mode} -> {node_id}")
|
|
115
|
+
|
|
116
|
+
def on_command_send(self, event):
|
|
117
|
+
text = self.view.m_textCtrl_cmd.GetValue().strip()
|
|
118
|
+
if not text:
|
|
119
|
+
return
|
|
120
|
+
self.view.append_status_message(f"> {text}")
|
|
121
|
+
tokens = text.split()
|
|
122
|
+
if tokens[0].lower() == "merge_sd_files":
|
|
123
|
+
self.view.m_textCtrl_cmd.SetValue("")
|
|
124
|
+
threading.Thread(target=self._run_merge_sd_files, daemon=True).start()
|
|
125
|
+
return
|
|
126
|
+
if len(tokens) >= 2 and tokens[0].lower() == "sd_stream" and tokens[1].lower() == "start":
|
|
127
|
+
hours = 0
|
|
128
|
+
if len(tokens) >= 3:
|
|
129
|
+
try:
|
|
130
|
+
hours = int(tokens[2])
|
|
131
|
+
except ValueError:
|
|
132
|
+
hours = 0
|
|
133
|
+
self.view.m_textCtrl_cmd.SetValue("")
|
|
134
|
+
threading.Thread(
|
|
135
|
+
target=self.mesh.stream_sd_sequential,
|
|
136
|
+
args=(build_sd_stream_start(hours), f"sd_stream_start({hours})"),
|
|
137
|
+
daemon=True,
|
|
138
|
+
).start()
|
|
139
|
+
return
|
|
140
|
+
try:
|
|
141
|
+
payload = build_command_from_tokens(tokens)
|
|
142
|
+
except ValueError as exc:
|
|
143
|
+
self.view.append_status_message(f"[cmd] {exc}")
|
|
144
|
+
return
|
|
145
|
+
if payload is None:
|
|
146
|
+
return
|
|
147
|
+
sent = self.mesh.broadcast_command(payload, label=text)
|
|
148
|
+
self.view.append_status_message(f"[cmd] sent to {sent} clients")
|
|
149
|
+
self.view.m_textCtrl_cmd.SetValue("")
|
|
150
|
+
|
|
151
|
+
def _run_merge_sd_files(self):
|
|
152
|
+
try:
|
|
153
|
+
self.model.merge_sd_files()
|
|
154
|
+
finally:
|
|
155
|
+
wx.CallAfter(self.view.mesh_status_data_view)
|
|
156
|
+
wx.CallAfter(self.view.m_grid2.ForceRefresh)
|
|
157
|
+
wx.CallAfter(self.view.init_plot)
|
|
158
|
+
wx.CallAfter(self.view.figure_update)
|
|
159
|
+
wx.CallAfter(self.view.init_merged_plot)
|
|
160
|
+
wx.CallAfter(self.view.figure_update_merged)
|
|
161
|
+
wx.CallAfter(self.view.show_merged_plots)
|
|
162
|
+
|
|
163
|
+
def _run_merge_rt_files(self):
|
|
164
|
+
try:
|
|
165
|
+
self.model.merge_rt_streamed_files()
|
|
166
|
+
finally:
|
|
167
|
+
wx.CallAfter(self.view.mesh_status_data_view)
|
|
168
|
+
wx.CallAfter(self.view.m_grid2.ForceRefresh)
|
|
169
|
+
wx.CallAfter(self.view.init_plot)
|
|
170
|
+
wx.CallAfter(self.view.figure_update)
|
|
171
|
+
wx.CallAfter(self.view.init_merged_plot)
|
|
172
|
+
wx.CallAfter(self.view.figure_update_merged)
|
|
173
|
+
wx.CallAfter(self.view.show_merged_plots)
|
|
174
|
+
|
|
175
|
+
def _run_clear_rt_files(self):
|
|
176
|
+
try:
|
|
177
|
+
self.model.clear_rt_streamed_files()
|
|
178
|
+
finally:
|
|
179
|
+
wx.CallAfter(self.view.append_status_message, "[rt-clear] done")
|
|
180
|
+
|
|
181
|
+
def _run_clear_sd_files(self):
|
|
182
|
+
try:
|
|
183
|
+
self.model.clear_sd_streamed_files()
|
|
184
|
+
finally:
|
|
185
|
+
wx.CallAfter(self.view.append_status_message, "[sd-clear] done")
|
|
186
|
+
|
|
187
|
+
def _run_plot_rt_merged(self):
|
|
188
|
+
try:
|
|
189
|
+
self.model.load_latest_merged_rt()
|
|
190
|
+
finally:
|
|
191
|
+
wx.CallAfter(self.view.init_merged_plot)
|
|
192
|
+
wx.CallAfter(self.view.figure_update_merged)
|
|
193
|
+
wx.CallAfter(self.view.show_merged_plots)
|
|
194
|
+
|
|
195
|
+
def _run_plot_sd_merged(self):
|
|
196
|
+
try:
|
|
197
|
+
self.model.load_latest_merged_sd()
|
|
198
|
+
finally:
|
|
199
|
+
wx.CallAfter(self.view.init_merged_plot)
|
|
200
|
+
wx.CallAfter(self.view.figure_update_merged)
|
|
201
|
+
wx.CallAfter(self.view.show_merged_plots)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def run_gui(host: str = "0.0.0.0", port: int = 3333) -> None:
|
|
205
|
+
app = wx.App()
|
|
206
|
+
Controller(host=host, port=port)
|
|
207
|
+
app.MainLoop()
|