syndesi 0.5.0__py3-none-any.whl → 0.5.1__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.
- syndesi/adapters/adapter.py +32 -24
- syndesi/adapters/adapter_worker.py +28 -10
- syndesi/adapters/ip.py +7 -13
- syndesi/adapters/serialport.py +70 -12
- syndesi/adapters/stop_conditions.py +9 -16
- syndesi/adapters/timeout.py +1 -3
- syndesi/adapters/tracehub.py +229 -0
- syndesi/adapters/visa.py +96 -75
- syndesi/cli/shell.py +6 -5
- syndesi/component.py +2 -2
- syndesi/protocols/delimited.py +16 -28
- syndesi/protocols/modbus.py +0 -3
- syndesi/protocols/protocol.py +19 -5
- syndesi/protocols/raw.py +13 -8
- syndesi/scripts/syndesi_trace.py +711 -0
- syndesi/version.py +1 -1
- {syndesi-0.5.0.dist-info → syndesi-0.5.1.dist-info}/METADATA +3 -3
- syndesi-0.5.1.dist-info/RECORD +43 -0
- {syndesi-0.5.0.dist-info → syndesi-0.5.1.dist-info}/WHEEL +1 -1
- {syndesi-0.5.0.dist-info → syndesi-0.5.1.dist-info}/entry_points.txt +1 -1
- syndesi-0.5.0.dist-info/RECORD +0 -41
- {syndesi-0.5.0.dist-info → syndesi-0.5.1.dist-info}/licenses/LICENSE +0 -0
- {syndesi-0.5.0.dist-info → syndesi-0.5.1.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,711 @@
|
|
|
1
|
+
# File : syndesi_trace.py
|
|
2
|
+
# Author : Sébastien Deriaz
|
|
3
|
+
# License : GPL
|
|
4
|
+
"""
|
|
5
|
+
CLI viewer for Syndesi trace events (UDP).
|
|
6
|
+
|
|
7
|
+
This tool allows the user to see what's going inside syndesi while it's running in another process
|
|
8
|
+
|
|
9
|
+
Usage :
|
|
10
|
+
|
|
11
|
+
syndesi-trace [--mode MODE]
|
|
12
|
+
|
|
13
|
+
Modes
|
|
14
|
+
-----
|
|
15
|
+
- interactive (default): tabs per adapter, switch with ←/→ (or h/l), quit with q.
|
|
16
|
+
- flat: append-only timeline (stdout or --output file).
|
|
17
|
+
"""
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from abc import abstractmethod
|
|
21
|
+
from dataclasses import dataclass
|
|
22
|
+
from enum import StrEnum
|
|
23
|
+
from types import TracebackType
|
|
24
|
+
import argparse
|
|
25
|
+
import json
|
|
26
|
+
import os
|
|
27
|
+
import selectors
|
|
28
|
+
import socket
|
|
29
|
+
import struct
|
|
30
|
+
import sys
|
|
31
|
+
from collections import OrderedDict, deque
|
|
32
|
+
from collections.abc import Generator
|
|
33
|
+
from typing import Any, TYPE_CHECKING
|
|
34
|
+
from fnmatch import fnmatchcase
|
|
35
|
+
import termios
|
|
36
|
+
import tty
|
|
37
|
+
|
|
38
|
+
from syndesi.adapters.tracehub import (
|
|
39
|
+
DEFAULT_MULTICAST_GROUP,
|
|
40
|
+
DEFAULT_MULTICAST_PORT,
|
|
41
|
+
CloseEvent,
|
|
42
|
+
FragmentEvent,
|
|
43
|
+
OpenEvent,
|
|
44
|
+
ReadEvent,
|
|
45
|
+
TraceEvent,
|
|
46
|
+
WriteEvent,
|
|
47
|
+
json_to_trace_event,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
if TYPE_CHECKING:
|
|
51
|
+
from rich.align import Align
|
|
52
|
+
from rich.console import Console
|
|
53
|
+
from rich.live import Live
|
|
54
|
+
from rich.panel import Panel
|
|
55
|
+
from rich.text import Text
|
|
56
|
+
rich_available = True
|
|
57
|
+
else:
|
|
58
|
+
try:
|
|
59
|
+
from rich.align import Align
|
|
60
|
+
from rich.console import Console
|
|
61
|
+
from rich.live import Live
|
|
62
|
+
from rich.panel import Panel
|
|
63
|
+
from rich.text import Text
|
|
64
|
+
rich_available = True
|
|
65
|
+
except ImportError:
|
|
66
|
+
rich_available = False
|
|
67
|
+
|
|
68
|
+
class TraceMode(StrEnum):
|
|
69
|
+
"""Trace mode"""
|
|
70
|
+
INTERACTIVE = 'interactive'
|
|
71
|
+
FLAT = 'flat'
|
|
72
|
+
|
|
73
|
+
class TimeMode(StrEnum):
|
|
74
|
+
"""Time display mode"""
|
|
75
|
+
ABSOLUTE = 'abs'
|
|
76
|
+
RELATIVE = 'rel'
|
|
77
|
+
|
|
78
|
+
# -----------------------------
|
|
79
|
+
# Terminal input (interactive)
|
|
80
|
+
# -----------------------------
|
|
81
|
+
|
|
82
|
+
#pylint: disable=too-many-instance-attributes
|
|
83
|
+
class Trace:
|
|
84
|
+
"""Trace viewer base class"""
|
|
85
|
+
ADAPTER_STYLES = ["cyan", "magenta", "green", "yellow", "blue", "bright_cyan",
|
|
86
|
+
"bright_magenta", "bright_green"]
|
|
87
|
+
def __init__(
|
|
88
|
+
self,
|
|
89
|
+
*,
|
|
90
|
+
group : str,
|
|
91
|
+
port : int,
|
|
92
|
+
descriptor_filter : str,
|
|
93
|
+
time_mode : str,
|
|
94
|
+
no_fragments : bool
|
|
95
|
+
) -> None:
|
|
96
|
+
self.group = group
|
|
97
|
+
self.port = port
|
|
98
|
+
|
|
99
|
+
self._sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP)
|
|
100
|
+
self._sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
101
|
+
self._sock.bind(("0.0.0.0", self.port))
|
|
102
|
+
mreq = struct.pack("4s4s", socket.inet_aton(self.group), socket.inet_aton("127.0.0.1"))
|
|
103
|
+
self._sock.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, mreq)
|
|
104
|
+
self._sock.setblocking(False)
|
|
105
|
+
|
|
106
|
+
self._selector = selectors.DefaultSelector()
|
|
107
|
+
self._selector.register(self._sock, selectors.EVENT_READ, data="udp")
|
|
108
|
+
|
|
109
|
+
self._descriptor_filter = descriptor_filter.lower()
|
|
110
|
+
|
|
111
|
+
self._console = Console()
|
|
112
|
+
|
|
113
|
+
self._no_fragments = no_fragments
|
|
114
|
+
|
|
115
|
+
self._adapters: OrderedDict[str, AdapterProperties] = OrderedDict()
|
|
116
|
+
|
|
117
|
+
# Time
|
|
118
|
+
self._time_mode = TimeMode(time_mode)
|
|
119
|
+
self._first_event_timestamp : float | None = None
|
|
120
|
+
|
|
121
|
+
@abstractmethod
|
|
122
|
+
def run(self) -> None:
|
|
123
|
+
"""Base run method"""
|
|
124
|
+
|
|
125
|
+
def close(self) -> None:
|
|
126
|
+
"""Close the trace viewer"""
|
|
127
|
+
self._selector.unregister(self._sock)
|
|
128
|
+
try:
|
|
129
|
+
self._sock.close()
|
|
130
|
+
except OSError:
|
|
131
|
+
pass
|
|
132
|
+
|
|
133
|
+
def _allow_descriptor(self, name: str) -> bool:
|
|
134
|
+
return fnmatchcase(name.lower(), self._descriptor_filter)
|
|
135
|
+
|
|
136
|
+
def get_event(self, timeout : float | None) -> Generator[TraceEvent | None, Any, Any]:
|
|
137
|
+
"""Return an event or None if something else triggered the selector"""
|
|
138
|
+
events = self._selector.select(timeout=timeout)
|
|
139
|
+
for key, _ in events:
|
|
140
|
+
if key.data == "udp":
|
|
141
|
+
while True:
|
|
142
|
+
try:
|
|
143
|
+
data, _addr = self._sock.recvfrom(65535)
|
|
144
|
+
except BlockingIOError:
|
|
145
|
+
break
|
|
146
|
+
|
|
147
|
+
json_data = json.loads(data.decode("utf-8", "replace"))
|
|
148
|
+
# Parse data
|
|
149
|
+
yield json_to_trace_event(json_data)
|
|
150
|
+
else:
|
|
151
|
+
yield None
|
|
152
|
+
|
|
153
|
+
def _time_prefix(self, event : TraceEvent) -> str:
|
|
154
|
+
if self._first_event_timestamp is None:
|
|
155
|
+
self._first_event_timestamp = event.timestamp
|
|
156
|
+
|
|
157
|
+
if self._time_mode == TimeMode.RELATIVE:
|
|
158
|
+
relative_time = event.timestamp - self._first_event_timestamp
|
|
159
|
+
return f'{relative_time:+8.3f}s'
|
|
160
|
+
if self._time_mode == TimeMode.ABSOLUTE:
|
|
161
|
+
return f'{event.timestamp:.3f}s'
|
|
162
|
+
raise ValueError(f"Invalid time mode : {self._time_mode}")
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _format_entry(self, event : TraceEvent, display_descriptor : bool = False) -> Text | None:
|
|
166
|
+
fragments = [Text(self._time_prefix(event), style="dim"), Text(" ")]
|
|
167
|
+
|
|
168
|
+
adapter = self._adapters[event.descriptor]
|
|
169
|
+
|
|
170
|
+
if display_descriptor:
|
|
171
|
+
fragments += [
|
|
172
|
+
Text(event.descriptor, style="dim"),
|
|
173
|
+
Text(" ")
|
|
174
|
+
]
|
|
175
|
+
|
|
176
|
+
#last_write : float | None = None#self._adapter_last_write.get(event.descriptor, None)
|
|
177
|
+
|
|
178
|
+
if isinstance(event, OpenEvent):
|
|
179
|
+
fragments.append(
|
|
180
|
+
Text("open ●", style="bold green")
|
|
181
|
+
)
|
|
182
|
+
elif isinstance(event, CloseEvent):
|
|
183
|
+
fragments.append(
|
|
184
|
+
Text("close ●", style="bold red")
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
elif isinstance(event, WriteEvent):
|
|
188
|
+
adapter.last_write_timestamp = event.timestamp
|
|
189
|
+
#self._adapter_last_write[event.descriptor] = event.timestamp
|
|
190
|
+
fragments += [
|
|
191
|
+
Text("write →", style="bold dim"),
|
|
192
|
+
Text(f"{event.length:4d}B", style="dim"),
|
|
193
|
+
Text(f" {event.data}")
|
|
194
|
+
]
|
|
195
|
+
elif isinstance(event, FragmentEvent):
|
|
196
|
+
if self._no_fragments:
|
|
197
|
+
return None
|
|
198
|
+
fragments += [
|
|
199
|
+
Text(" ↓", style="dim"),
|
|
200
|
+
Text(f"{event.length:4d}B ", style="dim"),
|
|
201
|
+
Text(event.data, style="dim"),
|
|
202
|
+
Text(" (frag)", style="dim")
|
|
203
|
+
]
|
|
204
|
+
if adapter.last_write_timestamp is not None:
|
|
205
|
+
write_delta = event.timestamp - adapter.last_write_timestamp
|
|
206
|
+
fragments.append(
|
|
207
|
+
Text(f" {write_delta:+.3f}s", style="dim")
|
|
208
|
+
)
|
|
209
|
+
elif isinstance(event, ReadEvent):
|
|
210
|
+
fragments += [
|
|
211
|
+
Text("read ←", style="bold dim"),
|
|
212
|
+
Text(f"{event.length:4d}B ", style="dim"),
|
|
213
|
+
Text(f"{event.data} "),
|
|
214
|
+
Text(f'({event.stop_condition_indicator})', style="dim")
|
|
215
|
+
]
|
|
216
|
+
if adapter.last_write_timestamp is not None:
|
|
217
|
+
write_delta = event.timestamp - adapter.last_write_timestamp
|
|
218
|
+
fragments.append(
|
|
219
|
+
Text(f" {write_delta:+.3f}s", style="dim")
|
|
220
|
+
)
|
|
221
|
+
else:
|
|
222
|
+
fragments += [
|
|
223
|
+
Text("ERROR", style="bold red")
|
|
224
|
+
]
|
|
225
|
+
|
|
226
|
+
return Text.assemble(
|
|
227
|
+
*fragments
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
def _next_style(self) -> str:
|
|
231
|
+
color = self.ADAPTER_STYLES[len(self._adapters) % len(self.ADAPTER_STYLES)]
|
|
232
|
+
return color
|
|
233
|
+
|
|
234
|
+
def ingest(self, event : TraceEvent, max_events : int = 0) -> bool:
|
|
235
|
+
"""Ingest event"""
|
|
236
|
+
if not self._allow_descriptor(event.descriptor):
|
|
237
|
+
return False
|
|
238
|
+
|
|
239
|
+
if event.descriptor not in self._adapters:
|
|
240
|
+
self._adapters[event.descriptor] = AdapterProperties(
|
|
241
|
+
blocks=deque([], max_events) if max_events > 0 else None,
|
|
242
|
+
style=self._next_style()
|
|
243
|
+
)
|
|
244
|
+
return True
|
|
245
|
+
|
|
246
|
+
class FlatTrace(Trace):
|
|
247
|
+
"""Flat trace (stdout or file)"""
|
|
248
|
+
def __init__(
|
|
249
|
+
self,
|
|
250
|
+
*,
|
|
251
|
+
group: str,
|
|
252
|
+
port: int,
|
|
253
|
+
time_mode : str,
|
|
254
|
+
descriptor_filter : str,
|
|
255
|
+
no_fragments : bool,
|
|
256
|
+
output: str | None
|
|
257
|
+
) -> None:
|
|
258
|
+
super().__init__(
|
|
259
|
+
group=group,
|
|
260
|
+
port=port,
|
|
261
|
+
descriptor_filter=descriptor_filter,
|
|
262
|
+
time_mode=time_mode,
|
|
263
|
+
no_fragments=no_fragments
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
# pylint: disable=consider-using-with
|
|
267
|
+
self._output_fh = open(output, "a", encoding="utf-8") if output else None
|
|
268
|
+
|
|
269
|
+
class CSVColumn(StrEnum):
|
|
270
|
+
"""Name of CSV columns"""
|
|
271
|
+
DESCRIPTOR = "descriptor"
|
|
272
|
+
TIME = "time"
|
|
273
|
+
EVENT = "event"
|
|
274
|
+
SIZE = "size"
|
|
275
|
+
DATA = "data"
|
|
276
|
+
STOP_CONDITION = "stop_condition"
|
|
277
|
+
|
|
278
|
+
def run(self) -> None:
|
|
279
|
+
# No live UI; just print as events arrive.
|
|
280
|
+
while True:
|
|
281
|
+
for event in self.get_event(None):
|
|
282
|
+
if event is not None and self._allow_descriptor(event.descriptor):
|
|
283
|
+
if self._output_fh is not None:
|
|
284
|
+
file_event = self._format_file_entry(event)
|
|
285
|
+
if file_event is not None:
|
|
286
|
+
self._output_fh.write(file_event)
|
|
287
|
+
self.ingest(event)#self.print_event(event)
|
|
288
|
+
|
|
289
|
+
def _format_file_entry(self, event : TraceEvent) -> str | None:
|
|
290
|
+
columns : OrderedDict[FlatTrace.CSVColumn, str] = OrderedDict(
|
|
291
|
+
{k : "" for k in self.CSVColumn}
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
if isinstance(event, FragmentEvent) and self._no_fragments:
|
|
295
|
+
return None
|
|
296
|
+
|
|
297
|
+
columns[self.CSVColumn.DESCRIPTOR] = event.descriptor
|
|
298
|
+
columns[self.CSVColumn.TIME] = f"{event.timestamp:.6f}"
|
|
299
|
+
columns[self.CSVColumn.EVENT] = event.t
|
|
300
|
+
|
|
301
|
+
if isinstance(event, (WriteEvent, ReadEvent, FragmentEvent)):
|
|
302
|
+
columns[self.CSVColumn.DATA] = event.data
|
|
303
|
+
columns[self.CSVColumn.SIZE] = str(event.length)
|
|
304
|
+
|
|
305
|
+
if isinstance(event, ReadEvent):
|
|
306
|
+
columns[self.CSVColumn.STOP_CONDITION] = event.stop_condition_indicator
|
|
307
|
+
|
|
308
|
+
return ','.join(columns.values()) + '\n'
|
|
309
|
+
|
|
310
|
+
def close(self) -> None:
|
|
311
|
+
if self._output_fh:
|
|
312
|
+
try:
|
|
313
|
+
self._output_fh.flush()
|
|
314
|
+
self._output_fh.close()
|
|
315
|
+
except OSError:
|
|
316
|
+
pass
|
|
317
|
+
self._output_fh = None
|
|
318
|
+
super().close()
|
|
319
|
+
|
|
320
|
+
def _print_header(self) -> None:
|
|
321
|
+
hdr = f"# syndesi-trace flat mode | host={self.group} port={self.port}"
|
|
322
|
+
print(hdr)
|
|
323
|
+
|
|
324
|
+
def print_event(self, event : TraceEvent) -> None:
|
|
325
|
+
"""Print event to the console"""
|
|
326
|
+
output = self._format_entry(event, display_descriptor=True)
|
|
327
|
+
if output is not None:
|
|
328
|
+
self._console.print(output)
|
|
329
|
+
|
|
330
|
+
class InteractiveTrace(Trace):
|
|
331
|
+
"""Interactive trace with tabs for each adapter"""
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def __init__(
|
|
335
|
+
self,
|
|
336
|
+
*,
|
|
337
|
+
group: str,
|
|
338
|
+
port: int,
|
|
339
|
+
time_mode : str,
|
|
340
|
+
descriptor_filter: str,
|
|
341
|
+
no_fragments : bool,
|
|
342
|
+
max_events: int
|
|
343
|
+
) -> None:
|
|
344
|
+
super().__init__(
|
|
345
|
+
group=group,
|
|
346
|
+
port=port,
|
|
347
|
+
descriptor_filter=descriptor_filter,
|
|
348
|
+
time_mode=time_mode,
|
|
349
|
+
no_fragments=no_fragments
|
|
350
|
+
)
|
|
351
|
+
|
|
352
|
+
if Console is None:
|
|
353
|
+
print("This viewer requires 'rich'. Install it with: pip install rich", file=sys.stderr)
|
|
354
|
+
raise SystemExit(2)
|
|
355
|
+
|
|
356
|
+
self._use_stdin = os.name == "posix"
|
|
357
|
+
if self._use_stdin:
|
|
358
|
+
self._selector.register(sys.stdin, selectors.EVENT_READ, data="stdin")
|
|
359
|
+
|
|
360
|
+
self._selected_idx: int = 0
|
|
361
|
+
|
|
362
|
+
self.max_events = max_events
|
|
363
|
+
|
|
364
|
+
self._scroll_offset : int = 0
|
|
365
|
+
self._auto_follow : bool = True
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def _handle_key(self, token: str) -> bool:
|
|
369
|
+
# returns False to quit
|
|
370
|
+
token = token.strip()
|
|
371
|
+
if token in ("q", "Q"):
|
|
372
|
+
return False
|
|
373
|
+
if token in ("LEFT", "h"):
|
|
374
|
+
|
|
375
|
+
if self._adapters:
|
|
376
|
+
self._selected_idx = (self._selected_idx - 1) % len(self._adapters)
|
|
377
|
+
if token in ("RIGHT", "l"):
|
|
378
|
+
if self._adapters:
|
|
379
|
+
self._selected_idx = (self._selected_idx + 1) % len(self._adapters)
|
|
380
|
+
if token in ("UP", "k"):
|
|
381
|
+
if self._adapters:
|
|
382
|
+
#st = self.adapters[self.adapter_order[self.selected_idx]]
|
|
383
|
+
self._auto_follow = False
|
|
384
|
+
self._scroll_offset += 1
|
|
385
|
+
if token in ("DOWN", "j"):
|
|
386
|
+
if self._adapters:
|
|
387
|
+
#st = self.adapters[self.adapter_order[self.selected_idx]]
|
|
388
|
+
if self._scroll_offset > 0:
|
|
389
|
+
self._scroll_offset -= 1
|
|
390
|
+
if self._scroll_offset == 0:
|
|
391
|
+
self._auto_follow = True
|
|
392
|
+
return True
|
|
393
|
+
|
|
394
|
+
def run(self) -> None:
|
|
395
|
+
with _TerminalRawMode():
|
|
396
|
+
with Live(self._render_interactive(), refresh_per_second=20, screen=True) as live:
|
|
397
|
+
running = True
|
|
398
|
+
while running:
|
|
399
|
+
|
|
400
|
+
for event in self.get_event(timeout=0.05):
|
|
401
|
+
if event is None:
|
|
402
|
+
for k in _read_keys_nonblocking():
|
|
403
|
+
running = self._handle_key(k)
|
|
404
|
+
if not running:
|
|
405
|
+
break
|
|
406
|
+
else:
|
|
407
|
+
self.ingest(event)
|
|
408
|
+
|
|
409
|
+
if os.name == "nt":
|
|
410
|
+
for k in _read_keys_nonblocking():
|
|
411
|
+
running = self._handle_key(k)
|
|
412
|
+
if not running:
|
|
413
|
+
break
|
|
414
|
+
|
|
415
|
+
live.update(self._render_interactive())
|
|
416
|
+
if not running:
|
|
417
|
+
break
|
|
418
|
+
|
|
419
|
+
def ingest(self, event : TraceEvent, max_events : int = 0) -> bool:
|
|
420
|
+
"""Manage an incoming event"""
|
|
421
|
+
if not super().ingest(event, self.max_events):
|
|
422
|
+
return False
|
|
423
|
+
|
|
424
|
+
adapter = self._adapters[event.descriptor]
|
|
425
|
+
|
|
426
|
+
block = self._format_entry(event)
|
|
427
|
+
if block is not None:
|
|
428
|
+
if adapter.blocks is not None:
|
|
429
|
+
adapter.blocks.append(block)
|
|
430
|
+
|
|
431
|
+
return True
|
|
432
|
+
|
|
433
|
+
def _render_tabs(self) -> Text:
|
|
434
|
+
t = Text()
|
|
435
|
+
t.append("Adapters: ", style="dim")
|
|
436
|
+
if self._adapters:
|
|
437
|
+
for i, (name, adapter) in enumerate(self._adapters.items()):
|
|
438
|
+
if i == self._selected_idx:
|
|
439
|
+
style = f"bold reverse {adapter.style}"
|
|
440
|
+
else:
|
|
441
|
+
style = f"{adapter.style}"
|
|
442
|
+
t.append(f" {name} ", style=style)
|
|
443
|
+
else:
|
|
444
|
+
t.append("(no adapters)")
|
|
445
|
+
|
|
446
|
+
t.append(" ", style="dim")
|
|
447
|
+
t.append("←/→ switch ↑/↓ scroll q quit (h/j/k/l also)", style="dim")
|
|
448
|
+
return t
|
|
449
|
+
|
|
450
|
+
def _render_interactive(self) -> Panel:
|
|
451
|
+
if not self._adapters:
|
|
452
|
+
body = Text("Waiting for events…", style="dim")
|
|
453
|
+
return Panel(Align.left(body), title="Syndesi Trace", border_style="dim")
|
|
454
|
+
|
|
455
|
+
# Compose recent blocks into lines
|
|
456
|
+
lines: list[Text] = []
|
|
457
|
+
selected_adapter = list(self._adapters.values())[self._selected_idx]
|
|
458
|
+
if selected_adapter.blocks is not None:
|
|
459
|
+
for block in selected_adapter.blocks:
|
|
460
|
+
lines.append(block)
|
|
461
|
+
|
|
462
|
+
if not lines:
|
|
463
|
+
lines = [Text("No events yet for this adapter.", style="dim")]
|
|
464
|
+
|
|
465
|
+
body = Text("\n").join(self._visible_lines(lines))
|
|
466
|
+
header = self._render_tabs()
|
|
467
|
+
|
|
468
|
+
# Wrap body in a panel; header goes as title-ish via subtitle
|
|
469
|
+
return Panel(
|
|
470
|
+
Align.left(body),
|
|
471
|
+
title=header,
|
|
472
|
+
border_style="bright_black",
|
|
473
|
+
padding=(1, 1),
|
|
474
|
+
)
|
|
475
|
+
|
|
476
|
+
def _visible_lines(self, lines: list[Text]) -> list[Text]:
|
|
477
|
+
height = self._console.size.height if self._console is not None else 24
|
|
478
|
+
# Reserve space for borders, title, and padding.
|
|
479
|
+
visible = max(1, height - 6)
|
|
480
|
+
total = len(lines)
|
|
481
|
+
max_offset = max(0, total - visible)
|
|
482
|
+
|
|
483
|
+
if self._auto_follow:
|
|
484
|
+
self._scroll_offset = 0
|
|
485
|
+
else:
|
|
486
|
+
self._scroll_offset = min(max_offset, self._scroll_offset)
|
|
487
|
+
start = max(0, total - visible - self._scroll_offset)
|
|
488
|
+
return lines[start:start + visible]
|
|
489
|
+
|
|
490
|
+
class _TerminalRawMode:
|
|
491
|
+
"""Best-effort cbreak/raw mode (POSIX). Windows falls back to polling msvcrt."""
|
|
492
|
+
def __init__(self) -> None:
|
|
493
|
+
self._enabled = False
|
|
494
|
+
self._old : list[Any] | None = None
|
|
495
|
+
|
|
496
|
+
def __enter__(self) -> _TerminalRawMode:
|
|
497
|
+
if os.name != "posix":
|
|
498
|
+
return self
|
|
499
|
+
try:
|
|
500
|
+
fd = sys.stdin.fileno()
|
|
501
|
+
self._old = termios.tcgetattr(fd)
|
|
502
|
+
tty.setcbreak(fd)
|
|
503
|
+
self._enabled = True
|
|
504
|
+
except OSError:
|
|
505
|
+
self._enabled = False
|
|
506
|
+
return self
|
|
507
|
+
|
|
508
|
+
def __exit__(
|
|
509
|
+
self,
|
|
510
|
+
type_: type[BaseException] | None,
|
|
511
|
+
value: BaseException | None,
|
|
512
|
+
traceback: TracebackType | None
|
|
513
|
+
) -> None:
|
|
514
|
+
if os.name != "posix":
|
|
515
|
+
return
|
|
516
|
+
if not self._enabled or self._old is None:
|
|
517
|
+
return
|
|
518
|
+
try:
|
|
519
|
+
termios.tcsetattr(sys.stdin.fileno(), termios.TCSADRAIN, self._old)
|
|
520
|
+
except OSError:
|
|
521
|
+
pass
|
|
522
|
+
|
|
523
|
+
def _read_keys_nonblocking() -> list[str]:
|
|
524
|
+
"""
|
|
525
|
+
Read available keys without blocking.
|
|
526
|
+
Returns a list of key tokens: "LEFT", "RIGHT", "q", etc.
|
|
527
|
+
"""
|
|
528
|
+
keys: list[str] = []
|
|
529
|
+
|
|
530
|
+
if os.name == "nt":
|
|
531
|
+
raise NotImplementedError()
|
|
532
|
+
# try:
|
|
533
|
+
# import msvcrt
|
|
534
|
+
# while msvcrt.kbhit():
|
|
535
|
+
# ch = msvcrt.getwch()
|
|
536
|
+
# if ch in ("\x00", "\xe0"): # special key prefix
|
|
537
|
+
# ch2 = msvcrt.getwch()
|
|
538
|
+
# if ch2 == "K":
|
|
539
|
+
# keys.append("LEFT")
|
|
540
|
+
# elif ch2 == "M":
|
|
541
|
+
# keys.append("RIGHT")
|
|
542
|
+
# elif ch2 == "H":
|
|
543
|
+
# keys.append("UP")
|
|
544
|
+
# elif ch2 == "P":
|
|
545
|
+
# keys.append("DOWN")
|
|
546
|
+
# else:
|
|
547
|
+
# keys.append(ch)
|
|
548
|
+
# except Exception:
|
|
549
|
+
# return keys
|
|
550
|
+
# return keys
|
|
551
|
+
|
|
552
|
+
# POSIX: stdin will be readable via selectors. We still decode escape sequences here.
|
|
553
|
+
try:
|
|
554
|
+
data = os.read(sys.stdin.fileno(), 64)
|
|
555
|
+
except OSError:
|
|
556
|
+
return keys
|
|
557
|
+
|
|
558
|
+
if not data:
|
|
559
|
+
return keys
|
|
560
|
+
|
|
561
|
+
s = data.decode("utf-8", "ignore")
|
|
562
|
+
i = 0
|
|
563
|
+
while i < len(s):
|
|
564
|
+
ch = s[i]
|
|
565
|
+
if ch == "\x1b": # escape
|
|
566
|
+
# Arrow keys: ESC [ D/C
|
|
567
|
+
if i + 2 < len(s) and s[i + 1] == "[":
|
|
568
|
+
code = s[i + 2]
|
|
569
|
+
if code == "D":
|
|
570
|
+
keys.append("LEFT")
|
|
571
|
+
i += 3
|
|
572
|
+
continue
|
|
573
|
+
if code == "C":
|
|
574
|
+
keys.append("RIGHT")
|
|
575
|
+
i += 3
|
|
576
|
+
continue
|
|
577
|
+
if code == "A":
|
|
578
|
+
keys.append("UP")
|
|
579
|
+
i += 3
|
|
580
|
+
continue
|
|
581
|
+
if code == "B":
|
|
582
|
+
keys.append("DOWN")
|
|
583
|
+
i += 3
|
|
584
|
+
continue
|
|
585
|
+
i += 1
|
|
586
|
+
continue
|
|
587
|
+
keys.append(ch)
|
|
588
|
+
i += 1
|
|
589
|
+
|
|
590
|
+
return keys
|
|
591
|
+
|
|
592
|
+
# -----------------------------
|
|
593
|
+
# Viewer state
|
|
594
|
+
# -----------------------------
|
|
595
|
+
|
|
596
|
+
@dataclass
|
|
597
|
+
class AdapterProperties:
|
|
598
|
+
"""Adapter state class"""
|
|
599
|
+
blocks : deque[Text] | None
|
|
600
|
+
style : str
|
|
601
|
+
last_write_timestamp: float | None = None
|
|
602
|
+
|
|
603
|
+
|
|
604
|
+
# -----------------------------
|
|
605
|
+
# Main loop
|
|
606
|
+
# -----------------------------
|
|
607
|
+
|
|
608
|
+
DEFAULT_MAX_EVENTS = 1000
|
|
609
|
+
|
|
610
|
+
def main(argv: list[str] | None = None) -> None:
|
|
611
|
+
"""Main syndesi-trace entry-point"""
|
|
612
|
+
|
|
613
|
+
parser = argparse.ArgumentParser(
|
|
614
|
+
prog="syndesi-trace",
|
|
615
|
+
description="Live console viewer for Syndesi trace events (UDP)."
|
|
616
|
+
)
|
|
617
|
+
|
|
618
|
+
parser.add_argument(
|
|
619
|
+
"--mode",
|
|
620
|
+
choices=[str(x) for x in TraceMode],
|
|
621
|
+
default=TraceMode.INTERACTIVE.value,
|
|
622
|
+
help="Display mode. interactive: tabs per adapter. flat: append-only timeline.",
|
|
623
|
+
)
|
|
624
|
+
|
|
625
|
+
# Network
|
|
626
|
+
parser.add_argument(
|
|
627
|
+
"--port",
|
|
628
|
+
type=int,
|
|
629
|
+
default=DEFAULT_MULTICAST_PORT,
|
|
630
|
+
help="UDP port to bind (default: 12000)."
|
|
631
|
+
)
|
|
632
|
+
parser.add_argument(
|
|
633
|
+
"--group",
|
|
634
|
+
default=DEFAULT_MULTICAST_GROUP,
|
|
635
|
+
help="If set, join this UDP multicast group "\
|
|
636
|
+
"(enables true pub/sub without producer client management).",
|
|
637
|
+
)
|
|
638
|
+
|
|
639
|
+
# Filtering
|
|
640
|
+
parser.add_argument(
|
|
641
|
+
"--filter",
|
|
642
|
+
default="*",
|
|
643
|
+
type=str,
|
|
644
|
+
help="Filter which adapters are displayed"
|
|
645
|
+
)
|
|
646
|
+
|
|
647
|
+
# Time
|
|
648
|
+
parser.add_argument(
|
|
649
|
+
"--time",
|
|
650
|
+
dest="time_mode",
|
|
651
|
+
choices=[str(x) for x in TimeMode],
|
|
652
|
+
default=TimeMode.RELATIVE.value,
|
|
653
|
+
help="Time display: rel = relative to adapter open/first seen, "\
|
|
654
|
+
"abs = absolute timestamp")
|
|
655
|
+
|
|
656
|
+
# Content verbosity
|
|
657
|
+
parser.add_argument(
|
|
658
|
+
"--no-frag",
|
|
659
|
+
action="store_true",
|
|
660
|
+
help="Disable fragment display"
|
|
661
|
+
)
|
|
662
|
+
parser.add_argument(
|
|
663
|
+
"--max-events",
|
|
664
|
+
type=int,
|
|
665
|
+
default=DEFAULT_MAX_EVENTS,
|
|
666
|
+
help="Max event blocks kept per adapter in interactive mode (0 = unlimited)."
|
|
667
|
+
)
|
|
668
|
+
|
|
669
|
+
# Output (flat)
|
|
670
|
+
parser.add_argument(
|
|
671
|
+
"--output",
|
|
672
|
+
default=None,
|
|
673
|
+
help="Write flat output to this file instead of stdout."
|
|
674
|
+
)
|
|
675
|
+
|
|
676
|
+
args = parser.parse_args(argv)
|
|
677
|
+
|
|
678
|
+
mode = TraceMode(args.mode)
|
|
679
|
+
|
|
680
|
+
trace : Trace
|
|
681
|
+
|
|
682
|
+
if mode == TraceMode.INTERACTIVE:
|
|
683
|
+
trace = InteractiveTrace(
|
|
684
|
+
group=args.group,
|
|
685
|
+
port=args.port,
|
|
686
|
+
time_mode=args.time_mode,
|
|
687
|
+
descriptor_filter=args.filter,
|
|
688
|
+
no_fragments=args.no_frag,
|
|
689
|
+
max_events=args.max_events
|
|
690
|
+
)
|
|
691
|
+
elif mode == TraceMode.FLAT:
|
|
692
|
+
trace = FlatTrace(
|
|
693
|
+
group=args.group,
|
|
694
|
+
port=args.port,
|
|
695
|
+
time_mode=args.time_mode,
|
|
696
|
+
descriptor_filter=args.filter,
|
|
697
|
+
no_fragments=args.no_frag,
|
|
698
|
+
output=args.output
|
|
699
|
+
)
|
|
700
|
+
else:
|
|
701
|
+
raise ValueError(f'Invalid mode : {mode}')
|
|
702
|
+
|
|
703
|
+
try:
|
|
704
|
+
trace.run()
|
|
705
|
+
except KeyboardInterrupt:
|
|
706
|
+
pass
|
|
707
|
+
finally:
|
|
708
|
+
trace.close()
|
|
709
|
+
|
|
710
|
+
if __name__ == "__main__":
|
|
711
|
+
main()
|