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. Everything it does is also available
as an importable Python SDK.
Links: PyPI · Hubble docs · Embedded SDK
pip install pyhubblenetwork
# or, for CLI-only use, into its own environment:
pipx install pyhubblenetworkexport HUBBLE_ORG_ID=<your org id>
export HUBBLE_API_TOKEN=<your api token>
hubblenetwork doctordoctor checks credentials, Bluetooth, Docker and the cached satellite receiver
image, and names the fix for anything broken:
✗ Credentials not set
Set both, or pass --org-id/--token:
export HUBBLE_ORG_ID=<your org id>
export HUBBLE_API_TOKEN=<your api token>
✓ Bluetooth usage description present
✗ Docker Docker is not available
Only `sat` commands need Docker.
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).
| Goal | Command |
|---|---|
| Check my setup is working | doctor |
| Watch nearby devices report | ble scan |
| Prove one device works end to end | ble validate |
| See what's registered to my org | org list-devices |
| Read a device's history from the cloud | org get-packets <id> |
| Register a new device and get its key | org register-device |
| Hear a device's satellite uplink | sat scan |
| Capture raw RF for offline analysis | sat record |
Reference: Reading the output · Configuration · Requirements · Python library · Troubleshooting
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 <group> <command>. The
two setup commands, doctor and validate-credentials, sit at the top level.
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.
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. It runs until
Ctrl+C unless you bound it.
hubblenetwork ble scan| 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. Omit it to auto-detect — see below. |
-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. Omit it to auto-detect — see below. |
--network-id |
34-bit ID | Show only this network. Unencrypted protocol only. |
--ingest |
— | Relay decrypted packets to the Hubble Cloud. Without --key nothing is ingested. Needs 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. |
--debug |
— | Add the EPOCH, TAG and SALT forensic columns. |
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.
--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.
ble validate is the quickest way to answer "is my device working?". It walks the
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.
hubblenetwork ble validate \
--key "a562a2f7e4c62bed52ab09633878f62b" \
--device-id "3f4b2c0c-2d43-4cbe-9c1f-0a4c2d59e2a1"| Option | Description |
|---|---|
--key, -k |
Device key, used to test packet encryption (required). Accepts hex or base64, 16- or 32-byte. |
--device-id, -d |
Device UUID, used to test the backend (required). |
--org-id |
Organization ID (defaults to the HUBBLE_ORG_ID env var). |
--token |
API token (defaults to the HUBBLE_API_TOKEN env var). |
--timeout, -t |
BLE scan timeout in seconds (default: 30). |
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.
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 <key> reports how many days a device's clock is off real
UTC (more than 2 is out of spec), and ble detect -k <key> answers just the "which
EID mode is this key using?" question that ble scan folds into its auto-detection.
The org group talks to the Hubble Cloud, so it needs credentials.
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 <id> # last 7 days by default
hubblenetwork org get-packets <id> --days 30 --format csv
hubblenetwork org get-packets <id> -n 50 --debug
hubblenetwork org register-device # returns the new device's key
hubblenetwork org set-device-name <id> <name>
hubblenetwork org delete-device <id>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 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ⁿ
seconds; the cloud accepts 10-15, default 15 ≈ 9h). The two period flags are mutually
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, 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.
# Stream packets until Ctrl+C
hubblenetwork sat scan
# Bounded runs
hubblenetwork sat scan --timeout 30
hubblenetwork sat scan -n 5
hubblenetwork sat scan -o json --timeout 60 -n 20
# Decrypt payloads locally with a device key
hubblenetwork sat scan --key "a562a2f7e4c62bed52ab09633878f62b"
hubblenetwork sat scan --key "<key>" --counter-mode DEVICE_UPTIME
hubblenetwork sat scan --key "<key>" --show-failed-decryption
# No hardware to hand? Stream fake packets
hubblenetwork sat mock-scanDecryption 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 Docker container
(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.
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.
# Capture 10 s of raw IQ samples to a .npy file
hubblenetwork sat record 10
hubblenetwork sat record 10 --output capture.npy
# Record 10 s and save an RF signal-diagnostic report to a .txt file
hubblenetwork sat signal-report 10
hubblenetwork sat signal-report 10 --output report.txt --mockrecordcaptures the raw radio signal only — no decoding. Output is a NumPy.npyfile of IQ samples.signal-reportrecords IQ, then re-analyzes it offline into a plain-text link-health diagnostic: per-symbol timing/drift, channel-hopping validation, amplitude/SNR, chipset metrics. It holds no decoded payloads — for those, usesat scan --key.
Commands that print packet data (ble scan, sat scan, ble detect,
org get-packets) take --payload-format:
auto— printable ASCII shows as text, anything else as uppercase hexbase64— encode payloads as base64hex— display payloads as hexadecimalstring— decode payloads as UTF-8 (falls back to<invalid UTF-8>)
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.
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 ███▏ 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
─────────────────────────────────────────────────────────────────────────────
3 packets · 2 decrypted, 1 failed · RSSI -62 to -74 dBm · 12s
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, AES-CTR — a 4-byte EID, and aCTR/SEQshowing the day counter once a packet decrypts (20693above) or the advertisement's own sequence number when it doesn't (302).1, unencrypted — no EID at all, so aNET_IDcolumn takes that space.2, AES-EAX — an 8-byte EID, and aCTR/SEQthat stays-: its only counter-shaped value is a random per-message nonce salt, which has its ownSALTcolumn under--debug.
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
org get-packets.
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 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
(useful in CI). Both flags work on any command, before or after the subcommand.
Two environment variables carry your credentials:
export HUBBLE_ORG_ID=org_123 # your organization id
export HUBBLE_API_TOKEN=sk_XXXX # passed through as a bearer tokenFour 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. |
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:
hubblenetwork org --org-id <id> --token <token> list-devicesCheck whichever route you used with hubblenetwork validate-credentials or
hubblenetwork doctor. The SDK does not read the environment — see below.
- Python 3.10+ (3.11/3.12 recommended)
- Bluetooth, for the
blegroup:- macOS: CoreBluetooth. Run from a real terminal app and grant it Bluetooth
access when prompted. macOS kills any process whose executable has no
NSBluetoothAlwaysUsageDescriptionin an Info.plist, and a bare Python binary has none — see 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
bluetoothgroup). - Windows: a compatible BLE stack/adapter.
- macOS: CoreBluetooth. Run from a real terminal app and grant it Bluetooth
access when prompted. macOS kills any process whose executable has no
- Docker and a PlutoSDR, for the
satgroup: Docker Desktop (macOS/Windows) or Docker Engine (Linux) installed and running, and an Analog Devices ADALM-PLUTO connected over USB.sat mock-scan,sat record --mockandsat signal-report --mockneed Docker but no SDR.
hubblenetwork doctor reports on all of this.
Import from the package top-level for a stable surface:
from hubblenetwork import (
ble, cloud, sat,
Organization, Device, Credentials, Environment,
EncryptedPacket, UnencryptedPacket, AesEaxPacket, UnknownPacket,
DecryptedPacket, SatellitePacket, Location,
decrypt, decrypt_eax, decrypt_satellite,
UNIX_TIME, DEVICE_UPTIME,
InvalidCredentialsError,
)Unlike the CLI, the SDK does not read the environment. Organization() requires
its credentials explicitly:
import os
from hubblenetwork import Organization
org = Organization(
org_id=os.environ["HUBBLE_ORG_ID"],
api_token=os.environ["HUBBLE_API_TOKEN"],
)
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)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.
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:
from hubblenetwork import ble, decrypt, decrypt_eax, AesEaxPacket, EncryptedPacket
key = bytes.fromhex("a562a2f7e4c62bed52ab09633878f62b")
for pkt in ble.scan(timeout=5.0):
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 DecryptedPacketcounter_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.
Satellite, which manages the Docker container for you:
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: 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",
)
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 diagnosticThe 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
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. 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.
from hubblenetwork.errors import DockerError, SatelliteError- macOS:
ble scancrashes instead of prompting for Bluetooth (Termination Reason: Namespace TCC, missingNSBluetoothAlwaysUsageDescription) — macOS refuses CoreBluetooth to any executable without that key in an Info.plist, and Homebrew'spython3has 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/<ver>/Resources/Python.app. ble scanfinds nothing: verify BLE permissions and adapter state, and try a longer--timeout. A second attempt often succeeds.- Auth errors: run
hubblenetwork doctor.validate-credentialsreports 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 …). Preferpipxfor CLI-only usage. DockerError: Docker is not available: the Docker daemon is not running. Start Docker Desktop (macOS/Windows) orsudo systemctl start docker(Linux).DockerError: The 'docker' Python package is required: runpip install docker(it ships withpyhubblenetworkbut 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 runningsat scan, and that no other process is using it.sat scanhangs pulling the image: the first run fetchesghcr.io/hubblenetwork/sdr-docker:latest, which may take a minute on a slow connection. Later runs use the cached image.
python3 -m venv .venv && source .venv/bin/activate
pip install -e '.[dev]'
ruff check src
pytestThere 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 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.
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 — no API tokens are
stored in the repo. The pypi environment requires manual approval in the
Actions UI.
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.