Request
curl "http://192.168.2.1:8080/api/v1/health"import requests
BASE_URL = "http://192.168.2.1:8080"
response = requests.get(f"{BASE_URL}/api/v1/health")
response.raise_for_status()
print(response.json())let base_url = "http://192.168.2.1:8080";
let response = reqwest::Client::new()
.get(format!("{base_url}/api/v1/health"))
.send()
.await?
.error_for_status()?;
let body: serde_json::Value = response.json().await?;
println!("{body:#}");Response
{
"status": "string"
}/api/v1/metricsMetrics#
The Prometheus registry, as JSON, on the API port.
WHY IT EXISTS ALONGSIDE :9090/metrics. The exposition is served on the
observability port, which is right for a scraper and unusable from the
dashboard: the frontend talks to the API port, a second origin needs CORS
the metrics server does not set, and a browser has no business splitting
exposition text by hand.
404 WHEN THERE IS NO EXPORTER, rather than an empty list. "This daemon has
no metrics" and "every metric reads zero" are different facts and a caller
cannot tell them apart from [].
Responses
200OKapplication/json
array of MetricFamilyDRO
Request
curl "http://192.168.2.1:8080/api/v1/metrics"import requests
BASE_URL = "http://192.168.2.1:8080"
response = requests.get(f"{BASE_URL}/api/v1/metrics")
response.raise_for_status()
print(response.json())let base_url = "http://192.168.2.1:8080";
let response = reqwest::Client::new()
.get(format!("{base_url}/api/v1/metrics"))
.send()
.await?
.error_for_status()?;
let body: serde_json::Value = response.json().await?;
println!("{body:#}");Response
[
{}
]/api/v1/system/capabilitiesCapabilities#
Responses
200OKapplication/json
modulationsrequiredarray of string
Supported modulations (strings matching the profile enum).
rx_channels requiredinteger · int32
Number of RX channels exposed by the platform.
tx_channels requiredinteger · int32
Number of TX channels exposed by the platform.
mockrequiredboolean
true when the PL driver is a mock (no real HW attached).
symbol_rates requiredarray of SymbolRateDRO
THE SYMBOL RATES THIS MODEM CAN PRODUCE, and the two it cannot.
Not a menu someone chose: the rate is ad9361_rate / (sps * interp)
with sps fixed at 4 and interp decoded only over 1..32, so the set is
fixed by the hardware. Clients build their forms from this rather than
offering a free number — phy.symbol_rate_baud accepted anything and
reached no register at all, so a profile declaring 9600 baud transmitted
at about 2 MBaud (measured 2026-09-13).
The unreachable entries are PRESENT, with the bound they hit, rather than silently absent: a list that quietly drops what it cannot do reads as a complete list of what exists.
SymbolRateDRO · 6 fields
baudrequirednumber · double
The exact rate in baud. Fractional at the bottom of the ladder: the hardware produces 39 062.5, and rounding it would publish a rate nobody can select.
labelrequiredstring
As an operator writes it, e.g. "39.063 ksym/s".
availablerequiredboolean
False when this rate cannot be produced at all.
ad9361_rate_sps integer · int64 · nullable
What the AD9361 must be clocked at to realise it. null when unavailable.
interpinteger · int32 · nullable
The DUC interpolation that realises it. null when unavailable.
unavailable_reason string · nullable
Which bound it hits. Present exactly when available is false.
Request
curl "http://192.168.2.1:8080/api/v1/system/capabilities"import requests
BASE_URL = "http://192.168.2.1:8080"
response = requests.get(f"{BASE_URL}/api/v1/system/capabilities")
response.raise_for_status()
print(response.json())let base_url = "http://192.168.2.1:8080";
let response = reqwest::Client::new()
.get(format!("{base_url}/api/v1/system/capabilities"))
.send()
.await?
.error_for_status()?;
let body: serde_json::Value = response.json().await?;
println!("{body:#}");Response
{
"modulations": [
"string"
],
"rx_channels": 0,
"tx_channels": 0,
"mock": true,
"symbol_rates": [
{
"baud": 0.0,
"label": "string",
"available": true,
"ad9361_rate_sps": 0,
"interp": 0,
"unavailable_reason": "string"
}
]
}/api/v1/system/clocksClocks#
Responses
200OKapplication/json
availablerequiredboolean
None when debugfs is not mounted — distinct from "no clocks", which
would read as a healthy empty set.
sourcerequiredstring
clocksrequiredarray of ClockDRO
ClockDRO · 4 fields
namerequiredstring
enablerequiredinteger · int32
Authoritative: non-zero means the clock is enabled.
preparerequiredinteger · int32
Authoritative: non-zero means it is prepared.
rate_hz requiredinteger · int64
NOT authoritative. Computed by the clock framework from the parent and the dividers, and printed unchanged with the MMCM off (STE-943). Present for display; never judge on it.
Request
curl "http://192.168.2.1:8080/api/v1/system/clocks"import requests
BASE_URL = "http://192.168.2.1:8080"
response = requests.get(f"{BASE_URL}/api/v1/system/clocks")
response.raise_for_status()
print(response.json())let base_url = "http://192.168.2.1:8080";
let response = reqwest::Client::new()
.get(format!("{base_url}/api/v1/system/clocks"))
.send()
.await?
.error_for_status()?;
let body: serde_json::Value = response.json().await?;
println!("{body:#}");Response
{
"available": true,
"source": "string",
"clocks": [
{
"name": "string",
"enable": 0,
"prepare": 0,
"rate_hz": 0
}
]
}/api/v1/system/statusStatus#
Responses
200OKapplication/json
uptime_seconds requiredinteger · int64
pl_driver requiredstring
mock or uio.
servicesrequiredarray of ServiceStatusDRO
ServiceStatusDRO · 2 fields
namerequiredstring
healthyrequiredboolean
Request
curl "http://192.168.2.1:8080/api/v1/system/status"import requests
BASE_URL = "http://192.168.2.1:8080"
response = requests.get(f"{BASE_URL}/api/v1/system/status")
response.raise_for_status()
print(response.json())let base_url = "http://192.168.2.1:8080";
let response = reqwest::Client::new()
.get(format!("{base_url}/api/v1/system/status"))
.send()
.await?
.error_for_status()?;
let body: serde_json::Value = response.json().await?;
println!("{body:#}");Response
{
"uptime_seconds": 0,
"pl_driver": "string",
"services": [
{
"name": "string",
"healthy": true
}
]
}/api/v1/versionVersion#
Responses
200OKapplication/json
firmwarerequiredstring
satlinkd crate version.
apirequiredstring
API contract version (v1).
fpgastring · nullable
PL bitstream identifier read from the shell — None in mock mode.
This is the shell's BUILD_ID register, read live over /dev/uio0. It
says which bitstream is LOADED, which no host-side manifest can.
releaserequiredstring
Release bundle this daemon was built for, or "unreleased".
Compare it with the manifest of the bundle you deployed: they must be equal. On a ramdisk rootfs, deploying and running are two different things, and this field is what separates them (STE-985).
Request
curl "http://192.168.2.1:8080/api/v1/version"import requests
BASE_URL = "http://192.168.2.1:8080"
response = requests.get(f"{BASE_URL}/api/v1/version")
response.raise_for_status()
print(response.json())let base_url = "http://192.168.2.1:8080";
let response = reqwest::Client::new()
.get(format!("{base_url}/api/v1/version"))
.send()
.await?
.error_for_status()?;
let body: serde_json::Value = response.json().await?;
println!("{body:#}");Response
{
"firmware": "string",
"api": "string",
"fpga": "string",
"release": "string"
}