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 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
@@ -0,0 +1,8 @@
1
+ """Enable ``python -m uart_proxy``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .cli import main
6
+
7
+ if __name__ == "__main__":
8
+ raise SystemExit(main())
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