diff --git a/.github/ISSUE_TEMPLATE/bug.yml b/.github/ISSUE_TEMPLATE/bug.yml new file mode 100644 index 0000000..099f193 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug.yml @@ -0,0 +1,39 @@ +name: Bug report +description: Report a reproducible OpenNet problem +title: "[Bug]: " +labels: ["bug", "needs-triage"] +body: + - type: markdown + attributes: + value: Do not include credentials or security vulnerabilities. Use a private security advisory for vulnerabilities. + - type: textarea + id: summary + attributes: + label: What happened? + description: Include the expected and actual behavior. + validations: + required: true + - type: input + id: version + attributes: + label: OpenNet version or commit + validations: + required: true + - type: textarea + id: environment + attributes: + label: Environment + description: Device, OS, Python/Arduino core, compiler, and network. + validations: + required: true + - type: textarea + id: reproduce + attributes: + label: Minimal reproduction + validations: + required: true + - type: textarea + id: logs + attributes: + label: Sanitized logs + render: text diff --git a/.github/ISSUE_TEMPLATE/compatibility.yml b/.github/ISSUE_TEMPLATE/compatibility.yml new file mode 100644 index 0000000..dc2445b --- /dev/null +++ b/.github/ISSUE_TEMPLATE/compatibility.yml @@ -0,0 +1,25 @@ +name: Hardware compatibility report +description: Contribute a reproducible device test result +title: "[Compatibility]: " +labels: ["compatibility"] +body: + - type: input + id: device + attributes: + label: Exact device and revision + validations: + required: true + - type: textarea + id: software + attributes: + label: Software versions + description: OS, Python, Arduino core, PlatformIO/IDE, and OpenNet commit. + validations: + required: true + - type: textarea + id: procedure + attributes: + label: Procedure and result + description: Network, example, duration, messages, sizes, and failures. + validations: + required: true diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..474a4f0 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: false +contact_links: + - name: Security vulnerability + url: https://github.com/devkyato/OpenNet/security/advisories/new + about: Report vulnerabilities privately. diff --git a/.github/ISSUE_TEMPLATE/feature.yml b/.github/ISSUE_TEMPLATE/feature.yml new file mode 100644 index 0000000..e4225c3 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature.yml @@ -0,0 +1,35 @@ +name: Feature proposal +description: Propose a compatible, testable improvement +title: "[Proposal]: " +labels: ["enhancement", "needs-discussion"] +body: + - type: textarea + id: problem + attributes: + label: Problem + description: What real use case cannot be handled today? + validations: + required: true + - type: textarea + id: proposal + attributes: + label: Proposed behavior + validations: + required: true + - type: textarea + id: alternatives + attributes: + label: Alternatives and compatibility impact + validations: + required: true + - type: dropdown + id: help + attributes: + label: Can you help implement or test it? + options: + - Implement and test + - Hardware-test + - Review or document + - Not currently + validations: + required: true diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..f8f137e --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,12 @@ +version: 2 +updates: + - package-ecosystem: pip + directory: /python + schedule: + interval: monthly + labels: [dependencies, python] + - package-ecosystem: github-actions + directory: / + schedule: + interval: monthly + labels: [dependencies, ci] diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..cd8b386 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,19 @@ +## Summary + +Explain the problem and the smallest useful solution. + +Closes # + +## Verification + +- [ ] Python tests pass, if applicable. +- [ ] ESP32 example compiles, if applicable. +- [ ] Protocol changes include cross-language vectors. +- [ ] Documentation and changelog are updated. +- [ ] No credentials, personal data, or generated build output are included. + +## Hardware report + +For embedded changes, list the exact board, Arduino core/toolchain versions, network +setup, payload sizes, message count, and test duration. Write `Not hardware-tested` +when that is the honest status. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..83fb48f --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,68 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + python: + name: Python ${{ matrix.python-version }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.9", "3.10", "3.11", "3.12", "3.13", "3.14"] + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-python@v7 + with: + python-version: ${{ matrix.python-version }} + cache: pip + cache-dependency-path: python/pyproject.toml + - run: python -m pip install -e "./python[dev]" + - run: python -m pytest python + - if: matrix.python-version == '3.14' + run: | + python -m ruff check python + python -m mypy python/src + + esp32: + name: ESP32 Arduino compile + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-python@v7 + with: + python-version: "3.14" + cache: pip + - run: python -m pip install platformio + - run: platformio run -e esp32dev + + package: + name: Reproducible packages + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-python@v7 + with: + python-version: "3.14" + cache: pip + - run: python -m pip install build + - run: python scripts/build_release.py + - run: python -m build python + - uses: actions/upload-artifact@v6 + with: + name: packages + path: | + dist/OpenNet-*.zip + python/dist/* + if-no-files-found: error diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..abad022 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,33 @@ +name: CodeQL + +on: + push: + branches: [main] + pull_request: + schedule: + - cron: "17 3 * * 2" + +permissions: + contents: read + security-events: write + +jobs: + analyze: + runs-on: ubuntu-latest + strategy: + matrix: + language: ["python", "cpp"] + steps: + - uses: actions/checkout@v7 + - uses: github/codeql-action/init@v4 + with: + languages: ${{ matrix.language }} + - if: matrix.language == 'cpp' + uses: actions/setup-python@v7 + with: + python-version: "3.14" + - if: matrix.language == 'cpp' + run: | + python -m pip install platformio + platformio run -e esp32dev + - uses: github/codeql-action/analyze@v4 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..43e9184 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,38 @@ +name: Release + +on: + push: + tags: ["v*.*.*"] + +permissions: + contents: write + +jobs: + release: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + - uses: actions/setup-python@v7 + with: + python-version: "3.14" + cache: pip + - name: Verify, test, and build + run: | + python -m pip install -e "./python[dev]" build + python -m pytest python + python -m ruff check python + python -m mypy python/src + python scripts/build_release.py --expected-version "${GITHUB_REF_NAME#v}" + python -m build python + - name: Create GitHub release + env: + GH_TOKEN: ${{ github.token }} + run: >- + gh release create "$GITHUB_REF_NAME" + dist/OpenNet-*.zip + python/dist/* + --generate-notes + --verify-tag + --title "OpenNet $GITHUB_REF_NAME" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..83032db --- /dev/null +++ b/.gitignore @@ -0,0 +1,15 @@ +.pio/ +.venv/ +__pycache__/ +*.py[cod] +*.egg-info/ +build/ +dist/ +.coverage +.pytest_cache/ +.mypy_cache/ +.DS_Store +.idea/ +.vscode/* +!.vscode/extensions.json +!.vscode/settings.json diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..1647c3d --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,6 @@ +{ + "recommendations": [ + "platformio.platformio-ide", + "ms-python.python" + ] +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..eb41b61 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,5 @@ +{ + "python.defaultInterpreterPath": "${workspaceFolder}/.venv/Scripts/python.exe", + "python.testing.pytestEnabled": true, + "python.testing.pytestArgs": ["python/tests"] +} diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..d52aabb --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,16 @@ +# Changelog + +All notable changes are documented here. OpenNet follows +[Semantic Versioning](https://semver.org/). + +## [Unreleased] + +### Added + +- ONP/1 protocol specification and typed binary framing. +- Python asyncio client and server. +- ESP32 Arduino client. +- Arduino IDE, PlatformIO, and Python examples. +- Cross-platform tests, CI, packaging, and contributor documentation. + +[Unreleased]: https://github.com/devkyato/OpenNet/compare/v0.1.0...HEAD diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..8127c16 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,28 @@ +# Code of Conduct + +## Our pledge + +We pledge to make participation in OpenNet a respectful, harassment-free +experience for everyone, regardless of background, identity, experience, or +education level. + +## Expected behavior + +- Be patient with learners and specific when giving feedback. +- Discuss technical decisions, not personal traits. +- Credit other people's work. +- Protect private information and report security issues privately. + +## Unacceptable behavior + +Harassment, threats, discriminatory language, sexualized attention, deliberate +disruption, and publishing another person's private information are not accepted. + +## Enforcement + +Report conduct concerns privately to the repository owner through the security +advisory form. Maintainers may edit or remove content and temporarily or +permanently restrict participation. Reports will be handled as confidentially as +practical. + +This policy is adapted from the Contributor Covenant 2.1. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..fdbdef3 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,47 @@ +# Contributing to OpenNet + +Thank you for helping. Contributions from students and first-time open-source +developers are especially welcome. + +## Choose a task + +Comment on an existing issue before starting substantial work. Issues labeled +`good first issue` are scoped for newcomers; `help wanted` items may require +protocol or embedded experience. For a new idea, open an issue and explain the +problem before proposing an API. + +## Development setup + +Python: + +```bash +python -m venv .venv +.venv/Scripts/python -m pip install -e "./python[dev]" +.venv/Scripts/python -m pytest +``` + +On Linux/macOS, activate with `source .venv/bin/activate`. For embedded work, +install PlatformIO and run: + +```bash +pio test -e native +pio run -e esp32dev +``` + +## Pull requests + +1. Branch from `main`. +2. Make one coherent change with tests and documentation. +3. Use a Conventional Commit subject such as `feat: add discovery packet`. +4. Run the relevant checks locally. +5. Complete the pull-request checklist and link the issue. + +Do not add generated build output or credentials. Protocol changes must update +`docs/protocol.md` and include cross-language test vectors. Compatibility claims +must name the exact board/OS, dependency versions, and test procedure. + +## Review + +Maintainers review correctness, compatibility, tests, security, and clarity. +Authors cannot approve their own pull requests; another reviewer should approve +before merge when one is available. CI must pass. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f24b1a7 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 devkyato + +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 5485b14..07b4887 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,80 @@ -# Open-Proxies -Proxies, dont need to ask where I got em'. +# OpenNet -Can be used for our personal or public projects. -@九六七 / Developer +![OpenNet cover](docs/assets/opennet-cover.png) + +[![CI](https://github.com/devkyato/OpenNet/actions/workflows/ci.yml/badge.svg)](https://github.com/devkyato/OpenNet/actions/workflows/ci.yml) +[![License: MIT](https://img.shields.io/badge/license-MIT-4a638f.svg)](LICENSE) +[![Protocol](https://img.shields.io/badge/protocol-ONP%2F1-4a638f.svg)](docs/protocol.md) + +OpenNet is a small, typed messaging protocol and library for sending JSON, text, +numbers, and arbitrary binary data between ESP32 devices, Raspberry Pi computers, +and backend services over Wi-Fi or Ethernet. + +The first release deliberately targets one dependable path: an ESP32 client and a +Python 3.9+ client/server communicating over TCP. The wire format is documented and +language-neutral, so additional transports and language bindings can be added +without inventing incompatible message formats. + +## What it provides + +- A compact binary frame with versioning, message IDs, type information, CRC-32, + and a 16 MiB defensive payload limit. +- JSON, UTF-8 text, signed integers, IEEE-754 doubles, booleans, null, and raw bytes. +- Async Python client/server APIs for Raspberry Pi and backend systems. +- An Arduino-compatible ESP32 client with reconnect, heartbeat, and delivery ACKs. +- Optional TLS at the transport layer. +- Arduino IDE, PlatformIO, Python, and VS Code examples. +- Protocol conformance tests and cross-platform CI. + +## Five-minute start + +Run the Python server: + +```bash +python -m pip install -e "./python[dev]" +opennet-server --host 0.0.0.0 --port 8765 +``` + +Install this repository as an Arduino library, open +`File > Examples > OpenNet > TelemetryClient`, set the Wi-Fi credentials and +server address, then upload it to an ESP32. + +Python clients are equally small: + +```python +import asyncio +from opennet import OpenNetClient + +async def main(): + async with OpenNetClient("192.168.1.50", 8765) as client: + await client.send("temperature", {"celsius": 24.7}) + +asyncio.run(main()) +``` + +See the [getting-started guide](docs/getting-started.md), [protocol +specification](docs/protocol.md), and [compatibility policy](docs/compatibility.md). + +## Project status + +OpenNet is pre-1.0 software. ONP/1 framing is stable for the v0.x series, while +higher-level APIs may improve based on real hardware feedback. Do not represent +untested boards or operating systems as supported; add a compatibility report when +you test one. + +## Contributing + +Student contributions are welcome. Good first tasks are labeled in the issue +tracker, and every feature should include tests or a reproducible hardware test +report. Read [CONTRIBUTING.md](CONTRIBUTING.md) and the +[code of conduct](CODE_OF_CONDUCT.md) before opening a pull request. + +## Security + +CRC detects accidental corruption; it is not encryption or authentication. Use TLS +on untrusted networks. See [SECURITY.md](SECURITY.md) for the threat model and +private reporting instructions. + +## License + +OpenNet is available under the [MIT License](LICENSE). diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..5f602d6 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,27 @@ +# Security Policy + +## Supported versions + +Security fixes are provided for the latest released minor version. OpenNet is +currently pre-1.0 and should not be used as the only safety mechanism in critical +systems. + +## Reporting a vulnerability + +Use **Security > Report a vulnerability** in this GitHub repository. Do not open a +public issue containing exploit details, credentials, private addresses, or device +identifiers. Include affected versions, reproduction steps, impact, and a proposed +fix if available. + +The maintainer aims to acknowledge a complete report within seven days and will +coordinate disclosure after a fix is available. + +## Threat model + +- ONP/1 CRC-32 detects transmission errors, not malicious modification. +- Plain TCP provides no confidentiality, peer identity, or replay protection. +- Use TLS with certificate validation across untrusted networks. +- Do not use `setInsecure()` in production. +- Applications must authorize topics and validate payloads. +- The 16 MiB frame limit reduces memory-exhaustion risk; deployments should choose + a smaller application limit appropriate for their hardware. diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..e121881 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,44 @@ +# Architecture + +OpenNet separates the parts that must interoperate from platform-specific I/O. + +```text +Application values and topics + | + OpenNet API + | + ONP/1 frame codec <- language-neutral and tested with vectors + | + TCP or TLS byte stream <- reliable ordered transport + | + Wi-Fi / Ethernet / OS networking +``` + +The Python package provides the reference codec plus asyncio client and server. The +Arduino library uses the ESP32 networking clients and a bounded receive state +machine. Both implementations use the same 24-byte header. + +## Reliability + +TCP already retransmits lost packets and preserves order. ONP/1 acknowledgements +add application-level evidence that a complete frame was parsed and accepted. +Applications needing durable delivery must persist outgoing messages and decide +how to handle duplicates after reconnecting; OpenNet does not pretend RAM queues +survive power loss. + +## Latency + +OpenNet disables Nagle's algorithm where supported, uses a compact header, and +does not require a broker round trip. Actual latency still depends on radio +conditions, access-point load, power-saving mode, operating-system scheduling, and +the application. Low-signal or disconnected areas cannot be made reliable by a +software library alone. + +## Planned extensions + +- Authenticated session handshake and topic authorization helpers. +- Optional service discovery for trusted LANs. +- Persistent outbound queues. +- TypeScript and portable C bindings. + +Extensions will be proposed in issues and specified before implementation. diff --git a/docs/assets/opennet-cover.png b/docs/assets/opennet-cover.png new file mode 100644 index 0000000..24f89ab Binary files /dev/null and b/docs/assets/opennet-cover.png differ diff --git a/docs/compatibility.md b/docs/compatibility.md new file mode 100644 index 0000000..dde9524 --- /dev/null +++ b/docs/compatibility.md @@ -0,0 +1,31 @@ +# Compatibility + +## Supported + +| Platform | Minimum | Status | Verification | +|---|---|---|---| +| Python | CPython 3.9 | Supported | CI on 3.9–3.13 | +| Raspberry Pi OS | Python 3.9+ | Supported | Same pure-Python code; hardware reports invited | +| ESP32 Arduino core | 2.x, 3.x | Supported | CI compile for `esp32dev`; hardware reports invited | +| VS Code | Current + PlatformIO | Supported workflow | PlatformIO configuration | +| Arduino IDE | 2.x | Supported workflow | `library.properties` and examples | + +## Not claimed + +- Original ESP8266, RP2040/Pico W, Arduino UNO WiFi, or non-ESP32 Arduino boards. +- Every historical Raspberry Pi OS image. A board must run a maintained Python + 3.9+ environment and have working TCP/IP networking. +- Guaranteed operation through NAT, captive portals, or without network coverage. +- Real-time deadlines or safety certification. + +## Adding a report + +Open a compatibility issue with: + +- exact device and revision; +- OS, Python, Arduino core, and toolchain versions; +- Wi-Fi or Ethernet adapter; +- client/server example used; +- duration, message count, payload sizes, and observed failures. + +Verified reports may be promoted into the supported table. diff --git a/docs/getting-started.md b/docs/getting-started.md new file mode 100644 index 0000000..eedf3e5 --- /dev/null +++ b/docs/getting-started.md @@ -0,0 +1,72 @@ +# Getting started + +## Network model + +One machine listens as the server; other devices connect as clients. On a home or +lab network, a Raspberry Pi is a convenient always-on server. OpenNet does not +bypass routers, firewalls, captive portals, or internet service outages. + +## Raspberry Pi or backend server + +OpenNet supports Python 3.9 or newer on operating systems supported by Python. +This includes current Raspberry Pi OS releases on Pi boards capable of running a +supported Python version. + +```bash +git clone https://github.com/devkyato/OpenNet.git +cd OpenNet +python -m venv .venv +source .venv/bin/activate +python -m pip install -e "./python" +opennet-server --host 0.0.0.0 --port 8765 +``` + +On Windows, activate with `.venv\Scripts\activate`. + +The example server logs metadata, not full binary payloads. For internet-facing +deployments, configure TLS and an application authorization policy before opening +the firewall. + +## ESP32 with Arduino IDE + +1. Download the release ZIP. +2. In Arduino IDE, choose **Sketch > Include Library > Add .ZIP Library**. +3. Install Espressif's `esp32` board package. +4. Open **File > Examples > OpenNet > TelemetryClient**. +5. Set Wi-Fi name, password, and the server's LAN IP address. +6. Select your exact ESP32 board and upload. + +Call `client.poll()` frequently from `loop()`. OpenNet avoids blocking delays after +the TCP connection is established. + +## PlatformIO and VS Code + +Clone the repository and open the folder in VS Code with PlatformIO installed. +Build the included example: + +```bash +pio run -e esp32dev +``` + +For another project: + +```ini +lib_deps = + https://github.com/devkyato/OpenNet.git#v0.1.0 +``` + +## Sending values + +Topics describe what a value means: + +```cpp +client.sendText("lab/status", "online"); +client.sendJson("lab/telemetry", "{\"temperature\":24.7}"); +client.sendBinary("camera/chunk", bytes, byteCount); +client.sendInt("counter", 42); +client.sendDouble("voltage", 3.3); +client.sendBool("door/open", false); +``` + +Validate incoming JSON against an application schema and bound binary payload sizes +for the receiving device. diff --git a/docs/protocol.md b/docs/protocol.md new file mode 100644 index 0000000..981c4ca --- /dev/null +++ b/docs/protocol.md @@ -0,0 +1,101 @@ +# OpenNet Protocol 1 (ONP/1) + +Status: stable for the OpenNet 0.x series. + +ONP/1 is a framed, topic-based messaging protocol. It is intentionally smaller +than a broker protocol: peers establish a reliable byte stream (normally TCP or +TLS), exchange frames, and decide at the application layer which topics are +allowed. + +## Byte order and limits + +All multibyte integers and floating-point values use network byte order (big +endian). A receiver must reject frames with a payload length greater than +16,777,216 bytes before allocating the payload. Embedded applications should set a +smaller limit. + +## Frame + +The fixed header is 24 bytes: + +| Offset | Size | Field | Meaning | +|---:|---:|---|---| +| 0 | 2 | magic | ASCII `ON` (`0x4f 0x4e`) | +| 2 | 1 | version | `0x01` | +| 3 | 1 | kind | frame kind | +| 4 | 1 | flags | bit field | +| 5 | 1 | value type | application payload type | +| 6 | 2 | topic length | UTF-8 topic byte length | +| 8 | 4 | message ID | unsigned, non-zero for DATA | +| 12 | 4 | payload length | application payload bytes | +| 16 | 4 | CRC-32 | CRC of topic bytes followed by payload bytes | +| 20 | 4 | reserved | must be zero; ignore when receiving | + +The header is followed by `topic length` bytes and then `payload length` bytes. +Topics must be valid UTF-8, between 1 and 1024 bytes for DATA frames, and use `/` +as an optional hierarchy separator. + +## Frame kinds + +| Value | Name | Topic/payload | +|---:|---|---| +| 1 | DATA | typed application message | +| 2 | ACK | empty; message ID identifies accepted DATA | +| 3 | PING | empty | +| 4 | PONG | empty | +| 5 | CLOSE | optional UTF-8 reason | +| 6 | ERROR | UTF-8 diagnostic; must not contain secrets | + +Unknown frame kinds must produce ERROR and close the connection. A peer receiving +PING should promptly return PONG. + +## Flags + +- Bit 0 (`0x01`), `ACK_REQUIRED`: receiver sends ACK after accepting a DATA frame. +- Bit 1 (`0x02`), `DUPLICATE`: retransmission of the same message ID. +- Bits 2–7 are reserved and must be zero when sending. + +ACK confirms that the receiver parsed and accepted the frame, not that application +side effects completed. Message IDs are scoped to one connection. Receivers should +deduplicate repeated IDs for the duration of the connection. + +## Value types + +| Value | Type | Encoding | +|---:|---|---| +| 0 | BYTES | uninterpreted bytes | +| 1 | UTF8 | valid UTF-8 | +| 2 | JSON | UTF-8 JSON value | +| 3 | INT64 | signed 64-bit integer | +| 4 | FLOAT64 | IEEE-754 binary64 | +| 5 | BOOL | exactly one byte: `0x00` or `0x01` | +| 6 | NULL | empty payload | + +Kind-specific control payloads use the encoding described in the frame-kind table, +regardless of the value-type byte. + +## CRC + +CRC-32 is the IEEE polynomial used by zlib. Initialize to zero through the public +zlib-style API and calculate over the exact topic bytes, then continue over the +payload bytes. The expected value for topic `sensor/temp` and payload `24.5` is +defined by the conformance tests. + +CRC protects against accidental corruption only. TLS is required for +confidentiality, peer authentication, and tamper resistance. + +## Connection lifecycle + +1. Connect a reliable byte stream. +2. Either peer may send DATA or PING. +3. Send PING after an application-configured idle interval; require PONG before + the liveness timeout. +4. Send CLOSE when practical before ending a healthy connection. +5. Reconnect with exponential backoff and jitter. Application code decides whether + to replay unacknowledged messages. + +## Compatibility + +Senders must use version 1. A version-1 receiver must ignore non-zero reserved +header bytes for forward compatibility but reject reserved flag bits. New value +types or frame kinds require a protocol revision or an extension specification. diff --git a/docs/release.md b/docs/release.md new file mode 100644 index 0000000..db8a89c --- /dev/null +++ b/docs/release.md @@ -0,0 +1,14 @@ +# Release process + +1. Confirm CI passes on `main`. +2. Update `CHANGELOG.md` and versions in `library.properties`, `library.json`, and + `python/pyproject.toml`. +3. Create and push an annotated `vX.Y.Z` tag. +4. The release workflow verifies that all versions match, runs tests, builds the + Python wheel/source distribution and Arduino ZIP, then creates a GitHub release. +5. A maintainer checks installation from the published artifacts and edits the + generated release notes if needed. + +PyPI publication is intentionally not automatic until the owner configures trusted +publishing. Release assets remain installable and reproducible without a registry +credential. diff --git a/examples/BinaryClient/BinaryClient.ino b/examples/BinaryClient/BinaryClient.ino new file mode 100644 index 0000000..232520d --- /dev/null +++ b/examples/BinaryClient/BinaryClient.ino @@ -0,0 +1,29 @@ +#include +#include + +const char* WIFI_NAME = "YOUR_WIFI_NAME"; +const char* WIFI_PASSWORD = "YOUR_WIFI_PASSWORD"; +const char* OPENNET_HOST = "192.168.1.50"; + +WiFiClient transport; +OpenNetClient client(transport); + +void setup() { + Serial.begin(115200); + WiFi.begin(WIFI_NAME, WIFI_PASSWORD); + while (WiFi.status() != WL_CONNECTED) { + delay(250); + } + if (!client.connect(OPENNET_HOST, 8765)) { + Serial.println("OpenNet connection failed"); + return; + } + + const uint8_t sample[] = {0x00, 0x01, 0x7f, 0x80, 0xfe, 0xff}; + client.sendBinary("device/sample", sample, sizeof(sample)); +} + +void loop() { + client.poll(); + delay(1); +} diff --git a/examples/TelemetryClient/TelemetryClient.ino b/examples/TelemetryClient/TelemetryClient.ino new file mode 100644 index 0000000..203996e --- /dev/null +++ b/examples/TelemetryClient/TelemetryClient.ino @@ -0,0 +1,59 @@ +#include +#include + +const char* WIFI_NAME = "YOUR_WIFI_NAME"; +const char* WIFI_PASSWORD = "YOUR_WIFI_PASSWORD"; +const char* OPENNET_HOST = "192.168.1.50"; +const uint16_t OPENNET_PORT = 8765; + +WiFiClient transport; +OpenNetClient client(transport); + +unsigned long nextSend = 0; +unsigned long nextReconnect = 0; + +void connectNetwork() { + WiFi.mode(WIFI_STA); + WiFi.begin(WIFI_NAME, WIFI_PASSWORD); + Serial.print("Connecting to Wi-Fi"); + while (WiFi.status() != WL_CONNECTED) { + delay(250); + Serial.print("."); + } + Serial.println(); + Serial.println(WiFi.localIP()); +} + +void setup() { + Serial.begin(115200); + connectNetwork(); + client.onMessage([](const OpenNetMessage& message) { + Serial.printf("received id=%lu topic=%s bytes=%u\n", + static_cast(message.messageId), + message.topic.c_str(), + static_cast(message.payload.size())); + }); +} + +void loop() { + client.poll(); + + const unsigned long now = millis(); + if (!client.connected() && now >= nextReconnect) { + Serial.println("Connecting to OpenNet..."); + if (client.connect(OPENNET_HOST, OPENNET_PORT)) { + Serial.println("OpenNet connected"); + } + nextReconnect = now + 2000; + } + + if (client.connected() && now >= nextSend) { + const float temperature = 20.0f + static_cast(esp_random() % 1000) / 100.0f; + const String json = + "{\"device\":\"esp32-example\",\"temperature_c\":" + + String(temperature, 2) + "}"; + const uint32_t id = client.sendJson("lab/telemetry", json); + Serial.printf("sent message %lu\n", static_cast(id)); + nextSend = now + 2000; + } +} diff --git a/keywords.txt b/keywords.txt new file mode 100644 index 0000000..e3bcd8d --- /dev/null +++ b/keywords.txt @@ -0,0 +1,10 @@ +OpenNetClient KEYWORD1 +OpenNetMessage KEYWORD1 +OpenNetValueType KEYWORD1 +connect KEYWORD2 +poll KEYWORD2 +send KEYWORD2 +sendText KEYWORD2 +sendJson KEYWORD2 +sendBinary KEYWORD2 +connected KEYWORD2 diff --git a/library.json b/library.json new file mode 100644 index 0000000..1d7dbb1 --- /dev/null +++ b/library.json @@ -0,0 +1,15 @@ +{ + "name": "OpenNet", + "version": "0.1.0", + "description": "Typed, reliable messaging between ESP32, Raspberry Pi, and backend services.", + "keywords": ["esp32", "raspberry-pi", "iot", "tcp", "messaging"], + "repository": { + "type": "git", + "url": "https://github.com/devkyato/OpenNet.git" + }, + "authors": [{"name": "devkyato", "maintainer": true}], + "license": "MIT", + "frameworks": ["arduino"], + "platforms": ["espressif32"], + "headers": "OpenNet.h" +} diff --git a/library.properties b/library.properties new file mode 100644 index 0000000..ad3910b --- /dev/null +++ b/library.properties @@ -0,0 +1,10 @@ +name=OpenNet +version=0.1.0 +author=devkyato +maintainer=devkyato +sentence=Typed, reliable messaging between ESP32, Raspberry Pi, and backend services. +paragraph=OpenNet sends JSON, text, numeric values, and binary data using the documented ONP/1 framing protocol over TCP or TLS. +category=Communication +url=https://github.com/devkyato/OpenNet +architectures=esp32 +includes=OpenNet.h diff --git a/platformio.ini b/platformio.ini new file mode 100644 index 0000000..8a2cbb6 --- /dev/null +++ b/platformio.ini @@ -0,0 +1,15 @@ +[platformio] +default_envs = esp32dev +src_dir = examples/TelemetryClient + +[env:esp32dev] +platform = espressif32 +board = esp32dev +framework = arduino +lib_extra_dirs = . +build_flags = -Wall -Wextra + +[env:native] +platform = native +test_framework = unity +build_flags = -std=c++17 diff --git a/proxies.txt.txt b/proxies.txt.txt deleted file mode 100644 index bf0243f..0000000 --- a/proxies.txt.txt +++ /dev/null @@ -1,1099 +0,0 @@ -122.6.245.14:8090 -123.184.6.251:8088 -123.236.215.131:6588 -172.163.146.56:6588 -189.37.28.147:6588 -190.53.89.103:6588 -200.104.104.91:6588 -200.126.98.135:6588 -200.252.201.144:80 -201.42.59.201:6588 -202.134.202.226:80 -211.140.151.214:8080 -212.12.114.252:3128 -218.252.37.227:808 -59.95.1.229:6588 -59.95.73.173:6588 -61.174.122.2:8088 -61.54.82.130:808 -66.167.100.59:6649 -68.175.47.31:9090 -69.36.7.253:4004 -70.86.138.210:8131 -71.61.176.210:3128 -78.138.131.150:8080 -79.172.81.79:44933 -81.164.167.69:9090 -82.228.101.113:14841 -82.23.123.51:9090 -83.167.122.41:3128 -85.11.150.211:6588 -85.24.89.199:6588 -98.175.97.7:8080 -125.137.151.104:3128 -193.19.82.24:3128 -195.89.143.211:8090 -200.112.83.72:6588 -200.140.83.108:6588 -200.150.139.211:6588 -201.17.188.5:6588 -201.229.208.2:80 -201.229.208.3:80 -207.38.251.111:9090 -212.17.86.109:8080 -218.83.248.32:8088 -222.88.211.181:8089 -24.1.166.12:9090 -24.10.243.231:7212 -24.10.84.226:9090 -24.116.25.150:9090 -24.118.207.118:9090 -24.125.84.2:9090 -24.126.147.186:9090 -24.127.113.227:9090 -24.137.215.227:9090 -24.139.68.242:9090 -24.14.112.139:9090 -24.15.52.62:9090 -24.185.121.80:9090 -24.189.131.189:9090 -24.189.29.189:9090 -24.217.194.73:9090 -24.222.220.140:9090 -24.230.182.225:9090 -24.3.105.116:9090 -24.4.239.144:9090 -24.98.81.111:9090 -58.253.111.2:8089 -59.94.189.230:6588 -61.153.140.106:808 -64.179.172.134:9090 -64.30.123.252:9090 -65.75.189.33:9090 -67.149.165.201:9090 -67.149.215.109:9090 -67.161.239.245:9090 -67.166.66.244:9090 -67.191.220.137:9090 -67.9.3.63:9090 -68.10.87.155:9090 -68.180.117.215:9090 -68.194.177.78:9090 -68.198.252.19:9090 -68.198.72.147:9090 -68.59.217.62:9090 -68.63.27.57:9090 -68.84.47.147:9090 -69.113.232.218:9090 -69.120.173.183:9090 -69.123.44.118:9090 -69.127.115.255:9090 -69.136.136.125:9090 -69.138.47.42:9090 -69.180.245.32:9090 -69.180.8.201:9090 -69.22.123.154:9090 -69.242.176.42:9090 -69.246.117.136:9090 -69.254.246.123:9090 -71.194.216.20:7212 -71.195.130.227:9090 -71.197.189.88:9090 -71.199.134.177:9090 -71.205.113.223:9090 -71.205.37.198:9090 -71.224.87.71:9090 -71.228.231.241:9090 -71.236.126.53:9090 -71.237.41.13:9090 -71.237.98.13:9090 -72.198.36.75:9090 -75.64.232.63:9090 -75.64.35.123:9090 -75.66.1.212:9090 -75.85.136.141:9090 -76.106.127.211:9090 -76.116.82.97:9090 -76.117.245.191:9090 -76.183.112.143:9090 -76.28.0.206:9090 -76.28.250.36:9090 -76.29.10.61:11055 -81.192.16.53:6588 -82.238.120.88:8080 -82.32.122.223:9090 -82.34.88.122:9090 -82.35.73.55:9090 -86.4.25.128:9090 -86.6.122.30:9090 -87.116.164.85:6588 -87.120.67.39:6588 -88.165.169.130:9090 -88.171.218.44:9090 -88.172.20.212:11033 -96.21.139.56:9090 -96.28.160.240:9090 -98.163.204.145:9090 -98.181.63.127:9090 -98.192.95.181:9090 -98.216.3.48:9090 -98.223.204.15:9090 -98.240.186.255:9090 -99.199.237.158:9090 -125.128.25.151:11011 -157.182.52.224:9090 -160.7.251.98:9090 -189.111.166.103:6588 -189.18.103.16:6588 -200.150.47.167:6588 -200.223.197.176:6588 -201.40.178.226:6588 -202.105.31.68:443 -204.111.219.182:9090 -208.53.199.48:9090 -208.53.199.75:9090 -211.90.22.106:8088 -216.51.236.24:9090 -218.206.194.247:8800 -218.75.23.110:3128 -218.75.76.74:8088 -218.97.194.94:80 -219.121.151.29:80 -24.125.217.129:9090 -24.13.108.167:9090 -24.164.253.14:9090 -24.175.116.55:9090 -24.186.57.77:9090 -24.188.121.167:9090 -24.19.124.152:9090 -24.190.104.34:9090 -24.190.65.208:9090 -24.20.45.101:9090 -24.201.119.10:9090 -24.209.62.190:9090 -24.211.49.0:9090 -24.23.182.99:9090 -24.231.250.241:7212 -24.254.113.238:9090 -24.255.219.247:9090 -24.5.195.141:9090 -24.8.191.246:9090 -58.20.51.207:808 -59.92.40.60:6588 -61.234.254.69:8088 -63.245.152.3:9090 -64.136.197.96:9090 -64.179.170.189:9090 -66.165.197.37:9090 -66.175.164.59:9090 -66.177.219.202:9090 -66.214.17.189:9090 -66.229.205.251:9090 -66.38.121.88:9090 -66.91.101.52:9090 -67.162.165.118:9090 -67.165.189.3:9090 -67.188.156.177:9090 -67.48.22.73:9090 -67.84.148.144:9090 -67.84.35.131:9090 -67.87.24.194:9090 -68.104.55.221:9090 -68.11.237.184:9090 -68.199.153.104:9090 -68.201.24.46:9090 -68.225.96.18:9090 -68.8.224.217:9090 -68.81.191.233:9090 -68.83.91.141:9090 -69.116.42.119:9090 -69.122.222.90:9090 -69.124.132.102:9090 -69.127.175.231:9090 -69.136.70.21:9090 -69.161.78.160:9090 -69.246.61.14:9090 -69.249.151.19:9090 -69.47.165.83:9090 -69.47.174.178:9090 -70.177.47.119:9090 -70.177.53.179:9090 -70.180.206.70:9090 -70.180.62.153:9090 -70.186.174.186:9090 -70.225.83.202:9090 -70.64.225.85:9090 -71.192.234.31:9090 -71.204.73.210:9090 -71.205.238.140:9090 -71.205.238.236:9090 -71.207.56.148:9090 -71.224.107.188:9090 -71.226.155.186:9090 -71.64.8.82:9090 -72.134.20.99:9090 -72.178.207.48:9090 -72.188.68.197:9090 -72.227.236.241:9090 -72.227.36.24:9090 -74.15.86.86:9090 -75.108.122.150:9090 -75.181.46.254:9090 -75.64.211.234:9090 -75.87.189.110:9090 -76.102.161.57:9090 -76.102.95.54:9090 -76.110.211.162:9090 -76.114.240.59:9090 -76.115.37.7:9090 -76.182.53.239:9090 -76.26.216.31:9090 -76.89.23.238:9090 -80.193.168.196:9090 -80.25.151.126:15353 -80.4.60.88:9090 -82.2.234.184:9090 -82.38.36.40:9090 -82.4.69.11:9090 -89.241.213.95:9090 -92.9.76.236:9090 -96.18.67.70:9090 -96.3.152.82:9090 -96.3.172.29:9090 -97.85.152.126:9090 -97.91.188.113:9090 -98.121.129.95:9090 -98.193.185.111:9090 -98.194.172.64:9090 -98.247.53.216:9090 -99.139.195.186:9090 -99.228.98.56:9090 -99.242.140.117:9090 -123.127.110.243:80 -189.19.233.32:6588 -194.117.157.72:27076 -201.17.130.27:6588 -201.228.130.234:6588 -201.251.34.65:6588 -204.112.136.25:9090 -210.52.15.210:808 -211.167.248.228:8080 -216.164.170.134:9090 -218.194.80.220:808 -220.174.209.178:808 -24.10.186.31:9090 -24.118.147.89:9090 -24.151.126.249:9090 -24.175.139.203:9090 -24.2.4.204:9090 -24.22.86.147:9090 -24.77.22.225:9090 -24.90.177.18:9090 -64.111.32.54:9090 -64.83.209.110:9090 -65.30.92.48:9090 -66.57.1.142:9090 -66.99.71.247:9090 -67.9.255.2:9090 -67.9.28.224:9090 -68.105.0.173:9090 -68.144.70.254:9090 -68.62.176.8:9090 -69.151.73.128:9090 -69.246.218.125:9090 -69.246.38.184:9090 -70.127.205.107:9090 -70.186.168.130:9090 -70.26.57.239:9090 -71.178.155.167:9090 -71.192.233.196:9090 -71.200.233.55:9090 -71.89.55.232:9090 -72.190.121.79:9090 -72.24.212.232:9090 -72.24.213.96:9090 -72.9.72.199:9090 -74.131.139.186:9090 -74.192.92.162:9090 -74.79.165.21:9090 -75.83.57.219:9090 -76.170.85.232:9090 -76.68.66.51:9090 -76.84.237.206:9090 -77.97.26.167:9090 -82.33.117.189:9090 -82.35.165.44:9090 -82.40.28.187:9090 -82.44.239.26:9090 -82.6.69.14:9090 -86.42.180.157:9090 -98.206.20.88:9090 -98.210.111.207:9090 -99.253.188.98:9090 -142.59.52.201:9090 -173.32.150.159:9090 -189.79.63.28:6588 -200.161.6.214:6588 -200.58.206.145:6588 -201.21.210.84:6588 -201.254.56.35:6588 -207.181.207.36:9090 -217.119.20.82:3128 -24.166.174.56:7212 -24.170.82.144:9090 -24.2.69.26:9090 -24.208.37.143:9090 -24.30.90.20:9090 -60.28.209.8:80 -65.185.5.17:9090 -66.186.237.190:9090 -66.57.75.68:9090 -66.67.106.227:9090 -68.104.19.90:9090 -68.113.102.37:9090 -68.117.211.122:9090 -68.118.245.35:9090 -68.207.186.253:9090 -69.245.52.76:9090 -70.74.213.38:9090 -72.141.35.81:9090 -72.141.53.106:9090 -72.174.161.51:9090 -75.18.209.8:7212 -75.183.7.150:9090 -76.107.137.6:9090 -80.192.65.203:9090 -82.6.184.38:9090 -83.220.195.232:9090 -92.233.226.34:9090 -92.234.58.166:9090 -97.87.101.89:9090 -98.161.24.110:9090 -99.199.229.95:9090 -99.21.134.25:9090 -201.3.53.102:6588 -204.85.72.128:9090 -216.117.225.240:9090 -24.168.35.163:9090 -24.179.152.2:9090 -59.56.174.199:808 -65.28.107.26:9090 -65.28.8.13:9090 -67.177.158.94:9090 -67.68.46.169:9090 -68.188.91.22:9090 -68.4.217.7:9090 -70.82.140.29:9090 -71.90.230.116:9090 -72.136.252.159:9090 -76.27.54.31:9090 -77.99.183.136:9090 -78.43.175.129:9090 -80.6.34.116:9090 -86.2.160.241:9090 -88.109.194.140:9090 -201.86.70.162:80 -208.53.196.161:9090 -66.253.168.169:9090 -66.26.117.254:9090 -67.61.171.202:9090 -70.125.110.220:9090 -71.200.118.56:9090 -75.152.23.65:9090 -76.107.94.147:9090 -81.106.225.136:9090 -92.238.40.83:9090 -200.220.222.215:6588 -201.26.133.204:8080 -209.159.241.112:9090 -24.78.155.155:9090 -66.57.230.14:9090 -70.76.83.81:9090 -75.68.212.175:9090 -24.11.124.76:9090 -66.253.232.57:9090 -91.188.161.235:3128 -86.12.223.22:9090 -116.7.255.88:8080 -124.107.149.63:3128 -20.132.16.22:80 -201.245.152.230:80 -202.129.181.242:8080 -202.83.174.14:8080 -202.99.29.27:80 -207.248.228.166:80 -212.95.178.130:80 -219.102.226.100:8080 -62.69.178.8:80 -70.86.138.210:8125 -114.30.47.10:80 -117.102.54.110:8080 -12.130.107.115:80 -130.225.245.156:8080 -131.247.155.3:9090 -142.33.100.41:8080 -165.166.201.241:8080 -190.144.112.42:8080 -192.115.90.150:8080 -210.51.22.185:80 -212.119.85.164:80 -212.191.130.227:8080 -212.24.237.49:8080 -213.130.111.206:8080 -219.198.208.140:8080 -219.53.216.132:8080 -221.249.144.93:8088 -222.188.91.85:8080 -24.72.74.152:9090 -38.103.164.174:9090 -58.22.101.251:80 -61.19.222.7:80 -62.99.163.242:8080 -66.171.189.82:9090 -66.187.205.130:80 -71.227.225.115:8000 -75.148.238.58:8080 -75.68.4.225:9090 -76.110.43.10:7212 -82.76.17.46:8080 -83.141.17.20:80 -85.105.144.28:8088 -87.245.155.179:80 -89.189.84.1:80 -89.189.84.118:80 -89.189.85.20:80 -89.189.85.67:80 -91.203.132.59:3128 -119.147.23.118:8080 -211.100.4.71:80 -218.4.65.118:8080 -219.106.246.6:80 -219.117.209.83:80 -219.133.180.67:80 -219.142.121.37:80 -220.157.98.136:80 -221.11.27.110:8080 -24.126.135.71:9090 -58.42.242.10:8089 -60.213.25.170:3128 -62.150.81.35:80 -78.109.149.162:3128 -120.28.64.69:8080 -160.97.36.70:8000 -193.173.119.83:8080 -195.209.224.91:3128 -200.163.66.116:80 -203.67.172.25:8080 -207.157.9.179:9090 -212.14.233.47:3128 -213.97.52.28:8080 -219.239.90.83:80 -221.91.128.132:8080 -67.162.88.110:9090 -67.85.66.187:9090 -82.233.131.115:7212 -121.73.79.171:9090 -189.19.231.136:8000 -194.176.176.82:8080 -202.106.121.134:80 -206.64.92.16:8000 -208.77.219.76:80 -63.162.106.70:80 -64.184.84.246:80 -66.253.186.239:9090 -70.181.122.211:9090 -75.148.238.57:8080 -75.185.79.63:8080 -80.126.22.246:80 -195.248.239.142:80 -200.243.51.252:80 -203.94.229.227:8080 -210.86.181.202:80 -24.189.79.154:8000 -62.150.126.228:80 -200.104.250.92:80 -66.146.139.235:8000 -85.252.212.138:9090 -118.98.232.202:8080 -151.11.232.92:80 -203.70.96.9:80 -216.195.37.250:82 -74.54.156.73:80 -195.226.16.132:80 -200.55.208.203:80 -59.61.88.193:80 -211.138.198.6:10917 -213.132.44.241:80 -217.172.187.98:80 -220.224.224.66:3128 -92.64.178.98:8080 -62.149.95.43:8080 -208.81.7.18:80 -91.121.84.92:80 -125.89.73.159:80 -116.90.129.134:80 -159.148.82.3:3128 -189.51.255.19:8080 -192.203.138.63:80 -193.45.143.12:80 -193.45.143.41:80 -194.44.221.137:3128 -200.165.90.114:8080 -200.171.58.137:3128 -200.211.233.132:3128 -200.213.117.2:8080 -200.49.141.56:8080 -201.133.171.213:8080 -201.14.116.3:3128 -202.134.202.144:80 -202.40.182.82:8080 -202.71.103.109:3128 -206.227.34.10:3128 -208.45.143.105:80 -208.67.195.32:80 -208.74.50.197:3128 -209.124.34.27:80 -209.20.82.228:80 -209.235.218.83:80 -209.237.236.110:80 -209.34.241.44:80 -216.14.98.225:80 -216.173.2.205:8080 -217.12.51.38:8080 -218.107.24.3:3128 -222.92.51.221:808 -59.93.43.186:3128 -60.217.80.36:8080 -60.6.205.20:808 -63.146.109.190:80 -65.199.160.6:80 -65.243.68.184:80 -65.57.240.117:80 -65.57.240.121:80 -65.57.240.174:80 -65.57.240.176:80 -65.57.240.177:80 -65.57.240.234:80 -66.77.122.132:80 -66.77.122.135:80 -66.94.172.129:8080 -67.15.104.28:80 -77.42.159.251:80 -79.188.118.70:8080 -80.51.221.94:3128 -80.81.52.225:8080 -81.177.3.10:3128 -83.170.109.8:8080 -87.204.246.122:8080 -87.204.246.192:8080 -87.204.246.68:8080 -87.204.246.78:8080 -88.191.44.184:8080 -89.208.43.141:80 -91.121.61.20:80 -119.70.40.101:8080 -121.14.211.71:80 -121.15.164.9:3128 -121.207.252.139:80 -121.80.179.213:80 -159.148.82.4:3128 -189.108.155.120:3128 -189.21.136.84:3128 -190.24.8.82:3128 -190.65.211.154:8080 -190.65.224.10:80 -195.248.240.146:8080 -195.251.38.71:8888 -200.207.9.168:3128 -200.223.102.82:80 -200.223.19.101:3128 -200.29.106.240:3128 -200.43.141.173:8080 -201.24.240.130:3128 -201.65.121.102:3128 -202.107.231.157:8080 -202.181.211.237:80 -202.29.22.12:3128 -202.29.22.3:3128 -202.29.22.4:3128 -203.153.214.146:80 -203.160.1.103:80 -203.160.1.112:80 -203.160.1.121:80 -203.160.1.130:554 -203.160.1.162:554 -203.160.1.66:80 -203.77.192.92:80 -203.89.30.142:80 -208.45.143.104:80 -210.238.184.150:3128 -212.109.155.130:8080 -212.117.162.228:3128 -212.93.193.72:443 -212.93.193.74:443 -212.93.193.78:443 -212.93.193.82:443 -212.93.193.83:443 -213.207.35.43:80 -218.17.246.197:3128 -218.75.100.114:8080 -219.101.35.83:3128 -219.132.142.10:8080 -219.134.242.204:3128 -219.159.77.144:3128 -220.227.31.179:8080 -220.227.47.6:8080 -220.227.47.7:8080 -221.215.143.18:808 -221.224.53.83:3128 -221.230.139.83:3128 -222.124.5.92:8080 -222.223.127.130:808 -222.73.227.233:80 -222.73.86.75:80 -58.242.42.91:3128 -58.254.201.237:3128 -60.2.93.11:80 -66.104.77.20:3128 -66.197.252.182:80 -66.198.41.11:3128 -67.19.148.234:3128 -67.228.42.208:80 -67.69.254.243:80 -67.69.254.246:80 -67.69.254.248:80 -67.69.254.252:80 -67.69.254.254:80 -67.69.254.255:80 -72.249.149.144:80 -75.126.176.161:80 -77.240.82.6:80 -80.91.112.202:8080 -82.192.83.7:3128 -85.214.59.79:8080 -87.224.243.113:3128 -87.237.228.18:8080 -89.144.96.79:808 -89.210.114.220:8080 -89.31.143.2:80 -95.82.194.72:3128 -117.102.15.218:8080 -117.102.60.205:8080 -117.102.93.131:3128 -118.144.187.156:80 -119.10.182.20:80 -119.146.73.114:8080 -12.191.44.111:80 -12.228.215.52:9090 -12.228.215.61:9090 -12.228.215.62:9090 -121.22.29.180:80 -121.22.29.181:80 -121.22.29.182:80 -121.22.29.183:80 -122.224.97.85:3128 -123.49.42.81:8080 -124.106.77.200:3128 -124.164.247.43:3128 -124.30.18.245:3128 -124.81.45.233:80 -125.242.128.78:8080 -125.93.187.234:3128 -187.4.61.2:3128 -189.21.246.135:3128 -190.136.215.70:3128 -196.212.252.194:8080 -200.174.31.29:3128 -200.254.12.2:8080 -200.254.221.146:3128 -201.26.37.196:3128 -201.75.105.179:8080 -203.160.1.75:80 -203.160.1.85:80 -203.160.1.94:80 -203.162.183.222:80 -203.162.7.82:8080 -203.172.138.187:80 -203.177.152.162:3128 -203.179.87.186:3128 -211.95.176.6:808 -218.0.1.30:8080 -218.14.227.197:3128 -219.136.252.120:3128 -219.239.90.85:3128 -219.239.90.96:3128 -221.203.154.26:8080 -222.122.19.72:80 -222.122.19.76:80 -222.124.9.122:80 -222.135.178.62:8080 -222.208.242.30:3128 -222.239.222.56:8080 -41.242.150.184:3128 -41.244.100.108:3128 -58.222.254.13:3128 -58.61.143.137:3128 -59.18.13.75:80 -60.188.118.172:8088 -60.28.252.206:3128 -61.139.73.6:8080 -61.144.24.106:3128 -64.66.192.62:80 -67.69.254.245:80 -69.90.107.184:80 -78.138.4.34:80 -89.211.49.84:3128 -89.31.143.3:80 -118.98.172.34:3128 -196.29.205.45:80 -201.26.74.58:3128 -201.6.125.234:8080 -201.9.131.84:80 -203.151.233.143:80 -212.116.208.242:3128 -220.128.169.153:3128 -222.215.152.139:8080 -59.120.47.230:3128 -60.249.176.80:3128 -60.49.177.228:3128 -67.69.254.240:80 -67.69.254.244:80 -67.69.254.250:80 -77.71.0.246:3128 -82.206.129.160:3128 -89.207.240.235:3128 -12.228.215.55:9090 -122.224.222.134:3128 -125.22.252.134:80 -196.202.28.38:80 -200.199.22.92:3128 -200.201.187.250:3128 -200.41.60.135:3128 -201.251.159.7:3128 -201.48.46.3:3128 -60.12.190.109:8088 -60.251.140.10:3128 -67.69.254.253:80 -86.105.181.238:3128 -87.120.60.3:8080 -89.31.143.1:80 -120.50.176.58:8080 -122.224.171.91:3128 -187.0.92.17:3128 -189.108.124.155:3128 -189.85.162.190:3128 -190.18.106.88:80 -194.44.170.81:3128 -195.234.182.13:3128 -200.242.232.8:8080 -219.88.253.41:80 -222.187.221.91:80 -87.120.58.65:8080 -89.28.20.132:3128 -170.210.18.245:8080 -187.5.67.55:80 -196.29.205.49:80 -201.82.32.56:8080 -203.73.180.16:3128 -59.120.77.147:3128 -60.190.151.77:8088 -118.98.169.66:80 -164.100.42.68:8080 -189.114.225.170:80 -189.74.11.18:8080 -201.17.130.41:3128 -210.51.14.197:80 -213.169.51.151:8080 -213.185.116.152:8080 -213.215.7.108:3128 -58.213.152.15:3128 -60.30.83.220:8080 -60.50.80.111:3128 -61.19.213.91:80 -67.69.254.247:80 -67.69.254.251:80 -81.85.253.74:80 -89.42.17.147:3128 -91.121.14.70:3128 -219.93.178.162:3128 -68.100.4.165:3128 -85.114.131.54:8081 -87.120.57.8:8080 -187.2.151.207:3128 -201.75.54.245:3128 -202.101.8.184:80 -220.194.221.29:3128 -222.124.172.220:8080 -123.255.250.5:3128 -217.10.246.2:8080 -217.133.80.15:3128 -61.105.175.113:88 -61.247.7.8:3128 -221.2.145.28:3128 -200.243.243.150:3128 -212.0.143.5:3128 -123.127.110.244:80 -200.109.72.53:6588 -201.15.184.136:6588 -201.44.24.98:80 -201.53.164.111:3128 -201.53.73.44:6588 -24.23.29.41:8080 -58.34.239.226:8088 -59.95.161.172:6588 -59.99.129.193:6588 -66.199.247.42:6649 -72.55.191.6:3128 -86.122.132.27:8080 -92.48.127.2:8080 -121.204.86.172:8088 -121.206.106.110:8090 -195.135.236.204:3128 -200.118.158.45:6588 -201.13.183.108:6588 -201.43.215.59:6588 -201.88.248.243:6588 -202.134.202.251:80 -203.86.31.92:3128 -216.183.95.239:80 -24.125.134.220:9090 -24.125.71.100:9090 -24.156.135.87:9090 -24.188.125.225:9090 -24.239.46.244:9090 -24.61.52.46:9090 -65.182.107.212:9090 -65.30.216.140:9090 -67.191.141.209:9090 -67.84.241.17:9090 -67.86.138.122:9090 -68.11.182.166:9090 -68.11.249.230:9090 -68.42.122.142:9090 -68.43.246.42:9090 -68.45.42.160:9090 -68.52.135.28:9090 -68.55.132.134:9090 -68.55.225.102:9090 -69.119.243.192:9090 -69.127.102.247:9090 -69.136.140.1:9090 -69.136.58.38:9090 -69.251.251.153:9090 -69.253.188.82:9090 -71.57.210.123:9090 -72.196.135.11:9090 -74.131.165.152:9090 -74.141.111.159:9090 -76.107.38.217:9090 -76.107.44.181:9090 -76.127.22.84:9090 -76.22.0.234:9090 -76.30.187.68:9090 -80.143.226.158:8080 -82.41.96.73:9090 -82.43.58.68:9090 -82.5.189.202:7212 -98.141.23.139:9090 -98.192.124.144:9090 -98.27.180.252:9090 -99.163.104.229:9090 -99.233.94.169:9090 -12.208.190.71:9090 -12.51.72.38:9090 -123.6.23.77:8090 -142.59.90.148:9090 -200.3.252.170:3128 -201.75.9.88:6588 -210.245.63.218:80 -216.80.118.13:9090 -220.72.71.220:8080 -222.179.57.225:808 -24.12.214.237:9090 -24.127.136.205:9090 -24.161.131.67:9090 -24.189.5.235:9090 -24.192.240.240:9090 -24.228.49.186:9090 -24.59.34.24:9090 -58.33.52.65:8088 -61.191.53.137:808 -61.238.104.200:808 -65.29.85.76:9090 -66.167.228.62:9090 -67.163.161.226:9090 -67.217.17.182:9090 -67.84.196.173:9090 -67.87.64.23:9090 -68.11.145.150:9090 -68.194.169.106:7212 -68.49.227.31:9090 -69.142.108.83:9090 -69.142.114.143:9090 -69.181.224.191:9090 -69.71.85.202:9090 -70.172.242.76:9090 -70.95.110.195:9090 -71.101.126.174:9090 -71.12.195.20:9090 -71.82.77.13:9090 -71.86.150.78:9090 -72.174.123.174:9090 -74.211.5.196:9090 -75.34.25.118:7212 -75.87.150.14:9090 -76.104.70.144:9090 -76.105.105.96:9090 -76.123.18.157:9090 -76.176.208.180:9090 -76.228.201.224:9090 -76.9.38.239:9090 -76.98.163.114:9090 -77.99.40.240:9090 -80.195.186.31:9090 -81.105.32.186:9090 -86.17.234.113:9090 -87.232.56.70:9090 -92.236.137.151:9090 -98.169.171.231:9090 -98.181.60.131:9090 -98.202.107.151:9090 -98.208.46.176:9090 -98.210.147.111:9090 -99.155.153.203:9090 -12.167.39.75:9090 -12.240.37.195:9090 -156.34.176.45:9090 -171.66.223.221:9090 -174.0.50.242:9090 -200.89.39.210:6588 -207.50.148.37:9090 -210.34.14.166:81 -218.6.15.70:8080 -221.11.11.202:30781 -24.125.158.161:9090 -59.37.166.56:3128 -64.17.66.234:9090 -65.25.158.119:7212 -65.31.98.112:9090 -66.184.78.194:9090 -68.11.226.141:9090 -68.118.147.60:9090 -69.246.123.26:9090 -70.101.148.107:9090 -70.176.119.94:9090 -70.179.177.238:9090 -71.14.93.226:9090 -71.14.94.189:9090 -71.229.16.100:9090 -71.86.181.6:9090 -71.89.7.99:28848 -72.128.40.214:9090 -72.203.130.111:9090 -74.222.221.250:9090 -74.77.117.65:9090 -76.22.128.2:9090 -77.96.218.150:9090 -82.22.138.43:9090 -82.36.17.30:9090 -99.254.218.6:9090 -202.98.141.200:808 -203.129.53.177:9090 -222.242.221.156:8080 -24.230.163.136:9090 -65.190.207.153:9090 -66.211.127.78:9090 -68.111.231.178:9090 -69.250.8.55:9090 -69.46.16.232:34311 -70.24.40.159:9090 -71.17.227.187:9090 -71.71.69.209:9090 -74.60.191.215:9090 -75.81.22.134:9090 -75.94.87.64:9090 -76.102.189.58:9090 -76.107.111.45:9090 -81.103.66.124:9090 -82.39.25.203:9090 -82.41.10.6:9090 -92.233.176.50:9090 -99.252.209.25:9090 -202.149.113.148:8080 -206.174.3.131:9090 -207.192.207.240:9090 -209.124.242.193:9090 -209.145.114.173:9090 -24.108.35.246:7212 -24.174.246.62:9090 -24.197.130.9:9090 -24.70.39.70:9090 -65.50.35.144:9090 -65.50.35.157:9090 -68.13.235.68:9090 -71.205.51.156:9090 -74.197.219.75:9090 -75.119.93.168:9090 -76.107.42.95:9090 -76.107.93.40:9090 -81.100.109.24:9090 -82.29.59.18:9090 -82.33.114.102:9090 -82.38.0.167:9090 -92.233.166.55:9090 -194.117.157.72:14624 -208.59.142.26:9090 -68.183.54.233:7212 -81.106.241.109:9090 -82.33.168.194:9090 -86.61.76.7:8088 -99.225.136.21:9090 -24.68.44.46:7212 -68.98.0.233:9090 -24.14.107.77:9090 -208.149.67.192:9090 -72.222.172.142:9090 -76.9.42.163:9090 -77.103.136.52:9090 -99.253.240.184:9090 -193.69.186.83:80 -70.111.46.122:7212 -81.98.109.201:9090 -71.109.3.95:7212 -82.46.144.165:9090 -163.29.250.4:3128 -190.67.73.194:8080 -193.2.8.8:80 -212.193.5.226:80 -217.12.254.154:80 -220.15.224.200:8080 -221.25.244.167:8080 -67.84.8.11:7212 -80.88.242.32:9090 -84.113.7.221:8080 -124.35.158.72:80 -203.82.52.210:8080 -219.191.64.95:8080 -220.22.196.3:80 -24.44.64.205:8000 -67.208.3.220:8080 -68.196.110.175:8000 -68.43.151.18:9090 -72.191.26.100:7212 -72.214.91.6:8008 -74.210.245.5:8008 -84.23.101.224:8080 -91.203.68.222:80 -92.239.116.56:9090 -98.165.169.136:8000 -124.29.140.84:80 -124.5.165.8:8080 -195.34.253.66:3128 -200.253.135.236:80 -212.11.179.159:80 -212.57.189.242:80 -213.166.145.170:80 -216.102.26.104:8000 -41.240.178.30:80 -76.31.231.123:8000 -220.53.245.8:8080 \ No newline at end of file diff --git a/python/README.md b/python/README.md new file mode 100644 index 0000000..fa19f1f --- /dev/null +++ b/python/README.md @@ -0,0 +1,23 @@ +# OpenNet for Python + +The reference ONP/1 client and server for Raspberry Pi and backend systems. + +```bash +python -m pip install opennet-protocol +opennet-server --host 0.0.0.0 --port 8765 +``` + +```python +import asyncio +from opennet import OpenNetClient + +async def main(): + async with OpenNetClient("127.0.0.1") as client: + await client.send("demo/message", {"hello": "OpenNet"}) + +asyncio.run(main()) +``` + +The complete documentation, ESP32 Arduino library, examples, protocol +specification, and security guidance are in the +[OpenNet repository](https://github.com/devkyato/OpenNet). diff --git a/python/examples/echo_server.py b/python/examples/echo_server.py new file mode 100644 index 0000000..6f28459 --- /dev/null +++ b/python/examples/echo_server.py @@ -0,0 +1,25 @@ +"""Reply to each OpenNet DATA frame.""" + +import asyncio + +from opennet import Frame, OpenNetServer, Peer + + +async def echo(peer: Peer, frame: Frame) -> None: + print(peer.address, frame.topic, len(frame.payload)) + await peer.send_frame( + Frame( + frame.kind, + frame.value_type, + f"echo/{frame.topic}", + frame.payload, + frame.message_id, + ) + ) + + +async def main() -> None: + await OpenNetServer(echo, host="0.0.0.0").serve_forever() + + +asyncio.run(main()) diff --git a/python/examples/sensor_client.py b/python/examples/sensor_client.py new file mode 100644 index 0000000..91ff79f --- /dev/null +++ b/python/examples/sensor_client.py @@ -0,0 +1,28 @@ +"""Send sample telemetry to an OpenNet server.""" + +import argparse +import asyncio +import random + +from opennet import OpenNetClient + + +async def run(host: str, port: int) -> None: + async with OpenNetClient(host, port) as client: + while True: + await client.send( + "lab/telemetry", + { + "temperature_c": round(random.uniform(22, 28), 2), + "source": "python-example", + }, + ) + await asyncio.sleep(2) + + +if __name__ == "__main__": + parser = argparse.ArgumentParser() + parser.add_argument("host") + parser.add_argument("--port", type=int, default=8765) + args = parser.parse_args() + asyncio.run(run(args.host, args.port)) diff --git a/python/pyproject.toml b/python/pyproject.toml new file mode 100644 index 0000000..ed60ecd --- /dev/null +++ b/python/pyproject.toml @@ -0,0 +1,57 @@ +[build-system] +requires = ["hatchling>=1.25"] +build-backend = "hatchling.build" + +[project] +name = "opennet-protocol" +version = "0.1.0" +description = "Typed ONP/1 messaging for Raspberry Pi and backend systems" +readme = "README.md" +requires-python = ">=3.9" +license = "MIT" +authors = [{name = "devkyato"}] +keywords = ["iot", "esp32", "raspberry-pi", "messaging", "tcp"] +classifiers = [ + "Development Status :: 3 - Alpha", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3 :: Only", + "Topic :: System :: Networking" +] +dependencies = [] + +[project.optional-dependencies] +dev = [ + "mypy>=1.10", + "pytest>=8", + "pytest-asyncio>=0.23", + "ruff>=0.5" +] + +[project.scripts] +opennet-server = "opennet.cli:main" + +[project.urls] +Homepage = "https://github.com/devkyato/OpenNet" +Documentation = "https://github.com/devkyato/OpenNet/tree/main/docs" +Issues = "https://github.com/devkyato/OpenNet/issues" + +[tool.hatch.build.targets.wheel] +packages = ["src/opennet"] + +[tool.pytest.ini_options] +asyncio_mode = "auto" +testpaths = ["tests"] + +[tool.ruff] +target-version = "py39" +line-length = 100 + +[tool.ruff.lint] +select = ["E", "F", "I", "B", "SIM"] + +[tool.mypy] +python_version = "3.9" +strict = true +packages = ["opennet"] +mypy_path = "src" diff --git a/python/src/opennet/__init__.py b/python/src/opennet/__init__.py new file mode 100644 index 0000000..e87c99d --- /dev/null +++ b/python/src/opennet/__init__.py @@ -0,0 +1,28 @@ +"""OpenNet Protocol 1 reference implementation.""" + +from .client import OpenNetClient +from .protocol import ( + Flags, + Frame, + FrameKind, + ProtocolError, + ValueType, + decode_value, + encode_value, +) +from .server import OpenNetServer, Peer + +__all__ = [ + "Flags", + "Frame", + "FrameKind", + "OpenNetClient", + "OpenNetServer", + "Peer", + "ProtocolError", + "ValueType", + "decode_value", + "encode_value", +] + +__version__ = "0.1.0" diff --git a/python/src/opennet/cli.py b/python/src/opennet/cli.py new file mode 100644 index 0000000..cc6eeab --- /dev/null +++ b/python/src/opennet/cli.py @@ -0,0 +1,50 @@ +"""Small observable OpenNet development server.""" + +from __future__ import annotations + +import argparse +import asyncio +import contextlib +import logging + +from .protocol import Frame, decode_value +from .server import OpenNetServer, Peer + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Run an ONP/1 development server") + parser.add_argument("--host", default="127.0.0.1") + parser.add_argument("--port", type=int, default=8765) + parser.add_argument("--max-payload", type=int, default=1024 * 1024) + return parser.parse_args() + + +async def run(args: argparse.Namespace) -> None: + async def log_message(peer: Peer, frame: Frame) -> None: + value = decode_value(frame.value_type, frame.payload) + summary = ( + f"<{len(value)} bytes>" if isinstance(value, bytes) else repr(value) + ) + logging.info( + "%s id=%d topic=%s value=%s", + peer.address, + frame.message_id, + frame.topic, + summary, + ) + + server = OpenNetServer( + log_message, host=args.host, port=args.port, max_payload=args.max_payload + ) + logging.info("listening on %s:%d", args.host, args.port) + await server.serve_forever() + + +def main() -> None: + logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") + with contextlib.suppress(KeyboardInterrupt): + asyncio.run(run(parse_args())) + + +if __name__ == "__main__": + main() diff --git a/python/src/opennet/client.py b/python/src/opennet/client.py new file mode 100644 index 0000000..d65d5bb --- /dev/null +++ b/python/src/opennet/client.py @@ -0,0 +1,168 @@ +"""Async OpenNet client.""" + +from __future__ import annotations + +import asyncio +import contextlib +import ssl +from collections import deque +from types import TracebackType +from typing import Any, AsyncIterator, Deque, Optional, Type + +from .protocol import ( + DEFAULT_MAX_PAYLOAD, + Flags, + Frame, + FrameKind, + ProtocolError, + encode_value, +) +from .stream import read_frame, write_frame + + +class OpenNetClient: + """A single ONP/1 connection with typed send/receive operations.""" + + def __init__( + self, + host: str, + port: int = 8765, + *, + ssl_context: Optional[ssl.SSLContext] = None, + max_payload: int = DEFAULT_MAX_PAYLOAD, + ack_timeout: float = 5.0, + ) -> None: + self.host = host + self.port = port + self.ssl_context = ssl_context + self.max_payload = max_payload + self.ack_timeout = ack_timeout + self._reader: Optional[asyncio.StreamReader] = None + self._writer: Optional[asyncio.StreamWriter] = None + self._next_id = 1 + self._pending: dict[int, asyncio.Future[None]] = {} + self._messages: asyncio.Queue[Frame] = asyncio.Queue() + self._backlog: Deque[Frame] = deque() + self._reader_task: Optional[asyncio.Task[None]] = None + + @property + def connected(self) -> bool: + return self._writer is not None and not self._writer.is_closing() + + async def connect(self) -> None: + if self.connected: + return + self._reader, self._writer = await asyncio.open_connection( + self.host, self.port, ssl=self.ssl_context + ) + sock = self._writer.get_extra_info("socket") + if sock is not None: + sock.setsockopt(6, 1, 1) # IPPROTO_TCP, TCP_NODELAY + self._reader_task = asyncio.create_task(self._read_loop(), name="opennet-reader") + + async def close(self) -> None: + writer = self._writer + if writer is None: + return + with contextlib.suppress(Exception): + await write_frame(writer, Frame(FrameKind.CLOSE), self.max_payload) + writer.close() + with contextlib.suppress(Exception): + await writer.wait_closed() + if self._reader_task is not None: + self._reader_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await self._reader_task + self._reader = None + self._writer = None + self._fail_pending(ConnectionError("OpenNet connection closed")) + + async def send( + self, + topic: str, + value: Any, + *, + require_ack: bool = True, + ) -> int: + writer = self._require_writer() + value_type, payload = encode_value(value) + message_id = self._next_message_id() + flags = Flags.ACK_REQUIRED if require_ack else Flags.NONE + frame = Frame(FrameKind.DATA, value_type, topic, payload, message_id, flags) + future: Optional[asyncio.Future[None]] = None + if require_ack: + future = asyncio.get_running_loop().create_future() + self._pending[message_id] = future + try: + await write_frame(writer, frame, self.max_payload) + if future is not None: + await asyncio.wait_for(future, self.ack_timeout) + finally: + self._pending.pop(message_id, None) + return message_id + + async def receive(self) -> Frame: + if self._backlog: + return self._backlog.popleft() + return await self._messages.get() + + async def messages(self) -> AsyncIterator[Frame]: + while self.connected: + yield await self.receive() + + async def ping(self, timeout: float = 5.0) -> None: + writer = self._require_writer() + await write_frame(writer, Frame(FrameKind.PING), self.max_payload) + while True: + frame = await asyncio.wait_for(self._messages.get(), timeout) + if frame.kind is FrameKind.PONG: + return + self._backlog.append(frame) + + async def __aenter__(self) -> "OpenNetClient": + await self.connect() + return self + + async def __aexit__( + self, + exc_type: Optional[Type[BaseException]], + exc: Optional[BaseException], + traceback: Optional[TracebackType], + ) -> None: + await self.close() + + def _require_writer(self) -> asyncio.StreamWriter: + if not self.connected or self._writer is None: + raise ConnectionError("OpenNet client is not connected") + return self._writer + + def _next_message_id(self) -> int: + message_id = self._next_id + self._next_id = 1 if message_id == 0xFFFFFFFF else message_id + 1 + return message_id + + async def _read_loop(self) -> None: + assert self._reader is not None and self._writer is not None + try: + while True: + frame = await read_frame(self._reader, self.max_payload) + if frame.kind is FrameKind.ACK: + future = self._pending.get(frame.message_id) + if future is not None and not future.done(): + future.set_result(None) + elif frame.kind is FrameKind.PING: + await write_frame(self._writer, Frame(FrameKind.PONG), self.max_payload) + elif frame.kind is FrameKind.CLOSE: + return + else: + await self._messages.put(frame) + except (asyncio.IncompleteReadError, ConnectionError, ProtocolError) as exc: + self._fail_pending(exc) + finally: + if self._writer is not None: + self._writer.close() + + def _fail_pending(self, exc: BaseException) -> None: + for future in self._pending.values(): + if not future.done(): + future.set_exception(exc) diff --git a/python/src/opennet/protocol.py b/python/src/opennet/protocol.py new file mode 100644 index 0000000..b5fd0d0 --- /dev/null +++ b/python/src/opennet/protocol.py @@ -0,0 +1,256 @@ +"""ONP/1 framing and typed-value codec.""" + +from __future__ import annotations + +import json +import math +import struct +import zlib +from dataclasses import dataclass +from enum import IntEnum, IntFlag +from typing import Any, Final, Tuple + +MAGIC: Final = b"ON" +VERSION: Final = 1 +HEADER: Final = struct.Struct("!2sBBBBHIIII") +HEADER_SIZE: Final = HEADER.size +MAX_TOPIC_SIZE: Final = 1024 +DEFAULT_MAX_PAYLOAD: Final = 16 * 1024 * 1024 + + +class ProtocolError(ValueError): + """Raised when bytes violate ONP/1.""" + + +class FrameKind(IntEnum): + DATA = 1 + ACK = 2 + PING = 3 + PONG = 4 + CLOSE = 5 + ERROR = 6 + + +class Flags(IntFlag): + NONE = 0 + ACK_REQUIRED = 1 + DUPLICATE = 2 + + +class ValueType(IntEnum): + BYTES = 0 + UTF8 = 1 + JSON = 2 + INT64 = 3 + FLOAT64 = 4 + BOOL = 5 + NULL = 6 + + +@dataclass(frozen=True) +class Frame: + kind: FrameKind + value_type: ValueType = ValueType.NULL + topic: str = "" + payload: bytes = b"" + message_id: int = 0 + flags: Flags = Flags.NONE + + def __post_init__(self) -> None: + topic_bytes = self.topic.encode("utf-8") + if len(topic_bytes) > MAX_TOPIC_SIZE: + raise ProtocolError("topic is longer than 1024 UTF-8 bytes") + if self.kind is FrameKind.DATA: + if not topic_bytes: + raise ProtocolError("DATA frames require a topic") + if not 1 <= self.message_id <= 0xFFFFFFFF: + raise ProtocolError("DATA message_id must be between 1 and 2^32-1") + if not 0 <= self.message_id <= 0xFFFFFFFF: + raise ProtocolError("message_id is outside uint32 range") + if int(self.flags) & ~int(Flags.ACK_REQUIRED | Flags.DUPLICATE): + raise ProtocolError("reserved flag bits are set") + + def to_bytes(self, max_payload: int = DEFAULT_MAX_PAYLOAD) -> bytes: + topic = self.topic.encode("utf-8") + if len(self.payload) > max_payload: + raise ProtocolError(f"payload exceeds configured {max_payload}-byte limit") + checksum = zlib.crc32(topic) + checksum = zlib.crc32(self.payload, checksum) & 0xFFFFFFFF + return b"".join( + ( + HEADER.pack( + MAGIC, + VERSION, + int(self.kind), + int(self.flags), + int(self.value_type), + len(topic), + self.message_id, + len(self.payload), + checksum, + 0, + ), + topic, + self.payload, + ) + ) + + @classmethod + def from_parts( + cls, + header: bytes, + body: bytes, + max_payload: int = DEFAULT_MAX_PAYLOAD, + ) -> "Frame": + if len(header) != HEADER_SIZE: + raise ProtocolError(f"header must be exactly {HEADER_SIZE} bytes") + ( + magic, + version, + raw_kind, + raw_flags, + raw_type, + topic_length, + message_id, + payload_length, + expected_crc, + _reserved, + ) = HEADER.unpack(header) + if magic != MAGIC: + raise ProtocolError("invalid frame magic") + if version != VERSION: + raise ProtocolError(f"unsupported protocol version {version}") + if raw_flags & ~int(Flags.ACK_REQUIRED | Flags.DUPLICATE): + raise ProtocolError("reserved flag bits are set") + if topic_length > MAX_TOPIC_SIZE: + raise ProtocolError("topic exceeds protocol limit") + if payload_length > max_payload: + raise ProtocolError("payload exceeds configured limit") + if len(body) != topic_length + payload_length: + raise ProtocolError("body length does not match header") + topic_bytes = body[:topic_length] + payload = body[topic_length:] + checksum = zlib.crc32(topic_bytes) + checksum = zlib.crc32(payload, checksum) & 0xFFFFFFFF + if checksum != expected_crc: + raise ProtocolError("CRC-32 mismatch") + try: + topic = topic_bytes.decode("utf-8") + kind = FrameKind(raw_kind) + value_type = ValueType(raw_type) + except (UnicodeDecodeError, ValueError) as exc: + raise ProtocolError(str(exc)) from exc + return cls(kind, value_type, topic, payload, message_id, Flags(raw_flags)) + + +def inspect_header(header: bytes, max_payload: int = DEFAULT_MAX_PAYLOAD) -> Tuple[int, int]: + """Validate allocation-sensitive header fields and return body lengths.""" + if len(header) != HEADER_SIZE: + raise ProtocolError(f"header must be exactly {HEADER_SIZE} bytes") + ( + magic, + version, + _kind, + flags, + _type, + topic_length, + _message_id, + payload_length, + _crc, + _reserved, + ) = HEADER.unpack(header) + if magic != MAGIC or version != VERSION: + raise ProtocolError("invalid magic or unsupported version") + if flags & ~int(Flags.ACK_REQUIRED | Flags.DUPLICATE): + raise ProtocolError("reserved flag bits are set") + if topic_length > MAX_TOPIC_SIZE or payload_length > max_payload: + raise ProtocolError("declared frame size exceeds configured limit") + return topic_length, payload_length + + +def encode_value(value: Any, value_type: ValueType | None = None) -> tuple[ValueType, bytes]: + """Encode a Python value, inferring the ONP type when omitted.""" + if value_type is None: + if value is None: + value_type = ValueType.NULL + elif isinstance(value, bool): + value_type = ValueType.BOOL + elif isinstance(value, bytes): + value_type = ValueType.BYTES + elif isinstance(value, str): + value_type = ValueType.UTF8 + elif isinstance(value, int): + value_type = ValueType.INT64 + elif isinstance(value, float): + value_type = ValueType.FLOAT64 + else: + value_type = ValueType.JSON + + if value_type is ValueType.NULL: + if value is not None: + raise TypeError("NULL requires None") + return value_type, b"" + if value_type is ValueType.BOOL: + if not isinstance(value, bool): + raise TypeError("BOOL requires bool") + return value_type, bytes((int(value),)) + if value_type is ValueType.BYTES: + if not isinstance(value, bytes): + raise TypeError("BYTES requires bytes") + return value_type, value + if value_type is ValueType.UTF8: + if not isinstance(value, str): + raise TypeError("UTF8 requires str") + return value_type, value.encode("utf-8") + if value_type is ValueType.JSON: + return value_type, json.dumps( + value, ensure_ascii=False, separators=(",", ":"), allow_nan=False + ).encode("utf-8") + if value_type is ValueType.INT64: + if isinstance(value, bool) or not isinstance(value, int): + raise TypeError("INT64 requires int") + try: + return value_type, struct.pack("!q", value) + except struct.error as exc: + raise OverflowError("integer is outside int64 range") from exc + if value_type is ValueType.FLOAT64: + if not isinstance(value, (int, float)) or isinstance(value, bool): + raise TypeError("FLOAT64 requires a number") + number = float(value) + if not math.isfinite(number): + raise ValueError("FLOAT64 must be finite") + return value_type, struct.pack("!d", number) + raise TypeError(f"unsupported value type {value_type!r}") + + +def decode_value(value_type: ValueType, payload: bytes) -> Any: + """Decode and strictly validate an ONP typed payload.""" + try: + if value_type is ValueType.NULL: + if payload: + raise ProtocolError("NULL payload must be empty") + return None + if value_type is ValueType.BOOL: + if payload not in (b"\x00", b"\x01"): + raise ProtocolError("BOOL payload must be one 0 or 1 byte") + return payload == b"\x01" + if value_type is ValueType.BYTES: + return payload + if value_type is ValueType.UTF8: + return payload.decode("utf-8") + if value_type is ValueType.JSON: + return json.loads(payload.decode("utf-8")) + if value_type is ValueType.INT64: + if len(payload) != 8: + raise ProtocolError("INT64 payload must be 8 bytes") + return struct.unpack("!q", payload)[0] + if value_type is ValueType.FLOAT64: + if len(payload) != 8: + raise ProtocolError("FLOAT64 payload must be 8 bytes") + value = struct.unpack("!d", payload)[0] + if not math.isfinite(value): + raise ProtocolError("FLOAT64 must be finite") + return value + except (UnicodeDecodeError, json.JSONDecodeError) as exc: + raise ProtocolError(str(exc)) from exc + raise ProtocolError(f"unsupported value type {value_type!r}") diff --git a/python/src/opennet/server.py b/python/src/opennet/server.py new file mode 100644 index 0000000..cbf61c0 --- /dev/null +++ b/python/src/opennet/server.py @@ -0,0 +1,149 @@ +"""Async OpenNet server.""" + +from __future__ import annotations + +import asyncio +import contextlib +import inspect +import ssl +from dataclasses import dataclass +from typing import Awaitable, Callable, Optional, Union + +from .protocol import ( + DEFAULT_MAX_PAYLOAD, + Flags, + Frame, + FrameKind, + ProtocolError, + ValueType, +) +from .stream import read_frame, write_frame + +Handler = Callable[["Peer", Frame], Union[None, Awaitable[None]]] + + +@dataclass +class Peer: + """A connected peer passed to server handlers.""" + + reader: asyncio.StreamReader + writer: asyncio.StreamWriter + max_payload: int + + @property + def address(self) -> object: + return self.writer.get_extra_info("peername") + + async def send_frame(self, frame: Frame) -> None: + await write_frame(self.writer, frame, self.max_payload) + + async def send( + self, topic: str, payload: bytes, message_id: int, value_type: ValueType + ) -> None: + await self.send_frame( + Frame(FrameKind.DATA, value_type, topic, payload, message_id) + ) + + async def close(self) -> None: + if not self.writer.is_closing(): + with contextlib.suppress(Exception): + await self.send_frame(Frame(FrameKind.CLOSE)) + self.writer.close() + with contextlib.suppress(Exception): + await self.writer.wait_closed() + + +class OpenNetServer: + """ONP/1 TCP/TLS server that acknowledges accepted DATA frames.""" + + def __init__( + self, + handler: Handler, + *, + host: str = "127.0.0.1", + port: int = 8765, + ssl_context: Optional[ssl.SSLContext] = None, + max_payload: int = DEFAULT_MAX_PAYLOAD, + ) -> None: + self.handler = handler + self.host = host + self.port = port + self.ssl_context = ssl_context + self.max_payload = max_payload + self._server: Optional[asyncio.AbstractServer] = None + self._peers: set[asyncio.StreamWriter] = set() + + @property + def sockets(self) -> list[object]: + sockets = getattr(self._server, "sockets", None) + return list(sockets or ()) + + async def start(self) -> None: + if self._server is not None: + return + self._server = await asyncio.start_server( + self._accept, self.host, self.port, ssl=self.ssl_context + ) + + async def serve_forever(self) -> None: + await self.start() + assert self._server is not None + async with self._server: + await self._server.serve_forever() + + async def close(self) -> None: + if self._server is not None: + self._server.close() + await self._server.wait_closed() + self._server = None + writers = list(self._peers) + for writer in writers: + writer.close() + await asyncio.gather( + *(writer.wait_closed() for writer in writers), return_exceptions=True + ) + + async def __aenter__(self) -> "OpenNetServer": + await self.start() + return self + + async def __aexit__(self, *_args: object) -> None: + await self.close() + + async def _accept( + self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter + ) -> None: + self._peers.add(writer) + peer = Peer(reader, writer, self.max_payload) + seen: set[int] = set() + try: + while True: + frame = await read_frame(reader, self.max_payload) + if frame.kind is FrameKind.CLOSE: + break + if frame.kind is FrameKind.PING: + await peer.send_frame(Frame(FrameKind.PONG)) + continue + if frame.kind is not FrameKind.DATA: + continue + duplicate = frame.message_id in seen + seen.add(frame.message_id) + if not duplicate: + result = self.handler(peer, frame) + if inspect.isawaitable(result): + await result + if frame.flags & Flags.ACK_REQUIRED: + await peer.send_frame( + Frame( + FrameKind.ACK, + message_id=frame.message_id, + flags=Flags.DUPLICATE if duplicate else Flags.NONE, + ) + ) + except (asyncio.IncompleteReadError, ConnectionError, ProtocolError): + pass + finally: + self._peers.discard(writer) + writer.close() + with contextlib.suppress(Exception): + await writer.wait_closed() diff --git a/python/src/opennet/stream.py b/python/src/opennet/stream.py new file mode 100644 index 0000000..31261b8 --- /dev/null +++ b/python/src/opennet/stream.py @@ -0,0 +1,22 @@ +"""Async stream helpers shared by client and server.""" + +from __future__ import annotations + +import asyncio + +from .protocol import DEFAULT_MAX_PAYLOAD, HEADER_SIZE, Frame, inspect_header + + +async def read_frame( + reader: asyncio.StreamReader, + max_payload: int = DEFAULT_MAX_PAYLOAD, +) -> Frame: + header = await reader.readexactly(HEADER_SIZE) + topic_length, payload_length = inspect_header(header, max_payload) + body = await reader.readexactly(topic_length + payload_length) + return Frame.from_parts(header, body, max_payload) + + +async def write_frame(writer: asyncio.StreamWriter, frame: Frame, max_payload: int) -> None: + writer.write(frame.to_bytes(max_payload)) + await writer.drain() diff --git a/python/tests/test_integration.py b/python/tests/test_integration.py new file mode 100644 index 0000000..6fe98eb --- /dev/null +++ b/python/tests/test_integration.py @@ -0,0 +1,61 @@ +import asyncio + +from opennet import Frame, FrameKind, OpenNetClient, OpenNetServer, decode_value + + +async def test_client_server_ack_and_values(): + received: asyncio.Queue[tuple[str, object]] = asyncio.Queue() + + async def handler(_peer, frame: Frame): + await received.put((frame.topic, decode_value(frame.value_type, frame.payload))) + + server = OpenNetServer(handler, host="127.0.0.1", port=0) + await server.start() + port = server.sockets[0].getsockname()[1] # type: ignore[union-attr] + try: + async with OpenNetClient("127.0.0.1", port) as client: + message_id = await client.send("lab/temperature", {"celsius": 24.5}) + assert message_id == 1 + assert await asyncio.wait_for(received.get(), 1) == ( + "lab/temperature", + {"celsius": 24.5}, + ) + await client.ping(timeout=1) + finally: + await server.close() + + +async def test_duplicate_data_is_delivered_once_and_acknowledged_twice(): + calls = 0 + ack_count = 0 + + async def handler(_peer, _frame): + nonlocal calls + calls += 1 + + server = OpenNetServer(handler, host="127.0.0.1", port=0) + await server.start() + port = server.sockets[0].getsockname()[1] # type: ignore[union-attr] + reader, writer = await asyncio.open_connection("127.0.0.1", port) + frame = Frame( + FrameKind.DATA, + topic="event", + payload=b"", + message_id=7, + flags=1, + ) + try: + writer.write(frame.to_bytes() + frame.to_bytes()) + await writer.drain() + from opennet.stream import read_frame + + for _ in range(2): + ack = await asyncio.wait_for(read_frame(reader), 1) + assert ack.kind is FrameKind.ACK + ack_count += 1 + assert calls == 1 + assert ack_count == 2 + finally: + writer.close() + await writer.wait_closed() + await server.close() diff --git a/python/tests/test_protocol.py b/python/tests/test_protocol.py new file mode 100644 index 0000000..5d20bb8 --- /dev/null +++ b/python/tests/test_protocol.py @@ -0,0 +1,97 @@ +import struct + +import pytest + +from opennet.protocol import ( + HEADER, + Flags, + Frame, + FrameKind, + ProtocolError, + ValueType, + decode_value, + encode_value, + inspect_header, +) + + +@pytest.mark.parametrize( + ("value", "expected_type"), + [ + (None, ValueType.NULL), + (True, ValueType.BOOL), + (b"\x00\xff", ValueType.BYTES), + ("hello", ValueType.UTF8), + (42, ValueType.INT64), + (3.5, ValueType.FLOAT64), + ({"ok": True}, ValueType.JSON), + ], +) +def test_typed_value_round_trip(value, expected_type): + value_type, payload = encode_value(value) + assert value_type is expected_type + assert decode_value(value_type, payload) == value + + +def test_frame_round_trip_and_known_vector(): + value_type, payload = encode_value({"celsius": 24.5}, ValueType.JSON) + frame = Frame( + FrameKind.DATA, + value_type, + "sensor/temp", + payload, + 0x01020304, + Flags.ACK_REQUIRED, + ) + encoded = frame.to_bytes() + # Fixed vector consumed by implementations in other languages. + assert encoded.hex() == ( + "4f4e01010102000b010203040000001049a1dc1c00000000" + "73656e736f722f74656d707b2263656c73697573223a32342e357d" + ) + topic_length, payload_length = inspect_header(encoded[: HEADER.size]) + decoded = Frame.from_parts( + encoded[: HEADER.size], + encoded[HEADER.size : HEADER.size + topic_length + payload_length], + ) + assert decoded == frame + + +def test_bad_crc_is_rejected(): + encoded = bytearray( + Frame(FrameKind.DATA, ValueType.UTF8, "topic", b"value", 1).to_bytes() + ) + encoded[-1] ^= 0x01 + with pytest.raises(ProtocolError, match="CRC"): + Frame.from_parts(bytes(encoded[: HEADER.size]), bytes(encoded[HEADER.size :])) + + +def test_oversize_is_rejected_before_body_allocation(): + header = HEADER.pack( + b"ON", + 1, + FrameKind.DATA, + 0, + ValueType.BYTES, + 1, + 1, + 4097, + 0, + 0, + ) + with pytest.raises(ProtocolError, match="size"): + inspect_header(header, max_payload=4096) + + +@pytest.mark.parametrize( + ("value_type", "payload"), + [ + (ValueType.BOOL, b"\x02"), + (ValueType.INT64, b"\x00"), + (ValueType.FLOAT64, struct.pack("!d", float("nan"))), + (ValueType.NULL, b"x"), + ], +) +def test_invalid_typed_payloads(value_type, payload): + with pytest.raises(ProtocolError): + decode_value(value_type, payload) diff --git a/scripts/build_release.py b/scripts/build_release.py new file mode 100644 index 0000000..052f7d4 --- /dev/null +++ b/scripts/build_release.py @@ -0,0 +1,78 @@ +"""Build and validate the Arduino release ZIP with only installable files.""" + +from __future__ import annotations + +import argparse +import json +import re +import tempfile +import zipfile +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +TEXT_FILES = [ + "README.md", + "LICENSE", + "CHANGELOG.md", + "library.properties", + "library.json", + "keywords.txt", +] +DIRECTORIES = ["src", "examples", "docs"] + + +def versions() -> dict[str, str]: + properties = (ROOT / "library.properties").read_text(encoding="utf-8") + arduino = re.search(r"^version=(.+)$", properties, re.MULTILINE) + metadata = json.loads((ROOT / "library.json").read_text(encoding="utf-8")) + pyproject = (ROOT / "python" / "pyproject.toml").read_text(encoding="utf-8") + python = re.search(r'^version = "([^"]+)"$', pyproject, re.MULTILINE) + init = (ROOT / "python" / "src" / "opennet" / "__init__.py").read_text( + encoding="utf-8" + ) + package = re.search(r'^__version__ = "([^"]+)"$', init, re.MULTILINE) + if not arduino or not python or not package: + raise SystemExit("Could not read every project version") + return { + "arduino": arduino.group(1), + "platformio": str(metadata["version"]), + "python": python.group(1), + "package": package.group(1), + } + + +def build(expected: str | None) -> Path: + project_versions = versions() + unique = set(project_versions.values()) + if len(unique) != 1: + raise SystemExit(f"Version mismatch: {project_versions}") + version = unique.pop() + if expected is not None and expected != version: + raise SystemExit(f"Tag version {expected} does not match project {version}") + + output = ROOT / "dist" / f"OpenNet-{version}.zip" + output.parent.mkdir(exist_ok=True) + with tempfile.TemporaryDirectory() as temporary: + staging = Path(temporary) / "OpenNet" + staging.mkdir() + for relative in TEXT_FILES: + target = staging / relative + target.write_bytes((ROOT / relative).read_bytes()) + for directory in DIRECTORIES: + for source in (ROOT / directory).rglob("*"): + if source.is_file(): + target = staging / source.relative_to(ROOT) + target.parent.mkdir(parents=True, exist_ok=True) + target.write_bytes(source.read_bytes()) + with zipfile.ZipFile(output, "w", zipfile.ZIP_DEFLATED) as archive: + for source in sorted(staging.rglob("*")): + if source.is_file(): + archive.write(source, source.relative_to(staging.parent)) + return output + + +if __name__ == "__main__": + parser = argparse.ArgumentParser() + parser.add_argument("--expected-version") + result = build(parser.parse_args().expected_version) + print(result) diff --git a/src/OpenNet.cpp b/src/OpenNet.cpp new file mode 100644 index 0000000..7c00ad6 --- /dev/null +++ b/src/OpenNet.cpp @@ -0,0 +1,349 @@ +#include "OpenNet.h" + +#include +#include + +String OpenNetMessage::text() const { + if (valueType != OpenNetValueType::Utf8 && + valueType != OpenNetValueType::Json) { + return String(); + } + String result; + result.reserve(payload.size()); + for (uint8_t byte : payload) { + result += static_cast(byte); + } + return result; +} + +OpenNetClient::OpenNetClient(Client& transport, uint32_t maxPayload) + : transport_(transport), + maxPayload_(maxPayload), + nextMessageId_(1), + lastAcknowledged_(0), + lastError_(OpenNetError::None), + headerReceived_(0), + bodyReceived_(0), + expectedTopicLength_(0), + expectedPayloadLength_(0) {} + +bool OpenNetClient::connect(const char* host, uint16_t port) { + if (host == nullptr || host[0] == '\0' || port == 0) { + lastError_ = OpenNetError::InvalidArgument; + return false; + } + resetReceiver(); + if (!transport_.connect(host, port)) { + lastError_ = OpenNetError::NotConnected; + return false; + } + lastError_ = OpenNetError::None; + return true; +} + +void OpenNetClient::disconnect(const char* reason) { + if (transport_.connected()) { + const uint8_t* bytes = reinterpret_cast(reason); + const size_t length = reason == nullptr ? 0 : strlen(reason); + sendFrame(OpenNetFrameKind::Close, OpenNetValueType::Utf8, "", bytes, + length, 0, 0); + } + transport_.stop(); + resetReceiver(); +} + +bool OpenNetClient::connected() const { return transport_.connected(); } + +void OpenNetClient::poll() { + if (!transport_.connected()) { + return; + } + + while (transport_.available() > 0) { + if (headerReceived_ < kHeaderSize) { + const int byte = transport_.read(); + if (byte < 0) { + return; + } + header_[headerReceived_++] = static_cast(byte); + if (headerReceived_ == kHeaderSize && !prepareBody()) { + transport_.stop(); + resetReceiver(); + return; + } + } else { + const size_t total = + static_cast(expectedTopicLength_) + expectedPayloadLength_; + while (bodyReceived_ < total && transport_.available() > 0) { + const int byte = transport_.read(); + if (byte < 0) { + return; + } + body_[bodyReceived_++] = static_cast(byte); + } + if (bodyReceived_ == total) { + handleFrame(); + resetReceiver(); + } + } + } +} + +void OpenNetClient::onMessage(MessageHandler handler) { + handler_ = std::move(handler); +} + +OpenNetError OpenNetClient::lastError() const { return lastError_; } + +uint32_t OpenNetClient::lastAcknowledgedMessage() const { + return lastAcknowledged_; +} + +uint32_t OpenNetClient::send(const char* topic, OpenNetValueType type, + const uint8_t* payload, size_t length, + bool requireAck) { + if (!transport_.connected()) { + lastError_ = OpenNetError::NotConnected; + return 0; + } + if (topic == nullptr || topic[0] == '\0' || + (payload == nullptr && length != 0)) { + lastError_ = OpenNetError::InvalidArgument; + return 0; + } + const uint32_t id = nextMessageId_; + nextMessageId_ = id == UINT32_MAX ? 1 : id + 1; + if (!sendFrame(OpenNetFrameKind::Data, type, topic, payload, length, id, + requireAck ? kAckRequired : 0)) { + return 0; + } + return id; +} + +uint32_t OpenNetClient::sendText(const char* topic, const String& value, + bool requireAck) { + return send(topic, OpenNetValueType::Utf8, + reinterpret_cast(value.c_str()), value.length(), + requireAck); +} + +uint32_t OpenNetClient::sendJson(const char* topic, const String& json, + bool requireAck) { + return send(topic, OpenNetValueType::Json, + reinterpret_cast(json.c_str()), json.length(), + requireAck); +} + +uint32_t OpenNetClient::sendBinary(const char* topic, const uint8_t* value, + size_t length, bool requireAck) { + return send(topic, OpenNetValueType::Bytes, value, length, requireAck); +} + +uint32_t OpenNetClient::sendInt(const char* topic, int64_t value, + bool requireAck) { + uint8_t bytes[8]; + const uint64_t bits = static_cast(value); + for (size_t i = 0; i < sizeof(bytes); ++i) { + bytes[i] = static_cast(bits >> (56 - 8 * i)); + } + return send(topic, OpenNetValueType::Int64, bytes, sizeof(bytes), requireAck); +} + +uint32_t OpenNetClient::sendDouble(const char* topic, double value, + bool requireAck) { + static_assert(sizeof(double) == sizeof(uint64_t), + "OpenNet requires an IEEE-754 64-bit double"); + uint64_t bits; + memcpy(&bits, &value, sizeof(bits)); + uint8_t bytes[8]; + for (size_t i = 0; i < sizeof(bytes); ++i) { + bytes[i] = static_cast(bits >> (56 - 8 * i)); + } + return send(topic, OpenNetValueType::Float64, bytes, sizeof(bytes), + requireAck); +} + +uint32_t OpenNetClient::sendBool(const char* topic, bool value, + bool requireAck) { + const uint8_t byte = value ? 1 : 0; + return send(topic, OpenNetValueType::Bool, &byte, 1, requireAck); +} + +uint32_t OpenNetClient::sendNull(const char* topic, bool requireAck) { + return send(topic, OpenNetValueType::Null, nullptr, 0, requireAck); +} + +bool OpenNetClient::ping() { + return sendFrame(OpenNetFrameKind::Ping, OpenNetValueType::Null, "", nullptr, + 0, 0, 0); +} + +bool OpenNetClient::sendFrame(OpenNetFrameKind kind, OpenNetValueType type, + const char* topic, const uint8_t* payload, + size_t length, uint32_t messageId, + uint8_t flags) { + if (!transport_.connected()) { + lastError_ = OpenNetError::NotConnected; + return false; + } + const char* safeTopic = topic == nullptr ? "" : topic; + const size_t topicLength = strlen(safeTopic); + if (topicLength > kMaxTopicLength || topicLength > UINT16_MAX) { + lastError_ = OpenNetError::TopicTooLong; + return false; + } + if (length > maxPayload_ || length > UINT32_MAX) { + lastError_ = OpenNetError::PayloadTooLarge; + return false; + } + + uint8_t header[kHeaderSize] = {}; + header[0] = 'O'; + header[1] = 'N'; + header[2] = 1; + header[3] = static_cast(kind); + header[4] = flags; + header[5] = static_cast(type); + writeU16(header + 6, static_cast(topicLength)); + writeU32(header + 8, messageId); + writeU32(header + 12, static_cast(length)); + uint32_t checksum = + crc32(reinterpret_cast(safeTopic), topicLength); + checksum = crc32(payload, length, checksum); + writeU32(header + 16, checksum); + + const size_t expected = kHeaderSize + topicLength + length; + size_t written = transport_.write(header, kHeaderSize); + written += transport_.write(reinterpret_cast(safeTopic), + topicLength); + if (length > 0) { + written += transport_.write(payload, length); + } + if (written != expected) { + lastError_ = OpenNetError::TransportWrite; + return false; + } + lastError_ = OpenNetError::None; + return true; +} + +void OpenNetClient::resetReceiver() { + headerReceived_ = 0; + body_.clear(); + bodyReceived_ = 0; + expectedTopicLength_ = 0; + expectedPayloadLength_ = 0; +} + +bool OpenNetClient::prepareBody() { + if (header_[0] != 'O' || header_[1] != 'N' || header_[2] != 1 || + header_[3] < static_cast(OpenNetFrameKind::Data) || + header_[3] > static_cast(OpenNetFrameKind::Error) || + header_[5] > static_cast(OpenNetValueType::Null) || + (header_[4] & ~kAllowedFlags) != 0) { + lastError_ = OpenNetError::InvalidFrame; + return false; + } + expectedTopicLength_ = readU16(header_ + 6); + expectedPayloadLength_ = readU32(header_ + 12); + if (header_[3] == static_cast(OpenNetFrameKind::Data) && + (expectedTopicLength_ == 0 || readU32(header_ + 8) == 0)) { + lastError_ = OpenNetError::InvalidFrame; + return false; + } + if (expectedTopicLength_ > kMaxTopicLength || + expectedPayloadLength_ > maxPayload_) { + lastError_ = OpenNetError::PayloadTooLarge; + return false; + } + body_.resize(static_cast(expectedTopicLength_) + + expectedPayloadLength_); + if (body_.empty()) { + handleFrame(); + resetReceiver(); + } + return true; +} + +void OpenNetClient::handleFrame() { + const uint32_t expectedChecksum = readU32(header_ + 16); + const uint8_t* topicBytes = body_.empty() ? nullptr : body_.data(); + const uint8_t* payload = + body_.empty() ? nullptr : body_.data() + expectedTopicLength_; + uint32_t actualChecksum = crc32(topicBytes, expectedTopicLength_); + actualChecksum = crc32(payload, expectedPayloadLength_, actualChecksum); + if (actualChecksum != expectedChecksum) { + lastError_ = OpenNetError::ChecksumMismatch; + transport_.stop(); + return; + } + + const auto kind = static_cast(header_[3]); + const uint32_t messageId = readU32(header_ + 8); + if (kind == OpenNetFrameKind::Ack) { + lastAcknowledged_ = messageId; + return; + } + if (kind == OpenNetFrameKind::Ping) { + sendFrame(OpenNetFrameKind::Pong, OpenNetValueType::Null, "", nullptr, 0, + 0, 0); + return; + } + if (kind != OpenNetFrameKind::Data) { + return; + } + + OpenNetMessage message; + message.kind = kind; + message.valueType = static_cast(header_[5]); + message.messageId = messageId; + message.flags = header_[4]; + message.topic.reserve(expectedTopicLength_); + for (size_t i = 0; i < expectedTopicLength_; ++i) { + message.topic += static_cast(body_[i]); + } + message.payload.assign(payload, payload + expectedPayloadLength_); + if (handler_) { + handler_(message); + } + if ((message.flags & kAckRequired) != 0) { + sendFrame(OpenNetFrameKind::Ack, OpenNetValueType::Null, "", nullptr, 0, + message.messageId, 0); + } +} + +uint16_t OpenNetClient::readU16(const uint8_t* bytes) { + return static_cast((static_cast(bytes[0]) << 8) | + bytes[1]); +} + +uint32_t OpenNetClient::readU32(const uint8_t* bytes) { + return (static_cast(bytes[0]) << 24) | + (static_cast(bytes[1]) << 16) | + (static_cast(bytes[2]) << 8) | bytes[3]; +} + +void OpenNetClient::writeU16(uint8_t* bytes, uint16_t value) { + bytes[0] = static_cast(value >> 8); + bytes[1] = static_cast(value); +} + +void OpenNetClient::writeU32(uint8_t* bytes, uint32_t value) { + bytes[0] = static_cast(value >> 24); + bytes[1] = static_cast(value >> 16); + bytes[2] = static_cast(value >> 8); + bytes[3] = static_cast(value); +} + +uint32_t OpenNetClient::crc32(const uint8_t* data, size_t length, + uint32_t previous) { + uint32_t crc = previous ^ 0xFFFFFFFFu; + for (size_t i = 0; i < length; ++i) { + crc ^= data[i]; + for (uint8_t bit = 0; bit < 8; ++bit) { + const uint32_t mask = -(crc & 1u); + crc = (crc >> 1) ^ (0xEDB88320u & mask); + } + } + return crc ^ 0xFFFFFFFFu; +} diff --git a/src/OpenNet.h b/src/OpenNet.h new file mode 100644 index 0000000..2b2c92a --- /dev/null +++ b/src/OpenNet.h @@ -0,0 +1,117 @@ +#pragma once + +#include +#include + +#include +#include + +enum class OpenNetFrameKind : uint8_t { + Data = 1, + Ack = 2, + Ping = 3, + Pong = 4, + Close = 5, + Error = 6, +}; + +enum class OpenNetValueType : uint8_t { + Bytes = 0, + Utf8 = 1, + Json = 2, + Int64 = 3, + Float64 = 4, + Bool = 5, + Null = 6, +}; + +struct OpenNetMessage { + OpenNetFrameKind kind; + OpenNetValueType valueType; + String topic; + std::vector payload; + uint32_t messageId; + uint8_t flags; + + String text() const; +}; + +enum class OpenNetError : uint8_t { + None, + NotConnected, + InvalidArgument, + TopicTooLong, + PayloadTooLarge, + TransportWrite, + InvalidFrame, + ChecksumMismatch, +}; + +class OpenNetClient { + public: + using MessageHandler = std::function; + + static constexpr uint32_t kDefaultMaxPayload = 4096; + static constexpr uint16_t kMaxTopicLength = 1024; + + explicit OpenNetClient(Client& transport, + uint32_t maxPayload = kDefaultMaxPayload); + + bool connect(const char* host, uint16_t port); + void disconnect(const char* reason = nullptr); + bool connected() const; + void poll(); + + void onMessage(MessageHandler handler); + OpenNetError lastError() const; + uint32_t lastAcknowledgedMessage() const; + + uint32_t send(const char* topic, OpenNetValueType type, + const uint8_t* payload, size_t length, + bool requireAck = true); + uint32_t sendText(const char* topic, const String& value, + bool requireAck = true); + uint32_t sendJson(const char* topic, const String& json, + bool requireAck = true); + uint32_t sendBinary(const char* topic, const uint8_t* value, size_t length, + bool requireAck = true); + uint32_t sendInt(const char* topic, int64_t value, bool requireAck = true); + uint32_t sendDouble(const char* topic, double value, + bool requireAck = true); + uint32_t sendBool(const char* topic, bool value, bool requireAck = true); + uint32_t sendNull(const char* topic, bool requireAck = true); + bool ping(); + + private: + static constexpr size_t kHeaderSize = 24; + static constexpr uint8_t kAckRequired = 0x01; + static constexpr uint8_t kAllowedFlags = 0x03; + + Client& transport_; + uint32_t maxPayload_; + uint32_t nextMessageId_; + uint32_t lastAcknowledged_; + OpenNetError lastError_; + MessageHandler handler_; + + uint8_t header_[kHeaderSize]; + size_t headerReceived_; + std::vector body_; + size_t bodyReceived_; + uint16_t expectedTopicLength_; + uint32_t expectedPayloadLength_; + + bool sendFrame(OpenNetFrameKind kind, OpenNetValueType type, + const char* topic, const uint8_t* payload, size_t length, + uint32_t messageId, uint8_t flags); + void resetReceiver(); + bool prepareBody(); + void handleFrame(); + + static uint16_t readU16(const uint8_t* bytes); + static uint32_t readU32(const uint8_t* bytes); + static void writeU16(uint8_t* bytes, uint16_t value); + static void writeU32(uint8_t* bytes, uint32_t value); + static uint32_t crc32(const uint8_t* data, size_t length, + uint32_t previous = 0); +};