enode-host 0.1.0__py3-none-any.whl → 0.1.2__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.
@@ -1,4 +1,5 @@
1
1
  import asyncio
2
+ import logging
2
3
  import struct
3
4
  from dataclasses import dataclass
4
5
  from typing import Dict, Optional
@@ -14,6 +15,8 @@ from .protocol import (
14
15
  parse_status,
15
16
  )
16
17
 
18
+ logger = logging.getLogger(__name__)
19
+
17
20
 
18
21
  @dataclass
19
22
  class SocketResponse:
@@ -62,51 +65,70 @@ async def handle_client(reader: asyncio.StreamReader, writer: asyncio.StreamWrit
62
65
  peer = writer.get_extra_info("peername")
63
66
  peer_id = f"{peer[0]}:{peer[1]}" if peer else "unknown"
64
67
  clients[peer_id] = writer
65
- print(f"[server] connected {peer_id} (clients={len(clients)})")
68
+ logger.info("[server] connected %s (clients=%d)", peer_id, len(clients))
66
69
 
67
70
  try:
68
71
  while True:
69
72
  msg_type, payload = await read_frame(reader)
70
73
  if msg_type == MsgType.STATUS:
71
74
  status = parse_status(payload)
72
- print(
73
- "[status] "
74
- f"node={status.node_type}:{status.node_number} "
75
- f"level={status.level} "
76
- f"parent={format_mac(status.parent_mac)} "
77
- f"self={format_mac(status.self_mac)} "
78
- f"rssi={status.rssi}"
75
+ logger.info(
76
+ "[status] node=%s:%s level=%s parent=%s self=%s rssi=%s",
77
+ status.node_type,
78
+ status.node_number,
79
+ status.level,
80
+ format_mac(status.parent_mac),
81
+ format_mac(status.self_mac),
82
+ status.rssi,
79
83
  )
80
84
  writer.write(build_ack(MsgType.STATUS, 0, "ok"))
81
85
  await writer.drain()
82
86
  elif msg_type == MsgType.DATA:
83
87
  batch = parse_acc_batch(payload)
84
- print(f"[data] node={batch.node_type}:{batch.node_number} samples={len(batch.samples)}")
88
+ logger.info(
89
+ "[data] node=%s:%s samples=%d",
90
+ batch.node_type,
91
+ batch.node_number,
92
+ len(batch.samples),
93
+ )
85
94
  writer.write(build_ack(MsgType.DATA, 0, "ok"))
86
95
  await writer.drain()
87
96
  elif msg_type == MsgType.PPS:
88
97
  pps = parse_pps(payload)
89
- print(f"[pps] node={pps.node_type}:{pps.node_number} cc={pps.cc} epoch={pps.epoch}")
98
+ logger.info(
99
+ "[pps] node=%s:%s cc=%s epoch=%s",
100
+ pps.node_type,
101
+ pps.node_number,
102
+ pps.cc,
103
+ pps.epoch,
104
+ )
90
105
  writer.write(build_ack(MsgType.PPS, 0, "ok"))
91
106
  await writer.drain()
92
107
  elif msg_type == MsgType.SD_STREAM:
93
108
  chunk = parse_sd_chunk(payload)
94
- print(
95
- f"[sd] node={chunk.node_type}:{chunk.node_number} "
96
- f"file_time={chunk.file_time} offset={chunk.offset} size={len(chunk.data)}"
109
+ logger.info(
110
+ "[sd] node=%s:%s file_time=%s offset=%s size=%d",
111
+ chunk.node_type,
112
+ chunk.node_number,
113
+ chunk.file_time,
114
+ chunk.offset,
115
+ len(chunk.data),
97
116
  )
98
117
  writer.write(build_ack(MsgType.SD_STREAM, 0, "ok"))
99
118
  await writer.drain()
100
119
  elif msg_type == MsgType.SD_DONE:
101
120
  done = parse_sd_done(payload)
102
- print(
103
- f"[sd] node={done.node_type}:{done.node_number} "
104
- f"file_time={done.file_time} status={done.status}"
121
+ logger.info(
122
+ "[sd] node=%s:%s file_time=%s status=%s",
123
+ done.node_type,
124
+ done.node_number,
125
+ done.file_time,
126
+ done.status,
105
127
  )
106
128
  writer.write(build_ack(MsgType.SD_DONE, 0, "ok"))
107
129
  await writer.drain()
108
130
  else:
109
- print(f"[server] msg_type=0x{msg_type:02X} len={len(payload)}")
131
+ logger.info("[server] msg_type=0x%02X len=%d", msg_type, len(payload))
110
132
  writer.write(build_ack(msg_type, 0, "ok"))
111
133
  await writer.drain()
112
134
  except asyncio.IncompleteReadError:
@@ -115,7 +137,7 @@ async def handle_client(reader: asyncio.StreamReader, writer: asyncio.StreamWrit
115
137
  clients.pop(peer_id, None)
116
138
  writer.close()
117
139
  await writer.wait_closed()
118
- print(f"[server] disconnected {peer_id} (clients={len(clients)})")
140
+ logger.info("[server] disconnected %s (clients=%d)", peer_id, len(clients))
119
141
 
120
142
 
121
143
  async def broadcast_messages(
@@ -125,7 +147,7 @@ async def broadcast_messages(
125
147
  while True:
126
148
  payload = await queue.get()
127
149
  if not clients:
128
- print("[server] no clients to broadcast to")
150
+ logger.info("[server] no clients to broadcast to")
129
151
  continue
130
152
  for peer_id, writer in list(clients.items()):
131
153
  try:
@@ -133,7 +155,7 @@ async def broadcast_messages(
133
155
  await writer.drain()
134
156
  except ConnectionError:
135
157
  clients.pop(peer_id, None)
136
- print(f"[server] broadcast to {len(clients)} clients")
158
+ logger.info("[server] broadcast to %d clients", len(clients))
137
159
 
138
160
 
139
161
  async def run_server(
enode_host/cli.py CHANGED
@@ -1,6 +1,12 @@
1
1
  import argparse
2
2
  import asyncio
3
3
  import contextlib
4
+ import datetime as dt
5
+ import configparser
6
+ import logging
7
+ import os
8
+ import sys
9
+ from pathlib import Path
4
10
  from typing import List, Optional
5
11
 
6
12
  from .async_socket import run_client, run_server
@@ -13,6 +19,7 @@ from .protocol import (
13
19
  build_sd_stream_stop,
14
20
  build_sd_clear,
15
21
  build_set_mode,
22
+ build_shutdown,
16
23
  build_start_daq,
17
24
  build_stop_daq,
18
25
  )
@@ -61,9 +68,129 @@ def build_command_from_tokens(tokens: List[str]) -> Optional[bytes]:
61
68
  return build_sd_stream_stop()
62
69
  if cmd == "sd_clear":
63
70
  return build_sd_clear()
71
+ if cmd == "shutdown":
72
+ return build_shutdown()
64
73
  raise ValueError(f"unknown command: {cmd}")
65
74
 
66
75
 
76
+ def _is_truthy(value: Optional[str]) -> bool:
77
+ if not value:
78
+ return False
79
+ return value.strip().lower() in {"1", "true", "yes", "y", "on"}
80
+
81
+
82
+ def _setup_logging() -> None:
83
+ config = configparser.ConfigParser()
84
+ log_date = dt.datetime.now().strftime("%Y-%m%d")
85
+ log_name = f"enode-host-{log_date}.log"
86
+ repo_root = Path(__file__).resolve().parents[3]
87
+ logs_dir = repo_root / "logs"
88
+ try:
89
+ logs_dir.mkdir(parents=True, exist_ok=True)
90
+ except OSError as exc:
91
+ print(f"[log] failed to create logs dir: {logs_dir} ({exc})", file=sys.stderr)
92
+ return
93
+
94
+ log_path = logs_dir / log_name
95
+ log_format = "%(asctime)s %(levelname)s %(name)s: %(message)s"
96
+ formatter = logging.Formatter(log_format, datefmt="%Y-%m-%d %H:%M:%S")
97
+
98
+ root = logging.getLogger()
99
+ root.setLevel(logging.INFO)
100
+
101
+ config_candidates = [Path.cwd() / "config.ini", repo_root / "python" / "config.ini"]
102
+ config.read([str(path) for path in config_candidates if path.exists()])
103
+ file_pps = config.getboolean("Logging", "file_pps", fallback=True)
104
+ file_conn_rpt = config.getboolean("Logging", "file_conn_rpt", fallback=True)
105
+ console_pps = config.getboolean("Logging", "terminal_pps", fallback=True)
106
+ console_conn_rpt = config.getboolean("Logging", "terminal_conn_rpt", fallback=False)
107
+
108
+ class _ConsoleFilter(logging.Filter):
109
+ def filter(self, record: logging.LogRecord) -> bool:
110
+ msg = record.getMessage()
111
+ if not console_pps and msg.startswith("[pps]"):
112
+ return False
113
+ if not console_conn_rpt and msg.startswith("Conn Rpt:"):
114
+ return False
115
+ return True
116
+
117
+ class _FileFilter(logging.Filter):
118
+ def filter(self, record: logging.LogRecord) -> bool:
119
+ msg = record.getMessage()
120
+ if not file_pps and msg.startswith("[pps]"):
121
+ return False
122
+ if not file_conn_rpt and msg.startswith("Conn Rpt:"):
123
+ return False
124
+ return True
125
+
126
+ want_file = True
127
+ want_console = True
128
+ for handler in root.handlers:
129
+ if isinstance(handler, logging.FileHandler):
130
+ try:
131
+ if Path(handler.baseFilename).resolve() == log_path.resolve():
132
+ want_file = False
133
+ except OSError:
134
+ pass
135
+ handler.addFilter(_FileFilter())
136
+ elif isinstance(handler, logging.StreamHandler):
137
+ if not isinstance(handler, logging.FileHandler):
138
+ want_console = False
139
+ handler.addFilter(_ConsoleFilter())
140
+ handler.setFormatter(formatter)
141
+
142
+ if want_file:
143
+ file_handler = logging.FileHandler(log_path, encoding="utf-8")
144
+ file_handler.setFormatter(formatter)
145
+ file_handler.addFilter(_FileFilter())
146
+ root.addHandler(file_handler)
147
+ if want_console:
148
+ console_handler = logging.StreamHandler()
149
+ console_handler.setFormatter(formatter)
150
+ console_handler.addFilter(_ConsoleFilter())
151
+ root.addHandler(console_handler)
152
+
153
+
154
+ def _display_available() -> bool:
155
+ wayland_display = os.environ.get("WAYLAND_DISPLAY")
156
+ xdg_runtime_dir = os.environ.get("XDG_RUNTIME_DIR")
157
+ if wayland_display and xdg_runtime_dir:
158
+ if Path(xdg_runtime_dir, wayland_display).exists():
159
+ return True
160
+
161
+ display = os.environ.get("DISPLAY")
162
+ if not display:
163
+ return False
164
+
165
+ host, _, rest = display.rpartition(":")
166
+ if not rest:
167
+ return False
168
+
169
+ display_num = rest.split(".", 1)[0]
170
+ if not display_num.isdigit():
171
+ return True
172
+
173
+ host = host.lower()
174
+ if host in ("", "unix"):
175
+ return Path(f"/tmp/.X11-unix/X{display_num}").exists()
176
+
177
+ return True
178
+
179
+
180
+ def _ensure_gui_display() -> bool:
181
+ if _is_truthy(os.environ.get("ENODE_GUI_FORCE")):
182
+ return True
183
+ if _display_available():
184
+ return True
185
+ print("[gui] no display detected (X11/Wayland).", file=sys.stderr)
186
+ print(
187
+ "[gui] Run `enode-host server ...`, or start an X11/Wayland session, or use `xvfb-run enode-host gui`.",
188
+ file=sys.stderr,
189
+ )
190
+ print("[gui] Set ENODE_GUI_FORCE=1 to try anyway.", file=sys.stderr)
191
+ return False
192
+
193
+
67
194
  def build_parser() -> argparse.ArgumentParser:
68
195
  parser = argparse.ArgumentParser(
69
196
  prog="enode-host",
@@ -112,6 +239,7 @@ def build_parser() -> argparse.ArgumentParser:
112
239
 
113
240
 
114
241
  def main() -> int:
242
+ _setup_logging()
115
243
  parser = build_parser()
116
244
  args = parser.parse_args()
117
245
 
@@ -120,6 +248,8 @@ def main() -> int:
120
248
  return 0
121
249
 
122
250
  if args.command is None:
251
+ if not _ensure_gui_display():
252
+ return 2
123
253
  try:
124
254
  from .gui_framed import run_gui
125
255
  except Exception as exc:
@@ -142,6 +272,8 @@ def main() -> int:
142
272
  return 0
143
273
 
144
274
  if args.command == "gui":
275
+ if not _ensure_gui_display():
276
+ return 2
145
277
  try:
146
278
  from .gui_framed import run_gui
147
279
  except Exception as exc:
enode_host/constants.py CHANGED
@@ -17,6 +17,7 @@ PACKET_SIZE = 23
17
17
  SPEED_COL = "Speed\n(kB/s)"
18
18
  RSSI_COL = "RSSI\n(dB)"
19
19
  PPS_COL = "PPS\nage"
20
+ BAT_COL = "BAT\n(V)"
20
21
  LEVEL_COL = "Lv"
21
22
 
22
23
  PPS_AGE_LIMIT_SEC = 10
enode_host/framed_mesh.py CHANGED
@@ -1,4 +1,5 @@
1
1
  import asyncio
2
+ import logging
2
3
  import threading
3
4
  from typing import Dict, Optional
4
5
 
@@ -16,6 +17,8 @@ from .protocol import (
16
17
  parse_gnss,
17
18
  )
18
19
 
20
+ logger = logging.getLogger(__name__)
21
+
19
22
 
20
23
  class FramedMesh:
21
24
  def __init__(self, model, host: str = "0.0.0.0", port: int = 3333):
@@ -71,6 +74,7 @@ class FramedMesh:
71
74
  daq_mode=status.daq_mode,
72
75
  daq_on=status.daq_on,
73
76
  stream_status=status.stream_status,
77
+ bat_vol=status.bat_vol,
74
78
  notify=False,
75
79
  )
76
80
  elif msg_type == MsgType.DATA:
@@ -87,12 +91,15 @@ class FramedMesh:
87
91
  pps = parse_pps(payload)
88
92
  except Exception as exc:
89
93
  preview = payload[:12].hex()
90
- print(f"[pps] parse failed len={len(payload)} head={preview} err={exc}")
94
+ logger.warning(
95
+ "[pps] parse failed len=%d head=%s err=%s", len(payload), preview, exc
96
+ )
91
97
  continue
92
98
  node_id = self.model.make_node_key(pps.node_type, pps.node_number)
93
99
  self.clients[node_id] = writer
94
100
  with self._stat_lock:
95
101
  self._pps_frames += 1
102
+ self._ui_log(f"[pps] node={node_id} cc={pps.cc} epoch={pps.epoch}")
96
103
  self.model.enqueue_pps(node_id, pps.cc, pps.epoch)
97
104
  elif msg_type == MsgType.SD_STREAM:
98
105
  chunk = parse_sd_chunk(payload)
@@ -158,7 +165,7 @@ class FramedMesh:
158
165
  if callable(ui_log):
159
166
  ui_log(msg)
160
167
  else:
161
- print(msg)
168
+ logger.info("%s", msg)
162
169
 
163
170
  async def _send(self, writer: asyncio.StreamWriter, payload: bytes) -> None:
164
171
  writer.write(payload)
enode_host/gui_framed.py CHANGED
@@ -4,7 +4,7 @@ import threading
4
4
  import wx
5
5
 
6
6
  from .framed_mesh import FramedMesh
7
- from .cli import build_command_from_tokens
7
+ from .cli import _setup_logging, build_command_from_tokens
8
8
  from .protocol import (
9
9
  Toggle,
10
10
  build_realtime_stream,
@@ -12,6 +12,7 @@ from .protocol import (
12
12
  build_sd_stream_start,
13
13
  build_sd_stream_stop,
14
14
  build_set_daq_mode,
15
+ build_shutdown,
15
16
  build_start_daq,
16
17
  build_stop_daq,
17
18
  )
@@ -30,6 +31,10 @@ class Controller:
30
31
  self.mesh = FramedMesh(self.model, host=host, port=port)
31
32
 
32
33
  self.view.m_button_nodes_update.Bind(wx.EVT_BUTTON, self.on_change_node_nums)
34
+ self.view.m_textCtrl_acc.Bind(wx.EVT_TEXT_ENTER, self.on_change_node_nums)
35
+ self.view.m_textCtrl_tmp.Bind(wx.EVT_TEXT_ENTER, self.on_change_node_nums)
36
+ self.view.m_textCtrl_str.Bind(wx.EVT_TEXT_ENTER, self.on_change_node_nums)
37
+ self.view.m_textCtrl_veh.Bind(wx.EVT_TEXT_ENTER, self.on_change_node_nums)
33
38
  self.view.Bind(wx.EVT_MENU, self.on_exit, self.view.m_menu_file_quit)
34
39
  self.view.Bind(wx.EVT_MENU, self.on_merge_rt_files, self.view.m_menu_data_export)
35
40
  self.view.Bind(wx.EVT_MENU, self.on_merge_sd_files, self.view.m_menu_data_merge_sd)
@@ -37,7 +42,7 @@ class Controller:
37
42
  self.view.Bind(wx.EVT_MENU, self.on_plot_sd_merged, self.view.m_menu_data_plot_sd)
38
43
  self.view.Bind(wx.EVT_MENU, self.on_clear_rt_files, self.view.m_menu_data_clear_rt)
39
44
  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)
45
+ self.view.m_button_clf.Bind(wx.EVT_BUTTON, self.on_view_clf)
41
46
  self.view.m_button1.Bind(wx.EVT_BUTTON, self.on_command_choice_send)
42
47
  self.view.m_textCtrl_cmd.Bind(wx.EVT_TEXT_ENTER, self.on_command_send)
43
48
 
@@ -79,6 +84,7 @@ class Controller:
79
84
  self.model.init_other_data()
80
85
  self.view.init_plot()
81
86
  self.view.figure_update()
87
+ self.view.show_rt_plots()
82
88
 
83
89
  def _broadcast(self, payload: bytes, label: str) -> None:
84
90
  sent = self.mesh.broadcast_command(payload, label=label)
@@ -105,6 +111,8 @@ class Controller:
105
111
  self._broadcast(build_sd_stream_stop(), "sd_stream_stop")
106
112
  elif selection == "SD Clear All":
107
113
  self._broadcast(build_sd_clear(), "sd_clear")
114
+ elif selection == "Shutdown":
115
+ self._broadcast(build_shutdown(), "shutdown")
108
116
 
109
117
  def set_daq_mode(self, node_id: str, mode: int) -> None:
110
118
  payload = build_set_daq_mode(mode)
@@ -202,6 +210,7 @@ class Controller:
202
210
 
203
211
 
204
212
  def run_gui(host: str = "0.0.0.0", port: int = 3333) -> None:
213
+ _setup_logging()
205
214
  app = wx.App()
206
215
  Controller(host=host, port=port)
207
216
  app.MainLoop()