embedded-lab-control 0.2.0__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.
- embedded_lab_control-0.2.0.dist-info/METADATA +855 -0
- embedded_lab_control-0.2.0.dist-info/RECORD +64 -0
- embedded_lab_control-0.2.0.dist-info/WHEEL +5 -0
- embedded_lab_control-0.2.0.dist-info/entry_points.txt +2 -0
- embedded_lab_control-0.2.0.dist-info/licenses/LICENSE +21 -0
- embedded_lab_control-0.2.0.dist-info/top_level.txt +1 -0
- labctl/__init__.py +8 -0
- labctl/actuators/__init__.py +56 -0
- labctl/actuators/base.py +183 -0
- labctl/actuators/lcus1.py +232 -0
- labctl/actuators/mock.py +81 -0
- labctl/actuators/runtime.py +1178 -0
- labctl/cli.py +7485 -0
- labctl/core/__init__.py +36 -0
- labctl/core/admin_files.py +388 -0
- labctl/core/audit.py +269 -0
- labctl/core/claim_keepalive.py +342 -0
- labctl/core/config.py +1068 -0
- labctl/core/database.py +880 -0
- labctl/core/images.py +503 -0
- labctl/core/manager.py +2815 -0
- labctl/core/models.py +815 -0
- labctl/core/safe_open.py +109 -0
- labctl/extras.py +128 -0
- labctl/health/__init__.py +43 -0
- labctl/health/alerts.py +275 -0
- labctl/health/checks.py +533 -0
- labctl/health/daemon.py +397 -0
- labctl/mcp_server.py +5254 -0
- labctl/power/__init__.py +13 -0
- labctl/power/base.py +145 -0
- labctl/power/kasa.py +446 -0
- labctl/power/shelly.py +89 -0
- labctl/power/tasmota.py +95 -0
- labctl/sdwire/__init__.py +10 -0
- labctl/sdwire/controller.py +1116 -0
- labctl/sdwire/power_guard.py +190 -0
- labctl/serial/__init__.py +18 -0
- labctl/serial/boot_test.py +342 -0
- labctl/serial/capture.py +430 -0
- labctl/serial/keys.py +89 -0
- labctl/serial/proxy.py +802 -0
- labctl/serial/ser2net.py +338 -0
- labctl/serial/udev.py +273 -0
- labctl/services.py +210 -0
- labctl/web/__init__.py +9 -0
- labctl/web/activity_broadcaster.py +204 -0
- labctl/web/api.py +1003 -0
- labctl/web/app.py +202 -0
- labctl/web/auth.py +97 -0
- labctl/web/claims.py +232 -0
- labctl/web/static/css/style.css +477 -0
- labctl/web/static/js/app.js +76 -0
- labctl/web/templates/activity.html +202 -0
- labctl/web/templates/base.html +49 -0
- labctl/web/templates/claim_refused.html +38 -0
- labctl/web/templates/console.html +237 -0
- labctl/web/templates/dashboard.html +100 -0
- labctl/web/templates/login.html +25 -0
- labctl/web/templates/sbc_detail.html +306 -0
- labctl/web/templates/settings.html +78 -0
- labctl/web/templates/status_history.html +65 -0
- labctl/web/views.py +496 -0
- labctl/web/websocket.py +208 -0
labctl/serial/proxy.py
ADDED
|
@@ -0,0 +1,802 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Multi-client serial proxy for lab controller.
|
|
3
|
+
|
|
4
|
+
Enables multiple clients to simultaneously access a serial console with
|
|
5
|
+
first-writer-wins write lock policy. Includes session logging.
|
|
6
|
+
|
|
7
|
+
Use cases:
|
|
8
|
+
- Multiple viewers watching console output (read-only)
|
|
9
|
+
- Single writer with multiple readers
|
|
10
|
+
- CLI and web clients viewing same console simultaneously
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import asyncio
|
|
14
|
+
import json
|
|
15
|
+
import logging
|
|
16
|
+
import os
|
|
17
|
+
import socket
|
|
18
|
+
import uuid
|
|
19
|
+
from dataclasses import dataclass, field
|
|
20
|
+
from datetime import datetime
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import Optional
|
|
23
|
+
|
|
24
|
+
logger = logging.getLogger(__name__)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _proxy_state_dir(log_dir: Optional[Path]) -> Path:
|
|
28
|
+
"""Return the directory used for persistent proxy state files."""
|
|
29
|
+
base = log_dir.parent if log_dir else (Path.home() / ".local" / "share" / "labctl")
|
|
30
|
+
return base / "proxy-state"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _proxy_state_path(name: str, log_dir: Optional[Path]) -> Path:
|
|
34
|
+
"""Return the state-file path for a named proxy."""
|
|
35
|
+
return _proxy_state_dir(log_dir) / f"{name}.json"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _pid_is_alive(pid: int) -> bool:
|
|
39
|
+
"""Return True when the process is still alive."""
|
|
40
|
+
try:
|
|
41
|
+
os.kill(pid, 0)
|
|
42
|
+
except PermissionError:
|
|
43
|
+
return True
|
|
44
|
+
except OSError:
|
|
45
|
+
return False
|
|
46
|
+
return True
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def write_proxy_state(
|
|
50
|
+
*,
|
|
51
|
+
name: str,
|
|
52
|
+
log_dir: Optional[Path],
|
|
53
|
+
proxy_port: int,
|
|
54
|
+
ser2net_port: int,
|
|
55
|
+
allow_write: bool,
|
|
56
|
+
) -> Path:
|
|
57
|
+
"""Persist a foreground proxy's runtime metadata for other processes."""
|
|
58
|
+
state_dir = _proxy_state_dir(log_dir)
|
|
59
|
+
state_dir.mkdir(parents=True, exist_ok=True)
|
|
60
|
+
path = _proxy_state_path(name, log_dir)
|
|
61
|
+
payload = {
|
|
62
|
+
"name": name,
|
|
63
|
+
"pid": os.getpid(),
|
|
64
|
+
"proxy_port": proxy_port,
|
|
65
|
+
"ser2net_port": ser2net_port,
|
|
66
|
+
"allow_write": allow_write,
|
|
67
|
+
"started_at": datetime.now().isoformat(),
|
|
68
|
+
}
|
|
69
|
+
path.write_text(json.dumps(payload, indent=2))
|
|
70
|
+
return path
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def remove_proxy_state(name: str, log_dir: Optional[Path]) -> None:
|
|
74
|
+
"""Remove a proxy state file if present."""
|
|
75
|
+
path = _proxy_state_path(name, log_dir)
|
|
76
|
+
try:
|
|
77
|
+
path.unlink()
|
|
78
|
+
except FileNotFoundError:
|
|
79
|
+
pass
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def read_proxy_state(name: str, log_dir: Optional[Path]) -> Optional[dict]:
|
|
83
|
+
"""Read proxy state for an SBC, pruning stale files automatically."""
|
|
84
|
+
path = _proxy_state_path(name, log_dir)
|
|
85
|
+
if not path.exists():
|
|
86
|
+
return None
|
|
87
|
+
|
|
88
|
+
try:
|
|
89
|
+
payload = json.loads(path.read_text())
|
|
90
|
+
except (OSError, json.JSONDecodeError):
|
|
91
|
+
return None
|
|
92
|
+
|
|
93
|
+
pid = payload.get("pid")
|
|
94
|
+
if not isinstance(pid, int) or not _pid_is_alive(pid):
|
|
95
|
+
remove_proxy_state(name, log_dir)
|
|
96
|
+
return None
|
|
97
|
+
|
|
98
|
+
return payload
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@dataclass
|
|
102
|
+
class ProxyClient:
|
|
103
|
+
"""Represents a connected proxy client."""
|
|
104
|
+
|
|
105
|
+
client_id: str
|
|
106
|
+
reader: asyncio.StreamReader
|
|
107
|
+
writer: asyncio.StreamWriter
|
|
108
|
+
connected_at: datetime = field(default_factory=datetime.now)
|
|
109
|
+
last_activity: datetime = field(default_factory=datetime.now)
|
|
110
|
+
has_write_lock: bool = False
|
|
111
|
+
|
|
112
|
+
@property
|
|
113
|
+
def address(self) -> str:
|
|
114
|
+
"""Get client address as string."""
|
|
115
|
+
try:
|
|
116
|
+
peername = self.writer.get_extra_info("peername")
|
|
117
|
+
if peername:
|
|
118
|
+
return f"{peername[0]}:{peername[1]}"
|
|
119
|
+
except Exception:
|
|
120
|
+
pass
|
|
121
|
+
return "unknown"
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class LogRotator:
|
|
125
|
+
"""Manages log rotation for session logs.
|
|
126
|
+
|
|
127
|
+
Rotates logs based on:
|
|
128
|
+
- Size (max_size_mb)
|
|
129
|
+
- Age (max_age_days)
|
|
130
|
+
"""
|
|
131
|
+
|
|
132
|
+
def __init__(
|
|
133
|
+
self,
|
|
134
|
+
log_dir: Path,
|
|
135
|
+
max_size_mb: float = 10.0,
|
|
136
|
+
max_age_days: int = 30,
|
|
137
|
+
compress: bool = True,
|
|
138
|
+
):
|
|
139
|
+
self.log_dir = log_dir
|
|
140
|
+
self.max_size_bytes = int(max_size_mb * 1024 * 1024)
|
|
141
|
+
self.max_age_days = max_age_days
|
|
142
|
+
self.compress = compress
|
|
143
|
+
|
|
144
|
+
def should_rotate(self, log_file: Path) -> bool:
|
|
145
|
+
"""Check if log file should be rotated based on size."""
|
|
146
|
+
if not log_file.exists():
|
|
147
|
+
return False
|
|
148
|
+
return log_file.stat().st_size >= self.max_size_bytes
|
|
149
|
+
|
|
150
|
+
def rotate(self, log_file: Path) -> Optional[Path]:
|
|
151
|
+
"""Rotate a log file.
|
|
152
|
+
|
|
153
|
+
Renames current log to .1, compresses old rotated logs.
|
|
154
|
+
Returns path to rotated file or None if no rotation needed.
|
|
155
|
+
"""
|
|
156
|
+
import gzip
|
|
157
|
+
import shutil
|
|
158
|
+
|
|
159
|
+
if not log_file.exists():
|
|
160
|
+
return None
|
|
161
|
+
|
|
162
|
+
# Find next rotation number
|
|
163
|
+
base = log_file.stem
|
|
164
|
+
suffix = log_file.suffix
|
|
165
|
+
|
|
166
|
+
for i in range(99, 0, -1):
|
|
167
|
+
old_path = self.log_dir / f"{base}.{i}{suffix}"
|
|
168
|
+
new_path = self.log_dir / f"{base}.{i + 1}{suffix}"
|
|
169
|
+
old_gz = self.log_dir / f"{base}.{i}{suffix}.gz"
|
|
170
|
+
new_gz = self.log_dir / f"{base}.{i + 1}{suffix}.gz"
|
|
171
|
+
|
|
172
|
+
if old_gz.exists():
|
|
173
|
+
if i + 1 <= 99:
|
|
174
|
+
shutil.move(old_gz, new_gz)
|
|
175
|
+
elif old_path.exists():
|
|
176
|
+
if i + 1 <= 99:
|
|
177
|
+
shutil.move(old_path, new_path)
|
|
178
|
+
|
|
179
|
+
# Rotate current to .1
|
|
180
|
+
rotated = self.log_dir / f"{base}.1{suffix}"
|
|
181
|
+
shutil.move(log_file, rotated)
|
|
182
|
+
|
|
183
|
+
# Compress rotated log
|
|
184
|
+
if self.compress:
|
|
185
|
+
with open(rotated, "rb") as f_in:
|
|
186
|
+
with gzip.open(f"{rotated}.gz", "wb") as f_out:
|
|
187
|
+
shutil.copyfileobj(f_in, f_out)
|
|
188
|
+
rotated.unlink()
|
|
189
|
+
rotated = Path(f"{rotated}.gz")
|
|
190
|
+
|
|
191
|
+
return rotated
|
|
192
|
+
|
|
193
|
+
def cleanup_old_logs(self) -> int:
|
|
194
|
+
"""Remove logs older than max_age_days.
|
|
195
|
+
|
|
196
|
+
Returns number of files deleted.
|
|
197
|
+
"""
|
|
198
|
+
import time
|
|
199
|
+
|
|
200
|
+
if not self.log_dir.exists():
|
|
201
|
+
return 0
|
|
202
|
+
|
|
203
|
+
cutoff_time = time.time() - (self.max_age_days * 24 * 60 * 60)
|
|
204
|
+
deleted = 0
|
|
205
|
+
|
|
206
|
+
for log_file in self.log_dir.glob("*.log*"):
|
|
207
|
+
try:
|
|
208
|
+
if log_file.stat().st_mtime < cutoff_time:
|
|
209
|
+
log_file.unlink()
|
|
210
|
+
deleted += 1
|
|
211
|
+
logger.debug(f"Deleted old log: {log_file}")
|
|
212
|
+
except Exception as e:
|
|
213
|
+
logger.warning(f"Failed to delete {log_file}: {e}")
|
|
214
|
+
|
|
215
|
+
return deleted
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
class SessionLogger:
|
|
219
|
+
"""Logs all serial traffic to timestamped files.
|
|
220
|
+
|
|
221
|
+
Supports log rotation based on size and cleanup of old logs.
|
|
222
|
+
"""
|
|
223
|
+
|
|
224
|
+
def __init__(
|
|
225
|
+
self,
|
|
226
|
+
log_dir: Path,
|
|
227
|
+
session_name: str,
|
|
228
|
+
max_size_mb: float = 10.0,
|
|
229
|
+
max_age_days: int = 30,
|
|
230
|
+
):
|
|
231
|
+
self.log_dir = log_dir
|
|
232
|
+
self.session_name = session_name
|
|
233
|
+
self.log_file: Optional[Path] = None
|
|
234
|
+
self._file_handle = None
|
|
235
|
+
self._rotator = LogRotator(
|
|
236
|
+
log_dir,
|
|
237
|
+
max_size_mb=max_size_mb,
|
|
238
|
+
max_age_days=max_age_days,
|
|
239
|
+
)
|
|
240
|
+
self._bytes_written = 0
|
|
241
|
+
|
|
242
|
+
def start(self) -> Path:
|
|
243
|
+
"""Start logging session, returns log file path."""
|
|
244
|
+
self.log_dir.mkdir(parents=True, exist_ok=True)
|
|
245
|
+
|
|
246
|
+
# Cleanup old logs on start
|
|
247
|
+
deleted = self._rotator.cleanup_old_logs()
|
|
248
|
+
if deleted > 0:
|
|
249
|
+
logger.info(f"Cleaned up {deleted} old log files")
|
|
250
|
+
|
|
251
|
+
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S_%f")[:-3]
|
|
252
|
+
self.log_file = self.log_dir / f"{self.session_name}_{timestamp}.log"
|
|
253
|
+
self._bytes_written = 0
|
|
254
|
+
|
|
255
|
+
self._file_handle = open(self.log_file, "a", buffering=1) # Line buffered
|
|
256
|
+
self._write_header()
|
|
257
|
+
return self.log_file
|
|
258
|
+
|
|
259
|
+
def _write_header(self) -> None:
|
|
260
|
+
"""Write session header to log file."""
|
|
261
|
+
if self._file_handle:
|
|
262
|
+
self._file_handle.write(f"# Session: {self.session_name}\n")
|
|
263
|
+
self._file_handle.write(f"# Started: {datetime.now().isoformat()}\n")
|
|
264
|
+
self._file_handle.write("# Format: [timestamp] [direction] data\n")
|
|
265
|
+
self._file_handle.write("# Direction: >> = from device, << = to device\n")
|
|
266
|
+
self._file_handle.write("#" + "=" * 60 + "\n")
|
|
267
|
+
|
|
268
|
+
def _check_rotation(self) -> None:
|
|
269
|
+
"""Check if rotation is needed and perform if so."""
|
|
270
|
+
if self.log_file and self._rotator.should_rotate(self.log_file):
|
|
271
|
+
# Close current file
|
|
272
|
+
if self._file_handle:
|
|
273
|
+
self._file_handle.close()
|
|
274
|
+
self._file_handle = None
|
|
275
|
+
|
|
276
|
+
try:
|
|
277
|
+
# Rotate
|
|
278
|
+
rotated = self._rotator.rotate(self.log_file)
|
|
279
|
+
logger.info(f"Rotated log to {rotated}")
|
|
280
|
+
|
|
281
|
+
# Start new file
|
|
282
|
+
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S_%f")[:-3]
|
|
283
|
+
self.log_file = self.log_dir / f"{self.session_name}_{timestamp}.log"
|
|
284
|
+
self._file_handle = open(self.log_file, "a", buffering=1)
|
|
285
|
+
self._write_header()
|
|
286
|
+
self._bytes_written = 0
|
|
287
|
+
except Exception as e:
|
|
288
|
+
logger.error(f"Log rotation failed: {e}")
|
|
289
|
+
# _file_handle remains None; subsequent log calls are no-ops
|
|
290
|
+
|
|
291
|
+
def log_output(self, data: bytes) -> None:
|
|
292
|
+
"""Log data received from device (output)."""
|
|
293
|
+
if self._file_handle:
|
|
294
|
+
timestamp = datetime.now().strftime("%H:%M:%S.%f")[:-3]
|
|
295
|
+
# Escape non-printable bytes for readability
|
|
296
|
+
text = data.decode("utf-8", errors="replace")
|
|
297
|
+
line = f"[{timestamp}] >> {repr(text)}\n"
|
|
298
|
+
self._file_handle.write(line)
|
|
299
|
+
self._bytes_written += len(line.encode())
|
|
300
|
+
self._check_rotation()
|
|
301
|
+
|
|
302
|
+
def log_input(self, data: bytes, client_id: str) -> None:
|
|
303
|
+
"""Log data sent to device (input)."""
|
|
304
|
+
if self._file_handle:
|
|
305
|
+
timestamp = datetime.now().strftime("%H:%M:%S.%f")[:-3]
|
|
306
|
+
text = data.decode("utf-8", errors="replace")
|
|
307
|
+
line = f"[{timestamp}] << [{client_id[:8]}] {repr(text)}\n"
|
|
308
|
+
self._file_handle.write(line)
|
|
309
|
+
self._bytes_written += len(line.encode())
|
|
310
|
+
self._check_rotation()
|
|
311
|
+
|
|
312
|
+
def stop(self) -> None:
|
|
313
|
+
"""Stop logging session."""
|
|
314
|
+
if self._file_handle:
|
|
315
|
+
self._file_handle.write("#" + "=" * 60 + "\n")
|
|
316
|
+
self._file_handle.write(f"# Ended: {datetime.now().isoformat()}\n")
|
|
317
|
+
self._file_handle.close()
|
|
318
|
+
self._file_handle = None
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
class SerialProxy:
|
|
322
|
+
"""
|
|
323
|
+
Multi-client serial proxy with first-writer-wins policy.
|
|
324
|
+
|
|
325
|
+
Connects to ser2net TCP port and allows multiple clients to:
|
|
326
|
+
- Read all console output (broadcast to all)
|
|
327
|
+
- Write to console (first client to write gets exclusive lock)
|
|
328
|
+
"""
|
|
329
|
+
|
|
330
|
+
def __init__(
|
|
331
|
+
self,
|
|
332
|
+
name: str,
|
|
333
|
+
ser2net_host: str,
|
|
334
|
+
ser2net_port: int,
|
|
335
|
+
proxy_port: int,
|
|
336
|
+
log_dir: Optional[Path] = None,
|
|
337
|
+
write_policy: str = "first",
|
|
338
|
+
max_clients: int = 10,
|
|
339
|
+
allow_write: bool = False,
|
|
340
|
+
reconnect_on_disconnect: bool = True,
|
|
341
|
+
reconnect_delay: float = 1.0,
|
|
342
|
+
):
|
|
343
|
+
"""
|
|
344
|
+
Initialize serial proxy.
|
|
345
|
+
|
|
346
|
+
Args:
|
|
347
|
+
name: Name for this proxy session (e.g., SBC name)
|
|
348
|
+
ser2net_host: Host where ser2net is running
|
|
349
|
+
ser2net_port: TCP port of ser2net connection
|
|
350
|
+
proxy_port: Port this proxy listens on
|
|
351
|
+
log_dir: Directory for session logs (None to disable)
|
|
352
|
+
write_policy: Write policy - "first", "all", or "queue"
|
|
353
|
+
max_clients: Maximum concurrent clients
|
|
354
|
+
allow_write: Whether proxy clients may write upstream
|
|
355
|
+
reconnect_on_disconnect: Reconnect after upstream serial drops
|
|
356
|
+
reconnect_delay: Seconds to wait between reconnect attempts
|
|
357
|
+
"""
|
|
358
|
+
self.name = name
|
|
359
|
+
self.ser2net_host = ser2net_host
|
|
360
|
+
self.ser2net_port = ser2net_port
|
|
361
|
+
self.proxy_port = proxy_port
|
|
362
|
+
self.log_dir = log_dir
|
|
363
|
+
self.write_policy = write_policy
|
|
364
|
+
self.max_clients = max_clients
|
|
365
|
+
self.allow_write = allow_write
|
|
366
|
+
self.reconnect_on_disconnect = reconnect_on_disconnect
|
|
367
|
+
self.reconnect_delay = reconnect_delay
|
|
368
|
+
|
|
369
|
+
self.clients: dict[str, ProxyClient] = {}
|
|
370
|
+
self.writer_client_id: Optional[str] = None
|
|
371
|
+
self._lock = asyncio.Lock()
|
|
372
|
+
self._server: Optional[asyncio.Server] = None
|
|
373
|
+
self._ser2net_reader: Optional[asyncio.StreamReader] = None
|
|
374
|
+
self._ser2net_writer: Optional[asyncio.StreamWriter] = None
|
|
375
|
+
self._running = False
|
|
376
|
+
self._read_task: Optional[asyncio.Task] = None
|
|
377
|
+
self._session_logger: Optional[SessionLogger] = None
|
|
378
|
+
self._stopping = False
|
|
379
|
+
|
|
380
|
+
@property
|
|
381
|
+
def client_count(self) -> int:
|
|
382
|
+
"""Return number of connected clients."""
|
|
383
|
+
return len(self.clients)
|
|
384
|
+
|
|
385
|
+
@property
|
|
386
|
+
def is_running(self) -> bool:
|
|
387
|
+
"""Check if proxy is running."""
|
|
388
|
+
return self._running
|
|
389
|
+
|
|
390
|
+
async def start(self) -> None:
|
|
391
|
+
"""Start the proxy server."""
|
|
392
|
+
if self._running:
|
|
393
|
+
raise RuntimeError("Proxy already running")
|
|
394
|
+
|
|
395
|
+
await self._connect_upstream()
|
|
396
|
+
|
|
397
|
+
# Start session logger
|
|
398
|
+
if self.log_dir:
|
|
399
|
+
self._session_logger = SessionLogger(self.log_dir, self.name)
|
|
400
|
+
log_file = self._session_logger.start()
|
|
401
|
+
logger.info(f"Session logging to {log_file}")
|
|
402
|
+
|
|
403
|
+
# Start proxy server
|
|
404
|
+
self._server = await asyncio.start_server(
|
|
405
|
+
self._handle_client,
|
|
406
|
+
"127.0.0.1",
|
|
407
|
+
self.proxy_port,
|
|
408
|
+
)
|
|
409
|
+
self._running = True
|
|
410
|
+
write_proxy_state(
|
|
411
|
+
name=self.name,
|
|
412
|
+
log_dir=self.log_dir,
|
|
413
|
+
proxy_port=self.proxy_port,
|
|
414
|
+
ser2net_port=self.ser2net_port,
|
|
415
|
+
allow_write=self.allow_write,
|
|
416
|
+
)
|
|
417
|
+
|
|
418
|
+
# Start reading from ser2net
|
|
419
|
+
self._read_task = asyncio.create_task(self._read_serial_loop())
|
|
420
|
+
|
|
421
|
+
logger.info(f"Proxy '{self.name}' started on port {self.proxy_port}")
|
|
422
|
+
|
|
423
|
+
async def stop(self) -> None:
|
|
424
|
+
"""Stop the proxy server gracefully."""
|
|
425
|
+
if self._stopping:
|
|
426
|
+
return
|
|
427
|
+
if (
|
|
428
|
+
not self._running
|
|
429
|
+
and self._server is None
|
|
430
|
+
and self._ser2net_writer is None
|
|
431
|
+
and self._read_task is None
|
|
432
|
+
):
|
|
433
|
+
return
|
|
434
|
+
|
|
435
|
+
self._stopping = True
|
|
436
|
+
self._running = False
|
|
437
|
+
logger.info(f"Stopping proxy '{self.name}'")
|
|
438
|
+
remove_proxy_state(self.name, self.log_dir)
|
|
439
|
+
|
|
440
|
+
# Cancel read task
|
|
441
|
+
current_task = asyncio.current_task()
|
|
442
|
+
if self._read_task and self._read_task is not current_task:
|
|
443
|
+
self._read_task.cancel()
|
|
444
|
+
try:
|
|
445
|
+
await self._read_task
|
|
446
|
+
except asyncio.CancelledError:
|
|
447
|
+
pass
|
|
448
|
+
self._read_task = None
|
|
449
|
+
|
|
450
|
+
# Disconnect all clients
|
|
451
|
+
for client in list(self.clients.values()):
|
|
452
|
+
await self._disconnect_client(client, "Proxy shutting down")
|
|
453
|
+
|
|
454
|
+
await self._close_upstream()
|
|
455
|
+
|
|
456
|
+
# Stop server
|
|
457
|
+
if self._server:
|
|
458
|
+
self._server.close()
|
|
459
|
+
await self._server.wait_closed()
|
|
460
|
+
self._server = None
|
|
461
|
+
|
|
462
|
+
# Stop session logger
|
|
463
|
+
if self._session_logger:
|
|
464
|
+
self._session_logger.stop()
|
|
465
|
+
self._session_logger = None
|
|
466
|
+
|
|
467
|
+
logger.info(f"Proxy '{self.name}' stopped")
|
|
468
|
+
self._stopping = False
|
|
469
|
+
|
|
470
|
+
async def _connect_upstream(self) -> None:
|
|
471
|
+
"""Connect to the upstream ser2net TCP socket."""
|
|
472
|
+
logger.info(f"Connecting to ser2net at {self.ser2net_host}:{self.ser2net_port}")
|
|
473
|
+
try:
|
|
474
|
+
self._ser2net_reader, self._ser2net_writer = await asyncio.open_connection(
|
|
475
|
+
self.ser2net_host, self.ser2net_port
|
|
476
|
+
)
|
|
477
|
+
except Exception as e:
|
|
478
|
+
host_port = f"{self.ser2net_host}:{self.ser2net_port}"
|
|
479
|
+
raise ConnectionError(f"Failed to connect to ser2net at {host_port}: {e}")
|
|
480
|
+
|
|
481
|
+
async def _close_upstream(self) -> None:
|
|
482
|
+
"""Close the upstream ser2net TCP connection."""
|
|
483
|
+
writer = self._ser2net_writer
|
|
484
|
+
self._ser2net_reader = None
|
|
485
|
+
self._ser2net_writer = None
|
|
486
|
+
if writer:
|
|
487
|
+
writer.close()
|
|
488
|
+
try:
|
|
489
|
+
await writer.wait_closed()
|
|
490
|
+
except Exception:
|
|
491
|
+
pass
|
|
492
|
+
|
|
493
|
+
async def _broadcast_notice(self, message: str) -> None:
|
|
494
|
+
"""Broadcast a proxy status line to all connected clients."""
|
|
495
|
+
if not self.clients:
|
|
496
|
+
return
|
|
497
|
+
await self._broadcast(f"\r\n[Proxy: {message}]\r\n".encode())
|
|
498
|
+
|
|
499
|
+
async def _handle_upstream_disconnect(self, reason: str) -> bool:
|
|
500
|
+
"""Handle loss of the upstream serial connection.
|
|
501
|
+
|
|
502
|
+
Returns True if the proxy should keep running and try to reconnect.
|
|
503
|
+
"""
|
|
504
|
+
logger.warning(reason)
|
|
505
|
+
await self._close_upstream()
|
|
506
|
+
|
|
507
|
+
if not self._running:
|
|
508
|
+
return False
|
|
509
|
+
|
|
510
|
+
if not self.reconnect_on_disconnect:
|
|
511
|
+
logger.error("ser2net connection lost, stopping proxy")
|
|
512
|
+
asyncio.create_task(self.stop())
|
|
513
|
+
return False
|
|
514
|
+
|
|
515
|
+
await self._broadcast_notice("Serial connection lost; retrying...")
|
|
516
|
+
return True
|
|
517
|
+
|
|
518
|
+
async def _handle_client(
|
|
519
|
+
self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter
|
|
520
|
+
) -> None:
|
|
521
|
+
"""Handle a new client connection."""
|
|
522
|
+
client_id = str(uuid.uuid4())
|
|
523
|
+
client = ProxyClient(
|
|
524
|
+
client_id=client_id,
|
|
525
|
+
reader=reader,
|
|
526
|
+
writer=writer,
|
|
527
|
+
)
|
|
528
|
+
|
|
529
|
+
# Check max clients
|
|
530
|
+
if len(self.clients) >= self.max_clients:
|
|
531
|
+
logger.warning(f"Rejecting client {client_id}: max clients reached")
|
|
532
|
+
writer.write(
|
|
533
|
+
b"\r\n[Proxy: Maximum clients reached, connection refused]\r\n"
|
|
534
|
+
)
|
|
535
|
+
await writer.drain()
|
|
536
|
+
writer.close()
|
|
537
|
+
return
|
|
538
|
+
|
|
539
|
+
self.clients[client_id] = client
|
|
540
|
+
logger.info(f"Client {client_id[:8]} connected from {client.address}")
|
|
541
|
+
|
|
542
|
+
# Send welcome message
|
|
543
|
+
writer.write(f"\r\n[Proxy: Connected to {self.name}".encode())
|
|
544
|
+
if not self.allow_write:
|
|
545
|
+
writer.write(b" (read-only)]")
|
|
546
|
+
elif self.writer_client_id:
|
|
547
|
+
writer.write(b" (read-only, another client has write lock)]")
|
|
548
|
+
else:
|
|
549
|
+
writer.write(b" (you have write access)]")
|
|
550
|
+
writer.write(b"\r\n")
|
|
551
|
+
await writer.drain()
|
|
552
|
+
|
|
553
|
+
try:
|
|
554
|
+
await self._client_read_loop(client)
|
|
555
|
+
except Exception as e:
|
|
556
|
+
logger.debug(f"Client {client_id[:8]} error: {e}")
|
|
557
|
+
finally:
|
|
558
|
+
await self._disconnect_client(client, "Client disconnected")
|
|
559
|
+
|
|
560
|
+
async def _client_read_loop(self, client: ProxyClient) -> None:
|
|
561
|
+
"""Read data from client and forward to serial if allowed."""
|
|
562
|
+
while self._running:
|
|
563
|
+
try:
|
|
564
|
+
data = await asyncio.wait_for(client.reader.read(1024), timeout=1.0)
|
|
565
|
+
if not data:
|
|
566
|
+
break # Client disconnected
|
|
567
|
+
|
|
568
|
+
client.last_activity = datetime.now()
|
|
569
|
+
|
|
570
|
+
# Check write permission
|
|
571
|
+
if not self._can_write(client.client_id):
|
|
572
|
+
if not self.allow_write:
|
|
573
|
+
continue # Read-only proxy: silently ignore client input
|
|
574
|
+
# Try to acquire write lock (atomic via asyncio.Lock)
|
|
575
|
+
async with self._lock:
|
|
576
|
+
if self.writer_client_id is None:
|
|
577
|
+
self.writer_client_id = client.client_id
|
|
578
|
+
client.has_write_lock = True
|
|
579
|
+
if client.has_write_lock:
|
|
580
|
+
logger.info(
|
|
581
|
+
f"Client {client.client_id[:8]} acquired write lock"
|
|
582
|
+
)
|
|
583
|
+
client.writer.write(
|
|
584
|
+
b"\r\n[Proxy: You now have write access]\r\n"
|
|
585
|
+
)
|
|
586
|
+
await client.writer.drain()
|
|
587
|
+
else:
|
|
588
|
+
continue # Silently drop the write
|
|
589
|
+
|
|
590
|
+
# Forward to ser2net
|
|
591
|
+
if self._ser2net_writer and self._can_write(client.client_id):
|
|
592
|
+
self._ser2net_writer.write(data)
|
|
593
|
+
await self._ser2net_writer.drain()
|
|
594
|
+
|
|
595
|
+
# Log input
|
|
596
|
+
if self._session_logger:
|
|
597
|
+
self._session_logger.log_input(data, client.client_id)
|
|
598
|
+
|
|
599
|
+
except asyncio.TimeoutError:
|
|
600
|
+
continue
|
|
601
|
+
except Exception as e:
|
|
602
|
+
logger.debug(f"Client read error: {e}")
|
|
603
|
+
break
|
|
604
|
+
|
|
605
|
+
def _can_write(self, client_id: str) -> bool:
|
|
606
|
+
"""Check if client can write based on policy."""
|
|
607
|
+
if not self.allow_write:
|
|
608
|
+
return False
|
|
609
|
+
if self.write_policy == "all":
|
|
610
|
+
return True
|
|
611
|
+
elif self.write_policy == "first":
|
|
612
|
+
return self.writer_client_id is None or self.writer_client_id == client_id
|
|
613
|
+
else:
|
|
614
|
+
# queue policy - not implemented yet
|
|
615
|
+
return self.writer_client_id is None or self.writer_client_id == client_id
|
|
616
|
+
|
|
617
|
+
async def _read_serial_loop(self) -> None:
|
|
618
|
+
"""Read from ser2net and broadcast to all clients."""
|
|
619
|
+
while self._running:
|
|
620
|
+
if self._ser2net_reader is None:
|
|
621
|
+
try:
|
|
622
|
+
await self._connect_upstream()
|
|
623
|
+
except ConnectionError as e:
|
|
624
|
+
logger.warning(f"Reconnect to ser2net failed: {e}")
|
|
625
|
+
await asyncio.sleep(self.reconnect_delay)
|
|
626
|
+
continue
|
|
627
|
+
|
|
628
|
+
logger.info("ser2net connection restored")
|
|
629
|
+
await self._broadcast_notice("Serial connection restored")
|
|
630
|
+
|
|
631
|
+
try:
|
|
632
|
+
data = await asyncio.wait_for(
|
|
633
|
+
self._ser2net_reader.read(4096), timeout=1.0
|
|
634
|
+
)
|
|
635
|
+
if not data:
|
|
636
|
+
should_retry = await self._handle_upstream_disconnect(
|
|
637
|
+
"ser2net connection closed"
|
|
638
|
+
)
|
|
639
|
+
if not should_retry:
|
|
640
|
+
break
|
|
641
|
+
await asyncio.sleep(self.reconnect_delay)
|
|
642
|
+
continue
|
|
643
|
+
|
|
644
|
+
# Log output
|
|
645
|
+
if self._session_logger:
|
|
646
|
+
self._session_logger.log_output(data)
|
|
647
|
+
|
|
648
|
+
# Broadcast to all clients
|
|
649
|
+
await self._broadcast(data)
|
|
650
|
+
|
|
651
|
+
except asyncio.TimeoutError:
|
|
652
|
+
continue
|
|
653
|
+
except asyncio.CancelledError:
|
|
654
|
+
break
|
|
655
|
+
except Exception as e:
|
|
656
|
+
should_retry = await self._handle_upstream_disconnect(
|
|
657
|
+
f"Serial read error: {e}"
|
|
658
|
+
)
|
|
659
|
+
if not should_retry:
|
|
660
|
+
break
|
|
661
|
+
await asyncio.sleep(self.reconnect_delay)
|
|
662
|
+
|
|
663
|
+
async def _broadcast(self, data: bytes) -> None:
|
|
664
|
+
"""Send data to all connected clients."""
|
|
665
|
+
disconnected = []
|
|
666
|
+
|
|
667
|
+
for client_id, client in list(self.clients.items()):
|
|
668
|
+
try:
|
|
669
|
+
client.writer.write(data)
|
|
670
|
+
await client.writer.drain()
|
|
671
|
+
except Exception as e:
|
|
672
|
+
logger.debug(f"Failed to send to client {client_id[:8]}: {e}")
|
|
673
|
+
disconnected.append(client)
|
|
674
|
+
|
|
675
|
+
# Clean up disconnected clients
|
|
676
|
+
for client in disconnected:
|
|
677
|
+
await self._disconnect_client(client, "Write failed")
|
|
678
|
+
|
|
679
|
+
async def _disconnect_client(self, client: ProxyClient, reason: str) -> None:
|
|
680
|
+
"""Disconnect a client and clean up."""
|
|
681
|
+
client_id = client.client_id
|
|
682
|
+
|
|
683
|
+
if client_id not in self.clients:
|
|
684
|
+
return
|
|
685
|
+
|
|
686
|
+
del self.clients[client_id]
|
|
687
|
+
|
|
688
|
+
# Release write lock if this client had it
|
|
689
|
+
if self.writer_client_id == client_id:
|
|
690
|
+
self.writer_client_id = None
|
|
691
|
+
logger.info(f"Write lock released by {client_id[:8]}")
|
|
692
|
+
|
|
693
|
+
try:
|
|
694
|
+
client.writer.close()
|
|
695
|
+
await client.writer.wait_closed()
|
|
696
|
+
except Exception:
|
|
697
|
+
pass
|
|
698
|
+
|
|
699
|
+
logger.info(f"Client {client_id[:8]} disconnected: {reason}")
|
|
700
|
+
|
|
701
|
+
def get_clients_info(self) -> list[dict]:
|
|
702
|
+
"""Get information about connected clients."""
|
|
703
|
+
return [
|
|
704
|
+
{
|
|
705
|
+
"client_id": c.client_id,
|
|
706
|
+
"address": c.address,
|
|
707
|
+
"connected_at": c.connected_at.isoformat(),
|
|
708
|
+
"has_write_lock": c.has_write_lock,
|
|
709
|
+
}
|
|
710
|
+
for c in self.clients.values()
|
|
711
|
+
]
|
|
712
|
+
|
|
713
|
+
|
|
714
|
+
class ProxyManager:
|
|
715
|
+
"""Manages multiple serial proxy instances."""
|
|
716
|
+
|
|
717
|
+
def __init__(self, log_dir: Optional[Path] = None):
|
|
718
|
+
self.proxies: dict[str, SerialProxy] = {}
|
|
719
|
+
self.log_dir = log_dir
|
|
720
|
+
self._port_counter = 5500
|
|
721
|
+
|
|
722
|
+
@staticmethod
|
|
723
|
+
def _port_is_available(port: int, host: str = "127.0.0.1") -> bool:
|
|
724
|
+
"""Return True if the TCP port can be bound on the given host."""
|
|
725
|
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
|
726
|
+
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
727
|
+
try:
|
|
728
|
+
sock.bind((host, port))
|
|
729
|
+
except OSError:
|
|
730
|
+
return False
|
|
731
|
+
return True
|
|
732
|
+
|
|
733
|
+
def get_next_port(self, base: int = 5500, range_size: int = 100) -> int:
|
|
734
|
+
"""Get next available proxy port."""
|
|
735
|
+
used_ports = {p.proxy_port for p in self.proxies.values()}
|
|
736
|
+
for port in range(base, base + range_size):
|
|
737
|
+
if port not in used_ports and self._port_is_available(port):
|
|
738
|
+
return port
|
|
739
|
+
raise RuntimeError("No available proxy ports")
|
|
740
|
+
|
|
741
|
+
async def create_proxy(
|
|
742
|
+
self,
|
|
743
|
+
name: str,
|
|
744
|
+
ser2net_host: str,
|
|
745
|
+
ser2net_port: int,
|
|
746
|
+
proxy_port: Optional[int] = None,
|
|
747
|
+
write_policy: str = "first",
|
|
748
|
+
max_clients: int = 10,
|
|
749
|
+
allow_write: bool = False,
|
|
750
|
+
) -> SerialProxy:
|
|
751
|
+
"""Create and start a new proxy."""
|
|
752
|
+
if name in self.proxies:
|
|
753
|
+
raise ValueError(f"Proxy '{name}' already exists")
|
|
754
|
+
|
|
755
|
+
if proxy_port is None:
|
|
756
|
+
proxy_port = self.get_next_port()
|
|
757
|
+
|
|
758
|
+
proxy = SerialProxy(
|
|
759
|
+
name=name,
|
|
760
|
+
ser2net_host=ser2net_host,
|
|
761
|
+
ser2net_port=ser2net_port,
|
|
762
|
+
proxy_port=proxy_port,
|
|
763
|
+
log_dir=self.log_dir,
|
|
764
|
+
write_policy=write_policy,
|
|
765
|
+
max_clients=max_clients,
|
|
766
|
+
allow_write=allow_write,
|
|
767
|
+
)
|
|
768
|
+
|
|
769
|
+
await proxy.start()
|
|
770
|
+
self.proxies[name] = proxy
|
|
771
|
+
return proxy
|
|
772
|
+
|
|
773
|
+
async def stop_proxy(self, name: str) -> bool:
|
|
774
|
+
"""Stop and remove a proxy."""
|
|
775
|
+
if name not in self.proxies:
|
|
776
|
+
return False
|
|
777
|
+
|
|
778
|
+
proxy = self.proxies.pop(name)
|
|
779
|
+
await proxy.stop()
|
|
780
|
+
return True
|
|
781
|
+
|
|
782
|
+
async def stop_all(self) -> None:
|
|
783
|
+
"""Stop all proxies."""
|
|
784
|
+
for name in list(self.proxies.keys()):
|
|
785
|
+
await self.stop_proxy(name)
|
|
786
|
+
|
|
787
|
+
def get_proxy(self, name: str) -> Optional[SerialProxy]:
|
|
788
|
+
"""Get a proxy by name."""
|
|
789
|
+
return self.proxies.get(name)
|
|
790
|
+
|
|
791
|
+
def list_proxies(self) -> list[dict]:
|
|
792
|
+
"""List all active proxies."""
|
|
793
|
+
return [
|
|
794
|
+
{
|
|
795
|
+
"name": p.name,
|
|
796
|
+
"proxy_port": p.proxy_port,
|
|
797
|
+
"ser2net_port": p.ser2net_port,
|
|
798
|
+
"client_count": p.client_count,
|
|
799
|
+
"writer_client": p.writer_client_id[:8] if p.writer_client_id else None,
|
|
800
|
+
}
|
|
801
|
+
for p in self.proxies.values()
|
|
802
|
+
]
|