diff --git a/.gitignore b/.gitignore deleted file mode 100644 index af4d558a..00000000 --- a/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -__pycache__/ -.hypothesis/ diff --git a/ANSWERS.md b/ANSWERS.md deleted file mode 100644 index 8a0a60b8..00000000 --- a/ANSWERS.md +++ /dev/null @@ -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::, (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. diff --git a/CHANGELOG.md b/CHANGELOG.md deleted file mode 100644 index 1438fd8c..00000000 --- a/CHANGELOG.md +++ /dev/null @@ -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` 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 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md deleted file mode 100644 index 43b8bc19..00000000 --- a/CONTRIBUTING.md +++ /dev/null @@ -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 ` 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 # 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 diff --git a/DESIGN.md b/DESIGN.md deleted file mode 100644 index 1f18ea10..00000000 --- a/DESIGN.md +++ /dev/null @@ -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` slots for each, and the corresponding task is -only spawned when present. - -| Peripheral | Slot | Task | Examples | -|---|---|---|---| -| Display | `Option` | `display_task` | SSD1306 OLED, ST7789 TFT | -| Input | `Option` | `input_task` | T-Deck keyboard, buttons | -| Telemetry | `Option` | `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/` | diff --git a/LICENSE b/LICENSE deleted file mode 100644 index a4ad6f14..00000000 --- a/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2025 Stephen Waits - -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. diff --git a/README.md b/README.md index 3fa7bc76..4848cffd 100644 --- a/README.md +++ b/README.md @@ -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** GitHub organization](https://github.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://github.com/donglora/firmware) | +| Rust client library | [donglora/client-rs](https://github.com/donglora/client-rs) | +| Python client library | [donglora/client-py](https://github.com/donglora/client-py) | +| Rust mux daemon | [donglora/mux-rs](https://github.com/donglora/mux-rs) | +| Python mux daemon | [donglora/mux-py](https://github.com/donglora/mux-py) | +| MeshCore bridge | [donglora/bridge](https://github.com/donglora/bridge) | +| MeshCore AI bot | [donglora/ai-bot](https://github.com/donglora/ai-bot) | +| Example scripts | [donglora/examples](https://github.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.* - -
-Board Roadmap - -### 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 | - -
- -## Contributing - -See [CONTRIBUTING.md](CONTRIBUTING.md). - -## License - -[MIT](LICENSE) — Stephen Waits +This repo is archived and will not receive further updates. diff --git a/clients/python/.gitignore b/clients/python/.gitignore deleted file mode 100644 index 21d0b898..00000000 --- a/clients/python/.gitignore +++ /dev/null @@ -1 +0,0 @@ -.venv/ diff --git a/clients/python/README.md b/clients/python/README.md deleted file mode 100644 index 77756c27..00000000 --- a/clients/python/README.md +++ /dev/null @@ -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). diff --git a/clients/python/donglora/__init__.py b/clients/python/donglora/__init__.py deleted file mode 100644 index bb58efad..00000000 --- a/clients/python/donglora/__init__.py +++ /dev/null @@ -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(" 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(" 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("= 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, -} diff --git a/clients/python/mise.toml b/clients/python/mise.toml deleted file mode 100644 index a245b1c0..00000000 --- a/clients/python/mise.toml +++ /dev/null @@ -1,4 +0,0 @@ -[tools] -python = "3.14" -uv = "latest" -just = "latest" diff --git a/clients/python/pyproject.toml b/clients/python/pyproject.toml deleted file mode 100644 index 488621be..00000000 --- a/clients/python/pyproject.toml +++ /dev/null @@ -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" diff --git a/clients/python/uv.lock b/clients/python/uv.lock deleted file mode 100644 index 6e68fded..00000000 --- a/clients/python/uv.lock +++ /dev/null @@ -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" }, -] diff --git a/clients/rust/.gitignore b/clients/rust/.gitignore deleted file mode 100644 index ea8c4bf7..00000000 --- a/clients/rust/.gitignore +++ /dev/null @@ -1 +0,0 @@ -/target diff --git a/clients/rust/CHANGELOG.md b/clients/rust/CHANGELOG.md deleted file mode 100644 index cef926af..00000000 --- a/clients/rust/CHANGELOG.md +++ /dev/null @@ -1,27 +0,0 @@ -# Changelog - -## 0.1.0 — 2026-04-06 - -Initial release. - -### Features - -- High-level `Client` 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 diff --git a/clients/rust/CLAUDE_TODO.md b/clients/rust/CLAUDE_TODO.md deleted file mode 100644 index 7fa5ebc3..00000000 --- a/clients/rust/CLAUDE_TODO.md +++ /dev/null @@ -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> { - // 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, 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. diff --git a/clients/rust/Cargo.lock b/clients/rust/Cargo.lock deleted file mode 100644 index 039bb419..00000000 --- a/clients/rust/Cargo.lock +++ /dev/null @@ -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" diff --git a/clients/rust/Cargo.toml b/clients/rust/Cargo.toml deleted file mode 100644 index ee6579ce..00000000 --- a/clients/rust/Cargo.toml +++ /dev/null @@ -1,21 +0,0 @@ -[package] -name = "donglora-client" -version = "0.1.0" -edition = "2024" -authors = ["Stephen Waits "] -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" diff --git a/clients/rust/LICENSE b/clients/rust/LICENSE deleted file mode 100644 index a4ad6f14..00000000 --- a/clients/rust/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2025 Stephen Waits - -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. diff --git a/clients/rust/README.md b/clients/rust/README.md deleted file mode 100644 index d84defb5..00000000 --- a/clients/rust/README.md +++ /dev/null @@ -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` 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 diff --git a/clients/rust/justfile b/clients/rust/justfile deleted file mode 100644 index e0e33239..00000000 --- a/clients/rust/justfile +++ /dev/null @@ -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 diff --git a/clients/rust/src/client.rs b/clients/rust/src/client.rs deleted file mode 100644 index c9142b95..00000000 --- a/clients/rust/src/client.rs +++ /dev/null @@ -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 { - transport: T, - rx_buffer: VecDeque, -} - -impl Client { - /// 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 { - 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 = 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> { - 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> { - let mut packets: Vec = 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, - ) -> 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 { - 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); - } -} diff --git a/clients/rust/src/codec.rs b/clients/rust/src/codec.rs deleted file mode 100644 index fcdcfdea..00000000 --- a/clients/rust/src/codec.rs +++ /dev/null @@ -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 { - 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> { - 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, -} - -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> { - 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>> { - 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::::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 = (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()); - } -} diff --git a/clients/rust/src/connect.rs b/clients/rust/src/connect.rs deleted file mode 100644 index 2b56b866..00000000 --- a/clients/rust/src/connect.rs +++ /dev/null @@ -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 { - 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> { - 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> { - 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> { - // 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> { - 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> { - // 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 { - 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() -} diff --git a/clients/rust/src/discovery.rs b/clients/rust/src/discovery.rs deleted file mode 100644 index 0ce8d8dd..00000000 --- a/clients/rust/src/discovery.rs +++ /dev/null @@ -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 { - 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)); - } -} diff --git a/clients/rust/src/lib.rs b/clients/rust/src/lib.rs deleted file mode 100644 index ec67a35c..00000000 --- a/clients/rust/src/lib.rs +++ /dev/null @@ -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; diff --git a/clients/rust/src/protocol.rs b/clients/rust/src/protocol.rs deleted file mode 100644 index a84ed18d..00000000 --- a/clients/rust/src/protocol.rs +++ /dev/null @@ -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 { - 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 { - 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, - payload: Vec, - }, - 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 { - 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 { - 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 { - 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, - }, - 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 { - 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 { - 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] - ); - } -} diff --git a/clients/rust/src/transport.rs b/clients/rust/src/transport.rs deleted file mode 100644 index 735f8ccc..00000000 --- a/clients/rust/src/transport.rs +++ /dev/null @@ -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, - 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 { - 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 { - self.port.read(buf) - } -} - -impl Write for SerialTransport { - fn write(&mut self, buf: &[u8]) -> io::Result { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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(), - } - } -} diff --git a/demo.jpg b/demo.jpg deleted file mode 100644 index 811a0b21..00000000 Binary files a/demo.jpg and /dev/null differ diff --git a/examples/.gitignore b/examples/.gitignore deleted file mode 100644 index 093900ec..00000000 --- a/examples/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -.venv/ -meshcore/corpus/generated/ diff --git a/examples/README.md b/examples/README.md deleted file mode 100644 index b8de3063..00000000 --- a/examples/README.md +++ /dev/null @@ -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 diff --git a/examples/all_commands.py b/examples/all_commands.py deleted file mode 100755 index 3a3f1e74..00000000 --- a/examples/all_commands.py +++ /dev/null @@ -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) diff --git a/examples/justfile b/examples/justfile deleted file mode 100644 index e6c8c0c5..00000000 --- a/examples/justfile +++ /dev/null @@ -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}} diff --git a/examples/lora_bridge.py b/examples/lora_bridge.py deleted file mode 100755 index de373616..00000000 --- a/examples/lora_bridge.py +++ /dev/null @@ -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(" 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(" 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() diff --git a/examples/meshcore/README.md b/examples/meshcore/README.md deleted file mode 100644 index e6af1379..00000000 --- a/examples/meshcore/README.md +++ /dev/null @@ -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 -``` diff --git a/examples/meshcore/ai_bot.py b/examples/meshcore/ai_bot.py deleted file mode 100644 index 659ce161..00000000 --- a/examples/meshcore/ai_bot.py +++ /dev/null @@ -1,1424 +0,0 @@ -#!/usr/bin/env python3 -"""Orac: a terse AI assistant on the MeshCore LoRa mesh network. - -Monitors all decryptable MeshCore channels for !askai, !ai, !claude, or !orac -triggers. Also accepts direct messages (DMs) — no trigger needed for DMs. -Advertises itself on the mesh and persists state across restarts. -""" -import collections -import csv -import hashlib -import hmac -import json -import os -import random -import struct -import sys -import time - -import anthropic -import serial -from cobs import cobs -from Crypto.Cipher import AES -from nacl.bindings import crypto_scalarmult -from nacl.signing import SigningKey, VerifyKey -from pathlib import Path - -import donglora as dl - - -# ── Constants ───────────────────────────────────────────────────── - -BOT_NAME = "Orac" -MAX_GRP_TEXT = 163 # max bytes for "SenderName: message" in a GRP_TXT -MAX_RESPONSE_CHARS = MAX_GRP_TEXT - len(BOT_NAME) - 2 # subtract "Orac: " -# DM plaintext: timestamp(4) + txt_type_attempt(1) + text + padding to 16 -# Max payload 184 - dst(1) - src(1) - mac(2) = 180 bytes ciphertext -# 180 bytes ciphertext / 16 = 11 blocks → 176 bytes plaintext max -# 176 - 5 (header) = 171 bytes text, minus null terminator padding -MAX_DM_TEXT = 170 - -TRIGGERS = ("!askai", "!ai", "!claude", "!orac") -AT_MENTIONS = (f"@{BOT_NAME}".lower(), f"@[{BOT_NAME}]".lower()) # "@orac" and "@[Orac]" - -CHANNEL_HISTORY_SIZE = 20 # messages per channel -DM_HISTORY_SIZE = 20 # messages per DM peer - -RATE_LIMIT_PER_SENDER = 5 # for channel messages -RATE_LIMIT_GLOBAL = 2 # between any two responses -RATE_LIMIT_DM = 2 # for DMs (more interactive) - -ADVERT_INTERVAL = 7200 # 2 hours in seconds - -DATA_DIR = Path.home() / ".donglora" -KEY_FILE = DATA_DIR / "orac_key.bin" -STATE_FILE = DATA_DIR / "orac_state.json" - -RADIO_CONFIG = { - "freq_hz": 910_525_000, - "bw": 6, # 62.5 kHz - "sf": 7, - "cr": 5, # CR 4/5 - "sync_word": 0x3444, - "tx_power_dbm": -128, # TX_POWER_MAX -} - -SYSTEM_PROMPT = f"""\ -You are Orac, a terse AI assistant on a MeshCore LoRa radio mesh network. - -ABSOLUTE HARD LIMIT: Your ENTIRE response must be {{max_chars}} characters or fewer. -This is a physical constraint of the radio protocol. Every character beyond this limit -is permanently lost and never transmitted. There is NO exception. - -Stay well under {{max_chars}}. Aim for 120 or fewer. NEVER show a character count or HTML tags in your reply. - -You can see recent messages for context. When someone asks you a question, they may -be referring to the ongoing conversation — use the chat history to understand what they mean. -For example, "what do you think?" refers to whatever was just discussed. "tell me more" means -elaborate on the recent topic. Read the room. - -Rules: -- One sentence or short phrase. Never more. -- Emoji OK but don't overdo it. No markdown, no bullet points, no formatting. -- Use common abbreviations freely (e.g., w/, b/c, approx, etc). -- Skip all pleasantries, greetings, and filler. -- You have a dry, sardonic wit. Be helpful but never wordy. -- If the question requires a long answer, give the most important point only. -- NEVER repeat a previous answer. Always find a fresh angle, new phrasing, or different take. -- Be creative and unpredictable. Surprise the reader.\ -""" - -# ── ANSI helpers ────────────────────────────────────────────────── - -RST = "\033[0m" -DIM = "\033[2m" -RED = "\033[31m" -GRN = "\033[32m" -YEL = "\033[33m" -CYN = "\033[36m" -BCYN = "\033[1;36m" -BGRN = "\033[1;32m" -BMAG = "\033[1;35m" -BWHT = "\033[1;37m" - - -# ── Persistent state ───────────────────────────────────────────── - -_state: dict = { - "channel_history": {}, # channel_name → list of messages - "dm_history": {}, # peer_pubkey_hex → list of messages - "known_nodes": {}, # pubkey_hex → {"name": str, "seen": float} -} - - -def _load_state(): - global _state - if STATE_FILE.is_file(): - try: - with open(STATE_FILE) as f: - loaded = json.load(f) - # Merge with defaults - for key in _state: - if key in loaded: - _state[key] = loaded[key] - print(f" {DIM}Loaded state from {STATE_FILE}{RST}") - except Exception as e: - print(f" {YEL}Failed to load state: {e}{RST}") - - -def _save_state(): - try: - DATA_DIR.mkdir(parents=True, exist_ok=True) - tmp = STATE_FILE.with_suffix(".tmp") - with open(tmp, "w") as f: - json.dump(_state, f, indent=2) - tmp.rename(STATE_FILE) - except Exception as e: - print(f" {RED}Failed to save state: {e}{RST}") - - -# ── Identity / Keypair ─────────────────────────────────────────── - -_signing_key: SigningKey | None = None -_verify_key: VerifyKey | None = None -_pubkey_bytes: bytes = b"" - - -def _init_identity(): - """Load or generate Ed25519 keypair. Persisted to disk.""" - global _signing_key, _verify_key, _pubkey_bytes - - DATA_DIR.mkdir(parents=True, exist_ok=True) - - if KEY_FILE.is_file(): - seed = KEY_FILE.read_bytes() - if len(seed) == 32: - _signing_key = SigningKey(seed) - print(f" {DIM}Loaded keypair from {KEY_FILE}{RST}") - else: - print(f" {YEL}Invalid key file, regenerating{RST}") - _signing_key = SigningKey.generate() - KEY_FILE.write_bytes(bytes(_signing_key)) - else: - _signing_key = SigningKey.generate() - KEY_FILE.write_bytes(bytes(_signing_key)) - print(f" {BWHT}Generated new keypair → {KEY_FILE}{RST}") - - _verify_key = _signing_key.verify_key - _pubkey_bytes = bytes(_verify_key) - print(f" {DIM}Pubkey: {_pubkey_bytes.hex()}{RST}") - print(f" {DIM}Node hash: 0x{_pubkey_bytes[0]:02x}{RST}") - - -def _my_hash() -> int: - """Our 1-byte node hash (first byte of pubkey).""" - return _pubkey_bytes[0] - - -# ── MeshCore channel crypto ────────────────────────────────────── - - -def _channel_secret_from_hashtag(name: str) -> bytes: - h = hashlib.sha256(name.encode()).digest() - return h[:16] + b"\x00" * 16 - - -def _channel_hash(secret: bytes) -> int: - 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: - key_len = 32 # HMAC always uses full 32-byte secret (spec Section 14) - computed = hmac.new(secret[:key_len], ciphertext, hashlib.sha256).digest()[:2] - if computed != mac_bytes: - return None - 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: - if len(plaintext) < 5: - return None - timestamp = struct.unpack_from(" bytes: - """Compute X25519 shared secret from our Ed25519 key and peer's Ed25519 pubkey.""" - my_x25519 = bytes(_signing_key.to_curve25519_private_key()) - peer_verify = VerifyKey(peer_ed25519_pub) - peer_x25519 = bytes(peer_verify.to_curve25519_public_key()) - return crypto_scalarmult(my_x25519, peer_x25519) - - -def _peer_encrypt(shared_secret: bytes, text: str) -> bytes: - """Encrypt a DM plaintext. Returns mac(2) + ciphertext.""" - plaintext = struct.pack(" bytes | None: - """Verify MAC then decrypt a peer message. Returns plaintext or None.""" - computed = hmac.new(shared_secret[:32], ciphertext, hashlib.sha256).digest()[:2] - if computed != mac_bytes: - return None - cipher = AES.new(shared_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_peer_plaintext(plaintext: bytes) -> str | None: - """Parse decrypted peer message: timestamp(4) + txt_type_attempt(1) + text.""" - if len(plaintext) < 5: - return None - text = plaintext[5:].split(b"\x00", 1)[0] - return text.decode("utf-8", errors="replace") if text else None - - -# ── Node registry ──────────────────────────────────────────────── - -# pubkey_hex → {"name": str, "seen": float} -# Stored in _state["known_nodes"], persisted to disk - -ADVERT_MAX_AGE = 43200 # 12 hours — used for display, NOT for key expiry - -# Pending DMs from unknown senders — queued until we learn their pubkey via ADVERT -# src_hash → list of (raw_payload, monotonic_timestamp) -_pending_dms: dict[int, list[tuple[bytes, float]]] = {} -PENDING_DM_TTL = 300 # 5 minutes — discard stale pending DMs - -# ── Route table ───────────────────────────────────────────────── -# Learned return paths from incoming packets. -# src_hash (int) → (reversed_path_hops: list[bytes], hash_size: int) -_route_table: dict[int, tuple[list[bytes], int]] = {} - - -def _learn_route(src_hash: int, path_hops: list[bytes], hash_size: int): - """Learn a return route from an incoming packet's path. - - path_hops is ordered closest-relay-first as received. - We reverse it so our outgoing path goes through the same relays - back toward the sender. Always updates the route, but prefers - larger hash sizes (3 > 2 > 1) for better addressing precision. - """ - existing = _route_table.get(src_hash) - if existing is not None: - _, existing_hs = existing - if hash_size < existing_hs: - return # Don't downgrade hash size - _route_table[src_hash] = (list(reversed(path_hops)), hash_size) - - -def _get_route(dest_hash: int) -> tuple[list[bytes], int] | None: - """Get a learned return route for a destination, or None if unknown.""" - return _route_table.get(dest_hash) - - -_RT_NAMES = {0: "tflood", 1: "flood", 2: "direct", 3: "tdirect"} - - -def _route_name(route_type: int) -> str: - return _RT_NAMES.get(route_type, f"rt{route_type}") - - -def _register_node(pubkey: bytes, name: str) -> bool: - """Register a node. Returns True if this is a NEW node (not just an update).""" - pk_hex = pubkey.hex() - is_new = pk_hex not in _state["known_nodes"] - _state["known_nodes"][pk_hex] = {"name": name, "seen": time.time()} - _save_state() - return is_new - - -def _lookup_node_by_hash(hash_byte: int) -> list[tuple[bytes, str]]: - """Find all known nodes whose pubkey first byte matches. Returns [(pubkey, name)]. - - Never expires keys — once we learn a peer's pubkey, we can always decrypt - their DMs. Peers shouldn't need to re-advertise just to message the bot. - """ - results = [] - for pk_hex, info in _state["known_nodes"].items(): - pk_bytes = bytes.fromhex(pk_hex) - if pk_bytes[0] == hash_byte: - results.append((pk_bytes, info["name"])) - return results - - -def _node_name(pubkey_hex: str) -> str: - info = _state["known_nodes"].get(pubkey_hex) - return info["name"] if info else pubkey_hex[:8] - - -# ── Channel registry ───────────────────────────────────────────── - -_KNOWN_CHANNELS: dict[str, bytes] = {} -_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(): - if _CHANNELS_CSV.is_file(): - 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: - secret = bytes.fromhex(key_hex) + b"\x00" * 16 - _register_channel(name, secret) - else: - print(f" {RED}channels.csv not found at {_CHANNELS_CSV}{RST}") - - for name in ("#devtest", "#devtestdevtest", "#askai", "#orac", "#ai", "#aibot", "#aibots"): - _register_channel(name, _channel_secret_from_hashtag(name)) - - -_init_channels() - - -# ── GRP_TXT encrypt + transmit ─────────────────────────────────── - - -def _grp_encrypt(secret: bytes, sender: str, text: str) -> bytes: - ch = _channel_hash(secret) - plaintext = struct.pack(" bytes: - header = bytes([0x15]) # GRP_TXT flood: (5 << 2) | 1 - path_len = bytes([0x40]) # 0 hops, 2-byte hash mode - return header + path_len + channel_payload - - -def _transmit_packet(ser: serial.Serial, packet: bytes, label: str = "Transmit"): - """Transmit a raw MeshCore packet.""" - try: - resp = send_cmd(ser, {"type": "Transmit", "payload": packet}, label=label) - if resp is None: - print(f" {RED}TX failed: no response from radio{RST}") - elif resp["type"] == "Error": - print(f" {RED}TX failed: {resp}{RST}") - except Exception as e: - print(f" {RED}TX exception: {e}{RST}") - - -def _grp_transmit(ser: serial.Serial, channel_name: str, sender: str, text: str): - secret = _KNOWN_CHANNELS.get(channel_name) - if secret is None: - print(f" {RED}TX failed: unknown channel {channel_name}{RST}") - return - payload = _grp_encrypt(secret, sender, text) - packet = _grp_build_packet(payload) - _transmit_packet(ser, packet) - - -# ── ADVERT construction + transmit ─────────────────────────────── - - -def _build_advert_packet() -> bytes: - """Build a MeshCore ADVERT packet for this node.""" - timestamp = struct.pack(" bytes: - """Build a packet using a learned route if available, otherwise flood.""" - route = _get_route(dest_hash) - if route is not None: - hops, hash_size = route - hash_size_code = hash_size - 1 - path_len_byte = (hash_size_code << 6) | (len(hops) & 0x3F) - path_data = b"".join(hops) - # direct route_type = 2 - header = bytes([(payload_type << 2) | 2]) - return header + bytes([path_len_byte]) + path_data + payload - else: - # flood route_type = 1 - header = bytes([(payload_type << 2) | 1]) - path_len = bytes([0x40]) # 0 hops, 2-byte hash mode - return header + path_len + payload - - -def _dm_transmit(ser: serial.Serial, peer_pubkey: bytes, text: str): - """Encrypt and transmit a TXT_MSG DM using learned route or flood.""" - # Simulate human typing delay (~40ms/char, clamped to 1–5s) - delay = min(max(len(text) * 0.04, 1.0), 5.0) - time.sleep(delay) - shared_secret = _ecdh_shared_secret(peer_pubkey) - mac_ct = _peer_encrypt(shared_secret, text) - - # Payload: dest_hash(1) + src_hash(1) + mac(2) + ciphertext - dm_payload = bytes([peer_pubkey[0], _my_hash()]) + mac_ct - - packet = _build_routed_packet(0x02, dm_payload, peer_pubkey[0]) - - peer_name = _node_name(peer_pubkey.hex()) - route = _get_route(peer_pubkey[0]) - route_str = "direct" if route is not None else "flood" - _transmit_packet(ser, packet, label=f"DM({route_str})\u2192{peer_name}") - - -# ── ACK transmit ───────────────────────────────────────────────── - - -def _compute_ack_hash(plaintext: bytes, sender_pubkey: bytes) -> bytes: - """Compute 4-byte ACK hash per MeshCore BaseChatMesh.cpp. - - Hash = SHA-256(frag1 || sender_pubkey)[0:4] - frag1 = timestamp(4) + txt_type_attempt(1) + text (no null terminator) - sender_pubkey = sender's Ed25519 public key (32 bytes) - """ - text_bytes = plaintext[5:] - null_pos = text_bytes.find(b"\x00") - text_len = null_pos if null_pos >= 0 else len(text_bytes) - frag1 = plaintext[: 5 + text_len] - return hashlib.sha256(frag1 + sender_pubkey).digest()[:4] - - -def _send_ack(ser: serial.Serial, ack_crc: bytes, dest_hash: int): - """Send an ACK using learned route or flood, with a brief delay for the DM flood to clear.""" - time.sleep(0.5) - packet = _build_routed_packet(0x03, ack_crc, dest_hash) - _transmit_packet(ser, packet, label="ACK") - - -def _send_path_return(ser: serial.Serial, peer_pubkey: bytes, path_hops: list[bytes], hash_size: int): - """Send a PATH return (0x08) so the sender can learn a direct route to us. - - Per spec Section 9: inner plaintext = path_len(1) + path + extra_type(1) + extra. - The path is reversed (our incoming path becomes the sender's outgoing path). - """ - reversed_hops = list(reversed(path_hops)) - hash_size_code = hash_size - 1 - path_len_byte = (hash_size_code << 6) | (len(reversed_hops) & 0x3F) - path_data = b"".join(reversed_hops) - - # Inner plaintext: path_len + path + 0xFF (dummy extra) + 4 random bytes - inner = bytes([path_len_byte]) + path_data + b"\xff" + os.urandom(4) - pad_len = (16 - len(inner) % 16) % 16 - inner += b"\x00" * pad_len - - shared_secret = _ecdh_shared_secret(peer_pubkey) - cipher = AES.new(shared_secret[:16], AES.MODE_ECB) - ciphertext = b"" - for i in range(0, len(inner), 16): - ciphertext += cipher.encrypt(inner[i : i + 16]) - mac = hmac.new(shared_secret[:32], ciphertext, hashlib.sha256).digest()[:2] - - # Outer: dest_hash + src_hash + mac + ciphertext - path_payload = bytes([peer_pubkey[0], _my_hash()]) + mac + ciphertext - packet = _build_routed_packet(0x08, path_payload, peer_pubkey[0]) - _transmit_packet(ser, packet, label="PATH") - - -# ── Serial / USB ───────────────────────────────────────────────── - - -def open_connection(port: str | None = None): - """Open a DongLoRa connection (mux auto-detected, falls back to USB).""" - return dl.connect(port=port, timeout=2) - - -# ── COBS framing ───────────────────────────────────────────────── - - -def cobs_frame(payload: bytes) -> bytes: - return cobs.encode(payload) + b"\x00" - - -def read_frame(ser: serial.Serial) -> bytes | None: - 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 - - -# ── Command encoding / response decoding ───────────────────────── - - -def encode_radio_config(cfg: dict) -> bytes: - return struct.pack( - " bytes: - kind = cmd["type"] - tags = { - "Ping": 0, "GetConfig": 1, "SetConfig": 2, "StartRx": 3, - "StopRx": 4, "Transmit": 5, "DisplayOn": 6, "DisplayOff": 7, - } - out = bytes([tags[kind]]) - if kind == "SetConfig": - out += encode_radio_config(cmd["config"]) - elif kind == "Transmit": - config = cmd.get("config") - if config is None: - out += b"\x00" - else: - out += b"\x01" + encode_radio_config(config) - payload = cmd["payload"] - out += struct.pack(" dict: - if not data: - return {"type": "Empty"} - tag = data[0] - rest = data[1:] - if tag == 0: - return {"type": "Pong"} - elif tag == 1: - return {"type": "Config", "raw": rest.hex()} - elif tag == 2: - rssi = struct.unpack_from(" dict | None: - payload = encode_command(cmd) - frame = cobs_frame(payload) - if not quiet: - print(f" {DIM}>>>{RST} {label}") - ser.write(frame) - ser.flush() - for _ in range(50): - resp_data = read_frame(ser) - if resp_data is None: - if not quiet: - print(f" {DIM}<<<{RST} {YEL}timeout{RST}") - return None - resp = decode_response(resp_data) - if resp["type"] == "RxPacket": - continue - if not quiet: - t = resp["type"] - print(f" {DIM}<<<{RST} {t}") - return resp - if not quiet: - print(f" {DIM}<<<{RST} {YEL}timeout (RxPacket flood){RST}") - return None - - -# ── Claude API ─────────────────────────────────────────────────── - -_client: anthropic.Anthropic | None = None - - -def _get_client() -> anthropic.Anthropic: - global _client - if _client is None: - _client = anthropic.Anthropic() - return _client - - -def _shorten_with_claude(text: str, max_chars: int) -> str: - try: - resp = _get_client().messages.create( - model="claude-sonnet-4-6", - max_tokens=256, - messages=[ - { - "role": "user", - "content": f"Shorten this to {max_chars} characters: {text}", - } - ], - ) - shortened = resp.content[0].text.strip() - except Exception as e: - print(f" {RED}Shorten API error: {e}{RST}") - shortened = text - encoded = shortened.encode("utf-8") - if len(encoded) > max_chars: - while len(shortened.encode("utf-8")) > max_chars - 1: - shortened = shortened[:-1] - shortened += "…" - return shortened - - -def _extract_text(resp) -> str: - text = "" - for block in resp.content: - if block.type == "text" and block.text.strip(): - text = block.text.strip() - return text - - -def call_claude(query: str, sender: str, history: list[str], max_chars: int, context_label: str) -> str | None: - """Send a query to Claude with conversation context. Returns response text or None.""" - try: - nonce = random.randint(1000, 9999) - now = time.strftime("%Y-%m-%d %H:%M %Z") - prompt = SYSTEM_PROMPT.replace("{max_chars}", str(max_chars)) + f"\n\nCurrent date/time: {now}" - - if history: - context = "\n".join(history) - user_content = ( - f"[seed:{nonce}]\n" - f"Recent messages in {context_label}:\n{context}\n\n" - f"{sender} says: {query}" - ) - else: - user_content = f"[seed:{nonce}] {sender} says: {query}" - - messages = [{"role": "user", "content": user_content}] - tools = [{"type": "web_search_20260209", "name": "web_search"}] - - text = "" - for round_num in range(3): - resp = _get_client().messages.create( - model="claude-sonnet-4-6", - max_tokens=4096, - temperature=1.0, - system=prompt, - tools=tools, - messages=messages, - ) - - text = _extract_text(resp) - if text: - break - - if resp.stop_reason != "tool_use": - block_types = [b.type for b in resp.content] - print(f" {YEL}No text, stop={resp.stop_reason}, blocks={block_types}{RST}") - break - - print(f" {DIM}(web search round {round_num + 1})...{RST}") - messages.append({"role": "assistant", "content": resp.content}) - tool_results = [] - for block in resp.content: - if hasattr(block, "id") and block.type == "server_tool_use": - tool_results.append( - {"type": "tool_result", "tool_use_id": block.id, "content": ""} - ) - if tool_results: - messages.append({"role": "user", "content": tool_results}) - else: - break - - if not text: - print(f" {YEL}No text after tool loop{RST}") - return None - except Exception as e: - print(f" {RED}Claude API error: {e}{RST}") - return None - - if len(text.encode("utf-8")) > max_chars: - print(f" {YEL}Response too long ({len(text.encode('utf-8'))}B), shortening...{RST}") - text = _shorten_with_claude(text, max_chars) - - return text - - -def _rate_limit_message() -> str: - try: - nonce = random.randint(1000, 9999) - resp = _get_client().messages.create( - model="claude-sonnet-4-6", - max_tokens=100, - temperature=1.0, - system=( - f"You are Orac, a sardonic AI on a radio mesh network. " - f"Generate a single short message ({MAX_RESPONSE_CHARS} chars max, ASCII only) " - f"telling someone to slow down and try again in a few seconds. " - f"Be witty, varied, and in-character. No quotes around the message." - ), - messages=[ - {"role": "user", "content": f"[seed:{nonce}] Rate limit hit."} - ], - ) - text = resp.content[0].text.strip().strip('"\'') - if len(text.encode("utf-8")) <= MAX_RESPONSE_CHARS and text: - return text - except Exception: - pass - return "Patience. Try again in a moment." - - -# ── Trigger detection ──────────────────────────────────────────── - - -def _extract_trigger_query(text: str) -> str | None: - """Check for !triggers and @mentions. Returns the query (everything except the trigger) or None.""" - lower = text.lower() - for trigger in (*TRIGGERS, *AT_MENTIONS): - idx = lower.find(trigger) - if idx != -1: - before = text[:idx] - after = text[idx + len(trigger) :] - query = (before + " " + after).strip() - if query: - return query - return None - - -# ── Packet parsing helpers ─────────────────────────────────────── - - -def _parse_header_and_path(packet: bytes) -> tuple[int, int, int, int, bytes, list[bytes], int] | None: - """Parse MeshCore header + path. - - Returns (payload_type, route_type, payload_ver, pos, payload, path_hops, hash_size) or None. - path_hops is a list of raw hash bytes for each hop (closest relay first). - """ - if len(packet) < 3: - return None - - header = packet[0] - route_type = header & 0x03 - payload_type = (header >> 2) & 0x0F - payload_ver = (header >> 6) & 0x03 - - if payload_ver != 0: - return None - - has_tc = route_type in (0, 3) - - for skip_tc in ([True, False] if has_tc else [False]): - pos = 5 if skip_tc else 1 - if pos >= len(packet): - continue - - path_len_byte = packet[pos] - pos += 1 - hash_size_code = path_len_byte >> 6 - if hash_size_code == 3: - continue - hash_size = hash_size_code + 1 - hop_count = path_len_byte & 0x3F - path_bytes = hop_count * hash_size - if path_bytes > 64 or pos + path_bytes > len(packet): - continue - - hops = [] - for i in range(hop_count): - hops.append(packet[pos + i * hash_size : pos + (i + 1) * hash_size]) - pos += path_bytes - - payload = packet[pos:] - if payload and len(payload) <= 184: - return payload_type, route_type, payload_ver, pos, payload, hops, hash_size - - return None - - -# ── GRP_TXT interception ──────────────────────────────────────── - - -def _try_decrypt_grp(raw_payload: bytes) -> tuple[str, str] | None: - if len(raw_payload) < 19: - return None - ch = raw_payload[0] - mac_bytes = raw_payload[1:3] - ciphertext = raw_payload[3:] - - 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: - _, text = parsed - return chan_name, text - return None - - -# ── DM interception ────────────────────────────────────────────── - - -def _try_decrypt_dm(raw_payload: bytes) -> tuple[bytes, str, str, bytes] | None: - """Try to decrypt a TXT_MSG DM addressed to us. - - Returns (sender_pubkey, sender_name, message_text, raw_plaintext) or None. - raw_plaintext is needed for ACK hash computation. - """ - if len(raw_payload) < 20: - return None - - dest_hash = raw_payload[0] - src_hash = raw_payload[1] - mac_bytes = raw_payload[2:4] - ciphertext = raw_payload[4:] - - print(f" {DIM}TXT_MSG dest=0x{dest_hash:02x} src=0x{src_hash:02x} me=0x{_my_hash():02x} [{len(ciphertext)}B]{RST}") - - if dest_hash != _my_hash(): - return None - - candidates = _lookup_node_by_hash(src_hash) - if not candidates: - print(f" {YEL}DM for us but no known node with hash 0x{src_hash:02x}{RST}") - return None - - for peer_pubkey, peer_name in candidates: - try: - shared_secret = _ecdh_shared_secret(peer_pubkey) - plaintext = _peer_verify_and_decrypt(shared_secret, mac_bytes, ciphertext) - if plaintext is not None: - text = _parse_peer_plaintext(plaintext) - if text: - return peer_pubkey, peer_name, text, plaintext - except Exception: - # ECDH failed — pubkey is likely corrupted (bad ADVERT). Evict it. - pk_hex = peer_pubkey.hex() - print(f" {YEL}Evicting bad key for {peer_name} ({pk_hex[:16]}...){RST}") - _state["known_nodes"].pop(pk_hex, None) - _save_state() - continue - - return None - - -RESP_SERVER_LOGIN_OK = 0x01 - - -def _try_decrypt_anon_req(raw_payload: bytes) -> tuple[bytes, str, bytes | None] | None: - """Try to decrypt an ANON_REQ (login). Returns (sender_pubkey, sender_name, plaintext_or_None) or None.""" - if len(raw_payload) < 51: - return None - - dest_hash = raw_payload[0] - sender_pubkey = raw_payload[1:33] - mac_bytes = raw_payload[33:35] - ciphertext = raw_payload[35:] - - print(f" {BCYN}ANON_REQ{RST} dest=0x{dest_hash:02x} sender={sender_pubkey.hex()[:16]}... [{len(ciphertext)}B]") - - if dest_hash != _my_hash(): - return None - - # Register sender — we now have their full pubkey - peer_name = _node_name(sender_pubkey.hex()) - is_new = _register_node(sender_pubkey, peer_name) - if is_new: - print(f" {BCYN}Registered new peer from ANON_REQ: {peer_name} (hash=0x{sender_pubkey[0]:02x}){RST}") - - try: - shared_secret = _ecdh_shared_secret(sender_pubkey) - plaintext = _peer_verify_and_decrypt(shared_secret, mac_bytes, ciphertext) - if plaintext is not None: - return sender_pubkey, peer_name, plaintext - else: - print(f" {YEL}ANON_REQ MAC mismatch{RST}") - except Exception as e: - print(f" {RED}ANON_REQ decrypt error: {e}{RST}") - - # Even if decryption failed, we registered the pubkey — return with no plaintext - return sender_pubkey, peer_name, None - - -def _send_login_response(ser: serial.Serial, peer_pubkey: bytes): - """Send a RESPONSE packet (login OK) back to the peer, like a room server.""" - shared_secret = _ecdh_shared_secret(peer_pubkey) - - # Response payload: timestamp(4) + RESP_SERVER_LOGIN_OK(1) + zero(1) + permissions(2) - reply = struct.pack(" tuple[bytes, str] | None: - """Decode and verify an ADVERT payload. Returns (pubkey, name) or None.""" - if len(raw_payload) < 100: - return None - - pubkey = raw_payload[0:32] - timestamp = raw_payload[32:36] - signature = raw_payload[36:100] - app_data = raw_payload[100:] - - # Verify Ed25519 signature to reject corrupted ADVERTs - try: - verify_key = VerifyKey(pubkey) - sign_msg = pubkey + timestamp + app_data - verify_key.verify(sign_msg, signature) - except Exception: - return None - - name = "" - if app_data: - flags = app_data[0] - pos = 1 - if flags & 0x10: # has location - pos += 8 - if flags & 0x20: # feat1 - pos += 2 - if flags & 0x40: # feat2 - pos += 2 - if flags & 0x80 and pos < len(app_data): # has name - name = app_data[pos:].decode("utf-8", errors="replace") - - return pubkey, name if name else pubkey.hex()[:8] - - -# ── Deduplication ──────────────────────────────────────────────── - -DEDUP_TTL = 120 -_seen_packets: dict[bytes, float] = {} - -# DM dedup on decrypted text (catches retries with different attempt counters) -DM_DEDUP_TTL = 60 -_seen_dm_texts: dict[str, float] = {} # "pubkey_hex:text" → monotonic timestamp - - -def _compute_packet_hash(payload_type: int, payload: bytes) -> bytes: - """Compute 8-byte packet hash per spec Section 16.""" - return hashlib.sha256(bytes([payload_type]) + payload).digest()[:8] - - -def _is_duplicate(payload_type: int, payload: bytes) -> bool: - """Dedup via 8-byte packet hash (spec Section 16).""" - now = time.monotonic() - if len(_seen_packets) > 500: - expired = [k for k, t in _seen_packets.items() if now - t > DEDUP_TTL] - for k in expired: - del _seen_packets[k] - - pkt_hash = _compute_packet_hash(payload_type, payload) - if pkt_hash in _seen_packets and now - _seen_packets[pkt_hash] < DEDUP_TTL: - return True - _seen_packets[pkt_hash] = now - return False - - -def _is_dm_duplicate(peer_pubkey_hex: str, text: str) -> bool: - """Dedup on decrypted DM text (catches retries with different attempt counters).""" - now = time.monotonic() - if len(_seen_dm_texts) > 200: - expired = [k for k, t in _seen_dm_texts.items() if now - t > DM_DEDUP_TTL] - for k in expired: - del _seen_dm_texts[k] - - key = f"{peer_pubkey_hex}:{text}" - if key in _seen_dm_texts and now - _seen_dm_texts[key] < DM_DEDUP_TTL: - return True - _seen_dm_texts[key] = now - return False - - -# ── History (persistent) ──────────────────────────────────────── - - -def _record_channel_msg(channel: str, text: str): - hist = _state["channel_history"] - if channel not in hist: - hist[channel] = [] - hist[channel].append(text) - # Trim to max size - if len(hist[channel]) > CHANNEL_HISTORY_SIZE: - hist[channel] = hist[channel][-CHANNEL_HISTORY_SIZE:] - _save_state() - - -def _get_channel_history(channel: str) -> list[str]: - return list(_state["channel_history"].get(channel, [])) - - -def _record_dm_msg(peer_pubkey_hex: str, text: str): - hist = _state["dm_history"] - if peer_pubkey_hex not in hist: - hist[peer_pubkey_hex] = [] - hist[peer_pubkey_hex].append(text) - if len(hist[peer_pubkey_hex]) > DM_HISTORY_SIZE: - hist[peer_pubkey_hex] = hist[peer_pubkey_hex][-DM_HISTORY_SIZE:] - _save_state() - - -def _get_dm_history(peer_pubkey_hex: str) -> list[str]: - return list(_state["dm_history"].get(peer_pubkey_hex, [])) - - -# ── Rate limiting ──────────────────────────────────────────────── - -_last_response: dict[str, float] = {} -_last_global_response = 0.0 -_last_rate_limit_reply: dict[str, float] = {} -RATE_LIMIT_REPLY_COOLDOWN = 30 - - -def _rate_limit_check(key: str) -> bool: - global _last_global_response - now = time.monotonic() - if now - _last_global_response < RATE_LIMIT_GLOBAL: - return False - limit = RATE_LIMIT_DM if key.startswith("dm:") else RATE_LIMIT_PER_SENDER - if key in _last_response and now - _last_response[key] < limit: - return False - return True - - -def _rate_limit_reply_ok(key: str) -> bool: - now = time.monotonic() - if key in _last_rate_limit_reply and now - _last_rate_limit_reply[key] < RATE_LIMIT_REPLY_COOLDOWN: - return False - _last_rate_limit_reply[key] = now - return True - - -def _rate_limit_record(key: str): - global _last_global_response - now = time.monotonic() - _last_response[key] = now - _last_global_response = now - - -# ── Bot loop ───────────────────────────────────────────────────── - - -def bot_loop(ser: serial.Serial): - send_cmd(ser, {"type": "Ping"}, "Ping") - send_cmd( - ser, - {"type": "SetConfig", "config": RADIO_CONFIG}, - "SetConfig 910.525/62.5k/SF7/CR4/5", - ) - send_cmd(ser, {"type": "StartRx"}, "StartRx") - - # Send initial ADVERT - _send_advert(ser) - last_advert = time.monotonic() - - print(f"\n {BWHT}{BOT_NAME} listening{RST} {DIM}(Ctrl+C to stop){RST}") - triggers_str = ", ".join(TRIGGERS) - print(f" {DIM}Channels: {len(_KNOWN_CHANNELS)} | Triggers: {triggers_str} | DMs: enabled{RST}\n") - ser.timeout = 1 - - while True: - # Periodic ADVERT - now = time.monotonic() - if now - last_advert >= ADVERT_INTERVAL: - _send_advert(ser) - last_advert = now - - - data = read_frame(ser) - if data is None: - continue - try: - resp = decode_response(data) - except Exception: - continue - if resp["type"] != "RxPacket": - continue - packet = resp["payload"] - parsed = _parse_header_and_path(packet) - if parsed is None: - continue - - payload_type, route_type, payload_ver, pos, raw_payload, path_hops, hash_size = parsed - - # Learn return route from incoming packet's path - if raw_payload and len(raw_payload) >= 2: - if payload_type in (0x01, 0x02, 0x08): # RESPONSE, TXT_MSG, PATH - src_hash = raw_payload[1] - elif payload_type == 0x04: # ADVERT - src_hash = raw_payload[0] - else: - src_hash = None - if src_hash is not None: - _learn_route(src_hash, path_hops, hash_size) - - # Dedup via packet hash (spec Section 16) - if _is_duplicate(payload_type, raw_payload): - continue - - # ── ACK (0x03) — ignore (we fire-and-forget DMs) ── - if payload_type == 0x03: - continue - - # ── ADVERT (0x04) ── - if payload_type == 0x04: - result = _try_decode_advert(raw_payload) - if result: - pubkey, name = result - if pubkey == _pubkey_bytes: - continue # ignore our own ADVERT echo - is_new = _register_node(pubkey, name) - print(f" {BCYN}ADVERT{RST} {name} {DIM}(hash=0x{pubkey[0]:02x} pk={pubkey.hex()[:16]}...){RST}") - - # Check for pending DMs from this node's hash - node_hash = pubkey[0] - if node_hash in _pending_dms and _pending_dms[node_hash]: - now = time.monotonic() - pending = _pending_dms.pop(node_hash) - print(f" {BCYN}Processing {len(pending)} pending DM(s) from {name}{RST}") - for pending_payload, ts in pending: - if now - ts > PENDING_DM_TTL: - continue # expired - dm_result = _try_decrypt_dm(pending_payload) - if dm_result: - peer_pubkey, peer_name, dm_text, dm_plaintext = dm_result - pk_hex = peer_pubkey.hex() - _send_ack(ser, _compute_ack_hash(dm_plaintext, peer_pubkey), peer_pubkey[0]) - if _is_dm_duplicate(pk_hex, dm_text): - continue - print(f" {BCYN}DM(flood){RST} from {BWHT}{peer_name}{RST}: {dm_text}") - _record_dm_msg(pk_hex, f"{peer_name}: {dm_text}") - history = _get_dm_history(pk_hex) - response = call_claude(dm_text, peer_name, history, MAX_DM_TEXT, f"DM with {peer_name}") - if response: - _rt = "direct" if _get_route(peer_pubkey[0]) else "flood" - print(f" {BGRN}<<< DM({_rt}) {BOT_NAME} → {peer_name}: {response}{RST}") - _dm_transmit(ser, peer_pubkey, response) - _record_dm_msg(pk_hex, f"{BOT_NAME}: {response}") - _rate_limit_record(f"dm:{pk_hex}") - continue - - # ── GRP_TXT (0x05) ── - if payload_type == 0x05: - result = _try_decrypt_grp(raw_payload) - if result is None: - continue - - channel_name, text = result - print(f" {BMAG}GRP_TXT{RST} {BGRN}{channel_name}{RST} {BWHT}{text}{RST}") - _record_channel_msg(channel_name, text) - - if ": " not in text: - continue - sender, _, body = text.partition(": ") - if sender == BOT_NAME: - continue - - query = _extract_trigger_query(body) - if query is None: - continue - - print(f" {BMAG}>>> Query from {sender} on {channel_name}: {query}{RST}") - - rl_key = f"ch:{channel_name}:{sender}" - if not _rate_limit_check(rl_key): - print(f" {YEL}Rate limited: {sender} on {channel_name}{RST}") - if _rate_limit_reply_ok(f"ch:{channel_name}"): - msg = _rate_limit_message() - print(f" {BGRN}<<< {BOT_NAME}: {msg}{RST}") - _grp_transmit(ser, channel_name, BOT_NAME, msg) - _rate_limit_record(rl_key) - continue - - history = _get_channel_history(channel_name) - response = call_claude(query, sender, history, MAX_RESPONSE_CHARS, f"channel {channel_name}") - if response is None: - continue - - print(f" {BGRN}<<< {BOT_NAME}: {response}{RST}") - _grp_transmit(ser, channel_name, BOT_NAME, response) - _record_channel_msg(channel_name, f"{BOT_NAME}: {response}") - _rate_limit_record(rl_key) - continue - - # ── ANON_REQ (0x07) — LOGIN from companion (includes full pubkey) ── - if payload_type == 0x07: - result = _try_decrypt_anon_req(raw_payload) - if result is None: - continue - - peer_pubkey, peer_name, plaintext = result - - # Learn route before responding so login response can be direct-routed - _learn_route(peer_pubkey[0], path_hops, hash_size) - - # Send login-OK response so the companion knows we accepted them - print(f" {BCYN}LOGIN{RST} from {BWHT}{peer_name}{RST} — sending login OK") - _send_login_response(ser, peer_pubkey) - - # If we also got a decrypted message, process pending DMs from this peer - pk_hex = peer_pubkey.hex() - node_hash = peer_pubkey[0] - if node_hash in _pending_dms and _pending_dms[node_hash]: - now_mono = time.monotonic() - pending = _pending_dms.pop(node_hash) - print(f" {BCYN}Processing {len(pending)} pending DM(s) from {peer_name}{RST}") - for pending_payload, ts in pending: - if now_mono - ts > PENDING_DM_TTL: - continue - dm_result = _try_decrypt_dm(pending_payload) - if dm_result: - dm_pubkey, dm_name, dm_text, dm_plaintext = dm_result - # ACK + dedup - _send_ack(ser, _compute_ack_hash(dm_plaintext, dm_pubkey), dm_pubkey[0]) - if _is_dm_duplicate(pk_hex, dm_text): - continue - print(f" {BCYN}DM(flood){RST} from {BWHT}{dm_name}{RST}: {dm_text}") - _record_dm_msg(pk_hex, f"{dm_name}: {dm_text}") - history = _get_dm_history(pk_hex) - response = call_claude(dm_text, dm_name, history, MAX_DM_TEXT, f"DM with {dm_name}") - if response: - _rt = "direct" if _get_route(dm_pubkey[0]) else "flood" - print(f" {BGRN}<<< DM({_rt}) {BOT_NAME} → {dm_name}: {response}{RST}") - _dm_transmit(ser, dm_pubkey, response) - _record_dm_msg(pk_hex, f"{BOT_NAME}: {response}") - _rate_limit_record(f"dm:{pk_hex}") - continue - - # ── TXT_MSG DM (0x02) ── - if payload_type == 0x02: - result = _try_decrypt_dm(raw_payload) - if result is None: - # If addressed to us but sender unknown, queue for later - if len(raw_payload) >= 4 and raw_payload[0] == _my_hash(): - src_hash = raw_payload[1] - if not _lookup_node_by_hash(src_hash): - _pending_dms.setdefault(src_hash, []).append((raw_payload, time.monotonic())) - if len(_pending_dms[src_hash]) == 1: - print(f" {BCYN}Queued DM from unknown 0x{src_hash:02x}, waiting for their ADVERT{RST}") - continue - - peer_pubkey, peer_name, dm_text, raw_plaintext = result - - # Send ACK immediately so the sender stops retrying - ack_crc = _compute_ack_hash(raw_plaintext, peer_pubkey) - _send_ack(ser, ack_crc, peer_pubkey[0]) - - # Send PATH return so sender can learn a direct route to us - _send_path_return(ser, peer_pubkey, path_hops, hash_size) - - pk_hex = peer_pubkey.hex() - - # Dedup on decrypted text (catches retries with different attempt counters) - if _is_dm_duplicate(pk_hex, dm_text): - continue - - print(f" {BCYN}DM({_route_name(route_type)}){RST} from {BWHT}{peer_name}{RST}: {dm_text}") - _record_dm_msg(pk_hex, f"{peer_name}: {dm_text}") - - rl_key = f"dm:{pk_hex}" - if not _rate_limit_check(rl_key): - print(f" {YEL}Rate limited DM: {peer_name}{RST}") - continue - - history = _get_dm_history(pk_hex) - response = call_claude(dm_text, peer_name, history, MAX_DM_TEXT, f"DM with {peer_name}") - if response is None: - continue - - _rt = "direct" if _get_route(peer_pubkey[0]) else "flood" - print(f" {BGRN}<<< DM({_rt}) {BOT_NAME} → {peer_name}: {response}{RST}") - _dm_transmit(ser, peer_pubkey, response) - _record_dm_msg(pk_hex, f"{BOT_NAME}: {response}") - _rate_limit_record(rl_key) - continue - - - -# ── Main ───────────────────────────────────────────────────────── - - -def main(): - if not os.environ.get("ANTHROPIC_API_KEY"): - print(f" {RED}ANTHROPIC_API_KEY environment variable not set{RST}") - sys.exit(1) - - _init_identity() - _load_state() - - print(f" {BWHT}{BOT_NAME}{RST} — MeshCore AI Bot") - print(f" {DIM}Max GRP response: {MAX_RESPONSE_CHARS} chars | Max DM response: {MAX_DM_TEXT} chars{RST}") - print(f" {DIM}Channels: {len(_KNOWN_CHANNELS)} | Known nodes: {len(_state['known_nodes'])}{RST}") - - port = sys.argv[1] if len(sys.argv) > 1 else None - - while True: - try: - print(f" {DIM}Connecting...{RST}") - ser = open_connection(port) - print(f" {GRN}Connected{RST}") - bot_loop(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() - _save_state() - try: - ser.timeout = 2 - send_cmd(ser, {"type": "StopRx"}, "StopRx") - except Exception: - pass - break - - -if __name__ == "__main__": - main() diff --git a/examples/meshcore/channels.csv b/examples/meshcore/channels.csv deleted file mode 100644 index 81aeb515..00000000 --- a/examples/meshcore/channels.csv +++ /dev/null @@ -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 diff --git a/examples/meshcore/corpus/build_hand.py b/examples/meshcore/corpus/build_hand.py deleted file mode 100755 index b611e577..00000000 --- a/examples/meshcore/corpus/build_hand.py +++ /dev/null @@ -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(" 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(" 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(" 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(" 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("= 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}/") diff --git a/examples/meshcore/corpus/generate.py b/examples/meshcore/corpus/generate.py deleted file mode 100755 index 756883c5..00000000 --- a/examples/meshcore/corpus/generate.py +++ /dev/null @@ -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(" 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("> 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() diff --git a/examples/meshcore/corpus/hand/ack_basic.json b/examples/meshcore/corpus/hand/ack_basic.json deleted file mode 100644 index c99a7f36..00000000 --- a/examples/meshcore/corpus/hand/ack_basic.json +++ /dev/null @@ -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 - } - } -} diff --git a/examples/meshcore/corpus/hand/ack_with_path.json b/examples/meshcore/corpus/hand/ack_with_path.json deleted file mode 100644 index 70d6fe82..00000000 --- a/examples/meshcore/corpus/hand/ack_with_path.json +++ /dev/null @@ -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 - } - } -} diff --git a/examples/meshcore/corpus/hand/advert_basic.json b/examples/meshcore/corpus/hand/advert_basic.json deleted file mode 100644 index 87045681..00000000 --- a/examples/meshcore/corpus/hand/advert_basic.json +++ /dev/null @@ -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" - } - } - } -} diff --git a/examples/meshcore/corpus/hand/advert_emoji_name.json b/examples/meshcore/corpus/hand/advert_emoji_name.json deleted file mode 100644 index b3b94cb7..00000000 --- a/examples/meshcore/corpus/hand/advert_emoji_name.json +++ /dev/null @@ -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" - } - } - } -} diff --git a/examples/meshcore/corpus/hand/advert_feat_flags_as_name.json b/examples/meshcore/corpus/hand/advert_feat_flags_as_name.json deleted file mode 100644 index 9e06be63..00000000 --- a/examples/meshcore/corpus/hand/advert_feat_flags_as_name.json +++ /dev/null @@ -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" - } - } - } -} diff --git a/examples/meshcore/corpus/hand/advert_location.json b/examples/meshcore/corpus/hand/advert_location.json deleted file mode 100644 index 43b6c159..00000000 --- a/examples/meshcore/corpus/hand/advert_location.json +++ /dev/null @@ -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" - } - } - } -} diff --git a/examples/meshcore/corpus/hand/advert_no_appdata.json b/examples/meshcore/corpus/hand/advert_no_appdata.json deleted file mode 100644 index 8aafcaed..00000000 --- a/examples/meshcore/corpus/hand/advert_no_appdata.json +++ /dev/null @@ -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 - } - } -} diff --git a/examples/meshcore/corpus/hand/advert_sensor.json b/examples/meshcore/corpus/hand/advert_sensor.json deleted file mode 100644 index c5865534..00000000 --- a/examples/meshcore/corpus/hand/advert_sensor.json +++ /dev/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" - } - } - } -} diff --git a/examples/meshcore/corpus/hand/advert_truncated.json b/examples/meshcore/corpus/hand/advert_truncated.json deleted file mode 100644 index 3f111509..00000000 --- a/examples/meshcore/corpus/hand/advert_truncated.json +++ /dev/null @@ -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": [] - } - } -} diff --git a/examples/meshcore/corpus/hand/advert_with_path.json b/examples/meshcore/corpus/hand/advert_with_path.json deleted file mode 100644 index 4a20e68c..00000000 --- a/examples/meshcore/corpus/hand/advert_with_path.json +++ /dev/null @@ -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" - } - } - } -} diff --git a/examples/meshcore/corpus/hand/anon_req_tc_fallback.json b/examples/meshcore/corpus/hand/anon_req_tc_fallback.json deleted file mode 100644 index 1d038318..00000000 --- a/examples/meshcore/corpus/hand/anon_req_tc_fallback.json +++ /dev/null @@ -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 - } - } -} diff --git a/examples/meshcore/corpus/hand/bad_path_len.json b/examples/meshcore/corpus/hand/bad_path_len.json deleted file mode 100644 index 1b1c68b3..00000000 --- a/examples/meshcore/corpus/hand/bad_path_len.json +++ /dev/null @@ -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" - } -} diff --git a/examples/meshcore/corpus/hand/bad_path_len_reserved_hash_size.json b/examples/meshcore/corpus/hand/bad_path_len_reserved_hash_size.json deleted file mode 100644 index f4f30d77..00000000 --- a/examples/meshcore/corpus/hand/bad_path_len_reserved_hash_size.json +++ /dev/null @@ -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" - } -} diff --git a/examples/meshcore/corpus/hand/control_discover_req.json b/examples/meshcore/corpus/hand/control_discover_req.json deleted file mode 100644 index cadc5791..00000000 --- a/examples/meshcore/corpus/hand/control_discover_req.json +++ /dev/null @@ -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" - } - } -} diff --git a/examples/meshcore/corpus/hand/empty_packet.json b/examples/meshcore/corpus/hand/empty_packet.json deleted file mode 100644 index e893db91..00000000 --- a/examples/meshcore/corpus/hand/empty_packet.json +++ /dev/null @@ -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" - } -} diff --git a/examples/meshcore/corpus/hand/grp_txt_basic.json b/examples/meshcore/corpus/hand/grp_txt_basic.json deleted file mode 100644 index bf645987..00000000 --- a/examples/meshcore/corpus/hand/grp_txt_basic.json +++ /dev/null @@ -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 - } - } -} diff --git a/examples/meshcore/corpus/hand/multipart_ack.json b/examples/meshcore/corpus/hand/multipart_ack.json deleted file mode 100644 index 4b1cec29..00000000 --- a/examples/meshcore/corpus/hand/multipart_ack.json +++ /dev/null @@ -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" - } - } -} diff --git a/examples/meshcore/corpus/hand/noise_packet.json b/examples/meshcore/corpus/hand/noise_packet.json deleted file mode 100644 index 8140c32e..00000000 --- a/examples/meshcore/corpus/hand/noise_packet.json +++ /dev/null @@ -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" - } -} diff --git a/examples/meshcore/corpus/hand/path_3byte_hash.json b/examples/meshcore/corpus/hand/path_3byte_hash.json deleted file mode 100644 index 386e4d2b..00000000 --- a/examples/meshcore/corpus/hand/path_3byte_hash.json +++ /dev/null @@ -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 - } - } -} diff --git a/examples/meshcore/corpus/hand/path_4byte_hash.json b/examples/meshcore/corpus/hand/path_4byte_hash.json deleted file mode 100644 index dd4a99cc..00000000 --- a/examples/meshcore/corpus/hand/path_4byte_hash.json +++ /dev/null @@ -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" - } -} diff --git a/examples/meshcore/corpus/hand/req_transport_direct.json b/examples/meshcore/corpus/hand/req_transport_direct.json deleted file mode 100644 index 17772d48..00000000 --- a/examples/meshcore/corpus/hand/req_transport_direct.json +++ /dev/null @@ -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 - } - } -} diff --git a/examples/meshcore/corpus/hand/response_corrupted_pathlen_1.json b/examples/meshcore/corpus/hand/response_corrupted_pathlen_1.json deleted file mode 100644 index bd188ef6..00000000 --- a/examples/meshcore/corpus/hand/response_corrupted_pathlen_1.json +++ /dev/null @@ -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" - } -} diff --git a/examples/meshcore/corpus/hand/response_corrupted_pathlen_2.json b/examples/meshcore/corpus/hand/response_corrupted_pathlen_2.json deleted file mode 100644 index 111aa42d..00000000 --- a/examples/meshcore/corpus/hand/response_corrupted_pathlen_2.json +++ /dev/null @@ -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" - } -} diff --git a/examples/meshcore/corpus/hand/response_valid_1hop.json b/examples/meshcore/corpus/hand/response_valid_1hop.json deleted file mode 100644 index 5041e87b..00000000 --- a/examples/meshcore/corpus/hand/response_valid_1hop.json +++ /dev/null @@ -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} - } -} diff --git a/examples/meshcore/corpus/hand/response_valid_2hop.json b/examples/meshcore/corpus/hand/response_valid_2hop.json deleted file mode 100644 index eb01f5cc..00000000 --- a/examples/meshcore/corpus/hand/response_valid_2hop.json +++ /dev/null @@ -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} - } -} diff --git a/examples/meshcore/corpus/hand/too_short_1byte.json b/examples/meshcore/corpus/hand/too_short_1byte.json deleted file mode 100644 index e9f72c7d..00000000 --- a/examples/meshcore/corpus/hand/too_short_1byte.json +++ /dev/null @@ -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" - } -} diff --git a/examples/meshcore/corpus/hand/too_short_2bytes.json b/examples/meshcore/corpus/hand/too_short_2bytes.json deleted file mode 100644 index a80f66c6..00000000 --- a/examples/meshcore/corpus/hand/too_short_2bytes.json +++ /dev/null @@ -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" - } -} diff --git a/examples/meshcore/corpus/hand/trace_4byte_hash.json b/examples/meshcore/corpus/hand/trace_4byte_hash.json deleted file mode 100644 index 00bc543e..00000000 --- a/examples/meshcore/corpus/hand/trace_4byte_hash.json +++ /dev/null @@ -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 - } - } -} diff --git a/examples/meshcore/corpus/hand/trace_basic.json b/examples/meshcore/corpus/hand/trace_basic.json deleted file mode 100644 index a32e8a20..00000000 --- a/examples/meshcore/corpus/hand/trace_basic.json +++ /dev/null @@ -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 - } - } -} diff --git a/examples/meshcore/corpus/hand/txt_msg_direct_2b_hash.json b/examples/meshcore/corpus/hand/txt_msg_direct_2b_hash.json deleted file mode 100644 index c03b9fc9..00000000 --- a/examples/meshcore/corpus/hand/txt_msg_direct_2b_hash.json +++ /dev/null @@ -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 - } - } -} diff --git a/examples/meshcore/corpus/hand/txt_msg_flood_with_loops.json b/examples/meshcore/corpus/hand/txt_msg_flood_with_loops.json deleted file mode 100644 index 15749c2e..00000000 --- a/examples/meshcore/corpus/hand/txt_msg_flood_with_loops.json +++ /dev/null @@ -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 - } - } -} diff --git a/examples/meshcore/corpus/schema.json b/examples/meshcore/corpus/schema.json deleted file mode 100644 index add8bb8d..00000000 --- a/examples/meshcore/corpus/schema.json +++ /dev/null @@ -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 - } - } -} diff --git a/examples/meshcore/corpus/validate.py b/examples/meshcore/corpus/validate.py deleted file mode 100755 index 0308a561..00000000 --- a/examples/meshcore/corpus/validate.py +++ /dev/null @@ -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 ["", "", ""]: - 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() diff --git a/examples/meshcore/meshcore_rx.py b/examples/meshcore/meshcore_rx.py deleted file mode 100755 index e656d871..00000000 --- a/examples/meshcore/meshcore_rx.py +++ /dev/null @@ -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(" 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(" 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(" 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}{RST} {DIM}{data.hex()}{RST}", [] - - header = data[0] - if header == 0xFF: - return f"{DIM} {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}{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("{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}=100>{RST} {DIM}{payload.hex()}{RST}", [] - pubkey = payload[0:32] - timestamp = struct.unpack_from(" 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}{RST} {DIM}{payload.hex()}{RST}" - ) - crc = struct.unpack_from(" str: - if len(payload) < 20: - return f"{prefix} {RED}{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}{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}{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}{RST} {DIM}{payload.hex()}{RST}" - ) - tag = struct.unpack_from("'.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}{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}{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("= 6: - node_type = payload[0] & 0x0F - snr_raw = struct.unpack_from(" 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() diff --git a/examples/meshcore/telemetry_monitor.py b/examples/meshcore/telemetry_monitor.py deleted file mode 100644 index 43eb8308..00000000 --- a/examples/meshcore/telemetry_monitor.py +++ /dev/null @@ -1,1143 +0,0 @@ -#!/usr/bin/env python3 -"""MeshCore repeater telemetry monitor. - -Periodically logs into configured repeaters and fetches telemetry data -(battery voltage, temperature, etc.). Uses route escalation to minimise -flooding: last-known route → zero-hop direct → flood, then learns the -return path for next time. - -Configuration lives in ~/.donglora/telemetry.json: - - { - "repeaters": [ - { - "name": "Hilltop-1", - "password": "guest123" - } - ], - "poll_interval_secs": 3600 - } - -The "pubkey" field is optional. If missing (or empty), the monitor -listens for MeshCore ADVERTs, matches by name, fills in the pubkey -automatically, and saves the config back to disk. -""" -import hashlib -import hmac -import json -import os -import random -import struct -import sys -import time -from pathlib import Path - -import serial -from cobs import cobs -from Crypto.Cipher import AES -from nacl.bindings import crypto_scalarmult -from nacl.signing import SigningKey, VerifyKey - -import donglora as dl - - -# ── Constants ───────────────────────────────────────────────────── - -DATA_DIR = Path.home() / ".donglora" -CONFIG_FILE = DATA_DIR / "telemetry.json" -KEY_FILE = DATA_DIR / "telemetry_key.bin" -STATE_FILE = DATA_DIR / "telemetry_state.json" - -DEFAULT_POLL_INTERVAL = 3600 # 1 hour - -# MeshCore protocol constants -REQ_TYPE_GET_TELEMETRY_DATA = 0x03 -TELEM_PERM_BASE = 0x01 -TELEM_PERM_LOCATION = 0x02 -TELEM_PERM_ENVIRONMENT = 0x04 - -# Timeouts and retries -LOGIN_TIMEOUT = 15.0 # seconds to wait for login response -TELEM_TIMEOUT = 15.0 # seconds to wait for telemetry response -TX_SETTLE = 1.0 # delay between transmissions -FLOOD_RETRIES = 3 # retry flood login/telemetry this many times - -RADIO_CONFIG = { - "freq_hz": 910_525_000, - "bw": 6, # 62.5 kHz - "sf": 7, - "cr": 5, # CR 4/5 - "sync_word": 0x3444, - "tx_power_dbm": -128, # TX_POWER_MAX -} - - -# ── ANSI helpers ────────────────────────────────────────────────── - -RST = "\033[0m" -DIM = "\033[2m" -RED = "\033[31m" -GRN = "\033[32m" -YEL = "\033[33m" -CYN = "\033[36m" -BCYN = "\033[1;36m" -BGRN = "\033[1;32m" -BWHT = "\033[1;37m" - - -# ── Identity ────────────────────────────────────────────────────── - -_signing_key: SigningKey | None = None -_verify_key: VerifyKey | None = None -_pubkey_bytes: bytes = b"" - - -def _init_identity(): - global _signing_key, _verify_key, _pubkey_bytes - DATA_DIR.mkdir(parents=True, exist_ok=True) - - if KEY_FILE.is_file(): - seed = KEY_FILE.read_bytes() - if len(seed) == 32: - _signing_key = SigningKey(seed) - print(f" {DIM}Loaded keypair from {KEY_FILE}{RST}") - else: - _signing_key = SigningKey.generate() - KEY_FILE.write_bytes(bytes(_signing_key)) - else: - _signing_key = SigningKey.generate() - KEY_FILE.write_bytes(bytes(_signing_key)) - print(f" {BWHT}Generated new keypair → {KEY_FILE}{RST}") - - _verify_key = _signing_key.verify_key - _pubkey_bytes = bytes(_verify_key) - print(f" {DIM}Pubkey: {_pubkey_bytes.hex()}{RST}") - print(f" {DIM}Node hash: 0x{_pubkey_bytes[0]:02x}{RST}") - - -def _my_hash() -> int: - return _pubkey_bytes[0] - - -# ── Crypto ──────────────────────────────────────────────────────── - - -def _ecdh_shared_secret(peer_ed25519_pub: bytes) -> bytes: - my_x25519 = bytes(_signing_key.to_curve25519_private_key()) - peer_verify = VerifyKey(peer_ed25519_pub) - peer_x25519 = bytes(peer_verify.to_curve25519_public_key()) - return crypto_scalarmult(my_x25519, peer_x25519) - - -def _encrypt(shared_secret: bytes, plaintext: bytes) -> bytes: - """Encrypt-then-MAC. Returns mac(2) + ciphertext.""" - pad_len = (16 - len(plaintext) % 16) % 16 - plaintext += b"\x00" * pad_len - cipher = AES.new(shared_secret[:16], AES.MODE_ECB) - ciphertext = b"" - for i in range(0, len(plaintext), 16): - ciphertext += cipher.encrypt(plaintext[i : i + 16]) - mac = hmac.new(shared_secret[:32], ciphertext, hashlib.sha256).digest()[:2] - return mac + ciphertext - - -def _verify_and_decrypt(shared_secret: bytes, mac_bytes: bytes, ciphertext: bytes) -> bytes | None: - computed = hmac.new(shared_secret[:32], ciphertext, hashlib.sha256).digest()[:2] - if computed != mac_bytes: - return None - cipher = AES.new(shared_secret[:16], AES.MODE_ECB) - plaintext = b"" - for i in range(0, len(ciphertext), 16): - plaintext += cipher.decrypt(ciphertext[i : i + 16]) - return plaintext - - -# ── Route table ─────────────────────────────────────────────────── -# pubkey_hex → (path_hops: list[bytes], hash_size: int) - -_route_table: dict[str, tuple[list[bytes], int]] = {} - - -MIN_HASH_SIZE = 2 # always use 2-byte routing minimum - - -def _learn_route(pubkey_hex: str, path_hops: list[bytes], hash_size: int): - """Learn a return route from an incoming packet's path (reversed).""" - if hash_size < MIN_HASH_SIZE: - return # reject 1-byte routes - existing = _route_table.get(pubkey_hex) - if existing is not None: - _, existing_hs = existing - if hash_size < existing_hs: - return - _route_table[pubkey_hex] = (list(reversed(path_hops)), hash_size) - - -def _get_route(pubkey_hex: str) -> tuple[list[bytes], int] | None: - return _route_table.get(pubkey_hex) - - -# ── Persistent state ───────────────────────────────────────────── - -_state: dict = {"routes": {}} - - -def _load_state(): - global _state - if STATE_FILE.is_file(): - try: - with open(STATE_FILE) as f: - loaded = json.load(f) - _state.update(loaded) - # Restore routes from state - for pk_hex, route_data in _state.get("routes", {}).items(): - hops = [bytes.fromhex(h) for h in route_data["hops"]] - _route_table[pk_hex] = (hops, route_data["hash_size"]) - print(f" {DIM}Loaded state ({len(_route_table)} routes){RST}") - except Exception as e: - print(f" {YEL}Failed to load state: {e}{RST}") - - -def _save_state(): - try: - # Serialise routes - routes = {} - for pk_hex, (hops, hs) in _route_table.items(): - routes[pk_hex] = {"hops": [h.hex() for h in hops], "hash_size": hs} - _state["routes"] = routes - - DATA_DIR.mkdir(parents=True, exist_ok=True) - tmp = STATE_FILE.with_suffix(".tmp") - with open(tmp, "w") as f: - json.dump(_state, f, indent=2) - tmp.rename(STATE_FILE) - except Exception as e: - print(f" {RED}Failed to save state: {e}{RST}") - - -# ── Serial / USB ────────────────────────────────────────────────── - -def open_connection(port: str | None = None): - """Open a DongLoRa connection (mux auto-detected, falls back to USB).""" - return dl.connect(port=port, timeout=2) - - -# ── COBS framing ───────────────────────────────────────────────── - - -def cobs_frame(payload: bytes) -> bytes: - return cobs.encode(payload) + b"\x00" - - -def read_frame(ser: serial.Serial) -> bytes | None: - 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 - - -# ── Command encoding / response decoding ───────────────────────── - - -def encode_radio_config(cfg: dict) -> bytes: - return struct.pack( - " bytes: - kind = cmd["type"] - tags = { - "Ping": 0, "GetConfig": 1, "SetConfig": 2, "StartRx": 3, - "StopRx": 4, "Transmit": 5, "DisplayOn": 6, "DisplayOff": 7, - } - out = bytes([tags[kind]]) - if kind == "SetConfig": - out += encode_radio_config(cmd["config"]) - elif kind == "Transmit": - config = cmd.get("config") - if config is None: - out += b"\x00" - else: - out += b"\x01" + encode_radio_config(config) - payload = cmd["payload"] - out += struct.pack(" dict: - if not data: - return {"type": "Empty"} - tag = data[0] - rest = data[1:] - if tag == 0: - return {"type": "Pong"} - elif tag == 1: - return {"type": "Config", "raw": rest.hex()} - elif tag == 2: - rssi = struct.unpack_from(" dict | None: - payload = encode_command(cmd) - frame = cobs_frame(payload) - if label: - print(f" {DIM}>>> {label}{RST}") - ser.write(frame) - ser.flush() - for _ in range(50): - resp_data = read_frame(ser) - if resp_data is None: - return None - resp = decode_response(resp_data) - if resp["type"] == "RxPacket": - continue - return resp - return None - - -def transmit(ser: serial.Serial, packet: bytes, label: str = "TX"): - resp = send_cmd(ser, {"type": "Transmit", "payload": packet}, label=label) - if resp is None: - print(f" {RED}TX failed: no response{RST}") - elif resp["type"] == "Error": - print(f" {RED}TX error: {resp}{RST}") - - -# ── MeshCore packet parsing ────────────────────────────────────── - - -def parse_header_and_path(packet: bytes): - """Parse MeshCore header + path. - - Returns (payload_type, route_type, pos, payload, path_hops, hash_size) or None. - """ - if len(packet) < 3: - return None - - header = packet[0] - route_type = header & 0x03 - payload_type = (header >> 2) & 0x0F - payload_ver = (header >> 6) & 0x03 - if payload_ver != 0: - return None - - has_tc = route_type in (0, 3) - - for skip_tc in ([True, False] if has_tc else [False]): - pos = 5 if skip_tc else 1 - if pos >= len(packet): - continue - - path_len_byte = packet[pos] - pos += 1 - hash_size_code = path_len_byte >> 6 - if hash_size_code == 3: - continue - hash_size = hash_size_code + 1 - hop_count = path_len_byte & 0x3F - path_bytes = hop_count * hash_size - if path_bytes > 64 or pos + path_bytes > len(packet): - continue - - hops = [] - for i in range(hop_count): - hops.append(packet[pos + i * hash_size : pos + (i + 1) * hash_size]) - pos += path_bytes - - payload = packet[pos:] - if payload and len(payload) <= 184: - return payload_type, route_type, pos, payload, hops, hash_size - - return None - - -# ── Packet construction ────────────────────────────────────────── - -_RT_NAMES = {0: "tflood", 1: "flood", 2: "direct", 3: "tdirect"} - - -def _build_routed_packet( - payload_type: int, - payload: bytes, - route: tuple[list[bytes], int] | None, -) -> tuple[bytes, str]: - """Build a packet with the given route. Returns (packet, route_description).""" - if route is not None: - hops, hash_size = route - hash_size_code = hash_size - 1 - path_len_byte = (hash_size_code << 6) | (len(hops) & 0x3F) - path_data = b"".join(hops) - header = bytes([(payload_type << 2) | 2]) # DIRECT - return header + bytes([path_len_byte]) + path_data + payload, "direct" - else: - header = bytes([(payload_type << 2) | 1]) # FLOOD - path_len = bytes([0x40]) # 0 hops, 2-byte hash mode - return header + path_len + payload, "flood" - - -def _build_direct_zero_hop_packet(payload_type: int, payload: bytes) -> bytes: - """Build a zero-hop direct packet (reaches only immediate neighbours).""" - header = bytes([(payload_type << 2) | 2]) # DIRECT - path_len = bytes([0x40]) # 0 hops, 2-byte hash mode - return header + path_len + payload - - -# ── ANON_REQ (login) ───────────────────────────────────────────── - - -def build_login_packet( - repeater_pubkey: bytes, - password: str, - route: tuple[list[bytes], int] | None, -) -> tuple[bytes, str]: - """Build an ANON_REQ login packet for a repeater. - - Plaintext: timestamp(4) + password (UTF-8, no null terminator needed — - encryption pads to 16-byte boundary, server reads until null/end). - - Returns (packet, route_description). - """ - shared_secret = _ecdh_shared_secret(repeater_pubkey) - - # Repeater login plaintext: timestamp(4) + password bytes - plaintext = struct.pack(" tuple[bytes, str]: - """Build a REQ packet with protocol_code=GET_TELEMETRY_DATA. - - Plaintext: timestamp(4) + protocol_code(1) + inverse_perm_mask(1) - We want base + environment (battery + temp), skip location. - - Returns (packet, route_description). - """ - shared_secret = _ecdh_shared_secret(repeater_pubkey) - - # inverse perm mask: skip location only - inv_mask = TELEM_PERM_LOCATION - - plaintext = struct.pack(" list[dict]: - """Decode CayenneLPP payload. Returns list of sensor readings.""" - sensors = [] - pos = 0 - while pos < len(data): - if pos + 2 > len(data): - break - channel = data[pos] - type_id = data[pos + 1] - pos += 2 - - type_info = CAYENNE_TYPES.get(type_id) - if type_info is None: - # Unknown type — can't determine size, stop parsing - break - - name, size, divisor = type_info - if pos + size > len(data): - break - - raw = data[pos : pos + size] - pos += size - - # Decode value (big-endian per CayenneLPP spec) - signed = name in ("temperature", "analog_in", "analog_out", "voltage", "current") - val_raw = int.from_bytes(raw, "big", signed=signed) if size > 1 else raw[0] - value = val_raw / divisor - - UNITS = { - "temperature": "°C", "humidity": "%", "barometer": "hPa", - "voltage": "V", "analog_in": "V", "analog_out": "V", - "current": "A", "power": "W", "distance": "m", - "illuminance": "lux", "percentage": "%", - } - sensors.append({ - "channel": channel, "type": name, - "value": value, "unit": UNITS.get(name, ""), - }) - - return sensors - - -# ── Response waiting ────────────────────────────────────────────── - - -def wait_for_response( - ser: serial.Serial, - repeater_pubkey: bytes, - expected_types: set[int], - timeout: float, -) -> tuple[dict | None, list[bytes], int]: - """Wait for a MeshCore response addressed to us from the repeater. - - Returns (parsed_info, path_hops, hash_size) or (None, [], 0). - parsed_info depends on payload type. - """ - pk_hex = repeater_pubkey.hex() - shared_secret = _ecdh_shared_secret(repeater_pubkey) - dest_hash = repeater_pubkey[0] - deadline = time.monotonic() + timeout - old_timeout = ser.timeout - ser.timeout = 1 - - try: - while time.monotonic() < deadline: - data = read_frame(ser) - if data is None: - continue - - resp = decode_response(data) - if resp["type"] != "RxPacket": - continue - - packet = resp["payload"] - parsed = parse_header_and_path(packet) - if parsed is None: - continue - - payload_type, route_type, pos, raw_payload, path_hops, hash_size = parsed - - if payload_type not in expected_types: - continue - - # ── PATH (0x08) — responses routed back via PATH ── - if payload_type == 0x08: - if len(raw_payload) < 20: - continue - dst = raw_payload[0] - src = raw_payload[1] - if dst != _my_hash(): - continue - if src != dest_hash: - continue - - mac_bytes = raw_payload[2:4] - ciphertext = raw_payload[4:] - plaintext = _verify_and_decrypt(shared_secret, mac_bytes, ciphertext) - if plaintext is None: - continue - - # PATH inner: path_len(1) + path_data + extra_type(1) + extra_data - inner_path_len_byte = plaintext[0] - inner_hash_size = (inner_path_len_byte >> 6) + 1 - inner_hop_count = inner_path_len_byte & 0x3F - inner_path_bytes = inner_hop_count * inner_hash_size - ipos = 1 + inner_path_bytes - - if ipos < len(plaintext): - extra_type = plaintext[ipos] - extra_data = plaintext[ipos + 1:] - else: - extra_type = 0xFF - extra_data = b"" - - # Learn route from inner path - if inner_hop_count > 0: - inner_hops = [] - for i in range(inner_hop_count): - inner_hops.append(plaintext[1 + i * inner_hash_size : 1 + (i + 1) * inner_hash_size]) - _learn_route(pk_hex, inner_hops, inner_hash_size) - _save_state() - - if extra_type != 0x01 or len(extra_data) < 4: - continue - - timestamp = struct.unpack_from("= 13 - and extra_data[4] in (0x00, 0x80) - and extra_data[12] in (0x01, 0x02, 0x03) - ) - - if is_login_response: - if 0x01 in expected_types: - # Waiting for telemetry — skip this stale login response - continue # skip stale login response - response_code = extra_data[4] - is_admin = extra_data[6] if len(extra_data) > 6 else 0 - return { - "type": "login_response", - "response_code": response_code, - "is_admin": bool(is_admin), - "timestamp": timestamp, - }, path_hops, hash_size - - # Telemetry response: ts(4) + CayenneLPP (no response_code) - if 0x01 in expected_types: - lpp_data = extra_data[4:] - while lpp_data and lpp_data[-1] == 0: - lpp_data = lpp_data[:-1] - sensors = decode_cayenne_lpp(lpp_data) if lpp_data else [] - return { - "type": "telemetry", - "reflected_timestamp": timestamp, - "sensors": sensors, - "raw_hex": extra_data.hex(), - }, path_hops, hash_size - - continue - - # ── RESPONSE (0x01) — telemetry data ── - if payload_type == 0x01: - if len(raw_payload) < 20: - continue - dst = raw_payload[0] - src = raw_payload[1] - if dst != _my_hash(): - continue - if src != dest_hash: - continue - - mac_bytes = raw_payload[2:4] - ciphertext = raw_payload[4:] - plaintext = _verify_and_decrypt(shared_secret, mac_bytes, ciphertext) - if plaintext is None: - continue - - # Telemetry response: timestamp(4) + CayenneLPP (no response_code) - if len(plaintext) < 4: - continue - - reflected_ts = struct.unpack_from(" list[tuple[tuple[list[bytes], int] | None, str]]: - """Return route strategies in escalation order. - - a) last known route b) zero-hop direct c) flood - """ - strategies = [] - - # a) Last known route - known = _get_route(pk_hex) - if known is not None: - strategies.append((known, "last-known")) - - # b) Zero-hop direct (immediate neighbours only — no flooding) - strategies.append(("zero-hop", "zero-hop")) - - # c) Flood (only as last resort) - strategies.append((None, "flood")) - - return strategies - - -# ── Poll one repeater ───────────────────────────────────────────── - - -def poll_repeater(ser: serial.Serial, repeater: dict) -> dict | None: - """Login to repeater, fetch telemetry. Returns telemetry dict or None.""" - name = repeater["name"] - pubkey = bytes.fromhex(repeater["pubkey"]) - password = repeater["password"] - pk_hex = repeater["pubkey"] - - print(f"\n {BCYN}── {name} ──{RST}") - - strategies = _route_strategies(pk_hex) - - # Step 1: Login with route escalation + flood retries - login_ok = False - for route_val, route_desc in strategies: - # Flood gets multiple attempts since it should always eventually work - attempts = FLOOD_RETRIES if route_val is None else 1 - - for attempt in range(attempts): - if route_val == "zero-hop": - shared_secret = _ecdh_shared_secret(pubkey) - plaintext = struct.pack(" 1 and attempt > 0 else "" - print(f" {DIM}Login via {actual_desc}{retry_label}...{RST}", end="", flush=True) - transmit(ser, packet, label=f"LOGIN({actual_desc})→{name}") - time.sleep(TX_SETTLE) - - result, path_hops, hash_size = wait_for_response( - ser, pubkey, {0x08}, LOGIN_TIMEOUT - ) - - if result is not None and result.get("response_code") in (0x00, 0x80): - login_ok = True - if path_hops: - _learn_route(pk_hex, path_hops, hash_size) - _save_state() - print(f" {GRN}OK{RST} {DIM}(learned {len(path_hops)}-hop route){RST}") - else: - print(f" {GRN}OK{RST}") - break - elif result is not None: - print(f" {RED}rejected (code=0x{result.get('response_code', -1):02x}){RST}") - login_ok = False - break # Wrong password — don't retry - else: - print(f" {YEL}timeout{RST}") - - if login_ok or (result is not None and result.get("response_code") not in (0x00, 0x80)): - break - - if not login_ok: - print(f" {RED}Login failed for {name}{RST}") - return None - - # Step 2: Request telemetry with escalation (direct → flood with retries) - telem_strategies = [] - known = _get_route(pk_hex) - if known is not None: - telem_strategies.append((known, "direct")) - telem_strategies.append((None, "flood")) - - result = None - for route, route_desc in telem_strategies: - attempts = FLOOD_RETRIES if route is None else 1 - - for attempt in range(attempts): - packet, _ = build_telemetry_request(pubkey, route) - retry_label = f" #{attempt + 1}" if attempts > 1 and attempt > 0 else "" - print(f" {DIM}Requesting telemetry via {route_desc}{retry_label}...{RST}", end="", flush=True) - transmit(ser, packet, label=f"TELEM_REQ({route_desc})→{name}") - time.sleep(TX_SETTLE) - - result, path_hops, hash_size = wait_for_response( - ser, pubkey, {0x01, 0x08}, TELEM_TIMEOUT - ) - - if result is not None: - if path_hops: - _learn_route(pk_hex, path_hops, hash_size) - _save_state() - print(f" {GRN}OK{RST}") - break - else: - print(f" {YEL}timeout{RST}") - - if result is not None: - break - # Clear stale route before escalating to next strategy - _route_table.pop(pk_hex, None) - - if result is None: - print(f" {RED}Telemetry failed for {name}{RST}") - return None - - # Display raw hex for debugging - raw_hex = result.get("raw_hex", "") - if raw_hex: - print(f" {DIM}raw: {raw_hex}{RST}") - - # Display parsed sensors — deduplicate by (channel, type), keep first - # (external sensors are added before MCU fallback in firmware) - sensors = result.get("sensors", []) - seen = set() - deduped = [] - for s in sensors: - key = (s["channel"], s["type"]) - if key not in seen: - seen.add(key) - deduped.append(s) - - if deduped: - for s in deduped: - stype = s["type"] - val = s["value"] - unit = s.get("unit", "") - if stype == "temperature": - f_val = val * 9 / 5 + 32 - print(f" {BWHT}temperature{RST}: {val:.1f}°C / {f_val:.1f}°F") - elif stype in ("voltage", "analog_in", "analog_out") and unit == "V": - print(f" {BWHT}voltage{RST}: {val:.2f}V") - elif stype in ("digital_in", "digital_out"): - state = "On" if val else "Off" - print(f" {DIM}digital {stype.split('_')[1]}{RST}: {state}") - else: - print(f" {BWHT}{stype}{RST} ch{s['channel']}: {val:.2f}{unit}") - - return result - - -# ── ADVERT decoding ────────────────────────────────────────────── - - -def decode_advert(raw_payload: bytes) -> tuple[bytes, str, int] | None: - """Decode and verify an ADVERT. Returns (pubkey, name, node_type) or None.""" - if len(raw_payload) < 100: - return None - - pubkey = raw_payload[0:32] - timestamp = raw_payload[32:36] - signature = raw_payload[36:100] - app_data = raw_payload[100:] - - try: - verify_key = VerifyKey(pubkey) - sign_msg = pubkey + timestamp + app_data - verify_key.verify(sign_msg, signature) - except Exception: - return None - - name = "" - node_type = 0 - if app_data: - flags = app_data[0] - node_type = flags & 0x0F - pos = 1 - if flags & 0x10: # has location - pos += 8 - if flags & 0x20: # feat1 - pos += 2 - if flags & 0x40: # feat2 - pos += 2 - if flags & 0x80 and pos < len(app_data): # has name - name = app_data[pos:].decode("utf-8", errors="replace") - - return pubkey, name if name else pubkey.hex()[:8], node_type - - -def discover_pubkeys(ser: serial.Serial, wanted: list[dict]) -> int: - """Listen for ADVERTs and match against repeaters missing pubkeys. - - Returns number of pubkeys discovered. - """ - # Build a lookup: lowercase name → list of config entries wanting that name - by_name: dict[str, list[dict]] = {} - for r in wanted: - by_name.setdefault(r["name"].lower(), []).append(r) - - found = 0 - total = len(wanted) - print(f"\n {BCYN}Waiting for ADVERTs from {total} repeater(s):{RST}") - for r in wanted: - print(f" {CYN}{r['name']}{RST}") - - old_timeout = ser.timeout - ser.timeout = 1 - - try: - while found < total: - data = read_frame(ser) - if data is None: - continue - - resp = decode_response(data) - if resp["type"] != "RxPacket": - continue - - packet = resp["payload"] - parsed = parse_header_and_path(packet) - if parsed is None: - continue - - payload_type, route_type, pos, raw_payload, path_hops, hash_size = parsed - if payload_type != 0x04: # not ADVERT - continue - - result = decode_advert(raw_payload) - if result is None: - continue - - pubkey, adv_name, node_type = result - pk_hex = pubkey.hex() - - # Match against wanted names (case-insensitive) - matches = by_name.get(adv_name.lower(), []) - if not matches: - print(f" {DIM}ADVERT {adv_name} (not in config){RST}") - continue - - for r in matches: - if r.get("pubkey"): - continue # already discovered - r["pubkey"] = pk_hex - found += 1 - # Learn route from the ADVERT path too - if path_hops: - _learn_route(pk_hex, path_hops, hash_size) - print( - f" {BGRN}Discovered {adv_name}{RST} " - f"{DIM}pubkey={pk_hex[:16]}... " - f"hash=0x{pubkey[0]:02x}{RST}" - ) - - if found >= total: - break - finally: - ser.timeout = old_timeout - - return found - - -# ── Config ──────────────────────────────────────────────────────── - - -def load_config() -> dict: - if not CONFIG_FILE.is_file(): - print(f" {RED}Config not found: {CONFIG_FILE}{RST}") - print(f" {DIM}Create it with:{RST}") - print(f""" - {{ - "repeaters": [ - {{ - "name": "MyRepeater", - "password": "guest123" - }} - ], - "poll_interval_secs": 3600 - }}""") - sys.exit(1) - - with open(CONFIG_FILE) as f: - config = json.load(f) - - repeaters = config.get("repeaters", []) - if not repeaters: - print(f" {RED}No repeaters configured in {CONFIG_FILE}{RST}") - sys.exit(1) - - for r in repeaters: - if "name" not in r or "password" not in r: - print(f" {RED}Repeater missing name or password: {r}{RST}") - sys.exit(1) - pk = r.get("pubkey", "") - if pk and len(pk) != 64: - print(f" {RED}Invalid pubkey length for {r['name']}: expected 64 hex chars{RST}") - sys.exit(1) - - return config - - -def save_config(config: dict): - """Write config back to disk (atomic).""" - try: - DATA_DIR.mkdir(parents=True, exist_ok=True) - tmp = CONFIG_FILE.with_suffix(".tmp") - with open(tmp, "w") as f: - json.dump(config, f, indent=4, ensure_ascii=False) - tmp.rename(CONFIG_FILE) - except Exception as e: - print(f" {RED}Failed to save config: {e}{RST}") - - -def needs_discovery(config: dict) -> list[dict]: - """Return repeaters that are missing their pubkey.""" - return [r for r in config["repeaters"] if not r.get("pubkey")] - - -# ── Main loop ───────────────────────────────────────────────────── - - -def _print_repeater_status(repeaters: list[dict]): - for r in repeaters: - pk = r.get("pubkey", "") - if pk: - route = _get_route(pk) - route_str = f"{len(route[0])}-hop" if route else "none" - print(f" {CYN}{r['name']}{RST} {DIM}(hash=0x{bytes.fromhex(pk)[0]:02x} route={route_str}){RST}") - else: - print(f" {CYN}{r['name']}{RST} {YEL}(pubkey unknown — will discover via ADVERT){RST}") - - -def main(): - import argparse - parser = argparse.ArgumentParser(description="MeshCore repeater telemetry monitor") - parser.add_argument("--port", "-p", default=None, help="Serial port (auto-detect if omitted)") - args = parser.parse_args() - - _init_identity() - _load_state() - config = load_config() - - repeaters = config["repeaters"] - poll_interval = config.get("poll_interval_secs", DEFAULT_POLL_INTERVAL) - - print(f"\n {BWHT}Telemetry Monitor{RST}") - print(f" {DIM}Repeaters: {len(repeaters)} | Poll interval: {poll_interval}s{RST}") - _print_repeater_status(repeaters) - - port = args.port - - while True: - try: - print(f" {DIM}Connecting...{RST}") - ser = open_connection(port) - print(f" {GRN}Connected{RST}") - - send_cmd(ser, {"type": "Ping"}, "Ping") - send_cmd(ser, {"type": "SetConfig", "config": RADIO_CONFIG}, "SetConfig") - send_cmd(ser, {"type": "StartRx"}, "StartRx") - - # Discover any repeaters missing their pubkey - missing = needs_discovery(config) - if missing: - found = discover_pubkeys(ser, missing) - if found: - save_config(config) - _save_state() - print(f" {BGRN}Saved {found} pubkey(s) to {CONFIG_FILE}{RST}") - still_missing = needs_discovery(config) - if still_missing: - names = ", ".join(r["name"] for r in still_missing) - print(f" {YEL}Still waiting for: {names}{RST}") - print(f" {DIM}Will retry discovery each poll cycle{RST}") - - while True: - ts = time.strftime("%Y-%m-%d %H:%M:%S %Z") - print(f"\n {BWHT}═══ Poll cycle {ts} ═══{RST}") - - # Re-check for missing pubkeys each cycle - missing = needs_discovery(config) - if missing: - print(f" {DIM}Listening for {len(missing)} missing ADVERT(s)...{RST}") - found = discover_pubkeys(ser, missing) - if found: - save_config(config) - _save_state() - - for repeater in repeaters: - if not repeater.get("pubkey"): - print(f"\n {BCYN}── {repeater['name']} ──{RST}") - print(f" {YEL}Skipping — pubkey not yet discovered{RST}") - continue - try: - poll_repeater(ser, repeater) - except (ConnectionError, OSError): - raise # mux/serial disconnect — let outer loop reconnect - except Exception as e: - print(f" {RED}Error polling {repeater['name']}: {e}{RST}") - - _save_state() - - next_poll = time.strftime( - "%H:%M:%S", time.localtime(time.time() + poll_interval) - ) - print(f"\n {DIM}Next poll at {next_poll} (sleeping {poll_interval}s){RST}") - - # Sleep in 1-second increments so Ctrl-C is responsive - deadline = time.monotonic() + poll_interval - while time.monotonic() < deadline: - time.sleep(1) - - except (serial.SerialException, ConnectionError, OSError) as e: - print(f"\n {RED}Disconnected: {e}{RST}") - print(f" {DIM}Reconnecting...{RST}") - time.sleep(1) - except KeyboardInterrupt: - print() - _save_state() - try: - ser.timeout = 2 - send_cmd(ser, {"type": "StopRx"}, "StopRx") - except Exception: - pass - break - - -if __name__ == "__main__": - main() diff --git a/examples/mise.toml b/examples/mise.toml deleted file mode 100644 index a245b1c0..00000000 --- a/examples/mise.toml +++ /dev/null @@ -1,4 +0,0 @@ -[tools] -python = "3.14" -uv = "latest" -just = "latest" diff --git a/examples/ping_pong.py b/examples/ping_pong.py deleted file mode 100755 index d289999a..00000000 --- a/examples/ping_pong.py +++ /dev/null @@ -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) diff --git a/examples/pyproject.toml b/examples/pyproject.toml deleted file mode 100644 index 7efe40e4..00000000 --- a/examples/pyproject.toml +++ /dev/null @@ -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 } diff --git a/examples/simple_rx.py b/examples/simple_rx.py deleted file mode 100755 index a5cb1d9a..00000000 --- a/examples/simple_rx.py +++ /dev/null @@ -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) diff --git a/examples/simple_tx.py b/examples/simple_tx.py deleted file mode 100755 index dab7465b..00000000 --- a/examples/simple_tx.py +++ /dev/null @@ -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) diff --git a/examples/uv.lock b/examples/uv.lock deleted file mode 100644 index 211e1c08..00000000 --- a/examples/uv.lock +++ /dev/null @@ -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" }, -] diff --git a/firmware/.cargo/config.toml b/firmware/.cargo/config.toml deleted file mode 100644 index b18ce923..00000000 --- a/firmware/.cargo/config.toml +++ /dev/null @@ -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", -] diff --git a/firmware/.gitignore b/firmware/.gitignore deleted file mode 100644 index 96aeed3b..00000000 --- a/firmware/.gitignore +++ /dev/null @@ -1,5 +0,0 @@ -/target -/vendor -/builds -/.hypothesis -src/board/mod.rs diff --git a/firmware/Cargo.lock b/firmware/Cargo.lock deleted file mode 100644 index 5dcd879c..00000000 --- a/firmware/Cargo.lock +++ /dev/null @@ -1,2017 +0,0 @@ -# This file is automatically @generated by Cargo. -# It is not intended for manual editing. -version = 4 - -[[package]] -name = "ahash" -version = "0.8.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" -dependencies = [ - "cfg-if", - "once_cell", - "version_check", - "zerocopy", -] - -[[package]] -name = "autocfg" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" - -[[package]] -name = "az" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b7e4c2464d97fe331d41de9d5db0def0a96f4d823b8b32a2efd503578988973" - -[[package]] -name = "bare-metal" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5deb64efa5bd81e31fcd1938615a6d98c82eafcbcd787162b6f63b91d6bac5b3" -dependencies = [ - "rustc_version", -] - -[[package]] -name = "bitfield" -version = "0.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46afbd2983a5d5a7bd740ccb198caf5b82f45c40c09c0eed36052d91cb92e719" - -[[package]] -name = "bitfield" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d7e60934ceec538daadb9d8432424ed043a904d8e0243f3c6446bce549a46ac" - -[[package]] -name = "bitfield" -version = "0.19.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21ba6517c6b0f2bf08be60e187ab64b038438f22dd755614d8fe4d4098c46419" -dependencies = [ - "bitfield-macros", -] - -[[package]] -name = "bitfield-macros" -version = "0.19.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f48d6ace212fdf1b45fd6b566bb40808415344642b76c3224c07c8df9da81e97" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[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 = "byte-slice-cast" -version = "1.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7575182f7272186991736b70173b0ea045398f984bf5ebbb3804736ce1330c9d" - -[[package]] -name = "bytemuck" -version = "1.25.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" - -[[package]] -name = "byteorder" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" - -[[package]] -name = "cfg-if" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" - -[[package]] -name = "cortex-m" -version = "0.7.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ec610d8f49840a5b376c69663b6369e71f4b34484b9b2eb29fb918d92516cb9" -dependencies = [ - "bare-metal", - "bitfield 0.13.2", - "critical-section", - "embedded-hal 0.2.7", - "volatile-register", -] - -[[package]] -name = "cortex-m-rt" -version = "0.7.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "801d4dec46b34c299ccf6b036717ae0fce602faa4f4fe816d9013b9a7c9f5ba6" -dependencies = [ - "cortex-m-rt-macros", -] - -[[package]] -name = "cortex-m-rt-macros" -version = "0.7.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e37549a379a9e0e6e576fd208ee60394ccb8be963889eebba3ffe0980364f472" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "critical-section" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" - -[[package]] -name = "crunchy" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" - -[[package]] -name = "crypto-common" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" -dependencies = [ - "generic-array", - "typenum", -] - -[[package]] -name = "darling" -version = "0.20.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" -dependencies = [ - "darling_core 0.20.11", - "darling_macro 0.20.11", -] - -[[package]] -name = "darling" -version = "0.21.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0" -dependencies = [ - "darling_core 0.21.3", - "darling_macro 0.21.3", -] - -[[package]] -name = "darling" -version = "0.23.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" -dependencies = [ - "darling_core 0.23.0", - "darling_macro 0.23.0", -] - -[[package]] -name = "darling_core" -version = "0.20.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" -dependencies = [ - "fnv", - "ident_case", - "proc-macro2", - "quote", - "strsim", - "syn 2.0.117", -] - -[[package]] -name = "darling_core" -version = "0.21.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1247195ecd7e3c85f83c8d2a366e4210d588e802133e1e355180a9870b517ea4" -dependencies = [ - "fnv", - "ident_case", - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "darling_core" -version = "0.23.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" -dependencies = [ - "ident_case", - "proc-macro2", - "quote", - "strsim", - "syn 2.0.117", -] - -[[package]] -name = "darling_macro" -version = "0.20.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" -dependencies = [ - "darling_core 0.20.11", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "darling_macro" -version = "0.21.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" -dependencies = [ - "darling_core 0.21.3", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "darling_macro" -version = "0.23.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" -dependencies = [ - "darling_core 0.23.0", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "defmt" -version = "0.3.100" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0963443817029b2024136fc4dd07a5107eb8f977eaf18fcd1fdeb11306b64ad" -dependencies = [ - "defmt 1.0.1", -] - -[[package]] -name = "defmt" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "548d977b6da32fa1d1fda2876453da1e7df63ad0304c8b3dae4dbe7b96f39b78" -dependencies = [ - "bitflags 1.3.2", - "defmt-macros", -] - -[[package]] -name = "defmt-macros" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d4fc12a85bcf441cfe44344c4b72d58493178ce635338a3f3b78943aceb258e" -dependencies = [ - "defmt-parser", - "proc-macro-error2", - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "defmt-parser" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" -dependencies = [ - "thiserror", -] - -[[package]] -name = "defmt-rtt" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93d5a25c99d89c40f5676bec8cefe0614f17f0f40e916f98e345dae941807f9e" -dependencies = [ - "critical-section", - "defmt 1.0.1", -] - -[[package]] -name = "delegate" -version = "0.13.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "780eb241654bf097afb00fc5f054a09b687dad862e485fdcf8399bb056565370" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "digest" -version = "0.10.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" -dependencies = [ - "crypto-common", -] - -[[package]] -name = "display-interface" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ba2aab1ef3793e6f7804162debb5ac5edb93b3d650fbcc5aeb72fcd0e6c03a0" - -[[package]] -name = "display-interface-i2c" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d964fa85bbbb5a6ecd06e58699407ac5dc3e3ad72dac0ab7e6b0d00a1cd262d" -dependencies = [ - "display-interface", - "embedded-hal 1.0.0", - "embedded-hal-async", -] - -[[package]] -name = "display-interface-spi" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f86b9ec30048b1955da2038fcc3c017f419ab21bb0001879d16c0a3749dc6b7a" -dependencies = [ - "byte-slice-cast", - "display-interface", - "embedded-hal 1.0.0", - "embedded-hal-async", -] - -[[package]] -name = "document-features" -version = "0.2.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" -dependencies = [ - "litrs", -] - -[[package]] -name = "donglora" -version = "0.2.0" -dependencies = [ - "cfg-if", - "cortex-m", - "cortex-m-rt", - "critical-section", - "defmt 1.0.1", - "defmt-rtt", - "display-interface", - "embassy-embedded-hal 0.6.0", - "embassy-executor", - "embassy-futures", - "embassy-nrf", - "embassy-sync 0.8.0", - "embassy-time", - "embassy-usb", - "embassy-usb-driver", - "embedded-graphics", - "embedded-hal 1.0.0", - "embedded-hal-async", - "embedded-io-async 0.7.0", - "esp-backtrace", - "esp-hal", - "esp-println", - "esp-rtos", - "heapless 0.9.2", - "lora-phy", - "minijinja", - "owo-colors", - "panic-probe", - "ssd1306", - "static_cell", - "ucobs", -] - -[[package]] -name = "embassy-embedded-hal" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "554e3e840696f54b4c9afcf28a0f24da431c927f4151040020416e7393d6d0d8" -dependencies = [ - "embassy-futures", - "embassy-hal-internal 0.3.0", - "embassy-sync 0.7.2", - "embedded-hal 0.2.7", - "embedded-hal 1.0.0", - "embedded-hal-async", - "embedded-storage", - "embedded-storage-async", - "nb 1.1.0", -] - -[[package]] -name = "embassy-embedded-hal" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0641612053b2f34fc250bb63f6630ae75de46e02ade7f457268447081d709ce" -dependencies = [ - "defmt 1.0.1", - "embassy-futures", - "embassy-hal-internal 0.4.0", - "embassy-sync 0.8.0", - "embassy-time", - "embedded-hal 0.2.7", - "embedded-hal 1.0.0", - "embedded-hal-async", - "embedded-storage", - "embedded-storage-async", - "nb 1.1.0", -] - -[[package]] -name = "embassy-executor" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06070468370195e0e86f241c8e5004356d696590a678d47d6676795b2e439c6b" -dependencies = [ - "cortex-m", - "critical-section", - "document-features", - "embassy-executor-macros", - "embassy-executor-timer-queue", -] - -[[package]] -name = "embassy-executor-macros" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dfdddc3a04226828316bf31393b6903ee162238576b1584ee2669af215d55472" -dependencies = [ - "darling 0.20.11", - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "embassy-executor-timer-queue" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2fc328bf943af66b80b98755db9106bf7e7471b0cf47dc8559cd9a6be504cc9c" - -[[package]] -name = "embassy-futures" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc2d050bdc5c21e0862a89256ed8029ae6c290a93aecefc73084b3002cdebb01" - -[[package]] -name = "embassy-hal-internal" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95285007a91b619dc9f26ea8f55452aa6c60f7115a4edc05085cd2bd3127cd7a" -dependencies = [ - "num-traits", -] - -[[package]] -name = "embassy-hal-internal" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f10ce10a4dfdf6402d8e9bd63128986b96a736b1a0a6680547ed2ac55d55dba" -dependencies = [ - "num-traits", -] - -[[package]] -name = "embassy-hal-internal" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "568659fc53866d3d85c60fa33723fb751aa69e71507634fc2c19e7649432fb75" -dependencies = [ - "cortex-m", - "critical-section", - "defmt 1.0.1", - "num-traits", -] - -[[package]] -name = "embassy-net-driver" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "524eb3c489760508f71360112bca70f6e53173e6fe48fc5f0efd0f5ab217751d" - -[[package]] -name = "embassy-net-driver-channel" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a07d2eb9f05a6fc876500949856ea1be40773d866d8cb99384f72d0ae4568c16" -dependencies = [ - "embassy-futures", - "embassy-net-driver", - "embassy-sync 0.8.0", -] - -[[package]] -name = "embassy-nrf" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b715380574504335c40ec3016bc44598fac3c385f82959b26c41b25c30b7648d" -dependencies = [ - "bitflags 2.11.0", - "cfg-if", - "cortex-m", - "cortex-m-rt", - "critical-section", - "defmt 1.0.1", - "document-features", - "embassy-embedded-hal 0.6.0", - "embassy-futures", - "embassy-hal-internal 0.5.0", - "embassy-sync 0.8.0", - "embassy-time-driver", - "embassy-time-queue-utils", - "embassy-usb-driver", - "embedded-hal 0.2.7", - "embedded-hal 1.0.0", - "embedded-hal-async", - "embedded-io 0.7.1", - "embedded-io-async 0.7.0", - "embedded-storage", - "embedded-storage-async", - "fixed", - "nrf-pac", - "rand_core 0.6.4", - "rand_core 0.9.5", -] - -[[package]] -name = "embassy-sync" -version = "0.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8d2c8cdff05a7a51ba0087489ea44b0b1d97a296ca6b1d6d1a33ea7423d34049" -dependencies = [ - "cfg-if", - "critical-section", - "embedded-io-async 0.6.1", - "futures-sink", - "futures-util", - "heapless 0.8.0", -] - -[[package]] -name = "embassy-sync" -version = "0.7.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73974a3edbd0bd286759b3d483540f0ebef705919a5f56f4fc7709066f71689b" -dependencies = [ - "cfg-if", - "critical-section", - "embedded-io-async 0.6.1", - "futures-core", - "futures-sink", - "heapless 0.8.0", -] - -[[package]] -name = "embassy-sync" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7bbd85cf5a5ae56bdf26f618364af642d1d0a4e245cdd75cd9aabda382f65a81" -dependencies = [ - "cfg-if", - "critical-section", - "defmt 1.0.1", - "embedded-io-async 0.7.0", - "futures-core", - "futures-sink", - "heapless 0.9.2", -] - -[[package]] -name = "embassy-time" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "592b0c143ec626e821d4d90da51a2bd91d559d6c442b7c74a47d368c9e23d97a" -dependencies = [ - "cfg-if", - "critical-section", - "document-features", - "embassy-time-driver", - "embedded-hal 0.2.7", - "embedded-hal 1.0.0", - "embedded-hal-async", - "futures-core", -] - -[[package]] -name = "embassy-time-driver" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ee71af1b3a0deaa53eaf2d39252f83504c853646e472400b763060389b9fcc9" -dependencies = [ - "document-features", -] - -[[package]] -name = "embassy-time-queue-utils" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "80e2ee86063bd028a420a5fb5898c18c87a8898026da1d4c852af2c443d0a454" -dependencies = [ - "embassy-executor-timer-queue", - "heapless 0.8.0", -] - -[[package]] -name = "embassy-usb" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a25746d8b152b72fbf2a217f489a083dbbe243f281f09184a1f2cfbe9bbb245f" -dependencies = [ - "bitflags 2.11.0", - "defmt 1.0.1", - "embassy-futures", - "embassy-net-driver-channel", - "embassy-sync 0.8.0", - "embassy-time", - "embassy-usb-driver", - "embedded-io-async 0.7.0", - "heapless 0.9.2", - "ssmarshal", - "usbd-hid", -] - -[[package]] -name = "embassy-usb-driver" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17119855ccc2d1f7470a39756b12068454ae27a3eabb037d940b5c03d9c77b7a" -dependencies = [ - "defmt 1.0.1", - "embedded-io-async 0.6.1", -] - -[[package]] -name = "embassy-usb-synopsys-otg" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cbe46f4083109c7ea12a03ca61095d1e87c76fec52c7ca9ee06a42935606dacb" -dependencies = [ - "critical-section", - "embassy-sync 0.8.0", - "embassy-usb-driver", -] - -[[package]] -name = "embedded-can" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9d2e857f87ac832df68fa498d18ddc679175cf3d2e4aa893988e5601baf9438" -dependencies = [ - "nb 1.1.0", -] - -[[package]] -name = "embedded-graphics" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e8da660bb0c829b34a56a965490597f82a55e767b91f9543be80ce8ccb416fe" -dependencies = [ - "az", - "byteorder", - "embedded-graphics-core", - "float-cmp", - "micromath", -] - -[[package]] -name = "embedded-graphics-core" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95743bef3ff70fcba3930246c4e6872882bbea0dcc6da2ca860112e0cd4bd09f" -dependencies = [ - "az", - "byteorder", -] - -[[package]] -name = "embedded-hal" -version = "0.2.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "35949884794ad573cf46071e41c9b60efb0cb311e3ca01f7af807af1debc66ff" -dependencies = [ - "nb 0.1.3", - "void", -] - -[[package]] -name = "embedded-hal" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "361a90feb7004eca4019fb28352a9465666b24f840f5c3cddf0ff13920590b89" - -[[package]] -name = "embedded-hal-async" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c4c685bbef7fe13c3c6dd4da26841ed3980ef33e841cddfa15ce8a8fb3f1884" -dependencies = [ - "embedded-hal 1.0.0", -] - -[[package]] -name = "embedded-io" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d" - -[[package]] -name = "embedded-io" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9eb1aa714776b75c7e67e1da744b81a129b3ff919c8712b5e1b32252c1f07cc7" - -[[package]] -name = "embedded-io-async" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ff09972d4073aa8c299395be75161d582e7629cd663171d62af73c8d50dba3f" -dependencies = [ - "embedded-io 0.6.1", -] - -[[package]] -name = "embedded-io-async" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2564b9f813c544241430e147d8bc454815ef9ac998878d30cc3055449f7fd4c0" -dependencies = [ - "embedded-io 0.7.1", -] - -[[package]] -name = "embedded-storage" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a21dea9854beb860f3062d10228ce9b976da520a73474aed3171ec276bc0c032" - -[[package]] -name = "embedded-storage-async" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1763775e2323b7d5f0aa6090657f5e21cfa02ede71f5dc40eead06d64dcd15cc" -dependencies = [ - "embedded-storage", -] - -[[package]] -name = "encode_unicode" -version = "0.3.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a357d28ed41a50f9c765dbfe56cbc04a64e53e5fc58ba79fbc34c10ef3df831f" - -[[package]] -name = "enumset" -version = "1.1.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25b07a8dfbbbfc0064c0a6bdf9edcf966de6b1c33ce344bdeca3b41615452634" -dependencies = [ - "enumset_derive", -] - -[[package]] -name = "enumset_derive" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f43e744e4ea338060faee68ed933e46e722fb7f3617e722a5772d7e856d8b3ce" -dependencies = [ - "darling 0.21.3", - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "equivalent" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" - -[[package]] -name = "esp-backtrace" -version = "0.18.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3318413fb566c7227387f67736cf70cd74d80a11f2bb31c7b95a9eb48d079669" -dependencies = [ - "cfg-if", - "defmt 1.0.1", - "document-features", - "esp-config", - "esp-metadata-generated", - "esp-println", - "heapless 0.9.2", - "riscv", - "semihosting", - "xtensa-lx", -] - -[[package]] -name = "esp-config" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "102871054f8dd98202177b9890cb4b71d0c6fe1f1413b7a379a8e0841fc2473c" -dependencies = [ - "document-features", - "esp-metadata-generated", - "serde", - "serde_yaml", - "somni-expr", -] - -[[package]] -name = "esp-hal" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "54786287c0a61ca0f78cb0c338a39427551d1be229103b4444591796c579e093" -dependencies = [ - "bitfield 0.19.4", - "bitflags 2.11.0", - "bytemuck", - "cfg-if", - "critical-section", - "delegate", - "digest", - "document-features", - "embassy-embedded-hal 0.5.0", - "embassy-futures", - "embassy-sync 0.7.2", - "embassy-usb-driver", - "embassy-usb-synopsys-otg", - "embedded-can", - "embedded-hal 1.0.0", - "embedded-hal-async", - "embedded-io 0.6.1", - "embedded-io 0.7.1", - "embedded-io-async 0.6.1", - "embedded-io-async 0.7.0", - "enumset", - "esp-config", - "esp-hal-procmacros", - "esp-metadata-generated", - "esp-riscv-rt", - "esp-rom-sys", - "esp-sync", - "esp-synopsys-usb-otg", - "esp32", - "esp32c2", - "esp32c3", - "esp32c6", - "esp32h2", - "esp32s2", - "esp32s3", - "fugit", - "instability", - "nb 1.1.0", - "paste", - "portable-atomic", - "rand_core 0.6.4", - "rand_core 0.9.5", - "riscv", - "strum", - "ufmt-write", - "xtensa-lx", - "xtensa-lx-rt", -] - -[[package]] -name = "esp-hal-procmacros" -version = "0.21.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e025a7a7a0affdb4ff913b5c4494aef96ee03d085bf83c27453ae3a71d50da6" -dependencies = [ - "document-features", - "object", - "proc-macro-crate", - "proc-macro2", - "quote", - "syn 2.0.117", - "termcolor", -] - -[[package]] -name = "esp-metadata-generated" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a93e39c8ad8d390d248dc7b9f4b59a873f313bf535218b8e2351356972399e3" - -[[package]] -name = "esp-println" -version = "0.16.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a30e6c9fbcc01c348d46706fef8131c7775ab84c254a3cd65d0cd3f6414d592" -dependencies = [ - "defmt 1.0.1", - "document-features", - "esp-metadata-generated", - "esp-sync", - "log", - "portable-atomic", -] - -[[package]] -name = "esp-riscv-rt" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "502744a5b1e7268d27fd2a4e56ad45efe42ead517d6c517a6961540de949b0ee" -dependencies = [ - "document-features", - "riscv", - "riscv-rt", -] - -[[package]] -name = "esp-rom-sys" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd66cccc6dd2d13e9f33668a57717ab14a6d217180ec112e6be533de93e7ecbf" -dependencies = [ - "cfg-if", - "document-features", - "esp-metadata-generated", -] - -[[package]] -name = "esp-rtos" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "162ec711c8d06e79c67b75d01595539e86b0aac209643af98ca87a12250428b3" -dependencies = [ - "cfg-if", - "document-features", - "embassy-executor", - "embassy-sync 0.7.2", - "embassy-time-driver", - "embassy-time-queue-utils", - "esp-config", - "esp-hal", - "esp-hal-procmacros", - "esp-metadata-generated", - "esp-sync", - "portable-atomic", -] - -[[package]] -name = "esp-sync" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d44974639b4e88914f83fe60d2832c00276657d7d857628fdfc966cc7302e8a8" -dependencies = [ - "cfg-if", - "document-features", - "embassy-sync 0.6.2", - "embassy-sync 0.7.2", - "esp-metadata-generated", - "riscv", - "xtensa-lx", -] - -[[package]] -name = "esp-synopsys-usb-otg" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8938451cb19032f13365328ea66ab38c8d16deecdf322067442297110eb74468" -dependencies = [ - "critical-section", - "embedded-hal 0.2.7", - "ral-registers", - "usb-device", - "vcell", -] - -[[package]] -name = "esp32" -version = "0.39.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b76170a463d18f888a1ad258031901036fd827a9ef126733053ba5f8739fb0c8" -dependencies = [ - "critical-section", - "vcell", -] - -[[package]] -name = "esp32c2" -version = "0.28.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e62cf8932966b8d445b6f1832977b468178f0a84effb2e9fda89f60c24d45aa3" -dependencies = [ - "critical-section", - "vcell", -] - -[[package]] -name = "esp32c3" -version = "0.31.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "356af3771d0d6536c735bf71136594f4d1cbb506abf6e0c51a6639e9bf4e7988" -dependencies = [ - "critical-section", - "vcell", -] - -[[package]] -name = "esp32c6" -version = "0.22.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f5e511df672d79cd63365c92045135e01ba952b6bddd25b660baff5e1110f6b" -dependencies = [ - "critical-section", - "vcell", -] - -[[package]] -name = "esp32h2" -version = "0.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed4a50bbd1380931e095e0973b9b12f782a9c481f2edf1f7c42e7eb4ff736d6d" -dependencies = [ - "critical-section", - "vcell", -] - -[[package]] -name = "esp32s2" -version = "0.30.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "98574d4c577fbe888fe3e6df7fc80d25a05624d9998f7d7de1500ae21fcca78f" -dependencies = [ - "critical-section", - "vcell", -] - -[[package]] -name = "esp32s3" -version = "0.34.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1810d8ee4845ef87542af981e38eb80ab531d0ef1061e1486014ab7af74c337a" -dependencies = [ - "critical-section", - "vcell", -] - -[[package]] -name = "fixed" -version = "1.29.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "707070ccf8c4173548210893a0186e29c266901b71ed20cd9e2ca0193dfe95c3" -dependencies = [ - "az", - "bytemuck", - "half", - "typenum", -] - -[[package]] -name = "float-cmp" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "98de4bbd547a563b716d8dfa9aad1cb19bfab00f4fa09a6a4ed21dbcf44ce9c4" -dependencies = [ - "num-traits", -] - -[[package]] -name = "fnv" -version = "1.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" - -[[package]] -name = "fugit" -version = "0.3.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e639847d312d9a82d2e75b0edcc1e934efcc64e6cb7aa94f0b1fbec0bc231d6" -dependencies = [ - "gcd", -] - -[[package]] -name = "futures-core" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" - -[[package]] -name = "futures-sink" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" - -[[package]] -name = "futures-task" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" - -[[package]] -name = "futures-util" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" -dependencies = [ - "futures-core", - "futures-task", - "pin-project-lite", -] - -[[package]] -name = "gcd" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d758ba1b47b00caf47f24925c0074ecb20d6dfcffe7f6d53395c0465674841a" - -[[package]] -name = "generic-array" -version = "0.14.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" -dependencies = [ - "typenum", - "version_check", -] - -[[package]] -name = "half" -version = "2.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" -dependencies = [ - "cfg-if", - "crunchy", - "zerocopy", -] - -[[package]] -name = "hash32" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47d60b12902ba28e2730cd37e95b8c9223af2808df9e902d4df49588d1470606" -dependencies = [ - "byteorder", -] - -[[package]] -name = "hashbrown" -version = "0.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43a3c133739dddd0d2990f9a4bdf8eb4b21ef50e4851ca85ab661199821d510e" -dependencies = [ - "ahash", -] - -[[package]] -name = "hashbrown" -version = "0.16.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" - -[[package]] -name = "heapless" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bfb9eb618601c89945a70e254898da93b13be0388091d42117462b265bb3fad" -dependencies = [ - "hash32", - "stable_deref_trait", -] - -[[package]] -name = "heapless" -version = "0.9.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2af2455f757db2b292a9b1768c4b70186d443bcb3b316252d6b540aec1cd89ed" -dependencies = [ - "hash32", - "stable_deref_trait", -] - -[[package]] -name = "heck" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" - -[[package]] -name = "ident_case" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" - -[[package]] -name = "indexmap" -version = "2.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" -dependencies = [ - "equivalent", - "hashbrown 0.16.1", -] - -[[package]] -name = "indoc" -version = "2.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" -dependencies = [ - "rustversion", -] - -[[package]] -name = "instability" -version = "0.3.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5eb2d60ef19920a3a9193c3e371f726ec1dafc045dac788d0fb3704272458971" -dependencies = [ - "darling 0.23.0", - "indoc", - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "itoa" -version = "1.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" - -[[package]] -name = "litrs" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" - -[[package]] -name = "log" -version = "0.4.29" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" - -[[package]] -name = "lora-modulation" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c96c4a41a1f1ad04de765306e1829c7007a37898fe4033204fa82cf2e055624" - -[[package]] -name = "lora-phy" -version = "3.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61471c3b2909789e3332083577f6cf6c41a4fcf37674ef15156bcbb20504ac65" -dependencies = [ - "defmt 0.3.100", - "embedded-hal 1.0.0", - "embedded-hal-async", - "lora-modulation", - "num-traits", -] - -[[package]] -name = "maybe-async-cfg" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1e083394889336bc66a4eaf1011ffbfa74893e910f902a9f271fa624c61e1b2" -dependencies = [ - "proc-macro-error", - "proc-macro2", - "pulldown-cmark", - "quote", - "syn 1.0.109", -] - -[[package]] -name = "memchr" -version = "2.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" - -[[package]] -name = "memo-map" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38d1115007560874e373613744c6fba374c17688327a71c1476d1a5954cc857b" - -[[package]] -name = "micromath" -version = "2.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3c8dda44ff03a2f238717214da50f65d5a53b45cd213a7370424ffdb6fae815" - -[[package]] -name = "minijinja" -version = "2.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "328251e58ad8e415be6198888fc207502727dc77945806421ab34f35bf012e7d" -dependencies = [ - "memo-map", - "serde", -] - -[[package]] -name = "nb" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "801d31da0513b6ec5214e9bf433a77966320625a37860f910be265be6e18d06f" -dependencies = [ - "nb 1.1.0", -] - -[[package]] -name = "nb" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8d5439c4ad607c3c23abf66de8c8bf57ba8adcd1f129e699851a6e43935d339d" - -[[package]] -name = "nrf-pac" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bddd547fab92b0553ff37032034fd04ce8b8b60d3f9a83f89ea815dfd0400336" -dependencies = [ - "cortex-m", - "cortex-m-rt", -] - -[[package]] -name = "num-traits" -version = "0.2.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" -dependencies = [ - "autocfg", -] - -[[package]] -name = "object" -version = "0.37.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" -dependencies = [ - "memchr", -] - -[[package]] -name = "once_cell" -version = "1.21.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" - -[[package]] -name = "owo-colors" -version = "4.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d211803b9b6b570f68772237e415a029d5a50c65d382910b879fb19d3271f94d" - -[[package]] -name = "panic-probe" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd402d00b0fb94c5aee000029204a46884b1262e0c443f166d86d2c0747e1a1a" -dependencies = [ - "cortex-m", - "defmt 1.0.1", -] - -[[package]] -name = "paste" -version = "1.0.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" - -[[package]] -name = "pin-project-lite" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" - -[[package]] -name = "portable-atomic" -version = "1.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" - -[[package]] -name = "proc-macro-crate" -version = "3.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" -dependencies = [ - "toml_edit", -] - -[[package]] -name = "proc-macro-error" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" -dependencies = [ - "proc-macro-error-attr", - "proc-macro2", - "quote", - "syn 1.0.109", - "version_check", -] - -[[package]] -name = "proc-macro-error-attr" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" -dependencies = [ - "proc-macro2", - "quote", - "version_check", -] - -[[package]] -name = "proc-macro-error-attr2" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" -dependencies = [ - "proc-macro2", - "quote", -] - -[[package]] -name = "proc-macro-error2" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" -dependencies = [ - "proc-macro-error-attr2", - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[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 = "pulldown-cmark" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "679341d22c78c6c649893cbd6c3278dcbe9fc4faa62fea3a9296ae2b50c14625" -dependencies = [ - "bitflags 2.11.0", - "memchr", - "unicase", -] - -[[package]] -name = "quote" -version = "1.0.45" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" -dependencies = [ - "proc-macro2", -] - -[[package]] -name = "ral-registers" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46b71a9d9206e8b46714c74255adcaea8b11e0350c1d8456165073c3f75fc81a" - -[[package]] -name = "rand_core" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" - -[[package]] -name = "rand_core" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" - -[[package]] -name = "riscv" -version = "0.15.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b05cfa3f7b30c84536a9025150d44d26b8e1cc20ddf436448d74cd9591eefb25" -dependencies = [ - "critical-section", - "embedded-hal 1.0.0", - "paste", - "riscv-macros", - "riscv-pac", -] - -[[package]] -name = "riscv-macros" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d323d13972c1b104aa036bc692cd08b822c8bbf23d79a27c526095856499799" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "riscv-pac" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8188909339ccc0c68cfb5a04648313f09621e8b87dc03095454f1a11f6c5d436" - -[[package]] -name = "riscv-rt" -version = "0.16.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d07b9f3a0eff773fc4df11f44ada4fa302e529bff4b7fe7e6a4b98a65ce9174" -dependencies = [ - "riscv", - "riscv-pac", - "riscv-rt-macros", - "riscv-target-parser", -] - -[[package]] -name = "riscv-rt-macros" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "def519ddeeb5e43c2b4fc3952c27b3a86782fc05192f322b2309125cd85b1fc3" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "riscv-target-parser" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1376b15f3ff160e9b1e8ea564ce427f2f6fcf77528cc0a8bf405cb476f9cea7" - -[[package]] -name = "rustc_version" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "138e3e0acb6c9fb258b19b67cb8abd63c00679d2851805ea151465464fe9030a" -dependencies = [ - "semver", -] - -[[package]] -name = "rustversion" -version = "1.0.22" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" - -[[package]] -name = "ryu" -version = "1.0.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" - -[[package]] -name = "semihosting" -version = "0.1.25" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8e4abf97879f4e80db69a9fba7bd64998e9bdad25f58ef045a778e191172fd4" - -[[package]] -name = "semver" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d7eb9ef2c18661902cc47e535f9bc51b78acd254da71d375c2f6720d9a40403" -dependencies = [ - "semver-parser", -] - -[[package]] -name = "semver-parser" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "388a1df253eca08550bef6c72392cfe7c30914bf41df5269b68cbd6ff8f570a3" - -[[package]] -name = "serde" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" -dependencies = [ - "serde_core", - "serde_derive", -] - -[[package]] -name = "serde_core" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" -dependencies = [ - "serde_derive", -] - -[[package]] -name = "serde_derive" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "serde_yaml" -version = "0.9.34+deprecated" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" -dependencies = [ - "indexmap", - "itoa", - "ryu", - "serde", - "unsafe-libyaml", -] - -[[package]] -name = "somni-expr" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ed9b7648d5e8b2df6c5e49940c54bcdd2b4dd71eafc6e8f1c714eb4581b0f53" -dependencies = [ - "somni-parser", -] - -[[package]] -name = "somni-parser" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0f368519fc6c85fc1afdb769fb5a51123f6158013e143656e25a3485a0d401c" - -[[package]] -name = "ssd1306" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ea6aac2d078bbc71d9b8ac3f657335311f3b6625e9a1a96ccc29f5abfa77c56" -dependencies = [ - "display-interface", - "display-interface-i2c", - "display-interface-spi", - "embedded-graphics-core", - "embedded-hal 1.0.0", - "embedded-hal-async", - "maybe-async-cfg", -] - -[[package]] -name = "ssmarshal" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3e6ad23b128192ed337dfa4f1b8099ced0c2bf30d61e551b65fda5916dbb850" -dependencies = [ - "encode_unicode", - "serde", -] - -[[package]] -name = "stable_deref_trait" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" - -[[package]] -name = "static_cell" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0530892bb4fa575ee0da4b86f86c667132a94b74bb72160f58ee5a4afec74c23" -dependencies = [ - "portable-atomic", -] - -[[package]] -name = "strsim" -version = "0.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" - -[[package]] -name = "strum" -version = "0.27.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" -dependencies = [ - "strum_macros", -] - -[[package]] -name = "strum_macros" -version = "0.27.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" -dependencies = [ - "heck", - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "syn" -version = "1.0.109" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[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 = "termcolor" -version = "1.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" -dependencies = [ - "winapi-util", -] - -[[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 2.0.117", -] - -[[package]] -name = "toml_datetime" -version = "1.1.1+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" -dependencies = [ - "serde_core", -] - -[[package]] -name = "toml_edit" -version = "0.25.9+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da053d28fe57e2c9d21b48261e14e7b4c8b670b54d2c684847b91feaf4c7dac5" -dependencies = [ - "indexmap", - "toml_datetime", - "toml_parser", - "winnow", -] - -[[package]] -name = "toml_parser" -version = "1.1.1+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39ca317ebc49f06bd748bfba29533eac9485569dc9bf80b849024b025e814fb9" -dependencies = [ - "winnow", -] - -[[package]] -name = "typenum" -version = "1.19.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" - -[[package]] -name = "ucobs" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "311fbab511bafd13c109aff41f67d4c4426bf919a6670647e69f0d2af95ac01d" - -[[package]] -name = "ufmt-write" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e87a2ed6b42ec5e28cc3b94c09982969e9227600b2e3dcbc1db927a84c06bd69" - -[[package]] -name = "unicase" -version = "2.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" - -[[package]] -name = "unicode-ident" -version = "1.0.24" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" - -[[package]] -name = "unsafe-libyaml" -version = "0.2.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" - -[[package]] -name = "usb-device" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "98816b1accafbb09085168b90f27e93d790b4bfa19d883466b5e53315b5f06a6" -dependencies = [ - "heapless 0.8.0", - "portable-atomic", -] - -[[package]] -name = "usbd-hid" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68beab087e4971a2fe76f631478b0e91d39593f58efd2775026ce6dc07a7bac6" -dependencies = [ - "usb-device", - "usbd-hid-macros", -] - -[[package]] -name = "usbd-hid-descriptors" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b297f021719c4308d5d0c61b6c1e7c6b3ba383deba774b49aa5484f996bdb8f1" -dependencies = [ - "bitfield 0.14.0", -] - -[[package]] -name = "usbd-hid-macros" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "011a3219e0933f5b3ad7dc90d9a66541a967d084c98c067deed1cd608e557ed7" -dependencies = [ - "byteorder", - "hashbrown 0.13.2", - "log", - "proc-macro2", - "quote", - "serde", - "syn 2.0.117", - "usbd-hid-descriptors", -] - -[[package]] -name = "vcell" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77439c1b53d2303b20d9459b1ade71a83c716e3f9c34f3228c00e6f185d6c002" - -[[package]] -name = "version_check" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" - -[[package]] -name = "void" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a02e4885ed3bc0f2de90ea6dd45ebcbb66dacffe03547fadbb0eeae2770887d" - -[[package]] -name = "volatile-register" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "de437e2a6208b014ab52972a27e59b33fa2920d3e00fe05026167a1c509d19cc" -dependencies = [ - "vcell", -] - -[[package]] -name = "winapi-util" -version = "0.1.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" -dependencies = [ - "windows-sys", -] - -[[package]] -name = "windows-link" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" - -[[package]] -name = "windows-sys" -version = "0.61.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" -dependencies = [ - "windows-link", -] - -[[package]] -name = "winnow" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09dac053f1cd375980747450bfc7250c264eaae0583872e845c0c7cd578872b5" -dependencies = [ - "memchr", -] - -[[package]] -name = "xtensa-lx" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e012d667b0aa6d2592ace8ef145a98bff3e76cca7a644f4181ecd7a916ed289b" -dependencies = [ - "critical-section", -] - -[[package]] -name = "xtensa-lx-rt" -version = "0.21.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8709f037fb123fe7ff146d2bce86f9dc0dfc53045c016bfd9d703317b6502845" -dependencies = [ - "document-features", - "xtensa-lx", - "xtensa-lx-rt-proc-macros", -] - -[[package]] -name = "xtensa-lx-rt-proc-macros" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96fb42cd29c42f8744c74276e9f5bee7b06685bbe5b88df891516d72cb320450" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "zerocopy" -version = "0.8.48" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" -dependencies = [ - "zerocopy-derive", -] - -[[package]] -name = "zerocopy-derive" -version = "0.8.48" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] diff --git a/firmware/Cargo.toml b/firmware/Cargo.toml deleted file mode 100644 index f65fd306..00000000 --- a/firmware/Cargo.toml +++ /dev/null @@ -1,127 +0,0 @@ -[workspace] -members = ["."] - -[package] -name = "donglora" -version = "0.2.0" -edition = "2021" -build = "build.rs" -authors = ["Stephen Waits "] -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 diff --git a/firmware/PROTOCOL.md b/firmware/PROTOCOL.md deleted file mode 100644 index 5a5f999c..00000000 --- a/firmware/PROTOCOL.md +++ /dev/null @@ -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(" 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. diff --git a/firmware/README.md b/firmware/README.md deleted file mode 100644 index f997b623..00000000 --- a/firmware/README.md +++ /dev/null @@ -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. diff --git a/firmware/TODO.md b/firmware/TODO.md deleted file mode 100644 index 1ce960ad..00000000 --- a/firmware/TODO.md +++ /dev/null @@ -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 diff --git a/firmware/build.rs b/firmware/build.rs deleted file mode 100644 index 75f1ce76..00000000 --- a/firmware/build.rs +++ /dev/null @@ -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 = 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::` 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> = 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"); -} diff --git a/firmware/justfile b/firmware/justfile deleted file mode 100644 index 34880717..00000000 --- a/firmware/justfile +++ /dev/null @@ -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 diff --git a/firmware/ld/nrf52840-memory.x b/firmware/ld/nrf52840-memory.x deleted file mode 100644 index 8de03147..00000000 --- a/firmware/ld/nrf52840-memory.x +++ /dev/null @@ -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 -} diff --git a/firmware/mise.toml b/firmware/mise.toml deleted file mode 100644 index 2e61d348..00000000 --- a/firmware/mise.toml +++ /dev/null @@ -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" diff --git a/firmware/src/board/PORTING.md b/firmware/src/board/PORTING.md deleted file mode 100644 index 7ed53189..00000000 --- a/firmware/src/board/PORTING.md +++ /dev/null @@ -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/.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::` 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 -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 -``` - -### 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 { - // 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 { - // 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 -``` diff --git a/firmware/src/board/esp32s3.rs b/firmware/src/board/esp32s3.rs deleted file mode 100644 index 1a1cda5c..00000000 --- a/firmware/src/board/esp32s3.rs +++ /dev/null @@ -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, Input<'static>>; -type RadioSpiDevice = SpiDevice<'static, NoopRawMutex, SpiBus, Nss>; -pub type RadioDriver = Sx126x; - -// ── 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, - ssd1306::size::DisplaySize128x64, - ssd1306::mode::BufferedGraphicsModeAsync, ->; - -/// Construct and initialize an SSD1306 display from raw I2C. -pub async fn create_display(i2c: DisplayI2c) -> Option { - 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, - 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, - } -} diff --git a/firmware/src/board/heltec_v3.rs b/firmware/src/board/heltec_v3.rs deleted file mode 100644 index cec9e691..00000000 --- a/firmware/src/board/heltec_v3.rs +++ /dev/null @@ -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>; - -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 { - 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(), - } - } -} diff --git a/firmware/src/board/heltec_v3_uart.rs b/firmware/src/board/heltec_v3_uart.rs deleted file mode 100644 index 80c1b492..00000000 --- a/firmware/src/board/heltec_v3_uart.rs +++ /dev/null @@ -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>; - -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 { - 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(), - } - } -} diff --git a/firmware/src/board/heltec_v4.rs b/firmware/src/board/heltec_v4.rs deleted file mode 100644 index 3aa77c54..00000000 --- a/firmware/src/board/heltec_v4.rs +++ /dev/null @@ -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>; - -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 { - 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(), - } - } -} diff --git a/firmware/src/board/mod.rs.j2 b/firmware/src/board/mod.rs.j2 deleted file mode 100644 index ba6daa79..00000000 --- a/firmware/src/board/mod.rs.j2 +++ /dev/null @@ -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 %} -} diff --git a/firmware/src/board/rak_wisblock_4631.rs b/firmware/src/board/rak_wisblock_4631.rs deleted file mode 100644 index c17ccc19..00000000 --- a/firmware/src/board/rak_wisblock_4631.rs +++ /dev/null @@ -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, Input<'static>>; -type RadioSpiDevice = SpiDevice<'static, NoopRawMutex, mcu::SpiBus, Nss>; -pub type RadioDriver = Sx126x; -pub type UsbDriver = mcu::UsbNrfDriver; -pub type DisplayI2c = mcu::I2cBus; -pub type LedDriver = (); - -pub type DisplayDriver = ssd1306::Ssd1306Async< - ssd1306::prelude::I2CInterface, - ssd1306::size::DisplaySize128x64, - ssd1306::mode::BufferedGraphicsModeAsync, ->; - -// ── 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 { - 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 { - 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(), - } - } -} diff --git a/firmware/src/board/traits.rs b/firmware/src/board/traits.rs deleted file mode 100644 index 28b68124..00000000 --- a/firmware/src/board/traits.rs +++ /dev/null @@ -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; -} - -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; - - /// 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; -} - -/// Peripheral bundles for each firmware task, produced by [`LoRaBoard::into_parts`]. -pub struct BoardParts { - pub radio: R, - pub host: C, - pub display: Option, - pub led: L, - pub mac: [u8; 6], -} diff --git a/firmware/src/board/wio_tracker_l1.rs b/firmware/src/board/wio_tracker_l1.rs deleted file mode 100644 index ccf1c2bd..00000000 --- a/firmware/src/board/wio_tracker_l1.rs +++ /dev/null @@ -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, Input<'static>>; -type RadioSpiDevice = SpiDevice<'static, NoopRawMutex, mcu::SpiBus, Nss>; -pub type RadioDriver = Sx126x; -pub type UsbDriver = mcu::UsbNrfDriver; -pub type DisplayI2c = mcu::I2cBus; - -pub type LedDriver = crate::driver::simple_led::SimpleLed>; - -pub type DisplayDriver = crate::driver::sh1106::Sh1106; - -// ── 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 { - 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 { - 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(), - } - } -} diff --git a/firmware/src/channel.rs b/firmware/src/channel.rs deleted file mode 100644 index e431aaa1..00000000 --- a/firmware/src/channel.rs +++ /dev/null @@ -1,67 +0,0 @@ -//! Inter-task communication channels. -//! -//! Each channel connects exactly two tasks: -//! - [`CommandChannel`]: USB → Radio (host commands) -//! - [`ResponseChannel`]: Radio → USB (firmware responses) -//! - [`DisplayCommandChannel`]: USB → Display (on/off/reset) -//! - [`StatusWatch`]: Radio → Display (observable radio state) - -use embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex; -use embassy_sync::channel::Channel; -use embassy_sync::watch::Watch; - -use crate::protocol::{Command, RadioConfig, Response}; - -/// Display commands routed from host_task to display_task. -#[derive(Debug, Clone, Copy, defmt::Format)] -pub enum DisplayCommand { - On, - Off, - Reset, -} - -/// USB-to-display command channel (depth 4: display commands are rare events). -pub type DisplayCommandChannel = Channel; - -/// Host-to-radio command channel (depth 16: buffer bursty host command sequences). -pub type CommandChannel = Channel; - -/// Radio-to-host response channel (depth 32: buffer RX packets while USB writes). -pub type ResponseChannel = Channel; - -/// Observable radio status for the display task (2 receivers max). -/// Display always sees the latest state — intermediate updates may be -/// skipped, which is fine for a status display. -pub type StatusWatch = Watch; - -/// Current radio state exposed to observers (e.g. display). -#[derive(Debug, Clone, defmt::Format)] -pub struct RadioStatus { - pub state: RadioState, - pub config: Option, - pub rx_count: u32, - pub tx_count: u32, - pub last_rssi: Option, - pub last_snr: Option, -} - -/// Radio state machine states. -#[derive(Debug, Clone, Copy, PartialEq, Eq, defmt::Format)] -pub enum RadioState { - Idle, - Receiving, - Transmitting, -} - -impl Default for RadioStatus { - fn default() -> Self { - Self { - state: RadioState::Idle, - config: None, - rx_count: 0, - tx_count: 0, - last_rssi: None, - last_snr: None, - } - } -} diff --git a/firmware/src/display.rs b/firmware/src/display.rs deleted file mode 100644 index 0d29ba72..00000000 --- a/firmware/src/display.rs +++ /dev/null @@ -1,631 +0,0 @@ -//! OLED display dashboard: radio status, sparkline, splash screen. - -use embassy_executor::task; -use embassy_futures::select::{select3, Either3}; -use embassy_time::Timer; -use embedded_graphics::draw_target::DrawTarget; -use embedded_graphics::pixelcolor::BinaryColor; - -use crate::board::{Board, DisplayParts, LedDriver, LoRaBoard, RgbLed}; -use crate::channel::{DisplayCommand, DisplayCommandChannel, RadioState, RadioStatus, StatusWatch}; - -use render::RSSI_HISTORY_LEN; - -const BOARD_NAME: &str = Board::NAME; - -/// Duration per sparkline slot. 128 slots * 1s = ~2 minutes of history. -const SPARK_SLOT_MS: u64 = 1000; - -/// Sentinel: no packet received in this slot. Below SX1262 sensitivity -/// floor (-120 dBm), so it cannot be confused with a real RSSI value. -const NO_SIGNAL: i16 = -121; - -struct DisplayState { - rssi_history: [i16; RSSI_HISTORY_LEN], - tx_history: [bool; RSSI_HISTORY_LEN], - rssi_count: usize, - current_slot_rssi: i16, - current_slot_tx: bool, - display_on: bool, - disconnected: bool, - last_status: RadioStatus, -} - -impl DisplayState { - fn new() -> Self { - Self { - rssi_history: [NO_SIGNAL; RSSI_HISTORY_LEN], - tx_history: [false; RSSI_HISTORY_LEN], - rssi_count: 0, - current_slot_rssi: NO_SIGNAL, - current_slot_tx: false, - display_on: true, - disconnected: false, - last_status: RadioStatus::default(), - } - } - - /// Record an RSSI sample in the current time slot (keep best). - fn record_rssi(&mut self, rssi: i16) { - if self.current_slot_rssi == NO_SIGNAL || rssi > self.current_slot_rssi { - self.current_slot_rssi = rssi; - } - } - - /// Mark the current time slot as having a transmit. - fn record_tx(&mut self) { - self.current_slot_tx = true; - } - - /// Advance to the next time slot, committing the current slot's data. - fn advance_slot(&mut self) { - let idx = self.rssi_count % RSSI_HISTORY_LEN; - self.rssi_history[idx] = self.current_slot_rssi; - self.tx_history[idx] = self.current_slot_tx; - self.rssi_count += 1; - self.current_slot_rssi = NO_SIGNAL; - self.current_slot_tx = false; - } - - /// Whether the active dashboard should be shown (RX or TX mode). - fn is_active(&self) -> bool { - matches!( - self.last_status.state, - RadioState::Receiving | RadioState::Transmitting - ) - } -} - -/// Map SNR (dB) to LED brightness (4..64). Stronger signal = brighter. -fn snr_brightness(snr: Option) -> u8 { - let snr = snr.unwrap_or(-10); - let clamped = snr.clamp(-20, 15) as i32; - // -20 → 4, +15 → 64 - ((clamped + 20) * 60 / 35 + 4) as u8 -} - -/// Render the appropriate screen for the current state into the display buffer. -fn render_current( - display: &mut impl DrawTarget, - state: &DisplayState, - board: &render::BoardInfo<'_>, -) { - if state.is_active() { - render::dashboard( - display, - &state.last_status, - &state.rssi_history, - &state.tx_history, - state.rssi_count, - state.current_slot_rssi, - state.current_slot_tx, - board, - ); - } else { - render::splash(display, board); - } -} - -#[task] -pub async fn display_task( - parts: DisplayParts, - mut led: LedDriver, - status: &'static StatusWatch, - display_commands: &'static DisplayCommandChannel, -) { - // Format MAC address as "XX:XX:XX:XX:XX:XX" - let mut mac_str: heapless::String<18> = heapless::String::new(); - let m = Board::mac_address(); - let _ = core::fmt::Write::write_fmt( - &mut mac_str, - format_args!( - "{:02X}:{:02X}:{:02X}:{:02X}:{:02X}:{:02X}", - m[0], m[1], m[2], m[3], m[4], m[5] - ), - ); - - let board_info = render::BoardInfo { - name: BOARD_NAME, - version: env!("CARGO_PKG_VERSION"), - mac: &mac_str, - }; - - let Some(mut display) = crate::board::create_display(parts.i2c).await else { - defmt::error!("display init failed, giving up"); - return; - }; - - let mut state = DisplayState::new(); - let Some(mut receiver) = status.receiver() else { - defmt::error!("no watch receiver available for display"); - return; - }; - - // Show splash/waiting screen - render::splash(&mut display, &board_info); - let _ = display.flush().await; - - loop { - match select3( - receiver.changed(), - display_commands.receive(), - Timer::after_millis(SPARK_SLOT_MS), - ) - .await - { - Either3::First(radio_status) => { - if state.disconnected { - continue; - } - let rx_packet = radio_status.rx_count != state.last_status.rx_count; - let tx_packet = radio_status.tx_count != state.last_status.tx_count; - - if let Some(rssi) = radio_status.last_rssi { - if rx_packet { - state.record_rssi(rssi); - } - } - if tx_packet { - state.record_tx(); - } - let last_snr = radio_status.last_snr; - state.last_status = radio_status; - - if state.display_on { - render_current(&mut display, &state, &board_info); - let _ = display.flush().await; - - // Brief LED blink: red on TX, green scaled by SNR on RX - if tx_packet { - led.set_rgb(32, 0, 0).await; - Timer::after_millis(50).await; - led.set_rgb(0, 0, 0).await; - } else if rx_packet { - let b = snr_brightness(last_snr); - led.set_rgb(0, b, 0).await; - Timer::after_millis(50).await; - led.set_rgb(0, 0, 0).await; - } - } - } - Either3::Second(cmd) => match cmd { - DisplayCommand::Off => { - state.disconnected = false; - state.display_on = false; - render::blank(&mut display); - let _ = display.flush().await; - led.set_rgb(0, 0, 0).await; - } - DisplayCommand::On => { - state.disconnected = false; - state.display_on = true; - if let Some(s) = receiver.try_get() { - state.last_status = s; - } - render_current(&mut display, &state, &board_info); - let _ = display.flush().await; - } - DisplayCommand::Reset => { - state = DisplayState::new(); - render::splash(&mut display, &board_info); - let _ = display.flush().await; - led.set_rgb(0, 0, 0).await; - } - }, - Either3::Third(()) => { - // Timer tick: advance sparkline slot - state.advance_slot(); - if state.display_on && state.is_active() { - render_current(&mut display, &state, &board_info); - let _ = display.flush().await; - } - } - } - } -} - -// ── Rendering ─────────────────────────────────────────────────────── - -mod render { - use core::fmt::Write; - - use embedded_graphics::mono_font::ascii::{FONT_6X10, FONT_9X15_BOLD}; - use embedded_graphics::mono_font::MonoTextStyle; - use embedded_graphics::pixelcolor::BinaryColor; - use embedded_graphics::prelude::*; - use embedded_graphics::primitives::{Line, PrimitiveStyle, Rectangle}; - use embedded_graphics::text::renderer::CharacterStyle; - use embedded_graphics::text::{Alignment, Text}; - use heapless::String; - - use crate::channel::{RadioState, RadioStatus}; - use crate::protocol::Bandwidth; - - /// RSSI ring buffer length — supports displays up to 256px wide (2px per bar). - pub const RSSI_HISTORY_LEN: usize = 128; - - // Font metrics (FONT_6X10) - const CHAR_W: i32 = 6; - const FONT_H: i32 = 10; - - // Mode box: 2 chars + 1px padding each side - const MODE_BOX_W: i32 = 2 * CHAR_W + 2; // 14px - - // RSSI mapping range (dBm) - const RSSI_MIN: i16 = -120; - const RSSI_MAX: i16 = 0; - - /// Static board identity info for display rendering. - pub struct BoardInfo<'a> { - pub name: &'a str, - pub version: &'a str, - pub mac: &'a str, - } - - /// Render the active dashboard (shown when radio is in RX or TX mode). - #[allow(clippy::too_many_arguments)] - pub fn dashboard( - target: &mut impl DrawTarget, - status: &RadioStatus, - rssi_history: &[i16; RSSI_HISTORY_LEN], - tx_history: &[bool; RSSI_HISTORY_LEN], - rssi_count: usize, - current_slot_rssi: i16, - current_slot_tx: bool, - board: &BoardInfo, - ) { - let bb = target.bounding_box(); - let w = bb.size.width as i32; - let h = bb.size.height as i32; - let title_x = MODE_BOX_W + 1; - let title_w = w - title_x; - let header_h = 2 * FONT_H; - let sep1_y = header_h + 3; // 3px gap below header text (clears descenders) - let info_y = sep1_y + 1; // 1px line, info text tight below separator - let sep2_y = info_y + 2 * FONT_H + 3; // 3px gap below info text (clears descenders) - let spark_top = sep2_y + 2; // 1px line + 1px gap above graph - let spark_h = h - spark_top; - let visible_bars = ((w / 2) as usize).min(RSSI_HISTORY_LEN); - - let _ = target.clear(BinaryColor::Off); - let style = MonoTextStyle::new(&FONT_6X10, BinaryColor::On); - let mut buf: String<32> = String::new(); - - // ── Mode box (left column, 2 rows tall) ───────────────────────── - let fill = PrimitiveStyle::with_fill(BinaryColor::On); - let mut inv_style = MonoTextStyle::new(&FONT_6X10, BinaryColor::Off); - inv_style.set_background_color(Some(BinaryColor::On)); - - match status.state { - RadioState::Receiving => { - // "RX" inverted on row 0 - Rectangle::new( - Point::new(0, 0), - Size::new(MODE_BOX_W as u32, FONT_H as u32), - ) - .into_styled(fill) - .draw(target) - .ok(); - Text::new("RX", Point::new(1, FONT_H - 1), inv_style) - .draw(target) - .ok(); - } - RadioState::Transmitting => { - // "TX" inverted on row 1 - Rectangle::new( - Point::new(0, FONT_H), - Size::new(MODE_BOX_W as u32, FONT_H as u32), - ) - .into_styled(fill) - .draw(target) - .ok(); - Text::new("TX", Point::new(1, 2 * FONT_H - 1), inv_style) - .draw(target) - .ok(); - } - RadioState::Idle => {} // Not shown on active screen - } - - // Vertical separator between mode box and title box - Line::new(Point::new(MODE_BOX_W, 0), Point::new(MODE_BOX_W, sep1_y)) - .into_styled(PrimitiveStyle::with_stroke(BinaryColor::On, 1)) - .draw(target) - .ok(); - - // ── Title box (right of mode box) ─────────────────────────────── - let title_center_x = title_x + title_w / 2; - - // Row 0: "DongLoRa v{version}" - buf.clear(); - let _ = write!(buf, "DongLoRa v{}", board.version); - Text::with_alignment( - &buf, - Point::new(title_center_x, FONT_H - 1), - style, - Alignment::Center, - ) - .draw(target) - .ok(); - - // Row 1: radio settings "{freq}/{bw}/{sf}/{cr}" - if let Some(cfg) = status.config { - buf.clear(); - let freq_mhz = cfg.freq_hz / 1_000_000; - let freq_khz = (cfg.freq_hz % 1_000_000) / 1_000; - let bw_str = match cfg.bw { - Bandwidth::Khz7 => "7.8", - Bandwidth::Khz10 => "10.4", - Bandwidth::Khz15 => "15.6", - Bandwidth::Khz20 => "20.8", - Bandwidth::Khz31 => "31.2", - Bandwidth::Khz41 => "41.7", - Bandwidth::Khz62 => "62.5", - Bandwidth::Khz125 => "125", - Bandwidth::Khz250 => "250", - Bandwidth::Khz500 => "500", - }; - let _ = write!( - buf, - "{}.{:03}/{}/{}/{}", - freq_mhz, freq_khz, bw_str, cfg.sf, cfg.cr - ); - Text::with_alignment( - &buf, - Point::new(title_center_x, 2 * FONT_H - 1), - style, - Alignment::Center, - ) - .draw(target) - .ok(); - } - - // ── First separator line ──────────────────────────────────────── - Line::new(Point::new(0, sep1_y), Point::new(w - 1, sep1_y)) - .into_styled(PrimitiveStyle::with_stroke(BinaryColor::On, 1)) - .draw(target) - .ok(); - - // ── Info rows (centered) ──────────────────────────────────────── - let center_x = w / 2; - - // Row 2: packet counters - buf.clear(); - let _ = write!( - buf, - "RX:{} TX:{}", - compact_count(status.rx_count), - compact_count(status.tx_count) - ); - Text::with_alignment( - &buf, - Point::new(center_x, info_y + FONT_H - 1), - style, - Alignment::Center, - ) - .draw(target) - .ok(); - - // Row 3: RSSI + SNR - buf.clear(); - match (status.last_rssi, status.last_snr) { - (Some(rssi), Some(snr)) => { - let _ = write!(buf, "RSSI:{}dBm SNR:{}dB", rssi, snr); - } - (Some(rssi), None) => { - let _ = write!(buf, "RSSI:{}dBm", rssi); - } - _ => { - let _ = write!(buf, "No signal"); - } - } - Text::with_alignment( - &buf, - Point::new(center_x, info_y + 2 * FONT_H - 1), - style, - Alignment::Center, - ) - .draw(target) - .ok(); - - // ── Second separator line ─────────────────────────────────────── - Line::new(Point::new(0, sep2_y), Point::new(w - 1, sep2_y)) - .into_styled(PrimitiveStyle::with_stroke(BinaryColor::On, 1)) - .draw(target) - .ok(); - - // ── Sparkline bar graph ───────────────────────────────────────── - if spark_h > 0 { - rssi_sparkline( - target, - rssi_history, - tx_history, - rssi_count, - current_slot_rssi, - current_slot_tx, - spark_top, - spark_h, - visible_bars, - ); - } - } - - /// Render the splash/waiting screen (shown when idle or no config). - pub fn splash(target: &mut impl DrawTarget, board: &BoardInfo) { - let bb = target.bounding_box(); - let w = bb.size.width as i32; - - let _ = target.clear(BinaryColor::Off); - let style = MonoTextStyle::new(&FONT_6X10, BinaryColor::On); - let title_style = MonoTextStyle::new(&FONT_9X15_BOLD, BinaryColor::On); - let mut buf: String<32> = String::new(); - - let center_x = w / 2; - - // Row 1: "DongLoRa" bold left, version small right - Text::new("DongLoRa", Point::new(4, 15), title_style) - .draw(target) - .ok(); - buf.clear(); - let _ = write!(buf, "v{}", board.version); - Text::with_alignment(&buf, Point::new(w - 2, 15), style, Alignment::Right) - .draw(target) - .ok(); - - // Row 2: board name - Text::with_alignment( - board.name, - Point::new(center_x, 28), - style, - Alignment::Center, - ) - .draw(target) - .ok(); - - // Row 3: MAC address - Text::with_alignment( - board.mac, - Point::new(center_x, 41), - style, - Alignment::Center, - ) - .draw(target) - .ok(); - - // Row 4: status - Text::with_alignment( - "Waiting for host...", - Point::new(center_x, 54), - style, - Alignment::Center, - ) - .draw(target) - .ok(); - } - - /// Format a u32 count compactly for display. - fn compact_count(n: u32) -> String<10> { - let mut s: String<10> = String::new(); - if n > 9_999_999 { - let _ = write!(s, "{}M", n / 1_000_000); - } else if n > 999_999 { - let _ = write!(s, "{}k", n / 1_000); - } else { - let _ = write!(s, "{}", n); - } - s - } - - /// Render the RSSI history as a bar-chart sparkline. - /// - /// The current (uncommitted) slot is rendered at the rightmost position - /// so that incoming packets appear on the graph immediately. - #[allow(clippy::too_many_arguments)] - fn rssi_sparkline( - target: &mut impl DrawTarget, - history: &[i16; RSSI_HISTORY_LEN], - tx_history: &[bool; RSSI_HISTORY_LEN], - count: usize, - current_rssi: i16, - current_tx: bool, - spark_top: i32, - spark_h: i32, - visible_bars: usize, - ) { - let live = current_rssi > RSSI_MIN || current_tx; - let committed = count.min(RSSI_HISTORY_LEN); - let hist_slots = if live { - committed.min(visible_bars.saturating_sub(1)) - } else { - committed.min(visible_bars) - }; - let total = hist_slots + if live { 1 } else { 0 }; - - if total == 0 { - return; - } - - let fill = PrimitiveStyle::with_fill(BinaryColor::On); - - // Draw committed history - for i in 0..hist_slots { - let idx = if count <= RSSI_HISTORY_LEN { - // Buffer hasn't wrapped: slots occupy indices 0..committed-1. - // Skip oldest entries that don't fit on screen. - let effective_bars = if live { visible_bars - 1 } else { visible_bars }; - i + committed.saturating_sub(effective_bars) - } else { - let start = count - RSSI_HISTORY_LEN; - let skip = - RSSI_HISTORY_LEN.saturating_sub(if live { visible_bars - 1 } else { visible_bars }); - (start + skip + i) % RSSI_HISTORY_LEN - }; - let is_tx = tx_history[idx]; - let rssi = history[idx]; - - if let Some(bar_h) = bar_height(rssi, is_tx, spark_h) { - let x = (visible_bars - total + i) as i32 * 2; - draw_bar(target, x, bar_h, is_tx, spark_top, spark_h, &fill); - } - } - - // Draw live (current) bar at the rightmost position - if live { - if let Some(bar_h) = bar_height(current_rssi, current_tx, spark_h) { - let x = (visible_bars - 1) as i32 * 2; - draw_bar(target, x, bar_h, current_tx, spark_top, spark_h, &fill); - } - } - } - - /// Compute the pixel height for a sparkline bar, or None to skip. - fn bar_height(rssi: i16, is_tx: bool, spark_h: i32) -> Option { - let h = if rssi <= RSSI_MIN { - if is_tx { - spark_h / 3 - } else { - return None; - } - } else { - let clamped = rssi.clamp(RSSI_MIN, RSSI_MAX); - ((clamped - RSSI_MIN) as i32 * spark_h) / (RSSI_MAX - RSSI_MIN) as i32 - }; - if h == 0 { - None - } else { - Some(h) - } - } - - /// Draw a single sparkline bar (solid for RX, dotted for TX). - fn draw_bar( - target: &mut impl DrawTarget, - x: i32, - bar_h: i32, - is_tx: bool, - spark_top: i32, - spark_h: i32, - fill: &PrimitiveStyle, - ) { - let y = spark_top + spark_h - bar_h; - if is_tx { - for row in 0..bar_h { - if row % 2 == 0 { - Rectangle::new(Point::new(x, y + row), Size::new(2, 1)) - .into_styled(*fill) - .draw(target) - .ok(); - } - } - } else { - Rectangle::new(Point::new(x, y), Size::new(2, bar_h as u32)) - .into_styled(*fill) - .draw(target) - .ok(); - } - } - - /// Clear the display (display-off state). - pub fn blank(target: &mut impl DrawTarget) { - let _ = target.clear(BinaryColor::Off); - } -} diff --git a/firmware/src/driver/mod.rs b/firmware/src/driver/mod.rs deleted file mode 100644 index 9a0f6a46..00000000 --- a/firmware/src/driver/mod.rs +++ /dev/null @@ -1,5 +0,0 @@ -#[cfg(feature = "wio_tracker_l1")] -pub mod sh1106; - -#[cfg(any(feature = "heltec_v3", feature = "heltec_v3_uart", feature = "heltec_v4", feature = "wio_tracker_l1"))] -pub mod simple_led; diff --git a/firmware/src/driver/sh1106.rs b/firmware/src/driver/sh1106.rs deleted file mode 100644 index c5cc2a15..00000000 --- a/firmware/src/driver/sh1106.rs +++ /dev/null @@ -1,132 +0,0 @@ -//! Minimal async SH1106 I2C OLED driver (128x64, page addressing). -//! -//! The SH1106 is very similar to the SSD1306 but only supports page-mode -//! addressing (no horizontal/vertical auto-advance). This driver sends -//! framebuffer data page-by-page with explicit page/column commands. - -use embedded_graphics::draw_target::DrawTarget; -use embedded_graphics::geometry::{OriginDimensions, Size}; -use embedded_graphics::pixelcolor::BinaryColor; -use embedded_graphics::Pixel; -use embedded_hal_async::i2c::I2c; - -const WIDTH: usize = 128; -const HEIGHT: usize = 64; -const PAGES: usize = HEIGHT / 8; -const BUF_SIZE: usize = WIDTH * PAGES; // 1024 - -/// SH1106 has a 132-column RAM; visible 128 columns are centered with a -/// 2-column offset on each side. -const COL_OFFSET: u8 = 2; - -pub struct Sh1106 { - i2c: I, - addr: u8, - buffer: [u8; BUF_SIZE], -} - -impl Sh1106 { - pub fn new(i2c: I, addr: u8) -> Self { - Self { - i2c, - addr, - buffer: [0u8; BUF_SIZE], - } - } - - /// Send a sequence of commands (each prefixed with the I2C command byte 0x00). - async fn cmd(&mut self, commands: &[u8]) -> Result<(), I::Error> { - for &c in commands { - self.i2c.write(self.addr, &[0x00, c]).await?; - } - Ok(()) - } - - /// Initialize the display with sensible defaults. - pub async fn init(&mut self) -> Result<(), I::Error> { - self.cmd(&[ - 0xAE, // display off - 0xD5, 0x80, // clock div: default - 0xA8, 0x3F, // multiplex ratio: 64 - 0xD3, 0x00, // display offset: 0 - 0x40, // start line: 0 - 0x8D, 0x14, // charge pump: enable - 0xA1, // segment remap: column 127 mapped to SEG0 - 0xC8, // COM scan direction: remapped - 0xDA, 0x12, // COM pin config: alternative, no L/R remap - 0x81, 0xCF, // contrast: 207 - 0xD9, 0xF1, // pre-charge: phase1=1, phase2=15 - 0xDB, 0x40, // VCOMH deselect: ~0.77×Vcc - 0xA4, // entire display ON (follow RAM) - 0xA6, // normal display (not inverted) - 0xAF, // display on - ]) - .await - } - - /// Flush the entire framebuffer to the display, page by page. - pub async fn flush(&mut self) -> Result<(), I::Error> { - // Each I2C data write is prefixed with 0x40 (data continuation byte). - let mut page_buf = [0u8; 1 + WIDTH]; // 0x40 + 128 data bytes - page_buf[0] = 0x40; - - for page in 0..PAGES { - // Set page address and column start (with SH1106 2-column offset). - self.cmd(&[ - 0xB0 | page as u8, // page address - COL_OFFSET & 0x0F, // column low nibble - 0x10 | (COL_OFFSET >> 4), // column high nibble - ]) - .await?; - - let start = page * WIDTH; - page_buf[1..].copy_from_slice(&self.buffer[start..start + WIDTH]); - self.i2c.write(self.addr, &page_buf).await?; - } - Ok(()) - } - - /// Set display brightness (contrast). - pub async fn set_brightness(&mut self, value: u8) -> Result<(), I::Error> { - self.cmd(&[0x81, value]).await - } - -} - -impl DrawTarget for Sh1106 { - type Color = BinaryColor; - type Error = core::convert::Infallible; - - fn draw_iter(&mut self, pixels: T) -> Result<(), Self::Error> - where - T: IntoIterator>, - { - for Pixel(pos, color) in pixels { - let x = pos.x; - let y = pos.y; - if x >= 0 && x < WIDTH as i32 && y >= 0 && y < HEIGHT as i32 { - let x = x as usize; - let y = y as usize; - let idx = (y / 8) * WIDTH + x; - let bit = (y % 8) as u8; - if color.is_on() { - self.buffer[idx] |= 1 << bit; - } else { - self.buffer[idx] &= !(1 << bit); - } - } - } - Ok(()) - } - - fn clear(&mut self, color: Self::Color) -> Result<(), Self::Error> { - self.buffer.fill(if color.is_on() { 0xFF } else { 0x00 }); - Ok(()) - } -} - -impl OriginDimensions for Sh1106 { - fn size(&self) -> Size { - Size::new(WIDTH as u32, HEIGHT as u32) - } -} diff --git a/firmware/src/driver/simple_led.rs b/firmware/src/driver/simple_led.rs deleted file mode 100644 index 6839a0c6..00000000 --- a/firmware/src/driver/simple_led.rs +++ /dev/null @@ -1,20 +0,0 @@ -//! Simple single-color LED driver (GPIO on/off). -//! -//! Generic over any `OutputPin`. Any non-zero RGB value turns the LED on; -//! all zeros turns it off. - -use embedded_hal::digital::OutputPin; - -use crate::board::RgbLed; - -pub struct SimpleLed

(pub P); - -impl RgbLed for SimpleLed

{ - async fn set_rgb(&mut self, r: u8, g: u8, b: u8) { - if r > 0 || g > 0 || b > 0 { - let _ = self.0.set_high(); - } else { - let _ = self.0.set_low(); - } - } -} diff --git a/firmware/src/hal/esp32s3.rs b/firmware/src/hal/esp32s3.rs deleted file mode 100644 index db5f9b5b..00000000 --- a/firmware/src/hal/esp32s3.rs +++ /dev/null @@ -1,82 +0,0 @@ -//! ESP32-S3 MCU initialization primitives. -//! -//! Provides low-level bus init for ESP32-S3 boards. -//! Board files call these with specific pins and construct higher-level -//! drivers (Sx126x, SSD1306, USB, UART, etc.) themselves. - -use embassy_sync::blocking_mutex::raw::NoopRawMutex; -use esp_hal::dma::{DmaRxBuf, DmaTxBuf}; -use esp_hal::i2c::master::{Config as I2cConfig, I2c}; -use esp_hal::spi::master::{Config as SpiConfig, Spi, SpiDmaBus}; -use esp_hal::spi::Mode as SpiMode; -use esp_hal::time::Rate; -use esp_hal::timer::timg::TimerGroup; -use static_cell::StaticCell; - -// ── MCU-level types ───────────────────────────────────────────────── - -pub type SpiBus = SpiDmaBus<'static, esp_hal::Async>; -pub type I2cBus = I2c<'static, esp_hal::Async>; - -// ── Timer ─────────────────────────────────────────────────────────── - -/// Start the ESP-RTOS scheduler and Embassy time driver. -pub fn start_timer(timg0: esp_hal::peripherals::TIMG0<'static>) { - let timg0 = TimerGroup::new(timg0); - esp_rtos::start(timg0.timer0); -} - -// ── SPI bus ───────────────────────────────────────────────────────── - -/// Initialize SPI2 with DMA and wrap in a shared bus (StaticCell + Mutex). -pub fn init_spi( - spi2: esp_hal::peripherals::SPI2<'static>, - dma_ch0: esp_hal::peripherals::DMA_CH0<'static>, - sck: esp_hal::peripherals::GPIO9<'static>, - mosi: esp_hal::peripherals::GPIO10<'static>, - miso: esp_hal::peripherals::GPIO11<'static>, -) -> &'static embassy_sync::mutex::Mutex { - let spi = Spi::new( - spi2, - SpiConfig::default() - .with_frequency(Rate::from_mhz(1)) - .with_mode(SpiMode::_0), - ) - .expect("SPI init") - .with_sck(sck) - .with_mosi(mosi) - .with_miso(miso) - .with_dma(dma_ch0); - - let (rx_buf, rx_desc, tx_buf, tx_desc) = esp_hal::dma_buffers!(256); - let dma_rx = DmaRxBuf::new(rx_desc, rx_buf).expect("DMA RX buf"); - let dma_tx = DmaTxBuf::new(tx_desc, tx_buf).expect("DMA TX buf"); - - let spi = spi.with_buffers(dma_rx, dma_tx).into_async(); - - static SPI_BUS: StaticCell> = - StaticCell::new(); - SPI_BUS.init(embassy_sync::mutex::Mutex::new(spi)) -} - -// ── I2C bus ───────────────────────────────────────────────────────── - -/// Initialize I2C0 for peripherals (display, sensors, etc.). -pub fn init_i2c( - i2c0: esp_hal::peripherals::I2C0<'static>, - sda: esp_hal::peripherals::GPIO17<'static>, - scl: esp_hal::peripherals::GPIO18<'static>, -) -> I2cBus { - I2c::new(i2c0, I2cConfig::default()) - .expect("I2C init") - .with_sda(sda) - .with_scl(scl) - .into_async() -} - -// ── MAC address ───────────────────────────────────────────────────── - -/// Read the factory-programmed MAC address from eFuse. -pub fn mac_address() -> [u8; 6] { - esp_hal::efuse::Efuse::mac_address() -} diff --git a/firmware/src/hal/mod.rs b/firmware/src/hal/mod.rs deleted file mode 100644 index 372fea3a..00000000 --- a/firmware/src/hal/mod.rs +++ /dev/null @@ -1,10 +0,0 @@ -//! MCU family initialization primitives. -//! -//! Each submodule provides low-level bus and peripheral init for one MCU family. -//! Board files call these with specific pins and construct higher-level drivers. - -#[cfg(any(feature = "heltec_v3", feature = "heltec_v3_uart", feature = "heltec_v4"))] -pub mod esp32s3; - -#[cfg(any(feature = "rak_wisblock_4631", feature = "wio_tracker_l1"))] -pub mod nrf52840; diff --git a/firmware/src/hal/nrf52840.rs b/firmware/src/hal/nrf52840.rs deleted file mode 100644 index 1bf4c029..00000000 --- a/firmware/src/hal/nrf52840.rs +++ /dev/null @@ -1,82 +0,0 @@ -//! nRF52840 MCU initialization primitives. -//! -//! Provides shared boilerplate for nRF52840 boards: interrupt bindings, -//! type aliases, StaticCell bus wrapping, USB init, and MAC address reading. - -use embassy_nrf::bind_interrupts; -use embassy_sync::blocking_mutex::raw::NoopRawMutex; -use static_cell::StaticCell; - -// ── Interrupts (shared by all nRF52840 boards) ───────────────────── - -bind_interrupts!(pub struct Irqs { - USBD => embassy_nrf::usb::InterruptHandler; - SPIM3 => embassy_nrf::spim::InterruptHandler; - TWISPI0 => embassy_nrf::twim::InterruptHandler; -}); - -// ── MCU-level types ───────────────────────────────────────────────── - -pub type SpiBus = embassy_nrf::spim::Spim<'static>; -pub type I2cBus = embassy_nrf::twim::Twim<'static>; -pub type UsbNrfDriver = - embassy_nrf::usb::Driver<'static, &'static embassy_nrf::usb::vbus_detect::SoftwareVbusDetect>; - -// ── SPI bus sharing ───────────────────────────────────────────────── - -/// Wrap an initialized SPI peripheral in a shared bus (StaticCell + Mutex). -pub fn share_spi_bus( - spi: SpiBus, -) -> &'static embassy_sync::mutex::Mutex { - static SPI_BUS: StaticCell> = - StaticCell::new(); - SPI_BUS.init(embassy_sync::mutex::Mutex::new(spi)) -} - -// ── I2C DMA buffer ────────────────────────────────────────────────── - -/// Allocate a DMA-safe buffer for Twim I2C. -/// -/// Every nRF52840 Twim user needs a `&'static mut [u8]` buffer. -/// This provides one via StaticCell so board files don't repeat the pattern. -pub fn alloc_i2c_buffer() -> &'static mut [u8; 256] { - static TWIM_BUF: StaticCell<[u8; 256]> = StaticCell::new(); - TWIM_BUF.init([0u8; 256]) -} - -// ── USB VBUS detection ────────────────────────────────────────────── - -/// Allocate and initialize software VBUS detection. -pub fn alloc_vbus_detect( - vbus_detect: bool, - self_powered: bool, -) -> &'static embassy_nrf::usb::vbus_detect::SoftwareVbusDetect { - static VBUS: StaticCell = StaticCell::new(); - VBUS.init(embassy_nrf::usb::vbus_detect::SoftwareVbusDetect::new( - vbus_detect, - self_powered, - )) -} - -// ── MAC address ───────────────────────────────────────────────────── - -/// Read the factory-programmed device address from FICR registers. -pub fn mac_address() -> [u8; 6] { - // SAFETY: 0x10000000 is the nRF52840 FICR (Factory Information Configuration - // Registers) base address. Offsets 0x0A4 and 0x0A8 are the DEVICEADDR[0] and - // DEVICEADDR[1] registers containing the factory-programmed device address. - // read_volatile is correct for memory-mapped hardware registers. - unsafe { - let ficr = 0x10000000 as *const u32; - let addr0 = core::ptr::read_volatile(ficr.byte_add(0x0A4)); - let addr1 = core::ptr::read_volatile(ficr.byte_add(0x0A8)); - [ - addr0 as u8, - (addr0 >> 8) as u8, - (addr0 >> 16) as u8, - (addr0 >> 24) as u8, - addr1 as u8, - (addr1 >> 8) as u8, - ] - } -} diff --git a/firmware/src/host/framing.rs b/firmware/src/host/framing.rs deleted file mode 100644 index 114d1a09..00000000 --- a/firmware/src/host/framing.rs +++ /dev/null @@ -1,124 +0,0 @@ -//! Shared COBS protocol helpers for USB and UART transports. -//! -//! Both `usb_task` and `uart_task` use these helpers for frame -//! accumulation, response encoding, and command routing. - -use defmt::warn; - -use crate::channel::{CommandChannel, DisplayCommand, DisplayCommandChannel, ResponseChannel}; -use crate::protocol::{Command, ErrorCode, Response}; - -/// Maximum COBS frame size (bytes). -pub const MAX_FRAME: usize = 512; - -// Worst case: tag(1) + rssi(2) + snr(2) + len(2) + payload(256) = 263 bytes. -// COBS adds ceil(263/254)+1 = 3 bytes. 266 < 512. Plenty of room. -const _: () = assert!( - MAX_FRAME >= crate::protocol::MAX_PAYLOAD + 64, - "MAX_FRAME too small for max payload + COBS overhead" -); - -/// Decodes COBS-framed commands from a byte stream. -pub struct CobsDecoder { - buf: [u8; MAX_FRAME], - len: usize, -} - -impl CobsDecoder { - pub const fn new() -> Self { - Self { - buf: [0u8; MAX_FRAME], - len: 0, - } - } - - /// Reset the accumulator (discard partial frame). - pub fn reset(&mut self) { - self.len = 0; - } - - /// Feed a chunk of bytes, calling `on_command` for each decoded command. - pub fn feed(&mut self, data: &[u8], mut on_command: impl FnMut(Command)) { - for &byte in data { - if byte == 0x00 { - // End of COBS frame — decode and dispatch - if self.len > 0 { - let mut decode_buf = [0u8; MAX_FRAME]; - if let Some(decoded_len) = - ucobs::decode(&self.buf[..self.len], &mut decode_buf) - { - if let Some(cmd) = Command::from_bytes(&decode_buf[..decoded_len]) { - on_command(cmd); - } - } - } - self.len = 0; - } else if self.len < MAX_FRAME { - self.buf[self.len] = byte; - self.len += 1; - } else { - // Frame too large — discard - self.len = 0; - } - } - } -} - -/// COBS-encode a response into `encode_buf` with trailing 0x00 sentinel. -/// -/// Returns the slice to send (encoded frame + sentinel), or `None` on overflow. -pub fn cobs_encode_response<'a>( - response: Response, - write_buf: &mut [u8; MAX_FRAME], - encode_buf: &'a mut [u8; MAX_FRAME], -) -> Option<&'a [u8]> { - let raw_len = response.write_to(write_buf); - let encoded_len = ucobs::encode(&write_buf[..raw_len], encode_buf).unwrap_or(0); - if encoded_len < encode_buf.len() { - encode_buf[encoded_len] = 0x00; - Some(&encode_buf[..encoded_len + 1]) - } else { - warn!("COBS encode buffer overflow"); - None - } -} - -/// Route a parsed command to the appropriate handler. -/// -/// Display and MAC commands are handled locally (response sent immediately). -/// All other commands are forwarded to the radio task (response sent later). -/// Both paths feed the same `ResponseChannel`, so the one-outstanding-command -/// rule in PROTOCOL.md is required to keep solicited responses ordered. -pub async fn route_command( - cmd: Command, - commands: &CommandChannel, - responses: &ResponseChannel, - display_commands: &DisplayCommandChannel, - has_display: bool, - mac: [u8; 6], -) { - match cmd { - Command::DisplayOn => { - if has_display { - display_commands.send(DisplayCommand::On).await; - responses.send(Response::Ok).await; - } else { - responses.send(Response::Error(ErrorCode::NoDisplay)).await; - } - } - Command::DisplayOff => { - if has_display { - display_commands.send(DisplayCommand::Off).await; - responses.send(Response::Ok).await; - } else { - responses.send(Response::Error(ErrorCode::NoDisplay)).await; - } - } - Command::GetMac => { - responses.send(Response::MacAddress(mac)).await; - } - other => { - commands.send(other).await; - } - } -} diff --git a/firmware/src/host/mod.rs b/firmware/src/host/mod.rs deleted file mode 100644 index 15467ba0..00000000 --- a/firmware/src/host/mod.rs +++ /dev/null @@ -1,16 +0,0 @@ -//! Host communication: unified USB/UART transport. -//! -//! Exports a single `host_task` regardless of transport. -//! The cfg dispatch is contained entirely within this module. - -pub mod framing; - -cfg_if::cfg_if! { - if #[cfg(feature = "heltec_v3_uart")] { - mod uart; - pub use uart::host_task; - } else { - mod usb; - pub use usb::host_task; - } -} diff --git a/firmware/src/host/uart.rs b/firmware/src/host/uart.rs deleted file mode 100644 index a5a8fa1c..00000000 --- a/firmware/src/host/uart.rs +++ /dev/null @@ -1,75 +0,0 @@ -//! UART host task: COBS-framed fixed-size LE command/response protocol. -//! -//! Used for boards with USB-UART bridge chips (e.g. CP2102 on Heltec V3). - -use defmt::warn; -use embassy_executor::task; -use embedded_io_async::Write; - -use crate::channel::{CommandChannel, DisplayCommand, DisplayCommandChannel, ResponseChannel}; -use super::framing::{self, CobsDecoder, MAX_FRAME}; - -#[task] -pub async fn host_task( - parts: crate::board::UartParts, - commands: &'static CommandChannel, - responses: &'static ResponseChannel, - display_commands: &'static DisplayCommandChannel, - has_display: bool, - mac: [u8; 6], -) { - let (mut rx, mut tx) = parts.driver.split(); - - // UART has no DTR — signal the display that a host is present at boot. - if has_display { - display_commands.send(DisplayCommand::Reset).await; - } - - let mut read_buf = [0u8; 64]; - let mut write_buf = [0u8; MAX_FRAME]; - let mut cobs_encode_buf = [0u8; MAX_FRAME]; - let mut decoder = CobsDecoder::new(); - - loop { - use embassy_futures::select::{select, Either}; - - match select( - embedded_io_async::Read::read(&mut rx, &mut read_buf), - responses.receive(), - ) - .await - { - Either::First(result) => { - let n = match result { - Ok(0) => continue, - Ok(n) => n, - Err(_) => { - decoder.reset(); - embassy_time::Timer::after_millis(100).await; - continue; - } - }; - - let mut cmds = heapless::Vec::<_, 4>::new(); - decoder.feed(&read_buf[..n], |cmd| { - let _ = cmds.push(cmd); - }); - for cmd in cmds { - framing::route_command( - cmd, commands, responses, display_commands, has_display, mac, - ) - .await; - } - } - Either::Second(response) => { - if let Some(frame) = - framing::cobs_encode_response(response, &mut write_buf, &mut cobs_encode_buf) - { - if tx.write_all(frame).await.is_err() { - warn!("UART write failed, response dropped"); - } - } - } - } - } -} diff --git a/firmware/src/host/usb.rs b/firmware/src/host/usb.rs deleted file mode 100644 index 71e089e7..00000000 --- a/firmware/src/host/usb.rs +++ /dev/null @@ -1,137 +0,0 @@ -//! USB CDC-ACM host task: COBS-framed fixed-size LE command/response protocol. - -use defmt::warn; -use embassy_executor::task; -use embassy_futures::join::join; -use embassy_usb::class::cdc_acm::{CdcAcmClass, Receiver, Sender, State}; -use embassy_usb::Builder; -use static_cell::StaticCell; - -use crate::channel::{CommandChannel, DisplayCommand, DisplayCommandChannel, ResponseChannel}; -use super::framing::{self, CobsDecoder, MAX_FRAME}; - -const MAX_PACKET: usize = 64; - -#[task] -pub async fn host_task( - parts: crate::board::UsbParts, - commands: &'static CommandChannel, - responses: &'static ResponseChannel, - display_commands: &'static DisplayCommandChannel, - has_display: bool, - mac: [u8; 6], -) { - let mut config = embassy_usb::Config::new(0x1209, 0x5741); - config.manufacturer = Some("DongLoRa"); - config.product = Some("DongLoRa LoRa Radio"); - config.device_class = 0xEF; - config.device_sub_class = 0x02; - config.device_protocol = 0x01; - config.composite_with_iads = true; - - static SERIAL_BUF: StaticCell<[u8; 12]> = StaticCell::new(); - let serial_buf = SERIAL_BUF.init([0u8; 12]); - const HEX: &[u8; 16] = b"0123456789ABCDEF"; - for (i, &byte) in mac.iter().enumerate() { - serial_buf[i * 2] = HEX[(byte >> 4) as usize]; - serial_buf[i * 2 + 1] = HEX[(byte & 0x0F) as usize]; - } - config.serial_number = Some(core::str::from_utf8(serial_buf).expect("MAC hex is valid UTF-8")); - - static DESC_BUF: StaticCell<[u8; 256]> = StaticCell::new(); - static CONF_BUF: StaticCell<[u8; 256]> = StaticCell::new(); - static BOS_BUF: StaticCell<[u8; 256]> = StaticCell::new(); - static CTRL_BUF: StaticCell<[u8; 64]> = StaticCell::new(); - static CDC_STATE: StaticCell = StaticCell::new(); - - let desc_buf = DESC_BUF.init([0; 256]); - let conf_buf = CONF_BUF.init([0; 256]); - let bos_buf = BOS_BUF.init([0; 256]); - let ctrl_buf = CTRL_BUF.init([0; 64]); - let cdc_state = CDC_STATE.init(State::new()); - - let mut builder = Builder::new(parts.driver, config, desc_buf, conf_buf, bos_buf, ctrl_buf); - - let class = CdcAcmClass::new(&mut builder, cdc_state, MAX_PACKET as u16); - let mut usb_dev = builder.build(); - let (sender, receiver) = class.split(); - - join( - usb_dev.run(), - protocol_loop(sender, receiver, commands, responses, display_commands, has_display, mac), - ) - .await; -} - -async fn protocol_loop<'d, D: embassy_usb_driver::Driver<'d>>( - mut sender: Sender<'d, D>, - mut receiver: Receiver<'d, D>, - commands: &'static CommandChannel, - responses: &'static ResponseChannel, - display_commands: &'static DisplayCommandChannel, - has_display: bool, - mac: [u8; 6], -) { - use embassy_futures::select::{select3, Either3}; - - let mut read_buf = [0u8; MAX_PACKET]; - let mut write_buf = [0u8; MAX_FRAME]; - let mut cobs_encode_buf = [0u8; MAX_FRAME]; - let mut decoder = CobsDecoder::new(); - let mut was_connected = false; - - loop { - match select3( - receiver.read_packet(&mut read_buf), - responses.receive(), - embassy_time::Timer::after_millis(250), - ) - .await - { - Either3::First(result) => { - let n = match result { - Ok(0) => continue, - Ok(n) => n, - Err(_) => { - decoder.reset(); - embassy_time::Timer::after_millis(100).await; - continue; - } - }; - - let mut cmds = heapless::Vec::<_, 4>::new(); - decoder.feed(&read_buf[..n], |cmd| { - let _ = cmds.push(cmd); - }); - for cmd in cmds { - framing::route_command( - cmd, commands, responses, display_commands, has_display, mac, - ) - .await; - } - } - Either3::Second(response) => { - if let Some(frame) = - framing::cobs_encode_response(response, &mut write_buf, &mut cobs_encode_buf) - { - for chunk in frame.chunks(MAX_PACKET) { - if sender.write_packet(chunk).await.is_err() { - warn!("USB write failed, response dropped"); - break; - } - } - } - } - Either3::Third(()) => {} - } - - let connected = receiver.dtr(); - if was_connected && !connected && has_display { - display_commands.send(DisplayCommand::Reset).await; - } - if !was_connected && connected && has_display { - display_commands.send(DisplayCommand::On).await; - } - was_connected = connected; - } -} diff --git a/firmware/src/main.rs b/firmware/src/main.rs deleted file mode 100644 index 7ebd943e..00000000 --- a/firmware/src/main.rs +++ /dev/null @@ -1,102 +0,0 @@ -//! DongLoRa firmware — transparent LoRa radio over USB. -//! -//! Three async tasks communicate via static channels: -//! -//! ```text -//! host_task ──Command──► radio_task ──► SX1262 -//! ◄──Response── │ -//! StatusWatch -//! ▼ -//! display_task (optional) -//! ``` -//! -//! The host drives everything. The radio idles until commanded. - -#![cfg_attr(not(test), no_std)] -#![cfg_attr(not(test), no_main)] - -#[cfg(not(test))] -mod board; -#[cfg(not(test))] -mod channel; -#[cfg(not(test))] -mod display; -#[cfg(not(test))] -mod driver; -#[cfg(not(test))] -mod hal; -#[cfg(not(test))] -mod host; -mod protocol; -#[cfg(not(test))] -mod radio; - -#[cfg(not(test))] -use embassy_executor::Spawner; - -#[cfg(not(test))] -use crate::board::LoRaBoard; -#[cfg(not(test))] -use crate::channel::{CommandChannel, DisplayCommandChannel, ResponseChannel, StatusWatch}; - -#[cfg(not(test))] -cfg_if::cfg_if! { - if #[cfg(any(feature = "rak_wisblock_4631", feature = "wio_tracker_l1"))] { - use defmt_rtt as _; - use panic_probe as _; - } else if #[cfg(any(feature = "heltec_v3", feature = "heltec_v3_uart", feature = "heltec_v4"))] { - use esp_backtrace as _; - use esp_println as _; - } -} - -#[cfg(not(test))] -static COMMANDS: CommandChannel = CommandChannel::new(); -#[cfg(not(test))] -static RESPONSES: ResponseChannel = ResponseChannel::new(); -#[cfg(not(test))] -static STATUS: StatusWatch = StatusWatch::new(); -#[cfg(not(test))] -static DISPLAY_COMMANDS: DisplayCommandChannel = DisplayCommandChannel::new(); - -#[cfg(not(test))] -cfg_if::cfg_if! { - if #[cfg(any(feature = "rak_wisblock_4631", feature = "wio_tracker_l1"))] { - #[embassy_executor::main] - async fn main(spawner: Spawner) { - run(spawner).await; - } - } else if #[cfg(any(feature = "heltec_v3", feature = "heltec_v3_uart", feature = "heltec_v4"))] { - #[esp_rtos::main] - async fn main(spawner: Spawner) { - run(spawner).await; - } - } -} - -#[cfg(not(test))] -async fn run(spawner: Spawner) { - let board = ::init(); - let parts = board.into_parts(); - - spawner - .spawn(radio::radio_task(parts.radio, &COMMANDS, &RESPONSES, &STATUS)) - .expect("spawn radio_task"); - - spawner - .spawn(host::host_task( - parts.host, - &COMMANDS, - &RESPONSES, - &DISPLAY_COMMANDS, - parts.display.is_some(), - parts.mac, - )) - .expect("spawn host_task"); - - if let Some(dp) = parts.display { - spawner - .spawn(display::display_task(dp, parts.led, &STATUS, &DISPLAY_COMMANDS)) - .expect("spawn display_task"); - } -} diff --git a/firmware/src/protocol.rs b/firmware/src/protocol.rs deleted file mode 100644 index a21731b7..00000000 --- a/firmware/src/protocol.rs +++ /dev/null @@ -1,707 +0,0 @@ -//! Wire protocol types and fixed-size little-endian serialization. -//! -//! Every integer is fixed-width LE. No varints, no zigzag. -//! See `docs/PROTOCOL.md` for the complete specification. - -use heapless::Vec; - -/// 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; - -/// LoRa signal bandwidth. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -#[cfg_attr(not(test), derive(defmt::Format))] -#[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 { - fn from_u8(v: u8) -> Option { - 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, - } - } -} - -/// 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)] -#[cfg_attr(not(test), derive(defmt::Format))] -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 - /// the firmware default (16 symbols). Valid range: 6–65535. - pub preamble_len: u16, - /// Channel Activity Detection (listen-before-talk) before TX. - /// 0 = disabled, non-zero = enabled. Default: 1 (enabled). - pub cad: u8, -} - -impl RadioConfig { - /// Validate fields against hardware limits. - pub fn validate(&self, power_range: (i8, i8)) -> Result<(), &'static str> { - if !(150_000_000..=960_000_000).contains(&self.freq_hz) { - return Err("frequency out of range (150-960 MHz)"); - } - if !(5..=12).contains(&self.sf) { - return Err("spreading factor out of range (5-12)"); - } - if !(5..=8).contains(&self.cr) { - return Err("coding rate out of range (5-8)"); - } - if self.tx_power_dbm != TX_POWER_MAX - && !(power_range.0..=power_range.1).contains(&self.tx_power_dbm) - { - return Err("TX power out of range for this board"); - } - if self.preamble_len != PREAMBLE_DEFAULT && self.preamble_len < 6 { - return Err("preamble length too short (min 6)"); - } - Ok(()) - } - - /// Resolve sentinel values to concrete defaults. - pub fn resolve(mut self, power_range: (i8, i8)) -> Self { - if self.tx_power_dbm == TX_POWER_MAX { - self.tx_power_dbm = power_range.1; - } - if self.preamble_len == PREAMBLE_DEFAULT { - self.preamble_len = 16; - } - self - } - - /// Whether CAD (listen-before-talk) is enabled. - pub fn cad_enabled(&self) -> bool { - self.cad != 0 - } - - /// Serialize to fixed-size LE bytes. Returns number of bytes written (always 13). - pub fn write_to(self, buf: &mut [u8]) -> usize { - 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; - RADIO_CONFIG_SIZE - } - - /// Deserialize from fixed-size LE bytes. - pub fn from_bytes(buf: &[u8]) -> Option { - if buf.len() < RADIO_CONFIG_SIZE { - return None; - } - Some(Self { - freq_hz: u32::from_le_bytes(buf[0..4].try_into().ok()?), - bw: Bandwidth::from_u8(buf[4])?, - sf: buf[5], - cr: buf[6], - sync_word: u16::from_le_bytes(buf[7..9].try_into().ok()?), - tx_power_dbm: buf[9] as i8, - preamble_len: u16::from_le_bytes(buf[10..12].try_into().ok()?), - cad: buf[12], - }) - } -} - -/// Host → firmware commands. -#[allow(clippy::large_enum_variant)] -#[derive(Debug, PartialEq)] -pub enum Command { - Ping, - GetConfig, - SetConfig(RadioConfig), - StartRx, - StopRx, - Transmit { - config: Option, - payload: Vec, - }, - DisplayOn, - DisplayOff, - GetMac, -} - -impl Command { - /// Deserialize from a COBS-decoded frame. - pub fn from_bytes(buf: &[u8]) -> Option { - 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 => { - // Transmit: has_config:u8 + [RadioConfig if 1] + len:u16 LE + payload - 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..pos + 2].try_into().ok()?) as usize; - let data_start = pos + 2; - if rest.len() < data_start + len { - return None; - } - let mut payload = Vec::new(); - let _ = payload.extend_from_slice(&rest[data_start..data_start + len]); - Some(Self::Transmit { config, payload }) - } - 6 => Some(Self::DisplayOn), - 7 => Some(Self::DisplayOff), - 8 => Some(Self::GetMac), - _ => None, - } - } -} - -/// Firmware → host responses. -#[allow(clippy::large_enum_variant)] -#[derive(Debug, PartialEq)] -pub enum Response { - Pong, - Config(RadioConfig), - RxPacket { - rssi: i16, - snr: i16, - payload: Vec, - }, - TxDone, - Ok, - Error(ErrorCode), - MacAddress([u8; 6]), -} - -impl Response { - /// Serialize to fixed-size LE bytes. Returns number of bytes written. - pub fn write_to(self, buf: &mut [u8]) -> usize { - match self { - Self::Pong => { - buf[0] = 0; - 1 - } - Self::Config(cfg) => { - buf[0] = 1; - 1 + cfg.write_to(&mut buf[1..]) - } - Self::RxPacket { rssi, snr, payload } => { - buf[0] = 2; - buf[1..3].copy_from_slice(&rssi.to_le_bytes()); - buf[3..5].copy_from_slice(&snr.to_le_bytes()); - buf[5..7].copy_from_slice(&(payload.len() as u16).to_le_bytes()); - buf[7..7 + payload.len()].copy_from_slice(&payload); - 7 + payload.len() - } - Self::TxDone => { - buf[0] = 3; - 1 - } - Self::Ok => { - buf[0] = 4; - 1 - } - Self::Error(code) => { - buf[0] = 5; - buf[1] = code as u8; - 2 - } - Self::MacAddress(mac) => { - buf[0] = 6; - buf[1..7].copy_from_slice(&mac); - 7 - } - } - } -} - -/// Error codes reported to the host. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -#[cfg_attr(not(test), derive(defmt::Format))] -#[repr(u8)] -pub enum ErrorCode { - InvalidConfig = 0, - RadioBusy = 1, - TxTimeout = 2, - // 3 reserved (was CrcError — SX1262 silently drops bad-CRC packets) - NotConfigured = 4, - NoDisplay = 5, -} - -// ── 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 roundtrip ─────────────────────────────────────── - - #[test] - fn radio_config_roundtrip() { - let cfg = make_config(); - let mut buf = [0u8; RADIO_CONFIG_SIZE]; - let n = cfg.write_to(&mut buf); - assert_eq!(n, RADIO_CONFIG_SIZE); - assert_eq!(RadioConfig::from_bytes(&buf), Some(cfg)); - } - - #[test] - fn radio_config_roundtrip_all_bandwidths() { - for bw_val in 0u8..=9 { - let bw = Bandwidth::from_u8(bw_val).unwrap(); - let cfg = RadioConfig { - freq_hz: 433_000_000, - bw, - sf: 12, - cr: 8, - sync_word: 0x1234, - tx_power_dbm: -9, - preamble_len: 16, - cad: 1, - }; - let mut buf = [0u8; RADIO_CONFIG_SIZE]; - cfg.write_to(&mut buf); - assert_eq!(RadioConfig::from_bytes(&buf), Some(cfg)); - } - } - - #[test] - fn radio_config_roundtrip_negative_power() { - let cfg = RadioConfig { - tx_power_dbm: TX_POWER_MAX, - ..make_config() - }; - let mut buf = [0u8; RADIO_CONFIG_SIZE]; - cfg.write_to(&mut buf); - assert_eq!(RadioConfig::from_bytes(&buf), Some(cfg)); - } - - #[test] - fn radio_config_from_short_buffer() { - let buf = [0u8; RADIO_CONFIG_SIZE - 1]; - assert!(RadioConfig::from_bytes(&buf).is_none()); - } - - #[test] - fn radio_config_from_empty_buffer() { - assert!(RadioConfig::from_bytes(&[]).is_none()); - } - - #[test] - fn radio_config_invalid_bandwidth() { - let mut buf = [0u8; RADIO_CONFIG_SIZE]; - make_config().write_to(&mut buf); - buf[4] = 255; // invalid bandwidth - assert!(RadioConfig::from_bytes(&buf).is_none()); - } - - // ── RadioConfig::validate ─────────────────────────────────────── - - #[test] - fn validate_freq_boundaries() { - let power_range = (-9, 22); - let base = make_config(); - - let mut cfg = RadioConfig { - freq_hz: 150_000_000, - ..base - }; - assert!(cfg.validate(power_range).is_ok()); - - cfg.freq_hz = 960_000_000; - assert!(cfg.validate(power_range).is_ok()); - - cfg.freq_hz = 149_999_999; - assert!(cfg.validate(power_range).is_err()); - - cfg.freq_hz = 960_000_001; - assert!(cfg.validate(power_range).is_err()); - } - - #[test] - fn validate_sf_boundaries() { - let power_range = (-9, 22); - let base = make_config(); - - for sf in 5..=12 { - assert!(RadioConfig { sf, ..base }.validate(power_range).is_ok()); - } - assert!(RadioConfig { sf: 4, ..base }.validate(power_range).is_err()); - assert!(RadioConfig { sf: 13, ..base } - .validate(power_range) - .is_err()); - } - - #[test] - fn validate_cr_boundaries() { - let power_range = (-9, 22); - let base = make_config(); - - for cr in 5..=8 { - assert!(RadioConfig { cr, ..base }.validate(power_range).is_ok()); - } - assert!(RadioConfig { cr: 4, ..base }.validate(power_range).is_err()); - assert!(RadioConfig { cr: 9, ..base }.validate(power_range).is_err()); - } - - #[test] - fn validate_tx_power_max_sentinel() { - let cfg = RadioConfig { - tx_power_dbm: TX_POWER_MAX, - ..make_config() - }; - assert!(cfg.validate((-9, 22)).is_ok()); - } - - #[test] - fn validate_tx_power_out_of_range() { - let cfg = RadioConfig { - tx_power_dbm: 23, - ..make_config() - }; - assert!(cfg.validate((-9, 22)).is_err()); - - let cfg = RadioConfig { - tx_power_dbm: -10, - ..make_config() - }; - assert!(cfg.validate((-9, 22)).is_err()); - } - - #[test] - fn validate_preamble_default_sentinel() { - let cfg = RadioConfig { - preamble_len: PREAMBLE_DEFAULT, - ..make_config() - }; - assert!(cfg.validate((-9, 22)).is_ok()); - } - - #[test] - fn validate_preamble_boundaries() { - let power_range = (-9, 22); - let base = make_config(); - - assert!(RadioConfig { - preamble_len: 6, - ..base - } - .validate(power_range) - .is_ok()); - assert!(RadioConfig { - preamble_len: 5, - ..base - } - .validate(power_range) - .is_err()); - } - - // ── RadioConfig::resolve ────────────────────────────────────── - - #[test] - fn resolve_power_max_sentinel() { - let cfg = RadioConfig { - tx_power_dbm: TX_POWER_MAX, - ..make_config() - }; - assert_eq!(cfg.resolve((-9, 22)).tx_power_dbm, 22); - } - - #[test] - fn resolve_power_explicit_unchanged() { - let cfg = RadioConfig { - tx_power_dbm: 10, - ..make_config() - }; - assert_eq!(cfg.resolve((-9, 22)).tx_power_dbm, 10); - } - - #[test] - fn resolve_preamble_default() { - let cfg = RadioConfig { - preamble_len: PREAMBLE_DEFAULT, - ..make_config() - }; - assert_eq!(cfg.resolve((-9, 22)).preamble_len, 16); - } - - #[test] - fn resolve_preamble_explicit_unchanged() { - let cfg = RadioConfig { - preamble_len: 32, - ..make_config() - }; - assert_eq!(cfg.resolve((-9, 22)).preamble_len, 32); - } - - // ── Command::from_bytes ───────────────────────────────────────── - - #[test] - fn command_ping() { - assert_eq!(Command::from_bytes(&[0]), Some(Command::Ping)); - } - - #[test] - fn command_get_config() { - assert_eq!(Command::from_bytes(&[1]), Some(Command::GetConfig)); - } - - #[test] - fn command_set_config() { - let cfg = make_config(); - let mut buf = [0u8; 1 + RADIO_CONFIG_SIZE]; - buf[0] = 2; - cfg.write_to(&mut buf[1..]); - assert_eq!(Command::from_bytes(&buf), Some(Command::SetConfig(cfg))); - } - - #[test] - fn command_start_stop_rx() { - assert_eq!(Command::from_bytes(&[3]), Some(Command::StartRx)); - assert_eq!(Command::from_bytes(&[4]), Some(Command::StopRx)); - } - - #[test] - fn command_transmit_no_config() { - let payload = b"hello"; - let mut buf = [0u8; 64]; - buf[0] = 5; // tag - buf[1] = 0; // has_config = false - buf[2..4].copy_from_slice(&(payload.len() as u16).to_le_bytes()); - buf[4..9].copy_from_slice(payload); - - match Command::from_bytes(&buf[..9]).unwrap() { - Command::Transmit { config, payload: p } => { - assert!(config.is_none()); - assert_eq!(p.as_slice(), b"hello"); - } - _ => panic!("expected Transmit"), - } - } - - #[test] - fn command_transmit_with_config() { - let cfg = make_config(); - let mut buf = [0u8; 64]; - buf[0] = 5; // tag - buf[1] = 1; // has_config = true - cfg.write_to(&mut buf[2..]); - let payload = b"test"; - let pos = 2 + RADIO_CONFIG_SIZE; - buf[pos..pos + 2].copy_from_slice(&(payload.len() as u16).to_le_bytes()); - buf[pos + 2..pos + 6].copy_from_slice(payload); - - match Command::from_bytes(&buf[..pos + 6]).unwrap() { - Command::Transmit { config, payload: p } => { - assert_eq!(config, Some(cfg)); - assert_eq!(p.as_slice(), b"test"); - } - _ => panic!("expected Transmit"), - } - } - - #[test] - fn command_transmit_empty_payload() { - let mut buf = [0u8; 4]; - buf[0] = 5; // tag - buf[1] = 0; // has_config = false - buf[2..4].copy_from_slice(&0u16.to_le_bytes()); - - match Command::from_bytes(&buf).unwrap() { - Command::Transmit { config, payload } => { - assert!(config.is_none()); - assert!(payload.is_empty()); - } - _ => panic!("expected Transmit"), - } - } - - #[test] - fn command_transmit_truncated() { - // Tag only — missing has_config byte - assert!(Command::from_bytes(&[5]).is_none()); - - // has_config=1 but no config bytes - assert!(Command::from_bytes(&[5, 1]).is_none()); - - // has_config=0 but no length bytes - assert!(Command::from_bytes(&[5, 0]).is_none()); - - // has_config=0, length says 5 but only 2 bytes of payload - let mut buf = [0u8; 6]; - buf[0] = 5; - buf[1] = 0; - buf[2..4].copy_from_slice(&5u16.to_le_bytes()); - buf[4] = 0xAA; - buf[5] = 0xBB; - assert!(Command::from_bytes(&buf).is_none()); - } - - #[test] - fn command_display_and_mac() { - assert_eq!(Command::from_bytes(&[6]), Some(Command::DisplayOn)); - assert_eq!(Command::from_bytes(&[7]), Some(Command::DisplayOff)); - assert_eq!(Command::from_bytes(&[8]), Some(Command::GetMac)); - } - - #[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()); - } - - // ── Response::write_to ────────────────────────────────────────── - - #[test] - fn response_pong() { - let mut buf = [0u8; 1]; - assert_eq!(Response::Pong.write_to(&mut buf), 1); - assert_eq!(buf[0], 0); - } - - #[test] - fn response_config() { - let cfg = make_config(); - let mut buf = [0u8; 1 + RADIO_CONFIG_SIZE]; - let n = Response::Config(cfg).write_to(&mut buf); - assert_eq!(n, 1 + RADIO_CONFIG_SIZE); - assert_eq!(buf[0], 1); - assert_eq!(RadioConfig::from_bytes(&buf[1..]), Some(cfg)); - } - - #[test] - fn response_rx_packet() { - let mut payload = Vec::new(); - let _ = payload.extend_from_slice(b"data"); - let mut buf = [0u8; 64]; - let n = Response::RxPacket { - rssi: -80, - snr: 10, - payload, - } - .write_to(&mut buf); - assert_eq!(n, 7 + 4); // tag(1) + rssi(2) + snr(2) + len(2) + "data"(4) - assert_eq!(buf[0], 2); - assert_eq!(i16::from_le_bytes([buf[1], buf[2]]), -80); - assert_eq!(i16::from_le_bytes([buf[3], buf[4]]), 10); - assert_eq!(u16::from_le_bytes([buf[5], buf[6]]), 4); - assert_eq!(&buf[7..11], b"data"); - } - - #[test] - fn response_tx_done() { - let mut buf = [0u8; 1]; - assert_eq!(Response::TxDone.write_to(&mut buf), 1); - assert_eq!(buf[0], 3); - } - - #[test] - fn response_ok() { - let mut buf = [0u8; 1]; - assert_eq!(Response::Ok.write_to(&mut buf), 1); - assert_eq!(buf[0], 4); - } - - #[test] - fn response_error_codes() { - let mut buf = [0u8; 2]; - for (code, val) in [ - (ErrorCode::InvalidConfig, 0), - (ErrorCode::RadioBusy, 1), - (ErrorCode::TxTimeout, 2), - (ErrorCode::NotConfigured, 4), - (ErrorCode::NoDisplay, 5), - ] { - let n = Response::Error(code).write_to(&mut buf); - assert_eq!(n, 2); - assert_eq!(buf[0], 5); - assert_eq!(buf[1], val); - } - } - - #[test] - fn response_mac_address() { - let mac = [0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF]; - let mut buf = [0u8; 7]; - let n = Response::MacAddress(mac).write_to(&mut buf); - assert_eq!(n, 7); - assert_eq!(buf[0], 6); - assert_eq!(&buf[1..7], &mac); - } -} diff --git a/firmware/src/radio.rs b/firmware/src/radio.rs deleted file mode 100644 index 99d09e3b..00000000 --- a/firmware/src/radio.rs +++ /dev/null @@ -1,386 +0,0 @@ -//! LoRa radio task: SX1262 state machine driven by host commands. -//! -//! Owns the radio peripheral exclusively. Receives [`Command`]s from the USB -//! task, drives the SX1262 via [`lora_phy`], and sends [`Response`]s back. -//! Publishes [`RadioStatus`] to the display task via a watch channel. -//! -//! # State machine -//! -//! ```text -//! Idle ──StartRx──► Receiving ──StopRx──► Idle -//! │ │ -//! └──Transmit──► Transmitting ──TxDone──► (previous state) -//! ``` -//! -//! # Invariants -//! -//! - This task never panics. All errors are reported to the host or logged. -//! - Config is validated before use (see [`RadioConfig::validate`]). - -use defmt::{error, info, warn}; -use embassy_executor::task; -use embassy_futures::select::{select, Either}; -use embassy_time::Delay; -use lora_phy::mod_params::RadioError; -use lora_phy::{LoRa, RxMode}; - -use crate::board::{Board, LoRaBoard, RadioDriver, RadioParts}; -use crate::channel::{CommandChannel, RadioState, RadioStatus, ResponseChannel, StatusWatch}; -use crate::protocol::{self, Command, ErrorCode, RadioConfig, Response}; - -const MAX_PAYLOAD: usize = protocol::MAX_PAYLOAD; -// TX power range comes from the board trait: Board::TX_POWER_RANGE - -type Radio = LoRa; - -// ── Fixed LoRa radio parameters ───────────────────────────────────── -// -// These are intentionally not host-configurable. They are sensible -// defaults for a general-purpose LoRa pipe. Making them configurable -// would add protocol complexity for marginal benefit. - -/// Explicit header mode (variable-length packets). -const IMPLICIT_HEADER: bool = false; - -/// Enable CRC on received/transmitted packets. -const CRC_ON: bool = true; - -/// Standard IQ polarity (not inverted). -const IQ_INVERTED: bool = false; - -// ── Task entry point ──────────────────────────────────────────────── - -#[task] -pub async fn radio_task( - parts: RadioParts, - commands: &'static CommandChannel, - responses: &'static ResponseChannel, - status: &'static StatusWatch, -) { - let mut state = RadioStatus::default(); - status.sender().send(state.clone()); - - let mut lora = match LoRa::new(parts.driver, false, parts.delay).await { - Ok(l) => l, - Err(e) => { - error!("radio init failed: {}", e); - responses - .send(Response::Error(ErrorCode::InvalidConfig)) - .await; - // Radio is non-functional — respond to commands but can't do RF. - loop { - let cmd = commands.receive().await; - if let Command::Ping = cmd { - responses.send(Response::Pong).await; - } else { - responses - .send(Response::Error(ErrorCode::InvalidConfig)) - .await; - } - } - } - }; - - info!("radio initialized"); - let mut rx_buf = [0u8; MAX_PAYLOAD]; - - loop { - if state.state == RadioState::Receiving { - // Defensive: config must be Some when Receiving. If not, recover. - let cfg = match state.config { - Some(c) => c, - None => { - warn!("BUG: receiving without config, returning to idle"); - state.state = RadioState::Idle; - status.sender().send(state.clone()); - continue; - } - }; - - match select(rx_once(&mut lora, &cfg, &mut rx_buf), commands.receive()).await { - Either::First(rx_result) => match rx_result { - Ok((len, pkt_status)) => { - state.rx_count = state.rx_count.wrapping_add(1); - state.last_rssi = Some(pkt_status.rssi); - state.last_snr = Some(pkt_status.snr); - status.sender().send(state.clone()); - - let copy_len = (len as usize).min(MAX_PAYLOAD); - if (len as usize) > copy_len { - warn!("RX payload truncated: {} > {}", len, MAX_PAYLOAD); - } - let mut payload = heapless::Vec::new(); - // copy_len <= MAX_PAYLOAD == Vec capacity, so this cannot fail. - let _ = payload.extend_from_slice(&rx_buf[..copy_len]); - - responses - .send(Response::RxPacket { - rssi: pkt_status.rssi, - snr: pkt_status.snr, - payload, - }) - .await; - - if let Err(e) = start_rx(&mut lora, &cfg).await { - warn!("restart RX failed: {}", e); - state.state = RadioState::Idle; - status.sender().send(state.clone()); - responses.send(Response::Error(ErrorCode::RadioBusy)).await; - } - } - Err(e) => { - warn!("RX error: {}", e); - if start_rx(&mut lora, &cfg).await.is_err() { - state.state = RadioState::Idle; - status.sender().send(state.clone()); - responses.send(Response::Error(ErrorCode::RadioBusy)).await; - } - } - }, - Either::Second(cmd) => { - handle_cmd(cmd, &mut lora, &mut state, responses, status).await; - } - } - } else { - let cmd = commands.receive().await; - handle_cmd(cmd, &mut lora, &mut state, responses, status).await; - } - } -} - -// ── Command handler ───────────────────────────────────────────────── - -async fn handle_cmd( - cmd: Command, - lora: &mut Radio, - state: &mut RadioStatus, - responses: &ResponseChannel, - status: &StatusWatch, -) { - match cmd { - Command::Ping => { - responses.send(Response::Pong).await; - } - Command::GetConfig => { - if let Some(cfg) = state.config { - responses.send(Response::Config(cfg)).await; - } else { - responses - .send(Response::Error(ErrorCode::NotConfigured)) - .await; - } - } - Command::SetConfig(cfg) => { - if let Err(reason) = cfg.validate(Board::TX_POWER_RANGE) { - warn!("SetConfig rejected: {}", reason); - responses - .send(Response::Error(ErrorCode::InvalidConfig)) - .await; - } else { - // Resolve TX_POWER_MAX sentinel to board's actual max - state.config = Some(cfg.resolve(Board::TX_POWER_RANGE)); - status.sender().send(state.clone()); - responses.send(Response::Ok).await; - } - } - Command::StartRx => { - if let Some(cfg) = state.config { - match start_rx(lora, &cfg).await { - Ok(()) => { - state.state = RadioState::Receiving; - status.sender().send(state.clone()); - responses.send(Response::Ok).await; - } - Err(e) => { - warn!("StartRx failed: {}", e); - responses - .send(Response::Error(ErrorCode::InvalidConfig)) - .await; - } - } - } else { - responses - .send(Response::Error(ErrorCode::NotConfigured)) - .await; - } - } - Command::StopRx => { - // Best-effort: if standby fails, radio is in unknown state - // but there's nothing useful we can do except continue. - let _ = lora.enter_standby().await; - state.state = RadioState::Idle; - status.sender().send(state.clone()); - responses.send(Response::Ok).await; - } - Command::DisplayOn | Command::DisplayOff | Command::GetMac => {} - Command::Transmit { config, payload } => { - let tx_config = config - .map(|c| c.resolve(Board::TX_POWER_RANGE)) - .or(state.config); - if let Some(cfg) = tx_config { - if let Err(reason) = cfg.validate(Board::TX_POWER_RANGE) { - warn!("TX config rejected: {}", reason); - responses - .send(Response::Error(ErrorCode::InvalidConfig)) - .await; - return; - } - - let was_receiving = state.state == RadioState::Receiving; - state.state = RadioState::Transmitting; - status.sender().send(state.clone()); - - match do_tx(lora, &cfg, &payload).await { - Ok(()) => { - state.tx_count = state.tx_count.wrapping_add(1); - responses.send(Response::TxDone).await; - } - Err(e) => { - warn!("TX failed: {}", e); - responses.send(Response::Error(ErrorCode::TxTimeout)).await; - } - } - - // Restore previous state. If we were receiving, restart RX - // using the stored config — NOT the per-packet TX override. - state.state = if was_receiving { - // state.config is always Some when was_receiving (invariant). - let rx_cfg = state.config.unwrap_or(cfg); - match start_rx(lora, &rx_cfg).await { - Ok(()) => RadioState::Receiving, - Err(e) => { - warn!("post-TX RX restart failed: {}", e); - RadioState::Idle - } - } - } else { - RadioState::Idle - }; - status.sender().send(state.clone()); - } else { - responses - .send(Response::Error(ErrorCode::NotConfigured)) - .await; - } - } - } -} - -// ── LoRa helpers ──────────────────────────────────────────────────── - -fn to_bw(bw: protocol::Bandwidth) -> lora_phy::mod_params::Bandwidth { - use lora_phy::mod_params::Bandwidth::*; - match bw { - protocol::Bandwidth::Khz7 => _7KHz, - protocol::Bandwidth::Khz10 => _10KHz, - protocol::Bandwidth::Khz15 => _15KHz, - protocol::Bandwidth::Khz20 => _20KHz, - protocol::Bandwidth::Khz31 => _31KHz, - protocol::Bandwidth::Khz41 => _41KHz, - protocol::Bandwidth::Khz62 => _62KHz, - protocol::Bandwidth::Khz125 => _125KHz, - protocol::Bandwidth::Khz250 => _250KHz, - protocol::Bandwidth::Khz500 => _500KHz, - } -} - -fn to_sf(sf: u8) -> lora_phy::mod_params::SpreadingFactor { - use lora_phy::mod_params::SpreadingFactor::*; - match sf { - 5 => _5, - 6 => _6, - 7 => _7, - 8 => _8, - 9 => _9, - 10 => _10, - 11 => _11, - 12 => _12, - _ => { - warn!("BUG: invalid SF {} passed validation", sf); - _7 - } - } -} - -fn to_cr(cr: u8) -> lora_phy::mod_params::CodingRate { - use lora_phy::mod_params::CodingRate::*; - match cr { - 5 => _4_5, - 6 => _4_6, - 7 => _4_7, - 8 => _4_8, - _ => { - warn!("BUG: invalid CR {} passed validation", cr); - _4_5 - } - } -} - -/// Create modulation parameters from a validated config. -fn modulation_params( - lora: &mut Radio, - cfg: &RadioConfig, -) -> Result { - lora.create_modulation_params(to_sf(cfg.sf), to_bw(cfg.bw), to_cr(cfg.cr), cfg.freq_hz) -} - -async fn start_rx(lora: &mut Radio, cfg: &RadioConfig) -> Result<(), RadioError> { - let mdltn = modulation_params(lora, cfg)?; - let pkt = lora.create_rx_packet_params( - cfg.preamble_len, - IMPLICIT_HEADER, - MAX_PAYLOAD as u8, - CRC_ON, - IQ_INVERTED, - &mdltn, - )?; - lora.prepare_for_rx(RxMode::Continuous, &mdltn, &pkt).await -} - -async fn rx_once( - lora: &mut Radio, - cfg: &RadioConfig, - buf: &mut [u8], -) -> Result<(u8, lora_phy::mod_params::PacketStatus), RadioError> { - let mdltn = modulation_params(lora, cfg)?; - let pkt = lora.create_rx_packet_params( - cfg.preamble_len, - IMPLICIT_HEADER, - MAX_PAYLOAD as u8, - CRC_ON, - IQ_INVERTED, - &mdltn, - )?; - lora.rx(&pkt, buf).await -} - -/// Maximum CAD attempts before transmitting anyway. -const CAD_MAX_RETRIES: u8 = 10; - -async fn do_tx(lora: &mut Radio, cfg: &RadioConfig, payload: &[u8]) -> Result<(), RadioError> { - let mdltn = modulation_params(lora, cfg)?; - - // Listen-before-talk: check channel is clear before transmitting. - if cfg.cad_enabled() { - lora.prepare_for_cad(&mdltn).await?; - for _ in 0..CAD_MAX_RETRIES { - if !lora.cad(&mdltn).await? { - break; // Channel is clear - } - // Channel busy — wait briefly and retry - embassy_time::Timer::after_millis(10).await; - lora.prepare_for_cad(&mdltn).await?; - } - } - - let mut tx_pkt = lora.create_tx_packet_params( - cfg.preamble_len, - IMPLICIT_HEADER, - CRC_ON, - IQ_INVERTED, - &mdltn, - )?; - lora.prepare_for_tx(&mdltn, &mut tx_pkt, cfg.tx_power_dbm as i32, payload) - .await?; - lora.tx().await -} diff --git a/mux/python/.gitignore b/mux/python/.gitignore deleted file mode 100644 index 21d0b898..00000000 --- a/mux/python/.gitignore +++ /dev/null @@ -1 +0,0 @@ -.venv/ diff --git a/mux/python/README.md b/mux/python/README.md deleted file mode 100644 index 551b067f..00000000 --- a/mux/python/README.md +++ /dev/null @@ -1,26 +0,0 @@ -# DongLoRa Mux (Python) - -USB multiplexer daemon — lets multiple applications share one DongLoRa -dongle simultaneously. - -## What It Does - -- Owns the USB serial connection exclusively -- Exposes a Unix domain socket (and optional TCP) speaking the same - COBS-framed protocol -- RxPacket frames broadcast to all connected clients -- StartRx/StopRx reference-counted across clients -- SetConfig locked once set (single client can change freely) - -## Running - -```sh -just run # start the mux daemon -just verbose # start with verbose logging -just run --tcp 5741 --port /dev/ttyACM0 # with options -``` - -## Depends On - -- [clients/python](../../clients/python/) — the `donglora` client library - (protocol encoding, device discovery) diff --git a/mux/python/donglora_mux/__init__.py b/mux/python/donglora_mux/__init__.py deleted file mode 100644 index 02877d82..00000000 --- a/mux/python/donglora_mux/__init__.py +++ /dev/null @@ -1,517 +0,0 @@ -"""DongLoRa USB Multiplexer — share one dongle with multiple applications. - -Owns the USB serial connection and exposes a Unix domain socket that speaks -the same COBS-framed protocol. Clients connect via the socket (or just call -dl.connect() which auto-detects the mux) and get: - - • Solicited responses routed back to the commanding client - • RxPacket frames broadcast to ALL connected clients - • StartRx/StopRx reference-counted so the radio stays in RX as long as - anyone wants it - • SetConfig locked once set — subsequent clients must use the same config - (a single client can change config freely, like a scanner) - -Usage: - donglora-mux [--port /dev/ttyACM0] [--socket /tmp/donglora-mux.sock] [--tcp 5741] -""" - -from __future__ import annotations - -import argparse -import asyncio -import logging -import os -import signal -from pathlib import Path - -import serial -from cobs import cobs - -import donglora as dl - -log = logging.getLogger("donglora-mux") - -# ── Protocol constants ─────────────────────────────────────────── - -TAG_RXPACKET = 2 -TAG_OK = 4 -TAG_ERROR = 5 -CMD_TAG_SET_CONFIG = 2 -CMD_TAG_START_RX = 3 -CMD_TAG_STOP_RX = 4 - -ERROR_INVALID_CONFIG = 0 - - -def default_socket_path() -> str: - """Resolve the mux socket path in priority order.""" - env = os.environ.get("DONGLORA_MUX") - if env: - return env - xdg = os.environ.get("XDG_RUNTIME_DIR") - if xdg: - return os.path.join(xdg, "donglora", "mux.sock") - return "/tmp/donglora-mux.sock" - - -# ── Per-client state ───────────────────────────────────────────── - -class Client: - """Tracks one connected client.""" - - _next_id = 0 - - def __init__(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter): - self.id = Client._next_id - Client._next_id += 1 - self.reader = reader - self.writer = writer - self.rx_interested = False # has this client called StartRx? - self.send_queue: asyncio.Queue[bytes] = asyncio.Queue(maxsize=256) - self._sender_task: asyncio.Task | None = None - - @property - def label(self) -> str: - return f"client-{self.id}" - - def start_sender(self) -> None: - self._sender_task = asyncio.create_task(self._sender(), name=f"{self.label}-sender") - - async def _sender(self) -> None: - """Drain send_queue → socket. Runs until cancelled or write fails.""" - try: - while True: - frame = await self.send_queue.get() - self.writer.write(frame) - await self.writer.drain() - except (ConnectionError, asyncio.CancelledError): - pass - - async def enqueue(self, cobs_frame: bytes) -> None: - """Enqueue a COBS frame for sending. Drops oldest RxPacket on overflow.""" - try: - self.send_queue.put_nowait(cobs_frame) - except asyncio.QueueFull: - # Drop oldest to make room (best-effort for slow clients) - try: - self.send_queue.get_nowait() - except asyncio.QueueEmpty: - pass - try: - self.send_queue.put_nowait(cobs_frame) - except asyncio.QueueFull: - pass - - def close(self) -> None: - if self._sender_task: - self._sender_task.cancel() - try: - self.writer.close() - except Exception: - pass - - -# ── Mux daemon ─────────────────────────────────────────────────── - -class MuxDaemon: - def __init__(self, serial_port: str, socket_path: str, tcp_addr: tuple[str, int] | None = None): - self.serial_port = serial_port - self.socket_path = socket_path - self.tcp_addr = tcp_addr - self.ser: serial.Serial | None = None - self.clients: dict[int, Client] = {} - self.cmd_queue: asyncio.Queue[tuple[int, bytes]] = asyncio.Queue() - self.pending_response: asyncio.Future[bytes] | None = None - self.locked_config: bytes | None = None # raw RadioConfig bytes (13 bytes) once set - self._shutdown = asyncio.Event() - - # ── Serial I/O (blocking, run in threads) ──────────────────── - - def _serial_read_frame(self) -> bytes | None: - """Blocking read of one COBS frame from dongle.""" - assert self.ser is not None - return dl.read_frame(self.ser) - - def _serial_write(self, data: bytes) -> None: - """Blocking write to dongle.""" - assert self.ser is not None - self.ser.write(data) - self.ser.flush() - - # ── Dongle reader task ─────────────────────────────────────── - - async def dongle_reader(self) -> None: - """Read frames from the dongle, dispatch solicited vs broadcast.""" - consecutive_errors = 0 - max_retries = 3 - while not self._shutdown.is_set(): - try: - raw = await asyncio.to_thread(self._serial_read_frame) - except (serial.SerialException, OSError) as e: - consecutive_errors += 1 - if consecutive_errors >= max_retries: - log.error("Dongle read error (giving up after %d consecutive failures): %s", consecutive_errors, e) - self._handle_dongle_disconnect() - return - log.warning("Dongle read glitch (%d/%d): %s", consecutive_errors, max_retries, e) - await asyncio.sleep(0.1) - continue - - consecutive_errors = 0 - - if raw is None: - continue - - tag = raw[0] - cobs_frame = dl.cobs_encode(raw) - - if tag == TAG_RXPACKET: - # Broadcast to all clients - for client in list(self.clients.values()): - await client.enqueue(cobs_frame) - else: - # Solicited response — resolve the pending future - if self.pending_response and not self.pending_response.done(): - self.pending_response.set_result(raw) - else: - log.warning("Unsolicited non-RxPacket response (tag %d) — dropped", tag) - - # ── Dongle writer task ─────────────────────────────────────── - - async def dongle_writer(self) -> None: - """Pull commands from queue, send one at a time, route responses.""" - loop = asyncio.get_running_loop() - - while not self._shutdown.is_set(): - client_id, raw_cmd = await self.cmd_queue.get() - - # Check for StartRx/StopRx ref-counting intercept - intercepted = await self._maybe_intercept(client_id, raw_cmd) - if intercepted is not None: - # Send the synthetic response to the client - if client_id in self.clients: - await self.clients[client_id].enqueue(dl.cobs_encode(intercepted)) - continue - - # Create future for the solicited response - self.pending_response = loop.create_future() - - # Send COBS-encoded command to dongle - cobs_frame = dl.cobs_encode(raw_cmd) - try: - await asyncio.to_thread(self._serial_write, cobs_frame) - except (serial.SerialException, OSError) as e: - log.error("Dongle write error: %s", e) - self.pending_response.set_result(bytes([5, 1])) # Error(RadioBusy) - self._handle_dongle_disconnect() - return - - # Wait for dongle_reader to resolve the future - try: - response = await asyncio.wait_for(self.pending_response, timeout=10.0) - except asyncio.TimeoutError: - log.error("Dongle response timeout") - response = bytes([5, 1]) # Error(RadioBusy) - finally: - self.pending_response = None - - # Track state on successful forward - cmd_tag = raw_cmd[0] - resp_tag = response[0] if response else 0xFF - if cmd_tag == CMD_TAG_SET_CONFIG and resp_tag == TAG_OK: - self.locked_config = raw_cmd[1:] - log.info("Radio config locked: %s", self.locked_config.hex()) - elif cmd_tag == CMD_TAG_START_RX and resp_tag == TAG_OK: - if client_id in self.clients: - self.clients[client_id].rx_interested = True - elif cmd_tag == CMD_TAG_STOP_RX and resp_tag == TAG_OK: - if client_id in self.clients: - self.clients[client_id].rx_interested = False - - # Route response to commanding client (if still connected) - if client_id in self.clients: - await self.clients[client_id].enqueue(dl.cobs_encode(response)) - - # ── StartRx / StopRx reference counting ────────────────────── - - def _rx_interest_count(self) -> int: - return sum(1 for c in self.clients.values() if c.rx_interested) - - async def _maybe_intercept(self, client_id: int, raw_cmd: bytes) -> bytes | None: - """Intercept SetConfig/StartRx/StopRx for smart multiplexing. - - Returns a synthetic response if intercepted, None to forward normally. - """ - if not raw_cmd: - return None - cmd_tag = raw_cmd[0] - client = self.clients.get(client_id) - - if cmd_tag == CMD_TAG_SET_CONFIG: - config_bytes = raw_cmd[1:] # 13-byte RadioConfig - if len(self.clients) <= 1: - # Single client — allow free config changes (scanner mode) - # Forward to dongle, update locked_config on success - return None - if self.locked_config is None: - # First SetConfig with multiple clients — forward and lock - return None - if config_bytes == self.locked_config: - # Same config — reply Ok without hitting the dongle - log.debug("%s: SetConfig matches locked config — Ok", - client.label if client else f"id-{client_id}") - return bytes([TAG_OK]) - # Different config — reject - log.warning("%s: SetConfig rejected (conflicts with locked config)", - client.label if client else f"id-{client_id}") - return bytes([TAG_ERROR, ERROR_INVALID_CONFIG]) - - if cmd_tag == CMD_TAG_START_RX: - if client and client.rx_interested: - # Already interested — no-op, reply Ok - return bytes([TAG_OK]) - if self._rx_interest_count() > 0: - # Someone else is already receiving — just mark this client, reply Ok - if client: - client.rx_interested = True - return bytes([TAG_OK]) - # First interested client — forward to dongle (return None) - return None - - if cmd_tag == CMD_TAG_STOP_RX: - if client and not client.rx_interested: - # Wasn't interested — no-op, reply Ok - return bytes([TAG_OK]) - if client: - client.rx_interested = False - if self._rx_interest_count() > 0: - # Others still interested — don't stop, reply Ok - return bytes([TAG_OK]) - # Last interested client — forward to dongle (return None) - return None - - return None - - # ── Client handling ────────────────────────────────────────── - - async def client_handler(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None: - client = Client(reader, writer) - self.clients[client.id] = client - client.start_sender() - log.info("%s connected (%d total)", client.label, len(self.clients)) - - try: - buf = b"" - while True: - data = await reader.read(4096) - if not data: - break - buf += data - # Extract complete COBS frames (delimited by 0x00) - while b"\x00" in buf: - idx = buf.index(b"\x00") - encoded = buf[:idx] - buf = buf[idx + 1:] - if not encoded: - continue - try: - raw_cmd = cobs.decode(encoded) - except cobs.DecodeError: - log.warning("%s: bad COBS frame — skipped", client.label) - continue - await self.cmd_queue.put((client.id, raw_cmd)) - except (ConnectionError, asyncio.CancelledError): - pass - finally: - await self._remove_client(client) - - async def _remove_client(self, client: Client) -> None: - """Clean up after a disconnected client.""" - was_interested = client.rx_interested - client.close() - self.clients.pop(client.id, None) - log.info("%s disconnected (%d remain)", client.label, len(self.clients)) - - # If this client had RX interest and was the last one, stop RX - if was_interested and self._rx_interest_count() == 0: - log.info("Last RX-interested client gone — sending StopRx") - await self.cmd_queue.put((-1, bytes([CMD_TAG_STOP_RX]))) - - # Reset locked config when all clients are gone - if not self.clients: - if self.locked_config is not None: - log.info("All clients gone — config lock released") - self.locked_config = None - - # ── Dongle disconnect ──────────────────────────────────────── - - _dongle_lost: asyncio.Event - - def _handle_dongle_disconnect(self) -> None: - log.error("Dongle disconnected — will reconnect") - if self.pending_response and not self.pending_response.done(): - self.pending_response.set_result(bytes([5, 1])) # Error(RadioBusy) - if self.ser: - try: - self.ser.close() - except Exception: - pass - self.ser = None - self._dongle_lost.set() - - # ── Dongle connection ────────────────────────────────────────── - - def _open_dongle(self) -> bool: - """Open and ping the dongle. Returns True on success.""" - try: - self.ser = serial.Serial(self.serial_port, timeout=2.0, exclusive=True) - self.ser.reset_input_buffer() - self._serial_write(dl.cobs_encode(dl.encode_command("Ping"))) - pong = self._serial_read_frame() - if not pong or pong[0] != 0: - log.warning("Dongle did not respond to Ping") - self.ser.close() - self.ser = None - return False - log.info("Dongle responded to Ping") - return True - except (serial.SerialException, OSError) as e: - log.warning("Could not open dongle: %s", e) - self.ser = None - return False - - # ── Main entry ─────────────────────────────────────────────── - - async def run(self) -> None: - self._dongle_lost = asyncio.Event() - - # Prepare socket path - sock_path = Path(self.socket_path) - sock_path.parent.mkdir(parents=True, exist_ok=True) - if sock_path.exists(): - sock_path.unlink() - - # Start servers (these stay up for the lifetime of the daemon) - server = await asyncio.start_unix_server(self.client_handler, path=str(sock_path)) - log.info("Unix socket listening on %s", self.socket_path) - - tcp_server = None - if self.tcp_addr: - tcp_server = await asyncio.start_server( - self.client_handler, self.tcp_addr[0], self.tcp_addr[1], - ) - log.info("TCP listening on %s:%d", *self.tcp_addr) - - # Connect/reconnect loop — servers stay up, dongle comes and goes - try: - while not self._shutdown.is_set(): - # Connect to dongle (retry until it appears) - log.info("Opening dongle on %s", self.serial_port) - while not self._shutdown.is_set(): - if await asyncio.to_thread(self._open_dongle): - break - await asyncio.sleep(2) - - if self._shutdown.is_set(): - break - - # Run dongle reader/writer until dongle is lost - self._dongle_lost.clear() - reader_task = asyncio.create_task(self.dongle_reader(), name="dongle-reader") - writer_task = asyncio.create_task(self.dongle_writer(), name="dongle-writer") - - # Wait for dongle loss or shutdown - shutdown_task = asyncio.create_task(self._shutdown.wait()) - lost_task = asyncio.create_task(self._dongle_lost.wait()) - await asyncio.wait( - [shutdown_task, lost_task], - return_when=asyncio.FIRST_COMPLETED, - ) - shutdown_task.cancel() - lost_task.cancel() - - # Tear down dongle tasks - reader_task.cancel() - writer_task.cancel() - try: - await asyncio.gather(reader_task, writer_task, return_exceptions=True) - except asyncio.CancelledError: - pass - - # Drain any pending commands with errors - while not self.cmd_queue.empty(): - try: - self.cmd_queue.get_nowait() - except asyncio.QueueEmpty: - break - - if not self._shutdown.is_set(): - log.info("Reconnecting in 2 seconds...") - await asyncio.sleep(2) - finally: - # Cleanup - log.info("Shutting down...") - for client in list(self.clients.values()): - client.close() - server.close() - await server.wait_closed() - if tcp_server: - tcp_server.close() - await tcp_server.wait_closed() - if self.ser: - self.ser.close() - if sock_path.exists(): - sock_path.unlink() - log.info("Stopped.") - - -# ── CLI ────────────────────────────────────────────────────────── - -def main() -> None: - parser = argparse.ArgumentParser(description="DongLoRa USB Multiplexer") - parser.add_argument("--port", "-p", default=None, help="Serial port (auto-detect if omitted)") - parser.add_argument("--socket", "-s", default=None, help="Unix socket path") - parser.add_argument("--tcp", "-t", default="0.0.0.0:5741", metavar="[HOST:]PORT", - help="TCP listen address (default: 0.0.0.0:5741, 'none' to disable)") - parser.add_argument("--verbose", "-v", action="store_true") - args = parser.parse_args() - - logging.basicConfig( - level=logging.DEBUG if args.verbose else logging.INFO, - format="%(asctime)s %(name)s %(levelname)s %(message)s", - datefmt="%H:%M:%S", - ) - - port = args.port or dl.find_port() - if not port: - log.info("Waiting for DongLoRa device...") - port = dl.wait_for_device() - - socket_path = args.socket or default_socket_path() - - tcp_addr = None - if args.tcp and args.tcp.lower() != "none": - if ":" in args.tcp: - host, _, p = args.tcp.rpartition(":") - tcp_addr = (host, int(p)) - else: - tcp_addr = ("0.0.0.0", int(args.tcp)) - - daemon = MuxDaemon(port, socket_path, tcp_addr=tcp_addr) - - loop = asyncio.new_event_loop() - - def _signal_handler() -> None: - daemon._shutdown.set() - - for sig in (signal.SIGINT, signal.SIGTERM): - loop.add_signal_handler(sig, _signal_handler) - - try: - loop.run_until_complete(daemon.run()) - finally: - loop.close() - - -if __name__ == "__main__": - main() diff --git a/mux/python/donglora_mux/__main__.py b/mux/python/donglora_mux/__main__.py deleted file mode 100644 index b52f6c79..00000000 --- a/mux/python/donglora_mux/__main__.py +++ /dev/null @@ -1,3 +0,0 @@ -from donglora_mux import main - -main() diff --git a/mux/python/justfile b/mux/python/justfile deleted file mode 100644 index e37cddd9..00000000 --- a/mux/python/justfile +++ /dev/null @@ -1,15 +0,0 @@ -set shell := ["bash", "-c"] - -default: run - -[private] -_ensure_tools: - @mise trust --yes . 2>/dev/null; mise install --quiet - -# Run the mux daemon -run *args: _ensure_tools - @uv run -m donglora_mux {{args}} - -# Run with verbose logging -verbose *args: _ensure_tools - @uv run -m donglora_mux --verbose {{args}} diff --git a/mux/python/mise.toml b/mux/python/mise.toml deleted file mode 100644 index a245b1c0..00000000 --- a/mux/python/mise.toml +++ /dev/null @@ -1,4 +0,0 @@ -[tools] -python = "3.14" -uv = "latest" -just = "latest" diff --git a/mux/python/pyproject.toml b/mux/python/pyproject.toml deleted file mode 100644 index 8ff3deaa..00000000 --- a/mux/python/pyproject.toml +++ /dev/null @@ -1,22 +0,0 @@ -[project] -name = "donglora-mux" -version = "0.1.0" -description = "DongLoRa USB Multiplexer — share one dongle with multiple applications" -license = "MIT" -requires-python = ">=3.10" -authors = [{ name = "Stephen Waits" }] -dependencies = [ - "donglora", - "cobs", - "pyserial", -] - -[project.scripts] -donglora-mux = "donglora_mux:main" - -[build-system] -requires = ["hatchling"] -build-backend = "hatchling.build" - -[tool.uv.sources] -donglora = { path = "../../clients/python", editable = true } diff --git a/mux/python/uv.lock b/mux/python/uv.lock deleted file mode 100644 index 7ea4c811..00000000 --- a/mux/python/uv.lock +++ /dev/null @@ -1,64 +0,0 @@ -version = 1 -revision = 3 -requires-python = ">=3.10" - -[[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 = "donglora" -version = "0.1.0" -source = { editable = "../../clients/python" } -dependencies = [ - { name = "cobs" }, - { name = "pyserial" }, -] - -[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-mux" -version = "0.1.0" -source = { editable = "." } -dependencies = [ - { name = "cobs" }, - { name = "donglora" }, - { name = "pyserial" }, -] - -[package.metadata] -requires-dist = [ - { name = "cobs" }, - { name = "donglora", editable = "../../clients/python" }, - { name = "pyserial" }, -] - -[[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" }, -] diff --git a/mux/rust/.gitignore b/mux/rust/.gitignore deleted file mode 100644 index ea8c4bf7..00000000 --- a/mux/rust/.gitignore +++ /dev/null @@ -1 +0,0 @@ -/target diff --git a/mux/rust/CHANGELOG.md b/mux/rust/CHANGELOG.md deleted file mode 100644 index 07452c90..00000000 --- a/mux/rust/CHANGELOG.md +++ /dev/null @@ -1,29 +0,0 @@ -# Changelog - -## 0.1.1 — 2026-04-06 - -### Fixed - -- Prevent multiple mux instances from running simultaneously via file-based - exclusive lock (`fd-lock`); stale sockets from crashed instances are cleaned - up automatically -- Clearer error message when TCP port is already in use - -## 0.1.0 — 2026-04-06 - -Initial release. - -### Features - -- USB multiplexer daemon: share one DongLoRa dongle with multiple applications -- Unix domain socket and optional TCP listeners (same COBS-framed protocol) -- RxPacket broadcast to all connected clients -- Reference-counted StartRx/StopRx across clients -- Config locking: first client sets radio config, others must match or fail -- Auto-reconnect on USB hot-plug with ping verification -- Bounded per-client send queues (256 frames) with backpressure isolation -- Smart interception: redundant commands get synthetic responses without - hitting the dongle -- Graceful shutdown on SIGINT/SIGTERM with socket cleanup -- TCP_NODELAY on accepted connections for low-latency frame delivery -- CLI: `--port`, `--socket`, `--tcp`, `--verbose` options diff --git a/mux/rust/Cargo.lock b/mux/rust/Cargo.lock deleted file mode 100644 index ec5d4d5a..00000000 --- a/mux/rust/Cargo.lock +++ /dev/null @@ -1,940 +0,0 @@ -# This file is automatically @generated by Cargo. -# It is not intended for manual editing. -version = 4 - -[[package]] -name = "aho-corasick" -version = "1.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" -dependencies = [ - "memchr", -] - -[[package]] -name = "anstream" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" -dependencies = [ - "anstyle", - "anstyle-parse", - "anstyle-query", - "anstyle-wincon", - "colorchoice", - "is_terminal_polyfill", - "utf8parse", -] - -[[package]] -name = "anstyle" -version = "1.0.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" - -[[package]] -name = "anstyle-parse" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" -dependencies = [ - "utf8parse", -] - -[[package]] -name = "anstyle-query" -version = "1.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" -dependencies = [ - "windows-sys 0.61.2", -] - -[[package]] -name = "anstyle-wincon" -version = "3.0.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" -dependencies = [ - "anstyle", - "once_cell_polyfill", - "windows-sys 0.61.2", -] - -[[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 = "bytes" -version = "1.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" - -[[package]] -name = "cfg-if" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" - -[[package]] -name = "cfg_aliases" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" - -[[package]] -name = "clap" -version = "4.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b193af5b67834b676abd72466a96c1024e6a6ad978a1f484bd90b85c94041351" -dependencies = [ - "clap_builder", - "clap_derive", -] - -[[package]] -name = "clap_builder" -version = "4.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" -dependencies = [ - "anstream", - "anstyle", - "clap_lex", - "strsim", -] - -[[package]] -name = "clap_derive" -version = "4.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1110bd8a634a1ab8cb04345d8d878267d57c3cf1b38d91b71af6686408bbca6a" -dependencies = [ - "heck", - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "clap_lex" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" - -[[package]] -name = "colorchoice" -version = "1.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" - -[[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 = "donglora-mux" -version = "0.1.1" -dependencies = [ - "anyhow", - "clap", - "donglora-client", - "fd-lock", - "tokio", - "tokio-serial", - "tokio-util", - "tracing", - "tracing-subscriber", -] - -[[package]] -name = "errno" -version = "0.3.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" -dependencies = [ - "libc", - "windows-sys 0.61.2", -] - -[[package]] -name = "fd-lock" -version = "4.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ce92ff622d6dadf7349484f42c93271a0d49b7cc4d466a936405bacbe10aa78" -dependencies = [ - "cfg-if", - "rustix", - "windows-sys 0.59.0", -] - -[[package]] -name = "futures" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" -dependencies = [ - "futures-channel", - "futures-core", - "futures-executor", - "futures-io", - "futures-sink", - "futures-task", - "futures-util", -] - -[[package]] -name = "futures-channel" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" -dependencies = [ - "futures-core", - "futures-sink", -] - -[[package]] -name = "futures-core" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" - -[[package]] -name = "futures-executor" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" -dependencies = [ - "futures-core", - "futures-task", - "futures-util", -] - -[[package]] -name = "futures-io" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" - -[[package]] -name = "futures-macro" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "futures-sink" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" - -[[package]] -name = "futures-task" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" - -[[package]] -name = "futures-util" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" -dependencies = [ - "futures-channel", - "futures-core", - "futures-io", - "futures-macro", - "futures-sink", - "futures-task", - "memchr", - "pin-project-lite", - "slab", -] - -[[package]] -name = "heck" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" - -[[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 = "is_terminal_polyfill" -version = "1.70.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" - -[[package]] -name = "lazy_static" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" - -[[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 = "linux-raw-sys" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" - -[[package]] -name = "lock_api" -version = "0.4.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" -dependencies = [ - "scopeguard", -] - -[[package]] -name = "log" -version = "0.4.29" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" - -[[package]] -name = "mach2" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d640282b302c0bb0a2a8e0233ead9035e3bed871f0b7e81fe4a1ec829765db44" -dependencies = [ - "libc", -] - -[[package]] -name = "matchers" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" -dependencies = [ - "regex-automata", -] - -[[package]] -name = "memchr" -version = "2.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" - -[[package]] -name = "mio" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" -dependencies = [ - "libc", - "log", - "wasi", - "windows-sys 0.61.2", -] - -[[package]] -name = "mio-serial" -version = "5.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "029e1f407e261176a983a6599c084efd322d9301028055c87174beac71397ba3" -dependencies = [ - "log", - "mio", - "nix 0.29.0", - "serialport", - "winapi", -] - -[[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 = "nix" -version = "0.29.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" -dependencies = [ - "bitflags 2.11.0", - "cfg-if", - "cfg_aliases", - "libc", -] - -[[package]] -name = "nu-ansi-term" -version = "0.50.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" -dependencies = [ - "windows-sys 0.61.2", -] - -[[package]] -name = "once_cell" -version = "1.21.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" - -[[package]] -name = "once_cell_polyfill" -version = "1.70.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" - -[[package]] -name = "parking_lot" -version = "0.12.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" -dependencies = [ - "lock_api", - "parking_lot_core", -] - -[[package]] -name = "parking_lot_core" -version = "0.9.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" -dependencies = [ - "cfg-if", - "libc", - "redox_syscall", - "smallvec", - "windows-link", -] - -[[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 = "redox_syscall" -version = "0.5.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" -dependencies = [ - "bitflags 2.11.0", -] - -[[package]] -name = "regex-automata" -version = "0.4.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" -dependencies = [ - "aho-corasick", - "memchr", - "regex-syntax", -] - -[[package]] -name = "regex-syntax" -version = "0.8.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" - -[[package]] -name = "rustix" -version = "1.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" -dependencies = [ - "bitflags 2.11.0", - "errno", - "libc", - "linux-raw-sys", - "windows-sys 0.61.2", -] - -[[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 0.26.4", - "scopeguard", - "unescaper", - "windows-sys 0.52.0", -] - -[[package]] -name = "sharded-slab" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" -dependencies = [ - "lazy_static", -] - -[[package]] -name = "signal-hook-registry" -version = "1.4.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" -dependencies = [ - "errno", - "libc", -] - -[[package]] -name = "slab" -version = "0.4.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" - -[[package]] -name = "smallvec" -version = "1.15.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" - -[[package]] -name = "socket2" -version = "0.6.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" -dependencies = [ - "libc", - "windows-sys 0.61.2", -] - -[[package]] -name = "strsim" -version = "0.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" - -[[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 = "thread_local" -version = "1.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" -dependencies = [ - "cfg-if", -] - -[[package]] -name = "tokio" -version = "1.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2bd1c4c0fc4a7ab90fc15ef6daaa3ec3b893f004f915f2392557ed23237820cd" -dependencies = [ - "bytes", - "libc", - "mio", - "parking_lot", - "pin-project-lite", - "signal-hook-registry", - "socket2", - "tokio-macros", - "windows-sys 0.61.2", -] - -[[package]] -name = "tokio-macros" -version = "2.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "tokio-serial" -version = "5.4.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aa1d5427f11ba7c5e6384521cfd76f2d64572ff29f3f4f7aa0f496282923fdc8" -dependencies = [ - "cfg-if", - "futures", - "log", - "mio-serial", - "serialport", - "tokio", -] - -[[package]] -name = "tokio-util" -version = "0.7.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" -dependencies = [ - "bytes", - "futures-core", - "futures-sink", - "pin-project-lite", - "tokio", -] - -[[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", - "valuable", -] - -[[package]] -name = "tracing-log" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" -dependencies = [ - "log", - "once_cell", - "tracing-core", -] - -[[package]] -name = "tracing-subscriber" -version = "0.3.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" -dependencies = [ - "matchers", - "nu-ansi-term", - "once_cell", - "regex-automata", - "sharded-slab", - "smallvec", - "thread_local", - "tracing", - "tracing-core", - "tracing-log", -] - -[[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 = "utf8parse" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" - -[[package]] -name = "valuable" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" - -[[package]] -name = "wasi" -version = "0.11.1+wasi-snapshot-preview1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" - -[[package]] -name = "winapi" -version = "0.3.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" -dependencies = [ - "winapi-i686-pc-windows-gnu", - "winapi-x86_64-pc-windows-gnu", -] - -[[package]] -name = "winapi-i686-pc-windows-gnu" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" - -[[package]] -name = "winapi-x86_64-pc-windows-gnu" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" - -[[package]] -name = "windows-link" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" - -[[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-sys" -version = "0.59.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" -dependencies = [ - "windows-targets", -] - -[[package]] -name = "windows-sys" -version = "0.61.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" -dependencies = [ - "windows-link", -] - -[[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" diff --git a/mux/rust/Cargo.toml b/mux/rust/Cargo.toml deleted file mode 100644 index 2591586b..00000000 --- a/mux/rust/Cargo.toml +++ /dev/null @@ -1,26 +0,0 @@ -[package] -name = "donglora-mux" -version = "0.1.1" -edition = "2024" -authors = ["Stephen Waits "] -license = "MIT" -description = "DongLoRa USB Multiplexer — share one dongle with multiple applications" -repository = "https://github.com/swaits/donglora" -keywords = ["lora", "radio", "usb", "multiplexer"] -categories = ["hardware-support"] - -[dependencies] -donglora-client = { version = "0.1.0", path = "../../clients/rust" } -anyhow = "1" -clap = { version = "4", features = ["derive"] } -tokio = { version = "1", features = ["full"] } -tokio-serial = "5" -tokio-util = "0.7" -tracing = "0.1" -tracing-subscriber = { version = "0.3", features = ["env-filter"] } -fd-lock = "4" - -[lints.clippy] -unwrap_used = "deny" -expect_used = "deny" -panic = "deny" diff --git a/mux/rust/LICENSE b/mux/rust/LICENSE deleted file mode 100644 index a4ad6f14..00000000 --- a/mux/rust/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2025 Stephen Waits - -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. diff --git a/mux/rust/README.md b/mux/rust/README.md deleted file mode 100644 index 013aadeb..00000000 --- a/mux/rust/README.md +++ /dev/null @@ -1,29 +0,0 @@ -# DongLoRa Mux (Rust) - -USB multiplexer daemon — lets multiple applications share one DongLoRa -dongle simultaneously. - -## What It Does - -- Owns the USB serial connection exclusively -- Exposes a Unix domain socket (and optional TCP) speaking the same - COBS-framed protocol -- RxPacket frames broadcast to all connected clients -- StartRx/StopRx reference-counted across clients -- SetConfig locked once set (single client can change freely) -- Single-instance enforcement via file lock (stale sockets auto-cleaned) -- Automatic dongle reconnect on hot-plug -- No panics — enforced by clippy deny lints - -## Running - -```sh -just run # start the mux daemon -just verbose # start with verbose logging -just run -- --tcp 5741 --port /dev/ttyACM0 # with options -``` - -## Depends On - -- [clients/rust](../../clients/rust/) — the `donglora-client` library - (protocol types, COBS framing, device discovery) diff --git a/mux/rust/justfile b/mux/rust/justfile deleted file mode 100644 index bfd0025e..00000000 --- a/mux/rust/justfile +++ /dev/null @@ -1,19 +0,0 @@ -set shell := ["bash", "-c"] - -default: run - -# Run the mux daemon -run *args: - cargo run -- {{args}} - -# Run with verbose logging -verbose *args: - cargo run -- --verbose {{args}} - -# Run clippy and tests -check: - cargo clippy -- -D warnings && cargo test - -# Build release -build: - cargo build --release diff --git a/mux/rust/src/daemon.rs b/mux/rust/src/daemon.rs deleted file mode 100644 index 92001e63..00000000 --- a/mux/rust/src/daemon.rs +++ /dev/null @@ -1,651 +0,0 @@ -//! Core mux daemon: dongle I/O, client management, reconnect loop. -//! -//! The daemon owns the USB serial connection and exposes Unix domain socket -//! and optional TCP listeners. It routes commands from clients to the dongle -//! and broadcasts/routes responses back. - -use std::collections::HashMap; -use std::path::Path; -use std::sync::Arc; -use std::time::Duration; - -use donglora_client::{ - encode_frame, FrameReader, CMD_TAG_SET_CONFIG, CMD_TAG_START_RX, CMD_TAG_STOP_RX, - RADIO_CONFIG_SIZE, RESP_TAG_OK, RESP_TAG_RX_PACKET, -}; -use tokio::io::{AsyncReadExt, AsyncWriteExt}; -use tokio::net::{TcpListener, UnixListener}; -use tokio::sync::{mpsc, oneshot, Mutex}; - -/// A queued command: (client_id, raw_cmd_bytes). -type QueuedCmd = (u64, Vec); - -/// Shared command receiver, wrapped for use across reconnect cycles. -type SharedCmdRx = Arc>>; -use tokio_util::sync::CancellationToken; -use tracing::{debug, error, info, warn}; - -use crate::intercept::{self, MuxState}; -use crate::session::ClientSession; - -/// Command timeout waiting for dongle response. -const COMMAND_TIMEOUT: Duration = Duration::from_secs(10); - -/// Delay between dongle reconnect attempts. -const RECONNECT_DELAY: Duration = Duration::from_secs(2); - -/// Max consecutive dongle read errors before declaring disconnect. -const MAX_CONSECUTIVE_ERRORS: u32 = 3; - -/// Sentinel client ID for synthetic commands (e.g. auto StopRx). -const SYNTHETIC_CLIENT_ID: u64 = u64::MAX; - -/// Shared mux state, protected by async mutex. -type SharedState = Arc>; - -struct MuxInner { - sessions: HashMap, - mux_state: MuxState, -} - -/// The multiplexer daemon. -pub struct MuxDaemon { - serial_port: String, - socket_path: String, - tcp_addr: Option<(String, u16)>, - shutdown: CancellationToken, -} - -impl MuxDaemon { - pub fn new( - serial_port: String, - socket_path: String, - tcp_addr: Option<(String, u16)>, - shutdown: CancellationToken, - ) -> Self { - Self { - serial_port, - socket_path, - tcp_addr, - shutdown, - } - } - - /// Run the mux daemon. Returns when shutdown is signalled. - pub async fn run(&self) -> anyhow::Result<()> { - let state: SharedState = Arc::new(Mutex::new(MuxInner { - sessions: HashMap::new(), - mux_state: MuxState::new(), - })); - - // Command channel: client handlers → dongle writer - let (cmd_tx, cmd_rx) = mpsc::channel::<(u64, Vec)>(64); - let cmd_rx = Arc::new(Mutex::new(cmd_rx)); - - // Pending response slot: dongle writer installs, dongle reader resolves - let pending_response: Arc>>>> = - Arc::new(Mutex::new(None)); - - // Prepare socket directory - let sock_path = Path::new(&self.socket_path); - if let Some(parent) = sock_path.parent() { - tokio::fs::create_dir_all(parent).await.ok(); - } - // Acquire an exclusive lock to prevent multiple mux instances. - // The lock is held for the process lifetime and auto-released on exit (even SIGKILL). - let lock_path = format!("{}.lock", self.socket_path); - let lock_file = std::fs::OpenOptions::new() - .create(true) - .write(true) - .truncate(false) - .open(&lock_path) - .map_err(|e| anyhow::anyhow!("failed to open lock file {lock_path}: {e}"))?; - let mut lock = fd_lock::RwLock::new(lock_file); - let _lock_guard = match lock.try_write() { - Ok(guard) => guard, - Err(_) => anyhow::bail!("another donglora-mux is already running (lock held on {lock_path})"), - }; - - if sock_path.exists() { - // Lock acquired but socket file exists — stale from a crashed instance. - info!("removing stale socket {}", self.socket_path); - tokio::fs::remove_file(sock_path).await.ok(); - } - - // Start Unix socket server - let unix_listener = UnixListener::bind(&self.socket_path) - .map_err(|e| anyhow::anyhow!("failed to bind Unix socket {}: {e}", self.socket_path))?; - info!("Unix socket listening on {}", self.socket_path); - - // Start TCP server (optional) - let tcp_listener = if let Some((ref host, port)) = self.tcp_addr { - let addr = format!("{host}:{port}"); - let listener = TcpListener::bind(&addr).await.map_err(|e| { - if e.kind() == std::io::ErrorKind::AddrInUse { - anyhow::anyhow!("another donglora-mux is already listening on TCP {addr}") - } else { - anyhow::anyhow!("failed to bind TCP {addr}: {e}") - } - })?; - info!("TCP listening on {addr}"); - Some(listener) - } else { - None - }; - - // Spawn client accept tasks - tokio::spawn(accept_unix_clients( - unix_listener, - state.clone(), - cmd_tx.clone(), - self.shutdown.clone(), - )); - if let Some(listener) = tcp_listener { - tokio::spawn(accept_tcp_clients( - listener, - state.clone(), - cmd_tx.clone(), - self.shutdown.clone(), - )); - } - - // Connect/reconnect loop - self.reconnect_loop(state, cmd_tx, cmd_rx, pending_response) - .await; - - // Cleanup - info!("shutting down..."); - if Path::new(&self.socket_path).exists() { - tokio::fs::remove_file(&self.socket_path).await.ok(); - } - info!("stopped."); - Ok(()) - } - - async fn reconnect_loop( - &self, - state: SharedState, - _cmd_tx: mpsc::Sender<(u64, Vec)>, // kept alive to prevent channel close - cmd_rx: SharedCmdRx, - pending_response: Arc>>>>, - ) { - loop { - if self.shutdown.is_cancelled() { - break; - } - - info!("opening dongle on {}", self.serial_port); - let serial = loop { - if self.shutdown.is_cancelled() { - return; - } - match open_and_ping(&self.serial_port).await { - Ok(s) => break s, - Err(e) => { - debug!("could not open dongle: {e}"); - tokio::select! { - () = self.shutdown.cancelled() => return, - () = tokio::time::sleep(RECONNECT_DELAY) => continue, - } - } - } - }; - - info!("dongle connected"); - let (serial_read, serial_write) = tokio::io::split(serial); - - let dongle_lost = CancellationToken::new(); - - let reader_handle = tokio::spawn(dongle_reader( - serial_read, - state.clone(), - pending_response.clone(), - dongle_lost.clone(), - self.shutdown.clone(), - )); - - let writer_handle = tokio::spawn(dongle_writer( - serial_write, - cmd_rx.clone(), - state.clone(), - pending_response.clone(), - dongle_lost.clone(), - self.shutdown.clone(), - )); - - // Wait for dongle loss or shutdown - tokio::select! { - () = self.shutdown.cancelled() => {}, - () = dongle_lost.cancelled() => {}, - } - - reader_handle.abort(); - writer_handle.abort(); - let _ = reader_handle.await; - let _ = writer_handle.await; - - // Drain pending commands - { - let mut rx = cmd_rx.lock().await; - while rx.try_recv().is_ok() {} - } - - if !self.shutdown.is_cancelled() { - info!("reconnecting in 2 seconds..."); - tokio::select! { - () = self.shutdown.cancelled() => return, - () = tokio::time::sleep(RECONNECT_DELAY) => {}, - } - } - } - } -} - -// ── Dongle I/O ───────────────────────────────────────────────────── - -async fn open_and_ping(port: &str) -> anyhow::Result { - let builder = tokio_serial::new(port, 115_200).timeout(Duration::from_secs(2)); - let mut serial = tokio_serial::SerialStream::open(&builder) - .map_err(|e| anyhow::anyhow!("failed to open {port}: {e}"))?; - - let ping_frame = encode_frame(&[0]); - AsyncWriteExt::write_all(&mut serial, &ping_frame) - .await - .map_err(|e| anyhow::anyhow!("failed to write ping: {e}"))?; - - let mut reader = FrameReader::new(); - let mut buf = [0u8; 64]; - let deadline = tokio::time::Instant::now() + Duration::from_secs(2); - - loop { - let remaining = deadline.saturating_duration_since(tokio::time::Instant::now()); - if remaining.is_zero() { - anyhow::bail!("ping timeout"); - } - - let n = tokio::select! { - result = serial.read(&mut buf) => { - result.map_err(|e| anyhow::anyhow!("serial read error: {e}"))? - } - () = tokio::time::sleep(remaining) => { - anyhow::bail!("ping timeout"); - } - }; - - if n == 0 { - anyhow::bail!("serial port closed during ping"); - } - - for frame in reader.feed(&buf[..n]) { - if frame.first() == Some(&0) { - info!("dongle responded to Ping"); - return Ok(serial); - } - } - } -} - -async fn dongle_reader( - mut serial: tokio::io::ReadHalf, - state: SharedState, - pending_response: Arc>>>>, - dongle_lost: CancellationToken, - shutdown: CancellationToken, -) { - let mut reader = FrameReader::new(); - let mut buf = [0u8; 512]; - let mut consecutive_errors: u32 = 0; - - loop { - if shutdown.is_cancelled() || dongle_lost.is_cancelled() { - return; - } - - let read_result = tokio::select! { - result = serial.read(&mut buf) => result, - () = shutdown.cancelled() => return, - () = dongle_lost.cancelled() => return, - }; - - match read_result { - Ok(0) => { - error!("serial port closed"); - resolve_pending_with_error(&pending_response).await; - dongle_lost.cancel(); - return; - } - Ok(n) => { - consecutive_errors = 0; - for frame in reader.feed(&buf[..n]) { - dispatch_frame(&frame, &state, &pending_response).await; - } - } - Err(e) => { - consecutive_errors += 1; - if consecutive_errors >= MAX_CONSECUTIVE_ERRORS { - error!("dongle read error (giving up after {consecutive_errors}): {e}"); - resolve_pending_with_error(&pending_response).await; - dongle_lost.cancel(); - return; - } - warn!("dongle read glitch ({consecutive_errors}/{MAX_CONSECUTIVE_ERRORS}): {e}"); - tokio::time::sleep(Duration::from_millis(100)).await; - } - } - } -} - -async fn dispatch_frame( - frame: &[u8], - state: &SharedState, - pending_response: &Arc>>>>, -) { - let Some(&tag) = frame.first() else { - return; - }; - - if tag == RESP_TAG_RX_PACKET { - let cobs_frame = encode_frame(frame); - let inner = state.lock().await; - for session in inner.sessions.values() { - session.enqueue(cobs_frame.clone()); - } - } else { - let mut slot = pending_response.lock().await; - if let Some(sender) = slot.take() { - let _ = sender.send(frame.to_vec()); - } else { - warn!("unsolicited non-RxPacket response (tag {tag}) — dropped"); - } - } -} - -async fn resolve_pending_with_error( - pending_response: &Arc>>>>, -) { - let mut slot = pending_response.lock().await; - if let Some(sender) = slot.take() { - let _ = sender.send(vec![5, 1]); // Error(RadioBusy) - } -} - -async fn dongle_writer( - mut serial: tokio::io::WriteHalf, - cmd_rx: SharedCmdRx, - state: SharedState, - pending_response: Arc>>>>, - dongle_lost: CancellationToken, - shutdown: CancellationToken, -) { - loop { - // Pull next command from queue - let (client_id, raw_cmd) = { - let mut rx = cmd_rx.lock().await; - tokio::select! { - cmd = rx.recv() => { - match cmd { - Some(c) => c, - None => return, - } - } - () = shutdown.cancelled() => return, - () = dongle_lost.cancelled() => return, - } - }; - - // Check for interception - let intercepted = { - let mut inner = state.lock().await; - let locked = inner.mux_state.locked_config; - intercept::maybe_intercept( - &raw_cmd, - client_id, - &mut inner.sessions, - &locked, - ) - }; - if let Some(synthetic) = intercepted { - let inner = state.lock().await; - if let Some(session) = inner.sessions.get(&client_id) { - session.enqueue(encode_frame(&synthetic)); - } - continue; - } - - // Install oneshot for the response - let (resp_tx, resp_rx) = oneshot::channel(); - { - let mut slot = pending_response.lock().await; - *slot = Some(resp_tx); - } - - // Send command to dongle - let cobs_frame = encode_frame(&raw_cmd); - if let Err(e) = AsyncWriteExt::write_all(&mut serial, &cobs_frame).await { - error!("dongle write error: {e}"); - resolve_pending_with_error(&pending_response).await; - dongle_lost.cancel(); - return; - } - - // Wait for response - let response = match tokio::time::timeout(COMMAND_TIMEOUT, resp_rx).await { - Ok(Ok(resp)) => resp, - Ok(Err(_)) => { - warn!("response channel dropped"); - vec![5, 1] - } - Err(_) => { - error!("dongle response timeout"); - let mut slot = pending_response.lock().await; - *slot = None; - vec![5, 1] - } - }; - - // Track state on successful forward - let cmd_tag = raw_cmd.first().copied().unwrap_or(0xFF); - let resp_tag = response.first().copied().unwrap_or(0xFF); - { - let mut inner = state.lock().await; - if cmd_tag == CMD_TAG_SET_CONFIG && resp_tag == RESP_TAG_OK { - if let Some(config_bytes) = raw_cmd.get(1..1 + RADIO_CONFIG_SIZE) { - let mut locked = [0u8; RADIO_CONFIG_SIZE]; - locked.copy_from_slice(config_bytes); - inner.mux_state.locked_config = Some(locked); - info!("radio config locked"); - } - } else if cmd_tag == CMD_TAG_START_RX && resp_tag == RESP_TAG_OK { - if let Some(session) = inner.sessions.get_mut(&client_id) { - session.rx_interested = true; - } - } else if cmd_tag == CMD_TAG_STOP_RX - && resp_tag == RESP_TAG_OK - && let Some(session) = inner.sessions.get_mut(&client_id) - { - session.rx_interested = false; - } - } - - // Route response to commanding client - if client_id != SYNTHETIC_CLIENT_ID { - let inner = state.lock().await; - if let Some(session) = inner.sessions.get(&client_id) { - session.enqueue(encode_frame(&response)); - } - } - } -} - -// ── Client management ────────────────────────────────────────────── - -async fn accept_unix_clients( - listener: UnixListener, - state: SharedState, - cmd_tx: mpsc::Sender<(u64, Vec)>, - shutdown: CancellationToken, -) { - loop { - let accept_result = tokio::select! { - result = listener.accept() => result, - () = shutdown.cancelled() => return, - }; - - match accept_result { - Ok((stream, _addr)) => { - let (read_half, write_half) = stream.into_split(); - spawn_client(read_half, write_half, state.clone(), cmd_tx.clone(), shutdown.clone()); - } - Err(e) => warn!("Unix accept error: {e}"), - } - } -} - -async fn accept_tcp_clients( - listener: TcpListener, - state: SharedState, - cmd_tx: mpsc::Sender<(u64, Vec)>, - shutdown: CancellationToken, -) { - loop { - let accept_result = tokio::select! { - result = listener.accept() => result, - () = shutdown.cancelled() => return, - }; - - match accept_result { - Ok((stream, addr)) => { - debug!("TCP connection from {addr}"); - // Low-latency: disable Nagle (COBS frames are small) - let _ = stream.set_nodelay(true); - let (read_half, write_half) = stream.into_split(); - spawn_client(read_half, write_half, state.clone(), cmd_tx.clone(), shutdown.clone()); - } - Err(e) => warn!("TCP accept error: {e}"), - } - } -} - -fn spawn_client( - read_half: R, - write_half: W, - state: SharedState, - cmd_tx: mpsc::Sender<(u64, Vec)>, - shutdown: CancellationToken, -) where - R: AsyncReadExt + Unpin + Send + 'static, - W: AsyncWriteExt + Unpin + Send + 'static, -{ - let (session, send_rx) = ClientSession::new(); - let client_id = session.id; - let label = session.label(); - - let state_clone = state.clone(); - let cmd_tx_clone = cmd_tx.clone(); - - tokio::spawn(async move { - { - let mut inner = state_clone.lock().await; - inner.sessions.insert(client_id, session); - info!("{label} connected ({} total)", inner.sessions.len()); - } - - let writer_shutdown = shutdown.clone(); - let writer_handle = tokio::spawn(client_writer(write_half, send_rx, writer_shutdown)); - - client_reader(read_half, client_id, cmd_tx_clone.clone(), shutdown).await; - - writer_handle.abort(); - let _ = writer_handle.await; - - remove_client(client_id, &state_clone, &label, &cmd_tx_clone).await; - }); -} - -async fn client_reader( - mut read_half: R, - client_id: u64, - cmd_tx: mpsc::Sender<(u64, Vec)>, - shutdown: CancellationToken, -) { - let mut reader = FrameReader::new(); - let mut buf = [0u8; 4096]; - - loop { - let read_result = tokio::select! { - result = read_half.read(&mut buf) => result, - () = shutdown.cancelled() => return, - }; - - match read_result { - Ok(0) => return, - Ok(n) => { - for frame in reader.feed(&buf[..n]) { - if cmd_tx.send((client_id, frame)).await.is_err() { - return; - } - } - } - Err(_) => return, - } - } -} - -async fn client_writer( - mut write_half: W, - mut send_rx: mpsc::Receiver>, - shutdown: CancellationToken, -) { - loop { - let frame = tokio::select! { - f = send_rx.recv() => { - match f { - Some(f) => f, - None => return, - } - } - () = shutdown.cancelled() => return, - }; - - if AsyncWriteExt::write_all(&mut write_half, &frame) - .await - .is_err() - { - return; - } - } -} - -async fn remove_client( - client_id: u64, - state: &SharedState, - label: &str, - cmd_tx: &mpsc::Sender<(u64, Vec)>, -) { - let mut inner = state.lock().await; - - let was_interested = inner - .sessions - .get(&client_id) - .is_some_and(|s| s.rx_interested); - - inner.sessions.remove(&client_id); - info!("{label} disconnected ({} remain)", inner.sessions.len()); - - // If last RX-interested client gone, send synthetic StopRx - if was_interested && intercept::rx_interest_count(&inner.sessions) == 0 { - info!("last RX-interested client gone — sending StopRx"); - // Drop the lock before sending to avoid deadlock - drop(inner); - let _ = cmd_tx - .send((SYNTHETIC_CLIENT_ID, vec![CMD_TAG_STOP_RX])) - .await; - return; - } - - // Reset locked config when all clients are gone - if inner.sessions.is_empty() && inner.mux_state.locked_config.is_some() { - info!("all clients gone — config lock released"); - inner.mux_state.locked_config = None; - } -} diff --git a/mux/rust/src/intercept.rs b/mux/rust/src/intercept.rs deleted file mode 100644 index 8e626433..00000000 --- a/mux/rust/src/intercept.rs +++ /dev/null @@ -1,291 +0,0 @@ -//! Smart multiplexing: SetConfig locking, StartRx/StopRx reference counting. -//! -//! The mux intercepts certain commands to avoid redundant dongle operations -//! when multiple clients are connected. Intercepted commands get synthetic -//! responses without hitting the physical dongle. - -use std::collections::HashMap; - -use donglora_client::{ - CMD_TAG_SET_CONFIG, CMD_TAG_START_RX, CMD_TAG_STOP_RX, ERROR_INVALID_CONFIG, RADIO_CONFIG_SIZE, - RESP_TAG_ERROR, RESP_TAG_OK, -}; -use tracing::{debug, warn}; - -use crate::session::ClientSession; - -/// Mux-level state for interception logic. -pub struct MuxState { - /// Raw 13-byte RadioConfig, locked once set by any client with others connected. - pub locked_config: Option<[u8; RADIO_CONFIG_SIZE]>, -} - -impl MuxState { - pub fn new() -> Self { - Self { - locked_config: None, - } - } -} - -/// Check if a command should be intercepted (synthetic response) vs forwarded to the dongle. -/// -/// Returns `Some(response_bytes)` if intercepted, `None` to forward normally. -pub fn maybe_intercept( - raw_cmd: &[u8], - client_id: u64, - sessions: &mut HashMap, - locked_config: &Option<[u8; RADIO_CONFIG_SIZE]>, -) -> Option> { - let cmd_tag = *raw_cmd.first()?; - - match cmd_tag { - CMD_TAG_SET_CONFIG => intercept_set_config(raw_cmd, client_id, sessions, locked_config), - CMD_TAG_START_RX => intercept_start_rx(client_id, sessions), - CMD_TAG_STOP_RX => intercept_stop_rx(client_id, sessions), - _ => None, - } -} - -fn intercept_set_config( - raw_cmd: &[u8], - client_id: u64, - sessions: &HashMap, - locked_config: &Option<[u8; RADIO_CONFIG_SIZE]>, -) -> Option> { - let config_bytes = raw_cmd.get(1..1 + RADIO_CONFIG_SIZE)?; - - // Single client — allow free config changes (scanner mode) - if sessions.len() <= 1 { - return None; - } - - // First SetConfig with multiple clients — forward and lock on success - let Some(locked) = locked_config else { - return None; - }; - - // Same config — reply Ok without hitting the dongle - if config_bytes == locked.as_slice() { - let label = sessions - .get(&client_id) - .map(ClientSession::label) - .unwrap_or_else(|| format!("id-{client_id}")); - debug!("{label}: SetConfig matches locked config — Ok"); - return Some(vec![RESP_TAG_OK]); - } - - // Different config — reject - let label = sessions - .get(&client_id) - .map(ClientSession::label) - .unwrap_or_else(|| format!("id-{client_id}")); - warn!("{label}: SetConfig rejected (conflicts with locked config)"); - Some(vec![RESP_TAG_ERROR, ERROR_INVALID_CONFIG]) -} - -fn intercept_start_rx( - client_id: u64, - sessions: &mut HashMap, -) -> Option> { - let client = sessions.get(&client_id)?; - - // Already interested — no-op - if client.rx_interested { - return Some(vec![RESP_TAG_OK]); - } - - // Others already receiving — just mark this client - if rx_interest_count(sessions) > 0 { - if let Some(c) = sessions.get_mut(&client_id) { - c.rx_interested = true; - } - return Some(vec![RESP_TAG_OK]); - } - - // First interested client — forward to dongle - None -} - -fn intercept_stop_rx( - client_id: u64, - sessions: &mut HashMap, -) -> Option> { - let client = sessions.get(&client_id)?; - - // Wasn't interested — no-op - if !client.rx_interested { - return Some(vec![RESP_TAG_OK]); - } - - // Mark as not interested - if let Some(c) = sessions.get_mut(&client_id) { - c.rx_interested = false; - } - - // Others still interested — don't stop the dongle - if rx_interest_count(sessions) > 0 { - return Some(vec![RESP_TAG_OK]); - } - - // Last interested client — forward StopRx to dongle - None -} - -/// Count how many sessions have rx_interested set. -pub fn rx_interest_count(sessions: &HashMap) -> usize { - sessions.values().filter(|s| s.rx_interested).count() -} - -// ── Tests ────────────────────────────────────────────────────────── - -#[cfg(test)] -mod tests { - use super::*; - - fn make_sessions(n: usize) -> HashMap { - let mut map = HashMap::new(); - for _ in 0..n { - let (session, _rx) = ClientSession::new(); - let id = session.id; - map.insert(id, session); - } - map - } - - fn first_id(sessions: &HashMap) -> u64 { - *sessions.keys().min().unwrap() - } - - fn second_id(sessions: &HashMap) -> u64 { - let mut ids: Vec = sessions.keys().copied().collect(); - ids.sort(); - ids[1] - } - - // ── SetConfig ──────────────────────────────���─────────────────── - - #[test] - fn set_config_single_client_forwards() { - let mut sessions = make_sessions(1); - let locked = None; - let id = first_id(&sessions); - let cmd = [CMD_TAG_SET_CONFIG, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]; - assert!(maybe_intercept(&cmd, id, &mut sessions, &locked).is_none()); - } - - #[test] - fn set_config_first_with_multiple_forwards() { - let mut sessions = make_sessions(2); - let locked = None; - let id = first_id(&sessions); - let cmd = [CMD_TAG_SET_CONFIG, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13]; - assert!(maybe_intercept(&cmd, id, &mut sessions, &locked).is_none()); - } - - #[test] - fn set_config_matching_returns_ok() { - let mut sessions = make_sessions(2); - let config = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13]; - let locked = Some(config); - let id = first_id(&sessions); - let mut cmd = vec![CMD_TAG_SET_CONFIG]; - cmd.extend_from_slice(&config); - assert_eq!(maybe_intercept(&cmd, id, &mut sessions, &locked), Some(vec![RESP_TAG_OK])); - } - - #[test] - fn set_config_conflicting_returns_error() { - let mut sessions = make_sessions(2); - let locked = Some([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13]); - let id = first_id(&sessions); - let mut cmd = vec![CMD_TAG_SET_CONFIG]; - cmd.extend_from_slice(&[99, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13]); - assert_eq!( - maybe_intercept(&cmd, id, &mut sessions, &locked), - Some(vec![RESP_TAG_ERROR, ERROR_INVALID_CONFIG]) - ); - } - - // ── StartRx ──────────────────────────────────────────────────── - - #[test] - fn start_rx_first_client_forwards() { - let mut sessions = make_sessions(1); - let locked = None; - let id = first_id(&sessions); - assert!(maybe_intercept(&[CMD_TAG_START_RX], id, &mut sessions, &locked).is_none()); - } - - #[test] - fn start_rx_already_interested_returns_ok() { - let mut sessions = make_sessions(1); - let locked = None; - let id = first_id(&sessions); - sessions.get_mut(&id).unwrap().rx_interested = true; - assert_eq!( - maybe_intercept(&[CMD_TAG_START_RX], id, &mut sessions, &locked), - Some(vec![RESP_TAG_OK]) - ); - } - - #[test] - fn start_rx_others_interested_marks_and_returns_ok() { - let mut sessions = make_sessions(2); - let locked = None; - let id1 = first_id(&sessions); - let id2 = second_id(&sessions); - sessions.get_mut(&id1).unwrap().rx_interested = true; - let result = maybe_intercept(&[CMD_TAG_START_RX], id2, &mut sessions, &locked); - assert_eq!(result, Some(vec![RESP_TAG_OK])); - assert!(sessions.get(&id2).unwrap().rx_interested); - } - - // ── StopRx ───────────────────────────────────────────────────── - - #[test] - fn stop_rx_not_interested_returns_ok() { - let mut sessions = make_sessions(1); - let locked = None; - let id = first_id(&sessions); - assert_eq!( - maybe_intercept(&[CMD_TAG_STOP_RX], id, &mut sessions, &locked), - Some(vec![RESP_TAG_OK]) - ); - } - - #[test] - fn stop_rx_others_remain_returns_ok() { - let mut sessions = make_sessions(2); - let locked = None; - let id1 = first_id(&sessions); - let id2 = second_id(&sessions); - sessions.get_mut(&id1).unwrap().rx_interested = true; - sessions.get_mut(&id2).unwrap().rx_interested = true; - let result = maybe_intercept(&[CMD_TAG_STOP_RX], id1, &mut sessions, &locked); - assert_eq!(result, Some(vec![RESP_TAG_OK])); - assert!(!sessions.get(&id1).unwrap().rx_interested); - } - - #[test] - fn stop_rx_last_interested_forwards() { - let mut sessions = make_sessions(1); - let locked = None; - let id = first_id(&sessions); - sessions.get_mut(&id).unwrap().rx_interested = true; - assert!(maybe_intercept(&[CMD_TAG_STOP_RX], id, &mut sessions, &locked).is_none()); - assert!(!sessions.get(&id).unwrap().rx_interested); - } - - // ── Other commands ───────────────────────────────────────────── - - #[test] - fn other_commands_not_intercepted() { - let mut sessions = make_sessions(1); - let locked = None; - let id = first_id(&sessions); - for tag in [0u8, 1, 5, 6, 7, 8] { - assert!(maybe_intercept(&[tag], id, &mut sessions, &locked).is_none()); - } - } -} diff --git a/mux/rust/src/main.rs b/mux/rust/src/main.rs deleted file mode 100644 index dfcf0e59..00000000 --- a/mux/rust/src/main.rs +++ /dev/null @@ -1,115 +0,0 @@ -//! DongLoRa USB Multiplexer — share one dongle with multiple applications. -//! -//! Owns the USB serial connection and exposes a Unix domain socket (and -//! optional TCP) that speaks the same COBS-framed protocol. Clients connect -//! and get solicited responses routed, RxPackets broadcast, and StartRx/StopRx -//! reference-counted. - -mod daemon; -mod intercept; -mod session; - -use clap::Parser; -use tokio_util::sync::CancellationToken; -use tracing::info; - -#[derive(Parser)] -#[command(name = "donglora-mux", about = "DongLoRa USB Multiplexer")] -struct Args { - /// Serial port (auto-detect by USB VID:PID if omitted) - #[arg(short, long)] - port: Option, - - /// Unix socket path (default: XDG or /tmp/donglora-mux.sock) - #[arg(short, long)] - socket: Option, - - /// TCP listen address [HOST:]PORT (default: 0.0.0.0:5741, "none" to disable) - #[arg(short, long, default_value = "0.0.0.0:5741")] - tcp: String, - - /// Enable debug logging - #[arg(short, long)] - verbose: bool, -} - -#[tokio::main] -async fn main() -> anyhow::Result<()> { - let args = Args::parse(); - - // Init tracing — daemon-style: wall-clock time, level, message - let filter = if args.verbose { "debug" } else { "info" }; - tracing_subscriber::fmt() - .with_env_filter( - tracing_subscriber::EnvFilter::try_from_default_env() - .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new(filter)), - ) - .with_target(false) - .init(); - - // Resolve serial port - let port = match args.port { - Some(p) => p, - None => tokio::task::spawn_blocking(|| { - donglora_client::find_port().unwrap_or_else(|| { - info!("waiting for DongLoRa device..."); - donglora_client::wait_for_device() - }) - }) - .await - .map_err(|e| anyhow::anyhow!("device discovery task failed: {e}"))?, - }; - - // Resolve socket path - let socket_path = args - .socket - .unwrap_or_else(donglora_client::default_socket_path); - - // Parse TCP address - let tcp_addr = parse_tcp_addr(&args.tcp); - - // Shutdown token - let shutdown = CancellationToken::new(); - - // Install signal handlers - let sig_shutdown = shutdown.clone(); - tokio::spawn(async move { - if let Err(e) = signal_handler(sig_shutdown).await { - tracing::error!("signal handler error: {e}"); - } - }); - - // Run the daemon - let daemon = daemon::MuxDaemon::new(port, socket_path, tcp_addr, shutdown); - daemon.run().await -} - -fn parse_tcp_addr(tcp: &str) -> Option<(String, u16)> { - if tcp.eq_ignore_ascii_case("none") { - return None; - } - if let Some((host, port_str)) = tcp.rsplit_once(':') - && let Ok(port) = port_str.parse() - { - return Some((host.to_string(), port)); - } - if let Ok(port) = tcp.parse() { - return Some(("0.0.0.0".to_string(), port)); - } - None -} - -async fn signal_handler(shutdown: CancellationToken) -> anyhow::Result<()> { - let mut sigint = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::interrupt()) - .map_err(|e| anyhow::anyhow!("failed to register SIGINT handler: {e}"))?; - let mut sigterm = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) - .map_err(|e| anyhow::anyhow!("failed to register SIGTERM handler: {e}"))?; - - tokio::select! { - _ = sigint.recv() => info!("received SIGINT"), - _ = sigterm.recv() => info!("received SIGTERM"), - } - - shutdown.cancel(); - Ok(()) -} diff --git a/mux/rust/src/session.rs b/mux/rust/src/session.rs deleted file mode 100644 index 55a1ce5a..00000000 --- a/mux/rust/src/session.rs +++ /dev/null @@ -1,57 +0,0 @@ -//! Per-client session state and bounded send queue. -//! -//! Each connected client gets a [`ClientSession`] that tracks its ID, -//! RX interest flag, and a bounded channel for outgoing COBS frames. - -use std::sync::atomic::{AtomicU64, Ordering}; - -use tokio::sync::mpsc; -use tracing::warn; - -/// Bounded send queue capacity per client. -const SEND_QUEUE_CAP: usize = 256; - -/// Global client ID counter. -static NEXT_CLIENT_ID: AtomicU64 = AtomicU64::new(0); - -/// Per-client state tracked by the mux daemon. -pub struct ClientSession { - /// Unique client identifier. - pub id: u64, - /// Whether this client has called StartRx. - pub rx_interested: bool, - /// Sender half of the bounded channel to the client's writer task. - tx: mpsc::Sender>, -} - -impl ClientSession { - /// Create a new session. Returns the session and the receiver half - /// for the client's writer task to drain. - pub fn new() -> (Self, mpsc::Receiver>) { - let id = NEXT_CLIENT_ID.fetch_add(1, Ordering::Relaxed); - let (tx, rx) = mpsc::channel(SEND_QUEUE_CAP); - let session = Self { - id, - rx_interested: false, - tx, - }; - (session, rx) - } - - /// Human-readable label for logging. - pub fn label(&self) -> String { - format!("client-{}", self.id) - } - - /// Best-effort enqueue of a COBS frame for sending to this client. - /// - /// If the channel is full (slow client), the frame is dropped with a warning. - /// This prevents backpressure from one slow client blocking the entire mux. - pub fn enqueue(&self, frame: Vec) { - if let Err(mpsc::error::TrySendError::Full(_)) = self.tx.try_send(frame) { - warn!("{}: send queue full, dropping frame", self.label()); - } - // Closed channel (client disconnected) is silently ignored — the session - // will be cleaned up when the client handler task exits. - } -}