PiMonitor exposes a small, versioned REST API under /api/v1/... intended
both for its own web dashboard and for third-party consumers (e.g. home
automation systems such as openHAB, Home Assistant, or Node-RED).
Breaking changes to an existing version's response shape will not happen
in place — a new /api/v2/... path would be introduced instead, so
existing integrations against /api/v1/... keep working.
By default, no authentication is required — PiMonitor is meant to run on a
trusted local network. If you set api_key in config.yaml (or the
PIMONITOR_API_KEY environment variable), every request to an endpoint
behind apiRoute — every /api/v1/... route, plus GET /metrics when
prometheus_enabled is set (see below) — must include one of:
Authorization: Bearer <api_key>X-Api-Key: <api_key>
The key can be supplied three ways, in increasing precedence: api_key in
the config file, the PIMONITOR_API_KEY environment variable, and the
-api-key flag. Prefer the config file — install.sh restricts it to
mode 640 root:pimonitor. PIMONITOR_API_KEY is the deployment-friendly
alternative (systemd EnvironmentFile= pointing at a file with the same
restricted permissions). The -api-key flag is for local development only:
command lines are world-readable via /proc/<pid>/cmdline, so every local
user on the machine can read a key passed that way.
Requests without a valid key receive 401 Unauthorized. GET /healthz is
never gated by the API key, so external health checks keep working
regardless of authentication configuration.
The bundled web dashboard uses this same API: when an api_key is set it
shows an "API key required" prompt on first load, then stores the entered
key in the browser's localStorage and sends it as X-Api-Key on every
request. Setting a key therefore does not disable the dashboard — each
browser just has to unlock it once.
Responses from any endpoint behind apiRoute (every /api/v1/... route,
plus GET /metrics) are gzip-compressed when the request sends
Accept-Encoding: gzip (the response then carries Content-Encoding: gzip
and Vary: Accept-Encoding); the body itself is unchanged, only its wire
encoding differs. Requests without that header receive the identity
(uncompressed) response, so existing clients keep working unmodified. A
Prometheus server sends Accept-Encoding: gzip by default, so a scrape of
GET /metrics normally takes this path rather than the identity one.
The header is parsed as the q-value list it is (RFC 9110 §12.5.3): an
explicit refusal (Accept-Encoding: gzip;q=0) is honoured and yields an
identity response, Accept-Encoding: * counts as accepting gzip, and the
deprecated x-gzip token is not treated as gzip.
Responses from any endpoint behind apiRoute (every /api/v1/... route,
plus GET /metrics) carry Cache-Control: no-store and a Vary naming
Authorization and X-Api-Key alongside Accept-Encoding. Metric/alert/
config snapshots — and the Prometheus rendering of the same snapshot — are
point-in-time data with no reuse value, and when api_key is configured
they are credential-protected, so a reverse proxy placed in front of
PiMonitor (see "Authentication" above) must not cache them or serve a
response captured with one client's credentials to another.
Every endpoint behind apiRoute (every /api/v1/... route, plus GET /metrics) shares a single limit on how many requests may be actively
processing at once. GET /api/v1/metrics/history is the expensive one — it can require
copying and re-serialising the whole retained history window — so an unbounded number of
concurrent callers could otherwise starve metric collection on constrained hardware such
as a Raspberry Pi Zero.
A request beyond the limit receives 503 Service Unavailable with a Retry-After: 1
header and a plain-text body, instead of being queued. Clients should treat this the same
as any other transient server error: back off (the Retry-After value is in seconds) and
retry. GET /healthz is never subject to this limit, so liveness checks keep working even
while the API is shedding load.
A Prometheus scrape of GET /metrics that lands on this 503 is recorded as a failed
scrape (up == 0 for that target) rather than a gap in an otherwise-successful one — unlike
a JSON client, Prometheus doesn't retry mid-scrape-interval. This is normally a non-issue
(the limit is generous relative to a typical scrape concurrency), but worth knowing if
/metrics is scraped from multiple Prometheus instances, or alongside heavy dashboard
polling of GET /api/v1/metrics/history, against the same PiMonitor instance.
Plain-text liveness check. Not versioned; intended for systemd/monitoring
tooling, not for metric data.
Returns 200 ok when the latest collected snapshot is fresh. It returns
503 Service Unavailable with a plain-text body when the snapshot is older
than healthz_max_staleness_seconds (default: 3x poll_interval_seconds
plus a margin for the slowest a single healthy collection cycle may
legitimately take, e.g. a hung vcgencmd call) — this catches a stalled
collector goroutine that would otherwise leave /healthz reporting healthy
while /api/v1/metrics silently serves stale
data. Set healthz_max_staleness_seconds in the config file to tune the
bound; see packaging/pimonitor.example.yaml.
Returns the most recently collected snapshot of every metric. This is the main endpoint for third-party integrations: poll it on an interval and extract the fields you need (e.g. via JSONPath in openHAB's HTTP binding).
{
"timestamp": "2026-07-12T18:32:00Z",
"uptime_seconds": 372014.5,
"cpu": {
"overall_percent": 12.4,
"per_core_percent": [10.1, 14.8, 11.2, 13.5]
},
"cpu_frequency": [
{ "core": 0, "mhz": 600, "governor": "ondemand" },
{ "core": 1, "mhz": 1500, "governor": "ondemand" },
{ "core": 2, "mhz": 600, "governor": "ondemand" },
{ "core": 3, "mhz": 1500, "governor": "ondemand" }
],
"load_average": { "load1": 0.42, "load5": 0.38, "load15": 0.31 },
"cpu_count": 4,
"temperature": { "zone": "cpu-thermal", "celsius": 48.6 },
"gpu_temperature": { "celsius": 47.8 },
"pmic_temperature": { "celsius": 52.1 },
"throttled": {
"under_voltage_now": false,
"frequency_capped_now": false,
"throttled_now": false,
"soft_temp_limit_now": false,
"under_voltage_since_boot": true,
"frequency_capped_since_boot": false,
"throttled_since_boot": true,
"soft_temp_limit_since_boot": false,
"raw": "0x50000"
},
"memory": {
"total_bytes": 4137000000, "available_bytes": 2900000000, "used_percent": 29.9
},
"swap": { "total_bytes": 104857600, "used_bytes": 0, "used_percent": 0 },
"disks": [
{
"mountpoint": "/",
"device": "/dev/root",
"fstype": "ext4",
"total_bytes": 31000000000,
"used_bytes": 8000000000,
"used_percent": 25.8
}
],
"disk_io": [
{ "device": "mmcblk0", "read_bytes_per_sec": 20480, "write_bytes_per_sec": 8192 }
],
"network": [
{ "name": "eth0", "rx_bytes_per_sec": 1240.5, "tx_bytes_per_sec": 302.1 }
],
"wireless": [
{ "interface": "wlan0", "link_quality": 70, "signal_dbm": -40 }
],
"sensors": [
{ "chip": "cpu_thermal", "label": "cpu_thermal temp1", "hwmon": "hwmon0", "celsius": 48.6 },
{ "chip": "nvme", "label": "Composite", "hwmon": "hwmon1", "celsius": 34.9 }
],
"system": {
"kernel_version": "6.6.31+rpt-rpi-v8",
"distribution": "Raspberry Pi OS Bookworm (Debian 12)",
"pi_model": "Raspberry Pi 4 Model B Rev 1.4",
"cpu_model": "ARMv8 Processor rev 1 (v8l)"
},
"updates": {
"count": 3,
"packages": [
{
"name": "curl",
"new_version": "7.88.1-10+deb12u5",
"old_version": "7.88.1-10+deb12u4",
"arch": "arm64"
}
],
"cache_age_seconds": 1800,
"stale": false,
"checked_at": "2026-07-12T18:20:00Z"
}
}Notes:
timestampis the Pi's own clock at collection time (useful as the displayed device time), anduptime_secondsis the time elapsed since boot.system.cpu_modelis best-effort: it is empty on kernels whose/proc/cpuinfoomits amodel namefield (common on some Raspberry Pi kernels).disks[].used_percentfollowsdf's semantics: it is computed asused / (used + available), whereavailablecounts only blocks writable by unprivileged processes. Blocks reserved for root (typically 5% on ext4) therefore count as used capacity, and the value reaches 100 when services can no longer write — matchingdf'sUse%rather than a rawused / totalratio (which would still read ~95% on a full ext4 filesystem).total_bytesandused_bytesremain the raw filesystem totals, soused_percentcan slightly exceedused_bytes / total_bytes * 100.diskscontains at most one entry per mountpoint (the filesystem actually visible at that path when a mountpoint is overmounted), and network filesystems (NFS, CIFS/SMB, SSHFS, ...) are excluded — only local storage is reported.disk_iois a delta-based reading (sectors × 512 bytes from/proc/diskstats, seeDocumentation/admin-guide/iostats.rstin the kernel source) between two collection ticks, so it reads as an empty array on the first tick after process start, before a prior sample exists to diff against. Loop and RAM devices are excluded, and so are partitions of the usualsd*/hd*/vd*/xvd*/mmcblk*/nvme*naming schemes (e.g.sda1,mmcblk0p1): the kernel already folds a partition's sectors into its parent device's own counters, so reporting both would double-count the same I/O. Entries are sorted by device name. Unlikedisks,disk_ioreports per block device, not per mountpoint, so its entries do not correspond 1:1 withdisks— and should not be summed for a host total, since not every double-counting case (e.g. LVM/LUKS-mapped devices layered over a whole disk) is filtered out.networkentries are sorted by interface name.wirelessreports link quality and signal level per wireless interface, parsed from/proc/net/wireless, and is omitted entirely when the host has no wireless interface with a current reading (this includes a host with no wireless hardware at all: the file is usually present regardless — created by kernels built with wireless extensions support, which is the common case — but then simply lists no interfaces, or lists one with no reading yet, e.g. not currently associated to a network).link_qualityis the raw driver-reported value from the file'slinkcolumn - its maximum is driver-dependent (commonly, but not always, 70) so it is not normalized to a 0-100 percentage.signal_dbmis the received signal strength in dBm on drivers that report an absolute value; some drivers instead report a driver-relative signal quality number in this same field. More negative (or lower) means weaker signal either way.sensorsenumerates every temperature channel exposed by the kernel's hwmon subsystem (/sys/class/hwmon/hwmon*/temp*_input) — the SoC sensor itself, plus, depending on the board and attached hardware, a PoE-HAT fan controller, NVMe/SSD drives, or user-attached I2C/1-Wire sensors. This is additive breadth alongsidetemperatureabove, not a replacement for it:temperatureremains the primary, clearly-labelled CPU/SoC reading used by the dashboard gauge and the alert engine.chipis the hwmon driver's name (e.g.cpu_thermal,nvme);labelis the sysfstemp*_labelfile when present, otherwisechipplus the channel index.hwmonis the sysfs directory the reading came from (e.g.hwmon0), included becausechip/labelalone cannot distinguish two identical chips (e.g. two NVMe drives, both namednvmewith aCompositechannel) — but note that hwmon numbering is not guaranteed stable across reboots, sohwmonidentifies a sensor only within a single boot, not across restarts. The array is omitted entirely when no hwmon sensor is found (this includes hosts without/sys/class/hwmonat all) or when disabled viahwmon_enabled: false.cpu_frequencyis one entry per CPU core with a readable sysfscpufreqdirectory (scaling_cur_freq,scaling_governor), sorted bycoreindex. It is omitted entirely on systems without a cpufreq driver (e.g. many development machines, or a kernel built withoutCONFIG_CPU_FREQ), and a core that is offline or whose driver doesn't expose both files is simply left out rather than failing the whole reading.gpu_temperatureis only present ifvcgencmdis installed and responded successfully; otherwise the field is omitted. Note that it is not a second physical sensor: CPU and GPU share the SoC die, so this reads the same sensor astemperature, by a different route.pmic_temperatureis the Power-Management IC's own sensor — genuinely separate silicon from the SoC, and useful for spotting power-delivery or board-level heat distinct from CPU load. It exists only on the Raspberry Pi 4 and 5, is read viavcgencmd measure_temp pmic, and is omitted (exactly likegpu_temperature) whenevervcgencmdis unavailable or the board has no PMIC sensor. It is not exposed through sysfs hwmon on Raspberry Pi OS, so it never appears insensorseither.throttleddecodes the Raspberry Pivcgencmd get_throttledbitmask. The*_nowflags reflect the current state; the*_since_bootflags latch whether the condition has occurred at any point since boot. A setunder_voltage_*flag usually means an inadequate power supply or cable. Likegpu_temperature, the whole object is omitted whenvcgencmdis unavailable (e.g. off-Pi), andrawcarries the original hex bitmask.networkis omitted entirely when network monitoring is disabled (network_enabled: false). This toggle coversnetwork(interface throughput) only —wireless(link quality/signal level) is unaffected by it and has no config toggle of its own; see thewirelessbullet above for when it is omitted instead.updates.staleistruewhen the underlying apt cache (refreshed by a separate root-privileged systemd timer, not by this process) is older than the configured staleness threshold — treat the update count as possibly outdated when this is set.- Fields may read as zero values (
0,"", empty arrays) briefly after process start, before the first collection tick completes, or permanently on non-Pi/non-Linux systems for hardware-specific fields liketemperatureorpi_model.
Narrow, read-only views of the snapshot above, for integrators that poll a single value and would rather not fetch and parse the whole thing:
| Endpoint | Body |
|---|---|
GET /api/v1/metrics/cpu |
the cpu object |
GET /api/v1/metrics/temperature |
the temperature object |
GET /api/v1/metrics/memory |
the memory object |
GET /api/v1/metrics/disks |
the disks array |
GET /api/v1/metrics/network |
the network array |
GET /api/v1/metrics/updates |
the updates object |
Each endpoint returns exactly the correspondingly named field of
GET /api/v1/metrics — the same JSON, sliced out, with no wrapper object
and no shape of its own:
curl -s http://raspberrypi.local:8080/api/v1/metrics/temperature{ "zone": "cpu-thermal", "celsius": 48.1 }Field names, types and units are therefore the ones documented under
GET /api/v1/metrics above, and cannot drift from
them.
Notes:
- These are additive to
v1:GET /api/v1/metricsis unchanged, and keeps returning every field, including the ones with no endpoint of their own (timestamp,uptime_seconds,load_average,cpu_count,cpu_frequency,swap,gpu_temperature,pmic_temperature,throttled,system,disk_io,wireless,sensors). Poll the full snapshot if you need several metrics at once — six narrow requests cost more than one full one. - A field that carries no data is never a
404— the endpoint exists and is answering, and a404would be indistinguishable from a misspelled path. What it returns instead depends on the field's type, exactly as in the full snapshot:- The array-valued endpoints (
disks,network) returnnullwith a200:GET /api/v1/metrics/networkwithnetwork_enabled: false(where the full snapshot omits the key entirely), and either of them before the first collection tick completes. - The object-valued endpoints degrade to zero values rather than
null—GET /api/v1/metrics/temperaturereturns{"zone": "", "celsius": 0}on a host with no readable thermal zone, the same bytes thetemperaturefield ofGET /api/v1/metricscarries there. A0from such an endpoint is therefore not distinguishable from a genuine reading; see the last note underGET /api/v1/metrics.
- The array-valued endpoints (
- Any other sub-path (
/api/v1/metrics/cpu/overall, a typo, ...) is not a route and returns404. - Authentication, gzip,
Cache-Control: no-storeand the shared in-flight limit apply exactly as they do to every other/api/v1/...endpoint, and each endpoint gets its own key inGET /api/v1/serverstats.
Returns the retained history (a rolling window, typically the last 30-60
minutes) for every time-series metric. When history persistence is enabled
(history_persist_enabled, on by default), history is periodically
snapshotted to disk and restored on startup, so the returned window may
span service restarts and reboots; points older than the configured window
are dropped on restore. With persistence disabled, history is in-memory
only and starts empty after every restart.
{
"cpu_percent": [{ "t": "2026-07-12T18:00:00Z", "v": 10.2 }],
"load1": [{ "t": "2026-07-12T18:00:00Z", "v": 0.4 }],
"load5": [{ "t": "2026-07-12T18:00:00Z", "v": 0.38 }],
"load15": [{ "t": "2026-07-12T18:00:00Z", "v": 0.31 }],
"temperature": [{ "t": "2026-07-12T18:00:00Z", "v": 48.1 }],
"memory_used_percent": [{ "t": "2026-07-12T18:00:00Z", "v": 29.9 }],
"swap_used_percent": [{ "t": "2026-07-12T18:00:00Z", "v": 0 }],
"disk_used_percent": {
"/": [{ "t": "2026-07-12T18:00:00Z", "v": 25.8 }]
},
"disk_io_read_bytes_per_sec": {
"mmcblk0": [{ "t": "2026-07-12T18:00:00Z", "v": 20480 }]
},
"disk_io_write_bytes_per_sec": {
"mmcblk0": [{ "t": "2026-07-12T18:00:00Z", "v": 8192 }]
},
"network_rx_bytes_per_sec": {
"eth0": [{ "t": "2026-07-12T18:00:00Z", "v": 1240.5 }]
},
"network_tx_bytes_per_sec": {
"eth0": [{ "t": "2026-07-12T18:00:00Z", "v": 302.1 }]
}
}disk_used_percent, disk_io_read_bytes_per_sec,
disk_io_write_bytes_per_sec, network_rx_bytes_per_sec, and
network_tx_bytes_per_sec are keyed by mountpoint/device/interface name and
are omitted entirely if empty (e.g. network history when monitoring is
disabled).
A client that already holds the window can ask for only what it hasn't seen yet, instead of re-downloading (and making the Pi re-serialise) the whole window on every poll:
GET /api/v1/metrics/history?since=2026-07-12T18:31:00Z
sinceis an RFC 3339 timestamp. Fractional seconds and any UTC offset are accepted; a bare local time without an offset is not.- Only points strictly newer than
sinceare returned. A point whose timestamp is exactlysinceis excluded, so passing back thetof the newest point you hold returns exactly the points you are missing, with no duplicate. Pass thattback verbatim: timestamps carry sub-millisecond precision, and a value rounded to milliseconds (as JavaScript'sDate.toISOString()produces) asks for a point you already have and gets it back every time. - The response has the same shape as the full-window response. Scalar
series are present but may be empty;
disk_used_percent,disk_io_read_bytes_per_sec,disk_io_write_bytes_per_sec,network_rx_bytes_per_secandnetwork_tx_bytes_per_secare filtered per device, and a device with no newer points is omitted entirely — as is the whole map once every device is omitted. - A
sincenewer than every retained point returns empty series and no device maps — not an error. - A
sinceolder than the retained window returns the full window: the server has nothing older to give. - A
sincethe server cannot parse returns400 Bad Request; it is never silently treated as a full-window request. - Omitting
sincereturns the full window, exactly as before — the parameter is optional and purely additive.
Beware of gaps when appending deltas locally. Points leave the retained
window as new ones arrive, so a client that stops polling for longer than
history_window_minutes (a backgrounded tab, a lost connection) will get a
delta that starts after the newest point it holds — appending that yields
a series with a silent hole. A restart also replaces the window wholesale
(history is restored from disk). A client that accumulates history should
therefore re-request the full window whenever the returned points do not
continue where its own leave off, and re-sync periodically regardless; the
bundled dashboard does both (mergeHistory in app.js).
Returns the server-side alert engine's current per-metric state plus a
rolling list of recent transition events. The engine maps each collected
snapshot against the configured thresholds into ok/warn/crit states,
applying a debounce (alerts.for_seconds) so a threshold crossing must
persist before it is reported — this suppresses short-lived spikes and
momentary dips. The states mirror the color-coding the dashboard already
shows; the events make sustained crossings actionable (e.g. an openHAB rule
polling this endpoint).
{
"enabled": true,
"states": [
{ "metric": "cpu", "level": "ok", "value": 12.4, "since": "2026-07-12T18:00:00Z" },
{ "metric": "disk", "resource": "/", "level": "warn", "value": 82.1, "since": "2026-07-12T18:25:00Z" },
{ "metric": "memory", "level": "ok", "value": 45.2, "since": "2026-07-12T18:00:00Z" },
{ "metric": "swap", "level": "ok", "value": 0, "since": "2026-07-12T18:00:00Z" },
{ "metric": "temperature", "level": "crit", "value": 78.5, "since": "2026-07-12T18:30:10Z" }
],
"events": [
{
"metric": "disk",
"resource": "/",
"kind": "fired",
"from": "ok",
"to": "warn",
"value": 82.1,
"at": "2026-07-12T18:25:00Z"
},
{
"metric": "temperature",
"kind": "fired",
"from": "warn",
"to": "crit",
"value": 78.5,
"at": "2026-07-12T18:30:10Z"
}
]
}Notes:
enabledisfalse(with emptystates/events) when alerting is disabled viaalerts.enabled: false.stateslists one entry per evaluated metric:cpu,temperature,memory,swap, and onediskentry per mounted filesystem (distinguished byresource, the mountpoint).resourceis omitted for non-per-device metrics.- A metric whose collection fails on a given tick is skipped rather than
evaluated against a bogus zero, so its state is left unchanged (or absent
if it has never been collected). In particular, on hardware without a
readable thermal zone (containers, non-Pi dev machines) there is no
temperatureentry at all — do not assume every metric is always present. - A per-filesystem
diskstate is dropped when its mountpoint disappears from the sample (e.g. an unplugged USB drive). If that filesystem was still alerting, a final syntheticclearedevent is emitted for it; that event'svalueis the last reading before the mount vanished (which may still be>=a threshold), so acleared/to: "ok"event carrying a highvalueon an unmount is expected, not a bug. levelis the debounced state actually reported;valueis the most recent reading andsinceis when the current level was entered.- Each
eventsentry is a confirmed transition:kindisfiredwhen the severity increased (e.g.ok→warn,warn→crit) andclearedwhen it decreased (e.g.crit→ok).from/tocarry the levels andatis the transition time. The list is bounded to the most recent transitions and is in-memory only (it starts empty after a restart). - The value cutoffs match the dashboard's card coloring: a level is
critwhenvalue >= *_crit,warnwhenvalue >= *_warn, otherwiseok. - The same
fired/clearedtransitions can also be pushed to external HTTP webhooks (Slack, Discord, Home Assistant, ntfy, ...). This is delivery-only and configured underalerts.webhooksin the config file — it adds no new API endpoint; seepackaging/pimonitor.example.yaml.
Returns non-sensitive runtime configuration, so the web dashboard (or a third-party client) doesn't need to hardcode values separately from the server:
{
"version": "1.2.3",
"poll_interval_seconds": 5,
"history_window_minutes": 60,
"network_enabled": true,
"thresholds": {
"temperature_warn_c": 60,
"temperature_crit_c": 75,
"cpu_warn_percent": 80,
"cpu_crit_percent": 95,
"disk_warn_percent": 80,
"disk_crit_percent": 95,
"swap_warn_percent": 50,
"swap_crit_percent": 90,
"memory_warn_percent": 80,
"memory_crit_percent": 95
}
}Notes:
versionis the build-time version of the running binary, injected via-ldflags "-X main.version=...". Release builds report the release tag; a local build made without version injection reportsdev. The value may include a leadingvdepending on the build path (e.g. agit describestring likev1.2.3-5-gabc123); the dashboard strips that leadingvwhen it renders the version in its footer.history_window_minutesis how far backGET /api/v1/metrics/historyretains points. A client accumulating history from?since=deltas needs it to bound its local window to the same span the server keeps.
Returns the top-N running processes by CPU usage and by resident memory (RSS), each ranked independently.
It is off by default: set processes_enabled: true in the config file
(see packaging/pimonitor.example.yaml)
to register the route — walking every /proc/<pid> entry is more invasive
than the other metrics (it exposes what's running on the host, not just
aggregate load), so this is a deliberate opt-in, the same pattern as
GET /metrics. Left disabled, GET /api/v1/processes returns 404 Not Found rather than existing but empty.
processes_top_n controls how many entries each ranking returns (default
5).
{
"by_cpu": [
{ "pid": 1234, "name": "python3", "cpu_percent": 34.2, "rss_bytes": 52428800 },
{ "pid": 5678, "name": "pimonitor", "cpu_percent": 2.1, "rss_bytes": 18874368 }
],
"by_memory": [
{ "pid": 1234, "name": "python3", "cpu_percent": 34.2, "rss_bytes": 52428800 },
{ "pid": 5678, "name": "pimonitor", "cpu_percent": 2.1, "rss_bytes": 18874368 }
]
}Notes:
- This is recomputed on the slow tick (
updates_check_minutes), not the fast one: walking every/proc/<pid>entry is far more expensive than reading a single/procfile, so doing it atpoll_interval_seconds's cadence would cost too much on constrained hardware such as a Pi Zero. Expect the reported CPU percentages to reflect average usage over that slower interval, not an instantaneous reading. cpu_percentis 0 for every process immediately after startup (there is no prior sample yet to compute a delta from); it becomes meaningful from the second slow tick onward.rss_bytesis accurate immediately.cpu_percentis normalized against total CPU capacity, the same waycpu.overall_percentinGET /api/v1/metricsis: a process pegging a single core on a 4-core Pi is reported around 25%, not 100%, so the two values are on a comparable 0-100 scale rather than followingtop's default per-core convention (which can exceed 100% on multi-core hardware).- A process that exits between two slow ticks, or whose
/proc/<pid>files are momentarily unreadable (e.g. a zombie), is silently excluded from that tick's rankings rather than causing an error. - A process ID reused by a different process between two slow ticks is detected (via the process's start time) and reported at 0% CPU for that tick — the same as a genuinely new process — rather than a bogus delta computed against the previous, unrelated process's counters.
- This is deliberately its own endpoint rather than a field of
GET /api/v1/metrics, so the main snapshot's payload size doesn't grow with however many processes are running on the host.
Returns in-memory counters of PiMonitor's own HTTP traffic: total requests
served, broken down by response status class and by route. These are
recorded for every request regardless of the access_log_enabled config
setting (see packaging/pimonitor.example.yaml),
so request volume stays visible even with per-request debug logging turned
off.
{
"total": 143,
"by_status_class": {
"1xx": 0,
"2xx": 140,
"3xx": 0,
"4xx": 3,
"5xx": 0
},
"by_route": {
"/healthz": 12,
"/metrics": 0,
"/api/v1/metrics": 100,
"/api/v1/metrics/history": 20,
"/api/v1/metrics/cpu": 0,
"/api/v1/metrics/temperature": 4,
"/api/v1/metrics/memory": 0,
"/api/v1/metrics/disks": 0,
"/api/v1/metrics/network": 0,
"/api/v1/metrics/updates": 0,
"/api/v1/alerts": 5,
"/api/v1/config": 3,
"/api/v1/processes": 0,
"/api/v1/serverstats": 1,
"other-api": 0,
"static": 2
}
}Notes:
- Counters are process-lifetime totals, in-memory only: they start at zero after every restart and are not persisted.
by_routecovers every registered route by exact path; a request to any other/api/v1/...path is counted underother-api, and any other path (the dashboard's static assets) understatic— this keeps the counter set a fixed, bounded size regardless of what a client (or a scanner) requests./metrics(seeGET /metrics) always has its own key, regardless ofprometheus_enabled— the response shape doesn't vary with configuration. The key stays0until something actually requests that path; a scraper pointed at an instance with the endpoint disabled shows up here too, with a matching4xxcount, since the bucket comes from the request path, not from which handler — or the mux's404fallback — ended up serving the request.- A request is only counted once its response has been fully written, so a call to this endpoint never sees itself reflected in the numbers it returns — a following call does.
Returns the current snapshot rendered in the
Prometheus text exposition format,
for a Prometheus server to scrape directly instead of polling the JSON
GET /api/v1/metrics endpoint. Unlike the rest of this document, this path
is deliberately not under /api/v1/... — it is a different wire format
entirely, not a versioned JSON contract.
It is off by default: set prometheus_enabled: true in the config file
(see packaging/pimonitor.example.yaml)
to register the route. Left disabled, GET /metrics returns 404 Not Found rather than existing but empty. When api_key is set, GET /metrics
honours it exactly like every other endpoint (Authorization: Bearer or
X-Api-Key) — configure the same value as your Prometheus scrape config's
authorization/bearer_token.
GET /metrics goes through the same middleware chain as every /api/v1/...
route (it's registered via the same apiRoute wrapper — see "Compression",
"Caching", and "Rate limiting" above), so it is gzip-compressed, marked
Cache-Control: no-store, and shares the same concurrency limit. The one
worth calling out here specifically: if that limit is ever hit, the scrape
gets 503 Service Unavailable rather than a slow response, which Prometheus
records as a failed scrape (up == 0) — see "Rate limiting" for when that
can realistically happen.
# HELP pimonitor_cpu_usage_percent Overall CPU usage percentage.
# TYPE pimonitor_cpu_usage_percent gauge
pimonitor_cpu_usage_percent 12.4
# HELP pimonitor_cpu_core_usage_percent Per-core CPU usage percentage.
# TYPE pimonitor_cpu_core_usage_percent gauge
pimonitor_cpu_core_usage_percent{core="0"} 10.1
# HELP pimonitor_temperature_celsius CPU temperature in Celsius.
# TYPE pimonitor_temperature_celsius gauge
pimonitor_temperature_celsius{zone="cpu-thermal"} 48.6
# HELP pimonitor_memory_used_percent RAM used percentage.
# TYPE pimonitor_memory_used_percent gauge
pimonitor_memory_used_percent 29.9
# HELP pimonitor_disk_used_percent Filesystem used percentage (df semantics).
# TYPE pimonitor_disk_used_percent gauge
pimonitor_disk_used_percent{mount="/"} 25.8
# HELP pimonitor_network_receive_bytes_per_second Network interface receive throughput in bytes/sec.
# TYPE pimonitor_network_receive_bytes_per_second gauge
pimonitor_network_receive_bytes_per_second{iface="eth0"} 1240.5
# HELP pimonitor_updates_pending Number of upgradable apt packages.
# TYPE pimonitor_updates_pending gauge
pimonitor_updates_pending 3
Metrics exposed (all gauges, prefixed pimonitor_):
| Metric | Labels | Notes |
|---|---|---|
cpu_usage_percent |
— | Overall CPU usage. Deliberately its own unlabeled family rather than a core="overall" value inside cpu_core_usage_percent, so a naive sum()/avg by (...) over the per-core family can't silently double-count it |
cpu_core_usage_percent |
core (0-based index) |
Omitted entirely on platforms without per-core data |
temperature_celsius |
zone |
Omitted entirely — the whole family is skipped — whenever the most recent temperature collection failed (e.g. no readable thermal zone) or hasn't completed yet; a 0 reading is never fabricated for a missing sensor |
gpu_temperature_celsius |
— | Only present when vcgencmd responded, like gpu_temperature in GET /api/v1/metrics |
pmic_temperature_celsius |
— | Only present when vcgencmd measure_temp pmic responded (Raspberry Pi 4/5), like pmic_temperature in GET /api/v1/metrics |
memory_total_bytes, memory_available_bytes, memory_used_percent |
— | |
swap_total_bytes, swap_used_bytes, swap_used_percent |
— | |
disk_total_bytes, disk_used_bytes, disk_used_percent |
mount |
One series per mounted filesystem, same set as disks in GET /api/v1/metrics (pseudo-filesystems and network filesystems already excluded) |
network_receive_bytes_per_second, network_transmit_bytes_per_second |
iface |
Omitted entirely when network monitoring is disabled (network_enabled: false), same as network in GET /api/v1/metrics |
updates_pending |
— | Count of upgradable apt packages |
Example prometheus.yml scrape config:
scrape_configs:
- job_name: pimonitor
static_configs:
- targets: ["raspberrypi.local:8080"]
# Only needed when api_key is set in PiMonitor's config.
# authorization:
# credentials: "your-api-key"curl -s http://raspberrypi.local:8080/api/v1/metrics | jq '.cpu.overall_percent'With an API key configured:
curl -s -H "X-Api-Key: $PIMONITOR_API_KEY" \
http://raspberrypi.local:8080/api/v1/metrics | jq '.temperature.celsius'A Thing definition polling the temperature every 30 seconds:
Thing http:url:pimonitor "PiMonitor" [
baseURL="http://raspberrypi.local:8080/api/v1/metrics",
refresh=30
] {
Channels:
Type number : temperature "CPU Temperature" [
stateTransformation="JSONPATH:$.temperature.celsius"
]
}
The HTTP binding fetches baseURL once per refresh cycle, and each Channel's
stateTransformation runs its JSONPath against whatever that Channel's own
request actually returns — baseURL alone, or baseURL plus that Channel's
stateExtension if it sets one. A stateTransformation must match that
body: the example above has no stateExtension, so its JSONPath is written
against the full snapshot at baseURL. An earlier version of this example
got that wrong two ways at once: its stateExtension="temperature/celsius"
polled .../api/v1/metrics/temperature/celsius, which isn't a route and
returns 404, so the Channel never gets a value and the Item stays
NULL/UNDEF; and even a valid stateExtension would still have broken
the JSONPath, since it was written for the full-snapshot body, not the
narrower one a sub-resource returns.
Polling the full snapshot like this pays off once a Thing has several
Channels, since they all share one request. For a Thing with only one or two
Channels, set that Channel's stateExtension to a
per-metric sub-resource instead (e.g.
stateExtension="temperature") and write its stateTransformation against
that narrower body (JSONPATH:$.celsius) — one request per Channel, but
each one smaller. See
docs/integrations/openhab.md for a full,
multi-channel example and troubleshooting.