tpi
The command-line client.
Newest release v1.10.0, 21 September 2026. 15 in total. Each entry is this repository's own CHANGELOG.md where it has one, and the release note where it does not — fetched by just refresh-changelog, so this page and the repository cannot disagree.
v1.10.0 — 21 September 2026
Added
tpi network address show | apply | confirm | revert. The BMC's own address from the command line:apply --dhcp, orapply --static 192.168.1.20/24 --gateway 192.168.1.1 --dns 192.168.1.1, applied and NOT kept untilconfirmreaches the board at the new address -- run it as a new invocation with--host <new address>. A confirmation from a shell on the board is refused, as the switch's is. Needs bmcd 2.38.0.tpi ntpprints what chrony thinks of each source: state (selected,combined,excluded,unreachable,falseticker), stratum, how many of the last eight polls answered, the offset -- and, when nothing is selected, one sentence on whatunreachableandfalsetickermean. "NOT synchronised" on its own sent a user to Discord with nothing to act on.
v1.9.0 — 20 September 2026
Changed
-
A refusal in
application/problem+jsonprints its sentence, not the whole object. Every path on the fork's own endpoints refuses in that shape and puts the message indetail; this read only the legacy wrapper, so a perfectly clear refusal came out as escaped JSON and read like a parser error. -
A board still on its factory password says so in its own words. It refuses almost every call, and the bare refusal read as a permissions problem with whatever was asked for. It is not — it is a board that has not been set up — so it gets its own message, with the one thing that fixes it.
Recognised by the problem document's title, never by the status: 403 is also what a wrong password and a refused switch confirmation answer, and matching on the status would have told somebody confirming a switch change from the board's console to go and change their password.
Added
-
tpi network switch show --table— the running document alone, as JSON, and nothing else. The other half ofapply --table, so that editing a layout by hand is a round trip rather than a transcription:tpi network switch show --table > mine.json$EDITOR mine.jsontpi network switch apply --table mine.jsonStarting from what the board is running, rather than from a blank file, means the ports you did not mean to change keep what they already had.
A board whose answer carries no document is an error here rather than an empty file: an older daemon answers an unrouted path with 200 and
index.html, and writing that into the file somebody is about to apply would be worse than saying so. -
VLAN names are shown where VLAN numbers are.
20 (nodes)rather than20, wherever the document the board sent carries a name for it. The number comes first and is never replaced — it is whatbridge vlan showprints and what the router is configured with, so output that hid it could not be checked against anything.
Changed
-
applyandshownow say where a confirmation has to come from. One line under the confirm command:Confirm from this machine, or from the board's interface. A confirmation sent from a shell on the board itself is refused: it crossed no switch port, so it would prove nothing.
The daemon refuses it outright; this is the version somebody reads before they try. Both say the same thing, in the same words, on purpose.
Added
-
tpi network switch—show,presets,apply,confirm,revert. The command line for the on-board switch.applytakes--preset flat|split|trunkor--table file.json, with--mgmt-vid,--node-vidand--second-uplinkfor trunk, and--windowfor the confirm window.It prints what it has done in the terms that matter: the change is on the switch and not kept, the board will put the previous configuration back unless you confirm, and the countdown starts when the uplink forwards rather than now. The confirm command is printed ready to paste, because the moment you need it is the moment the page you were reading may have gone.
confirmis a separate invocation on purpose. That is the proof: if it can reach the board, the new configuration works.Trunk's two VLAN identifiers are refused here rather than at the board when they are missing or equal. They are the operator's — the router on the other end has to agree, and this tool cannot know what is free there.
presetsnever expands anything itself. A client that expanded a preset would eventually disagree with the board about what it means, and that disagreement is a board nobody can reach.
Added
-
tpi tls show,tpi tls install --cert --keyandtpi tls reset. The certificate the board serves over HTTPS, readable and replaceable from the command line. For anyone running their own CA who wants a board a browser opens without a warning — and a serial console that works, since a click-through exception does not cover the console's WebSocket.showprints the subject, issuer, validity, key type, the names the certificate asserts and its fingerprint. It says in words whether the board issued the certificate, and therefore renews it, or whether somebody installed one and renewal is now their job.installreads both files and checks their shape before contacting the board:--certand--keyswapped, or one file holding both halves. The board validates properly and refuses before writing anything; this is about the message. A combined file is refused outright, because sending it would put the private key in the field the board treats as public.resetremoves an installed certificate and has the board issue its own.These are the only commands that do not go through the legacy dispatcher. That is the daemon's decision: it writes every mutating legacy query to the audit log in full, so a private key sent that way would be recorded in clear on the board.
A board whose daemon is too old is reported as an old daemon rather than with whatever it happened to answer. There is no version gate, because the board's own answer costs nothing and cannot be wrong about the board.
Two answers mean the same thing, and both are handled. A later daemon may route these paths and reply
404; today's does not — bmcd serves the web interface from the same listener and falls back toindex.htmlfor anything it does not route, so an older board replies 200 with a page of HTML and the only symptom is JSON that will not parse. Reading the status alone would quote a page of markup at you instead of one sentence.
1.8.0 — 11 September 2026
Fixed
-
tpi reboot --helpsaid the compute modules lose power. They do not. The daemon's own summary says the opposite, and every firmware flash this estate has done measured it: all four modules stayed powered and their Kubernetes nodes never restarted. A warning that overstates the blast radius talks people out of a reboot they should do (SQU-200). -
tpi power statusbroke on a rail the board could not read. The daemon answers"Unknown"as a third value — its own schema says so — and the printer parsed straight to a number, so such a board producedinvalid digit found in stringand a raw JSON dump. That is exactly the board you would be running this against.
Added
-
tpi health— uptime, load across the three windows, memory including the daemon's own resident set, NAND eraseblocks available and bad, and whether the clock is actually disciplined rather than merely configured, with its offset. -
tpi sdcard [path]— what is on the microSD card and what can be written to a module. The companion totpi flash --local, which until now required the operator to know a path and type it correctly for the most destructive thing this board does. A non-candidate is listed with its reason rather than hidden: a.tpusays it is BMC firmware and belongs on the firmware page.Every field was read off a board rather than assumed — the first draft used
load1andfree_eraseblocks, neither of which the daemon sends, and an absent key prints nothing, so the mistake would have been silent.
1.7.0 — 11 September 2026
Fixed
-
Renaming a board does not move its metrics history, and the tool stops saying it does (SQU-191).
tpi hostname <name>told the operator, on success and in--help, that the metricsinstancelabel changed with the rename and that a Prometheus history would not follow the board across it.Measured on a board rather than reasoned about: the daemon's exposition is 190 lines with zero occurrences of
instanceorhostname, and the board's own name appears nowhere in it. The labels it emits aredevice,kind,name,node,port,result,sensor,slot,state,versionandvolume.instanceis assigned by whatever scrapes the board.So renaming moves nothing, and editing the scrape label moves everything. The warning was written from a worry rather than from what the daemon emits, and it survived a real rename of two boards without anyone checking.
1.6.0 — 9 September 2026
Changed
-
firmware installno longer resolves the version before posting it. The cached listing now informs the confirmation line; it cannot refuse.The refusal existed because of a comment claiming the board "would accept a source/version pair that offers nothing and fail later, in the middle of a download". Measured on a board: the daemon answers 400 in 0.48 s with
tpi-selfupdate: cannot fetch SHA256SUMS for v9.9.9, before any download, staging nothing. The refusal protected against nothing.It cost something real, though. The listing is a cache that lags a release by up to half an hour, and
firmware checkreads a different cache with its own timing, socheckprintedinstall it with: tpi firmware install <v>and the next command rejected it. That is now impossible, because install has no opinion to disagree with.1.5.0 and 1.5.1 both tried to fix this by making the stale listing fresh — 1.5.0 inertly, 1.5.1 by waiting up to three minutes. Both were the wrong shape. The board is the authority on what the board can fetch.
When the listing does not carry the version, the source is chosen in order:
--source; the source the running firmware came from; the only remote source. A local source is never guessed — a version absent from the listing cannot be the file on the SD card. Ambiguity asks.
Fixed
-
firmware list --refreshcould tell you a stale listing was current. It waited 30 attempts of 2 s, then gave up and printed the previous listing with nothing said, indistinguishable from a fresh one. Polls timed on a board took 74 s, 78 s and 140 s, so a minute was short of even the ordinary case.The bound is four minutes, it says so when it gives up, and it names the timestamp of what it is showing.
-
firmware listnever showed how old the listing was. It does now, on every run, refreshed or not. The daemon had always sent the age; the client discarded it.
1.5.1 — 9 September 2026
Fixed
-
1.5.0's fix did not work; this one does (SQU-184). 1.5.0 asked the board to re-poll its sources and resolved against the answer. But
firmware_available&refresh=1does not fetch — the daemon spawns the poll and returns the cached list immediately, withrefreshingset. So the second resolution ran against exactly the same stale list as the first, and the change was inert.Confirmed on the board: a forced request came back in one second carrying the same
checked_atit had before the call.installnow waits for the poll to land before re-resolving. The predicate ischecked_at, which advances exactly once a poll completes, so it can actually be false while the poll runs — which is what 1.5.0 never checked. The wait is bounded at 180 s; a refusal that hits that bound says so rather than claiming no source has the version.Measured on the board, same command, same question, a version that exists nowhere:
build elapsed checked_at1.5.0 1 s unchanged 1.5.1 58 s 20:50:57 → 20:52:11 The deadline is a measurement too: a poll over four sources took 78 s, where the daemon's own comment says 16. There is a test asserting the bound clears it, so shrinking it cannot silently bring the wrong refusal back.
1.5.0 — 9 September 2026
This release does not do what the entry below says. The retry it added is inert, for the reason in 1.5.1. Kept as written, because the mistake is more useful on the record than tidied away.
Fixed
-
firmware installno longer refuses the commandfirmware checkjust printed (SQU-184). The two read different caches with independent timing:checkasks the board's update checker, which reaches GitHub, whileinstallresolved the requested version against the firmware catalogue, whose entries are fresh for half an hour. For up to that long after a release, one could see it and the other could not.Reproduced on a board at 18:57:03 UTC, twenty-seven seconds after v2.16.0 published:
checkprintedinstall it with: tpi firmware install v2.16.0and exited 10; the very next command answeredno source offers v2.16.0and exited 1.installnow re-resolves against a forced poll before believing that no source has the version. The successful path is unchanged and pays nothing; only the request that was about to be refused waits for the fan-out, which is nothing against a firmware download. Aligning the two cache lifetimes was considered and rejected: the checker saw the release immediately despite a nominally longer cache, so their states are independent and no ordering between them can be assumed.The refusal itself stays. Posting an unresolved version to the board would fail in the middle of a download instead of before it starts.
1.4.0 — 9 September 2026
Removed
-
tpi metrics show | rotate(SQU-178). The metrics token is gone from the daemon:/metricsmoved to its own listener on port 9110 that takes no credential, so there is nothing left to show or rotate. A script that calls this now gets a clear "unrecognized subcommand" from clap rather than a request that quietly does nothing. -
tpi config export --with-secrets, and the warnings that went with it. The export carried exactly one secret, the metrics token, so with that gone the document is no longer a credential and the flag had nothing to include.
1.3.0 — 9 September 2026
Added
-
tpi cooling set <device> <speed> --holdholds the fan at a step by pausing the zone's governor, and--autohands it back (SQU-170). Without--holdthe command means what it always did: write the step and let the governor take it back a few seconds later.--overrideis accepted as an alias for--hold. -
tpi cooling statushas a Governor column, reading theoverriddenfield bmcd 2.21 added. A daemon that does not send it prints-rather thanrunning, because "the governor is running" is the one fact the column exists to report and an older daemon has not reported it.
1.2.3 — 9 September 2026
Fixed
-
Releases were green and empty. The release job was upstream's, written for a push to master: read the version from
Cargo.toml, and if a tag for it already exists, abort. This fork changed the trigger to the tag itself, so the job ran because the tag was pushed, found it, and skipped every step that publishes anything — while reporting success.The result is that 1.1.0, 1.1.1, 1.2.0, 1.2.1 and 1.2.2 have no release page and no binaries, on a tool the documentation tells people to install. This is the first release of this fork that actually ships something.
The job now publishes from the tag it was triggered by, and two guards stop the failure recurring quietly: the tag must agree with
Cargo.toml, and there must be at least one artifact to attach. Both were checked in each direction before this was tagged. The actions it calls are pinned by commit, matching the other repositories here.
1.2.2 — 9 September 2026 (no release of its own)
Fixed
firmware install <version>had never worked in any release. The positional was namedversion, and the--versionflag clap generates for every subcommand has the same id. clap's debug assertion reports the collision by panicking; a release build skips the assertion and gets undefined argument handling instead. Every install this fork ever performed went through the web interface,curl, or the on-board updater. Found by using the command as the flash tool for v2.9.2 — which is what it exists for — and verified by doing exactly that: the install went through the transfer endpoint, the board staged it,firmware listsaid so, and the reboot promoted it.firmware list --refreshprinted the previous list. The daemon answers a refresh at once and re-polls the sources behind itself (bmcd 2.11.0). That is right for a page, which draws a spinner, and wrong for a shell command that asked for a fresh answer. It now waits for the daemon to finish, bounded at a minute, and says polling the sources… on stderr so the wait is not mistaken for a hang.
1.2.1 — 9 September 2026 (no release of its own)
Five bugs, all found by running the tool against a board for the first time. Every fork command was broken; none of them had ever been executed against real hardware, and the examples in the documentation had never been produced.
Fixed
- Every fork command failed with "invalid type: map, expected a string."
unwrap_responsereturnedbody["response"]— the array — instead of theresultinside it. Serde will deserialise a struct from a sequence, taking the elements as fields in order, soAboutread the single{"result": …}map as its firstStringfield. Every command begins with the version gate, which readsabout, so every one of them failed. The unwrapping now digs through the wrapper and passes a bare body through unchanged, because the transfer endpoint answers{"handle": N}with no wrapper at all. firmware checkprinted " is current" with no version. The daemon answers per channel —{checked_at, error, stable: {…}, edge: {…}}— and this deserialised the outer object into a struct whose every field is#[serde(default)]. So it "succeeded" with everything empty.defaulton every field is what turned a shape mismatch into silence; it now reads the stable channel and fails loudly if no channel is there.thermalprinted raw JSON. Its formatter expected an array of{name, temp}in millidegrees — a shape the daemon has never sent. It now renders the sensors with the trip that is governing the fan, and the fan's step with the duty that step commands.- Every refusal printed as raw JSON. A refusal arrives in the same wrapper
as a success, and the error path read
responseas a string, so the message came out buried and escaped inside{"response":[{"result":"…"}]}. firmware list --allpanicked.-awas claimed by both--alland the global--api-version, which clap's own debug assertion catches by panicking.--allis long-only now.
Changed
- The rename warning prints after the board accepts. Printed first, it announced a rename that the next line then refused, and a warning about a consequence that did not happen is worse than no warning.
1.2.0 — 9 September 2026 (no release of its own)
Added
-
tpi hostname [<name>](SQU-138). Prints the live name, and the one that takes effect at the next boot when the two disagree — which happens when somebody has runhostnameby hand. Setting it says, before it happens, that the metricsinstancelabel moves with the name and a Prometheus history will not follow the board across the rename. That is the part nobody expects, and renaming back does not undo it. -
tpi ntp show | set <servers…>(SQU-167). The first server is the preferred one.setwith no servers goes back to the pool the image ships with.showprints the clock's state underneath, and says outright when the board's firmware has nosourcedirline — a saved list that is silently never read is the one failure printing the servers cannot reveal. -
tpi config export [--file] [--with-secrets]andtpi config import <file>(SQU-142).--with-secretsincludes the metrics token and warns on stderr that the file is now a credential; when the export goes to stdout it says nothing, because stdout is being piped somewhere and a stray sentence would land in the file.Import parses the file before posting it, so something that is not an export fails before anything on the board changes, and prints the daemon's per-field report. It exits 1 if any field failed: the import is not transactional, and a script that moved on from a half-configured board would be worse than one that stopped.
-
tpi firmware listsays what is staged and how the last boot went. Without it a shell user had no way to learn a rollback had happened at all — the gate rejects an image, the board reboots onto the old one, and the list would report the old version as running with nothing to say an install had been attempted and refused.
1.1.1 — 9 September 2026 (no release of its own)
Fixed
-
firmware installcould not install anything thelocalsource listed (SQU-168). It postedfirmware_install&source=local, which the daemon refuses by design — "a local image is installed throughopt=set&type=firmwarewithlocal=1" — so an image parked on the SD card resolved correctly and then failed with a 400. Found while flashing the promotion-gate test image, which had to be installed by hand.A local candidate now goes through the transfer endpoint with the path the catalogue reported, and the flash is followed to completion. That last part matters: the endpoint answers with a handle and writes in the background, so printing "staged" on the response would invite a reboot onto a half-written image.
-
The default host failed with an error that named the URL and not the problem.
tpi aboutwith no--hostdied inside reqwest with error sending request for url (…). The default isturingpi.local, which the board advertises over mDNS — and firmware v2.8.0 and earlier of this fork ship without an mDNS responder, so the name is silent on a board that is otherwise working. The name is now resolved before the request, and only when it is the default: an address someone typed deserves the real error, not a guess about DNS.
Added
SourceCatalogcarries the source'skind, which is what decides the two paths above.devbox.json,.envrcand aJustfile, sojust checkruns exactly what CI runs. The repository had no way to build it locally at all — the toolchain came from whatever happened to be on the machine, and--features native-tlsneeds pkg-config and OpenSSL that CI's runner image happens to carry.
1.1.0 — 8 September 2026 (no release of its own)
Added
firmware list,firmware install,firmware check,firmware sources,about,thermalandmetrics token show|rotate— every endpoint this fork's daemon added, which until now were reachable only from the browser.firmware checkexits 10 when an upgrade exists, sotpi firmware check || notify-meworks from cron without parsing output. It is deliberately not1, which stays "the command failed".- Each command asks the board its version first and refuses with "this board
runs bmcd X;
…needs Y" rather than the daemon's "Invalidtypeparameter", which names the query parameter and tells you nothing.
Fixed
- The version comparison is numeric. As text
"2.10.0" < "2.9.0", so a string compare would tell a board on 2.9.0 that it satisfied a 2.10.0 requirement. - A cursor could move right past the end of the prompt buffer.
Changed
- The toolchain is pinned by
rust-toolchain.tomlto 1.98.1, the version the firmware's Buildroot builds the on-board copy with. CI usedstable, so a Rust release could change what CI validated without anyone touching the repo. - CI off the archived
actions-rs/*.clippy-checkposted findings as a GitHub Check the token could not create, so the job failed with "Resource not accessible by integration" while clippy itself reported no warnings. - Releases are cut by a version tag rather than a push to the default branch.