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/capture.py
ADDED
|
@@ -0,0 +1,430 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Serial port capture and send utilities.
|
|
3
|
+
|
|
4
|
+
Provides functions to capture serial output and send data via ser2net TCP
|
|
5
|
+
connections. Used by CLI commands and MCP tools.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import logging
|
|
9
|
+
import re
|
|
10
|
+
import select
|
|
11
|
+
import socket
|
|
12
|
+
import time
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from typing import Optional, Union
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class CaptureResult:
|
|
21
|
+
"""Result of a serial capture operation."""
|
|
22
|
+
|
|
23
|
+
output: str = ""
|
|
24
|
+
lines: int = 0
|
|
25
|
+
pattern_matched: bool = False
|
|
26
|
+
elapsed_seconds: float = 0.0
|
|
27
|
+
|
|
28
|
+
def to_mcp_string(self, pattern: Optional[str] = None) -> str:
|
|
29
|
+
"""Format as MCP tool return string."""
|
|
30
|
+
status = f"pattern '{pattern}' matched" if self.pattern_matched else "timeout"
|
|
31
|
+
header = (
|
|
32
|
+
f"[Captured {self.lines} lines in {self.elapsed_seconds:.1f}s, "
|
|
33
|
+
f"{status}]"
|
|
34
|
+
)
|
|
35
|
+
return f"{header}\n{self.output}"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass
|
|
39
|
+
class SendResult:
|
|
40
|
+
"""Result of a serial send operation."""
|
|
41
|
+
|
|
42
|
+
sent: bool = False
|
|
43
|
+
bytes_sent: int = 0
|
|
44
|
+
capture: Optional[CaptureResult] = None
|
|
45
|
+
|
|
46
|
+
def to_mcp_string(self) -> str:
|
|
47
|
+
"""Format as MCP tool return string."""
|
|
48
|
+
if self.capture:
|
|
49
|
+
return self.capture.to_mcp_string()
|
|
50
|
+
return f"Sent {self.bytes_sent} bytes"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass
|
|
54
|
+
class SendPacing:
|
|
55
|
+
"""Outbound write pacing to avoid receiver UART RX-FIFO overrun.
|
|
56
|
+
|
|
57
|
+
A gapless burst gives the receiver's RX ISR no window to drain its
|
|
58
|
+
FIFO, so a sustained write can lose a contiguous mid-payload byte
|
|
59
|
+
window. Writing ``chunk_size`` bytes per slice with ``delay_ms``
|
|
60
|
+
between slices inserts the gaps the receiver needs. ``chunk_size=1``
|
|
61
|
+
expresses per-byte pacing.
|
|
62
|
+
|
|
63
|
+
The wire still transmits at the configured baud rate; pacing only
|
|
64
|
+
controls how the payload is fed to ser2net, which is what creates
|
|
65
|
+
the inter-slice gaps on the UART.
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
chunk_size: int = 0
|
|
69
|
+
delay_ms: float = 0.0
|
|
70
|
+
|
|
71
|
+
@property
|
|
72
|
+
def enabled(self) -> bool:
|
|
73
|
+
return self.chunk_size > 0 and self.delay_ms > 0
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
# Pause between the parts of a multi-part send (one per named key). Meant
|
|
77
|
+
# to let consoles that time out a lone ESC quickly (e.g. U-Boot's menus)
|
|
78
|
+
# see it as a key; not checked on hardware, and firmware that waits longer
|
|
79
|
+
# for the rest of a sequence (EDK2 waits up to ~2 s) is not helped by it.
|
|
80
|
+
PART_GAP_SECONDS = 0.05
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _write_payload(
|
|
84
|
+
sock: socket.socket,
|
|
85
|
+
raw: Union[bytes, list[bytes]],
|
|
86
|
+
pacing: Optional[SendPacing] = None,
|
|
87
|
+
) -> None:
|
|
88
|
+
"""Write ``raw`` to ``sock``, optionally paced into delayed slices.
|
|
89
|
+
|
|
90
|
+
A list is written part by part with PART_GAP_SECONDS between parts.
|
|
91
|
+
"""
|
|
92
|
+
if isinstance(raw, list):
|
|
93
|
+
for i, part in enumerate(raw):
|
|
94
|
+
if i:
|
|
95
|
+
time.sleep(PART_GAP_SECONDS)
|
|
96
|
+
_write_payload(sock, part, pacing)
|
|
97
|
+
return
|
|
98
|
+
if pacing is None or not pacing.enabled:
|
|
99
|
+
sock.sendall(raw)
|
|
100
|
+
return
|
|
101
|
+
delay = pacing.delay_ms / 1000.0
|
|
102
|
+
step = pacing.chunk_size
|
|
103
|
+
for i in range(0, len(raw), step):
|
|
104
|
+
sock.sendall(raw[i : i + step])
|
|
105
|
+
if i + step < len(raw):
|
|
106
|
+
time.sleep(delay)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _payload_len(raw: Union[bytes, list[bytes]]) -> int:
|
|
110
|
+
return sum(map(len, raw)) if isinstance(raw, list) else len(raw)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def resolve_port(manager, port_name: str):
|
|
114
|
+
"""Resolve a port name to a SerialPort object.
|
|
115
|
+
|
|
116
|
+
Tries alias first, then falls back to SBC name + console port.
|
|
117
|
+
|
|
118
|
+
Args:
|
|
119
|
+
manager: ResourceManager instance
|
|
120
|
+
port_name: Port alias or SBC name
|
|
121
|
+
|
|
122
|
+
Returns:
|
|
123
|
+
SerialPort object
|
|
124
|
+
|
|
125
|
+
Raises:
|
|
126
|
+
ValueError: If port cannot be resolved
|
|
127
|
+
"""
|
|
128
|
+
# Try alias first
|
|
129
|
+
port = manager.get_serial_port_by_alias(port_name)
|
|
130
|
+
if port:
|
|
131
|
+
return port
|
|
132
|
+
|
|
133
|
+
# Fall back to SBC name -> console port
|
|
134
|
+
sbc = manager.get_sbc_by_name(port_name)
|
|
135
|
+
if sbc:
|
|
136
|
+
if sbc.console_port:
|
|
137
|
+
return sbc.console_port
|
|
138
|
+
raise ValueError(f"SBC '{port_name}' has no console port assigned")
|
|
139
|
+
|
|
140
|
+
raise ValueError(f"'{port_name}' is not a known port alias or SBC name")
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def capture_serial_output(
|
|
144
|
+
tcp_host: str,
|
|
145
|
+
tcp_port: int,
|
|
146
|
+
timeout: float = 15.0,
|
|
147
|
+
until_pattern: Optional[str] = None,
|
|
148
|
+
tail: Optional[int] = None,
|
|
149
|
+
) -> CaptureResult:
|
|
150
|
+
"""Capture serial output from a ser2net TCP port.
|
|
151
|
+
|
|
152
|
+
Connects to the ser2net TCP port, reads output until timeout or
|
|
153
|
+
pattern match, then disconnects.
|
|
154
|
+
|
|
155
|
+
Args:
|
|
156
|
+
tcp_host: Host where ser2net is running (usually "localhost")
|
|
157
|
+
tcp_port: TCP port for the serial connection
|
|
158
|
+
timeout: Maximum seconds to capture
|
|
159
|
+
until_pattern: Regex pattern to stop on (matched per line)
|
|
160
|
+
tail: Return only last N lines (None = all)
|
|
161
|
+
|
|
162
|
+
Returns:
|
|
163
|
+
CaptureResult with captured output and metadata.
|
|
164
|
+
"""
|
|
165
|
+
compiled_pattern = None
|
|
166
|
+
if until_pattern:
|
|
167
|
+
compiled_pattern = re.compile(until_pattern)
|
|
168
|
+
|
|
169
|
+
captured_lines: list[str] = []
|
|
170
|
+
pattern_matched = False
|
|
171
|
+
start_time = time.monotonic()
|
|
172
|
+
buffer = b""
|
|
173
|
+
|
|
174
|
+
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
175
|
+
sock.settimeout(5.0) # Connection timeout
|
|
176
|
+
|
|
177
|
+
try:
|
|
178
|
+
sock.connect((tcp_host, tcp_port))
|
|
179
|
+
sock.setblocking(False)
|
|
180
|
+
|
|
181
|
+
while True:
|
|
182
|
+
elapsed = time.monotonic() - start_time
|
|
183
|
+
remaining = timeout - elapsed
|
|
184
|
+
if remaining <= 0:
|
|
185
|
+
break
|
|
186
|
+
|
|
187
|
+
wait = min(remaining, 0.5)
|
|
188
|
+
readable, _, _ = select.select([sock], [], [], wait)
|
|
189
|
+
|
|
190
|
+
if readable:
|
|
191
|
+
try:
|
|
192
|
+
data = sock.recv(4096)
|
|
193
|
+
if not data:
|
|
194
|
+
break # Connection closed
|
|
195
|
+
|
|
196
|
+
buffer += data
|
|
197
|
+
|
|
198
|
+
while b"\n" in buffer:
|
|
199
|
+
line, buffer = buffer.split(b"\n", 1)
|
|
200
|
+
line_str = line.decode("utf-8", errors="replace").rstrip()
|
|
201
|
+
captured_lines.append(line_str)
|
|
202
|
+
|
|
203
|
+
if compiled_pattern and compiled_pattern.search(line_str):
|
|
204
|
+
pattern_matched = True
|
|
205
|
+
break
|
|
206
|
+
|
|
207
|
+
# Check partial buffer for pattern (e.g. prompts without newline)
|
|
208
|
+
if not pattern_matched and compiled_pattern and buffer:
|
|
209
|
+
partial = buffer.decode("utf-8", errors="replace")
|
|
210
|
+
if compiled_pattern.search(partial):
|
|
211
|
+
captured_lines.append(partial.rstrip())
|
|
212
|
+
buffer = b""
|
|
213
|
+
pattern_matched = True
|
|
214
|
+
|
|
215
|
+
if pattern_matched:
|
|
216
|
+
break
|
|
217
|
+
|
|
218
|
+
except BlockingIOError:
|
|
219
|
+
pass
|
|
220
|
+
|
|
221
|
+
# Handle any remaining data in buffer (no trailing newline)
|
|
222
|
+
if buffer:
|
|
223
|
+
line_str = buffer.decode("utf-8", errors="replace").rstrip()
|
|
224
|
+
if line_str:
|
|
225
|
+
captured_lines.append(line_str)
|
|
226
|
+
if compiled_pattern and compiled_pattern.search(line_str):
|
|
227
|
+
pattern_matched = True
|
|
228
|
+
|
|
229
|
+
except (ConnectionRefusedError, OSError) as e:
|
|
230
|
+
raise RuntimeError(
|
|
231
|
+
f"Cannot connect to serial port at {tcp_host}:{tcp_port}: {e}"
|
|
232
|
+
)
|
|
233
|
+
finally:
|
|
234
|
+
try:
|
|
235
|
+
sock.close()
|
|
236
|
+
except Exception:
|
|
237
|
+
pass
|
|
238
|
+
|
|
239
|
+
elapsed = time.monotonic() - start_time
|
|
240
|
+
|
|
241
|
+
if tail and len(captured_lines) > tail:
|
|
242
|
+
output_lines = captured_lines[-tail:]
|
|
243
|
+
else:
|
|
244
|
+
output_lines = captured_lines
|
|
245
|
+
|
|
246
|
+
return CaptureResult(
|
|
247
|
+
output="\n".join(output_lines),
|
|
248
|
+
lines=len(output_lines),
|
|
249
|
+
pattern_matched=pattern_matched,
|
|
250
|
+
elapsed_seconds=round(elapsed, 1),
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def send_serial_data(
|
|
255
|
+
tcp_host: str,
|
|
256
|
+
tcp_port: int,
|
|
257
|
+
data: Union[str, list[bytes]],
|
|
258
|
+
newline: bool = True,
|
|
259
|
+
capture_timeout: Optional[float] = None,
|
|
260
|
+
capture_until: Optional[str] = None,
|
|
261
|
+
pacing: Optional[SendPacing] = None,
|
|
262
|
+
) -> SendResult:
|
|
263
|
+
"""Send data to a serial port via ser2net TCP.
|
|
264
|
+
|
|
265
|
+
Optionally captures the response after sending.
|
|
266
|
+
|
|
267
|
+
Args:
|
|
268
|
+
tcp_host: Host where ser2net is running
|
|
269
|
+
tcp_port: TCP port for the serial connection
|
|
270
|
+
data: Text to send (UTF-8), or a list of byte parts written with
|
|
271
|
+
a short pause between them
|
|
272
|
+
newline: Append \\r\\n after data (default True)
|
|
273
|
+
capture_timeout: If set, capture response for this many seconds
|
|
274
|
+
capture_until: If set, capture until this regex matches
|
|
275
|
+
pacing: Optional write pacing to avoid receiver RX-FIFO overrun
|
|
276
|
+
|
|
277
|
+
Returns:
|
|
278
|
+
SendResult with send status and optional capture.
|
|
279
|
+
"""
|
|
280
|
+
if isinstance(data, list):
|
|
281
|
+
raw = list(data)
|
|
282
|
+
if newline:
|
|
283
|
+
raw.append(b"\r\n")
|
|
284
|
+
else:
|
|
285
|
+
raw = data.encode("utf-8")
|
|
286
|
+
if newline:
|
|
287
|
+
raw += b"\r\n"
|
|
288
|
+
|
|
289
|
+
if capture_timeout is not None:
|
|
290
|
+
# Send and capture in one connection
|
|
291
|
+
return _send_and_capture(
|
|
292
|
+
tcp_host,
|
|
293
|
+
tcp_port,
|
|
294
|
+
raw,
|
|
295
|
+
capture_timeout,
|
|
296
|
+
capture_until,
|
|
297
|
+
pacing,
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
# Send only
|
|
301
|
+
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
302
|
+
sock.settimeout(5.0)
|
|
303
|
+
|
|
304
|
+
try:
|
|
305
|
+
sock.connect((tcp_host, tcp_port))
|
|
306
|
+
_write_payload(sock, raw, pacing)
|
|
307
|
+
return SendResult(sent=True, bytes_sent=_payload_len(raw))
|
|
308
|
+
except (ConnectionRefusedError, OSError) as e:
|
|
309
|
+
raise RuntimeError(
|
|
310
|
+
f"Cannot connect to serial port at {tcp_host}:{tcp_port}: {e}"
|
|
311
|
+
)
|
|
312
|
+
finally:
|
|
313
|
+
try:
|
|
314
|
+
sock.close()
|
|
315
|
+
except Exception:
|
|
316
|
+
pass
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def _send_and_capture(
|
|
320
|
+
tcp_host: str,
|
|
321
|
+
tcp_port: int,
|
|
322
|
+
raw_data: Union[bytes, list[bytes]],
|
|
323
|
+
timeout: float,
|
|
324
|
+
until_pattern: Optional[str] = None,
|
|
325
|
+
pacing: Optional[SendPacing] = None,
|
|
326
|
+
) -> SendResult:
|
|
327
|
+
"""Send data then capture response on the same connection."""
|
|
328
|
+
compiled_pattern = None
|
|
329
|
+
if until_pattern:
|
|
330
|
+
compiled_pattern = re.compile(until_pattern)
|
|
331
|
+
|
|
332
|
+
captured_lines: list[str] = []
|
|
333
|
+
pattern_matched = False
|
|
334
|
+
buffer = b""
|
|
335
|
+
|
|
336
|
+
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
337
|
+
sock.settimeout(5.0)
|
|
338
|
+
|
|
339
|
+
try:
|
|
340
|
+
sock.connect((tcp_host, tcp_port))
|
|
341
|
+
|
|
342
|
+
# Drain stale data (100ms)
|
|
343
|
+
sock.setblocking(False)
|
|
344
|
+
drain_end = time.monotonic() + 0.1
|
|
345
|
+
while time.monotonic() < drain_end:
|
|
346
|
+
readable, _, _ = select.select([sock], [], [], 0.05)
|
|
347
|
+
if readable:
|
|
348
|
+
try:
|
|
349
|
+
sock.recv(4096)
|
|
350
|
+
except BlockingIOError:
|
|
351
|
+
pass
|
|
352
|
+
|
|
353
|
+
# Send data
|
|
354
|
+
sock.setblocking(True)
|
|
355
|
+
sock.settimeout(5.0)
|
|
356
|
+
_write_payload(sock, raw_data, pacing)
|
|
357
|
+
bytes_sent = _payload_len(raw_data)
|
|
358
|
+
|
|
359
|
+
# Capture response
|
|
360
|
+
sock.setblocking(False)
|
|
361
|
+
start_time = time.monotonic()
|
|
362
|
+
|
|
363
|
+
while True:
|
|
364
|
+
elapsed = time.monotonic() - start_time
|
|
365
|
+
remaining = timeout - elapsed
|
|
366
|
+
if remaining <= 0:
|
|
367
|
+
break
|
|
368
|
+
|
|
369
|
+
wait = min(remaining, 0.5)
|
|
370
|
+
readable, _, _ = select.select([sock], [], [], wait)
|
|
371
|
+
|
|
372
|
+
if readable:
|
|
373
|
+
try:
|
|
374
|
+
data = sock.recv(4096)
|
|
375
|
+
if not data:
|
|
376
|
+
break
|
|
377
|
+
|
|
378
|
+
buffer += data
|
|
379
|
+
|
|
380
|
+
while b"\n" in buffer:
|
|
381
|
+
line, buffer = buffer.split(b"\n", 1)
|
|
382
|
+
line_str = line.decode("utf-8", errors="replace").rstrip()
|
|
383
|
+
captured_lines.append(line_str)
|
|
384
|
+
|
|
385
|
+
if compiled_pattern and compiled_pattern.search(line_str):
|
|
386
|
+
pattern_matched = True
|
|
387
|
+
break
|
|
388
|
+
|
|
389
|
+
# Check partial buffer for pattern (e.g. prompts without newline)
|
|
390
|
+
if not pattern_matched and compiled_pattern and buffer:
|
|
391
|
+
partial = buffer.decode("utf-8", errors="replace")
|
|
392
|
+
if compiled_pattern.search(partial):
|
|
393
|
+
captured_lines.append(partial.rstrip())
|
|
394
|
+
buffer = b""
|
|
395
|
+
pattern_matched = True
|
|
396
|
+
|
|
397
|
+
if pattern_matched:
|
|
398
|
+
break
|
|
399
|
+
|
|
400
|
+
except BlockingIOError:
|
|
401
|
+
pass
|
|
402
|
+
|
|
403
|
+
# Remaining buffer
|
|
404
|
+
if buffer:
|
|
405
|
+
line_str = buffer.decode("utf-8", errors="replace").rstrip()
|
|
406
|
+
if line_str:
|
|
407
|
+
captured_lines.append(line_str)
|
|
408
|
+
if compiled_pattern and compiled_pattern.search(line_str):
|
|
409
|
+
pattern_matched = True
|
|
410
|
+
|
|
411
|
+
elapsed = time.monotonic() - start_time
|
|
412
|
+
|
|
413
|
+
capture = CaptureResult(
|
|
414
|
+
output="\n".join(captured_lines),
|
|
415
|
+
lines=len(captured_lines),
|
|
416
|
+
pattern_matched=pattern_matched,
|
|
417
|
+
elapsed_seconds=round(elapsed, 1),
|
|
418
|
+
)
|
|
419
|
+
|
|
420
|
+
return SendResult(sent=True, bytes_sent=bytes_sent, capture=capture)
|
|
421
|
+
|
|
422
|
+
except (ConnectionRefusedError, OSError) as e:
|
|
423
|
+
raise RuntimeError(
|
|
424
|
+
f"Cannot connect to serial port at {tcp_host}:{tcp_port}: {e}"
|
|
425
|
+
)
|
|
426
|
+
finally:
|
|
427
|
+
try:
|
|
428
|
+
sock.close()
|
|
429
|
+
except Exception:
|
|
430
|
+
pass
|
labctl/serial/keys.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""Named keys for serial consoles, as the bytes a terminal sends.
|
|
2
|
+
|
|
3
|
+
Tool arguments are text written by a model, which can't reliably carry
|
|
4
|
+
raw control characters (an ESC is dropped before it reaches the server),
|
|
5
|
+
so MCP clients name keys instead (issue #33). Encodings are xterm's,
|
|
6
|
+
except Backspace (0x08, which U-Boot and EDK2 take as backspace; Linux
|
|
7
|
+
ttys usually erase with 0x7F). None has been checked on real firmware.
|
|
8
|
+
Function keys vary by terminal type (EDK2 VT100+ uses ESC 1..ESC 0,
|
|
9
|
+
ESC ! and ESC @ for F1-F12); callers can send those as raw hex.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
_NAMED = {
|
|
15
|
+
"esc": b"\x1b",
|
|
16
|
+
"enter": b"\r",
|
|
17
|
+
"tab": b"\t",
|
|
18
|
+
"space": b" ",
|
|
19
|
+
"backspace": b"\x08",
|
|
20
|
+
"up": b"\x1b[A",
|
|
21
|
+
"down": b"\x1b[B",
|
|
22
|
+
"right": b"\x1b[C",
|
|
23
|
+
"left": b"\x1b[D",
|
|
24
|
+
"home": b"\x1b[H",
|
|
25
|
+
"end": b"\x1b[F",
|
|
26
|
+
"insert": b"\x1b[2~",
|
|
27
|
+
"delete": b"\x1b[3~",
|
|
28
|
+
"pgup": b"\x1b[5~",
|
|
29
|
+
"pgdn": b"\x1b[6~",
|
|
30
|
+
"f1": b"\x1bOP",
|
|
31
|
+
"f2": b"\x1bOQ",
|
|
32
|
+
"f3": b"\x1bOR",
|
|
33
|
+
"f4": b"\x1bOS",
|
|
34
|
+
"f5": b"\x1b[15~",
|
|
35
|
+
"f6": b"\x1b[17~",
|
|
36
|
+
"f7": b"\x1b[18~",
|
|
37
|
+
"f8": b"\x1b[19~",
|
|
38
|
+
"f9": b"\x1b[20~",
|
|
39
|
+
"f10": b"\x1b[21~",
|
|
40
|
+
"f11": b"\x1b[23~",
|
|
41
|
+
"f12": b"\x1b[24~",
|
|
42
|
+
}
|
|
43
|
+
_ALIASES = {
|
|
44
|
+
"escape": "esc",
|
|
45
|
+
"return": "enter",
|
|
46
|
+
"pageup": "pgup",
|
|
47
|
+
"pagedown": "pgdn",
|
|
48
|
+
"page up": "pgup",
|
|
49
|
+
"page down": "pgdn",
|
|
50
|
+
"del": "delete",
|
|
51
|
+
"ins": "insert",
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
KEY_NAMES = (
|
|
55
|
+
"ESC, Enter, Tab, Space, Backspace (0x08), Up, Down, Left, Right, "
|
|
56
|
+
"Home, End, Insert, Delete, PgUp, PgDn, F1-F12, Ctrl-A..Ctrl-Z "
|
|
57
|
+
"(0x01-0x1A; also written Ctrl+C or ^C)"
|
|
58
|
+
)
|
|
59
|
+
_CTRL_PREFIXES = ("ctrl-", "ctrl+", "control-", "control+", "^")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def encode_key(name: str) -> bytes:
|
|
63
|
+
"""The bytes for key ``name`` (case-insensitive); ValueError if unknown."""
|
|
64
|
+
key = name.strip().lower() if isinstance(name, str) else ""
|
|
65
|
+
key = _ALIASES.get(key, key)
|
|
66
|
+
if key in _NAMED:
|
|
67
|
+
return _NAMED[key]
|
|
68
|
+
for prefix in _CTRL_PREFIXES:
|
|
69
|
+
if key.startswith(prefix):
|
|
70
|
+
letter = key[len(prefix) :]
|
|
71
|
+
if len(letter) == 1 and "a" <= letter <= "z":
|
|
72
|
+
return bytes([ord(letter) - ord("a") + 1])
|
|
73
|
+
raise ValueError(f"unknown key {name!r}; known keys: {KEY_NAMES}")
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def encode_keys(names: list[str]) -> list[bytes]:
|
|
77
|
+
"""The bytes for each of a sequence of key names, in order."""
|
|
78
|
+
return [encode_key(n) for n in names]
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def decode_hex(text: str) -> bytes:
|
|
82
|
+
"""Bytes from hex text, e.g. "1b5b32337e" or "1b 5b 32"; ValueError if
|
|
83
|
+
malformed."""
|
|
84
|
+
try:
|
|
85
|
+
return bytes.fromhex(text)
|
|
86
|
+
except (TypeError, ValueError):
|
|
87
|
+
raise ValueError(
|
|
88
|
+
f"data_hex must be pairs of hex digits (spaces allowed): {text!r}"
|
|
89
|
+
) from None
|