<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
  <title>Turing Pi 2 BMC firmware releases</title>
  <subtitle>Every release of this fork's firmware, with what changed in it.</subtitle>
  <link href="https://turingpi.xyz/feed.xml" rel="self"/>
  <link href="https://turingpi.xyz/news/"/>
  <id>https://turingpi.xyz/feed.xml</id>
  <updated>2026-09-23T00:00:00Z</updated>
  <author><name>excavador-turing</name></author>
  <entry>
    <title>Firmware v2.39.0: Naming a VLAN greyed out Try it and Apply, and blamed the board</title>
    <link href="https://turingpi.xyz/news/v2.39.0/"/>
    <id>tag:turingpi.xyz,2026-09-23:firmware/2.39.0</id>
    <updated>2026-09-23T00:00:00Z</updated>
    <content type="text">Pins **bmcd 2.38.3** and **BMC-UI 3.38.0**; tpi stays at 1.10.0. One bug,
from a reader who named a VLAN.

### Fixed

- **Naming a VLAN greyed out *Try it* and *Apply*, under a line blaming the
  board.** Type a word beside a VLAN number in the switch card and the
  buttons went grey with *&quot;This board cannot check a configuration before
  it is applied.&quot;* — a sentence true of neither the board nor the layout.
  The daemon had refused the request over a corner of how it reads one:
  `validate` and `PUT` accept either a preset or a whole document, and the
  type that says &quot;either&quot; buffers the body before deciding; in the buffered
  form a map key `&quot;50&quot;` never becomes the number the names table is keyed
  by. So a document with no names read fine and a document with one name
  was refused, on both endpoints, on every release since names existed
  (v2.33.0). Reported as
  [#59](https://github.com/excavador-turing/BMC-Firmware/issues/59) and
  reproduced on a board with the same document twice.

  bmcd 2.38.3 reads the names by their wire form, so both paths agree, with
  a test that fails on the old code with the reporter's exact message. And
  BMC-UI 3.38.0 stops standing one sentence in for two failures: a rejected
  request now shows the daemon's own words — *&quot;The board refused the
  question rather than the layout: …&quot;* — and *cannot check* is kept for the
  one case it was written for, no answer at all.</content>
  </entry>
  <entry>
    <title>Firmware v2.38.0: The page now says what the board is doing while it installs</title>
    <link href="https://turingpi.xyz/news/v2.38.0/"/>
    <id>tag:turingpi.xyz,2026-09-23:firmware/2.38.0</id>
    <updated>2026-09-23T00:00:00Z</updated>
    <content type="text">Pins **BMC-UI 3.37.0**. bmcd stays at 2.38.2 and tpi at 1.10.0. One change,
from a reader who had just done an update for the first time.

### Changed

- **The Firmware tab now says what the board is doing while it installs.**
  Installing from a source took about half a minute of silence — a greyed
  button and nothing else — because the daemon does the whole job inside one
  request (download the image, check its sum, write it to the spare slot,
  arm the next boot) and answers when it is done. A reader wondered whether
  to refresh; he did not need to, and now the page says so: the button reads
  *Installing…* and a status line counts &quot;about 26 s, now at N&quot;, the number
  measured on a real board like the reboot banner's. Past it the line says
  the board is still working and that a slow link takes longer. Nothing is
  armed until the board says staged, and the page says that too.

  This is also the first release built with the new CI: the cross-toolchain
  comes prebuilt from an image, the Rust packages from a compiler cache, and
  a firmware build takes about eleven minutes instead of twenty-four. Nothing
  in the image changes because of that — the compiler is the same binary,
  built from the same defconfig in the same container — and both boards
  here are the check.</content>
  </entry>
  <entry>
    <title>Firmware v2.37.0: The password form could not be typed into, and nothing noticed</title>
    <link href="https://turingpi.xyz/news/v2.37.0/"/>
    <id>tag:turingpi.xyz,2026-09-22:firmware/2.37.0</id>
    <updated>2026-09-22T00:00:00Z</updated>
    <content type="text">Pins **BMC-UI 3.36.0**. bmcd stays at 2.38.2 and tpi at 1.10.0. One fix, and
it is the one a new board meets first.

### Fixed

- **The password form would not accept typing, in any of its three boxes.**
  The interface's shared text field dropped the change handler for password
  inputs, and a React field that holds its value but cannot report a change
  is read-only: every keystroke was reverted, silently, with markup that
  looks perfect. It hit the password card on Access **and the page a board
  still on its shipped password shows instead of everything else** — so such
  a board could not be taken off that password from a browser at all, only
  over SSH. Logging in was unaffected, which is why it went unnoticed: that
  form reads the page on submit rather than holding state.

  Reported as [#48](https://github.com/excavador-turing/BMC-Firmware/issues/48)
  by a user on v2.34.0, and present since long before this fork. The release
  also adds the gate that would have caught it: the interface's CI now types
  into every visible box of every tab and fails on one that does not keep
  what was typed. Nothing that existed could see it — it builds, it lints,
  it renders, and the page-length gate measures height.</content>
  </entry>
  <entry>
    <title>Firmware v2.36.0: Two ways a board with no DNS failed to mention it</title>
    <link href="https://turingpi.xyz/news/v2.36.0/"/>
    <id>tag:turingpi.xyz,2026-09-22:firmware/2.36.0</id>
    <updated>2026-09-22T00:00:00Z</updated>
    <content type="text">Pins **bmcd 2.38.2** and **BMC-UI 3.35.0**; tpi stays at 1.10.0. Two fixes,
both from one report the day after v2.35.0 shipped, by the same reader whose
report shaped it, on the same 2.4 board: a static address that lost its
resolvers at every reboot, and a firmware check that said &quot;nothing new&quot; on a
board that could not resolve anything at all. They share a cause — the board
had no DNS — and neither of them said so.

### Fixed

- **A static address came back from every reboot with no resolvers.** The
  stanza the address card writes carried a `#` — the tag the DHCP client
  puts on its own resolv.conf lines, kept so a later lease can replace them
  — and the board's `ifup` reads `#` anywhere on a line as a comment. The
  hook that rebuilds `/etc/resolv.conf` (a link into memory on this image,
  empty at every boot) was cut off, the shell refused it, and `ifup br0`
  failed at every boot after the address was already on the bridge. The
  board came up reachable, with a clock that could not find its server. The
  hook now spells the character so it survives; a board the old stanza was
  already written to is repaired the first time this daemon starts, without
  waiting for the address to be changed again.

- **A firmware source it could not reach said &quot;nothing new&quot;.**
  `tpi-selfupdate --list` calls `die` when curl cannot reach the source, and
  `die` exits — but on the left of a pipe it exits only its own subshell.
  The JSON array had already been opened, the loop then read nothing, and
  the script closed the array and exited 0: a well-formed `&quot;releases&quot;:[]`
  with the real reason on stderr, which nobody was reading. bmcd took that
  as &quot;this source offers nothing&quot; and the firmware page said there was no
  update, on a board that simply had no DNS. Reported alongside the clock,
  from the same board; reproduced on board B with an empty `resolv.conf` —
  four sources, no candidates, no errors.

  Both listing branches now collect into a variable first, where a non-zero
  exit is visible, and print nothing on stdout unless they succeeded.
  `tests/listing.sh` stubs `curl` and covers it in dash, busybox ash and
  sh; run against the original script it reports five failures, which is
  how it was checked. bmcd 2.38.2 is the other half: it reads the exit
  status before the output, so the next script that fails cheerfully cannot
  put the page back to lying.

### Changed

- **BMC-UI 3.35.0 changes nothing on a board.** It teaches the site's demo
  to run the address flow — apply, the window, confirm, revert — so a
  reader can try it. Pinned because a release ships the latest of every
  component, the rule this image has kept since v2.26.0.</content>
  </entry>
  <entry>
    <title>Firmware v2.35.0: A fixed address from the Network tab, and a clock that says why</title>
    <link href="https://turingpi.xyz/news/v2.35.0/"/>
    <id>tag:turingpi.xyz,2026-09-21:firmware/2.35.0</id>
    <updated>2026-09-21T00:00:00Z</updated>
    <content type="text">Pins **bmcd 2.38.0**, **BMC-UI 3.34.1** and **tpi 1.10.0**. All three come
from one Discord report on 2026-09-21: a user on a 2.4 board — the second
2.4 board confirmed on this fork — had set the Split layout up, given the
BMC a fixed address over SSH, and then found the clock saying &quot;NOT
synchronised&quot; with nothing to act on. Every component's own changelog has
the detail; what follows is what this image adds on top of them.

### Added

- **The board's own address, from the Network tab.** DHCP or a static
  address with its prefix, gateway, resolvers and search domain, beside the
  hostname. The rules are the board's: it refuses what could never be reached
  (a gateway off the subnet, the network or broadcast address) and warns
  about what is merely unwise — a static address with no resolver, which is
  exactly the state the reporter's board was in. **Apply** puts the address
  on the bridge and keeps it only when a confirmation reaches the board *at
  the new address*; otherwise the old one comes back by itself after the
  window. The bridge is never brought down, so the modules' ports stay in it
  throughout. `tpi network address` does the same from a shell. Only a
  confirmed address is written to `/etc/network/interfaces` — in the same
  stanza this image ships and migrates, so nothing about boot changed.

- **The clock says why.** The Time card, `tpi ntp` and `GET ?type=ntp` carry
  chrony's verdict on every source — selected, combined, excluded,
  unreachable, falseticker — and, when chrony has nothing at all, the
  configured names as *unresolved*. That last state is what &quot;no resolver&quot;
  looks like from the outside, and it is what an empty `chronyc sources`
  had been hiding.

### Changed

- **&quot;Reset network&quot; is now &quot;Reset the switch chip&quot;**, which is what it does
  and always did; it never touched the address.

### Fixed

- **The Time and Hostname boxes are no longer blank on a second visit** to
  the tab; both cards seed from the query now. Reported from the same 2.4
  board.
- **A countdown that could not count**: the switch card read the daemon's
  `{secs_since_epoch, nanos_since_epoch}` as a date and showed NaN.</content>
  </entry>
  <entry>
    <title>Firmware v2.34.0: The switch gets VLANs, the board asks for a password, and a module is one click</title>
    <link href="https://turingpi.xyz/news/v2.34.0/"/>
    <id>tag:turingpi.xyz,2026-09-21:firmware/2.34.0</id>
    <updated>2026-09-21T00:00:00Z</updated>
    <content type="text">Pins **BMC-UI 3.32.0**. bmcd stays at 2.37.0 and tpi at 1.9.0.

### Changed

- **Which module: four pills, not a drop-down.** Console, USB and Flash each
  opened with a list that unfolded to show four fixed entries, one already
  chosen. The three tabs now share one picker, Node 1 to 4 side by side with
  the chosen one green — the same change the fleet shows, because it is one
  component serving both.</content>
  </entry>
  <entry>
    <title>Firmware v2.33.0: The board's switch gets VLANs, and the board asks for a password</title>
    <link href="https://turingpi.xyz/news/v2.34.0/"/>
    <id>tag:turingpi.xyz,2026-09-20:firmware/2.33.0</id>
    <updated>2026-09-20T00:00:00Z</updated>
    <content type="text">Pins **bmcd 2.37.0**, **BMC-UI 3.30.0** and **tpi 1.9.0** — the first release
carrying the on-board switch, the certificate endpoint and the forced password
change. Every component's own changelog has the detail; what follows is what
this image adds on top of them.

### Added

- **A Certificates section in the README**: what the board issues itself, that
  it renews and now reissues on rename, that it never touches a certificate it
  did not issue, the three ways to install your own, and why the serial console
  is the one tab that breaks on an untrusted certificate.

### Fixed

- **A renamed board reissues its own certificate.** The self-signed generator
  reissued on four conditions — no certificate, a mismatched pair, expiry
  within the renewal window, and never for a certificate it did not issue —
  and none of them was *the board is not called that any more*. The names were
  computed and used only when issuing, never compared against what was on
  disk.

  So renaming a board with `tpi hostname`, or letting its address move, left
  the old names in the certificate **for up to 825 days** while every browser
  rejected it for a name mismatch, on a board that was otherwise perfectly
  healthy. It is the one trigger a &quot;reissue now&quot; button would have been for,
  and it is detectable, so it should never have needed a button.

  The comparison asks openssl one name at a time with `-checkhost` and
  `-checkip`, and reads **what it says** rather than what it returns: this
  workstation's openssl exits 1 for a name that is not in the certificate, and
  the one on GitHub's runners exits 0 and says so only in its output. A check
  built on the status passed here and did nothing there — the same bug being
  fixed, inside the fix. There is a test that proves the primitive on whatever
  openssl is present before the rest of the suite trusts it.

  It reads the extension through openssl rather than comparing it as text. Text cannot be made
  to work: the SAN is written `DNS:board,IP:fd00:0:0:0:0:0:1533:6065` and
  printed back as `DNS:board, IP Address:FD00:0:0:0:0:0:1533:6065` — a
  different separator, a different label, and an IPv6 address expanded and
  upper-cased. Normalising that by hand means writing an IPv6 canonicaliser in
  POSIX sh, and getting it subtly wrong means reissuing on **every run**: a new
  key at every boot and every pinned client broken daily, which is a far worse
  failure than the one being fixed. There is a test for that failure as well as
  for the fix.

  The predicate is *every name the board has now is in the certificate*, not
  *the two lists are equal*. An address the board has dropped leaves a stale
  name that asserts something untrue but breaks nothing and is gone at the next
  renewal; a name the board has gained is what breaks browsers, and it is
  caught.</content>
  </entry>
  <entry>
    <title>Firmware v2.32.0: A module armed for USB boot says so</title>
    <link href="https://turingpi.xyz/news/v2.32.0/"/>
    <id>tag:turingpi.xyz,2026-09-13:firmware/2.32.0</id>
    <updated>2026-09-13T00:00:00Z</updated>
    <content type="text">Pins BMC-UI 3.29.0. bmcd stays at 2.36.3 and tpi is unchanged.

### Added

- **A node armed for USB boot says so, on its own liveness line.** A module
  whose USB-boot pin is held will not boot from its own eMMC. The daemon
  persists that configuration and re-applies it on every start, so a power
  cycle does not clear it, and the reboot that reveals it can be weeks after
  whatever armed it. What you get then is indistinguishable from dead
  hardware: silent on the serial console, off the network, and the board
  still reporting its rail on. Twenty minutes went into exactly that on
  2026-09-12.

  bmcd 2.36.3 already exported `bmcd_node_usb_boot_armed`, which reaches
  whoever armed a module while they still remember doing it. This is the half
  for the other person — the operator already staring at a node that will not
  come up. It leads the liveness line, in red, ahead of power state and link
  state, because both of those read perfectly normal in this failure.

  The USB selector on the same card already showed `Flash` for that node. A
  select says what you may *set*, not what is *wrong*, and says nothing about
  the consequence or the remedy; the note beside the warning names both.

### Changed

- **A toast names the board it is about.** With a fleet on one screen,
  &quot;Flashing started&quot; told you an operation began somewhere, and a
  notification from one board was indistinguishable from the same
  notification from another. Toasts raised inside a board's scope now carry
  that board's name.</content>
  </entry>
  <entry>
    <title>Firmware v2.31.0: The console stops replaying its scrollback twice</title>
    <link href="https://turingpi.xyz/news/v2.31.0/"/>
    <id>tag:turingpi.xyz,2026-09-12:firmware/2.31.0</id>
    <updated>2026-09-12T00:00:00Z</updated>
    <content type="text">Pins BMC-UI 3.28.0. bmcd stays at 2.36.3 and tpi is unchanged.

### Fixed

- **The console wrote a second copy of its scrollback on every reconnect.**
  Pressing Reconnect keeps the terminal, as it is meant to, and then replayed
  the daemon's whole 16 KiB ring buffer underneath what was already on screen
  — the same lines carrying the same kernel timestamps, twice.

  The replay exists because the daemon forwards only what arrives after a
  subscriber joins; without it a console opened on a module that has been up
  for hours shows nothing at all. It just never asked whether the terminal
  already had that output.

  Clearing first would have fixed the duplication and cost the thing the
  scrollback is for: the daemon keeps only the last 16 KiB and one module boot
  is about 82 KB, so the terminal is the only place a full boot survives.
  Instead the replay works out where what it has already shown ends inside the
  buffer it has just been handed, and writes only what follows. A reconnect
  with nothing new writes nothing; one after a gap writes exactly the gap.

  Redraw is unchanged and still clears first. It answers a different question.</content>
  </entry>
  <entry>
    <title>Firmware v2.30.0: The metrics say which module is armed for USB boot</title>
    <link href="https://turingpi.xyz/news/v2.30.0/"/>
    <id>tag:turingpi.xyz,2026-09-12:firmware/2.30.0</id>
    <updated>2026-09-12T00:00:00Z</updated>
    <content type="text">Pins bmcd 2.36.3. BMC-UI stays at 3.27.0 and tpi is unchanged.

### Fixed

- **The board refused every attempt to resume a TLS session, fatally, and that
  was the intermittent console failure.** A serial console or an API call
  through the fleet gateway would occasionally fail outright, on both boards,
  with nothing in the daemon's log and the board healthy either side of it.

  Envoy keeps one TLS session per upstream cluster and offers it on the next
  connection it opens. On the gateway, a third of all new connections to the
  two boards died that way — 8 of 28 to one, 19 of 45 to the other — while
  every connection taken from the pool was fine, which is what made it look
  random from a browser. Reproduced from a pod on the gateway's own node: 30
  fresh connections all succeeded, and 30 offering back a saved session all
  failed with `tlsv1 alert internal error`.

  OpenSSL will not resume a session on a server that asks for client
  certificates unless the context carries a session id context, and the
  refusal is not a quiet cache miss — it is `internal_error`, fatal, sent
  before a byte of HTTP. The error is raised on the server, which never logged
  it. So the fault arrived with client certificates in 2.30.0 and was
  invisible for six releases, and it was never specific to the gateway: any
  client that resumes hit the same wall.

### Added

- **`/metrics` says which module is armed for USB boot.** Two families:
  `bmcd_node_usb_boot_armed`, one sample per module, and `bmcd_usb_config`,
  which names the node, mode, route and bus type of the *persisted*
  configuration.

  `tpi flash` and `tpi advanced msd` leave that configuration at
  `Flashing(NodeN, …)`, and the daemon re-applies it on every start, which
  asserts the module's USB-boot pin and stops it booting from its own eMMC.
  The module keeps running, because it is already booted — so the fault
  appears at its **next** reboot, possibly weeks later.

  Until now nothing exported that. A module in flash mode is indistinguishable
  from dead hardware from every remote angle: nothing at all on the serial
  console, not even a bootloader banner, because the loader does not use the
  console; nothing on the network; and the BMC reporting the rail on. `tpi usb
  status` cannot separate the two either — it prints the same route for `UsbA`
  and `Flashing`. Measured on hive-6 on 2026-09-12: twenty minutes spent on a
  module that looked dead, with the answer sitting unexported in the daemon's
  own database.</content>
  </entry>
  <entry>
    <title>Firmware v2.29.0</title>
    <link href="https://turingpi.xyz/news/v2.29.0/"/>
    <id>tag:turingpi.xyz,2026-09-12:firmware/2.29.0</id>
    <updated>2026-09-12T00:00:00Z</updated>
    <content type="text">Pins bmcd 2.36.1. BMC-UI stays at 3.27.0 and tpi is unchanged.

### Fixed

- **Every refusal from the access endpoints answered 500.** Found on bmc-2
  within the hour of flashing v2.28.0, by asking the board the questions its
  new guards are supposed to refuse: they all refused, with the right message,
  and all of them came back `500 Internal Server Error`.

  500 is the canonical retryable status, so a client told 500 for &quot;that
  password is wrong&quot; is invited to retry an answer that will never change, and
  a person reading it is told the board is broken when the request was. The
  guards themselves were correct throughout; only the number on them was
  wrong.</content>
  </entry>
  <entry>
    <title>Firmware v2.28.0: Who may reach this board, from the interface that asks for the password</title>
    <link href="https://turingpi.xyz/news/v2.28.0/"/>
    <id>tag:turingpi.xyz,2026-09-12:firmware/2.28.0</id>
    <updated>2026-09-12T00:00:00Z</updated>
    <content type="text">Pins bmcd 2.36.0 and BMC-UI 3.27.0; tpi is unchanged and already current.

### Added

- **Who may reach a board, from the interface that asks for the password.**
  Settings gains a card for the two things that decide who gets in and which
  until now existed only on the filesystem: the local password, and the
  certificate authority whose client certificates may name an operator. It
  shows how *you* got here first, because an operator arriving through the
  fleet is not holding the board's password and the rest of the card reads
  differently depending on which you are.

  The daemon's rules appear as disabled controls rather than refusals after
  the fact: the current password is required even from an operator a proxy
  vouched for, twelve characters counted as characters, and &quot;stop trusting any
  proxy&quot; is unavailable when you are authenticated by that proxy.

### Fixed

- **The fleet's serial console could not connect.** It refused before opening
  a socket, demanding a session token the fleet has no reason to hold, while
  every other tab on the same page answered normally. The certificate was
  never involved: the browser talks to its own origin and never to a board.
  Through the gateway the browser presents nothing, and the identity is
  attached on a hop it is not part of.

  Proved against bmc-1 through a stand-in for the gateway — `101 Switching
  Protocols`, subprotocol `bmcd.serial.v1` — and then &quot;connected&quot; in a real
  browser with zero console errors.

### Note

v2.26.0 and v2.27.0 were released without entries here. What they carried is
recorded in the package hash files, which document every pin bump with its
measurement: BMC-UI 3.25.0 (the SD card picker and two dead flash buttons) and
3.26.0 (temperature on Board Health, an honest console hint), and bmcd 2.35.0
(the catalogue refresh that could wedge for ever, and the on-disk listing).</content>
  </entry>
  <entry>
    <title>Firmware v2.27.0: The latest of all three, and the SD card listed</title>
    <link href="https://turingpi.xyz/news/v2.27.0/"/>
    <id>tag:turingpi.xyz,2026-09-12:firmware/2.27.0</id>
    <updated>2026-09-12T00:00:00Z</updated>
    <content type="text">The first release that carries the latest of all three: BMC-UI 3.26.0, bmcd
2.34.0 and tpi 1.8.0.

### Fixed

- **The pin gate moved to the workflow that publishes**, so it can actually
  refuse. It ran where it could report a stale pin and not stop the release,
  which is how v2.26.0 shipped a `tpi` two versions behind the daemon it was
  packaged with.</content>
  </entry>
  <entry>
    <title>Firmware v2.26.0: Choose an image off the SD card</title>
    <link href="https://turingpi.xyz/news/v2.26.0/"/>
    <id>tag:turingpi.xyz,2026-09-12:firmware/2.26.0</id>
    <updated>2026-09-12T00:00:00Z</updated>
    <content type="text">Pins BMC-UI 3.25.0: the SD card picker, and the flash button that threw
instead of flashing.

### Known at the time

- This release shipped a stale `tpi`. The gate that should have refused it ran
  in a workflow that could not stop a release; v2.27.0 moved it and carries
  the right one.</content>
  </entry>
  <entry>
    <title>Firmware v2.25.0: The install confirmation is a modal</title>
    <link href="https://turingpi.xyz/news/v2.25.0/"/>
    <id>tag:turingpi.xyz,2026-09-11:firmware/2.25.0</id>
    <updated>2026-09-11T00:00:00Z</updated>
    <content type="text">### Fixed

- **BMC-UI v3.23.0 → v3.24.0: the firmware install confirmation is a modal.**
  It rendered inline, after every candidate. A board with several sources has
  a long catalogue, so pressing INSTALL scrolled the question off the bottom
  of the window — the button appeared to do nothing, and the obvious response
  is to press it again. It now uses the same modal the reboot and node-power
  confirmations use, and its drawer form fixes the phone case the inline panel
  was worst on. Reported from use.

### Changed

- **tpi → v1.7.0**, which releases the fix already on the branch: renaming a
  board no longer claims to move its metrics history, because the series never
  carried a board name.

  Bumped because the pin check added in v2.24.0 caught it — it flagged tpi as
  stale the moment the interface was bumped, which is exactly the drift it was
  written for, on its first real use.</content>
  </entry>
  <entry>
    <title>Firmware v2.24.0: Everything current — the daemon, the interface and the CLI</title>
    <link href="https://turingpi.xyz/news/v2.24.0/"/>
    <id>tag:turingpi.xyz,2026-09-11:firmware/2.24.0</id>
    <updated>2026-09-11T00:00:00Z</updated>
    <content type="text">Everything current: the daemon, the interface and the CLI.

### Fixed

- **bmcd v2.32.0 → v2.34.0.** Two releases.

  **v2.33.0 — a node's USB bus number is not part of its identity, and pinning
  it refused every flash** (SQU-199). The port check that stops a flash of
  node 2 writing node 1 compared the whole path, bus included, against a bus
  hardcoded to 1. This board pairs an OHCI and an EHCI controller as
  **companions for the same physical ports**, so which bus a device lands on is
  decided by its *speed*: the same hub port is `1-1.2` for a full-speed device
  and `2-1.2` for a high-speed one. A Rockchip in maskrom is high-speed, so the
  daemon refused every RK1 it was asked to flash or expose as mass storage,
  with `node 2 requested on 1-1.2; found Rockusb on 2-1.2 instead`, about
  modules that were entirely healthy. **Nothing on this board could be flashed
  through the daemon until this release.**

  **v2.34.0 — the daemon can say what is on the microSD card** (SQU-198). Name,
  size, modified time, and whether each entry could be written to a node.
  `tpi flash --local` already reads images off that card; there was simply no
  way to see what was there, so an operator had to know the path and type it.
  A non-candidate is listed with its reason rather than hidden. Every path is
  confined to the card by one function with its own tests, catching `../..` and
  a symlink off the card with the same rule.

- **BMC-UI v3.21.0 → v3.23.0.** v3.22.0 stopped the daemon's messages being
  escaped twice — i18next escapes interpolated values and React escapes again,
  so a path arrived as `&amp;#x2F;` in the page.

  v3.23.0 is the fleet gaining every control the board interface has, built
  from the **same components** rather than copies. That reaches this package
  too, because those components are the board's own bundle: toasts were capped
  at one and carried no board name, &quot;don't ask again&quot; for node power was a
  single setting shared by every board, and logout walked out of the
  application under a sub-path. All three are fixed here as well. Export and
  Import configuration is now **Backup and restore**.

- **tpi → 79ff091: the rename does not move your metrics history.** Renaming a
  board was described as carrying its metrics with it. The series carry no
  board name at all, so nothing moves, and the CLI stops claiming otherwise.</content>
  </entry>
  <entry>
    <title>Firmware v2.23.0: TLS made usable — named, renewed, and every key served</title>
    <link href="https://turingpi.xyz/news/v2.23.0/"/>
    <id>tag:turingpi.xyz,2026-09-11:firmware/2.23.0</id>
    <updated>2026-09-11T00:00:00Z</updated>
    <content type="text">TLS, made usable rather than merely present.

### Changed

- **bmcd v2.30.0 → v2.32.0: the daemon offers TLS 1.3, with 1.2 as the
  fallback** (SQU-136). Its acceptor was built from Mozilla's version 4
  intermediate profile, which pins the maximum protocol version to TLS 1.2.
  The board's OpenSSL is 3.5.7 and was capable of 1.3 the whole time.

  Not housekeeping. Under TLS 1.2 a client's `supported_groups` extension
  constrains the curve of the **server's** certificate as well as the key
  exchange, so a client whose list stops at P-256 — Envoy's default — cannot
  use a P-384 certificate at all. Every certificate in the estate these boards
  run in is P-384, so the gateway in front of them failed every handshake and
  both boards showed as unreachable behind a login page that worked perfectly.

- **The self-signed certificate is one a client can actually trust, and it
  renews itself.** The script that issues it, when nobody has installed a real
  certificate, had four problems, each now fixed:

  - It set **no subjectAltName**. Every browser since 2017 matches on the SAN
    and ignores the common name, so that certificate could not be accepted by
    any of them. A self-signed certificate nobody can choose to trust is
    decoration. It now names the board's hostname, its `.local` name and every
    global address it holds.
  - It was valid for **30 days** — openssl's default, never passed — and only
    ever regenerated when a file was missing. A board left running served an
    expired certificate for as long as it stayed up; one here did for over a
    year (SQU-115). It now issues for 825 days and reissues 30 days before
    expiry, so neither a long uptime nor a month powered off produces an
    expired certificate.
  - It generated **RSA 4096** on a board with about 87 MB of usable RAM. Now
    EC P-384, the estate's standing key type: stronger per bit, faster, smaller.
  - Its pair check ran `openssl rsa -noout -modulus`, which **fails on any key
    that is not RSA** — and the failure branch deletes both files and
    regenerates. An operator installing an EC or Ed25519 certificate could have
    it destroyed at the next boot. It now compares public keys, which works for
    every key type, and **refuses to touch any certificate it did not issue**:
    a certificate from a real CA is left exactly where it is, expired or not,
    with a warning rather than a replacement.

  Fifteen assertions cover this in CI, including the two that matter most: a
  certificate this script did not issue comes out byte for byte unchanged.

### Added

- **`/metrics` says when the serving certificate expires** —
  `bmcd_tls_certificate_expiry_timestamp_seconds` — and which key it is built
  on, `bmcd_tls_certificate_info{key=&quot;ecdsa-p384&quot;}`. A number a scrape can
  alert on is the difference between noticing an expiry and a calendar
  reminder somebody stops reading. A date the daemon cannot parse reports no
  series at all rather than a zero, which would read as 1970 and fire every
  rule written against it.

### Confirmed

- **Every key an operator might install is served**, over TLS 1.3 and 1.2
  alike: RSA, EC P-256, P-384 and P-521, and Ed25519. Already true, now a test
  that performs a real handshake per key type against the real acceptor.</content>
  </entry>
  <entry>
    <title>Firmware v2.22.0</title>
    <link href="https://turingpi.xyz/news/v2.22.0/"/>
    <id>tag:turingpi.xyz,2026-09-11:firmware/2.22.0</id>
    <updated>2026-09-11T00:00:00Z</updated>
    <content type="text">### Added

- **bmcd v2.29.0 → v2.30.0: a proxy holding a certificate from a trusted CA
  can name the human it authenticated** (SQU-136). `tls.client_ca` turns it
  on and is absent by default, so a board that never gets one behaves exactly
  as before. `tls.identity_header` says which header carries the name. A name
  without a certificate is nobody, and that rule has a test.

  This is what makes a fleet interface possible without giving anything a
  password to every board.</content>
  </entry>
  <entry>
    <title>Firmware v2.21.0</title>
    <link href="https://turingpi.xyz/news/v2.21.0/"/>
    <id>tag:turingpi.xyz,2026-09-10:firmware/2.21.0</id>
    <updated>2026-09-10T00:00:00Z</updated>
    <content type="text">### Changed

- **BMC-UI v3.19.0 → v3.21.0**, two interface releases in one pin. v3.20.0
  put the Nodes page on a two-by-two grid — four full-width rows scrolled on
  a 1080-pixel screen with a third of the width empty beside each — and added
  the demo mode that answers from captured fixtures, which is what
  turingpi.xyz serves as its live demo. v3.21.0 tightened that grid until it
  also fits the demo's frame: measured at 868 pixels in the 1664 by 879 frame
  the site gives it.
- **The Grafana dashboard names the board on every series.** With two boards
  reporting, a panel that did not carry the instance label was drawing both
  as one line.</content>
  </entry>
  <entry>
    <title>Firmware v2.20.0</title>
    <link href="https://turingpi.xyz/news/v2.20.0/"/>
    <id>tag:turingpi.xyz,2026-09-10:firmware/2.20.0</id>
    <updated>2026-09-10T00:00:00Z</updated>
    <content type="text">### Added

- **bmcd v2.28.0 → v2.29.0: `bmcd_firmware_last_promotion_timestamp_seconds`**
  (SQU-141). The counter beside it says how often the gate reached a verdict;
  this says when the last one was, which is what an alert on &quot;no promotion
  since&quot; needs. Absent rather than wrong when the board's clock cannot be
  trusted, and only UTC is accepted.

### Removed

- **BMC-UI v3.18.0 → v3.19.0: the red maskrom warning on the flash page**
  (SQU-157, with SQU-105). It said the daemon writes to whichever module is in
  maskrom first, whatever the picker says. That was true and is not any more.

  Proven on the board on 2026-09-10 before the warning came down: with **two**
  modules in maskrom at once, flashing node 2 left node 4 answering `talosctl`
  with the cluster's certificate authority while node 2 rejected it as unknown.
  A node that was overwritten cannot present the cluster CA; one that was not
  cannot fail to. The write landed where it was aimed, and node 2 was
  reprovisioned back into the cluster afterwards.

  A warning that is no longer true is worse than no warning: it teaches people
  to ignore the red ones.</content>
  </entry>
  <entry>
    <title>Firmware v2.19.0</title>
    <link href="https://turingpi.xyz/news/v2.19.0/"/>
    <id>tag:turingpi.xyz,2026-09-10:firmware/2.19.0</id>
    <updated>2026-09-10T00:00:00Z</updated>
    <content type="text">### Changed

- **`tpi` v1.5.1 → v1.6.0: the check that caused all of this is gone.**

  Three releases in a row carried attempts to make `firmware install` resolve
  a version correctly against the board's cached listing. The listing lags a
  release by up to half an hour, so `firmware check` would print
  `install it with: tpi firmware install &lt;v&gt;` and the next command would
  reject it. v2.17.0's attempt was inert; v2.18.0's worked by waiting up to
  three minutes.

  Both were the wrong shape. The resolution existed because of a comment
  claiming the board would otherwise fail &quot;in the middle of a download&quot;.
  Measured: the daemon answers **400 in 0.48 s** with
  `tpi-selfupdate: cannot fetch SHA256SUMS for v9.9.9`, before any download,
  staging nothing. There was nothing to protect against.

  So the listing now informs the confirmation line and cannot refuse, and the
  board — which is what downloads the image — decides. On a version that
  exists nowhere: 140 s and a possibly-wrong refusal before, 1 s and the
  board's own message now.

### Fixed

- **`tpi firmware list --refresh` could confirm that a release did not
  exist.** It waited 60 s for a poll, then gave up and printed the previous
  listing with nothing said, indistinguishable from a current one. Polls
  measured on this board took 74 s, 78 s and 140 s. Now four minutes, it says
  when it gives up, and `list` prints how old the listing is on every run —
  the daemon had always sent that, and the client discarded it.</content>
  </entry>
  <entry>
    <title>Firmware v2.18.0</title>
    <link href="https://turingpi.xyz/news/v2.18.0/"/>
    <id>tag:turingpi.xyz,2026-09-09:firmware/2.18.0</id>
    <updated>2026-09-09T00:00:00Z</updated>
    <content type="text">### Fixed

- **`tpi` v1.5.0 → v1.5.1: the fix v2.17.0 shipped did not work.** `firmware
  install` still refused the command `firmware check` had just printed.

  v1.5.0 asked the board to re-poll its firmware sources and resolved against
  the answer — but `firmware_available&amp;refresh=1` does not fetch. The daemon
  *spawns* the poll and returns the cached list immediately with `refreshing`
  set, so the second resolution ran against the same stale list as the first.
  Confirmed on the board: a forced request came back in one second carrying
  the same `checked_at` it had before the call.

  v1.5.1 waits for the poll to land before re-resolving, keyed on
  `checked_at`, which advances exactly once a poll completes. Bounded at 180 s,
  and a refusal that hits the bound says the poll did not finish rather than
  claiming no source has the version.

  Proved on the board before this release rather than after, with a version
  that exists nowhere so nothing could install either way:

  | build | elapsed | `checked_at` |
  |---|---|---|
  | 1.5.0 | 1 s | unchanged |
  | 1.5.1 | 58 s | 20:50:57 → 20:52:11 |</content>
  </entry>
  <entry>
    <title>Firmware v2.17.0</title>
    <link href="https://turingpi.xyz/news/v2.17.0/"/>
    <id>tag:turingpi.xyz,2026-09-09:firmware/2.17.0</id>
    <updated>2026-09-09T00:00:00Z</updated>
    <content type="text">&gt; **The `tpi` change below is inert.** It describes behaviour v1.5.0 did not
&gt; have; v2.18.0 carries the version that does. Kept as written, because a
&gt; release note that quietly becomes true later teaches nobody anything.



### Changed

- **`tpi` v1.4.0 → v1.5.0.** `firmware install` no longer refuses the command
  `firmware check` just printed.

  The two read different caches with independent timing: `check` asks the
  board's update checker, which reaches GitHub, while `install` resolved the
  requested version against the firmware catalogue, whose entries are fresh
  for half an hour. Reproduced on a board twenty-seven seconds after v2.16.0
  published — `check` said `install it with: tpi firmware install v2.16.0` and
  exited 10; the next command said `no source offers v2.16.0` and exited 1.

  `install` now re-resolves against a forced poll before believing no source
  has the version. The successful path pays nothing. The refusal itself stays:
  posting an unresolved version would fail in the middle of a download rather
  than before it starts.</content>
  </entry>
  <entry>
    <title>Firmware v2.16.0</title>
    <link href="https://turingpi.xyz/news/v2.16.0/"/>
    <id>tag:turingpi.xyz,2026-09-09:firmware/2.16.0</id>
    <updated>2026-09-09T00:00:00Z</updated>
    <content type="text">### Changed

- **bmcd v2.27.0 → v2.28.0.** The ten handlers that assembled their answer
  with `json!` now serialise named types, so every read operation in the
  OpenAPI document describes what it answers with and none is left as &quot;not
  described here&quot;. The shapes are reconstructions of what the daemon already
  sent, odd corners preserved and explained: power, USB and SD card answer a
  one-element array because that is upstream's shape; node power reports
  strings because an unreadable rail is `Unknown` and a boolean could not say
  so; the SD card's used-bytes field is `use` on the wire because `use` is
  what upstream sent and a Rust keyword.

  **Naming a cooling device that does not exist now answers 400, not 500.**
  A client branches on status, and 500 is the retryable one — so the old
  answer asked callers to retry something that could never change.

- **BMC-UI v3.17.0 → v3.18.0.** Twenty-three hand-written response interfaces
  become aliases onto types generated from the `openapi.json` bmcd publishes,
  and CI regenerates and diffs, so a hand edit or a pin moved without
  regenerating fails there rather than on a board. Nothing changes on screen.

  Two things this shook out. The generated types are stricter than the
  hand-written ones — schemars cannot tell a field always sent as `null` from
  one omitted when empty — so a reading is now recognised by a type guard
  rather than by a runtime check the compiler could not see. And BMC-UI's
  quality workflow had been `pull_request` only, in a fork that never opens
  one, so its lint, build and tests had never run in CI at all.

### Added

- **`docs/architecture.md`** — where the daemon, the interface and the
  listeners live, the board measurements the arrangement rests on, and what
  was declined along the way. Linked from the README.

### Fixed

- **CI's shell install no longer fails on a third-party apt repository it does
  not use.** A hash mismatch in the runner image's Google Chrome repository
  failed the promotion-gate job on a documentation-only change. The step now
  drops the runner's third-party `.list` files before updating.</content>
  </entry>
  <entry>
    <title>Firmware v2.15.0</title>
    <link href="https://turingpi.xyz/news/v2.15.0/"/>
    <id>tag:turingpi.xyz,2026-09-09:firmware/2.15.0</id>
    <updated>2026-09-09T00:00:00Z</updated>
    <content type="text">### Changed

- **`/metrics` is served on port 9110, plain HTTP, with no credential**
  (SQU-178). It used to sit on `:443` beside the API and the web interface,
  behind TLS and a token of its own.

  The token existed for one reason: so that a credential in a scrape config
  could not also reach `/api/bmc` and power four compute modules off. On a
  listener that serves nothing but `/metrics` there is nothing else to reach,
  so the property survives and the mechanism is a port instead of a secret.
  The TLS was always scraped with verification disabled, because this board's
  certificate is expired and carries no SAN, so it bought nothing and cost a
  handshake per scrape on a Cortex-A7.

  **A scrape config pointed at `:443` will stop working.** Point it at 9110,
  drop the basic auth and drop the TLS block.

- **The promotion gate no longer depends on the `/api/bmc` loopback bypass.**
  It used to reach through it to mint itself a metrics token before it could
  check the endpoint; it now makes one plain request. The on-board `tpi` is
  the only remaining user of that bypass.

  `tests/gate.sh` shrank with it — the stub's token behaviour and the three
  cases exercising token retrieval are gone, replaced by one case for an
  endpoint that answers with the wrong body. Ten cases, all passing, and
  proven to fail by accepting any body at all.

- **bmcd v2.23.0 → v2.27.0**, **`tpi` v1.3.0 → v1.4.0**, **BMC-UI v3.15.0 →
  v3.17.0**. Beyond the metrics change: the OpenAPI document now describes
  what its operations answer with and is checked against what the daemon
  sends, `bmcd --openapi` prints it without a board, and the interface folded
  its standing explanations behind (i) popovers linking to the docs site.

### Removed

- **`/mnt/overlay/metrics-token`.** Nothing writes or reads it. An existing
  file is harmless and is simply ignored; the daemon will not recreate one.</content>
  </entry>
  <entry>
    <title>Firmware v2.14.0</title>
    <link href="https://turingpi.xyz/news/v2.14.0/"/>
    <id>tag:turingpi.xyz,2026-09-09:firmware/2.14.0</id>
    <updated>2026-09-09T00:00:00Z</updated>
    <content type="text">### Changed

- **bmcd v2.19.0 → v2.23.0**, four releases:

    - **A fan can be held at a step** (SQU-170). `opt=set&amp;type=cooling` takes
      `mode=manual` to pause the zone's governor and `mode=auto` to hand the
      fan back. Until now a written step was returned to the governor's own
      choice within a poll, which is what made the interface's slider a
      control that lied. A held fan is taken back above the zone's hottest
      `active` trip, because this board declares no `critical` trip and
      nothing else would intervene.

    - **A flash targets the module that was asked for** (SQU-105). All four
      modules sit behind one hub on a v2.5 board, and the daemon used to take
      whichever answered first — a flash of node 2 could write node 1 and
      report success. The node-to-port mapping is read from this board's own
      device tree rather than assumed.

    - **An audit line per mutating call** (SQU-108), naming the action, node,
      caller, address and outcome, and naming the loopback bypass explicitly
      when there was no credential at all. Sent to the system log as well as
      the rotating file, because `/tmp` and `/var/log` are both tmpfs here.

    - **`bmcd_process_threads`**, the companion to the resident-set metric.

- **`tpi` v1.2.2 → v1.3.0**: `cooling set --hold` and `--auto`, and a Governor
  column that says `-` rather than `running` on a daemon that does not report
  it.

- **BMC-UI v3.14.0 → v3.15.0**: the fan slider now sits behind an explicit
  Override switch, offered only where the daemon reports it can actually hold
  a step.

### Added

- **`/etc/default/syslogd`**, carrying the remote-logging knob documented and
  deliberately unset (SQU-108). `syslogd -R &lt;host&gt;:&lt;port&gt;` sends this board's
  kernel and daemon logs somewhere that survives a reboot; `/var/log` is a
  tmpfs and the overlay is the wrong answer, on a NAND with five free
  eraseblocks and a workload guaranteed to grow. A default pointing at a host
  nobody configured would make every boot wait on a DNS lookup.</content>
  </entry>
  <entry>
    <title>Firmware v2.13.0</title>
    <link href="https://turingpi.xyz/news/v2.13.0/"/>
    <id>tag:turingpi.xyz,2026-09-09:firmware/2.13.0</id>
    <updated>2026-09-09T00:00:00Z</updated>
    <content type="text">### Changed

- BMC-UI **v3.10.1 → v3.14.0**, four releases of interface work:

  - The console replays the module's scrollback when you open it, instead of
    showing a blank terminal however long the module has been running, and
    gains a **Redraw** button.
  - The reboot dialogs stop claiming the compute modules lose power. They do
    not, and this fork's not cutting them is one of the things it exists for.
    Both live places said otherwise, in all six languages.
  - Rebooting from Settings now says when a firmware is staged, so a reboot for
    an unrelated reason no longer silently applies an update.
  - An upload no longer offers a reboot that would do nothing. It parks the
    image on the SD card, and the message says so.
  - **Install OS** is red, like every other consequential action, and the
    shared confirmation dialog commits in red rather than in the colour of
    Save.
  - The firmware sources editor no longer breaks at 390 px.

### Fixed

- **mDNS was eating the board** (SQU-175). This is the cause of both outages on
  2026-09-09, and it was ours.

  `mdnsd` binds every interface it can see. On this board that means the DSA
  switch ports — `node1`..`node4`, `ge0`, `dsa` — which share one MAC and one
  link-local address, because they are ports of a single switch rather than
  separate hosts. mdnsd announced on each, saw its own announcement arrive on
  the others, and called that a name conflict. Every conflict triggers a config
  reload, and mdnsd 0.12 leaks on reload.

  Measured on the board: **825 reloads in twelve seconds**, and the process
  growing **956 kB a minute** on a machine with 118 MB of RAM and no swap. That
  is ninety minutes from boot to a board that answers ping and nothing else.
  The same storm wrote those 825 log lines into `/var/log`, which is on the
  58 MB tmpfs, so it was consuming memory from both ends.

  `/etc/default/mdnsd` now binds it to `br0`, the only interface with an
  address and the one the default route uses. After the change, on the same
  board: **zero reloads in ninety seconds, and 8 kB of growth.**

### Added

- **`mdnsd-guard`**, run every five minutes from cron. Restarts mdnsd if its
  resident set passes 20 MB, and logs the size that triggered it.

  A net under the fix above, not a substitute for it. The leak is upstream's
  and still present; only its trigger has been removed. On a board with no
  watchdog, where the failure mode is losing the machine entirely, a threshold
  check that does nothing on a healthy system is cheap insurance — and if it
  ever fires, the log line is the field evidence that something still reloads.</content>
  </entry>
  <entry>
    <title>Firmware v2.12.0</title>
    <link href="https://turingpi.xyz/news/v2.12.0/"/>
    <id>tag:turingpi.xyz,2026-09-09:firmware/2.12.0</id>
    <updated>2026-09-09T00:00:00Z</updated>
    <content type="text">### Fixed

- **A serial console could reboot the board by accident.** The kernel boots
  with `console=ttyS0` and a BREAK on that line triggers SysRq, with the next
  byte taken as the command. USB-serial adapters assert BREAK whenever a port
  is opened, closed or reconfigured, and nothing guarded it. The kernel's own
  help for `MAGIC_SYSRQ_SERIAL` describes the hazard exactly: *&quot;a disconnected
  TTL level serial which can generate some garbage that can lead to spurious
  false sysrq detects.&quot;*

  The first instinct was to mask the dangerous commands off. That was the wrong
  trade: this board has **no watchdog**, so a serial reset is the only remedy
  short of walking to the rack, and it is worth keeping.

  So the trigger is guarded rather than the commands removed.
  `MAGIC_SYSRQ_SERIAL_SEQUENCE=&quot;sysrq&quot;` means a BREAK on its own now does
  nothing; the sequence must follow it before any command is accepted. Garbage
  cannot produce that and a person typing it means it. The full command set
  stays available:

      BREAK, then &quot;sysrq&quot;, then the key
        b  reboot        o  power off      s  sync
        w  blocked tasks m  memory         t  all tasks

  `S00sysrq` sets the runtime value to match, so the intent is visible in the
  init scripts and not only in a kernel config.</content>
  </entry>
  <entry>
    <title>Firmware v2.11.0</title>
    <link href="https://turingpi.xyz/news/v2.11.0/"/>
    <id>tag:turingpi.xyz,2026-09-09:firmware/2.11.0</id>
    <updated>2026-09-09T00:00:00Z</updated>
    <content type="text">Built but **not installed**: the board wedged before this could be flashed and
needs a physical power cycle first (SQU-172). This is the image to install when
it comes back.

### Changed

- bmcd **2.19.0**: the health gate's history as a metric
  (`bmcd_firmware_promotion_total`), the daemon's own resident set
  (`bmcd_process_resident_bytes`), a `refreshing` flag that can no longer stick
  through a panic, and a rollback slot whose version can finally be named.
- BMC-UI **3.10.1**: a red warning on v2.5 boards that flashing may not target
  the module you chose, a bounded poll on the firmware page, and Reset network
  made destructive with a confirmation that says what it costs.
- `tpi` **1.2.2**: `firmware install` works — it had never worked in any
  release — and `firmware list --refresh` waits for the daemon's re-poll.

### Added

- The gate records what a promoted image replaced, so a rollback can be named
  rather than reported as &quot;version not readable&quot;.</content>
  </entry>
  <entry>
    <title>Firmware v2.10.0</title>
    <link href="https://turingpi.xyz/news/v2.10.0/"/>
    <id>tag:turingpi.xyz,2026-09-09:firmware/2.10.0</id>
    <updated>2026-09-09T00:00:00Z</updated>
    <content type="text">### Changed

- bmcd **2.16.0**: every API operation has a path of its own —
  `GET /api/bmc/thermal`, `POST /api/bmc/hostname` — over the same dispatcher
  as the `?opt=&amp;type=` form, which stays as it is; refusals on the new paths
  are RFC 9457 problem documents; and the daemon describes itself at
  `/api/bmc/openapi.json` (OpenAPI 3.1, response bodies not yet typed).
- `tpi` **1.2.2**: `firmware install` works — it had never worked in any
  release — and `firmware list --refresh` waits for the daemon's re-poll
  instead of printing the previous list.</content>
  </entry>
  <entry>
    <title>Firmware v2.9.2</title>
    <link href="https://turingpi.xyz/news/v2.9.2/"/>
    <id>tag:turingpi.xyz,2026-09-09:firmware/2.9.2</id>
    <updated>2026-09-09T00:00:00Z</updated>
    <content type="text">### Changed

- BMC-UI **3.9.2**: each firmware source lists its newest three versions,
  always, with the rest behind &quot;show N more&quot;. The previous rule showed only
  versions at or above the running one, which left an empty card the moment
  the board ran something no source had published yet.</content>
  </entry>
  <entry>
    <title>Firmware v2.9.1</title>
    <link href="https://turingpi.xyz/news/v2.9.1/"/>
    <id>tag:turingpi.xyz,2026-09-09:firmware/2.9.1</id>
    <updated>2026-09-09T00:00:00Z</updated>
    <content type="text">### Changed

- `tpi` **1.2.1** and BMC-UI **3.9.1**. The CLI in v2.9.0 was the one whose
  every fork command failed against a real board — the first release of the
  tool ever run against one found five bugs, all fixed here. The interface's
  USB selector no longer prints its label through its value, and every
  candidate from a GitHub source has a link to its release notes beside
  Install.</content>
  </entry>
  <entry>
    <title>Firmware v2.9.0</title>
    <link href="https://turingpi.xyz/news/v2.9.0/"/>
    <id>tag:turingpi.xyz,2026-09-09:firmware/2.9.0</id>
    <updated>2026-09-09T00:00:00Z</updated>
    <content type="text">### Changed

- bmcd **2.15.1**, `tpi` **1.2.0**, BMC-UI **3.9.0**. Between them this release
  carries: a firmware catalogue that answers from cache and refreshes behind
  itself instead of blocking the page for sixteen seconds; park mode, so an
  uploaded image lands on the SD card and installing stays a separate choice;
  the hostname and the time servers as settings; configuration export and
  import before board B; the thermal zone's trip points, so the fan's step has
  a reason attached; a seven-tab interface reorganised around what a person is
  doing; and a command line that reaches all of it.

### Added

- **Contract tests for the promotion gate** (`tests/gate.sh`). The gate decides
  whether a freshly booted image is kept or rejected, and nothing checked it: a
  change that promoted everything would have been found by a flash, or not at
  all. The harness sources `S99postupdate`, points its paths at scratch files,
  stubs `curl`, and asserts both the verdict and the reason for twelve cases —
  four of which must be **rejections**. It runs under `dash` and under
  `busybox ash`, the board's own shell.
- A `checks` workflow that runs those tests and `shellcheck` on every push, in
  under a minute, separately from the two-hour image build. It also **mutates
  the gate twice and requires the suite to catch both**: a suite that stays
  green when a rejection is removed is decorative.

### Changed

- **A release candidate no longer outranks its own release** (SQU-169).
  `is_newer` in `tpi-selfupdate` used GNU `sort -V` alone, which puts
  `v2.8.1-rc1` *after* `v2.8.1` — so a board on the finished release was
  offered its own candidate as an upgrade, and `--check` would have exited 10
  for ever. It now compares the numeric part first and only then the suffix,
  where carrying one loses to carrying none. `v2.2.0-unstable-hive.12` is
  likewise work towards v2.2.0 rather than after it.
- `tests/version.sh` covers that ordering — eighteen cases including the tenth
  minor release, which a lexical compare gets wrong — and CI mutates the
  function back to the old behaviour and requires the suite to notice.
- `tpi-selfupdate` can be sourced with `TPI_SELFUPDATE_LIB=1` to get its
  functions without running the update. That is what makes the above testable
  without a network or a board.
- `S99postupdate` takes the path to `curl` from `CURL_BIN` rather than a
  literal, so the harness can exercise the `-x` check and the argument handling
  instead of bypassing them.</content>
  </entry>
  <entry>
    <title>Firmware v2.8.1</title>
    <link href="https://turingpi.xyz/news/v2.8.1/"/>
    <id>tag:turingpi.xyz,2026-09-09:firmware/2.8.1</id>
    <updated>2026-09-09T00:00:00Z</updated>
    <content type="text">### Added

- **`turingpi.local` resolves again** (SQU-162). Dropping avahi for rootfs
  headroom (SQU-110) also removed the board's mDNS advertisement, and nothing
  replaced it — so `tpi` with no `--host` has failed since v2.3.0, because its
  default host is literally `turingpi.local`, and upstream's documentation
  tells people to reach the board that way. Nobody noticed here because this
  estate always passes an address.

  `mdnsd` replaces avahi: BSD-3, about 40 KB, and no D-Bus, expat or libdaemon
  behind it — which is what made avahi expensive, not avahi itself. The board
  advertises its own hostname, so a default image is `turingpi.local` again and
  a renamed board is whatever it was renamed to.
- The web interface is advertised as `_https._tcp` on 443, so a Bonjour browser
  finds the board. Port 443 rather than the port-80 redirect: advertising the
  redirect costs a client two round trips to reach a page that was always going
  to be served over TLS.
- **The promotion gate checks that the image is the one that was staged, and
  that it serves metrics** (SQU-140). The two existing checks prove the image
  is *alive* — the daemon answers, the switch ports exist — and neither proves
  it is *correct*: a build whose `/metrics` was completely broken would have
  been promoted. `release_matches()` now also requires that `/etc/os-release`
  agrees with the version named in the staged note, and that `/metrics`
  answers with `bmcd_build_info`.

  Verified on hardware: a note tampered to `v0.0.1` rolled the board back to
  v2.8.0 in about 35 seconds with the disagreement in `postupdate.log` — the
  first rollback this firmware has ever performed — and the same image,
  installed untampered, promoted. Every compute module's uptime continued
  uninterrupted through both reboots.

  Two details the board taught this change. `/metrics` has **no loopback
  exception** — only `/api/bmc` does — so the gate asks the daemon for a token
  via `type=metrics_token`, which mints one if none exists; reading the overlay
  file instead would have rolled back every good image on a fresh board. And a
  staged note that names a file but no version now says so, rather than
  claiming there was no note at all.

### Changed

- bmcd **2.10.1** and `tpi` **1.1.1**. Between them: a parked image is ordered
  against the running version instead of showing as `unknown`, and
  `tpi firmware install` can actually install one — 1.1.0 posted it to an
  endpoint the daemon refuses for local sources, so the resolution succeeded
  and the install failed with a 400.</content>
  </entry>
  <entry>
    <title>Firmware v2.8.0</title>
    <link href="https://turingpi.xyz/news/v2.8.0/"/>
    <id>tag:turingpi.xyz,2026-09-08:firmware/2.8.0</id>
    <updated>2026-09-08T00:00:00Z</updated>
    <content type="text">### Changed

- First release built entirely in `excavador-turing`. Firmware sources point at
  this fork; `tpi-selfupdate` follows.
- bmcd 2.10.0, `tpi` 1.1.0, BMC-UI 3.7.0.</content>
  </entry>
  <entry>
    <title>Firmware v2.7.0</title>
    <link href="https://turingpi.xyz/news/v2.7.0/"/>
    <id>tag:turingpi.xyz,2026-09-08:firmware/2.7.0</id>
    <updated>2026-09-08T00:00:00Z</updated>
    <content type="text">### Added

- Re-cut of the fork's work under the new organisation, carrying the versions
  the board was already running so that no board-visible version goes
  backwards.

### Changed

- Kernel 6.12.109 LTS (upstream: 6.8, never a longterm release).
- Buildroot 2025.02.17 LTS (upstream: 2024.05.1, end of life).
- Health-gated A/B promotion: a new image boots tentatively and is kept only
  if the daemon answers and every compute module's switch port is present.
  Otherwise the board reboots onto the image it had.
- A firmware update no longer power-cycles the compute modules.
- The board temperature is exposed through `thermal_zone0`, and the kernel
  drives the fan from it instead of a fixed persisted speed.
- `SHA256SUMS` published per release and verified on download.</content>
  </entry>
</feed>
