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/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
@@ -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)
@@ -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()