Hardware: a Motherboard card

The board, the chassis and the firmware on it, which the System card was
reducing to two lines. Maker, model, version, serial and asset tag; chassis
and where the board sits in it; the firmware's vendor, version, revision,
date, ROM size and whether the machine boots UEFI — that last one is a line
in SMBIOS's characteristics list rather than a field, so the parser now keeps
those lists too.

Expansion slots come with it, with the device found at each occupied slot's
PCI address rather than the address alone. They are only shown when the
firmware tracks occupancy at all: core's NucBox lists five PCIe x1 slots it
does not physically have, all "Available", copied out of a reference design.
grid's board reports real addresses and real occupancy, which is the case
worth printing.

A machine with no SMBIOS gets a card that says so.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Mitchell-Johnson
2026-09-25 22:21:27 -07:00
co-authored by Claude Opus 5
parent 38039d1adc
commit 03e0761f3e
3 changed files with 189 additions and 31 deletions
+124 -23
View File
@@ -26,15 +26,44 @@ import (
)
type System struct {
Model string `json:"model,omitempty"` // "GMKtec NucBox G3"
Serial string `json:"serial,omitempty"`
Board string `json:"board,omitempty"` // motherboard, when it differs from the model
Chassis string `json:"chassis,omitempty"`
BIOS string `json:"bios,omitempty"`
BIOSDate string `json:"bios_date,omitempty"`
Kernel string `json:"kernel,omitempty"`
Arch string `json:"arch,omitempty"`
Virt string `json:"virt,omitempty"` // "kvm", "lxc", … when this is not real hardware
Model string `json:"model,omitempty"` // "GMKtec NucBox G3"
Serial string `json:"serial,omitempty"`
Family string `json:"family,omitempty"`
Kernel string `json:"kernel,omitempty"`
Arch string `json:"arch,omitempty"`
Virt string `json:"virt,omitempty"` // "kvm", "lxc", … when this is not real hardware
}
// Slot is an expansion slot as the firmware describes it. Worth treating with suspicion on
// a mini PC, where the table is often copied from a reference design and lists slots the
// case has no room for.
type Slot struct {
Designation string `json:"designation"`
Type string `json:"type,omitempty"`
Usage string `json:"usage,omitempty"` // Available | In Use | Unknown
Length string `json:"length,omitempty"`
Width string `json:"width,omitempty"`
Bus string `json:"bus,omitempty"` // PCI address of what is in it
Device string `json:"device,omitempty"` // and what that turns out to be
}
// Board is the motherboard and the firmware on it.
type Board struct {
Maker string `json:"maker,omitempty"`
Product string `json:"product,omitempty"`
Version string `json:"version,omitempty"`
Serial string `json:"serial,omitempty"`
AssetTag string `json:"asset_tag,omitempty"`
Type string `json:"type,omitempty"` // "Motherboard", "Processor Module", …
Location string `json:"location,omitempty"` // where it sits in the chassis
Chassis string `json:"chassis,omitempty"`
ChassisMaker string `json:"chassis_maker,omitempty"`
BIOS string `json:"bios,omitempty"` // vendor and version
BIOSDate string `json:"bios_date,omitempty"`
BIOSRevision string `json:"bios_revision,omitempty"`
ROMSize string `json:"rom_size,omitempty"`
UEFI bool `json:"uefi,omitempty"`
Slots []Slot `json:"slots,omitempty"`
}
type Cache struct {
@@ -114,6 +143,7 @@ type GPU struct {
type Report struct {
At time.Time `json:"at"`
System System `json:"system"`
Board Board `json:"board"`
CPU CPU `json:"cpu"`
Memory Memory `json:"memory"`
Drives []Drive `json:"drives"`
@@ -159,6 +189,7 @@ func collect(ctx context.Context) *Report {
r.Notes = append(r.Notes, note)
}
r.System = system(ctx, dmi)
r.Board = board(dmi, pciNames(ctx))
r.CPU = cpu(ctx, dmi)
r.Memory = memory(dmi)
if len(r.Memory.Modules) == 0 && note == "" {
@@ -180,6 +211,10 @@ type record struct {
V map[string]string
}
// listKey holds every doubly-indented line of a record — dmidecode's lists, which have no
// key of their own: BIOS characteristics, board features, slot characteristics.
const listKey = "\x00list"
var handleRe = regexp.MustCompile(`^Handle 0x[0-9A-Fa-f]+, DMI type (\d+)`)
// readDMI runs dmidecode once for every type the page wants and parses the lot. It needs
@@ -193,7 +228,7 @@ func readDMI(ctx context.Context) ([]record, string) {
}
tool = "/usr/sbin/dmidecode"
}
args := []string{tool, "-t", "bios", "-t", "system", "-t", "baseboard", "-t", "chassis", "-t", "processor", "-t", "memory"}
args := []string{tool, "-t", "bios", "-t", "system", "-t", "baseboard", "-t", "chassis", "-t", "slot", "-t", "processor", "-t", "memory"}
if os.Geteuid() != 0 {
if !priv.Available() {
return nil, "the board and the memory modules come from the firmware's table, which only root may read"
@@ -223,12 +258,16 @@ func parse(text string) []record {
cur = &recs[len(recs)-1]
continue
}
if cur == nil || !strings.HasPrefix(line, "\t") || strings.HasPrefix(line, "\t\t") {
if cur == nil || !strings.HasPrefix(line, "\t") {
if strings.TrimSpace(line) == "" {
cur = nil
}
continue
}
if strings.HasPrefix(line, "\t\t") { // a list under the field above it, e.g. BIOS characteristics
cur.V[listKey] += strings.TrimSpace(line) + "\n"
continue
}
k, v, ok := strings.Cut(strings.TrimSpace(line), ":")
if !ok {
continue
@@ -280,19 +319,12 @@ func all(recs []record, t int) []record {
// ---- system ----
func system(ctx context.Context, dmi []record) System {
sys, board, bios, chassis := first(dmi, 1), first(dmi, 2), first(dmi, 0), first(dmi, 3)
sys := first(dmi, 1)
s := System{
Model: join(sys.get("Manufacturer"), sys.get("Product Name")),
Serial: sys.get("Serial Number"),
Board: join(board.get("Manufacturer"), board.get("Product Name")),
Chassis: chassis.get("Type"),
BIOS: join(bios.get("Vendor"), bios.get("Version")),
BIOSDate: bios.get("Release Date"),
Arch: runtimeArch(),
}
// a mini PC names its board after itself — "GMKtec" under "GMKtec NucBox G3" says nothing
if s.Board != "" && (s.Board == s.Model || strings.Contains(strings.ToLower(s.Model), strings.ToLower(s.Board))) {
s.Board = ""
Model: join(sys.get("Manufacturer"), sys.get("Product Name")),
Serial: sys.get("Serial Number"),
Family: sys.get("Family"),
Arch: runtimeArch(),
}
s.Kernel = readFile("/proc/sys/kernel/osrelease")
if s.Model == "" { // no SMBIOS: a Pi, a Mac, anything with a device tree
@@ -306,6 +338,53 @@ func system(ctx context.Context, dmi []record) System {
return s
}
// board reads the motherboard, the chassis it sits in, the firmware on it and the
// expansion slots the firmware claims. All of it is SMBIOS, so a machine without one — a
// Mac, a Pi — gets an empty board, and the page says so rather than inventing one.
func board(dmi []record, pci map[string]string) Board {
b, ch, bios := first(dmi, 2), first(dmi, 3), first(dmi, 0)
out := Board{
Maker: b.get("Manufacturer"),
Product: b.get("Product Name"),
Version: b.get("Version"),
Serial: b.get("Serial Number"),
AssetTag: b.get("Asset Tag"),
Type: b.get("Type"),
Location: b.get("Location In Chassis"),
Chassis: ch.get("Type"),
ChassisMaker: ch.get("Manufacturer"),
BIOS: join(bios.get("Vendor"), bios.get("Version")),
BIOSDate: bios.get("Release Date"),
BIOSRevision: bios.get("BIOS Revision"),
}
// "ROM Size: 0 MB" is a board that never filled the field in, not a board with no ROM
if rom := bios.get("ROM Size"); rom != "" && !strings.HasPrefix(rom, "0 ") {
out.ROMSize = rom
}
if out.Product == out.Maker {
out.Product = "" // a board named after its maker says nothing twice
}
out.UEFI = strings.Contains(bios.V[listKey], "UEFI") // a characteristics line, not a field
for _, sl := range all(dmi, 9) {
s := Slot{
Designation: sl.get("Designation"),
Type: sl.get("Type"),
Usage: sl.get("Current Usage"),
Length: sl.get("Length"),
Width: sl.get("Data Bus Width"),
}
// every slot on some boards claims 0000:00:00.0, which is the host bridge and
// certainly not what is plugged into that slot
if bus := sl.get("Bus Address"); bus != "" && bus != "0000:00:00.0" {
s.Bus, s.Device = bus, pci[bus]
}
if s.Designation != "" {
out.Slots = append(out.Slots, s)
}
}
return out
}
func join(parts ...string) string {
var keep []string
for _, p := range parts {
@@ -667,6 +746,28 @@ func gpus(ctx context.Context) []GPU {
return out
}
// pciNames maps a PCI address to a readable name, so a slot the firmware says is in use
// can say what is in it.
func pciNames(ctx context.Context) map[string]string {
m := map[string]string{}
if _, err := exec.LookPath("lspci"); err != nil {
return m
}
out, err := exec.CommandContext(ctx, "lspci", "-D", "-mm", "-nn").Output()
if err != nil {
return m
}
for _, line := range strings.Split(string(out), "\n") {
slot, _, _ := strings.Cut(line, " ")
f := quoted.FindAllStringSubmatch(line, -1)
if slot == "" || len(f) < 3 {
continue
}
m[slot] = strings.TrimSpace(stripID(f[1][1]) + " " + stripID(f[2][1]))
}
return m
}
func displayClass(c string) string {
switch {
case strings.Contains(c, "[0300]"):