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.
Files changed (64) hide show
  1. embedded_lab_control-0.2.0.dist-info/METADATA +855 -0
  2. embedded_lab_control-0.2.0.dist-info/RECORD +64 -0
  3. embedded_lab_control-0.2.0.dist-info/WHEEL +5 -0
  4. embedded_lab_control-0.2.0.dist-info/entry_points.txt +2 -0
  5. embedded_lab_control-0.2.0.dist-info/licenses/LICENSE +21 -0
  6. embedded_lab_control-0.2.0.dist-info/top_level.txt +1 -0
  7. labctl/__init__.py +8 -0
  8. labctl/actuators/__init__.py +56 -0
  9. labctl/actuators/base.py +183 -0
  10. labctl/actuators/lcus1.py +232 -0
  11. labctl/actuators/mock.py +81 -0
  12. labctl/actuators/runtime.py +1178 -0
  13. labctl/cli.py +7485 -0
  14. labctl/core/__init__.py +36 -0
  15. labctl/core/admin_files.py +388 -0
  16. labctl/core/audit.py +269 -0
  17. labctl/core/claim_keepalive.py +342 -0
  18. labctl/core/config.py +1068 -0
  19. labctl/core/database.py +880 -0
  20. labctl/core/images.py +503 -0
  21. labctl/core/manager.py +2815 -0
  22. labctl/core/models.py +815 -0
  23. labctl/core/safe_open.py +109 -0
  24. labctl/extras.py +128 -0
  25. labctl/health/__init__.py +43 -0
  26. labctl/health/alerts.py +275 -0
  27. labctl/health/checks.py +533 -0
  28. labctl/health/daemon.py +397 -0
  29. labctl/mcp_server.py +5254 -0
  30. labctl/power/__init__.py +13 -0
  31. labctl/power/base.py +145 -0
  32. labctl/power/kasa.py +446 -0
  33. labctl/power/shelly.py +89 -0
  34. labctl/power/tasmota.py +95 -0
  35. labctl/sdwire/__init__.py +10 -0
  36. labctl/sdwire/controller.py +1116 -0
  37. labctl/sdwire/power_guard.py +190 -0
  38. labctl/serial/__init__.py +18 -0
  39. labctl/serial/boot_test.py +342 -0
  40. labctl/serial/capture.py +430 -0
  41. labctl/serial/keys.py +89 -0
  42. labctl/serial/proxy.py +802 -0
  43. labctl/serial/ser2net.py +338 -0
  44. labctl/serial/udev.py +273 -0
  45. labctl/services.py +210 -0
  46. labctl/web/__init__.py +9 -0
  47. labctl/web/activity_broadcaster.py +204 -0
  48. labctl/web/api.py +1003 -0
  49. labctl/web/app.py +202 -0
  50. labctl/web/auth.py +97 -0
  51. labctl/web/claims.py +232 -0
  52. labctl/web/static/css/style.css +477 -0
  53. labctl/web/static/js/app.js +76 -0
  54. labctl/web/templates/activity.html +202 -0
  55. labctl/web/templates/base.html +49 -0
  56. labctl/web/templates/claim_refused.html +38 -0
  57. labctl/web/templates/console.html +237 -0
  58. labctl/web/templates/dashboard.html +100 -0
  59. labctl/web/templates/login.html +25 -0
  60. labctl/web/templates/sbc_detail.html +306 -0
  61. labctl/web/templates/settings.html +78 -0
  62. labctl/web/templates/status_history.html +65 -0
  63. labctl/web/views.py +496 -0
  64. 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
+ ]