From 548ba6b45a89b4a7359dc7aad75ac427293c9928 Mon Sep 17 00:00:00 2001 From: Paul Buckley Date: Thu, 27 Aug 2026 15:14:21 -0700 Subject: [PATCH 1/4] docs: restructure README around use cases Lead with the CLI and route from a "what do you want to do?" table into a section per task, instead of a library-first tour organised by module. The library is still documented, collapsed into one section. Claude-Session: https://claude.ai/code/session_01Auxp5B8pSEsvFHRTCWFMUs --- README.md | 735 ++++++++++++++++++++++++------------------------------ 1 file changed, 331 insertions(+), 404 deletions(-) diff --git a/README.md b/README.md index 924590a..092f2c1 100644 --- a/README.md +++ b/README.md @@ -4,160 +4,39 @@ [![Python](https://img.shields.io/pypi/pyversions/pyhubblenetwork.svg)](https://pypi.org/project/pyhubblenetwork) [![License](https://img.shields.io/github/license/HubbleNetwork/pyhubblenetwork)](LICENSE) -**pyhubblenetwork** is a Python SDK for communicating with Hubble Network devices over Bluetooth Low Energy (BLE) and securely relaying data to the Hubble Cloud. It provides a simple API for scanning, sending, and managing devices—no embedded firmware knowledge required. +**`hubblenetwork` is the command-line tool for Hubble Network IoT devices.** Watch +nearby devices report over Bluetooth, receive their packets from satellite, decrypt +payloads locally with a device key, and manage your fleet in the Hubble Cloud — no +embedded firmware knowledge required. +It is also an importable Python SDK: everything the CLI does is available as a +library. See [Using it as a Python library](#using-it-as-a-python-library). -## Table of contents +Links: [PyPI](https://pypi.org/project/pyhubblenetwork/) · +[Hubble docs](https://docs.hubble.com/docs/intro) · +[Embedded SDK](https://github.com/HubbleNetwork/sdk) -- [Quick links](#quick-links) -- [Requirements & supported platforms](#requirements--supported-platforms) -- [Installation](#installation) -- [Quick start](#quick-start) -- [CLI usage](#cli-usage) -- [Validating a device end-to-end](#validating-a-device-end-to-end) -- [Satellite scanning (PlutoSDR)](#satellite-scanning-plutosdr) -- [Configuration](#configuration) -- [Public API (summary)](#public-api-summary) -- [Development & tests](#development--tests) -- [Troubleshooting](#troubleshooting) -- [Telemetry](#telemetry) -- [Releases & versioning](#releases--versioning) - -## Quick links - -- [PyPI](https://pypi.org/project/pyhubblenetwork/): `pip install pyhubblenetwork` -- [Hubble official doc site](https://docs.hubble.com/docs/intro) -- [Hubble embedded SDK](https://github.com/HubbleNetwork/sdk) - - -## Requirements & supported platforms - -- Python **3.10+** (3.11/3.12 recommended) -- BLE platform prerequisites (only needed if you use `ble.scan()`): - - **macOS**: CoreBluetooth. Run from a real terminal app and grant it Bluetooth - access when prompted. macOS kills any process whose executable has no - `NSBluetoothAlwaysUsageDescription` in an Info.plist, and a bare Python binary - has none — see [Troubleshooting](#troubleshooting) if you hit a crash rather - than a permission prompt. - - **Linux**: BlueZ required; user must have permission to access the BLE adapter (often `bluetooth` group). - - **Windows**: Requires a compatible BLE stack/adapter. -- Satellite scanning prerequisites (only needed if you use `sat.scan()`): - - **Docker**: [Docker Desktop](https://www.docker.com/get-started/) (macOS/Windows) or Docker Engine (Linux) must be installed and running. - - **PlutoSDR**: An Analog Devices ADALM-PLUTO SDR dongle connected via USB. - -## Installation - -### Users (stable release) +## Install ```bash pip install pyhubblenetwork -# or install CLI into an isolated environment: -pipx install pyhubblenetwork -``` - -### Developers (editable install) - -From the repo root: - -```bash -python3 -m venv .venv && source .venv/bin/activate -pip install -e '.[dev]' -``` - -## Quick start - -### Scan locally, then ingest to backend - -```python -from hubblenetwork import ble, Organization - -org = Organization(org_id="org_123", api_token="sk_XXX") -pkts = ble.scan(timeout=5.0) -if len(pkts) > 0: - org.ingest_packet(pkts[0]) -else: - print("No packet seen within timeout") -``` - -### Manage devices and query packets - -```python -from hubblenetwork import Organization - -org = Organization(org_id="org_123", api_token="sk_XXX") -# Create a new device -new_dev = org.register_device() -print("new device id:", new_dev.id) - -# List devices -for d in org.list_devices(): - print(d.id, d.name) - -# Get packets from a device (returns a list of DecryptedPacket) -packets = org.retrieve_packets(new_dev) -if len(packets) > 0: - print("latest RSSI:", packets[0].rssi, "payload bytes:", len(packets[0].payload)) -``` - -### Local decryption (when you have the key) - -```python -from hubblenetwork import Device, ble, decrypt -from typing import Optional - -dev = Device(id="dev_abc", key=b"") - -pkts = ble.scan(timeout=5.0) # might return a list or a single packet depending on API -for pkt in pkts: - maybe_dec = decrypt(dev.key, pkt) - if maybe_dec: - print("payload:", maybe_dec.payload) - else: - print("failed to decrypt packet") -``` - -For devices using counter-based EID (DEVICE_UPTIME mode), pass `counter_mode="DEVICE_UPTIME"`: - -```python -maybe_dec = decrypt(dev.key, pkt, counter_mode="DEVICE_UPTIME") -``` - -The `counter_mode` parameter accepts `"UNIX_TIME"` (default, UTC day-based) or `"DEVICE_UPTIME"` (counter values 0–127, fixed pool size of 128). - -### Receive satellite packets - -```python -from hubblenetwork import sat - -# sat.scan() manages the Docker container automatically: -# pulls the image, starts the container, polls for packets, and stops on exit. -for pkt in sat.scan(timeout=60.0): - print(f"device={pkt.device_id} seq={pkt.seq_num} rssi={pkt.rssi_dB} dB payload={pkt.payload.hex()}") -``` - -Docker must be running before calling `sat.scan()`. The PlutoSDR dongle must be connected. - -## CLI usage - -If installed, the `hubblenetwork` command is available: - -```bash -hubblenetwork --help -hubblenetwork ble scan -hubblenetwork ble scan --payload-format hex -hubblenetwork ble scan --key "base64key=" --counter-mode DEVICE_UPTIME # counter-based EID -hubblenetwork org get-packets --payload-format string +# or, for CLI-only use, into its own environment: +pipx install pyhubblenetwork ``` -Start with `hubblenetwork doctor`, which checks whether this machine can actually -talk to Hubble and names the fix for anything broken: +Set your credentials, then check the machine is actually able to do the work: ```bash +export HUBBLE_ORG_ID= +export HUBBLE_API_TOKEN= hubblenetwork doctor ``` +`doctor` is the setup step. It checks credentials, Bluetooth and Docker, and names +the fix for anything broken: + ``` x Credentials not set Set both, or pass --org-id/--token: @@ -173,130 +52,79 @@ Not ready. | 1 ok | 2 failed It exits 1 when something needed is broken, so a script can gate on it. A skipped check is not a failure: it means the check does not apply on this platform, or could not be answered without doing real work (pulling the satellite receiver image, say). -The Bluetooth check reads the interpreter's Info.plist rather than attempting a scan, -because attempting one is exactly what macOS kills. -Every command lives inside a group, so it is always `hubblenetwork `: -`org` for the cloud, `ble` for nearby devices, `ready` for provisioning, `sat` for -satellite, `metrics` for fleet counts. `hubblenetwork --help` prints the full list with -a one-line description and the required arguments for each, and every command takes -`--help` for its own options. -You don't have to remember which group a command is in. If you type one at the wrong -level the CLI finds it for you: +## What do you want to do? -``` -$ hubblenetwork list-devices +| Goal | Command | +|------|---------| +| Check my setup is working | [`doctor`](#install) | +| Watch nearby devices report | [`ble scan`](#watch-nearby-devices-report) | +| Prove one device works end to end | [`ble validate`](#prove-one-device-works-end-to-end) | +| See what's registered to my org | [`org list-devices`](#work-with-your-fleet-in-the-cloud) | +| Read a device's history from the cloud | [`org get-packets `](#work-with-your-fleet-in-the-cloud) | +| Register a new device and get its key | [`org register-device`](#work-with-your-fleet-in-the-cloud) | +| Receive packets from satellite | [`sat scan`](#receive-satellite-packets) | +| Capture raw RF for offline analysis | [`sat record`](#one-shot-capture-record--signal-report) | -Usage: hubblenetwork [OPTIONS] COMMAND [ARGS]... -Try 'hubblenetwork --help' for help. - -Error: No such command 'list-devices'. - - Did you mean: hubblenetwork org list-devices -``` +Reference: [Reading the output](#reading-the-output) · +[Configuration](#configuration) · +[Requirements](#requirements) · +[Python library](#using-it-as-a-python-library) · +[Troubleshooting](#troubleshooting) -The same applies to missing arguments (they say how to find the value), unknown options -(they list what the command accepts), and missing credentials (they name the environment -variables and the flags). `validate-credentials` exits 1 when credentials are invalid, so -scripts can branch on it. - -### Payload format option +Every command lives inside a group, so it is always `hubblenetwork `: -Commands that output packet data (`ble scan`, `sat scan`, `ble detect`, `org get-packets`) support the `--payload-format` flag to control how payloads are displayed: +* **`org`** — your devices in the Hubble Cloud +* **`ble`** — nearby devices over Bluetooth +* **`ready`** — provision a device over GATT +* **`sat`** — satellite packets via PlutoSDR +* **`metrics`** — fleet counts -* `auto` — printable ASCII shows as text, anything else as uppercase hex -* `base64` — encode payloads as base64 -* `hex` — display payloads as hexadecimal -* `string` — decode payloads as UTF-8 text (falls back to `` if bytes are not valid UTF-8) +`hubblenetwork --help` prints the full list with a one-line description and the +required arguments for each, and every command takes `--help` for its own options. -All four values work with every output format, but the **default** differs by -format, because a person and a program want different things. Tabular output -defaults to `auto`, so a decrypted payload reads as `T=21.4` rather than -`VD0yMS40`. JSON and CSV default to `base64` so the machine contract stays -stable. An explicit `--payload-format` always wins. -### Organization commands +## Watch nearby devices report -`org list-devices` and `org get-packets` stream rows as pages arrive, so the first -rows appear in about a second rather than after the whole window downloads. A busy -device can hold tens of thousands of packets; Ctrl+C stops early and still prints a -summary, and `--limit N` caps the run (it says how it stopped, never silently). +`ble scan` listens for Hubble beacon advertisements (UUID 0xFCA6) and prints one line +per packet as it arrives. No credentials needed — this is purely local. ```bash -hubblenetwork org info -hubblenetwork org list-devices # streams, tags summarised once -hubblenetwork org list-devices -f json # machine-readable -hubblenetwork org list-devices -n 20 -hubblenetwork org get-packets -n 50 --debug -``` - -`list-devices` takes `--format tabular|json` and `--limit`. `get-packets` takes -`--limit` and `--debug` (which adds `EPOCH`, `CTR` and `SEQ` columns). As with the -scan commands, rows go to stdout and headings, progress and summaries go to stderr. - -The SDK mirrors this: `Organization.iter_devices()` and `Organization.iter_packets()` -are generators that yield as pages arrive, and both accept an `on_page(page, total)` -callback for progress. `list_devices()` and `retrieve_packets()` still return lists. - -### Scan output layout - -`ble scan` and `sat scan` print one line per packet with a signal bar, and close -with a summary on stderr: - -``` - TIME RSSI V EID CTR/SEQ PAYLOAD -───────────────────────────────────────────────────────────────────────────── -✓ 00:06:40 -62 ███▏ 2 9c4e2ab77d3f0e1a 20320 T=21.4,B=87 -✓ 00:06:43 -66 ██▉ 2 9c4e2ab77d3f0e1b 20321 T=21.4,B=87 -✗ 00:06:49 -74 ██▏ 2 9c4e2ab77d3f0e1d - D307912C66BA4018E5 -───────────────────────────────────────────────────────────────────────────── - -4 packets · 3 decrypted, 1 failed · RSSI -62 to -74 dBm · 12s -``` - -The bar next to RSSI is signal strength: length is the magnitude, so you can watch -it shrink as you walk away from a device. The `✓`/`✗` mark only appears with -`--show-failed-decryption`, and the mark carries the state on its own, so the output -still reads correctly without colour. - -### Terminals that can't do box-drawing - -Not every terminal can render `─` and `█`. Writing them to a stdout using a legacy -code page raises `UnicodeEncodeError`, and because most of them are East Asian Width -"Ambiguous" they render double-width under a CJK terminal configuration, which shears -every column. - -Pass `--ascii` (or set `HUBBLE_ASCII=1`) for a pure-ASCII rendering with identical -column widths: - -``` - TIME RSSI V EID CTR/SEQ PAYLOAD ---------------------------------------------------------------------------- - 15:50:13 -62 ###= 0 2030405 300 0A0B0C0D0E0F ---------------------------------------------------------------------------- - -1 packets | RSSI -62 to -62 dBm | 0s +hubblenetwork ble scan ``` -The encoding case is detected automatically, so you only need the flag for the -double-width one. `--no-ascii` forces the Unicode rendering if the detection is -wrong for you. - -Colour is a separate axis: `--no-color`, `NO_COLOR=1`, or a non-TTY stdout all -disable it, and `FORCE_COLOR=1` keeps it on where a pipe would otherwise strip it -(useful in CI). Both flags work on any command, before or after the subcommand. - -Packet rows go to stdout and everything else (the scanning notice, detection -lines, the summary) goes to stderr, so `hubblenetwork ble scan > packets.txt` -captures data only. Pass `--debug` to add the forensic columns: `EPOCH`, `TAG` -and `SALT` for `ble scan`, `RS_CORR`, `SYM_MS` and `GAP_MS` for `sat scan`. - -## Validating a device end-to-end - -The `ble validate` command runs a full end-to-end health check on a single Hubble -device, confirming that everything from your credentials to the cloud backend is -wired up correctly. It is the quickest way to answer "is my device working?" +It runs until Ctrl+C unless you bound it. Pass `--key` and the payloads are decrypted +locally as they arrive. + +| Flag | Takes | Description | +|------|-------|-------------| +| `-t`, `--timeout` | seconds | Stop after this long. Default: no timeout. | +| `-n`, `--count` | N | Stop after N packets. | +| `-k`, `--key` | hex or base64, 16 or 32 bytes | Decrypt payloads locally with this device key. | +| `--show-failed-decryption` | — | Also show packets the key can't decrypt, with a `✓`/`✗` mark per row. Off by default, so they are hidden. | +| `--counter-mode` | `UNIX_TIME` or `DEVICE_UPTIME` | EID counter source for AES-CTR packets. Default: `UNIX_TIME`. | +| `-d`, `--days` | N | Days to search when decrypting AES-CTR packets in `UNIX_TIME` mode. Default: 2. | +| `-e`, `--period-exponent` | 0-15 | EID rotation period for AES-EAX packets; period = 2ⁿ seconds. Matches `rot_exp` in the device config. Default: 0. | +| `--network-id` | 34-bit ID | Show only this network. Unencrypted protocol only. | +| `--ingest` | — | Relay received packets to the Hubble Cloud. Needs `--key` and credentials. | +| `--org-id` | ID | Organization ID for `--ingest`. Env: `HUBBLE_ORG_ID`. | +| `--token` | token | API token for `--ingest`. Env: `HUBBLE_API_TOKEN`. | +| `-o`, `--format` | `tabular` or `json` | Output format. Default: `tabular`. | +| `--payload-format` | `auto`, `base64`, `hex`, `string` | How to render payloads — see [Payload format](#payload-format). | +| `--debug` | — | Add the `EPOCH`, `TAG` and `SALT` forensic columns. | + + +## Prove one device works end to end + +`ble validate` is the quickest way to answer "is my device working?". It walks the +whole chain — your credentials, the device's registration, its advertisements, the +key, and the cloud round trip — and stops at the first failure. + +It validates the **terrestrial** path: the device advertising over Bluetooth, this +machine acting as the gateway, and the packet reaching the cloud and coming back. It +says nothing about whether the device is reaching the satellite network — for that, +receive it directly with [`sat scan`](#receive-satellite-packets). ```bash hubblenetwork ble validate \ @@ -304,7 +132,7 @@ hubblenetwork ble validate \ --device-id "3f4b2c0c-2d43-4cbe-9c1f-0a4c2d59e2a1" ``` -The command performs these steps in order, stopping at the first failure: +The steps, in order: 1. **Validates input formats** — the device key (hex or base64, 16- or 32-byte) and the device ID (standard 8-4-4-4-12 UUID). @@ -318,8 +146,6 @@ The command performs these steps in order, stopping at the first failure: 7. **Ingests the packet** into the backend and **reads it back** to confirm the full round trip succeeded. -### Options - | Option | Description | |--------|-------------| | `--key`, `-k` | Device key, used to test packet encryption (required). Accepts hex or base64, 16- or 32-byte. | @@ -332,68 +158,85 @@ If a step fails, the command prints targeted debugging tips. A common cause of a failed scan is a slow advertising interval combined with OS-level BLE scan optimizations — simply running the command again often resolves it. -## Satellite scanning (PlutoSDR) -The `sat` command group receives packets via a PlutoSDR SDR dongle. It runs a Docker container ([`ghcr.io/hubblenetwork/sdr-docker`](https://ghcr.io/hubblenetwork/sdr-docker)) that handles RF reception and decoding, then polls that container's HTTP API and streams decoded packets to stdout. +## Work with your fleet in the cloud + +The `org` group talks to the Hubble Cloud, so it needs credentials. + +```bash +hubblenetwork org info # which org and environment am I on? +hubblenetwork org list-devices # everything registered +hubblenetwork org list-devices -n 20 +hubblenetwork org list-devices -f json # machine-readable + +hubblenetwork org get-packets # last 7 days by default +hubblenetwork org get-packets --days 30 --format csv +hubblenetwork org get-packets -n 50 --debug + +hubblenetwork org register-device # returns the new device's key +hubblenetwork org set-device-name +hubblenetwork org delete-device +``` + +`list-devices` and `get-packets` stream rows as pages arrive, so the first rows appear +in about a second rather than after the whole window downloads. A busy device can hold +tens of thousands of packets; Ctrl+C stops early and still prints a summary, and +`--limit`/`-n` caps the run. It always says how it stopped, never silently. + +`register-device` takes `--encryption`, `--counter-source`, and — for AES-128-EAX on +`DEVICE_UPTIME` — either `--period-seconds` or `--period-exponent` (period = 2ⁿ +seconds; the cloud accepts 10-15, default 15 ≈ 9h). The two period flags are mutually +exclusive. + -### Requirements +## Receive satellite packets -- **Docker daemon running** — Docker Desktop (macOS/Windows) or Docker Engine (Linux). -- **PlutoSDR connected** — ADALM-PLUTO dongle plugged in via USB before starting the scan. +The `sat` group receives packets through a PlutoSDR dongle. It runs a Docker container +([`ghcr.io/hubblenetwork/sdr-docker`](https://ghcr.io/hubblenetwork/sdr-docker)) that +handles RF reception and decoding, polls that container's HTTP API, and streams +decoded packets to stdout. -### CLI commands +**Needs Docker running and an ADALM-PLUTO plugged in over USB.** `hubblenetwork doctor` +checks the Docker half. ```bash # Stream packets until Ctrl+C hubblenetwork sat scan -# Stop after 30 seconds +# Bounded runs hubblenetwork sat scan --timeout 30 - -# Stop after receiving 5 packets hubblenetwork sat scan -n 5 - -# JSON output (a single array, streamed as packets arrive) -hubblenetwork sat scan -o json - -# Combine options hubblenetwork sat scan -o json --timeout 60 -n 20 -# Decrypt payloads locally with a device key (hex or base64, 16 or 32 bytes) +# Decrypt payloads locally with a device key hubblenetwork sat scan --key "a562a2f7e4c62bed52ab09633878f62b" - -# Force the DEVICE_UPTIME counter instead of auto-detecting hubblenetwork sat scan --key "" --counter-mode DEVICE_UPTIME - -# Show packets the key can't decrypt too (adds a ✓/✗ decrypt mark per row) hubblenetwork sat scan --key "" --show-failed-decryption + +# No hardware to hand? Stream fake packets +hubblenetwork sat mock-scan ``` -When `--key` is supplied, each packet's payload is decrypted locally using the -same AES-CTR scheme as BLE, which supports both the UNIX_TIME (day-based) and -DEVICE_UPTIME counter sources. The counter source is auto-detected from the -packets (and announced) unless `--counter-mode UNIX_TIME|DEVICE_UPTIME` is given. -For the UNIX_TIME counter, `--days` controls how many days around each packet's -timestamp are searched (default 2). Packets the key cannot decrypt are hidden -unless `--show-failed-decryption` is given. - -The command automatically: -1. Verifies Docker is available -2. Pulls the latest PlutoSDR image (if not cached) -3. Starts the container in privileged mode so it can access USB -4. Waits for the receiver API to become ready -5. Streams new packets as they arrive (deduplicating by device ID + sequence number) -6. Stops and removes the container on exit or Ctrl+C +Decryption uses the same AES-CTR scheme as BLE and supports both counter sources. The +source is auto-detected from the packets and announced, unless `--counter-mode` is +given. For `UNIX_TIME`, `--days` controls how many days around each packet's timestamp +are searched (default 2). Packets the key cannot decrypt are hidden unless +`--show-failed-decryption` is given. + +`sat scan` handles the container for you: it verifies Docker, pulls the image if it +isn't cached, starts the container privileged so it can reach USB, waits for the +receiver API and for the SDR to connect, deduplicates packets by device ID and +sequence number, then stops and removes the container on exit. ### One-shot capture (`record` / `signal-report`) -Alongside the live `scan` stream, two one-shot commands record for a fixed -duration, save a single file, and exit. Both accept `--output PATH` (default: an -auto-generated timestamped name), `--mock` (use the simulated receiver — no -PlutoSDR required), `--pluto-uri`, and `--debug`. +Alongside the live stream, two commands record for a fixed duration, save a single +file, and exit. Both accept `--output PATH` (default: an auto-generated timestamped +name), `--mock` (use the simulated receiver — no PlutoSDR required), `--pluto-uri`, +and `--debug`. ```bash -# Capture 10 s of raw IQ samples to a .npy file (for offline analysis / reprocessing) +# Capture 10 s of raw IQ samples to a .npy file hubblenetwork sat record 10 hubblenetwork sat record 10 --output capture.npy @@ -405,54 +248,93 @@ hubblenetwork sat signal-report 10 --output report.txt --mock - **`record`** captures the raw radio signal only — no decoding. The output is a NumPy `.npy` file of IQ samples. - **`signal-report`** records IQ, then re-analyzes it offline into a plain-text - **link-health diagnostic**: per-symbol timing/drift, channel-hopping - validation, amplitude/SNR, and chipset metrics. It reports on signal quality - and does **not** contain decoded packet payloads — to receive payloads, use - `sat scan` (optionally with `--key`). + **link-health diagnostic**: per-symbol timing/drift, channel-hopping validation, + amplitude/SNR, and chipset metrics. It reports on signal quality and does **not** + contain decoded packet payloads — to receive payloads, use `sat scan --key`. -### Python API -```python -from hubblenetwork import sat, SatellitePacket +## Reading the output -# Generator — yields SatellitePacket as packets arrive -for pkt in sat.scan(timeout=60.0, poll_interval=2.0): - print(pkt.device_id, pkt.seq_num, pkt.rssi_dB, pkt.payload.hex()) +### Payload format -# Or fetch the current packet buffer without managing the container yourself -packets: list[SatellitePacket] = sat.fetch_packets() +Commands that print packet data (`ble scan`, `sat scan`, `ble detect`, +`org get-packets`) take `--payload-format`: -# One-shot captures (manage the container, run once, return the result) -iq_bytes: bytes = sat.record(10.0) # raw IQ samples (.npy file body) -report: str = sat.signal_report(10.0) # plain-text RF signal-diagnostic report +* `auto` — printable ASCII shows as text, anything else as uppercase hex +* `base64` — encode payloads as base64 +* `hex` — display payloads as hexadecimal +* `string` — decode payloads as UTF-8 (falls back to ``) -# Decrypt a packet's payload locally. -# counter_mode defaults to UNIX_TIME; pass DEVICE_UPTIME for uptime-based EIDs. -from hubblenetwork import decrypt_satellite +All four work with every output format, but the **default** differs, because a person +and a program want different things. Tabular output defaults to `auto`, so a decrypted +payload reads as `T=21.4` rather than `VD0yMS40`. JSON and CSV default to `base64` so +the machine contract stays stable. An explicit `--payload-format` always wins. -for pkt in sat.scan(timeout=60.0): - if pkt.auth_tag is not None: - plaintext = decrypt_satellite( - key, seq_no=pkt.seq_num, auth_tag=pkt.auth_tag, - encrypted_payload=pkt.payload, timestamp=pkt.timestamp, - counter_mode="UNIX_TIME", - ) - if plaintext is not None: - print(pkt.device_id, plaintext) +### Scan layout + +`ble scan` and `sat scan` print one line per packet with a signal bar, and close with +a summary: + +``` + TIME RSSI V EID CTR/SEQ PAYLOAD +───────────────────────────────────────────────────────────────────────────── +✓ 00:06:40 -62 ███▏ 2 9c4e2ab77d3f0e1a 20320 T=21.4,B=87 +✓ 00:06:43 -66 ██▉ 2 9c4e2ab77d3f0e1b 20321 T=21.4,B=87 +✗ 00:06:49 -74 ██▏ 2 9c4e2ab77d3f0e1d - D307912C66BA4018E5 +───────────────────────────────────────────────────────────────────────────── + +4 packets · 3 decrypted, 1 failed · RSSI -62 to -74 dBm · 12s +``` + +The bar next to RSSI is signal strength: length is the magnitude, so you can watch it +shrink as you walk away from a device. The `✓`/`✗` mark only appears with +`--show-failed-decryption`, and it carries the state on its own, so the output still +reads correctly without colour. + +Packet rows go to **stdout** and everything else — the scanning notice, detection +lines, the summary — goes to **stderr**, so this captures data only: + +```bash +hubblenetwork ble scan > packets.txt ``` -`SatellitePacket` fields: `device_id`, `seq_num`, `device_type`, `timestamp`, `rssi_dB`, `channel_num`, `freq_offset_hz`, `payload` (bytes), `auth_tag` (bytes or `None`). +The same split applies to `org list-devices` and `org get-packets`. -### Errors +Pass `--debug` for the forensic columns: `EPOCH`, `TAG` and `SALT` on `ble scan`, +`RS_CORR`, `SYM_MS` and `GAP_MS` on `sat scan`, `EPOCH`, `CTR` and `SEQ` on +`org get-packets`. + +### Terminals that can't do box-drawing + +Not every terminal can render `─` and `█`. Writing them to a stdout using a legacy +code page raises `UnicodeEncodeError`, and because most of them are East Asian Width +"Ambiguous" they render double-width under a CJK terminal configuration, which shears +every column. + +Pass `--ascii` (or set `HUBBLE_ASCII=1`) for a pure-ASCII rendering with identical +column widths: + +``` + TIME RSSI V EID CTR/SEQ PAYLOAD +--------------------------------------------------------------------------- + 15:50:13 -62 ###= 0 2030405 300 0A0B0C0D0E0F +--------------------------------------------------------------------------- + +1 packets | RSSI -62 to -62 dBm | 0s +``` + +The encoding case is detected automatically, so you only need the flag for the +double-width one. `--no-ascii` forces the Unicode rendering if the detection is wrong +for you. + +Colour is a separate axis: `--no-color`, `NO_COLOR=1`, or a non-TTY stdout all disable +it, and `FORCE_COLOR=1` keeps it on where a pipe would otherwise strip it (useful in +CI). Both flags work on any command, before or after the subcommand. -| Exception | Cause | -|-----------|-------| -| `DockerError` | Docker not installed, daemon not running, or container failed to start | -| `SatelliteError` | Container started but receiver API did not become ready in time | ## Configuration -The **CLI** reads two environment variables: +The CLI reads two environment variables: * `HUBBLE_ORG_ID` — your organization id * `HUBBLE_API_TOKEN` — your API token, passed through as a bearer token @@ -462,32 +344,45 @@ export HUBBLE_ORG_ID=org_123 export HUBBLE_API_TOKEN=sk_XXXX ``` -Every command that needs credentials also takes `--org-id` and `--token`, and -`--help` names the environment variable for each. On the `org` and `metrics` -groups those flags belong to the group, so they go before the subcommand: +Every command that needs credentials also takes `--org-id` and `--token`, and `--help` +names the environment variable for each. On the `org` and `metrics` groups those flags +belong to the group, so they go before the subcommand: ```bash hubblenetwork org --org-id --token list-devices ``` -Check whichever route you used with `hubblenetwork validate-credentials`. +Check whichever route you used with `hubblenetwork validate-credentials` or +`hubblenetwork doctor`. -**The SDK does not read the environment.** `Organization()` requires its -credentials explicitly, so exporting the variables does nothing for library code: +**The SDK does not read the environment** — see below. -```python -from hubblenetwork import Organization -import os -org = Organization( - org_id=os.environ["HUBBLE_ORG_ID"], - api_token=os.environ["HUBBLE_API_TOKEN"], -) -``` +## Requirements + +- Python **3.10+** (3.11/3.12 recommended) +- **Bluetooth**, for the `ble` and `ready` groups: + - **macOS**: CoreBluetooth. Run from a real terminal app and grant it Bluetooth + access when prompted. macOS kills any process whose executable has no + `NSBluetoothAlwaysUsageDescription` in an Info.plist, and a bare Python binary + has none — see [Troubleshooting](#troubleshooting) if you hit a crash rather + than a permission prompt. + - **Linux**: BlueZ required; the user must have permission to access the BLE + adapter (often the `bluetooth` group). + - **Windows**: a compatible BLE stack/adapter. +- **Docker and a PlutoSDR**, for the `sat` group: + [Docker Desktop](https://www.docker.com/get-started/) (macOS/Windows) or Docker + Engine (Linux) installed and running, and an Analog Devices ADALM-PLUTO connected + over USB. `sat mock-scan`, `sat record --mock` and `sat signal-report --mock` need + Docker but no SDR. + +`hubblenetwork doctor` reports on all of this. + -## Public API (summary) +## Using it as a Python library -Import from the package top-level for a stable surface: +Everything the CLI does is available as a library. Import from the package top-level +for a stable surface: ```python from hubblenetwork import ( @@ -501,42 +396,76 @@ from hubblenetwork import ( ) ``` -Key objects & functions: +**Unlike the CLI, the SDK does not read the environment.** `Organization()` requires +its credentials explicitly: -* `Organization` provides credentials for performing cloud actions (e.g. registering devices, retrieving decrypted packets, retrieving devices, etc.) -* `EncryptedPacket` a packet that has not been decrypted (can be decrypted locally given a key or ingested to the backend) -* `DecryptedPacket` a packet that has been successfully decrypted either locally or by the backend. -* `SatellitePacket` a packet decoded by the satellite receiver (PlutoSDR). -* `Location` data about where a packet was seen. -* `ble.scan` function for locally scanning for devices with BLE. -* `sat.scan` generator for receiving satellite packets via PlutoSDR (requires Docker). -* `Organization.iter_devices()` / `iter_packets()` generators that yield as each API - page arrives instead of accumulating, so you can start processing immediately on a - device with tens of thousands of packets. Both take an optional - `on_page(page, total_so_far)` callback. `list_devices()` and `retrieve_packets()` - are `list()` wrappers over them and still return lists. +```python +import os +from hubblenetwork import Organization -See code for full details. +org = Organization( + org_id=os.environ["HUBBLE_ORG_ID"], + api_token=os.environ["HUBBLE_API_TOKEN"], +) -## Development & tests +new_dev = org.register_device() # returns a Device, with its key +for d in org.iter_devices(): # streams as pages arrive + print(d.id, d.name) +for pkt in org.iter_packets(new_dev): # ditto; both take on_page(page, total) + print(pkt.rssi, pkt.payload) +``` -Set up a virtualenv and install dev deps: +`iter_devices()` and `iter_packets()` are generators that yield as each API page +arrives instead of accumulating, so you can start processing immediately on a device +with tens of thousands of packets. `list_devices()` and `retrieve_packets()` are +`list()` wrappers over them and still return lists. -```bash -python3 -m venv .venv -source .venv/bin/activate -pip install -e '.[dev]' +Scanning and local decryption: + +```python +from hubblenetwork import ble, decrypt + +for pkt in ble.scan(timeout=5.0): + plaintext = decrypt(key, pkt) # UNIX_TIME by default + plaintext = decrypt(key, pkt, counter_mode="DEVICE_UPTIME") + if plaintext: + print(plaintext.payload) ``` -Run linters: +`counter_mode` accepts `"UNIX_TIME"` (default, UTC day-based) or `"DEVICE_UPTIME"` +(counter values 0–127, fixed pool size of 128). BLE and provisioning functions each +have sync and async variants — `ble.scan()` / `ble.scan_async()`. -```bash -ruff check src +Satellite, which manages the Docker container for you: + +```python +from hubblenetwork import sat, SatellitePacket, decrypt_satellite + +for pkt in sat.scan(timeout=60.0, poll_interval=2.0): + print(pkt.device_id, pkt.seq_num, pkt.rssi_dB, pkt.payload.hex()) + if pkt.auth_tag is not None: + plaintext = decrypt_satellite( + key, seq_no=pkt.seq_num, auth_tag=pkt.auth_tag, + encrypted_payload=pkt.payload, timestamp=pkt.timestamp, + counter_mode="UNIX_TIME", + ) + +packets: list[SatellitePacket] = sat.fetch_packets() # current buffer, no lifecycle +iq_bytes: bytes = sat.record(10.0) # raw IQ (.npy file body) +report: str = sat.signal_report(10.0) # plain-text RF diagnostic ``` +`SatellitePacket` fields: `device_id`, `seq_num`, `device_type`, `timestamp`, +`rssi_dB`, `channel_num`, `freq_offset_hz`, `payload` (bytes), `auth_tag` (bytes or +`None`). `sat.scan()` raises `DockerError` if Docker isn't available, and +`SatelliteError` if the container starts but the receiver API or the SDR never comes +up. + +See the code for the full surface. + + ## Troubleshooting -* **`ble.scan()` finds nothing**: verify BLE permissions and adapter state; try increasing `timeout`. * **macOS: `ble scan` crashes instead of prompting for Bluetooth** — you'll see `Termination Reason: Namespace TCC` and a message about a missing `NSBluetoothAlwaysUsageDescription` key. macOS refuses CoreBluetooth to any @@ -547,14 +476,34 @@ ruff check src that carries the key; the framework build at `$(brew --prefix)/Frameworks/Python.framework/Versions//Resources/Python.app` is a usable starting point to copy and amend. -* **Auth errors**: confirm `Organization(org_id, api_token)` or env vars are set; check - token scope/expiry. `hubblenetwork validate-credentials` reports which environment - accepted them and exits 1 if neither did, so it is safe to use in a script. -* **Import errors**: ensure you installed into the Python you’re running (`python -m pip …`). Prefer `pipx` for CLI-only usage. -* **`DockerError: Docker is not available`**: Docker daemon is not running. Start Docker Desktop (macOS/Windows) or `sudo systemctl start docker` (Linux). -* **`DockerError: The ‘docker’ Python package is required`**: run `pip install docker` (it is bundled with `pyhubblenetwork` but may be missing in some environments). -* **`SatelliteError: Satellite receiver API did not become ready`**: the PlutoSDR container started but couldn’t access the hardware. Ensure the ADALM-PLUTO dongle is plugged in before running `sat scan`, and that no other process is using it. -* **`sat scan` hangs pulling the image**: first run fetches `ghcr.io/hubblenetwork/sdr-docker:latest`; this may take a minute on a slow connection. Subsequent runs use the cached image. +* **`ble scan` finds nothing**: verify BLE permissions and adapter state, and try a + longer `--timeout`. Slow advertising intervals plus OS-level scan optimizations mean + a second attempt often succeeds. +* **Auth errors**: run `hubblenetwork doctor`. `validate-credentials` reports which + environment accepted them and exits 1 if neither did, so it is safe in a script. +* **Import errors**: ensure you installed into the Python you're running + (`python -m pip …`). Prefer `pipx` for CLI-only usage. +* **`DockerError: Docker is not available`**: the Docker daemon is not running. Start + Docker Desktop (macOS/Windows) or `sudo systemctl start docker` (Linux). +* **`DockerError: The 'docker' Python package is required`**: run `pip install docker` + (it ships with `pyhubblenetwork` but may be missing in some environments). +* **`SatelliteError: No PlutoSDR detected`**: the container started but the SDR never + connected. Ensure the ADALM-PLUTO is plugged in before running `sat scan`, and that + no other process is using it. +* **`sat scan` hangs pulling the image**: the first run fetches + `ghcr.io/hubblenetwork/sdr-docker:latest`, which may take a minute on a slow + connection. Later runs use the cached image. + + +## Development & tests + +```bash +python3 -m venv .venv && source .venv/bin/activate +pip install -e '.[dev]' + +ruff check src +pytest +``` ## Telemetry @@ -582,37 +531,15 @@ the bar is set before anyone writes the code: * **Tested.** A test asserting the payload contains no credential and no device identifier, so a future field cannot quietly widen it. -## Releases & versioning -Follows **SemVer** (MAJOR.MINOR.PATCH). Pushing a version tag triggers a GitHub Actions workflow that runs tests, builds the package, creates a GitHub Release, and publishes to PyPI. - -### Cutting a release - -1. **Bump the version** in `pyproject.toml`: - ``` - version = "0.6.0" - ``` - -2. **Add release notes** to the top of `release-notes.md`: - ```markdown - ## [0.6.0] - 2026-04-01 - - ### Added - - feat(cli): new command description - - ### Fixed - - fix(org): bug description - ``` - -3. **Commit, tag, and push:** - ```bash - git add pyproject.toml release-notes.md - git commit -m "chore: release 0.6.0" - git push origin main - git tag v0.6.0 - git push origin v0.6.0 - ``` +## Releases & versioning -4. **Approve the publish step** in the [GitHub Actions UI](https://github.com/HubbleNetwork/pyhubblenetwork/actions) (the `pypi` environment requires manual approval). +Follows **SemVer**. Pushing a `vX.Y.Z` tag triggers a GitHub Actions workflow that +runs tests, builds the package, creates a GitHub Release, and publishes to PyPI via +[Trusted Publishing](https://docs.pypi.org/trusted-publishers/) — no API tokens are +stored in the repo. The `pypi` environment requires manual approval in the +[Actions UI](https://github.com/HubbleNetwork/pyhubblenetwork/actions). -The workflow verifies the tag matches the version in `pyproject.toml`, so both must agree. PyPI publishing uses [Trusted Publishing](https://docs.pypi.org/trusted-publishers/) (OIDC) — no API tokens are stored in the repo. +To cut a release, use the `/release` skill: it bumps `pyproject.toml`, generates +notes into `release-notes.md` from the conventional-commit log, commits, tags, and +pushes. From 51d1411545992f11872bd4726084dec1ce9d39ac Mon Sep 17 00:00:00 2001 From: Paul Buckley Date: Fri, 28 Aug 2026 07:03:32 -0700 Subject: [PATCH 2/4] docs: correct README claims against the CLI and SDK The use-case restructure carried over several statements that no longer matched the code, and the new framing made a few of them contradictory. Every claim here was checked against a live run rather than the source. - ble scan: --counter-mode / --period-exponent no longer advertise a default that auto-detection overrides. Document the sweep, the real "[INFO] Detected:" line, and the two usage rules (DEVICE_UPTIME needs --key, conflicts with --days) that a reader following the table hit. - "Every command lives inside a group" was false: doctor and validate-credentials are top level. Name them, and restore the cross-group "Did you mean" recovery demo dropped in the restructure. - Both scan samples are now byte-identical to what the printers emit. The old one showed 3 rows under a "4 packets" summary, and paired a V=2 (AES-EAX, counter 0-127) row with a 20320 day counter, which decrypt_eax cannot produce. Explain what V implies for the EID and CTR/SEQ columns instead. Doctor sample regenerated with the Unicode glyphs it actually prints. - Configuration listed two environment variables; there are six. SDR_DOCKER_IMAGE was documented nowhere. - Telemetry named only org and metrics as Cloud API callers, omitting doctor, validate-credentials, ble validate, ble scan --ingest and ready provision. - Org streaming applies to tabular output only; -o json/csv buffer, and get-packets applies --limit after the download. - sat is a ground receiver: an SDR beside you hears the device's uplink. Nothing arrives from a satellite. - Python examples: define key, filter by packet type before decrypt() (it returns None rather than raising on a type it can't handle), and distinguish decrypt_satellite's bytes from the others' DecryptedPacket. Give DockerError/SatelliteError their real import path and add the four SatellitePacket diagnostic fields behind the --debug columns. - Add the missing provisioning section: ready provision plus the eight per-characteristic commands, ble check-time and ble detect. Claude-Session: https://claude.ai/code/session_01Auxp5B8pSEsvFHRTCWFMUs --- README.md | 257 +++++++++++++++++++++++++++++++++++++++++++----------- 1 file changed, 205 insertions(+), 52 deletions(-) diff --git a/README.md b/README.md index 092f2c1..6e8780f 100644 --- a/README.md +++ b/README.md @@ -5,9 +5,9 @@ [![License](https://img.shields.io/github/license/HubbleNetwork/pyhubblenetwork)](LICENSE) **`hubblenetwork` is the command-line tool for Hubble Network IoT devices.** Watch -nearby devices report over Bluetooth, receive their packets from satellite, decrypt -payloads locally with a device key, and manage your fleet in the Hubble Cloud — no -embedded firmware knowledge required. +nearby devices report over Bluetooth, pick their satellite uplink off the air with an +SDR, provision a device, decrypt payloads locally with a device key, and manage your +fleet in the Hubble Cloud — no embedded firmware knowledge required. It is also an importable Python SDK: everything the CLI does is available as a library. See [Using it as a Python library](#using-it-as-a-python-library). @@ -26,6 +26,9 @@ pip install pyhubblenetwork pipx install pyhubblenetwork ``` + +## Check your setup with `doctor` + Set your credentials, then check the machine is actually able to do the work: ```bash @@ -34,19 +37,19 @@ export HUBBLE_API_TOKEN= hubblenetwork doctor ``` -`doctor` is the setup step. It checks credentials, Bluetooth and Docker, and names -the fix for anything broken: +`doctor` is the setup step. It checks credentials, Bluetooth, Docker and the cached +satellite receiver image, and names the fix for anything broken: ``` - x Credentials not set + ✗ Credentials not set Set both, or pass --org-id/--token: export HUBBLE_ORG_ID= export HUBBLE_API_TOKEN= - + Bluetooth usage description present - x Docker Docker is not available + ✓ Bluetooth usage description present + ✗ Docker Docker is not available Only `sat` commands need Docker. -Not ready. | 1 ok | 2 failed +Not ready. · 1 ok · 2 failed ``` It exits 1 when something needed is broken, so a script can gate on it. A skipped @@ -58,13 +61,14 @@ not be answered without doing real work (pulling the satellite receiver image, s | Goal | Command | |------|---------| -| Check my setup is working | [`doctor`](#install) | +| Check my setup is working | [`doctor`](#check-your-setup-with-doctor) | | Watch nearby devices report | [`ble scan`](#watch-nearby-devices-report) | | Prove one device works end to end | [`ble validate`](#prove-one-device-works-end-to-end) | +| Put a key and config onto a new device | [`ready provision`](#provision-a-device-over-bluetooth) | | See what's registered to my org | [`org list-devices`](#work-with-your-fleet-in-the-cloud) | | Read a device's history from the cloud | [`org get-packets `](#work-with-your-fleet-in-the-cloud) | | Register a new device and get its key | [`org register-device`](#work-with-your-fleet-in-the-cloud) | -| Receive packets from satellite | [`sat scan`](#receive-satellite-packets) | +| Hear a device's satellite uplink | [`sat scan`](#receive-a-devices-satellite-uplink) | | Capture raw RF for offline analysis | [`sat record`](#one-shot-capture-record--signal-report) | Reference: [Reading the output](#reading-the-output) · @@ -73,7 +77,8 @@ Reference: [Reading the output](#reading-the-output) · [Python library](#using-it-as-a-python-library) · [Troubleshooting](#troubleshooting) -Every command lives inside a group, so it is always `hubblenetwork `: +Almost every command lives inside a group, so it is usually +`hubblenetwork `: * **`org`** — your devices in the Hubble Cloud * **`ble`** — nearby devices over Bluetooth @@ -81,9 +86,31 @@ Every command lives inside a group, so it is always `hubblenetwork # status flags +hubblenetwork ready read-key-info -a # which key and cipher are loaded +hubblenetwork ready read-config -a # EID type, rotation period, pool size +hubblenetwork ready read-time -a + +hubblenetwork ready write-key -a -k +hubblenetwork ready write-config -a --eid-type utc +hubblenetwork ready write-time -a # defaults to now +``` + +Two related checks live on the `ble` side, because they read advertisements instead of +connecting: `ble check-time -k ` reports how many days a device's clock is off +real UTC (more than 2 is out of spec), and `ble detect -k ` answers just the +"which EID mode is this key using?" question that `ble scan` folds into its +auto-detection. + + ## Work with your fleet in the cloud The `org` group talks to the Hubble Cloud, so it needs credentials. @@ -178,10 +260,17 @@ hubblenetwork org set-device-name hubblenetwork org delete-device ``` -`list-devices` and `get-packets` stream rows as pages arrive, so the first rows appear -in about a second rather than after the whole window downloads. A busy device can hold -tens of thousands of packets; Ctrl+C stops early and still prints a summary, and -`--limit`/`-n` caps the run. It always says how it stopped, never silently. +In the default tabular output, `list-devices` and `get-packets` stream rows as pages +arrive, so the first rows appear in about a second rather than after the whole window +downloads. A busy device can hold tens of thousands of packets; Ctrl+C stops early and +still prints a summary, and `--limit`/`-n` caps the run. It always says how it +stopped, never silently. + +`-o json` and `-o csv` keep their byte-for-byte output, so they buffer instead: the +whole result is collected before anything is printed, and there is no summary. For +`get-packets` that also means `--limit` trims the result after the download rather +than stopping it, so `-n 50 -o json` still fetches the full window. Use the tabular +output when you want the run itself bounded. `register-device` takes `--encryption`, `--counter-source`, and — for AES-128-EAX on `DEVICE_UPTIME` — either `--period-seconds` or `--period-exponent` (period = 2ⁿ @@ -189,9 +278,12 @@ seconds; the cloud accepts 10-15, default 15 ≈ 9h). The two period flags are m exclusive. -## Receive satellite packets +## Receive a device's satellite uplink -The `sat` group receives packets through a PlutoSDR dongle. It runs a Docker container +The `sat` group listens, on the ground, to the transmissions a device sends up to the +satellite network. Nothing is received *from* a satellite: a PlutoSDR beside you picks +the uplink out of the air, which is how you confirm a device is transmitting on the +satellite path at all. It runs a Docker container ([`ghcr.io/hubblenetwork/sdr-docker`](https://ghcr.io/hubblenetwork/sdr-docker)) that handles RF reception and decoding, polls that container's HTTP API, and streams decoded packets to stdout. @@ -278,12 +370,12 @@ a summary: ``` TIME RSSI V EID CTR/SEQ PAYLOAD ───────────────────────────────────────────────────────────────────────────── -✓ 00:06:40 -62 ███▏ 2 9c4e2ab77d3f0e1a 20320 T=21.4,B=87 -✓ 00:06:43 -66 ██▉ 2 9c4e2ab77d3f0e1b 20321 T=21.4,B=87 -✗ 00:06:49 -74 ██▏ 2 9c4e2ab77d3f0e1d - D307912C66BA4018E5 +✓ 00:06:40 -62 ███▏ 0 9c4e2ab7 20693 T=21.4,B=87 +✓ 00:06:43 -66 ██▉ 0 9c4e2ab7 20693 T=21.4,B=87 +✗ 00:06:49 -74 ██▏ 0 51d7be04 302 D307912C66BA4018E5 ───────────────────────────────────────────────────────────────────────────── -4 packets · 3 decrypted, 1 failed · RSSI -62 to -74 dBm · 12s +3 packets · 2 decrypted, 1 failed · RSSI -62 to -74 dBm · 12s ``` The bar next to RSSI is signal strength: length is the magnitude, so you can watch it @@ -291,6 +383,14 @@ shrink as you walk away from a device. The `✓`/`✗` mark only appears with `--show-failed-decryption`, and it carries the state on its own, so the output still reads correctly without colour. +`V` is the protocol version, and it decides what the two columns after it hold. `0` is +AES-CTR: a 4-byte EID, and a `CTR/SEQ` showing the day counter once a packet decrypts +(`20693` above) or the advertisement's own sequence number when it doesn't (`302`). +`1` is the unencrypted protocol, which has no EID at all, so a `NET_ID` column takes +that space instead. `2` is AES-EAX: an 8-byte EID, and a `CTR/SEQ` that stays `-`, +because the only counter-shaped value it carries is a random per-message nonce salt +and it already has its own `SALT` column under `--debug`. + Packet rows go to **stdout** and everything else — the scanning notice, detection lines, the summary — goes to **stderr**, so this captures data only: @@ -311,30 +411,37 @@ code page raises `UnicodeEncodeError`, and because most of them are East Asian W "Ambiguous" they render double-width under a CJK terminal configuration, which shears every column. -Pass `--ascii` (or set `HUBBLE_ASCII=1`) for a pure-ASCII rendering with identical -column widths: +Pass `--ascii` (or set `HUBBLE_ASCII=1`) for a pure-ASCII rendering of the same rows, +with identical column widths: ``` - TIME RSSI V EID CTR/SEQ PAYLOAD ---------------------------------------------------------------------------- - 15:50:13 -62 ###= 0 2030405 300 0A0B0C0D0E0F ---------------------------------------------------------------------------- + TIME RSSI V EID CTR/SEQ PAYLOAD +----------------------------------------------------------------------------- ++ 00:06:40 -62 ###= 0 9c4e2ab7 20693 T=21.4,B=87 ++ 00:06:43 -66 ##= 0 9c4e2ab7 20693 T=21.4,B=87 +x 00:06:49 -74 ##= 0 51d7be04 302 D307912C66BA4018E5 +----------------------------------------------------------------------------- -1 packets | RSSI -62 to -62 dBm | 0s +3 packets | 2 decrypted, 1 failed | RSSI -62 to -74 dBm | 12s ``` +Every substitution is the same display width as the glyph it replaces, so the columns +line up either way. The one thing that changes is the bar's precision: ASCII has no +sub-cell fill, so the eight partial blocks collapse to a single `=` tier and two +nearby readings can land on the same bar. The exact dBm is in the column beside it. + The encoding case is detected automatically, so you only need the flag for the double-width one. `--no-ascii` forces the Unicode rendering if the detection is wrong for you. -Colour is a separate axis: `--no-color`, `NO_COLOR=1`, or a non-TTY stdout all disable -it, and `FORCE_COLOR=1` keeps it on where a pipe would otherwise strip it (useful in -CI). Both flags work on any command, before or after the subcommand. +Colour is a separate axis: `--no-color`, a non-empty `NO_COLOR`, or a non-TTY stdout +all disable it, and `FORCE_COLOR` keeps it on where a pipe would otherwise strip it +(useful in CI). Both flags work on any command, before or after the subcommand. ## Configuration -The CLI reads two environment variables: +Two environment variables carry your credentials: * `HUBBLE_ORG_ID` — your organization id * `HUBBLE_API_TOKEN` — your API token, passed through as a bearer token @@ -344,6 +451,15 @@ export HUBBLE_ORG_ID=org_123 export HUBBLE_API_TOKEN=sk_XXXX ``` +Four more change how the CLI behaves, and none of them are required: + +| Variable | Effect | +|----------|--------| +| `HUBBLE_ASCII` | `1`/`true`/`yes`/`on` forces the ASCII rendering; `0`/`false`/`no`/`off` forces Unicode. Same as `--ascii`/`--no-ascii`, which win over it. | +| `NO_COLOR` | Any non-empty value disables colour, per [no-color.org](https://no-color.org). | +| `FORCE_COLOR` | Any non-empty value keeps colour on where a pipe would otherwise strip it. | +| `SDR_DOCKER_IMAGE` | Overrides the satellite receiver image. Default: `ghcr.io/hubblenetwork/sdr-docker:latest`. | + Every command that needs credentials also takes `--org-id` and `--token`, and `--help` names the environment variable for each. On the `org` and `metrics` groups those flags belong to the group, so they go before the subcommand: @@ -420,31 +536,49 @@ arrives instead of accumulating, so you can start processing immediately on a de with tens of thousands of packets. `list_devices()` and `retrieve_packets()` are `list()` wrappers over them and still return lists. -Scanning and local decryption: +Scanning and local decryption. `ble.scan()` returns a mixed list — the unencrypted +protocol and AES-EAX have their own packet types — so filter to `EncryptedPacket` +before handing anything to `decrypt()`, which only understands AES-CTR. It returns +`None` rather than raising on a packet it can't handle, so an unfiltered loop looks +like a wrong key: ```python -from hubblenetwork import ble, decrypt +from hubblenetwork import ble, decrypt, decrypt_eax, AesEaxPacket, EncryptedPacket + +key = bytes.fromhex("a562a2f7e4c62bed52ab09633878f62b") for pkt in ble.scan(timeout=5.0): - plaintext = decrypt(key, pkt) # UNIX_TIME by default - plaintext = decrypt(key, pkt, counter_mode="DEVICE_UPTIME") - if plaintext: - print(plaintext.payload) + if isinstance(pkt, EncryptedPacket): # AES-CTR + decrypted = decrypt(key, pkt) # UNIX_TIME by default + # decrypted = decrypt(key, pkt, counter_mode="DEVICE_UPTIME") + elif isinstance(pkt, AesEaxPacket): + decrypted = decrypt_eax(key, pkt, period_exponent=0) + else: + continue # nothing to decrypt + if decrypted: + print(decrypted.payload) # a DecryptedPacket ``` `counter_mode` accepts `"UNIX_TIME"` (default, UTC day-based) or `"DEVICE_UPTIME"` (counter values 0–127, fixed pool size of 128). BLE and provisioning functions each have sync and async variants — `ble.scan()` / `ble.scan_async()`. +The CLI's auto-detection is available too, in `hubblenetwork.detect`: +`detect_eid_type()` classifies a key's rotation mode from a batch of packets, and +`CtrCounterModeDetector` / `EaxExponentDetector` are the per-scan objects that own the +sweep and its cache. + Satellite, which manages the Docker container for you: ```python from hubblenetwork import sat, SatellitePacket, decrypt_satellite +key = bytes.fromhex("a562a2f7e4c62bed52ab09633878f62b") + for pkt in sat.scan(timeout=60.0, poll_interval=2.0): print(pkt.device_id, pkt.seq_num, pkt.rssi_dB, pkt.payload.hex()) if pkt.auth_tag is not None: - plaintext = decrypt_satellite( + plaintext: bytes | None = decrypt_satellite( key, seq_no=pkt.seq_num, auth_tag=pkt.auth_tag, encrypted_payload=pkt.payload, timestamp=pkt.timestamp, counter_mode="UNIX_TIME", @@ -455,11 +589,28 @@ iq_bytes: bytes = sat.record(10.0) # raw IQ (.npy file body) report: str = sat.signal_report(10.0) # plain-text RF diagnostic ``` +Note the return types differ: `decrypt()` and `decrypt_eax()` hand back a +`DecryptedPacket`, while `decrypt_satellite()` hands back the plaintext `bytes` +directly, because a satellite packet's metadata never left the `SatellitePacket` you +already have. Both return `None` on failure. + `SatellitePacket` fields: `device_id`, `seq_num`, `device_type`, `timestamp`, `rssi_dB`, `channel_num`, `freq_offset_hz`, `payload` (bytes), `auth_tag` (bytes or -`None`). `sat.scan()` raises `DockerError` if Docker isn't available, and -`SatelliteError` if the container starts but the receiver API or the SDR never comes -up. +`None`), plus four optional receiver diagnostics that back the `--debug` columns — +`pdu_n_corr` and `header_n_corr` (Reed-Solomon corrections, `RS_CORR`), `sym_mean_ms` +(`SYM_MS`) and `gap_mean_ms` (`GAP_MS`). + +The two satellite exceptions live in `hubblenetwork.errors` rather than the top-level +surface, so import them from there: + +```python +from hubblenetwork.errors import DockerError, SatelliteError +``` + +`sat.scan()` raises `DockerError` when Docker is missing, not running, or the +container fails to start, and `SatelliteError` when the container starts but the +receiver API or the SDR never comes up. Both descend from `HubbleError`, alongside the +backend, network, validation, BLE and decryption errors in the same module. See the code for the full surface. @@ -508,10 +659,12 @@ pytest ## Telemetry -**There is none.** The CLI makes no network call except the ones a command -explicitly needs: the Hubble Cloud API for `org` and `metrics`, `localhost` for the -satellite receiver container, and Docker pulling that container image from -`ghcr.io` on first `sat` use. Nothing is reported anywhere about how you use it. +**There is none.** The CLI makes no network call except the ones a command explicitly +needs: the Hubble Cloud API for the commands that use credentials (`org`, `metrics`, +`doctor`, `validate-credentials`, `ble validate`, `ble scan --ingest` and +`ready provision`); `localhost` for the satellite receiver container; and Docker +pulling that container image from `ghcr.io` on first `sat` use. Nothing is reported +anywhere about how you use it. If that changes, these are the constraints it would have to meet, recorded here so the bar is set before anyone writes the code: From de1c7dc02ed495c95f751ba3fe97daea58ddf03e Mon Sep 17 00:00:00 2001 From: Paul Buckley Date: Fri, 28 Aug 2026 07:25:20 -0700 Subject: [PATCH 3/4] docs: remove the ready command group from the README The `ready` commands are not working, so documenting them sends users at a broken path. Drops the "Provision a device over Bluetooth" section and every pointer to it: the use-case table row, the group list bullet, the intro blurb, the Bluetooth requirement, the SDK import list, and the telemetry command list. `ble check-time` and `ble detect` were documented inside that section because they contrast with connecting over GATT. They move to the end of `ble validate` with the lead rewritten, rather than being lost with it. Claude-Session: https://claude.ai/code/session_01Auxp5B8pSEsvFHRTCWFMUs --- README.md | 59 +++++++++++-------------------------------------------- 1 file changed, 12 insertions(+), 47 deletions(-) diff --git a/README.md b/README.md index 6e8780f..5860b53 100644 --- a/README.md +++ b/README.md @@ -6,8 +6,8 @@ **`hubblenetwork` is the command-line tool for Hubble Network IoT devices.** Watch nearby devices report over Bluetooth, pick their satellite uplink off the air with an -SDR, provision a device, decrypt payloads locally with a device key, and manage your -fleet in the Hubble Cloud — no embedded firmware knowledge required. +SDR, decrypt payloads locally with a device key, and manage your fleet in the Hubble +Cloud — no embedded firmware knowledge required. It is also an importable Python SDK: everything the CLI does is available as a library. See [Using it as a Python library](#using-it-as-a-python-library). @@ -64,7 +64,6 @@ not be answered without doing real work (pulling the satellite receiver image, s | Check my setup is working | [`doctor`](#check-your-setup-with-doctor) | | Watch nearby devices report | [`ble scan`](#watch-nearby-devices-report) | | Prove one device works end to end | [`ble validate`](#prove-one-device-works-end-to-end) | -| Put a key and config onto a new device | [`ready provision`](#provision-a-device-over-bluetooth) | | See what's registered to my org | [`org list-devices`](#work-with-your-fleet-in-the-cloud) | | Read a device's history from the cloud | [`org get-packets `](#work-with-your-fleet-in-the-cloud) | | Register a new device and get its key | [`org register-device`](#work-with-your-fleet-in-the-cloud) | @@ -82,7 +81,6 @@ Almost every command lives inside a group, so it is usually * **`org`** — your devices in the Hubble Cloud * **`ble`** — nearby devices over Bluetooth -* **`ready`** — provision a device over GATT * **`sat`** — satellite packets via PlutoSDR * **`metrics`** — fleet counts @@ -202,43 +200,10 @@ If a step fails, the command prints targeted debugging tips. A common cause of a failed scan is a slow advertising interval combined with OS-level BLE scan optimizations — simply running the command again often resolves it. - -## Provision a device over Bluetooth - -The `ready` group talks to a Hubble Ready device over a GATT connection (UUID 0xFCA7) -rather than just listening to its advertisements. `ready provision` is the whole flow -in one command: it scans, lets you pick a device, registers it with the Hubble Cloud, -then writes the key, the EID configuration and the clock, and reads each one back to -verify it. The encryption mode (AES-256-CTR or AES-128-CTR) is read off the device, -not guessed. - -```bash -hubblenetwork ready scan # what's advertising 0xFCA7 nearby? -hubblenetwork ready provision # the whole flow, interactive -hubblenetwork ready provision -v # with per-step progress -``` - -The individual steps are there too, for when you want one characteristic rather than -the whole flow. Each takes `-a`/`--address` — required everywhere except `ready info`, -which scans if you omit it — plus `-t`/`--timeout` and `-o`/`--format`: - -```bash -hubblenetwork ready info -a AA:BB:CC:DD:EE:FF # every characteristic -hubblenetwork ready read-status -a # status flags -hubblenetwork ready read-key-info -a # which key and cipher are loaded -hubblenetwork ready read-config -a # EID type, rotation period, pool size -hubblenetwork ready read-time -a - -hubblenetwork ready write-key -a -k -hubblenetwork ready write-config -a --eid-type utc -hubblenetwork ready write-time -a # defaults to now -``` - -Two related checks live on the `ble` side, because they read advertisements instead of -connecting: `ble check-time -k ` reports how many days a device's clock is off -real UTC (more than 2 is out of spec), and `ble detect -k ` answers just the -"which EID mode is this key using?" question that `ble scan` folds into its -auto-detection. +Two narrower checks sit alongside it, both reading advertisements rather than the +cloud: `ble check-time -k ` reports how many days a device's clock is off real +UTC (more than 2 is out of spec), and `ble detect -k ` answers just the "which +EID mode is this key using?" question that `ble scan` folds into its auto-detection. ## Work with your fleet in the cloud @@ -477,7 +442,7 @@ Check whichever route you used with `hubblenetwork validate-credentials` or ## Requirements - Python **3.10+** (3.11/3.12 recommended) -- **Bluetooth**, for the `ble` and `ready` groups: +- **Bluetooth**, for the `ble` group: - **macOS**: CoreBluetooth. Run from a real terminal app and grant it Bluetooth access when prompted. macOS kills any process whose executable has no `NSBluetoothAlwaysUsageDescription` in an Info.plist, and a bare Python binary @@ -502,7 +467,7 @@ for a stable surface: ```python from hubblenetwork import ( - ble, cloud, ready, sat, + ble, cloud, sat, Organization, Device, Credentials, Environment, EncryptedPacket, UnencryptedPacket, AesEaxPacket, UnknownPacket, DecryptedPacket, SatellitePacket, Location, @@ -560,8 +525,8 @@ for pkt in ble.scan(timeout=5.0): ``` `counter_mode` accepts `"UNIX_TIME"` (default, UTC day-based) or `"DEVICE_UPTIME"` -(counter values 0–127, fixed pool size of 128). BLE and provisioning functions each -have sync and async variants — `ble.scan()` / `ble.scan_async()`. +(counter values 0–127, fixed pool size of 128). The BLE functions have sync and async +variants — `ble.scan()` / `ble.scan_async()`. The CLI's auto-detection is available too, in `hubblenetwork.detect`: `detect_eid_type()` classifies a key's rotation mode from a batch of packets, and @@ -661,8 +626,8 @@ pytest **There is none.** The CLI makes no network call except the ones a command explicitly needs: the Hubble Cloud API for the commands that use credentials (`org`, `metrics`, -`doctor`, `validate-credentials`, `ble validate`, `ble scan --ingest` and -`ready provision`); `localhost` for the satellite receiver container; and Docker +`doctor`, `validate-credentials`, `ble validate` and `ble scan --ingest`); +`localhost` for the satellite receiver container; and Docker pulling that container image from `ghcr.io` on first `sat` use. Nothing is reported anywhere about how you use it. From 6bd5490182e85ee75b0e84a9681294fca0903e15 Mon Sep 17 00:00:00 2001 From: Paul Buckley Date: Fri, 28 Aug 2026 08:13:47 -0700 Subject: [PATCH 4/4] docs: trim README verbosity Cut ~13% of the README's words without dropping any commands, flags, or reference tables: - Merge the duplicated "also an SDK" intros into the opening sentence. - Drop the two illustrative blocks that repeated their own prose: the `Did you mean:` demo and the second (ASCII) sample table, which showed the same three rows as the Unicode one. - Collapse `ble validate`'s seven numbered steps into one arrow chain; the list restated what each step's name already said. - Turn the `V` protocol-version run-on into three bullets, one per case. - Compress the detection, satellite-container, org-buffering, ASCII and macOS-crash passages, and the telemetry constraints list. Claude-Session: https://claude.ai/code/session_01Auxp5B8pSEsvFHRTCWFMUs --- README.md | 366 ++++++++++++++++++++---------------------------------- 1 file changed, 134 insertions(+), 232 deletions(-) diff --git a/README.md b/README.md index 5860b53..4b8fcd1 100644 --- a/README.md +++ b/README.md @@ -7,10 +7,8 @@ **`hubblenetwork` is the command-line tool for Hubble Network IoT devices.** Watch nearby devices report over Bluetooth, pick their satellite uplink off the air with an SDR, decrypt payloads locally with a device key, and manage your fleet in the Hubble -Cloud — no embedded firmware knowledge required. - -It is also an importable Python SDK: everything the CLI does is available as a -library. See [Using it as a Python library](#using-it-as-a-python-library). +Cloud — no embedded firmware knowledge required. Everything it does is also available +as an importable [Python SDK](#using-it-as-a-python-library). Links: [PyPI](https://pypi.org/project/pyhubblenetwork/) · [Hubble docs](https://docs.hubble.com/docs/intro) · @@ -29,16 +27,14 @@ pipx install pyhubblenetwork ## Check your setup with `doctor` -Set your credentials, then check the machine is actually able to do the work: - ```bash export HUBBLE_ORG_ID= export HUBBLE_API_TOKEN= hubblenetwork doctor ``` -`doctor` is the setup step. It checks credentials, Bluetooth, Docker and the cached -satellite receiver image, and names the fix for anything broken: +`doctor` checks credentials, Bluetooth, Docker and the cached satellite receiver +image, and names the fix for anything broken: ``` ✗ Credentials not set @@ -76,52 +72,29 @@ Reference: [Reading the output](#reading-the-output) · [Python library](#using-it-as-a-python-library) · [Troubleshooting](#troubleshooting) -Almost every command lives inside a group, so it is usually -`hubblenetwork `: - -* **`org`** — your devices in the Hubble Cloud -* **`ble`** — nearby devices over Bluetooth -* **`sat`** — satellite packets via PlutoSDR -* **`metrics`** — fleet counts - -The two setup commands sit at the top level instead, because they are what you run -before you have anything working: `hubblenetwork doctor` and -`hubblenetwork validate-credentials`. - -`hubblenetwork --help` prints the full list with a one-line description and the -required arguments for each, and every command takes `--help` for its own options. - -You don't have to remember which group a command is in. If you type one at the wrong -level the CLI searches the whole tree and finds it for you: - -``` -$ hubblenetwork list-devices - -Usage: hubblenetwork [OPTIONS] COMMAND [ARGS]... -Try 'hubblenetwork --help' for help. - -Error: No such command 'list-devices'. - - Did you mean: hubblenetwork org list-devices -``` +Commands live in groups — **`org`** (your devices in the Hubble Cloud), **`ble`** +(nearby devices over Bluetooth), **`sat`** (satellite packets via PlutoSDR) and +**`metrics`** (fleet counts) — so it is usually `hubblenetwork `. The +two setup commands, `doctor` and `validate-credentials`, sit at the top level. -The same applies to missing arguments (they say how to find the value), unknown -options (they list what the command accepts), and missing credentials (they name the -environment variables and the flags). +`hubblenetwork --help` lists everything; every command takes `--help` for its own +options. You don't have to remember which group a command is in: type one at the wrong +level and the CLI searches the whole tree and points you at it (`Did you mean: +hubblenetwork org list-devices`). Missing arguments say how to find the value, unknown +options list what the command accepts, and missing credentials name the environment +variables and the flags. ## Watch nearby devices report `ble scan` listens for Hubble beacon advertisements (UUID 0xFCA6) and prints one line -per packet as it arrives. No credentials needed — this is purely local. +per packet as it arrives. No credentials needed — this is purely local. It runs until +Ctrl+C unless you bound it. ```bash hubblenetwork ble scan ``` -It runs until Ctrl+C unless you bound it. Pass `--key` and the payloads are decrypted -locally as they arrive. - | Flag | Takes | Description | |------|-------|-------------| | `-t`, `--timeout` | seconds | Stop after this long. Default: no timeout. | @@ -139,32 +112,26 @@ locally as they arrive. | `--payload-format` | `auto`, `base64`, `hex`, `string` | How to render payloads — see [Payload format](#payload-format). | | `--debug` | — | Add the `EPOCH`, `TAG` and `SALT` forensic columns. | -With `--key` and neither of those two flags given, the decryption strategy is worked -out from the packets themselves, so you don't have to know how the device was -provisioned. A `[WARN]` line says the sweep has started, and the first packet that -decrypts prints what it found, once, on stderr: - -``` -[INFO] Detected: AES-256-CTR, counter_source=DEVICE_UPTIME -``` +With `--key` and neither `--counter-mode` nor `--period-exponent` given, the +decryption strategy is worked out from the packets themselves, so you don't have to +know how the device was provisioned. The first packet that decrypts prints what it +found, once, on stderr — `[INFO] Detected: AES-256-CTR, +counter_source=DEVICE_UPTIME`. Passing either flag pins that half of the detection and +skips its sweep. -Passing `--counter-mode` or `--period-exponent` pins that half of the detection and -skips its sweep. Two rules come with it: `--counter-mode DEVICE_UPTIME` requires -`--key`, and it cannot be combined with `--days`, which only means something for the -day-based `UNIX_TIME` counter. Both are rejected with a usage error rather than -quietly ignored. `sat scan` behaves the same way. +`--counter-mode DEVICE_UPTIME` requires `--key` and cannot be combined with `--days`, +which only means something for the day-based `UNIX_TIME` counter; both are rejected +with a usage error rather than quietly ignored. `sat scan` behaves the same way. ## Prove one device works end to end `ble validate` is the quickest way to answer "is my device working?". It walks the -whole chain — your credentials, the device's registration, its advertisements, the -key, and the cloud round trip — and stops at the first failure. - -It validates the **terrestrial** path: the device advertising over Bluetooth, this -machine acting as the gateway, and the packet reaching the cloud and coming back. It -says nothing about whether the device is reaching the satellite network — for that, -hear the uplink yourself with [`sat scan`](#receive-a-devices-satellite-uplink). +whole chain and stops at the first failure: key and device-ID formats → credentials → +organization → the device's registration → BLE advertisements → decrypting a packet +(reporting the EID type as `UNIX_TIME`, `DEVICE_UPTIME`, or `AMBIGUOUS` when a packet +resolves under both, usually several devices in range with different configs) → +ingesting it and reading it back. ```bash hubblenetwork ble validate \ @@ -172,22 +139,6 @@ hubblenetwork ble validate \ --device-id "3f4b2c0c-2d43-4cbe-9c1f-0a4c2d59e2a1" ``` -The steps, in order: - -1. **Validates input formats** — the device key (hex or base64, 16- or 32-byte) - and the device ID (standard 8-4-4-4-12 UUID). -2. **Loads credentials** — from `--org-id`/`--token` or the `HUBBLE_ORG_ID` and - `HUBBLE_API_TOKEN` environment variables. -3. **Validates the organization credentials** against the backend. -4. **Confirms the device is registered** in your organization. -5. **Scans for BLE advertisements** from Hubble-compatible devices. -6. **Decrypts a received packet** with the provided key and reports the detected - EID type: `UNIX_TIME`, `DEVICE_UPTIME`, or `AMBIGUOUS` when a packet resolves - under both — usually several devices in range with different configs, so it - prints a note telling you to check the device config. -7. **Ingests the packet** into the backend and **reads it back** to confirm the - full round trip succeeded. - | Option | Description | |--------|-------------| | `--key`, `-k` | Device key, used to test packet encryption (required). Accepts hex or base64, 16- or 32-byte. | @@ -196,9 +147,14 @@ The steps, in order: | `--token` | API token (defaults to the `HUBBLE_API_TOKEN` env var). | | `--timeout`, `-t` | BLE scan timeout in seconds (default: 30). | -If a step fails, the command prints targeted debugging tips. A common cause of a -failed scan is a slow advertising interval combined with OS-level BLE scan -optimizations — simply running the command again often resolves it. +This is the **terrestrial** path only — the device advertising over Bluetooth, this +machine as the gateway, and the round trip through the cloud. It says nothing about +whether the device is reaching the satellite network; for that, hear the uplink +yourself with [`sat scan`](#receive-a-devices-satellite-uplink). + +A failing step prints targeted debugging tips. A failed scan is often just a slow +advertising interval meeting OS-level BLE scan optimizations — running it again +usually resolves it. Two narrower checks sit alongside it, both reading advertisements rather than the cloud: `ble check-time -k ` reports how many days a device's clock is off real @@ -225,17 +181,15 @@ hubblenetwork org set-device-name hubblenetwork org delete-device ``` -In the default tabular output, `list-devices` and `get-packets` stream rows as pages -arrive, so the first rows appear in about a second rather than after the whole window -downloads. A busy device can hold tens of thousands of packets; Ctrl+C stops early and -still prints a summary, and `--limit`/`-n` caps the run. It always says how it -stopped, never silently. +In tabular output, `list-devices` and `get-packets` stream rows as pages arrive, so +the first rows appear in about a second rather than after the whole window downloads. +A busy device can hold tens of thousands of packets; Ctrl+C stops early and still +prints a summary, `--limit`/`-n` caps the run, and it always says how it stopped. -`-o json` and `-o csv` keep their byte-for-byte output, so they buffer instead: the -whole result is collected before anything is printed, and there is no summary. For -`get-packets` that also means `--limit` trims the result after the download rather -than stopping it, so `-n 50 -o json` still fetches the full window. Use the tabular -output when you want the run itself bounded. +`-o json` and `-o csv` keep their byte-for-byte output, so they buffer the whole +result and print no summary. For `get-packets`, `--limit` then trims after the +download rather than stopping it — `-n 50 -o json` still fetches the full window. Use +tabular output when you want the run itself bounded. `register-device` takes `--encryption`, `--counter-source`, and — for AES-128-EAX on `DEVICE_UPTIME` — either `--period-seconds` or `--period-exponent` (period = 2ⁿ @@ -247,11 +201,8 @@ exclusive. The `sat` group listens, on the ground, to the transmissions a device sends up to the satellite network. Nothing is received *from* a satellite: a PlutoSDR beside you picks -the uplink out of the air, which is how you confirm a device is transmitting on the -satellite path at all. It runs a Docker container -([`ghcr.io/hubblenetwork/sdr-docker`](https://ghcr.io/hubblenetwork/sdr-docker)) that -handles RF reception and decoding, polls that container's HTTP API, and streams -decoded packets to stdout. +the uplink out of the air, confirming the device is transmitting on the satellite path +at all. **Needs Docker running and an ADALM-PLUTO plugged in over USB.** `hubblenetwork doctor` checks the Docker half. @@ -274,23 +225,23 @@ hubblenetwork sat scan --key "" --show-failed-decryption hubblenetwork sat mock-scan ``` -Decryption uses the same AES-CTR scheme as BLE and supports both counter sources. The -source is auto-detected from the packets and announced, unless `--counter-mode` is -given. For `UNIX_TIME`, `--days` controls how many days around each packet's timestamp -are searched (default 2). Packets the key cannot decrypt are hidden unless -`--show-failed-decryption` is given. +Decryption uses the same AES-CTR scheme as BLE, with the counter source auto-detected +and announced unless `--counter-mode` is given. `--days` (default 2) controls how many +days around each packet's timestamp are searched in `UNIX_TIME` mode, and packets the +key cannot decrypt are hidden unless `--show-failed-decryption` is given. -`sat scan` handles the container for you: it verifies Docker, pulls the image if it -isn't cached, starts the container privileged so it can reach USB, waits for the -receiver API and for the SDR to connect, deduplicates packets by device ID and -sequence number, then stops and removes the container on exit. +`sat scan` handles the Docker container +([`ghcr.io/hubblenetwork/sdr-docker`](https://ghcr.io/hubblenetwork/sdr-docker)) for +you: it verifies Docker, pulls the image if it isn't cached, starts the container +privileged so it can reach USB, waits for the receiver API and the SDR, polls the +container's HTTP API and deduplicates by device ID and sequence number, then stops and +removes the container on exit. ### One-shot capture (`record` / `signal-report`) -Alongside the live stream, two commands record for a fixed duration, save a single -file, and exit. Both accept `--output PATH` (default: an auto-generated timestamped -name), `--mock` (use the simulated receiver — no PlutoSDR required), `--pluto-uri`, -and `--debug`. +Two commands record for a fixed duration, save a single file, and exit. Both accept +`--output PATH` (default: an auto-generated timestamped name), `--mock` (simulated +receiver — no PlutoSDR required), `--pluto-uri`, and `--debug`. ```bash # Capture 10 s of raw IQ samples to a .npy file @@ -302,12 +253,12 @@ hubblenetwork sat signal-report 10 hubblenetwork sat signal-report 10 --output report.txt --mock ``` -- **`record`** captures the raw radio signal only — no decoding. The output is a - NumPy `.npy` file of IQ samples. +- **`record`** captures the raw radio signal only — no decoding. Output is a NumPy + `.npy` file of IQ samples. - **`signal-report`** records IQ, then re-analyzes it offline into a plain-text **link-health diagnostic**: per-symbol timing/drift, channel-hopping validation, - amplitude/SNR, and chipset metrics. It reports on signal quality and does **not** - contain decoded packet payloads — to receive payloads, use `sat scan --key`. + amplitude/SNR, chipset metrics. It holds no decoded payloads — for those, use + `sat scan --key`. ## Reading the output @@ -322,10 +273,10 @@ Commands that print packet data (`ble scan`, `sat scan`, `ble detect`, * `hex` — display payloads as hexadecimal * `string` — decode payloads as UTF-8 (falls back to ``) -All four work with every output format, but the **default** differs, because a person -and a program want different things. Tabular output defaults to `auto`, so a decrypted -payload reads as `T=21.4` rather than `VD0yMS40`. JSON and CSV default to `base64` so -the machine contract stays stable. An explicit `--payload-format` always wins. +All four work with every output format, but the **default** differs: tabular output +defaults to `auto`, so a decrypted payload reads as `T=21.4` rather than `VD0yMS40`, +while JSON and CSV default to `base64` so the machine contract stays stable. An +explicit `--payload-format` always wins. ### Scan layout @@ -343,27 +294,24 @@ a summary: 3 packets · 2 decrypted, 1 failed · RSSI -62 to -74 dBm · 12s ``` -The bar next to RSSI is signal strength: length is the magnitude, so you can watch it -shrink as you walk away from a device. The `✓`/`✗` mark only appears with -`--show-failed-decryption`, and it carries the state on its own, so the output still -reads correctly without colour. +The bar's length is signal magnitude, so you can watch it shrink as you walk away from +a device. The `✓`/`✗` mark only appears with `--show-failed-decryption`, and it carries +the state on its own, so the output still reads correctly without colour. -`V` is the protocol version, and it decides what the two columns after it hold. `0` is -AES-CTR: a 4-byte EID, and a `CTR/SEQ` showing the day counter once a packet decrypts -(`20693` above) or the advertisement's own sequence number when it doesn't (`302`). -`1` is the unencrypted protocol, which has no EID at all, so a `NET_ID` column takes -that space instead. `2` is AES-EAX: an 8-byte EID, and a `CTR/SEQ` that stays `-`, -because the only counter-shaped value it carries is a random per-message nonce salt -and it already has its own `SALT` column under `--debug`. +`V` is the protocol version, and it decides what the two columns after it hold: -Packet rows go to **stdout** and everything else — the scanning notice, detection -lines, the summary — goes to **stderr**, so this captures data only: +* **`0`, AES-CTR** — a 4-byte EID, and a `CTR/SEQ` showing the day counter once a + packet decrypts (`20693` above) or the advertisement's own sequence number when it + doesn't (`302`). +* **`1`, unencrypted** — no EID at all, so a `NET_ID` column takes that space. +* **`2`, AES-EAX** — an 8-byte EID, and a `CTR/SEQ` that stays `-`: its only + counter-shaped value is a random per-message nonce salt, which has its own `SALT` + column under `--debug`. -```bash -hubblenetwork ble scan > packets.txt -``` - -The same split applies to `org list-devices` and `org get-packets`. +Packet rows go to **stdout** and everything else — the scanning notice, detection +lines, the summary — goes to **stderr**, so `hubblenetwork ble scan > packets.txt` +captures data only. The same split applies to `org list-devices` and +`org get-packets`. Pass `--debug` for the forensic columns: `EPOCH`, `TAG` and `SALT` on `ble scan`, `RS_CORR`, `SYM_MS` and `GAP_MS` on `sat scan`, `EPOCH`, `CTR` and `SEQ` on @@ -371,33 +319,16 @@ Pass `--debug` for the forensic columns: `EPOCH`, `TAG` and `SALT` on `ble scan` ### Terminals that can't do box-drawing -Not every terminal can render `─` and `█`. Writing them to a stdout using a legacy -code page raises `UnicodeEncodeError`, and because most of them are East Asian Width -"Ambiguous" they render double-width under a CJK terminal configuration, which shears -every column. - -Pass `--ascii` (or set `HUBBLE_ASCII=1`) for a pure-ASCII rendering of the same rows, -with identical column widths: - -``` - TIME RSSI V EID CTR/SEQ PAYLOAD ------------------------------------------------------------------------------ -+ 00:06:40 -62 ###= 0 9c4e2ab7 20693 T=21.4,B=87 -+ 00:06:43 -66 ##= 0 9c4e2ab7 20693 T=21.4,B=87 -x 00:06:49 -74 ##= 0 51d7be04 302 D307912C66BA4018E5 ------------------------------------------------------------------------------ - -3 packets | 2 decrypted, 1 failed | RSSI -62 to -74 dBm | 12s -``` - -Every substitution is the same display width as the glyph it replaces, so the columns -line up either way. The one thing that changes is the bar's precision: ASCII has no -sub-cell fill, so the eight partial blocks collapse to a single `=` tier and two -nearby readings can land on the same bar. The exact dBm is in the column beside it. +Not every terminal can render `─` and `█`: a legacy code page raises +`UnicodeEncodeError`, and a CJK configuration renders them double-width, which shears +every column. Pass `--ascii` (or set `HUBBLE_ASCII=1`) for a pure-ASCII rendering with +identical column widths — every substitution is the same display width as the glyph it +replaces. Only the bar's precision changes: ASCII has no sub-cell fill, so the eight +partial blocks collapse to one `=` tier and two nearby readings can land on the same +bar. The exact dBm is in the column beside it. The encoding case is detected automatically, so you only need the flag for the -double-width one. `--no-ascii` forces the Unicode rendering if the detection is wrong -for you. +double-width one; `--no-ascii` forces the Unicode rendering back. Colour is a separate axis: `--no-color`, a non-empty `NO_COLOR`, or a non-TTY stdout all disable it, and `FORCE_COLOR` keeps it on where a pipe would otherwise strip it @@ -408,12 +339,9 @@ all disable it, and `FORCE_COLOR` keeps it on where a pipe would otherwise strip Two environment variables carry your credentials: -* `HUBBLE_ORG_ID` — your organization id -* `HUBBLE_API_TOKEN` — your API token, passed through as a bearer token - ```bash -export HUBBLE_ORG_ID=org_123 -export HUBBLE_API_TOKEN=sk_XXXX +export HUBBLE_ORG_ID=org_123 # your organization id +export HUBBLE_API_TOKEN=sk_XXXX # passed through as a bearer token ``` Four more change how the CLI behaves, and none of them are required: @@ -434,9 +362,7 @@ hubblenetwork org --org-id --token list-devices ``` Check whichever route you used with `hubblenetwork validate-credentials` or -`hubblenetwork doctor`. - -**The SDK does not read the environment** — see below. +`hubblenetwork doctor`. **The SDK does not read the environment** — see below. ## Requirements @@ -462,8 +388,7 @@ Check whichever route you used with `hubblenetwork validate-credentials` or ## Using it as a Python library -Everything the CLI does is available as a library. Import from the package top-level -for a stable surface: +Import from the package top-level for a stable surface: ```python from hubblenetwork import ( @@ -496,16 +421,14 @@ for pkt in org.iter_packets(new_dev): # ditto; both take on_page(page, total print(pkt.rssi, pkt.payload) ``` -`iter_devices()` and `iter_packets()` are generators that yield as each API page -arrives instead of accumulating, so you can start processing immediately on a device -with tens of thousands of packets. `list_devices()` and `retrieve_packets()` are -`list()` wrappers over them and still return lists. +The iterators yield as each API page arrives instead of accumulating, so you can start +processing immediately on a device with tens of thousands of packets. +`list_devices()` and `retrieve_packets()` are `list()` wrappers over them. -Scanning and local decryption. `ble.scan()` returns a mixed list — the unencrypted -protocol and AES-EAX have their own packet types — so filter to `EncryptedPacket` -before handing anything to `decrypt()`, which only understands AES-CTR. It returns -`None` rather than raising on a packet it can't handle, so an unfiltered loop looks -like a wrong key: +`ble.scan()` returns a mixed list — the unencrypted protocol and AES-EAX have their +own packet types — so filter to `EncryptedPacket` before handing anything to +`decrypt()`, which only understands AES-CTR. It returns `None` rather than raising on +a packet it can't handle, so an unfiltered loop looks like a wrong key: ```python from hubblenetwork import ble, decrypt, decrypt_eax, AesEaxPacket, EncryptedPacket @@ -526,12 +449,10 @@ for pkt in ble.scan(timeout=5.0): `counter_mode` accepts `"UNIX_TIME"` (default, UTC day-based) or `"DEVICE_UPTIME"` (counter values 0–127, fixed pool size of 128). The BLE functions have sync and async -variants — `ble.scan()` / `ble.scan_async()`. - -The CLI's auto-detection is available too, in `hubblenetwork.detect`: -`detect_eid_type()` classifies a key's rotation mode from a batch of packets, and -`CtrCounterModeDetector` / `EaxExponentDetector` are the per-scan objects that own the -sweep and its cache. +variants — `ble.scan()` / `ble.scan_async()`. The CLI's auto-detection is available +too, in `hubblenetwork.detect`: `detect_eid_type()` classifies a key's rotation mode +from a batch of packets, and `CtrCounterModeDetector` / `EaxExponentDetector` are the +per-scan objects that own the sweep and its cache. Satellite, which manages the Docker container for you: @@ -554,10 +475,10 @@ iq_bytes: bytes = sat.record(10.0) # raw IQ (.npy file body) report: str = sat.signal_report(10.0) # plain-text RF diagnostic ``` -Note the return types differ: `decrypt()` and `decrypt_eax()` hand back a -`DecryptedPacket`, while `decrypt_satellite()` hands back the plaintext `bytes` -directly, because a satellite packet's metadata never left the `SatellitePacket` you -already have. Both return `None` on failure. +The return types differ: `decrypt()` and `decrypt_eax()` hand back a +`DecryptedPacket`, while `decrypt_satellite()` hands back plaintext `bytes` directly, +because a satellite packet's metadata never left the `SatellitePacket` you already +have. Both return `None` on failure. `SatellitePacket` fields: `device_id`, `seq_num`, `device_type`, `timestamp`, `rssi_dB`, `channel_num`, `freq_offset_hz`, `payload` (bytes), `auth_tag` (bytes or @@ -566,35 +487,28 @@ already have. Both return `None` on failure. (`SYM_MS`) and `gap_mean_ms` (`GAP_MS`). The two satellite exceptions live in `hubblenetwork.errors` rather than the top-level -surface, so import them from there: - -```python -from hubblenetwork.errors import DockerError, SatelliteError -``` - -`sat.scan()` raises `DockerError` when Docker is missing, not running, or the +surface. `sat.scan()` raises `DockerError` when Docker is missing, not running, or the container fails to start, and `SatelliteError` when the container starts but the receiver API or the SDR never comes up. Both descend from `HubbleError`, alongside the backend, network, validation, BLE and decryption errors in the same module. -See the code for the full surface. +```python +from hubblenetwork.errors import DockerError, SatelliteError +``` ## Troubleshooting -* **macOS: `ble scan` crashes instead of prompting for Bluetooth** — you'll see - `Termination Reason: Namespace TCC` and a message about a missing - `NSBluetoothAlwaysUsageDescription` key. macOS refuses CoreBluetooth to any - executable without that key in an Info.plist, and Homebrew's `python3` binary has - no Info.plist at all. Run from a real terminal app (Terminal, iTerm) rather than an - embedded IDE shell and grant it Bluetooth under System Settings → Privacy & - Security → Bluetooth. If it still aborts, run the CLI through a small app bundle - that carries the key; the framework build at - `$(brew --prefix)/Frameworks/Python.framework/Versions//Resources/Python.app` - is a usable starting point to copy and amend. +* **macOS: `ble scan` crashes instead of prompting for Bluetooth** (`Termination + Reason: Namespace TCC`, missing `NSBluetoothAlwaysUsageDescription`) — macOS refuses + CoreBluetooth to any executable without that key in an Info.plist, and Homebrew's + `python3` has no Info.plist at all. Run from a real terminal app (Terminal, iTerm) + rather than an embedded IDE shell, and grant it Bluetooth under System Settings → + Privacy & Security → Bluetooth. If it still aborts, run the CLI through a small app + bundle carrying the key — copy and amend + `$(brew --prefix)/Frameworks/Python.framework/Versions//Resources/Python.app`. * **`ble scan` finds nothing**: verify BLE permissions and adapter state, and try a - longer `--timeout`. Slow advertising intervals plus OS-level scan optimizations mean - a second attempt often succeeds. + longer `--timeout`. A second attempt often succeeds. * **Auth errors**: run `hubblenetwork doctor`. `validate-credentials` reports which environment accepted them and exits 1 if neither did, so it is safe in a script. * **Import errors**: ensure you installed into the Python you're running @@ -626,28 +540,16 @@ pytest **There is none.** The CLI makes no network call except the ones a command explicitly needs: the Hubble Cloud API for the commands that use credentials (`org`, `metrics`, -`doctor`, `validate-credentials`, `ble validate` and `ble scan --ingest`); -`localhost` for the satellite receiver container; and Docker -pulling that container image from `ghcr.io` on first `sat` use. Nothing is reported -anywhere about how you use it. - -If that changes, these are the constraints it would have to meet, recorded here so -the bar is set before anyone writes the code: - -* **Opt-in only.** Off by default, no collection before an explicit yes, and no - dark-pattern prompt that treats a dismissed dialog as consent. -* **Nothing sensitive, ever.** No API tokens, org IDs, device IDs, encryption keys, - payloads, coordinates, hostnames, or file paths. This tool handles customer device - keys, so the bar is higher than for a typical CLI. Command name, exit status, and - version is the ceiling. -* **Documented in this file**, listing every field actually sent, not a link to a - policy page. -* **Killable two ways**, a flag and an environment variable, both honoured on every - command. -* **Never blocks or slows a command.** No network call on the critical path, and - silent failure when offline. -* **Tested.** A test asserting the payload contains no credential and no device - identifier, so a future field cannot quietly widen it. +`doctor`, `validate-credentials`, `ble validate` and `ble scan --ingest`); `localhost` +for the satellite receiver container; and Docker pulling that container image from +`ghcr.io` on first `sat` use. Nothing is reported anywhere about how you use it. + +If that ever changes, it would have to be opt-in and off by default; carry nothing +sensitive (no tokens, org or device IDs, keys, payloads, coordinates, hostnames or +paths — command name, exit status and version is the ceiling); list every field sent +in this file; be killable by both a flag and an environment variable; never sit on a +command's critical path; and be covered by a test asserting the payload holds no +credential and no device identifier. ## Releases & versioning