3 Commits
Author SHA1 Message Date
swaits c539133488 chore: repoint remaining links and host-specific tooling at git.swaits.com
Covers files missed by the first pass (.svelte/.astro components, bare org and
profile links, plugin doc headers), plus content that needed rewriting rather
than a URL swap: the chezmoi bootstrap script now provisions SSH for
git.swaits.com (User git, port 2222) and initialises from Gitea.

Link labels were updated to match their new targets.
2026-08-31 18:35:22 -06:00
swaits d353c2acdf chore: repoint remaining links at git.swaits.com
Rewrites lingering github.com / *.sr.ht references to this Gitea instance,
translating GitHub and Sourcehut URL shapes to Gitea's (/blob/<ref>/ and
sr.ht /tree/<ref>/item/ -> /src/branch/<ref>/, /tree/<tag> -> /src/tag/<tag>,
/actions/workflows/X -> /actions?workflow=X, /edit/ -> /_edit/).

Left pointing at the old hosts on purpose: pull-request links (PRs were not
migrated), forks that were never migrated, and GitHub-only surfaces Gitea
lacks. OpenPGP identity proofs are untouched by design.
2026-08-31 18:32:14 -06:00
swaits 2a97e87c16 replace repo contents with redirect to donglora org 2026-04-07 14:54:30 -06:00
134 changed files with 14 additions and 20100 deletions

No files matched your search

-2
View File
@@ -1,2 +0,0 @@
__pycache__/
.hypothesis/
-257
View File
@@ -1,257 +0,0 @@
## Module Naming & Structure
1. The unified host communication module — do you prefer comm/, host/, or something else?
host/
2. Should protocol.rs stay as a single file, or split into protocol/types.rs + protocol/framing.rs (absorbing protocol_io.rs)?
Split it apart logically
3. Or should the framing code live inside the comm/ module since it's only used there?
You decide what makes the most sense based on our first principles
4. The radio/ directory currently has just task.rs (and presumably a mod.rs). Should it stay a directory or flatten to radio.rs?
Flatten. No directory should ever have just one (or even 2?) things in it!
5. Same question for display/ — it has task.rs, render.rs, and the driver. Keep as directory?
Nope. See previous question.
6. Should there be a board/drivers/ subdirectory, or should the SH1106 driver just stay at the board level (e.g. board/sh1106.rs)?
Somehow, things like drivers need to be clearly separated from boards, both by
filename+location and by how the code is structured (abstractions, etc)
7. The channel.rs file is standalone — is that fine, or should inter-task communication be grouped differently?
I guess fine?
Board Definition Contract
8. into_parts() is currently an inherent method, not on the trait. Should it be added to LoRaBoard (requires associated types), or is the implicit contract fine?
Nothing ever implicit. This is rust.
9. Should we add a spawn_host_task() function to each board, or keep that concern in the comm/ module with internal cfg?
If it is going to vary by hardware, then yeah I think we have to push it down
into the board abstraction level. Which sucks, but at least so far seems to be
how it works. I frankly still don't believe that the Heltec V3 cannot be made to
look like a proper ttyACM0 device! I'm not giving up on that shit yet!!!
10. Should boards export a single BoardParts struct (with radio, comm, display fields) instead of returning a tuple?
struct > tuple.
11. Should LoRaBoard gain associated constants like const HAS_DISPLAY: bool or const COMM_TYPE: CommType?
I don't know. You can have a HTV4 with an OLED or without one. Does anything
above the board abstraction layer need to know this???
12. Is the current LoRaBoard trait the right level of abstraction? Should it grow (more methods), shrink (fewer), or stay the same?
You decide.
13. Should mac_address() remain on the trait, or become a field in a parts struct?
I think it can fit nicely in the struct. If that's a pattern we're choosing, be
consistent.
14. The into_parts() currently consumes self (the Board). Is this the right ownership model?
I guess? Sounds rusty.
## Chip-Family Helpers
15. For nrf52840.rs — should it be a single file or a directory (nrf52840/mod.rs + submodules)?
Maybe a directory? But how are we separating components from board
definitions??? I don't think we should just have one directory full of random
directories for MCUs, displays, UARTS, and boards all plopped in there. That
sounds **disorganized**, not **organized**.
16. The nRF52840 bind_interrupts! macro — should it live in the helper or per-board? (Boards might use different peripherals.)
If you're **very** confident we'll need to customize it per-board, then go ahead
and break it out. If not, leave it generic across all boards.
17. The mac_address() unsafe FICR block is identical in both nRF boards. Definitely extract to helper?
Yes.
18. Both ESP32-S3 boards have identical Vext GPIO36 and display reset GPIO21 logic. Should this be in esp32s3.rs or per-board?
If you're confident this **will need to be board specific** before long, break
it out. Otherwise conslidate.
19. Should chip family helpers define ALL possible init functions (USB, UART, etc.) even if not all boards use them, or only what's actually used?
Good question. I'm not sure. Best advice from you?
20. Should esp32s3.rs be split into submodules (esp32s3/radio.rs, esp32s3/usb.rs, esp32s3/display.rs)?
Uhhhh.. and ESP32 is an MCU, not a radio, not a USB, not a display. GET THE
ABSTRACTIONS AND SEPARATION RIGHT!!!!!!!!!!!!!!!!!!!!!
21. If we add an nrf52840.rs, should the existing esp32s3.rs be renamed for consistency (both are chip-family helpers but esp32s3 is named after the chip while nrf52840 would
be named after the chip too — good)?
Make the naming scheme consistent.
## Display
22. Should the board provide an already-initialized display (DisplayParts { display: DisplayDriver, mac }) or raw parts with a separate create_display() async function?
Board should provide the means to its consumer to init display, radio, etc. It
shouldn't preemptively init stuff.
23. Since into_parts() is sync but display init is async — does the create_display() approach seem right?
I don't know. What's your advice?
24. Do we need an AsyncDisplay trait (for flush + brightness), or is relying on the concrete type's methods via duck-typing sufficient?
What do you think?
25. Both drivers implement DrawTarget — rendering is already generic. The only non-generic part is flush() and init(). Is pushing init/flush into the board layer the right call?
I think so.
26. The SH1106 driver (display/sh1106.rs) — is it a hardware driver that belongs in board/drivers/, or display infrastructure that should stay in display/?
Think of the abstractions. Imagine you're just looking at this project for the
first time. Where would you look for a display driver??? drivers/? Something else?
27. The display init retry logic (try twice, give up) is identical for both drivers. Should this be a common wrapper?
I think that retry is stupid. Let the client retry.
28. Display brightness: SSD1306 uses Brightness::BRIGHTEST, SH1106 uses 0xFF. Should create_display() handle this internally?
Make it generic across displays.
29. Currently display errors are silently ignored (let _ = display.flush().await). Is this intentional and should remain?
Seems like it should be elegantly handled and propagated up the abstraction layers.
## Host Communication
30. USB has DTR disconnect detection that sends DisplayCommand::Reset/On. UART has no equivalent. Is it OK for UART boards to simply not have this feature?
I mean, do we have a choice? I **really** don't want to support these UART
boards. And I'm still refusing to believe that the Heltec V3's USB hardware
cannot be made to look like a proper ttyACM. People use these MCUs and boards to
create keyboards/HID and other things after all! So my REAL preference is to NOT
support these boards. That said, we have to support the Heltec V3. So, if we
can't do it properly, then I guess we don't have a choice here.
31. The USB task runs join(usb_dev.run(), protocol_loop(...)) — UART just loops. Is having structurally different task bodies behind the same host_task name acceptable?
Consistency is important to me. I don't know the right answer here. But be
consistent and smart.
32. Should comm/mod.rs compile ONLY the active transport, or compile both and let the linker dead-code-eliminate?
Umm if there's a nice way to do it, then sure. But no leaky abstractions! Use
traits and abstractions and layers so we compile the right thing for a given configuration.
33. The USB task's COBS accumulation is inline and the UART task uses FrameAccumulator. I've verified they're semantically identical. Can I just replace the USB inline code with FrameAccumulator?
Sure. Is "FrameAccumulator" the right name for this though?
34. The USB task's route_command (inline, lines 206-238) is byte-for-byte identical to protocol_io::route_command. Can I just delete the inline one?
Yes.
35. MAX_FRAME is defined in both usb/task.rs (line 16) and protocol_io.rs (line 12). Should there be one canonical definition?
Yes.
## Build System
36. How should build.rs discover chip-family helpers? Options: (a) scan board files for use super::<helper>, (b) naming convention, (c) explicit list in build.rs, (d) marker comments in board files?
I like build.rs scanning for board files. I just want them all in a logical
directory tree.
37. The Jinja template approach — keep it, or is there something better?
I'm pretty happy with it. You tell me: is there something better???
38. The justfile _info helper uses a hardcoded if/elif chain. Should this become data-driven (scan a file or Cargo.toml)?
Yes.
39. Should the justfile board list at line 15 be auto-derived from Cargo.toml features?
Maybe. What if it's derived from the source files it finds?
40. build.rs excludes mod, traits, esp32s3 from board discovery. With nrf52840.rs added, the exclusion list grows. Is a naming convention better (e.g.
all-lowercase-with-underscores = board, CamelCase or chip-family-names = helper)?
"Exclustion lists" are evil. Fix the abstractions and design so we don't have to
deal with this shit.
## Future-Proofing
41. Do ALL future boards use SX1262, or could there be SX1276/SX1280/other radios?
There will be other radios.
42. Could a future board have NO display at all? (Currently all boards return Some(DisplayParts) or have optional display.)
Yes, many boards will have no display.
43. Could a future board use SPI display instead of I2C?
Probably.
44. Could a future board use a completely different MCU family (STM32, RP2040)?
Yes. In fact I have both STM and RP2040 boards sitting here now.
45. Is there a realistic scenario where a board needs both USB AND UART?
No. FUCK UART.
46. Could a future board need additional tasks beyond radio/comm/display (e.g. LED, sensor)?
I am thinking of adding an RGB LED interface down into the boards. Then if the
host has turned on the display, we'd blink the LED green anytime we receive and
red any time we transmit a packet.
And these boards often have lots of other hardware. Wi-Fi, BLE, sensors,
whatever. DongLoRa is really just about the radio though so I'm not prioritizing
supporting these, beyond the RGB LED thing I mentioned.
## Migration & Testing
47. Should this be one atomic refactor, or phased (dedup first, then reorg, then display)? I propose phased — each step independently compilable and testable.
Probably phased. Use jj to checkpoint as you go along. When you're happy with a
phase, "jj commit -m 'WIP: phase description'" (jj commit is the same as a jj
describe + jj new)
48. Are there any in-flight changes on other branches/bookmarks that would conflict?
Nope. We just finished getting the Heltec V3 to work, which are the last few
"WIP" commits.
49. The protocol tests (just test) use DONGLORA_HOST_TEST=1 to skip build.rs board logic. Will the reorg affect this?
You tell me. What I do know is testing is extremely important. I'd like
comprehensive tests, property tests, mutation testing, fuzz testing. Basically
throw everything we have at it.
50. After the reorg, do you want a CI-enforceable rule like "no #[cfg(feature = \"board_name\")] outside board/ and comm/mod.rs"?
That sounds great. Any mechanisms to enforce our abstractions and first
principles are very welcome.
BTW, NO unsafe code unless it is ABSOLUTELY REQUIRED. I know we're doing
embedded stuff. There are probably a few unsafe things we have to do. But always
research for ways to eliminate unsafe code.
-71
View File
@@ -1,71 +0,0 @@
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/),
and this project adheres to [Semantic Versioning](https://semver.org/).
## [0.2.0] - 2026-04-07
### Added
- Wio Tracker L1 board support (nRF52840 + SX1262, SH1106 OLED)
- LED blink on packet activity: brief flash on RX and TX when display is on
- RX brightness scales with SNR (stronger signal = brighter)
- Works on all boards with a user LED (Heltec V3/V4, Wio Tracker)
- Native USB CDC-ACM support for Heltec V3 (requires hardware mod: solder R29/R3)
- `heltec_v3_uart` feature for stock (unmodified) V3 boards via CP2102 bridge
- `RgbLed` trait and `SimpleLed` driver for GPIO-based LEDs
- `LoRaBoard` trait with full associated types (`RadioParts`, `CommParts`,
`DisplayParts`, `DisplayDriver`, `LedDriver`) and `BoardParts<R, C, D, L>` struct
### Changed
- Major firmware architecture reorganization:
- `hal/` module for MCU-specific primitives (ESP32-S3, nRF52840)
- `driver/` module for hardware peripheral drivers (SH1106, SimpleLed)
- `host/` module for unified USB/UART communication with internal cfg dispatch
- `board/` contains only board definitions — no MCU helpers or drivers mixed in
- Board files are declarative wiring diagrams calling hal primitives
- `run()` function has zero board-specific conditional compilation
- Display init moved to board layer (`create_display()`) — display task has no cfg
- Flattened `radio/` and `display/` directories (no single-file directories)
- `build.rs` auto-discovers boards by content (implements `LoRaBoard`) and
helper modules by `use super::` imports — no exclusion lists
- `check` and `clippy` commands now use `--release` profile
- Deduplicated USB/UART protocol framing (shared `CobsDecoder` + `route_command`)
- Removed `ErrorCode::CrcError` (SX1262 silently drops bad-CRC packets)
### Fixed
- Display stuck on splash screen for UART boards (disconnected flag started true)
## [0.1.0] - 2026-03-15
### Added
- Transparent LoRa radio over USB CDC-ACM (VID `1209`, PID `5741`)
- COBS-framed fixed-size LE binary protocol (9 commands, 7 responses)
- Radio configuration: frequency, bandwidth, spreading factor, coding rate, TX power
- Continuous RX mode with RSSI/SNR per packet
- Single-shot TX with optional per-packet config override
- SSD1306 OLED display support with status dashboard
- Radio state, frequency, modulation parameters
- Packet counters (RX/TX)
- RSSI sparkline (1-minute history, TX shown as dotted bars)
- Combined splash/waiting screen on disconnect
- Auto-detect USB host connect/disconnect via DTR
- Config validation against per-board hardware limits
- `TX_POWER_MAX` sentinel (`-128`): auto-use the board's maximum TX power
- Board support: Heltec V3, Heltec V4, RAK WisBlock 4631
- Python host library (`clients/python/`) with `pip install` and `donglora-mux` CLI
- USB multiplexer for sharing one dongle with multiple applications
- Two-way LoRa bridge over TCP (`examples/lora_bridge.py`)
### Technical
- Embassy async runtime (no threads, CPU sleeps when idle)
- Zero-config: boots and waits for host commands
- Protocol-agnostic: firmware has no opinion about what you transmit
- Tasks never panic: all errors reported to host or logged
- Board abstraction: one `.rs` file + one Cargo feature per board
-54
View File
@@ -1,54 +0,0 @@
# Contributing to DongLoRa
## Adding Board Support
DongLoRa is designed to make adding new boards easy:
1. Create `firmware/src/board/your_board.rs` implementing the `init()` function
2. Add a Cargo feature in `firmware/Cargo.toml` with the board's dependencies
3. Add the board's feature/target/chip to the `justfile` boards list
4. `firmware/build.rs` auto-discovers the new file and generates the module selector
See existing board files (`heltec_v3.rs`, `rak_wisblock_4631.rs`) as templates.
Each board file owns all hardware init and returns peripheral bundles that the
common tasks consume. No shared board code — each board is self-contained.
## Code Style
- **Clippy-clean.** `just clippy <board>` must pass with no warnings.
- **No panics in tasks.** Use `match`/`if let`/`warn!` — never `.unwrap()` in async task code. Board init (runs once at startup) may use `.expect("reason")`.
- **Display rendering uses `.ok()`.** Drawing is best-effort; display errors are not recoverable.
- **Every `let _ =` gets an inline comment** explaining why the result is discarded.
## Client Libraries
Client libraries exist in [Python](clients/python/) and [Rust](clients/rust/).
To add a library in another language:
1. Create a directory with the language's standard project layout
2. Implement the wire protocol from [firmware/PROTOCOL.md](firmware/PROTOCOL.md)
3. Include device discovery (USB VID `1209`, PID `5741`)
4. Include COBS framing, command encoding, and response decoding
5. Add a mux client (connect to the mux daemon's Unix socket or TCP)
The protocol is intentionally simple — 8 commands, 7 responses, fixed-size LE.
A minimal client library should be implementable in a weekend.
## Building and Testing
```sh
cd firmware
just check-all # Compile-check all boards
just build-all # Build release firmware for all boards
just clippy <board> # Lint a specific board
just test # Host-side protocol unit tests
```
Tool versions are pinned in each project's `mise.toml`.
Tools are installed automatically on first run.
## Commits
- [Conventional Commits](https://www.conventionalcommits.org/) format
- Logically grouped (one concern per commit)
- We use [jj](https://martinvonz.github.io/jj/) for version control
-108
View File
@@ -1,108 +0,0 @@
# Design Principles
These are the non-negotiable first principles of this project. Every design decision,
naming choice, module boundary, and abstraction is evaluated against them. When in
doubt, choose simplicity and clarity over cleverness.
## What This Is
A transparent LoRa radio over USB. Plug the dongle into your PC, talk to any LoRa
radio within range. That's it.
## What This Is Not
Not a mesh network node. Not a Meshtastic device. Not a protocol implementation. Not
an application framework. This firmware has no opinion about what you do with your
radio — it just gives you clean access to it.
## Principles
1. **Single purpose.** Transparent LoRa radio over USB. No mesh, no routing, no
application logic. Ever.
2. **Protocol-agnostic.** The firmware is a dumb pipe. It exposes clean LoRa
parameters (frequency, bandwidth, spreading factor, coding rate, sync word, TX
power) and nothing else. No raw register access, no chip-specific leakage.
3. **Host-driven.** The firmware makes zero autonomous decisions. The radio idles on
boot until the host commands it. No persistence across reboots. The host is always
in control.
4. **Zero-config.** Boots, enumerates USB, waits for commands. The host plugs in and
goes.
5. **Robustness over features.** Tasks never panic. Errors are reported to the host.
The radio self-recovers. Ship fewer features, but make them bulletproof.
6. **Minimal footprint.** Avoid heavy dependencies. Keep the binary small. Every
dependency must justify its inclusion.
7. **Auto-detect peripherals.** If the board has a display, keyboard, or sensors,
use them. No user configuration required.
8. **World-class Rust.** Fully leverage the type system for safety. Strict `no_std`.
Always clippy-clean. Thoughtfully designed — everything has an obvious home.
Anyone who reads this code should immediately think *"this is nice."*
9. **Easy board support.** Adding a new board means dropping a `.rs` file and adding
a Cargo feature. Nothing else changes.
10. **Support all common LoRa boards.** This project supports every common LoRa
board, and even some uncommon ones. The architecture is designed to accommodate
them cleanly. If the code isn't structured for a new board, fix the code.
11. **Minimal overhead.** The dongle should add as little latency as possible
between the radio and the host. No unnecessary copies, no blocking operations
in the radio path, no computation that isn't strictly needed.
12. **Standard toolchain.** No custom Rust forks or special SDKs. Use stock `rustup`
channels (stable, nightly). Xtensa targets use nightly + `-Zbuild-std`; Cortex-M
and RISC-V targets use stable. Boards that need the `esp` toolchain (via `espup`)
are supported but gracefully skipped when the toolchain isn't installed.
## Architecture
```
Host PC
│ USB CDC-ACM (COBS-framed fixed-size LE)
▼
usb_task ──Command──► radio_task ──► SX1262
◄──Response── │
StatusWatch
▼
display_task (optional) ──► OLED/TFT
```
Each task exclusively owns its peripheral. No shared state except typed channels and
a watch signal. Pure actor model.
## Peripheral Model
Boards can have optional peripherals beyond the radio and USB. The board's
`into_parts()` returns `Option<T>` slots for each, and the corresponding task is
only spawned when present.
| Peripheral | Slot | Task | Examples |
|---|---|---|---|
| Display | `Option<DisplayParts>` | `display_task` | SSD1306 OLED, ST7789 TFT |
| Input | `Option<InputParts>` | `input_task` | T-Deck keyboard, buttons |
| Telemetry | `Option<TelemetryParts>` | `telemetry_task` | Battery voltage, temperature |
**Board files are always separate.** A T-Deck is `t_deck.rs`, not a variant of
Heltec V3. Each board owns its own init code and pin mappings. Shared logic between
boards that use the same MCU can be factored into helper modules if it earns its
keep, but never prematurely.
## Technical Decisions
| Decision | Choice | Why |
|---|---|---|
| USB framing | COBS | Compact, `no_std`, zero-overhead frame delimiting |
| Serialization | Fixed-size LE | Every field at a known offset. No varints, no surprises |
| Async runtime | Embassy | De facto standard for async embedded Rust |
| Display | Radio status dashboard | Freq, BW, SF, packet counts, RSSI/SNR |
| Boot behavior | Idle until host commands | Host-driven principle |
| Persistence | None | Always start fresh, true dumb pipe |
| Board codegen | Jinja2 via build.rs | Auto-discovers boards from filesystem |
| Rust toolchain | Stock `rustup` (stable/nightly) | No forks; `espup` for Xtensa boards only |
| Firmware output | `firmware/builds/donglora-{board}-{profile}.elf` | Readable names, not buried in `target/` |
-21
View File
@@ -1,21 +0,0 @@
MIT License
Copyright (c) 2025 Stephen Waits <steve@waits.net>
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+14 -211
View File
@@ -1,214 +1,17 @@
# DongLoRa
# DongLoRa has moved
**/ˈdɒŋ.ɡəl.ɔːr.ə/** — a [portmanteau](https://en.wikipedia.org/wiki/Portmanteau) of **dongle** and **LoRa**.
This repository has been split into individual repos under the
[**donglora** organization](https://git.swaits.com/donglora).
Transparent LoRa radio over USB. Flash the firmware onto a supported
board, plug it in, and send/receive LoRa packets from your computer.
The firmware handles the radio; your code decides what to do with it.
| What | New location |
|------|--------------|
| Firmware | [donglora/firmware](https://git.swaits.com/donglora/firmware) |
| Rust client library | [donglora/client-rs](https://git.swaits.com/donglora/client-rs) |
| Python client library | [donglora/client-py](https://git.swaits.com/donglora/client-py) |
| Rust mux daemon | [donglora/mux-rs](https://git.swaits.com/donglora/mux-rs) |
| Python mux daemon | [donglora/mux-py](https://git.swaits.com/donglora/mux-py) |
| MeshCore bridge | [donglora/bridge](https://git.swaits.com/donglora/bridge) |
| MeshCore AI bot | [donglora/ai-bot](https://git.swaits.com/donglora/ai-bot) |
| Example scripts | [donglora/examples](https://git.swaits.com/donglora/examples) |
Any LoRa board becomes a reusable peripheral that works with any host —
a laptop, a desktop, a Raspberry Pi, a server. The built-in multiplexer
lets multiple applications share one dongle at the same time, so you can
run a MeshCore bot, a packet analyzer, and a bridge all on the same
radio without dedicated hardware for each.
![photograph of firmware running on Heltec V4](./demo.jpg)
## How It Works
```
Your code ──► USB serial ──► firmware ──► LoRa radio
Your code ──► client library ──► USB serial ──► firmware ──► LoRa radio
Your code ──► client library ──┐
Your code ──► client library ──┤
... ├──► mux daemon ──► USB serial ──► firmware ──► LoRa radio
Your code ──► client library ──┤
Your code ──► client library ──┘
```
Four components, each self-contained with its own dependencies and tooling:
| Component | What it is | Language |
|-----------|-----------|----------|
| **[firmware/](firmware/)** | Embedded firmware flashed onto a LoRa board. Speaks a simple [binary protocol](firmware/PROTOCOL.md) over USB. | Rust |
| **[clients/](clients/)** | Client libraries that handle device discovery, COBS framing, and protocol encoding/decoding. This is what your code imports. | [Python](clients/python/), [Rust](clients/rust/) |
| **[mux/](mux/)** | Optional daemon that lets multiple applications share one dongle. Owns the USB port, exposes a Unix socket (and optional TCP). | [Python](mux/python/), [Rust](mux/rust/) |
| **[examples/](examples/)** | Ready-to-run scripts demonstrating the client library — receive, transmit, bridge, MeshCore decoder, AI bot. | Python |
The client library can talk directly to the firmware over USB, or connect
through the mux when multiple applications need the same dongle.
## Quick Start
1. Get a [supported board](#supported-boards)
2. `cd firmware && just flash heltec_v4`
3. `cd examples && just rx`
4. See packets
## Supported Boards
| Board | MCU | Radio | Display |
|-------|-----|-------|---------|
| Heltec V3 | ESP32-S3 | SX1262 | SSD1306 OLED |
| Heltec V4 | ESP32-S3 | SX1262 | SSD1315 OLED |
| RAK WisBlock 4631 | nRF52840 | SX1262 | SSD1306 OLED (optional) |
## Examples
Dependencies are handled automatically:
```sh
cd examples
just rx # receive packets
just tx # transmit a packet
just ping-pong --role tx # two-dongle ping-pong demo
just test-commands # exercise all DongLoRa commands
just bridge --mode server # LoRa bridge over TCP
just meshcore # MeshCore packet decoder
just orac # MeshCore AI bot (needs ANTHROPIC_API_KEY)
just telemetry # MeshCore repeater telemetry monitor
```
## Multiplexer
To share one dongle across multiple applications, run the mux daemon:
```sh
# Python
cd mux/python
just run # start the mux daemon
just verbose # start with verbose logging
just run --tcp 5741 --port /dev/ttyACM0 # with explicit options
# Rust
cd mux/rust
just run # start the mux daemon
just verbose # start with verbose logging
just run -- --tcp 5741 --port /dev/ttyACM0 # with explicit options
```
The mux owns the USB serial port exclusively and exposes a Unix socket
(and optional TCP) speaking the same protocol. Client libraries connect
through it transparently.
| Script | Description |
|--------|-------------|
| [`simple_rx.py`](examples/simple_rx.py) | Configure radio, receive and print packets |
| [`simple_tx.py`](examples/simple_tx.py) | Transmit a single packet |
| [`ping_pong.py`](examples/ping_pong.py) | Two-dongle demo (`--role tx` / `--role rx`) |
| [`all_commands.py`](examples/all_commands.py) | Exercise all 9 commands |
| [`lora_bridge.py`](examples/lora_bridge.py) | Two-way LoRa bridge over TCP (works over Tailscale, WireGuard, etc.) |
| [`meshcore/`](examples/meshcore/) | Full MeshCore packet decoder, AI bot, telemetry monitor |
## Device Permissions (Linux)
Your user needs read/write access to the serial device (`/dev/ttyACM*` or
`/dev/ttyUSB*`). The cleanest fix is a udev rule:
```sh
# /etc/udev/rules.d/99-donglora.rules
SUBSYSTEM=="tty", ATTRS{idVendor}=="1209", ATTRS{idProduct}=="5741", MODE="0666"
```
Then reload:
```sh
sudo udevadm control --reload-rules && sudo udevadm trigger
```
Without this, you'll get permission errors when opening the serial port.
## Building
Requires [just](https://github.com/casey/just) and [mise](https://mise.jdx.dev/).
Everything else is installed automatically on first run.
```sh
cd firmware
just build heltec_v4 # build a specific board
just build-all # build firmware for all boards
just flash heltec_v4 # build + flash
just check-all # compile-check only
just clippy heltec_v4 # lint
just test # host-side protocol unit tests
```
## Roadmap
### Now: Solidify the Foundation
- [ ] Test on more boards (Heltec V2, T-Beam Supreme, T-Echo, RAK 3112)
- [ ] Python client library on PyPI — the existing library, properly packaged
- [ ] CI for all boards — automated build/clippy/size-check on every commit
- [ ] Protocol documentation polish
### Next: Cross-Language Client Libraries
The protocol is 8 commands over COBS-framed LE. Any language that can
open a serial port can speak DongLoRa.
- [x] **Rust** crate — async, tokio-native, zero-copy COBS
- [ ] **Go** module — goroutine-friendly, cross-platform
- [ ] **C** library — `libdonglora` for FFI from any language
- [ ] **Ruby** gem — simple protocol, weekend gem, new community
- [ ] **Python** on PyPI — type hints and async support
### Then: Infrastructure and Tooling
- [x] Cross-platform mux daemon rewritten in Rust (single static binary, no Python required)
- [ ] `donglora-ctl` CLI — ping, configure, receive, transmit, scan frequencies. Think `ip link` for LoRa.
- [ ] Protocol versioning — version handshake so client libraries can detect firmware capabilities
- [ ] Firmware OTA over USB — field-upgradeable without a flash tool
### Future: Things That Would Be Amazing
All host-driven, firmware stays dumb. Every item passes the pipe test.
- [ ] **Spectrum analyzer** — host sweeps frequencies, radio reports RSSI at each step. Instant RF site survey tool.
- [ ] **Multi-dongle coordination** — one host, multiple dongles on different frequencies. Frequency-hopping, parallel monitoring, diversity reception — all host-driven.
- [ ] **LoRa packet capture** — PCAP-NG with a LoRa link type. Wireshark dissectors. Record, replay, analyze LoRa traffic with standard tools.
- [ ] **Time-synchronized RX** — "start RX at T, stop at T+N" for precise duty-cycle control and TDMA-style protocols. Firmware just follows the schedule.
- [ ] **Raw IQ streaming** — SDR-lite for LoRa. Demodulation and signal analysis on the host.
- [ ] **Mesh protocol test harness** — host-side framework for testing any mesh protocol over real radios. Inject packets, measure latency, simulate topology. The dongle is just the radio — test logic lives entirely on the host.
*Have an idea? Open an issue. The protocol is stable and the firmware is
intentionally boring — the interesting stuff happens on the host.*
<details>
<summary><strong>Board Roadmap</strong></summary>
### Next Up
| Board | MCU | Radio |
|-------|-----|-------|
| Heltec V2 | ESP32 | SX1276 |
| Heltec Wireless Tracker | ESP32-S3 | SX1262 |
| Heltec Vision Master E213/E290 | ESP32-S3 | SX1262 |
| LilyGo T-Beam (SX1262) | ESP32 | SX1262 |
| LilyGo T-Beam Supreme | ESP32-S3 | SX1262 |
| LilyGo T-Echo | nRF52840 | SX1262 |
| LilyGo T3 S3 | ESP32-S3 | SX1262 |
| Seeed Studio Xiao nRF52 WIO | nRF52840 | SX1262 |
### Future
| Board | MCU | Radio |
|-------|-----|-------|
| Elecrow ThinkNode M1-M6 | various | various |
| Heltec Wireless Paper / MeshPocket / T114 | various | SX1262 |
| LilyGo T-Deck / T-Echo Lite / LoRa32 | various | various |
| RAK WisBlock 3112 / WisMesh variants | various | SX1262 |
| RPI Pico 2040 + WaveShare SX1262 | RP2040 | SX1262 |
| Seeed Studio SenseCAP / Xiao variants | various | SX1262 |
</details>
## Contributing
See [CONTRIBUTING.md](CONTRIBUTING.md).
## License
[MIT](LICENSE) — Stephen Waits
This repo is archived and will not receive further updates.
-1
View File
@@ -1 +0,0 @@
.venv/
-31
View File
@@ -1,31 +0,0 @@
# DongLoRa Python Client
Python client library for talking to a DongLoRa device — either directly
over USB or through the [mux daemon](../../mux/).
## What's in Here
- `donglora/` — the library: device discovery, COBS framing, protocol
encoding/decoding, mux client support
- Installable as `donglora` via pip/uv
## Usage
```python
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())
```
## Dependencies
- `cobs` — COBS framing
- `pyserial` — USB serial communication
Optional extras: `meshcore` (crypto), `orac` (AI bot).
-381
View File
@@ -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,
}
-4
View File
@@ -1,4 +0,0 @@
[tools]
python = "3.14"
uv = "latest"
just = "latest"
-25
View File
@@ -1,25 +0,0 @@
[project]
name = "donglora"
version = "0.1.0"
description = "DongLoRa host library — talk LoRa from Python over USB"
license = "MIT"
requires-python = ">=3.10"
authors = [{ name = "Stephen Waits" }]
keywords = ["lora", "radio", "usb", "embedded"]
classifiers = [
"Development Status :: 3 - Alpha",
"Topic :: Communications :: Ham Radio",
"Topic :: System :: Hardware",
]
dependencies = [
"cobs",
"pyserial",
]
[project.optional-dependencies]
meshcore = ["pycryptodome", "pynacl"]
orac = ["pycryptodome", "pynacl", "anthropic"]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
-606
View File
@@ -1,606 +0,0 @@
version = 1
revision = 3
requires-python = ">=3.10"
[[package]]
name = "annotated-types"
version = "0.7.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" },
]
[[package]]
name = "anthropic"
version = "0.88.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "distro" },
{ name = "docstring-parser" },
{ name = "httpx" },
{ name = "jiter" },
{ name = "pydantic" },
{ name = "sniffio" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/86/68/565f13059c0a6a6fd5f96f306f2a0fb478a0e1174ec18a4df16b5fac9379/anthropic-0.88.0.tar.gz", hash = "sha256:f4c7f6863d08c869913516f08d658fe53caaf8bcc4fbea3218df343d2a876c58", size = 596654, upload-time = "2026-04-01T19:59:05.287Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ad/ac/68f646998160c9f2e6f9353a31dd87292ef02b915b455aaf70a52a059a75/anthropic-0.88.0-py3-none-any.whl", hash = "sha256:71898b32332bc75d9739bc10095288d40a29605da6d00da2fe832b1aa036552f", size = 478338, upload-time = "2026-04-01T19:59:03.832Z" },
]
[[package]]
name = "anyio"
version = "4.13.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "exceptiongroup", marker = "python_full_version < '3.11'" },
{ name = "idna" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" },
]
[[package]]
name = "certifi"
version = "2026.2.25"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029, upload-time = "2026-02-25T02:54:17.342Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" },
]
[[package]]
name = "cffi"
version = "2.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pycparser", marker = "implementation_name != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/93/d7/516d984057745a6cd96575eea814fe1edd6646ee6efd552fb7b0921dec83/cffi-2.0.0-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44", size = 184283, upload-time = "2025-09-08T23:22:08.01Z" },
{ url = "https://files.pythonhosted.org/packages/9e/84/ad6a0b408daa859246f57c03efd28e5dd1b33c21737c2db84cae8c237aa5/cffi-2.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49", size = 180504, upload-time = "2025-09-08T23:22:10.637Z" },
{ url = "https://files.pythonhosted.org/packages/50/bd/b1a6362b80628111e6653c961f987faa55262b4002fcec42308cad1db680/cffi-2.0.0-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:53f77cbe57044e88bbd5ed26ac1d0514d2acf0591dd6bb02a3ae37f76811b80c", size = 208811, upload-time = "2025-09-08T23:22:12.267Z" },
{ url = "https://files.pythonhosted.org/packages/4f/27/6933a8b2562d7bd1fb595074cf99cc81fc3789f6a6c05cdabb46284a3188/cffi-2.0.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3e837e369566884707ddaf85fc1744b47575005c0a229de3327f8f9a20f4efeb", size = 216402, upload-time = "2025-09-08T23:22:13.455Z" },
{ url = "https://files.pythonhosted.org/packages/05/eb/b86f2a2645b62adcfff53b0dd97e8dfafb5c8aa864bd0d9a2c2049a0d551/cffi-2.0.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5eda85d6d1879e692d546a078b44251cdd08dd1cfb98dfb77b670c97cee49ea0", size = 203217, upload-time = "2025-09-08T23:22:14.596Z" },
{ url = "https://files.pythonhosted.org/packages/9f/e0/6cbe77a53acf5acc7c08cc186c9928864bd7c005f9efd0d126884858a5fe/cffi-2.0.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9332088d75dc3241c702d852d4671613136d90fa6881da7d770a483fd05248b4", size = 203079, upload-time = "2025-09-08T23:22:15.769Z" },
{ url = "https://files.pythonhosted.org/packages/98/29/9b366e70e243eb3d14a5cb488dfd3a0b6b2f1fb001a203f653b93ccfac88/cffi-2.0.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc7de24befaeae77ba923797c7c87834c73648a05a4bde34b3b7e5588973a453", size = 216475, upload-time = "2025-09-08T23:22:17.427Z" },
{ url = "https://files.pythonhosted.org/packages/21/7a/13b24e70d2f90a322f2900c5d8e1f14fa7e2a6b3332b7309ba7b2ba51a5a/cffi-2.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cf364028c016c03078a23b503f02058f1814320a56ad535686f90565636a9495", size = 218829, upload-time = "2025-09-08T23:22:19.069Z" },
{ url = "https://files.pythonhosted.org/packages/60/99/c9dc110974c59cc981b1f5b66e1d8af8af764e00f0293266824d9c4254bc/cffi-2.0.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e11e82b744887154b182fd3e7e8512418446501191994dbf9c9fc1f32cc8efd5", size = 211211, upload-time = "2025-09-08T23:22:20.588Z" },
{ url = "https://files.pythonhosted.org/packages/49/72/ff2d12dbf21aca1b32a40ed792ee6b40f6dc3a9cf1644bd7ef6e95e0ac5e/cffi-2.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8ea985900c5c95ce9db1745f7933eeef5d314f0565b27625d9a10ec9881e1bfb", size = 218036, upload-time = "2025-09-08T23:22:22.143Z" },
{ url = "https://files.pythonhosted.org/packages/e2/cc/027d7fb82e58c48ea717149b03bcadcbdc293553edb283af792bd4bcbb3f/cffi-2.0.0-cp310-cp310-win32.whl", hash = "sha256:1f72fb8906754ac8a2cc3f9f5aaa298070652a0ffae577e0ea9bd480dc3c931a", size = 172184, upload-time = "2025-09-08T23:22:23.328Z" },
{ url = "https://files.pythonhosted.org/packages/33/fa/072dd15ae27fbb4e06b437eb6e944e75b068deb09e2a2826039e49ee2045/cffi-2.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:b18a3ed7d5b3bd8d9ef7a8cb226502c6bf8308df1525e1cc676c3680e7176739", size = 182790, upload-time = "2025-09-08T23:22:24.752Z" },
{ url = "https://files.pythonhosted.org/packages/12/4a/3dfd5f7850cbf0d06dc84ba9aa00db766b52ca38d8b86e3a38314d52498c/cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe", size = 184344, upload-time = "2025-09-08T23:22:26.456Z" },
{ url = "https://files.pythonhosted.org/packages/4f/8b/f0e4c441227ba756aafbe78f117485b25bb26b1c059d01f137fa6d14896b/cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c", size = 180560, upload-time = "2025-09-08T23:22:28.197Z" },
{ url = "https://files.pythonhosted.org/packages/b1/b7/1200d354378ef52ec227395d95c2576330fd22a869f7a70e88e1447eb234/cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92", size = 209613, upload-time = "2025-09-08T23:22:29.475Z" },
{ url = "https://files.pythonhosted.org/packages/b8/56/6033f5e86e8cc9bb629f0077ba71679508bdf54a9a5e112a3c0b91870332/cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93", size = 216476, upload-time = "2025-09-08T23:22:31.063Z" },
{ url = "https://files.pythonhosted.org/packages/dc/7f/55fecd70f7ece178db2f26128ec41430d8720f2d12ca97bf8f0a628207d5/cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5", size = 203374, upload-time = "2025-09-08T23:22:32.507Z" },
{ url = "https://files.pythonhosted.org/packages/84/ef/a7b77c8bdc0f77adc3b46888f1ad54be8f3b7821697a7b89126e829e676a/cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664", size = 202597, upload-time = "2025-09-08T23:22:34.132Z" },
{ url = "https://files.pythonhosted.org/packages/d7/91/500d892b2bf36529a75b77958edfcd5ad8e2ce4064ce2ecfeab2125d72d1/cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26", size = 215574, upload-time = "2025-09-08T23:22:35.443Z" },
{ url = "https://files.pythonhosted.org/packages/44/64/58f6255b62b101093d5df22dcb752596066c7e89dd725e0afaed242a61be/cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9", size = 218971, upload-time = "2025-09-08T23:22:36.805Z" },
{ url = "https://files.pythonhosted.org/packages/ab/49/fa72cebe2fd8a55fbe14956f9970fe8eb1ac59e5df042f603ef7c8ba0adc/cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414", size = 211972, upload-time = "2025-09-08T23:22:38.436Z" },
{ url = "https://files.pythonhosted.org/packages/0b/28/dd0967a76aab36731b6ebfe64dec4e981aff7e0608f60c2d46b46982607d/cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743", size = 217078, upload-time = "2025-09-08T23:22:39.776Z" },
{ url = "https://files.pythonhosted.org/packages/2b/c0/015b25184413d7ab0a410775fdb4a50fca20f5589b5dab1dbbfa3baad8ce/cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5", size = 172076, upload-time = "2025-09-08T23:22:40.95Z" },
{ url = "https://files.pythonhosted.org/packages/ae/8f/dc5531155e7070361eb1b7e4c1a9d896d0cb21c49f807a6c03fd63fc877e/cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5", size = 182820, upload-time = "2025-09-08T23:22:42.463Z" },
{ url = "https://files.pythonhosted.org/packages/95/5c/1b493356429f9aecfd56bc171285a4c4ac8697f76e9bbbbb105e537853a1/cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d", size = 177635, upload-time = "2025-09-08T23:22:43.623Z" },
{ url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" },
{ url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" },
{ url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" },
{ url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" },
{ url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" },
{ url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" },
{ url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" },
{ url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" },
{ url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" },
{ url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" },
{ url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" },
{ url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" },
{ url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" },
{ url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" },
{ url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" },
{ url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" },
{ url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" },
{ url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" },
{ url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" },
{ url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" },
{ url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" },
{ url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" },
{ url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" },
{ url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" },
{ url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" },
{ url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" },
{ url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" },
{ url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" },
{ url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" },
{ url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" },
{ url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" },
{ url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" },
{ url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" },
{ url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" },
{ url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" },
{ url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" },
{ url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" },
{ url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" },
{ url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" },
{ url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" },
{ url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" },
{ url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" },
{ url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" },
{ url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" },
{ url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" },
{ url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" },
]
[[package]]
name = "cobs"
version = "1.2.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/34/ef/ea149311227a4fc3160cc885fce06da7c7d76782a308ef070b8065c69953/cobs-1.2.2.tar.gz", hash = "sha256:dbdd5e32111d72786f83d0c269215dcd6ac629b1ac1962c6878221f3b2ca98da", size = 14582, upload-time = "2025-07-20T01:08:35.434Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e5/e0/9574590639a624a9a84c89a569f36343523a306fff2ffddc9468a2b9b05d/cobs-1.2.2-cp312-cp312-win32.whl", hash = "sha256:3afe0cec5d91164f84dcf35b471949aa36285664d21ee984350e39d485707d11", size = 26400, upload-time = "2025-07-20T01:08:30.288Z" },
{ url = "https://files.pythonhosted.org/packages/d3/4f/5418d9decdc4cf27a814fc189bc4fa44d216d37b871d633654f37ed331f8/cobs-1.2.2-cp312-cp312-win_amd64.whl", hash = "sha256:4da6048f6e16cbd6c8ec5e95e8520e226248ffb8d31e9d6fc5e0888b3b65c54b", size = 27776, upload-time = "2025-07-20T01:08:31.377Z" },
{ url = "https://files.pythonhosted.org/packages/b4/df/7f07559954d53d09680aa4f5c72db999e7643253e2ca4e431023fe5c1c40/cobs-1.2.2-cp313-cp313-win32.whl", hash = "sha256:a8fdf6ca70247e7271caea6f2f6f9c0343f5e872e2fd47e21f71831fa9ababa8", size = 26401, upload-time = "2025-07-20T01:08:32.508Z" },
{ url = "https://files.pythonhosted.org/packages/0c/00/5b063105ff567bb7fb72c3cd3e96116a3889381fa2c0e7747497391dbecc/cobs-1.2.2-cp313-cp313-win_amd64.whl", hash = "sha256:fe3f7194d5fbbc02e8fb03d26ad626a33fa0d420b50d66ed60ac4bf516322d8e", size = 27774, upload-time = "2025-07-20T01:08:33.692Z" },
{ url = "https://files.pythonhosted.org/packages/78/35/de1e889c26fcfc9ae6852b8f99e744e6690a3baae99f63e2b9fffdd21710/cobs-1.2.2-cp314-cp314-win32.whl", hash = "sha256:bcf08cb0b68c1824a87cfa89e0684d5a3eaee9b9e08b02ce4a54d5589536f01c", size = 26779, upload-time = "2026-03-09T02:18:48.711Z" },
{ 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 = "distro"
version = "1.9.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/fc/f8/98eea607f65de6527f8a2e8885fc8015d3e6f5775df186e443e0964a11c3/distro-1.9.0.tar.gz", hash = "sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed", size = 60722, upload-time = "2023-12-24T09:54:32.31Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277, upload-time = "2023-12-24T09:54:30.421Z" },
]
[[package]]
name = "docstring-parser"
version = "0.17.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b2/9d/c3b43da9515bd270df0f80548d9944e389870713cc1fe2b8fb35fe2bcefd/docstring_parser-0.17.0.tar.gz", hash = "sha256:583de4a309722b3315439bb31d64ba3eebada841f2e2cee23b99df001434c912", size = 27442, upload-time = "2025-07-21T07:35:01.868Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/55/e2/2537ebcff11c1ee1ff17d8d0b6f4db75873e3b0fb32c2d4a2ee31ecb310a/docstring_parser-0.17.0-py3-none-any.whl", hash = "sha256:cf2569abd23dce8099b300f9b4fa8191e9582dda731fd533daf54c4551658708", size = 36896, upload-time = "2025-07-21T07:35:00.684Z" },
]
[[package]]
name = "donglora"
version = "0.1.0"
source = { editable = "." }
dependencies = [
{ name = "cobs" },
{ name = "pyserial" },
]
[package.optional-dependencies]
meshcore = [
{ name = "pycryptodome" },
{ name = "pynacl" },
]
orac = [
{ name = "anthropic" },
{ name = "pycryptodome" },
{ name = "pynacl" },
]
[package.metadata]
requires-dist = [
{ name = "anthropic", marker = "extra == 'orac'" },
{ name = "cobs" },
{ name = "pycryptodome", marker = "extra == 'meshcore'" },
{ name = "pycryptodome", marker = "extra == 'orac'" },
{ name = "pynacl", marker = "extra == 'meshcore'" },
{ name = "pynacl", marker = "extra == 'orac'" },
{ name = "pyserial" },
]
provides-extras = ["meshcore", "orac"]
[[package]]
name = "exceptiongroup"
version = "1.3.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" },
]
[[package]]
name = "h11"
version = "0.16.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
]
[[package]]
name = "httpcore"
version = "1.0.9"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
{ name = "h11" },
]
sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
]
[[package]]
name = "httpx"
version = "0.28.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "certifi" },
{ name = "httpcore" },
{ name = "idna" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
]
[[package]]
name = "idna"
version = "3.11"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" }
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" },
]
[[package]]
name = "jiter"
version = "0.13.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/0d/5e/4ec91646aee381d01cdb9974e30882c9cd3b8c5d1079d6b5ff4af522439a/jiter-0.13.0.tar.gz", hash = "sha256:f2839f9c2c7e2dffc1bc5929a510e14ce0a946be9365fd1219e7ef342dae14f4", size = 164847, upload-time = "2026-02-02T12:37:56.441Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d0/5a/41da76c5ea07bec1b0472b6b2fdb1b651074d504b19374d7e130e0cdfb25/jiter-0.13.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:2ffc63785fd6c7977defe49b9824ae6ce2b2e2b77ce539bdaf006c26da06342e", size = 311164, upload-time = "2026-02-02T12:35:17.688Z" },
{ url = "https://files.pythonhosted.org/packages/40/cb/4a1bf994a3e869f0d39d10e11efb471b76d0ad70ecbfb591427a46c880c2/jiter-0.13.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4a638816427006c1e3f0013eb66d391d7a3acda99a7b0cf091eff4497ccea33a", size = 320296, upload-time = "2026-02-02T12:35:19.828Z" },
{ url = "https://files.pythonhosted.org/packages/09/82/acd71ca9b50ecebadc3979c541cd717cce2fe2bc86236f4fa597565d8f1a/jiter-0.13.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:19928b5d1ce0ff8c1ee1b9bdef3b5bfc19e8304f1b904e436caf30bc15dc6cf5", size = 352742, upload-time = "2026-02-02T12:35:21.258Z" },
{ url = "https://files.pythonhosted.org/packages/71/03/d1fc996f3aecfd42eb70922edecfb6dd26421c874503e241153ad41df94f/jiter-0.13.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:309549b778b949d731a2f0e1594a3f805716be704a73bf3ad9a807eed5eb5721", size = 363145, upload-time = "2026-02-02T12:35:24.653Z" },
{ url = "https://files.pythonhosted.org/packages/f1/61/a30492366378cc7a93088858f8991acd7d959759fe6138c12a4644e58e81/jiter-0.13.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bcdabaea26cb04e25df3103ce47f97466627999260290349a88c8136ecae0060", size = 487683, upload-time = "2026-02-02T12:35:26.162Z" },
{ url = "https://files.pythonhosted.org/packages/20/4e/4223cffa9dbbbc96ed821c5aeb6bca510848c72c02086d1ed3f1da3d58a7/jiter-0.13.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a3a377af27b236abbf665a69b2bdd680e3b5a0bd2af825cd3b81245279a7606c", size = 373579, upload-time = "2026-02-02T12:35:27.582Z" },
{ url = "https://files.pythonhosted.org/packages/fe/c9/b0489a01329ab07a83812d9ebcffe7820a38163c6d9e7da644f926ff877c/jiter-0.13.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fe49d3ff6db74321f144dff9addd4a5874d3105ac5ba7c5b77fac099cfae31ae", size = 362904, upload-time = "2026-02-02T12:35:28.925Z" },
{ url = "https://files.pythonhosted.org/packages/05/af/53e561352a44afcba9a9bc67ee1d320b05a370aed8df54eafe714c4e454d/jiter-0.13.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2113c17c9a67071b0f820733c0893ed1d467b5fcf4414068169e5c2cabddb1e2", size = 392380, upload-time = "2026-02-02T12:35:30.385Z" },
{ url = "https://files.pythonhosted.org/packages/76/2a/dd805c3afb8ed5b326c5ae49e725d1b1255b9754b1b77dbecdc621b20773/jiter-0.13.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:ab1185ca5c8b9491b55ebf6c1e8866b8f68258612899693e24a92c5fdb9455d5", size = 517939, upload-time = "2026-02-02T12:35:31.865Z" },
{ url = "https://files.pythonhosted.org/packages/20/2a/7b67d76f55b8fe14c937e7640389612f05f9a4145fc28ae128aaa5e62257/jiter-0.13.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:9621ca242547edc16400981ca3231e0c91c0c4c1ab8573a596cd9bb3575d5c2b", size = 551696, upload-time = "2026-02-02T12:35:33.306Z" },
{ url = "https://files.pythonhosted.org/packages/85/9c/57cdd64dac8f4c6ab8f994fe0eb04dc9fd1db102856a4458fcf8a99dfa62/jiter-0.13.0-cp310-cp310-win32.whl", hash = "sha256:a7637d92b1c9d7a771e8c56f445c7f84396d48f2e756e5978840ecba2fac0894", size = 204592, upload-time = "2026-02-02T12:35:34.58Z" },
{ url = "https://files.pythonhosted.org/packages/a7/38/f4f3ea5788b8a5bae7510a678cdc747eda0c45ffe534f9878ff37e7cf3b3/jiter-0.13.0-cp310-cp310-win_amd64.whl", hash = "sha256:c1b609e5cbd2f52bb74fb721515745b407df26d7b800458bd97cb3b972c29e7d", size = 206016, upload-time = "2026-02-02T12:35:36.435Z" },
{ url = "https://files.pythonhosted.org/packages/71/29/499f8c9eaa8a16751b1c0e45e6f5f1761d180da873d417996cc7bddc8eef/jiter-0.13.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:ea026e70a9a28ebbdddcbcf0f1323128a8db66898a06eaad3a4e62d2f554d096", size = 311157, upload-time = "2026-02-02T12:35:37.758Z" },
{ url = "https://files.pythonhosted.org/packages/50/f6/566364c777d2ab450b92100bea11333c64c38d32caf8dc378b48e5b20c46/jiter-0.13.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:66aa3e663840152d18cc8ff1e4faad3dd181373491b9cfdc6004b92198d67911", size = 319729, upload-time = "2026-02-02T12:35:39.246Z" },
{ url = "https://files.pythonhosted.org/packages/73/dd/560f13ec5e4f116d8ad2658781646cca91b617ae3b8758d4a5076b278f70/jiter-0.13.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c3524798e70655ff19aec58c7d05adb1f074fecff62da857ea9be2b908b6d701", size = 354766, upload-time = "2026-02-02T12:35:40.662Z" },
{ url = "https://files.pythonhosted.org/packages/7c/0d/061faffcfe94608cbc28a0d42a77a74222bdf5055ccdbe5fd2292b94f510/jiter-0.13.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ec7e287d7fbd02cb6e22f9a00dd9c9cd504c40a61f2c61e7e1f9690a82726b4c", size = 362587, upload-time = "2026-02-02T12:35:42.025Z" },
{ url = "https://files.pythonhosted.org/packages/92/c9/c66a7864982fd38a9773ec6e932e0398d1262677b8c60faecd02ffb67bf3/jiter-0.13.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:47455245307e4debf2ce6c6e65a717550a0244231240dcf3b8f7d64e4c2f22f4", size = 487537, upload-time = "2026-02-02T12:35:43.459Z" },
{ url = "https://files.pythonhosted.org/packages/6c/86/84eb4352cd3668f16d1a88929b5888a3fe0418ea8c1dfc2ad4e7bf6e069a/jiter-0.13.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ee9da221dca6e0429c2704c1b3655fe7b025204a71d4d9b73390c759d776d165", size = 373717, upload-time = "2026-02-02T12:35:44.928Z" },
{ url = "https://files.pythonhosted.org/packages/6e/09/9fe4c159358176f82d4390407a03f506a8659ed13ca3ac93a843402acecf/jiter-0.13.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:24ab43126d5e05f3d53a36a8e11eb2f23304c6c1117844aaaf9a0aa5e40b5018", size = 362683, upload-time = "2026-02-02T12:35:46.636Z" },
{ url = "https://files.pythonhosted.org/packages/c9/5e/85f3ab9caca0c1d0897937d378b4a515cae9e119730563572361ea0c48ae/jiter-0.13.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9da38b4fedde4fb528c740c2564628fbab737166a0e73d6d46cb4bb5463ff411", size = 392345, upload-time = "2026-02-02T12:35:48.088Z" },
{ url = "https://files.pythonhosted.org/packages/12/4c/05b8629ad546191939e6f0c2f17e29f542a398f4a52fb987bc70b6d1eb8b/jiter-0.13.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:0b34c519e17658ed88d5047999a93547f8889f3c1824120c26ad6be5f27b6cf5", size = 517775, upload-time = "2026-02-02T12:35:49.482Z" },
{ url = "https://files.pythonhosted.org/packages/4d/88/367ea2eb6bc582c7052e4baf5ddf57ebe5ab924a88e0e09830dfb585c02d/jiter-0.13.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:d2a6394e6af690d462310a86b53c47ad75ac8c21dc79f120714ea449979cb1d3", size = 551325, upload-time = "2026-02-02T12:35:51.104Z" },
{ url = "https://files.pythonhosted.org/packages/f3/12/fa377ffb94a2f28c41afaed093e0d70cfe512035d5ecb0cad0ae4792d35e/jiter-0.13.0-cp311-cp311-win32.whl", hash = "sha256:0f0c065695f616a27c920a56ad0d4fc46415ef8b806bf8fc1cacf25002bd24e1", size = 204709, upload-time = "2026-02-02T12:35:52.467Z" },
{ url = "https://files.pythonhosted.org/packages/cb/16/8e8203ce92f844dfcd3d9d6a5a7322c77077248dbb12da52d23193a839cd/jiter-0.13.0-cp311-cp311-win_amd64.whl", hash = "sha256:0733312953b909688ae3c2d58d043aa040f9f1a6a75693defed7bc2cc4bf2654", size = 204560, upload-time = "2026-02-02T12:35:53.925Z" },
{ url = "https://files.pythonhosted.org/packages/44/26/97cc40663deb17b9e13c3a5cf29251788c271b18ee4d262c8f94798b8336/jiter-0.13.0-cp311-cp311-win_arm64.whl", hash = "sha256:5d9b34ad56761b3bf0fbe8f7e55468704107608512350962d3317ffd7a4382d5", size = 189608, upload-time = "2026-02-02T12:35:55.304Z" },
{ url = "https://files.pythonhosted.org/packages/2e/30/7687e4f87086829955013ca12a9233523349767f69653ebc27036313def9/jiter-0.13.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:0a2bd69fc1d902e89925fc34d1da51b2128019423d7b339a45d9e99c894e0663", size = 307958, upload-time = "2026-02-02T12:35:57.165Z" },
{ url = "https://files.pythonhosted.org/packages/c3/27/e57f9a783246ed95481e6749cc5002a8a767a73177a83c63ea71f0528b90/jiter-0.13.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f917a04240ef31898182f76a332f508f2cc4b57d2b4d7ad2dbfebbfe167eb505", size = 318597, upload-time = "2026-02-02T12:35:58.591Z" },
{ url = "https://files.pythonhosted.org/packages/cf/52/e5719a60ac5d4d7c5995461a94ad5ef962a37c8bf5b088390e6fad59b2ff/jiter-0.13.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c1e2b199f446d3e82246b4fd9236d7cb502dc2222b18698ba0d986d2fecc6152", size = 348821, upload-time = "2026-02-02T12:36:00.093Z" },
{ url = "https://files.pythonhosted.org/packages/61/db/c1efc32b8ba4c740ab3fc2d037d8753f67685f475e26b9d6536a4322bcdd/jiter-0.13.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:04670992b576fa65bd056dbac0c39fe8bd67681c380cb2b48efa885711d9d726", size = 364163, upload-time = "2026-02-02T12:36:01.937Z" },
{ url = "https://files.pythonhosted.org/packages/55/8a/fb75556236047c8806995671a18e4a0ad646ed255276f51a20f32dceaeec/jiter-0.13.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5a1aff1fbdb803a376d4d22a8f63f8e7ccbce0b4890c26cc7af9e501ab339ef0", size = 483709, upload-time = "2026-02-02T12:36:03.41Z" },
{ url = "https://files.pythonhosted.org/packages/7e/16/43512e6ee863875693a8e6f6d532e19d650779d6ba9a81593ae40a9088ff/jiter-0.13.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3b3fb8c2053acaef8580809ac1d1f7481a0a0bdc012fd7f5d8b18fb696a5a089", size = 370480, upload-time = "2026-02-02T12:36:04.791Z" },
{ url = "https://files.pythonhosted.org/packages/f8/4c/09b93e30e984a187bc8aaa3510e1ec8dcbdcd71ca05d2f56aac0492453aa/jiter-0.13.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bdaba7d87e66f26a2c45d8cbadcbfc4bf7884182317907baf39cfe9775bb4d93", size = 360735, upload-time = "2026-02-02T12:36:06.994Z" },
{ url = "https://files.pythonhosted.org/packages/1a/1b/46c5e349019874ec5dfa508c14c37e29864ea108d376ae26d90bee238cd7/jiter-0.13.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7b88d649135aca526da172e48083da915ec086b54e8e73a425ba50999468cc08", size = 391814, upload-time = "2026-02-02T12:36:08.368Z" },
{ url = "https://files.pythonhosted.org/packages/15/9e/26184760e85baee7162ad37b7912797d2077718476bf91517641c92b3639/jiter-0.13.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:e404ea551d35438013c64b4f357b0474c7abf9f781c06d44fcaf7a14c69ff9e2", size = 513990, upload-time = "2026-02-02T12:36:09.993Z" },
{ url = "https://files.pythonhosted.org/packages/e9/34/2c9355247d6debad57a0a15e76ab1566ab799388042743656e566b3b7de1/jiter-0.13.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:1f4748aad1b4a93c8bdd70f604d0f748cdc0e8744c5547798acfa52f10e79228", size = 548021, upload-time = "2026-02-02T12:36:11.376Z" },
{ url = "https://files.pythonhosted.org/packages/ac/4a/9f2c23255d04a834398b9c2e0e665382116911dc4d06b795710503cdad25/jiter-0.13.0-cp312-cp312-win32.whl", hash = "sha256:0bf670e3b1445fc4d31612199f1744f67f889ee1bbae703c4b54dc097e5dd394", size = 203024, upload-time = "2026-02-02T12:36:12.682Z" },
{ url = "https://files.pythonhosted.org/packages/09/ee/f0ae675a957ae5a8f160be3e87acea6b11dc7b89f6b7ab057e77b2d2b13a/jiter-0.13.0-cp312-cp312-win_amd64.whl", hash = "sha256:15db60e121e11fe186c0b15236bd5d18381b9ddacdcf4e659feb96fc6c969c92", size = 205424, upload-time = "2026-02-02T12:36:13.93Z" },
{ url = "https://files.pythonhosted.org/packages/1b/02/ae611edf913d3cbf02c97cdb90374af2082c48d7190d74c1111dde08bcdd/jiter-0.13.0-cp312-cp312-win_arm64.whl", hash = "sha256:41f92313d17989102f3cb5dd533a02787cdb99454d494344b0361355da52fcb9", size = 186818, upload-time = "2026-02-02T12:36:15.308Z" },
{ url = "https://files.pythonhosted.org/packages/91/9c/7ee5a6ff4b9991e1a45263bfc46731634c4a2bde27dfda6c8251df2d958c/jiter-0.13.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1f8a55b848cbabf97d861495cd65f1e5c590246fabca8b48e1747c4dfc8f85bf", size = 306897, upload-time = "2026-02-02T12:36:16.748Z" },
{ url = "https://files.pythonhosted.org/packages/7c/02/be5b870d1d2be5dd6a91bdfb90f248fbb7dcbd21338f092c6b89817c3dbf/jiter-0.13.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f556aa591c00f2c45eb1b89f68f52441a016034d18b65da60e2d2875bbbf344a", size = 317507, upload-time = "2026-02-02T12:36:18.351Z" },
{ url = "https://files.pythonhosted.org/packages/da/92/b25d2ec333615f5f284f3a4024f7ce68cfa0604c322c6808b2344c7f5d2b/jiter-0.13.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f7e1d61da332ec412350463891923f960c3073cf1aae93b538f0bb4c8cd46efb", size = 350560, upload-time = "2026-02-02T12:36:19.746Z" },
{ url = "https://files.pythonhosted.org/packages/be/ec/74dcb99fef0aca9fbe56b303bf79f6bd839010cb18ad41000bf6cc71eec0/jiter-0.13.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3097d665a27bc96fd9bbf7f86178037db139f319f785e4757ce7ccbf390db6c2", size = 363232, upload-time = "2026-02-02T12:36:21.243Z" },
{ url = "https://files.pythonhosted.org/packages/1b/37/f17375e0bb2f6a812d4dd92d7616e41917f740f3e71343627da9db2824ce/jiter-0.13.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d01ecc3a8cbdb6f25a37bd500510550b64ddf9f7d64a107d92f3ccb25035d0f", size = 483727, upload-time = "2026-02-02T12:36:22.688Z" },
{ url = "https://files.pythonhosted.org/packages/77/d2/a71160a5ae1a1e66c1395b37ef77da67513b0adba73b993a27fbe47eb048/jiter-0.13.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ed9bbc30f5d60a3bdf63ae76beb3f9db280d7f195dfcfa61af792d6ce912d159", size = 370799, upload-time = "2026-02-02T12:36:24.106Z" },
{ url = "https://files.pythonhosted.org/packages/01/99/ed5e478ff0eb4e8aa5fd998f9d69603c9fd3f32de3bd16c2b1194f68361c/jiter-0.13.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:98fbafb6e88256f4454de33c1f40203d09fc33ed19162a68b3b257b29ca7f663", size = 359120, upload-time = "2026-02-02T12:36:25.519Z" },
{ url = "https://files.pythonhosted.org/packages/16/be/7ffd08203277a813f732ba897352797fa9493faf8dc7995b31f3d9cb9488/jiter-0.13.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5467696f6b827f1116556cb0db620440380434591e93ecee7fd14d1a491b6daa", size = 390664, upload-time = "2026-02-02T12:36:26.866Z" },
{ url = "https://files.pythonhosted.org/packages/d1/84/e0787856196d6d346264d6dcccb01f741e5f0bd014c1d9a2ebe149caf4f3/jiter-0.13.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:2d08c9475d48b92892583df9da592a0e2ac49bcd41fae1fec4f39ba6cf107820", size = 513543, upload-time = "2026-02-02T12:36:28.217Z" },
{ url = "https://files.pythonhosted.org/packages/65/50/ecbd258181c4313cf79bca6c88fb63207d04d5bf5e4f65174114d072aa55/jiter-0.13.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:aed40e099404721d7fcaf5b89bd3b4568a4666358bcac7b6b15c09fb6252ab68", size = 547262, upload-time = "2026-02-02T12:36:29.678Z" },
{ url = "https://files.pythonhosted.org/packages/27/da/68f38d12e7111d2016cd198161b36e1f042bd115c169255bcb7ec823a3bf/jiter-0.13.0-cp313-cp313-win32.whl", hash = "sha256:36ebfbcffafb146d0e6ffb3e74d51e03d9c35ce7c625c8066cdbfc7b953bdc72", size = 200630, upload-time = "2026-02-02T12:36:31.808Z" },
{ url = "https://files.pythonhosted.org/packages/25/65/3bd1a972c9a08ecd22eb3b08a95d1941ebe6938aea620c246cf426ae09c2/jiter-0.13.0-cp313-cp313-win_amd64.whl", hash = "sha256:8d76029f077379374cf0dbc78dbe45b38dec4a2eb78b08b5194ce836b2517afc", size = 202602, upload-time = "2026-02-02T12:36:33.679Z" },
{ url = "https://files.pythonhosted.org/packages/15/fe/13bd3678a311aa67686bb303654792c48206a112068f8b0b21426eb6851e/jiter-0.13.0-cp313-cp313-win_arm64.whl", hash = "sha256:bb7613e1a427cfcb6ea4544f9ac566b93d5bf67e0d48c787eca673ff9c9dff2b", size = 185939, upload-time = "2026-02-02T12:36:35.065Z" },
{ url = "https://files.pythonhosted.org/packages/49/19/a929ec002ad3228bc97ca01dbb14f7632fffdc84a95ec92ceaf4145688ae/jiter-0.13.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:fa476ab5dd49f3bf3a168e05f89358c75a17608dbabb080ef65f96b27c19ab10", size = 316616, upload-time = "2026-02-02T12:36:36.579Z" },
{ url = "https://files.pythonhosted.org/packages/52/56/d19a9a194afa37c1728831e5fb81b7722c3de18a3109e8f282bfc23e587a/jiter-0.13.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ade8cb6ff5632a62b7dbd4757d8c5573f7a2e9ae285d6b5b841707d8363205ef", size = 346850, upload-time = "2026-02-02T12:36:38.058Z" },
{ url = "https://files.pythonhosted.org/packages/36/4a/94e831c6bf287754a8a019cb966ed39ff8be6ab78cadecf08df3bb02d505/jiter-0.13.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9950290340acc1adaded363edd94baebcee7dabdfa8bee4790794cd5cfad2af6", size = 358551, upload-time = "2026-02-02T12:36:39.417Z" },
{ url = "https://files.pythonhosted.org/packages/a2/ec/a4c72c822695fa80e55d2b4142b73f0012035d9fcf90eccc56bc060db37c/jiter-0.13.0-cp313-cp313t-win_amd64.whl", hash = "sha256:2b4972c6df33731aac0742b64fd0d18e0a69bc7d6e03108ce7d40c85fd9e3e6d", size = 201950, upload-time = "2026-02-02T12:36:40.791Z" },
{ url = "https://files.pythonhosted.org/packages/b6/00/393553ec27b824fbc29047e9c7cd4a3951d7fbe4a76743f17e44034fa4e4/jiter-0.13.0-cp313-cp313t-win_arm64.whl", hash = "sha256:701a1e77d1e593c1b435315ff625fd071f0998c5f02792038a5ca98899261b7d", size = 185852, upload-time = "2026-02-02T12:36:42.077Z" },
{ url = "https://files.pythonhosted.org/packages/6e/f5/f1997e987211f6f9bd71b8083047b316208b4aca0b529bb5f8c96c89ef3e/jiter-0.13.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:cc5223ab19fe25e2f0bf2643204ad7318896fe3729bf12fde41b77bfc4fafff0", size = 308804, upload-time = "2026-02-02T12:36:43.496Z" },
{ url = "https://files.pythonhosted.org/packages/cd/8f/5482a7677731fd44881f0204981ce2d7175db271f82cba2085dd2212e095/jiter-0.13.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9776ebe51713acf438fd9b4405fcd86893ae5d03487546dae7f34993217f8a91", size = 318787, upload-time = "2026-02-02T12:36:45.071Z" },
{ url = "https://files.pythonhosted.org/packages/f3/b9/7257ac59778f1cd025b26a23c5520a36a424f7f1b068f2442a5b499b7464/jiter-0.13.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:879e768938e7b49b5e90b7e3fecc0dbec01b8cb89595861fb39a8967c5220d09", size = 353880, upload-time = "2026-02-02T12:36:47.365Z" },
{ url = "https://files.pythonhosted.org/packages/c3/87/719eec4a3f0841dad99e3d3604ee4cba36af4419a76f3cb0b8e2e691ad67/jiter-0.13.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:682161a67adea11e3aae9038c06c8b4a9a71023228767477d683f69903ebc607", size = 366702, upload-time = "2026-02-02T12:36:48.871Z" },
{ url = "https://files.pythonhosted.org/packages/d2/65/415f0a75cf6921e43365a1bc227c565cb949caca8b7532776e430cbaa530/jiter-0.13.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a13b68cd1cd8cc9de8f244ebae18ccb3e4067ad205220ef324c39181e23bbf66", size = 486319, upload-time = "2026-02-02T12:36:53.006Z" },
{ url = "https://files.pythonhosted.org/packages/54/a2/9e12b48e82c6bbc6081fd81abf915e1443add1b13d8fc586e1d90bb02bb8/jiter-0.13.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:87ce0f14c6c08892b610686ae8be350bf368467b6acd5085a5b65441e2bf36d2", size = 372289, upload-time = "2026-02-02T12:36:54.593Z" },
{ url = "https://files.pythonhosted.org/packages/4e/c1/e4693f107a1789a239c759a432e9afc592366f04e901470c2af89cfd28e1/jiter-0.13.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c365005b05505a90d1c47856420980d0237adf82f70c4aff7aebd3c1cc143ad", size = 360165, upload-time = "2026-02-02T12:36:56.112Z" },
{ url = "https://files.pythonhosted.org/packages/17/08/91b9ea976c1c758240614bd88442681a87672eebc3d9a6dde476874e706b/jiter-0.13.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1317fdffd16f5873e46ce27d0e0f7f4f90f0cdf1d86bf6abeaea9f63ca2c401d", size = 389634, upload-time = "2026-02-02T12:36:57.495Z" },
{ url = "https://files.pythonhosted.org/packages/18/23/58325ef99390d6d40427ed6005bf1ad54f2577866594bcf13ce55675f87d/jiter-0.13.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:c05b450d37ba0c9e21c77fef1f205f56bcee2330bddca68d344baebfc55ae0df", size = 514933, upload-time = "2026-02-02T12:36:58.909Z" },
{ url = "https://files.pythonhosted.org/packages/5b/25/69f1120c7c395fd276c3996bb8adefa9c6b84c12bb7111e5c6ccdcd8526d/jiter-0.13.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:775e10de3849d0631a97c603f996f518159272db00fdda0a780f81752255ee9d", size = 548842, upload-time = "2026-02-02T12:37:00.433Z" },
{ url = "https://files.pythonhosted.org/packages/18/05/981c9669d86850c5fbb0d9e62bba144787f9fba84546ba43d624ee27ef29/jiter-0.13.0-cp314-cp314-win32.whl", hash = "sha256:632bf7c1d28421c00dd8bbb8a3bac5663e1f57d5cd5ed962bce3c73bf62608e6", size = 202108, upload-time = "2026-02-02T12:37:01.718Z" },
{ url = "https://files.pythonhosted.org/packages/8d/96/cdcf54dd0b0341db7d25413229888a346c7130bd20820530905fdb65727b/jiter-0.13.0-cp314-cp314-win_amd64.whl", hash = "sha256:f22ef501c3f87ede88f23f9b11e608581c14f04db59b6a801f354397ae13739f", size = 204027, upload-time = "2026-02-02T12:37:03.075Z" },
{ url = "https://files.pythonhosted.org/packages/fb/f9/724bcaaab7a3cd727031fe4f6995cb86c4bd344909177c186699c8dec51a/jiter-0.13.0-cp314-cp314-win_arm64.whl", hash = "sha256:07b75fe09a4ee8e0c606200622e571e44943f47254f95e2436c8bdcaceb36d7d", size = 187199, upload-time = "2026-02-02T12:37:04.414Z" },
{ url = "https://files.pythonhosted.org/packages/62/92/1661d8b9fd6a3d7a2d89831db26fe3c1509a287d83ad7838831c7b7a5c7e/jiter-0.13.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:964538479359059a35fb400e769295d4b315ae61e4105396d355a12f7fef09f0", size = 318423, upload-time = "2026-02-02T12:37:05.806Z" },
{ url = "https://files.pythonhosted.org/packages/4f/3b/f77d342a54d4ebcd128e520fc58ec2f5b30a423b0fd26acdfc0c6fef8e26/jiter-0.13.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e104da1db1c0991b3eaed391ccd650ae8d947eab1480c733e5a3fb28d4313e40", size = 351438, upload-time = "2026-02-02T12:37:07.189Z" },
{ url = "https://files.pythonhosted.org/packages/76/b3/ba9a69f0e4209bd3331470c723c2f5509e6f0482e416b612431a5061ed71/jiter-0.13.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0e3a5f0cde8ff433b8e88e41aa40131455420fb3649a3c7abdda6145f8cb7202", size = 364774, upload-time = "2026-02-02T12:37:08.579Z" },
{ url = "https://files.pythonhosted.org/packages/b3/16/6cdb31fa342932602458dbb631bfbd47f601e03d2e4950740e0b2100b570/jiter-0.13.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:57aab48f40be1db920a582b30b116fe2435d184f77f0e4226f546794cedd9cf0", size = 487238, upload-time = "2026-02-02T12:37:10.066Z" },
{ url = "https://files.pythonhosted.org/packages/ed/b1/956cc7abaca8d95c13aa8d6c9b3f3797241c246cd6e792934cc4c8b250d2/jiter-0.13.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7772115877c53f62beeb8fd853cab692dbc04374ef623b30f997959a4c0e7e95", size = 372892, upload-time = "2026-02-02T12:37:11.656Z" },
{ url = "https://files.pythonhosted.org/packages/26/c4/97ecde8b1e74f67b8598c57c6fccf6df86ea7861ed29da84629cdbba76c4/jiter-0.13.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1211427574b17b633cfceba5040de8081e5abf114f7a7602f73d2e16f9fdaa59", size = 360309, upload-time = "2026-02-02T12:37:13.244Z" },
{ url = "https://files.pythonhosted.org/packages/4b/d7/eabe3cf46715854ccc80be2cd78dd4c36aedeb30751dbf85a1d08c14373c/jiter-0.13.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7beae3a3d3b5212d3a55d2961db3c292e02e302feb43fce6a3f7a31b90ea6dfe", size = 389607, upload-time = "2026-02-02T12:37:14.881Z" },
{ url = "https://files.pythonhosted.org/packages/df/2d/03963fc0804e6109b82decfb9974eb92df3797fe7222428cae12f8ccaa0c/jiter-0.13.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:e5562a0f0e90a6223b704163ea28e831bd3a9faa3512a711f031611e6b06c939", size = 514986, upload-time = "2026-02-02T12:37:16.326Z" },
{ url = "https://files.pythonhosted.org/packages/f6/6c/8c83b45eb3eb1c1e18d841fe30b4b5bc5619d781267ca9bc03e005d8fd0a/jiter-0.13.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:6c26a424569a59140fb51160a56df13f438a2b0967365e987889186d5fc2f6f9", size = 548756, upload-time = "2026-02-02T12:37:17.736Z" },
{ url = "https://files.pythonhosted.org/packages/47/66/eea81dfff765ed66c68fd2ed8c96245109e13c896c2a5015c7839c92367e/jiter-0.13.0-cp314-cp314t-win32.whl", hash = "sha256:24dc96eca9f84da4131cdf87a95e6ce36765c3b156fc9ae33280873b1c32d5f6", size = 201196, upload-time = "2026-02-02T12:37:19.101Z" },
{ url = "https://files.pythonhosted.org/packages/ff/32/4ac9c7a76402f8f00d00842a7f6b83b284d0cf7c1e9d4227bc95aa6d17fa/jiter-0.13.0-cp314-cp314t-win_amd64.whl", hash = "sha256:0a8d76c7524087272c8ae913f5d9d608bd839154b62c4322ef65723d2e5bb0b8", size = 204215, upload-time = "2026-02-02T12:37:20.495Z" },
{ url = "https://files.pythonhosted.org/packages/f9/8e/7def204fea9f9be8b3c21a6f2dd6c020cf56c7d5ff753e0e23ed7f9ea57e/jiter-0.13.0-cp314-cp314t-win_arm64.whl", hash = "sha256:2c26cf47e2cad140fa23b6d58d435a7c0161f5c514284802f25e87fddfe11024", size = 187152, upload-time = "2026-02-02T12:37:22.124Z" },
{ url = "https://files.pythonhosted.org/packages/79/b3/3c29819a27178d0e461a8571fb63c6ae38be6dc36b78b3ec2876bbd6a910/jiter-0.13.0-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:b1cbfa133241d0e6bdab48dcdc2604e8ba81512f6bbd68ec3e8e1357dd3c316c", size = 307016, upload-time = "2026-02-02T12:37:42.755Z" },
{ url = "https://files.pythonhosted.org/packages/eb/ae/60993e4b07b1ac5ebe46da7aa99fdbb802eb986c38d26e3883ac0125c4e0/jiter-0.13.0-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:db367d8be9fad6e8ebbac4a7578b7af562e506211036cba2c06c3b998603c3d2", size = 305024, upload-time = "2026-02-02T12:37:44.774Z" },
{ url = "https://files.pythonhosted.org/packages/77/fa/2227e590e9cf98803db2811f172b2d6460a21539ab73006f251c66f44b14/jiter-0.13.0-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:45f6f8efb2f3b0603092401dc2df79fa89ccbc027aaba4174d2d4133ed661434", size = 339337, upload-time = "2026-02-02T12:37:46.668Z" },
{ url = "https://files.pythonhosted.org/packages/2d/92/015173281f7eb96c0ef580c997da8ef50870d4f7f4c9e03c845a1d62ae04/jiter-0.13.0-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:597245258e6ad085d064780abfb23a284d418d3e61c57362d9449c6c7317ee2d", size = 346395, upload-time = "2026-02-02T12:37:48.09Z" },
{ url = "https://files.pythonhosted.org/packages/80/60/e50fa45dd7e2eae049f0ce964663849e897300433921198aef94b6ffa23a/jiter-0.13.0-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:3d744a6061afba08dd7ae375dcde870cffb14429b7477e10f67e9e6d68772a0a", size = 305169, upload-time = "2026-02-02T12:37:50.376Z" },
{ url = "https://files.pythonhosted.org/packages/d2/73/a009f41c5eed71c49bec53036c4b33555afcdee70682a18c6f66e396c039/jiter-0.13.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:ff732bd0a0e778f43d5009840f20b935e79087b4dc65bd36f1cd0f9b04b8ff7f", size = 303808, upload-time = "2026-02-02T12:37:52.092Z" },
{ url = "https://files.pythonhosted.org/packages/c4/10/528b439290763bff3d939268085d03382471b442f212dca4ff5f12802d43/jiter-0.13.0-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ab44b178f7981fcaea7e0a5df20e773c663d06ffda0198f1a524e91b2fde7e59", size = 337384, upload-time = "2026-02-02T12:37:53.582Z" },
{ 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 = "pycparser"
version = "3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" },
]
[[package]]
name = "pycryptodome"
version = "3.23.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/8e/a6/8452177684d5e906854776276ddd34eca30d1b1e15aa1ee9cefc289a33f5/pycryptodome-3.23.0.tar.gz", hash = "sha256:447700a657182d60338bab09fdb27518f8856aecd80ae4c6bdddb67ff5da44ef", size = 4921276, upload-time = "2025-05-17T17:21:45.242Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/04/5d/bdb09489b63cd34a976cc9e2a8d938114f7a53a74d3dd4f125ffa49dce82/pycryptodome-3.23.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:0011f7f00cdb74879142011f95133274741778abba114ceca229adbf8e62c3e4", size = 2495152, upload-time = "2025-05-17T17:20:20.833Z" },
{ url = "https://files.pythonhosted.org/packages/a7/ce/7840250ed4cc0039c433cd41715536f926d6e86ce84e904068eb3244b6a6/pycryptodome-3.23.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:90460fc9e088ce095f9ee8356722d4f10f86e5be06e2354230a9880b9c549aae", size = 1639348, upload-time = "2025-05-17T17:20:23.171Z" },
{ url = "https://files.pythonhosted.org/packages/ee/f0/991da24c55c1f688d6a3b5a11940567353f74590734ee4a64294834ae472/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4764e64b269fc83b00f682c47443c2e6e85b18273712b98aa43bcb77f8570477", size = 2184033, upload-time = "2025-05-17T17:20:25.424Z" },
{ url = "https://files.pythonhosted.org/packages/54/16/0e11882deddf00f68b68dd4e8e442ddc30641f31afeb2bc25588124ac8de/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eb8f24adb74984aa0e5d07a2368ad95276cf38051fe2dc6605cbcf482e04f2a7", size = 2270142, upload-time = "2025-05-17T17:20:27.808Z" },
{ url = "https://files.pythonhosted.org/packages/d5/fc/4347fea23a3f95ffb931f383ff28b3f7b1fe868739182cb76718c0da86a1/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d97618c9c6684a97ef7637ba43bdf6663a2e2e77efe0f863cce97a76af396446", size = 2309384, upload-time = "2025-05-17T17:20:30.765Z" },
{ url = "https://files.pythonhosted.org/packages/6e/d9/c5261780b69ce66d8cfab25d2797bd6e82ba0241804694cd48be41add5eb/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9a53a4fe5cb075075d515797d6ce2f56772ea7e6a1e5e4b96cf78a14bac3d265", size = 2183237, upload-time = "2025-05-17T17:20:33.736Z" },
{ url = "https://files.pythonhosted.org/packages/5a/6f/3af2ffedd5cfa08c631f89452c6648c4d779e7772dfc388c77c920ca6bbf/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:763d1d74f56f031788e5d307029caef067febf890cd1f8bf61183ae142f1a77b", size = 2343898, upload-time = "2025-05-17T17:20:36.086Z" },
{ url = "https://files.pythonhosted.org/packages/9a/dc/9060d807039ee5de6e2f260f72f3d70ac213993a804f5e67e0a73a56dd2f/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:954af0e2bd7cea83ce72243b14e4fb518b18f0c1649b576d114973e2073b273d", size = 2269197, upload-time = "2025-05-17T17:20:38.414Z" },
{ url = "https://files.pythonhosted.org/packages/f9/34/e6c8ca177cb29dcc4967fef73f5de445912f93bd0343c9c33c8e5bf8cde8/pycryptodome-3.23.0-cp313-cp313t-win32.whl", hash = "sha256:257bb3572c63ad8ba40b89f6fc9d63a2a628e9f9708d31ee26560925ebe0210a", size = 1768600, upload-time = "2025-05-17T17:20:40.688Z" },
{ url = "https://files.pythonhosted.org/packages/e4/1d/89756b8d7ff623ad0160f4539da571d1f594d21ee6d68be130a6eccb39a4/pycryptodome-3.23.0-cp313-cp313t-win_amd64.whl", hash = "sha256:6501790c5b62a29fcb227bd6b62012181d886a767ce9ed03b303d1f22eb5c625", size = 1799740, upload-time = "2025-05-17T17:20:42.413Z" },
{ url = "https://files.pythonhosted.org/packages/5d/61/35a64f0feaea9fd07f0d91209e7be91726eb48c0f1bfc6720647194071e4/pycryptodome-3.23.0-cp313-cp313t-win_arm64.whl", hash = "sha256:9a77627a330ab23ca43b48b130e202582e91cc69619947840ea4d2d1be21eb39", size = 1703685, upload-time = "2025-05-17T17:20:44.388Z" },
{ url = "https://files.pythonhosted.org/packages/db/6c/a1f71542c969912bb0e106f64f60a56cc1f0fabecf9396f45accbe63fa68/pycryptodome-3.23.0-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:187058ab80b3281b1de11c2e6842a357a1f71b42cb1e15bce373f3d238135c27", size = 2495627, upload-time = "2025-05-17T17:20:47.139Z" },
{ url = "https://files.pythonhosted.org/packages/6e/4e/a066527e079fc5002390c8acdd3aca431e6ea0a50ffd7201551175b47323/pycryptodome-3.23.0-cp37-abi3-macosx_10_9_x86_64.whl", hash = "sha256:cfb5cd445280c5b0a4e6187a7ce8de5a07b5f3f897f235caa11f1f435f182843", size = 1640362, upload-time = "2025-05-17T17:20:50.392Z" },
{ url = "https://files.pythonhosted.org/packages/50/52/adaf4c8c100a8c49d2bd058e5b551f73dfd8cb89eb4911e25a0c469b6b4e/pycryptodome-3.23.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:67bd81fcbe34f43ad9422ee8fd4843c8e7198dd88dd3d40e6de42ee65fbe1490", size = 2182625, upload-time = "2025-05-17T17:20:52.866Z" },
{ url = "https://files.pythonhosted.org/packages/5f/e9/a09476d436d0ff1402ac3867d933c61805ec2326c6ea557aeeac3825604e/pycryptodome-3.23.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c8987bd3307a39bc03df5c8e0e3d8be0c4c3518b7f044b0f4c15d1aa78f52575", size = 2268954, upload-time = "2025-05-17T17:20:55.027Z" },
{ url = "https://files.pythonhosted.org/packages/f9/c5/ffe6474e0c551d54cab931918127c46d70cab8f114e0c2b5a3c071c2f484/pycryptodome-3.23.0-cp37-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:aa0698f65e5b570426fc31b8162ed4603b0c2841cbb9088e2b01641e3065915b", size = 2308534, upload-time = "2025-05-17T17:20:57.279Z" },
{ url = "https://files.pythonhosted.org/packages/18/28/e199677fc15ecf43010f2463fde4c1a53015d1fe95fb03bca2890836603a/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:53ecbafc2b55353edcebd64bf5da94a2a2cdf5090a6915bcca6eca6cc452585a", size = 2181853, upload-time = "2025-05-17T17:20:59.322Z" },
{ url = "https://files.pythonhosted.org/packages/ce/ea/4fdb09f2165ce1365c9eaefef36625583371ee514db58dc9b65d3a255c4c/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_i686.whl", hash = "sha256:156df9667ad9f2ad26255926524e1c136d6664b741547deb0a86a9acf5ea631f", size = 2342465, upload-time = "2025-05-17T17:21:03.83Z" },
{ url = "https://files.pythonhosted.org/packages/22/82/6edc3fc42fe9284aead511394bac167693fb2b0e0395b28b8bedaa07ef04/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:dea827b4d55ee390dc89b2afe5927d4308a8b538ae91d9c6f7a5090f397af1aa", size = 2267414, upload-time = "2025-05-17T17:21:06.72Z" },
{ url = "https://files.pythonhosted.org/packages/59/fe/aae679b64363eb78326c7fdc9d06ec3de18bac68be4b612fc1fe8902693c/pycryptodome-3.23.0-cp37-abi3-win32.whl", hash = "sha256:507dbead45474b62b2bbe318eb1c4c8ee641077532067fec9c1aa82c31f84886", size = 1768484, upload-time = "2025-05-17T17:21:08.535Z" },
{ url = "https://files.pythonhosted.org/packages/54/2f/e97a1b8294db0daaa87012c24a7bb714147c7ade7656973fd6c736b484ff/pycryptodome-3.23.0-cp37-abi3-win_amd64.whl", hash = "sha256:c75b52aacc6c0c260f204cbdd834f76edc9fb0d8e0da9fbf8352ef58202564e2", size = 1799636, upload-time = "2025-05-17T17:21:10.393Z" },
{ url = "https://files.pythonhosted.org/packages/18/3d/f9441a0d798bf2b1e645adc3265e55706aead1255ccdad3856dbdcffec14/pycryptodome-3.23.0-cp37-abi3-win_arm64.whl", hash = "sha256:11eeeb6917903876f134b56ba11abe95c0b0fd5e3330def218083c7d98bbcb3c", size = 1703675, upload-time = "2025-05-17T17:21:13.146Z" },
{ url = "https://files.pythonhosted.org/packages/d9/12/e33935a0709c07de084d7d58d330ec3f4daf7910a18e77937affdb728452/pycryptodome-3.23.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:ddb95b49df036ddd264a0ad246d1be5b672000f12d6961ea2c267083a5e19379", size = 1623886, upload-time = "2025-05-17T17:21:20.614Z" },
{ url = "https://files.pythonhosted.org/packages/22/0b/aa8f9419f25870889bebf0b26b223c6986652bdf071f000623df11212c90/pycryptodome-3.23.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d8e95564beb8782abfd9e431c974e14563a794a4944c29d6d3b7b5ea042110b4", size = 1672151, upload-time = "2025-05-17T17:21:22.666Z" },
{ url = "https://files.pythonhosted.org/packages/d4/5e/63f5cbde2342b7f70a39e591dbe75d9809d6338ce0b07c10406f1a140cdc/pycryptodome-3.23.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:14e15c081e912c4b0d75632acd8382dfce45b258667aa3c67caf7a4d4c13f630", size = 1664461, upload-time = "2025-05-17T17:21:25.225Z" },
{ url = "https://files.pythonhosted.org/packages/d6/92/608fbdad566ebe499297a86aae5f2a5263818ceeecd16733006f1600403c/pycryptodome-3.23.0-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a7fc76bf273353dc7e5207d172b83f569540fc9a28d63171061c42e361d22353", size = 1702440, upload-time = "2025-05-17T17:21:27.991Z" },
{ url = "https://files.pythonhosted.org/packages/d1/92/2eadd1341abd2989cce2e2740b4423608ee2014acb8110438244ee97d7ff/pycryptodome-3.23.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:45c69ad715ca1a94f778215a11e66b7ff989d792a4d63b68dc586a1da1392ff5", size = 1803005, upload-time = "2025-05-17T17:21:31.37Z" },
]
[[package]]
name = "pydantic"
version = "2.12.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "annotated-types" },
{ name = "pydantic-core" },
{ name = "typing-extensions" },
{ name = "typing-inspection" },
]
sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" },
]
[[package]]
name = "pydantic-core"
version = "2.41.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c6/90/32c9941e728d564b411d574d8ee0cf09b12ec978cb22b294995bae5549a5/pydantic_core-2.41.5-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:77b63866ca88d804225eaa4af3e664c5faf3568cea95360d21f4725ab6e07146", size = 2107298, upload-time = "2025-11-04T13:39:04.116Z" },
{ url = "https://files.pythonhosted.org/packages/fb/a8/61c96a77fe28993d9a6fb0f4127e05430a267b235a124545d79fea46dd65/pydantic_core-2.41.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:dfa8a0c812ac681395907e71e1274819dec685fec28273a28905df579ef137e2", size = 1901475, upload-time = "2025-11-04T13:39:06.055Z" },
{ url = "https://files.pythonhosted.org/packages/5d/b6/338abf60225acc18cdc08b4faef592d0310923d19a87fba1faf05af5346e/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5921a4d3ca3aee735d9fd163808f5e8dd6c6972101e4adbda9a4667908849b97", size = 1918815, upload-time = "2025-11-04T13:39:10.41Z" },
{ url = "https://files.pythonhosted.org/packages/d1/1c/2ed0433e682983d8e8cba9c8d8ef274d4791ec6a6f24c58935b90e780e0a/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e25c479382d26a2a41b7ebea1043564a937db462816ea07afa8a44c0866d52f9", size = 2065567, upload-time = "2025-11-04T13:39:12.244Z" },
{ url = "https://files.pythonhosted.org/packages/b3/24/cf84974ee7d6eae06b9e63289b7b8f6549d416b5c199ca2d7ce13bbcf619/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f547144f2966e1e16ae626d8ce72b4cfa0caedc7fa28052001c94fb2fcaa1c52", size = 2230442, upload-time = "2025-11-04T13:39:13.962Z" },
{ url = "https://files.pythonhosted.org/packages/fd/21/4e287865504b3edc0136c89c9c09431be326168b1eb7841911cbc877a995/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6f52298fbd394f9ed112d56f3d11aabd0d5bd27beb3084cc3d8ad069483b8941", size = 2350956, upload-time = "2025-11-04T13:39:15.889Z" },
{ url = "https://files.pythonhosted.org/packages/a8/76/7727ef2ffa4b62fcab916686a68a0426b9b790139720e1934e8ba797e238/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:100baa204bb412b74fe285fb0f3a385256dad1d1879f0a5cb1499ed2e83d132a", size = 2068253, upload-time = "2025-11-04T13:39:17.403Z" },
{ url = "https://files.pythonhosted.org/packages/d5/8c/a4abfc79604bcb4c748e18975c44f94f756f08fb04218d5cb87eb0d3a63e/pydantic_core-2.41.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:05a2c8852530ad2812cb7914dc61a1125dc4e06252ee98e5638a12da6cc6fb6c", size = 2177050, upload-time = "2025-11-04T13:39:19.351Z" },
{ url = "https://files.pythonhosted.org/packages/67/b1/de2e9a9a79b480f9cb0b6e8b6ba4c50b18d4e89852426364c66aa82bb7b3/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:29452c56df2ed968d18d7e21f4ab0ac55e71dc59524872f6fc57dcf4a3249ed2", size = 2147178, upload-time = "2025-11-04T13:39:21Z" },
{ url = "https://files.pythonhosted.org/packages/16/c1/dfb33f837a47b20417500efaa0378adc6635b3c79e8369ff7a03c494b4ac/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:d5160812ea7a8a2ffbe233d8da666880cad0cbaf5d4de74ae15c313213d62556", size = 2341833, upload-time = "2025-11-04T13:39:22.606Z" },
{ url = "https://files.pythonhosted.org/packages/47/36/00f398642a0f4b815a9a558c4f1dca1b4020a7d49562807d7bc9ff279a6c/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:df3959765b553b9440adfd3c795617c352154e497a4eaf3752555cfb5da8fc49", size = 2321156, upload-time = "2025-11-04T13:39:25.843Z" },
{ url = "https://files.pythonhosted.org/packages/7e/70/cad3acd89fde2010807354d978725ae111ddf6d0ea46d1ea1775b5c1bd0c/pydantic_core-2.41.5-cp310-cp310-win32.whl", hash = "sha256:1f8d33a7f4d5a7889e60dc39856d76d09333d8a6ed0f5f1190635cbec70ec4ba", size = 1989378, upload-time = "2025-11-04T13:39:27.92Z" },
{ url = "https://files.pythonhosted.org/packages/76/92/d338652464c6c367e5608e4488201702cd1cbb0f33f7b6a85a60fe5f3720/pydantic_core-2.41.5-cp310-cp310-win_amd64.whl", hash = "sha256:62de39db01b8d593e45871af2af9e497295db8d73b085f6bfd0b18c83c70a8f9", size = 2013622, upload-time = "2025-11-04T13:39:29.848Z" },
{ url = "https://files.pythonhosted.org/packages/e8/72/74a989dd9f2084b3d9530b0915fdda64ac48831c30dbf7c72a41a5232db8/pydantic_core-2.41.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a3a52f6156e73e7ccb0f8cced536adccb7042be67cb45f9562e12b319c119da6", size = 2105873, upload-time = "2025-11-04T13:39:31.373Z" },
{ url = "https://files.pythonhosted.org/packages/12/44/37e403fd9455708b3b942949e1d7febc02167662bf1a7da5b78ee1ea2842/pydantic_core-2.41.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7f3bf998340c6d4b0c9a2f02d6a400e51f123b59565d74dc60d252ce888c260b", size = 1899826, upload-time = "2025-11-04T13:39:32.897Z" },
{ url = "https://files.pythonhosted.org/packages/33/7f/1d5cab3ccf44c1935a359d51a8a2a9e1a654b744b5e7f80d41b88d501eec/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:378bec5c66998815d224c9ca994f1e14c0c21cb95d2f52b6021cc0b2a58f2a5a", size = 1917869, upload-time = "2025-11-04T13:39:34.469Z" },
{ url = "https://files.pythonhosted.org/packages/6e/6a/30d94a9674a7fe4f4744052ed6c5e083424510be1e93da5bc47569d11810/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b576130c69225432866fe2f4a469a85a54ade141d96fd396dffcf607b558f8", size = 2063890, upload-time = "2025-11-04T13:39:36.053Z" },
{ url = "https://files.pythonhosted.org/packages/50/be/76e5d46203fcb2750e542f32e6c371ffa9b8ad17364cf94bb0818dbfb50c/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6cb58b9c66f7e4179a2d5e0f849c48eff5c1fca560994d6eb6543abf955a149e", size = 2229740, upload-time = "2025-11-04T13:39:37.753Z" },
{ url = "https://files.pythonhosted.org/packages/d3/ee/fed784df0144793489f87db310a6bbf8118d7b630ed07aa180d6067e653a/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:88942d3a3dff3afc8288c21e565e476fc278902ae4d6d134f1eeda118cc830b1", size = 2350021, upload-time = "2025-11-04T13:39:40.94Z" },
{ url = "https://files.pythonhosted.org/packages/c8/be/8fed28dd0a180dca19e72c233cbf58efa36df055e5b9d90d64fd1740b828/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f31d95a179f8d64d90f6831d71fa93290893a33148d890ba15de25642c5d075b", size = 2066378, upload-time = "2025-11-04T13:39:42.523Z" },
{ url = "https://files.pythonhosted.org/packages/b0/3b/698cf8ae1d536a010e05121b4958b1257f0b5522085e335360e53a6b1c8b/pydantic_core-2.41.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c1df3d34aced70add6f867a8cf413e299177e0c22660cc767218373d0779487b", size = 2175761, upload-time = "2025-11-04T13:39:44.553Z" },
{ url = "https://files.pythonhosted.org/packages/b8/ba/15d537423939553116dea94ce02f9c31be0fa9d0b806d427e0308ec17145/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4009935984bd36bd2c774e13f9a09563ce8de4abaa7226f5108262fa3e637284", size = 2146303, upload-time = "2025-11-04T13:39:46.238Z" },
{ url = "https://files.pythonhosted.org/packages/58/7f/0de669bf37d206723795f9c90c82966726a2ab06c336deba4735b55af431/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:34a64bc3441dc1213096a20fe27e8e128bd3ff89921706e83c0b1ac971276594", size = 2340355, upload-time = "2025-11-04T13:39:48.002Z" },
{ url = "https://files.pythonhosted.org/packages/e5/de/e7482c435b83d7e3c3ee5ee4451f6e8973cff0eb6007d2872ce6383f6398/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c9e19dd6e28fdcaa5a1de679aec4141f691023916427ef9bae8584f9c2fb3b0e", size = 2319875, upload-time = "2025-11-04T13:39:49.705Z" },
{ url = "https://files.pythonhosted.org/packages/fe/e6/8c9e81bb6dd7560e33b9053351c29f30c8194b72f2d6932888581f503482/pydantic_core-2.41.5-cp311-cp311-win32.whl", hash = "sha256:2c010c6ded393148374c0f6f0bf89d206bf3217f201faa0635dcd56bd1520f6b", size = 1987549, upload-time = "2025-11-04T13:39:51.842Z" },
{ url = "https://files.pythonhosted.org/packages/11/66/f14d1d978ea94d1bc21fc98fcf570f9542fe55bfcc40269d4e1a21c19bf7/pydantic_core-2.41.5-cp311-cp311-win_amd64.whl", hash = "sha256:76ee27c6e9c7f16f47db7a94157112a2f3a00e958bc626e2f4ee8bec5c328fbe", size = 2011305, upload-time = "2025-11-04T13:39:53.485Z" },
{ url = "https://files.pythonhosted.org/packages/56/d8/0e271434e8efd03186c5386671328154ee349ff0354d83c74f5caaf096ed/pydantic_core-2.41.5-cp311-cp311-win_arm64.whl", hash = "sha256:4bc36bbc0b7584de96561184ad7f012478987882ebf9f9c389b23f432ea3d90f", size = 1972902, upload-time = "2025-11-04T13:39:56.488Z" },
{ url = "https://files.pythonhosted.org/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", size = 2110990, upload-time = "2025-11-04T13:39:58.079Z" },
{ url = "https://files.pythonhosted.org/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", size = 1896003, upload-time = "2025-11-04T13:39:59.956Z" },
{ url = "https://files.pythonhosted.org/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", size = 1919200, upload-time = "2025-11-04T13:40:02.241Z" },
{ url = "https://files.pythonhosted.org/packages/38/de/8c36b5198a29bdaade07b5985e80a233a5ac27137846f3bc2d3b40a47360/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75", size = 2052578, upload-time = "2025-11-04T13:40:04.401Z" },
{ url = "https://files.pythonhosted.org/packages/00/b5/0e8e4b5b081eac6cb3dbb7e60a65907549a1ce035a724368c330112adfdd/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05", size = 2208504, upload-time = "2025-11-04T13:40:06.072Z" },
{ url = "https://files.pythonhosted.org/packages/77/56/87a61aad59c7c5b9dc8caad5a41a5545cba3810c3e828708b3d7404f6cef/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc", size = 2335816, upload-time = "2025-11-04T13:40:07.835Z" },
{ url = "https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c", size = 2075366, upload-time = "2025-11-04T13:40:09.804Z" },
{ url = "https://files.pythonhosted.org/packages/d3/43/ebef01f69baa07a482844faaa0a591bad1ef129253ffd0cdaa9d8a7f72d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5", size = 2171698, upload-time = "2025-11-04T13:40:12.004Z" },
{ url = "https://files.pythonhosted.org/packages/b1/87/41f3202e4193e3bacfc2c065fab7706ebe81af46a83d3e27605029c1f5a6/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c", size = 2132603, upload-time = "2025-11-04T13:40:13.868Z" },
{ url = "https://files.pythonhosted.org/packages/49/7d/4c00df99cb12070b6bccdef4a195255e6020a550d572768d92cc54dba91a/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294", size = 2329591, upload-time = "2025-11-04T13:40:15.672Z" },
{ url = "https://files.pythonhosted.org/packages/cc/6a/ebf4b1d65d458f3cda6a7335d141305dfa19bdc61140a884d165a8a1bbc7/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1", size = 2319068, upload-time = "2025-11-04T13:40:17.532Z" },
{ url = "https://files.pythonhosted.org/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d", size = 1985908, upload-time = "2025-11-04T13:40:19.309Z" },
{ url = "https://files.pythonhosted.org/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815", size = 2020145, upload-time = "2025-11-04T13:40:21.548Z" },
{ url = "https://files.pythonhosted.org/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3", size = 1976179, upload-time = "2025-11-04T13:40:23.393Z" },
{ url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403, upload-time = "2025-11-04T13:40:25.248Z" },
{ url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206, upload-time = "2025-11-04T13:40:27.099Z" },
{ url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307, upload-time = "2025-11-04T13:40:29.806Z" },
{ url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258, upload-time = "2025-11-04T13:40:33.544Z" },
{ url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917, upload-time = "2025-11-04T13:40:35.479Z" },
{ url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186, upload-time = "2025-11-04T13:40:37.436Z" },
{ url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164, upload-time = "2025-11-04T13:40:40.289Z" },
{ url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146, upload-time = "2025-11-04T13:40:42.809Z" },
{ url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788, upload-time = "2025-11-04T13:40:44.752Z" },
{ url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133, upload-time = "2025-11-04T13:40:46.66Z" },
{ url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852, upload-time = "2025-11-04T13:40:48.575Z" },
{ url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679, upload-time = "2025-11-04T13:40:50.619Z" },
{ url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766, upload-time = "2025-11-04T13:40:52.631Z" },
{ url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005, upload-time = "2025-11-04T13:40:54.734Z" },
{ url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" },
{ url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" },
{ url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" },
{ url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691, upload-time = "2025-11-04T13:41:03.504Z" },
{ url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897, upload-time = "2025-11-04T13:41:05.804Z" },
{ url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302, upload-time = "2025-11-04T13:41:07.809Z" },
{ url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877, upload-time = "2025-11-04T13:41:09.827Z" },
{ url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680, upload-time = "2025-11-04T13:41:12.379Z" },
{ url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960, upload-time = "2025-11-04T13:41:14.627Z" },
{ url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102, upload-time = "2025-11-04T13:41:16.868Z" },
{ url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039, upload-time = "2025-11-04T13:41:18.934Z" },
{ url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126, upload-time = "2025-11-04T13:41:21.418Z" },
{ url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489, upload-time = "2025-11-04T13:41:24.076Z" },
{ url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288, upload-time = "2025-11-04T13:41:26.33Z" },
{ url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255, upload-time = "2025-11-04T13:41:28.569Z" },
{ url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760, upload-time = "2025-11-04T13:41:31.055Z" },
{ url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092, upload-time = "2025-11-04T13:41:33.21Z" },
{ url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385, upload-time = "2025-11-04T13:41:35.508Z" },
{ url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832, upload-time = "2025-11-04T13:41:37.732Z" },
{ url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585, upload-time = "2025-11-04T13:41:40Z" },
{ url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078, upload-time = "2025-11-04T13:41:42.323Z" },
{ url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914, upload-time = "2025-11-04T13:41:45.221Z" },
{ url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560, upload-time = "2025-11-04T13:41:47.474Z" },
{ url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244, upload-time = "2025-11-04T13:41:49.992Z" },
{ url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955, upload-time = "2025-11-04T13:41:54.079Z" },
{ url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" },
{ url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" },
{ url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" },
{ url = "https://files.pythonhosted.org/packages/11/72/90fda5ee3b97e51c494938a4a44c3a35a9c96c19bba12372fb9c634d6f57/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:b96d5f26b05d03cc60f11a7761a5ded1741da411e7fe0909e27a5e6a0cb7b034", size = 2115441, upload-time = "2025-11-04T13:42:39.557Z" },
{ url = "https://files.pythonhosted.org/packages/1f/53/8942f884fa33f50794f119012dc6a1a02ac43a56407adaac20463df8e98f/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:634e8609e89ceecea15e2d61bc9ac3718caaaa71963717bf3c8f38bfde64242c", size = 1930291, upload-time = "2025-11-04T13:42:42.169Z" },
{ url = "https://files.pythonhosted.org/packages/79/c8/ecb9ed9cd942bce09fc888ee960b52654fbdbede4ba6c2d6e0d3b1d8b49c/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:93e8740d7503eb008aa2df04d3b9735f845d43ae845e6dcd2be0b55a2da43cd2", size = 1948632, upload-time = "2025-11-04T13:42:44.564Z" },
{ url = "https://files.pythonhosted.org/packages/2e/1b/687711069de7efa6af934e74f601e2a4307365e8fdc404703afc453eab26/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f15489ba13d61f670dcc96772e733aad1a6f9c429cc27574c6cdaed82d0146ad", size = 2138905, upload-time = "2025-11-04T13:42:47.156Z" },
{ url = "https://files.pythonhosted.org/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd", size = 2110495, upload-time = "2025-11-04T13:42:49.689Z" },
{ url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388, upload-time = "2025-11-04T13:42:52.215Z" },
{ url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879, upload-time = "2025-11-04T13:42:56.483Z" },
{ url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" },
{ url = "https://files.pythonhosted.org/packages/e6/b0/1a2aa41e3b5a4ba11420aba2d091b2d17959c8d1519ece3627c371951e73/pydantic_core-2.41.5-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b5819cd790dbf0c5eb9f82c73c16b39a65dd6dd4d1439dcdea7816ec9adddab8", size = 2103351, upload-time = "2025-11-04T13:43:02.058Z" },
{ url = "https://files.pythonhosted.org/packages/a4/ee/31b1f0020baaf6d091c87900ae05c6aeae101fa4e188e1613c80e4f1ea31/pydantic_core-2.41.5-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:5a4e67afbc95fa5c34cf27d9089bca7fcab4e51e57278d710320a70b956d1b9a", size = 1925363, upload-time = "2025-11-04T13:43:05.159Z" },
{ url = "https://files.pythonhosted.org/packages/e1/89/ab8e86208467e467a80deaca4e434adac37b10a9d134cd2f99b28a01e483/pydantic_core-2.41.5-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ece5c59f0ce7d001e017643d8d24da587ea1f74f6993467d85ae8a5ef9d4f42b", size = 2135615, upload-time = "2025-11-04T13:43:08.116Z" },
{ url = "https://files.pythonhosted.org/packages/99/0a/99a53d06dd0348b2008f2f30884b34719c323f16c3be4e6cc1203b74a91d/pydantic_core-2.41.5-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:16f80f7abe3351f8ea6858914ddc8c77e02578544a0ebc15b4c2e1a0e813b0b2", size = 2175369, upload-time = "2025-11-04T13:43:12.49Z" },
{ url = "https://files.pythonhosted.org/packages/6d/94/30ca3b73c6d485b9bb0bc66e611cff4a7138ff9736b7e66bcf0852151636/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:33cb885e759a705b426baada1fe68cbb0a2e68e34c5d0d0289a364cf01709093", size = 2144218, upload-time = "2025-11-04T13:43:15.431Z" },
{ url = "https://files.pythonhosted.org/packages/87/57/31b4f8e12680b739a91f472b5671294236b82586889ef764b5fbc6669238/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:c8d8b4eb992936023be7dee581270af5c6e0697a8559895f527f5b7105ecd36a", size = 2329951, upload-time = "2025-11-04T13:43:18.062Z" },
{ url = "https://files.pythonhosted.org/packages/7d/73/3c2c8edef77b8f7310e6fb012dbc4b8551386ed575b9eb6fb2506e28a7eb/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:242a206cd0318f95cd21bdacff3fcc3aab23e79bba5cac3db5a841c9ef9c6963", size = 2318428, upload-time = "2025-11-04T13:43:20.679Z" },
{ url = "https://files.pythonhosted.org/packages/2f/02/8559b1f26ee0d502c74f9cca5c0d2fd97e967e083e006bbbb4e97f3a043a/pydantic_core-2.41.5-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:d3a978c4f57a597908b7e697229d996d77a6d3c94901e9edee593adada95ce1a", size = 2147009, upload-time = "2025-11-04T13:43:23.286Z" },
{ url = "https://files.pythonhosted.org/packages/5f/9b/1b3f0e9f9305839d7e84912f9e8bfbd191ed1b1ef48083609f0dabde978c/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b2379fa7ed44ddecb5bfe4e48577d752db9fc10be00a6b7446e9663ba143de26", size = 2101980, upload-time = "2025-11-04T13:43:25.97Z" },
{ url = "https://files.pythonhosted.org/packages/a4/ed/d71fefcb4263df0da6a85b5d8a7508360f2f2e9b3bf5814be9c8bccdccc1/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:266fb4cbf5e3cbd0b53669a6d1b039c45e3ce651fd5442eff4d07c2cc8d66808", size = 1923865, upload-time = "2025-11-04T13:43:28.763Z" },
{ url = "https://files.pythonhosted.org/packages/ce/3a/626b38db460d675f873e4444b4bb030453bbe7b4ba55df821d026a0493c4/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58133647260ea01e4d0500089a8c4f07bd7aa6ce109682b1426394988d8aaacc", size = 2134256, upload-time = "2025-11-04T13:43:31.71Z" },
{ url = "https://files.pythonhosted.org/packages/83/d9/8412d7f06f616bbc053d30cb4e5f76786af3221462ad5eee1f202021eb4e/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:287dad91cfb551c363dc62899a80e9e14da1f0e2b6ebde82c806612ca2a13ef1", size = 2174762, upload-time = "2025-11-04T13:43:34.744Z" },
{ url = "https://files.pythonhosted.org/packages/55/4c/162d906b8e3ba3a99354e20faa1b49a85206c47de97a639510a0e673f5da/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:03b77d184b9eb40240ae9fd676ca364ce1085f203e1b1256f8ab9984dca80a84", size = 2143141, upload-time = "2025-11-04T13:43:37.701Z" },
{ url = "https://files.pythonhosted.org/packages/1f/f2/f11dd73284122713f5f89fc940f370d035fa8e1e078d446b3313955157fe/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:a668ce24de96165bb239160b3d854943128f4334822900534f2fe947930e5770", size = 2330317, upload-time = "2025-11-04T13:43:40.406Z" },
{ url = "https://files.pythonhosted.org/packages/88/9d/b06ca6acfe4abb296110fb1273a4d848a0bfb2ff65f3ee92127b3244e16b/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f14f8f046c14563f8eb3f45f499cc658ab8d10072961e07225e507adb700e93f", size = 2316992, upload-time = "2025-11-04T13:43:43.602Z" },
{ 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 = "pynacl"
version = "1.6.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/d9/9a/4019b524b03a13438637b11538c82781a5eda427394380381af8f04f467a/pynacl-1.6.2.tar.gz", hash = "sha256:018494d6d696ae03c7e656e5e74cdfd8ea1326962cc401bcf018f1ed8436811c", size = 3511692, upload-time = "2026-01-01T17:48:10.851Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/4b/79/0e3c34dc3c4671f67d251c07aa8eb100916f250ee470df230b0ab89551b4/pynacl-1.6.2-cp314-cp314t-macosx_10_10_universal2.whl", hash = "sha256:622d7b07cc5c02c666795792931b50c91f3ce3c2649762efb1ef0d5684c81594", size = 390064, upload-time = "2026-01-01T17:31:57.264Z" },
{ url = "https://files.pythonhosted.org/packages/eb/1c/23a26e931736e13b16483795c8a6b2f641bf6a3d5238c22b070a5112722c/pynacl-1.6.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d071c6a9a4c94d79eb665db4ce5cedc537faf74f2355e4d502591d850d3913c0", size = 809370, upload-time = "2026-01-01T17:31:59.198Z" },
{ url = "https://files.pythonhosted.org/packages/87/74/8d4b718f8a22aea9e8dcc8b95deb76d4aae380e2f5b570cc70b5fd0a852d/pynacl-1.6.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fe9847ca47d287af41e82be1dd5e23023d3c31a951da134121ab02e42ac218c9", size = 1408304, upload-time = "2026-01-01T17:32:01.162Z" },
{ url = "https://files.pythonhosted.org/packages/fd/73/be4fdd3a6a87fe8a4553380c2b47fbd1f7f58292eb820902f5c8ac7de7b0/pynacl-1.6.2-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:04316d1fc625d860b6c162fff704eb8426b1a8bcd3abacea11142cbd99a6b574", size = 844871, upload-time = "2026-01-01T17:32:02.824Z" },
{ url = "https://files.pythonhosted.org/packages/55/ad/6efc57ab75ee4422e96b5f2697d51bbcf6cdcc091e66310df91fbdc144a8/pynacl-1.6.2-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:44081faff368d6c5553ccf55322ef2819abb40e25afaec7e740f159f74813634", size = 1446356, upload-time = "2026-01-01T17:32:04.452Z" },
{ url = "https://files.pythonhosted.org/packages/78/b7/928ee9c4779caa0a915844311ab9fb5f99585621c5d6e4574538a17dca07/pynacl-1.6.2-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:a9f9932d8d2811ce1a8ffa79dcbdf3970e7355b5c8eb0c1a881a57e7f7d96e88", size = 826814, upload-time = "2026-01-01T17:32:06.078Z" },
{ url = "https://files.pythonhosted.org/packages/f7/a9/1bdba746a2be20f8809fee75c10e3159d75864ef69c6b0dd168fc60e485d/pynacl-1.6.2-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:bc4a36b28dd72fb4845e5d8f9760610588a96d5a51f01d84d8c6ff9849968c14", size = 1411742, upload-time = "2026-01-01T17:32:07.651Z" },
{ url = "https://files.pythonhosted.org/packages/f3/2f/5e7ea8d85f9f3ea5b6b87db1d8388daa3587eed181bdeb0306816fdbbe79/pynacl-1.6.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3bffb6d0f6becacb6526f8f42adfb5efb26337056ee0831fb9a7044d1a964444", size = 801714, upload-time = "2026-01-01T17:32:09.558Z" },
{ url = "https://files.pythonhosted.org/packages/06/ea/43fe2f7eab5f200e40fb10d305bf6f87ea31b3bbc83443eac37cd34a9e1e/pynacl-1.6.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2fef529ef3ee487ad8113d287a593fa26f48ee3620d92ecc6f1d09ea38e0709b", size = 1372257, upload-time = "2026-01-01T17:32:11.026Z" },
{ url = "https://files.pythonhosted.org/packages/4d/54/c9ea116412788629b1347e415f72195c25eb2f3809b2d3e7b25f5c79f13a/pynacl-1.6.2-cp314-cp314t-win32.whl", hash = "sha256:a84bf1c20339d06dc0c85d9aea9637a24f718f375d861b2668b2f9f96fa51145", size = 231319, upload-time = "2026-01-01T17:32:12.46Z" },
{ url = "https://files.pythonhosted.org/packages/ce/04/64e9d76646abac2dccf904fccba352a86e7d172647557f35b9fe2a5ee4a1/pynacl-1.6.2-cp314-cp314t-win_amd64.whl", hash = "sha256:320ef68a41c87547c91a8b58903c9caa641ab01e8512ce291085b5fe2fcb7590", size = 244044, upload-time = "2026-01-01T17:32:13.781Z" },
{ url = "https://files.pythonhosted.org/packages/33/33/7873dc161c6a06f43cda13dec67b6fe152cb2f982581151956fa5e5cdb47/pynacl-1.6.2-cp314-cp314t-win_arm64.whl", hash = "sha256:d29bfe37e20e015a7d8b23cfc8bd6aa7909c92a1b8f41ee416bbb3e79ef182b2", size = 188740, upload-time = "2026-01-01T17:32:15.083Z" },
{ url = "https://files.pythonhosted.org/packages/be/7b/4845bbf88e94586ec47a432da4e9107e3fc3ce37eb412b1398630a37f7dd/pynacl-1.6.2-cp38-abi3-macosx_10_10_universal2.whl", hash = "sha256:c949ea47e4206af7c8f604b8278093b674f7c79ed0d4719cc836902bf4517465", size = 388458, upload-time = "2026-01-01T17:32:16.829Z" },
{ url = "https://files.pythonhosted.org/packages/1e/b4/e927e0653ba63b02a4ca5b4d852a8d1d678afbf69b3dbf9c4d0785ac905c/pynacl-1.6.2-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8845c0631c0be43abdd865511c41eab235e0be69c81dc66a50911594198679b0", size = 800020, upload-time = "2026-01-01T17:32:18.34Z" },
{ url = "https://files.pythonhosted.org/packages/7f/81/d60984052df5c97b1d24365bc1e30024379b42c4edcd79d2436b1b9806f2/pynacl-1.6.2-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:22de65bb9010a725b0dac248f353bb072969c94fa8d6b1f34b87d7953cf7bbe4", size = 1399174, upload-time = "2026-01-01T17:32:20.239Z" },
{ url = "https://files.pythonhosted.org/packages/68/f7/322f2f9915c4ef27d140101dd0ed26b479f7e6f5f183590fd32dfc48c4d3/pynacl-1.6.2-cp38-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:46065496ab748469cdd999246d17e301b2c24ae2fdf739132e580a0e94c94a87", size = 835085, upload-time = "2026-01-01T17:32:22.24Z" },
{ url = "https://files.pythonhosted.org/packages/3e/d0/f301f83ac8dbe53442c5a43f6a39016f94f754d7a9815a875b65e218a307/pynacl-1.6.2-cp38-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8a66d6fb6ae7661c58995f9c6435bda2b1e68b54b598a6a10247bfcdadac996c", size = 1437614, upload-time = "2026-01-01T17:32:23.766Z" },
{ url = "https://files.pythonhosted.org/packages/c4/58/fc6e649762b029315325ace1a8c6be66125e42f67416d3dbd47b69563d61/pynacl-1.6.2-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:26bfcd00dcf2cf160f122186af731ae30ab120c18e8375684ec2670dccd28130", size = 818251, upload-time = "2026-01-01T17:32:25.69Z" },
{ url = "https://files.pythonhosted.org/packages/c9/a8/b917096b1accc9acd878819a49d3d84875731a41eb665f6ebc826b1af99e/pynacl-1.6.2-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:c8a231e36ec2cab018c4ad4358c386e36eede0319a0c41fed24f840b1dac59f6", size = 1402859, upload-time = "2026-01-01T17:32:27.215Z" },
{ url = "https://files.pythonhosted.org/packages/85/42/fe60b5f4473e12c72f977548e4028156f4d340b884c635ec6b063fe7e9a5/pynacl-1.6.2-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:68be3a09455743ff9505491220b64440ced8973fe930f270c8e07ccfa25b1f9e", size = 791926, upload-time = "2026-01-01T17:32:29.314Z" },
{ url = "https://files.pythonhosted.org/packages/fa/f9/e40e318c604259301cc091a2a63f237d9e7b424c4851cafaea4ea7c4834e/pynacl-1.6.2-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:8b097553b380236d51ed11356c953bf8ce36a29a3e596e934ecabe76c985a577", size = 1363101, upload-time = "2026-01-01T17:32:31.263Z" },
{ url = "https://files.pythonhosted.org/packages/48/47/e761c254f410c023a469284a9bc210933e18588ca87706ae93002c05114c/pynacl-1.6.2-cp38-abi3-win32.whl", hash = "sha256:5811c72b473b2f38f7e2a3dc4f8642e3a3e9b5e7317266e4ced1fba85cae41aa", size = 227421, upload-time = "2026-01-01T17:32:33.076Z" },
{ url = "https://files.pythonhosted.org/packages/41/ad/334600e8cacc7d86587fe5f565480fde569dfb487389c8e1be56ac21d8ac/pynacl-1.6.2-cp38-abi3-win_amd64.whl", hash = "sha256:62985f233210dee6548c223301b6c25440852e13d59a8b81490203c3227c5ba0", size = 239754, upload-time = "2026-01-01T17:32:34.557Z" },
{ url = "https://files.pythonhosted.org/packages/29/7d/5945b5af29534641820d3bd7b00962abbbdfee84ec7e19f0d5b3175f9a31/pynacl-1.6.2-cp38-abi3-win_arm64.whl", hash = "sha256:834a43af110f743a754448463e8fd61259cd4ab5bbedcf70f9dabad1d28a394c", size = 184801, upload-time = "2026-01-01T17:32:36.309Z" },
]
[[package]]
name = "pyserial"
version = "3.5"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/1e/7d/ae3f0a63f41e4d2f6cb66a5b57197850f919f59e558159a4dd3a818f5082/pyserial-3.5.tar.gz", hash = "sha256:3c77e014170dfffbd816e6ffc205e9842efb10be9f58ec16d3e8675b4925cddb", size = 159125, upload-time = "2020-11-23T03:59:15.045Z" }
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" },
]
[[package]]
name = "sniffio"
version = "1.3.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" }
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" },
]
[[package]]
name = "typing-extensions"
version = "4.15.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
]
[[package]]
name = "typing-inspection"
version = "0.4.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" },
]
-1
View File
@@ -1 +0,0 @@
/target
-27
View File
@@ -1,27 +0,0 @@
# Changelog
## 0.1.0 — 2026-04-06
Initial release.
### Features
- High-level `Client<T>` with send/recv and command helpers (ping, set_config,
start_rx, stop_rx, transmit, get_config, get_mac, display_on, display_off)
- COBS wire framing via `ucobs` (matches firmware implementation)
- Auto-detection connection: TCP mux, Unix socket mux, direct USB serial
- Bounded RX packet buffering (256 packets, FIFO eviction)
- `FrameReader` accumulator for streaming byte sources
- USB device discovery by VID:PID with blocking wait
### Resilience
- Cross-platform timeout handling: `TimedOut` (Windows) and `WouldBlock`
(Linux/macOS) both treated as clean timeouts in `read_frame`
- `EINTR`/`Interrupted` signals retried automatically in `read_frame`
- `drain_rx` always restores the original timeout, even on I/O errors
- TCP mux connections use `connect_timeout` (bounded by caller's timeout)
- Mux sockets set both read and write timeouts
- `SerialTransport` tracks timeout accurately for save/restore
- Unexpected unsolicited frames logged via `tracing::warn` instead of silently
discarded
-86
View File
@@ -1,86 +0,0 @@
# Add non-blocking `connect` variant
## Problem
`connect(None, timeout)` in `src/connect.rs` has a three-step fallback:
1. `DONGLORA_MUX_TCP` env var -> TCP mux
2. Unix socket mux (if socket file exists)
3. Direct USB serial via `find_port().unwrap_or_else(wait_for_device)`
Step 3 calls `wait_for_device()` which polls `find_port()` every 500ms
**indefinitely** until a USB device appears. This makes `connect(None, ...)`
unsuitable for callers that need to retry with backoff or report disconnected
status (e.g. donglora-bridge's reconnect loop).
Today, donglora-bridge works around this by manually reimplementing the
fallback chain:
```rust
if let Ok(c) = connect_mux_auto(timeout) {
(c, "mux".to_string())
} else {
let port_path = find_port()
.ok_or_else(|| anyhow!("no mux or USB dongle found"))?;
let c = connect(Some(&port_path), timeout)?;
(c, shorten_path(&port_path))
}
```
This duplicates logic from `connect()` (steps 1-2 via `connect_mux_auto`, step
3 minus the blocking wait) and won't pick up any future changes to the
`connect()` fallback order.
## Proposed solution
Add a `try_connect` (or similar) that runs the same mux -> serial fallback as
`connect(None, timeout)` but replaces `wait_for_device()` with a single
`find_port()` call, returning an error immediately if no device is found:
```rust
/// Like [`connect`] but returns an error instead of blocking when no USB
/// device is present. Suitable for callers with their own retry logic.
pub fn try_connect(timeout: Duration) -> Result<Client<AnyTransport>> {
// Steps 1-2: identical to connect() — try TCP mux, then Unix socket mux
// Step 3: non-blocking USB scan
let port_path = discovery::find_port()
.ok_or_else(|| anyhow!("no DongLoRa device found (no mux, no USB device)"))?;
let transport = SerialTransport::open(&port_path, timeout)?;
Ok(Client::new(AnyTransport::Serial(transport)))
}
```
This would let donglora-bridge collapse its connection code to:
```rust
let (mut client, device) = if let Some(port) = port {
let c = connect(Some(port), timeout)?;
(c, shorten_path(port))
} else {
let c = try_connect(timeout)?;
let device = match c.transport() {
AnyTransport::Mux(_) => "mux".to_string(),
AnyTransport::Serial(_) => find_port()
.map(|p| shorten_path(&p))
.unwrap_or_else(|| "serial".to_string()),
};
(c, device)
};
```
### Device name from transport
The bridge also needs to know *what* it connected to for display purposes. Two
options:
1. Match on `AnyTransport` variant after connecting (shown above) -- works but
requires a redundant `find_port()` call for the display name since
`SerialTransport` has private fields.
2. Have `try_connect` return the port path alongside the client, e.g.
`-> Result<(Client<AnyTransport>, ConnectionInfo)>` where `ConnectionInfo`
is an enum like `Mux { socket_path }` | `Serial { port_path }`. Cleaner
but a larger API change.
Either works. Option 2 would also benefit `connect()` callers who want to log
what they connected to.
-326
View File
@@ -1,326 +0,0 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "anyhow"
version = "1.0.102"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
[[package]]
name = "bitflags"
version = "1.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
[[package]]
name = "bitflags"
version = "2.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af"
[[package]]
name = "cfg-if"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "core-foundation"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]]
name = "core-foundation-sys"
version = "0.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
[[package]]
name = "donglora-client"
version = "0.1.0"
dependencies = [
"anyhow",
"serialport",
"tracing",
"ucobs",
]
[[package]]
name = "io-kit-sys"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "617ee6cf8e3f66f3b4ea67a4058564628cde41901316e19f559e14c7c72c5e7b"
dependencies = [
"core-foundation-sys",
"mach2",
]
[[package]]
name = "libc"
version = "0.2.184"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af"
[[package]]
name = "libudev"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78b324152da65df7bb95acfcaab55e3097ceaab02fb19b228a9eb74d55f135e0"
dependencies = [
"libc",
"libudev-sys",
]
[[package]]
name = "libudev-sys"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c8469b4a23b962c1396b9b451dda50ef5b283e8dd309d69033475fa9b334324"
dependencies = [
"libc",
"pkg-config",
]
[[package]]
name = "mach2"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d640282b302c0bb0a2a8e0233ead9035e3bed871f0b7e81fe4a1ec829765db44"
dependencies = [
"libc",
]
[[package]]
name = "nix"
version = "0.26.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "598beaf3cc6fdd9a5dfb1630c2800c7acd31df7aaf0f565796fba2b53ca1af1b"
dependencies = [
"bitflags 1.3.2",
"cfg-if",
"libc",
]
[[package]]
name = "once_cell"
version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "pin-project-lite"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
[[package]]
name = "pkg-config"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
[[package]]
name = "proc-macro2"
version = "1.0.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
dependencies = [
"proc-macro2",
]
[[package]]
name = "scopeguard"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "serialport"
version = "4.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4d91116f97173694f1642263b2ff837f80d933aa837e2314969f6728f661df3"
dependencies = [
"bitflags 2.11.0",
"cfg-if",
"core-foundation",
"core-foundation-sys",
"io-kit-sys",
"libudev",
"mach2",
"nix",
"scopeguard",
"unescaper",
"windows-sys",
]
[[package]]
name = "syn"
version = "2.0.117"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "thiserror"
version = "2.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
dependencies = [
"thiserror-impl",
]
[[package]]
name = "thiserror-impl"
version = "2.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "tracing"
version = "0.1.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
dependencies = [
"pin-project-lite",
"tracing-attributes",
"tracing-core",
]
[[package]]
name = "tracing-attributes"
version = "0.1.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "tracing-core"
version = "0.1.36"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
dependencies = [
"once_cell",
]
[[package]]
name = "ucobs"
version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "311fbab511bafd13c109aff41f67d4c4426bf919a6670647e69f0d2af95ac01d"
[[package]]
name = "unescaper"
version = "0.1.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4064ed685c487dbc25bd3f0e9548f2e34bab9d18cefc700f9ec2dba74ba1138e"
dependencies = [
"thiserror",
]
[[package]]
name = "unicode-ident"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "windows-sys"
version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
dependencies = [
"windows-targets",
]
[[package]]
name = "windows-targets"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
dependencies = [
"windows_aarch64_gnullvm",
"windows_aarch64_msvc",
"windows_i686_gnu",
"windows_i686_gnullvm",
"windows_i686_msvc",
"windows_x86_64_gnu",
"windows_x86_64_gnullvm",
"windows_x86_64_msvc",
]
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
[[package]]
name = "windows_aarch64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
[[package]]
name = "windows_i686_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
[[package]]
name = "windows_i686_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
[[package]]
name = "windows_i686_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
[[package]]
name = "windows_x86_64_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
[[package]]
name = "windows_x86_64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
-21
View File
@@ -1,21 +0,0 @@
[package]
name = "donglora-client"
version = "0.1.0"
edition = "2024"
authors = ["Stephen Waits <steve@waits.net>"]
license = "MIT"
description = "DongLoRa host library — connect, configure, send/receive LoRa packets"
repository = "https://github.com/swaits/donglora"
keywords = ["lora", "radio", "usb", "serial"]
categories = ["hardware-support"]
[dependencies]
anyhow = "1"
ucobs = "0.3"
serialport = "4"
tracing = "0.1"
[lints.clippy]
unwrap_used = "deny"
expect_used = "deny"
panic = "deny"
-21
View File
@@ -1,21 +0,0 @@
MIT License
Copyright (c) 2025 Stephen Waits <steve@waits.net>
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
-37
View File
@@ -1,37 +0,0 @@
# DongLoRa Rust Client
Rust client library for talking to a DongLoRa device — either directly
over USB or through the [mux daemon](../../mux/).
## What's in Here
- `src/protocol.rs` — wire protocol types (`RadioConfig`, `Command`, `Response`, `ErrorCode`)
- `src/codec.rs` — COBS framing, frame accumulator
- `src/discovery.rs` — USB VID:PID device discovery
- `src/transport.rs` — serial and mux socket transports
- `src/client.rs` — high-level `Client<T>` with send/recv
- `src/connect.rs` — auto-detection (mux socket, TCP, direct USB)
## Usage
```rust
use donglora_client::*;
let mut client = connect_default()?;
client.ping()?;
client.set_config(RadioConfig::default())?;
client.start_rx()?;
loop {
if let Some(Response::RxPacket { rssi, snr, payload }) = client.recv()? {
println!("RX rssi={rssi} snr={snr} len={}", payload.len());
}
}
```
## Dependencies
- `ucobs` — COBS framing (same implementation as the firmware)
- `serialport` — USB serial communication
- `anyhow` — error handling
- `tracing` — logging
-11
View File
@@ -1,11 +0,0 @@
set shell := ["bash", "-c"]
default: check
# Run clippy and tests
check:
cargo clippy -- -D warnings && cargo test
# Build release
build:
cargo build --release
-222
View File
@@ -1,222 +0,0 @@
//! High-level DongLoRa client.
//!
//! The [`Client`] wraps a transport and provides ergonomic send/recv methods
//! that handle the command/response discipline (buffering unsolicited RxPackets
//! while waiting for solicited responses).
use std::collections::VecDeque;
use crate::codec::{encode_frame, read_frame};
use crate::protocol::{Command, ErrorCode, RadioConfig, Response};
use crate::transport::Transport;
/// Maximum number of buffered RxPackets before oldest are dropped.
const RX_BUFFER_CAP: usize = 256;
/// Maximum frames to read while waiting for a solicited response before giving up.
const MAX_UNSOLICITED_BEFORE_TIMEOUT: usize = 50;
/// High-level DongLoRa client, generic over transport.
///
/// Works with any [`Transport`]: direct USB serial, Unix socket mux, or TCP mux.
pub struct Client<T: Transport> {
transport: T,
rx_buffer: VecDeque<Response>,
}
impl<T: Transport> Client<T> {
/// Create a new client wrapping the given transport.
pub fn new(transport: T) -> Self {
Self {
transport,
rx_buffer: VecDeque::with_capacity(64),
}
}
/// Send a command and wait for the solicited response.
///
/// Any unsolicited `RxPacket` frames encountered while waiting are buffered
/// and retrievable via [`recv`](Self::recv) or [`drain_rx`](Self::drain_rx).
pub fn send(&mut self, cmd: Command) -> anyhow::Result<Response> {
let frame = encode_frame(&cmd.to_bytes());
std::io::Write::write_all(&mut self.transport, &frame)?;
std::io::Write::flush(&mut self.transport)?;
for _ in 0..MAX_UNSOLICITED_BEFORE_TIMEOUT {
let data = read_frame(&mut self.transport)?
.ok_or_else(|| anyhow::anyhow!("timeout waiting for response"))?;
let resp = Response::from_bytes(&data)
.ok_or_else(|| {
let hex: Vec<String> = data.iter().map(|b| format!("{b:02x}")).collect();
anyhow::anyhow!("malformed response ({} bytes): [{}]", data.len(), hex.join(" "))
})?;
if resp.is_rx_packet() {
self.buffer_rx(resp);
continue;
}
return Ok(resp);
}
anyhow::bail!("no solicited response after {MAX_UNSOLICITED_BEFORE_TIMEOUT} frames")
}
/// Return the next RxPacket from the buffer or the wire.
///
/// Returns `Ok(None)` on timeout (no packet available).
pub fn recv(&mut self) -> anyhow::Result<Option<Response>> {
if let Some(pkt) = self.rx_buffer.pop_front() {
return Ok(Some(pkt));
}
let Some(data) = read_frame(&mut self.transport)? else {
return Ok(None);
};
let resp = Response::from_bytes(&data)
.ok_or_else(|| anyhow::anyhow!("malformed response ({} bytes)", data.len()))?;
if resp.is_rx_packet() {
Ok(Some(resp))
} else {
tracing::warn!("recv: discarding unexpected unsolicited response: {resp:?}");
Ok(None)
}
}
/// 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.
pub fn drain_rx(&mut self) -> anyhow::Result<Vec<Response>> {
let mut packets: Vec<Response> = self.rx_buffer.drain(..).collect();
let old_timeout = self.transport.timeout();
self.transport
.set_timeout(std::time::Duration::from_millis(10))?;
let result: anyhow::Result<()> = (|| {
loop {
let Some(data) = read_frame(&mut self.transport)? else {
return Ok(());
};
if let Some(resp) = Response::from_bytes(&data)
&& resp.is_rx_packet()
{
packets.push(resp);
}
}
})();
// Always restore timeout, even if the loop errored
self.transport.set_timeout(old_timeout)?;
result?;
Ok(packets)
}
/// Send a Ping and verify the Pong response.
pub fn ping(&mut self) -> anyhow::Result<()> {
match self.send(Command::Ping)? {
Response::Pong => Ok(()),
other => anyhow::bail!("unexpected response to Ping: {other:?}"),
}
}
/// Set the radio configuration.
pub fn set_config(&mut self, config: RadioConfig) -> anyhow::Result<()> {
match self.send(Command::SetConfig(config))? {
Response::Ok => Ok(()),
Response::Error(code) => anyhow::bail!("SetConfig failed: {code}"),
other => anyhow::bail!("unexpected response to SetConfig: {other:?}"),
}
}
/// Start receiving LoRa packets.
pub fn start_rx(&mut self) -> anyhow::Result<()> {
match self.send(Command::StartRx)? {
Response::Ok => Ok(()),
Response::Error(code) => anyhow::bail!("StartRx failed: {code}"),
other => anyhow::bail!("unexpected response to StartRx: {other:?}"),
}
}
/// Stop receiving LoRa packets.
pub fn stop_rx(&mut self) -> anyhow::Result<()> {
match self.send(Command::StopRx)? {
Response::Ok => Ok(()),
Response::Error(code) => anyhow::bail!("StopRx failed: {code}"),
other => anyhow::bail!("unexpected response to StopRx: {other:?}"),
}
}
/// Transmit a LoRa packet.
pub fn transmit(
&mut self,
payload: &[u8],
config: Option<RadioConfig>,
) -> anyhow::Result<()> {
let cmd = Command::Transmit {
config,
payload: payload.to_vec(),
};
match self.send(cmd)? {
Response::TxDone => Ok(()),
Response::Error(ErrorCode::TxTimeout) => anyhow::bail!("transmit timed out"),
Response::Error(code) => anyhow::bail!("Transmit failed: {code}"),
other => anyhow::bail!("unexpected response to Transmit: {other:?}"),
}
}
/// Get the board's MAC address.
pub fn get_mac(&mut self) -> anyhow::Result<[u8; 6]> {
match self.send(Command::GetMac)? {
Response::MacAddress(mac) => Ok(mac),
Response::Error(code) => anyhow::bail!("GetMac failed: {code}"),
other => anyhow::bail!("unexpected response to GetMac: {other:?}"),
}
}
/// Get the current radio configuration from the device.
pub fn get_config(&mut self) -> anyhow::Result<RadioConfig> {
match self.send(Command::GetConfig)? {
Response::Config(cfg) => Ok(cfg),
Response::Error(code) => anyhow::bail!("GetConfig failed: {code}"),
other => anyhow::bail!("unexpected response to GetConfig: {other:?}"),
}
}
/// Turn on the display (if present).
pub fn display_on(&mut self) -> anyhow::Result<()> {
match self.send(Command::DisplayOn)? {
Response::Ok => Ok(()),
Response::Error(code) => anyhow::bail!("DisplayOn failed: {code}"),
other => anyhow::bail!("unexpected response to DisplayOn: {other:?}"),
}
}
/// Turn off the display (if present).
pub fn display_off(&mut self) -> anyhow::Result<()> {
match self.send(Command::DisplayOff)? {
Response::Ok => Ok(()),
Response::Error(code) => anyhow::bail!("DisplayOff failed: {code}"),
other => anyhow::bail!("unexpected response to DisplayOff: {other:?}"),
}
}
/// Consume the client and return the inner transport.
pub fn into_inner(self) -> T {
self.transport
}
/// Get a reference to the inner transport.
pub fn transport(&self) -> &T {
&self.transport
}
/// Get a mutable reference to the inner transport.
pub fn transport_mut(&mut self) -> &mut T {
&mut self.transport
}
fn buffer_rx(&mut self, resp: Response) {
if self.rx_buffer.len() >= RX_BUFFER_CAP {
self.rx_buffer.pop_front(); // drop oldest
}
self.rx_buffer.push_back(resp);
}
}
-330
View File
@@ -1,330 +0,0 @@
//! COBS framing: encode/decode frames and accumulate partial reads.
//!
//! Each frame is COBS-encoded and terminated with a `0x00` sentinel byte.
//! The sentinel never appears in the encoded data, so it unambiguously
//! marks frame boundaries.
//!
//! Uses the [`ucobs`] crate — the same COBS implementation as the firmware.
use tracing::warn;
/// COBS-encode data and append the `0x00` sentinel.
pub fn encode_frame(data: &[u8]) -> Vec<u8> {
let max_encoded = ucobs::max_encoded_len(data.len());
let mut buf = vec![0u8; max_encoded];
let n = match ucobs::encode(data, &mut buf) {
Some(n) => n,
None => return vec![0x00], // empty/failed encode → just sentinel
};
buf.truncate(n);
buf.push(0x00);
buf
}
/// COBS-decode a frame (without the sentinel). Returns `None` on decode error.
pub fn decode_frame(encoded: &[u8]) -> Option<Vec<u8>> {
if encoded.is_empty() {
return None;
}
let mut buf = vec![0u8; encoded.len()];
let n = ucobs::decode(encoded, &mut buf)?;
buf.truncate(n);
Some(buf)
}
/// Stateful COBS frame accumulator.
///
/// Feed raw byte chunks (from serial reads, socket reads, etc.) and extract
/// complete decoded frames. Handles partial reads, multiple frames per chunk,
/// and empty inter-frame gaps gracefully.
///
/// Used by both the sync client (`read_frame`) and the async mux to accumulate
/// data from serial and client socket streams.
pub struct FrameReader {
buf: Vec<u8>,
}
impl FrameReader {
/// Create a new empty frame reader.
pub fn new() -> Self {
Self {
buf: Vec::with_capacity(512),
}
}
/// Feed raw bytes and return all complete decoded frames.
///
/// COBS decode errors are logged and the bad frame is skipped.
/// Empty inter-frame gaps (consecutive `0x00` bytes) are ignored.
pub fn feed(&mut self, data: &[u8]) -> Vec<Vec<u8>> {
self.buf.extend_from_slice(data);
let mut frames = Vec::new();
while let Some(sentinel_pos) = self.buf.iter().position(|&b| b == 0x00) {
let encoded = &self.buf[..sentinel_pos];
if !encoded.is_empty() {
match decode_frame(encoded) {
Some(decoded) => frames.push(decoded),
None => warn!("bad COBS frame ({} bytes) — skipped", encoded.len()),
}
}
// Remove the consumed bytes including the sentinel
self.buf.drain(..=sentinel_pos);
}
frames
}
/// Return the number of buffered bytes not yet forming a complete frame.
pub fn buffered(&self) -> usize {
self.buf.len()
}
}
impl Default for FrameReader {
fn default() -> Self {
Self::new()
}
}
/// Read one complete COBS frame from a blocking `Read` source.
///
/// Reads byte-by-byte until a `0x00` sentinel is found, then COBS-decodes.
/// Returns `Ok(None)` on timeout (zero-length read from the underlying source).
/// Returns `Err` on I/O errors or COBS decode failures.
pub fn read_frame(reader: &mut dyn std::io::Read) -> anyhow::Result<Option<Vec<u8>>> {
let mut buf = Vec::with_capacity(280);
let mut byte = [0u8; 1];
loop {
match reader.read(&mut byte) {
Ok(0) => {
// Timeout or EOF
return Ok(None);
}
Ok(_) => {
if byte[0] == 0x00 {
break;
}
buf.push(byte[0]);
}
Err(e)
if matches!(
e.kind(),
std::io::ErrorKind::TimedOut | std::io::ErrorKind::WouldBlock
) =>
{
return Ok(None);
}
Err(e) if e.kind() == std::io::ErrorKind::Interrupted => continue,
Err(e) => return Err(e.into()),
}
}
if buf.is_empty() {
return Ok(None);
}
decode_frame(&buf)
.map(Some)
.ok_or_else(|| anyhow::anyhow!("COBS decode error"))
}
// ── Tests ──────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn encode_decode_roundtrip() {
let data = b"hello world";
let encoded = encode_frame(data);
// Encoded should end with 0x00 sentinel
assert_eq!(encoded.last(), Some(&0x00));
// Encoded should not contain 0x00 except the trailing sentinel
assert!(!encoded[..encoded.len() - 1].contains(&0x00));
// Decode (without sentinel)
let decoded = decode_frame(&encoded[..encoded.len() - 1]);
assert_eq!(decoded.as_deref(), Some(data.as_slice()));
}
#[test]
fn encode_decode_empty() {
// COBS encoding of empty data produces just the 0x00 sentinel.
// This is equivalent to an empty inter-frame gap and is correctly
// skipped by both FrameReader and read_frame.
let encoded = encode_frame(b"");
assert_eq!(*encoded.last().unwrap(), 0x00);
}
#[test]
fn encode_decode_with_zeros() {
let data = &[0x00, 0x01, 0x00, 0x02, 0x00];
let encoded = encode_frame(data);
let decoded = decode_frame(&encoded[..encoded.len() - 1]);
assert_eq!(decoded.as_deref(), Some(data.as_slice()));
}
#[test]
fn decode_frame_invalid() {
assert!(decode_frame(&[]).is_none());
}
#[test]
fn frame_reader_single_frame() {
let mut reader = FrameReader::new();
let data = b"test";
let frame = encode_frame(data);
let frames = reader.feed(&frame);
assert_eq!(frames.len(), 1);
assert_eq!(frames[0], data);
}
#[test]
fn frame_reader_multiple_frames_at_once() {
let mut reader = FrameReader::new();
let f1 = encode_frame(b"one");
let f2 = encode_frame(b"two");
let mut combined = f1;
combined.extend_from_slice(&f2);
let frames = reader.feed(&combined);
assert_eq!(frames.len(), 2);
assert_eq!(frames[0], b"one");
assert_eq!(frames[1], b"two");
}
#[test]
fn frame_reader_partial_then_complete() {
let mut reader = FrameReader::new();
let frame = encode_frame(b"split");
let mid = frame.len() / 2;
// Feed first half — no complete frames yet
let frames = reader.feed(&frame[..mid]);
assert!(frames.is_empty());
assert!(reader.buffered() > 0);
// Feed second half — now we get the frame
let frames = reader.feed(&frame[mid..]);
assert_eq!(frames.len(), 1);
assert_eq!(frames[0], b"split");
assert_eq!(reader.buffered(), 0);
}
#[test]
fn frame_reader_empty_gaps() {
let mut reader = FrameReader::new();
// Multiple sentinels in a row (empty gaps between frames)
let f = encode_frame(b"data");
let mut input = vec![0x00, 0x00]; // leading empty gaps
input.extend_from_slice(&f);
input.push(0x00); // trailing empty gap
let frames = reader.feed(&input);
assert_eq!(frames.len(), 1);
assert_eq!(frames[0], b"data");
}
#[test]
fn frame_reader_bad_cobs_skipped() {
let mut reader = FrameReader::new();
// Feed a known-bad COBS frame followed by a good one.
// A frame where the first byte claims a run longer than the frame is invalid.
let bad = &[0xFF, 0x01, 0x00]; // 0xFF says next 254 bytes, but only 1 present
let good = encode_frame(b"ok");
let mut input = bad.to_vec();
input.extend_from_slice(&good);
let frames = reader.feed(&input);
// Bad frame skipped, good frame decoded
assert_eq!(frames.len(), 1);
assert_eq!(frames[0], b"ok");
}
#[test]
fn read_frame_from_bytes() {
let data = b"frame";
let encoded = encode_frame(data);
let mut cursor = std::io::Cursor::new(encoded);
let result = read_frame(&mut cursor);
assert!(result.is_ok());
assert_eq!(result.ok().flatten().as_deref(), Some(data.as_slice()));
}
#[test]
fn read_frame_timeout() {
// Empty reader → returns None (timeout)
let mut cursor = std::io::Cursor::new(Vec::<u8>::new());
let result = read_frame(&mut cursor);
assert!(result.is_ok());
assert!(result.ok().flatten().is_none());
}
#[test]
fn read_frame_empty_gap_then_data() {
let data = b"after_gap";
let mut input = vec![0x00]; // empty gap
input.extend_from_slice(&encode_frame(data));
let mut cursor = std::io::Cursor::new(input);
// First read_frame returns None for the empty gap
let r1 = read_frame(&mut cursor);
assert_eq!(r1.ok().flatten(), None);
// Second read returns the actual frame
let r2 = read_frame(&mut cursor);
assert_eq!(r2.ok().flatten().as_deref(), Some(data.as_slice()));
}
#[test]
fn encode_all_byte_values_roundtrip() {
// Test that all possible byte values survive encode/decode
let data: Vec<u8> = (0..=255).collect();
let encoded = encode_frame(&data);
let decoded = decode_frame(&encoded[..encoded.len() - 1]);
assert_eq!(decoded, Some(data));
}
#[test]
fn config_response_cobs_roundtrip() {
// Exact Config response bytes: tag=1, freq=910525000, bw=62.5k, sf=7,
// cr=5, sync=0x3444, tx=22dBm, preamble=16, cad=1
// Note: preamble high byte is 0x00 — tests COBS with embedded zero.
let data = vec![
0x01, 0x48, 0x82, 0x45, 0x36, 0x06, 0x07, 0x05,
0x44, 0x34, 0x16, 0x10, 0x00, 0x01,
];
assert_eq!(data.len(), 14);
let encoded = encode_frame(&data);
let decoded = decode_frame(&encoded[..encoded.len() - 1]);
assert_eq!(decoded, Some(data));
}
#[test]
fn config_response_cobs_roundtrip_cad_zero() {
// Same but with cad=0 — two consecutive 0x00 bytes at end.
let data = vec![
0x01, 0x48, 0x82, 0x45, 0x36, 0x06, 0x07, 0x05,
0x44, 0x34, 0x16, 0x10, 0x00, 0x00,
];
assert_eq!(data.len(), 14);
let encoded = encode_frame(&data);
let decoded = decode_frame(&encoded[..encoded.len() - 1]);
assert_eq!(decoded, Some(data));
}
#[cfg(unix)]
#[test]
fn read_frame_socket_timeout_returns_none() {
use std::os::unix::net::UnixStream;
use std::time::Duration;
let (mut a, _b) = UnixStream::pair().unwrap();
a.set_read_timeout(Some(Duration::from_millis(10)))
.unwrap();
// No data written to _b, so read on `a` will time out with WouldBlock on Linux.
let result = read_frame(&mut a);
assert!(
result.is_ok(),
"socket timeout should be Ok(None), got: {result:?}"
);
assert!(result.unwrap().is_none());
}
}
-174
View File
@@ -1,174 +0,0 @@
//! Connection auto-detection and mux client helpers.
//!
//! The [`connect`] function tries mux connections first (TCP via env var, then
//! Unix socket), falling back to direct USB serial. This matches the Python
//! client's `connect()` behavior.
use std::time::Duration;
use tracing::debug;
use crate::client::Client;
use crate::discovery;
use crate::transport::{AnyTransport, MuxTransport, SerialTransport};
/// Default read timeout for connections.
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(2);
/// 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`
pub fn default_socket_path() -> String {
if let Ok(env) = std::env::var("DONGLORA_MUX") {
return env;
}
if let Ok(xdg) = std::env::var("XDG_RUNTIME_DIR") {
return format!("{xdg}/donglora/mux.sock");
}
"/tmp/donglora-mux.sock".to_string()
}
/// Find an existing mux socket path, or `None` if no socket file exists.
fn find_mux_socket() -> Option<String> {
if let Ok(env) = std::env::var("DONGLORA_MUX") {
if std::path::Path::new(&env).exists() {
return Some(env);
}
return None;
}
if let Ok(xdg) = std::env::var("XDG_RUNTIME_DIR") {
let p = format!("{xdg}/donglora/mux.sock");
if std::path::Path::new(&p).exists() {
return Some(p);
}
}
let p = "/tmp/donglora-mux.sock";
if std::path::Path::new(p).exists() {
return Some(p.to_string());
}
None
}
/// Connect to the mux daemon via Unix domain socket.
#[cfg(unix)]
pub fn mux_connect(
path: Option<&str>,
timeout: Duration,
) -> anyhow::Result<Client<MuxTransport>> {
let path = match path {
Some(p) => p.to_string(),
None => find_mux_socket()
.ok_or_else(|| anyhow::anyhow!("no mux socket found"))?,
};
let transport = MuxTransport::unix(&path, timeout)?;
Ok(Client::new(transport))
}
/// Connect to the mux daemon via TCP.
pub fn mux_tcp_connect(
host: &str,
port: u16,
timeout: Duration,
) -> anyhow::Result<Client<MuxTransport>> {
let transport = MuxTransport::tcp(host, port, timeout)?;
Ok(Client::new(transport))
}
/// 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 `Some`, skips mux detection and connects directly to that serial port.
pub fn connect(
port: Option<&str>,
timeout: Duration,
) -> anyhow::Result<Client<AnyTransport>> {
// If explicit port given, go direct
if let Some(port) = port {
debug!("opening serial port {port}");
let transport = SerialTransport::open(port, timeout)?;
return Ok(Client::new(AnyTransport::Serial(transport)));
}
// Try TCP mux via environment variable
if let Ok(tcp) = std::env::var("DONGLORA_MUX_TCP")
&& let Some(transport) = try_tcp_mux(&tcp, timeout)
{
debug!("connected to TCP mux at {tcp}");
return Ok(Client::new(AnyTransport::Mux(transport)));
}
// Try Unix socket mux
#[cfg(unix)]
if let Some(path) = find_mux_socket() {
match MuxTransport::unix(&path, timeout) {
Ok(transport) => {
debug!("connected to mux socket at {path}");
return Ok(Client::new(AnyTransport::Mux(transport)));
}
Err(_) => {
// Stale socket or connection refused — fall through
debug!("mux socket at {path} not reachable, falling back to USB");
}
}
}
// Direct USB serial — auto-detect or wait
let port_path = discovery::find_port()
.unwrap_or_else(discovery::wait_for_device);
debug!("opening serial port {port_path}");
let transport = SerialTransport::open(&port_path, timeout)?;
Ok(Client::new(AnyTransport::Serial(transport)))
}
/// Convenience: connect with default timeout.
pub fn connect_default() -> anyhow::Result<Client<AnyTransport>> {
connect(None, DEFAULT_TIMEOUT)
}
/// Connect to a mux daemon only (TCP via env var, then Unix socket).
///
/// Unlike [`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 Rust equivalent of the Python client's "sticky mux" behavior:
/// once you decide to use the mux, you stay on the mux.
pub fn connect_mux_auto(timeout: Duration) -> anyhow::Result<Client<AnyTransport>> {
// Try TCP mux via environment variable.
if let Ok(tcp) = std::env::var("DONGLORA_MUX_TCP")
&& let Some(transport) = try_tcp_mux(&tcp, timeout)
{
debug!("connected to TCP mux at {tcp}");
return Ok(Client::new(AnyTransport::Mux(transport)));
}
// Try Unix socket mux.
#[cfg(unix)]
{
let path = find_mux_socket()
.ok_or_else(|| anyhow::anyhow!("no mux socket found"))?;
let transport = MuxTransport::unix(&path, timeout)?;
debug!("connected to mux socket at {path}");
Ok(Client::new(AnyTransport::Mux(transport)))
}
#[cfg(not(unix))]
anyhow::bail!("mux-only mode requires Unix socket support or DONGLORA_MUX_TCP")
}
fn try_tcp_mux(addr: &str, timeout: Duration) -> Option<MuxTransport> {
let (host, port) = if let Some((h, p)) = addr.rsplit_once(':') {
let host = if h.is_empty() { "localhost" } else { h };
let port: u16 = p.parse().ok()?;
(host.to_string(), port)
} else {
let port: u16 = addr.parse().ok()?;
("localhost".to_string(), port)
};
MuxTransport::tcp(&host, port, timeout).ok()
}
-70
View File
@@ -1,70 +0,0 @@
//! USB device discovery for DongLoRa dongles.
//!
//! Finds the serial port by matching USB VID:PID using the `serialport` crate.
use std::thread;
use std::time::Duration;
use tracing::info;
/// DongLoRa USB Vendor ID.
pub const USB_VID: u16 = 0x1209;
/// DongLoRa USB Product ID.
pub const USB_PID: u16 = 0x5741;
/// Known USB-UART bridge VID:PIDs found on some board revisions.
const BRIDGE_VID_PIDS: &[(u16, u16)] = &[
(0x10C4, 0xEA60), // CP2102 (Silicon Labs)
(0x1A86, 0x55D4), // CH9102
(0x1A86, 0x7523), // CH340
(0x0403, 0x6001), // FT232R (FTDI)
];
/// Find the DongLoRa serial port by USB VID:PID.
///
/// Checks for native USB CDC-ACM first (VID:PID 1209:5741), then falls
/// back to known USB-UART bridge chips found on some board revisions.
/// Returns the first matching port path, or `None` if no device is found.
pub fn find_port() -> Option<String> {
let ports = serialport::available_ports().ok()?;
// Prefer native USB (our custom VID:PID)
if let Some(port) = ports.iter().find(|p| {
matches!(
&p.port_type,
serialport::SerialPortType::UsbPort(info)
if info.vid == USB_VID && info.pid == USB_PID
)
}) {
return Some(port.port_name.clone());
}
// Fall back to known USB-UART bridge chips
ports
.into_iter()
.find(|p| {
matches!(
&p.port_type,
serialport::SerialPortType::UsbPort(info)
if BRIDGE_VID_PIDS.contains(&(info.vid, info.pid))
)
})
.map(|p| p.port_name)
}
/// Block until a DongLoRa device appears on USB.
///
/// Polls [`find_port`] every 500ms and returns the port path once found.
pub fn wait_for_device() -> String {
info!("waiting for DongLoRa device...");
loop {
if let Some(port) = find_port() {
info!("found device at {port}");
// Brief delay for USB enumeration to settle
thread::sleep(Duration::from_millis(300));
return port;
}
thread::sleep(Duration::from_millis(500));
}
}
-45
View File
@@ -1,45 +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.
//!
//! # Quick start
//!
//! ```no_run
//! use donglora_client::*;
//!
//! let mut client = connect_default()?;
//! client.ping()?;
//! client.set_config(RadioConfig::default())?;
//! client.start_rx()?;
//!
//! loop {
//! if let Some(Response::RxPacket { rssi, snr, payload }) = client.recv()? {
//! println!("RX rssi={rssi} snr={snr} len={}", payload.len());
//! }
//! }
//! # Ok::<(), anyhow::Error>(())
//! ```
pub mod client;
pub mod codec;
pub mod connect;
pub mod discovery;
pub mod protocol;
pub mod transport;
// Flat re-exports for convenience
pub use client::Client;
pub use codec::{decode_frame, encode_frame, read_frame, FrameReader};
pub use connect::{connect, connect_default, connect_mux_auto, default_socket_path};
pub use discovery::{find_port, wait_for_device, USB_PID, USB_VID};
pub use protocol::{
Bandwidth, Command, ErrorCode, RadioConfig, Response, CMD_TAG_SET_CONFIG, CMD_TAG_START_RX,
CMD_TAG_STOP_RX, ERROR_INVALID_CONFIG, MAX_PAYLOAD, PREAMBLE_DEFAULT, RADIO_CONFIG_SIZE,
RESP_TAG_ERROR, RESP_TAG_OK, RESP_TAG_RX_PACKET, TX_POWER_MAX,
};
pub use transport::{AnyTransport, MuxTransport, SerialTransport, Transport};
#[cfg(unix)]
pub use connect::mux_connect;
pub use connect::mux_tcp_connect;
-693
View File
@@ -1,693 +0,0 @@
//! Wire protocol types and fixed-size little-endian serialization.
//!
//! Mirrors the firmware's `protocol.rs` using std types. Every integer is
//! fixed-width LE. See `firmware/PROTOCOL.md` for the full specification.
/// Maximum LoRa payload size in bytes.
pub const MAX_PAYLOAD: usize = 256;
/// RadioConfig wire size (fixed).
pub const RADIO_CONFIG_SIZE: usize = 13;
/// Sentinel value for `tx_power_dbm`: use the board's maximum TX power.
pub const TX_POWER_MAX: i8 = i8::MIN; // -128 on the wire
/// Sentinel value for `preamble_len`: use the firmware default (16 symbols).
pub const PREAMBLE_DEFAULT: u16 = 0;
// ── Tag constants ──────────────────────────────────────────────────
/// Command tag for SetConfig.
pub const CMD_TAG_SET_CONFIG: u8 = 2;
/// Command tag for StartRx.
pub const CMD_TAG_START_RX: u8 = 3;
/// Command tag for StopRx.
pub const CMD_TAG_STOP_RX: u8 = 4;
/// Response tag for RxPacket.
pub const RESP_TAG_RX_PACKET: u8 = 2;
/// Response tag for Ok.
pub const RESP_TAG_OK: u8 = 4;
/// Response tag for Error.
pub const RESP_TAG_ERROR: u8 = 5;
/// Error code for InvalidConfig.
pub const ERROR_INVALID_CONFIG: u8 = 0;
// ── Bandwidth ──────────────────────────────────────────────────────
/// LoRa signal bandwidth.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(u8)]
pub enum Bandwidth {
Khz7 = 0,
Khz10 = 1,
Khz15 = 2,
Khz20 = 3,
Khz31 = 4,
Khz41 = 5,
Khz62 = 6,
Khz125 = 7,
Khz250 = 8,
Khz500 = 9,
}
impl Bandwidth {
/// Convert from raw byte. Returns `None` for invalid values.
pub fn from_u8(v: u8) -> Option<Self> {
match v {
0 => Some(Self::Khz7),
1 => Some(Self::Khz10),
2 => Some(Self::Khz15),
3 => Some(Self::Khz20),
4 => Some(Self::Khz31),
5 => Some(Self::Khz41),
6 => Some(Self::Khz62),
7 => Some(Self::Khz125),
8 => Some(Self::Khz250),
9 => Some(Self::Khz500),
_ => None,
}
}
}
// ── RadioConfig ────────────────────────────────────────────────────
/// Complete LoRa radio configuration.
///
/// Wire layout (13 bytes, all little-endian):
/// ```text
/// [freq_hz:4] [bw:1] [sf:1] [cr:1] [sync_word:2] [tx_power_dbm:1] [preamble_len:2] [cad:1]
/// ```
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct RadioConfig {
/// Frequency in Hz (150_000_000 - 960_000_000 for SX1262).
pub freq_hz: u32,
pub bw: Bandwidth,
/// Spreading factor (5-12).
pub sf: u8,
/// Coding rate denominator (5-8). E.g. 5 = CR 4/5, 8 = CR 4/8.
pub cr: u8,
pub sync_word: u16,
/// Transmit power in dBm. Set to [`TX_POWER_MAX`] (-128) for the board's maximum.
pub tx_power_dbm: i8,
/// Preamble length in symbols. Set to [`PREAMBLE_DEFAULT`] (0) for firmware default (16).
pub preamble_len: u16,
/// Channel Activity Detection (listen-before-talk). 0 = disabled, non-zero = enabled.
pub cad: u8,
}
impl RadioConfig {
/// Serialize to fixed-size LE bytes.
pub fn to_bytes(&self) -> [u8; RADIO_CONFIG_SIZE] {
let mut buf = [0u8; RADIO_CONFIG_SIZE];
buf[0..4].copy_from_slice(&self.freq_hz.to_le_bytes());
buf[4] = self.bw as u8;
buf[5] = self.sf;
buf[6] = self.cr;
buf[7..9].copy_from_slice(&self.sync_word.to_le_bytes());
buf[9] = self.tx_power_dbm as u8;
buf[10..12].copy_from_slice(&self.preamble_len.to_le_bytes());
buf[12] = self.cad;
buf
}
/// Deserialize from fixed-size LE bytes. Returns `None` if too short or invalid.
pub fn from_bytes(buf: &[u8]) -> Option<Self> {
if buf.len() < RADIO_CONFIG_SIZE {
return None;
}
Some(Self {
freq_hz: u32::from_le_bytes([buf[0], buf[1], buf[2], buf[3]]),
bw: Bandwidth::from_u8(buf[4])?,
sf: buf[5],
cr: buf[6],
sync_word: u16::from_le_bytes([buf[7], buf[8]]),
tx_power_dbm: buf[9] as i8,
preamble_len: u16::from_le_bytes([buf[10], buf[11]]),
cad: buf[12],
})
}
}
/// Default: 915 MHz, 125 kHz BW, SF7, CR 4/5, sync 0x1424, max power, default preamble, CAD on.
impl Default for RadioConfig {
fn default() -> Self {
Self {
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,
}
}
}
// ── Command ────────────────────────────────────────────────────────
/// Host → firmware commands.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Command {
Ping,
GetConfig,
SetConfig(RadioConfig),
StartRx,
StopRx,
Transmit {
config: Option<RadioConfig>,
payload: Vec<u8>,
},
DisplayOn,
DisplayOff,
GetMac,
}
impl Command {
/// Returns the wire tag byte for this command.
pub fn tag(&self) -> u8 {
match self {
Self::Ping => 0,
Self::GetConfig => 1,
Self::SetConfig(_) => 2,
Self::StartRx => 3,
Self::StopRx => 4,
Self::Transmit { .. } => 5,
Self::DisplayOn => 6,
Self::DisplayOff => 7,
Self::GetMac => 8,
}
}
/// Serialize to fixed-size LE bytes.
pub fn to_bytes(&self) -> Vec<u8> {
match self {
Self::Ping => vec![0],
Self::GetConfig => vec![1],
Self::SetConfig(cfg) => {
let mut out = vec![2];
out.extend_from_slice(&cfg.to_bytes());
out
}
Self::StartRx => vec![3],
Self::StopRx => vec![4],
Self::Transmit { config, payload } => {
let mut out = vec![5];
match config {
None => out.push(0),
Some(cfg) => {
out.push(1);
out.extend_from_slice(&cfg.to_bytes());
}
}
out.extend_from_slice(&(payload.len() as u16).to_le_bytes());
out.extend_from_slice(payload);
out
}
Self::DisplayOn => vec![6],
Self::DisplayOff => vec![7],
Self::GetMac => vec![8],
}
}
/// Deserialize from a decoded frame. Returns `None` on malformed input.
pub fn from_bytes(buf: &[u8]) -> Option<Self> {
let tag = *buf.first()?;
let rest = &buf[1..];
match tag {
0 => Some(Self::Ping),
1 => Some(Self::GetConfig),
2 => Some(Self::SetConfig(RadioConfig::from_bytes(rest)?)),
3 => Some(Self::StartRx),
4 => Some(Self::StopRx),
5 => {
if rest.is_empty() {
return None;
}
let (config, pos) = if rest[0] == 0 {
(None, 1)
} else if rest[0] == 1 && rest.len() > RADIO_CONFIG_SIZE {
(Some(RadioConfig::from_bytes(&rest[1..])?), 1 + RADIO_CONFIG_SIZE)
} else {
return None;
};
if rest.len() < pos + 2 {
return None;
}
let len = u16::from_le_bytes([rest[pos], rest[pos + 1]]) as usize;
let data_start = pos + 2;
if rest.len() < data_start + len || len > MAX_PAYLOAD {
return None;
}
Some(Self::Transmit {
config,
payload: rest[data_start..data_start + len].to_vec(),
})
}
6 => Some(Self::DisplayOn),
7 => Some(Self::DisplayOff),
8 => Some(Self::GetMac),
_ => None,
}
}
}
// ── ErrorCode ──────────────────────────────────────────────────────
/// Error codes reported by the firmware.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(u8)]
pub enum ErrorCode {
InvalidConfig = 0,
RadioBusy = 1,
TxTimeout = 2,
CrcError = 3,
NotConfigured = 4,
NoDisplay = 5,
}
impl ErrorCode {
/// Convert from raw byte. Returns `None` for unknown codes.
pub fn from_u8(v: u8) -> Option<Self> {
match v {
0 => Some(Self::InvalidConfig),
1 => Some(Self::RadioBusy),
2 => Some(Self::TxTimeout),
3 => Some(Self::CrcError),
4 => Some(Self::NotConfigured),
5 => Some(Self::NoDisplay),
_ => None,
}
}
}
impl std::fmt::Display for ErrorCode {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::InvalidConfig => write!(f, "InvalidConfig"),
Self::RadioBusy => write!(f, "RadioBusy"),
Self::TxTimeout => write!(f, "TxTimeout"),
Self::CrcError => write!(f, "CrcError"),
Self::NotConfigured => write!(f, "NotConfigured"),
Self::NoDisplay => write!(f, "NoDisplay"),
}
}
}
// ── Response ───────────────────────────────────────────────────────
/// Firmware → host responses.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Response {
Pong,
Config(RadioConfig),
RxPacket {
rssi: i16,
snr: i16,
payload: Vec<u8>,
},
TxDone,
Ok,
Error(ErrorCode),
MacAddress([u8; 6]),
}
impl Response {
/// Returns the wire tag byte for this response.
pub fn tag(&self) -> u8 {
match self {
Self::Pong => 0,
Self::Config(_) => 1,
Self::RxPacket { .. } => 2,
Self::TxDone => 3,
Self::Ok => 4,
Self::Error(_) => 5,
Self::MacAddress(_) => 6,
}
}
/// Whether this is an unsolicited RxPacket.
pub fn is_rx_packet(&self) -> bool {
matches!(self, Self::RxPacket { .. })
}
/// Serialize to fixed-size LE bytes.
pub fn to_bytes(&self) -> Vec<u8> {
match self {
Self::Pong => vec![0],
Self::Config(cfg) => {
let mut out = vec![1];
out.extend_from_slice(&cfg.to_bytes());
out
}
Self::RxPacket { rssi, snr, payload } => {
let mut out = vec![2];
out.extend_from_slice(&rssi.to_le_bytes());
out.extend_from_slice(&snr.to_le_bytes());
out.extend_from_slice(&(payload.len() as u16).to_le_bytes());
out.extend_from_slice(payload);
out
}
Self::TxDone => vec![3],
Self::Ok => vec![4],
Self::Error(code) => vec![5, *code as u8],
Self::MacAddress(mac) => {
let mut out = vec![6];
out.extend_from_slice(mac);
out
}
}
}
/// Deserialize from a decoded frame. Returns `None` on malformed input.
pub fn from_bytes(buf: &[u8]) -> Option<Self> {
let tag = *buf.first()?;
let rest = &buf[1..];
match tag {
0 => Some(Self::Pong),
1 => Some(Self::Config(RadioConfig::from_bytes(rest)?)),
2 => {
if rest.len() < 6 {
return None;
}
let rssi = i16::from_le_bytes([rest[0], rest[1]]);
let snr = i16::from_le_bytes([rest[2], rest[3]]);
let len = u16::from_le_bytes([rest[4], rest[5]]) as usize;
if rest.len() < 6 + len || len > MAX_PAYLOAD {
return None;
}
Some(Self::RxPacket {
rssi,
snr,
payload: rest[6..6 + len].to_vec(),
})
}
3 => Some(Self::TxDone),
4 => Some(Self::Ok),
5 => Some(Self::Error(ErrorCode::from_u8(*rest.first()?)?)),
6 => {
if rest.len() < 6 {
return None;
}
let mut mac = [0u8; 6];
mac.copy_from_slice(&rest[..6]);
Some(Self::MacAddress(mac))
}
_ => None,
}
}
}
// ── Tests ──────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
fn make_config() -> RadioConfig {
RadioConfig {
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 ────────────────────────────────────────────────
#[test]
fn radio_config_roundtrip() {
let cfg = make_config();
let bytes = cfg.to_bytes();
assert_eq!(bytes.len(), RADIO_CONFIG_SIZE);
assert_eq!(RadioConfig::from_bytes(&bytes), Some(cfg));
}
#[test]
fn radio_config_default_roundtrip() {
let cfg = RadioConfig::default();
let bytes = cfg.to_bytes();
assert_eq!(RadioConfig::from_bytes(&bytes), Some(cfg));
}
#[test]
fn radio_config_all_bandwidths() {
for bw_val in 0u8..=9 {
let bw = Bandwidth::from_u8(bw_val);
assert!(bw.is_some(), "bandwidth {bw_val} should be valid");
let cfg = RadioConfig { bw: bw.unwrap(), ..make_config() };
let bytes = cfg.to_bytes();
assert_eq!(RadioConfig::from_bytes(&bytes), Some(cfg));
}
}
#[test]
fn radio_config_invalid_bandwidth() {
let mut buf = make_config().to_bytes();
buf[4] = 255;
assert!(RadioConfig::from_bytes(&buf).is_none());
}
#[test]
fn radio_config_negative_power() {
let cfg = RadioConfig { tx_power_dbm: TX_POWER_MAX, ..make_config() };
let bytes = cfg.to_bytes();
assert_eq!(RadioConfig::from_bytes(&bytes), Some(cfg));
}
#[test]
fn radio_config_short_buffer() {
assert!(RadioConfig::from_bytes(&[0u8; 12]).is_none());
assert!(RadioConfig::from_bytes(&[]).is_none());
}
// ── Command ────────────────────────────────────────────────────
#[test]
fn command_simple_roundtrips() {
for cmd in [
Command::Ping,
Command::GetConfig,
Command::StartRx,
Command::StopRx,
Command::DisplayOn,
Command::DisplayOff,
Command::GetMac,
] {
let bytes = cmd.to_bytes();
assert_eq!(Command::from_bytes(&bytes), Some(cmd));
}
}
#[test]
fn command_set_config_roundtrip() {
let cmd = Command::SetConfig(make_config());
let bytes = cmd.to_bytes();
assert_eq!(Command::from_bytes(&bytes), Some(cmd));
}
#[test]
fn command_transmit_no_config_roundtrip() {
let cmd = Command::Transmit {
config: None,
payload: b"hello".to_vec(),
};
let bytes = cmd.to_bytes();
assert_eq!(Command::from_bytes(&bytes), Some(cmd));
}
#[test]
fn command_transmit_with_config_roundtrip() {
let cmd = Command::Transmit {
config: Some(make_config()),
payload: b"test".to_vec(),
};
let bytes = cmd.to_bytes();
assert_eq!(Command::from_bytes(&bytes), Some(cmd));
}
#[test]
fn command_transmit_empty_payload() {
let cmd = Command::Transmit {
config: None,
payload: vec![],
};
let bytes = cmd.to_bytes();
assert_eq!(Command::from_bytes(&bytes), Some(cmd));
}
#[test]
fn command_transmit_truncated() {
assert!(Command::from_bytes(&[5]).is_none()); // tag only
assert!(Command::from_bytes(&[5, 1]).is_none()); // has_config=1, no config
assert!(Command::from_bytes(&[5, 0]).is_none()); // has_config=0, no length
}
#[test]
fn command_invalid_tag() {
assert!(Command::from_bytes(&[9]).is_none());
assert!(Command::from_bytes(&[255]).is_none());
}
#[test]
fn command_empty_buffer() {
assert!(Command::from_bytes(&[]).is_none());
}
#[test]
fn command_tags() {
assert_eq!(Command::Ping.tag(), 0);
assert_eq!(Command::GetConfig.tag(), 1);
assert_eq!(Command::SetConfig(make_config()).tag(), 2);
assert_eq!(Command::StartRx.tag(), 3);
assert_eq!(Command::StopRx.tag(), 4);
assert_eq!(Command::Transmit { config: None, payload: vec![] }.tag(), 5);
assert_eq!(Command::DisplayOn.tag(), 6);
assert_eq!(Command::DisplayOff.tag(), 7);
assert_eq!(Command::GetMac.tag(), 8);
}
// ── Response ───────────────────────────────────────────────────
#[test]
fn response_simple_roundtrips() {
for resp in [Response::Pong, Response::TxDone, Response::Ok] {
let bytes = resp.to_bytes();
assert_eq!(Response::from_bytes(&bytes), Some(resp));
}
}
#[test]
fn response_config_roundtrip() {
let resp = Response::Config(make_config());
let bytes = resp.to_bytes();
assert_eq!(Response::from_bytes(&bytes), Some(resp));
}
#[test]
fn response_rx_packet_roundtrip() {
let resp = Response::RxPacket {
rssi: -80,
snr: 10,
payload: b"data".to_vec(),
};
let bytes = resp.to_bytes();
assert_eq!(Response::from_bytes(&bytes), Some(resp));
}
#[test]
fn response_rx_packet_empty_payload() {
let resp = Response::RxPacket {
rssi: -120,
snr: -5,
payload: vec![],
};
let bytes = resp.to_bytes();
assert_eq!(Response::from_bytes(&bytes), Some(resp));
}
#[test]
fn response_error_codes() {
for code in [
ErrorCode::InvalidConfig,
ErrorCode::RadioBusy,
ErrorCode::TxTimeout,
ErrorCode::CrcError,
ErrorCode::NotConfigured,
ErrorCode::NoDisplay,
] {
let resp = Response::Error(code);
let bytes = resp.to_bytes();
assert_eq!(Response::from_bytes(&bytes), Some(resp));
}
}
#[test]
fn response_mac_address_roundtrip() {
let resp = Response::MacAddress([0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF]);
let bytes = resp.to_bytes();
assert_eq!(Response::from_bytes(&bytes), Some(resp));
}
#[test]
fn response_tags() {
assert_eq!(Response::Pong.tag(), 0);
assert_eq!(Response::Config(make_config()).tag(), 1);
assert_eq!(
Response::RxPacket { rssi: 0, snr: 0, payload: vec![] }.tag(),
2
);
assert_eq!(Response::TxDone.tag(), 3);
assert_eq!(Response::Ok.tag(), 4);
assert_eq!(Response::Error(ErrorCode::RadioBusy).tag(), 5);
assert_eq!(Response::MacAddress([0; 6]).tag(), 6);
}
#[test]
fn response_is_rx_packet() {
assert!(Response::RxPacket { rssi: 0, snr: 0, payload: vec![] }.is_rx_packet());
assert!(!Response::Ok.is_rx_packet());
assert!(!Response::Pong.is_rx_packet());
}
#[test]
fn response_invalid_tag() {
assert!(Response::from_bytes(&[7]).is_none());
assert!(Response::from_bytes(&[255]).is_none());
}
#[test]
fn response_truncated() {
assert!(Response::from_bytes(&[]).is_none());
assert!(Response::from_bytes(&[5]).is_none()); // Error with no code
assert!(Response::from_bytes(&[2, 0, 0]).is_none()); // RxPacket too short
assert!(Response::from_bytes(&[6, 0, 0, 0]).is_none()); // MacAddress too short
}
// ── ErrorCode ──────────────────────────────────────────────────
#[test]
fn error_code_from_u8() {
for v in 0..=5 {
assert!(ErrorCode::from_u8(v).is_some());
}
assert!(ErrorCode::from_u8(6).is_none());
assert!(ErrorCode::from_u8(255).is_none());
}
#[test]
fn error_code_display() {
assert_eq!(ErrorCode::InvalidConfig.to_string(), "InvalidConfig");
assert_eq!(ErrorCode::RadioBusy.to_string(), "RadioBusy");
}
// ── Cross-compatibility with firmware encoding ─────────────────
#[test]
fn firmware_worked_example() {
// From PROTOCOL.md: 915 MHz, 125 kHz BW, SF7, CR 4/5, sync 0x1424, max power
let 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,
};
let cmd = Command::SetConfig(cfg);
let bytes = cmd.to_bytes();
// Expected: 02 C0 CA 89 36 07 07 05 24 14 80 00 00 01
assert_eq!(
bytes,
[0x02, 0xC0, 0xCA, 0x89, 0x36, 0x07, 0x07, 0x05, 0x24, 0x14, 0x80, 0x00, 0x00, 0x01]
);
}
}
-228
View File
@@ -1,228 +0,0 @@
//! Transport abstraction for byte-stream connections.
//!
//! The [`Transport`] trait unifies serial ports and mux socket connections,
//! allowing the [`Client`](crate::client::Client) to work over any of them.
use std::io::{self, Read, Write};
use std::time::Duration;
/// A byte-stream transport that the client can talk over.
pub trait Transport: Read + Write + Send {
/// Set the read timeout. A read that takes longer returns zero bytes.
fn set_timeout(&mut self, timeout: Duration) -> anyhow::Result<()>;
/// Get the current read timeout.
fn timeout(&self) -> Duration;
}
// ── Serial transport ───────────────────────────────────────────────
/// Direct USB serial port connection to a DongLoRa dongle.
pub struct SerialTransport {
port: Box<dyn serialport::SerialPort>,
timeout: Duration,
}
impl SerialTransport {
/// Open a serial port with the given timeout.
///
/// Baud rate is irrelevant for USB CDC-ACM but we pass a conventional value.
pub fn open(path: &str, timeout: Duration) -> anyhow::Result<Self> {
let port = serialport::new(path, 115_200)
.timeout(timeout)
.open()
.map_err(|e| anyhow::anyhow!("failed to open serial port {path}: {e}"))?;
Ok(Self { port, timeout })
}
/// Clear the serial input buffer.
pub fn reset_input_buffer(&self) -> anyhow::Result<()> {
self.port
.clear(serialport::ClearBuffer::Input)
.map_err(|e| anyhow::anyhow!("failed to clear input buffer: {e}"))
}
}
impl Read for SerialTransport {
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
self.port.read(buf)
}
}
impl Write for SerialTransport {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
self.port.write(buf)
}
fn flush(&mut self) -> io::Result<()> {
self.port.flush()
}
}
impl Transport for SerialTransport {
fn set_timeout(&mut self, timeout: Duration) -> anyhow::Result<()> {
self.port
.set_timeout(timeout)
.map_err(|e| anyhow::anyhow!("failed to set serial timeout: {e}"))?;
self.timeout = timeout;
Ok(())
}
fn timeout(&self) -> Duration {
self.timeout
}
}
// ── Mux transport ──────────────────────────────────────────────────
/// Connection to the DongLoRa mux daemon via Unix socket or TCP.
pub struct MuxTransport {
stream: MuxStream,
timeout: Duration,
}
enum MuxStream {
#[cfg(unix)]
Unix(std::os::unix::net::UnixStream),
Tcp(std::net::TcpStream),
}
impl MuxTransport {
/// Connect to the mux daemon via Unix domain socket.
#[cfg(unix)]
pub fn unix(path: &str, timeout: Duration) -> anyhow::Result<Self> {
let stream = std::os::unix::net::UnixStream::connect(path)
.map_err(|e| anyhow::anyhow!("failed to connect to mux socket {path}: {e}"))?;
stream
.set_read_timeout(Some(timeout))
.map_err(|e| anyhow::anyhow!("failed to set socket read timeout: {e}"))?;
stream
.set_write_timeout(Some(timeout))
.map_err(|e| anyhow::anyhow!("failed to set socket write timeout: {e}"))?;
Ok(Self {
stream: MuxStream::Unix(stream),
timeout,
})
}
/// Connect to the mux daemon via TCP.
pub fn tcp(host: &str, port: u16, timeout: Duration) -> anyhow::Result<Self> {
use std::net::ToSocketAddrs;
let addr = format!("{host}:{port}");
let sock_addr = addr
.to_socket_addrs()
.map_err(|e| anyhow::anyhow!("failed to resolve mux address {addr}: {e}"))?
.next()
.ok_or_else(|| anyhow::anyhow!("no addresses found for {addr}"))?;
let stream = std::net::TcpStream::connect_timeout(&sock_addr, timeout)
.map_err(|e| anyhow::anyhow!("failed to connect to mux at {addr}: {e}"))?;
stream
.set_read_timeout(Some(timeout))
.map_err(|e| anyhow::anyhow!("failed to set TCP read timeout: {e}"))?;
stream
.set_write_timeout(Some(timeout))
.map_err(|e| anyhow::anyhow!("failed to set TCP write timeout: {e}"))?;
Ok(Self {
stream: MuxStream::Tcp(stream),
timeout,
})
}
}
impl Read for MuxTransport {
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
match &mut self.stream {
#[cfg(unix)]
MuxStream::Unix(s) => s.read(buf),
MuxStream::Tcp(s) => s.read(buf),
}
}
}
impl Write for MuxTransport {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
match &mut self.stream {
#[cfg(unix)]
MuxStream::Unix(s) => s.write(buf),
MuxStream::Tcp(s) => s.write(buf),
}
}
fn flush(&mut self) -> io::Result<()> {
match &mut self.stream {
#[cfg(unix)]
MuxStream::Unix(s) => s.flush(),
MuxStream::Tcp(s) => s.flush(),
}
}
}
impl Transport for MuxTransport {
fn set_timeout(&mut self, timeout: Duration) -> anyhow::Result<()> {
self.timeout = timeout;
let result = match &self.stream {
#[cfg(unix)]
MuxStream::Unix(s) => s
.set_read_timeout(Some(timeout))
.and_then(|()| s.set_write_timeout(Some(timeout))),
MuxStream::Tcp(s) => s
.set_read_timeout(Some(timeout))
.and_then(|()| s.set_write_timeout(Some(timeout))),
};
result.map_err(|e| anyhow::anyhow!("failed to set timeout: {e}"))
}
fn timeout(&self) -> Duration {
self.timeout
}
}
// ── AnyTransport ───────────────────────────────────────────────────
/// Type-erased transport for the [`connect`](crate::connect::connect) return type.
pub enum AnyTransport {
Serial(SerialTransport),
Mux(MuxTransport),
}
impl Read for AnyTransport {
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
match self {
Self::Serial(t) => t.read(buf),
Self::Mux(t) => t.read(buf),
}
}
}
impl Write for AnyTransport {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
match self {
Self::Serial(t) => t.write(buf),
Self::Mux(t) => t.write(buf),
}
}
fn flush(&mut self) -> io::Result<()> {
match self {
Self::Serial(t) => t.flush(),
Self::Mux(t) => t.flush(),
}
}
}
impl Transport for AnyTransport {
fn set_timeout(&mut self, timeout: Duration) -> anyhow::Result<()> {
match self {
Self::Serial(t) => t.set_timeout(timeout),
Self::Mux(t) => t.set_timeout(timeout),
}
}
fn timeout(&self) -> Duration {
match self {
Self::Serial(t) => t.timeout(),
Self::Mux(t) => t.timeout(),
}
}
}
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 726 KiB

-2
View File
@@ -1,2 +0,0 @@
.venv/
meshcore/corpus/generated/
-21
View File
@@ -1,21 +0,0 @@
# DongLoRa Examples
Example scripts demonstrating the [Python client library](../clients/python/).
## Running
```sh
cd examples
just rx # receive packets
just tx # transmit a packet
just ping-pong --role tx # two-dongle ping-pong demo
just test-commands # exercise all DongLoRa commands
just bridge --mode server # LoRa bridge over TCP
just meshcore # MeshCore packet decoder
just orac # MeshCore AI bot (needs ANTHROPIC_API_KEY)
just telemetry # MeshCore repeater telemetry monitor
```
## Depends On
- [clients/python](../clients/python/) — the `donglora` client library
-50
View File
@@ -1,50 +0,0 @@
#!/usr/bin/env python3
"""Exercise every DongLoRa command to verify the API works.
Usage:
python examples/all_commands.py [PORT]
"""
import sys
import serial
import donglora as dl
try:
ser = dl.connect(sys.argv[1] if len(sys.argv) > 1 else None)
print("── Ping ──")
print(dl.send(ser, "Ping"))
print("\n── SetConfig ──")
print(dl.send(ser, "SetConfig", config=dl.DEFAULT_CONFIG))
print("\n── GetConfig ──")
print(dl.send(ser, "GetConfig"))
print("\n── StartRx ──")
print(dl.send(ser, "StartRx"))
print("\n── StopRx ──")
print(dl.send(ser, "StopRx"))
print("\n── Transmit ──")
print(dl.send(ser, "Transmit", payload=b"Hello from all_commands.py!"))
print("\n── DisplayOff ──")
print(dl.send(ser, "DisplayOff"))
print("\n── DisplayOn ──")
print(dl.send(ser, "DisplayOn"))
print("\n── GetMac ──")
print(dl.send(ser, "GetMac"))
print("\nAll 9 commands exercised successfully.")
except KeyboardInterrupt:
print("\nInterrupted.")
except serial.SerialException as e:
print(f"\nSerial error: {e}", file=sys.stderr)
sys.exit(1)
-42
View File
@@ -1,42 +0,0 @@
set shell := ["bash", "-c"]
_proj := source_directory()
_uv := "uv run --project " + _proj
_uv_meshcore := "uv run --project " + _proj + " --extra meshcore"
_uv_orac := "uv run --project " + _proj + " --extra orac"
[private]
_ensure_tools:
@mise trust --yes . 2>/dev/null; mise install --quiet
# Receive LoRa packets (Ctrl-C to stop)
rx *args: _ensure_tools
@{{_uv}} {{source_directory()}}/simple_rx.py {{args}}
# Transmit a single packet
tx *args: _ensure_tools
@{{_uv}} {{source_directory()}}/simple_tx.py {{args}}
# Two-dongle ping-pong demo (--role tx|rx)
ping-pong *args: _ensure_tools
@{{_uv}} {{source_directory()}}/ping_pong.py {{args}}
# Exercise all DongLoRa commands
test-commands *args: _ensure_tools
@{{_uv}} {{source_directory()}}/all_commands.py {{args}}
# Two-way LoRa bridge over TCP
bridge *args: _ensure_tools
@{{_uv}} {{source_directory()}}/lora_bridge.py {{args}}
# MeshCore packet decoder/monitor
meshcore *args: _ensure_tools
@{{_uv_meshcore}} {{source_directory()}}/meshcore/meshcore_rx.py {{args}}
# MeshCore AI bot (requires ANTHROPIC_API_KEY)
orac *args: _ensure_tools
@{{_uv_orac}} {{source_directory()}}/meshcore/ai_bot.py {{args}}
# MeshCore repeater telemetry monitor
telemetry *args: _ensure_tools
@{{_uv_meshcore}} {{source_directory()}}/meshcore/telemetry_monitor.py {{args}}
-174
View File
@@ -1,174 +0,0 @@
#!/usr/bin/env python3
"""Two-way LoRa bridge over TCP — relay packets between two DongLoRa dongles
across any IP network (LAN, Tailscale, WireGuard, internet).
Architecture:
[Radio A] <-USB-> [DongLoRa A] <-TCP-> [DongLoRa B] <-USB-> [Radio B]
Usage:
# Machine A (server):
python examples/lora_bridge.py --mode server --port 9100
# Machine B (client):
python examples/lora_bridge.py --mode client --host machineA --port 9100
Both sides:
- Open local DongLoRa, configure radio, start RX
- Forward received LoRa packets -> TCP -> remote side -> TX
- Bidirectional: both sides receive AND transmit
"""
import argparse
import serial
import socket
import struct
import sys
import threading
import time
import donglora as dl
def tcp_send(sock: socket.socket, payload: bytes):
"""Send a length-prefixed message over TCP."""
sock.sendall(struct.pack("<I", len(payload)) + payload)
def tcp_recv(sock: socket.socket) -> bytes | None:
"""Receive a length-prefixed message from TCP. Returns None on disconnect."""
header = b""
while len(header) < 4:
chunk = sock.recv(4 - len(header))
if not chunk:
return None
header += chunk
length = struct.unpack("<I", header)[0]
if length > 65536:
return None # sanity check
data = b""
while len(data) < length:
chunk = sock.recv(length - len(data))
if not chunk:
return None
data += chunk
return data
_ser_lock = threading.Lock()
def radio_to_tcp(ser: serial.Serial, sock: socket.socket):
"""Thread: forward LoRa RX packets to TCP."""
ser.timeout = 1
while True:
try:
with _ser_lock:
data = dl.read_frame(ser)
if data is None:
continue
resp = dl.decode_response(data)
if resp["type"] == "RxPacket":
payload = resp["payload"]
snr = resp["snr"]
rssi = resp["rssi"]
sf = dl.DEFAULT_CONFIG["sf"]
min_snr = -2.5 * (sf - 4)
# Grade per PROTOCOL.md — drop likely-corrupt packets
if snr < -32 or snr > 32:
grade = "INVALID"
elif snr < min_snr:
grade = "UNRELIABLE"
elif snr < min_snr + 3:
grade = "MARGINAL"
else:
grade = "GOOD"
if grade in ("INVALID", "UNRELIABLE"):
print(f" RX drop len:{len(payload):3d} rssi:{rssi}dBm snr:{snr}dB [{grade}]")
continue
tag = f" [{grade}]" if grade == "MARGINAL" else ""
print(f" RX→TCP len:{len(payload):3d} rssi:{rssi}dBm snr:{snr}dB{tag}")
tcp_send(sock, payload)
except (serial.SerialException, OSError) as e:
print(f" [radio→tcp error: {e}]")
break
def tcp_to_radio(ser: serial.Serial, sock: socket.socket):
"""Thread: forward TCP packets to LoRa TX."""
while True:
try:
payload = tcp_recv(sock)
if payload is None:
print(" [TCP disconnected]")
break
print(f" TCP→TX len:{len(payload):3d}")
with _ser_lock:
dl.send(ser, "Transmit", payload=payload)
except (serial.SerialException, OSError) as e:
print(f" [tcp→radio error: {e}]")
break
def run_bridge(ser: serial.Serial, sock: socket.socket):
"""Run bidirectional bridge between serial and TCP."""
print("Bridge active — forwarding packets bidirectionally\n")
t1 = threading.Thread(target=radio_to_tcp, args=(ser, sock), daemon=True)
t2 = threading.Thread(target=tcp_to_radio, args=(ser, sock), daemon=True)
t1.start()
t2.start()
try:
while t1.is_alive() and t2.is_alive():
time.sleep(0.5)
except KeyboardInterrupt:
pass
print("\nBridge stopped.")
def main():
parser = argparse.ArgumentParser(description="DongLoRa two-way LoRa bridge over TCP")
parser.add_argument("--mode", choices=["server", "client"], required=True)
parser.add_argument("--host", default="localhost", help="Remote host (client mode)")
parser.add_argument("--port", type=int, default=9100, help="TCP port")
parser.add_argument("--serial", default=None, help="Serial port (auto-detect if omitted)")
args = parser.parse_args()
try:
# Open DongLoRa
ser = dl.connect(args.serial)
print(dl.send(ser, "SetConfig", config=dl.DEFAULT_CONFIG))
print(dl.send(ser, "StartRx"))
# Establish TCP connection
if args.mode == "server":
print(f"Listening on port {args.port}...")
srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
srv.bind(("0.0.0.0", args.port))
srv.listen(1)
sock, addr = srv.accept()
print(f"Connected from {addr}")
srv.close()
else:
print(f"Connecting to {args.host}:{args.port}...")
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.connect((args.host, args.port))
print("Connected")
run_bridge(ser, sock)
except KeyboardInterrupt:
print("\nInterrupted.")
except serial.SerialException as e:
print(f"\nSerial error: {e}", file=sys.stderr)
sys.exit(1)
except OSError as e:
print(f"\nNetwork error: {e}", file=sys.stderr)
sys.exit(1)
if __name__ == "__main__":
main()
-30
View File
@@ -1,30 +0,0 @@
# MeshCore Example
This is a **protocol implementation** built on top of DongLoRa. The DongLoRa
firmware itself is protocol-agnostic — it just gives you a LoRa radio pipe.
MeshCore is one of many possible protocols you can run over it.
## What's here
- **meshcore_rx.py** — MeshCore packet decoder, Watchman mesh health reporter
- **channels.csv** — 276 known MeshCore channels (Public + hashtag channels)
- **corpus/** — Test vectors for MeshCore packet decoders
## Usage
From the `examples/` directory:
```sh
just meshcore [PORT]
```
Connects to a DongLoRa device, configures the radio for the MeshCore
frequency/modulation, and decodes received packets in real time.
## Running the test corpus
From the `examples/` directory:
```sh
uv run --extra meshcore meshcore/corpus/validate.py
```
File diff suppressed because it is too large. Load diff
-281
View File
@@ -1,281 +0,0 @@
channel_name,hashtag,key_hex,channel_hash,description,region
Public,False,8b3387e9c5cdea6ac9e5edbaa115cd72,17,"Default public channel (fixed PSK, not hashtag-derived)",global
#test,True,9cd8fcf22a47333b591d96a2b848b73f,156,Global testing channel,global
#testing,True,cde5e82cf515647dcb547a79a4f065d1,205,Testing (alternate name),global
#ping,True,3cae16fd067ba9c32a98be22e9b98525,60,Ping-bot testing channel,global
#emergency,True,e1ad578d25108e344808f30dfdaaf926,225,Emergency communications,global
#hamradio,True,83c8b01997654265938da8765cbc7db9,131,HAM radio operators,global
#qrv,True,4d6de18b9bb6ef3f7c7197e8441044ed,77,HAM QSO coordination/logging,global
#hard-software,True,c4419524d9ce72ac093fdd2a0a856595,196,Hardware/software discussion & troubleshooting,global
#blackout,True,75420460f335577fa27cdd20dacf9edd,117,Power outage info/notifications,global
#warnings,True,31d44d29ed676f8020dc09211a2ee9d8,49,General warnings channel,global
#aurora-alert,True,e823127951fc6a425abe0c65ac07b323,232,Aurora/northern lights alerts,global
#flachwitze,True,ec98cd8821fada7fe21ff9e1df6c619f,236,Jokes channel (German flat jokes),global
#general,True,4c49f3f24629f5ee4ad5b3965db47985,76,General chat (common default),global
#switzerland,True,8ad1ce57ad257627090ed28413c1f0b7,138,Swiss national channel,CH
#svizzera,True,e571e6921d9c19d1c393f2ba9286abba,229,Italian-speaking Switzerland,CH
#suisse,True,2b8dd2d22f0820d9945316be97359ab1,43,French-speaking Switzerland,CH
#chtest,True,ee95ca5221f5b41941b26d89fd702c37,238,Swiss testing channel,CH
#bern,True,7658cadca2e40ba98cb13ce19782e994,118,Bern region,CH
#zuerich,True,15d09e6787401c4fdbef4bf10ffcef53,21,Zürich canton/city,CH
#wallis,True,1e65af5328cec2c29d03c77a5dfb8cb8,30,Wallis/Valais canton,CH
#freiburg,True,ec1c88f03aad858a6d74ec36ac04c420,236,"Freiburg (DE, near CH border)",CH/DE
#netzstatus,True,e84d5ce15046d5a7f69db3a40f604851,232,"Network status, mainly Germany",DE
#muenchen,True,3f50b3350a8ed17a303f07e9641dff4a,63,Munich area,DE
#hansemesh,True,21ddee26dc5215153c3903165fd4262f,33,HanseMesh community (Hamburg),DE
#de-sn-dd,True,c252cf274c306e76d9531fdf96f8dfa0,194,Dresden/Saxony test channel,DE
#berlin,True,8bffd7b0bf481d92afc625e409b88a16,139,Berlin (mentioned in MeshCore tutorial),DE
#hamburg,True,6ee6b4b98d5856392596cc1929eb2e36,110,Hamburg area,DE
#koeln,True,cc2de011842824b10ee2e28e97f91339,204,Cologne area,DE
#frankfurt,True,0d8e969330d62f9356b5e4d415ea7f98,13,Frankfurt area,DE
#stuttgart,True,74710f9e61866f184734bc367d9f492a,116,Stuttgart area,DE
#deutschland,True,6870d6013221f0278122cb8c88990794,104,Germany national,DE
#ruhrgebiet,True,6b5f2c549d9de0116749aa087e3caa4e,107,Ruhr area,DE
#nuernberg,True,a6e4f905a64512843b12ec20750f4161,166,Nuremberg area,DE
#dresden,True,a6f1bb7dbc598884b7a15be59e61ecd6,166,Dresden area,DE
#leipzig,True,0cc55d55809a7b048c39dd01c3254381,12,Leipzig area,DE
#hannover,True,b9430bfc2908b23efcae886f16eb425b,185,Hannover area,DE
#duesseldorf,True,1dfb747e50229101f2232e391d1eb5a7,29,Düsseldorf area,DE
#dortmund,True,37951d1ca4e428fd24c57f3087f160f0,55,Dortmund area,DE
#essen,True,80174f513e9099612b537bc1cd450a41,128,Essen area,DE
#austria,True,faaa5ef01081222e319a8205357321f4,250,Austria national channel,AT
#wien,True,aba9d5e2fb16d2b02111db5926819065,171,Vienna,AT
#graz,True,ea00635b84c245d519d29dc4044a43a1,234,Graz,AT
#salzburg,True,132d8883191473fc8a82f3447909a37d,19,Salzburg,AT
#innsbruck,True,dee607ccd01ca7ef375c0567c554db97,222,Innsbruck,AT
#linz,True,9b417e09eb452be6d068c90b3c46303d,155,Linz,AT
#italy,True,f8d4dd36f6b9476eb4cec18a1536b17d,248,Italy (English name variant),IT
#italia,True,17fa9f15bec81ed7da12116253db5a20,23,Italy (Italian name variant),IT
#roma,True,3523b8022329e6648738f0e5448ffcc1,53,Rome,IT
#milano,True,ea8929998e608b67fe5c2bcf29473d8f,234,Milan,IT
#nederland,True,c14f7b1e0416e4fa74f5f7b81a707b50,193,Netherlands national,NL
#amsterdam,True,d768f5a0aa65f8c54e4ea521bd49eb4f,215,Amsterdam,NL
#rotterdam,True,e3f3ad251e512a029bd27161b14f0bdd,227,Rotterdam,NL
#utrecht,True,042e458dfb05b17cbdee57f93df4be4c,4,Utrecht,NL
#denhaag,True,84eed36f62c7b22527dbf8883585ad14,132,The Hague,NL
#eindhoven,True,aceb8cceae584a3890c8db173668ae2b,172,Eindhoven,NL
#groningen,True,6a18329035bd3d2be69fed9094ffa5f4,106,Groningen,NL
#localmesh,True,13934f45750ce5f54311771bc52dc2a9,19,LocalMesh NL community,NL
#belgium,True,7085b78ed010599094f8c8e7d1aa0e27,112,Belgium national,BE
#belgie,True,559a87a20b1170a11656ffbe15d27c07,85,Belgium (Dutch),BE
#brussel,True,58648303d8e2b4e35f4301c4551d6307,88,Brussels,BE
#antwerpen,True,5900168627ddb2963d3fd8dba3fecbb9,89,Antwerp,BE
#gent,True,82f82c2835bd5fbda1ab0eb134364532,130,Ghent,BE
#france,True,edcb362b38e74b99025a7e551d925d20,237,France national,FR
#paris,True,d0fc2b1ec400eb8669010ce0311a00fb,208,Paris,FR
#lyon,True,0b4b602516088ea6d245be43102643b0,11,Lyon,FR
#marseille,True,c91ed99a23e560300b60325c0b9fb3ea,201,Marseille,FR
#uk,True,22b2eed34b5cc429ce1dc5e88635ff84,34,United Kingdom,UK
#london,True,9881d2b7ab9105a41a8d0f6ba449447e,152,London (mentioned in MeshCore tutorial),UK
#manchester,True,5ddc4f781fa3f504e9f514e77df13790,93,Manchester,UK
#birmingham,True,13b1c76ef007b4ad0c3b168610337513,19,Birmingham,UK
#scotland,True,f4fce403cfd56f7089920d065718f29c,244,Scotland,UK
#ireland,True,1b2a12acc5db1517d9d407946756b1da,27,Ireland,IE
#dublin,True,8792638977132bc05a1f72d6bb913694,135,Dublin,IE
#seattle,True,ef627a9bbbb549347fdb76bf0cd3bc14,239,Seattle (Puget Mesh),US-WA
#bot,True,eb50a1bcb3e4e5d7bf69a57c9dada211,235,Bot channel (Puget Mesh),US-WA
#bot-tacoma,True,278df637e12a0dda01c826e1fd9779fd,39,Bot channel Tacoma,US-WA
#bot-van,True,bf07fbf914e37710561cb741e914efeb,191,Bot channel Vancouver,US-WA/CA
#capitolhill,True,7f281916c8ec32e13c5ef687d182160a,127,Capitol Hill Seattle,US-WA
#protest,True,195e9167baf010c4538d8532b6d7296e,25,Protest coordination,US-WA
#queer,True,5754476f162d93bbee3de0efba136860,87,LGBTQ+ community,US-WA
#seahawks,True,4973d6ceb6c35a09894a83688847c8a3,73,Seattle Seahawks,US-WA
#sports,True,e8ee81f3aabf105d9ba2d2d4bd94fe4a,232,Sports discussion,US-WA
#salishmesh,True,c1ec55555132c7598393c869715b7555,193,Salish Mesh (Vancouver BC area),CA-BC
#dwebyvr,True,b8f956aab74dae02f70f11ddba36de53,184,DWeb Vancouver,CA-BC
#zspace,True,fc86b8cceea87091d45f166c7a698f78,252,ZSpace Vancouver,CA-BC
#portland,True,45c6bc719c15b9fb809f48f594359877,69,Portland OR,US-OR
#pnw,True,98059014c708581fbf0a398cfe8a486d,152,Pacific Northwest,US-PNW
#tacoma,True,9ba83a22a865c9baaffc6bc8b6a33d2f,155,Tacoma WA,US-WA
#idahomesh,True,60baad727ec5235cee359135aeecb3f4,96,Idaho statewide mesh,US-ID
#nodakmesh,True,156134e4a3f6d9826a39e0b7f85afdcd,21,North Dakota mesh,US-ND
#florida,True,c44bd74eac2c81dcb6dfb217727b05cd,196,Florida statewide,US-FL
#miami,True,d81c566a5d337d588ebd250df6fc1b63,216,Miami,US-FL
#tampa,True,8d10508c39d5d8e6c5e3e8fab41d1c09,141,Tampa,US-FL
#orlando,True,3553bdbd9b3a54da760624a27dcda156,53,Orlando,US-FL
#jacksonville,True,6cf63df8459d99d4a4a6177acf6661f6,108,Jacksonville,US-FL
#texas,True,c4a214e133de5e9ad276f99fdbd7216f,196,Texas statewide,US-TX
#austin,True,b2e6f9af95d959734d71cdf90ca62533,178,Austin TX,US-TX
#dallas,True,5b2efa4e2ad0a2b83c5486ca4dd244de,91,Dallas TX,US-TX
#houston,True,c001fbacad2d97676395ca37e2576345,192,Houston TX,US-TX
#sanantonio,True,c2e72548c615d1cf2796a6f77b9545b0,194,San Antonio TX,US-TX
#dfw,True,5b7dc809ba579affccab5462c537244e,91,Dallas-Fort Worth,US-TX
#california,True,55da17570958a6a311939c99582e6f60,85,California statewide,US-CA
#losangeles,True,3dd9373dcea0294bd05ab067cc58e9d4,61,Los Angeles,US-CA
#sanfrancisco,True,ae3608083279fd2ad03f0d6a99617d25,174,San Francisco,US-CA
#sandiego,True,08623bbd90a96ecdc1f5c34e7292b35f,8,San Diego,US-CA
#bayarea,True,7f9a5fd3070ad14e337ba100ca53a89b,127,Bay Area,US-CA
#sacramento,True,a6d6927f0b48762cf1346e2ae95cec14,166,Sacramento,US-CA
#socal,True,f4018307615ac79d2e5ef17bb44654d4,244,Southern California,US-CA
#norcal,True,f2438510715f5d9d55eb4370664330f5,242,Northern California,US-CA
#sanjose,True,ce964c28170b1c043d06073e6fcd83a7,206,San Jose,US-CA
#sfbay,True,5bd50243f42644439c21dfe367eea031,91,SF Bay,US-CA
#baymesh,True,1be0e569ad371f72d1e36da7242ae2f0,27,Bay Mesh community,US-CA
#boston,True,9587d847a7208da684c89cc1f525bc03,149,Boston MA,US-MA
#newyork,True,82a78024dd7edf6c9be298c919632e25,130,New York,US-NY
#nyc,True,6e6554655f84ca26fbc09d81d15d6b96,110,New York City,US-NY
#chicago,True,c1c289b131e5222370cbc2048445844b,193,Chicago IL,US-IL
#denver,True,b24355a0d22ed2bf393ec530d75810b4,178,Denver CO,US-CO
#phoenix,True,027850d9410fa98809819c96644ec04c,2,Phoenix AZ,US-AZ
#atlanta,True,3c8f15665f99a349a97427e7c312ee0e,60,Atlanta GA,US-GA
#nashville,True,717490d2561c2e1f211b13e95073fb16,113,Nashville TN,US-TN
#charlotte,True,b67efd607080d98640774cb31181b0ce,182,Charlotte NC,US-NC
#raleigh,True,1a2999ed1fea10e2cdf456ff118193d9,26,Raleigh NC,US-NC
#minneapolis,True,3283cca82b7b0ac50e8014c344cb8a86,50,Minneapolis MN,US-MN
#stlouis,True,f366422991a19e745f65096e59b43d51,243,St. Louis MO,US-MO
#pittsburgh,True,926cb5461a1ef572619da576ef7de04b,146,Pittsburgh PA,US-PA
#detroit,True,bd01f26bf7d8c90952753157a41c61ac,189,Detroit MI,US-MI
#washingtondc,True,0067d451cc79f26bc9924b3fc53f28d7,0,Washington DC,US-DC
#dc,True,0f3aa71fed514f5c16ebaf265ef05b2e,15,DC (short form),US-DC
#colorado,True,ef61c9e5a3286053746f7603044bcb08,239,Colorado statewide,US-CO
#ohio,True,e89e8c2f72ebae476c4ded78a360d0ea,232,Ohio statewide,US-OH
#virginia,True,07655778c8adeb16c1a0470cc3516b01,7,Virginia statewide,US-VA
#georgia,True,6621a99c4c21a1f4897a9833a95ac962,102,Georgia statewide,US-GA
#michigan,True,4b0dc02774aa2ad8fe120d4ab0d17f1c,75,Michigan statewide,US-MI
#newengland,True,4ad1e19832319b916ab4f3a0fbb0a38e,74,New England region,US-NE
#appalachia,True,ab353b2bf5d281d97cd73d897ef8e149,171,Appalachian region,US
#hawaii,True,3fd57495820328594e1641d14583faa6,63,Hawaii,US-HI
#alaska,True,2a5841192b151422baec71537b0b5238,42,Alaska,US-AK
#vegas,True,d45435b6467e7ded33c28f6796bf5183,212,Las Vegas,US-NV
#lasvegas,True,b82b823d6dcc845ead47cee8cfa758b3,184,Las Vegas (long form),US-NV
#saltlakecity,True,07e180549b35782901d407832b3ec6a6,7,Salt Lake City,US-UT
#slc,True,fd31a70b220eb3e2430e59c15432ef8c,253,SLC (short form),US-UT
#utah,True,ee42ff5f055f2e83527e4efb91b10d0a,238,Utah statewide,US-UT
#canada,True,8373bb1055f34164c6dd2663927cb6d9,131,Canada national,CA
#toronto,True,e0d3774dd1da4dc5c55d8bd731555334,224,Toronto,CA-ON
#vancouver,True,16d6034be448ab86d11858cfa4c57c9c,22,Vancouver,CA-BC
#montreal,True,0c4c03b5fbea5b80f89e2a2a16ed3f40,12,Montreal,CA-QC
#calgary,True,bbbb1ad23fe1cdd7739667418204a57d,187,Calgary,CA-AB
#ottawa,True,7871ec72b45617696c35c970bddd8124,120,Ottawa,CA-ON
#edmonton,True,ce451a3a24ee89ee31564ae29e9dee04,206,Edmonton,CA-AB
#winnipeg,True,3c3294aac8a83b1143ab6bedee5b5226,60,Winnipeg,CA-MB
#australia,True,eadb84fa1da64c44b77c40fd11b9d78e,234,Australia national,AU
#sydney,True,57fe5284a5b905835193868d9bdbe1e9,87,Sydney,AU-NSW
#melbourne,True,4d73731d9450ccb9673eb923c0f40af2,77,Melbourne,AU-VIC
#brisbane,True,e4bd09784621decac600d0d4d857e3b2,228,Brisbane (MBUG community),AU-QLD
#perth,True,9873c6cf14dac409b5a573574cff5ce1,152,Perth,AU-WA
#adelaide,True,9ad0c536f3eabc7c2f0434d68e242009,154,Adelaide,AU-SA
#canberra,True,c716951221e6c17faf5f22b6245ce64b,199,Canberra,AU-ACT
#newzealand,True,1870a961e4aa06f62b02b835efcacb71,24,New Zealand national,NZ
#nz,True,eb87ee8817ba71315ac7be9c733b523a,235,NZ (short form),NZ
#auckland,True,7c6f1c71a5a3d6823a1d7bfd2a349ac8,124,Auckland,NZ
#wellington,True,c2da57d74f78996ad71bbe3e22446f16,194,Wellington,NZ
#christchurch,True,7b88b875fc5ae76305c8a15349549a3b,123,Christchurch,NZ
#gisborne,True,d8b45a1eb52d0bd45655d5f2a72d571f,216,Gisborne (mentioned in MeshCore tutorial),NZ
#weather,True,88f502554fee92a1625cfb311546e7cb,136,Weather updates,topical
#jokes,True,abe610d61dee508c0ac18793c36c41c3,171,Jokes channel,topical
#quotes,True,086be6f920280b7cc51d6ad91d6a786c,8,Quotes channel,topical
#secret,True,be657c0527e122bf93bc735999cd7e0d,190,Secret channel (Brisbane MBUG),topical
#chat,True,d0bdd6d71538138ed979eec00d98ad97,208,General chat,topical
#news,True,ecadb1a7d803db8958bea1302ca6e8be,236,News channel,topical
#help,True,dcc67fae2067046832af7b2b0b743165,220,Help channel,topical
#sos,True,9ed2c78bcd68ac7ce2a2fc3bb4045114,158,SOS / distress,topical
#prepper,True,1eec1a7df7080a392cb490473c4a9920,30,Prepper community,topical
#offgrid,True,aaa26662bebf122262692d0bca61dcef,170,Off-grid living,topical
#radio,True,266f225baced1b2a868dcc8e9c69a304,38,Radio hobby,topical
#lora,True,0749ea267c6be7b54ca1dbbae7dba0aa,7,LoRa technology discussion,topical
#meshcore,True,2fa78a5aef618e7c2a78f0ab5c8869b3,47,MeshCore project discussion,topical
#meshtastic,True,73a2e13ff0dea9ed19b24b2ab753650f,115,Meshtastic discussion,topical
#iot,True,233b42534ed82b8ae6a88d785f1b27fd,35,IoT / sensors,topical
#solar,True,7592a730ff1af2b40b95589fbafcca01,117,Solar power,topical
#emcomm,True,a9a49340642dfc4c562d7849b7c8a258,169,Emergency communications,topical
#ares,True,44419e394cde859e45710f288db939a8,68,ARES (Amateur Radio Emergency Service),topical
#gmrs,True,2ad811e84ab4ad0e3a648d8fcf3d650a,42,GMRS radio,topical
#cb,True,a35b5f87bbb67859a02e7666c823de7a,163,CB radio,topical
#hiking,True,2370a013053e384e5f18918bc2b26baf,35,Hiking,topical
#camping,True,c011b7c2abf33748cd9bd5a78c2b4955,192,Camping,topical
#hunting,True,0c0c28fd9bbf096ee06e01af840037af,12,Hunting,topical
#fishing,True,a671ca13cc499898aea4596e804ecf20,166,Fishing,topical
#skiing,True,b3baf3586d347ecaf2c113106a564207,179,Skiing,topical
#biking,True,9c0591d708b7c7970c9ad6712e3c4d05,156,Biking,topical
#ham,True,5270db3979da687fa133fec6684cd952,82,HAM radio (short form),topical
#cq,True,bc9da8bdebcaeee40fefff7dd16855f1,188,CQ (calling all stations),topical
#dx,True,adf77706da76b66bb0a313c1484d3be4,173,DX (long distance contacts),topical
#repeater,True,289991a3077903263f2d31493887c651,40,Repeater discussion,topical
#firmware,True,6163564e7449c1c842e58dcf2cd1ab4f,97,Firmware discussion,topical
#debug,True,fd7a60ed4796efcd1965c2de466105cb,253,Debug channel,topical
#dev,True,d41bcba61e9dca7177c7b8533d23a0bc,212,Development channel,topical
#admin,True,889334b7e486938c776dbdde120da9de,136,Admin channel,topical
#monitor,True,131479235e65b7c591307f699d64dc74,19,Monitoring channel,topical
#alert,True,678c7d2e08c019e113ace03eaaa128ad,103,Alert channel,topical
#local,True,d2d35fa76be9875ed254db80397483a5,210,Local community,topical
#neighborhood,True,53403881955f0e11573ba1e5d3a82c4b,83,Neighborhood chat,topical
#community,True,b9323ce55a729068ed8e3ed49b9b84b5,185,Community channel,topical
#events,True,d2cbcc606979cdb05c7bd66f49f23baa,210,Events channel,topical
#trade,True,c2be982890fb3a63113de637b9aee6fe,194,Trading/swap channel,topical
#marketplace,True,354a741c2975c843ec0706089390b968,53,Marketplace,topical
#gaming,True,3802c79121f195ea50ad9ab2aa2c402a,56,Gaming,topical
#music,True,b025ab29b0a5f56fb68a474741d7a2cc,176,Music,topical
#tech,True,5177b749eefbfab3c90a21b4e2518c5f,81,Technology,topical
#science,True,8abc980d3764fdabda5dfa6b6bc3a6cc,138,Science,topical
#crypto,True,0aa7c45d99be27024b82a1474c693951,10,Cryptocurrency,topical
#bitcoin,True,94bf6fd2688b1456c071ebad63d7021d,148,Bitcoin,topical
#linux,True,02c74eda5d8ec8b9bb945049fb2f55c6,2,Linux,topical
#arduino,True,ce8c596922eff9274f3b1e19ad296754,206,Arduino,topical
#raspberry,True,469bc476d8263b64b6f90ca868b04b91,70,Raspberry Pi,topical
#esp32,True,9f8aac4c48973b07bbdc47acbaea3e8e,159,ESP32 projects,topical
#heltec,True,71af5d9a209e8b17a19ee22aa7b6be23,113,Heltec devices,topical
#lilygo,True,a33dd4b025cc82b81f97318a63a9d5d3,163,LilyGo devices,topical
#rak,True,1f4734675fdfcabfaa67a0a059c65c5f,31,RAK Wireless devices,topical
#antenna,True,e8979157107155ade90822e29b186ee8,232,Antenna discussion,topical
#rf,True,46aaa16f78b3969040aad2b6b694b59e,70,RF engineering,topical
#preppers,True,4877173813de9668b9ef33adbc1b8f37,72,Preppers community,topical
#survival,True,e1f465ea51df09fc901389758f1c5f01,225,Survival skills,topical
#grid-down,True,94ddbd51f2ee0bd47426a6f3ed79be99,148,Grid-down scenarios,topical
#wildfire,True,b9124c3aecc0c813634f79f144b93f83,185,Wildfire alerts,topical
#hurricane,True,c13a2f9d18400e5d93bae4bb3afef40c,193,Hurricane alerts,topical
#earthquake,True,0c7082c04a1a90502a5f32fc6a9f6524,12,Earthquake alerts,topical
#flood,True,f2a0fd0abe4c9fc6865b5f8eebf319d6,242,Flood alerts,topical
#tornado,True,76e577e8187d0bab89052343f3835039,118,Tornado alerts,topical
#storm,True,113761b9e31a5c30e0ee4fc78dc7310b,17,Storm alerts,topical
#traffic,True,c1216dacbb7e4c3b9a3356e52f1c27fa,193,Traffic updates,topical
#fire,True,3d74a070077293ab66baf3aa724349ff,61,Fire alerts,topical
#police,True,79b89aa870ea85dbb8396f401e547d99,121,Police scanner / alerts,topical
#ems,True,f714461f26d43d9a17b2530c3ad29bc2,247,EMS / first responders,topical
#search-rescue,True,cd8ab9e4944ee656ae6e961a4437c551,205,Search and rescue,topical
#volunteer,True,2d3d64746075cb99407d681e547b99cb,45,Volunteer coordination,topical
#data,True,b0d9c01c8278a711c68ffdca98ef1245,176,Data/telemetry channel,topical
#sensor,True,6218df72386dd48259024327e8ebf66f,98,Sensor data channel,topical
#tracker,True,70258e69aeb03d1cd2e16072e1fe14b5,112,Asset/pet tracker,topical
#gps,True,af4b9f722a6200b1eb2696660017d0df,175,GPS tracking,topical
#boat,True,c03117578e38c3f1eb5ae8c3d0019753,192,Boating/maritime,topical
#sailing,True,c2f6052f8b89639f81c8ff4def32deae,194,Sailing,topical
#marine,True,7f4b40aac63642805a54a959beb07694,127,Marine channel,topical
#aviation,True,e17538c835ee0990b349ca1879b4f57a,225,Aviation,topical
#drone,True,4460931543103de42d2f85093a80273c,68,Drone / UAV,topical
#fpv,True,6d2752bc94797cfce43e2138eab7a772,109,FPV flying,topical
#farm,True,4a2f1b2147165cf942402a4c6bd7d42d,74,Farming / agriculture,topical
#ranch,True,5885e728fc06bfcbe7d6c9020c13ff10,88,Ranch / livestock,topical
#tucson,True,cf989ccea881cf5ddcf40d87bbfc441e,207,Tucson AZ,US-AZ
#albuquerque,True,2a59b6ce7d3ce661664217f9b8255e86,42,Albuquerque NM,US-NM
#boise,True,f4e641f9c90ac3bd5bc442ef379f6eb3,244,Boise ID,US-ID
#spokane,True,dfcf2a863b3baa99e3d3274bfb56439f,223,Spokane WA,US-WA
#olympia,True,baec83dd15ef758ad8bf8f357237c9cb,186,Olympia WA,US-WA
#bellingham,True,6000c8e3c1343fd920749eb3ad2a42b4,96,Bellingham WA,US-WA
#eugene,True,e6ce6f5e71d88bb90c0b7dacc2235662,230,Eugene OR,US-OR
#bend,True,291ddea6fc1c1742f7ad425819b66f7b,41,Bend OR,US-OR
#reno,True,e9edf1edf57a04b8f66e75829b4edc06,233,Reno NV,US-NV
#provo,True,72744153a7a2d84ed69338be4dc3fc31,63,Provo,US-UT
#orem,True,97236968b8a6c0cf297f1c1365539d0d,157,Orem,US-UT
#ogden,True,cdeec2a40203056a6c6f2d1fb5f50b81,245,Ogden,US-UT
#logan,True,b0ee430ede14470d5ff8fafbe092ee50,145,Logan,US-UT
#stgeorge,True,7792659def2811af89a02270b2918235,128,St. George,US-UT
#parkcity,True,bbb64d6ae8eaeaf4302f501844f64511,25,Park City,US-UT
#lehi,True,18f20a96d251665105d59df6ca5bcc5c,98,Lehi,US-UT
#byu,True,65633d6a99bbcb173cb01afeae3a51d0,194,Brigham Young University,US-UT
#uvu,True,cb305b9b3d0ab33fce2186ee6b9c58ff,91,Utah Valley University,US-UT
#uofu,True,c08fe0d4d37b4b7f233f2977296979d5,178,University of Utah,US-UT
#usu,True,ceb222637d010d1c5551a5035e86f89d,104,Utah State University,US-UT
#utahcounty,True,f191a8a833d2ae61b7fbfa8c9891cc0c,23,Utah County,US-UT
#daviscounty,True,926847daaed0baa7758df5ef8af76f32,11,Davis County,US-UT
#webercounty,True,364726e43d1fb9b8079ededb03f00764,172,Weber County,US-UT
#wasatchfront,True,eb9fa3bfa390eed3737634bed39c7460,44,Wasatch Front metro corridor,US-UT
#wasatch,True,15bf0e80d3d88092d38e4c82da7e523e,0,Wasatch region,US-UT
#saltlake,True,56327c6958d9108213f8638fb2a2b7a4,173,Salt Lake (alternate),US-UT
#tooele,True,092cbdcf351bdbe77ce6c00e1dbeb294,184,Tooele,US-UT
#sanpete,True,4ae01748b17bfc90e13004f19e734bbc,151,Sanpete County,US-UT
#nodeops,True,33677d99a0e707e37b57de3e3cd26cf3,151,Node operations channel,topical
#weekly-net,True,bdc4f5cb11e766c9a09eeb3586e65a58,18,Weekly net check-in,topical
#net,True,13aef6b8ce642cf4c482b91f0816fe68,157,Net channel,topical
#repeater-ops,True,915c91c726e0c9d558365a7ecc72bbd6,108,Repeater operations,topical
#watchman,True,11489fe661e776b3ccdcf864bd14f7d6,245,Watchman mesh health alerts,global
1 channel_name hashtag key_hex channel_hash description region
2 Public False 8b3387e9c5cdea6ac9e5edbaa115cd72 17 Default public channel (fixed PSK, not hashtag-derived) global
3 #test True 9cd8fcf22a47333b591d96a2b848b73f 156 Global testing channel global
4 #testing True cde5e82cf515647dcb547a79a4f065d1 205 Testing (alternate name) global
5 #ping True 3cae16fd067ba9c32a98be22e9b98525 60 Ping-bot testing channel global
6 #emergency True e1ad578d25108e344808f30dfdaaf926 225 Emergency communications global
7 #hamradio True 83c8b01997654265938da8765cbc7db9 131 HAM radio operators global
8 #qrv True 4d6de18b9bb6ef3f7c7197e8441044ed 77 HAM QSO coordination/logging global
9 #hard-software True c4419524d9ce72ac093fdd2a0a856595 196 Hardware/software discussion & troubleshooting global
10 #blackout True 75420460f335577fa27cdd20dacf9edd 117 Power outage info/notifications global
11 #warnings True 31d44d29ed676f8020dc09211a2ee9d8 49 General warnings channel global
12 #aurora-alert True e823127951fc6a425abe0c65ac07b323 232 Aurora/northern lights alerts global
13 #flachwitze True ec98cd8821fada7fe21ff9e1df6c619f 236 Jokes channel (German flat jokes) global
14 #general True 4c49f3f24629f5ee4ad5b3965db47985 76 General chat (common default) global
15 #switzerland True 8ad1ce57ad257627090ed28413c1f0b7 138 Swiss national channel CH
16 #svizzera True e571e6921d9c19d1c393f2ba9286abba 229 Italian-speaking Switzerland CH
17 #suisse True 2b8dd2d22f0820d9945316be97359ab1 43 French-speaking Switzerland CH
18 #chtest True ee95ca5221f5b41941b26d89fd702c37 238 Swiss testing channel CH
19 #bern True 7658cadca2e40ba98cb13ce19782e994 118 Bern region CH
20 #zuerich True 15d09e6787401c4fdbef4bf10ffcef53 21 Zürich canton/city CH
21 #wallis True 1e65af5328cec2c29d03c77a5dfb8cb8 30 Wallis/Valais canton CH
22 #freiburg True ec1c88f03aad858a6d74ec36ac04c420 236 Freiburg (DE, near CH border) CH/DE
23 #netzstatus True e84d5ce15046d5a7f69db3a40f604851 232 Network status, mainly Germany DE
24 #muenchen True 3f50b3350a8ed17a303f07e9641dff4a 63 Munich area DE
25 #hansemesh True 21ddee26dc5215153c3903165fd4262f 33 HanseMesh community (Hamburg) DE
26 #de-sn-dd True c252cf274c306e76d9531fdf96f8dfa0 194 Dresden/Saxony test channel DE
27 #berlin True 8bffd7b0bf481d92afc625e409b88a16 139 Berlin (mentioned in MeshCore tutorial) DE
28 #hamburg True 6ee6b4b98d5856392596cc1929eb2e36 110 Hamburg area DE
29 #koeln True cc2de011842824b10ee2e28e97f91339 204 Cologne area DE
30 #frankfurt True 0d8e969330d62f9356b5e4d415ea7f98 13 Frankfurt area DE
31 #stuttgart True 74710f9e61866f184734bc367d9f492a 116 Stuttgart area DE
32 #deutschland True 6870d6013221f0278122cb8c88990794 104 Germany national DE
33 #ruhrgebiet True 6b5f2c549d9de0116749aa087e3caa4e 107 Ruhr area DE
34 #nuernberg True a6e4f905a64512843b12ec20750f4161 166 Nuremberg area DE
35 #dresden True a6f1bb7dbc598884b7a15be59e61ecd6 166 Dresden area DE
36 #leipzig True 0cc55d55809a7b048c39dd01c3254381 12 Leipzig area DE
37 #hannover True b9430bfc2908b23efcae886f16eb425b 185 Hannover area DE
38 #duesseldorf True 1dfb747e50229101f2232e391d1eb5a7 29 Düsseldorf area DE
39 #dortmund True 37951d1ca4e428fd24c57f3087f160f0 55 Dortmund area DE
40 #essen True 80174f513e9099612b537bc1cd450a41 128 Essen area DE
41 #austria True faaa5ef01081222e319a8205357321f4 250 Austria national channel AT
42 #wien True aba9d5e2fb16d2b02111db5926819065 171 Vienna AT
43 #graz True ea00635b84c245d519d29dc4044a43a1 234 Graz AT
44 #salzburg True 132d8883191473fc8a82f3447909a37d 19 Salzburg AT
45 #innsbruck True dee607ccd01ca7ef375c0567c554db97 222 Innsbruck AT
46 #linz True 9b417e09eb452be6d068c90b3c46303d 155 Linz AT
47 #italy True f8d4dd36f6b9476eb4cec18a1536b17d 248 Italy (English name variant) IT
48 #italia True 17fa9f15bec81ed7da12116253db5a20 23 Italy (Italian name variant) IT
49 #roma True 3523b8022329e6648738f0e5448ffcc1 53 Rome IT
50 #milano True ea8929998e608b67fe5c2bcf29473d8f 234 Milan IT
51 #nederland True c14f7b1e0416e4fa74f5f7b81a707b50 193 Netherlands national NL
52 #amsterdam True d768f5a0aa65f8c54e4ea521bd49eb4f 215 Amsterdam NL
53 #rotterdam True e3f3ad251e512a029bd27161b14f0bdd 227 Rotterdam NL
54 #utrecht True 042e458dfb05b17cbdee57f93df4be4c 4 Utrecht NL
55 #denhaag True 84eed36f62c7b22527dbf8883585ad14 132 The Hague NL
56 #eindhoven True aceb8cceae584a3890c8db173668ae2b 172 Eindhoven NL
57 #groningen True 6a18329035bd3d2be69fed9094ffa5f4 106 Groningen NL
58 #localmesh True 13934f45750ce5f54311771bc52dc2a9 19 LocalMesh NL community NL
59 #belgium True 7085b78ed010599094f8c8e7d1aa0e27 112 Belgium national BE
60 #belgie True 559a87a20b1170a11656ffbe15d27c07 85 Belgium (Dutch) BE
61 #brussel True 58648303d8e2b4e35f4301c4551d6307 88 Brussels BE
62 #antwerpen True 5900168627ddb2963d3fd8dba3fecbb9 89 Antwerp BE
63 #gent True 82f82c2835bd5fbda1ab0eb134364532 130 Ghent BE
64 #france True edcb362b38e74b99025a7e551d925d20 237 France national FR
65 #paris True d0fc2b1ec400eb8669010ce0311a00fb 208 Paris FR
66 #lyon True 0b4b602516088ea6d245be43102643b0 11 Lyon FR
67 #marseille True c91ed99a23e560300b60325c0b9fb3ea 201 Marseille FR
68 #uk True 22b2eed34b5cc429ce1dc5e88635ff84 34 United Kingdom UK
69 #london True 9881d2b7ab9105a41a8d0f6ba449447e 152 London (mentioned in MeshCore tutorial) UK
70 #manchester True 5ddc4f781fa3f504e9f514e77df13790 93 Manchester UK
71 #birmingham True 13b1c76ef007b4ad0c3b168610337513 19 Birmingham UK
72 #scotland True f4fce403cfd56f7089920d065718f29c 244 Scotland UK
73 #ireland True 1b2a12acc5db1517d9d407946756b1da 27 Ireland IE
74 #dublin True 8792638977132bc05a1f72d6bb913694 135 Dublin IE
75 #seattle True ef627a9bbbb549347fdb76bf0cd3bc14 239 Seattle (Puget Mesh) US-WA
76 #bot True eb50a1bcb3e4e5d7bf69a57c9dada211 235 Bot channel (Puget Mesh) US-WA
77 #bot-tacoma True 278df637e12a0dda01c826e1fd9779fd 39 Bot channel Tacoma US-WA
78 #bot-van True bf07fbf914e37710561cb741e914efeb 191 Bot channel Vancouver US-WA/CA
79 #capitolhill True 7f281916c8ec32e13c5ef687d182160a 127 Capitol Hill Seattle US-WA
80 #protest True 195e9167baf010c4538d8532b6d7296e 25 Protest coordination US-WA
81 #queer True 5754476f162d93bbee3de0efba136860 87 LGBTQ+ community US-WA
82 #seahawks True 4973d6ceb6c35a09894a83688847c8a3 73 Seattle Seahawks US-WA
83 #sports True e8ee81f3aabf105d9ba2d2d4bd94fe4a 232 Sports discussion US-WA
84 #salishmesh True c1ec55555132c7598393c869715b7555 193 Salish Mesh (Vancouver BC area) CA-BC
85 #dwebyvr True b8f956aab74dae02f70f11ddba36de53 184 DWeb Vancouver CA-BC
86 #zspace True fc86b8cceea87091d45f166c7a698f78 252 ZSpace Vancouver CA-BC
87 #portland True 45c6bc719c15b9fb809f48f594359877 69 Portland OR US-OR
88 #pnw True 98059014c708581fbf0a398cfe8a486d 152 Pacific Northwest US-PNW
89 #tacoma True 9ba83a22a865c9baaffc6bc8b6a33d2f 155 Tacoma WA US-WA
90 #idahomesh True 60baad727ec5235cee359135aeecb3f4 96 Idaho statewide mesh US-ID
91 #nodakmesh True 156134e4a3f6d9826a39e0b7f85afdcd 21 North Dakota mesh US-ND
92 #florida True c44bd74eac2c81dcb6dfb217727b05cd 196 Florida statewide US-FL
93 #miami True d81c566a5d337d588ebd250df6fc1b63 216 Miami US-FL
94 #tampa True 8d10508c39d5d8e6c5e3e8fab41d1c09 141 Tampa US-FL
95 #orlando True 3553bdbd9b3a54da760624a27dcda156 53 Orlando US-FL
96 #jacksonville True 6cf63df8459d99d4a4a6177acf6661f6 108 Jacksonville US-FL
97 #texas True c4a214e133de5e9ad276f99fdbd7216f 196 Texas statewide US-TX
98 #austin True b2e6f9af95d959734d71cdf90ca62533 178 Austin TX US-TX
99 #dallas True 5b2efa4e2ad0a2b83c5486ca4dd244de 91 Dallas TX US-TX
100 #houston True c001fbacad2d97676395ca37e2576345 192 Houston TX US-TX
101 #sanantonio True c2e72548c615d1cf2796a6f77b9545b0 194 San Antonio TX US-TX
102 #dfw True 5b7dc809ba579affccab5462c537244e 91 Dallas-Fort Worth US-TX
103 #california True 55da17570958a6a311939c99582e6f60 85 California statewide US-CA
104 #losangeles True 3dd9373dcea0294bd05ab067cc58e9d4 61 Los Angeles US-CA
105 #sanfrancisco True ae3608083279fd2ad03f0d6a99617d25 174 San Francisco US-CA
106 #sandiego True 08623bbd90a96ecdc1f5c34e7292b35f 8 San Diego US-CA
107 #bayarea True 7f9a5fd3070ad14e337ba100ca53a89b 127 Bay Area US-CA
108 #sacramento True a6d6927f0b48762cf1346e2ae95cec14 166 Sacramento US-CA
109 #socal True f4018307615ac79d2e5ef17bb44654d4 244 Southern California US-CA
110 #norcal True f2438510715f5d9d55eb4370664330f5 242 Northern California US-CA
111 #sanjose True ce964c28170b1c043d06073e6fcd83a7 206 San Jose US-CA
112 #sfbay True 5bd50243f42644439c21dfe367eea031 91 SF Bay US-CA
113 #baymesh True 1be0e569ad371f72d1e36da7242ae2f0 27 Bay Mesh community US-CA
114 #boston True 9587d847a7208da684c89cc1f525bc03 149 Boston MA US-MA
115 #newyork True 82a78024dd7edf6c9be298c919632e25 130 New York US-NY
116 #nyc True 6e6554655f84ca26fbc09d81d15d6b96 110 New York City US-NY
117 #chicago True c1c289b131e5222370cbc2048445844b 193 Chicago IL US-IL
118 #denver True b24355a0d22ed2bf393ec530d75810b4 178 Denver CO US-CO
119 #phoenix True 027850d9410fa98809819c96644ec04c 2 Phoenix AZ US-AZ
120 #atlanta True 3c8f15665f99a349a97427e7c312ee0e 60 Atlanta GA US-GA
121 #nashville True 717490d2561c2e1f211b13e95073fb16 113 Nashville TN US-TN
122 #charlotte True b67efd607080d98640774cb31181b0ce 182 Charlotte NC US-NC
123 #raleigh True 1a2999ed1fea10e2cdf456ff118193d9 26 Raleigh NC US-NC
124 #minneapolis True 3283cca82b7b0ac50e8014c344cb8a86 50 Minneapolis MN US-MN
125 #stlouis True f366422991a19e745f65096e59b43d51 243 St. Louis MO US-MO
126 #pittsburgh True 926cb5461a1ef572619da576ef7de04b 146 Pittsburgh PA US-PA
127 #detroit True bd01f26bf7d8c90952753157a41c61ac 189 Detroit MI US-MI
128 #washingtondc True 0067d451cc79f26bc9924b3fc53f28d7 0 Washington DC US-DC
129 #dc True 0f3aa71fed514f5c16ebaf265ef05b2e 15 DC (short form) US-DC
130 #colorado True ef61c9e5a3286053746f7603044bcb08 239 Colorado statewide US-CO
131 #ohio True e89e8c2f72ebae476c4ded78a360d0ea 232 Ohio statewide US-OH
132 #virginia True 07655778c8adeb16c1a0470cc3516b01 7 Virginia statewide US-VA
133 #georgia True 6621a99c4c21a1f4897a9833a95ac962 102 Georgia statewide US-GA
134 #michigan True 4b0dc02774aa2ad8fe120d4ab0d17f1c 75 Michigan statewide US-MI
135 #newengland True 4ad1e19832319b916ab4f3a0fbb0a38e 74 New England region US-NE
136 #appalachia True ab353b2bf5d281d97cd73d897ef8e149 171 Appalachian region US
137 #hawaii True 3fd57495820328594e1641d14583faa6 63 Hawaii US-HI
138 #alaska True 2a5841192b151422baec71537b0b5238 42 Alaska US-AK
139 #vegas True d45435b6467e7ded33c28f6796bf5183 212 Las Vegas US-NV
140 #lasvegas True b82b823d6dcc845ead47cee8cfa758b3 184 Las Vegas (long form) US-NV
141 #saltlakecity True 07e180549b35782901d407832b3ec6a6 7 Salt Lake City US-UT
142 #slc True fd31a70b220eb3e2430e59c15432ef8c 253 SLC (short form) US-UT
143 #utah True ee42ff5f055f2e83527e4efb91b10d0a 238 Utah statewide US-UT
144 #canada True 8373bb1055f34164c6dd2663927cb6d9 131 Canada national CA
145 #toronto True e0d3774dd1da4dc5c55d8bd731555334 224 Toronto CA-ON
146 #vancouver True 16d6034be448ab86d11858cfa4c57c9c 22 Vancouver CA-BC
147 #montreal True 0c4c03b5fbea5b80f89e2a2a16ed3f40 12 Montreal CA-QC
148 #calgary True bbbb1ad23fe1cdd7739667418204a57d 187 Calgary CA-AB
149 #ottawa True 7871ec72b45617696c35c970bddd8124 120 Ottawa CA-ON
150 #edmonton True ce451a3a24ee89ee31564ae29e9dee04 206 Edmonton CA-AB
151 #winnipeg True 3c3294aac8a83b1143ab6bedee5b5226 60 Winnipeg CA-MB
152 #australia True eadb84fa1da64c44b77c40fd11b9d78e 234 Australia national AU
153 #sydney True 57fe5284a5b905835193868d9bdbe1e9 87 Sydney AU-NSW
154 #melbourne True 4d73731d9450ccb9673eb923c0f40af2 77 Melbourne AU-VIC
155 #brisbane True e4bd09784621decac600d0d4d857e3b2 228 Brisbane (MBUG community) AU-QLD
156 #perth True 9873c6cf14dac409b5a573574cff5ce1 152 Perth AU-WA
157 #adelaide True 9ad0c536f3eabc7c2f0434d68e242009 154 Adelaide AU-SA
158 #canberra True c716951221e6c17faf5f22b6245ce64b 199 Canberra AU-ACT
159 #newzealand True 1870a961e4aa06f62b02b835efcacb71 24 New Zealand national NZ
160 #nz True eb87ee8817ba71315ac7be9c733b523a 235 NZ (short form) NZ
161 #auckland True 7c6f1c71a5a3d6823a1d7bfd2a349ac8 124 Auckland NZ
162 #wellington True c2da57d74f78996ad71bbe3e22446f16 194 Wellington NZ
163 #christchurch True 7b88b875fc5ae76305c8a15349549a3b 123 Christchurch NZ
164 #gisborne True d8b45a1eb52d0bd45655d5f2a72d571f 216 Gisborne (mentioned in MeshCore tutorial) NZ
165 #weather True 88f502554fee92a1625cfb311546e7cb 136 Weather updates topical
166 #jokes True abe610d61dee508c0ac18793c36c41c3 171 Jokes channel topical
167 #quotes True 086be6f920280b7cc51d6ad91d6a786c 8 Quotes channel topical
168 #secret True be657c0527e122bf93bc735999cd7e0d 190 Secret channel (Brisbane MBUG) topical
169 #chat True d0bdd6d71538138ed979eec00d98ad97 208 General chat topical
170 #news True ecadb1a7d803db8958bea1302ca6e8be 236 News channel topical
171 #help True dcc67fae2067046832af7b2b0b743165 220 Help channel topical
172 #sos True 9ed2c78bcd68ac7ce2a2fc3bb4045114 158 SOS / distress topical
173 #prepper True 1eec1a7df7080a392cb490473c4a9920 30 Prepper community topical
174 #offgrid True aaa26662bebf122262692d0bca61dcef 170 Off-grid living topical
175 #radio True 266f225baced1b2a868dcc8e9c69a304 38 Radio hobby topical
176 #lora True 0749ea267c6be7b54ca1dbbae7dba0aa 7 LoRa technology discussion topical
177 #meshcore True 2fa78a5aef618e7c2a78f0ab5c8869b3 47 MeshCore project discussion topical
178 #meshtastic True 73a2e13ff0dea9ed19b24b2ab753650f 115 Meshtastic discussion topical
179 #iot True 233b42534ed82b8ae6a88d785f1b27fd 35 IoT / sensors topical
180 #solar True 7592a730ff1af2b40b95589fbafcca01 117 Solar power topical
181 #emcomm True a9a49340642dfc4c562d7849b7c8a258 169 Emergency communications topical
182 #ares True 44419e394cde859e45710f288db939a8 68 ARES (Amateur Radio Emergency Service) topical
183 #gmrs True 2ad811e84ab4ad0e3a648d8fcf3d650a 42 GMRS radio topical
184 #cb True a35b5f87bbb67859a02e7666c823de7a 163 CB radio topical
185 #hiking True 2370a013053e384e5f18918bc2b26baf 35 Hiking topical
186 #camping True c011b7c2abf33748cd9bd5a78c2b4955 192 Camping topical
187 #hunting True 0c0c28fd9bbf096ee06e01af840037af 12 Hunting topical
188 #fishing True a671ca13cc499898aea4596e804ecf20 166 Fishing topical
189 #skiing True b3baf3586d347ecaf2c113106a564207 179 Skiing topical
190 #biking True 9c0591d708b7c7970c9ad6712e3c4d05 156 Biking topical
191 #ham True 5270db3979da687fa133fec6684cd952 82 HAM radio (short form) topical
192 #cq True bc9da8bdebcaeee40fefff7dd16855f1 188 CQ (calling all stations) topical
193 #dx True adf77706da76b66bb0a313c1484d3be4 173 DX (long distance contacts) topical
194 #repeater True 289991a3077903263f2d31493887c651 40 Repeater discussion topical
195 #firmware True 6163564e7449c1c842e58dcf2cd1ab4f 97 Firmware discussion topical
196 #debug True fd7a60ed4796efcd1965c2de466105cb 253 Debug channel topical
197 #dev True d41bcba61e9dca7177c7b8533d23a0bc 212 Development channel topical
198 #admin True 889334b7e486938c776dbdde120da9de 136 Admin channel topical
199 #monitor True 131479235e65b7c591307f699d64dc74 19 Monitoring channel topical
200 #alert True 678c7d2e08c019e113ace03eaaa128ad 103 Alert channel topical
201 #local True d2d35fa76be9875ed254db80397483a5 210 Local community topical
202 #neighborhood True 53403881955f0e11573ba1e5d3a82c4b 83 Neighborhood chat topical
203 #community True b9323ce55a729068ed8e3ed49b9b84b5 185 Community channel topical
204 #events True d2cbcc606979cdb05c7bd66f49f23baa 210 Events channel topical
205 #trade True c2be982890fb3a63113de637b9aee6fe 194 Trading/swap channel topical
206 #marketplace True 354a741c2975c843ec0706089390b968 53 Marketplace topical
207 #gaming True 3802c79121f195ea50ad9ab2aa2c402a 56 Gaming topical
208 #music True b025ab29b0a5f56fb68a474741d7a2cc 176 Music topical
209 #tech True 5177b749eefbfab3c90a21b4e2518c5f 81 Technology topical
210 #science True 8abc980d3764fdabda5dfa6b6bc3a6cc 138 Science topical
211 #crypto True 0aa7c45d99be27024b82a1474c693951 10 Cryptocurrency topical
212 #bitcoin True 94bf6fd2688b1456c071ebad63d7021d 148 Bitcoin topical
213 #linux True 02c74eda5d8ec8b9bb945049fb2f55c6 2 Linux topical
214 #arduino True ce8c596922eff9274f3b1e19ad296754 206 Arduino topical
215 #raspberry True 469bc476d8263b64b6f90ca868b04b91 70 Raspberry Pi topical
216 #esp32 True 9f8aac4c48973b07bbdc47acbaea3e8e 159 ESP32 projects topical
217 #heltec True 71af5d9a209e8b17a19ee22aa7b6be23 113 Heltec devices topical
218 #lilygo True a33dd4b025cc82b81f97318a63a9d5d3 163 LilyGo devices topical
219 #rak True 1f4734675fdfcabfaa67a0a059c65c5f 31 RAK Wireless devices topical
220 #antenna True e8979157107155ade90822e29b186ee8 232 Antenna discussion topical
221 #rf True 46aaa16f78b3969040aad2b6b694b59e 70 RF engineering topical
222 #preppers True 4877173813de9668b9ef33adbc1b8f37 72 Preppers community topical
223 #survival True e1f465ea51df09fc901389758f1c5f01 225 Survival skills topical
224 #grid-down True 94ddbd51f2ee0bd47426a6f3ed79be99 148 Grid-down scenarios topical
225 #wildfire True b9124c3aecc0c813634f79f144b93f83 185 Wildfire alerts topical
226 #hurricane True c13a2f9d18400e5d93bae4bb3afef40c 193 Hurricane alerts topical
227 #earthquake True 0c7082c04a1a90502a5f32fc6a9f6524 12 Earthquake alerts topical
228 #flood True f2a0fd0abe4c9fc6865b5f8eebf319d6 242 Flood alerts topical
229 #tornado True 76e577e8187d0bab89052343f3835039 118 Tornado alerts topical
230 #storm True 113761b9e31a5c30e0ee4fc78dc7310b 17 Storm alerts topical
231 #traffic True c1216dacbb7e4c3b9a3356e52f1c27fa 193 Traffic updates topical
232 #fire True 3d74a070077293ab66baf3aa724349ff 61 Fire alerts topical
233 #police True 79b89aa870ea85dbb8396f401e547d99 121 Police scanner / alerts topical
234 #ems True f714461f26d43d9a17b2530c3ad29bc2 247 EMS / first responders topical
235 #search-rescue True cd8ab9e4944ee656ae6e961a4437c551 205 Search and rescue topical
236 #volunteer True 2d3d64746075cb99407d681e547b99cb 45 Volunteer coordination topical
237 #data True b0d9c01c8278a711c68ffdca98ef1245 176 Data/telemetry channel topical
238 #sensor True 6218df72386dd48259024327e8ebf66f 98 Sensor data channel topical
239 #tracker True 70258e69aeb03d1cd2e16072e1fe14b5 112 Asset/pet tracker topical
240 #gps True af4b9f722a6200b1eb2696660017d0df 175 GPS tracking topical
241 #boat True c03117578e38c3f1eb5ae8c3d0019753 192 Boating/maritime topical
242 #sailing True c2f6052f8b89639f81c8ff4def32deae 194 Sailing topical
243 #marine True 7f4b40aac63642805a54a959beb07694 127 Marine channel topical
244 #aviation True e17538c835ee0990b349ca1879b4f57a 225 Aviation topical
245 #drone True 4460931543103de42d2f85093a80273c 68 Drone / UAV topical
246 #fpv True 6d2752bc94797cfce43e2138eab7a772 109 FPV flying topical
247 #farm True 4a2f1b2147165cf942402a4c6bd7d42d 74 Farming / agriculture topical
248 #ranch True 5885e728fc06bfcbe7d6c9020c13ff10 88 Ranch / livestock topical
249 #tucson True cf989ccea881cf5ddcf40d87bbfc441e 207 Tucson AZ US-AZ
250 #albuquerque True 2a59b6ce7d3ce661664217f9b8255e86 42 Albuquerque NM US-NM
251 #boise True f4e641f9c90ac3bd5bc442ef379f6eb3 244 Boise ID US-ID
252 #spokane True dfcf2a863b3baa99e3d3274bfb56439f 223 Spokane WA US-WA
253 #olympia True baec83dd15ef758ad8bf8f357237c9cb 186 Olympia WA US-WA
254 #bellingham True 6000c8e3c1343fd920749eb3ad2a42b4 96 Bellingham WA US-WA
255 #eugene True e6ce6f5e71d88bb90c0b7dacc2235662 230 Eugene OR US-OR
256 #bend True 291ddea6fc1c1742f7ad425819b66f7b 41 Bend OR US-OR
257 #reno True e9edf1edf57a04b8f66e75829b4edc06 233 Reno NV US-NV
258 #provo True 72744153a7a2d84ed69338be4dc3fc31 63 Provo US-UT
259 #orem True 97236968b8a6c0cf297f1c1365539d0d 157 Orem US-UT
260 #ogden True cdeec2a40203056a6c6f2d1fb5f50b81 245 Ogden US-UT
261 #logan True b0ee430ede14470d5ff8fafbe092ee50 145 Logan US-UT
262 #stgeorge True 7792659def2811af89a02270b2918235 128 St. George US-UT
263 #parkcity True bbb64d6ae8eaeaf4302f501844f64511 25 Park City US-UT
264 #lehi True 18f20a96d251665105d59df6ca5bcc5c 98 Lehi US-UT
265 #byu True 65633d6a99bbcb173cb01afeae3a51d0 194 Brigham Young University US-UT
266 #uvu True cb305b9b3d0ab33fce2186ee6b9c58ff 91 Utah Valley University US-UT
267 #uofu True c08fe0d4d37b4b7f233f2977296979d5 178 University of Utah US-UT
268 #usu True ceb222637d010d1c5551a5035e86f89d 104 Utah State University US-UT
269 #utahcounty True f191a8a833d2ae61b7fbfa8c9891cc0c 23 Utah County US-UT
270 #daviscounty True 926847daaed0baa7758df5ef8af76f32 11 Davis County US-UT
271 #webercounty True 364726e43d1fb9b8079ededb03f00764 172 Weber County US-UT
272 #wasatchfront True eb9fa3bfa390eed3737634bed39c7460 44 Wasatch Front metro corridor US-UT
273 #wasatch True 15bf0e80d3d88092d38e4c82da7e523e 0 Wasatch region US-UT
274 #saltlake True 56327c6958d9108213f8638fb2a2b7a4 173 Salt Lake (alternate) US-UT
275 #tooele True 092cbdcf351bdbe77ce6c00e1dbeb294 184 Tooele US-UT
276 #sanpete True 4ae01748b17bfc90e13004f19e734bbc 151 Sanpete County US-UT
277 #nodeops True 33677d99a0e707e37b57de3e3cd26cf3 151 Node operations channel topical
278 #weekly-net True bdc4f5cb11e766c9a09eeb3586e65a58 18 Weekly net check-in topical
279 #net True 13aef6b8ce642cf4c482b91f0816fe68 157 Net channel topical
280 #repeater-ops True 915c91c726e0c9d558365a7ecc72bbd6 108 Repeater operations topical
281 #watchman True 11489fe661e776b3ccdcf864bd14f7d6 245 Watchman mesh health alerts global
-649
View File
@@ -1,649 +0,0 @@
#!/usr/bin/env python3
"""Build hand-crafted MeshCore test vectors as individual JSON files."""
# /// script
# requires-python = ">=3.10"
# dependencies = []
# ///
import json
import os
import struct
from pathlib import Path
OUT = Path(__file__).parent / "hand"
OUT.mkdir(exist_ok=True)
def write_case(name: str, case: dict):
path = OUT / f"{name}.json"
path.write_text(json.dumps(case, indent=2, ensure_ascii=False) + "\n")
print(f" {path.name}")
# ── Packet building helpers ───────────────────────────────────────
def mc_header(payload_type: int, route_type: int, ver: int = 0) -> int:
return (ver << 6) | (payload_type << 2) | route_type
def path_len_byte(hop_count: int, hash_size: int) -> int:
return ((hash_size - 1) << 6) | (hop_count & 0x3F)
DUMMY_PUBKEY = bytes(range(32))
DUMMY_SIG = bytes(64)
TS_2026 = struct.pack("<I", 1774600000) # ~2026-03-24
def advert_packet(app_data: bytes, route_type: int = 1,
path_hops: list[bytes] | None = None,
hash_size: int = 1, pubkey: bytes = DUMMY_PUBKEY,
timestamp: bytes = TS_2026) -> bytes:
hdr = bytes([mc_header(0x04, route_type)])
hops = path_hops or []
pl = bytes([path_len_byte(len(hops), hash_size)])
path = b"".join(hops)
payload = pubkey + timestamp + DUMMY_SIG + app_data
return hdr + pl + path + payload
def peer_packet(payload_type: int, route_type: int, dst: int, src: int,
mac: bytes, ciphertext: bytes,
path_hops: list[bytes] | None = None,
hash_size: int = 1, tc: tuple[int, int] | None = None) -> bytes:
hdr = bytes([mc_header(payload_type, route_type)])
hops = path_hops or []
pl_byte = bytes([path_len_byte(len(hops), hash_size)])
path = b"".join(hops)
body = bytes([dst, src]) + mac + ciphertext
tc_bytes = b""
if tc is not None:
tc_bytes = struct.pack("<HH", tc[0], tc[1])
return hdr + tc_bytes + pl_byte + path + body
def ack_packet(crc: int, route_type: int = 1,
path_hops: list[bytes] | None = None,
hash_size: int = 1) -> bytes:
hdr = bytes([mc_header(0x03, route_type)])
hops = path_hops or []
pl_byte = bytes([path_len_byte(len(hops), hash_size)])
path = b"".join(hops)
return hdr + pl_byte + path + struct.pack("<I", crc)
def group_packet(payload_type: int, ch_hash: int, mac: bytes,
ciphertext: bytes, route_type: int = 1,
path_hops: list[bytes] | None = None,
hash_size: int = 1) -> bytes:
hdr = bytes([mc_header(payload_type, route_type)])
hops = path_hops or []
pl_byte = bytes([path_len_byte(len(hops), hash_size)])
path = b"".join(hops)
return hdr + pl_byte + path + bytes([ch_hash]) + mac + ciphertext
def anon_req_packet(dst: int, ephem_pub: bytes, mac: bytes, ciphertext: bytes,
route_type: int = 1, path_hops: list[bytes] | None = None,
hash_size: int = 1) -> bytes:
hdr = bytes([mc_header(0x07, route_type)])
hops = path_hops or []
pl_byte = bytes([path_len_byte(len(hops), hash_size)])
path = b"".join(hops)
return hdr + pl_byte + path + bytes([dst]) + ephem_pub + mac + ciphertext
def trace_packet(tag: int, auth_code: int, flags: int,
trace_hashes: bytes = b"",
route_type: int = 1,
path_hops: list[bytes] | None = None,
hash_size: int = 1) -> bytes:
hdr = bytes([mc_header(0x09, route_type)])
hops = path_hops or []
pl_byte = bytes([path_len_byte(len(hops), hash_size)])
path = b"".join(hops)
payload = struct.pack("<II", tag, auth_code) + bytes([flags]) + trace_hashes
return hdr + pl_byte + path + payload
def multipart_packet(remaining: int, inner_type: int, inner_payload: bytes,
route_type: int = 1) -> bytes:
hdr = bytes([mc_header(0x0A, route_type)])
pl_byte = bytes([path_len_byte(0, 1)])
control = bytes([(remaining << 4) | (inner_type & 0x0F)])
return hdr + pl_byte + control + inner_payload
def control_packet(flags_byte: int, payload: bytes,
route_type: int = 1) -> bytes:
hdr = bytes([mc_header(0x0B, route_type)])
pl_byte = bytes([path_len_byte(0, 1)])
return hdr + pl_byte + bytes([flags_byte]) + payload
# ── Test cases ────────────────────────────────────────────────────
print("Building hand-crafted test vectors...")
# ADVERT: basic chat node with name
write_case("advert_basic", {
"description": "ADVERT flood, chat node with simple ASCII name",
"tags": ["advert", "flood", "name", "chat"],
"packet_hex": advert_packet(bytes([0x81]) + b"TestNode").hex(),
"valid": True,
"expected": {
"route_type": "flood",
"payload_type": "ADVERT",
"payload_ver": 0,
"transport_codes": None,
"path": {"hash_size": 1, "hops": []},
"payload": {
"pubkey_prefix": DUMMY_PUBKEY[:4].hex(),
"timestamp": 1774600000,
"advert": {
"flags": 0x81,
"node_type": "chat",
"location": None,
"name": "TestNode"
}
}
}
})
# ADVERT: repeater with valid GPS location + name
write_case("advert_location", {
"description": "ADVERT flood, repeater with valid GPS coords and name",
"tags": ["advert", "flood", "location", "repeater"],
"packet_hex": advert_packet(
bytes([0x92])
+ struct.pack("<i", 37774900) + struct.pack("<i", -122419400)
+ b"HilltopRptr"
).hex(),
"valid": True,
"expected": {
"route_type": "flood",
"payload_type": "ADVERT",
"payload_ver": 0,
"transport_codes": None,
"path": {"hash_size": 1, "hops": []},
"payload": {
"pubkey_prefix": DUMMY_PUBKEY[:4].hex(),
"timestamp": 1774600000,
"advert": {
"flags": 0x92,
"node_type": "repeater",
"location": {"lat": 37.7749, "lon": -122.4194},
"name": "HilltopRptr"
}
}
}
})
# ADVERT: emoji name (multi-byte UTF-8)
emoji_name = "\U0001f985 Raptor" # 🦅 Raptor
write_case("advert_emoji_name", {
"description": "ADVERT flood, chat node with emoji in name (multi-byte UTF-8)",
"tags": ["advert", "flood", "emoji", "name", "utf8"],
"packet_hex": advert_packet(bytes([0x81]) + emoji_name.encode()).hex(),
"valid": True,
"expected": {
"route_type": "flood",
"payload_type": "ADVERT",
"payload_ver": 0,
"transport_codes": None,
"path": {"hash_size": 1, "hops": []},
"payload": {
"pubkey_prefix": DUMMY_PUBKEY[:4].hex(),
"timestamp": 1774600000,
"advert": {
"flags": 0x81,
"node_type": "chat",
"location": None,
"name": emoji_name
}
}
}
})
# ADVERT: feat flags set but data is actually name (scoring test)
write_case("advert_feat_flags_as_name", {
"description": "ADVERT flood, feat1+feat2 bits set but bytes are actually the name",
"tags": ["advert", "flood", "feat", "scoring", "emoji"],
"packet_hex": advert_packet(
bytes([0xe1]) + "\U0001f3d4\ufe0f MtnNode".encode()
).hex(),
"valid": True,
"expected": {
"route_type": "flood",
"payload_type": "ADVERT",
"payload_ver": 0,
"transport_codes": None,
"path": {"hash_size": 1, "hops": []},
"payload": {
"pubkey_prefix": DUMMY_PUBKEY[:4].hex(),
"timestamp": 1774600000,
"advert": {
"flags": 0xe1,
"node_type": "chat",
"location": None,
"name": "\U0001f3d4\ufe0f MtnNode"
}
}
}
})
# ADVERT: no app_data (bare 100-byte advert)
write_case("advert_no_appdata", {
"description": "ADVERT flood, bare minimum (no app_data)",
"tags": ["advert", "flood", "minimal"],
"packet_hex": advert_packet(b"").hex(),
"valid": True,
"expected": {
"route_type": "flood",
"payload_type": "ADVERT",
"payload_ver": 0,
"transport_codes": None,
"path": {"hash_size": 1, "hops": []},
"payload": {
"pubkey_prefix": DUMMY_PUBKEY[:4].hex(),
"timestamp": 1774600000,
"advert": None
}
}
})
# ADVERT: sensor node type
write_case("advert_sensor", {
"description": "ADVERT flood, sensor node type",
"tags": ["advert", "flood", "sensor"],
"packet_hex": advert_packet(bytes([0x84]) + b"TempSensor").hex(),
"valid": True,
"expected": {
"route_type": "flood",
"payload_type": "ADVERT",
"payload_ver": 0,
"transport_codes": None,
"path": {"hash_size": 1, "hops": []},
"payload": {
"pubkey_prefix": DUMMY_PUBKEY[:4].hex(),
"timestamp": 1774600000,
"advert": {
"flags": 0x84,
"node_type": "sensor",
"location": None,
"name": "TempSensor"
}
}
}
})
# ADVERT: with path (4 hops, 1-byte hash)
write_case("advert_with_path", {
"description": "ADVERT flood, 4 hops with 1-byte hashes",
"tags": ["advert", "flood", "path"],
"packet_hex": advert_packet(
bytes([0x81]) + b"RelayedNode",
path_hops=[b"\x2a", b"\x3d", b"\x35", b"\xed"]
).hex(),
"valid": True,
"expected": {
"route_type": "flood",
"payload_type": "ADVERT",
"payload_ver": 0,
"transport_codes": None,
"path": {"hash_size": 1, "hops": ["2a", "3d", "35", "ed"]},
"payload": {
"pubkey_prefix": DUMMY_PUBKEY[:4].hex(),
"timestamp": 1774600000,
"advert": {
"flags": 0x81,
"node_type": "chat",
"location": None,
"name": "RelayedNode"
}
}
}
})
# ACK: basic
write_case("ack_basic", {
"description": "ACK flood, simple acknowledgment",
"tags": ["ack", "flood"],
"packet_hex": ack_packet(0xDEADBEEF).hex(),
"valid": True,
"expected": {
"route_type": "flood",
"payload_type": "ACK",
"payload_ver": 0,
"transport_codes": None,
"path": {"hash_size": 1, "hops": []},
"payload": {"ack_crc": 0xDEADBEEF}
}
})
# ACK: with 1-byte hash path
write_case("ack_with_path", {
"description": "ACK flood, 2 hops with 1-byte hashes",
"tags": ["ack", "flood", "path"],
"packet_hex": ack_packet(
0x008DC29F, path_hops=[b"\xd1", b"\xac"]
).hex(),
"valid": True,
"expected": {
"route_type": "flood",
"payload_type": "ACK",
"payload_ver": 0,
"transport_codes": None,
"path": {"hash_size": 1, "hops": ["d1", "ac"]},
"payload": {"ack_crc": 0x008DC29F}
}
})
# TXT_MSG: direct, 2-byte hashes
write_case("txt_msg_direct_2b_hash", {
"description": "TXT_MSG direct, 2-byte hashes, 2 hops",
"tags": ["txt_msg", "direct", "2byte_hash", "encrypted"],
"packet_hex": peer_packet(
0x02, 2, 0x5b, 0x43, b"\xab\xcd", bytes(48),
path_hops=[b"\xac\x98", b"\xd1\x4d"], hash_size=2
).hex(),
"valid": True,
"expected": {
"route_type": "direct",
"payload_type": "TXT_MSG",
"payload_ver": 0,
"transport_codes": None,
"path": {"hash_size": 2, "hops": ["ac98", "d14d"]},
"payload": {"dst": "5b", "src": "43", "mac": "abcd", "ciphertext_len": 48}
}
})
# TXT_MSG: flood with loops (duplicate hops)
write_case("txt_msg_flood_with_loops", {
"description": "TXT_MSG flood with routing loop (ac98 appears 3x)",
"tags": ["txt_msg", "flood", "loop", "2byte_hash"],
"packet_hex": peer_packet(
0x02, 1, 0x5b, 0x43, b"\xab\xcd", bytes(32),
path_hops=[b"\xac\x98", b"\xd1\x4d", b"\xac\x98", b"\xff\x01", b"\xac\x98"],
hash_size=2
).hex(),
"valid": True,
"expected": {
"route_type": "flood",
"payload_type": "TXT_MSG",
"payload_ver": 0,
"transport_codes": None,
"path": {"hash_size": 2, "hops": ["ac98", "d14d", "ac98", "ff01", "ac98"]},
"payload": {"dst": "5b", "src": "43", "mac": "abcd", "ciphertext_len": 32}
}
})
# REQ: with transport codes (transport direct)
write_case("req_transport_direct", {
"description": "REQ transport-direct with transport codes and 1-byte path",
"tags": ["req", "tdirect", "transport_codes", "encrypted"],
"packet_hex": peer_packet(
0x00, 3, 0xa1, 0xb2, b"\xef\x01", bytes(32),
path_hops=[b"\xcc", b"\xdd"], hash_size=1,
tc=(0x1234, 0x5678)
).hex(),
"valid": True,
"expected": {
"route_type": "tdirect",
"payload_type": "REQ",
"payload_ver": 0,
"transport_codes": {"tc1": 0x1234, "tc2": 0x5678},
"path": {"hash_size": 1, "hops": ["cc", "dd"]},
"payload": {"dst": "a1", "src": "b2", "mac": "ef01", "ciphertext_len": 32}
}
})
# ANON_REQ: route_type=3 but no actual TC (older firmware, TC fallback)
ephem = bytes(range(0x20, 0x40)) # 32-byte ephemeral pubkey
write_case("anon_req_tc_fallback", {
"description": "ANON_REQ tdirect header but NO transport codes (older firmware)",
"tags": ["anon_req", "tdirect", "tc_fallback"],
"packet_hex": (
bytes([mc_header(0x07, 3)])
+ bytes([path_len_byte(3, 1)]) + b"\x13\x35\x42"
+ bytes([0xd9]) + ephem + b"\xb2\xe2" + bytes(48)
).hex(),
"valid": True,
"expected": {
"route_type": "tdirect",
"payload_type": "ANON_REQ",
"payload_ver": 0,
"transport_codes": None,
"path": {"hash_size": 1, "hops": ["13", "35", "42"]},
"payload": {
"dst": "d9",
"ephem_prefix": ephem[:4].hex(),
"mac": "b2e2",
"ciphertext_len": 48
}
}
})
# GRP_TXT: basic group text
write_case("grp_txt_basic", {
"description": "GRP_TXT flood, group text message",
"tags": ["grp_txt", "flood", "group", "encrypted"],
"packet_hex": group_packet(0x05, 0xCC, b"\xab\xcd", bytes(32)).hex(),
"valid": True,
"expected": {
"route_type": "flood",
"payload_type": "GRP_TXT",
"payload_ver": 0,
"transport_codes": None,
"path": {"hash_size": 1, "hops": []},
"payload": {"channel_hash": "cc", "mac": "abcd", "ciphertext_len": 32}
}
})
# TRACE: basic trace packet
write_case("trace_basic", {
"description": "TRACE flood with tag, auth, and 2 trace hops",
"tags": ["trace", "flood"],
"packet_hex": trace_packet(
0x12345678, 0xAABBCCDD, 0x00, b"\x11\x22"
).hex(),
"valid": True,
"expected": {
"route_type": "flood",
"payload_type": "TRACE",
"payload_ver": 0,
"transport_codes": None,
"path": {"hash_size": 1, "hops": []},
"payload": {"trace_tag": 0x12345678, "trace_hops": 2}
}
})
# TRACE: flags=2 means hash_size=4 (power of 2 encoding: 1 << 2 = 4)
write_case("trace_4byte_hash", {
"description": "TRACE flood with 4-byte trace hashes (flags=0x02, hash_size=4)",
"tags": ["trace", "flood", "4byte_hash"],
"packet_hex": trace_packet(
0xAABBCCDD, 0x11223344, 0x02, b"\xde\xad\xbe\xef\xca\xfe\xba\xbe"
).hex(),
"valid": True,
"expected": {
"route_type": "flood",
"payload_type": "TRACE",
"payload_ver": 0,
"transport_codes": None,
"path": {"hash_size": 1, "hops": []},
"payload": {"trace_tag": 0xAABBCCDD, "trace_hops": 2}
}
})
# MULTIPART: ACK inner type
write_case("multipart_ack", {
"description": "MULTIPART flood, remaining=3, inner type=ACK",
"tags": ["multipart", "flood", "ack"],
"packet_hex": multipart_packet(
3, 0x03, struct.pack("<I", 0xCAFEBABE)
).hex(),
"valid": True,
"expected": {
"route_type": "flood",
"payload_type": "MULTIPART",
"payload_ver": 0,
"transport_codes": None,
"path": {"hash_size": 1, "hops": []},
"payload": {"multipart_remaining": 3, "multipart_inner": "ACK"}
}
})
# CONTROL: DISCOVER_REQ
write_case("control_discover_req", {
"description": "CONTROL flood, DISCOVER_REQ sub-type",
"tags": ["control", "flood", "discover"],
"packet_hex": control_packet(
0x80, bytes([0x03]) + struct.pack("<I", 0xABCD1234)
).hex(),
"valid": True,
"expected": {
"route_type": "flood",
"payload_type": "CONTROL",
"payload_ver": 0,
"transport_codes": None,
"path": {"hash_size": 1, "hops": []},
"payload": {"control_subtype": "DISCOVER_REQ"}
}
})
# PATH: 3-byte hash, 1 hop
write_case("path_3byte_hash", {
"description": "PATH flood, 1 hop with 3-byte hash",
"tags": ["path", "flood", "3byte_hash", "encrypted"],
"packet_hex": peer_packet(
0x08, 1, 0xe6, 0xe9, b"\x2e\x47", bytes(46),
path_hops=[b"\x1a\xff\xf3"], hash_size=3
).hex(),
"valid": True,
"expected": {
"route_type": "flood",
"payload_type": "PATH",
"payload_ver": 0,
"transport_codes": None,
"path": {"hash_size": 3, "hops": ["1afff3"]},
"payload": {"dst": "e6", "src": "e9", "mac": "2e47", "ciphertext_len": 46}
}
})
# PATH: 4-byte hash (hash_size_code 3 = RESERVED, must be rejected)
write_case("path_4byte_hash", {
"description": "PATH flood, 4-byte hash (reserved hash_size_code 3, invalid)",
"tags": ["invalid", "path", "flood", "4byte_hash", "reserved_hash_size"],
"packet_hex": peer_packet(
0x08, 1, 0xe6, 0xe9, b"\x2e\x47", bytes(16),
path_hops=[b"\xaa\xbb\xcc\xdd"], hash_size=4
).hex(),
"valid": False,
"expected": {
"route_type": "flood",
"payload_type": "PATH",
"error": "bad_framing"
}
})
# ── Invalid packets ───────────────────────────────────────────────
# Too short: 1 byte
write_case("too_short_1byte", {
"description": "Packet too short (1 byte)",
"tags": ["invalid", "too_short"],
"packet_hex": "11",
"valid": False,
"expected": {
"route_type": "flood",
"payload_type": "ADVERT",
"error": "too_short"
}
})
# Too short: 2 bytes (need at least 3: header + path_len + 1 byte payload)
write_case("too_short_2bytes", {
"description": "Packet too short (2 bytes, need >= 3)",
"tags": ["invalid", "too_short"],
"packet_hex": "1100",
"valid": False,
"expected": {
"route_type": "flood",
"payload_type": "ADVERT",
"error": "too_short"
}
})
# Bad path_len: reserved hash_size_code 3 (bits 6-7 = 0b11)
bad_pkt = bytes([mc_header(0x02, 1), 0xC1]) + bytes(24)
write_case("bad_path_len_reserved_hash_size", {
"description": "TXT_MSG flood, path_len has reserved hash_size_code 3",
"tags": ["invalid", "bad_framing", "path", "reserved_hash_size"],
"packet_hex": bad_pkt.hex(),
"valid": False,
"expected": {
"route_type": "flood",
"payload_type": "TXT_MSG",
"error": "bad_framing"
}
})
# Bad path_len: claims 33 hops (2B hash) but only 24 bytes remain
bad_pkt2 = bytes([mc_header(0x02, 1), 0x61]) + bytes(24)
write_case("bad_path_len", {
"description": "TXT_MSG flood, path_len claims 33 hops (2B hash) but data is too short",
"tags": ["invalid", "bad_framing", "path"],
"packet_hex": bad_pkt2.hex(),
"valid": False,
"expected": {
"route_type": "flood",
"payload_type": "TXT_MSG",
"error": "bad_framing"
}
})
# Noise: random bytes that don't parse as meshcore
write_case("noise_packet", {
"description": "Random bytes, not a valid MeshCore packet",
"tags": ["invalid", "noise"],
"packet_hex": "03d135839fc28d00",
"valid": False,
"expected": {
"route_type": "tdirect",
"payload_type": "REQ",
"error": "not_meshcore"
}
})
# Empty packet
write_case("empty_packet", {
"description": "Empty packet (0 bytes)",
"tags": ["invalid", "too_short"],
"packet_hex": "",
"valid": False,
"expected": {
"route_type": "flood",
"payload_type": "REQ",
"error": "too_short"
}
})
# Bad advert: too short for pubkey+timestamp+signature
short_advert = advert_packet(b"") # 102 bytes: hdr + path_len + 100 payload
truncated = short_advert[:50] # cut in half
write_case("advert_truncated", {
"description": "ADVERT flood, truncated mid-signature (50 bytes)",
"tags": ["invalid", "advert", "truncated"],
"packet_hex": truncated.hex(),
"valid": True, # parses as ADVERT but payload is bad
"expected": {
"route_type": "flood",
"payload_type": "ADVERT",
"payload_ver": 0,
"transport_codes": None,
"path": {"hash_size": 1, "hops": []}
}
})
print(f"\nDone! {len(list(OUT.glob('*.json')))} test vectors written to {OUT}/")
-333
View File
@@ -1,333 +0,0 @@
#!/usr/bin/env python3
"""Generate MeshCore test vectors using Hypothesis (property-based testing)."""
# /// script
# requires-python = ">=3.10"
# dependencies = ["hypothesis", "jsonschema"]
# ///
import argparse
import json
import struct
import sys
from pathlib import Path
from hypothesis import given, settings, HealthCheck, Phase
from hypothesis import strategies as st
CORPUS_DIR = Path(__file__).parent
OUT = CORPUS_DIR / "generated"
SCHEMA_PATH = CORPUS_DIR / "schema.json"
# MeshCore constants
MAX_PAYLOAD = 184
MAX_PATH_SIZE = 64
MAX_ADVERT_DATA_SIZE = 32
PUB_KEY_SIZE = 32
SIGNATURE_SIZE = 64
ROUTE_TYPES = {0: "tflood", 1: "flood", 2: "direct", 3: "tdirect"}
PAYLOAD_TYPES = {
0: "REQ", 1: "RESPONSE", 2: "TXT_MSG", 3: "ACK", 4: "ADVERT",
5: "GRP_TXT", 6: "GRP_DATA", 7: "ANON_REQ", 8: "PATH",
9: "TRACE", 10: "MULTIPART", 11: "CONTROL", 15: "RAW_CUSTOM",
}
NODE_TYPES = {1: "chat", 2: "repeater", 3: "room", 4: "sensor"}
def mc_header(payload_type: int, route_type: int, ver: int = 0) -> int:
return (ver << 6) | (payload_type << 2) | route_type
def path_len_byte(hop_count: int, hash_size: int) -> int:
return ((hash_size - 1) << 6) | (hop_count & 0x3F)
# ── Hypothesis strategies ─────────────────────────────────────────
hash_sizes = st.sampled_from([1, 2, 3])
route_types_no_tc = st.sampled_from([1, 2]) # flood, direct (no transport codes)
route_types_all = st.sampled_from([0, 1, 2, 3])
payload_vers = st.sampled_from([0]) # only v0 is well-defined
@st.composite
def path_strategy(draw, hash_size=None):
hs = hash_size or draw(hash_sizes)
max_hops = min(63, MAX_PATH_SIZE // hs)
hop_count = draw(st.integers(min_value=0, max_value=min(max_hops, 10)))
hops = [draw(st.binary(min_size=hs, max_size=hs)) for _ in range(hop_count)]
return hs, hops
@st.composite
def advert_appdata(draw):
"""Generate valid ADVERT app_data."""
node_type = draw(st.sampled_from([1, 2, 3, 4]))
has_loc = draw(st.booleans())
has_name = draw(st.booleans())
flags = node_type
data = b""
loc = None
name = None
if has_loc:
flags |= 0x10
lat = draw(st.integers(min_value=-90_000_000, max_value=90_000_000))
lon = draw(st.integers(min_value=-180_000_000, max_value=180_000_000))
data += struct.pack("<ii", lat, lon)
loc = {"lat": round(lat / 1e6, 4), "lon": round(lon / 1e6, 4)}
if has_name:
flags |= 0x80
# Generate ASCII names (to avoid UTF-8 scoring ambiguity in generated tests)
name = draw(st.text(
alphabet=st.characters(whitelist_categories=("L", "N", "P", "Z"),
max_codepoint=127),
min_size=1, max_size=20
))
name_bytes = name.encode("ascii")
data += name_bytes
if len(data) + 1 > MAX_ADVERT_DATA_SIZE:
data = data[:MAX_ADVERT_DATA_SIZE - 1]
if name:
name = data[8 if has_loc else 0:].decode("ascii", errors="replace")
app_data = bytes([flags]) + data
node_label = NODE_TYPES.get(node_type, f"0x{node_type:02x}")
return app_data, {"flags": flags, "node_type": node_label, "location": loc, "name": name}
@st.composite
def valid_advert_packet(draw):
"""Generate a complete valid ADVERT packet with expected decode."""
route_type = draw(route_types_no_tc)
ver = draw(payload_vers)
hs, hops = draw(path_strategy())
pubkey = draw(st.binary(min_size=PUB_KEY_SIZE, max_size=PUB_KEY_SIZE))
timestamp = draw(st.integers(min_value=0, max_value=0xFFFFFFFF))
signature = draw(st.binary(min_size=SIGNATURE_SIZE, max_size=SIGNATURE_SIZE))
app_data, advert_expected = draw(advert_appdata())
hdr = bytes([mc_header(0x04, route_type, ver)])
pl = bytes([path_len_byte(len(hops), hs)])
path = b"".join(hops)
payload = pubkey + struct.pack("<I", timestamp) + signature + app_data
packet = hdr + pl + path + payload
return packet, {
"route_type": ROUTE_TYPES[route_type],
"payload_type": "ADVERT",
"payload_ver": ver,
"transport_codes": None,
"path": {"hash_size": hs, "hops": [h.hex() for h in hops]},
"payload": {
"pubkey_prefix": pubkey[:4].hex(),
"timestamp": timestamp,
"advert": advert_expected,
}
}
@st.composite
def valid_ack_packet(draw):
route_type = draw(route_types_no_tc)
hs, hops = draw(path_strategy())
crc = draw(st.integers(min_value=0, max_value=0xFFFFFFFF))
hdr = bytes([mc_header(0x03, route_type)])
pl = bytes([path_len_byte(len(hops), hs)])
path = b"".join(hops)
packet = hdr + pl + path + struct.pack("<I", crc)
return packet, {
"route_type": ROUTE_TYPES[route_type],
"payload_type": "ACK",
"payload_ver": 0,
"transport_codes": None,
"path": {"hash_size": hs, "hops": [h.hex() for h in hops]},
"payload": {"ack_crc": crc}
}
@st.composite
def valid_encrypted_peer_packet(draw):
ptype = draw(st.sampled_from([0, 1, 2, 8])) # REQ/RESPONSE/TXT_MSG/PATH
route_type = draw(route_types_no_tc)
hs, hops = draw(path_strategy())
dst = draw(st.integers(min_value=0, max_value=255))
src = draw(st.integers(min_value=0, max_value=255))
mac = draw(st.binary(min_size=2, max_size=2))
ct_len = draw(st.integers(min_value=16, max_value=64).filter(lambda n: n % 16 == 0))
ct = draw(st.binary(min_size=ct_len, max_size=ct_len))
hdr = bytes([mc_header(ptype, route_type)])
pl = bytes([path_len_byte(len(hops), hs)])
path = b"".join(hops)
packet = hdr + pl + path + bytes([dst, src]) + mac + ct
return packet, {
"route_type": ROUTE_TYPES[route_type],
"payload_type": PAYLOAD_TYPES[ptype],
"payload_ver": 0,
"transport_codes": None,
"path": {"hash_size": hs, "hops": [h.hex() for h in hops]},
"payload": {
"dst": f"{dst:02x}",
"src": f"{src:02x}",
"mac": mac.hex(),
"ciphertext_len": ct_len
}
}
@st.composite
def valid_group_packet(draw):
ptype = draw(st.sampled_from([5, 6])) # GRP_TXT/GRP_DATA
route_type = draw(route_types_no_tc)
hs, hops = draw(path_strategy())
ch = draw(st.integers(min_value=0, max_value=255))
mac = draw(st.binary(min_size=2, max_size=2))
ct_len = draw(st.integers(min_value=16, max_value=64).filter(lambda n: n % 16 == 0))
ct = draw(st.binary(min_size=ct_len, max_size=ct_len))
hdr = bytes([mc_header(ptype, route_type)])
pl = bytes([path_len_byte(len(hops), hs)])
path = b"".join(hops)
packet = hdr + pl + path + bytes([ch]) + mac + ct
return packet, {
"route_type": ROUTE_TYPES[route_type],
"payload_type": PAYLOAD_TYPES[ptype],
"payload_ver": 0,
"transport_codes": None,
"path": {"hash_size": hs, "hops": [h.hex() for h in hops]},
"payload": {
"channel_hash": f"{ch:02x}",
"mac": mac.hex(),
"ciphertext_len": ct_len
}
}
@st.composite
def invalid_packet(draw):
"""Generate an invalid MeshCore packet via mutation."""
mutation = draw(st.sampled_from(["truncate", "bad_path_len", "random"]))
if mutation == "truncate":
# Valid packet truncated to random length
pkt, _ = draw(valid_ack_packet())
cut = draw(st.integers(min_value=0, max_value=max(0, len(pkt) - 2)))
packet = pkt[:cut]
if len(packet) < 3:
error = "too_short"
else:
error = "bad_framing"
elif mutation == "bad_path_len":
# Header + impossible path_len
route_type = draw(route_types_no_tc)
ptype = draw(st.sampled_from(list(PAYLOAD_TYPES.keys())))
hdr = bytes([mc_header(ptype, route_type)])
# path_len claiming huge path
# Use hash_size_code 3 (reserved, 0xC0-0xFF) or valid codes with huge counts
bad_pl = draw(st.sampled_from([
*range(0xC0, 0x100), # reserved hash_size_code 3
0x3F, # 63 hops × 1 byte = 63 (valid encoding, will overflow data)
0x60, # 32 hops × 2 bytes = 64 (valid encoding, will overflow data)
0x95, # 21 hops × 3 bytes = 63 (valid encoding, will overflow data)
]))
tail = draw(st.binary(min_size=1, max_size=20))
packet = hdr + bytes([bad_pl]) + tail
error = "bad_framing"
else: # random
packet = draw(st.binary(min_size=3, max_size=50))
error = "not_meshcore"
header = packet[0] if packet else 0
route_type = header & 0x03
payload_type = (header >> 2) & 0x0F
return packet, {
"route_type": ROUTE_TYPES.get(route_type, f"rt{route_type}"),
"payload_type": PAYLOAD_TYPES.get(payload_type, f"0x{payload_type:02x}"),
"error": error if len(packet) < 2 else error
}
# ── Generation ────────────────────────────────────────────────────
_counter = 0
def save_case(packet: bytes, expected: dict, valid: bool, prefix: str):
global _counter
_counter += 1
ptype = expected["payload_type"].lower()
name = f"{prefix}_{ptype}_{_counter:04d}"
tags = [ptype, expected["route_type"]]
if not valid:
tags.append("invalid")
if "error" in expected:
tags.append(expected["error"])
case = {
"description": f"Generated {prefix}: {expected['payload_type']} {expected['route_type']}",
"tags": tags,
"packet_hex": packet.hex(),
"valid": valid,
"expected": expected,
}
# Validate against schema
import jsonschema
schema = json.loads(SCHEMA_PATH.read_text())
try:
jsonschema.validate(case, schema)
except jsonschema.ValidationError as e:
print(f" SKIP {name}: schema error: {e.message}")
return
(OUT / f"{name}.json").write_text(json.dumps(case, indent=2, ensure_ascii=False) + "\n")
def generate(count: int):
OUT.mkdir(exist_ok=True)
# Clear previous generated files
for f in OUT.glob("*.json"):
f.unlink()
global _counter
_counter = 0
strategies = [
("valid", valid_advert_packet(), True),
("valid", valid_ack_packet(), True),
("valid", valid_encrypted_peer_packet(), True),
("valid", valid_group_packet(), True),
("invalid", invalid_packet(), False),
]
per_strategy = max(1, count // len(strategies))
for prefix, strategy, valid in strategies:
for i in range(per_strategy):
example = strategy.example()
packet, expected = example
save_case(packet, expected, valid, prefix)
total = len(list(OUT.glob("*.json")))
print(f"Generated {total} test vectors in {OUT}/")
def main():
parser = argparse.ArgumentParser(description="Generate MeshCore test vectors")
parser.add_argument("--count", type=int, default=50, help="Total test cases to generate")
args = parser.parse_args()
generate(args.count)
if __name__ == "__main__":
main()
@@ -1,22 +0,0 @@
{
"description": "ACK flood, simple acknowledgment",
"tags": [
"ack",
"flood"
],
"packet_hex": "0d00efbeadde",
"valid": true,
"expected": {
"route_type": "flood",
"payload_type": "ACK",
"payload_ver": 0,
"transport_codes": null,
"path": {
"hash_size": 1,
"hops": []
},
"payload": {
"ack_crc": 3735928559
}
}
}
@@ -1,26 +0,0 @@
{
"description": "ACK flood, 2 hops with 1-byte hashes",
"tags": [
"ack",
"flood",
"path"
],
"packet_hex": "0d02d1ac9fc28d00",
"valid": true,
"expected": {
"route_type": "flood",
"payload_type": "ACK",
"payload_ver": 0,
"transport_codes": null,
"path": {
"hash_size": 1,
"hops": [
"d1",
"ac"
]
},
"payload": {
"ack_crc": 9290399
}
}
}
@@ -1,31 +0,0 @@
{
"description": "ADVERT flood, chat node with simple ASCII name",
"tags": [
"advert",
"flood",
"name",
"chat"
],
"packet_hex": "1100000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f403fc6690000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000081546573744e6f6465",
"valid": true,
"expected": {
"route_type": "flood",
"payload_type": "ADVERT",
"payload_ver": 0,
"transport_codes": null,
"path": {
"hash_size": 1,
"hops": []
},
"payload": {
"pubkey_prefix": "00010203",
"timestamp": 1774600000,
"advert": {
"flags": 129,
"node_type": "chat",
"location": null,
"name": "TestNode"
}
}
}
}
@@ -1,32 +0,0 @@
{
"description": "ADVERT flood, chat node with emoji in name (multi-byte UTF-8)",
"tags": [
"advert",
"flood",
"emoji",
"name",
"utf8"
],
"packet_hex": "1100000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f403fc6690000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000081f09fa68520526170746f72",
"valid": true,
"expected": {
"route_type": "flood",
"payload_type": "ADVERT",
"payload_ver": 0,
"transport_codes": null,
"path": {
"hash_size": 1,
"hops": []
},
"payload": {
"pubkey_prefix": "00010203",
"timestamp": 1774600000,
"advert": {
"flags": 129,
"node_type": "chat",
"location": null,
"name": "🦅 Raptor"
}
}
}
}
@@ -1,32 +0,0 @@
{
"description": "ADVERT flood, feat1+feat2 bits set but bytes are actually the name",
"tags": [
"advert",
"flood",
"feat",
"scoring",
"emoji"
],
"packet_hex": "1100000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f403fc66900000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000e1f09f8f94efb88f204d746e4e6f6465",
"valid": true,
"expected": {
"route_type": "flood",
"payload_type": "ADVERT",
"payload_ver": 0,
"transport_codes": null,
"path": {
"hash_size": 1,
"hops": []
},
"payload": {
"pubkey_prefix": "00010203",
"timestamp": 1774600000,
"advert": {
"flags": 225,
"node_type": "chat",
"location": null,
"name": "🏔️ MtnNode"
}
}
}
}
@@ -1,34 +0,0 @@
{
"description": "ADVERT flood, repeater with valid GPS coords and name",
"tags": [
"advert",
"flood",
"location",
"repeater"
],
"packet_hex": "1100000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f403fc6690000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000092346640023807b4f848696c6c746f7052707472",
"valid": true,
"expected": {
"route_type": "flood",
"payload_type": "ADVERT",
"payload_ver": 0,
"transport_codes": null,
"path": {
"hash_size": 1,
"hops": []
},
"payload": {
"pubkey_prefix": "00010203",
"timestamp": 1774600000,
"advert": {
"flags": 146,
"node_type": "repeater",
"location": {
"lat": 37.7749,
"lon": -122.4194
},
"name": "HilltopRptr"
}
}
}
}
@@ -1,25 +0,0 @@
{
"description": "ADVERT flood, bare minimum (no app_data)",
"tags": [
"advert",
"flood",
"minimal"
],
"packet_hex": "1100000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f403fc66900000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000",
"valid": true,
"expected": {
"route_type": "flood",
"payload_type": "ADVERT",
"payload_ver": 0,
"transport_codes": null,
"path": {
"hash_size": 1,
"hops": []
},
"payload": {
"pubkey_prefix": "00010203",
"timestamp": 1774600000,
"advert": null
}
}
}
@@ -1,30 +0,0 @@
{
"description": "ADVERT flood, sensor node type",
"tags": [
"advert",
"flood",
"sensor"
],
"packet_hex": "1100000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f403fc669000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000008454656d7053656e736f72",
"valid": true,
"expected": {
"route_type": "flood",
"payload_type": "ADVERT",
"payload_ver": 0,
"transport_codes": null,
"path": {
"hash_size": 1,
"hops": []
},
"payload": {
"pubkey_prefix": "00010203",
"timestamp": 1774600000,
"advert": {
"flags": 132,
"node_type": "sensor",
"location": null,
"name": "TempSensor"
}
}
}
}
@@ -1,20 +0,0 @@
{
"description": "ADVERT flood, truncated mid-signature (50 bytes)",
"tags": [
"invalid",
"advert",
"truncated"
],
"packet_hex": "1100000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f403fc669000000000000000000000000",
"valid": true,
"expected": {
"route_type": "flood",
"payload_type": "ADVERT",
"payload_ver": 0,
"transport_codes": null,
"path": {
"hash_size": 1,
"hops": []
}
}
}
@@ -1,35 +0,0 @@
{
"description": "ADVERT flood, 4 hops with 1-byte hashes",
"tags": [
"advert",
"flood",
"path"
],
"packet_hex": "11042a3d35ed000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f403fc669000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000008152656c617965644e6f6465",
"valid": true,
"expected": {
"route_type": "flood",
"payload_type": "ADVERT",
"payload_ver": 0,
"transport_codes": null,
"path": {
"hash_size": 1,
"hops": [
"2a",
"3d",
"35",
"ed"
]
},
"payload": {
"pubkey_prefix": "00010203",
"timestamp": 1774600000,
"advert": {
"flags": 129,
"node_type": "chat",
"location": null,
"name": "RelayedNode"
}
}
}
}
@@ -1,30 +0,0 @@
{
"description": "ANON_REQ tdirect header but NO transport codes (older firmware)",
"tags": [
"anon_req",
"tdirect",
"tc_fallback"
],
"packet_hex": "1f03133542d9202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3fb2e2000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000",
"valid": true,
"expected": {
"route_type": "tdirect",
"payload_type": "ANON_REQ",
"payload_ver": 0,
"transport_codes": null,
"path": {
"hash_size": 1,
"hops": [
"13",
"35",
"42"
]
},
"payload": {
"dst": "d9",
"ephem_prefix": "20212223",
"mac": "b2e2",
"ciphertext_len": 48
}
}
}
@@ -1,15 +0,0 @@
{
"description": "TXT_MSG flood, path_len claims 33 hops (2B hash) but data is too short",
"tags": [
"invalid",
"bad_framing",
"path"
],
"packet_hex": "0961000000000000000000000000000000000000000000000000",
"valid": false,
"expected": {
"route_type": "flood",
"payload_type": "TXT_MSG",
"error": "bad_framing"
}
}
@@ -1,16 +0,0 @@
{
"description": "TXT_MSG flood, path_len has reserved hash_size_code 3",
"tags": [
"invalid",
"bad_framing",
"path",
"reserved_hash_size"
],
"packet_hex": "09c1000000000000000000000000000000000000000000000000",
"valid": false,
"expected": {
"route_type": "flood",
"payload_type": "TXT_MSG",
"error": "bad_framing"
}
}
@@ -1,23 +0,0 @@
{
"description": "CONTROL flood, DISCOVER_REQ sub-type",
"tags": [
"control",
"flood",
"discover"
],
"packet_hex": "2d0080033412cdab",
"valid": true,
"expected": {
"route_type": "flood",
"payload_type": "CONTROL",
"payload_ver": 0,
"transport_codes": null,
"path": {
"hash_size": 1,
"hops": []
},
"payload": {
"control_subtype": "DISCOVER_REQ"
}
}
}
@@ -1,14 +0,0 @@
{
"description": "Empty packet (0 bytes)",
"tags": [
"invalid",
"too_short"
],
"packet_hex": "",
"valid": false,
"expected": {
"route_type": "flood",
"payload_type": "REQ",
"error": "too_short"
}
}
@@ -1,26 +0,0 @@
{
"description": "GRP_TXT flood, group text message",
"tags": [
"grp_txt",
"flood",
"group",
"encrypted"
],
"packet_hex": "1500ccabcd0000000000000000000000000000000000000000000000000000000000000000",
"valid": true,
"expected": {
"route_type": "flood",
"payload_type": "GRP_TXT",
"payload_ver": 0,
"transport_codes": null,
"path": {
"hash_size": 1,
"hops": []
},
"payload": {
"channel_hash": "cc",
"mac": "abcd",
"ciphertext_len": 32
}
}
}
@@ -1,24 +0,0 @@
{
"description": "MULTIPART flood, remaining=3, inner type=ACK",
"tags": [
"multipart",
"flood",
"ack"
],
"packet_hex": "290033bebafeca",
"valid": true,
"expected": {
"route_type": "flood",
"payload_type": "MULTIPART",
"payload_ver": 0,
"transport_codes": null,
"path": {
"hash_size": 1,
"hops": []
},
"payload": {
"multipart_remaining": 3,
"multipart_inner": "ACK"
}
}
}
@@ -1,14 +0,0 @@
{
"description": "Random bytes, not a valid MeshCore packet",
"tags": [
"invalid",
"noise"
],
"packet_hex": "03d135839fc28d00",
"valid": false,
"expected": {
"route_type": "tdirect",
"payload_type": "REQ",
"error": "not_meshcore"
}
}
@@ -1,29 +0,0 @@
{
"description": "PATH flood, 1 hop with 3-byte hash",
"tags": [
"path",
"flood",
"3byte_hash",
"encrypted"
],
"packet_hex": "21811afff3e6e92e4700000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000",
"valid": true,
"expected": {
"route_type": "flood",
"payload_type": "PATH",
"payload_ver": 0,
"transport_codes": null,
"path": {
"hash_size": 3,
"hops": [
"1afff3"
]
},
"payload": {
"dst": "e6",
"src": "e9",
"mac": "2e47",
"ciphertext_len": 46
}
}
}
@@ -1,17 +0,0 @@
{
"description": "PATH flood, 4-byte hash (reserved hash_size_code 3, invalid)",
"tags": [
"invalid",
"path",
"flood",
"4byte_hash",
"reserved_hash_size"
],
"packet_hex": "21c1aabbccdde6e92e4700000000000000000000000000000000",
"valid": false,
"expected": {
"route_type": "flood",
"payload_type": "PATH",
"error": "bad_framing"
}
}
@@ -1,33 +0,0 @@
{
"description": "REQ transport-direct with transport codes and 1-byte path",
"tags": [
"req",
"tdirect",
"transport_codes",
"encrypted"
],
"packet_hex": "033412785602ccdda1b2ef010000000000000000000000000000000000000000000000000000000000000000",
"valid": true,
"expected": {
"route_type": "tdirect",
"payload_type": "REQ",
"payload_ver": 0,
"transport_codes": {
"tc1": 4660,
"tc2": 22136
},
"path": {
"hash_size": 1,
"hops": [
"cc",
"dd"
]
},
"payload": {
"dst": "a1",
"src": "b2",
"mac": "ef01",
"ciphertext_len": 32
}
}
}
@@ -1,11 +0,0 @@
{
"description": "RESPONSE flood, bit-corrupted path_len (0xc7 should be small), LoRa bit error",
"tags": ["invalid", "response", "flood", "corrupted", "real_capture", "bit_error"],
"packet_hex": "05c735935ed17863f6ec7bbbd9bf8e5ea7d44b6a0e8f6318",
"valid": false,
"expected": {
"route_type": "flood",
"payload_type": "RESPONSE",
"error": "bad_framing"
}
}
@@ -1,11 +0,0 @@
{
"description": "RESPONSE flood, bit-corrupted path_len (0x32 should be 0x02 — bits 4,5 flipped), LoRa bit error",
"tags": ["invalid", "response", "flood", "corrupted", "real_capture", "bit_error"],
"packet_hex": "0532af1a5bd17863f6ec7bbbd9bfbe5ea7d44b6a0e8f6318",
"valid": false,
"expected": {
"route_type": "flood",
"payload_type": "RESPONSE",
"error": "bad_framing"
}
}
@@ -1,14 +0,0 @@
{
"description": "RESPONSE flood, 1 hop (real capture — same message as corrupted variants)",
"tags": ["response", "flood", "path", "real_capture"],
"packet_hex": "0501ac5bd17863f6ec7bbbd9bf8e5ea7d44b6a0e8f6318",
"valid": true,
"expected": {
"route_type": "flood",
"payload_type": "RESPONSE",
"payload_ver": 0,
"transport_codes": null,
"path": {"hash_size": 1, "hops": ["ac"]},
"payload": {"dst": "5b", "src": "d1", "mac": "7863", "ciphertext_len": 16}
}
}
@@ -1,14 +0,0 @@
{
"description": "RESPONSE flood, 2 hops [ac>ea] (real capture — same message as corrupted variants)",
"tags": ["response", "flood", "path", "real_capture"],
"packet_hex": "0502acea5bd17863f6ec7bbbd9bf8e5ea7d44b6a0e8f6318",
"valid": true,
"expected": {
"route_type": "flood",
"payload_type": "RESPONSE",
"payload_ver": 0,
"transport_codes": null,
"path": {"hash_size": 1, "hops": ["ac", "ea"]},
"payload": {"dst": "5b", "src": "d1", "mac": "7863", "ciphertext_len": 16}
}
}
@@ -1,14 +0,0 @@
{
"description": "Packet too short (1 byte)",
"tags": [
"invalid",
"too_short"
],
"packet_hex": "11",
"valid": false,
"expected": {
"route_type": "flood",
"payload_type": "ADVERT",
"error": "too_short"
}
}
@@ -1,14 +0,0 @@
{
"description": "Packet too short (2 bytes, need >= 3)",
"tags": [
"invalid",
"too_short"
],
"packet_hex": "1100",
"valid": false,
"expected": {
"route_type": "flood",
"payload_type": "ADVERT",
"error": "too_short"
}
}
@@ -1,24 +0,0 @@
{
"description": "TRACE flood with 4-byte trace hashes (flags=0x02, hash_size=4)",
"tags": [
"trace",
"flood",
"4byte_hash"
],
"packet_hex": "2500ddccbbaa4433221102deadbeefcafebabe",
"valid": true,
"expected": {
"route_type": "flood",
"payload_type": "TRACE",
"payload_ver": 0,
"transport_codes": null,
"path": {
"hash_size": 1,
"hops": []
},
"payload": {
"trace_tag": 2864434397,
"trace_hops": 2
}
}
}
@@ -1,23 +0,0 @@
{
"description": "TRACE flood with tag, auth, and 2 trace hops",
"tags": [
"trace",
"flood"
],
"packet_hex": "250078563412ddccbbaa001122",
"valid": true,
"expected": {
"route_type": "flood",
"payload_type": "TRACE",
"payload_ver": 0,
"transport_codes": null,
"path": {
"hash_size": 1,
"hops": []
},
"payload": {
"trace_tag": 305419896,
"trace_hops": 2
}
}
}
@@ -1,30 +0,0 @@
{
"description": "TXT_MSG direct, 2-byte hashes, 2 hops",
"tags": [
"txt_msg",
"direct",
"2byte_hash",
"encrypted"
],
"packet_hex": "0a42ac98d14d5b43abcd000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000",
"valid": true,
"expected": {
"route_type": "direct",
"payload_type": "TXT_MSG",
"payload_ver": 0,
"transport_codes": null,
"path": {
"hash_size": 2,
"hops": [
"ac98",
"d14d"
]
},
"payload": {
"dst": "5b",
"src": "43",
"mac": "abcd",
"ciphertext_len": 48
}
}
}
@@ -1,33 +0,0 @@
{
"description": "TXT_MSG flood with routing loop (ac98 appears 3x)",
"tags": [
"txt_msg",
"flood",
"loop",
"2byte_hash"
],
"packet_hex": "0945ac98d14dac98ff01ac985b43abcd0000000000000000000000000000000000000000000000000000000000000000",
"valid": true,
"expected": {
"route_type": "flood",
"payload_type": "TXT_MSG",
"payload_ver": 0,
"transport_codes": null,
"path": {
"hash_size": 2,
"hops": [
"ac98",
"d14d",
"ac98",
"ff01",
"ac98"
]
},
"payload": {
"dst": "5b",
"src": "43",
"mac": "abcd",
"ciphertext_len": 32
}
}
}
-128
View File
@@ -1,128 +0,0 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "MeshCore Packet Test Case",
"description": "A single test vector for MeshCore packet decoders",
"type": "object",
"required": ["description", "tags", "packet_hex", "valid", "expected"],
"additionalProperties": false,
"properties": {
"description": {
"type": "string",
"minLength": 1
},
"tags": {
"type": "array",
"items": { "type": "string" },
"minItems": 1
},
"packet_hex": {
"type": "string",
"pattern": "^[0-9a-f]*$",
"description": "Raw MeshCore packet as lowercase hex (no 0x prefix)"
},
"valid": {
"type": "boolean",
"description": "Whether this packet should parse successfully"
},
"expected": {
"type": "object",
"required": ["route_type", "payload_type"],
"properties": {
"route_type": {
"type": "string",
"enum": ["flood", "direct", "tflood", "tdirect"]
},
"payload_type": {
"type": "string",
"enum": [
"REQ", "RESPONSE", "TXT_MSG", "ACK", "ADVERT",
"GRP_TXT", "GRP_DATA", "ANON_REQ", "PATH",
"TRACE", "MULTIPART", "CONTROL", "RAW_CUSTOM"
]
},
"payload_ver": { "type": "integer", "minimum": 0, "maximum": 3 },
"transport_codes": {
"oneOf": [
{ "type": "null" },
{
"type": "object",
"required": ["tc1", "tc2"],
"properties": {
"tc1": { "type": "integer" },
"tc2": { "type": "integer" }
},
"additionalProperties": false
}
]
},
"path": {
"type": "object",
"required": ["hash_size", "hops"],
"properties": {
"hash_size": { "type": "integer", "minimum": 1, "maximum": 4 },
"hops": {
"type": "array",
"items": { "type": "string", "pattern": "^[0-9a-f]+$" }
}
},
"additionalProperties": false
},
"error": {
"type": "string",
"enum": ["too_short", "not_meshcore", "bad_framing"],
"description": "Expected error category for invalid packets"
},
"payload": {
"type": "object",
"description": "Payload-type-specific decoded fields",
"properties": {
"dst": { "type": "string", "pattern": "^[0-9a-f]{2}$" },
"src": { "type": "string", "pattern": "^[0-9a-f]{2}$" },
"mac": { "type": "string", "pattern": "^[0-9a-f]+$" },
"ciphertext_len": { "type": "integer", "minimum": 0 },
"channel_hash": { "type": "string", "pattern": "^[0-9a-f]{2}$" },
"ack_crc": { "type": "integer" },
"pubkey_prefix": { "type": "string", "pattern": "^[0-9a-f]+$" },
"timestamp": { "type": "integer" },
"advert": {
"oneOf": [
{ "type": "null" },
{
"type": "object",
"properties": {
"flags": { "type": "integer", "minimum": 0, "maximum": 255 },
"node_type": { "type": "string" },
"location": {
"oneOf": [
{ "type": "null" },
{
"type": "object",
"required": ["lat", "lon"],
"properties": {
"lat": { "type": "number" },
"lon": { "type": "number" }
},
"additionalProperties": false
}
]
},
"name": { "oneOf": [{ "type": "string" }, { "type": "null" }] }
},
"additionalProperties": false
}
]
},
"ephem_prefix": { "type": "string", "pattern": "^[0-9a-f]+$" },
"trace_tag": { "type": "integer" },
"trace_hops": { "type": "integer" },
"multipart_remaining": { "type": "integer" },
"multipart_inner": { "type": "string" },
"control_subtype": { "type": "string" }
},
"additionalProperties": false
}
},
"additionalProperties": false
}
}
}
-207
View File
@@ -1,207 +0,0 @@
#!/usr/bin/env python3
"""Validate MeshCore test corpus against schema and decoder."""
# /// script
# requires-python = ">=3.10"
# dependencies = ["jsonschema", "cobs", "pyserial", "pycryptodome"]
# ///
import json
import re
import sys
from pathlib import Path
import jsonschema
# Add meshcore/ directory to path so we can import the decoder
sys.path.insert(0, str(Path(__file__).parent.parent))
from meshcore_rx import decode_meshcore_packet # noqa: E402
CORPUS_DIR = Path(__file__).parent
SCHEMA_PATH = CORPUS_DIR / "schema.json"
# ANSI escape stripper
_ANSI_RE = re.compile(r"\033\[[0-9;]*m")
def strip_ansi(s: str) -> str:
return _ANSI_RE.sub("", s)
def load_schema() -> dict:
return json.loads(SCHEMA_PATH.read_text())
def find_test_cases() -> list[Path]:
cases = []
for d in ["hand", "generated"]:
p = CORPUS_DIR / d
if p.is_dir():
cases.extend(sorted(p.glob("*.json")))
return cases
def validate_schema(case: dict, schema: dict, path: Path) -> list[str]:
"""Validate a test case against the JSON schema. Returns list of errors."""
errors = []
try:
jsonschema.validate(case, schema)
except jsonschema.ValidationError as e:
errors.append(f"Schema: {e.message}")
return errors
def validate_decode(case: dict, path: Path) -> list[str]:
"""Run the decoder and check output against expected fields."""
errors = []
packet = bytes.fromhex(case["packet_hex"]) if case["packet_hex"] else b""
expected = case["expected"]
# Run decoder — must not crash
try:
raw_output, _warnings = decode_meshcore_packet(packet)
except Exception as e:
errors.append(f"Decoder crashed: {e}")
return errors
output = strip_ansi(raw_output)
if case["valid"]:
# Valid packet: should contain the expected payload type name
ptype = expected["payload_type"]
if ptype not in output:
errors.append(f"Expected '{ptype}' in output, got: {output[:120]}")
# Check route type
rtype = expected["route_type"]
if rtype not in output:
errors.append(f"Expected route '{rtype}' in output, got: {output[:120]}")
# Check path hops if specified
if "path" in expected:
exp_path = expected["path"]
for hop in exp_path["hops"]:
if hop not in output:
errors.append(f"Expected hop '{hop}' in output")
# Check payload-specific fields
payload = expected.get("payload")
if payload:
_check_payload(payload, output, errors)
# Should NOT contain error markers
for marker in ["<too short>", "<not meshcore>", "<bad framing>"]:
if marker in output:
errors.append(f"Valid packet produced error marker: {marker}")
else:
# Invalid packet: must not crash. Error marker check is best-effort
# (random mutations may accidentally produce parseable packets).
pass # decoder didn't crash — that's the key requirement
return errors
def _check_payload(payload: dict, output: str, errors: list[str]):
"""Check payload-specific fields in the decoder output."""
if "ack_crc" in payload:
crc_hex = f"0x{payload['ack_crc']:08x}"
if crc_hex not in output:
errors.append(f"Expected CRC '{crc_hex}' in output")
if "dst" in payload:
if payload["dst"] not in output:
errors.append(f"Expected dst='{payload['dst']}' in output")
if "src" in payload:
if payload["src"] not in output:
errors.append(f"Expected src='{payload['src']}' in output")
if "mac" in payload:
if payload["mac"] not in output:
errors.append(f"Expected mac='{payload['mac']}' in output")
if "channel_hash" in payload:
if payload["channel_hash"] not in output:
errors.append(f"Expected ch='{payload['channel_hash']}' in output")
if "ciphertext_len" in payload:
ct_str = f"[{payload['ciphertext_len']}B]"
if ct_str not in output:
errors.append(f"Expected '{ct_str}' in output")
if "pubkey_prefix" in payload:
pk = payload["pubkey_prefix"]
if pk not in output:
errors.append(f"Expected pubkey prefix '{pk}' in output")
if "advert" in payload and payload["advert"] is not None:
advert = payload["advert"]
if "name" in advert and advert["name"] is not None:
if advert["name"] not in output:
errors.append(f"Expected name '{advert['name']}' in output")
if "node_type" in advert:
if advert["node_type"] not in output:
errors.append(f"Expected node_type '{advert['node_type']}' in output")
if "location" in advert and advert["location"] is not None:
loc = advert["location"]
lat_str = f"{loc['lat']:.4f}"
if lat_str not in output:
errors.append(f"Expected lat '{lat_str}' in output")
if "ephem_prefix" in payload:
ep = payload["ephem_prefix"]
if ep not in output:
errors.append(f"Expected ephemeral prefix '{ep}' in output")
if "multipart_remaining" in payload:
r = payload["multipart_remaining"]
if f"remaining={r}" not in output.replace(" ", ""):
errors.append(f"Expected remaining={r} in output")
if "multipart_inner" in payload:
inner = payload["multipart_inner"]
if inner not in output:
errors.append(f"Expected inner type '{inner}' in output")
if "control_subtype" in payload:
sub = payload["control_subtype"]
if sub not in output:
errors.append(f"Expected control subtype '{sub}' in output")
def main():
schema = load_schema()
cases = find_test_cases()
if not cases:
print("No test cases found!")
sys.exit(1)
passed = 0
failed = 0
total_errors: list[tuple[str, list[str]]] = []
for path in cases:
case = json.loads(path.read_text())
name = f"{path.parent.name}/{path.stem}"
errors = validate_schema(case, schema, path)
errors.extend(validate_decode(case, path))
if errors:
failed += 1
total_errors.append((name, errors))
print(f" \033[1;31mFAIL\033[0m {name}")
for e in errors:
print(f" {e}")
else:
passed += 1
print(f" \033[32mPASS\033[0m {name}")
print()
print(f"\033[1m{passed + failed} tests: {passed} passed, {failed} failed\033[0m")
sys.exit(1 if failed else 0)
if __name__ == "__main__":
main()
-931
View File
@@ -1,931 +0,0 @@
#!/usr/bin/env python3
"""DongLoRa MeshCore receiver: decode and display MeshCore packets."""
import base64
import csv
import donglora as dl
import hashlib
import hmac
import json
import struct
import serial
import sys
import tempfile
import time
from Crypto.Cipher import AES
from datetime import datetime, timezone
from pathlib import Path
# ── ANSI colors ───────────────────────────────────────────────────
RST = "\033[0m"
BOLD = "\033[1m"
DIM = "\033[2m"
RED = "\033[31m"
GRN = "\033[32m"
YEL = "\033[33m"
BLU = "\033[34m"
MAG = "\033[35m"
CYN = "\033[36m"
WHT = "\033[37m"
BRED = "\033[1;31m"
BGRN = "\033[1;32m"
BYEL = "\033[1;33m"
BBLU = "\033[1;34m"
BMAG = "\033[1;35m"
BCYN = "\033[1;36m"
BWHT = "\033[1;37m"
TYPE_COLORS = {
"ADVERT": BGRN,
"ACK": DIM,
"TXT_MSG": BCYN,
"REQ": BYEL,
"RESPONSE": BYEL,
"GRP_TXT": BMAG,
"GRP_DATA": BMAG,
"ANON_REQ": YEL,
"PATH": BBLU,
"TRACE": BLU,
"MULTIPART": YEL,
"CONTROL": BLU,
"RAW_CUSTOM": DIM,
}
def _rssi_color(rssi: int) -> str:
if rssi >= -70:
return GRN
if rssi >= -100:
return YEL
return RED
def _snr_color(snr: int) -> str:
if snr >= 0:
return GRN
if snr >= -10:
return YEL
return RED
# ── MeshCore channel crypto ────────────────────────────────────────
def _channel_secret_from_hashtag(name: str) -> bytes:
"""Derive 32-byte secret from a hashtag channel name (e.g., '#test')."""
h = hashlib.sha256(name.encode()).digest()
return h[:16] + b"\x00" * 16
def _channel_secret_from_psk(psk_b64: str) -> bytes:
"""Decode a base64 PSK into a 32-byte secret (16-byte key + 16 zero bytes)."""
key = base64.b64decode(psk_b64)
return key[:16] + b"\x00" * 16
def _channel_hash(secret: bytes) -> int:
"""Compute the 1-byte channel hash from a 32-byte secret."""
# Hash over the key portion (16 bytes for hashtag/PSK channels)
key_len = 16 if secret[16:] == b"\x00" * 16 else 32
return hashlib.sha256(secret[:key_len]).digest()[0]
def _grp_verify_and_decrypt(
secret: bytes, mac_bytes: bytes, ciphertext: bytes
) -> bytes | None:
"""Verify MAC then decrypt a GRP_TXT/GRP_DATA payload. Returns plaintext or None."""
if not ciphertext or len(ciphertext) % 16 != 0:
return None
# HMAC-SHA256 over ciphertext, truncated to 2 bytes
key_len = 16 if secret[16:] == b"\x00" * 16 else 32
computed_mac = hmac.new(secret[:key_len], ciphertext, hashlib.sha256).digest()[:2]
if computed_mac != mac_bytes:
return None
# AES-128-ECB decrypt
cipher = AES.new(secret[:16], AES.MODE_ECB)
plaintext = b""
for i in range(0, len(ciphertext), 16):
plaintext += cipher.decrypt(ciphertext[i : i + 16])
return plaintext
def _parse_grp_plaintext(plaintext: bytes) -> tuple[int, str] | None:
"""Parse decrypted GRP_TXT: timestamp(4) + flags(1) + text. Returns (timestamp, text)."""
if len(plaintext) < 5:
return None
timestamp = struct.unpack_from("<I", plaintext, 0)[0]
# flags_and_attempt at byte 4: (attempt & 3) | (txt_type << 2)
text = plaintext[5:].split(b"\x00", 1)[0] # null-terminated, strip zero padding
return timestamp, text.decode("utf-8", errors="replace")
# Known channels: name → 32-byte secret
# Public channel has a hardcoded PSK; hashtag channels derive from name
_KNOWN_CHANNELS: dict[str, bytes] = {}
# channel_hash (1 byte) → list of (name, secret) for matching
_CHANNEL_BY_HASH: dict[int, list[tuple[str, bytes]]] = {}
def _register_channel(name: str, secret: bytes):
_KNOWN_CHANNELS[name] = secret
h = _channel_hash(secret)
_CHANNEL_BY_HASH.setdefault(h, []).append((name, secret))
_CHANNELS_CSV = Path(__file__).parent / "channels.csv"
def _init_channels():
"""Load channels from channels.csv. Hashtag channels use key derivation;
PSK channels (hashtag=False) use the raw key_hex."""
if not _CHANNELS_CSV.is_file():
print(f" {RED}channels.csv not found at {_CHANNELS_CSV}{RST}")
return
with open(_CHANNELS_CSV, newline="") as f:
for row in csv.DictReader(f):
name = row["channel_name"]
is_hashtag = row["hashtag"].strip().lower() == "true"
key_hex = row["key_hex"].strip()
if is_hashtag:
secret = _channel_secret_from_hashtag(name)
else:
# PSK channel — raw 16-byte key
secret = bytes.fromhex(key_hex) + b"\x00" * 16
_register_channel(name, secret)
_init_channels()
# ── GRP_TXT transmit (encrypt + send) ─────────────────────────────
MAX_GRP_TEXT = 163 # max bytes for "SenderName: message" in a GRP_TXT
def _grp_encrypt(secret: bytes, sender: str, text: str) -> bytes:
"""Build an encrypted GRP_TXT payload (channel_hash + mac + ciphertext)."""
ch = _channel_hash(secret)
# Plaintext: timestamp(4) + flags(1) + "sender: text\0" + zero-pad
plaintext = struct.pack("<I", int(time.time())) + b"\x00"
plaintext += f"{sender}: {text}\x00".encode("utf-8")[:MAX_GRP_TEXT]
pad_len = (16 - len(plaintext) % 16) % 16
plaintext += b"\x00" * pad_len
# AES-128-ECB encrypt
cipher = AES.new(secret[:16], AES.MODE_ECB)
ciphertext = b""
for i in range(0, len(plaintext), 16):
ciphertext += cipher.encrypt(plaintext[i : i + 16])
# HMAC-SHA256 over ciphertext, truncated to 2 bytes
key_len = 16 if secret[16:] == b"\x00" * 16 else 32
mac = hmac.new(secret[:key_len], ciphertext, hashlib.sha256).digest()[:2]
return bytes([ch]) + mac + ciphertext
def _grp_build_packet(channel_payload: bytes) -> bytes:
"""Wrap encrypted GRP_TXT payload into a full MeshCore packet."""
header = bytes([0x15]) # GRP_TXT flood: (5 << 2) | 1
path_len = bytes([0x40]) # 0 hops, 2-byte hash mode (hash_size_code=1 << 6)
return header + path_len + channel_payload
def _grp_transmit(ser, channel_name: str, sender: str, text: str):
"""Encrypt and transmit a GRP_TXT message, then resume RX."""
secret = _KNOWN_CHANNELS.get(channel_name)
if secret is None:
return
payload = _grp_encrypt(secret, sender, text)
packet = _grp_build_packet(payload)
try:
send_cmd(ser, "Transmit", quiet=True, payload=packet)
except Exception:
pass
# Always try to resume RX, even if TX failed
try:
send_cmd(ser, "StartRx", quiet=True)
except Exception:
pass
# ── Loop/collision aggregator ─────────────────────────────────────
REPORT_CHANNEL = "#watchman"
REPORT_SENDER = "Watchman"
REPORT_WINDOW = 60 # seconds — collect events before reporting
REPORT_COOLDOWN = 3600 # seconds — suppress repeat reports for same issue
def _nodes_for_hash(hash_hex: str, hash_size: int) -> int:
"""Count how many known nodes share this path hash."""
return sum(1 for pk in _known_nodes if pk[: hash_size * 2] == hash_hex)
class LoopAggregator:
def __init__(self):
self._pending: list[dict] = []
self._cooldown: dict[str, float] = {}
self._window_start = time.monotonic()
def record(self, dupes: dict[str, int], hash_size: int, all_hops: list[str]):
"""Record duplicate hops from one packet."""
first_hop = all_hops[0] if all_hops else None
self._pending.append(
{
"dupes": dupes,
"hash_size": hash_size,
"hops": all_hops,
"first_hop": first_hop,
}
)
def maybe_report(self, ser) -> bool:
if not self._pending:
self._window_start = time.monotonic()
return False
if time.monotonic() - self._window_start < REPORT_WINDOW:
return False
self._send_report(ser)
return True
def _send_report(self, ser):
now = time.monotonic()
messages: list[str] = []
# Aggregate: (hash, hash_size) → {packets, relayers, senders}
agg: dict[tuple[str, int], dict] = {}
for ev in self._pending:
for h, count in ev["dupes"].items():
key = (h, ev["hash_size"])
if key not in agg:
agg[key] = {"packets": 0, "relayers": set(), "senders": set()}
agg[key]["packets"] += 1
if ev["first_hop"]:
agg[key]["senders"].add(ev["first_hop"])
for hop in ev["hops"]:
if hop != h:
agg[key]["relayers"].add(hop)
for (h, hs), info in agg.items():
issue_key = f"{h}:{hs}"
if (
issue_key in self._cooldown
and now - self._cooldown[issue_key] < REPORT_COOLDOWN
):
continue
n_known = _nodes_for_hash(h, hs)
pkts = info["packets"]
senders = sorted(info["senders"])
sender_str = ",".join(senders[:3]) if senders else "?"
if len(senders) > 3:
sender_str += f"+{len(senders) - 3}"
relayer_str = ",".join(sorted(info["relayers"])[:3])
if len(info["relayers"]) > 3:
relayer_str += f"+{len(info['relayers']) - 3}"
if n_known == 1:
# Exactly one node matches → confirmed loop
msg = f"Loop: {h} re-sent its own traffic ({pkts} pkt)."
if relayer_str:
msg += f" Relayed by {relayer_str}."
messages.append(msg)
elif hs >= 2:
if n_known > 1:
# 2B+ hash collision — rare, definitely a routing issue
msg = (
f"Routing conflict: {n_known} nodes share {hs}B "
f"hash {h} ({pkts} pkt)."
)
messages.append(msg)
else:
# 2B+ duplicate, unknown node → almost certainly a loop
msg = f"Probable loop: {h} appeared {pkts}x."
if relayer_str:
msg += f" Relayed by {relayer_str}."
messages.append(msg)
else:
# 1B hash — can't distinguish loop from collision
if n_known > 1:
msg = (
f"1B hash {h} duplicated in {pkts} pkt "
f"({n_known} known nodes share it). "
f"Sender {sender_str}: use 2B routing!"
)
messages.append(msg)
else:
msg = (
f"1B hash {h} duplicated in {pkts} pkt. "
f"Sender {sender_str}: use 2B routing to clarify!"
)
messages.append(msg)
self._cooldown[issue_key] = now
if not messages:
self._reset()
return
full = " | ".join(messages)
max_len = MAX_GRP_TEXT - len(REPORT_SENDER) - 2
if len(full) > max_len:
full = full[: max_len - 5] + "+more"
print(f" {DIM}>>>{RST} {BYEL}Report → {REPORT_CHANNEL}:{RST} {full}")
_grp_transmit(ser, REPORT_CHANNEL, REPORT_SENDER, full)
self._reset()
def _reset(self):
self._pending.clear()
self._window_start = time.monotonic()
_aggregator = LoopAggregator()
# ── MeshCore packet decoder ──────────────────────────────────────
ROUTE_NAMES = {0: "tflood", 1: "flood", 2: "direct", 3: "tdirect"}
PAYLOAD_NAMES = {
0x00: "REQ",
0x01: "RESPONSE",
0x02: "TXT_MSG",
0x03: "ACK",
0x04: "ADVERT",
0x05: "GRP_TXT",
0x06: "GRP_DATA",
0x07: "ANON_REQ",
0x08: "PATH",
0x09: "TRACE",
0x0A: "MULTIPART",
0x0B: "CONTROL",
0x0F: "RAW_CUSTOM",
}
NODE_TYPES = {0x00: "none", 0x01: "chat", 0x02: "repeater", 0x03: "room", 0x04: "sensor"}
MAX_PATH_SIZE = 64
# ── Node registry (persistent, pruned at 12h) ────────────────────
ADVERT_MAX_AGE = 12 * 3600 # seconds
_ADVERT_FILE = Path(tempfile.gettempdir()) / "donglora_adverts.json"
# In-memory: pubkey_hex → {"name": str, "seen": float (unix timestamp)}
_known_nodes: dict[str, dict] = {}
def _load_adverts():
"""Load persisted adverts from disk, pruning stale entries."""
global _known_nodes
try:
data = json.loads(_ADVERT_FILE.read_text())
cutoff = time.time() - ADVERT_MAX_AGE
_known_nodes = {k: v for k, v in data.items() if v.get("seen", 0) > cutoff}
except (FileNotFoundError, json.JSONDecodeError, ValueError):
_known_nodes = {}
def _save_adverts():
"""Persist current adverts to disk (atomic write)."""
try:
tmp = _ADVERT_FILE.with_suffix(".tmp")
tmp.write_text(json.dumps(_known_nodes))
tmp.rename(_ADVERT_FILE)
except OSError:
pass
def _lookup_hash(hash_hex: str, hash_size: int) -> list[str]:
"""Find node names whose pubkey prefix matches a path hash."""
return [
v["name"]
for pk_hex, v in _known_nodes.items()
if pk_hex[: hash_size * 2] == hash_hex
]
def _register_node(pubkey: bytes, name: str):
"""Register or update a node from an ADVERT."""
pk_hex = pubkey.hex()
_known_nodes[pk_hex] = {"name": name, "seen": time.time()}
_save_adverts()
def _utf8_score(data: bytes) -> int:
"""Score how cleanly data decodes as UTF-8. Higher = more replacement chars."""
return data.decode("utf-8", errors="replace").count("\ufffd")
def _best_name(candidates: list[bytes]) -> bytes:
"""Pick the candidate with the fewest UTF-8 replacement chars; longest wins ties."""
return min(candidates, key=lambda d: (_utf8_score(d), -len(d)))
def _decode_advert_appdata(app_data: bytes) -> tuple[str, str, list[str]]:
"""Decode advert app_data. Returns (display_string, node_name, quality_warnings)."""
if not app_data:
return "", "", []
flags = app_data[0]
node_type = flags & 0x0F
node_label = NODE_TYPES.get(node_type, f"0x{node_type:02x}")
parts = [f"{DIM}flags={RST}{flags:02x} {DIM}node={RST}{node_label}"]
warnings: list[str] = []
if node_type > 4:
warnings.append("bad node_type")
# Parse location if flagged and coords are valid
off = 1
loc_str = ""
if flags & 0x10 and off + 8 <= len(app_data):
lat_raw = struct.unpack_from("<i", app_data, off)[0]
lon_raw = struct.unpack_from("<i", app_data, off + 4)[0]
lat, lon = lat_raw / 1e6, lon_raw / 1e6
if -90 <= lat <= 90 and -180 <= lon <= 180:
loc_str = f"{DIM}loc={RST}{lat:.4f},{lon:.4f}"
off += 8
# Build candidate name slices — try with/without feat fields
off_with_feat = off
if flags & 0x20 and off_with_feat + 2 <= len(app_data):
off_with_feat += 2
if flags & 0x40 and off_with_feat + 2 <= len(app_data):
off_with_feat += 2
name = ""
if flags & 0x80:
candidates = []
if off_with_feat < len(app_data):
candidates.append(app_data[off_with_feat:])
if off < len(app_data) and off != off_with_feat:
candidates.append(app_data[off:])
# Also try raw name-at-byte-1 as a last resort (ignoring all flags)
if 1 not in (off, off_with_feat) and 1 < len(app_data):
candidates.append(app_data[1:])
if candidates:
best = _best_name(candidates)
name = best.decode("utf-8", errors="replace")
if "\ufffd" in name:
warnings.append("bad UTF-8")
if loc_str:
parts.append(loc_str)
if name:
parts.append(f"{BWHT}{name}{RST}")
return " ".join(parts), name, warnings
LOOP_INDENT = " " * 38 # align under decoded packet text (after RSSI/SNR/len)
def _detect_loops(hops: list[str], hash_size: int) -> str | None:
"""Check for duplicate hops in a path. Returns a warning line or None."""
if len(hops) < 2:
return None
counts: dict[str, int] = {}
for h in hops:
counts[h] = counts.get(h, 0) + 1
dupes = {h: c for h, c in counts.items() if c > 1}
if not dupes:
return None
# Feed the aggregator for deferred reporting
_aggregator.record(dupes, hash_size, hops)
parts = []
for h, c in dupes.items():
n_known = _nodes_for_hash(h, hash_size)
if n_known == 1:
# Exactly one node matches → confirmed loop
parts.append(f"{BRED}{h} x{c} LOOP{RST}")
elif hash_size >= 2:
if n_known > 1:
# 2B+ collision is rare and real
parts.append(f"{BCYN}{h} x{c} COLLISION {DIM}({n_known} nodes){RST}")
else:
# 2B+ duplicate, unknown node → almost certainly a loop
parts.append(f"{BRED}{h} x{c} LOOP{RST}")
else:
# 1B hash — ambiguous: could be loop or collision
if n_known > 1:
parts.append(
f"{YEL}{h} x{c} {DIM}(loop or collision? {n_known} nodes share this 1B hash){RST}"
)
else:
parts.append(f"{YEL}{h} x{c} {DIM}(loop or collision? 1B hash){RST}")
return f"{LOOP_INDENT}\u26a0 {', '.join(parts)}"
def _try_decode_body(
data: bytes,
pos: int,
type_name: str,
type_color: str,
payload_type: int,
payload_ver: int,
route_name: str,
tc_str: str,
) -> tuple[str | None, list[str]]:
"""Try to parse path + payload starting at pos. Returns (decoded_string, warnings) or (None, [])."""
if pos >= len(data):
return None, []
path_len_byte = data[pos]
pos += 1
hash_size_code = path_len_byte >> 6
if hash_size_code == 3:
return None, [] # reserved hash size code per spec Section 3
hash_size = hash_size_code + 1
hop_count = path_len_byte & 0x3F
path_bytes = hop_count * hash_size
if path_bytes > MAX_PATH_SIZE or pos + path_bytes > len(data):
return None, []
hops_str = ""
loop_warn = ""
hops: list[str] = []
if hop_count > 0:
for i in range(hop_count):
h = data[pos + i * hash_size : pos + (i + 1) * hash_size]
hops.append(h.hex())
hops_str = f" {DIM}path=[{RST}{'>'.join(hops)}{DIM}]{RST}"
warn = _detect_loops(hops, hash_size)
if warn:
loop_warn = f"\n{warn}"
pos += path_bytes
payload = data[pos:]
if not payload or len(payload) > 184:
return None, []
prefix = f"{type_color}{type_name}{RST} {route_name}{tc_str}{hops_str}"
warnings: list[str] = []
if payload_type == 0x04:
result, warnings = _decode_advert(prefix, payload)
elif payload_type == 0x03:
result = _decode_ack(prefix, payload)
elif payload_type in (0x00, 0x01, 0x02, 0x08):
result = _decode_encrypted_peer(prefix, type_name, payload)
elif payload_type in (0x05, 0x06):
result = _decode_encrypted_group(prefix, payload)
elif payload_type == 0x07:
result = _decode_anon_req(prefix, payload)
elif payload_type == 0x09:
result = _decode_trace(prefix, payload)
elif payload_type == 0x0A:
result = _decode_multipart(prefix, payload)
elif payload_type == 0x0B:
result = _decode_control(prefix, payload)
else:
result = f"{prefix} {DIM}{payload.hex()}{RST}" if payload else prefix
return result + loop_warn, warnings
def decode_meshcore_packet(data: bytes) -> tuple[str, list[str]]:
"""Decode a MeshCore packet. Returns (display_string, quality_warnings)."""
if len(data) < 3:
return f"{RED}<too short>{RST} {DIM}{data.hex()}{RST}", []
header = data[0]
if header == 0xFF:
return f"{DIM}<no-retransmit marker> {data[1:].hex()}{RST}", []
route_type = header & 0x03
payload_type = (header >> 2) & 0x0F
payload_ver = (header >> 6) & 0x03
# MeshCore only implements PAYLOAD_VER_1 (0x00) — versions 1-3 are reserved
# and rejected by real nodes. Packets with ver > 0 are noise or non-MeshCore.
if payload_ver != 0:
return (
f"{RED}<not meshcore>{RST} {DIM}hdr=0x{header:02x} "
f"(ver={payload_ver} — only v0 exists) {data[1:].hex()}{RST}",
[],
)
route_name = ROUTE_NAMES.get(route_type, f"rt{route_type}")
type_name = PAYLOAD_NAMES.get(payload_type, f"0x{payload_type:02x}")
type_color = TYPE_COLORS.get(type_name, WHT)
has_tc = route_type in (0, 3)
# Try with transport codes first if indicated, fallback without
if has_tc and len(data) >= 6:
tc1, tc2 = struct.unpack_from("<HH", data, 1)
tc_str = f" {DIM}tc={tc1:04x}:{tc2:04x}{RST}"
result, warnings = _try_decode_body(
data,
5,
type_name,
type_color,
payload_type,
payload_ver,
route_name,
tc_str,
)
if result is not None:
return result, warnings
# TC parse failed — retry without (older firmware compatibility)
result, warnings = _try_decode_body(
data, 1, type_name, type_color, payload_type, payload_ver, route_name, ""
)
if result is not None:
return result, warnings
else:
result, warnings = _try_decode_body(
data, 1, type_name, type_color, payload_type, payload_ver, route_name, ""
)
if result is not None:
return result, warnings
# Both attempts failed — show what we know from the header
return (
f"{RED}<bad framing>{RST} {type_color}{type_name}{RST} "
f"{route_name} {DIM}{data[1:].hex()}{RST}",
[],
)
def _decode_advert(prefix: str, payload: bytes) -> tuple[str, list[str]]:
if len(payload) < 100:
return f"{prefix} {RED}<bad advert: {len(payload)}B, need >=100>{RST} {DIM}{payload.hex()}{RST}", []
pubkey = payload[0:32]
timestamp = struct.unpack_from("<I", payload, 32)[0]
app_data = payload[100:]
warnings: list[str] = []
try:
ts = datetime.fromtimestamp(timestamp, tz=timezone.utc).strftime(
"%Y-%m-%d %H:%M:%S"
)
if timestamp > time.time() + 3600:
warnings.append("future ts")
except (OSError, OverflowError, ValueError):
ts = f"epoch={timestamp}"
warnings.append("bad ts")
app_str, name, app_warnings = _decode_advert_appdata(app_data)
warnings.extend(app_warnings)
if not warnings:
_register_node(pubkey, name)
return f"{prefix} {DIM}pub={RST}{pubkey[:4].hex()}.. {DIM}ts={RST}{ts}Z {app_str}", warnings
def _decode_ack(prefix: str, payload: bytes) -> str:
if len(payload) < 4:
return (
f"{prefix} {RED}<bad ack: {len(payload)}B>{RST} {DIM}{payload.hex()}{RST}"
)
crc = struct.unpack_from("<I", payload, 0)[0]
return f"{prefix} {DIM}crc={RST}0x{crc:08x}"
def _decode_encrypted_peer(prefix: str, type_name: str, payload: bytes) -> str:
if len(payload) < 20:
return f"{prefix} {RED}<bad {type_name}: {len(payload)}B>{RST} {DIM}{payload.hex()}{RST}"
dst = payload[0]
src = payload[1]
mac = payload[2:4]
ct_len = len(payload) - 4
return f"{prefix} {DIM}dst={RST}{dst:02x} {DIM}src={RST}{src:02x} {DIM}mac={RST}{mac.hex()} {DIM}[{ct_len}B]{RST}"
def _decode_encrypted_group(prefix: str, payload: bytes) -> str:
if len(payload) < 19:
return (
f"{prefix} {RED}<bad group: {len(payload)}B>{RST} {DIM}{payload.hex()}{RST}"
)
ch = payload[0]
mac_bytes = payload[1:3]
ciphertext = payload[3:]
# Try to decrypt with known channels matching this hash
candidates = _CHANNEL_BY_HASH.get(ch, [])
for chan_name, secret in candidates:
plaintext = _grp_verify_and_decrypt(secret, mac_bytes, ciphertext)
if plaintext is not None:
parsed = _parse_grp_plaintext(plaintext)
if parsed:
ts, text = parsed
try:
ts_str = datetime.fromtimestamp(ts, tz=timezone.utc).strftime(
"%H:%M:%S"
)
except (OSError, OverflowError, ValueError):
ts_str = f"epoch={ts}"
return (
f"{prefix} {BGRN}{chan_name}{RST} "
f"{DIM}ts={RST}{ts_str}Z {BWHT}{text}{RST}"
)
# MAC matched but plaintext unparseable
return (
f"{prefix} {BGRN}{chan_name}{RST} {DIM}[decrypted but unparseable]{RST}"
)
# No known channel matched — show raw info
return (
f"{prefix} {DIM}ch={RST}{ch:02x} {DIM}mac={RST}{mac_bytes.hex()} "
f"{DIM}[{len(ciphertext)}B]{RST}"
)
def _decode_anon_req(prefix: str, payload: bytes) -> str:
if len(payload) < 51:
return f"{prefix} {RED}<bad anon_req: {len(payload)}B>{RST} {DIM}{payload.hex()}{RST}"
dst = payload[0]
ephem_pub = payload[1:33]
mac = payload[33:35]
ct_len = len(payload) - 35
return f"{prefix} {DIM}dst={RST}{dst:02x} {DIM}ephem={RST}{ephem_pub[:4].hex()}.. {DIM}mac={RST}{mac.hex()} {DIM}[{ct_len}B]{RST}"
def _decode_trace(prefix: str, payload: bytes) -> str:
if len(payload) < 9:
return (
f"{prefix} {RED}<bad trace: {len(payload)}B>{RST} {DIM}{payload.hex()}{RST}"
)
tag = struct.unpack_from("<I", payload, 0)[0]
flags = payload[8]
trace_hash_size = 1 << (flags & 0x03)
trace_hashes = payload[9:]
n_hashes = len(trace_hashes) // trace_hash_size if trace_hash_size else 0
hops = []
for i in range(n_hashes):
h = trace_hashes[i * trace_hash_size : (i + 1) * trace_hash_size]
hops.append(h.hex())
hops_str = f" {DIM}trace=[{RST}{'>'.join(hops)}{DIM}]{RST}" if hops else ""
return f"{prefix} {DIM}tag={RST}0x{tag:08x}{hops_str}"
def _decode_multipart(prefix: str, payload: bytes) -> str:
if len(payload) < 2:
return f"{prefix} {RED}<bad multipart: {len(payload)}B>{RST}"
remaining = (payload[0] >> 4) & 0x0F
inner_type = payload[0] & 0x0F
inner_name = PAYLOAD_NAMES.get(inner_type, f"0x{inner_type:02x}")
return f"{prefix} {DIM}remaining={RST}{remaining} {DIM}inner={RST}{inner_name} {DIM}[{len(payload) - 1}B]{RST}"
def _decode_control(prefix: str, payload: bytes) -> str:
if len(payload) < 1:
return f"{prefix} {RED}<empty control>{RST}"
sub_type = (payload[0] >> 4) & 0x0F
sub_names = {0x8: "DISCOVER_REQ", 0x9: "DISCOVER_RESP"}
sub_name = sub_names.get(sub_type, f"sub=0x{sub_type:x}")
if sub_type == 0x8 and len(payload) >= 6:
type_filter = payload[1]
tag = struct.unpack_from("<I", payload, 2)[0]
return f"{prefix} {sub_name} {DIM}filter={RST}0x{type_filter:02x} {DIM}tag={RST}0x{tag:08x}"
elif sub_type == 0x9 and len(payload) >= 6:
node_type = payload[0] & 0x0F
snr_raw = struct.unpack_from("<b", payload, 1)[0]
tag = struct.unpack_from("<I", payload, 2)[0]
return f"{prefix} {sub_name} {DIM}node={RST}{NODE_TYPES.get(node_type, f'0x{node_type:02x}')} {DIM}snr={RST}{snr_raw / 4:.1f}dB {DIM}tag={RST}0x{tag:08x}"
return f"{prefix} {sub_name} {DIM}{payload[1:].hex()}{RST}"
# ── Main ───────────────────────────────────────────────────────────
def _format_response(resp: dict) -> str:
"""Format a response dict for themed display."""
t = resp["type"]
if t == "Pong":
return "Pong"
if t == "Ok":
return "Ok"
if t == "TxDone":
return "TxDone"
if t == "Config":
return f"Config {DIM}{resp.get('raw', '')}{RST}"
if t == "Error":
return f"{RED}Error (code {resp.get('code', '?')}){RST}"
return t
def send_cmd(ser, cmd_name: str, label: str = "", quiet: bool = False, **kwargs) -> dict | None:
frame = dl.cobs_encode(dl.encode_command(cmd_name, **kwargs))
if not quiet:
print(f" {DIM}>>>{RST} {label}")
ser.write(frame)
ser.flush()
resp_data = dl.read_frame(ser)
if resp_data is None:
if not quiet:
print(f" {DIM}<<<{RST} {YEL}timeout{RST}")
return None
resp = dl.decode_response(resp_data)
if not quiet:
print(f" {DIM}<<<{RST} {_format_response(resp)}")
return resp
RADIO_CONFIG = {
"freq_hz": 910_525_000,
"bw": 6, # Khz62 = variant index 6
"sf": 7,
"cr": 5, # CR 4/5 — denominator value
"sync_word": 0x3444,
"tx_power_dbm": -128, # TX_POWER_MAX: use board's maximum
"preamble_len": 0, # firmware default (16)
"cad": 1, # CAD on
}
def _format_rssi_snr(rssi: int, snr: int) -> str:
rc = _rssi_color(rssi)
sc = _snr_color(snr)
line = f"{rc}RSSI:{rssi:4d}dBm{RST} {sc}SNR:{snr:3d}dB{RST}"
# Flag bogus dongle responses (SNR outside SX126x range -32..+32)
if not (-32 <= snr <= 32):
line += f" {BRED}[bad SNR]{RST}"
return line
DISPLAY_IDLE_TIMEOUT = 90 # seconds — turn display off when no activity
def configure_and_listen(ser):
send_cmd(ser, "Ping", "Ping")
send_cmd(ser, "SetConfig", "SetConfig 910.525/62.5k/SF7/CR4/5", config=RADIO_CONFIG)
send_cmd(ser, "StartRx", "StartRx")
print(f"\n {BWHT}Listening{RST} {DIM}(Ctrl+C to stop){RST}\n")
ser.timeout = 1
last_activity = time.monotonic()
display_off = False
while True:
try:
_aggregator.maybe_report(ser)
except Exception:
pass
# Auto-off display after idle timeout
now = time.monotonic()
if not display_off and now - last_activity > DISPLAY_IDLE_TIMEOUT:
send_cmd(ser, "DisplayOff", quiet=True)
display_off = True
data = dl.read_frame(ser)
if data is None:
continue
try:
resp = dl.decode_response(data)
if resp["type"] == "RxPacket":
# Wake display on activity
if display_off:
send_cmd(ser, "DisplayOn", quiet=True)
display_off = False
last_activity = time.monotonic()
payload = resp["payload"]
decoded, warnings = decode_meshcore_packet(payload)
snr = resp["snr"]
# SNR below LoRa demodulation floor is one quality signal
sf = RADIO_CONFIG["sf"]
if -32 <= snr <= 32 and snr < -2.5 * (sf - 4):
warnings.append("low SNR")
warn_tag = ""
if warnings:
warn_tag = f" {BYEL}[{', '.join(warnings)}]{RST}"
rssi_snr = _format_rssi_snr(resp["rssi"], snr)
print(f" {rssi_snr} {DIM}len:{len(payload):3d}{RST} {decoded}{warn_tag}")
except Exception:
pass
def main():
_load_adverts()
n = len(_known_nodes)
if n:
print(f" {DIM}Loaded {n} known node(s){RST}")
port = sys.argv[1] if len(sys.argv) > 1 else None
while True:
try:
ser = dl.connect(port)
configure_and_listen(ser)
except (serial.SerialException, ConnectionError, OSError) as e:
print(f"\n {RED}Disconnected: {e}{RST}")
print(f" {DIM}Reconnecting when device reappears...{RST}")
time.sleep(1)
except KeyboardInterrupt:
print()
try:
ser.timeout = 2
send_cmd(ser, "StopRx", "StopRx")
except Exception:
pass
break
if __name__ == "__main__":
main()
File diff suppressed because it is too large. Load diff
-4
View File
@@ -1,4 +0,0 @@
[tools]
python = "3.14"
uv = "latest"
just = "latest"
-58
View File
@@ -1,58 +0,0 @@
#!/usr/bin/env python3
"""Two-dongle demo: one transmits, one receives.
Usage:
python examples/ping_pong.py --role tx [PORT]
python examples/ping_pong.py --role rx [PORT]
"""
import argparse
import sys
import time
import serial
import donglora as dl
parser = argparse.ArgumentParser(description="DongLoRa ping-pong demo")
parser.add_argument("--role", choices=["tx", "rx"], required=True)
parser.add_argument("port", nargs="?", help="Serial port (auto-detect if omitted)")
args = parser.parse_args()
try:
ser = dl.connect(args.port)
print(dl.send(ser, "SetConfig", config=dl.DEFAULT_CONFIG))
if args.role == "tx":
print("Transmitting every 2 seconds (Ctrl+C to stop)...\n")
seq = 0
while True:
msg = f"ping #{seq}"
resp = dl.send(ser, "Transmit", payload=msg.encode())
print(f" TX: {msg!r} → {resp['type']}")
seq += 1
time.sleep(2)
else:
print(dl.send(ser, "StartRx"))
print("Receiving (Ctrl+C to stop)...\n")
ser.timeout = 1
while True:
data = dl.read_frame(ser)
if data is None:
continue
resp = dl.decode_response(data)
if resp["type"] == "RxPacket":
p = resp["payload"]
text = p.decode("utf-8", errors="replace")
print(f" RX: {text!r} RSSI:{resp['rssi']}dBm SNR:{resp['snr']}dB")
except KeyboardInterrupt:
try:
dl.send(ser, "StopRx")
except Exception:
pass
print("\nDone.")
except serial.SerialException as e:
print(f"\nSerial error: {e}", file=sys.stderr)
sys.exit(1)
-19
View File
@@ -1,19 +0,0 @@
[project]
name = "donglora-examples"
version = "0.1.0"
description = "DongLoRa example scripts"
license = "MIT"
requires-python = ">=3.10"
dependencies = [
"donglora",
]
[project.optional-dependencies]
meshcore = ["donglora[meshcore]"]
orac = ["donglora[orac]"]
[tool.uv]
package = false
[tool.uv.sources]
donglora = { path = "../clients/python", editable = true }
-38
View File
@@ -1,38 +0,0 @@
#!/usr/bin/env python3
"""Receive LoRa packets and print them.
Usage:
python examples/simple_rx.py [PORT]
"""
import sys
import serial
import donglora as dl
try:
ser = dl.connect(sys.argv[1] if len(sys.argv) > 1 else None)
print(dl.send(ser, "SetConfig", config=dl.DEFAULT_CONFIG))
print(dl.send(ser, "StartRx"))
print("\nListening (Ctrl+C to stop)...\n")
ser.timeout = 1
while True:
data = dl.read_frame(ser)
if data is None:
continue
resp = dl.decode_response(data)
if resp["type"] == "RxPacket":
p = resp["payload"]
print(f" RSSI:{resp['rssi']:4d}dBm SNR:{resp['snr']:3d}dB len:{len(p):3d} {p.hex()}")
except KeyboardInterrupt:
try:
dl.send(ser, "StopRx")
except Exception:
pass
print("\nDone.")
except serial.SerialException as e:
print(f"\nSerial error: {e}", file=sys.stderr)
sys.exit(1)
-32
View File
@@ -1,32 +0,0 @@
#!/usr/bin/env python3
"""Transmit a single LoRa packet.
Usage:
python examples/simple_tx.py [PORT] [MESSAGE]
"""
import sys
import serial
import donglora as dl
port = None
message = "Hello from DongLoRa!"
args = sys.argv[1:]
if args and not args[0].startswith("/dev"):
message = " ".join(args)
elif args:
port = args[0]
if len(args) > 1:
message = " ".join(args[1:])
try:
ser = dl.connect(port)
print(dl.send(ser, "SetConfig", config=dl.DEFAULT_CONFIG))
print(dl.send(ser, "Transmit", payload=message.encode()))
print(f"Sent: {message!r}")
except serial.SerialException as e:
print(f"\nSerial error: {e}", file=sys.stderr)
sys.exit(1)
-630
View File
@@ -1,630 +0,0 @@
version = 1
revision = 3
requires-python = ">=3.10"
[[package]]
name = "annotated-types"
version = "0.7.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" },
]
[[package]]
name = "anthropic"
version = "0.88.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "distro" },
{ name = "docstring-parser" },
{ name = "httpx" },
{ name = "jiter" },
{ name = "pydantic" },
{ name = "sniffio" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/86/68/565f13059c0a6a6fd5f96f306f2a0fb478a0e1174ec18a4df16b5fac9379/anthropic-0.88.0.tar.gz", hash = "sha256:f4c7f6863d08c869913516f08d658fe53caaf8bcc4fbea3218df343d2a876c58", size = 596654, upload-time = "2026-04-01T19:59:05.287Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ad/ac/68f646998160c9f2e6f9353a31dd87292ef02b915b455aaf70a52a059a75/anthropic-0.88.0-py3-none-any.whl", hash = "sha256:71898b32332bc75d9739bc10095288d40a29605da6d00da2fe832b1aa036552f", size = 478338, upload-time = "2026-04-01T19:59:03.832Z" },
]
[[package]]
name = "anyio"
version = "4.13.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "exceptiongroup", marker = "python_full_version < '3.11'" },
{ name = "idna" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" },
]
[[package]]
name = "certifi"
version = "2026.2.25"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029, upload-time = "2026-02-25T02:54:17.342Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" },
]
[[package]]
name = "cffi"
version = "2.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pycparser", marker = "implementation_name != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/93/d7/516d984057745a6cd96575eea814fe1edd6646ee6efd552fb7b0921dec83/cffi-2.0.0-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44", size = 184283, upload-time = "2025-09-08T23:22:08.01Z" },
{ url = "https://files.pythonhosted.org/packages/9e/84/ad6a0b408daa859246f57c03efd28e5dd1b33c21737c2db84cae8c237aa5/cffi-2.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49", size = 180504, upload-time = "2025-09-08T23:22:10.637Z" },
{ url = "https://files.pythonhosted.org/packages/50/bd/b1a6362b80628111e6653c961f987faa55262b4002fcec42308cad1db680/cffi-2.0.0-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:53f77cbe57044e88bbd5ed26ac1d0514d2acf0591dd6bb02a3ae37f76811b80c", size = 208811, upload-time = "2025-09-08T23:22:12.267Z" },
{ url = "https://files.pythonhosted.org/packages/4f/27/6933a8b2562d7bd1fb595074cf99cc81fc3789f6a6c05cdabb46284a3188/cffi-2.0.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3e837e369566884707ddaf85fc1744b47575005c0a229de3327f8f9a20f4efeb", size = 216402, upload-time = "2025-09-08T23:22:13.455Z" },
{ url = "https://files.pythonhosted.org/packages/05/eb/b86f2a2645b62adcfff53b0dd97e8dfafb5c8aa864bd0d9a2c2049a0d551/cffi-2.0.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5eda85d6d1879e692d546a078b44251cdd08dd1cfb98dfb77b670c97cee49ea0", size = 203217, upload-time = "2025-09-08T23:22:14.596Z" },
{ url = "https://files.pythonhosted.org/packages/9f/e0/6cbe77a53acf5acc7c08cc186c9928864bd7c005f9efd0d126884858a5fe/cffi-2.0.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9332088d75dc3241c702d852d4671613136d90fa6881da7d770a483fd05248b4", size = 203079, upload-time = "2025-09-08T23:22:15.769Z" },
{ url = "https://files.pythonhosted.org/packages/98/29/9b366e70e243eb3d14a5cb488dfd3a0b6b2f1fb001a203f653b93ccfac88/cffi-2.0.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc7de24befaeae77ba923797c7c87834c73648a05a4bde34b3b7e5588973a453", size = 216475, upload-time = "2025-09-08T23:22:17.427Z" },
{ url = "https://files.pythonhosted.org/packages/21/7a/13b24e70d2f90a322f2900c5d8e1f14fa7e2a6b3332b7309ba7b2ba51a5a/cffi-2.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cf364028c016c03078a23b503f02058f1814320a56ad535686f90565636a9495", size = 218829, upload-time = "2025-09-08T23:22:19.069Z" },
{ url = "https://files.pythonhosted.org/packages/60/99/c9dc110974c59cc981b1f5b66e1d8af8af764e00f0293266824d9c4254bc/cffi-2.0.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e11e82b744887154b182fd3e7e8512418446501191994dbf9c9fc1f32cc8efd5", size = 211211, upload-time = "2025-09-08T23:22:20.588Z" },
{ url = "https://files.pythonhosted.org/packages/49/72/ff2d12dbf21aca1b32a40ed792ee6b40f6dc3a9cf1644bd7ef6e95e0ac5e/cffi-2.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8ea985900c5c95ce9db1745f7933eeef5d314f0565b27625d9a10ec9881e1bfb", size = 218036, upload-time = "2025-09-08T23:22:22.143Z" },
{ url = "https://files.pythonhosted.org/packages/e2/cc/027d7fb82e58c48ea717149b03bcadcbdc293553edb283af792bd4bcbb3f/cffi-2.0.0-cp310-cp310-win32.whl", hash = "sha256:1f72fb8906754ac8a2cc3f9f5aaa298070652a0ffae577e0ea9bd480dc3c931a", size = 172184, upload-time = "2025-09-08T23:22:23.328Z" },
{ url = "https://files.pythonhosted.org/packages/33/fa/072dd15ae27fbb4e06b437eb6e944e75b068deb09e2a2826039e49ee2045/cffi-2.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:b18a3ed7d5b3bd8d9ef7a8cb226502c6bf8308df1525e1cc676c3680e7176739", size = 182790, upload-time = "2025-09-08T23:22:24.752Z" },
{ url = "https://files.pythonhosted.org/packages/12/4a/3dfd5f7850cbf0d06dc84ba9aa00db766b52ca38d8b86e3a38314d52498c/cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe", size = 184344, upload-time = "2025-09-08T23:22:26.456Z" },
{ url = "https://files.pythonhosted.org/packages/4f/8b/f0e4c441227ba756aafbe78f117485b25bb26b1c059d01f137fa6d14896b/cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c", size = 180560, upload-time = "2025-09-08T23:22:28.197Z" },
{ url = "https://files.pythonhosted.org/packages/b1/b7/1200d354378ef52ec227395d95c2576330fd22a869f7a70e88e1447eb234/cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92", size = 209613, upload-time = "2025-09-08T23:22:29.475Z" },
{ url = "https://files.pythonhosted.org/packages/b8/56/6033f5e86e8cc9bb629f0077ba71679508bdf54a9a5e112a3c0b91870332/cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93", size = 216476, upload-time = "2025-09-08T23:22:31.063Z" },
{ url = "https://files.pythonhosted.org/packages/dc/7f/55fecd70f7ece178db2f26128ec41430d8720f2d12ca97bf8f0a628207d5/cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5", size = 203374, upload-time = "2025-09-08T23:22:32.507Z" },
{ url = "https://files.pythonhosted.org/packages/84/ef/a7b77c8bdc0f77adc3b46888f1ad54be8f3b7821697a7b89126e829e676a/cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664", size = 202597, upload-time = "2025-09-08T23:22:34.132Z" },
{ url = "https://files.pythonhosted.org/packages/d7/91/500d892b2bf36529a75b77958edfcd5ad8e2ce4064ce2ecfeab2125d72d1/cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26", size = 215574, upload-time = "2025-09-08T23:22:35.443Z" },
{ url = "https://files.pythonhosted.org/packages/44/64/58f6255b62b101093d5df22dcb752596066c7e89dd725e0afaed242a61be/cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9", size = 218971, upload-time = "2025-09-08T23:22:36.805Z" },
{ url = "https://files.pythonhosted.org/packages/ab/49/fa72cebe2fd8a55fbe14956f9970fe8eb1ac59e5df042f603ef7c8ba0adc/cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414", size = 211972, upload-time = "2025-09-08T23:22:38.436Z" },
{ url = "https://files.pythonhosted.org/packages/0b/28/dd0967a76aab36731b6ebfe64dec4e981aff7e0608f60c2d46b46982607d/cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743", size = 217078, upload-time = "2025-09-08T23:22:39.776Z" },
{ url = "https://files.pythonhosted.org/packages/2b/c0/015b25184413d7ab0a410775fdb4a50fca20f5589b5dab1dbbfa3baad8ce/cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5", size = 172076, upload-time = "2025-09-08T23:22:40.95Z" },
{ url = "https://files.pythonhosted.org/packages/ae/8f/dc5531155e7070361eb1b7e4c1a9d896d0cb21c49f807a6c03fd63fc877e/cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5", size = 182820, upload-time = "2025-09-08T23:22:42.463Z" },
{ url = "https://files.pythonhosted.org/packages/95/5c/1b493356429f9aecfd56bc171285a4c4ac8697f76e9bbbbb105e537853a1/cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d", size = 177635, upload-time = "2025-09-08T23:22:43.623Z" },
{ url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" },
{ url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" },
{ url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" },
{ url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" },
{ url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" },
{ url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" },
{ url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" },
{ url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" },
{ url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" },
{ url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" },
{ url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" },
{ url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" },
{ url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" },
{ url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" },
{ url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" },
{ url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" },
{ url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" },
{ url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" },
{ url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" },
{ url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" },
{ url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" },
{ url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" },
{ url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" },
{ url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" },
{ url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" },
{ url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" },
{ url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" },
{ url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" },
{ url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" },
{ url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" },
{ url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" },
{ url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" },
{ url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" },
{ url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" },
{ url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" },
{ url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" },
{ url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" },
{ url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" },
{ url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" },
{ url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" },
{ url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" },
{ url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" },
{ url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" },
{ url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" },
{ url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" },
{ url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" },
]
[[package]]
name = "cobs"
version = "1.2.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/34/ef/ea149311227a4fc3160cc885fce06da7c7d76782a308ef070b8065c69953/cobs-1.2.2.tar.gz", hash = "sha256:dbdd5e32111d72786f83d0c269215dcd6ac629b1ac1962c6878221f3b2ca98da", size = 14582, upload-time = "2025-07-20T01:08:35.434Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e5/e0/9574590639a624a9a84c89a569f36343523a306fff2ffddc9468a2b9b05d/cobs-1.2.2-cp312-cp312-win32.whl", hash = "sha256:3afe0cec5d91164f84dcf35b471949aa36285664d21ee984350e39d485707d11", size = 26400, upload-time = "2025-07-20T01:08:30.288Z" },
{ url = "https://files.pythonhosted.org/packages/d3/4f/5418d9decdc4cf27a814fc189bc4fa44d216d37b871d633654f37ed331f8/cobs-1.2.2-cp312-cp312-win_amd64.whl", hash = "sha256:4da6048f6e16cbd6c8ec5e95e8520e226248ffb8d31e9d6fc5e0888b3b65c54b", size = 27776, upload-time = "2025-07-20T01:08:31.377Z" },
{ url = "https://files.pythonhosted.org/packages/b4/df/7f07559954d53d09680aa4f5c72db999e7643253e2ca4e431023fe5c1c40/cobs-1.2.2-cp313-cp313-win32.whl", hash = "sha256:a8fdf6ca70247e7271caea6f2f6f9c0343f5e872e2fd47e21f71831fa9ababa8", size = 26401, upload-time = "2025-07-20T01:08:32.508Z" },
{ url = "https://files.pythonhosted.org/packages/0c/00/5b063105ff567bb7fb72c3cd3e96116a3889381fa2c0e7747497391dbecc/cobs-1.2.2-cp313-cp313-win_amd64.whl", hash = "sha256:fe3f7194d5fbbc02e8fb03d26ad626a33fa0d420b50d66ed60ac4bf516322d8e", size = 27774, upload-time = "2025-07-20T01:08:33.692Z" },
{ url = "https://files.pythonhosted.org/packages/78/35/de1e889c26fcfc9ae6852b8f99e744e6690a3baae99f63e2b9fffdd21710/cobs-1.2.2-cp314-cp314-win32.whl", hash = "sha256:bcf08cb0b68c1824a87cfa89e0684d5a3eaee9b9e08b02ce4a54d5589536f01c", size = 26779, upload-time = "2026-03-09T02:18:48.711Z" },
{ 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 = "distro"
version = "1.9.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/fc/f8/98eea607f65de6527f8a2e8885fc8015d3e6f5775df186e443e0964a11c3/distro-1.9.0.tar.gz", hash = "sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed", size = 60722, upload-time = "2023-12-24T09:54:32.31Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277, upload-time = "2023-12-24T09:54:30.421Z" },
]
[[package]]
name = "docstring-parser"
version = "0.17.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b2/9d/c3b43da9515bd270df0f80548d9944e389870713cc1fe2b8fb35fe2bcefd/docstring_parser-0.17.0.tar.gz", hash = "sha256:583de4a309722b3315439bb31d64ba3eebada841f2e2cee23b99df001434c912", size = 27442, upload-time = "2025-07-21T07:35:01.868Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/55/e2/2537ebcff11c1ee1ff17d8d0b6f4db75873e3b0fb32c2d4a2ee31ecb310a/docstring_parser-0.17.0-py3-none-any.whl", hash = "sha256:cf2569abd23dce8099b300f9b4fa8191e9582dda731fd533daf54c4551658708", size = 36896, upload-time = "2025-07-21T07:35:00.684Z" },
]
[[package]]
name = "donglora"
version = "0.1.0"
source = { editable = "../clients/python" }
dependencies = [
{ name = "cobs" },
{ name = "pyserial" },
]
[package.optional-dependencies]
meshcore = [
{ name = "pycryptodome" },
{ name = "pynacl" },
]
orac = [
{ name = "anthropic" },
{ name = "pycryptodome" },
{ name = "pynacl" },
]
[package.metadata]
requires-dist = [
{ name = "anthropic", marker = "extra == 'orac'" },
{ name = "cobs" },
{ name = "pycryptodome", marker = "extra == 'meshcore'" },
{ name = "pycryptodome", marker = "extra == 'orac'" },
{ name = "pynacl", marker = "extra == 'meshcore'" },
{ name = "pynacl", marker = "extra == 'orac'" },
{ name = "pyserial" },
]
provides-extras = ["meshcore", "orac"]
[[package]]
name = "donglora-examples"
version = "0.1.0"
source = { virtual = "." }
dependencies = [
{ name = "donglora" },
]
[package.optional-dependencies]
meshcore = [
{ name = "donglora", extra = ["meshcore"] },
]
orac = [
{ name = "donglora", extra = ["orac"] },
]
[package.metadata]
requires-dist = [
{ name = "donglora", editable = "../clients/python" },
{ name = "donglora", extras = ["meshcore"], marker = "extra == 'meshcore'", editable = "../clients/python" },
{ name = "donglora", extras = ["orac"], marker = "extra == 'orac'", editable = "../clients/python" },
]
provides-extras = ["meshcore", "orac"]
[[package]]
name = "exceptiongroup"
version = "1.3.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" },
]
[[package]]
name = "h11"
version = "0.16.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
]
[[package]]
name = "httpcore"
version = "1.0.9"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
{ name = "h11" },
]
sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
]
[[package]]
name = "httpx"
version = "0.28.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "certifi" },
{ name = "httpcore" },
{ name = "idna" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
]
[[package]]
name = "idna"
version = "3.11"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" }
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" },
]
[[package]]
name = "jiter"
version = "0.13.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/0d/5e/4ec91646aee381d01cdb9974e30882c9cd3b8c5d1079d6b5ff4af522439a/jiter-0.13.0.tar.gz", hash = "sha256:f2839f9c2c7e2dffc1bc5929a510e14ce0a946be9365fd1219e7ef342dae14f4", size = 164847, upload-time = "2026-02-02T12:37:56.441Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d0/5a/41da76c5ea07bec1b0472b6b2fdb1b651074d504b19374d7e130e0cdfb25/jiter-0.13.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:2ffc63785fd6c7977defe49b9824ae6ce2b2e2b77ce539bdaf006c26da06342e", size = 311164, upload-time = "2026-02-02T12:35:17.688Z" },
{ url = "https://files.pythonhosted.org/packages/40/cb/4a1bf994a3e869f0d39d10e11efb471b76d0ad70ecbfb591427a46c880c2/jiter-0.13.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4a638816427006c1e3f0013eb66d391d7a3acda99a7b0cf091eff4497ccea33a", size = 320296, upload-time = "2026-02-02T12:35:19.828Z" },
{ url = "https://files.pythonhosted.org/packages/09/82/acd71ca9b50ecebadc3979c541cd717cce2fe2bc86236f4fa597565d8f1a/jiter-0.13.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:19928b5d1ce0ff8c1ee1b9bdef3b5bfc19e8304f1b904e436caf30bc15dc6cf5", size = 352742, upload-time = "2026-02-02T12:35:21.258Z" },
{ url = "https://files.pythonhosted.org/packages/71/03/d1fc996f3aecfd42eb70922edecfb6dd26421c874503e241153ad41df94f/jiter-0.13.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:309549b778b949d731a2f0e1594a3f805716be704a73bf3ad9a807eed5eb5721", size = 363145, upload-time = "2026-02-02T12:35:24.653Z" },
{ url = "https://files.pythonhosted.org/packages/f1/61/a30492366378cc7a93088858f8991acd7d959759fe6138c12a4644e58e81/jiter-0.13.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bcdabaea26cb04e25df3103ce47f97466627999260290349a88c8136ecae0060", size = 487683, upload-time = "2026-02-02T12:35:26.162Z" },
{ url = "https://files.pythonhosted.org/packages/20/4e/4223cffa9dbbbc96ed821c5aeb6bca510848c72c02086d1ed3f1da3d58a7/jiter-0.13.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a3a377af27b236abbf665a69b2bdd680e3b5a0bd2af825cd3b81245279a7606c", size = 373579, upload-time = "2026-02-02T12:35:27.582Z" },
{ url = "https://files.pythonhosted.org/packages/fe/c9/b0489a01329ab07a83812d9ebcffe7820a38163c6d9e7da644f926ff877c/jiter-0.13.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fe49d3ff6db74321f144dff9addd4a5874d3105ac5ba7c5b77fac099cfae31ae", size = 362904, upload-time = "2026-02-02T12:35:28.925Z" },
{ url = "https://files.pythonhosted.org/packages/05/af/53e561352a44afcba9a9bc67ee1d320b05a370aed8df54eafe714c4e454d/jiter-0.13.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2113c17c9a67071b0f820733c0893ed1d467b5fcf4414068169e5c2cabddb1e2", size = 392380, upload-time = "2026-02-02T12:35:30.385Z" },
{ url = "https://files.pythonhosted.org/packages/76/2a/dd805c3afb8ed5b326c5ae49e725d1b1255b9754b1b77dbecdc621b20773/jiter-0.13.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:ab1185ca5c8b9491b55ebf6c1e8866b8f68258612899693e24a92c5fdb9455d5", size = 517939, upload-time = "2026-02-02T12:35:31.865Z" },
{ url = "https://files.pythonhosted.org/packages/20/2a/7b67d76f55b8fe14c937e7640389612f05f9a4145fc28ae128aaa5e62257/jiter-0.13.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:9621ca242547edc16400981ca3231e0c91c0c4c1ab8573a596cd9bb3575d5c2b", size = 551696, upload-time = "2026-02-02T12:35:33.306Z" },
{ url = "https://files.pythonhosted.org/packages/85/9c/57cdd64dac8f4c6ab8f994fe0eb04dc9fd1db102856a4458fcf8a99dfa62/jiter-0.13.0-cp310-cp310-win32.whl", hash = "sha256:a7637d92b1c9d7a771e8c56f445c7f84396d48f2e756e5978840ecba2fac0894", size = 204592, upload-time = "2026-02-02T12:35:34.58Z" },
{ url = "https://files.pythonhosted.org/packages/a7/38/f4f3ea5788b8a5bae7510a678cdc747eda0c45ffe534f9878ff37e7cf3b3/jiter-0.13.0-cp310-cp310-win_amd64.whl", hash = "sha256:c1b609e5cbd2f52bb74fb721515745b407df26d7b800458bd97cb3b972c29e7d", size = 206016, upload-time = "2026-02-02T12:35:36.435Z" },
{ url = "https://files.pythonhosted.org/packages/71/29/499f8c9eaa8a16751b1c0e45e6f5f1761d180da873d417996cc7bddc8eef/jiter-0.13.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:ea026e70a9a28ebbdddcbcf0f1323128a8db66898a06eaad3a4e62d2f554d096", size = 311157, upload-time = "2026-02-02T12:35:37.758Z" },
{ url = "https://files.pythonhosted.org/packages/50/f6/566364c777d2ab450b92100bea11333c64c38d32caf8dc378b48e5b20c46/jiter-0.13.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:66aa3e663840152d18cc8ff1e4faad3dd181373491b9cfdc6004b92198d67911", size = 319729, upload-time = "2026-02-02T12:35:39.246Z" },
{ url = "https://files.pythonhosted.org/packages/73/dd/560f13ec5e4f116d8ad2658781646cca91b617ae3b8758d4a5076b278f70/jiter-0.13.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c3524798e70655ff19aec58c7d05adb1f074fecff62da857ea9be2b908b6d701", size = 354766, upload-time = "2026-02-02T12:35:40.662Z" },
{ url = "https://files.pythonhosted.org/packages/7c/0d/061faffcfe94608cbc28a0d42a77a74222bdf5055ccdbe5fd2292b94f510/jiter-0.13.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ec7e287d7fbd02cb6e22f9a00dd9c9cd504c40a61f2c61e7e1f9690a82726b4c", size = 362587, upload-time = "2026-02-02T12:35:42.025Z" },
{ url = "https://files.pythonhosted.org/packages/92/c9/c66a7864982fd38a9773ec6e932e0398d1262677b8c60faecd02ffb67bf3/jiter-0.13.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:47455245307e4debf2ce6c6e65a717550a0244231240dcf3b8f7d64e4c2f22f4", size = 487537, upload-time = "2026-02-02T12:35:43.459Z" },
{ url = "https://files.pythonhosted.org/packages/6c/86/84eb4352cd3668f16d1a88929b5888a3fe0418ea8c1dfc2ad4e7bf6e069a/jiter-0.13.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ee9da221dca6e0429c2704c1b3655fe7b025204a71d4d9b73390c759d776d165", size = 373717, upload-time = "2026-02-02T12:35:44.928Z" },
{ url = "https://files.pythonhosted.org/packages/6e/09/9fe4c159358176f82d4390407a03f506a8659ed13ca3ac93a843402acecf/jiter-0.13.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:24ab43126d5e05f3d53a36a8e11eb2f23304c6c1117844aaaf9a0aa5e40b5018", size = 362683, upload-time = "2026-02-02T12:35:46.636Z" },
{ url = "https://files.pythonhosted.org/packages/c9/5e/85f3ab9caca0c1d0897937d378b4a515cae9e119730563572361ea0c48ae/jiter-0.13.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9da38b4fedde4fb528c740c2564628fbab737166a0e73d6d46cb4bb5463ff411", size = 392345, upload-time = "2026-02-02T12:35:48.088Z" },
{ url = "https://files.pythonhosted.org/packages/12/4c/05b8629ad546191939e6f0c2f17e29f542a398f4a52fb987bc70b6d1eb8b/jiter-0.13.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:0b34c519e17658ed88d5047999a93547f8889f3c1824120c26ad6be5f27b6cf5", size = 517775, upload-time = "2026-02-02T12:35:49.482Z" },
{ url = "https://files.pythonhosted.org/packages/4d/88/367ea2eb6bc582c7052e4baf5ddf57ebe5ab924a88e0e09830dfb585c02d/jiter-0.13.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:d2a6394e6af690d462310a86b53c47ad75ac8c21dc79f120714ea449979cb1d3", size = 551325, upload-time = "2026-02-02T12:35:51.104Z" },
{ url = "https://files.pythonhosted.org/packages/f3/12/fa377ffb94a2f28c41afaed093e0d70cfe512035d5ecb0cad0ae4792d35e/jiter-0.13.0-cp311-cp311-win32.whl", hash = "sha256:0f0c065695f616a27c920a56ad0d4fc46415ef8b806bf8fc1cacf25002bd24e1", size = 204709, upload-time = "2026-02-02T12:35:52.467Z" },
{ url = "https://files.pythonhosted.org/packages/cb/16/8e8203ce92f844dfcd3d9d6a5a7322c77077248dbb12da52d23193a839cd/jiter-0.13.0-cp311-cp311-win_amd64.whl", hash = "sha256:0733312953b909688ae3c2d58d043aa040f9f1a6a75693defed7bc2cc4bf2654", size = 204560, upload-time = "2026-02-02T12:35:53.925Z" },
{ url = "https://files.pythonhosted.org/packages/44/26/97cc40663deb17b9e13c3a5cf29251788c271b18ee4d262c8f94798b8336/jiter-0.13.0-cp311-cp311-win_arm64.whl", hash = "sha256:5d9b34ad56761b3bf0fbe8f7e55468704107608512350962d3317ffd7a4382d5", size = 189608, upload-time = "2026-02-02T12:35:55.304Z" },
{ url = "https://files.pythonhosted.org/packages/2e/30/7687e4f87086829955013ca12a9233523349767f69653ebc27036313def9/jiter-0.13.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:0a2bd69fc1d902e89925fc34d1da51b2128019423d7b339a45d9e99c894e0663", size = 307958, upload-time = "2026-02-02T12:35:57.165Z" },
{ url = "https://files.pythonhosted.org/packages/c3/27/e57f9a783246ed95481e6749cc5002a8a767a73177a83c63ea71f0528b90/jiter-0.13.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f917a04240ef31898182f76a332f508f2cc4b57d2b4d7ad2dbfebbfe167eb505", size = 318597, upload-time = "2026-02-02T12:35:58.591Z" },
{ url = "https://files.pythonhosted.org/packages/cf/52/e5719a60ac5d4d7c5995461a94ad5ef962a37c8bf5b088390e6fad59b2ff/jiter-0.13.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c1e2b199f446d3e82246b4fd9236d7cb502dc2222b18698ba0d986d2fecc6152", size = 348821, upload-time = "2026-02-02T12:36:00.093Z" },
{ url = "https://files.pythonhosted.org/packages/61/db/c1efc32b8ba4c740ab3fc2d037d8753f67685f475e26b9d6536a4322bcdd/jiter-0.13.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:04670992b576fa65bd056dbac0c39fe8bd67681c380cb2b48efa885711d9d726", size = 364163, upload-time = "2026-02-02T12:36:01.937Z" },
{ url = "https://files.pythonhosted.org/packages/55/8a/fb75556236047c8806995671a18e4a0ad646ed255276f51a20f32dceaeec/jiter-0.13.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5a1aff1fbdb803a376d4d22a8f63f8e7ccbce0b4890c26cc7af9e501ab339ef0", size = 483709, upload-time = "2026-02-02T12:36:03.41Z" },
{ url = "https://files.pythonhosted.org/packages/7e/16/43512e6ee863875693a8e6f6d532e19d650779d6ba9a81593ae40a9088ff/jiter-0.13.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3b3fb8c2053acaef8580809ac1d1f7481a0a0bdc012fd7f5d8b18fb696a5a089", size = 370480, upload-time = "2026-02-02T12:36:04.791Z" },
{ url = "https://files.pythonhosted.org/packages/f8/4c/09b93e30e984a187bc8aaa3510e1ec8dcbdcd71ca05d2f56aac0492453aa/jiter-0.13.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bdaba7d87e66f26a2c45d8cbadcbfc4bf7884182317907baf39cfe9775bb4d93", size = 360735, upload-time = "2026-02-02T12:36:06.994Z" },
{ url = "https://files.pythonhosted.org/packages/1a/1b/46c5e349019874ec5dfa508c14c37e29864ea108d376ae26d90bee238cd7/jiter-0.13.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7b88d649135aca526da172e48083da915ec086b54e8e73a425ba50999468cc08", size = 391814, upload-time = "2026-02-02T12:36:08.368Z" },
{ url = "https://files.pythonhosted.org/packages/15/9e/26184760e85baee7162ad37b7912797d2077718476bf91517641c92b3639/jiter-0.13.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:e404ea551d35438013c64b4f357b0474c7abf9f781c06d44fcaf7a14c69ff9e2", size = 513990, upload-time = "2026-02-02T12:36:09.993Z" },
{ url = "https://files.pythonhosted.org/packages/e9/34/2c9355247d6debad57a0a15e76ab1566ab799388042743656e566b3b7de1/jiter-0.13.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:1f4748aad1b4a93c8bdd70f604d0f748cdc0e8744c5547798acfa52f10e79228", size = 548021, upload-time = "2026-02-02T12:36:11.376Z" },
{ url = "https://files.pythonhosted.org/packages/ac/4a/9f2c23255d04a834398b9c2e0e665382116911dc4d06b795710503cdad25/jiter-0.13.0-cp312-cp312-win32.whl", hash = "sha256:0bf670e3b1445fc4d31612199f1744f67f889ee1bbae703c4b54dc097e5dd394", size = 203024, upload-time = "2026-02-02T12:36:12.682Z" },
{ url = "https://files.pythonhosted.org/packages/09/ee/f0ae675a957ae5a8f160be3e87acea6b11dc7b89f6b7ab057e77b2d2b13a/jiter-0.13.0-cp312-cp312-win_amd64.whl", hash = "sha256:15db60e121e11fe186c0b15236bd5d18381b9ddacdcf4e659feb96fc6c969c92", size = 205424, upload-time = "2026-02-02T12:36:13.93Z" },
{ url = "https://files.pythonhosted.org/packages/1b/02/ae611edf913d3cbf02c97cdb90374af2082c48d7190d74c1111dde08bcdd/jiter-0.13.0-cp312-cp312-win_arm64.whl", hash = "sha256:41f92313d17989102f3cb5dd533a02787cdb99454d494344b0361355da52fcb9", size = 186818, upload-time = "2026-02-02T12:36:15.308Z" },
{ url = "https://files.pythonhosted.org/packages/91/9c/7ee5a6ff4b9991e1a45263bfc46731634c4a2bde27dfda6c8251df2d958c/jiter-0.13.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1f8a55b848cbabf97d861495cd65f1e5c590246fabca8b48e1747c4dfc8f85bf", size = 306897, upload-time = "2026-02-02T12:36:16.748Z" },
{ url = "https://files.pythonhosted.org/packages/7c/02/be5b870d1d2be5dd6a91bdfb90f248fbb7dcbd21338f092c6b89817c3dbf/jiter-0.13.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f556aa591c00f2c45eb1b89f68f52441a016034d18b65da60e2d2875bbbf344a", size = 317507, upload-time = "2026-02-02T12:36:18.351Z" },
{ url = "https://files.pythonhosted.org/packages/da/92/b25d2ec333615f5f284f3a4024f7ce68cfa0604c322c6808b2344c7f5d2b/jiter-0.13.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f7e1d61da332ec412350463891923f960c3073cf1aae93b538f0bb4c8cd46efb", size = 350560, upload-time = "2026-02-02T12:36:19.746Z" },
{ url = "https://files.pythonhosted.org/packages/be/ec/74dcb99fef0aca9fbe56b303bf79f6bd839010cb18ad41000bf6cc71eec0/jiter-0.13.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3097d665a27bc96fd9bbf7f86178037db139f319f785e4757ce7ccbf390db6c2", size = 363232, upload-time = "2026-02-02T12:36:21.243Z" },
{ url = "https://files.pythonhosted.org/packages/1b/37/f17375e0bb2f6a812d4dd92d7616e41917f740f3e71343627da9db2824ce/jiter-0.13.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d01ecc3a8cbdb6f25a37bd500510550b64ddf9f7d64a107d92f3ccb25035d0f", size = 483727, upload-time = "2026-02-02T12:36:22.688Z" },
{ url = "https://files.pythonhosted.org/packages/77/d2/a71160a5ae1a1e66c1395b37ef77da67513b0adba73b993a27fbe47eb048/jiter-0.13.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ed9bbc30f5d60a3bdf63ae76beb3f9db280d7f195dfcfa61af792d6ce912d159", size = 370799, upload-time = "2026-02-02T12:36:24.106Z" },
{ url = "https://files.pythonhosted.org/packages/01/99/ed5e478ff0eb4e8aa5fd998f9d69603c9fd3f32de3bd16c2b1194f68361c/jiter-0.13.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:98fbafb6e88256f4454de33c1f40203d09fc33ed19162a68b3b257b29ca7f663", size = 359120, upload-time = "2026-02-02T12:36:25.519Z" },
{ url = "https://files.pythonhosted.org/packages/16/be/7ffd08203277a813f732ba897352797fa9493faf8dc7995b31f3d9cb9488/jiter-0.13.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5467696f6b827f1116556cb0db620440380434591e93ecee7fd14d1a491b6daa", size = 390664, upload-time = "2026-02-02T12:36:26.866Z" },
{ url = "https://files.pythonhosted.org/packages/d1/84/e0787856196d6d346264d6dcccb01f741e5f0bd014c1d9a2ebe149caf4f3/jiter-0.13.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:2d08c9475d48b92892583df9da592a0e2ac49bcd41fae1fec4f39ba6cf107820", size = 513543, upload-time = "2026-02-02T12:36:28.217Z" },
{ url = "https://files.pythonhosted.org/packages/65/50/ecbd258181c4313cf79bca6c88fb63207d04d5bf5e4f65174114d072aa55/jiter-0.13.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:aed40e099404721d7fcaf5b89bd3b4568a4666358bcac7b6b15c09fb6252ab68", size = 547262, upload-time = "2026-02-02T12:36:29.678Z" },
{ url = "https://files.pythonhosted.org/packages/27/da/68f38d12e7111d2016cd198161b36e1f042bd115c169255bcb7ec823a3bf/jiter-0.13.0-cp313-cp313-win32.whl", hash = "sha256:36ebfbcffafb146d0e6ffb3e74d51e03d9c35ce7c625c8066cdbfc7b953bdc72", size = 200630, upload-time = "2026-02-02T12:36:31.808Z" },
{ url = "https://files.pythonhosted.org/packages/25/65/3bd1a972c9a08ecd22eb3b08a95d1941ebe6938aea620c246cf426ae09c2/jiter-0.13.0-cp313-cp313-win_amd64.whl", hash = "sha256:8d76029f077379374cf0dbc78dbe45b38dec4a2eb78b08b5194ce836b2517afc", size = 202602, upload-time = "2026-02-02T12:36:33.679Z" },
{ url = "https://files.pythonhosted.org/packages/15/fe/13bd3678a311aa67686bb303654792c48206a112068f8b0b21426eb6851e/jiter-0.13.0-cp313-cp313-win_arm64.whl", hash = "sha256:bb7613e1a427cfcb6ea4544f9ac566b93d5bf67e0d48c787eca673ff9c9dff2b", size = 185939, upload-time = "2026-02-02T12:36:35.065Z" },
{ url = "https://files.pythonhosted.org/packages/49/19/a929ec002ad3228bc97ca01dbb14f7632fffdc84a95ec92ceaf4145688ae/jiter-0.13.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:fa476ab5dd49f3bf3a168e05f89358c75a17608dbabb080ef65f96b27c19ab10", size = 316616, upload-time = "2026-02-02T12:36:36.579Z" },
{ url = "https://files.pythonhosted.org/packages/52/56/d19a9a194afa37c1728831e5fb81b7722c3de18a3109e8f282bfc23e587a/jiter-0.13.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ade8cb6ff5632a62b7dbd4757d8c5573f7a2e9ae285d6b5b841707d8363205ef", size = 346850, upload-time = "2026-02-02T12:36:38.058Z" },
{ url = "https://files.pythonhosted.org/packages/36/4a/94e831c6bf287754a8a019cb966ed39ff8be6ab78cadecf08df3bb02d505/jiter-0.13.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9950290340acc1adaded363edd94baebcee7dabdfa8bee4790794cd5cfad2af6", size = 358551, upload-time = "2026-02-02T12:36:39.417Z" },
{ url = "https://files.pythonhosted.org/packages/a2/ec/a4c72c822695fa80e55d2b4142b73f0012035d9fcf90eccc56bc060db37c/jiter-0.13.0-cp313-cp313t-win_amd64.whl", hash = "sha256:2b4972c6df33731aac0742b64fd0d18e0a69bc7d6e03108ce7d40c85fd9e3e6d", size = 201950, upload-time = "2026-02-02T12:36:40.791Z" },
{ url = "https://files.pythonhosted.org/packages/b6/00/393553ec27b824fbc29047e9c7cd4a3951d7fbe4a76743f17e44034fa4e4/jiter-0.13.0-cp313-cp313t-win_arm64.whl", hash = "sha256:701a1e77d1e593c1b435315ff625fd071f0998c5f02792038a5ca98899261b7d", size = 185852, upload-time = "2026-02-02T12:36:42.077Z" },
{ url = "https://files.pythonhosted.org/packages/6e/f5/f1997e987211f6f9bd71b8083047b316208b4aca0b529bb5f8c96c89ef3e/jiter-0.13.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:cc5223ab19fe25e2f0bf2643204ad7318896fe3729bf12fde41b77bfc4fafff0", size = 308804, upload-time = "2026-02-02T12:36:43.496Z" },
{ url = "https://files.pythonhosted.org/packages/cd/8f/5482a7677731fd44881f0204981ce2d7175db271f82cba2085dd2212e095/jiter-0.13.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9776ebe51713acf438fd9b4405fcd86893ae5d03487546dae7f34993217f8a91", size = 318787, upload-time = "2026-02-02T12:36:45.071Z" },
{ url = "https://files.pythonhosted.org/packages/f3/b9/7257ac59778f1cd025b26a23c5520a36a424f7f1b068f2442a5b499b7464/jiter-0.13.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:879e768938e7b49b5e90b7e3fecc0dbec01b8cb89595861fb39a8967c5220d09", size = 353880, upload-time = "2026-02-02T12:36:47.365Z" },
{ url = "https://files.pythonhosted.org/packages/c3/87/719eec4a3f0841dad99e3d3604ee4cba36af4419a76f3cb0b8e2e691ad67/jiter-0.13.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:682161a67adea11e3aae9038c06c8b4a9a71023228767477d683f69903ebc607", size = 366702, upload-time = "2026-02-02T12:36:48.871Z" },
{ url = "https://files.pythonhosted.org/packages/d2/65/415f0a75cf6921e43365a1bc227c565cb949caca8b7532776e430cbaa530/jiter-0.13.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a13b68cd1cd8cc9de8f244ebae18ccb3e4067ad205220ef324c39181e23bbf66", size = 486319, upload-time = "2026-02-02T12:36:53.006Z" },
{ url = "https://files.pythonhosted.org/packages/54/a2/9e12b48e82c6bbc6081fd81abf915e1443add1b13d8fc586e1d90bb02bb8/jiter-0.13.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:87ce0f14c6c08892b610686ae8be350bf368467b6acd5085a5b65441e2bf36d2", size = 372289, upload-time = "2026-02-02T12:36:54.593Z" },
{ url = "https://files.pythonhosted.org/packages/4e/c1/e4693f107a1789a239c759a432e9afc592366f04e901470c2af89cfd28e1/jiter-0.13.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c365005b05505a90d1c47856420980d0237adf82f70c4aff7aebd3c1cc143ad", size = 360165, upload-time = "2026-02-02T12:36:56.112Z" },
{ url = "https://files.pythonhosted.org/packages/17/08/91b9ea976c1c758240614bd88442681a87672eebc3d9a6dde476874e706b/jiter-0.13.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1317fdffd16f5873e46ce27d0e0f7f4f90f0cdf1d86bf6abeaea9f63ca2c401d", size = 389634, upload-time = "2026-02-02T12:36:57.495Z" },
{ url = "https://files.pythonhosted.org/packages/18/23/58325ef99390d6d40427ed6005bf1ad54f2577866594bcf13ce55675f87d/jiter-0.13.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:c05b450d37ba0c9e21c77fef1f205f56bcee2330bddca68d344baebfc55ae0df", size = 514933, upload-time = "2026-02-02T12:36:58.909Z" },
{ url = "https://files.pythonhosted.org/packages/5b/25/69f1120c7c395fd276c3996bb8adefa9c6b84c12bb7111e5c6ccdcd8526d/jiter-0.13.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:775e10de3849d0631a97c603f996f518159272db00fdda0a780f81752255ee9d", size = 548842, upload-time = "2026-02-02T12:37:00.433Z" },
{ url = "https://files.pythonhosted.org/packages/18/05/981c9669d86850c5fbb0d9e62bba144787f9fba84546ba43d624ee27ef29/jiter-0.13.0-cp314-cp314-win32.whl", hash = "sha256:632bf7c1d28421c00dd8bbb8a3bac5663e1f57d5cd5ed962bce3c73bf62608e6", size = 202108, upload-time = "2026-02-02T12:37:01.718Z" },
{ url = "https://files.pythonhosted.org/packages/8d/96/cdcf54dd0b0341db7d25413229888a346c7130bd20820530905fdb65727b/jiter-0.13.0-cp314-cp314-win_amd64.whl", hash = "sha256:f22ef501c3f87ede88f23f9b11e608581c14f04db59b6a801f354397ae13739f", size = 204027, upload-time = "2026-02-02T12:37:03.075Z" },
{ url = "https://files.pythonhosted.org/packages/fb/f9/724bcaaab7a3cd727031fe4f6995cb86c4bd344909177c186699c8dec51a/jiter-0.13.0-cp314-cp314-win_arm64.whl", hash = "sha256:07b75fe09a4ee8e0c606200622e571e44943f47254f95e2436c8bdcaceb36d7d", size = 187199, upload-time = "2026-02-02T12:37:04.414Z" },
{ url = "https://files.pythonhosted.org/packages/62/92/1661d8b9fd6a3d7a2d89831db26fe3c1509a287d83ad7838831c7b7a5c7e/jiter-0.13.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:964538479359059a35fb400e769295d4b315ae61e4105396d355a12f7fef09f0", size = 318423, upload-time = "2026-02-02T12:37:05.806Z" },
{ url = "https://files.pythonhosted.org/packages/4f/3b/f77d342a54d4ebcd128e520fc58ec2f5b30a423b0fd26acdfc0c6fef8e26/jiter-0.13.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e104da1db1c0991b3eaed391ccd650ae8d947eab1480c733e5a3fb28d4313e40", size = 351438, upload-time = "2026-02-02T12:37:07.189Z" },
{ url = "https://files.pythonhosted.org/packages/76/b3/ba9a69f0e4209bd3331470c723c2f5509e6f0482e416b612431a5061ed71/jiter-0.13.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0e3a5f0cde8ff433b8e88e41aa40131455420fb3649a3c7abdda6145f8cb7202", size = 364774, upload-time = "2026-02-02T12:37:08.579Z" },
{ url = "https://files.pythonhosted.org/packages/b3/16/6cdb31fa342932602458dbb631bfbd47f601e03d2e4950740e0b2100b570/jiter-0.13.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:57aab48f40be1db920a582b30b116fe2435d184f77f0e4226f546794cedd9cf0", size = 487238, upload-time = "2026-02-02T12:37:10.066Z" },
{ url = "https://files.pythonhosted.org/packages/ed/b1/956cc7abaca8d95c13aa8d6c9b3f3797241c246cd6e792934cc4c8b250d2/jiter-0.13.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7772115877c53f62beeb8fd853cab692dbc04374ef623b30f997959a4c0e7e95", size = 372892, upload-time = "2026-02-02T12:37:11.656Z" },
{ url = "https://files.pythonhosted.org/packages/26/c4/97ecde8b1e74f67b8598c57c6fccf6df86ea7861ed29da84629cdbba76c4/jiter-0.13.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1211427574b17b633cfceba5040de8081e5abf114f7a7602f73d2e16f9fdaa59", size = 360309, upload-time = "2026-02-02T12:37:13.244Z" },
{ url = "https://files.pythonhosted.org/packages/4b/d7/eabe3cf46715854ccc80be2cd78dd4c36aedeb30751dbf85a1d08c14373c/jiter-0.13.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7beae3a3d3b5212d3a55d2961db3c292e02e302feb43fce6a3f7a31b90ea6dfe", size = 389607, upload-time = "2026-02-02T12:37:14.881Z" },
{ url = "https://files.pythonhosted.org/packages/df/2d/03963fc0804e6109b82decfb9974eb92df3797fe7222428cae12f8ccaa0c/jiter-0.13.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:e5562a0f0e90a6223b704163ea28e831bd3a9faa3512a711f031611e6b06c939", size = 514986, upload-time = "2026-02-02T12:37:16.326Z" },
{ url = "https://files.pythonhosted.org/packages/f6/6c/8c83b45eb3eb1c1e18d841fe30b4b5bc5619d781267ca9bc03e005d8fd0a/jiter-0.13.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:6c26a424569a59140fb51160a56df13f438a2b0967365e987889186d5fc2f6f9", size = 548756, upload-time = "2026-02-02T12:37:17.736Z" },
{ url = "https://files.pythonhosted.org/packages/47/66/eea81dfff765ed66c68fd2ed8c96245109e13c896c2a5015c7839c92367e/jiter-0.13.0-cp314-cp314t-win32.whl", hash = "sha256:24dc96eca9f84da4131cdf87a95e6ce36765c3b156fc9ae33280873b1c32d5f6", size = 201196, upload-time = "2026-02-02T12:37:19.101Z" },
{ url = "https://files.pythonhosted.org/packages/ff/32/4ac9c7a76402f8f00d00842a7f6b83b284d0cf7c1e9d4227bc95aa6d17fa/jiter-0.13.0-cp314-cp314t-win_amd64.whl", hash = "sha256:0a8d76c7524087272c8ae913f5d9d608bd839154b62c4322ef65723d2e5bb0b8", size = 204215, upload-time = "2026-02-02T12:37:20.495Z" },
{ url = "https://files.pythonhosted.org/packages/f9/8e/7def204fea9f9be8b3c21a6f2dd6c020cf56c7d5ff753e0e23ed7f9ea57e/jiter-0.13.0-cp314-cp314t-win_arm64.whl", hash = "sha256:2c26cf47e2cad140fa23b6d58d435a7c0161f5c514284802f25e87fddfe11024", size = 187152, upload-time = "2026-02-02T12:37:22.124Z" },
{ url = "https://files.pythonhosted.org/packages/79/b3/3c29819a27178d0e461a8571fb63c6ae38be6dc36b78b3ec2876bbd6a910/jiter-0.13.0-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:b1cbfa133241d0e6bdab48dcdc2604e8ba81512f6bbd68ec3e8e1357dd3c316c", size = 307016, upload-time = "2026-02-02T12:37:42.755Z" },
{ url = "https://files.pythonhosted.org/packages/eb/ae/60993e4b07b1ac5ebe46da7aa99fdbb802eb986c38d26e3883ac0125c4e0/jiter-0.13.0-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:db367d8be9fad6e8ebbac4a7578b7af562e506211036cba2c06c3b998603c3d2", size = 305024, upload-time = "2026-02-02T12:37:44.774Z" },
{ url = "https://files.pythonhosted.org/packages/77/fa/2227e590e9cf98803db2811f172b2d6460a21539ab73006f251c66f44b14/jiter-0.13.0-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:45f6f8efb2f3b0603092401dc2df79fa89ccbc027aaba4174d2d4133ed661434", size = 339337, upload-time = "2026-02-02T12:37:46.668Z" },
{ url = "https://files.pythonhosted.org/packages/2d/92/015173281f7eb96c0ef580c997da8ef50870d4f7f4c9e03c845a1d62ae04/jiter-0.13.0-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:597245258e6ad085d064780abfb23a284d418d3e61c57362d9449c6c7317ee2d", size = 346395, upload-time = "2026-02-02T12:37:48.09Z" },
{ url = "https://files.pythonhosted.org/packages/80/60/e50fa45dd7e2eae049f0ce964663849e897300433921198aef94b6ffa23a/jiter-0.13.0-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:3d744a6061afba08dd7ae375dcde870cffb14429b7477e10f67e9e6d68772a0a", size = 305169, upload-time = "2026-02-02T12:37:50.376Z" },
{ url = "https://files.pythonhosted.org/packages/d2/73/a009f41c5eed71c49bec53036c4b33555afcdee70682a18c6f66e396c039/jiter-0.13.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:ff732bd0a0e778f43d5009840f20b935e79087b4dc65bd36f1cd0f9b04b8ff7f", size = 303808, upload-time = "2026-02-02T12:37:52.092Z" },
{ url = "https://files.pythonhosted.org/packages/c4/10/528b439290763bff3d939268085d03382471b442f212dca4ff5f12802d43/jiter-0.13.0-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ab44b178f7981fcaea7e0a5df20e773c663d06ffda0198f1a524e91b2fde7e59", size = 337384, upload-time = "2026-02-02T12:37:53.582Z" },
{ 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 = "pycparser"
version = "3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" },
]
[[package]]
name = "pycryptodome"
version = "3.23.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/8e/a6/8452177684d5e906854776276ddd34eca30d1b1e15aa1ee9cefc289a33f5/pycryptodome-3.23.0.tar.gz", hash = "sha256:447700a657182d60338bab09fdb27518f8856aecd80ae4c6bdddb67ff5da44ef", size = 4921276, upload-time = "2025-05-17T17:21:45.242Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/04/5d/bdb09489b63cd34a976cc9e2a8d938114f7a53a74d3dd4f125ffa49dce82/pycryptodome-3.23.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:0011f7f00cdb74879142011f95133274741778abba114ceca229adbf8e62c3e4", size = 2495152, upload-time = "2025-05-17T17:20:20.833Z" },
{ url = "https://files.pythonhosted.org/packages/a7/ce/7840250ed4cc0039c433cd41715536f926d6e86ce84e904068eb3244b6a6/pycryptodome-3.23.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:90460fc9e088ce095f9ee8356722d4f10f86e5be06e2354230a9880b9c549aae", size = 1639348, upload-time = "2025-05-17T17:20:23.171Z" },
{ url = "https://files.pythonhosted.org/packages/ee/f0/991da24c55c1f688d6a3b5a11940567353f74590734ee4a64294834ae472/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4764e64b269fc83b00f682c47443c2e6e85b18273712b98aa43bcb77f8570477", size = 2184033, upload-time = "2025-05-17T17:20:25.424Z" },
{ url = "https://files.pythonhosted.org/packages/54/16/0e11882deddf00f68b68dd4e8e442ddc30641f31afeb2bc25588124ac8de/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eb8f24adb74984aa0e5d07a2368ad95276cf38051fe2dc6605cbcf482e04f2a7", size = 2270142, upload-time = "2025-05-17T17:20:27.808Z" },
{ url = "https://files.pythonhosted.org/packages/d5/fc/4347fea23a3f95ffb931f383ff28b3f7b1fe868739182cb76718c0da86a1/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d97618c9c6684a97ef7637ba43bdf6663a2e2e77efe0f863cce97a76af396446", size = 2309384, upload-time = "2025-05-17T17:20:30.765Z" },
{ url = "https://files.pythonhosted.org/packages/6e/d9/c5261780b69ce66d8cfab25d2797bd6e82ba0241804694cd48be41add5eb/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9a53a4fe5cb075075d515797d6ce2f56772ea7e6a1e5e4b96cf78a14bac3d265", size = 2183237, upload-time = "2025-05-17T17:20:33.736Z" },
{ url = "https://files.pythonhosted.org/packages/5a/6f/3af2ffedd5cfa08c631f89452c6648c4d779e7772dfc388c77c920ca6bbf/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:763d1d74f56f031788e5d307029caef067febf890cd1f8bf61183ae142f1a77b", size = 2343898, upload-time = "2025-05-17T17:20:36.086Z" },
{ url = "https://files.pythonhosted.org/packages/9a/dc/9060d807039ee5de6e2f260f72f3d70ac213993a804f5e67e0a73a56dd2f/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:954af0e2bd7cea83ce72243b14e4fb518b18f0c1649b576d114973e2073b273d", size = 2269197, upload-time = "2025-05-17T17:20:38.414Z" },
{ url = "https://files.pythonhosted.org/packages/f9/34/e6c8ca177cb29dcc4967fef73f5de445912f93bd0343c9c33c8e5bf8cde8/pycryptodome-3.23.0-cp313-cp313t-win32.whl", hash = "sha256:257bb3572c63ad8ba40b89f6fc9d63a2a628e9f9708d31ee26560925ebe0210a", size = 1768600, upload-time = "2025-05-17T17:20:40.688Z" },
{ url = "https://files.pythonhosted.org/packages/e4/1d/89756b8d7ff623ad0160f4539da571d1f594d21ee6d68be130a6eccb39a4/pycryptodome-3.23.0-cp313-cp313t-win_amd64.whl", hash = "sha256:6501790c5b62a29fcb227bd6b62012181d886a767ce9ed03b303d1f22eb5c625", size = 1799740, upload-time = "2025-05-17T17:20:42.413Z" },
{ url = "https://files.pythonhosted.org/packages/5d/61/35a64f0feaea9fd07f0d91209e7be91726eb48c0f1bfc6720647194071e4/pycryptodome-3.23.0-cp313-cp313t-win_arm64.whl", hash = "sha256:9a77627a330ab23ca43b48b130e202582e91cc69619947840ea4d2d1be21eb39", size = 1703685, upload-time = "2025-05-17T17:20:44.388Z" },
{ url = "https://files.pythonhosted.org/packages/db/6c/a1f71542c969912bb0e106f64f60a56cc1f0fabecf9396f45accbe63fa68/pycryptodome-3.23.0-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:187058ab80b3281b1de11c2e6842a357a1f71b42cb1e15bce373f3d238135c27", size = 2495627, upload-time = "2025-05-17T17:20:47.139Z" },
{ url = "https://files.pythonhosted.org/packages/6e/4e/a066527e079fc5002390c8acdd3aca431e6ea0a50ffd7201551175b47323/pycryptodome-3.23.0-cp37-abi3-macosx_10_9_x86_64.whl", hash = "sha256:cfb5cd445280c5b0a4e6187a7ce8de5a07b5f3f897f235caa11f1f435f182843", size = 1640362, upload-time = "2025-05-17T17:20:50.392Z" },
{ url = "https://files.pythonhosted.org/packages/50/52/adaf4c8c100a8c49d2bd058e5b551f73dfd8cb89eb4911e25a0c469b6b4e/pycryptodome-3.23.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:67bd81fcbe34f43ad9422ee8fd4843c8e7198dd88dd3d40e6de42ee65fbe1490", size = 2182625, upload-time = "2025-05-17T17:20:52.866Z" },
{ url = "https://files.pythonhosted.org/packages/5f/e9/a09476d436d0ff1402ac3867d933c61805ec2326c6ea557aeeac3825604e/pycryptodome-3.23.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c8987bd3307a39bc03df5c8e0e3d8be0c4c3518b7f044b0f4c15d1aa78f52575", size = 2268954, upload-time = "2025-05-17T17:20:55.027Z" },
{ url = "https://files.pythonhosted.org/packages/f9/c5/ffe6474e0c551d54cab931918127c46d70cab8f114e0c2b5a3c071c2f484/pycryptodome-3.23.0-cp37-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:aa0698f65e5b570426fc31b8162ed4603b0c2841cbb9088e2b01641e3065915b", size = 2308534, upload-time = "2025-05-17T17:20:57.279Z" },
{ url = "https://files.pythonhosted.org/packages/18/28/e199677fc15ecf43010f2463fde4c1a53015d1fe95fb03bca2890836603a/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:53ecbafc2b55353edcebd64bf5da94a2a2cdf5090a6915bcca6eca6cc452585a", size = 2181853, upload-time = "2025-05-17T17:20:59.322Z" },
{ url = "https://files.pythonhosted.org/packages/ce/ea/4fdb09f2165ce1365c9eaefef36625583371ee514db58dc9b65d3a255c4c/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_i686.whl", hash = "sha256:156df9667ad9f2ad26255926524e1c136d6664b741547deb0a86a9acf5ea631f", size = 2342465, upload-time = "2025-05-17T17:21:03.83Z" },
{ url = "https://files.pythonhosted.org/packages/22/82/6edc3fc42fe9284aead511394bac167693fb2b0e0395b28b8bedaa07ef04/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:dea827b4d55ee390dc89b2afe5927d4308a8b538ae91d9c6f7a5090f397af1aa", size = 2267414, upload-time = "2025-05-17T17:21:06.72Z" },
{ url = "https://files.pythonhosted.org/packages/59/fe/aae679b64363eb78326c7fdc9d06ec3de18bac68be4b612fc1fe8902693c/pycryptodome-3.23.0-cp37-abi3-win32.whl", hash = "sha256:507dbead45474b62b2bbe318eb1c4c8ee641077532067fec9c1aa82c31f84886", size = 1768484, upload-time = "2025-05-17T17:21:08.535Z" },
{ url = "https://files.pythonhosted.org/packages/54/2f/e97a1b8294db0daaa87012c24a7bb714147c7ade7656973fd6c736b484ff/pycryptodome-3.23.0-cp37-abi3-win_amd64.whl", hash = "sha256:c75b52aacc6c0c260f204cbdd834f76edc9fb0d8e0da9fbf8352ef58202564e2", size = 1799636, upload-time = "2025-05-17T17:21:10.393Z" },
{ url = "https://files.pythonhosted.org/packages/18/3d/f9441a0d798bf2b1e645adc3265e55706aead1255ccdad3856dbdcffec14/pycryptodome-3.23.0-cp37-abi3-win_arm64.whl", hash = "sha256:11eeeb6917903876f134b56ba11abe95c0b0fd5e3330def218083c7d98bbcb3c", size = 1703675, upload-time = "2025-05-17T17:21:13.146Z" },
{ url = "https://files.pythonhosted.org/packages/d9/12/e33935a0709c07de084d7d58d330ec3f4daf7910a18e77937affdb728452/pycryptodome-3.23.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:ddb95b49df036ddd264a0ad246d1be5b672000f12d6961ea2c267083a5e19379", size = 1623886, upload-time = "2025-05-17T17:21:20.614Z" },
{ url = "https://files.pythonhosted.org/packages/22/0b/aa8f9419f25870889bebf0b26b223c6986652bdf071f000623df11212c90/pycryptodome-3.23.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d8e95564beb8782abfd9e431c974e14563a794a4944c29d6d3b7b5ea042110b4", size = 1672151, upload-time = "2025-05-17T17:21:22.666Z" },
{ url = "https://files.pythonhosted.org/packages/d4/5e/63f5cbde2342b7f70a39e591dbe75d9809d6338ce0b07c10406f1a140cdc/pycryptodome-3.23.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:14e15c081e912c4b0d75632acd8382dfce45b258667aa3c67caf7a4d4c13f630", size = 1664461, upload-time = "2025-05-17T17:21:25.225Z" },
{ url = "https://files.pythonhosted.org/packages/d6/92/608fbdad566ebe499297a86aae5f2a5263818ceeecd16733006f1600403c/pycryptodome-3.23.0-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a7fc76bf273353dc7e5207d172b83f569540fc9a28d63171061c42e361d22353", size = 1702440, upload-time = "2025-05-17T17:21:27.991Z" },
{ url = "https://files.pythonhosted.org/packages/d1/92/2eadd1341abd2989cce2e2740b4423608ee2014acb8110438244ee97d7ff/pycryptodome-3.23.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:45c69ad715ca1a94f778215a11e66b7ff989d792a4d63b68dc586a1da1392ff5", size = 1803005, upload-time = "2025-05-17T17:21:31.37Z" },
]
[[package]]
name = "pydantic"
version = "2.12.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "annotated-types" },
{ name = "pydantic-core" },
{ name = "typing-extensions" },
{ name = "typing-inspection" },
]
sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" },
]
[[package]]
name = "pydantic-core"
version = "2.41.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c6/90/32c9941e728d564b411d574d8ee0cf09b12ec978cb22b294995bae5549a5/pydantic_core-2.41.5-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:77b63866ca88d804225eaa4af3e664c5faf3568cea95360d21f4725ab6e07146", size = 2107298, upload-time = "2025-11-04T13:39:04.116Z" },
{ url = "https://files.pythonhosted.org/packages/fb/a8/61c96a77fe28993d9a6fb0f4127e05430a267b235a124545d79fea46dd65/pydantic_core-2.41.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:dfa8a0c812ac681395907e71e1274819dec685fec28273a28905df579ef137e2", size = 1901475, upload-time = "2025-11-04T13:39:06.055Z" },
{ url = "https://files.pythonhosted.org/packages/5d/b6/338abf60225acc18cdc08b4faef592d0310923d19a87fba1faf05af5346e/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5921a4d3ca3aee735d9fd163808f5e8dd6c6972101e4adbda9a4667908849b97", size = 1918815, upload-time = "2025-11-04T13:39:10.41Z" },
{ url = "https://files.pythonhosted.org/packages/d1/1c/2ed0433e682983d8e8cba9c8d8ef274d4791ec6a6f24c58935b90e780e0a/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e25c479382d26a2a41b7ebea1043564a937db462816ea07afa8a44c0866d52f9", size = 2065567, upload-time = "2025-11-04T13:39:12.244Z" },
{ url = "https://files.pythonhosted.org/packages/b3/24/cf84974ee7d6eae06b9e63289b7b8f6549d416b5c199ca2d7ce13bbcf619/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f547144f2966e1e16ae626d8ce72b4cfa0caedc7fa28052001c94fb2fcaa1c52", size = 2230442, upload-time = "2025-11-04T13:39:13.962Z" },
{ url = "https://files.pythonhosted.org/packages/fd/21/4e287865504b3edc0136c89c9c09431be326168b1eb7841911cbc877a995/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6f52298fbd394f9ed112d56f3d11aabd0d5bd27beb3084cc3d8ad069483b8941", size = 2350956, upload-time = "2025-11-04T13:39:15.889Z" },
{ url = "https://files.pythonhosted.org/packages/a8/76/7727ef2ffa4b62fcab916686a68a0426b9b790139720e1934e8ba797e238/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:100baa204bb412b74fe285fb0f3a385256dad1d1879f0a5cb1499ed2e83d132a", size = 2068253, upload-time = "2025-11-04T13:39:17.403Z" },
{ url = "https://files.pythonhosted.org/packages/d5/8c/a4abfc79604bcb4c748e18975c44f94f756f08fb04218d5cb87eb0d3a63e/pydantic_core-2.41.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:05a2c8852530ad2812cb7914dc61a1125dc4e06252ee98e5638a12da6cc6fb6c", size = 2177050, upload-time = "2025-11-04T13:39:19.351Z" },
{ url = "https://files.pythonhosted.org/packages/67/b1/de2e9a9a79b480f9cb0b6e8b6ba4c50b18d4e89852426364c66aa82bb7b3/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:29452c56df2ed968d18d7e21f4ab0ac55e71dc59524872f6fc57dcf4a3249ed2", size = 2147178, upload-time = "2025-11-04T13:39:21Z" },
{ url = "https://files.pythonhosted.org/packages/16/c1/dfb33f837a47b20417500efaa0378adc6635b3c79e8369ff7a03c494b4ac/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:d5160812ea7a8a2ffbe233d8da666880cad0cbaf5d4de74ae15c313213d62556", size = 2341833, upload-time = "2025-11-04T13:39:22.606Z" },
{ url = "https://files.pythonhosted.org/packages/47/36/00f398642a0f4b815a9a558c4f1dca1b4020a7d49562807d7bc9ff279a6c/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:df3959765b553b9440adfd3c795617c352154e497a4eaf3752555cfb5da8fc49", size = 2321156, upload-time = "2025-11-04T13:39:25.843Z" },
{ url = "https://files.pythonhosted.org/packages/7e/70/cad3acd89fde2010807354d978725ae111ddf6d0ea46d1ea1775b5c1bd0c/pydantic_core-2.41.5-cp310-cp310-win32.whl", hash = "sha256:1f8d33a7f4d5a7889e60dc39856d76d09333d8a6ed0f5f1190635cbec70ec4ba", size = 1989378, upload-time = "2025-11-04T13:39:27.92Z" },
{ url = "https://files.pythonhosted.org/packages/76/92/d338652464c6c367e5608e4488201702cd1cbb0f33f7b6a85a60fe5f3720/pydantic_core-2.41.5-cp310-cp310-win_amd64.whl", hash = "sha256:62de39db01b8d593e45871af2af9e497295db8d73b085f6bfd0b18c83c70a8f9", size = 2013622, upload-time = "2025-11-04T13:39:29.848Z" },
{ url = "https://files.pythonhosted.org/packages/e8/72/74a989dd9f2084b3d9530b0915fdda64ac48831c30dbf7c72a41a5232db8/pydantic_core-2.41.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a3a52f6156e73e7ccb0f8cced536adccb7042be67cb45f9562e12b319c119da6", size = 2105873, upload-time = "2025-11-04T13:39:31.373Z" },
{ url = "https://files.pythonhosted.org/packages/12/44/37e403fd9455708b3b942949e1d7febc02167662bf1a7da5b78ee1ea2842/pydantic_core-2.41.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7f3bf998340c6d4b0c9a2f02d6a400e51f123b59565d74dc60d252ce888c260b", size = 1899826, upload-time = "2025-11-04T13:39:32.897Z" },
{ url = "https://files.pythonhosted.org/packages/33/7f/1d5cab3ccf44c1935a359d51a8a2a9e1a654b744b5e7f80d41b88d501eec/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:378bec5c66998815d224c9ca994f1e14c0c21cb95d2f52b6021cc0b2a58f2a5a", size = 1917869, upload-time = "2025-11-04T13:39:34.469Z" },
{ url = "https://files.pythonhosted.org/packages/6e/6a/30d94a9674a7fe4f4744052ed6c5e083424510be1e93da5bc47569d11810/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b576130c69225432866fe2f4a469a85a54ade141d96fd396dffcf607b558f8", size = 2063890, upload-time = "2025-11-04T13:39:36.053Z" },
{ url = "https://files.pythonhosted.org/packages/50/be/76e5d46203fcb2750e542f32e6c371ffa9b8ad17364cf94bb0818dbfb50c/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6cb58b9c66f7e4179a2d5e0f849c48eff5c1fca560994d6eb6543abf955a149e", size = 2229740, upload-time = "2025-11-04T13:39:37.753Z" },
{ url = "https://files.pythonhosted.org/packages/d3/ee/fed784df0144793489f87db310a6bbf8118d7b630ed07aa180d6067e653a/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:88942d3a3dff3afc8288c21e565e476fc278902ae4d6d134f1eeda118cc830b1", size = 2350021, upload-time = "2025-11-04T13:39:40.94Z" },
{ url = "https://files.pythonhosted.org/packages/c8/be/8fed28dd0a180dca19e72c233cbf58efa36df055e5b9d90d64fd1740b828/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f31d95a179f8d64d90f6831d71fa93290893a33148d890ba15de25642c5d075b", size = 2066378, upload-time = "2025-11-04T13:39:42.523Z" },
{ url = "https://files.pythonhosted.org/packages/b0/3b/698cf8ae1d536a010e05121b4958b1257f0b5522085e335360e53a6b1c8b/pydantic_core-2.41.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c1df3d34aced70add6f867a8cf413e299177e0c22660cc767218373d0779487b", size = 2175761, upload-time = "2025-11-04T13:39:44.553Z" },
{ url = "https://files.pythonhosted.org/packages/b8/ba/15d537423939553116dea94ce02f9c31be0fa9d0b806d427e0308ec17145/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4009935984bd36bd2c774e13f9a09563ce8de4abaa7226f5108262fa3e637284", size = 2146303, upload-time = "2025-11-04T13:39:46.238Z" },
{ url = "https://files.pythonhosted.org/packages/58/7f/0de669bf37d206723795f9c90c82966726a2ab06c336deba4735b55af431/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:34a64bc3441dc1213096a20fe27e8e128bd3ff89921706e83c0b1ac971276594", size = 2340355, upload-time = "2025-11-04T13:39:48.002Z" },
{ url = "https://files.pythonhosted.org/packages/e5/de/e7482c435b83d7e3c3ee5ee4451f6e8973cff0eb6007d2872ce6383f6398/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c9e19dd6e28fdcaa5a1de679aec4141f691023916427ef9bae8584f9c2fb3b0e", size = 2319875, upload-time = "2025-11-04T13:39:49.705Z" },
{ url = "https://files.pythonhosted.org/packages/fe/e6/8c9e81bb6dd7560e33b9053351c29f30c8194b72f2d6932888581f503482/pydantic_core-2.41.5-cp311-cp311-win32.whl", hash = "sha256:2c010c6ded393148374c0f6f0bf89d206bf3217f201faa0635dcd56bd1520f6b", size = 1987549, upload-time = "2025-11-04T13:39:51.842Z" },
{ url = "https://files.pythonhosted.org/packages/11/66/f14d1d978ea94d1bc21fc98fcf570f9542fe55bfcc40269d4e1a21c19bf7/pydantic_core-2.41.5-cp311-cp311-win_amd64.whl", hash = "sha256:76ee27c6e9c7f16f47db7a94157112a2f3a00e958bc626e2f4ee8bec5c328fbe", size = 2011305, upload-time = "2025-11-04T13:39:53.485Z" },
{ url = "https://files.pythonhosted.org/packages/56/d8/0e271434e8efd03186c5386671328154ee349ff0354d83c74f5caaf096ed/pydantic_core-2.41.5-cp311-cp311-win_arm64.whl", hash = "sha256:4bc36bbc0b7584de96561184ad7f012478987882ebf9f9c389b23f432ea3d90f", size = 1972902, upload-time = "2025-11-04T13:39:56.488Z" },
{ url = "https://files.pythonhosted.org/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", size = 2110990, upload-time = "2025-11-04T13:39:58.079Z" },
{ url = "https://files.pythonhosted.org/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", size = 1896003, upload-time = "2025-11-04T13:39:59.956Z" },
{ url = "https://files.pythonhosted.org/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", size = 1919200, upload-time = "2025-11-04T13:40:02.241Z" },
{ url = "https://files.pythonhosted.org/packages/38/de/8c36b5198a29bdaade07b5985e80a233a5ac27137846f3bc2d3b40a47360/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75", size = 2052578, upload-time = "2025-11-04T13:40:04.401Z" },
{ url = "https://files.pythonhosted.org/packages/00/b5/0e8e4b5b081eac6cb3dbb7e60a65907549a1ce035a724368c330112adfdd/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05", size = 2208504, upload-time = "2025-11-04T13:40:06.072Z" },
{ url = "https://files.pythonhosted.org/packages/77/56/87a61aad59c7c5b9dc8caad5a41a5545cba3810c3e828708b3d7404f6cef/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc", size = 2335816, upload-time = "2025-11-04T13:40:07.835Z" },
{ url = "https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c", size = 2075366, upload-time = "2025-11-04T13:40:09.804Z" },
{ url = "https://files.pythonhosted.org/packages/d3/43/ebef01f69baa07a482844faaa0a591bad1ef129253ffd0cdaa9d8a7f72d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5", size = 2171698, upload-time = "2025-11-04T13:40:12.004Z" },
{ url = "https://files.pythonhosted.org/packages/b1/87/41f3202e4193e3bacfc2c065fab7706ebe81af46a83d3e27605029c1f5a6/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c", size = 2132603, upload-time = "2025-11-04T13:40:13.868Z" },
{ url = "https://files.pythonhosted.org/packages/49/7d/4c00df99cb12070b6bccdef4a195255e6020a550d572768d92cc54dba91a/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294", size = 2329591, upload-time = "2025-11-04T13:40:15.672Z" },
{ url = "https://files.pythonhosted.org/packages/cc/6a/ebf4b1d65d458f3cda6a7335d141305dfa19bdc61140a884d165a8a1bbc7/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1", size = 2319068, upload-time = "2025-11-04T13:40:17.532Z" },
{ url = "https://files.pythonhosted.org/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d", size = 1985908, upload-time = "2025-11-04T13:40:19.309Z" },
{ url = "https://files.pythonhosted.org/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815", size = 2020145, upload-time = "2025-11-04T13:40:21.548Z" },
{ url = "https://files.pythonhosted.org/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3", size = 1976179, upload-time = "2025-11-04T13:40:23.393Z" },
{ url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403, upload-time = "2025-11-04T13:40:25.248Z" },
{ url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206, upload-time = "2025-11-04T13:40:27.099Z" },
{ url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307, upload-time = "2025-11-04T13:40:29.806Z" },
{ url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258, upload-time = "2025-11-04T13:40:33.544Z" },
{ url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917, upload-time = "2025-11-04T13:40:35.479Z" },
{ url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186, upload-time = "2025-11-04T13:40:37.436Z" },
{ url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164, upload-time = "2025-11-04T13:40:40.289Z" },
{ url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146, upload-time = "2025-11-04T13:40:42.809Z" },
{ url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788, upload-time = "2025-11-04T13:40:44.752Z" },
{ url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133, upload-time = "2025-11-04T13:40:46.66Z" },
{ url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852, upload-time = "2025-11-04T13:40:48.575Z" },
{ url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679, upload-time = "2025-11-04T13:40:50.619Z" },
{ url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766, upload-time = "2025-11-04T13:40:52.631Z" },
{ url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005, upload-time = "2025-11-04T13:40:54.734Z" },
{ url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" },
{ url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" },
{ url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" },
{ url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691, upload-time = "2025-11-04T13:41:03.504Z" },
{ url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897, upload-time = "2025-11-04T13:41:05.804Z" },
{ url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302, upload-time = "2025-11-04T13:41:07.809Z" },
{ url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877, upload-time = "2025-11-04T13:41:09.827Z" },
{ url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680, upload-time = "2025-11-04T13:41:12.379Z" },
{ url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960, upload-time = "2025-11-04T13:41:14.627Z" },
{ url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102, upload-time = "2025-11-04T13:41:16.868Z" },
{ url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039, upload-time = "2025-11-04T13:41:18.934Z" },
{ url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126, upload-time = "2025-11-04T13:41:21.418Z" },
{ url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489, upload-time = "2025-11-04T13:41:24.076Z" },
{ url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288, upload-time = "2025-11-04T13:41:26.33Z" },
{ url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255, upload-time = "2025-11-04T13:41:28.569Z" },
{ url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760, upload-time = "2025-11-04T13:41:31.055Z" },
{ url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092, upload-time = "2025-11-04T13:41:33.21Z" },
{ url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385, upload-time = "2025-11-04T13:41:35.508Z" },
{ url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832, upload-time = "2025-11-04T13:41:37.732Z" },
{ url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585, upload-time = "2025-11-04T13:41:40Z" },
{ url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078, upload-time = "2025-11-04T13:41:42.323Z" },
{ url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914, upload-time = "2025-11-04T13:41:45.221Z" },
{ url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560, upload-time = "2025-11-04T13:41:47.474Z" },
{ url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244, upload-time = "2025-11-04T13:41:49.992Z" },
{ url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955, upload-time = "2025-11-04T13:41:54.079Z" },
{ url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" },
{ url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" },
{ url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" },
{ url = "https://files.pythonhosted.org/packages/11/72/90fda5ee3b97e51c494938a4a44c3a35a9c96c19bba12372fb9c634d6f57/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:b96d5f26b05d03cc60f11a7761a5ded1741da411e7fe0909e27a5e6a0cb7b034", size = 2115441, upload-time = "2025-11-04T13:42:39.557Z" },
{ url = "https://files.pythonhosted.org/packages/1f/53/8942f884fa33f50794f119012dc6a1a02ac43a56407adaac20463df8e98f/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:634e8609e89ceecea15e2d61bc9ac3718caaaa71963717bf3c8f38bfde64242c", size = 1930291, upload-time = "2025-11-04T13:42:42.169Z" },
{ url = "https://files.pythonhosted.org/packages/79/c8/ecb9ed9cd942bce09fc888ee960b52654fbdbede4ba6c2d6e0d3b1d8b49c/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:93e8740d7503eb008aa2df04d3b9735f845d43ae845e6dcd2be0b55a2da43cd2", size = 1948632, upload-time = "2025-11-04T13:42:44.564Z" },
{ url = "https://files.pythonhosted.org/packages/2e/1b/687711069de7efa6af934e74f601e2a4307365e8fdc404703afc453eab26/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f15489ba13d61f670dcc96772e733aad1a6f9c429cc27574c6cdaed82d0146ad", size = 2138905, upload-time = "2025-11-04T13:42:47.156Z" },
{ url = "https://files.pythonhosted.org/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd", size = 2110495, upload-time = "2025-11-04T13:42:49.689Z" },
{ url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388, upload-time = "2025-11-04T13:42:52.215Z" },
{ url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879, upload-time = "2025-11-04T13:42:56.483Z" },
{ url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" },
{ url = "https://files.pythonhosted.org/packages/e6/b0/1a2aa41e3b5a4ba11420aba2d091b2d17959c8d1519ece3627c371951e73/pydantic_core-2.41.5-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b5819cd790dbf0c5eb9f82c73c16b39a65dd6dd4d1439dcdea7816ec9adddab8", size = 2103351, upload-time = "2025-11-04T13:43:02.058Z" },
{ url = "https://files.pythonhosted.org/packages/a4/ee/31b1f0020baaf6d091c87900ae05c6aeae101fa4e188e1613c80e4f1ea31/pydantic_core-2.41.5-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:5a4e67afbc95fa5c34cf27d9089bca7fcab4e51e57278d710320a70b956d1b9a", size = 1925363, upload-time = "2025-11-04T13:43:05.159Z" },
{ url = "https://files.pythonhosted.org/packages/e1/89/ab8e86208467e467a80deaca4e434adac37b10a9d134cd2f99b28a01e483/pydantic_core-2.41.5-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ece5c59f0ce7d001e017643d8d24da587ea1f74f6993467d85ae8a5ef9d4f42b", size = 2135615, upload-time = "2025-11-04T13:43:08.116Z" },
{ url = "https://files.pythonhosted.org/packages/99/0a/99a53d06dd0348b2008f2f30884b34719c323f16c3be4e6cc1203b74a91d/pydantic_core-2.41.5-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:16f80f7abe3351f8ea6858914ddc8c77e02578544a0ebc15b4c2e1a0e813b0b2", size = 2175369, upload-time = "2025-11-04T13:43:12.49Z" },
{ url = "https://files.pythonhosted.org/packages/6d/94/30ca3b73c6d485b9bb0bc66e611cff4a7138ff9736b7e66bcf0852151636/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:33cb885e759a705b426baada1fe68cbb0a2e68e34c5d0d0289a364cf01709093", size = 2144218, upload-time = "2025-11-04T13:43:15.431Z" },
{ url = "https://files.pythonhosted.org/packages/87/57/31b4f8e12680b739a91f472b5671294236b82586889ef764b5fbc6669238/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:c8d8b4eb992936023be7dee581270af5c6e0697a8559895f527f5b7105ecd36a", size = 2329951, upload-time = "2025-11-04T13:43:18.062Z" },
{ url = "https://files.pythonhosted.org/packages/7d/73/3c2c8edef77b8f7310e6fb012dbc4b8551386ed575b9eb6fb2506e28a7eb/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:242a206cd0318f95cd21bdacff3fcc3aab23e79bba5cac3db5a841c9ef9c6963", size = 2318428, upload-time = "2025-11-04T13:43:20.679Z" },
{ url = "https://files.pythonhosted.org/packages/2f/02/8559b1f26ee0d502c74f9cca5c0d2fd97e967e083e006bbbb4e97f3a043a/pydantic_core-2.41.5-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:d3a978c4f57a597908b7e697229d996d77a6d3c94901e9edee593adada95ce1a", size = 2147009, upload-time = "2025-11-04T13:43:23.286Z" },
{ url = "https://files.pythonhosted.org/packages/5f/9b/1b3f0e9f9305839d7e84912f9e8bfbd191ed1b1ef48083609f0dabde978c/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b2379fa7ed44ddecb5bfe4e48577d752db9fc10be00a6b7446e9663ba143de26", size = 2101980, upload-time = "2025-11-04T13:43:25.97Z" },
{ url = "https://files.pythonhosted.org/packages/a4/ed/d71fefcb4263df0da6a85b5d8a7508360f2f2e9b3bf5814be9c8bccdccc1/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:266fb4cbf5e3cbd0b53669a6d1b039c45e3ce651fd5442eff4d07c2cc8d66808", size = 1923865, upload-time = "2025-11-04T13:43:28.763Z" },
{ url = "https://files.pythonhosted.org/packages/ce/3a/626b38db460d675f873e4444b4bb030453bbe7b4ba55df821d026a0493c4/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58133647260ea01e4d0500089a8c4f07bd7aa6ce109682b1426394988d8aaacc", size = 2134256, upload-time = "2025-11-04T13:43:31.71Z" },
{ url = "https://files.pythonhosted.org/packages/83/d9/8412d7f06f616bbc053d30cb4e5f76786af3221462ad5eee1f202021eb4e/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:287dad91cfb551c363dc62899a80e9e14da1f0e2b6ebde82c806612ca2a13ef1", size = 2174762, upload-time = "2025-11-04T13:43:34.744Z" },
{ url = "https://files.pythonhosted.org/packages/55/4c/162d906b8e3ba3a99354e20faa1b49a85206c47de97a639510a0e673f5da/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:03b77d184b9eb40240ae9fd676ca364ce1085f203e1b1256f8ab9984dca80a84", size = 2143141, upload-time = "2025-11-04T13:43:37.701Z" },
{ url = "https://files.pythonhosted.org/packages/1f/f2/f11dd73284122713f5f89fc940f370d035fa8e1e078d446b3313955157fe/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:a668ce24de96165bb239160b3d854943128f4334822900534f2fe947930e5770", size = 2330317, upload-time = "2025-11-04T13:43:40.406Z" },
{ url = "https://files.pythonhosted.org/packages/88/9d/b06ca6acfe4abb296110fb1273a4d848a0bfb2ff65f3ee92127b3244e16b/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f14f8f046c14563f8eb3f45f499cc658ab8d10072961e07225e507adb700e93f", size = 2316992, upload-time = "2025-11-04T13:43:43.602Z" },
{ 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 = "pynacl"
version = "1.6.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/d9/9a/4019b524b03a13438637b11538c82781a5eda427394380381af8f04f467a/pynacl-1.6.2.tar.gz", hash = "sha256:018494d6d696ae03c7e656e5e74cdfd8ea1326962cc401bcf018f1ed8436811c", size = 3511692, upload-time = "2026-01-01T17:48:10.851Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/4b/79/0e3c34dc3c4671f67d251c07aa8eb100916f250ee470df230b0ab89551b4/pynacl-1.6.2-cp314-cp314t-macosx_10_10_universal2.whl", hash = "sha256:622d7b07cc5c02c666795792931b50c91f3ce3c2649762efb1ef0d5684c81594", size = 390064, upload-time = "2026-01-01T17:31:57.264Z" },
{ url = "https://files.pythonhosted.org/packages/eb/1c/23a26e931736e13b16483795c8a6b2f641bf6a3d5238c22b070a5112722c/pynacl-1.6.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d071c6a9a4c94d79eb665db4ce5cedc537faf74f2355e4d502591d850d3913c0", size = 809370, upload-time = "2026-01-01T17:31:59.198Z" },
{ url = "https://files.pythonhosted.org/packages/87/74/8d4b718f8a22aea9e8dcc8b95deb76d4aae380e2f5b570cc70b5fd0a852d/pynacl-1.6.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fe9847ca47d287af41e82be1dd5e23023d3c31a951da134121ab02e42ac218c9", size = 1408304, upload-time = "2026-01-01T17:32:01.162Z" },
{ url = "https://files.pythonhosted.org/packages/fd/73/be4fdd3a6a87fe8a4553380c2b47fbd1f7f58292eb820902f5c8ac7de7b0/pynacl-1.6.2-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:04316d1fc625d860b6c162fff704eb8426b1a8bcd3abacea11142cbd99a6b574", size = 844871, upload-time = "2026-01-01T17:32:02.824Z" },
{ url = "https://files.pythonhosted.org/packages/55/ad/6efc57ab75ee4422e96b5f2697d51bbcf6cdcc091e66310df91fbdc144a8/pynacl-1.6.2-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:44081faff368d6c5553ccf55322ef2819abb40e25afaec7e740f159f74813634", size = 1446356, upload-time = "2026-01-01T17:32:04.452Z" },
{ url = "https://files.pythonhosted.org/packages/78/b7/928ee9c4779caa0a915844311ab9fb5f99585621c5d6e4574538a17dca07/pynacl-1.6.2-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:a9f9932d8d2811ce1a8ffa79dcbdf3970e7355b5c8eb0c1a881a57e7f7d96e88", size = 826814, upload-time = "2026-01-01T17:32:06.078Z" },
{ url = "https://files.pythonhosted.org/packages/f7/a9/1bdba746a2be20f8809fee75c10e3159d75864ef69c6b0dd168fc60e485d/pynacl-1.6.2-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:bc4a36b28dd72fb4845e5d8f9760610588a96d5a51f01d84d8c6ff9849968c14", size = 1411742, upload-time = "2026-01-01T17:32:07.651Z" },
{ url = "https://files.pythonhosted.org/packages/f3/2f/5e7ea8d85f9f3ea5b6b87db1d8388daa3587eed181bdeb0306816fdbbe79/pynacl-1.6.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3bffb6d0f6becacb6526f8f42adfb5efb26337056ee0831fb9a7044d1a964444", size = 801714, upload-time = "2026-01-01T17:32:09.558Z" },
{ url = "https://files.pythonhosted.org/packages/06/ea/43fe2f7eab5f200e40fb10d305bf6f87ea31b3bbc83443eac37cd34a9e1e/pynacl-1.6.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2fef529ef3ee487ad8113d287a593fa26f48ee3620d92ecc6f1d09ea38e0709b", size = 1372257, upload-time = "2026-01-01T17:32:11.026Z" },
{ url = "https://files.pythonhosted.org/packages/4d/54/c9ea116412788629b1347e415f72195c25eb2f3809b2d3e7b25f5c79f13a/pynacl-1.6.2-cp314-cp314t-win32.whl", hash = "sha256:a84bf1c20339d06dc0c85d9aea9637a24f718f375d861b2668b2f9f96fa51145", size = 231319, upload-time = "2026-01-01T17:32:12.46Z" },
{ url = "https://files.pythonhosted.org/packages/ce/04/64e9d76646abac2dccf904fccba352a86e7d172647557f35b9fe2a5ee4a1/pynacl-1.6.2-cp314-cp314t-win_amd64.whl", hash = "sha256:320ef68a41c87547c91a8b58903c9caa641ab01e8512ce291085b5fe2fcb7590", size = 244044, upload-time = "2026-01-01T17:32:13.781Z" },
{ url = "https://files.pythonhosted.org/packages/33/33/7873dc161c6a06f43cda13dec67b6fe152cb2f982581151956fa5e5cdb47/pynacl-1.6.2-cp314-cp314t-win_arm64.whl", hash = "sha256:d29bfe37e20e015a7d8b23cfc8bd6aa7909c92a1b8f41ee416bbb3e79ef182b2", size = 188740, upload-time = "2026-01-01T17:32:15.083Z" },
{ url = "https://files.pythonhosted.org/packages/be/7b/4845bbf88e94586ec47a432da4e9107e3fc3ce37eb412b1398630a37f7dd/pynacl-1.6.2-cp38-abi3-macosx_10_10_universal2.whl", hash = "sha256:c949ea47e4206af7c8f604b8278093b674f7c79ed0d4719cc836902bf4517465", size = 388458, upload-time = "2026-01-01T17:32:16.829Z" },
{ url = "https://files.pythonhosted.org/packages/1e/b4/e927e0653ba63b02a4ca5b4d852a8d1d678afbf69b3dbf9c4d0785ac905c/pynacl-1.6.2-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8845c0631c0be43abdd865511c41eab235e0be69c81dc66a50911594198679b0", size = 800020, upload-time = "2026-01-01T17:32:18.34Z" },
{ url = "https://files.pythonhosted.org/packages/7f/81/d60984052df5c97b1d24365bc1e30024379b42c4edcd79d2436b1b9806f2/pynacl-1.6.2-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:22de65bb9010a725b0dac248f353bb072969c94fa8d6b1f34b87d7953cf7bbe4", size = 1399174, upload-time = "2026-01-01T17:32:20.239Z" },
{ url = "https://files.pythonhosted.org/packages/68/f7/322f2f9915c4ef27d140101dd0ed26b479f7e6f5f183590fd32dfc48c4d3/pynacl-1.6.2-cp38-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:46065496ab748469cdd999246d17e301b2c24ae2fdf739132e580a0e94c94a87", size = 835085, upload-time = "2026-01-01T17:32:22.24Z" },
{ url = "https://files.pythonhosted.org/packages/3e/d0/f301f83ac8dbe53442c5a43f6a39016f94f754d7a9815a875b65e218a307/pynacl-1.6.2-cp38-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8a66d6fb6ae7661c58995f9c6435bda2b1e68b54b598a6a10247bfcdadac996c", size = 1437614, upload-time = "2026-01-01T17:32:23.766Z" },
{ url = "https://files.pythonhosted.org/packages/c4/58/fc6e649762b029315325ace1a8c6be66125e42f67416d3dbd47b69563d61/pynacl-1.6.2-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:26bfcd00dcf2cf160f122186af731ae30ab120c18e8375684ec2670dccd28130", size = 818251, upload-time = "2026-01-01T17:32:25.69Z" },
{ url = "https://files.pythonhosted.org/packages/c9/a8/b917096b1accc9acd878819a49d3d84875731a41eb665f6ebc826b1af99e/pynacl-1.6.2-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:c8a231e36ec2cab018c4ad4358c386e36eede0319a0c41fed24f840b1dac59f6", size = 1402859, upload-time = "2026-01-01T17:32:27.215Z" },
{ url = "https://files.pythonhosted.org/packages/85/42/fe60b5f4473e12c72f977548e4028156f4d340b884c635ec6b063fe7e9a5/pynacl-1.6.2-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:68be3a09455743ff9505491220b64440ced8973fe930f270c8e07ccfa25b1f9e", size = 791926, upload-time = "2026-01-01T17:32:29.314Z" },
{ url = "https://files.pythonhosted.org/packages/fa/f9/e40e318c604259301cc091a2a63f237d9e7b424c4851cafaea4ea7c4834e/pynacl-1.6.2-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:8b097553b380236d51ed11356c953bf8ce36a29a3e596e934ecabe76c985a577", size = 1363101, upload-time = "2026-01-01T17:32:31.263Z" },
{ url = "https://files.pythonhosted.org/packages/48/47/e761c254f410c023a469284a9bc210933e18588ca87706ae93002c05114c/pynacl-1.6.2-cp38-abi3-win32.whl", hash = "sha256:5811c72b473b2f38f7e2a3dc4f8642e3a3e9b5e7317266e4ced1fba85cae41aa", size = 227421, upload-time = "2026-01-01T17:32:33.076Z" },
{ url = "https://files.pythonhosted.org/packages/41/ad/334600e8cacc7d86587fe5f565480fde569dfb487389c8e1be56ac21d8ac/pynacl-1.6.2-cp38-abi3-win_amd64.whl", hash = "sha256:62985f233210dee6548c223301b6c25440852e13d59a8b81490203c3227c5ba0", size = 239754, upload-time = "2026-01-01T17:32:34.557Z" },
{ url = "https://files.pythonhosted.org/packages/29/7d/5945b5af29534641820d3bd7b00962abbbdfee84ec7e19f0d5b3175f9a31/pynacl-1.6.2-cp38-abi3-win_arm64.whl", hash = "sha256:834a43af110f743a754448463e8fd61259cd4ab5bbedcf70f9dabad1d28a394c", size = 184801, upload-time = "2026-01-01T17:32:36.309Z" },
]
[[package]]
name = "pyserial"
version = "3.5"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/1e/7d/ae3f0a63f41e4d2f6cb66a5b57197850f919f59e558159a4dd3a818f5082/pyserial-3.5.tar.gz", hash = "sha256:3c77e014170dfffbd816e6ffc205e9842efb10be9f58ec16d3e8675b4925cddb", size = 159125, upload-time = "2020-11-23T03:59:15.045Z" }
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" },
]
[[package]]
name = "sniffio"
version = "1.3.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" }
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" },
]
[[package]]
name = "typing-extensions"
version = "4.15.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
]
[[package]]
name = "typing-inspection"
version = "0.4.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" },
]
-13
View File
@@ -1,13 +0,0 @@
[target.'cfg(target_arch = "xtensa")']
rustflags = [
"-C", "link-arg=-Wl,-Tlinkall.x",
"-C", "link-arg=-Wl,-Tdefmt.x",
"-C", "link-arg=-nostartfiles",
]
[target.thumbv7em-none-eabihf]
rustflags = [
"-C", "link-arg=-Tlink.x",
"-C", "link-arg=-Tdefmt.x",
"-C", "opt-level=z",
]
-5
View File
@@ -1,5 +0,0 @@
/target
/vendor
/builds
/.hypothesis
src/board/mod.rs
-2017
View File
File diff suppressed because it is too large. Load diff
-127
View File
@@ -1,127 +0,0 @@
[workspace]
members = ["."]
[package]
name = "donglora"
version = "0.2.0"
edition = "2021"
build = "build.rs"
authors = ["Stephen Waits <steve@waits.net>"]
license = "MIT"
description = "Transparent LoRa radio over USB — plug in a board, talk LoRa from your host"
repository = "https://github.com/swaits/donglora"
keywords = ["lora", "radio", "embedded", "usb", "firmware"]
categories = ["embedded", "hardware-support", "no-std"]
# UPGRADE BLOCKED: embassy-executor 0.10 and ucobs 0.3 need Rust >= 1.93
# stable. The ESP Xtensa toolchain (espup) is a nightly fork that may not
# support the required language features or pass MSRV gating. Revisit when
# espup catches up. Check: rustup run esp rustc --version
[dependencies]
cfg-if = "1.0"
critical-section = "1.2"
defmt = "1.0"
embedded-hal = "1.0"
embedded-hal-async = "1.0"
embassy-embedded-hal = "0.6"
embassy-executor = "0.9"
embassy-sync = "0.8"
embassy-time = "0.5"
embassy-usb = { version = "0.6", features = ["defmt"] }
embassy-usb-driver = "0.2"
ucobs = { version = "0.3", features = ["legacy-msrv"] }
heapless = "0.9"
lora-phy = "3.0"
static_cell = "2.1"
# Display rendering
display-interface = "0.5"
embedded-graphics = "0.8"
ssd1306 = { version = "0.10", features = ["async"] }
# Async IO traits and combinators
embedded-io-async = "0.7"
embassy-futures = "0.1"
# ESP32-S3 (Heltec V3) — requires esp toolchain (espup)
esp-backtrace = { version = "0.18", optional = true, features = [
"esp32s3",
"panic-handler",
"defmt",
] }
esp-hal = { version = "1.0", optional = true, features = [
"esp32s3",
"unstable",
] }
esp-println = { version = "0.16", optional = true, features = [
"esp32s3",
"defmt-espflash",
] }
esp-rtos = { version = "0.2", optional = true, features = [
"esp32s3",
"embassy",
] }
# nRF52840 (RAK 4631)
cortex-m = { version = "0.7", optional = true, features = ["critical-section-single-core"] }
cortex-m-rt = { version = "0.7", optional = true, features = ["set-vtor"] }
defmt-rtt = { version = "1.0", optional = true }
embassy-nrf = { version = "0.10", optional = true, features = [
"nrf52840",
"gpiote",
"time-driver-rtc1",
"defmt",
] }
panic-probe = { version = "1.0", optional = true, features = ["print-defmt"] }
[build-dependencies]
minijinja = "2.18"
owo-colors = "4.3"
[features]
heltec_v3 = [
"dep:esp-backtrace",
"dep:esp-hal",
"dep:esp-println",
"dep:esp-rtos",
]
heltec_v3_uart = [
"dep:esp-backtrace",
"dep:esp-hal",
"dep:esp-println",
"dep:esp-rtos",
]
heltec_v4 = [
"dep:esp-backtrace",
"dep:esp-hal",
"dep:esp-println",
"dep:esp-rtos",
]
rak_wisblock_4631 = [
"dep:cortex-m",
"dep:cortex-m-rt",
"dep:defmt-rtt",
"dep:embassy-nrf",
"dep:panic-probe",
"embassy-executor/arch-cortex-m",
"embassy-executor/executor-thread",
]
wio_tracker_l1 = [
"dep:cortex-m",
"dep:cortex-m-rt",
"dep:defmt-rtt",
"dep:embassy-nrf",
"dep:panic-probe",
"embassy-executor/arch-cortex-m",
"embassy-executor/executor-thread",
]
[lints.clippy]
undocumented_unsafe_blocks = "deny"
[profile.release]
codegen-units = 1
lto = "fat"
opt-level = 3 # "s"/"z" break Xtensa (l32r literal range exceeded)
panic = "abort"
strip = true
-291
View File
@@ -1,291 +0,0 @@
# DongLoRa USB Protocol
DongLoRa communicates over USB CDC-ACM (virtual serial port).
No baud rate configuration needed.
**USB identifiers:** VID `1209`, PID `5741`
## Framing
Each message is [COBS](https://en.wikipedia.org/wiki/Consistent_Overhead_Byte_Stuffing)-encoded
and terminated with a `0x00` sentinel byte:
```
[COBS-encoded bytes] [0x00]
```
COBS guarantees `0x00` never appears in the encoded data, so the
sentinel unambiguously marks frame boundaries. USB packets are
chunked at 64 bytes.
## Encoding
All integers are **fixed-size little-endian**. No variable-length
encoding. Every field has a known size at a known offset.
| Type | Size | Encoding |
|------|------|----------|
| `u8` | 1 | Raw byte |
| `i8` | 1 | Raw byte (two's complement) |
| `u16` | 2 | Little-endian |
| `i16` | 2 | Little-endian (two's complement) |
| `u32` | 4 | Little-endian |
## RadioConfig (13 bytes)
| Offset | Size | Field | Type | Valid range |
|--------|------|-------|------|-------------|
| 0 | 4 | `freq_hz` | u32 LE | 150,000,000 – 960,000,000 |
| 4 | 1 | `bw` | u8 | 0–9 (see bandwidth table) |
| 5 | 1 | `sf` | u8 | 5–12 |
| 6 | 1 | `cr` | u8 | 5–8 (coding rate denominator) |
| 7 | 2 | `sync_word` | u16 LE | any |
| 9 | 1 | `tx_power_dbm` | i8 | board-dependent, or `-128` for max |
| 10 | 2 | `preamble_len` | u16 LE | 6–65535, or `0` for default (16) |
| 12 | 1 | `cad` | u8 | 0 = disabled, non-zero = enabled |
Set `tx_power_dbm` to `-128` (0x80) to use the board's maximum
TX power. The firmware resolves this to the actual hardware max.
Set `preamble_len` to `0` to use the firmware default of 16 symbols.
Longer preambles improve reliability in mesh/noisy environments at
the cost of slightly longer air time.
Set `cad` to `1` (or any non-zero value) to enable Channel Activity
Detection (listen-before-talk) before each transmission. The radio
checks for channel activity and waits for a clear channel before
transmitting. Set to `0` to disable. Default: enabled.
## Commands (Host → Firmware)
Each command is a tag byte followed by fixed-size fields.
| Tag | Command | Payload |
|-----|---------|---------|
| 0 | Ping | — |
| 1 | GetConfig | — |
| 2 | SetConfig | RadioConfig (10 bytes) |
| 3 | StartRx | — |
| 4 | StopRx | — |
| 5 | Transmit | has_config (1) + [RadioConfig if 1] + len (u16 LE) + payload |
| 6 | DisplayOn | — |
| 7 | DisplayOff | — |
| 8 | GetMac | — |
## Responses (Firmware → Host)
| Tag | Response | Payload |
|-----|----------|---------|
| 0 | Pong | — |
| 1 | Config | RadioConfig (10 bytes) |
| 2 | RxPacket | rssi (i16 LE) + snr (i16 LE) + len (u16 LE) + payload |
| 3 | TxDone | — |
| 4 | Ok | — |
| 5 | Error | code (u8) |
| 6 | MacAddress | 6 raw bytes (board's MAC/device address) |
## Bandwidth
| Value | Bandwidth |
|-------|-----------|
| 0 | 7.8 kHz |
| 1 | 10.4 kHz |
| 2 | 15.6 kHz |
| 3 | 20.8 kHz |
| 4 | 31.25 kHz |
| 5 | 41.7 kHz |
| 6 | 62.5 kHz |
| 7 | 125 kHz |
| 8 | 250 kHz |
| 9 | 500 kHz |
## Error Codes
| Value | Error | Meaning |
|-------|-------|---------|
| 0 | InvalidConfig | Radio config validation failed |
| 1 | RadioBusy | Radio busy (e.g. RX restart failed) |
| 2 | TxTimeout | Transmission timed out |
| 3 | CrcError | CRC error on received packet |
| 4 | NotConfigured | SetConfig required first |
| 5 | NoDisplay | No display attached |
## Worked Example
Configure for 915 MHz, 125 kHz BW, SF7, CR 4/5, max TX power, default preamble, CAD on:
### 1. Build the bytes
```python
import struct
config = struct.pack("<IBBBHBHB",
915_000_000, # freq_hz
7, # bw (125 kHz)
7, # sf
5, # cr (4/5)
0x1424, # sync_word
0x80, # tx_power_dbm (-128 = max)
0, # preamble_len (0 = default 16)
1, # cad (1 = enabled)
)
command = b"\x02" + config # tag 2 = SetConfig
```
Raw bytes: `02 C0 CA 89 36 07 07 05 24 14 80 00 00 01`
### 2. COBS-encode and send
COBS-encode the bytes, append `0x00`, write to USB serial port.
Read back the response, COBS-decode it. First byte `0x04` = Ok.
## Command/Response Discipline
### Solicited vs. unsolicited responses
Every response is either **solicited** (exactly one per command) or
**unsolicited** (arrives at any time):
| Response | Category |
|----------|----------|
| `RxPacket` | **Unsolicited** — arrives whenever a LoRa packet is received during an active RX session |
| All others | **Solicited** — exactly one per command, in the order commands were sent |
### Command → response mapping
Each command produces exactly one solicited response:
| Command | Success | Possible errors |
|---------|---------|-----------------|
| Ping | Pong | — |
| GetConfig | Config | Error(NotConfigured) |
| SetConfig | Ok | Error(InvalidConfig) |
| StartRx | Ok | Error(NotConfigured), Error(InvalidConfig) |
| StopRx | Ok | — |
| Transmit | TxDone | Error(NotConfigured), Error(InvalidConfig), Error(TxTimeout) |
| DisplayOn | Ok | Error(NoDisplay) |
| DisplayOff | Ok | Error(NoDisplay) |
| GetMac | MacAddress | — |
### One outstanding command
The host **must** wait for the solicited response to the current
command before sending the next command. Pipelining multiple commands
without waiting for responses produces **undefined response ordering**.
### Processing order
Commands are processed in FIFO order. The firmware never reorders
commands or drops them silently.
### Interleaving with RxPacket
Between sending a command and receiving its solicited response, the
host may receive zero or more unsolicited `RxPacket` frames.
Hosts **must skip** `RxPacket` frames (tag 2) when waiting for a
solicited response. Skipped `RxPacket` frames should be buffered,
not discarded, if the application needs received LoRa data.
### Locally-handled commands
`DisplayOn`, `DisplayOff`, and `GetMac` are handled immediately by
the USB task and respond without involving the radio. All other
commands are routed to the radio task and respond after radio
processing. The one-outstanding-command rule ensures these two
response paths never conflict.
## Received Packet Quality
The SX126x radio will demodulate and deliver packets that are
technically intact (LoRa CRC passed) but were received so close to the
noise floor that the payload may be corrupt. This is inherent to LoRa:
the radio's internal CRC is only 16 bits, and at very low SNR the false-
positive rate is non-trivial. The firmware delivers every packet the
radio accepts — it is the host's responsibility to assess quality.
### SNR demodulation floor
Each spreading factor has a theoretical minimum SNR for reliable
demodulation. Packets received below this floor are statistically
likely to contain errors even though the radio reported a valid CRC:
| SF | Min SNR (dB) |
|----|--------------|
| 5 | -2.5 |
| 6 | -5.0 |
| 7 | -7.5 |
| 8 | -10.0 |
| 9 | -12.5 |
| 10 | -15.0 |
| 11 | -17.5 |
| 12 | -20.0 |
The pattern is: **min_snr = -2.5 × (SF - 4)**.
### SNR validity range
The SX126x reports SNR as a signed value in the range **-32 to +32 dB**.
Values outside this range indicate a firmware or decoding error — treat
the packet as unreliable.
### Recommended quality grading
Hosts should classify each `RxPacket` before trusting the payload:
```
function grade_packet(snr, sf):
# Step 1: reject impossible SNR values
if snr < -32 or snr > 32:
return INVALID # bad metadata, discard or flag
# Step 2: check against demodulation floor
min_snr = -2.5 * (sf - 4)
margin = snr - min_snr # dB above the floor
if margin < 0:
return UNRELIABLE # below floor, likely corrupt
if margin < 3:
return MARGINAL # near floor, may contain errors
return GOOD # comfortable margin
```
**What to do with each grade:**
- **GOOD** — process normally.
- **MARGINAL** — process, but prefer application-layer integrity
checks (checksums, sequence numbers, known-format validation)
before acting on the payload. Log for diagnostics.
- **UNRELIABLE** — log for diagnostics but do not trust the payload
for routing decisions, command execution, or retransmission.
Applications may still display the data with a warning.
- **INVALID** — discard. The SNR metadata itself is corrupt.
### Application-layer defenses
For safety-critical or command-and-control applications, do not rely
solely on the radio CRC. Add your own integrity checks:
- An application-layer CRC or hash over the payload
- Sequence numbers to detect duplicates and missing packets
- Known-format validation (magic bytes, expected lengths)
- Require acknowledgement before acting on commands
### Radio config impact
Wider bandwidth and lower spreading factor increase throughput but
reduce sensitivity, making low-SNR packets more common in marginal
links. If your application sees frequent MARGINAL/UNRELIABLE packets,
consider increasing SF or decreasing BW to improve link margin at the
cost of air time.
## Host Implementation Checklist
1. Open USB serial port (find by VID:PID `1209:5741`)
2. Implement COBS encode/decode (libraries exist for every language)
3. Pack commands with `struct.pack` or equivalent — all fields are fixed-size LE
4. Optionally send `GetMac`, read back `MacAddress` (useful with multiple dongles)
5. Send `SetConfig`, read back `Ok`
6. Send `StartRx`, read back `Ok`
7. Loop: read frames, decode `RxPacket` (rssi, snr, payload at known offsets)
8. To transmit: send `Transmit`, read back `TxDone`
See `examples/` for working Python implementations.
-61
View File
@@ -1,61 +0,0 @@
# DongLoRa Firmware
Embedded Rust firmware for DongLoRa boards. Exposes the LoRa radio over
USB CDC-ACM using a COBS-framed binary protocol.
## Building
```sh
just setup # one-time toolchain install
just build heltec_v4 # build for a specific board
just flash heltec_v4 # build + flash
just check-all # compile-check all boards
just clippy heltec_v4 # lint
just test # host-side protocol unit tests
```
All commands are run from the `firmware/` directory.
## Supported Boards
| Board | Feature | MCU | Radio | USB | Target |
|-------|---------|-----|-------|-----|--------|
| Heltec V3 | `heltec_v3` | ESP32-S3 | SX1262 | Native CDC-ACM | xtensa-esp32s3-none-elf |
| Heltec V3 (UART) | `heltec_v3_uart` | ESP32-S3 | SX1262 | CP2102 bridge | xtensa-esp32s3-none-elf |
| Heltec V4 | `heltec_v4` | ESP32-S3 | SX1262 | Native CDC-ACM | xtensa-esp32s3-none-elf |
| RAK WisBlock 4631 | `rak_wisblock_4631` | nRF52840 | SX1262 | Native CDC-ACM | thumbv7em-none-eabihf |
| Wio Tracker L1 | `wio_tracker_l1` | nRF52840 | SX1262 | Native CDC-ACM | thumbv7em-none-eabihf |
### Heltec V3 USB Note
The default `heltec_v3` build uses **native USB CDC-ACM**, which requires a
hardware modification to your board. Stock Heltec V3 boards route USB through a
CP2102 bridge chip to UART — the ESP32-S3's native USB pins (GPIO19/GPIO20) are
routed to header pins but not connected to the USB connector.
**To enable native USB on a Heltec V3:**
1. Solder 0-ohm (or 22-ohm) resistors at pads **R29** (D+) and **R3** (D-)
2. Disconnect the CP2102 from the USB connector's D+/D- lines (cut traces or
remove the CP2102 bridge resistors)
After this mod, the board appears as `/dev/ttyACM0` with the DongLoRa VID:PID
(1209:5741), just like the V4 and nRF boards.
**If you have a stock (unmodified) V3**, use `heltec_v3_uart` instead:
```sh
just build heltec_v3_uart
just flash heltec_v3_uart
```
This builds firmware that communicates via the CP2102 bridge (appears as
`/dev/ttyUSB0`).
## Adding a Board
See [src/board/PORTING.md](src/board/PORTING.md).
## Protocol
See [PROTOCOL.md](PROTOCOL.md) for the complete wire format specification.
-4
View File
@@ -1,4 +0,0 @@
- LED blinking on TX/RX? colored?
- publish new client, mux, bridge (updates in client for UART crap)
- split out mux and client repos
- move to GH organization
-121
View File
@@ -1,121 +0,0 @@
use minijinja::{context, Environment};
use owo_colors::OwoColorize;
use std::collections::HashMap;
use std::fs;
fn main() {
// Protocol tests run on the host without a board feature.
if std::env::var_os("DONGLORA_HOST_TEST").is_some() {
return;
}
let board_dir = "src/board";
// ── Discover boards vs helpers by content ───────────────────────
//
// A .rs file in src/board/ that implements LoRaBoard is a board.
// Everything else (traits.rs, esp32s3.rs, etc.) is a helper module.
// No exclusion lists — detection is based on what the file contains.
let mut boards: Vec<String> = Vec::new();
for entry in fs::read_dir(board_dir).expect("failed to read src/board directory") {
let p = match entry {
Ok(e) => e.path(),
Err(_) => continue,
};
if !p.is_file() || p.extension().and_then(|e| e.to_str()) != Some("rs") {
continue;
}
let stem = match p.file_stem().and_then(|s| s.to_str()) {
Some("mod") => continue, // Skip generated file
Some(s) => s.to_string(),
None => continue,
};
let content = match fs::read_to_string(&p) {
Ok(c) => c,
Err(_) => continue,
};
if content.contains("LoRaBoard for Board") {
boards.push(stem);
}
}
boards.sort(); // Deterministic ordering
// ── Validate exactly one board feature is enabled ───────────────
let enabled: Vec<_> = std::env::vars()
.filter(|(k, _)| k.starts_with("CARGO_FEATURE_"))
.map(|(k, _)| k.replace("CARGO_FEATURE_", "").to_lowercase())
.filter(|f| boards.contains(f))
.collect();
match enabled.len() {
0 => panic!(
"\n\n{}\n",
format!("No board selected! Found: {:?}", boards)
.red()
.bold()
),
1 => println!("cargo:rustc-cfg=board_selected"),
_ => panic!(
"\n\n{}\n",
format!("Conflict: Multiple boards enabled {:?}", enabled)
.yellow()
.bold()
),
}
// ── Auto-discover helper modules used by board files ────────────
//
// Scan each board file for `use super::<name>` to find which helper
// modules they depend on. Generate cfg gates so helpers compile only
// when a dependent board is selected.
let mut helper_boards: HashMap<String, Vec<String>> = HashMap::new();
for board in &boards {
let path = format!("{}/{}.rs", board_dir, board);
if let Ok(content) = fs::read_to_string(&path) {
for line in content.lines() {
let trimmed = line.trim();
if let Some(rest) = trimmed.strip_prefix("use super::") {
let helper = rest
.split(|c: char| !c.is_alphanumeric() && c != '_')
.next()
.unwrap_or("");
if !helper.is_empty() && helper != "traits" {
helper_boards
.entry(helper.to_string())
.or_default()
.push(board.clone());
}
}
}
}
}
// ── Render board module template ────────────────────────────────
let mut env = Environment::new();
let template = fs::read_to_string("src/board/mod.rs.j2").expect("missing src/board/mod.rs.j2");
env.add_template("mod", &template)
.expect("failed to parse board template");
let rendered = env
.get_template("mod")
.expect("template not found")
.render(context!(boards => boards, helpers => helper_boards))
.expect("failed to render board module");
fs::write("src/board/mod.rs", rendered).expect("failed to write src/board/mod.rs");
println!("cargo:rerun-if-changed=src/board/mod.rs.j2");
println!("cargo:rerun-if-changed={}", board_dir);
// cortex-m-rt's link.x does `INCLUDE memory.x`. Copy our nRF layout into
// OUT_DIR so it's only visible to ARM builds.
let target = std::env::var("TARGET").unwrap_or_default();
if !target.starts_with("xtensa") {
let out_dir = std::env::var("OUT_DIR").unwrap();
fs::copy("ld/nrf52840-memory.x", format!("{out_dir}/memory.x"))
.expect("failed to copy ld/nrf52840-memory.x");
println!("cargo:rustc-link-search={out_dir}");
}
println!("cargo:rerun-if-changed=ld/nrf52840-memory.x");
}
-211
View File
@@ -1,211 +0,0 @@
set shell := ["bash", "-c"]
# Board definitions: feature target chip
# Xtensa targets use nightly cargo + esp rustc + -Zbuild-std=core.
# Tool versions are pinned in mise.toml; run `just setup` to install everything.
heltec_v3 := "heltec_v3 xtensa-esp32s3-none-elf esp32s3"
heltec_v3_uart := "heltec_v3_uart xtensa-esp32s3-none-elf esp32s3"
heltec_v4 := "heltec_v4 xtensa-esp32s3-none-elf esp32s3"
rak_wisblock_4631 := "rak_wisblock_4631 thumbv7em-none-eabihf nRF52840_xxAA"
wio_tracker_l1 := "wio_tracker_l1 thumbv7em-none-eabihf nRF52840_xxAA"
builds_dir := "builds"
version := `sed -n 's/^version = "\(.*\)"/\1/p' Cargo.toml`
# All known boards
boards := "heltec_v3 heltec_v3_uart heltec_v4 rak_wisblock_4631 wio_tracker_l1"
# Install all required tools and toolchains
setup:
mise trust --yes
mise install
@# ESP Xtensa toolchain (not managed by mise)
@if ! rustup toolchain list | grep -q "^esp"; then \
echo "ESP toolchain not found, installing via espup (this may take a while)..."; \
espup install || { echo "error: espup install failed" >&2; exit 1; }; \
fi
@# nightly rust-src for -Zbuild-std
@if ! rustup component list --toolchain nightly --installed 2>/dev/null | grep -q "^rust-src"; then \
rustup component add rust-src --toolchain nightly; \
fi
@# ARM target
@rustup target list --installed | grep -q "^thumbv7em-none-eabihf$" || rustup target add thumbv7em-none-eabihf
# Build release firmware for all boards, installing toolchains as needed
build-all:
@trap 'exit 130' INT; \
for board in {{boards}}; do \
if just _can_build $board; then \
echo "── building $board ──"; \
just build $board || exit $?; \
else \
echo "── skipping $board (toolchain install failed) ──"; \
fi; \
done
# Check all boards compile, installing toolchains as needed
check-all:
@trap 'exit 130' INT; \
for board in {{boards}}; do \
if just _can_build $board; then \
echo "── checking $board ──"; \
just check $board || exit $?; \
else \
echo "── skipping $board (toolchain install failed) ──"; \
fi; \
done
# Check a single board compiles
check board:
@just _cargo {{board}} "check --release"
# Run clippy on a single board
clippy board:
@just _cargo {{board}} "clippy --release" "-- -D warnings"
# Build release firmware and copy to builds/ with a readable name
build board profile="release":
@just _cargo {{board}} "build --{{profile}}"
@just _copy_firmware {{board}} {{profile}}
# Build and flash a board (espflash for Xtensa, UF2 DFU for ARM)
flash board:
@just _ensure_tools
@just build {{board}} release
@read -r feat target chip <<< "$(just _info {{board}})"; \
case "$target" in \
xtensa-*) \
port=$(just _find_port "303a:1001" "1209:5741" "10c4:ea60"); \
if [ -n "$port" ]; then \
echo "Using $port"; \
espflash flash -p "$port" "{{builds_dir}}/donglora-{{board}}-v{{version}}.elf"; \
else \
echo "No port found, falling back to espflash auto-detection..." >&2; \
espflash flash "{{builds_dir}}/donglora-{{board}}-v{{version}}.elf"; \
fi ;; \
*) just _flash_uf2 {{board}} ;; \
esac
# Flash ARM board via debug probe (requires J-Link or similar)
flash-probe board:
@just _ensure_tools
@just build {{board}} release
@read -r feat target chip <<< "$(just _info {{board}})"; \
probe-rs run --chip $chip "{{builds_dir}}/donglora-{{board}}-v{{version}}.elf"
# Show binary size for a release build
size board:
@just _cargo {{board}} "size --release"
# Run host-side protocol unit tests
test:
DONGLORA_HOST_TEST=1 cargo test
# Check for outdated dependencies
outdated:
@# embassy-executor 0.10 renamed arch-* → platform-* features.
@# esp-rtos 0.2 still pins embassy-executor ^0.9, blocking the upgrade.
@# cargo-outdated crashes trying to resolve 0.10 with the old feature name.
cargo outdated --exclude embassy-executor
# ── Private helpers ───────────────────────────────────────────────────
# Install mise-managed tools if any are missing (fast no-op when current)
[private]
_ensure_tools:
@mise trust --yes . 2>/dev/null; mise install --quiet
# Run a cargo command for a board.
# Xtensa: nightly cargo + esp rustc (via RUSTC override) + -Zbuild-std=core
[private]
_cargo board cmd extra_args="":
@just _ensure_tools
@read -r feat target chip <<< "$(just _info {{board}})"; \
env=""; extra=""; \
case "$target" in xtensa-*) \
just _require_esp_toolchain; \
[ -f "$HOME/export-esp.sh" ] && . "$HOME/export-esp.sh"; \
env="RUSTC=$(rustup which rustc --toolchain esp)"; extra="+nightly"; buildstd="-Zbuild-std=core,alloc";; \
esac; \
eval $env cargo $extra {{cmd}} --target "$target" --features "$feat" $buildstd {{extra_args}}
# Ensure a board's toolchain is available, auto-installing if needed
[private]
_can_build board:
@read -r feat target chip <<< "$(just _info {{board}})"; \
case "$target" in \
xtensa-*) just _require_esp_toolchain ;; \
*) rustup target list --installed | grep -q "^$target$" || rustup target add "$target" ;; \
esac
# Ensure the ESP Xtensa toolchain is installed (espup installed via mise)
[private]
_require_esp_toolchain:
@just _ensure_tools
@if ! rustup toolchain list | grep -q "^esp"; then \
echo "ESP toolchain not found, installing via espup (this may take a while)..." >&2; \
espup install || { echo "error: espup install failed" >&2; exit 1; }; \
fi; \
if ! rustup component list --toolchain nightly --installed 2>/dev/null | grep -q "^rust-src"; then \
echo "Installing nightly rust-src (needed for -Zbuild-std)..." >&2; \
rustup component add rust-src --toolchain nightly || { echo "error: failed to install rust-src" >&2; exit 1; }; \
fi
# Find a serial port matching any of the given VID:PID pairs (checked in order)
[private]
_find_port +vid_pids:
@for vidpid in {{vid_pids}}; do \
vid="${vidpid%%:*}"; pid="${vidpid##*:}"; \
for dev in /dev/ttyACM* /dev/ttyUSB*; do \
[ -e "$dev" ] || continue; \
info=$(udevadm info --query=property --name="$dev" 2>/dev/null) || continue; \
dev_vid=$(echo "$info" | sed -n 's/^ID_VENDOR_ID=//p' | tr '[:upper:]' '[:lower:]'); \
dev_pid=$(echo "$info" | sed -n 's/^ID_MODEL_ID=//p' | tr '[:upper:]' '[:lower:]'); \
if [ "$dev_vid" = "$vid" ] && [ "$dev_pid" = "$pid" ]; then \
echo "$dev"; exit 0; \
fi; \
done; \
done
[private]
_copy_firmware board profile:
@mkdir -p {{builds_dir}}
@read -r feat target chip <<< "$(just _info {{board}})"; \
src="target/$target/{{profile}}/donglora"; \
dst="{{builds_dir}}/donglora-{{board}}-v{{version}}"; \
if [ -f "$src" ]; then \
cp "$src" "$dst.elf"; echo "→ $dst.elf"; \
case "$target" in \
thumbv7em-*) \
mise exec -- rust-objcopy -O ihex "$src" "$dst.hex"; \
mise exec -- cargo-hex-to-uf2 hex-to-uf2 --input-path "$dst.hex" --output-path "$dst.uf2" --family nrf52840; \
rm "$dst.hex"; \
echo "→ $dst.uf2"; \
;; \
esac; \
fi
# Copy UF2 to a mounted UF2 bootloader drive
[private]
_flash_uf2 board:
@uf2="{{builds_dir}}/donglora-{{board}}-v{{version}}.uf2"; \
if [ ! -f "$uf2" ]; then \
echo "error: $uf2 not found — run 'just build {{board}}' first" >&2; exit 1; \
fi; \
mnt="$(find /run/media -maxdepth 3 -name INFO_UF2.TXT -printf '%h\n' -quit 2>/dev/null)"; \
if [ -z "$mnt" ]; then \
echo "error: no UF2 drive found — double-tap reset to enter bootloader" >&2; exit 1; \
fi; \
echo "Copying to $mnt ..."; \
cp "$uf2" "$mnt/"; \
sync; \
echo "Done — board will reboot automatically"
[private]
_info name:
@if [ "{{name}}" == "heltec_v3" ]; then echo "{{heltec_v3}}"; \
elif [ "{{name}}" == "heltec_v3_uart" ]; then echo "{{heltec_v3_uart}}"; \
elif [ "{{name}}" == "heltec_v4" ]; then echo "{{heltec_v4}}"; \
elif [ "{{name}}" == "rak_wisblock_4631" ]; then echo "{{rak_wisblock_4631}}"; \
elif [ "{{name}}" == "wio_tracker_l1" ]; then echo "{{wio_tracker_l1}}"; \
else echo "Unknown board: {{name}}" >&2; exit 1; fi
-16
View File
@@ -1,16 +0,0 @@
/* nRF52840 memory layout — Adafruit UF2 bootloader
*
* 0x00000 – 0x01000 MBR (Master Boot Record)
* 0x01000 – 0xEA000 Application ← we live here
* 0xEA000 – 0xF4000 Reserved (app data)
* 0xF4000 – 0xFC000 UF2 Bootloader
* 0xFF000 – 0x100000 Bootloader settings
*
* RAM: first 8 bytes reserved for MBR interrupt forwarding.
*/
MEMORY
{
FLASH : ORIGIN = 0x00001000, LENGTH = 0xE9000
RAM : ORIGIN = 0x20000008, LENGTH = 255K
}
-9
View File
@@ -1,9 +0,0 @@
[tools]
rust = "1.93"
just = "latest"
"cargo:cargo-binutils" = "latest" # `cargo size`, `cargo objdump`, etc.
"cargo:cargo-hex-to-uf2" = "latest" # ELF → UF2 via Intel HEX for nRF52840 DFU
"cargo:espup" = "0.16"
"cargo:espflash" = "3" # 4.x has broken nix dependency on current stable Rust
"cargo:probe-rs-tools" = "0.31"
-96
View File
@@ -1,96 +0,0 @@
# Porting DongLoRa to a New Board
## Steps
### 1. Create the board file
Create `src/board/your_board.rs`. Use an existing board as a template:
- ESP32-S3 boards: copy `heltec_v4.rs` (uses shared helpers from `board/esp32s3.rs` and `hal/esp32s3.rs`)
- nRF52840 boards: copy `rak_wisblock_4631.rs` (uses shared helpers from `hal/nrf52840.rs`)
- New MCU family: create a new `hal/<mcu>.rs` with MCU primitives first
`build.rs` auto-discovers your board file — any `.rs` file in `src/board/` that
implements `LoRaBoard for Board` is automatically detected as a board. Helper
modules (like `esp32s3.rs`) are auto-discovered via `use super::<helper>` imports
in board files. No template editing or exclusion lists needed.
### 2. Define concrete types
Your board must export these type aliases:
```rust
pub type RadioDriver = ...; // Must work with lora_phy::LoRa<RadioDriver, Delay>
pub type UsbDriver = ...; // Must implement embassy_usb_driver::Driver<'static>
// (or UartDriver for UART boards)
pub type DisplayI2c = ...; // Must implement embedded_hal_async::i2c::I2c
pub type DisplayDriver = ...; // Must implement DrawTarget<Color = BinaryColor>
```
### 3. Define peripheral bundles
```rust
pub struct RadioParts {
pub driver: RadioDriver,
pub delay: embassy_time::Delay,
}
pub struct UsbParts { // or UartParts for UART boards
pub driver: UsbDriver,
}
pub struct DisplayParts {
pub i2c: DisplayI2c,
}
```
### 4. Implement the LoRaBoard trait
```rust
use super::traits::{BoardParts, LoRaBoard};
impl LoRaBoard for Board {
const NAME: &'static str = "Your Board Name";
const TX_POWER_RANGE: (i8, i8) = (-9, 22); // check your radio + PA
type RadioParts = RadioParts;
type CommParts = UsbParts; // or UartParts
type DisplayParts = DisplayParts;
type DisplayDriver = DisplayDriver;
fn init() -> Self { ... }
fn mac_address() -> [u8; 6] { ... } // read from efuse, FICR, etc.
fn into_parts(self) -> BoardParts<RadioParts, UsbParts, DisplayParts> {
// Initialize buses via hal::, construct drivers, return BoardParts
BoardParts { radio, host, display: Some(display_parts), mac: Self::mac_address() }
}
}
```
### 5. Add display init
```rust
pub async fn create_display(i2c: DisplayI2c) -> Option<DisplayDriver> {
// Construct and initialize your display driver (SSD1306, SH1106, etc.)
}
```
### 6. Add Cargo feature
In `firmware/Cargo.toml`, add a feature with your board's HAL dependencies:
```toml
[features]
your_board = ["dep:your-hal", ...]
```
### 7. Add to justfile
Add your board's feature/target/chip to the board definitions at the top of `justfile`.
### 8. Build and test
```sh
just check your_board # Must compile
just clippy your_board # Must be clean
just build your_board # Produces firmware
```
-98
View File
@@ -1,98 +0,0 @@
//! Shared types and peripheral wiring for ESP32-S3 boards.
//!
//! Delegates MCU-level init to `hal::esp32s3` and defines peripheral-level
//! types (RadioDriver, display) used by ESP32-S3 board files.
//! Each board defines its own comm types (UsbParts/UartParts) directly.
use embassy_embedded_hal::shared_bus::asynch::spi::SpiDevice;
use embassy_sync::blocking_mutex::raw::NoopRawMutex;
use embassy_time::Delay;
use esp_hal::gpio::{Input, InputConfig, Level, Output, OutputConfig, Pull};
use lora_phy::iv::GenericSx126xInterfaceVariant;
use lora_phy::sx126x::{self, Sx1262, Sx126x};
use crate::hal::esp32s3 as mcu;
// ── Re-export MCU types ─────────────────────────────────────────────
pub use mcu::{I2cBus as DisplayI2c, SpiBus};
// ── Concrete peripheral types ───────────────────────────────────────
type Nss = Output<'static>;
type Iv = GenericSx126xInterfaceVariant<Output<'static>, Input<'static>>;
type RadioSpiDevice = SpiDevice<'static, NoopRawMutex, SpiBus, Nss>;
pub type RadioDriver = Sx126x<RadioSpiDevice, Iv, Sx1262>;
// ── Peripheral bundles ──────────────────────────────────────────────
pub struct RadioParts {
pub driver: RadioDriver,
pub delay: Delay,
}
pub struct DisplayParts {
pub i2c: DisplayI2c,
}
// ── Display driver ──────────────────────────────────────────────────
pub type DisplayDriver = ssd1306::Ssd1306Async<
ssd1306::prelude::I2CInterface<DisplayI2c>,
ssd1306::size::DisplaySize128x64,
ssd1306::mode::BufferedGraphicsModeAsync<ssd1306::size::DisplaySize128x64>,
>;
/// Construct and initialize an SSD1306 display from raw I2C.
pub async fn create_display(i2c: DisplayI2c) -> Option<DisplayDriver> {
use ssd1306::mode::DisplayConfigAsync;
use ssd1306::prelude::{Brightness, DisplayRotation};
use ssd1306::size::DisplaySize128x64;
use ssd1306::{I2CDisplayInterface, Ssd1306Async};
let interface = I2CDisplayInterface::new(i2c);
let mut display = Ssd1306Async::new(interface, DisplaySize128x64, DisplayRotation::Rotate0)
.into_buffered_graphics_mode();
embassy_time::Timer::after_millis(100).await;
if display.init().await.is_err() {
defmt::error!("SSD1306 display init failed");
return None;
}
let _ = display.set_brightness(Brightness::BRIGHTEST).await;
Some(display)
}
// ── Shared peripheral init helpers ──────────────────────────────────
/// Construct SX1262 radio from an initialized SPI bus.
pub fn init_radio(
spi_bus: &'static embassy_sync::mutex::Mutex<NoopRawMutex, SpiBus>,
nss_pin: esp_hal::peripherals::GPIO8<'static>,
reset_pin: esp_hal::peripherals::GPIO12<'static>,
dio1_pin: esp_hal::peripherals::GPIO14<'static>,
busy_pin: esp_hal::peripherals::GPIO13<'static>,
) -> RadioParts {
let nss = Output::new(nss_pin, Level::High, OutputConfig::default());
let spi_device = SpiDevice::new(spi_bus, nss);
let reset = Output::new(reset_pin, Level::High, OutputConfig::default());
let dio1 = Input::new(dio1_pin, InputConfig::default().with_pull(Pull::Down));
let busy = Input::new(busy_pin, InputConfig::default().with_pull(Pull::Down));
let iv = GenericSx126xInterfaceVariant::new(reset, dio1, busy, None, None)
.expect("SX1262 interface init");
let sx_config = sx126x::Config {
chip: Sx1262,
tcxo_ctrl: Some(sx126x::TcxoCtrlVoltage::Ctrl1V8),
use_dcdc: true,
rx_boost: false,
};
RadioParts {
driver: Sx126x::new(spi_device, iv, sx_config),
delay: Delay,
}
}
-97
View File
@@ -1,97 +0,0 @@
//! Heltec WiFi LoRa 32 V3 — native USB (requires hardware mod).
//!
//! Stock V3 boards route USB through a CP2102 bridge to UART. To use native
//! USB CDC-ACM, solder R29 (D+) and R3 (D-) on the PCB and disconnect the
//! CP2102 from the USB data lines. See the project README for details.
//!
//! For stock (unmodified) V3 boards, use the `heltec_v3_uart` feature instead.
use esp_hal::gpio::{Level, Output, OutputConfig};
use static_cell::StaticCell;
use super::esp32s3;
use super::traits::{BoardParts, LoRaBoard};
use crate::driver::simple_led::SimpleLed;
use crate::hal::esp32s3 as mcu;
pub use super::esp32s3::{
create_display, DisplayDriver, DisplayParts, RadioDriver, RadioParts,
};
pub type UsbDriver = esp_hal::otg_fs::asynch::Driver<'static>;
pub type LedDriver = SimpleLed<Output<'static>>;
pub struct UsbParts {
pub driver: UsbDriver,
}
pub struct Board {
p: esp_hal::peripherals::Peripherals,
}
impl LoRaBoard for Board {
const NAME: &'static str = "Heltec V3";
const TX_POWER_RANGE: (i8, i8) = (-9, 22);
type RadioParts = RadioParts;
type CommParts = UsbParts;
type DisplayParts = DisplayParts;
type DisplayDriver = DisplayDriver;
type LedDriver = LedDriver;
fn init() -> Self {
let p = esp_hal::init(esp_hal::Config::default());
Self { p }
}
fn mac_address() -> [u8; 6] {
mcu::mac_address()
}
fn into_parts(self) -> BoardParts<RadioParts, UsbParts, DisplayParts, LedDriver> {
let p = self.p;
mcu::start_timer(p.TIMG0);
// Vext power: GPIO36, active LOW to enable peripherals
let vext = Output::new(p.GPIO36, Level::Low, OutputConfig::default());
core::mem::forget(vext);
let spi_bus = mcu::init_spi(p.SPI2, p.DMA_CH0, p.GPIO9, p.GPIO10, p.GPIO11);
let radio = esp32s3::init_radio(spi_bus, p.GPIO8, p.GPIO12, p.GPIO14, p.GPIO13);
// Note: switches internal USB PHY from Serial-JTAG to OTG.
let usb_inst = esp_hal::otg_fs::Usb::new(p.USB0, p.GPIO20, p.GPIO19);
static EP_OUT_BUF: StaticCell<[u8; 1024]> = StaticCell::new();
let ep_out_buf = EP_OUT_BUF.init([0u8; 1024]);
let host = UsbParts {
driver: esp_hal::otg_fs::asynch::Driver::new(
usb_inst,
ep_out_buf,
esp_hal::otg_fs::asynch::Config::default(),
),
};
// SSD1306 display reset: pulse GPIO21 low->high before I2C init
let mut display_rst = Output::new(p.GPIO21, Level::Low, OutputConfig::default());
esp_hal::delay::Delay::new().delay_millis(10);
display_rst.set_high();
esp_hal::delay::Delay::new().delay_millis(10);
core::mem::forget(display_rst);
let i2c = mcu::init_i2c(p.I2C0, p.GPIO17, p.GPIO18);
let display = Some(DisplayParts { i2c });
// White LED on GPIO35
let led_pin = Output::new(p.GPIO35, Level::Low, OutputConfig::default());
let led = SimpleLed(led_pin);
BoardParts {
radio,
host,
display,
led,
mac: Self::mac_address(),
}
}
}
-93
View File
@@ -1,93 +0,0 @@
//! Heltec WiFi LoRa 32 V3 — stock UART (via CP2102 bridge).
//!
//! This is for unmodified V3 boards where USB goes through the CP2102
//! bridge to UART0. The board appears as /dev/ttyUSB* (not ttyACM*).
//!
//! If you've done the hardware mod (solder R29/R3, disconnect CP2102),
//! use the `heltec_v3` feature instead for native USB CDC-ACM support.
use esp_hal::gpio::{Level, Output, OutputConfig};
use super::esp32s3;
use super::traits::{BoardParts, LoRaBoard};
use crate::driver::simple_led::SimpleLed;
use crate::hal::esp32s3 as mcu;
pub use super::esp32s3::{
create_display, DisplayDriver, DisplayParts, RadioDriver, RadioParts,
};
pub type UartDriver = esp_hal::uart::Uart<'static, esp_hal::Async>;
pub type LedDriver = SimpleLed<Output<'static>>;
pub struct UartParts {
pub driver: UartDriver,
}
pub struct Board {
p: esp_hal::peripherals::Peripherals,
}
impl LoRaBoard for Board {
const NAME: &'static str = "Heltec V3 (UART)";
const TX_POWER_RANGE: (i8, i8) = (-9, 22);
type RadioParts = RadioParts;
type CommParts = UartParts;
type DisplayParts = DisplayParts;
type DisplayDriver = DisplayDriver;
type LedDriver = LedDriver;
fn init() -> Self {
let p = esp_hal::init(esp_hal::Config::default());
Self { p }
}
fn mac_address() -> [u8; 6] {
mcu::mac_address()
}
fn into_parts(self) -> BoardParts<RadioParts, UartParts, DisplayParts, LedDriver> {
let p = self.p;
mcu::start_timer(p.TIMG0);
// Vext power: GPIO36, active LOW to enable peripherals
let vext = Output::new(p.GPIO36, Level::Low, OutputConfig::default());
core::mem::forget(vext);
let spi_bus = mcu::init_spi(p.SPI2, p.DMA_CH0, p.GPIO9, p.GPIO10, p.GPIO11);
let radio = esp32s3::init_radio(spi_bus, p.GPIO8, p.GPIO12, p.GPIO14, p.GPIO13);
use esp_hal::uart::{Config as UartConfig, Uart};
let host = UartParts {
driver: Uart::new(p.UART0, UartConfig::default())
.expect("UART init")
.with_tx(p.GPIO43)
.with_rx(p.GPIO44)
.into_async(),
};
// SSD1306 display reset: pulse GPIO21 low->high before I2C init
let mut display_rst = Output::new(p.GPIO21, Level::Low, OutputConfig::default());
esp_hal::delay::Delay::new().delay_millis(10);
display_rst.set_high();
esp_hal::delay::Delay::new().delay_millis(10);
core::mem::forget(display_rst);
let i2c = mcu::init_i2c(p.I2C0, p.GPIO17, p.GPIO18);
let display = Some(DisplayParts { i2c });
// White LED on GPIO35
let led_pin = Output::new(p.GPIO35, Level::Low, OutputConfig::default());
let led = SimpleLed(led_pin);
BoardParts {
radio,
host,
display,
led,
mac: Self::mac_address(),
}
}
}
-90
View File
@@ -1,90 +0,0 @@
use esp_hal::gpio::{Level, Output, OutputConfig};
use static_cell::StaticCell;
use super::esp32s3;
use super::traits::{BoardParts, LoRaBoard};
use crate::driver::simple_led::SimpleLed;
use crate::hal::esp32s3 as mcu;
pub use super::esp32s3::{
create_display, DisplayDriver, DisplayParts, RadioDriver, RadioParts,
};
pub type UsbDriver = esp_hal::otg_fs::asynch::Driver<'static>;
pub type LedDriver = SimpleLed<Output<'static>>;
pub struct UsbParts {
pub driver: UsbDriver,
}
pub struct Board {
p: esp_hal::peripherals::Peripherals,
}
impl LoRaBoard for Board {
const NAME: &'static str = "Heltec V4";
const TX_POWER_RANGE: (i8, i8) = (-9, 22);
type RadioParts = RadioParts;
type CommParts = UsbParts;
type DisplayParts = DisplayParts;
type DisplayDriver = DisplayDriver;
type LedDriver = LedDriver;
fn init() -> Self {
let p = esp_hal::init(esp_hal::Config::default());
Self { p }
}
fn mac_address() -> [u8; 6] {
mcu::mac_address()
}
fn into_parts(self) -> BoardParts<RadioParts, UsbParts, DisplayParts, LedDriver> {
let p = self.p;
mcu::start_timer(p.TIMG0);
// Vext power: GPIO36, active LOW to enable peripherals
let vext = Output::new(p.GPIO36, Level::Low, OutputConfig::default());
core::mem::forget(vext);
let spi_bus = mcu::init_spi(p.SPI2, p.DMA_CH0, p.GPIO9, p.GPIO10, p.GPIO11);
let radio = esp32s3::init_radio(spi_bus, p.GPIO8, p.GPIO12, p.GPIO14, p.GPIO13);
// Note: switches internal USB PHY from Serial-JTAG to OTG.
let usb_inst = esp_hal::otg_fs::Usb::new(p.USB0, p.GPIO20, p.GPIO19);
static EP_OUT_BUF: StaticCell<[u8; 1024]> = StaticCell::new();
let ep_out_buf = EP_OUT_BUF.init([0u8; 1024]);
let host = UsbParts {
driver: esp_hal::otg_fs::asynch::Driver::new(
usb_inst,
ep_out_buf,
esp_hal::otg_fs::asynch::Config::default(),
),
};
// SSD1315 display reset: pulse GPIO21 low->high before I2C init
let mut display_rst = Output::new(p.GPIO21, Level::Low, OutputConfig::default());
esp_hal::delay::Delay::new().delay_millis(10);
display_rst.set_high();
esp_hal::delay::Delay::new().delay_millis(10);
core::mem::forget(display_rst);
let i2c = mcu::init_i2c(p.I2C0, p.GPIO17, p.GPIO18);
let display = Some(DisplayParts { i2c });
// Orange LED on GPIO35
let led_pin = Output::new(p.GPIO35, Level::Low, OutputConfig::default());
let led = SimpleLed(led_pin);
BoardParts {
radio,
host,
display,
led,
mac: Self::mac_address(),
}
}
}
-16
View File
@@ -1,16 +0,0 @@
// @generated by build.rs — DO NOT EDIT
pub mod traits;
pub use traits::*;
{% for helper, helper_features in helpers|items -%}
#[cfg(any({% for f in helper_features %}feature = "{{ f }}"{% if not loop.last %}, {% endif %}{% endfor %}))]
mod {{ helper }};
{% endfor %}
cfg_if::cfg_if! {
{%- for board in boards %}
{% if loop.first %}if{% else %}else if{% endif %} #[cfg(feature = "{{ board }}")] {
mod {{ board }};
pub use {{ board }}::*;
}
{%- endfor %}
}
-144
View File
@@ -1,144 +0,0 @@
use embassy_embedded_hal::shared_bus::asynch::spi::SpiDevice;
use embassy_nrf::gpio::{Input, Level, Output, OutputDrive, Pull};
use embassy_nrf::spim::{self, Spim};
use embassy_nrf::twim;
use embassy_nrf::usb::Driver;
use embassy_sync::blocking_mutex::raw::NoopRawMutex;
use embassy_time::Delay;
use lora_phy::iv::GenericSx126xInterfaceVariant;
use lora_phy::sx126x::{self, Sx1262, Sx126x};
use super::traits::{BoardParts, LoRaBoard};
use crate::hal::nrf52840 as mcu;
// ── Concrete peripheral types ───────────────────────────────────────
type Nss = Output<'static>;
type Iv = GenericSx126xInterfaceVariant<Output<'static>, Input<'static>>;
type RadioSpiDevice = SpiDevice<'static, NoopRawMutex, mcu::SpiBus, Nss>;
pub type RadioDriver = Sx126x<RadioSpiDevice, Iv, Sx1262>;
pub type UsbDriver = mcu::UsbNrfDriver;
pub type DisplayI2c = mcu::I2cBus;
pub type LedDriver = ();
pub type DisplayDriver = ssd1306::Ssd1306Async<
ssd1306::prelude::I2CInterface<DisplayI2c>,
ssd1306::size::DisplaySize128x64,
ssd1306::mode::BufferedGraphicsModeAsync<ssd1306::size::DisplaySize128x64>,
>;
// ── Peripheral bundles ──────────────────────────────────────────────
pub struct RadioParts {
pub driver: RadioDriver,
pub delay: Delay,
}
pub struct UsbParts {
pub driver: UsbDriver,
}
pub struct DisplayParts {
pub i2c: DisplayI2c,
}
// ── Display init ────────────────────────────────────────────────────
pub async fn create_display(i2c: DisplayI2c) -> Option<DisplayDriver> {
use ssd1306::mode::DisplayConfigAsync;
use ssd1306::prelude::{Brightness, DisplayRotation};
use ssd1306::size::DisplaySize128x64;
use ssd1306::{I2CDisplayInterface, Ssd1306Async};
let interface = I2CDisplayInterface::new(i2c);
let mut display = Ssd1306Async::new(interface, DisplaySize128x64, DisplayRotation::Rotate0)
.into_buffered_graphics_mode();
embassy_time::Timer::after_millis(100).await;
if display.init().await.is_err() {
defmt::error!("SSD1306 display init failed");
return None;
}
let _ = display.set_brightness(Brightness::BRIGHTEST).await;
Some(display)
}
// ── Board init ──────────────────────────────────────────────────────
pub struct Board {
p: embassy_nrf::Peripherals,
}
impl LoRaBoard for Board {
const NAME: &'static str = "RAK WisBlock 4631";
const TX_POWER_RANGE: (i8, i8) = (-9, 22);
type RadioParts = RadioParts;
type CommParts = UsbParts;
type DisplayParts = DisplayParts;
type DisplayDriver = DisplayDriver;
type LedDriver = ();
fn init() -> Self {
let p = embassy_nrf::init(Default::default());
Self { p }
}
fn mac_address() -> [u8; 6] {
mcu::mac_address()
}
fn into_parts(self) -> BoardParts<RadioParts, UsbParts, DisplayParts, ()> {
let p = self.p;
// ── SPI bus for SX1262 ──────────────────────────────────
let mut spi_cfg = spim::Config::default();
spi_cfg.frequency = spim::Frequency::M1;
let spi = Spim::new(p.SPI3, mcu::Irqs, p.P1_11, p.P1_13, p.P1_12, spi_cfg);
let spi_bus = mcu::share_spi_bus(spi);
let nss = Output::new(p.P1_10, Level::High, OutputDrive::Standard);
let spi_device = SpiDevice::new(spi_bus, nss);
let reset = Output::new(p.P1_06, Level::High, OutputDrive::Standard);
let dio1 = Input::new(p.P1_15, Pull::Down);
let busy = Input::new(p.P1_14, Pull::Down);
let iv = GenericSx126xInterfaceVariant::new(reset, dio1, busy, None, None)
.expect("SX1262 interface init");
let sx_config = sx126x::Config {
chip: Sx1262,
tcxo_ctrl: Some(sx126x::TcxoCtrlVoltage::Ctrl1V8),
use_dcdc: true,
rx_boost: false,
};
let radio = RadioParts {
driver: Sx126x::new(spi_device, iv, sx_config),
delay: Delay,
};
// ── USB ─────────────────────────────────────────────────
let vbus = mcu::alloc_vbus_detect(true, false);
let host = UsbParts {
driver: Driver::new(p.USBD, mcu::Irqs, vbus),
};
// ── Display (optional RAK1921 SSD1306 OLED on I2C) ─────
let twim_buf = mcu::alloc_i2c_buffer();
let i2c = embassy_nrf::twim::Twim::new(
p.TWISPI0, mcu::Irqs, p.P0_13, p.P0_14, twim::Config::default(), twim_buf,
);
let display = Some(DisplayParts { i2c });
BoardParts {
radio,
host,
display,
led: (),
mac: Self::mac_address(),
}
}
}
-61
View File
@@ -1,61 +0,0 @@
//! Board abstraction trait.
//!
//! Every board must implement [`LoRaBoard`] with concrete associated types
//! for its radio, host communication, display, and LED peripherals. The
//! compiler enforces completeness — missing types or methods are compile errors.
//!
//! See `src/board/PORTING.md` for a step-by-step guide to adding a new board.
use embedded_graphics::draw_target::DrawTarget;
use embedded_graphics::pixelcolor::BinaryColor;
/// Async RGB LED control. Boards without an LED use `()` which is a no-op.
pub trait RgbLed {
fn set_rgb(&mut self, r: u8, g: u8, b: u8) -> impl core::future::Future<Output = ()>;
}
impl RgbLed for () {
async fn set_rgb(&mut self, _r: u8, _g: u8, _b: u8) {}
}
/// Compile-time contract for a DongLoRa board.
pub trait LoRaBoard: Sized {
/// Human-readable board name (shown on display splash screen).
const NAME: &'static str;
/// TX power range in dBm (min, max) for this board's radio + PA.
const TX_POWER_RANGE: (i8, i8);
/// Radio peripheral bundle (driver + delay).
type RadioParts;
/// Host communication peripheral bundle (USB or UART driver).
type CommParts;
/// Display peripheral bundle (I2C bus for display init).
type DisplayParts;
/// Concrete display driver type (must implement DrawTarget for rendering).
type DisplayDriver: DrawTarget<Color = BinaryColor>;
/// RGB LED driver. Boards without an LED use `()`.
type LedDriver: RgbLed;
/// Initialize the board hardware.
fn init() -> Self;
/// Read the board's unique hardware address (MAC, device ID, etc.).
fn mac_address() -> [u8; 6];
/// Decompose initialized board into peripheral bundles for each task.
fn into_parts(self) -> BoardParts<Self::RadioParts, Self::CommParts, Self::DisplayParts, Self::LedDriver>;
}
/// Peripheral bundles for each firmware task, produced by [`LoRaBoard::into_parts`].
pub struct BoardParts<R, C, D, L> {
pub radio: R,
pub host: C,
pub display: Option<D>,
pub led: L,
pub mac: [u8; 6],
}
-147
View File
@@ -1,147 +0,0 @@
use embassy_embedded_hal::shared_bus::asynch::spi::SpiDevice;
use embassy_nrf::gpio::{Input, Level, Output, OutputDrive, Pull};
use embassy_nrf::spim::{self, Spim};
use embassy_nrf::twim;
use embassy_nrf::usb::Driver;
use embassy_sync::blocking_mutex::raw::NoopRawMutex;
use embassy_time::Delay;
use lora_phy::iv::GenericSx126xInterfaceVariant;
use lora_phy::sx126x::{self, Sx1262, Sx126x};
use super::traits::{BoardParts, LoRaBoard};
use crate::hal::nrf52840 as mcu;
// ── Concrete peripheral types ───────────────────────────────────────
type Nss = Output<'static>;
type Iv = GenericSx126xInterfaceVariant<Output<'static>, Input<'static>>;
type RadioSpiDevice = SpiDevice<'static, NoopRawMutex, mcu::SpiBus, Nss>;
pub type RadioDriver = Sx126x<RadioSpiDevice, Iv, Sx1262>;
pub type UsbDriver = mcu::UsbNrfDriver;
pub type DisplayI2c = mcu::I2cBus;
pub type LedDriver = crate::driver::simple_led::SimpleLed<Output<'static>>;
pub type DisplayDriver = crate::driver::sh1106::Sh1106<DisplayI2c>;
// ── Peripheral bundles ──────────────────────────────────────────────
pub struct RadioParts {
pub driver: RadioDriver,
pub delay: Delay,
}
pub struct UsbParts {
pub driver: UsbDriver,
}
pub struct DisplayParts {
pub i2c: DisplayI2c,
}
// ── Display init ────────────────────────────────────────────────────
pub async fn create_display(i2c: DisplayI2c) -> Option<DisplayDriver> {
let mut display = crate::driver::sh1106::Sh1106::new(i2c, 0x3D);
embassy_time::Timer::after_millis(100).await;
if display.init().await.is_err() {
defmt::error!("SH1106 display init failed");
return None;
}
let _ = display.set_brightness(0xFF).await;
Some(display)
}
// ── Board init ──────────────────────────────────────────────────────
pub struct Board {
p: embassy_nrf::Peripherals,
}
impl LoRaBoard for Board {
const NAME: &'static str = "Wio Tracker L1";
const TX_POWER_RANGE: (i8, i8) = (-9, 22);
type RadioParts = RadioParts;
type CommParts = UsbParts;
type DisplayParts = DisplayParts;
type DisplayDriver = DisplayDriver;
type LedDriver = LedDriver;
fn init() -> Self {
let mut config = embassy_nrf::config::Config::default();
config.hfclk_source = embassy_nrf::config::HfclkSource::ExternalXtal;
config.lfclk_source = embassy_nrf::config::LfclkSource::ExternalXtal;
let p = embassy_nrf::init(config);
Self { p }
}
fn mac_address() -> [u8; 6] {
mcu::mac_address()
}
fn into_parts(self) -> BoardParts<RadioParts, UsbParts, DisplayParts, LedDriver> {
let p = self.p;
// ── SPI bus for SX1262 ──────────────────────────────────
let mut spi_cfg = spim::Config::default();
spi_cfg.frequency = spim::Frequency::M1;
let spi = Spim::new(p.SPI3, mcu::Irqs, p.P0_30, p.P0_03, p.P0_28, spi_cfg);
let spi_bus = mcu::share_spi_bus(spi);
let nss = Output::new(p.P1_14, Level::High, OutputDrive::Standard);
let spi_device = SpiDevice::new(spi_bus, nss);
let reset = Output::new(p.P1_07, Level::High, OutputDrive::Standard);
let dio1 = Input::new(p.P0_07, Pull::Down);
let busy = Input::new(p.P1_10, Pull::Down);
// RF switch: RXEN on P1.08 (GPIO, toggled by lora_phy), TX via DIO2 (SX1262 internal).
let rx_enable = Output::new(p.P1_08, Level::Low, OutputDrive::Standard);
let iv = GenericSx126xInterfaceVariant::new(reset, dio1, busy, Some(rx_enable), None)
.expect("SX1262 interface init");
let sx_config = sx126x::Config {
chip: Sx1262,
tcxo_ctrl: Some(sx126x::TcxoCtrlVoltage::Ctrl1V8),
use_dcdc: true,
rx_boost: false,
};
let radio = RadioParts {
driver: Sx126x::new(spi_device, iv, sx_config),
delay: Delay,
};
// ── USB ─────────────────────────────────────────────────
let vbus = mcu::alloc_vbus_detect(true, true);
let host = UsbParts {
driver: Driver::new(p.USBD, mcu::Irqs, vbus),
};
// ── BAT_CTL: P0.04, active HIGH — enables power to OLED and battery sense
let bat_ctl = Output::new(p.P0_04, Level::High, OutputDrive::Standard);
core::mem::forget(bat_ctl);
// ── Display (SH1106 OLED on I2C) ────────────────────────
let twim_buf = mcu::alloc_i2c_buffer();
let i2c = embassy_nrf::twim::Twim::new(
p.TWISPI0, mcu::Irqs, p.P0_06, p.P0_05, twim::Config::default(), twim_buf,
);
let display = Some(DisplayParts { i2c });
// Green LED on P1.01
let led_pin = Output::new(p.P1_01, Level::Low, OutputDrive::Standard);
let led = crate::driver::simple_led::SimpleLed(led_pin);
BoardParts {
radio,
host,
display,
led,
mac: Self::mac_address(),
}
}
}
Loaded 100 of 134 files, more files were not shown because too many files have changed in this diff. Show more