tty-egpf-monitor 0.5.26__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.
- tty_egpf_monitor/__init__.py +20 -0
- tty_egpf_monitor/cli.py +172 -0
- tty_egpf_monitor/client.py +324 -0
- tty_egpf_monitor/models.py +141 -0
- tty_egpf_monitor/py.typed +2 -0
- tty_egpf_monitor-0.5.26.dist-info/METADATA +237 -0
- tty_egpf_monitor-0.5.26.dist-info/RECORD +10 -0
- tty_egpf_monitor-0.5.26.dist-info/WHEEL +5 -0
- tty_egpf_monitor-0.5.26.dist-info/entry_points.txt +2 -0
- tty_egpf_monitor-0.5.26.dist-info/top_level.txt +1 -0
@@ -0,0 +1,20 @@
|
|
1
|
+
"""
|
2
|
+
TTY eBPF Monitor Python Client Library
|
3
|
+
|
4
|
+
A Python client library for interacting with the tty-egpf-monitord daemon.
|
5
|
+
Provides a clean, Pythonic interface to the HTTP+JSON API over Unix domain sockets.
|
6
|
+
"""
|
7
|
+
|
8
|
+
from .client import TTYMonitorClient, TTYMonitorError
|
9
|
+
from .models import Port, LogEntry
|
10
|
+
|
11
|
+
__version__ = "0.5.26"
|
12
|
+
__author__ = "TTY eBPF Monitor Team"
|
13
|
+
__license__ = "GPL-3.0"
|
14
|
+
|
15
|
+
__all__ = [
|
16
|
+
"TTYMonitorClient",
|
17
|
+
"TTYMonitorError",
|
18
|
+
"Port",
|
19
|
+
"LogEntry",
|
20
|
+
]
|
tty_egpf_monitor/cli.py
ADDED
@@ -0,0 +1,172 @@
|
|
1
|
+
#!/usr/bin/env python3
|
2
|
+
"""
|
3
|
+
TTY eBPF Monitor CLI
|
4
|
+
|
5
|
+
Command-line interface for the tty-egpf-monitor daemon using the Python client library.
|
6
|
+
"""
|
7
|
+
|
8
|
+
import argparse
|
9
|
+
import sys
|
10
|
+
import json
|
11
|
+
from typing import Optional
|
12
|
+
from .client import TTYMonitorClient, TTYMonitorError
|
13
|
+
|
14
|
+
|
15
|
+
def cmd_add(client: TTYMonitorClient, args: argparse.Namespace) -> int:
|
16
|
+
"""Add a port to monitor."""
|
17
|
+
try:
|
18
|
+
idx = client.add_port(args.device, args.baudrate, args.logfile)
|
19
|
+
print(json.dumps({"idx": idx}))
|
20
|
+
return 0
|
21
|
+
except TTYMonitorError as e:
|
22
|
+
print(f"Error: {e}", file=sys.stderr)
|
23
|
+
return 1
|
24
|
+
|
25
|
+
|
26
|
+
def cmd_list(client: TTYMonitorClient, args: argparse.Namespace) -> int:
|
27
|
+
"""List configured ports."""
|
28
|
+
try:
|
29
|
+
ports = client.list_ports()
|
30
|
+
ports_data = [{"idx": p.idx, "dev": p.device} for p in ports]
|
31
|
+
print(json.dumps(ports_data))
|
32
|
+
return 0
|
33
|
+
except TTYMonitorError as e:
|
34
|
+
print(f"Error: {e}", file=sys.stderr)
|
35
|
+
return 1
|
36
|
+
|
37
|
+
|
38
|
+
def cmd_logs(client: TTYMonitorClient, args: argparse.Namespace) -> int:
|
39
|
+
"""Download full log for a port."""
|
40
|
+
try:
|
41
|
+
# Parse port identifier (int or string)
|
42
|
+
port_id = args.port
|
43
|
+
try:
|
44
|
+
port_id = int(port_id)
|
45
|
+
except ValueError:
|
46
|
+
pass # Keep as string (device path)
|
47
|
+
|
48
|
+
logs = client.get_logs(port_id)
|
49
|
+
print(logs, end='') # Don't add extra newline, logs already have them
|
50
|
+
return 0
|
51
|
+
except TTYMonitorError as e:
|
52
|
+
print(f"Error: {e}", file=sys.stderr)
|
53
|
+
return 1
|
54
|
+
|
55
|
+
|
56
|
+
def cmd_stream(client: TTYMonitorClient, args: argparse.Namespace) -> int:
|
57
|
+
"""Live stream logs for a port."""
|
58
|
+
try:
|
59
|
+
# Parse port identifier (int or string)
|
60
|
+
port_id = args.port
|
61
|
+
try:
|
62
|
+
port_id = int(port_id)
|
63
|
+
except ValueError:
|
64
|
+
pass # Keep as string (device path)
|
65
|
+
|
66
|
+
for line in client.stream_logs(port_id):
|
67
|
+
print(line)
|
68
|
+
sys.stdout.flush()
|
69
|
+
|
70
|
+
return 0
|
71
|
+
except KeyboardInterrupt:
|
72
|
+
return 0
|
73
|
+
except TTYMonitorError as e:
|
74
|
+
print(f"Error: {e}", file=sys.stderr)
|
75
|
+
return 1
|
76
|
+
|
77
|
+
|
78
|
+
def cmd_remove(client: TTYMonitorClient, args: argparse.Namespace) -> int:
|
79
|
+
"""Remove a port from monitoring."""
|
80
|
+
try:
|
81
|
+
# Parse port identifier (int or string)
|
82
|
+
port_id = args.port
|
83
|
+
try:
|
84
|
+
port_id = int(port_id)
|
85
|
+
except ValueError:
|
86
|
+
pass # Keep as string (device path)
|
87
|
+
|
88
|
+
success = client.remove_port(port_id)
|
89
|
+
if success:
|
90
|
+
print(json.dumps({"ok": True}))
|
91
|
+
return 0
|
92
|
+
else:
|
93
|
+
print("Error: Remove failed", file=sys.stderr)
|
94
|
+
return 1
|
95
|
+
except TTYMonitorError as e:
|
96
|
+
print(f"Error: {e}", file=sys.stderr)
|
97
|
+
return 1
|
98
|
+
|
99
|
+
|
100
|
+
def main() -> int:
|
101
|
+
"""Main CLI entry point."""
|
102
|
+
parser = argparse.ArgumentParser(
|
103
|
+
description="TTY eBPF Monitor Python CLI",
|
104
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
105
|
+
epilog="""
|
106
|
+
Examples:
|
107
|
+
tty-egpf-monitor-py add /dev/ttyUSB0 115200
|
108
|
+
tty-egpf-monitor-py list
|
109
|
+
tty-egpf-monitor-py stream /dev/ttyUSB0
|
110
|
+
tty-egpf-monitor-py logs 0
|
111
|
+
tty-egpf-monitor-py remove /dev/ttyUSB0
|
112
|
+
|
113
|
+
For more information, see: https://github.com/seelso-net/tty-egpf-monitor
|
114
|
+
"""
|
115
|
+
)
|
116
|
+
|
117
|
+
parser.add_argument(
|
118
|
+
"--socket",
|
119
|
+
default="/run/tty-egpf-monitord.sock",
|
120
|
+
help="Path to daemon socket (default: %(default)s)"
|
121
|
+
)
|
122
|
+
|
123
|
+
subparsers = parser.add_subparsers(dest="command", help="Available commands")
|
124
|
+
|
125
|
+
# Add command
|
126
|
+
add_parser = subparsers.add_parser("add", help="Add a monitored port")
|
127
|
+
add_parser.add_argument("device", help="Device path (e.g., /dev/ttyUSB0)")
|
128
|
+
add_parser.add_argument("baudrate", nargs="?", type=int, default=115200, help="Baud rate (default: 115200)")
|
129
|
+
add_parser.add_argument("logfile", nargs="?", help="Custom log file path (optional)")
|
130
|
+
|
131
|
+
# List command
|
132
|
+
subparsers.add_parser("list", help="List configured ports")
|
133
|
+
|
134
|
+
# Logs command
|
135
|
+
logs_parser = subparsers.add_parser("logs", help="Download full log for a port")
|
136
|
+
logs_parser.add_argument("port", help="Port index or device path")
|
137
|
+
|
138
|
+
# Stream command
|
139
|
+
stream_parser = subparsers.add_parser("stream", help="Live stream logs for a port")
|
140
|
+
stream_parser.add_argument("port", help="Port index or device path")
|
141
|
+
|
142
|
+
# Remove command
|
143
|
+
remove_parser = subparsers.add_parser("remove", help="Remove a port from monitoring")
|
144
|
+
remove_parser.add_argument("port", help="Port index or device path")
|
145
|
+
|
146
|
+
args = parser.parse_args()
|
147
|
+
|
148
|
+
if not args.command:
|
149
|
+
parser.print_help()
|
150
|
+
return 2
|
151
|
+
|
152
|
+
# Create client
|
153
|
+
client = TTYMonitorClient(args.socket)
|
154
|
+
|
155
|
+
# Execute command
|
156
|
+
if args.command == "add":
|
157
|
+
return cmd_add(client, args)
|
158
|
+
elif args.command == "list":
|
159
|
+
return cmd_list(client, args)
|
160
|
+
elif args.command == "logs":
|
161
|
+
return cmd_logs(client, args)
|
162
|
+
elif args.command == "stream":
|
163
|
+
return cmd_stream(client, args)
|
164
|
+
elif args.command == "remove":
|
165
|
+
return cmd_remove(client, args)
|
166
|
+
else:
|
167
|
+
parser.print_help()
|
168
|
+
return 2
|
169
|
+
|
170
|
+
|
171
|
+
if __name__ == "__main__":
|
172
|
+
sys.exit(main())
|
@@ -0,0 +1,324 @@
|
|
1
|
+
"""
|
2
|
+
TTY eBPF Monitor Python Client
|
3
|
+
|
4
|
+
Provides a Python interface to the tty-egpf-monitord daemon via Unix domain socket.
|
5
|
+
"""
|
6
|
+
|
7
|
+
import json
|
8
|
+
import socket
|
9
|
+
import time
|
10
|
+
from typing import List, Optional, Iterator, Dict, Any, Union
|
11
|
+
from datetime import datetime
|
12
|
+
from .models import Port, LogEntry
|
13
|
+
|
14
|
+
|
15
|
+
class TTYMonitorError(Exception):
|
16
|
+
"""Exception raised for TTY Monitor API errors."""
|
17
|
+
pass
|
18
|
+
|
19
|
+
|
20
|
+
class TTYMonitorClient:
|
21
|
+
"""Client for interacting with tty-egpf-monitord daemon."""
|
22
|
+
|
23
|
+
def __init__(self, socket_path: str = "/run/tty-egpf-monitord.sock"):
|
24
|
+
"""
|
25
|
+
Initialize the client.
|
26
|
+
|
27
|
+
Args:
|
28
|
+
socket_path: Path to the daemon's Unix domain socket
|
29
|
+
"""
|
30
|
+
self.socket_path = socket_path
|
31
|
+
|
32
|
+
def _send_http_request(self, method: str, path: str, body: Optional[str] = None) -> str:
|
33
|
+
"""Send HTTP request over Unix domain socket and return response body."""
|
34
|
+
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
35
|
+
try:
|
36
|
+
sock.connect(self.socket_path)
|
37
|
+
|
38
|
+
# Build HTTP request
|
39
|
+
if body:
|
40
|
+
request = (
|
41
|
+
f"{method} {path} HTTP/1.1\r\n"
|
42
|
+
f"Host: localhost\r\n"
|
43
|
+
f"Content-Type: application/json\r\n"
|
44
|
+
f"Content-Length: {len(body)}\r\n"
|
45
|
+
f"Connection: close\r\n"
|
46
|
+
f"\r\n"
|
47
|
+
f"{body}"
|
48
|
+
)
|
49
|
+
else:
|
50
|
+
request = (
|
51
|
+
f"{method} {path} HTTP/1.1\r\n"
|
52
|
+
f"Host: localhost\r\n"
|
53
|
+
f"Connection: close\r\n"
|
54
|
+
f"\r\n"
|
55
|
+
)
|
56
|
+
|
57
|
+
sock.sendall(request.encode())
|
58
|
+
|
59
|
+
# Read response
|
60
|
+
response = b""
|
61
|
+
while True:
|
62
|
+
chunk = sock.recv(4096)
|
63
|
+
if not chunk:
|
64
|
+
break
|
65
|
+
response += chunk
|
66
|
+
|
67
|
+
response_str = response.decode('utf-8')
|
68
|
+
|
69
|
+
# Parse HTTP response
|
70
|
+
if '\r\n\r\n' not in response_str:
|
71
|
+
raise TTYMonitorError("Invalid HTTP response")
|
72
|
+
|
73
|
+
headers, body = response_str.split('\r\n\r\n', 1)
|
74
|
+
|
75
|
+
# Check status code
|
76
|
+
status_line = headers.split('\r\n')[0]
|
77
|
+
if not status_line.startswith('HTTP/1.1 2'):
|
78
|
+
# Extract error message from body if available
|
79
|
+
error_msg = body.strip() if body else "Request failed"
|
80
|
+
raise TTYMonitorError(f"HTTP error: {status_line} - {error_msg}")
|
81
|
+
|
82
|
+
return body
|
83
|
+
|
84
|
+
except socket.error as e:
|
85
|
+
raise TTYMonitorError(f"Socket error: {e}")
|
86
|
+
finally:
|
87
|
+
sock.close()
|
88
|
+
|
89
|
+
def list_ports(self) -> List[Port]:
|
90
|
+
"""
|
91
|
+
List all configured ports.
|
92
|
+
|
93
|
+
Returns:
|
94
|
+
List of Port objects
|
95
|
+
"""
|
96
|
+
body = self._send_http_request("GET", "/ports")
|
97
|
+
try:
|
98
|
+
ports_data = json.loads(body)
|
99
|
+
return [Port.from_dict(port_data) for port_data in ports_data]
|
100
|
+
except json.JSONDecodeError as e:
|
101
|
+
raise TTYMonitorError(f"Invalid JSON response: {e}")
|
102
|
+
|
103
|
+
def add_port(self, device: str, baudrate: int = 115200, log_path: Optional[str] = None) -> int:
|
104
|
+
"""
|
105
|
+
Add a port to monitor.
|
106
|
+
|
107
|
+
Args:
|
108
|
+
device: Device path (e.g., "/dev/ttyUSB0")
|
109
|
+
baudrate: Baud rate (default: 115200)
|
110
|
+
log_path: Custom log path (optional)
|
111
|
+
|
112
|
+
Returns:
|
113
|
+
Port index
|
114
|
+
"""
|
115
|
+
request_data = {
|
116
|
+
"dev": device,
|
117
|
+
"log": log_path or "",
|
118
|
+
"baudrate": baudrate
|
119
|
+
}
|
120
|
+
|
121
|
+
body = self._send_http_request("POST", "/ports", json.dumps(request_data))
|
122
|
+
try:
|
123
|
+
response = json.loads(body)
|
124
|
+
idx = response["idx"]
|
125
|
+
if not isinstance(idx, int):
|
126
|
+
raise TTYMonitorError(f"Invalid idx type: {type(idx)}")
|
127
|
+
return idx
|
128
|
+
except (json.JSONDecodeError, KeyError) as e:
|
129
|
+
raise TTYMonitorError(f"Invalid response: {e}")
|
130
|
+
|
131
|
+
def remove_port(self, port_identifier: Union[str, int]) -> bool:
|
132
|
+
"""
|
133
|
+
Remove a port from monitoring.
|
134
|
+
|
135
|
+
Args:
|
136
|
+
port_identifier: Port index (int) or device path (str)
|
137
|
+
|
138
|
+
Returns:
|
139
|
+
True if successful
|
140
|
+
"""
|
141
|
+
if isinstance(port_identifier, int):
|
142
|
+
# Remove by index
|
143
|
+
body = self._send_http_request("DELETE", f"/ports/{port_identifier}")
|
144
|
+
else:
|
145
|
+
# Remove by device path
|
146
|
+
request_data = {"dev": str(port_identifier)}
|
147
|
+
body = self._send_http_request("DELETE", "/ports", json.dumps(request_data))
|
148
|
+
|
149
|
+
try:
|
150
|
+
response = json.loads(body)
|
151
|
+
ok_value = response.get("ok", False)
|
152
|
+
return bool(ok_value)
|
153
|
+
except json.JSONDecodeError:
|
154
|
+
return False
|
155
|
+
|
156
|
+
def get_logs(self, port_identifier: Union[str, int]) -> str:
|
157
|
+
"""
|
158
|
+
Download full log for a port.
|
159
|
+
|
160
|
+
Args:
|
161
|
+
port_identifier: Port index (int) or device path (str)
|
162
|
+
|
163
|
+
Returns:
|
164
|
+
Raw log content
|
165
|
+
"""
|
166
|
+
if isinstance(port_identifier, str):
|
167
|
+
# Resolve device path to index
|
168
|
+
ports = self.list_ports()
|
169
|
+
idx = None
|
170
|
+
for port in ports:
|
171
|
+
if port.device == port_identifier:
|
172
|
+
idx = port.idx
|
173
|
+
break
|
174
|
+
if idx is None:
|
175
|
+
raise TTYMonitorError(f"Device not found: {port_identifier}")
|
176
|
+
else:
|
177
|
+
idx = port_identifier
|
178
|
+
|
179
|
+
return self._send_http_request("GET", f"/logs/{idx}")
|
180
|
+
|
181
|
+
def parse_logs(self, log_content: str) -> List[LogEntry]:
|
182
|
+
"""
|
183
|
+
Parse log content into LogEntry objects.
|
184
|
+
|
185
|
+
Args:
|
186
|
+
log_content: Raw log content from get_logs()
|
187
|
+
|
188
|
+
Returns:
|
189
|
+
List of parsed LogEntry objects
|
190
|
+
"""
|
191
|
+
entries = []
|
192
|
+
for line in log_content.strip().split('\n'):
|
193
|
+
if line.strip():
|
194
|
+
try:
|
195
|
+
entry = LogEntry.parse_simple_log(line)
|
196
|
+
entries.append(entry)
|
197
|
+
except Exception:
|
198
|
+
# If parsing fails, create a basic entry with raw line
|
199
|
+
entries.append(LogEntry(
|
200
|
+
timestamp=datetime.now(),
|
201
|
+
event_type="UNPARSED",
|
202
|
+
process="",
|
203
|
+
raw_line=line.strip()
|
204
|
+
))
|
205
|
+
return entries
|
206
|
+
|
207
|
+
def stream_logs(self, port_identifier: Union[str, int]) -> Iterator[str]:
|
208
|
+
"""
|
209
|
+
Stream live logs for a port.
|
210
|
+
|
211
|
+
Args:
|
212
|
+
port_identifier: Port index (int) or device path (str)
|
213
|
+
|
214
|
+
Yields:
|
215
|
+
Log lines as they arrive
|
216
|
+
"""
|
217
|
+
if isinstance(port_identifier, str):
|
218
|
+
# Resolve device path to index
|
219
|
+
ports = self.list_ports()
|
220
|
+
idx = None
|
221
|
+
for port in ports:
|
222
|
+
if port.device == port_identifier:
|
223
|
+
idx = port.idx
|
224
|
+
break
|
225
|
+
if idx is None:
|
226
|
+
raise TTYMonitorError(f"Device not found: {port_identifier}")
|
227
|
+
else:
|
228
|
+
idx = port_identifier
|
229
|
+
|
230
|
+
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
231
|
+
try:
|
232
|
+
sock.connect(self.socket_path)
|
233
|
+
|
234
|
+
# Send streaming request
|
235
|
+
request = (
|
236
|
+
f"GET /stream/{idx} HTTP/1.1\r\n"
|
237
|
+
f"Host: localhost\r\n"
|
238
|
+
f"Connection: close\r\n"
|
239
|
+
f"\r\n"
|
240
|
+
)
|
241
|
+
sock.sendall(request.encode())
|
242
|
+
|
243
|
+
# Read response headers
|
244
|
+
headers = b""
|
245
|
+
while b'\r\n\r\n' not in headers:
|
246
|
+
chunk = sock.recv(1)
|
247
|
+
if not chunk:
|
248
|
+
raise TTYMonitorError("Connection closed while reading headers")
|
249
|
+
headers += chunk
|
250
|
+
|
251
|
+
# Check status
|
252
|
+
headers_str = headers.decode('utf-8')
|
253
|
+
if not headers_str.startswith('HTTP/1.1 200'):
|
254
|
+
raise TTYMonitorError(f"Stream failed: {headers_str.split()[1]}")
|
255
|
+
|
256
|
+
# Read chunked response
|
257
|
+
buffer = b""
|
258
|
+
while True:
|
259
|
+
chunk = sock.recv(4096)
|
260
|
+
if not chunk:
|
261
|
+
break
|
262
|
+
|
263
|
+
buffer += chunk
|
264
|
+
|
265
|
+
# Process complete lines
|
266
|
+
while b'\n' in buffer:
|
267
|
+
line, buffer = buffer.split(b'\n', 1)
|
268
|
+
line_str = line.decode('utf-8', errors='replace').strip()
|
269
|
+
|
270
|
+
# Skip HTTP chunked encoding artifacts
|
271
|
+
if line_str and not line_str.isdigit() and line_str != '0':
|
272
|
+
# Skip chunked encoding hex numbers and empty lines
|
273
|
+
if not all(c in '0123456789abcdefABCDEF' for c in line_str):
|
274
|
+
yield line_str
|
275
|
+
|
276
|
+
except socket.error as e:
|
277
|
+
raise TTYMonitorError(f"Socket error: {e}")
|
278
|
+
finally:
|
279
|
+
sock.close()
|
280
|
+
|
281
|
+
def stream_parsed_logs(self, port_identifier: Union[str, int]) -> Iterator[LogEntry]:
|
282
|
+
"""
|
283
|
+
Stream live logs for a port, parsed into LogEntry objects.
|
284
|
+
|
285
|
+
Args:
|
286
|
+
port_identifier: Port index (int) or device path (str)
|
287
|
+
|
288
|
+
Yields:
|
289
|
+
Parsed LogEntry objects as they arrive
|
290
|
+
"""
|
291
|
+
for line in self.stream_logs(port_identifier):
|
292
|
+
try:
|
293
|
+
yield LogEntry.parse_simple_log(line)
|
294
|
+
except Exception:
|
295
|
+
# If parsing fails, yield raw entry
|
296
|
+
yield LogEntry(
|
297
|
+
timestamp=datetime.now(),
|
298
|
+
event_type="UNPARSED",
|
299
|
+
process="",
|
300
|
+
raw_line=line
|
301
|
+
)
|
302
|
+
|
303
|
+
def wait_for_event(self, port_identifier: Union[str, int], event_type: str, timeout: float = 30.0) -> Optional[LogEntry]:
|
304
|
+
"""
|
305
|
+
Wait for a specific event type on a port.
|
306
|
+
|
307
|
+
Args:
|
308
|
+
port_identifier: Port index (int) or device path (str)
|
309
|
+
event_type: Event type to wait for (e.g., "OPEN", "WRITE", "READ")
|
310
|
+
timeout: Maximum time to wait in seconds
|
311
|
+
|
312
|
+
Returns:
|
313
|
+
LogEntry if event found, None if timeout
|
314
|
+
"""
|
315
|
+
start_time = time.time()
|
316
|
+
|
317
|
+
for entry in self.stream_parsed_logs(port_identifier):
|
318
|
+
if entry.event_type == event_type:
|
319
|
+
return entry
|
320
|
+
|
321
|
+
if time.time() - start_time > timeout:
|
322
|
+
break
|
323
|
+
|
324
|
+
return None
|
@@ -0,0 +1,141 @@
|
|
1
|
+
"""
|
2
|
+
Data models for TTY eBPF Monitor client.
|
3
|
+
"""
|
4
|
+
|
5
|
+
from dataclasses import dataclass
|
6
|
+
from typing import Optional, Dict, Any
|
7
|
+
from datetime import datetime
|
8
|
+
|
9
|
+
|
10
|
+
@dataclass
|
11
|
+
class Port:
|
12
|
+
"""Represents a monitored TTY port."""
|
13
|
+
idx: int
|
14
|
+
device: str
|
15
|
+
baudrate: Optional[int] = None
|
16
|
+
log_path: Optional[str] = None
|
17
|
+
|
18
|
+
@classmethod
|
19
|
+
def from_dict(cls, data: Dict[str, Any]) -> 'Port':
|
20
|
+
"""Create Port from daemon response dictionary."""
|
21
|
+
return cls(
|
22
|
+
idx=data['idx'],
|
23
|
+
device=data['dev'],
|
24
|
+
baudrate=data.get('baudrate'),
|
25
|
+
log_path=data.get('log_path')
|
26
|
+
)
|
27
|
+
|
28
|
+
|
29
|
+
@dataclass
|
30
|
+
class LogEntry:
|
31
|
+
"""Represents a single log entry from the TTY monitor."""
|
32
|
+
timestamp: datetime
|
33
|
+
event_type: str
|
34
|
+
process: str
|
35
|
+
direction: Optional[str] = None
|
36
|
+
data: Optional[bytes] = None
|
37
|
+
baudrate: Optional[int] = None
|
38
|
+
ioctl_cmd: Optional[int] = None
|
39
|
+
raw_line: str = ""
|
40
|
+
|
41
|
+
@classmethod
|
42
|
+
def parse_simple_log(cls, line: str) -> 'LogEntry':
|
43
|
+
"""Parse a simple format log line."""
|
44
|
+
import re
|
45
|
+
|
46
|
+
# Parse format: [04.09.25 14:52:46.874] WRITE: picocom APP->DEV "data"
|
47
|
+
pattern = r'\[(\d{2}\.\d{2}\.\d{2}) (\d{2}:\d{2}:\d{2}\.\d{3})\] (\w+): (\w+)(?: (APP->DEV|DEV->APP))?(?: (.*))?'
|
48
|
+
match = re.match(pattern, line.strip())
|
49
|
+
|
50
|
+
if not match:
|
51
|
+
# Fallback for other formats like MODE_CHANGE
|
52
|
+
pattern2 = r'\[(\d{2}\.\d{2}\.\d{2}) (\d{2}:\d{2}:\d{2}\.\d{3})\] (\w+): (.*)'
|
53
|
+
match2 = re.match(pattern2, line.strip())
|
54
|
+
if match2:
|
55
|
+
date_str, time_str, event_type, details = match2.groups()
|
56
|
+
timestamp = cls._parse_datetime(date_str, time_str)
|
57
|
+
return cls(
|
58
|
+
timestamp=timestamp,
|
59
|
+
event_type=event_type,
|
60
|
+
process=details.split()[0] if details else "",
|
61
|
+
raw_line=line.strip()
|
62
|
+
)
|
63
|
+
|
64
|
+
# If no pattern matches, return basic entry
|
65
|
+
return cls(
|
66
|
+
timestamp=datetime.now(),
|
67
|
+
event_type="UNKNOWN",
|
68
|
+
process="",
|
69
|
+
raw_line=line.strip()
|
70
|
+
)
|
71
|
+
|
72
|
+
date_str, time_str, event_type, process, direction, data_part = match.groups()
|
73
|
+
timestamp = cls._parse_datetime(date_str, time_str)
|
74
|
+
|
75
|
+
# Parse data if present (quoted string with escapes)
|
76
|
+
data = None
|
77
|
+
if data_part and data_part.startswith('"') and data_part.endswith('"'):
|
78
|
+
data_str = data_part[1:-1] # Remove quotes
|
79
|
+
# Decode escape sequences
|
80
|
+
data = cls._decode_escaped_data(data_str)
|
81
|
+
|
82
|
+
return cls(
|
83
|
+
timestamp=timestamp,
|
84
|
+
event_type=event_type,
|
85
|
+
process=process,
|
86
|
+
direction=direction,
|
87
|
+
data=data,
|
88
|
+
raw_line=line.strip()
|
89
|
+
)
|
90
|
+
|
91
|
+
@staticmethod
|
92
|
+
def _parse_datetime(date_str: str, time_str: str) -> datetime:
|
93
|
+
"""Parse dd.mm.yy HH:MM:SS.mmm format."""
|
94
|
+
from datetime import datetime
|
95
|
+
|
96
|
+
# Parse date: dd.mm.yy
|
97
|
+
day_str, month_str, year_str = date_str.split('.')
|
98
|
+
year_int = int('20' + year_str) # Convert yy to 20yy
|
99
|
+
|
100
|
+
# Parse time: HH:MM:SS.mmm
|
101
|
+
time_part, ms_part = time_str.split('.')
|
102
|
+
hour_str, minute_str, second_str = time_part.split(':')
|
103
|
+
|
104
|
+
return datetime(
|
105
|
+
year=year_int,
|
106
|
+
month=int(month_str),
|
107
|
+
day=int(day_str),
|
108
|
+
hour=int(hour_str),
|
109
|
+
minute=int(minute_str),
|
110
|
+
second=int(second_str),
|
111
|
+
microsecond=int(ms_part) * 1000 # Convert ms to microseconds
|
112
|
+
)
|
113
|
+
|
114
|
+
@staticmethod
|
115
|
+
def _decode_escaped_data(data_str: str) -> bytes:
|
116
|
+
"""Decode escaped data string to bytes."""
|
117
|
+
result = bytearray()
|
118
|
+
i = 0
|
119
|
+
while i < len(data_str):
|
120
|
+
if data_str[i] == '\\' and i + 1 < len(data_str):
|
121
|
+
if data_str[i + 1] == 'x' and i + 3 < len(data_str):
|
122
|
+
# Hex escape: \xNN
|
123
|
+
try:
|
124
|
+
hex_val = int(data_str[i+2:i+4], 16)
|
125
|
+
result.append(hex_val)
|
126
|
+
i += 4
|
127
|
+
except ValueError:
|
128
|
+
result.append(ord(data_str[i]))
|
129
|
+
i += 1
|
130
|
+
elif data_str[i + 1] in ['\\', '"']:
|
131
|
+
# Escaped backslash or quote
|
132
|
+
result.append(ord(data_str[i + 1]))
|
133
|
+
i += 2
|
134
|
+
else:
|
135
|
+
result.append(ord(data_str[i]))
|
136
|
+
i += 1
|
137
|
+
else:
|
138
|
+
result.append(ord(data_str[i]))
|
139
|
+
i += 1
|
140
|
+
|
141
|
+
return bytes(result)
|
@@ -0,0 +1,237 @@
|
|
1
|
+
Metadata-Version: 2.4
|
2
|
+
Name: tty-egpf-monitor
|
3
|
+
Version: 0.5.26
|
4
|
+
Summary: Python client library for TTY eBPF Monitor daemon
|
5
|
+
Home-page: https://github.com/seelso-net/tty-egpf-monitor
|
6
|
+
Author: TTY eBPF Monitor Team
|
7
|
+
Author-email: TTY eBPF Monitor Team <contact@seelso.net>
|
8
|
+
Maintainer-email: TTY eBPF Monitor Team <contact@seelso.net>
|
9
|
+
License: GPL-3.0
|
10
|
+
Project-URL: Homepage, https://github.com/seelso-net/tty-egpf-monitor
|
11
|
+
Project-URL: Documentation, https://github.com/seelso-net/tty-egpf-monitor/blob/main/README.md
|
12
|
+
Project-URL: Repository, https://github.com/seelso-net/tty-egpf-monitor
|
13
|
+
Project-URL: Bug Tracker, https://github.com/seelso-net/tty-egpf-monitor/issues
|
14
|
+
Project-URL: APT Repository, https://seelso-net.github.io/tty-egpf-monitor
|
15
|
+
Keywords: serial,tty,monitoring,ebpf,uart,debugging,reverse-engineering,protocol-analysis,hardware
|
16
|
+
Classifier: Development Status :: 4 - Beta
|
17
|
+
Classifier: Environment :: Console
|
18
|
+
Classifier: Intended Audience :: Developers
|
19
|
+
Classifier: Intended Audience :: System Administrators
|
20
|
+
Classifier: License :: OSI Approved :: GNU General Public License v3 (GPLv3)
|
21
|
+
Classifier: Operating System :: POSIX :: Linux
|
22
|
+
Classifier: Programming Language :: Python :: 3
|
23
|
+
Classifier: Programming Language :: Python :: 3.8
|
24
|
+
Classifier: Programming Language :: Python :: 3.9
|
25
|
+
Classifier: Programming Language :: Python :: 3.10
|
26
|
+
Classifier: Programming Language :: Python :: 3.11
|
27
|
+
Classifier: Programming Language :: Python :: 3.12
|
28
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
29
|
+
Classifier: Topic :: System :: Hardware :: Hardware Drivers
|
30
|
+
Classifier: Topic :: System :: Monitoring
|
31
|
+
Classifier: Topic :: System :: Networking :: Monitoring
|
32
|
+
Classifier: Topic :: Terminals :: Serial
|
33
|
+
Requires-Python: >=3.8
|
34
|
+
Description-Content-Type: text/markdown
|
35
|
+
Provides-Extra: dev
|
36
|
+
Requires-Dist: pytest>=6.0; extra == "dev"
|
37
|
+
Requires-Dist: pytest-cov; extra == "dev"
|
38
|
+
Requires-Dist: black; extra == "dev"
|
39
|
+
Requires-Dist: flake8; extra == "dev"
|
40
|
+
Requires-Dist: mypy; extra == "dev"
|
41
|
+
Requires-Dist: build; extra == "dev"
|
42
|
+
Requires-Dist: twine; extra == "dev"
|
43
|
+
Dynamic: author
|
44
|
+
Dynamic: home-page
|
45
|
+
Dynamic: requires-python
|
46
|
+
|
47
|
+
# TTY eBPF Monitor Python Client
|
48
|
+
|
49
|
+
[](https://badge.fury.io/py/tty-egpf-monitor)
|
50
|
+
[](https://pypi.org/project/tty-egpf-monitor/)
|
51
|
+
[](https://www.gnu.org/licenses/gpl-3.0)
|
52
|
+
|
53
|
+
A Python client library for [TTY eBPF Monitor](https://github.com/seelso-net/tty-egpf-monitor), providing a clean, Pythonic interface to monitor serial port activity using eBPF technology.
|
54
|
+
|
55
|
+
## Features
|
56
|
+
|
57
|
+
- 🐍 **Pure Python** - No C dependencies, works with any Python 3.8+
|
58
|
+
- 🔌 **Unix Socket API** - Communicates with daemon via Unix domain socket
|
59
|
+
- 📊 **Parsed Log Entries** - Automatic parsing of log format with timestamps
|
60
|
+
- 🔄 **Live Streaming** - Real-time event streaming with iterator interface
|
61
|
+
- 🛠️ **CLI Wrapper** - Drop-in replacement for the C CLI tool
|
62
|
+
- 📚 **Rich Examples** - Comprehensive examples for common use cases
|
63
|
+
- 🔍 **Data Analysis** - Built-in support for protocol analysis and debugging
|
64
|
+
|
65
|
+
## Installation
|
66
|
+
|
67
|
+
### Install from PyPI
|
68
|
+
|
69
|
+
```bash
|
70
|
+
pip install tty-egpf-monitor
|
71
|
+
```
|
72
|
+
|
73
|
+
### Install the Daemon
|
74
|
+
|
75
|
+
The Python client requires the `tty-egpf-monitord` daemon to be running:
|
76
|
+
|
77
|
+
```bash
|
78
|
+
# Install daemon via APT repository
|
79
|
+
curl -fsSL https://raw.githubusercontent.com/seelso-net/tty-egpf-monitor/main/install.sh | bash
|
80
|
+
|
81
|
+
# Or install manually
|
82
|
+
CODENAME=$(lsb_release -cs)
|
83
|
+
REPO_URL=https://seelso-net.github.io/tty-egpf-monitor
|
84
|
+
curl -fsSL ${REPO_URL}/public-apt-key.asc | sudo gpg --dearmor -o /usr/share/keyrings/tty-egpf-monitor.gpg
|
85
|
+
echo "deb [signed-by=/usr/share/keyrings/tty-egpf-monitor.gpg] ${REPO_URL} ${CODENAME} main" | sudo tee /etc/apt/sources.list.d/tty-egpf-monitor.list
|
86
|
+
sudo apt-get update && sudo apt-get install -y tty-egpf-monitord
|
87
|
+
sudo systemctl enable --now tty-egpf-monitord
|
88
|
+
```
|
89
|
+
|
90
|
+
## Quick Start
|
91
|
+
|
92
|
+
### Library Usage
|
93
|
+
|
94
|
+
```python
|
95
|
+
from tty_egpf_monitor import TTYMonitorClient
|
96
|
+
|
97
|
+
# Create client
|
98
|
+
client = TTYMonitorClient()
|
99
|
+
|
100
|
+
# Add a port to monitor
|
101
|
+
idx = client.add_port("/dev/ttyUSB0", baudrate=115200)
|
102
|
+
print(f"Monitoring port {idx}")
|
103
|
+
|
104
|
+
# List all ports
|
105
|
+
ports = client.list_ports()
|
106
|
+
for port in ports:
|
107
|
+
print(f"Port {port.idx}: {port.device}")
|
108
|
+
|
109
|
+
# Stream live events
|
110
|
+
for entry in client.stream_parsed_logs("/dev/ttyUSB0"):
|
111
|
+
print(f"[{entry.timestamp}] {entry.event_type}: {entry.process}")
|
112
|
+
if entry.data:
|
113
|
+
print(f" Data: {entry.data}")
|
114
|
+
|
115
|
+
# Remove port when done
|
116
|
+
client.remove_port("/dev/ttyUSB0")
|
117
|
+
```
|
118
|
+
|
119
|
+
### CLI Usage
|
120
|
+
|
121
|
+
The package includes a CLI tool compatible with the C version:
|
122
|
+
|
123
|
+
```bash
|
124
|
+
# Add a port
|
125
|
+
tty-egpf-monitor-py add /dev/ttyUSB0 115200
|
126
|
+
|
127
|
+
# List ports
|
128
|
+
tty-egpf-monitor-py list
|
129
|
+
|
130
|
+
# Stream logs (by index or device path)
|
131
|
+
tty-egpf-monitor-py stream 0
|
132
|
+
tty-egpf-monitor-py stream /dev/ttyUSB0
|
133
|
+
|
134
|
+
# Download logs
|
135
|
+
tty-egpf-monitor-py logs /dev/ttyUSB0 > captured.jsonl
|
136
|
+
|
137
|
+
# Remove port
|
138
|
+
tty-egpf-monitor-py remove /dev/ttyUSB0
|
139
|
+
```
|
140
|
+
|
141
|
+
## API Reference
|
142
|
+
|
143
|
+
### TTYMonitorClient
|
144
|
+
|
145
|
+
#### Methods
|
146
|
+
|
147
|
+
- **`list_ports()`** → `List[Port]`
|
148
|
+
|
149
|
+
List all configured ports.
|
150
|
+
|
151
|
+
- **`add_port(device, baudrate=115200, log_path=None)`** → `int`
|
152
|
+
|
153
|
+
Add a port to monitor. Returns the port index.
|
154
|
+
|
155
|
+
- **`remove_port(port_identifier)`** → `bool`
|
156
|
+
|
157
|
+
Remove a port by index (int) or device path (str).
|
158
|
+
|
159
|
+
- **`get_logs(port_identifier)`** → `str`
|
160
|
+
|
161
|
+
Download full log content for a port.
|
162
|
+
|
163
|
+
- **`stream_logs(port_identifier)`** → `Iterator[str]`
|
164
|
+
|
165
|
+
Stream raw log lines as they arrive.
|
166
|
+
|
167
|
+
- **`stream_parsed_logs(port_identifier)`** → `Iterator[LogEntry]`
|
168
|
+
|
169
|
+
Stream parsed log entries as they arrive.
|
170
|
+
|
171
|
+
- **`wait_for_event(port_identifier, event_type, timeout=30.0)`** → `Optional[LogEntry]`
|
172
|
+
|
173
|
+
Wait for a specific event type with timeout.
|
174
|
+
|
175
|
+
### LogEntry
|
176
|
+
|
177
|
+
Represents a parsed log entry:
|
178
|
+
|
179
|
+
```python
|
180
|
+
@dataclass
|
181
|
+
class LogEntry:
|
182
|
+
timestamp: datetime # When the event occurred
|
183
|
+
event_type: str # OPEN, CLOSE, READ, WRITE, IOCTL, MODE_CHANGE
|
184
|
+
process: str # Process name that triggered the event
|
185
|
+
direction: Optional[str] # APP->DEV or DEV->APP (for READ/write)
|
186
|
+
data: Optional[bytes] # Raw data (for read/write events)
|
187
|
+
raw_line: str # Original log line
|
188
|
+
```
|
189
|
+
|
190
|
+
### Port
|
191
|
+
|
192
|
+
Represents a monitored port:
|
193
|
+
|
194
|
+
```python
|
195
|
+
@dataclass
|
196
|
+
class Port:
|
197
|
+
idx: int # Port index
|
198
|
+
device: str # Device path
|
199
|
+
baudrate: Optional[int] # Configured baud rate
|
200
|
+
log_path: Optional[str] # Log file path
|
201
|
+
```
|
202
|
+
|
203
|
+
## Examples
|
204
|
+
|
205
|
+
See the [`examples/`](examples/) directory for comprehensive usage examples:
|
206
|
+
|
207
|
+
- **`basic_usage.py`** - Core functionality demonstration
|
208
|
+
- **`monitor_serial_data.py`** - Real-time monitoring with processing
|
209
|
+
- **`automation_script.py`** - Automated testing and analysis
|
210
|
+
|
211
|
+
## Error Handling
|
212
|
+
|
213
|
+
```python
|
214
|
+
from tty_egpf_monitor import TTYMonitorError
|
215
|
+
|
216
|
+
try:
|
217
|
+
client = TTYMonitorClient()
|
218
|
+
client.add_port("/dev/ttyUSB0")
|
219
|
+
except TTYMonitorError as e:
|
220
|
+
print(f"Error: {e}")
|
221
|
+
```
|
222
|
+
|
223
|
+
## Requirements
|
224
|
+
|
225
|
+
- **Python**: 3.8 or later
|
226
|
+
- **Operating System**: Linux (Ubuntu 22.04+ recommended)
|
227
|
+
- **Daemon**: `tty-egpf-monitord` must be installed and running
|
228
|
+
- **Permissions**: Access to the daemon's Unix socket (usually `/run/tty-egpf-monitord.sock`)
|
229
|
+
|
230
|
+
## Related Projects
|
231
|
+
|
232
|
+
- **[TTY eBPF Monitor](https://github.com/seelso-net/tty-egpf-monitor)** - Main project with C daemon and CLI
|
233
|
+
- **[APT Repository](https://seelso-net.github.io/tty-egpf-monitor)** - Binary packages for Ubuntu
|
234
|
+
|
235
|
+
## License
|
236
|
+
|
237
|
+
This project is licensed under the GNU General Public License v3.0 - see the [LICENSE](https://github.com/seelso-net/tty-egpf-monitor/blob/main/LICENSE) file for details.
|
@@ -0,0 +1,10 @@
|
|
1
|
+
tty_egpf_monitor/__init__.py,sha256=fDYuwC4Fuu_EHgK8s08RiQmS_SaJdahFUKgyRGntI2M,473
|
2
|
+
tty_egpf_monitor/cli.py,sha256=1QF1YS-uCowrUEFsYdmdwAqxOdmz94hvddlpljBYgng,5255
|
3
|
+
tty_egpf_monitor/client.py,sha256=CZWZhdsYgMeUZwX-0yg53A-4qEx8wrfzJ6r5nVTUp18,10999
|
4
|
+
tty_egpf_monitor/models.py,sha256=luhXtyXfQg1h8QYMiSTTaIG7BOfXX-QtNo6t0Yx1M4Y,4826
|
5
|
+
tty_egpf_monitor/py.typed,sha256=UlkhTBVJqiS1Epx-KILVobCtCGlhR6YKM79R8vO1HdU,61
|
6
|
+
tty_egpf_monitor-0.5.26.dist-info/METADATA,sha256=2z6OmNdCCMJBCbghGZnrT1JKEnzyxNHOcDLHXu_tW9k,7776
|
7
|
+
tty_egpf_monitor-0.5.26.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
8
|
+
tty_egpf_monitor-0.5.26.dist-info/entry_points.txt,sha256=l1X-FXgjKnh6pmOe16MjBN-9sww3FKFuknTy16IFW2s,66
|
9
|
+
tty_egpf_monitor-0.5.26.dist-info/top_level.txt,sha256=uXb0ct7ljNig6oqHa1nVH-TkoURcJ0_uLf2q8SUJaQs,17
|
10
|
+
tty_egpf_monitor-0.5.26.dist-info/RECORD,,
|
@@ -0,0 +1 @@
|
|
1
|
+
tty_egpf_monitor
|