uart-proxy 1.20260612.1215230__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.
- uart_proxy/__init__.py +23 -0
- uart_proxy/__main__.py +8 -0
- uart_proxy/_version.py +12 -0
- uart_proxy/cli.py +468 -0
- uart_proxy/core/__init__.py +23 -0
- uart_proxy/core/bus.py +48 -0
- uart_proxy/core/events.py +46 -0
- uart_proxy/core/line_assembler.py +42 -0
- uart_proxy/core/recorder.py +103 -0
- uart_proxy/core/retention.py +133 -0
- uart_proxy/core/session.py +255 -0
- uart_proxy/core/timestamp.py +79 -0
- uart_proxy/io/__init__.py +9 -0
- uart_proxy/io/socket_source.py +167 -0
- uart_proxy/io/source.py +47 -0
- uart_proxy/io/uart_source.py +55 -0
- uart_proxy/plugins/__init__.py +8 -0
- uart_proxy/plugins/base.py +85 -0
- uart_proxy/plugins/builtin/__init__.py +1 -0
- uart_proxy/plugins/builtin/grep.py +55 -0
- uart_proxy/plugins/manager.py +126 -0
- uart_proxy/proxy/__init__.py +8 -0
- uart_proxy/proxy/protocol.py +93 -0
- uart_proxy/proxy/server.py +302 -0
- uart_proxy/ui/__init__.py +7 -0
- uart_proxy/ui/headless.py +54 -0
- uart_proxy/ui/tui.py +411 -0
- uart_proxy-1.20260612.1215230.dist-info/METADATA +388 -0
- uart_proxy-1.20260612.1215230.dist-info/RECORD +32 -0
- uart_proxy-1.20260612.1215230.dist-info/WHEEL +4 -0
- uart_proxy-1.20260612.1215230.dist-info/entry_points.txt +2 -0
- uart_proxy-1.20260612.1215230.dist-info/licenses/LICENSE +21 -0
uart_proxy/__init__.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""
|
|
2
|
+
uart-proxy: a cross-platform UART log reader / controller.
|
|
3
|
+
|
|
4
|
+
Features:
|
|
5
|
+
* Enumerate serial ports and open them for read & write.
|
|
6
|
+
* Dual time axis: absolute wall-clock window and relative elapsed time.
|
|
7
|
+
* Multi-stream file recording (raw / relative-timestamp / full-timestamp).
|
|
8
|
+
* Socket proxy so another machine (or a future mobile client) can attach
|
|
9
|
+
with an auth code and a role (full / readonly).
|
|
10
|
+
* Plugin system for line-by-line pattern matching (grep-style and beyond).
|
|
11
|
+
* Command-line entry point with an optional Textual TUI.
|
|
12
|
+
|
|
13
|
+
The serial engine is provided by the ``uart_helper`` package
|
|
14
|
+
(PyPI: `uart-helper <https://pypi.org/project/uart-helper/>`_).
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
# Version is defined once in _version.py and shared with the build backend
|
|
20
|
+
# (pyproject.toml [tool.hatch.version]). See _version.py.
|
|
21
|
+
from ._version import __version__
|
|
22
|
+
|
|
23
|
+
__all__ = ["__version__"]
|
uart_proxy/__main__.py
ADDED
uart_proxy/_version.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Single source of truth for the package version.
|
|
3
|
+
|
|
4
|
+
Version scheme: ``1.YYYYmmdd.1HHmmss`` (see CHANGELOG.md / ROADMAP.md).
|
|
5
|
+
|
|
6
|
+
This is the ONE place the version is defined. It is consumed by:
|
|
7
|
+
* the runtime / UI -> ``uart_proxy.__version__``
|
|
8
|
+
* the build backend (pyproject.toml ``[tool.hatch.version]``)
|
|
9
|
+
so the toml and the code can never drift apart.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
__version__ = "1.20260612.1215230"
|
uart_proxy/cli.py
ADDED
|
@@ -0,0 +1,468 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Command-line entry point for uart-proxy.
|
|
3
|
+
|
|
4
|
+
Subcommands
|
|
5
|
+
-----------
|
|
6
|
+
* ``ports`` list the serial ports on this machine.
|
|
7
|
+
* ``connect`` open a local UART for read & write (optionally also serve a proxy).
|
|
8
|
+
* ``remote`` attach to a remote uart-proxy server as a client.
|
|
9
|
+
|
|
10
|
+
Examples
|
|
11
|
+
--------
|
|
12
|
+
uart-proxy ports
|
|
13
|
+
uart-proxy connect --port /dev/tty.usbserial-110 --baud 115200
|
|
14
|
+
uart-proxy connect --port COM3 --serve --auth 123456 --auth 000000:readonly
|
|
15
|
+
uart-proxy connect --port /dev/ttyUSB0 --grep ERROR --grep "panic.*"
|
|
16
|
+
uart-proxy remote --host 192.168.1.10 --port 9600 --auth 123456
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import argparse
|
|
22
|
+
import json
|
|
23
|
+
import os
|
|
24
|
+
import sys
|
|
25
|
+
import time
|
|
26
|
+
from typing import Optional
|
|
27
|
+
|
|
28
|
+
from uart_helper import SerialMonitor, UARTConfig
|
|
29
|
+
|
|
30
|
+
from . import __version__
|
|
31
|
+
from .core.session import UartSession
|
|
32
|
+
from .core.recorder import Recorder
|
|
33
|
+
from .core.retention import (
|
|
34
|
+
DEFAULT_MAX_AGE_DAYS,
|
|
35
|
+
DEFAULT_MAX_TOTAL_MB,
|
|
36
|
+
apply_retention,
|
|
37
|
+
format_bytes,
|
|
38
|
+
scan_sessions,
|
|
39
|
+
)
|
|
40
|
+
from .io.socket_source import SocketSource
|
|
41
|
+
from .io.uart_source import UartSource
|
|
42
|
+
from .plugins.manager import PluginManager
|
|
43
|
+
from .proxy.protocol import Role, parse_auth_spec
|
|
44
|
+
from .proxy.server import ProxyServer
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
# ── ports ─────────────────────────────────────────────────────────────────────
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def cmd_ports(args: argparse.Namespace) -> int:
|
|
51
|
+
monitor = SerialMonitor()
|
|
52
|
+
found = monitor.scan_once()
|
|
53
|
+
if args.json:
|
|
54
|
+
data = [
|
|
55
|
+
{
|
|
56
|
+
"device": ident.tty_device,
|
|
57
|
+
"raw_device": ident.device,
|
|
58
|
+
"description": ident.description,
|
|
59
|
+
"vid_pid": ident.vid_pid_str,
|
|
60
|
+
"serial": ident.serial_number,
|
|
61
|
+
"manufacturer": ident.manufacturer,
|
|
62
|
+
}
|
|
63
|
+
for ident, _ in found
|
|
64
|
+
]
|
|
65
|
+
print(json.dumps({"status": True, "action": "ports", "data": data}))
|
|
66
|
+
return 0
|
|
67
|
+
|
|
68
|
+
if not found:
|
|
69
|
+
print("No serial ports found.")
|
|
70
|
+
return 0
|
|
71
|
+
print(f"Found {len(found)} port(s):\n")
|
|
72
|
+
for ident, _ in found:
|
|
73
|
+
line = f" {ident.tty_device}"
|
|
74
|
+
if ident.vid is not None:
|
|
75
|
+
line += f" {ident.vid_pid_str}"
|
|
76
|
+
if ident.description:
|
|
77
|
+
line += f' "{ident.description}"'
|
|
78
|
+
if ident.serial_number:
|
|
79
|
+
line += f" serial={ident.serial_number}"
|
|
80
|
+
print(line)
|
|
81
|
+
return 0
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
# ── sessions (retention management) ────────────────────────────────────────
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def cmd_sessions(args: argparse.Namespace) -> int:
|
|
88
|
+
root = DEFAULT_LOG_ROOT
|
|
89
|
+
|
|
90
|
+
if args.prune:
|
|
91
|
+
max_age, max_total = _resolve_retention(args)
|
|
92
|
+
report = apply_retention(
|
|
93
|
+
root, max_age_days=max_age, max_total_bytes=max_total
|
|
94
|
+
)
|
|
95
|
+
if args.json:
|
|
96
|
+
print(json.dumps({
|
|
97
|
+
"status": True, "action": "prune",
|
|
98
|
+
"data": {
|
|
99
|
+
"deleted": report.deleted,
|
|
100
|
+
"freed_bytes": report.freed_bytes,
|
|
101
|
+
"kept": report.kept,
|
|
102
|
+
"total_after": report.total_after,
|
|
103
|
+
},
|
|
104
|
+
}))
|
|
105
|
+
else:
|
|
106
|
+
print(f"Pruned {len(report.deleted)} session(s), "
|
|
107
|
+
f"freed {format_bytes(report.freed_bytes)}.")
|
|
108
|
+
print(f"Kept {report.kept} session(s), "
|
|
109
|
+
f"{format_bytes(report.total_after)} total.")
|
|
110
|
+
return 0
|
|
111
|
+
|
|
112
|
+
sessions = scan_sessions(root)
|
|
113
|
+
total = sum(s.size for s in sessions)
|
|
114
|
+
if args.json:
|
|
115
|
+
print(json.dumps({
|
|
116
|
+
"status": True, "action": "sessions",
|
|
117
|
+
"data": {
|
|
118
|
+
"root": root,
|
|
119
|
+
"total_bytes": total,
|
|
120
|
+
"sessions": [
|
|
121
|
+
{"path": s.path, "mtime": s.mtime, "size": s.size}
|
|
122
|
+
for s in sessions
|
|
123
|
+
],
|
|
124
|
+
},
|
|
125
|
+
}))
|
|
126
|
+
return 0
|
|
127
|
+
|
|
128
|
+
if not sessions:
|
|
129
|
+
print(f"No sessions in {root}")
|
|
130
|
+
return 0
|
|
131
|
+
print(f"Sessions in {root} ({format_bytes(total)} total):\n")
|
|
132
|
+
now = time.time()
|
|
133
|
+
for s in sorted(sessions, key=lambda x: x.mtime):
|
|
134
|
+
age_days = (now - s.mtime) / 86400.0
|
|
135
|
+
print(f" {os.path.basename(s.path):20s} "
|
|
136
|
+
f"{format_bytes(s.size):>10s} {age_days:5.1f}d old")
|
|
137
|
+
max_age, max_total = _resolve_retention(args)
|
|
138
|
+
age_str = "disabled" if not max_age else f"{max_age:g} days"
|
|
139
|
+
size_str = "disabled" if not max_total else format_bytes(max_total)
|
|
140
|
+
print(f"\nPolicy: keep ≤ {age_str}, store ≤ {size_str}. "
|
|
141
|
+
f"Run with --prune to apply now.")
|
|
142
|
+
return 0
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
# ── shared wiring ───────────────────────────────────────────────────────────
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _build_config(args: argparse.Namespace) -> UARTConfig:
|
|
149
|
+
return UARTConfig(
|
|
150
|
+
baudrate=args.baud,
|
|
151
|
+
bytesize=args.bytesize,
|
|
152
|
+
parity=args.parity,
|
|
153
|
+
stopbits=args.stopbits,
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
_EOL_MAP = {"crlf": b"\r\n", "lf": b"\n", "cr": b"\r", "none": b""}
|
|
158
|
+
|
|
159
|
+
# Default recording root when --output-dir is not given.
|
|
160
|
+
# normpath so the path prints with native separators (e.g. on Windows
|
|
161
|
+
# expanduser otherwise yields a mixed "C:\\Users\\you/.uart-proxy/sessions").
|
|
162
|
+
DEFAULT_LOG_ROOT = os.path.normpath(os.path.expanduser("~/.uart-proxy/sessions"))
|
|
163
|
+
CONFIG_PATH = os.path.normpath(os.path.expanduser("~/.uart-proxy/config.toml"))
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _load_config() -> dict:
|
|
167
|
+
"""Read ~/.uart-proxy/config.toml if present (TOML, read-only)."""
|
|
168
|
+
try:
|
|
169
|
+
import tomllib
|
|
170
|
+
except ImportError: # pragma: no cover - Python < 3.11
|
|
171
|
+
return {}
|
|
172
|
+
if not os.path.isfile(CONFIG_PATH):
|
|
173
|
+
return {}
|
|
174
|
+
try:
|
|
175
|
+
with open(CONFIG_PATH, "rb") as fh:
|
|
176
|
+
return tomllib.load(fh)
|
|
177
|
+
except Exception: # noqa: BLE001
|
|
178
|
+
return {}
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _resolve_retention(args: argparse.Namespace) -> tuple[float, int]:
|
|
182
|
+
"""Resolve (max_age_days, max_total_bytes): CLI flag > config > default.
|
|
183
|
+
|
|
184
|
+
A value of 0 on either axis disables that dimension.
|
|
185
|
+
"""
|
|
186
|
+
cfg = _load_config().get("retention", {})
|
|
187
|
+
max_age = getattr(args, "max_age_days", None)
|
|
188
|
+
if max_age is None:
|
|
189
|
+
max_age = cfg.get("max_age_days", DEFAULT_MAX_AGE_DAYS)
|
|
190
|
+
max_mb = getattr(args, "max_total_mb", None)
|
|
191
|
+
if max_mb is None:
|
|
192
|
+
max_mb = cfg.get("max_total_mb", DEFAULT_MAX_TOTAL_MB)
|
|
193
|
+
return float(max_age), int(max_mb) * 1024 * 1024
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _resolve_output_dir(args: argparse.Namespace) -> str:
|
|
197
|
+
"""Where to write logs: --output-dir if given, else a per-session folder
|
|
198
|
+
under ~/.uart-proxy/sessions/<YYYYmmdd-HHMMSS>/ so runs never clobber."""
|
|
199
|
+
if args.output_dir:
|
|
200
|
+
return args.output_dir
|
|
201
|
+
from datetime import datetime
|
|
202
|
+
|
|
203
|
+
stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
|
|
204
|
+
return os.path.join(DEFAULT_LOG_ROOT, stamp)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _attach_recorder(session: UartSession, args: argparse.Namespace) -> Optional[Recorder]:
|
|
208
|
+
if args.no_log:
|
|
209
|
+
return None
|
|
210
|
+
recorder = Recorder(
|
|
211
|
+
_resolve_output_dir(args),
|
|
212
|
+
base_name=args.log_base,
|
|
213
|
+
include_tx=args.log_tx,
|
|
214
|
+
append=args.log_append,
|
|
215
|
+
)
|
|
216
|
+
session.bus.subscribe(recorder.handle)
|
|
217
|
+
return recorder
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _build_plugins(session: UartSession, args: argparse.Namespace) -> PluginManager:
|
|
221
|
+
manager = PluginManager(session)
|
|
222
|
+
|
|
223
|
+
if args.grep:
|
|
224
|
+
manager.add_builtin(
|
|
225
|
+
"grep",
|
|
226
|
+
{"patterns": list(args.grep), "ignore_case": args.grep_ignore_case},
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
plugin_config = {}
|
|
230
|
+
if args.plugin_config:
|
|
231
|
+
with open(args.plugin_config, encoding="utf-8") as fh:
|
|
232
|
+
plugin_config = json.load(fh)
|
|
233
|
+
|
|
234
|
+
for name in args.plugin or []:
|
|
235
|
+
manager.add_builtin(name, plugin_config.get(name))
|
|
236
|
+
for path in args.plugin_file or []:
|
|
237
|
+
manager.load_file(path, plugin_config)
|
|
238
|
+
for directory in args.plugin_dir or []:
|
|
239
|
+
manager.load_dir(directory, plugin_config)
|
|
240
|
+
|
|
241
|
+
return manager
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _maybe_build_proxy(session: UartSession, args: argparse.Namespace) -> Optional[ProxyServer]:
|
|
245
|
+
if not args.serve:
|
|
246
|
+
return None
|
|
247
|
+
auth: dict[str, Role] = {}
|
|
248
|
+
for spec in args.auth or []:
|
|
249
|
+
code, role = parse_auth_spec(spec)
|
|
250
|
+
auth[code] = role
|
|
251
|
+
if not auth:
|
|
252
|
+
# A sensible default so --serve alone still works (full access).
|
|
253
|
+
auth["123456"] = Role.FULL
|
|
254
|
+
print("No --auth given; using default code 123456 (full access).", file=sys.stderr)
|
|
255
|
+
return ProxyServer(session, auth, host=args.listen, port=args.listen_port)
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _run_session(
|
|
259
|
+
session: UartSession,
|
|
260
|
+
args: argparse.Namespace,
|
|
261
|
+
*,
|
|
262
|
+
title: str,
|
|
263
|
+
) -> int:
|
|
264
|
+
# Apply the retention policy to the managed store before opening a new
|
|
265
|
+
# session folder (so the new one is never a deletion candidate).
|
|
266
|
+
if args.output_dir is None and not args.no_log:
|
|
267
|
+
max_age, max_total = _resolve_retention(args)
|
|
268
|
+
report = apply_retention(
|
|
269
|
+
DEFAULT_LOG_ROOT, max_age_days=max_age, max_total_bytes=max_total
|
|
270
|
+
)
|
|
271
|
+
if report.deleted:
|
|
272
|
+
print(
|
|
273
|
+
f"Retention: removed {len(report.deleted)} old session(s), "
|
|
274
|
+
f"freed {format_bytes(report.freed_bytes)} "
|
|
275
|
+
f"(store now {format_bytes(report.total_after)}).",
|
|
276
|
+
file=sys.stderr,
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
recorder = _attach_recorder(session, args)
|
|
280
|
+
plugins = _build_plugins(session, args)
|
|
281
|
+
proxy = _maybe_build_proxy(session, args)
|
|
282
|
+
|
|
283
|
+
log_dir = os.path.normpath(os.path.dirname(recorder.raw_path)) if recorder is not None else None
|
|
284
|
+
if log_dir:
|
|
285
|
+
print(f"Recording to {log_dir}", file=sys.stderr)
|
|
286
|
+
|
|
287
|
+
plugins.start()
|
|
288
|
+
if proxy is not None:
|
|
289
|
+
proxy.start()
|
|
290
|
+
print(f"Proxy listening on {args.listen}:{args.listen_port}", file=sys.stderr)
|
|
291
|
+
|
|
292
|
+
try:
|
|
293
|
+
if args.no_tui:
|
|
294
|
+
from .ui.headless import run_headless
|
|
295
|
+
|
|
296
|
+
run_headless(session, ts_mode=args.timestamp)
|
|
297
|
+
else:
|
|
298
|
+
from .ui.tui import run_tui
|
|
299
|
+
|
|
300
|
+
run_tui(session, title=title, ts_mode=args.timestamp, log_hint=log_dir)
|
|
301
|
+
except RuntimeError as exc:
|
|
302
|
+
print(f"Error: {exc}", file=sys.stderr)
|
|
303
|
+
return 1
|
|
304
|
+
finally:
|
|
305
|
+
if proxy is not None:
|
|
306
|
+
proxy.stop()
|
|
307
|
+
plugins.stop()
|
|
308
|
+
session.stop()
|
|
309
|
+
if recorder is not None:
|
|
310
|
+
recorder.close()
|
|
311
|
+
if recorder.paths:
|
|
312
|
+
print("Logs written:", file=sys.stderr)
|
|
313
|
+
for path in recorder.paths:
|
|
314
|
+
print(f" {path}", file=sys.stderr)
|
|
315
|
+
return 0
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
# ── connect (local UART) ──────────────────────────────────────────────────────
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def cmd_connect(args: argparse.Namespace) -> int:
|
|
322
|
+
config = _build_config(args)
|
|
323
|
+
source = UartSource(args.port, config)
|
|
324
|
+
session = UartSession(
|
|
325
|
+
source,
|
|
326
|
+
encoding=args.encoding,
|
|
327
|
+
default_eol=_EOL_MAP[args.eol],
|
|
328
|
+
auto_reconnect=not args.no_reconnect,
|
|
329
|
+
reconnect_interval=args.reconnect_interval,
|
|
330
|
+
)
|
|
331
|
+
return _run_session(session, args, title=f"uart-proxy · {args.port}")
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
# ── remote (socket client) ────────────────────────────────────────────────────
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def cmd_remote(args: argparse.Namespace) -> int:
|
|
338
|
+
source = SocketSource(args.host, args.port, args.auth)
|
|
339
|
+
session = UartSession(
|
|
340
|
+
source,
|
|
341
|
+
encoding=args.encoding,
|
|
342
|
+
default_eol=_EOL_MAP[args.eol],
|
|
343
|
+
auto_reconnect=not args.no_reconnect,
|
|
344
|
+
reconnect_interval=args.reconnect_interval,
|
|
345
|
+
)
|
|
346
|
+
# A remote client never re-serves by default; ignore --serve here.
|
|
347
|
+
args.serve = False
|
|
348
|
+
return _run_session(session, args, title=f"uart-proxy · {args.host}:{args.port}")
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
# ── argument parsing ──────────────────────────────────────────────────────────
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def _add_common_io_args(p: argparse.ArgumentParser) -> None:
|
|
355
|
+
p.add_argument("--encoding", default="utf-8",
|
|
356
|
+
help="Text decoding (e.g. utf-8, latin-1 for BBS). Default utf-8.")
|
|
357
|
+
p.add_argument("--eol", choices=list(_EOL_MAP), default="cr",
|
|
358
|
+
help="Line ending appended to sent text. Default cr — the "
|
|
359
|
+
"convention for Unix consoles (PuTTY/minicom/screen); "
|
|
360
|
+
"crlf can cause a double newline / double prompt.")
|
|
361
|
+
p.add_argument("--timestamp", choices=["none", "relative", "full"], default="relative",
|
|
362
|
+
help="Initial timestamp display mode. Default relative.")
|
|
363
|
+
p.add_argument("--no-tui", action="store_true",
|
|
364
|
+
help="Headless stream to stdout instead of the TUI.")
|
|
365
|
+
# reconnect
|
|
366
|
+
p.add_argument("--no-reconnect", action="store_true",
|
|
367
|
+
help="Don't wait for / auto-reattach the device; fail if absent.")
|
|
368
|
+
p.add_argument("--reconnect-interval", type=float, default=1.0,
|
|
369
|
+
help="Seconds between reconnect attempts. Default 1.0.")
|
|
370
|
+
# logging
|
|
371
|
+
p.add_argument("--output-dir", default=None,
|
|
372
|
+
help="Directory for log files. Default: "
|
|
373
|
+
"~/.uart-proxy/sessions/<timestamp>/ (use '.' for cwd).")
|
|
374
|
+
p.add_argument("--log-base", default="output", help="Log file base name.")
|
|
375
|
+
p.add_argument("--no-log", action="store_true", help="Disable file recording.")
|
|
376
|
+
p.add_argument("--log-tx", action="store_true",
|
|
377
|
+
help="Also record TX lines in the timestamped logs.")
|
|
378
|
+
p.add_argument("--log-append", action="store_true",
|
|
379
|
+
help="Append to existing log files instead of overwriting.")
|
|
380
|
+
# retention (only applies to the default ~/.uart-proxy/sessions store)
|
|
381
|
+
p.add_argument("--max-age-days", type=float, default=None,
|
|
382
|
+
help=f"Delete sessions older than N days "
|
|
383
|
+
f"(default {DEFAULT_MAX_AGE_DAYS}; 0=disable).")
|
|
384
|
+
p.add_argument("--max-total-mb", type=int, default=None,
|
|
385
|
+
help=f"Cap total size of the session store in MB; delete "
|
|
386
|
+
f"oldest when exceeded (default {DEFAULT_MAX_TOTAL_MB}; 0=disable).")
|
|
387
|
+
# plugins
|
|
388
|
+
p.add_argument("--grep", action="append", metavar="PATTERN",
|
|
389
|
+
help="Highlight lines matching PATTERN (repeatable).")
|
|
390
|
+
p.add_argument("--grep-ignore-case", action="store_true",
|
|
391
|
+
help="Case-insensitive --grep matching.")
|
|
392
|
+
p.add_argument("--plugin", action="append", metavar="NAME",
|
|
393
|
+
help="Load a built-in plugin by name (repeatable).")
|
|
394
|
+
p.add_argument("--plugin-file", action="append", metavar="PATH",
|
|
395
|
+
help="Load a user plugin .py file (repeatable).")
|
|
396
|
+
p.add_argument("--plugin-dir", action="append", metavar="DIR",
|
|
397
|
+
help="Load all plugins in a directory (repeatable).")
|
|
398
|
+
p.add_argument("--plugin-config", metavar="JSON",
|
|
399
|
+
help="JSON file mapping plugin name -> config dict.")
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
403
|
+
parser = argparse.ArgumentParser(
|
|
404
|
+
prog="uart-proxy",
|
|
405
|
+
description="UART log reader / controller with timestamping, recording, "
|
|
406
|
+
"socket proxy, and a plugin system.",
|
|
407
|
+
)
|
|
408
|
+
parser.add_argument("--version", action="version", version=f"uart-proxy {__version__}")
|
|
409
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
410
|
+
|
|
411
|
+
# ports
|
|
412
|
+
p_ports = sub.add_parser("ports", help="List serial ports on this machine.")
|
|
413
|
+
p_ports.add_argument("--json", action="store_true", help="Emit JSON output.")
|
|
414
|
+
p_ports.set_defaults(func=cmd_ports)
|
|
415
|
+
|
|
416
|
+
# sessions
|
|
417
|
+
p_sess = sub.add_parser("sessions",
|
|
418
|
+
help="List or prune recorded sessions (~/.uart-proxy/sessions).")
|
|
419
|
+
p_sess.add_argument("--prune", action="store_true",
|
|
420
|
+
help="Apply the retention policy now (delete old/oversized).")
|
|
421
|
+
p_sess.add_argument("--max-age-days", type=float, default=None,
|
|
422
|
+
help=f"Override max age in days (default {DEFAULT_MAX_AGE_DAYS}; 0=disable).")
|
|
423
|
+
p_sess.add_argument("--max-total-mb", type=int, default=None,
|
|
424
|
+
help=f"Override store size cap in MB (default {DEFAULT_MAX_TOTAL_MB}; 0=disable).")
|
|
425
|
+
p_sess.add_argument("--json", action="store_true", help="Emit JSON output.")
|
|
426
|
+
p_sess.set_defaults(func=cmd_sessions)
|
|
427
|
+
|
|
428
|
+
# connect
|
|
429
|
+
p_conn = sub.add_parser("connect", help="Open a local UART for read & write.")
|
|
430
|
+
p_conn.add_argument("--port", required=True, help="Serial device path or COM port.")
|
|
431
|
+
p_conn.add_argument("--baud", type=int, default=115200, help="Baud rate. Default 115200.")
|
|
432
|
+
p_conn.add_argument("--bytesize", type=int, default=8, choices=[5, 6, 7, 8])
|
|
433
|
+
p_conn.add_argument("--parity", default="N", choices=["N", "E", "O", "M", "S"])
|
|
434
|
+
p_conn.add_argument("--stopbits", type=float, default=1, choices=[1, 1.5, 2])
|
|
435
|
+
p_conn.add_argument("--serve", action="store_true",
|
|
436
|
+
help="Also expose this session via a socket proxy.")
|
|
437
|
+
p_conn.add_argument("--listen", default="0.0.0.0", help="Proxy bind address.")
|
|
438
|
+
p_conn.add_argument("--listen-port", type=int, default=9600, help="Proxy port.")
|
|
439
|
+
p_conn.add_argument("--auth", action="append", metavar="CODE[:role]",
|
|
440
|
+
help="Auth code, optional role (full|readonly). Repeatable.")
|
|
441
|
+
_add_common_io_args(p_conn)
|
|
442
|
+
p_conn.set_defaults(func=cmd_connect)
|
|
443
|
+
|
|
444
|
+
# remote
|
|
445
|
+
p_rem = sub.add_parser("remote", help="Attach to a remote uart-proxy server.")
|
|
446
|
+
p_rem.add_argument("--host", required=True, help="Remote server address.")
|
|
447
|
+
p_rem.add_argument("--port", type=int, default=9600, help="Remote server port.")
|
|
448
|
+
p_rem.add_argument("--auth", required=True, help="Auth code for the remote server.")
|
|
449
|
+
_add_common_io_args(p_rem)
|
|
450
|
+
p_rem.set_defaults(func=cmd_remote)
|
|
451
|
+
|
|
452
|
+
return parser
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def main(argv: Optional[list[str]] = None) -> int:
|
|
456
|
+
parser = build_parser()
|
|
457
|
+
args = parser.parse_args(argv)
|
|
458
|
+
try:
|
|
459
|
+
return args.func(args)
|
|
460
|
+
except KeyboardInterrupt:
|
|
461
|
+
return 130
|
|
462
|
+
except Exception as exc: # noqa: BLE001
|
|
463
|
+
print(f"Error: {exc}", file=sys.stderr)
|
|
464
|
+
return 1
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
if __name__ == "__main__":
|
|
468
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Core engine: timestamps, event bus, recorder, and the session data pump."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .bus import EventBus
|
|
6
|
+
from .events import Direction, Event, EventKind
|
|
7
|
+
from .line_assembler import LineAssembler
|
|
8
|
+
from .recorder import Recorder
|
|
9
|
+
from .session import UartSession
|
|
10
|
+
from .timestamp import Stamp, TimestampTracker, format_elapsed
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"EventBus",
|
|
14
|
+
"Direction",
|
|
15
|
+
"Event",
|
|
16
|
+
"EventKind",
|
|
17
|
+
"LineAssembler",
|
|
18
|
+
"Recorder",
|
|
19
|
+
"UartSession",
|
|
20
|
+
"Stamp",
|
|
21
|
+
"TimestampTracker",
|
|
22
|
+
"format_elapsed",
|
|
23
|
+
]
|
uart_proxy/core/bus.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""
|
|
2
|
+
A tiny thread-safe publish/subscribe bus.
|
|
3
|
+
|
|
4
|
+
The session publishes :class:`~uart_proxy.core.events.Event` objects from its
|
|
5
|
+
read thread; subscribers (recorder, plugins, proxy server, UI) receive them
|
|
6
|
+
synchronously in the publishing thread. Subscriber callbacks must therefore be
|
|
7
|
+
quick and must not block — anything heavy (socket writes, UI updates) should
|
|
8
|
+
hand off to its own queue/loop.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import logging
|
|
14
|
+
import threading
|
|
15
|
+
from typing import Callable
|
|
16
|
+
|
|
17
|
+
from .events import Event
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
Subscriber = Callable[[Event], None]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class EventBus:
|
|
25
|
+
def __init__(self) -> None:
|
|
26
|
+
self._subscribers: list[Subscriber] = []
|
|
27
|
+
self._lock = threading.Lock()
|
|
28
|
+
|
|
29
|
+
def subscribe(self, callback: Subscriber) -> Callable[[], None]:
|
|
30
|
+
"""Register a callback. Returns a function that unsubscribes it."""
|
|
31
|
+
with self._lock:
|
|
32
|
+
self._subscribers.append(callback)
|
|
33
|
+
|
|
34
|
+
def _unsubscribe() -> None:
|
|
35
|
+
with self._lock:
|
|
36
|
+
if callback in self._subscribers:
|
|
37
|
+
self._subscribers.remove(callback)
|
|
38
|
+
|
|
39
|
+
return _unsubscribe
|
|
40
|
+
|
|
41
|
+
def publish(self, event: Event) -> None:
|
|
42
|
+
with self._lock:
|
|
43
|
+
subscribers = list(self._subscribers)
|
|
44
|
+
for callback in subscribers:
|
|
45
|
+
try:
|
|
46
|
+
callback(event)
|
|
47
|
+
except Exception: # noqa: BLE001 - never let one subscriber break others
|
|
48
|
+
logger.exception("Error in event subscriber %r", callback)
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Event model shared across the whole pipeline.
|
|
3
|
+
|
|
4
|
+
A single :class:`Event` type flows through the :class:`~uart_proxy.core.bus.EventBus`
|
|
5
|
+
to every consumer (recorder, plugins, proxy server, UI). Producers are the
|
|
6
|
+
session's read loop (RX) and ``write`` calls (TX), plus plugins/system
|
|
7
|
+
(NOTICE / STATUS).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import enum
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from .timestamp import Stamp
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class EventKind(str, enum.Enum):
|
|
20
|
+
"""What a given event represents."""
|
|
21
|
+
|
|
22
|
+
DATA = "data" # a raw byte chunk as it arrived/left (live stream + raw log)
|
|
23
|
+
LINE = "line" # a fully assembled line (timestamped logs + plugins)
|
|
24
|
+
NOTICE = "notice" # a plugin or system message worth surfacing
|
|
25
|
+
STATUS = "status" # a connection/session state change
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class Direction(str, enum.Enum):
|
|
29
|
+
"""Which way the bytes travelled."""
|
|
30
|
+
|
|
31
|
+
RX = "rx" # received from the device / remote
|
|
32
|
+
TX = "tx" # sent to the device / remote
|
|
33
|
+
SYS = "sys" # produced by the app itself (notices, status)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class Event:
|
|
38
|
+
"""One thing that happened, stamped on both time axes."""
|
|
39
|
+
|
|
40
|
+
kind: EventKind
|
|
41
|
+
direction: Direction
|
|
42
|
+
stamp: Stamp
|
|
43
|
+
seq: int = 0
|
|
44
|
+
data: bytes = b"" # raw bytes (DATA, and the raw bytes of a LINE)
|
|
45
|
+
text: str = "" # decoded text
|
|
46
|
+
meta: dict[str, Any] = field(default_factory=dict)
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Reassemble a byte stream into lines.
|
|
3
|
+
|
|
4
|
+
UART data arrives in arbitrary chunks; line-oriented consumers (the
|
|
5
|
+
timestamped logs and most plugins) need whole lines. ``LineAssembler`` buffers
|
|
6
|
+
bytes and yields complete lines as they are terminated by ``\\n`` (a trailing
|
|
7
|
+
``\\r`` is stripped). Partial data can be force-emitted with :meth:`flush`,
|
|
8
|
+
which the session uses to surface prompts that lack a trailing newline (e.g.
|
|
9
|
+
``login: ``) after a short idle period.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class LineAssembler:
|
|
16
|
+
def __init__(self) -> None:
|
|
17
|
+
self._buf = bytearray()
|
|
18
|
+
|
|
19
|
+
def feed(self, data: bytes) -> list[bytes]:
|
|
20
|
+
"""Append ``data`` and return any complete lines (without terminators)."""
|
|
21
|
+
self._buf.extend(data)
|
|
22
|
+
lines: list[bytes] = []
|
|
23
|
+
while True:
|
|
24
|
+
idx = self._buf.find(b"\n")
|
|
25
|
+
if idx < 0:
|
|
26
|
+
break
|
|
27
|
+
raw = bytes(self._buf[:idx]).rstrip(b"\r")
|
|
28
|
+
del self._buf[: idx + 1]
|
|
29
|
+
lines.append(raw)
|
|
30
|
+
return lines
|
|
31
|
+
|
|
32
|
+
@property
|
|
33
|
+
def has_pending(self) -> bool:
|
|
34
|
+
return len(self._buf) > 0
|
|
35
|
+
|
|
36
|
+
def flush(self) -> bytes | None:
|
|
37
|
+
"""Return and clear any buffered partial line, or ``None`` if empty."""
|
|
38
|
+
if not self._buf:
|
|
39
|
+
return None
|
|
40
|
+
raw = bytes(self._buf).rstrip(b"\r")
|
|
41
|
+
self._buf.clear()
|
|
42
|
+
return raw
|