pico-uart 0.0.0.dev39596311668__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.
- pico_uart/__init__.py +115 -0
- pico_uart/__main__.py +3 -0
- pico_uart/cli.py +121 -0
- pico_uart/client.py +245 -0
- pico_uart/protocol.py +250 -0
- pico_uart/transport.py +199 -0
- pico_uart/web/__init__.py +1 -0
- pico_uart/web/app.py +477 -0
- pico_uart/web/assets/css/dashboard.css +217 -0
- pico_uart/web/assets/favicon.svg +6 -0
- pico_uart/web/assets/index.html +133 -0
- pico_uart/web/assets/js/dashboard.js +479 -0
- pico_uart-0.0.0.dev39596311668.dist-info/METADATA +105 -0
- pico_uart-0.0.0.dev39596311668.dist-info/RECORD +17 -0
- pico_uart-0.0.0.dev39596311668.dist-info/WHEEL +5 -0
- pico_uart-0.0.0.dev39596311668.dist-info/entry_points.txt +2 -0
- pico_uart-0.0.0.dev39596311668.dist-info/top_level.txt +1 -0
pico_uart/__init__.py
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""Python client tools for PicoUart hardware."""
|
|
2
|
+
|
|
3
|
+
from .client import (
|
|
4
|
+
PicoUartHid,
|
|
5
|
+
read_board_status,
|
|
6
|
+
read_board_temperature,
|
|
7
|
+
read_feature,
|
|
8
|
+
read_firmware_version,
|
|
9
|
+
read_configuration,
|
|
10
|
+
read_hardware_info,
|
|
11
|
+
read_overflow_counts,
|
|
12
|
+
reset_board,
|
|
13
|
+
send_command,
|
|
14
|
+
)
|
|
15
|
+
from .protocol import (
|
|
16
|
+
BOARD_STATUS_FLAG_HID_RESET,
|
|
17
|
+
BOARD_STATUS_LAYOUT_VERSION,
|
|
18
|
+
BOARD_STATUS_RESERVED0_KNOWN_FLAGS,
|
|
19
|
+
BOARD_STATUS_SIZE,
|
|
20
|
+
CONFIGURATION_SIZE,
|
|
21
|
+
COMMAND_ARM_RESET,
|
|
22
|
+
COMMAND_RESET_BOARD,
|
|
23
|
+
COMMAND_TOGGLE_LED,
|
|
24
|
+
HARDWARE_INFO_LAYOUT_VERSION,
|
|
25
|
+
HARDWARE_INFO_SIZE,
|
|
26
|
+
MCU_RP2040,
|
|
27
|
+
MCU_RP2350,
|
|
28
|
+
OVERFLOW_COUNTS_SIZE,
|
|
29
|
+
REPORT_ID_BOARD_STATUS,
|
|
30
|
+
REPORT_ID_COMMAND,
|
|
31
|
+
REPORT_ID_CONFIGURATION,
|
|
32
|
+
REPORT_ID_HARDWARE_INFO,
|
|
33
|
+
REPORT_ID_OVERFLOW_COUNTS,
|
|
34
|
+
REPORT_ID_STATUS,
|
|
35
|
+
RESET_ARM_WINDOW_S,
|
|
36
|
+
STATUS_CHANNEL_SIZE,
|
|
37
|
+
STATUS_HEADER_SIZE,
|
|
38
|
+
STATUS_LAYOUT_VERSION,
|
|
39
|
+
STATUS_SIGNATURE,
|
|
40
|
+
STATUS_SIZE,
|
|
41
|
+
UART_CHANNEL_COUNT,
|
|
42
|
+
decode_health,
|
|
43
|
+
parse_board_status,
|
|
44
|
+
parse_configuration,
|
|
45
|
+
parse_hardware_info,
|
|
46
|
+
parse_overflow_counts,
|
|
47
|
+
parse_status,
|
|
48
|
+
require_payload,
|
|
49
|
+
)
|
|
50
|
+
from .transport import (
|
|
51
|
+
HID_INTERFACE_NUMBER,
|
|
52
|
+
PRODUCT_ID,
|
|
53
|
+
PRODUCT_STRING,
|
|
54
|
+
USAGE,
|
|
55
|
+
USAGE_PAGE,
|
|
56
|
+
VENDOR_ID,
|
|
57
|
+
hid,
|
|
58
|
+
open_device,
|
|
59
|
+
open_enumerated_device,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
__all__ = [
|
|
63
|
+
"PicoUartHid",
|
|
64
|
+
"read_board_status",
|
|
65
|
+
"read_board_temperature",
|
|
66
|
+
"read_feature",
|
|
67
|
+
"read_firmware_version",
|
|
68
|
+
"read_configuration",
|
|
69
|
+
"read_hardware_info",
|
|
70
|
+
"read_overflow_counts",
|
|
71
|
+
"reset_board",
|
|
72
|
+
"send_command",
|
|
73
|
+
"BOARD_STATUS_FLAG_HID_RESET",
|
|
74
|
+
"BOARD_STATUS_LAYOUT_VERSION",
|
|
75
|
+
"BOARD_STATUS_RESERVED0_KNOWN_FLAGS",
|
|
76
|
+
"BOARD_STATUS_SIZE",
|
|
77
|
+
"CONFIGURATION_SIZE",
|
|
78
|
+
"COMMAND_ARM_RESET",
|
|
79
|
+
"COMMAND_RESET_BOARD",
|
|
80
|
+
"COMMAND_TOGGLE_LED",
|
|
81
|
+
"HID_INTERFACE_NUMBER",
|
|
82
|
+
"HARDWARE_INFO_LAYOUT_VERSION",
|
|
83
|
+
"HARDWARE_INFO_SIZE",
|
|
84
|
+
"MCU_RP2040",
|
|
85
|
+
"MCU_RP2350",
|
|
86
|
+
"OVERFLOW_COUNTS_SIZE",
|
|
87
|
+
"PRODUCT_ID",
|
|
88
|
+
"PRODUCT_STRING",
|
|
89
|
+
"REPORT_ID_BOARD_STATUS",
|
|
90
|
+
"REPORT_ID_COMMAND",
|
|
91
|
+
"REPORT_ID_CONFIGURATION",
|
|
92
|
+
"REPORT_ID_HARDWARE_INFO",
|
|
93
|
+
"REPORT_ID_OVERFLOW_COUNTS",
|
|
94
|
+
"REPORT_ID_STATUS",
|
|
95
|
+
"RESET_ARM_WINDOW_S",
|
|
96
|
+
"STATUS_CHANNEL_SIZE",
|
|
97
|
+
"STATUS_HEADER_SIZE",
|
|
98
|
+
"STATUS_LAYOUT_VERSION",
|
|
99
|
+
"STATUS_SIGNATURE",
|
|
100
|
+
"STATUS_SIZE",
|
|
101
|
+
"UART_CHANNEL_COUNT",
|
|
102
|
+
"USAGE",
|
|
103
|
+
"USAGE_PAGE",
|
|
104
|
+
"VENDOR_ID",
|
|
105
|
+
"decode_health",
|
|
106
|
+
"hid",
|
|
107
|
+
"open_device",
|
|
108
|
+
"open_enumerated_device",
|
|
109
|
+
"parse_board_status",
|
|
110
|
+
"parse_configuration",
|
|
111
|
+
"parse_hardware_info",
|
|
112
|
+
"parse_overflow_counts",
|
|
113
|
+
"parse_status",
|
|
114
|
+
"require_payload",
|
|
115
|
+
]
|
pico_uart/__main__.py
ADDED
pico_uart/cli.py
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""Command-line interface for PicoUart HID diagnostics and board controls."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
import math
|
|
6
|
+
import sys
|
|
7
|
+
import time
|
|
8
|
+
|
|
9
|
+
from .client import PicoUartHid
|
|
10
|
+
from .protocol import decode_health
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def print_status(status: dict[str, object]) -> None:
|
|
14
|
+
"""Print one concise status report for monitor and status commands."""
|
|
15
|
+
channels = " ".join(
|
|
16
|
+
f"cdc{channel['id']} health=0x{channel['health']:02x}"
|
|
17
|
+
f"[{','.join(decode_health(channel['health'])) or '-'}] "
|
|
18
|
+
f"uart_tx/rx={channel['controller_tx_bytes']}/{channel['controller_rx_bytes']} "
|
|
19
|
+
f"cdc_tx/rx={channel['cdc_tx_bytes']}/{channel['cdc_rx_bytes']} "
|
|
20
|
+
f"ring_peak={channel['ring_high_watermark']}"
|
|
21
|
+
for channel in status["channels"]
|
|
22
|
+
)
|
|
23
|
+
print(f"seq={status['sequence']} {channels}")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def monitor(client: PicoUartHid, duration: float) -> None:
|
|
27
|
+
"""Print valid status reports until the requested duration expires."""
|
|
28
|
+
deadline = time.monotonic() + duration
|
|
29
|
+
valid_reports = 0
|
|
30
|
+
while time.monotonic() < deadline:
|
|
31
|
+
status = client.read_status(timeout_ms=250)
|
|
32
|
+
if status is None:
|
|
33
|
+
continue
|
|
34
|
+
print_status(status)
|
|
35
|
+
valid_reports += 1
|
|
36
|
+
if valid_reports == 0:
|
|
37
|
+
raise RuntimeError("monitor timed out without receiving a valid status report")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def parse_arguments(argv: list[str] | None = None) -> argparse.Namespace:
|
|
41
|
+
"""Parse the HID command-line interface."""
|
|
42
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
43
|
+
selection = parser.add_mutually_exclusive_group()
|
|
44
|
+
selection.add_argument("--serial", help="select a PicoUart HID interface by USB serial number")
|
|
45
|
+
selection.add_argument("--device-path", help="select a PicoUart HID interface by hidapi path")
|
|
46
|
+
commands = parser.add_subparsers(dest="command", required=True)
|
|
47
|
+
|
|
48
|
+
monitor_parser = commands.add_parser("monitor", help="print periodic status reports")
|
|
49
|
+
monitor_parser.add_argument("--duration", type=float, default=5.0, help="monitor duration in seconds")
|
|
50
|
+
status_parser = commands.add_parser("status", help="read one channel status report")
|
|
51
|
+
status_parser.add_argument("--timeout", type=float, default=2.0, help="status read timeout in seconds")
|
|
52
|
+
status_parser.add_argument("--json", action="store_true", help="print status as JSON")
|
|
53
|
+
commands.add_parser("temperature", help="read the internal board temperature")
|
|
54
|
+
commands.add_parser("version", help="read the firmware semantic version (MAJOR.MINOR.PATCH)")
|
|
55
|
+
commands.add_parser("overruns", help="read cumulative UART RX dropped-byte counts")
|
|
56
|
+
commands.add_parser("hardware", help="read MCU identity and current system clock as JSON")
|
|
57
|
+
commands.add_parser("config", help="read persistent configuration diagnostics as JSON")
|
|
58
|
+
commands.add_parser("toggle-led", help="toggle the board's default LED")
|
|
59
|
+
commands.add_parser(
|
|
60
|
+
"reset",
|
|
61
|
+
help="arm then reset the board (requires firmware HID reset support)",
|
|
62
|
+
)
|
|
63
|
+
web_parser = commands.add_parser("web", help="start the local Flask diagnostics dashboard")
|
|
64
|
+
web_parser.add_argument("--port", type=int, default=5000, help="local web server port")
|
|
65
|
+
return parser.parse_args(argv)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def main(argv: list[str] | None = None) -> int:
|
|
69
|
+
"""Run the selected PicoUart HID command."""
|
|
70
|
+
arguments = parse_arguments(argv)
|
|
71
|
+
if arguments.command == "monitor" and (not math.isfinite(arguments.duration) or arguments.duration <= 0):
|
|
72
|
+
raise SystemExit("--duration must be a finite value greater than zero")
|
|
73
|
+
if arguments.command == "status" and (not math.isfinite(arguments.timeout) or arguments.timeout <= 0):
|
|
74
|
+
raise SystemExit("--timeout must be a finite value greater than zero")
|
|
75
|
+
if arguments.command == "web":
|
|
76
|
+
if not 1 <= arguments.port <= 65535:
|
|
77
|
+
raise SystemExit("--port must be between 1 and 65535")
|
|
78
|
+
try:
|
|
79
|
+
from .web.app import run_server
|
|
80
|
+
|
|
81
|
+
run_server(arguments.port, arguments.serial, arguments.device_path)
|
|
82
|
+
except (OSError, RuntimeError) as error:
|
|
83
|
+
print(f"error: {error}", file=sys.stderr)
|
|
84
|
+
return 1
|
|
85
|
+
return 0
|
|
86
|
+
|
|
87
|
+
try:
|
|
88
|
+
with PicoUartHid(arguments.serial, arguments.device_path) as client:
|
|
89
|
+
if arguments.command == "monitor":
|
|
90
|
+
monitor(client, arguments.duration)
|
|
91
|
+
elif arguments.command == "status":
|
|
92
|
+
deadline = time.monotonic() + arguments.timeout
|
|
93
|
+
status = None
|
|
94
|
+
while status is None and time.monotonic() < deadline:
|
|
95
|
+
remaining_ms = max(1, min(250, int((deadline - time.monotonic()) * 1000)))
|
|
96
|
+
status = client.read_status(remaining_ms)
|
|
97
|
+
if status is None:
|
|
98
|
+
raise RuntimeError("timed out without receiving a valid status report")
|
|
99
|
+
if arguments.json:
|
|
100
|
+
print(json.dumps(status, sort_keys=True))
|
|
101
|
+
else:
|
|
102
|
+
print_status(status)
|
|
103
|
+
elif arguments.command == "temperature":
|
|
104
|
+
print(f"temperature={client.read_board_temperature():.2f} C")
|
|
105
|
+
elif arguments.command == "version":
|
|
106
|
+
print(client.read_firmware_version())
|
|
107
|
+
elif arguments.command == "overruns":
|
|
108
|
+
print(" ".join(f"cdc{index}={count}" for index, count in enumerate(client.read_overflow_counts())))
|
|
109
|
+
elif arguments.command == "hardware":
|
|
110
|
+
print(json.dumps(client.read_hardware_info(), sort_keys=True))
|
|
111
|
+
elif arguments.command == "config":
|
|
112
|
+
print(json.dumps(client.read_configuration(), sort_keys=True))
|
|
113
|
+
elif arguments.command == "toggle-led":
|
|
114
|
+
client.toggle_led()
|
|
115
|
+
elif arguments.command == "reset":
|
|
116
|
+
client.reset_board()
|
|
117
|
+
except (OSError, RuntimeError) as error:
|
|
118
|
+
print(f"error: {error}", file=sys.stderr)
|
|
119
|
+
return 1
|
|
120
|
+
|
|
121
|
+
return 0
|
pico_uart/client.py
ADDED
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
"""Resource-owning Python API for PicoUart's HID status and board controls."""
|
|
2
|
+
|
|
3
|
+
import threading
|
|
4
|
+
import time
|
|
5
|
+
import struct
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from .protocol import (
|
|
9
|
+
BOARD_STATUS_FLAG_HID_RESET,
|
|
10
|
+
BOARD_STATUS_SIZE,
|
|
11
|
+
COMMAND_ARM_RESET,
|
|
12
|
+
COMMAND_RESET_BOARD,
|
|
13
|
+
COMMAND_TOGGLE_LED,
|
|
14
|
+
HARDWARE_INFO_SIZE,
|
|
15
|
+
IDENTITY_SIZE,
|
|
16
|
+
RUNTIME_SIZE,
|
|
17
|
+
LED_SIZE,
|
|
18
|
+
REPORT_ID_LED,
|
|
19
|
+
parse_led,
|
|
20
|
+
OVERFLOW_COUNTS_SIZE,
|
|
21
|
+
REPORT_ID_BOARD_STATUS,
|
|
22
|
+
REPORT_ID_COMMAND,
|
|
23
|
+
REPORT_ID_OVERFLOW_COUNTS,
|
|
24
|
+
REPORT_ID_HARDWARE_INFO,
|
|
25
|
+
REPORT_ID_IDENTITY,
|
|
26
|
+
REPORT_ID_RUNTIME,
|
|
27
|
+
REPORT_ID_STATUS,
|
|
28
|
+
RESET_ARM_WINDOW_S,
|
|
29
|
+
STATUS_SIZE,
|
|
30
|
+
parse_board_status,
|
|
31
|
+
parse_hardware_info,
|
|
32
|
+
parse_identity,
|
|
33
|
+
parse_runtime,
|
|
34
|
+
parse_overflow_counts,
|
|
35
|
+
parse_status,
|
|
36
|
+
require_payload,
|
|
37
|
+
REPORT_ID_CHANNEL_BASE,
|
|
38
|
+
CHANNEL_DIAGNOSTICS_SIZE,
|
|
39
|
+
parse_channel_diagnostics,
|
|
40
|
+
REPORT_ID_CONFIGURATION,
|
|
41
|
+
CONFIGURATION_SIZE,
|
|
42
|
+
parse_configuration,
|
|
43
|
+
REPORT_ID_CONFIGURATION_SAVE,
|
|
44
|
+
CONFIGURATION_SAVE_SIZE,
|
|
45
|
+
parse_configuration_save,
|
|
46
|
+
)
|
|
47
|
+
from .transport import open_device
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def read_feature(device: Any, report_id: int, payload_size: int) -> bytes:
|
|
51
|
+
"""Read a fixed-size feature report, excluding its HID report ID."""
|
|
52
|
+
return require_payload(
|
|
53
|
+
device.get_feature_report(report_id, payload_size + 1),
|
|
54
|
+
report_id,
|
|
55
|
+
payload_size,
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def read_board_status(device: Any) -> dict[str, object]:
|
|
60
|
+
"""Read temperature, firmware version, and board capabilities."""
|
|
61
|
+
payload = read_feature(device, REPORT_ID_BOARD_STATUS, BOARD_STATUS_SIZE)
|
|
62
|
+
return parse_board_status(payload)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def read_board_temperature(device: Any) -> float:
|
|
66
|
+
"""Read the internal RP2 temperature estimate in degrees Celsius."""
|
|
67
|
+
return float(read_board_status(device)["temperature_celsius"])
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def read_firmware_version(device: Any) -> str:
|
|
71
|
+
"""Read the firmware semantic version (MAJOR.MINOR.PATCH) from HID."""
|
|
72
|
+
return str(read_board_status(device)["firmware_version"])
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def read_overflow_counts(device: Any) -> list[int]:
|
|
76
|
+
"""Read cumulative UART-to-USB dropped-byte counts for every channel."""
|
|
77
|
+
payload = read_feature(device, REPORT_ID_OVERFLOW_COUNTS, OVERFLOW_COUNTS_SIZE)
|
|
78
|
+
return parse_overflow_counts(payload)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def read_hardware_info(device: Any) -> dict[str, object]:
|
|
82
|
+
"""Read the MCU model and current system clock from hardware-info report 6."""
|
|
83
|
+
return parse_hardware_info(read_feature(device, REPORT_ID_HARDWARE_INFO, HARDWARE_INFO_SIZE))
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def read_configuration(device: Any) -> dict[str, object]:
|
|
87
|
+
"""Read persistent configuration diagnostics without exposing stored data."""
|
|
88
|
+
return parse_configuration(read_feature(device, REPORT_ID_CONFIGURATION, CONFIGURATION_SIZE))
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def send_command(device: Any, command: int) -> None:
|
|
92
|
+
"""Send a one-byte board command through the HID feature-report path."""
|
|
93
|
+
bytes_written = device.send_feature_report([REPORT_ID_COMMAND, command])
|
|
94
|
+
if bytes_written != 2:
|
|
95
|
+
raise RuntimeError(f"HID command write was incomplete: wrote {bytes_written} bytes")
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def reset_board(device: Any) -> None:
|
|
99
|
+
"""Arm then reset the board only when firmware enables remote reset."""
|
|
100
|
+
if not read_board_status(device)["hid_reset_enabled"]:
|
|
101
|
+
raise RuntimeError("firmware HID reset is disabled; rebuild with -DPICO_UART_ALLOW_HID_RESET=1")
|
|
102
|
+
send_command(device, COMMAND_ARM_RESET)
|
|
103
|
+
time.sleep(min(0.05, RESET_ARM_WINDOW_S / 10.0))
|
|
104
|
+
send_command(device, COMMAND_RESET_BOARD)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class PicoUartHid:
|
|
108
|
+
"""Own a PicoUart HID connection and expose decoded status and controls."""
|
|
109
|
+
|
|
110
|
+
def __init__(
|
|
111
|
+
self,
|
|
112
|
+
serial_number: str | None = None,
|
|
113
|
+
device_path: str | None = None,
|
|
114
|
+
device: Any | None = None,
|
|
115
|
+
) -> None:
|
|
116
|
+
if device is not None and (serial_number is not None or device_path is not None):
|
|
117
|
+
raise ValueError("an injected device cannot be combined with a device selector")
|
|
118
|
+
self._device = device if device is not None else open_device(serial_number, device_path)
|
|
119
|
+
self._lock = threading.RLock()
|
|
120
|
+
self._closed = False
|
|
121
|
+
|
|
122
|
+
def _require_open(self) -> Any:
|
|
123
|
+
if self._closed:
|
|
124
|
+
raise RuntimeError("PicoUart HID connection is closed")
|
|
125
|
+
return self._device
|
|
126
|
+
|
|
127
|
+
def read_status(self, timeout_ms: int = 250) -> dict[str, object] | None:
|
|
128
|
+
"""Read one periodic channel report, returning None if none is ready."""
|
|
129
|
+
if timeout_ms < 0:
|
|
130
|
+
raise ValueError("timeout_ms must not be negative")
|
|
131
|
+
with self._lock:
|
|
132
|
+
report = self._require_open().read(STATUS_SIZE + 1, timeout_ms)
|
|
133
|
+
if not report:
|
|
134
|
+
return None
|
|
135
|
+
if report[0] != REPORT_ID_STATUS:
|
|
136
|
+
raise RuntimeError(f"unexpected HID report ID {report[0]}")
|
|
137
|
+
return parse_status(bytes(report[1:]))
|
|
138
|
+
|
|
139
|
+
def read_board_status(self) -> dict[str, object]:
|
|
140
|
+
with self._lock:
|
|
141
|
+
return read_board_status(self._require_open())
|
|
142
|
+
|
|
143
|
+
def read_board_temperature(self) -> float:
|
|
144
|
+
return float(self.read_board_status()["temperature_celsius"])
|
|
145
|
+
|
|
146
|
+
def read_firmware_version(self) -> str:
|
|
147
|
+
return str(self.read_board_status()["firmware_version"])
|
|
148
|
+
|
|
149
|
+
def read_overflow_counts(self) -> list[int]:
|
|
150
|
+
with self._lock:
|
|
151
|
+
return read_overflow_counts(self._require_open())
|
|
152
|
+
|
|
153
|
+
def read_hardware_info(self) -> dict[str, object]:
|
|
154
|
+
with self._lock:
|
|
155
|
+
return read_hardware_info(self._require_open())
|
|
156
|
+
|
|
157
|
+
def read_configuration(self) -> dict[str, object]:
|
|
158
|
+
with self._lock:
|
|
159
|
+
return read_configuration(self._require_open())
|
|
160
|
+
|
|
161
|
+
def read_configuration_save(self) -> dict[str, object]:
|
|
162
|
+
"""Read the most recent persistent profile save result."""
|
|
163
|
+
with self._lock:
|
|
164
|
+
return parse_configuration_save(read_feature(
|
|
165
|
+
self._require_open(), REPORT_ID_CONFIGURATION_SAVE, CONFIGURATION_SAVE_SIZE))
|
|
166
|
+
|
|
167
|
+
def save_configuration(self, profile: int, unsafe_overclock: bool = False) -> dict[str, object]:
|
|
168
|
+
"""Persist a supported clock profile; firmware reboots after confirming the save."""
|
|
169
|
+
if type(profile) is not int or profile not in (0, 1, 2) or type(unsafe_overclock) is not bool:
|
|
170
|
+
raise ValueError("invalid clock profile or overclock acknowledgement")
|
|
171
|
+
if profile != 0 and not unsafe_overclock:
|
|
172
|
+
raise ValueError("non-default clocks require explicit unsafe overclock acknowledgement")
|
|
173
|
+
with self._lock:
|
|
174
|
+
device = self._require_open()
|
|
175
|
+
hardware = self.read_hardware_info()
|
|
176
|
+
supported = {1: (0, 1), 2: (0, 2)}.get(hardware["mcu_id"], ())
|
|
177
|
+
if profile not in supported:
|
|
178
|
+
raise ValueError("clock profile is not supported on this MCU")
|
|
179
|
+
if self.read_configuration_save()["status"] == 1:
|
|
180
|
+
raise RuntimeError("a configuration save is already pending")
|
|
181
|
+
payload = bytes([REPORT_ID_CONFIGURATION_SAVE]) + struct.pack(
|
|
182
|
+
"<BBHI", 1, int(unsafe_overclock), 0, profile)
|
|
183
|
+
written = device.send_feature_report(list(payload))
|
|
184
|
+
if written != len(payload):
|
|
185
|
+
raise RuntimeError(f"HID configuration write was incomplete: wrote {written} bytes")
|
|
186
|
+
deadline = time.monotonic() + 3.0
|
|
187
|
+
errors = {
|
|
188
|
+
3: "firmware rejected the configuration request",
|
|
189
|
+
4: "close all CDC ports and stop UART traffic before saving configuration",
|
|
190
|
+
5: "configuration flash write or verification failed",
|
|
191
|
+
}
|
|
192
|
+
while time.monotonic() < deadline:
|
|
193
|
+
result = self.read_configuration_save()
|
|
194
|
+
status = result["status"]
|
|
195
|
+
if status in errors:
|
|
196
|
+
raise RuntimeError(errors[status])
|
|
197
|
+
if result["profile"] != profile:
|
|
198
|
+
raise RuntimeError("configuration save result does not match the requested profile")
|
|
199
|
+
if status == 2:
|
|
200
|
+
return result
|
|
201
|
+
time.sleep(0.05)
|
|
202
|
+
raise RuntimeError("configuration save timed out; read back the configuration before retrying")
|
|
203
|
+
|
|
204
|
+
def read_identity(self) -> dict[str, object]:
|
|
205
|
+
"""Read the product and USB serial / board UID."""
|
|
206
|
+
with self._lock:
|
|
207
|
+
return parse_identity(read_feature(self._require_open(), REPORT_ID_IDENTITY, IDENTITY_SIZE))
|
|
208
|
+
|
|
209
|
+
def read_runtime(self) -> dict[str, object]:
|
|
210
|
+
"""Read the device's runtime counters, not host session estimates."""
|
|
211
|
+
with self._lock:
|
|
212
|
+
return parse_runtime(read_feature(self._require_open(), REPORT_ID_RUNTIME, RUNTIME_SIZE))
|
|
213
|
+
|
|
214
|
+
def read_channel_diagnostics(self) -> list[dict[str, object]]:
|
|
215
|
+
"""Read six independently addressed channel diagnostic feature reports."""
|
|
216
|
+
with self._lock:
|
|
217
|
+
return [parse_channel_diagnostics(read_feature(
|
|
218
|
+
self._require_open(), REPORT_ID_CHANNEL_BASE + index, CHANNEL_DIAGNOSTICS_SIZE))
|
|
219
|
+
for index in range(6)]
|
|
220
|
+
|
|
221
|
+
def toggle_led(self) -> None:
|
|
222
|
+
with self._lock:
|
|
223
|
+
send_command(self._require_open(), COMMAND_TOGGLE_LED)
|
|
224
|
+
|
|
225
|
+
def read_led(self) -> dict[str, bool | None]:
|
|
226
|
+
"""Read the manual override and physical LED output."""
|
|
227
|
+
with self._lock:
|
|
228
|
+
return parse_led(read_feature(self._require_open(), REPORT_ID_LED, LED_SIZE))
|
|
229
|
+
|
|
230
|
+
def reset_board(self) -> None:
|
|
231
|
+
with self._lock:
|
|
232
|
+
reset_board(self._require_open())
|
|
233
|
+
|
|
234
|
+
def close(self) -> None:
|
|
235
|
+
with self._lock:
|
|
236
|
+
if not self._closed:
|
|
237
|
+
self._device.close()
|
|
238
|
+
self._closed = True
|
|
239
|
+
|
|
240
|
+
def __enter__(self) -> "PicoUartHid":
|
|
241
|
+
self._require_open()
|
|
242
|
+
return self
|
|
243
|
+
|
|
244
|
+
def __exit__(self, *_: object) -> None:
|
|
245
|
+
self.close()
|