Skip to main content

System stats from a host PC, drawn on a Badgeware badge

Project description

statsbadge

Your PC's vitals on a Badgeware badge. A compact, wireless and extensible hardware monitor for Windows, macOS and Linux.

CPU Load

Runs on Badgeware's Tufty, firmware v3.0.0 and above, with a 2.8" 320x240 colour IPS display.

Build your stats overview from a selection of pages - bar graphs and waterfalls to big, bold gauges - configured in a web UI on your computer and pushed to your badge at the press of a button. A server on the host measures things and serves them; the badge fetches and draws them, so rearranging the display needs no reinstall.

Install

Uses uv. As a tool, so it lands on your PATH and keeps its own environment:

uv tool install statsbadge
statsbadge serve

Working on a checkout instead:

uv sync
uv pip install --no-deps ./extensions/statsbadge-clock   # optional
uv run statsbadge serve

Or into whatever environment you already have, with pip or uv:

uv pip install statsbadge      # or from a checkout: uv pip install .
pip install statsbadge         # plain pip works too

Extras, if you want them: statsbadge[nvidia] for NVIDIA cards via NVML, statsbadge[install] for pushing the app to a badge over USB, statsbadge[all] for both.

With the badge on USB, in another terminal:

statsbadge install                       # copies the app, extensions and pairs it
statsbadge install --ssid "My Network"   # and sets up WiFi, from a brand new badge

That writes the pairing secret over the serial REPL and, if the app needs copying, offers to reset the badge into USB mass storage mode to do it. Answer no and you get credentials only.

Run it again whenever you have upgraded the package or installed an extension. It compares what is on the badge with what it would put there, copies only what changed, removes what no longer belongs, and leaves the badge alone entirely when nothing has: about a second, no reset. statsbadge update is the same command under the name you probably reached for. Credentials it has already written are left as they are, so a repeat run is purely a code update.

--ssid sets the WiFi details in the badge's secrets.py while that volume is mounted, so a new badge goes from unboxed to showing stats in one command. It prompts for the password, so the password stays out of your shell history; --pass takes it directly and an empty string means an open network. --region and --timezone set those too. Details the badge already has are left alone unless you pass --force-secrets.

No cable? Run statsbadge pair, or open the config UI and press Pair a badge. Launch Stats on the badge and press B to set up; it finds the host by itself and shows a six-character code. Check that code matches the one the host shows, and approve it there. Nothing is typed on the badge.

A server is not in pairing mode until you put it there, and the window closes on its own after five minutes or when you press Stop pairing. Requests are rate limited and capped, and one only pairs a badge when you approve it.

Then open http://127.0.0.1:8420/ to pick screens, themes and button bindings. A button can run a host command or do something on the badge itself - page back and forth, or cycle the brightness - and the badge can take that brightness down to suit a dim room off its own light sensor, or page through the screens by itself once nobody has touched it for a while.

Everything else you might want

statsbadge serve                       # the usual thing
statsbadge status                      # what is on the badge, what this host knows
statsbadge ext                         # installed extensions, and whether they loaded
statsbadge ext add clock               # install one and remember it
statsbadge probe                       # what this host can measure at all
statsbadge badges                      # which badges are paired here
statsbadge badges --forget <badge-id>

statsbadge install --force-app         # copy the app whether or not it changed
statsbadge install --no-extensions     # leave extension modules off the badge
statsbadge install --without clock     # everything except that one extension
statsbadge install --new-secret        # re-key this badge
statsbadge install --ssid "Other" --force-secrets    # change the WiFi it uses

statsbadge --config-dir ./cfg serve    # global options come before the subcommand

Each badge is configured separately: the picker in the header of the config UI says which one the page belongs to, and pages, theme, buttons and the rest are that badge's own. A badge that has just been paired draws the default until it is saved for the first time, which is what "Default, for any other badge" in the picker edits. Saving for one badge leaves the others where they are - a badge only refetches when its own layout's revision moves. Forgetting a badge takes its layout with it. What an extension is told - a place, an API key - stays one answer per host, since that is what it is.

Configuration lives in ~/.config/statsbadge on Linux, ~/Library/Application Support/statsbadge on macOS and %LOCALAPPDATA%\statsbadge on Windows, or $XDG_CONFIG_HOME/statsbadge wherever that is set. statsbadge status prints the path it is using. Three files: layout.json, server.json and badges.json, the last holding pairing secrets and kept at mode 600.

Changing IPs, and more than one computer

Credentials are keyed on a server id the host mints once, not on its address. So:

  • The host's IP changes. The badge notices the polls failing, hears the host's beacon, recognises the id it is already paired with and follows it to the new address. Nothing to re-pair.
  • Two computers. Pair with both - statsbadge install and statsbadge pair add a host rather than replacing one, each with its own secret and counter. The badge uses whichever it can reach, and switches by itself when the current one goes quiet.
  • Same computer, fresh install. install reuses the existing secret unless you pass --new-secret, and folds an older single-host config in rather than orphaning it.

statsbadge badges lists what a host has paired; --forget drops one.

On the badge

Button What it does
UP/DOWN previous/next page
A B C whatever the host has bound them to, if anything
HOME open the hosts menu
HOME, held leave the app

The hosts menu is how you switch between machines - laptop, desktop, that Linux box - and how you add another one. It rescans every time it opens, so a server you start after the app is already running turns up without a restart.

What it reports

Group Fields
cpu load, per-core, temperature, clock, load average, processes
mem used, total, percentage, swap
gpu load, temperature, VRAM, power, clock, fan
net up/down rate and totals, interface
disk used, total, read/write rate
power battery, charging, package watts
fans RPM
sys host, OS, CPU name, uptime

A field the host cannot measure is null, and pages that need it are dropped rather than shown empty. On macOS that means temperatures, fan speed and package power: those need root, so they are opt-in with --powermetrics, which allows one command and nothing else:

sudo visudo -f /etc/sudoers.d/statsbadge
# then, with your own username:
you ALL=(root) NOPASSWD: /usr/bin/powermetrics --samplers cpu_power,gpu_power,thermal -i 1000 -f plist

Run with --powermetrics and no rule in place and it says so, prints that line with your username already in it, and carries on without those fields. Windows needs LibreHardwareMonitor running with its web server on for temperatures and fans. NVIDIA GPUs need pip install statsbadge[nvidia].

Page kinds and themes

Twelve kinds and any field can go in any of them. Six show readings as they are: dial, dials, bars, graph, grid, text. Five say something a single number cannot:

Kind What it is for
rings up to four readings as concentric gauges, each coloured by its own value
spark six readings at once, name, current value and recent history a row each
radar three to six readings as a polygon: the shape of the load rather than its size
trend one big reading, which way it is going, and where it has been
waterfall a list field as lanes over time, interpolated between polls
badge the badge's own vitals, which need no field and no host

dial has a page to itself, so it is the one gauge big enough to read a ramp off: set Dial gauge to The Whole Ramp and it fills with a conical gradient carrying the theme's whole ramp, with the part past the reading drawn from the same stops at alpha 32 so the scale still shows behind it. Costs 0.6ms a frame.

The whole ramp

badge is the odd one out: battery, memory, both filesystems and the ambient light as levels, the clock, voltage, power source, uptime and screen as figures, and the board, firmware and uid underneath. Nothing on it comes from the host, so it is the page to turn to when you are wondering whether the badge or the network is the problem.

Badge

waterfall is the one that moves. Point it at cpu.cores and every core gets a lane, coloured by the theme's ramp and scrolling right to left at about 28fps - it interpolates between the once-a-second polls rather than stepping, so it reads as motion rather than as data arriving. Precision is what that trades away; the numbers are on the other pages. Sixteen themes, and forty-eight accents. Written down and grouped light and dark in the picker: Default Dark and Default Light, frost, vapor, sakura, the three Eva units, and four that come as a pair for a lit room and a dark one - Mono, Watermelon, Shell and Luminescence. Everything is drawn as vector shapes taking their colours from one table, so a theme is a palette and not a set of images - and the palette travels to the badge with the layout, so it is config: themes.py is the only place one is written down, and adding one needs no install.

Then four you can tune yourself, derived rather than written. Pick one of forty-eight accents - twelve hues in four families, Pastel, Normal, Saturated and Dark - and the rest of the palette is worked out from it: Tinted Dark and Tinted Light hold every hue at one chroma and send the ramp to red unless the accent is already there, and Tinted Bold Dark and Tinted Bold Light take each hue as far as sRGB allows and keep the ramp in it, sweeping lightness instead. The single-hue themes that used to be written down - red, green, cyan, amber, blueprint - are that second pair with an accent, so those names still resolve to what they always looked like. A palette can also carry a second accent, used sparingly: a graph's second series takes it, which is the one place the badge otherwise has to hunt through the ramp for a colour that will show. A derived theme picks it by rule - Same, Complementary, Triadic or Contrasting, the last being whichever offered hue lands furthest away once lightness and chroma are counted - and Watermelon Light names its own, that page having nowhere else to put its green. The config page previews whichever theme is selected, derived or not.

Cores Network Disk Processor Host Vapor Sakura Watermelon Shell Unit-01 Luminescence

Waterfall Rings Sparklines Radar Trend

Extensions

An extension is a pip install away. It adds data to the frame, and optionally badge-side code for a page the built-in kinds cannot draw:

statsbadge ext add clock
statsbadge install

Badge-side modules go on by default, so adding an extension and then running install is all of it. --no-extensions leaves them off, and --without NAME drops one from both the frame and the badge.

uv tool install is declarative: every run replaces the last, so naming one extension would drop the others. ext add keeps the list in extensions.txt beside your config and rebuilds from all of it, taking whatever the tool was already installed with as its starting point - extras like statsbadge[nvidia] included, read from uv's own record of how it was installed.

statsbadge ext                     # what is installed, and what the list asks for
statsbadge ext add iss quakes      # add, then rebuild
statsbadge ext remove clock        # take one out, and out of the environment
statsbadge ext sync                # make the environment match the list

Installed some other way - a venv, pipx, a checkout - and ext add says so and prints the uv pip install line to run instead. The list is still the record either way.

Three extensions are vendored here: statsbadge-clock for a clock and the weather, statsbadge-iss for the space station, and statsbadge-quakes for recent earthquakes. The last two draw on the badge firmware's own world map, so running both costs one copy of the coastlines and no geometry crosses the network.

An extension can declare settings that belong to one page rather than to the extension, so two pages of the same kind can show different things. The clock uses it for a place and a face: point one page at Tokyo and another at home, and each shows that city's weather and its own local time - Open-Meteo returns a location's UTC offset with its forecast, so a place settles the time too and there is no timezone to set. latitude and longitude are there per page as well, for a spot no name lands on. Settings that describe how the extension works, like units or an API key, stay under Extensions where there is one answer per machine, and the place set there is the default for any page that names none.

Settings are what an extension is told. What it works out is self.store, a small dict the host keeps between runs: store.get(key) and store.set(key, value), namespaced by the extension's entry point name and written under the config directory, so an extension never picks a filename or manages a directory of its own. It is in place by the time start runs. The clock keeps the coordinates a place name resolved to, since a town does not move: the geocoder is asked once per name ever rather than once per launch, and a badge coming up with the geocoder rate limited still knows where it is looking.

An extension can ship more than code. badge_assets lists further files to push, and the clock uses it for an icon font: its icons.txt names the Material Symbols it wants, and tools/make_icon_font.py packs them into an .af the badge loads with font.load().

uv sync --group fonts
python3 tools/make_icon_font.py extensions/statsbadge-clock

That fetches Material Symbols, fits each glyph to the text font's metrics so icons sit on the same baseline as the words beside them, and writes src/statsbadge_clock/badge/icons.af. Any vendored extension with an icons.txt builds the same way.

statsbadge-clock is a clock and the weather: five faces, including a Swiss railway station clock whose second hand sweeps at the badge's frame rate. Weather from Open-Meteo, no key needed.

Railway Dots Squircle Digital Digital LCD

statsbadge-quakes puts recent earthquakes on a world map, cycling through them on its own: the camera closes in on each and pulls back out to cross an ocean, and the rings leaving an epicentre are coloured by magnitude. From USGS, no key needed.

Quakes Quakes pulled out

statsbadge-iss tracks the space station across the same map, with an orbit of ground track either side of it and the day and night terminator washed over the half the sun is not on. The sub-solar point arrives with the position, so the terminator costs two numbers and no almanac. Both feeds are open, so there is no key and no account to set up.

ISS ISS following

See DEVELOPMENT.md for how to write one.

Security

Plain HTTP on the LAN, with every request signed HMAC-SHA256 against a shared secret from pairing, and a counter the host refuses to accept twice. So a command cannot be forged or replayed, and an unpaired device on the network learns nothing. TLS is affordable on this hardware but buys little without certificate validation - DEVELOPMENT.md has the measurements. The config API is bound to loopback because it can mint secrets. Host commands only run if you have bound them to a button.

Fonts

The badge draws with two typefaces, both under the SIL Open Font License, packed into .af by tools/make_text_font.py:

  • Lexend for everything, and again as thirteen digits packed wide for the clock face that draws numbers the height of the band. Licence.
  • DSEG by keshikan, DSEG7 Classic Bold, for the LCD clock face's seven segments. Licence.

Names

The repository, the package, the module and the command are all statsbadge. Keeping them identical is deliberate: name the distribution and the module differently and uv_build needs its module-name setting, which older uv treats as a fatal parse error rather than a warning. Extensions follow it - statsbadge-clock on PyPI, statsbadge_clock to import, clock to statsbadge ext add.

Layout

src/statsbadge/            the host server, a normal Python package
src/statsbadge/badge_app/  the badge app - MicroPython, runs only on the badge
src/statsbadge/web/        the config UI
extensions/                one package per extension
tools/                     host and on-badge development tools
tests/                     server, auth and framing tests

The badge app lives inside the package so that an installed wheel carries it and statsbadge install can put it on a badge with no network. It is MicroPython and is never imported on the host.

Working on it

uv sync                                             # dev environment
uv run statsbadge probe                             # what this host can measure
uv run python tests/test_core.py                    # server, auth, framing
uv run python tools/check_app.py                    # the app parses and is whole
uv run ruff check --config ci/ruff.toml src tools tests extensions
uv build                                            # sdist + wheel into dist/
ci/build-mpy.sh                                     # precompile the badge app

mpremote connect PORT mount . run tools/probe.py    # draw every page, time it
mpremote connect PORT mount . run tools/live.py     # talk to a real server
mpremote connect PORT mount . run tools/run.py      # the whole app, uninstalled
mpremote connect PORT mount . run tools/multihost_test.py   # pairing config
mpremote connect PORT mount . run tools/failover_test.py    # a changed host IP
uv run python tools/shots.py shots                  # framebuffer dumps to PNGs

statsbadge install uses precompiled bytecode by default: CI compiles it into the package before the wheel is built, so a pip install carries both that and the .py sources. It loads in 66ms where the sources take 763ms, because the badge compiles at every launch. Bytecode only loads on the firmware it was built for, so if a badge runs different firmware the install falls back to the sources and says so. --mpy DIR installs a build of your own, --source forces the sources.

Releases: tag vX.Y.Z and publish a GitHub release. The tag is the version - nothing in the repository states one - so a release cannot disagree with what it publishes. CI builds the wheel, checks it carries the badge app, attaches both the source and precompiled app zips, and publishes to PyPI over trusted publishing, with no API token to store.

Four packages come out of this repository, so the tag says which one a release is for and what version it is: v0.2.0 is statsbadge itself at 0.2.0, and clock-v0.1.0, iss-v0.1.0 and quakes-v0.1.0 are the extensions at 0.1.0. Each has a workflow of its own, because PyPI matches a publisher on the workflow's filename. Between tags a build reports what git says - 0.2.1.dev4+g1234abc - which is a version PyPI will not accept, so only a tag can publish.

DEVELOPMENT.md covers how it is put together and what each frame costs. It also explains why the server writes every response in a single write(), which is worth 30x on this hardware.

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

statsbadge-1.0.0.tar.gz (820.4 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

statsbadge-1.0.0-py3-none-any.whl (255.6 kB view details)

Uploaded Python 3

File details

Details for the file statsbadge-1.0.0.tar.gz.

File metadata

  • Download URL: statsbadge-1.0.0.tar.gz
  • Upload date:
  • Size: 820.4 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: uv/0.9.2

File hashes

Hashes for statsbadge-1.0.0.tar.gz
Algorithm Hash digest
SHA256 aeccac0587bb465f086d44511ecafdc38ab2a27c0dc7d44fc6a8b077a4437790
MD5 7b15ff03e153c96cc30a4ee90df2d8b5
BLAKE2b-256 be95922af101fe478e8fb58bc73a87d7d0dd6a484701ea9ab9ab430cd31e8bda

See more details on using hashes here.

File details

Details for the file statsbadge-1.0.0-py3-none-any.whl.

File metadata

  • Download URL: statsbadge-1.0.0-py3-none-any.whl
  • Upload date:
  • Size: 255.6 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: uv/0.9.2

File hashes

Hashes for statsbadge-1.0.0-py3-none-any.whl
Algorithm Hash digest
SHA256 d00dab86d315aca35e1170faef8387f7d02a31d03a52c28c6846ee70f7ba25aa
MD5 a8013198af222aadefbecbfdeba12227
BLAKE2b-256 11199e21d00a0359b1b4ae57633c84b2b00e9d6f9cf34ce194d8740deed36ec1

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page