feat!: v0.2.0 — module split, Rust parity, PyPI-ready
Split monolithic __init__.py into six modules matching the Rust client structure: protocol.py, codec.py, discovery.py, transport.py, connect.py, client.py. All symbols re-exported from top-level for backwards compat. New features: - Ping-on-connect validation (200ms handshake, matches Rust v0.2.0) - try_connect(), connect_default(), connect_mux_auto(), validate() - connect() commits to mux when socket exists (no USB fall-through) - Bandwidth/ErrorCode IntEnums, RadioConfig TypedDict, py.typed marker - MAX_PAYLOAD, RADIO_CONFIG_SIZE, tag/error constants - Payload size validation on Transmit - 46 pytest tests ported from Rust (protocol, codec, client, firmware compat) - GitHub Actions CI (ruff + pytest on 3.10-3.13 + PyPI publish) - Strict ruff linting (E, W, F, I, UP, B, C4, ARG, SIM, RUF) Breaking: package renamed back to donglora (from donglora-python). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
19 files changed
+1787
-395
No files matched your search
@@ -0,0 +1,42 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
tags: ["v*"]
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
lint:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: astral-sh/setup-uv@v6
|
||||||
|
- run: uv run ruff check .
|
||||||
|
- run: uv run ruff format --check .
|
||||||
|
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
python-version: ["3.10", "3.11", "3.12", "3.13"]
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: astral-sh/setup-uv@v6
|
||||||
|
with:
|
||||||
|
python-version: ${{ matrix.python-version }}
|
||||||
|
- run: uv sync
|
||||||
|
- run: uv run pytest -v --cov=donglora --cov-report=term-missing
|
||||||
|
|
||||||
|
publish:
|
||||||
|
needs: [lint, test]
|
||||||
|
if: startsWith(github.ref, 'refs/tags/v')
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
environment: pypi
|
||||||
|
permissions:
|
||||||
|
id-token: write
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: astral-sh/setup-uv@v6
|
||||||
|
- run: uv build
|
||||||
|
- uses: pypa/gh-action-pypi-publish@release/v1
|
||||||
@@ -1 +1,2 @@
|
|||||||
.venv/
|
.venv/
|
||||||
|
__pycache__/
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
## 0.2.0 — 2026-04-08
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **Module split matching Rust structure** — split monolithic `__init__.py` into
|
||||||
|
`protocol.py`, `codec.py`, `discovery.py`, `transport.py`, `connect.py`, and
|
||||||
|
`client.py`. All symbols re-exported from top-level for backwards compatibility.
|
||||||
|
- **Ping-on-connect validation** — all connect functions automatically ping the
|
||||||
|
device and reject non-DongLoRa devices within 200 ms (matches Rust v0.2.0).
|
||||||
|
- **`try_connect(timeout)`** — non-blocking alternative to `connect()`. Returns
|
||||||
|
an error immediately if no device is found.
|
||||||
|
- **`connect_default()`** — convenience wrapper with default timeout.
|
||||||
|
- **`connect_mux_auto(timeout)`** — mux-only connection, never falls back to USB
|
||||||
|
serial. Equivalent of Rust client's "sticky mux" behaviour.
|
||||||
|
- **`default_socket_path()`** — resolve the preferred mux socket path.
|
||||||
|
- **`validate(ser)`** — public function for manual device validation.
|
||||||
|
- **Payload size validation** — `encode_command("Transmit", ...)` rejects
|
||||||
|
payloads exceeding 256 bytes.
|
||||||
|
- **`Bandwidth` and `ErrorCode` enums** — `IntEnum` types matching the Rust
|
||||||
|
definitions.
|
||||||
|
- **`RadioConfig` TypedDict** — typed dict for radio configuration.
|
||||||
|
- **New constants** — `MAX_PAYLOAD`, `RADIO_CONFIG_SIZE`, `CMD_TAG_*`,
|
||||||
|
`RESP_TAG_*`, `ERROR_*` tag/error constants.
|
||||||
|
- **`py.typed` marker** — PEP 561 support for type checkers.
|
||||||
|
- **Comprehensive test suite** — 46 tests ported from the Rust client covering
|
||||||
|
protocol roundtrips, COBS framing, client send/recv, firmware wire
|
||||||
|
compatibility.
|
||||||
|
- **GitHub Actions CI** — ruff lint + format, pytest on Python 3.10–3.13, PyPI
|
||||||
|
publish on tag.
|
||||||
|
- **Strict ruff linting** — E, W, F, I, UP, B, C4, ARG, SIM, RUF rules.
|
||||||
|
|
||||||
|
### Breaking changes
|
||||||
|
|
||||||
|
- **Package renamed back to `donglora`** — the short-lived `donglora-python`
|
||||||
|
name is reverted. Import as `import donglora` (was always the intended API).
|
||||||
|
- **`connect()` no longer falls through from mux to USB serial** — if a mux
|
||||||
|
socket file exists, the function commits to the mux or raises an error
|
||||||
|
(matches Rust v0.2.1). This prevents port-stealing race conditions.
|
||||||
|
- **Sticky mux global removed** — the module-level `_mux_mode` variable and
|
||||||
|
`_reconnect_mux()` are gone. Use `connect_mux_auto()` for mux-only mode.
|
||||||
|
- **`DEFAULT_CONFIG` now includes `cad: 1`** — previously relied on
|
||||||
|
`encode_config()` defaulting it.
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
|
||||||
|
- Fixed broken dependency paths that prevented `uv sync` from resolving.
|
||||||
|
- Replaced `print()` calls with `logging` in discovery module.
|
||||||
|
|
||||||
|
## 0.1.0 — 2026-04-06
|
||||||
|
|
||||||
|
Initial release.
|
||||||
@@ -1,15 +1,21 @@
|
|||||||
# DongLoRa Python Client
|
# DongLoRa Python Client
|
||||||
|
|
||||||
Python client library for talking to a DongLoRa device — either directly
|
Python client library for talking to a DongLoRa device — either directly
|
||||||
over USB or through the [mux daemon](../../mux/).
|
over USB or through the [mux daemon](https://github.com/donglora/mux-py).
|
||||||
|
|
||||||
## What's in Here
|
## Install
|
||||||
|
|
||||||
- `donglora/` — the library: device discovery, COBS framing, protocol
|
```
|
||||||
encoding/decoding, mux client support
|
pip install donglora
|
||||||
- Installable as `donglora` via pip/uv
|
```
|
||||||
|
|
||||||
## Usage
|
Or with [uv](https://docs.astral.sh/uv/):
|
||||||
|
|
||||||
|
```
|
||||||
|
uv add donglora
|
||||||
|
```
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
```python
|
```python
|
||||||
import donglora as dl
|
import donglora as dl
|
||||||
@@ -17,15 +23,48 @@ import donglora as dl
|
|||||||
ser = dl.connect()
|
ser = dl.connect()
|
||||||
dl.send(ser, "SetConfig", config=dl.DEFAULT_CONFIG)
|
dl.send(ser, "SetConfig", config=dl.DEFAULT_CONFIG)
|
||||||
dl.send(ser, "StartRx")
|
dl.send(ser, "StartRx")
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
pkt = dl.recv(ser)
|
pkt = dl.recv(ser)
|
||||||
if pkt:
|
if pkt:
|
||||||
print(pkt["rssi"], pkt["payload"].hex())
|
print(pkt["rssi"], pkt["payload"].hex())
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Connection Functions
|
||||||
|
|
||||||
|
| Function | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| `connect()` | Auto-detect: TCP mux, Unix socket mux, then USB serial |
|
||||||
|
| `connect_default()` | Convenience wrapper with default timeout |
|
||||||
|
| `try_connect()` | Like `connect()` but non-blocking — raises if no device found |
|
||||||
|
| `connect_mux_auto()` | Mux only — never falls back to USB serial |
|
||||||
|
| `mux_connect()` | Connect via Unix domain socket |
|
||||||
|
| `mux_tcp_connect()` | Connect via TCP |
|
||||||
|
|
||||||
|
## Module Structure
|
||||||
|
|
||||||
|
| Module | Contents |
|
||||||
|
|--------|----------|
|
||||||
|
| `donglora.protocol` | Wire types, RadioConfig, encode/decode, constants |
|
||||||
|
| `donglora.codec` | COBS framing |
|
||||||
|
| `donglora.discovery` | USB device discovery by VID:PID |
|
||||||
|
| `donglora.transport` | MuxConnection (socket wrapper) |
|
||||||
|
| `donglora.connect` | Connection auto-detection and mux helpers |
|
||||||
|
| `donglora.client` | High-level send/recv/validate |
|
||||||
|
|
||||||
|
Everything is re-exported from the top-level `donglora` package.
|
||||||
|
|
||||||
## Dependencies
|
## Dependencies
|
||||||
|
|
||||||
- `cobs` — COBS framing
|
- `cobs` — COBS framing
|
||||||
- `pyserial` — USB serial communication
|
- `pyserial` — USB serial communication
|
||||||
|
|
||||||
Optional extras: `meshcore` (crypto), `orac` (AI bot).
|
Optional extras: `meshcore` (crypto), `orac` (AI bot).
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
```
|
||||||
|
just check # fmt + lint + test
|
||||||
|
just fmt # format code
|
||||||
|
just test # run tests
|
||||||
|
```
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
"""DongLoRa host library — connect, configure, send/receive LoRa packets.
|
||||||
|
|
||||||
|
Implements the DongLoRa USB protocol (COBS-framed fixed-size LE).
|
||||||
|
See firmware/PROTOCOL.md for the full specification.
|
||||||
|
|
||||||
|
Quick start::
|
||||||
|
|
||||||
|
import donglora as dl
|
||||||
|
|
||||||
|
ser = dl.connect()
|
||||||
|
dl.send(ser, "SetConfig", config=dl.DEFAULT_CONFIG)
|
||||||
|
dl.send(ser, "StartRx")
|
||||||
|
|
||||||
|
while True:
|
||||||
|
pkt = dl.recv(ser)
|
||||||
|
if pkt:
|
||||||
|
print(pkt["rssi"], pkt["payload"].hex())
|
||||||
|
"""
|
||||||
|
|
||||||
|
from donglora.client import (
|
||||||
|
HANDSHAKE_TIMEOUT,
|
||||||
|
drain_rx,
|
||||||
|
recv,
|
||||||
|
send,
|
||||||
|
validate,
|
||||||
|
)
|
||||||
|
from donglora.codec import cobs_encode, read_frame
|
||||||
|
from donglora.connect import (
|
||||||
|
DEFAULT_TIMEOUT,
|
||||||
|
connect,
|
||||||
|
connect_default,
|
||||||
|
connect_mux_auto,
|
||||||
|
default_socket_path,
|
||||||
|
mux_connect,
|
||||||
|
mux_tcp_connect,
|
||||||
|
try_connect,
|
||||||
|
)
|
||||||
|
from donglora.discovery import (
|
||||||
|
BRIDGE_VID_PIDS,
|
||||||
|
USB_PID,
|
||||||
|
USB_VID,
|
||||||
|
USB_VID_PID,
|
||||||
|
find_port,
|
||||||
|
wait_for_device,
|
||||||
|
)
|
||||||
|
from donglora.protocol import (
|
||||||
|
CMD_TAG_GET_CONFIG,
|
||||||
|
CMD_TAG_GET_MAC,
|
||||||
|
CMD_TAG_PING,
|
||||||
|
CMD_TAG_SET_CONFIG,
|
||||||
|
CMD_TAG_START_RX,
|
||||||
|
CMD_TAG_STOP_RX,
|
||||||
|
CMD_TAG_TRANSMIT,
|
||||||
|
DEFAULT_CONFIG,
|
||||||
|
ERROR_INVALID_CONFIG,
|
||||||
|
ERROR_NO_DISPLAY,
|
||||||
|
ERROR_NOT_CONFIGURED,
|
||||||
|
ERROR_RADIO_BUSY,
|
||||||
|
ERROR_TX_TIMEOUT,
|
||||||
|
MAX_PAYLOAD,
|
||||||
|
PREAMBLE_DEFAULT,
|
||||||
|
RADIO_CONFIG_SIZE,
|
||||||
|
RESP_TAG_CONFIG,
|
||||||
|
RESP_TAG_ERROR,
|
||||||
|
RESP_TAG_MAC_ADDRESS,
|
||||||
|
RESP_TAG_OK,
|
||||||
|
RESP_TAG_PONG,
|
||||||
|
RESP_TAG_RX_PACKET,
|
||||||
|
RESP_TAG_TX_DONE,
|
||||||
|
TX_POWER_MAX,
|
||||||
|
Bandwidth,
|
||||||
|
ErrorCode,
|
||||||
|
RadioConfig,
|
||||||
|
decode_config,
|
||||||
|
decode_response,
|
||||||
|
encode_command,
|
||||||
|
encode_config,
|
||||||
|
)
|
||||||
|
from donglora.transport import MuxConnection
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"BRIDGE_VID_PIDS",
|
||||||
|
"CMD_TAG_GET_CONFIG",
|
||||||
|
"CMD_TAG_GET_MAC",
|
||||||
|
# tag constants
|
||||||
|
"CMD_TAG_PING",
|
||||||
|
"CMD_TAG_SET_CONFIG",
|
||||||
|
"CMD_TAG_START_RX",
|
||||||
|
"CMD_TAG_STOP_RX",
|
||||||
|
"CMD_TAG_TRANSMIT",
|
||||||
|
"DEFAULT_CONFIG",
|
||||||
|
# connect
|
||||||
|
"DEFAULT_TIMEOUT",
|
||||||
|
# error constants
|
||||||
|
"ERROR_INVALID_CONFIG",
|
||||||
|
"ERROR_NOT_CONFIGURED",
|
||||||
|
"ERROR_NO_DISPLAY",
|
||||||
|
"ERROR_RADIO_BUSY",
|
||||||
|
"ERROR_TX_TIMEOUT",
|
||||||
|
# client
|
||||||
|
"HANDSHAKE_TIMEOUT",
|
||||||
|
# protocol
|
||||||
|
"MAX_PAYLOAD",
|
||||||
|
"PREAMBLE_DEFAULT",
|
||||||
|
"RADIO_CONFIG_SIZE",
|
||||||
|
"RESP_TAG_CONFIG",
|
||||||
|
"RESP_TAG_ERROR",
|
||||||
|
"RESP_TAG_MAC_ADDRESS",
|
||||||
|
"RESP_TAG_OK",
|
||||||
|
"RESP_TAG_PONG",
|
||||||
|
"RESP_TAG_RX_PACKET",
|
||||||
|
"RESP_TAG_TX_DONE",
|
||||||
|
"TX_POWER_MAX",
|
||||||
|
"USB_PID",
|
||||||
|
# discovery
|
||||||
|
"USB_VID",
|
||||||
|
"USB_VID_PID",
|
||||||
|
"Bandwidth",
|
||||||
|
"ErrorCode",
|
||||||
|
# transport
|
||||||
|
"MuxConnection",
|
||||||
|
"RadioConfig",
|
||||||
|
# codec
|
||||||
|
"cobs_encode",
|
||||||
|
"connect",
|
||||||
|
"connect_default",
|
||||||
|
"connect_mux_auto",
|
||||||
|
"decode_config",
|
||||||
|
"decode_response",
|
||||||
|
"default_socket_path",
|
||||||
|
"drain_rx",
|
||||||
|
"encode_command",
|
||||||
|
"encode_config",
|
||||||
|
"find_port",
|
||||||
|
"mux_connect",
|
||||||
|
"mux_tcp_connect",
|
||||||
|
"read_frame",
|
||||||
|
"recv",
|
||||||
|
"send",
|
||||||
|
"try_connect",
|
||||||
|
"validate",
|
||||||
|
"wait_for_device",
|
||||||
|
]
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
"""High-level DongLoRa client helpers.
|
||||||
|
|
||||||
|
Mirrors the Rust client's ``client.rs``. Provides :func:`send`,
|
||||||
|
:func:`recv`, :func:`drain_rx`, and :func:`validate` as module-level
|
||||||
|
functions that operate on any transport (serial port or mux connection).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import collections
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from donglora.codec import cobs_encode, read_frame
|
||||||
|
from donglora.protocol import decode_response, encode_command
|
||||||
|
|
||||||
|
RX_BUFFER_CAP: int = 256
|
||||||
|
"""Maximum number of buffered RxPackets before oldest are dropped."""
|
||||||
|
|
||||||
|
MAX_UNSOLICITED_BEFORE_TIMEOUT: int = 50
|
||||||
|
"""Safety bound: max frames to read while waiting for a solicited response."""
|
||||||
|
|
||||||
|
HANDSHAKE_TIMEOUT: float = 0.2
|
||||||
|
"""Timeout (seconds) for the ping-on-connect handshake."""
|
||||||
|
|
||||||
|
_rx_queue: collections.deque[dict[str, Any]] = collections.deque(maxlen=RX_BUFFER_CAP)
|
||||||
|
"""Buffer for RxPacket frames encountered while waiting for solicited responses."""
|
||||||
|
|
||||||
|
|
||||||
|
def validate(ser: Any) -> None:
|
||||||
|
"""Ping the device and verify the Pong response.
|
||||||
|
|
||||||
|
Uses a short timeout to fail fast on non-DongLoRa devices. Called
|
||||||
|
automatically by the ``connect`` family of functions.
|
||||||
|
|
||||||
|
Raises :class:`ConnectionError` if the device does not respond.
|
||||||
|
"""
|
||||||
|
saved = ser.timeout
|
||||||
|
ser.timeout = HANDSHAKE_TIMEOUT
|
||||||
|
try:
|
||||||
|
resp = send(ser, "Ping")
|
||||||
|
if resp["type"] != "Pong":
|
||||||
|
raise ConnectionError(f"device did not respond to ping — got {resp['type']}")
|
||||||
|
except ConnectionError:
|
||||||
|
raise
|
||||||
|
except Exception as exc:
|
||||||
|
raise ConnectionError("device did not respond to ping — not a DongLoRa device") from exc
|
||||||
|
finally:
|
||||||
|
ser.timeout = saved
|
||||||
|
|
||||||
|
|
||||||
|
def send(ser: Any, cmd: str, **kwargs: Any) -> dict[str, Any]:
|
||||||
|
"""Send a command and return the solicited response.
|
||||||
|
|
||||||
|
Any unsolicited ``RxPacket`` frames encountered while waiting are
|
||||||
|
buffered in the module-level receive queue (retrievable via
|
||||||
|
:func:`recv` / :func:`drain_rx`).
|
||||||
|
"""
|
||||||
|
ser.write(cobs_encode(encode_command(cmd, **kwargs)))
|
||||||
|
ser.flush()
|
||||||
|
for _ in range(MAX_UNSOLICITED_BEFORE_TIMEOUT):
|
||||||
|
data = read_frame(ser)
|
||||||
|
if data is None:
|
||||||
|
return {"type": "Timeout"}
|
||||||
|
resp = decode_response(data)
|
||||||
|
if resp["type"] == "RxPacket":
|
||||||
|
_rx_queue.append(resp)
|
||||||
|
continue
|
||||||
|
return resp
|
||||||
|
return {"type": "Timeout"}
|
||||||
|
|
||||||
|
|
||||||
|
def recv(ser: Any) -> dict[str, Any] | None:
|
||||||
|
"""Return the next RxPacket from the buffer or the wire.
|
||||||
|
|
||||||
|
Returns ``None`` on timeout (no packet available).
|
||||||
|
"""
|
||||||
|
if _rx_queue:
|
||||||
|
return _rx_queue.popleft()
|
||||||
|
data = read_frame(ser)
|
||||||
|
if data is None:
|
||||||
|
return None
|
||||||
|
resp = decode_response(data)
|
||||||
|
if resp["type"] == "RxPacket":
|
||||||
|
return resp
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def drain_rx(ser: Any) -> list[dict[str, Any]]:
|
||||||
|
"""Drain all buffered and pending RxPacket frames.
|
||||||
|
|
||||||
|
Temporarily reduces the read timeout to quickly drain any frames
|
||||||
|
still in flight, then restores the original timeout.
|
||||||
|
"""
|
||||||
|
packets: list[dict[str, Any]] = list(_rx_queue)
|
||||||
|
_rx_queue.clear()
|
||||||
|
old_timeout = ser.timeout
|
||||||
|
ser.timeout = 0.01
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
data = read_frame(ser)
|
||||||
|
if data is None:
|
||||||
|
break
|
||||||
|
resp = decode_response(data)
|
||||||
|
if resp["type"] == "RxPacket":
|
||||||
|
packets.append(resp)
|
||||||
|
finally:
|
||||||
|
ser.timeout = old_timeout
|
||||||
|
return packets
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
"""COBS framing for the DongLoRa USB protocol.
|
||||||
|
|
||||||
|
Mirrors the Rust client's ``codec.rs``. Frames are COBS-encoded with a
|
||||||
|
``0x00`` sentinel byte.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Protocol, runtime_checkable
|
||||||
|
|
||||||
|
from cobs import cobs
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class Readable(Protocol):
|
||||||
|
"""Anything with a blocking ``read(n)`` method (serial port, socket wrapper, etc.)."""
|
||||||
|
|
||||||
|
def read(self, n: int = 1) -> bytes: ...
|
||||||
|
|
||||||
|
|
||||||
|
def cobs_encode(data: bytes) -> bytes:
|
||||||
|
"""COBS-encode *data* and append the ``0x00`` sentinel."""
|
||||||
|
return cobs.encode(data) + b"\x00"
|
||||||
|
|
||||||
|
|
||||||
|
def read_frame(reader: Readable) -> bytes | None:
|
||||||
|
"""Read one COBS frame from *reader*.
|
||||||
|
|
||||||
|
Returns decoded payload bytes, or ``None`` on timeout (zero-length read).
|
||||||
|
"""
|
||||||
|
buf = b""
|
||||||
|
while True:
|
||||||
|
b = reader.read(1)
|
||||||
|
if not b:
|
||||||
|
return None
|
||||||
|
if b == b"\x00":
|
||||||
|
break
|
||||||
|
buf += b
|
||||||
|
if not buf:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return cobs.decode(buf)
|
||||||
|
except cobs.DecodeError:
|
||||||
|
return None
|
||||||
@@ -0,0 +1,213 @@
|
|||||||
|
"""Connection auto-detection and mux client helpers.
|
||||||
|
|
||||||
|
Mirrors the Rust client's ``connect.rs``. The :func:`connect` function
|
||||||
|
tries mux connections first (TCP via env var, then Unix socket), falling
|
||||||
|
back to direct USB serial. This matches the Rust client's ``connect()``
|
||||||
|
behaviour.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import socket
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import serial
|
||||||
|
|
||||||
|
from donglora.client import validate
|
||||||
|
from donglora.discovery import find_port, wait_for_device
|
||||||
|
from donglora.transport import MuxConnection
|
||||||
|
|
||||||
|
log = logging.getLogger("donglora")
|
||||||
|
|
||||||
|
DEFAULT_TIMEOUT: float = 2.0
|
||||||
|
"""Default read timeout for connections (seconds)."""
|
||||||
|
|
||||||
|
|
||||||
|
def default_socket_path() -> str:
|
||||||
|
"""Resolve the mux socket path in priority order.
|
||||||
|
|
||||||
|
1. ``$DONGLORA_MUX`` environment variable
|
||||||
|
2. ``$XDG_RUNTIME_DIR/donglora/mux.sock``
|
||||||
|
3. ``/tmp/donglora-mux.sock``
|
||||||
|
"""
|
||||||
|
env = os.environ.get("DONGLORA_MUX")
|
||||||
|
if env:
|
||||||
|
return env
|
||||||
|
xdg = os.environ.get("XDG_RUNTIME_DIR")
|
||||||
|
if xdg:
|
||||||
|
return os.path.join(xdg, "donglora", "mux.sock")
|
||||||
|
return "/tmp/donglora-mux.sock"
|
||||||
|
|
||||||
|
|
||||||
|
def _find_mux_socket() -> str | None:
|
||||||
|
"""Find an existing mux socket path, or ``None`` if no socket file exists."""
|
||||||
|
env = os.environ.get("DONGLORA_MUX")
|
||||||
|
if env:
|
||||||
|
return env if os.path.exists(env) else None
|
||||||
|
xdg = os.environ.get("XDG_RUNTIME_DIR")
|
||||||
|
if xdg:
|
||||||
|
p = os.path.join(xdg, "donglora", "mux.sock")
|
||||||
|
if os.path.exists(p):
|
||||||
|
return p
|
||||||
|
p = "/tmp/donglora-mux.sock"
|
||||||
|
return p if os.path.exists(p) else None
|
||||||
|
|
||||||
|
|
||||||
|
def _try_tcp_mux(addr: str, timeout: float) -> MuxConnection | None:
|
||||||
|
"""Try to connect to a TCP mux at *addr* (``[host:]port``).
|
||||||
|
|
||||||
|
Returns ``None`` on failure instead of raising.
|
||||||
|
"""
|
||||||
|
if ":" in addr:
|
||||||
|
host, _, port_str = addr.rpartition(":")
|
||||||
|
host = host or "localhost"
|
||||||
|
else:
|
||||||
|
host = "localhost"
|
||||||
|
port_str = addr
|
||||||
|
try:
|
||||||
|
port = int(port_str)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return mux_tcp_connect(host, port, timeout)
|
||||||
|
except (ConnectionRefusedError, OSError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ── Public connection functions ───────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def mux_connect(path: str | None = None, timeout: float = DEFAULT_TIMEOUT) -> MuxConnection:
|
||||||
|
"""Connect to the DongLoRa mux daemon via Unix domain socket."""
|
||||||
|
if path is None:
|
||||||
|
path = _find_mux_socket()
|
||||||
|
if path is None:
|
||||||
|
raise FileNotFoundError("no mux socket found")
|
||||||
|
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||||
|
sock.connect(path)
|
||||||
|
conn = MuxConnection(sock, timeout)
|
||||||
|
validate(conn)
|
||||||
|
return conn
|
||||||
|
|
||||||
|
|
||||||
|
def mux_tcp_connect(host: str, port: int, timeout: float = DEFAULT_TIMEOUT) -> MuxConnection:
|
||||||
|
"""Connect to the DongLoRa mux daemon via TCP."""
|
||||||
|
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||||
|
sock.connect((host, port))
|
||||||
|
conn = MuxConnection(sock, timeout)
|
||||||
|
validate(conn)
|
||||||
|
return conn
|
||||||
|
|
||||||
|
|
||||||
|
def connect(port: str | None = None, timeout: float = DEFAULT_TIMEOUT) -> Any:
|
||||||
|
"""Auto-detect and connect to a DongLoRa device.
|
||||||
|
|
||||||
|
Priority:
|
||||||
|
|
||||||
|
1. ``DONGLORA_MUX_TCP`` env var -> TCP mux connection
|
||||||
|
2. Unix socket mux (if socket file exists)
|
||||||
|
3. Direct USB serial (auto-detect by VID:PID, blocks until device appears)
|
||||||
|
|
||||||
|
If *port* is given, skips mux detection and connects directly.
|
||||||
|
|
||||||
|
.. note::
|
||||||
|
|
||||||
|
If a mux socket file exists, this function **commits** to the mux
|
||||||
|
and will raise on failure rather than falling through to USB serial.
|
||||||
|
This prevents port-stealing race conditions (matches Rust v0.2.1).
|
||||||
|
"""
|
||||||
|
# Explicit port — go direct
|
||||||
|
if port is not None:
|
||||||
|
log.debug("opening serial port %s", port)
|
||||||
|
ser = serial.Serial(port, baudrate=115200, timeout=timeout)
|
||||||
|
ser.reset_input_buffer()
|
||||||
|
validate(ser)
|
||||||
|
return ser
|
||||||
|
|
||||||
|
# Try TCP mux via environment variable
|
||||||
|
tcp = os.environ.get("DONGLORA_MUX_TCP")
|
||||||
|
if tcp:
|
||||||
|
conn = _try_tcp_mux(tcp, timeout)
|
||||||
|
if conn is not None:
|
||||||
|
log.debug("connected to TCP mux at %s", tcp)
|
||||||
|
return conn
|
||||||
|
|
||||||
|
# Try Unix socket mux — commit if socket exists (no fall-through)
|
||||||
|
sock_path = _find_mux_socket()
|
||||||
|
if sock_path is not None:
|
||||||
|
log.debug("mux socket found at %s — connecting via mux only", sock_path)
|
||||||
|
return mux_connect(sock_path, timeout)
|
||||||
|
|
||||||
|
# Direct USB serial — only reached when no mux socket exists at all
|
||||||
|
port_path = find_port()
|
||||||
|
if port_path is None:
|
||||||
|
port_path = wait_for_device()
|
||||||
|
log.debug("opening serial port %s", port_path)
|
||||||
|
ser = serial.Serial(port_path, baudrate=115200, timeout=timeout)
|
||||||
|
ser.reset_input_buffer()
|
||||||
|
validate(ser)
|
||||||
|
return ser
|
||||||
|
|
||||||
|
|
||||||
|
def connect_default() -> Any:
|
||||||
|
"""Connect with the default timeout. Convenience wrapper for ``connect(None)``."""
|
||||||
|
return connect(timeout=DEFAULT_TIMEOUT)
|
||||||
|
|
||||||
|
|
||||||
|
def connect_mux_auto(timeout: float = DEFAULT_TIMEOUT) -> Any:
|
||||||
|
"""Connect to a mux daemon only (TCP via env var, then Unix socket).
|
||||||
|
|
||||||
|
Unlike :func:`connect`, this **never** falls back to direct USB serial.
|
||||||
|
Returns an error if no mux is reachable — the caller can retry with
|
||||||
|
backoff.
|
||||||
|
|
||||||
|
This is the Python equivalent of the Rust client's "sticky mux" behaviour:
|
||||||
|
once you decide to use the mux, you stay on the mux.
|
||||||
|
"""
|
||||||
|
# Try TCP mux via environment variable
|
||||||
|
tcp = os.environ.get("DONGLORA_MUX_TCP")
|
||||||
|
if tcp:
|
||||||
|
conn = _try_tcp_mux(tcp, timeout)
|
||||||
|
if conn is not None:
|
||||||
|
log.debug("connected to TCP mux at %s", tcp)
|
||||||
|
return conn
|
||||||
|
|
||||||
|
# Try Unix socket mux
|
||||||
|
sock_path = _find_mux_socket()
|
||||||
|
if sock_path is None:
|
||||||
|
raise FileNotFoundError("no mux socket found")
|
||||||
|
return mux_connect(sock_path, timeout)
|
||||||
|
|
||||||
|
|
||||||
|
def try_connect(timeout: float = DEFAULT_TIMEOUT) -> Any:
|
||||||
|
"""Like :func:`connect` but non-blocking when no USB device is present.
|
||||||
|
|
||||||
|
Runs the same fallback chain (TCP mux, Unix socket mux, direct USB
|
||||||
|
serial) but uses a single non-blocking scan instead of polling
|
||||||
|
indefinitely for a USB device. Raises if nothing is found.
|
||||||
|
"""
|
||||||
|
# Try TCP mux via environment variable
|
||||||
|
tcp = os.environ.get("DONGLORA_MUX_TCP")
|
||||||
|
if tcp:
|
||||||
|
conn = _try_tcp_mux(tcp, timeout)
|
||||||
|
if conn is not None:
|
||||||
|
log.debug("connected to TCP mux at %s", tcp)
|
||||||
|
return conn
|
||||||
|
|
||||||
|
# Try Unix socket mux — commit if socket exists
|
||||||
|
sock_path = _find_mux_socket()
|
||||||
|
if sock_path is not None:
|
||||||
|
log.debug("mux socket found at %s — connecting via mux only", sock_path)
|
||||||
|
return mux_connect(sock_path, timeout)
|
||||||
|
|
||||||
|
# Direct USB serial — single non-blocking scan
|
||||||
|
port_path = find_port()
|
||||||
|
if port_path is None:
|
||||||
|
raise FileNotFoundError("no DongLoRa device found (no mux, no USB device)")
|
||||||
|
log.debug("opening serial port %s", port_path)
|
||||||
|
ser = serial.Serial(port_path, baudrate=115200, timeout=timeout)
|
||||||
|
ser.reset_input_buffer()
|
||||||
|
validate(ser)
|
||||||
|
return ser
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
"""USB device discovery for DongLoRa dongles.
|
||||||
|
|
||||||
|
Mirrors the Rust client's ``discovery.rs``. Scans ``/dev/ttyACM*`` and
|
||||||
|
``/dev/ttyUSB*`` by VID:PID, preferring native USB CDC-ACM over known
|
||||||
|
USB-UART bridge chips.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import glob
|
||||||
|
import logging
|
||||||
|
import subprocess
|
||||||
|
import time
|
||||||
|
|
||||||
|
log = logging.getLogger("donglora")
|
||||||
|
|
||||||
|
USB_VID: int = 0x1209
|
||||||
|
USB_PID: int = 0x5741
|
||||||
|
USB_VID_PID: str = "1209:5741"
|
||||||
|
|
||||||
|
BRIDGE_VID_PIDS: set[str] = {"10c4:ea60", "1a86:55d4", "1a86:7523", "0403:6001"}
|
||||||
|
"""Known USB-UART bridge VID:PIDs found on some board revisions."""
|
||||||
|
|
||||||
|
|
||||||
|
def find_port() -> str | None:
|
||||||
|
"""Find the DongLoRa serial port by USB VID:PID.
|
||||||
|
|
||||||
|
Checks for native USB CDC-ACM first (1209:5741), then falls back to
|
||||||
|
known USB-UART bridge chips found on some board revisions.
|
||||||
|
"""
|
||||||
|
bridge_match: str | None = None
|
||||||
|
for path in sorted(glob.glob("/dev/ttyACM*")) + sorted(glob.glob("/dev/ttyUSB*")):
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["udevadm", "info", "--query=property", f"--name={path}"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=2,
|
||||||
|
)
|
||||||
|
props = dict(line.split("=", 1) for line in result.stdout.splitlines() if "=" in line)
|
||||||
|
vid = props.get("ID_VENDOR_ID", "").lower()
|
||||||
|
pid = props.get("ID_MODEL_ID", "").lower()
|
||||||
|
vid_pid = f"{vid}:{pid}"
|
||||||
|
if vid_pid == USB_VID_PID:
|
||||||
|
return path
|
||||||
|
if bridge_match is None and vid_pid in BRIDGE_VID_PIDS:
|
||||||
|
bridge_match = path
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
if bridge_match is not None:
|
||||||
|
return bridge_match
|
||||||
|
ports = sorted(glob.glob("/dev/ttyACM*")) + sorted(glob.glob("/dev/ttyUSB*"))
|
||||||
|
return ports[0] if ports else None
|
||||||
|
|
||||||
|
|
||||||
|
def wait_for_device() -> str:
|
||||||
|
"""Block until a DongLoRa device appears, polling every 500 ms."""
|
||||||
|
log.info("waiting for DongLoRa...")
|
||||||
|
while True:
|
||||||
|
port = find_port()
|
||||||
|
if port:
|
||||||
|
log.info("found %s", port)
|
||||||
|
time.sleep(0.3)
|
||||||
|
return port
|
||||||
|
time.sleep(0.5)
|
||||||
@@ -0,0 +1,234 @@
|
|||||||
|
"""Wire protocol types and fixed-size little-endian serialization.
|
||||||
|
|
||||||
|
Mirrors the firmware's protocol and the Rust client's ``protocol.rs``
|
||||||
|
using Python types. Every integer is fixed-width LE.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import enum
|
||||||
|
import struct
|
||||||
|
from typing import Any, TypedDict
|
||||||
|
|
||||||
|
# ── Size constants ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
MAX_PAYLOAD: int = 256
|
||||||
|
"""Maximum LoRa payload size in bytes."""
|
||||||
|
|
||||||
|
RADIO_CONFIG_SIZE: int = 13
|
||||||
|
"""RadioConfig wire size (fixed)."""
|
||||||
|
|
||||||
|
TX_POWER_MAX: int = -128
|
||||||
|
"""Sentinel: set TX power to the board's maximum (i8::MIN on the wire)."""
|
||||||
|
|
||||||
|
PREAMBLE_DEFAULT: int = 0
|
||||||
|
"""Sentinel: use firmware default preamble length (16 symbols)."""
|
||||||
|
|
||||||
|
# ── Tag constants ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
CMD_TAG_PING: int = 0
|
||||||
|
CMD_TAG_GET_CONFIG: int = 1
|
||||||
|
CMD_TAG_SET_CONFIG: int = 2
|
||||||
|
CMD_TAG_START_RX: int = 3
|
||||||
|
CMD_TAG_STOP_RX: int = 4
|
||||||
|
CMD_TAG_TRANSMIT: int = 5
|
||||||
|
CMD_TAG_DISPLAY_ON: int = 6
|
||||||
|
CMD_TAG_DISPLAY_OFF: int = 7
|
||||||
|
CMD_TAG_GET_MAC: int = 8
|
||||||
|
|
||||||
|
RESP_TAG_PONG: int = 0
|
||||||
|
RESP_TAG_CONFIG: int = 1
|
||||||
|
RESP_TAG_RX_PACKET: int = 2
|
||||||
|
RESP_TAG_TX_DONE: int = 3
|
||||||
|
RESP_TAG_OK: int = 4
|
||||||
|
RESP_TAG_ERROR: int = 5
|
||||||
|
RESP_TAG_MAC_ADDRESS: int = 6
|
||||||
|
|
||||||
|
# ── Enums ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class Bandwidth(enum.IntEnum):
|
||||||
|
"""LoRa signal bandwidth."""
|
||||||
|
|
||||||
|
Khz7 = 0
|
||||||
|
Khz10 = 1
|
||||||
|
Khz15 = 2
|
||||||
|
Khz20 = 3
|
||||||
|
Khz31 = 4
|
||||||
|
Khz41 = 5
|
||||||
|
Khz62 = 6
|
||||||
|
Khz125 = 7
|
||||||
|
Khz250 = 8
|
||||||
|
Khz500 = 9
|
||||||
|
|
||||||
|
|
||||||
|
class ErrorCode(enum.IntEnum):
|
||||||
|
"""Error codes reported by the firmware."""
|
||||||
|
|
||||||
|
InvalidConfig = 0
|
||||||
|
RadioBusy = 1
|
||||||
|
TxTimeout = 2
|
||||||
|
CrcError = 3
|
||||||
|
NotConfigured = 4
|
||||||
|
NoDisplay = 5
|
||||||
|
|
||||||
|
|
||||||
|
ERROR_INVALID_CONFIG: int = ErrorCode.InvalidConfig
|
||||||
|
ERROR_RADIO_BUSY: int = ErrorCode.RadioBusy
|
||||||
|
ERROR_TX_TIMEOUT: int = ErrorCode.TxTimeout
|
||||||
|
ERROR_NOT_CONFIGURED: int = ErrorCode.NotConfigured
|
||||||
|
ERROR_NO_DISPLAY: int = ErrorCode.NoDisplay
|
||||||
|
|
||||||
|
# ── RadioConfig ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class RadioConfig(TypedDict):
|
||||||
|
"""Complete LoRa radio configuration.
|
||||||
|
|
||||||
|
Wire layout (13 bytes, all little-endian)::
|
||||||
|
|
||||||
|
[freq_hz:4] [bw:1] [sf:1] [cr:1] [sync_word:2]
|
||||||
|
[tx_power_dbm:1] [preamble_len:2] [cad:1]
|
||||||
|
"""
|
||||||
|
|
||||||
|
freq_hz: int
|
||||||
|
bw: int
|
||||||
|
sf: int
|
||||||
|
cr: int
|
||||||
|
sync_word: int
|
||||||
|
tx_power_dbm: int
|
||||||
|
preamble_len: int
|
||||||
|
cad: int
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_CONFIG: RadioConfig = {
|
||||||
|
"freq_hz": 915_000_000,
|
||||||
|
"bw": Bandwidth.Khz125,
|
||||||
|
"sf": 7,
|
||||||
|
"cr": 5,
|
||||||
|
"sync_word": 0x1424,
|
||||||
|
"tx_power_dbm": TX_POWER_MAX,
|
||||||
|
"preamble_len": PREAMBLE_DEFAULT,
|
||||||
|
"cad": 1,
|
||||||
|
}
|
||||||
|
"""Default radio config: 915 MHz, 125 kHz BW, SF7, CR 4/5, max power, CAD on."""
|
||||||
|
|
||||||
|
# ── Config wire encoding ──────────────────────────────────────────
|
||||||
|
|
||||||
|
_CONFIG_STRUCT = struct.Struct("<IBBBHBHB")
|
||||||
|
|
||||||
|
|
||||||
|
def encode_config(cfg: RadioConfig) -> bytes:
|
||||||
|
"""Encode a RadioConfig to 13 fixed-size LE bytes."""
|
||||||
|
return _CONFIG_STRUCT.pack(
|
||||||
|
cfg["freq_hz"],
|
||||||
|
cfg["bw"],
|
||||||
|
cfg["sf"],
|
||||||
|
cfg["cr"],
|
||||||
|
cfg["sync_word"],
|
||||||
|
cfg["tx_power_dbm"] & 0xFF,
|
||||||
|
cfg.get("preamble_len", PREAMBLE_DEFAULT),
|
||||||
|
cfg.get("cad", 1),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def decode_config(data: bytes) -> RadioConfig | None:
|
||||||
|
"""Decode a RadioConfig from 13 LE bytes. Returns ``None`` if too short or invalid."""
|
||||||
|
if len(data) < RADIO_CONFIG_SIZE:
|
||||||
|
return None
|
||||||
|
freq_hz, bw_raw, sf, cr, sync_word, _pwr_unsigned, preamble_len, cad = (
|
||||||
|
_CONFIG_STRUCT.unpack_from(data, 0)
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
Bandwidth(bw_raw)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
tx_power_dbm = struct.unpack_from("<b", data, 9)[0]
|
||||||
|
return {
|
||||||
|
"freq_hz": freq_hz,
|
||||||
|
"bw": bw_raw,
|
||||||
|
"sf": sf,
|
||||||
|
"cr": cr,
|
||||||
|
"sync_word": sync_word,
|
||||||
|
"tx_power_dbm": tx_power_dbm,
|
||||||
|
"preamble_len": preamble_len,
|
||||||
|
"cad": cad,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Command encoding ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
_COMMAND_TAGS: dict[str, int] = {
|
||||||
|
"Ping": CMD_TAG_PING,
|
||||||
|
"GetConfig": CMD_TAG_GET_CONFIG,
|
||||||
|
"SetConfig": CMD_TAG_SET_CONFIG,
|
||||||
|
"StartRx": CMD_TAG_START_RX,
|
||||||
|
"StopRx": CMD_TAG_STOP_RX,
|
||||||
|
"Transmit": CMD_TAG_TRANSMIT,
|
||||||
|
"DisplayOn": CMD_TAG_DISPLAY_ON,
|
||||||
|
"DisplayOff": CMD_TAG_DISPLAY_OFF,
|
||||||
|
"GetMac": CMD_TAG_GET_MAC,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def encode_command(cmd: str, **kwargs: Any) -> bytes:
|
||||||
|
"""Encode a command to fixed-size LE bytes.
|
||||||
|
|
||||||
|
Raises ``ValueError`` for unknown commands or oversized payloads.
|
||||||
|
"""
|
||||||
|
tag = _COMMAND_TAGS.get(cmd)
|
||||||
|
if tag is None:
|
||||||
|
raise ValueError(f"unknown command: {cmd!r}")
|
||||||
|
out = bytes([tag])
|
||||||
|
if cmd == "SetConfig":
|
||||||
|
out += encode_config(kwargs["config"])
|
||||||
|
elif cmd == "Transmit":
|
||||||
|
config: RadioConfig | None = kwargs.get("config")
|
||||||
|
if config is None:
|
||||||
|
out += b"\x00"
|
||||||
|
else:
|
||||||
|
out += b"\x01" + encode_config(config)
|
||||||
|
payload: bytes = kwargs["payload"]
|
||||||
|
if len(payload) > MAX_PAYLOAD:
|
||||||
|
raise ValueError(f"payload too large ({len(payload)} bytes, max {MAX_PAYLOAD})")
|
||||||
|
out += struct.pack("<H", len(payload)) + payload
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
# ── Response decoding ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def decode_response(data: bytes) -> dict[str, Any]:
|
||||||
|
"""Decode a fixed-size LE response into a dict."""
|
||||||
|
if not data:
|
||||||
|
return {"type": "Empty"}
|
||||||
|
tag = data[0]
|
||||||
|
rest = data[1:]
|
||||||
|
if tag == RESP_TAG_PONG:
|
||||||
|
return {"type": "Pong"}
|
||||||
|
if tag == RESP_TAG_CONFIG:
|
||||||
|
cfg = decode_config(rest)
|
||||||
|
if cfg is not None:
|
||||||
|
return {"type": "Config", **cfg}
|
||||||
|
return {"type": "Config", "raw": rest.hex()}
|
||||||
|
if tag == RESP_TAG_RX_PACKET:
|
||||||
|
if len(rest) < 6:
|
||||||
|
return {"type": "RxPacket", "rssi": 0, "snr": 0, "payload": b""}
|
||||||
|
rssi = struct.unpack_from("<h", rest, 0)[0]
|
||||||
|
snr = struct.unpack_from("<h", rest, 2)[0]
|
||||||
|
plen = struct.unpack_from("<H", rest, 4)[0]
|
||||||
|
payload = rest[6 : 6 + plen]
|
||||||
|
return {"type": "RxPacket", "rssi": rssi, "snr": snr, "payload": payload}
|
||||||
|
if tag == RESP_TAG_TX_DONE:
|
||||||
|
return {"type": "TxDone"}
|
||||||
|
if tag == RESP_TAG_OK:
|
||||||
|
return {"type": "Ok"}
|
||||||
|
if tag == RESP_TAG_ERROR:
|
||||||
|
code = rest[0] if rest else -1
|
||||||
|
return {"type": "Error", "code": code}
|
||||||
|
if tag == RESP_TAG_MAC_ADDRESS:
|
||||||
|
if len(rest) >= 6:
|
||||||
|
mac = ":".join(f"{b:02X}" for b in rest[:6])
|
||||||
|
return {"type": "MacAddress", "mac": mac}
|
||||||
|
return {"type": "MacAddress", "raw": rest.hex()}
|
||||||
|
return {"type": f"Unknown({tag})"}
|
||||||
Whitespace-only changes.
@@ -0,0 +1,50 @@
|
|||||||
|
"""Transport abstractions for serial and mux connections.
|
||||||
|
|
||||||
|
Mirrors the Rust client's ``transport.rs``. The :class:`MuxConnection`
|
||||||
|
class is a drop-in replacement for ``serial.Serial`` that talks to the
|
||||||
|
mux daemon over a Unix domain socket or TCP.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import socket
|
||||||
|
|
||||||
|
|
||||||
|
class MuxConnection:
|
||||||
|
"""Drop-in replacement for ``serial.Serial`` that talks to the mux daemon."""
|
||||||
|
|
||||||
|
def __init__(self, sock: socket.socket, timeout: float = 2.0) -> None:
|
||||||
|
self._sock = sock
|
||||||
|
self._timeout = timeout
|
||||||
|
self._sock.settimeout(timeout)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def timeout(self) -> float:
|
||||||
|
return self._timeout
|
||||||
|
|
||||||
|
@timeout.setter
|
||||||
|
def timeout(self, value: float) -> None:
|
||||||
|
self._timeout = value
|
||||||
|
self._sock.settimeout(value)
|
||||||
|
|
||||||
|
def read(self, n: int = 1) -> bytes:
|
||||||
|
try:
|
||||||
|
data = self._sock.recv(n)
|
||||||
|
if not data:
|
||||||
|
raise ConnectionError("mux disconnected")
|
||||||
|
return data
|
||||||
|
except TimeoutError:
|
||||||
|
return b""
|
||||||
|
|
||||||
|
def write(self, data: bytes) -> int:
|
||||||
|
self._sock.sendall(data)
|
||||||
|
return len(data)
|
||||||
|
|
||||||
|
def flush(self) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def reset_input_buffer(self) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
self._sock.close()
|
||||||
@@ -1,381 +0,0 @@
|
|||||||
"""DongLoRa host library — connect, configure, send/receive LoRa packets.
|
|
||||||
|
|
||||||
Implements the DongLoRa USB protocol (COBS-framed fixed-size LE).
|
|
||||||
See firmware/PROTOCOL.md for the full specification.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import collections
|
|
||||||
import glob
|
|
||||||
import os
|
|
||||||
import socket
|
|
||||||
import struct
|
|
||||||
import subprocess
|
|
||||||
|
|
||||||
import serial
|
|
||||||
import time
|
|
||||||
from cobs import cobs
|
|
||||||
|
|
||||||
# ── USB device discovery ──────────────────────────────────────────
|
|
||||||
|
|
||||||
USB_VID_PID = "1209:5741"
|
|
||||||
|
|
||||||
# Known USB-UART bridge VID:PIDs found on some board revisions.
|
|
||||||
BRIDGE_VID_PIDS = {"10c4:ea60", "1a86:55d4", "1a86:7523", "0403:6001"}
|
|
||||||
|
|
||||||
|
|
||||||
def find_port() -> str | None:
|
|
||||||
"""Find the DongLoRa serial port by USB VID:PID.
|
|
||||||
|
|
||||||
Checks for native USB CDC-ACM first (1209:5741), then falls back to
|
|
||||||
known USB-UART bridge chips found on some board revisions.
|
|
||||||
"""
|
|
||||||
bridge_match = None
|
|
||||||
for path in sorted(glob.glob("/dev/ttyACM*")) + sorted(glob.glob("/dev/ttyUSB*")):
|
|
||||||
try:
|
|
||||||
result = subprocess.run(
|
|
||||||
["udevadm", "info", "--query=property", f"--name={path}"],
|
|
||||||
capture_output=True, text=True, timeout=2,
|
|
||||||
)
|
|
||||||
props = dict(
|
|
||||||
line.split("=", 1) for line in result.stdout.splitlines() if "=" in line
|
|
||||||
)
|
|
||||||
vid = props.get("ID_VENDOR_ID", "").lower()
|
|
||||||
pid = props.get("ID_MODEL_ID", "").lower()
|
|
||||||
vid_pid = f"{vid}:{pid}"
|
|
||||||
if vid_pid == USB_VID_PID:
|
|
||||||
return path
|
|
||||||
if bridge_match is None and vid_pid in BRIDGE_VID_PIDS:
|
|
||||||
bridge_match = path
|
|
||||||
except Exception:
|
|
||||||
continue
|
|
||||||
if bridge_match is not None:
|
|
||||||
return bridge_match
|
|
||||||
ports = sorted(glob.glob("/dev/ttyACM*")) + sorted(glob.glob("/dev/ttyUSB*"))
|
|
||||||
return ports[0] if ports else None
|
|
||||||
|
|
||||||
|
|
||||||
def wait_for_device() -> str:
|
|
||||||
"""Block until a DongLoRa device appears."""
|
|
||||||
print("Waiting for DongLoRa...", end="", flush=True)
|
|
||||||
while True:
|
|
||||||
port = find_port()
|
|
||||||
if port:
|
|
||||||
print(f" found {port}")
|
|
||||||
time.sleep(0.3)
|
|
||||||
return port
|
|
||||||
print(".", end="", flush=True)
|
|
||||||
time.sleep(0.5)
|
|
||||||
|
|
||||||
|
|
||||||
# ── COBS framing ─────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def cobs_encode(data: bytes) -> bytes:
|
|
||||||
return cobs.encode(data) + b"\x00"
|
|
||||||
|
|
||||||
|
|
||||||
def read_frame(ser: serial.Serial) -> bytes | None:
|
|
||||||
"""Read one COBS frame. Returns decoded bytes or None on timeout."""
|
|
||||||
buf = b""
|
|
||||||
while True:
|
|
||||||
b = ser.read(1)
|
|
||||||
if not b:
|
|
||||||
return None
|
|
||||||
if b == b"\x00":
|
|
||||||
break
|
|
||||||
buf += b
|
|
||||||
if not buf:
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
return cobs.decode(buf)
|
|
||||||
except cobs.DecodeError:
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
# ── Fixed-size LE encoding ────────────────────────────────────────
|
|
||||||
|
|
||||||
def encode_config(cfg: dict) -> bytes:
|
|
||||||
"""Encode a RadioConfig to 13 fixed-size LE bytes."""
|
|
||||||
return struct.pack("<IBBBHBHB",
|
|
||||||
cfg["freq_hz"],
|
|
||||||
cfg["bw"],
|
|
||||||
cfg["sf"],
|
|
||||||
cfg["cr"],
|
|
||||||
cfg["sync_word"],
|
|
||||||
cfg["tx_power_dbm"] & 0xFF, # i8 as unsigned byte
|
|
||||||
cfg.get("preamble_len", 0), # 0 = firmware default (16)
|
|
||||||
cfg.get("cad", 1), # 0 = off, 1 = on (default)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def encode_command(cmd: str, **kwargs) -> bytes:
|
|
||||||
"""Encode a command to fixed-size LE bytes."""
|
|
||||||
tags = {
|
|
||||||
"Ping": 0, "GetConfig": 1, "SetConfig": 2, "StartRx": 3,
|
|
||||||
"StopRx": 4, "Transmit": 5, "DisplayOn": 6, "DisplayOff": 7,
|
|
||||||
"GetMac": 8,
|
|
||||||
}
|
|
||||||
out = bytes([tags[cmd]])
|
|
||||||
if cmd == "SetConfig":
|
|
||||||
out += encode_config(kwargs["config"])
|
|
||||||
elif cmd == "Transmit":
|
|
||||||
config = kwargs.get("config")
|
|
||||||
if config is None:
|
|
||||||
out += b"\x00"
|
|
||||||
else:
|
|
||||||
out += b"\x01" + encode_config(config)
|
|
||||||
payload = kwargs["payload"]
|
|
||||||
out += struct.pack("<H", len(payload)) + payload
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
# ── Response decoding ────────────────────────────────────────────
|
|
||||||
|
|
||||||
def decode_response(data: bytes) -> dict:
|
|
||||||
"""Decode a fixed-size LE response."""
|
|
||||||
if not data:
|
|
||||||
return {"type": "Empty"}
|
|
||||||
tag = data[0]
|
|
||||||
rest = data[1:]
|
|
||||||
if tag == 0:
|
|
||||||
return {"type": "Pong"}
|
|
||||||
elif tag == 1:
|
|
||||||
if len(rest) >= 13:
|
|
||||||
freq_hz, bw, sf, cr, sync_word, pwr, preamble_len, cad = struct.unpack_from("<IBBBHBHB", rest, 0)
|
|
||||||
tx_power_dbm = struct.unpack_from("<b", rest, 9)[0]
|
|
||||||
return {"type": "Config", "freq_hz": freq_hz, "bw": bw, "sf": sf,
|
|
||||||
"cr": cr, "sync_word": sync_word, "tx_power_dbm": tx_power_dbm,
|
|
||||||
"preamble_len": preamble_len, "cad": cad}
|
|
||||||
return {"type": "Config", "raw": rest.hex()}
|
|
||||||
elif tag == 2:
|
|
||||||
rssi = struct.unpack_from("<h", rest, 0)[0]
|
|
||||||
snr = struct.unpack_from("<h", rest, 2)[0]
|
|
||||||
plen = struct.unpack_from("<H", rest, 4)[0]
|
|
||||||
payload = rest[6:6 + plen]
|
|
||||||
return {"type": "RxPacket", "rssi": rssi, "snr": snr, "payload": payload}
|
|
||||||
elif tag == 3:
|
|
||||||
return {"type": "TxDone"}
|
|
||||||
elif tag == 4:
|
|
||||||
return {"type": "Ok"}
|
|
||||||
elif tag == 5:
|
|
||||||
code = rest[0] if rest else -1
|
|
||||||
return {"type": "Error", "code": code}
|
|
||||||
elif tag == 6:
|
|
||||||
if len(rest) >= 6:
|
|
||||||
mac = ":".join(f"{b:02X}" for b in rest[:6])
|
|
||||||
return {"type": "MacAddress", "mac": mac}
|
|
||||||
return {"type": "MacAddress", "raw": rest.hex()}
|
|
||||||
else:
|
|
||||||
return {"type": f"Unknown({tag})"}
|
|
||||||
|
|
||||||
|
|
||||||
# ── High-level helpers ───────────────────────────────────────────
|
|
||||||
|
|
||||||
# Buffer for RxPacket frames encountered while waiting for solicited responses.
|
|
||||||
# These are real received LoRa data — don't discard them.
|
|
||||||
_rx_queue: collections.deque = collections.deque(maxlen=256)
|
|
||||||
|
|
||||||
|
|
||||||
def send(ser: serial.Serial, cmd: str, **kwargs) -> dict:
|
|
||||||
"""Send a command and return the solicited response.
|
|
||||||
|
|
||||||
Any unsolicited RxPacket frames encountered while waiting are buffered
|
|
||||||
in the module-level receive queue (retrievable via recv/drain_rx).
|
|
||||||
"""
|
|
||||||
ser.write(cobs_encode(encode_command(cmd, **kwargs)))
|
|
||||||
ser.flush()
|
|
||||||
for _ in range(50): # safety bound — don't loop forever
|
|
||||||
data = read_frame(ser)
|
|
||||||
if data is None:
|
|
||||||
return {"type": "Timeout"}
|
|
||||||
resp = decode_response(data)
|
|
||||||
if resp["type"] == "RxPacket":
|
|
||||||
_rx_queue.append(resp)
|
|
||||||
continue
|
|
||||||
return resp
|
|
||||||
return {"type": "Timeout"}
|
|
||||||
|
|
||||||
|
|
||||||
def recv(ser: serial.Serial) -> dict | None:
|
|
||||||
"""Return the next RxPacket from the buffer or the wire.
|
|
||||||
|
|
||||||
Returns None on timeout (no packet available).
|
|
||||||
"""
|
|
||||||
if _rx_queue:
|
|
||||||
return _rx_queue.popleft()
|
|
||||||
data = read_frame(ser)
|
|
||||||
if data is None:
|
|
||||||
return None
|
|
||||||
resp = decode_response(data)
|
|
||||||
if resp["type"] == "RxPacket":
|
|
||||||
return resp
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def drain_rx(ser: serial.Serial) -> list[dict]:
|
|
||||||
"""Drain all buffered and pending RxPacket frames."""
|
|
||||||
packets = list(_rx_queue)
|
|
||||||
_rx_queue.clear()
|
|
||||||
old_timeout = ser.timeout
|
|
||||||
ser.timeout = 0.01
|
|
||||||
while True:
|
|
||||||
data = read_frame(ser)
|
|
||||||
if data is None:
|
|
||||||
break
|
|
||||||
resp = decode_response(data)
|
|
||||||
if resp["type"] == "RxPacket":
|
|
||||||
packets.append(resp)
|
|
||||||
ser.timeout = old_timeout
|
|
||||||
return packets
|
|
||||||
|
|
||||||
|
|
||||||
# ── Mux client support ──────────────────────────────────────────
|
|
||||||
|
|
||||||
class MuxConnection:
|
|
||||||
"""Drop-in replacement for serial.Serial that talks to the mux daemon."""
|
|
||||||
|
|
||||||
def __init__(self, sock: socket.socket, timeout: float = 2.0):
|
|
||||||
self._sock = sock
|
|
||||||
self._timeout = timeout
|
|
||||||
self._sock.settimeout(timeout)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def timeout(self) -> float:
|
|
||||||
return self._timeout
|
|
||||||
|
|
||||||
@timeout.setter
|
|
||||||
def timeout(self, value: float) -> None:
|
|
||||||
self._timeout = value
|
|
||||||
self._sock.settimeout(value)
|
|
||||||
|
|
||||||
def read(self, n: int = 1) -> bytes:
|
|
||||||
try:
|
|
||||||
data = self._sock.recv(n)
|
|
||||||
if not data:
|
|
||||||
raise ConnectionError("mux disconnected")
|
|
||||||
return data
|
|
||||||
except socket.timeout:
|
|
||||||
return b""
|
|
||||||
|
|
||||||
def write(self, data: bytes) -> int:
|
|
||||||
self._sock.sendall(data)
|
|
||||||
return len(data)
|
|
||||||
|
|
||||||
def flush(self) -> None:
|
|
||||||
pass
|
|
||||||
|
|
||||||
def reset_input_buffer(self) -> None:
|
|
||||||
pass
|
|
||||||
|
|
||||||
def close(self) -> None:
|
|
||||||
self._sock.close()
|
|
||||||
|
|
||||||
|
|
||||||
def _mux_socket_path() -> str | None:
|
|
||||||
"""Resolve the mux socket path, or None if no socket exists."""
|
|
||||||
env = os.environ.get("DONGLORA_MUX")
|
|
||||||
if env:
|
|
||||||
return env if os.path.exists(env) else None
|
|
||||||
xdg = os.environ.get("XDG_RUNTIME_DIR")
|
|
||||||
if xdg:
|
|
||||||
p = os.path.join(xdg, "donglora", "mux.sock")
|
|
||||||
if os.path.exists(p):
|
|
||||||
return p
|
|
||||||
p = "/tmp/donglora-mux.sock"
|
|
||||||
return p if os.path.exists(p) else None
|
|
||||||
|
|
||||||
|
|
||||||
def mux_connect(path: str | None = None, timeout: float = 2.0) -> MuxConnection:
|
|
||||||
"""Connect to the DongLoRa mux daemon via Unix socket."""
|
|
||||||
if path is None:
|
|
||||||
path = _mux_socket_path()
|
|
||||||
if path is None:
|
|
||||||
raise FileNotFoundError("No mux socket found")
|
|
||||||
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
|
||||||
sock.connect(path)
|
|
||||||
return MuxConnection(sock, timeout)
|
|
||||||
|
|
||||||
|
|
||||||
def mux_tcp_connect(host: str, port: int, timeout: float = 2.0) -> MuxConnection:
|
|
||||||
"""Connect to the DongLoRa mux daemon via TCP."""
|
|
||||||
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
||||||
sock.connect((host, port))
|
|
||||||
return MuxConnection(sock, timeout)
|
|
||||||
|
|
||||||
|
|
||||||
_mux_mode: tuple | None = None # ("unix",) or ("tcp", host, port) once sticky
|
|
||||||
|
|
||||||
|
|
||||||
def _reconnect_mux(timeout: float) -> MuxConnection:
|
|
||||||
"""Retry the mux connection until it comes back."""
|
|
||||||
assert _mux_mode is not None
|
|
||||||
print("Waiting for mux...", end="", flush=True)
|
|
||||||
while True:
|
|
||||||
try:
|
|
||||||
if _mux_mode[0] == "unix":
|
|
||||||
return mux_connect(timeout=timeout)
|
|
||||||
else:
|
|
||||||
return mux_tcp_connect(_mux_mode[1], _mux_mode[2], timeout)
|
|
||||||
except (FileNotFoundError, ConnectionRefusedError, OSError):
|
|
||||||
print(".", end="", flush=True)
|
|
||||||
time.sleep(1)
|
|
||||||
|
|
||||||
|
|
||||||
def connect(port: str | None = None, timeout: float = 2.0) -> serial.Serial | MuxConnection:
|
|
||||||
"""Open a connection to a DongLoRa device.
|
|
||||||
|
|
||||||
Priority: DONGLORA_MUX_TCP env var → Unix socket mux → direct USB serial.
|
|
||||||
Once connected via mux, subsequent calls only retry mux (never steals USB).
|
|
||||||
"""
|
|
||||||
global _mux_mode
|
|
||||||
|
|
||||||
# Sticky mux — if we connected via mux before, only retry mux
|
|
||||||
if _mux_mode is not None and port is None:
|
|
||||||
return _reconnect_mux(timeout)
|
|
||||||
|
|
||||||
if port is None:
|
|
||||||
# Try local Unix socket mux
|
|
||||||
sock_path = _mux_socket_path()
|
|
||||||
if sock_path is not None:
|
|
||||||
try:
|
|
||||||
conn = mux_connect(sock_path, timeout=timeout)
|
|
||||||
_mux_mode = ("unix",)
|
|
||||||
return conn
|
|
||||||
except (ConnectionRefusedError, OSError):
|
|
||||||
pass # stale socket — fall through
|
|
||||||
|
|
||||||
# Try TCP mux (e.g. remote access over Tailscale)
|
|
||||||
tcp = os.environ.get("DONGLORA_MUX_TCP")
|
|
||||||
if tcp:
|
|
||||||
try:
|
|
||||||
host, _, p = tcp.rpartition(":")
|
|
||||||
host = host or "localhost"
|
|
||||||
conn = mux_tcp_connect(host, int(p), timeout)
|
|
||||||
_mux_mode = ("tcp", host, int(p))
|
|
||||||
return conn
|
|
||||||
except (ConnectionRefusedError, OSError):
|
|
||||||
pass # mux not reachable — fall through
|
|
||||||
|
|
||||||
# No mux available — direct USB serial
|
|
||||||
if port is None:
|
|
||||||
port = wait_for_device()
|
|
||||||
ser = serial.Serial(port, baudrate=115200, timeout=timeout)
|
|
||||||
ser.reset_input_buffer()
|
|
||||||
return ser
|
|
||||||
|
|
||||||
|
|
||||||
# Sentinel: set TX power to the board's maximum
|
|
||||||
TX_POWER_MAX = -128 # i8::MIN on the wire
|
|
||||||
|
|
||||||
# Sentinel: use firmware default preamble length (16 symbols)
|
|
||||||
PREAMBLE_DEFAULT = 0
|
|
||||||
|
|
||||||
# Default radio config: 915 MHz, 125 kHz BW, SF7, CR 4/5, max power, default preamble
|
|
||||||
DEFAULT_CONFIG = {
|
|
||||||
"freq_hz": 915_000_000,
|
|
||||||
"bw": 7, # 125 kHz
|
|
||||||
"sf": 7,
|
|
||||||
"cr": 5, # CR 4/5
|
|
||||||
"sync_word": 0x1424,
|
|
||||||
"tx_power_dbm": TX_POWER_MAX,
|
|
||||||
"preamble_len": PREAMBLE_DEFAULT,
|
|
||||||
}
|
|
||||||
@@ -6,8 +6,8 @@ default: check
|
|||||||
_ensure_tools:
|
_ensure_tools:
|
||||||
@mise trust --yes . 2>/dev/null; mise install --quiet
|
@mise trust --yes . 2>/dev/null; mise install --quiet
|
||||||
|
|
||||||
# Run all checks (fmt, lint)
|
# Run all checks (fmt, lint, test)
|
||||||
check: fmt-check lint
|
check: fmt-check lint test
|
||||||
|
|
||||||
# Format code
|
# Format code
|
||||||
fmt: _ensure_tools
|
fmt: _ensure_tools
|
||||||
@@ -20,3 +20,7 @@ fmt-check: _ensure_tools
|
|||||||
# Lint
|
# Lint
|
||||||
lint: _ensure_tools
|
lint: _ensure_tools
|
||||||
@uv run ruff check .
|
@uv run ruff check .
|
||||||
|
|
||||||
|
# Run tests
|
||||||
|
test: _ensure_tools
|
||||||
|
@uv run pytest -v
|
||||||
+30
-5
@@ -1,13 +1,20 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "donglora-python"
|
name = "donglora"
|
||||||
version = "0.1.0"
|
version = "0.2.0"
|
||||||
description = "DongLoRa host library — talk LoRa from Python over USB"
|
description = "DongLoRa host library — talk LoRa from Python over USB"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
requires-python = ">=3.10"
|
requires-python = ">=3.10"
|
||||||
authors = [{ name = "Stephen Waits" }]
|
readme = "README.md"
|
||||||
keywords = ["lora", "radio", "usb", "embedded"]
|
authors = [{ name = "Stephen Waits", email = "steve@waits.net" }]
|
||||||
|
keywords = ["lora", "radio", "usb", "embedded", "serial"]
|
||||||
classifiers = [
|
classifiers = [
|
||||||
"Development Status :: 3 - Alpha",
|
"Development Status :: 3 - Alpha",
|
||||||
|
"License :: OSI Approved :: MIT License",
|
||||||
|
"Programming Language :: Python :: 3",
|
||||||
|
"Programming Language :: Python :: 3.10",
|
||||||
|
"Programming Language :: Python :: 3.11",
|
||||||
|
"Programming Language :: Python :: 3.12",
|
||||||
|
"Programming Language :: Python :: 3.13",
|
||||||
"Topic :: Communications :: Ham Radio",
|
"Topic :: Communications :: Ham Radio",
|
||||||
"Topic :: System :: Hardware",
|
"Topic :: System :: Hardware",
|
||||||
]
|
]
|
||||||
@@ -20,9 +27,27 @@ dependencies = [
|
|||||||
meshcore = ["pycryptodome", "pynacl"]
|
meshcore = ["pycryptodome", "pynacl"]
|
||||||
orac = ["pycryptodome", "pynacl", "anthropic"]
|
orac = ["pycryptodome", "pynacl", "anthropic"]
|
||||||
|
|
||||||
|
[project.urls]
|
||||||
|
Repository = "https://github.com/donglora/client-py"
|
||||||
|
Homepage = "https://github.com/donglora/client-py"
|
||||||
|
|
||||||
[dependency-groups]
|
[dependency-groups]
|
||||||
dev = ["ruff"]
|
dev = ["ruff", "pytest", "pytest-cov"]
|
||||||
|
|
||||||
[build-system]
|
[build-system]
|
||||||
requires = ["hatchling"]
|
requires = ["hatchling"]
|
||||||
build-backend = "hatchling.build"
|
build-backend = "hatchling.build"
|
||||||
|
|
||||||
|
[tool.ruff]
|
||||||
|
line-length = 100
|
||||||
|
target-version = "py310"
|
||||||
|
|
||||||
|
[tool.ruff.lint]
|
||||||
|
select = ["E", "W", "F", "I", "UP", "B", "C4", "ARG", "SIM", "RUF"]
|
||||||
|
ignore = ["E501"]
|
||||||
|
|
||||||
|
[tool.ruff.lint.isort]
|
||||||
|
known-first-party = ["donglora"]
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
testpaths = ["tests"]
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
"""Tests for donglora.client — send/recv/drain_rx/validate."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import struct
|
||||||
|
from io import BytesIO
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from donglora.client import _rx_queue, drain_rx, recv, send, validate
|
||||||
|
from donglora.codec import cobs_encode
|
||||||
|
|
||||||
|
|
||||||
|
def _make_mock(responses: list[bytes]) -> Any:
|
||||||
|
"""Create a mock transport that returns pre-encoded COBS frames."""
|
||||||
|
wire = b""
|
||||||
|
for resp in responses:
|
||||||
|
wire += cobs_encode(resp)
|
||||||
|
|
||||||
|
class Mock:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._stream = BytesIO(wire)
|
||||||
|
self.timeout = 2.0
|
||||||
|
self._written: list[bytes] = []
|
||||||
|
|
||||||
|
def read(self, n: int = 1) -> bytes:
|
||||||
|
return self._stream.read(n)
|
||||||
|
|
||||||
|
def write(self, data: bytes) -> int:
|
||||||
|
self._written.append(data)
|
||||||
|
return len(data)
|
||||||
|
|
||||||
|
def flush(self) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return Mock()
|
||||||
|
|
||||||
|
|
||||||
|
def _pong() -> bytes:
|
||||||
|
return bytes([0])
|
||||||
|
|
||||||
|
|
||||||
|
def _ok() -> bytes:
|
||||||
|
return bytes([4])
|
||||||
|
|
||||||
|
|
||||||
|
def _rx_packet(payload: bytes, rssi: int = -80, snr: int = 10) -> bytes:
|
||||||
|
return (
|
||||||
|
bytes([2])
|
||||||
|
+ struct.pack("<h", rssi)
|
||||||
|
+ struct.pack("<h", snr)
|
||||||
|
+ struct.pack("<H", len(payload))
|
||||||
|
+ payload
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestSend:
|
||||||
|
def setup_method(self) -> None:
|
||||||
|
_rx_queue.clear()
|
||||||
|
|
||||||
|
def test_basic_command(self) -> None:
|
||||||
|
mock = _make_mock([_pong()])
|
||||||
|
resp = send(mock, "Ping")
|
||||||
|
assert resp["type"] == "Pong"
|
||||||
|
|
||||||
|
def test_buffers_rx_packets(self) -> None:
|
||||||
|
mock = _make_mock([_rx_packet(b"pkt1"), _rx_packet(b"pkt2"), _ok()])
|
||||||
|
resp = send(mock, "StartRx")
|
||||||
|
assert resp["type"] == "Ok"
|
||||||
|
assert len(_rx_queue) == 2
|
||||||
|
|
||||||
|
def test_timeout(self) -> None:
|
||||||
|
mock = _make_mock([])
|
||||||
|
resp = send(mock, "Ping")
|
||||||
|
assert resp["type"] == "Timeout"
|
||||||
|
|
||||||
|
|
||||||
|
class TestRecv:
|
||||||
|
def setup_method(self) -> None:
|
||||||
|
_rx_queue.clear()
|
||||||
|
|
||||||
|
def test_from_buffer(self) -> None:
|
||||||
|
_rx_queue.append({"type": "RxPacket", "rssi": -80, "snr": 10, "payload": b"buf"})
|
||||||
|
mock = _make_mock([])
|
||||||
|
pkt = recv(mock)
|
||||||
|
assert pkt is not None
|
||||||
|
assert pkt["payload"] == b"buf"
|
||||||
|
|
||||||
|
def test_from_wire(self) -> None:
|
||||||
|
mock = _make_mock([_rx_packet(b"wire")])
|
||||||
|
pkt = recv(mock)
|
||||||
|
assert pkt is not None
|
||||||
|
assert pkt["payload"] == b"wire"
|
||||||
|
|
||||||
|
def test_timeout(self) -> None:
|
||||||
|
mock = _make_mock([])
|
||||||
|
assert recv(mock) is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestDrainRx:
|
||||||
|
def setup_method(self) -> None:
|
||||||
|
_rx_queue.clear()
|
||||||
|
|
||||||
|
def test_drains_buffer_and_wire(self) -> None:
|
||||||
|
_rx_queue.append({"type": "RxPacket", "rssi": 0, "snr": 0, "payload": b"buf"})
|
||||||
|
mock = _make_mock([_rx_packet(b"wire")])
|
||||||
|
packets = drain_rx(mock)
|
||||||
|
assert len(packets) == 2
|
||||||
|
assert packets[0]["payload"] == b"buf"
|
||||||
|
assert packets[1]["payload"] == b"wire"
|
||||||
|
assert len(_rx_queue) == 0
|
||||||
|
|
||||||
|
|
||||||
|
class TestValidate:
|
||||||
|
def setup_method(self) -> None:
|
||||||
|
_rx_queue.clear()
|
||||||
|
|
||||||
|
def test_success(self) -> None:
|
||||||
|
mock = _make_mock([_pong()])
|
||||||
|
validate(mock)
|
||||||
|
|
||||||
|
def test_failure(self) -> None:
|
||||||
|
mock = _make_mock([_ok()])
|
||||||
|
try:
|
||||||
|
validate(mock)
|
||||||
|
assert False, "should have raised" # noqa: B011
|
||||||
|
except ConnectionError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_timeout(self) -> None:
|
||||||
|
mock = _make_mock([])
|
||||||
|
try:
|
||||||
|
validate(mock)
|
||||||
|
assert False, "should have raised" # noqa: B011
|
||||||
|
except ConnectionError:
|
||||||
|
pass
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
"""Tests for donglora.codec — COBS framing."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from io import BytesIO
|
||||||
|
|
||||||
|
from cobs import cobs
|
||||||
|
|
||||||
|
from donglora.codec import cobs_encode, read_frame
|
||||||
|
|
||||||
|
|
||||||
|
class TestCobsEncode:
|
||||||
|
def test_basic(self) -> None:
|
||||||
|
data = b"hello"
|
||||||
|
encoded = cobs_encode(data)
|
||||||
|
assert encoded[-1:] == b"\x00"
|
||||||
|
assert cobs.decode(encoded[:-1]) == data
|
||||||
|
|
||||||
|
def test_empty(self) -> None:
|
||||||
|
encoded = cobs_encode(b"")
|
||||||
|
assert encoded[-1:] == b"\x00"
|
||||||
|
assert cobs.decode(encoded[:-1]) == b""
|
||||||
|
|
||||||
|
def test_zeros(self) -> None:
|
||||||
|
data = b"\x00\x00\x00"
|
||||||
|
encoded = cobs_encode(data)
|
||||||
|
assert b"\x00" not in encoded[:-1]
|
||||||
|
assert cobs.decode(encoded[:-1]) == data
|
||||||
|
|
||||||
|
def test_roundtrip(self) -> None:
|
||||||
|
for length in [0, 1, 10, 100, 254, 255, 256]:
|
||||||
|
data = bytes(range(length % 256)) * (length // 256 + 1)
|
||||||
|
data = data[:length]
|
||||||
|
encoded = cobs_encode(data)
|
||||||
|
decoded = cobs.decode(encoded[:-1])
|
||||||
|
assert decoded == data
|
||||||
|
|
||||||
|
|
||||||
|
class _MockSerial:
|
||||||
|
"""Minimal mock that behaves like serial.Serial for read_frame."""
|
||||||
|
|
||||||
|
def __init__(self, data: bytes) -> None:
|
||||||
|
self._stream = BytesIO(data)
|
||||||
|
|
||||||
|
def read(self, n: int = 1) -> bytes:
|
||||||
|
return self._stream.read(n)
|
||||||
|
|
||||||
|
|
||||||
|
class TestReadFrame:
|
||||||
|
def test_basic(self) -> None:
|
||||||
|
data = b"hello"
|
||||||
|
frame = cobs_encode(data)
|
||||||
|
result = read_frame(_MockSerial(frame))
|
||||||
|
assert result == data
|
||||||
|
|
||||||
|
def test_timeout(self) -> None:
|
||||||
|
result = read_frame(_MockSerial(b""))
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
def test_empty_frame(self) -> None:
|
||||||
|
result = read_frame(_MockSerial(b"\x00"))
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
def test_corrupt_frame(self) -> None:
|
||||||
|
result = read_frame(_MockSerial(b"\xff\x00"))
|
||||||
|
# cobs.decode of [0xff] may or may not raise — just verify we get bytes or None
|
||||||
|
assert result is None or isinstance(result, bytes)
|
||||||
|
|
||||||
|
def test_multiple_frames(self) -> None:
|
||||||
|
frame1 = cobs_encode(b"first")
|
||||||
|
frame2 = cobs_encode(b"second")
|
||||||
|
mock = _MockSerial(frame1 + frame2)
|
||||||
|
assert read_frame(mock) == b"first"
|
||||||
|
assert read_frame(mock) == b"second"
|
||||||
|
assert read_frame(mock) is None
|
||||||
@@ -0,0 +1,254 @@
|
|||||||
|
"""Tests for donglora.protocol — ported from client-rs/src/protocol.rs tests."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import struct
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from donglora.protocol import (
|
||||||
|
DEFAULT_CONFIG,
|
||||||
|
MAX_PAYLOAD,
|
||||||
|
PREAMBLE_DEFAULT,
|
||||||
|
RADIO_CONFIG_SIZE,
|
||||||
|
TX_POWER_MAX,
|
||||||
|
Bandwidth,
|
||||||
|
ErrorCode,
|
||||||
|
RadioConfig,
|
||||||
|
decode_config,
|
||||||
|
decode_response,
|
||||||
|
encode_command,
|
||||||
|
encode_config,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _make_config() -> RadioConfig:
|
||||||
|
return {
|
||||||
|
"freq_hz": 915_000_000,
|
||||||
|
"bw": Bandwidth.Khz125,
|
||||||
|
"sf": 7,
|
||||||
|
"cr": 5,
|
||||||
|
"sync_word": 0x3444,
|
||||||
|
"tx_power_dbm": 22,
|
||||||
|
"preamble_len": 16,
|
||||||
|
"cad": 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ── RadioConfig ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestRadioConfig:
|
||||||
|
def test_roundtrip(self) -> None:
|
||||||
|
cfg = _make_config()
|
||||||
|
encoded = encode_config(cfg)
|
||||||
|
assert len(encoded) == RADIO_CONFIG_SIZE
|
||||||
|
decoded = decode_config(encoded)
|
||||||
|
assert decoded == cfg
|
||||||
|
|
||||||
|
def test_default_roundtrip(self) -> None:
|
||||||
|
encoded = encode_config(DEFAULT_CONFIG)
|
||||||
|
decoded = decode_config(encoded)
|
||||||
|
assert decoded is not None
|
||||||
|
assert decoded["freq_hz"] == DEFAULT_CONFIG["freq_hz"]
|
||||||
|
assert decoded["bw"] == DEFAULT_CONFIG["bw"]
|
||||||
|
assert decoded["sf"] == DEFAULT_CONFIG["sf"]
|
||||||
|
assert decoded["cr"] == DEFAULT_CONFIG["cr"]
|
||||||
|
assert decoded["sync_word"] == DEFAULT_CONFIG["sync_word"]
|
||||||
|
assert decoded["tx_power_dbm"] == DEFAULT_CONFIG["tx_power_dbm"]
|
||||||
|
assert decoded["cad"] == DEFAULT_CONFIG["cad"]
|
||||||
|
|
||||||
|
def test_all_bandwidths(self) -> None:
|
||||||
|
for bw in Bandwidth:
|
||||||
|
cfg = {**_make_config(), "bw": bw}
|
||||||
|
encoded = encode_config(cfg)
|
||||||
|
decoded = decode_config(encoded)
|
||||||
|
assert decoded is not None
|
||||||
|
assert decoded["bw"] == bw
|
||||||
|
|
||||||
|
def test_invalid_bandwidth(self) -> None:
|
||||||
|
buf = bytearray(encode_config(_make_config()))
|
||||||
|
buf[4] = 255
|
||||||
|
assert decode_config(bytes(buf)) is None
|
||||||
|
|
||||||
|
def test_negative_power(self) -> None:
|
||||||
|
cfg = {**_make_config(), "tx_power_dbm": TX_POWER_MAX}
|
||||||
|
encoded = encode_config(cfg)
|
||||||
|
decoded = decode_config(encoded)
|
||||||
|
assert decoded is not None
|
||||||
|
assert decoded["tx_power_dbm"] == TX_POWER_MAX
|
||||||
|
|
||||||
|
def test_short_buffer(self) -> None:
|
||||||
|
assert decode_config(bytes(12)) is None
|
||||||
|
assert decode_config(b"") is None
|
||||||
|
|
||||||
|
|
||||||
|
# ── Command encoding ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestCommandEncoding:
|
||||||
|
def test_simple_tags(self) -> None:
|
||||||
|
assert encode_command("Ping") == bytes([0])
|
||||||
|
assert encode_command("GetConfig") == bytes([1])
|
||||||
|
assert encode_command("StartRx") == bytes([3])
|
||||||
|
assert encode_command("StopRx") == bytes([4])
|
||||||
|
assert encode_command("DisplayOn") == bytes([6])
|
||||||
|
assert encode_command("DisplayOff") == bytes([7])
|
||||||
|
assert encode_command("GetMac") == bytes([8])
|
||||||
|
|
||||||
|
def test_set_config(self) -> None:
|
||||||
|
cfg = _make_config()
|
||||||
|
cmd = encode_command("SetConfig", config=cfg)
|
||||||
|
assert cmd[0] == 2
|
||||||
|
assert len(cmd) == 1 + RADIO_CONFIG_SIZE
|
||||||
|
decoded = decode_config(cmd[1:])
|
||||||
|
assert decoded == cfg
|
||||||
|
|
||||||
|
def test_transmit_no_config(self) -> None:
|
||||||
|
cmd = encode_command("Transmit", payload=b"hello")
|
||||||
|
assert cmd[0] == 5
|
||||||
|
assert cmd[1] == 0 # has_config = false
|
||||||
|
length = struct.unpack_from("<H", cmd, 2)[0]
|
||||||
|
assert length == 5
|
||||||
|
assert cmd[4:] == b"hello"
|
||||||
|
|
||||||
|
def test_transmit_with_config(self) -> None:
|
||||||
|
cfg = _make_config()
|
||||||
|
cmd = encode_command("Transmit", config=cfg, payload=b"test")
|
||||||
|
assert cmd[0] == 5
|
||||||
|
assert cmd[1] == 1 # has_config = true
|
||||||
|
decoded = decode_config(cmd[2:])
|
||||||
|
assert decoded == cfg
|
||||||
|
offset = 2 + RADIO_CONFIG_SIZE
|
||||||
|
length = struct.unpack_from("<H", cmd, offset)[0]
|
||||||
|
assert length == 4
|
||||||
|
assert cmd[offset + 2 :] == b"test"
|
||||||
|
|
||||||
|
def test_transmit_empty_payload(self) -> None:
|
||||||
|
cmd = encode_command("Transmit", payload=b"")
|
||||||
|
assert cmd[0] == 5
|
||||||
|
length = struct.unpack_from("<H", cmd, 2)[0]
|
||||||
|
assert length == 0
|
||||||
|
|
||||||
|
def test_transmit_oversized_payload(self) -> None:
|
||||||
|
with pytest.raises(ValueError, match="payload too large"):
|
||||||
|
encode_command("Transmit", payload=bytes(MAX_PAYLOAD + 1))
|
||||||
|
|
||||||
|
def test_transmit_max_payload(self) -> None:
|
||||||
|
cmd = encode_command("Transmit", payload=bytes(MAX_PAYLOAD))
|
||||||
|
length = struct.unpack_from("<H", cmd, 2)[0]
|
||||||
|
assert length == MAX_PAYLOAD
|
||||||
|
|
||||||
|
def test_unknown_command(self) -> None:
|
||||||
|
with pytest.raises(ValueError, match="unknown command"):
|
||||||
|
encode_command("FakeCommand")
|
||||||
|
|
||||||
|
|
||||||
|
# ── Response decoding ────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestResponseDecoding:
|
||||||
|
def test_pong(self) -> None:
|
||||||
|
assert decode_response(bytes([0])) == {"type": "Pong"}
|
||||||
|
|
||||||
|
def test_config(self) -> None:
|
||||||
|
cfg = _make_config()
|
||||||
|
resp = decode_response(bytes([1]) + encode_config(cfg))
|
||||||
|
assert resp["type"] == "Config"
|
||||||
|
assert resp["freq_hz"] == cfg["freq_hz"]
|
||||||
|
assert resp["bw"] == cfg["bw"]
|
||||||
|
assert resp["sf"] == cfg["sf"]
|
||||||
|
assert resp["cr"] == cfg["cr"]
|
||||||
|
assert resp["sync_word"] == cfg["sync_word"]
|
||||||
|
assert resp["tx_power_dbm"] == cfg["tx_power_dbm"]
|
||||||
|
assert resp["preamble_len"] == cfg["preamble_len"]
|
||||||
|
assert resp["cad"] == cfg["cad"]
|
||||||
|
|
||||||
|
def test_rx_packet(self) -> None:
|
||||||
|
payload = b"data"
|
||||||
|
data = bytes([2])
|
||||||
|
data += struct.pack("<h", -80)
|
||||||
|
data += struct.pack("<h", 10)
|
||||||
|
data += struct.pack("<H", len(payload))
|
||||||
|
data += payload
|
||||||
|
resp = decode_response(data)
|
||||||
|
assert resp["type"] == "RxPacket"
|
||||||
|
assert resp["rssi"] == -80
|
||||||
|
assert resp["snr"] == 10
|
||||||
|
assert resp["payload"] == b"data"
|
||||||
|
|
||||||
|
def test_rx_packet_empty(self) -> None:
|
||||||
|
data = bytes([2])
|
||||||
|
data += struct.pack("<h", -120)
|
||||||
|
data += struct.pack("<h", -5)
|
||||||
|
data += struct.pack("<H", 0)
|
||||||
|
resp = decode_response(data)
|
||||||
|
assert resp["type"] == "RxPacket"
|
||||||
|
assert resp["payload"] == b""
|
||||||
|
|
||||||
|
def test_tx_done(self) -> None:
|
||||||
|
assert decode_response(bytes([3])) == {"type": "TxDone"}
|
||||||
|
|
||||||
|
def test_ok(self) -> None:
|
||||||
|
assert decode_response(bytes([4])) == {"type": "Ok"}
|
||||||
|
|
||||||
|
def test_error_codes(self) -> None:
|
||||||
|
for code in ErrorCode:
|
||||||
|
resp = decode_response(bytes([5, code]))
|
||||||
|
assert resp["type"] == "Error"
|
||||||
|
assert resp["code"] == code
|
||||||
|
|
||||||
|
def test_mac_address(self) -> None:
|
||||||
|
mac_bytes = bytes([0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF])
|
||||||
|
resp = decode_response(bytes([6]) + mac_bytes)
|
||||||
|
assert resp["type"] == "MacAddress"
|
||||||
|
assert resp["mac"] == "AA:BB:CC:DD:EE:FF"
|
||||||
|
|
||||||
|
def test_empty(self) -> None:
|
||||||
|
assert decode_response(b"") == {"type": "Empty"}
|
||||||
|
|
||||||
|
def test_unknown_tag(self) -> None:
|
||||||
|
resp = decode_response(bytes([255]))
|
||||||
|
assert resp["type"] == "Unknown(255)"
|
||||||
|
|
||||||
|
|
||||||
|
# ── Cross-compatibility with firmware encoding ────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestFirmwareCompatibility:
|
||||||
|
def test_worked_example(self) -> None:
|
||||||
|
"""From PROTOCOL.md: 915 MHz, 125 kHz BW, SF7, CR 4/5, sync 0x1424, max power."""
|
||||||
|
cfg: RadioConfig = {
|
||||||
|
"freq_hz": 915_000_000,
|
||||||
|
"bw": Bandwidth.Khz125,
|
||||||
|
"sf": 7,
|
||||||
|
"cr": 5,
|
||||||
|
"sync_word": 0x1424,
|
||||||
|
"tx_power_dbm": TX_POWER_MAX,
|
||||||
|
"preamble_len": PREAMBLE_DEFAULT,
|
||||||
|
"cad": 1,
|
||||||
|
}
|
||||||
|
cmd = encode_command("SetConfig", config=cfg)
|
||||||
|
expected = bytes(
|
||||||
|
[0x02, 0xC0, 0xCA, 0x89, 0x36, 0x07, 0x07, 0x05, 0x24, 0x14, 0x80, 0x00, 0x00, 0x01]
|
||||||
|
)
|
||||||
|
assert cmd == expected
|
||||||
|
|
||||||
|
|
||||||
|
# ── Enum coverage ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestEnums:
|
||||||
|
def test_bandwidth_values(self) -> None:
|
||||||
|
assert Bandwidth.Khz7 == 0
|
||||||
|
assert Bandwidth.Khz500 == 9
|
||||||
|
assert len(Bandwidth) == 10
|
||||||
|
|
||||||
|
def test_error_code_values(self) -> None:
|
||||||
|
assert ErrorCode.InvalidConfig == 0
|
||||||
|
assert ErrorCode.RadioBusy == 1
|
||||||
|
assert ErrorCode.TxTimeout == 2
|
||||||
|
assert ErrorCode.CrcError == 3
|
||||||
|
assert ErrorCode.NotConfigured == 4
|
||||||
|
assert ErrorCode.NoDisplay == 5
|
||||||
|
assert len(ErrorCode) == 6
|
||||||
@@ -149,6 +149,133 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/b6/64/390e226843cc83066acc60257e0e71ebdbce6dd915ec4f3d34101b9ac31b/cobs-1.2.2-cp314-cp314-win_amd64.whl", hash = "sha256:1f27b7a553d809487287e4ed88540b2c30ed9c9bb596173bfcf549e705af7bf0", size = 28205, upload-time = "2026-03-09T02:18:20.193Z" },
|
{ url = "https://files.pythonhosted.org/packages/b6/64/390e226843cc83066acc60257e0e71ebdbce6dd915ec4f3d34101b9ac31b/cobs-1.2.2-cp314-cp314-win_amd64.whl", hash = "sha256:1f27b7a553d809487287e4ed88540b2c30ed9c9bb596173bfcf549e705af7bf0", size = 28205, upload-time = "2026-03-09T02:18:20.193Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "colorama"
|
||||||
|
version = "0.4.6"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "coverage"
|
||||||
|
version = "7.13.5"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/9d/e0/70553e3000e345daff267cec284ce4cbf3fc141b6da229ac52775b5428f1/coverage-7.13.5.tar.gz", hash = "sha256:c81f6515c4c40141f83f502b07bbfa5c240ba25bbe73da7b33f1e5b6120ff179", size = 915967, upload-time = "2026-03-17T10:33:18.341Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/69/33/e8c48488c29a73fd089f9d71f9653c1be7478f2ad6b5bc870db11a55d23d/coverage-7.13.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e0723d2c96324561b9aa76fb982406e11d93cdb388a7a7da2b16e04719cf7ca5", size = 219255, upload-time = "2026-03-17T10:29:51.081Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/da/bd/b0ebe9f677d7f4b74a3e115eec7ddd4bcf892074963a00d91e8b164a6386/coverage-7.13.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:52f444e86475992506b32d4e5ca55c24fc88d73bcbda0e9745095b28ef4dc0cf", size = 219772, upload-time = "2026-03-17T10:29:52.867Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/48/cc/5cb9502f4e01972f54eedd48218bb203fe81e294be606a2bc93970208013/coverage-7.13.5-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:704de6328e3d612a8f6c07000a878ff38181ec3263d5a11da1db294fa6a9bdf8", size = 246532, upload-time = "2026-03-17T10:29:54.688Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7d/d8/3217636d86c7e7b12e126e4f30ef1581047da73140614523af7495ed5f2d/coverage-7.13.5-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a1a6d79a14e1ec1832cabc833898636ad5f3754a678ef8bb4908515208bf84f4", size = 248333, upload-time = "2026-03-17T10:29:56.221Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2b/30/2002ac6729ba2d4357438e2ed3c447ad8562866c8c63fc16f6dfc33afe56/coverage-7.13.5-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:79060214983769c7ba3f0cee10b54c97609dca4d478fa1aa32b914480fd5738d", size = 250211, upload-time = "2026-03-17T10:29:57.938Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6c/85/552496626d6b9359eb0e2f86f920037c9cbfba09b24d914c6e1528155f7d/coverage-7.13.5-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:356e76b46783a98c2a2fe81ec79df4883a1e62895ea952968fb253c114e7f930", size = 252125, upload-time = "2026-03-17T10:29:59.388Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/44/21/40256eabdcbccdb6acf6b381b3016a154399a75fe39d406f790ae84d1f3c/coverage-7.13.5-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0cef0cdec915d11254a7f549c1170afecce708d30610c6abdded1f74e581666d", size = 247219, upload-time = "2026-03-17T10:30:01.199Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b1/e8/96e2a6c3f21a0ea77d7830b254a1542d0328acc8d7bdf6a284ba7e529f77/coverage-7.13.5-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:dc022073d063b25a402454e5712ef9e007113e3a676b96c5f29b2bda29352f40", size = 248248, upload-time = "2026-03-17T10:30:03.317Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/da/ba/8477f549e554827da390ec659f3c38e4b6d95470f4daafc2d8ff94eaa9c2/coverage-7.13.5-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:9b74db26dfea4f4e50d48a4602207cd1e78be33182bc9cbf22da94f332f99878", size = 246254, upload-time = "2026-03-17T10:30:04.832Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/55/59/bc22aef0e6aa179d5b1b001e8b3654785e9adf27ef24c93dc4228ebd5d68/coverage-7.13.5-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:ad146744ca4fd09b50c482650e3c1b1f4dfa1d4792e0a04a369c7f23336f0400", size = 250067, upload-time = "2026-03-17T10:30:06.535Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/de/1b/c6a023a160806a5137dca53468fd97530d6acad24a22003b1578a9c2e429/coverage-7.13.5-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:c555b48be1853fe3997c11c4bd521cdd9a9612352de01fa4508f16ec341e6fe0", size = 246521, upload-time = "2026-03-17T10:30:08.486Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2d/3f/3532c85a55aa2f899fa17c186f831cfa1aa434d88ff792a709636f64130e/coverage-7.13.5-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:7034b5c56a58ae5e85f23949d52c14aca2cfc6848a31764995b7de88f13a1ea0", size = 247126, upload-time = "2026-03-17T10:30:09.966Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/aa/2e/b9d56af4a24ef45dfbcda88e06870cb7d57b2b0bfa3a888d79b4c8debd76/coverage-7.13.5-cp310-cp310-win32.whl", hash = "sha256:eb7fdf1ef130660e7415e0253a01a7d5a88c9c4d158bcf75cbbd922fd65a5b58", size = 221860, upload-time = "2026-03-17T10:30:11.393Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9f/cc/d938417e7a4d7f0433ad4edee8bb2acdc60dc7ac5af19e2a07a048ecbee3/coverage-7.13.5-cp310-cp310-win_amd64.whl", hash = "sha256:3e1bb5f6c78feeb1be3475789b14a0f0a5b47d505bfc7267126ccbd50289999e", size = 222788, upload-time = "2026-03-17T10:30:12.886Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4b/37/d24c8f8220ff07b839b2c043ea4903a33b0f455abe673ae3c03bbdb7f212/coverage-7.13.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:66a80c616f80181f4d643b0f9e709d97bcea413ecd9631e1dedc7401c8e6695d", size = 219381, upload-time = "2026-03-17T10:30:14.68Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/35/8b/cd129b0ca4afe886a6ce9d183c44d8301acbd4ef248622e7c49a23145605/coverage-7.13.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:145ede53ccbafb297c1c9287f788d1bc3efd6c900da23bf6931b09eafc931587", size = 219880, upload-time = "2026-03-17T10:30:16.231Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/55/2f/e0e5b237bffdb5d6c530ce87cc1d413a5b7d7dfd60fb067ad6d254c35c76/coverage-7.13.5-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0672854dc733c342fa3e957e0605256d2bf5934feeac328da9e0b5449634a642", size = 250303, upload-time = "2026-03-17T10:30:17.748Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/92/be/b1afb692be85b947f3401375851484496134c5554e67e822c35f28bf2fbc/coverage-7.13.5-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ec10e2a42b41c923c2209b846126c6582db5e43a33157e9870ba9fb70dc7854b", size = 252218, upload-time = "2026-03-17T10:30:19.804Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/da/69/2f47bb6fa1b8d1e3e5d0c4be8ccb4313c63d742476a619418f85740d597b/coverage-7.13.5-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be3d4bbad9d4b037791794ddeedd7d64a56f5933a2c1373e18e9e568b9141686", size = 254326, upload-time = "2026-03-17T10:30:21.321Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d5/d0/79db81da58965bd29dabc8f4ad2a2af70611a57cba9d1ec006f072f30a54/coverage-7.13.5-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4d2afbc5cc54d286bfb54541aa50b64cdb07a718227168c87b9e2fb8f25e1743", size = 256267, upload-time = "2026-03-17T10:30:23.094Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e5/32/d0d7cc8168f91ddab44c0ce4806b969df5f5fdfdbb568eaca2dbc2a04936/coverage-7.13.5-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3ad050321264c49c2fa67bb599100456fc51d004b82534f379d16445da40fb75", size = 250430, upload-time = "2026-03-17T10:30:25.311Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4d/06/a055311d891ddbe231cd69fdd20ea4be6e3603ffebddf8704b8ca8e10a3c/coverage-7.13.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7300c8a6d13335b29bb76d7651c66af6bd8658517c43499f110ddc6717bfc209", size = 252017, upload-time = "2026-03-17T10:30:27.284Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d6/f6/d0fd2d21e29a657b5f77a2fe7082e1568158340dceb941954f776dce1b7b/coverage-7.13.5-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:eb07647a5738b89baab047f14edd18ded523de60f3b30e75c2acc826f79c839a", size = 250080, upload-time = "2026-03-17T10:30:29.481Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4e/ab/0d7fb2efc2e9a5eb7ddcc6e722f834a69b454b7e6e5888c3a8567ecffb31/coverage-7.13.5-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:9adb6688e3b53adffefd4a52d72cbd8b02602bfb8f74dcd862337182fd4d1a4e", size = 253843, upload-time = "2026-03-17T10:30:31.301Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ba/6f/7467b917bbf5408610178f62a49c0ed4377bb16c1657f689cc61470da8ce/coverage-7.13.5-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7c8d4bc913dd70b93488d6c496c77f3aff5ea99a07e36a18f865bca55adef8bd", size = 249802, upload-time = "2026-03-17T10:30:33.358Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/75/2c/1172fb689df92135f5bfbbd69fc83017a76d24ea2e2f3a1154007e2fb9f8/coverage-7.13.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0e3c426ffc4cd952f54ee9ffbdd10345709ecc78a3ecfd796a57236bfad0b9b8", size = 250707, upload-time = "2026-03-17T10:30:35.2Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/67/21/9ac389377380a07884e3b48ba7a620fcd9dbfaf1d40565facdc6b36ec9ef/coverage-7.13.5-cp311-cp311-win32.whl", hash = "sha256:259b69bb83ad9894c4b25be2528139eecba9a82646ebdda2d9db1ba28424a6bf", size = 221880, upload-time = "2026-03-17T10:30:36.775Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/af/7f/4cd8a92531253f9d7c1bbecd9fa1b472907fb54446ca768c59b531248dc5/coverage-7.13.5-cp311-cp311-win_amd64.whl", hash = "sha256:258354455f4e86e3e9d0d17571d522e13b4e1e19bf0f8596bcf9476d61e7d8a9", size = 222816, upload-time = "2026-03-17T10:30:38.891Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/12/a6/1d3f6155fb0010ca68eba7fe48ca6c9da7385058b77a95848710ecf189b1/coverage-7.13.5-cp311-cp311-win_arm64.whl", hash = "sha256:bff95879c33ec8da99fc9b6fe345ddb5be6414b41d6d1ad1c8f188d26f36e028", size = 221483, upload-time = "2026-03-17T10:30:40.463Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a0/c3/a396306ba7db865bf96fc1fb3b7fd29bcbf3d829df642e77b13555163cd6/coverage-7.13.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:460cf0114c5016fa841214ff5564aa4864f11948da9440bc97e21ad1f4ba1e01", size = 219554, upload-time = "2026-03-17T10:30:42.208Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a6/16/a68a19e5384e93f811dccc51034b1fd0b865841c390e3c931dcc4699e035/coverage-7.13.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0e223ce4b4ed47f065bfb123687686512e37629be25cc63728557ae7db261422", size = 219908, upload-time = "2026-03-17T10:30:43.906Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/29/72/20b917c6793af3a5ceb7fb9c50033f3ec7865f2911a1416b34a7cfa0813b/coverage-7.13.5-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6e3370441f4513c6252bf042b9c36d22491142385049243253c7e48398a15a9f", size = 251419, upload-time = "2026-03-17T10:30:45.545Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8c/49/cd14b789536ac6a4778c453c6a2338bc0a2fb60c5a5a41b4008328b9acc1/coverage-7.13.5-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:03ccc709a17a1de074fb1d11f217342fb0d2b1582ed544f554fc9fc3f07e95f5", size = 254159, upload-time = "2026-03-17T10:30:47.204Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9d/00/7b0edcfe64e2ed4c0340dac14a52ad0f4c9bd0b8b5e531af7d55b703db7c/coverage-7.13.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3f4818d065964db3c1c66dc0fbdac5ac692ecbc875555e13374fdbe7eedb4376", size = 255270, upload-time = "2026-03-17T10:30:48.812Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/93/89/7ffc4ba0f5d0a55c1e84ea7cee39c9fc06af7b170513d83fbf3bbefce280/coverage-7.13.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:012d5319e66e9d5a218834642d6c35d265515a62f01157a45bcc036ecf947256", size = 257538, upload-time = "2026-03-17T10:30:50.77Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/81/bd/73ddf85f93f7e6fa83e77ccecb6162d9415c79007b4bc124008a4995e4a7/coverage-7.13.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8dd02af98971bdb956363e4827d34425cb3df19ee550ef92855b0acb9c7ce51c", size = 251821, upload-time = "2026-03-17T10:30:52.5Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a0/81/278aff4e8dec4926a0bcb9486320752811f543a3ce5b602cc7a29978d073/coverage-7.13.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f08fd75c50a760c7eb068ae823777268daaf16a80b918fa58eea888f8e3919f5", size = 253191, upload-time = "2026-03-17T10:30:54.543Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/70/ee/fe1621488e2e0a58d7e94c4800f0d96f79671553488d401a612bebae324b/coverage-7.13.5-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:843ea8643cf967d1ac7e8ecd4bb00c99135adf4816c0c0593fdcc47b597fcf09", size = 251337, upload-time = "2026-03-17T10:30:56.663Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/37/a6/f79fb37aa104b562207cc23cb5711ab6793608e246cae1e93f26b2236ed9/coverage-7.13.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:9d44d7aa963820b1b971dbecd90bfe5fe8f81cff79787eb6cca15750bd2f79b9", size = 255404, upload-time = "2026-03-17T10:30:58.427Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/75/f0/ed15262a58ec81ce457ceb717b7f78752a1713556b19081b76e90896e8d4/coverage-7.13.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:7132bed4bd7b836200c591410ae7d97bf7ae8be6fc87d160b2bd881df929e7bf", size = 250903, upload-time = "2026-03-17T10:31:00.093Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0f/e9/9129958f20e7e9d4d56d51d42ccf708d15cac355ff4ac6e736e97a9393d2/coverage-7.13.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a698e363641b98843c517817db75373c83254781426e94ada3197cabbc2c919c", size = 252780, upload-time = "2026-03-17T10:31:01.916Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a4/d7/0ad9b15812d81272db94379fe4c6df8fd17781cc7671fdfa30c76ba5ff7b/coverage-7.13.5-cp312-cp312-win32.whl", hash = "sha256:bdba0a6b8812e8c7df002d908a9a2ea3c36e92611b5708633c50869e6d922fdf", size = 222093, upload-time = "2026-03-17T10:31:03.642Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/29/3d/821a9a5799fac2556bcf0bd37a70d1d11fa9e49784b6d22e92e8b2f85f18/coverage-7.13.5-cp312-cp312-win_amd64.whl", hash = "sha256:d2c87e0c473a10bffe991502eac389220533024c8082ec1ce849f4218dded810", size = 222900, upload-time = "2026-03-17T10:31:05.651Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d4/fa/2238c2ad08e35cf4f020ea721f717e09ec3152aea75d191a7faf3ef009a8/coverage-7.13.5-cp312-cp312-win_arm64.whl", hash = "sha256:bf69236a9a81bdca3bff53796237aab096cdbf8d78a66ad61e992d9dac7eb2de", size = 221515, upload-time = "2026-03-17T10:31:07.293Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/74/8c/74fedc9663dcf168b0a059d4ea756ecae4da77a489048f94b5f512a8d0b3/coverage-7.13.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5ec4af212df513e399cf11610cc27063f1586419e814755ab362e50a85ea69c1", size = 219576, upload-time = "2026-03-17T10:31:09.045Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0c/c9/44fb661c55062f0818a6ffd2685c67aa30816200d5f2817543717d4b92eb/coverage-7.13.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:941617e518602e2d64942c88ec8499f7fbd49d3f6c4327d3a71d43a1973032f3", size = 219942, upload-time = "2026-03-17T10:31:10.708Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5f/13/93419671cee82b780bab7ea96b67c8ef448f5f295f36bf5031154ec9a790/coverage-7.13.5-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:da305e9937617ee95c2e39d8ff9f040e0487cbf1ac174f777ed5eddd7a7c1f26", size = 250935, upload-time = "2026-03-17T10:31:12.392Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ac/68/1666e3a4462f8202d836920114fa7a5ee9275d1fa45366d336c551a162dd/coverage-7.13.5-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:78e696e1cc714e57e8b25760b33a8b1026b7048d270140d25dafe1b0a1ee05a3", size = 253541, upload-time = "2026-03-17T10:31:14.247Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4e/5e/3ee3b835647be646dcf3c65a7c6c18f87c27326a858f72ab22c12730773d/coverage-7.13.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:02ca0eed225b2ff301c474aeeeae27d26e2537942aa0f87491d3e147e784a82b", size = 254780, upload-time = "2026-03-17T10:31:16.193Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/44/b3/cb5bd1a04cfcc49ede6cd8409d80bee17661167686741e041abc7ee1b9a9/coverage-7.13.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:04690832cbea4e4663d9149e05dba142546ca05cb1848816760e7f58285c970a", size = 256912, upload-time = "2026-03-17T10:31:17.89Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1b/66/c1dceb7b9714473800b075f5c8a84f4588f887a90eb8645282031676e242/coverage-7.13.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0590e44dd2745c696a778f7bab6aa95256de2cbc8b8cff4f7db8ff09813d6969", size = 251165, upload-time = "2026-03-17T10:31:19.605Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b7/62/5502b73b97aa2e53ea22a39cf8649ff44827bef76d90bf638777daa27a9d/coverage-7.13.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d7cfad2d6d81dd298ab6b89fe72c3b7b05ec7544bdda3b707ddaecff8d25c161", size = 252908, upload-time = "2026-03-17T10:31:21.312Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7d/37/7792c2d69854397ca77a55c4646e5897c467928b0e27f2d235d83b5d08c6/coverage-7.13.5-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:e092b9499de38ae0fbfbc603a74660eb6ff3e869e507b50d85a13b6db9863e15", size = 250873, upload-time = "2026-03-17T10:31:23.565Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a3/23/bc866fb6163be52a8a9e5d708ba0d3b1283c12158cefca0a8bbb6e247a43/coverage-7.13.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:48c39bc4a04d983a54a705a6389512883d4a3b9862991b3617d547940e9f52b1", size = 255030, upload-time = "2026-03-17T10:31:25.58Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7d/8b/ef67e1c222ef49860701d346b8bbb70881bef283bd5f6cbba68a39a086c7/coverage-7.13.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2d3807015f138ffea1ed9afeeb8624fd781703f2858b62a8dd8da5a0994c57b6", size = 250694, upload-time = "2026-03-17T10:31:27.316Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/46/0d/866d1f74f0acddbb906db212e096dee77a8e2158ca5e6bb44729f9d93298/coverage-7.13.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ee2aa19e03161671ec964004fb74b2257805d9710bf14a5c704558b9d8dbaf17", size = 252469, upload-time = "2026-03-17T10:31:29.472Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7a/f5/be742fec31118f02ce42b21c6af187ad6a344fed546b56ca60caacc6a9a0/coverage-7.13.5-cp313-cp313-win32.whl", hash = "sha256:ce1998c0483007608c8382f4ff50164bfc5bd07a2246dd272aa4043b75e61e85", size = 222112, upload-time = "2026-03-17T10:31:31.526Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/66/40/7732d648ab9d069a46e686043241f01206348e2bbf128daea85be4d6414b/coverage-7.13.5-cp313-cp313-win_amd64.whl", hash = "sha256:631efb83f01569670a5e866ceb80fe483e7c159fac6f167e6571522636104a0b", size = 222923, upload-time = "2026-03-17T10:31:33.633Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/48/af/fea819c12a095781f6ccd504890aaddaf88b8fab263c4940e82c7b770124/coverage-7.13.5-cp313-cp313-win_arm64.whl", hash = "sha256:f4cd16206ad171cbc2470dbea9103cf9a7607d5fe8c242fdf1edf36174020664", size = 221540, upload-time = "2026-03-17T10:31:35.445Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/23/d2/17879af479df7fbbd44bd528a31692a48f6b25055d16482fdf5cdb633805/coverage-7.13.5-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0428cbef5783ad91fe240f673cc1f76b25e74bbfe1a13115e4aa30d3f538162d", size = 220262, upload-time = "2026-03-17T10:31:37.184Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5b/4c/d20e554f988c8f91d6a02c5118f9abbbf73a8768a3048cb4962230d5743f/coverage-7.13.5-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e0b216a19534b2427cc201a26c25da4a48633f29a487c61258643e89d28200c0", size = 220617, upload-time = "2026-03-17T10:31:39.245Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/29/9c/f9f5277b95184f764b24e7231e166dfdb5780a46d408a2ac665969416d61/coverage-7.13.5-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:972a9cd27894afe4bc2b1480107054e062df08e671df7c2f18c205e805ccd806", size = 261912, upload-time = "2026-03-17T10:31:41.324Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d5/f6/7f1ab39393eeb50cfe4747ae8ef0e4fc564b989225aa1152e13a180d74f8/coverage-7.13.5-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4b59148601efcd2bac8c4dbf1f0ad6391693ccf7a74b8205781751637076aee3", size = 263987, upload-time = "2026-03-17T10:31:43.724Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a0/d7/62c084fb489ed9c6fbdf57e006752e7c516ea46fd690e5ed8b8617c7d52e/coverage-7.13.5-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:505d7083c8b0c87a8fa8c07370c285847c1f77739b22e299ad75a6af6c32c5c9", size = 266416, upload-time = "2026-03-17T10:31:45.769Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a9/f6/df63d8660e1a0bff6125947afda112a0502736f470d62ca68b288ea762d8/coverage-7.13.5-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:60365289c3741e4db327e7baff2a4aaacf22f788e80fa4683393891b70a89fbd", size = 267558, upload-time = "2026-03-17T10:31:48.293Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5b/02/353ca81d36779bd108f6d384425f7139ac3c58c750dcfaafe5d0bee6436b/coverage-7.13.5-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1b88c69c8ef5d4b6fe7dea66d6636056a0f6a7527c440e890cf9259011f5e606", size = 261163, upload-time = "2026-03-17T10:31:50.125Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2c/16/2e79106d5749bcaf3aee6d309123548e3276517cd7851faa8da213bc61bf/coverage-7.13.5-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5b13955d31d1633cf9376908089b7cebe7d15ddad7aeaabcbe969a595a97e95e", size = 263981, upload-time = "2026-03-17T10:31:51.961Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/29/c7/c29e0c59ffa6942030ae6f50b88ae49988e7e8da06de7ecdbf49c6d4feae/coverage-7.13.5-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:f70c9ab2595c56f81a89620e22899eea8b212a4041bd728ac6f4a28bf5d3ddd0", size = 261604, upload-time = "2026-03-17T10:31:53.872Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/40/48/097cdc3db342f34006a308ab41c3a7c11c3f0d84750d340f45d88a782e00/coverage-7.13.5-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:084b84a8c63e8d6fc7e3931b316a9bcafca1458d753c539db82d31ed20091a87", size = 265321, upload-time = "2026-03-17T10:31:55.997Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bb/1f/4994af354689e14fd03a75f8ec85a9a68d94e0188bbdab3fc1516b55e512/coverage-7.13.5-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:ad14385487393e386e2ea988b09d62dd42c397662ac2dabc3832d71253eee479", size = 260502, upload-time = "2026-03-17T10:31:58.308Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/22/c6/9bb9ef55903e628033560885f5c31aa227e46878118b63ab15dc7ba87797/coverage-7.13.5-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:7f2c47b36fe7709a6e83bfadf4eefb90bd25fbe4014d715224c4316f808e59a2", size = 262688, upload-time = "2026-03-17T10:32:00.141Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/14/4f/f5df9007e50b15e53e01edea486814783a7f019893733d9e4d6caad75557/coverage-7.13.5-cp313-cp313t-win32.whl", hash = "sha256:67e9bc5449801fad0e5dff329499fb090ba4c5800b86805c80617b4e29809b2a", size = 222788, upload-time = "2026-03-17T10:32:02.246Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e1/98/aa7fccaa97d0f3192bec013c4e6fd6d294a6ed44b640e6bb61f479e00ed5/coverage-7.13.5-cp313-cp313t-win_amd64.whl", hash = "sha256:da86cdcf10d2519e10cabb8ac2de03da1bcb6e4853790b7fbd48523332e3a819", size = 223851, upload-time = "2026-03-17T10:32:04.416Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3d/8b/e5c469f7352651e5f013198e9e21f97510b23de957dd06a84071683b4b60/coverage-7.13.5-cp313-cp313t-win_arm64.whl", hash = "sha256:0ecf12ecb326fe2c339d93fc131816f3a7367d223db37817208905c89bded911", size = 222104, upload-time = "2026-03-17T10:32:06.65Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8e/77/39703f0d1d4b478bfd30191d3c14f53caf596fac00efb3f8f6ee23646439/coverage-7.13.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fbabfaceaeb587e16f7008f7795cd80d20ec548dc7f94fbb0d4ec2e038ce563f", size = 219621, upload-time = "2026-03-17T10:32:08.589Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e2/3e/51dff36d99ae14639a133d9b164d63e628532e2974d8b1edb99dd1ebc733/coverage-7.13.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9bb2a28101a443669a423b665939381084412b81c3f8c0fcfbac57f4e30b5b8e", size = 219953, upload-time = "2026-03-17T10:32:10.507Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6a/6c/1f1917b01eb647c2f2adc9962bd66c79eb978951cab61bdc1acab3290c07/coverage-7.13.5-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bd3a2fbc1c6cccb3c5106140d87cc6a8715110373ef42b63cf5aea29df8c217a", size = 250992, upload-time = "2026-03-17T10:32:12.41Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/22/e5/06b1f88f42a5a99df42ce61208bdec3bddb3d261412874280a19796fc09c/coverage-7.13.5-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6c36ddb64ed9d7e496028d1d00dfec3e428e0aabf4006583bb1839958d280510", size = 253503, upload-time = "2026-03-17T10:32:14.449Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/80/28/2a148a51e5907e504fa7b85490277734e6771d8844ebcc48764a15e28155/coverage-7.13.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:380e8e9084d8eb38db3a9176a1a4f3c0082c3806fa0dc882d1d87abc3c789247", size = 254852, upload-time = "2026-03-17T10:32:16.56Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/61/77/50e8d3d85cc0b7ebe09f30f151d670e302c7ff4a1bf6243f71dd8b0981fa/coverage-7.13.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e808af52a0513762df4d945ea164a24b37f2f518cbe97e03deaa0ee66139b4d6", size = 257161, upload-time = "2026-03-17T10:32:19.004Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3b/c4/b5fd1d4b7bf8d0e75d997afd3925c59ba629fc8616f1b3aae7605132e256/coverage-7.13.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e301d30dd7e95ae068671d746ba8c34e945a82682e62918e41b2679acd2051a0", size = 251021, upload-time = "2026-03-17T10:32:21.344Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f8/66/6ea21f910e92d69ef0b1c3346ea5922a51bad4446c9126db2ae96ee24c4c/coverage-7.13.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:800bc829053c80d240a687ceeb927a94fd108bbdc68dfbe505d0d75ab578a882", size = 252858, upload-time = "2026-03-17T10:32:23.506Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9e/ea/879c83cb5d61aa2a35fb80e72715e92672daef8191b84911a643f533840c/coverage-7.13.5-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:0b67af5492adb31940ee418a5a655c28e48165da5afab8c7fa6fd72a142f8740", size = 250823, upload-time = "2026-03-17T10:32:25.516Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8a/fb/616d95d3adb88b9803b275580bdeee8bd1b69a886d057652521f83d7322f/coverage-7.13.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:c9136ff29c3a91e25b1d1552b5308e53a1e0653a23e53b6366d7c2dcbbaf8a16", size = 255099, upload-time = "2026-03-17T10:32:27.944Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1c/93/25e6917c90ec1c9a56b0b26f6cad6408e5f13bb6b35d484a0d75c9cf000d/coverage-7.13.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:cff784eef7f0b8f6cb28804fbddcfa99f89efe4cc35fb5627e3ac58f91ed3ac0", size = 250638, upload-time = "2026-03-17T10:32:29.914Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fc/7b/dc1776b0464145a929deed214aef9fb1493f159b59ff3c7eeeedf91eddd0/coverage-7.13.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:68a4953be99b17ac3c23b6efbc8a38330d99680c9458927491d18700ef23ded0", size = 252295, upload-time = "2026-03-17T10:32:31.981Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ea/fb/99cbbc56a26e07762a2740713f3c8f9f3f3106e3a3dd8cc4474954bccd34/coverage-7.13.5-cp314-cp314-win32.whl", hash = "sha256:35a31f2b1578185fbe6aa2e74cea1b1d0bbf4c552774247d9160d29b80ed56cc", size = 222360, upload-time = "2026-03-17T10:32:34.233Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8d/b7/4758d4f73fb536347cc5e4ad63662f9d60ba9118cb6785e9616b2ce5d7fa/coverage-7.13.5-cp314-cp314-win_amd64.whl", hash = "sha256:2aa055ae1857258f9e0045be26a6d62bdb47a72448b62d7b55f4820f361a2633", size = 223174, upload-time = "2026-03-17T10:32:36.369Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2c/f2/24d84e1dfe70f8ac9fdf30d338239860d0d1d5da0bda528959d0ebc9da28/coverage-7.13.5-cp314-cp314-win_arm64.whl", hash = "sha256:1b11eef33edeae9d142f9b4358edb76273b3bfd30bc3df9a4f95d0e49caf94e8", size = 221739, upload-time = "2026-03-17T10:32:38.736Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/60/5b/4a168591057b3668c2428bff25dd3ebc21b629d666d90bcdfa0217940e84/coverage-7.13.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:10a0c37f0b646eaff7cce1874c31d1f1ccb297688d4c747291f4f4c70741cc8b", size = 220351, upload-time = "2026-03-17T10:32:41.196Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f5/21/1fd5c4dbfe4a58b6b99649125635df46decdfd4a784c3cd6d410d303e370/coverage-7.13.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b5db73ba3c41c7008037fa731ad5459fc3944cb7452fc0aa9f822ad3533c583c", size = 220612, upload-time = "2026-03-17T10:32:43.204Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d6/fe/2a924b3055a5e7e4512655a9d4609781b0d62334fa0140c3e742926834e2/coverage-7.13.5-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:750db93a81e3e5a9831b534be7b1229df848b2e125a604fe6651e48aa070e5f9", size = 261985, upload-time = "2026-03-17T10:32:45.514Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d7/0d/c8928f2bd518c45990fe1a2ab8db42e914ef9b726c975facc4282578c3eb/coverage-7.13.5-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9ddb4f4a5479f2539644be484da179b653273bca1a323947d48ab107b3ed1f29", size = 264107, upload-time = "2026-03-17T10:32:47.971Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ef/ae/4ae35bbd9a0af9d820362751f0766582833c211224b38665c0f8de3d487f/coverage-7.13.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d8a7a2049c14f413163e2bdabd37e41179b1d1ccb10ffc6ccc4b7a718429c607", size = 266513, upload-time = "2026-03-17T10:32:50.1Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9c/20/d326174c55af36f74eac6ae781612d9492f060ce8244b570bb9d50d9d609/coverage-7.13.5-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1c85e0b6c05c592ea6d8768a66a254bfb3874b53774b12d4c89c481eb78cb90", size = 267650, upload-time = "2026-03-17T10:32:52.391Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7a/5e/31484d62cbd0eabd3412e30d74386ece4a0837d4f6c3040a653878bfc019/coverage-7.13.5-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:777c4d1eff1b67876139d24288aaf1817f6c03d6bae9c5cc8d27b83bcfe38fe3", size = 261089, upload-time = "2026-03-17T10:32:54.544Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e9/d8/49a72d6de146eebb0b7e48cc0f4bc2c0dd858e3d4790ab2b39a2872b62bd/coverage-7.13.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6697e29b93707167687543480a40f0db8f356e86d9f67ddf2e37e2dfd91a9dab", size = 263982, upload-time = "2026-03-17T10:32:56.803Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/06/3b/0351f1bd566e6e4dd39e978efe7958bde1d32f879e85589de147654f57bb/coverage-7.13.5-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:8fdf453a942c3e4d99bd80088141c4c6960bb232c409d9c3558e2dbaa3998562", size = 261579, upload-time = "2026-03-17T10:32:59.466Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5d/ce/796a2a2f4017f554d7810f5c573449b35b1e46788424a548d4d19201b222/coverage-7.13.5-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:32ca0c0114c9834a43f045a87dcebd69d108d8ffb666957ea65aa132f50332e2", size = 265316, upload-time = "2026-03-17T10:33:01.847Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3d/16/d5ae91455541d1a78bc90abf495be600588aff8f6db5c8b0dae739fa39c9/coverage-7.13.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:8769751c10f339021e2638cd354e13adeac54004d1941119b2c96fe5276d45ea", size = 260427, upload-time = "2026-03-17T10:33:03.945Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/48/11/07f413dba62db21fb3fad5d0de013a50e073cc4e2dc4306e770360f6dfc8/coverage-7.13.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cec2d83125531bd153175354055cdb7a09987af08a9430bd173c937c6d0fba2a", size = 262745, upload-time = "2026-03-17T10:33:06.285Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/91/15/d792371332eb4663115becf4bad47e047d16234b1aff687b1b18c58d60ae/coverage-7.13.5-cp314-cp314t-win32.whl", hash = "sha256:0cd9ed7a8b181775459296e402ca4fb27db1279740a24e93b3b41942ebe4b215", size = 223146, upload-time = "2026-03-17T10:33:08.756Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/db/51/37221f59a111dca5e85be7dbf09696323b5b9f13ff65e0641d535ed06ea8/coverage-7.13.5-cp314-cp314t-win_amd64.whl", hash = "sha256:301e3b7dfefecaca37c9f1aa6f0049b7d4ab8dd933742b607765d757aca77d43", size = 224254, upload-time = "2026-03-17T10:33:11.174Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/54/83/6acacc889de8987441aa7d5adfbdbf33d288dad28704a67e574f1df9bcbb/coverage-7.13.5-cp314-cp314t-win_arm64.whl", hash = "sha256:9dacc2ad679b292709e0f5fc1ac74a6d4d5562e424058962c7bb0c658ad25e45", size = 222276, upload-time = "2026-03-17T10:33:13.466Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9e/ee/a4cf96b8ce1e566ed238f0659ac2d3f007ed1d14b181bcb684e19561a69a/coverage-7.13.5-py3-none-any.whl", hash = "sha256:34b02417cf070e173989b3db962f7ed56d2f644307b2cf9d5a0f258e13084a61", size = 211346, upload-time = "2026-03-17T10:33:15.691Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.optional-dependencies]
|
||||||
|
toml = [
|
||||||
|
{ name = "tomli", marker = "python_full_version <= '3.11'" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "distro"
|
name = "distro"
|
||||||
version = "1.9.0"
|
version = "1.9.0"
|
||||||
@@ -169,7 +296,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "donglora"
|
name = "donglora"
|
||||||
version = "0.1.0"
|
version = "0.2.0"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "cobs" },
|
{ name = "cobs" },
|
||||||
@@ -187,6 +314,13 @@ orac = [
|
|||||||
{ name = "pynacl" },
|
{ name = "pynacl" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[package.dev-dependencies]
|
||||||
|
dev = [
|
||||||
|
{ name = "pytest" },
|
||||||
|
{ name = "pytest-cov" },
|
||||||
|
{ name = "ruff" },
|
||||||
|
]
|
||||||
|
|
||||||
[package.metadata]
|
[package.metadata]
|
||||||
requires-dist = [
|
requires-dist = [
|
||||||
{ name = "anthropic", marker = "extra == 'orac'" },
|
{ name = "anthropic", marker = "extra == 'orac'" },
|
||||||
@@ -199,6 +333,13 @@ requires-dist = [
|
|||||||
]
|
]
|
||||||
provides-extras = ["meshcore", "orac"]
|
provides-extras = ["meshcore", "orac"]
|
||||||
|
|
||||||
|
[package.metadata.requires-dev]
|
||||||
|
dev = [
|
||||||
|
{ name = "pytest" },
|
||||||
|
{ name = "pytest-cov" },
|
||||||
|
{ name = "ruff" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "exceptiongroup"
|
name = "exceptiongroup"
|
||||||
version = "1.3.1"
|
version = "1.3.1"
|
||||||
@@ -257,6 +398,15 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
|
{ url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "iniconfig"
|
||||||
|
version = "2.3.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "jiter"
|
name = "jiter"
|
||||||
version = "0.13.0"
|
version = "0.13.0"
|
||||||
@@ -354,6 +504,24 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/67/8a/a342b2f0251f3dac4ca17618265d93bf244a2a4d089126e81e4c1056ac50/jiter-0.13.0-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7bb00b6d26db67a05fe3e12c76edc75f32077fb51deed13822dc648fa373bc19", size = 343768, upload-time = "2026-02-02T12:37:55.055Z" },
|
{ url = "https://files.pythonhosted.org/packages/67/8a/a342b2f0251f3dac4ca17618265d93bf244a2a4d089126e81e4c1056ac50/jiter-0.13.0-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7bb00b6d26db67a05fe3e12c76edc75f32077fb51deed13822dc648fa373bc19", size = 343768, upload-time = "2026-02-02T12:37:55.055Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "packaging"
|
||||||
|
version = "26.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pluggy"
|
||||||
|
version = "1.6.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pycparser"
|
name = "pycparser"
|
||||||
version = "3.0"
|
version = "3.0"
|
||||||
@@ -531,6 +699,15 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/36/c7/cfc8e811f061c841d7990b0201912c3556bfeb99cdcb7ed24adc8d6f8704/pydantic_core-2.41.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51", size = 2145302, upload-time = "2025-11-04T13:43:46.64Z" },
|
{ url = "https://files.pythonhosted.org/packages/36/c7/cfc8e811f061c841d7990b0201912c3556bfeb99cdcb7ed24adc8d6f8704/pydantic_core-2.41.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51", size = 2145302, upload-time = "2025-11-04T13:43:46.64Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pygments"
|
||||||
|
version = "2.20.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pynacl"
|
name = "pynacl"
|
||||||
version = "1.6.2"
|
version = "1.6.2"
|
||||||
@@ -575,6 +752,63 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/07/bc/587a445451b253b285629263eb51c2d8e9bcea4fc97826266d186f96f558/pyserial-3.5-py2.py3-none-any.whl", hash = "sha256:c4451db6ba391ca6ca299fb3ec7bae67a5c55dde170964c7a14ceefec02f2cf0", size = 90585, upload-time = "2020-11-23T03:59:13.41Z" },
|
{ url = "https://files.pythonhosted.org/packages/07/bc/587a445451b253b285629263eb51c2d8e9bcea4fc97826266d186f96f558/pyserial-3.5-py2.py3-none-any.whl", hash = "sha256:c4451db6ba391ca6ca299fb3ec7bae67a5c55dde170964c7a14ceefec02f2cf0", size = 90585, upload-time = "2020-11-23T03:59:13.41Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pytest"
|
||||||
|
version = "9.0.3"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||||
|
{ name = "exceptiongroup", marker = "python_full_version < '3.11'" },
|
||||||
|
{ name = "iniconfig" },
|
||||||
|
{ name = "packaging" },
|
||||||
|
{ name = "pluggy" },
|
||||||
|
{ name = "pygments" },
|
||||||
|
{ name = "tomli", marker = "python_full_version < '3.11'" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pytest-cov"
|
||||||
|
version = "7.1.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "coverage", extra = ["toml"] },
|
||||||
|
{ name = "pluggy" },
|
||||||
|
{ name = "pytest" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ruff"
|
||||||
|
version = "0.15.9"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/e6/97/e9f1ca355108ef7194e38c812ef40ba98c7208f47b13ad78d023caa583da/ruff-0.15.9.tar.gz", hash = "sha256:29cbb1255a9797903f6dde5ba0188c707907ff44a9006eb273b5a17bfa0739a2", size = 4617361, upload-time = "2026-04-02T18:17:20.829Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0b/1f/9cdfd0ac4b9d1e5a6cf09bedabdf0b56306ab5e333c85c87281273e7b041/ruff-0.15.9-py3-none-linux_armv6l.whl", hash = "sha256:6efbe303983441c51975c243e26dff328aca11f94b70992f35b093c2e71801e1", size = 10511206, upload-time = "2026-04-02T18:16:41.574Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3d/f6/32bfe3e9c136b35f02e489778d94384118bb80fd92c6d92e7ccd97db12ce/ruff-0.15.9-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:4965bac6ac9ea86772f4e23587746f0b7a395eccabb823eb8bfacc3fa06069f7", size = 10923307, upload-time = "2026-04-02T18:17:08.645Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ca/25/de55f52ab5535d12e7aaba1de37a84be6179fb20bddcbe71ec091b4a3243/ruff-0.15.9-py3-none-macosx_11_0_arm64.whl", hash = "sha256:eaf05aad70ca5b5a0a4b0e080df3a6b699803916d88f006efd1f5b46302daab8", size = 10316722, upload-time = "2026-04-02T18:16:44.206Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/48/11/690d75f3fd6278fe55fff7c9eb429c92d207e14b25d1cae4064a32677029/ruff-0.15.9-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9439a342adb8725f32f92732e2bafb6d5246bd7a5021101166b223d312e8fc59", size = 10623674, upload-time = "2026-04-02T18:16:50.951Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bd/ec/176f6987be248fc5404199255522f57af1b4a5a1b57727e942479fec98ad/ruff-0.15.9-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9c5e6faf9d97c8edc43877c3f406f47446fc48c40e1442d58cfcdaba2acea745", size = 10351516, upload-time = "2026-04-02T18:16:57.206Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b2/fc/51cffbd2b3f240accc380171d51446a32aa2ea43a40d4a45ada67368fbd2/ruff-0.15.9-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7b34a9766aeec27a222373d0b055722900fbc0582b24f39661aa96f3fe6ad901", size = 11150202, upload-time = "2026-04-02T18:17:06.452Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d6/d4/25292a6dfc125f6b6528fe6af31f5e996e19bf73ca8e3ce6eb7fa5b95885/ruff-0.15.9-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:89dd695bc72ae76ff484ae54b7e8b0f6b50f49046e198355e44ea656e521fef9", size = 11988891, upload-time = "2026-04-02T18:17:18.575Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/13/e1/1eebcb885c10e19f969dcb93d8413dfee8172578709d7ee933640f5e7147/ruff-0.15.9-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ce187224ef1de1bd225bc9a152ac7102a6171107f026e81f317e4257052916d5", size = 11480576, upload-time = "2026-04-02T18:16:52.986Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ff/6b/a1548ac378a78332a4c3dcf4a134c2475a36d2a22ddfa272acd574140b50/ruff-0.15.9-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2b0c7c341f68adb01c488c3b7d4b49aa8ea97409eae6462d860a79cf55f431b6", size = 11254525, upload-time = "2026-04-02T18:17:02.041Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/42/aa/4bb3af8e61acd9b1281db2ab77e8b2c3c5e5599bf2a29d4a942f1c62b8d6/ruff-0.15.9-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:55cc15eee27dc0eebdfcb0d185a6153420efbedc15eb1d38fe5e685657b0f840", size = 11204072, upload-time = "2026-04-02T18:17:13.581Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/69/48/d550dc2aa6e423ea0bcc1d0ff0699325ffe8a811e2dba156bd80750b86dc/ruff-0.15.9-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a6537f6eed5cda688c81073d46ffdfb962a5f29ecb6f7e770b2dc920598997ed", size = 10594998, upload-time = "2026-04-02T18:16:46.369Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/63/47/321167e17f5344ed5ec6b0aa2cff64efef5f9e985af8f5622cfa6536043f/ruff-0.15.9-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:6d3fcbca7388b066139c523bda744c822258ebdcfbba7d24410c3f454cc9af71", size = 10359769, upload-time = "2026-04-02T18:17:10.994Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/67/5e/074f00b9785d1d2c6f8c22a21e023d0c2c1817838cfca4c8243200a1fa87/ruff-0.15.9-py3-none-musllinux_1_2_i686.whl", hash = "sha256:058d8e99e1bfe79d8a0def0b481c56059ee6716214f7e425d8e737e412d69677", size = 10850236, upload-time = "2026-04-02T18:16:48.749Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/76/37/804c4135a2a2caf042925d30d5f68181bdbd4461fd0d7739da28305df593/ruff-0.15.9-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:8e1ddb11dbd61d5983fa2d7d6370ef3eb210951e443cace19594c01c72abab4c", size = 11358343, upload-time = "2026-04-02T18:16:55.068Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/88/3d/1364fcde8656962782aa9ea93c92d98682b1ecec2f184e625a965ad3b4a6/ruff-0.15.9-py3-none-win32.whl", hash = "sha256:bde6ff36eaf72b700f32b7196088970bf8fdb2b917b7accd8c371bfc0fd573ec", size = 10583382, upload-time = "2026-04-02T18:17:04.261Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4c/56/5c7084299bd2cacaa07ae63a91c6f4ba66edc08bf28f356b24f6b717c799/ruff-0.15.9-py3-none-win_amd64.whl", hash = "sha256:45a70921b80e1c10cf0b734ef09421f71b5aa11d27404edc89d7e8a69505e43d", size = 11744969, upload-time = "2026-04-02T18:16:59.611Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/03/36/76704c4f312257d6dbaae3c959add2a622f63fcca9d864659ce6d8d97d3d/ruff-0.15.9-py3-none-win_arm64.whl", hash = "sha256:0694e601c028fd97dc5c6ee244675bc241aeefced7ef80cd9c6935a871078f53", size = 11005870, upload-time = "2026-04-02T18:17:15.773Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "sniffio"
|
name = "sniffio"
|
||||||
version = "1.3.1"
|
version = "1.3.1"
|
||||||
@@ -584,6 +818,60 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" },
|
{ url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tomli"
|
||||||
|
version = "2.4.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "typing-extensions"
|
name = "typing-extensions"
|
||||||
version = "4.15.0"
|
version = "4.15.0"
|
||||||
|
|||||||
Reference in new issue
Block a user