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.
- Quick links
- Requirements & supported platforms
- Installation
- Quick start
- CLI usage
- Validating a device end-to-end
- Satellite scanning (PlutoSDR)
- Configuration
- Public API (summary)
- Development & tests
- Troubleshooting
- Telemetry
- Releases & versioning
- PyPI:
pip install pyhubblenetwork - Hubble official doc site
- Hubble embedded SDK
- 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
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; user must have permission to access the BLE adapter (often
bluetoothgroup). - Windows: Requires 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
- Satellite scanning prerequisites (only needed if you use
sat.scan()):- Docker: Docker Desktop (macOS/Windows) or Docker Engine (Linux) must be installed and running.
- PlutoSDR: An Analog Devices ADALM-PLUTO SDR dongle connected via USB.
pip install pyhubblenetwork
# or install CLI into an isolated environment:
pipx install pyhubblenetworkFrom the repo root:
python3 -m venv .venv && source .venv/bin/activate
pip install -e '.[dev]'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")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))from hubblenetwork import Device, ble, decrypt
from typing import Optional
dev = Device(id="dev_abc", key=b"<secret-key>")
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":
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).
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.
If installed, the hubblenetwork command is available:
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 <id> --payload-format stringStart with hubblenetwork doctor, which checks whether this machine can actually
talk to Hubble and names the fix for anything broken:
hubblenetwork doctor x 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
x 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). 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 <group> <command>:
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:
$ 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
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.
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:
auto— printable ASCII shows as text, anything else as uppercase hexbase64— encode payloads as base64hex— display payloads as hexadecimalstring— decode payloads as UTF-8 text (falls back to<invalid UTF-8>if bytes are not valid UTF-8)
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.
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).
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 <id> -n 50 --debuglist-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.
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.
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.
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.
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?"
hubblenetwork ble validate \
--key "a562a2f7e4c62bed52ab09633878f62b" \
--device-id "3f4b2c0c-2d43-4cbe-9c1f-0a4c2d59e2a1"The command performs these steps in order, stopping at the first failure:
- Validates input formats — the device key (hex or base64, 16- or 32-byte) and the device ID (standard 8-4-4-4-12 UUID).
- Loads credentials — from
--org-id/--tokenor theHUBBLE_ORG_IDandHUBBLE_API_TOKENenvironment variables. - Validates the organization credentials against the backend.
- Confirms the device is registered in your organization.
- Scans for BLE advertisements from Hubble-compatible devices.
- Decrypts a received packet with the provided key and reports the detected
EID type (
UNIX_TIMEorDEVICE_UPTIME). - 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. |
--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). |
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.
The sat command group receives packets via a PlutoSDR SDR dongle. It runs a Docker container (ghcr.io/hubblenetwork/sdr-docker) that handles RF reception and decoding, then polls that container's HTTP API and streams decoded packets to stdout.
- Docker daemon running — Docker Desktop (macOS/Windows) or Docker Engine (Linux).
- PlutoSDR connected — ADALM-PLUTO dongle plugged in via USB before starting the scan.
# Stream packets until Ctrl+C
hubblenetwork sat scan
# Stop after 30 seconds
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)
hubblenetwork sat scan --key "a562a2f7e4c62bed52ab09633878f62b"
# Force the DEVICE_UPTIME counter instead of auto-detecting
hubblenetwork sat scan --key "<key>" --counter-mode DEVICE_UPTIME
# Show packets the key can't decrypt too (adds a ✓/✗ decrypt mark per row)
hubblenetwork sat scan --key "<key>" --show-failed-decryptionWhen --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:
- Verifies Docker is available
- Pulls the latest PlutoSDR image (if not cached)
- Starts the container in privileged mode so it can access USB
- Waits for the receiver API to become ready
- Streams new packets as they arrive (deduplicating by device ID + sequence number)
- Stops and removes the container on exit or Ctrl+C
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.
# Capture 10 s of raw IQ samples to a .npy file (for offline analysis / reprocessing)
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. The 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, and chipset metrics. It reports on signal quality and does not contain decoded packet payloads — to receive payloads, usesat scan(optionally with--key).
from hubblenetwork import sat, SatellitePacket
# 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())
# Or fetch the current packet buffer without managing the container yourself
packets: list[SatellitePacket] = sat.fetch_packets()
# 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
# Decrypt a packet's payload locally.
# counter_mode defaults to UNIX_TIME; pass DEVICE_UPTIME for uptime-based EIDs.
from hubblenetwork import decrypt_satellite
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)SatellitePacket fields: device_id, seq_num, device_type, timestamp, rssi_dB, channel_num, freq_offset_hz, payload (bytes), auth_tag (bytes or None).
| 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 |
The CLI reads two environment variables:
HUBBLE_ORG_ID— your organization idHUBBLE_API_TOKEN— your API token, passed through as a bearer token
export HUBBLE_ORG_ID=org_123
export HUBBLE_API_TOKEN=sk_XXXXEvery 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.
The SDK does not read the environment. Organization() requires its
credentials explicitly, so exporting the variables does nothing for library code:
from hubblenetwork import Organization
import os
org = Organization(
org_id=os.environ["HUBBLE_ORG_ID"],
api_token=os.environ["HUBBLE_API_TOKEN"],
)Import from the package top-level for a stable surface:
from hubblenetwork import (
ble, cloud, ready, sat,
Organization, Device, Credentials, Environment,
EncryptedPacket, UnencryptedPacket, AesEaxPacket, UnknownPacket,
DecryptedPacket, SatellitePacket, Location,
decrypt, decrypt_eax, decrypt_satellite,
UNIX_TIME, DEVICE_UPTIME,
InvalidCredentialsError,
)Key objects & functions:
Organizationprovides credentials for performing cloud actions (e.g. registering devices, retrieving decrypted packets, retrieving devices, etc.)EncryptedPacketa packet that has not been decrypted (can be decrypted locally given a key or ingested to the backend)DecryptedPacketa packet that has been successfully decrypted either locally or by the backend.SatellitePacketa packet decoded by the satellite receiver (PlutoSDR).Locationdata about where a packet was seen.ble.scanfunction for locally scanning for devices with BLE.sat.scangenerator 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 optionalon_page(page, total_so_far)callback.list_devices()andretrieve_packets()arelist()wrappers over them and still return lists.
See code for full details.
Set up a virtualenv and install dev deps:
python3 -m venv .venv
source .venv/bin/activate
pip install -e '.[dev]'Run linters:
ruff check srcble.scan()finds nothing: verify BLE permissions and adapter state; try increasingtimeout.- macOS:
ble scancrashes instead of prompting for Bluetooth — you'll seeTermination Reason: Namespace TCCand a message about a missingNSBluetoothAlwaysUsageDescriptionkey. macOS refuses CoreBluetooth to any executable without that key in an Info.plist, and Homebrew'spython3binary 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/<ver>/Resources/Python.appis 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-credentialsreports 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 …). Preferpipxfor CLI-only usage. DockerError: Docker is not available: 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 is bundled withpyhubblenetworkbut 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 runningsat scan, and that no other process is using it.sat scanhangs pulling the image: first run fetchesghcr.io/hubblenetwork/sdr-docker:latest; this may take a minute on a slow connection. Subsequent runs use the cached image.
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.
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.
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.
-
Bump the version in
pyproject.toml:version = "0.6.0" -
Add release notes to the top of
release-notes.md:## [0.6.0] - 2026-04-01 ### Added - feat(cli): new command description ### Fixed - fix(org): bug description
-
Commit, tag, and push:
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 -
Approve the publish step in the GitHub Actions UI (the
pypienvironment requires manual approval).
The workflow verifies the tag matches the version in pyproject.toml, so both must agree. PyPI publishing uses Trusted Publishing (OIDC) — no API tokens are stored in the repo.