DEV Community

Cover image for A satellite control room from 100% open data, self-hosted in Europe
Clément Igonet
Clément Igonet

Posted on • Originally published at overwatch.confinia.io

A satellite control room from 100% open data, self-hosted in Europe

A satellite control room from 100% open data, self-hosted in Europe

Live demo: overwatch.confinia.io
a MapLibre globe with 23 satellites moving in real time, each one clickable
into Grafana dashboards showing battery voltages, temperatures and currents
decoded from their actual radio beacons. Every byte of it comes from open
data, and the whole thing runs on one rootless-podman server in Europe.

This post is about the four design decisions that made it work — and one
upstream surprise that accidentally proved the project's whole point.

The stack in one diagram

CelesTrak ─┐                ┌─ MapLibre globe (Flask) ── reads cache only
SatNOGS  ──┤─►  ingest  ─►  Postgres                        │
           (the ONLY thing           └─ Grafana ────────────┘
            touching upstream)          embedded per-satellite dashboards
Enter fullscreen mode Exit fullscreen mode

Orbits come from CelesTrak two-line elements,
propagated locally with SGP4 every 15 seconds. Telemetry comes from
SatNOGS, the Libre Space Foundation's global network
of volunteer ground stations. The basemap is
Sentinel-2 cloudless by EOX — real Copernicus imagery,
served from Europe. Postgres caches everything; the browser and Grafana read
only from that cache.

Decision 1: the caching boundary is the architecture

Exactly one service is allowed to call an external API. CelesTrak firewalls
IPs that pull more than ~100 MB/day and asks you to fetch each dataset once
per update — a naive "fetch on every map refresh" design gets you banned, and
deserves to be. So the ingest service fetches elements on a 6-hour cadence,
propagates positions locally (no network involved), and polls telemetry every
30 minutes with cursor pagination — "give me only frames newer than what I
have." When the demo hit the front of a busy Slack channel, visitor traffic
touched nothing upstream. Politeness and scalability turn out to be the same
property.

Decision 2: decode the radio frames yourself

Mid-project, SatNOGS's API changed: it still serves raw frames — hex blobs
like 60A060A0A66E60A4A660… — but the decoded values now live only in
their internal InfluxDB. For a day, my dashboards were empty and the fix
options were: scrape someone else's Grafana, or decode the physics myself.

The answer was satnogs-decoders:
161 community-written Kaitai Struct definitions, each
describing one satellite family's beacon layout down to the bit — "bytes
17–18: battery voltage, big-endian, ×0.0075 volts." My ingest now runs
Decoder.from_bytes(bytes.fromhex(frame)) and flattens every numeric leaf
into Postgres rows.

That outage was the best thing that happened to the project. A platform that
depends on someone else's decoded data is a proxy; one that decodes raw
downlinks locally is infrastructure. If you care about data sovereignty,
the difference is everything — and it's also why a satellite with no public
decoder (looking at you, NEMO-HD) simply can't be on my map. The honest fix
is a .ksy file, not a fake dashboard.

Decision 3: normalize the chaos, keep the chaos too

Every decoder names things differently: csp_data_payload_eps_vbatt,
raw_frame_rt_tlm_batt_a_v, beacon_types_type_check_battery_voltage — and
units vary between volts and millivolts. Dashboards written per-satellite
don't scale past three satellites. So ingest derives canonical fields
(battery_v, battery_i, battery_pct, with heuristic mV/mA scaling)
next to the raw ones. One battery panel now works for the whole fleet,
while the raw fields stay queryable for anyone who wants the real thing.
A protocol-noise filter also drops AX.25 headers, SSIDs and checksums at
ingest — nobody's health dashboard needs dest_ssid_raw_hbit.

Decision 4: fuse position and telemetry — that's the product

Two features fall out of having orbits and telemetry in the same database,
and they're the two I'd show a satellite operator first:

Battery vs orbital sunlight. During propagation, a cylindrical
Earth-shadow test against a low-precision solar ephemeris marks every
position sample as sunlit or eclipsed. Overlaying that on battery voltage
shows charge/discharge cycles tracking orbital daylight — spacecraft
physiology, visible in one graph, from amateur radio receptions.

Who heard whom. Every SatNOGS frame names its receiving station, and
most station names embed a Maidenhead grid locator (KM7DOS-CN87xi). Decode
the locator and you can draw the reception network on the globe: this
satellite, heard from Kitakyushu, Buenos Aires and a garden in Bavaria, with
lines to where the satellite was at each reception. It's a live map of the
volunteer ground-segment — and of its gaps.

Running it sovereign

The whole stack is rootless podman on one Debian dedicated server: Postgres,
ingest, Flask, Grafana, an OpenTelemetry collector and Prometheus for
access observability (public dashboards stay anonymous-viewer; the ops
folder is admin-only). A shared Caddy edge terminates TLS with per-project
vhost files. GitHub Pages hosts a static mirror of the frontend, but every
byte of data it shows still comes from the European server — a CDN outage
would take down the shell, not the data; a data-sovereignty audit has
exactly one machine to look at.

Honest limits

Coverage is what volunteers make it: a satellite charts only when it
broadcasts in the clear and someone's antenna heard it. Of ~1,700 alive
satellites in the SatNOGS catalog, my weekly sweep found 23 currently
meeting that bar — that number is the true state of open satellite
telemetry, not a limitation of the stack. Ground tracks use a spherical
Earth (fine for dots on a map, not for targeting). And satellites render at
ground level for now; elevation-true rendering above the globe is next.

What's next

Multi-tenant embedding with proper short-lived-token auth (the anonymous
viewer is a POC seam, deliberately), a small SDR ground station to stop
depending exclusively on other people's antennas, and conversations with
anyone who operates satellites — or any European team that wants live
operational dashboards embedded in their product without a US hyperscaler
in the data path. That last sentence is the actual business; the satellites
are the proof it works.

Clément Igonet — confinia.io ·
contact@confinia.io. Data: CelesTrak, SatNOGS/Libre Space Foundation,
Copernicus/EOX. Built with MapLibre GL, Grafana, PostgreSQL, podman.

Top comments (0)