Files
Mitchell-JohnsonandClaude Opus 5 03e0761f3e 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>
2026-09-25 22:21:27 -07:00

844 lines
26 KiB
Go

// Package hardware is what is physically in the machine: the board it is, its processor,
// the memory modules in its slots, the drives attached to it and the graphics in it.
//
// None of this changes while the machine is running, so it is read once and remembered.
// Everything here is read from what Debian and its kin already have — dmidecode for the
// parts only the firmware knows, lscpu, lspci, sysfs — and every reader is allowed to
// come back empty: a machine with no dmidecode, no PCI bus or no root still gets a page,
// with a note saying what could not be read and why.
package hardware
import (
"bufio"
"context"
"os"
"os/exec"
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
"sync"
"time"
"hamlab/internal/priv"
"hamlab/internal/storage"
)
type System struct {
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 {
Level string `json:"level"` // L1d, L1i, L2, L3
Size string `json:"size"` // as lscpu puts it: "6 MiB (1 instance)"
}
type CPU struct {
Model string `json:"model,omitempty"`
Vendor string `json:"vendor,omitempty"`
Arch string `json:"arch,omitempty"`
Sockets int `json:"sockets,omitempty"`
SocketNames []string `json:"socket_names,omitempty"` // what the firmware calls each socket
Cores int `json:"cores,omitempty"`
Threads int `json:"threads,omitempty"`
ThreadsPerCore int `json:"threads_per_core,omitempty"`
MinMHz float64 `json:"min_mhz,omitempty"`
MaxMHz float64 `json:"max_mhz,omitempty"`
Family string `json:"family,omitempty"`
ModelID string `json:"model_id,omitempty"`
Stepping string `json:"stepping,omitempty"`
Microcode string `json:"microcode,omitempty"`
Virtualization string `json:"virtualization,omitempty"`
Caches []Cache `json:"caches,omitempty"`
Flags []string `json:"flags,omitempty"` // the handful anyone looks for
}
type Module struct {
Locator string `json:"locator"` // the slot, as the board labels it
Bank string `json:"bank,omitempty"`
Empty bool `json:"empty,omitempty"`
Size uint64 `json:"size,omitempty"`
Type string `json:"type,omitempty"` // DDR4
FormFactor string `json:"form_factor,omitempty"` // SODIMM
SpeedMTs int `json:"speed_mts,omitempty"` // what the module can do
RunningMTs int `json:"running_mts,omitempty"` // what the board is running it at
Maker string `json:"maker,omitempty"`
Part string `json:"part,omitempty"`
Serial string `json:"serial,omitempty"`
Rank int `json:"rank,omitempty"`
Voltage string `json:"voltage,omitempty"`
}
type Memory struct {
Total uint64 `json:"total"` // what the kernel can use
Installed uint64 `json:"installed,omitempty"` // what the modules add up to
MaxCapacity uint64 `json:"max_capacity,omitempty"` // what the board will take
Slots int `json:"slots,omitempty"`
Filled int `json:"filled,omitempty"`
ECC string `json:"ecc,omitempty"`
Modules []Module `json:"modules,omitempty"`
}
type Drive struct {
Name string `json:"name"`
Kind string `json:"kind"` // nvme | ssd | hdd | usb | emmc | sd | other
Model string `json:"model,omitempty"`
Vendor string `json:"vendor,omitempty"`
Serial string `json:"serial,omitempty"`
Firmware string `json:"firmware,omitempty"`
Tran string `json:"tran,omitempty"`
Size uint64 `json:"size"`
Rotational bool `json:"rotational,omitempty"`
Removable bool `json:"removable,omitempty"`
}
type GPU struct {
Model string `json:"model,omitempty"`
Vendor string `json:"vendor,omitempty"`
Slot string `json:"slot,omitempty"` // PCI address, when it is on a bus
Driver string `json:"driver,omitempty"`
Node string `json:"node,omitempty"` // card0
Memory uint64 `json:"memory,omitempty"`
Class string `json:"class,omitempty"` // VGA | 3D | Display
}
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"`
GPUs []GPU `json:"gpus"`
Notes []string `json:"notes,omitempty"` // what could not be read, and why
}
// Manager remembers the last report. Hardware does not change under a running kernel —
// the one thing that would, a drive plugged in, is what Refresh is for.
type Manager struct {
mu sync.Mutex
last *Report
at time.Time
}
func New() *Manager { return &Manager{} }
const keepFor = 10 * time.Minute
func (m *Manager) Report(ctx context.Context, refresh bool) *Report {
m.mu.Lock()
if !refresh && m.last != nil && time.Since(m.at) < keepFor {
r := m.last
m.mu.Unlock()
return r
}
m.mu.Unlock()
r := collect(ctx)
m.mu.Lock()
m.last, m.at = r, time.Now()
m.mu.Unlock()
return r
}
func collect(ctx context.Context) *Report {
ctx, cancel := context.WithTimeout(ctx, 25*time.Second)
defer cancel()
r := &Report{At: time.Now()}
dmi, note := readDMI(ctx)
if note != "" {
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 == "" {
r.Notes = append(r.Notes, "memory modules: the firmware's table says nothing about this machine's slots")
}
r.Drives = drives(ctx)
r.GPUs = gpus(ctx)
if len(r.GPUs) == 0 {
r.Notes = append(r.Notes, "graphics: nothing on the PCI bus and no kernel display device")
}
return r
}
// ---- dmidecode ----
// record is one entry of the firmware's table: its DMI type and its key/value lines.
type record struct {
Type int
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
// root; without it the page loses the board, the memory modules and the socket names, and
// says so rather than pretending the machine has none.
func readDMI(ctx context.Context) ([]record, string) {
tool, err := exec.LookPath("dmidecode")
if err != nil {
if _, e := os.Stat("/usr/sbin/dmidecode"); e != nil {
return nil, "the board and the memory modules need dmidecode, which isn't installed here"
}
tool = "/usr/sbin/dmidecode"
}
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"
}
args = priv.Wrap(args...)
}
out, err := exec.CommandContext(ctx, args[0], args[1:]...).Output()
if err != nil {
return nil, "the firmware's table could not be read: " + strings.TrimSpace(err.Error())
}
return parse(string(out)), ""
}
// parse turns dmidecode's output into records: a handle line starts one, and the single
// indented "Key: Value" lines under it are its fields. Doubly-indented lines are the
// continuation of a list (a processor's flags), which nothing here reads.
func parse(text string) []record {
var recs []record
var cur *record
sc := bufio.NewScanner(strings.NewReader(text))
sc.Buffer(make([]byte, 0, 64<<10), 2<<20)
for sc.Scan() {
line := sc.Text()
if m := handleRe.FindStringSubmatch(line); m != nil {
t, _ := strconv.Atoi(m[1])
recs = append(recs, record{Type: t, V: map[string]string{}})
cur = &recs[len(recs)-1]
continue
}
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
}
cur.V[k] = strings.TrimSpace(v)
}
return recs
}
func (r record) get(keys ...string) string {
for _, k := range keys {
if v := r.V[k]; v != "" && !unset(v) {
return v
}
}
return ""
}
// unset is every way the firmware has of saying "nobody filled this in".
func unset(v string) bool {
switch strings.ToLower(v) {
case "", "unknown", "not specified", "not provided", "none", "default string", "to be filled by o.e.m.",
"system manufacturer", "system product name", "to be filled by oem", "n/a", "empty", "no module installed",
"<out of spec>", "other", "not applicable":
return true
}
return false
}
func first(recs []record, t int) record {
for _, r := range recs {
if r.Type == t {
return r
}
}
return record{Type: t, V: map[string]string{}}
}
func all(recs []record, t int) []record {
var out []record
for _, r := range recs {
if r.Type == t {
out = append(out, r)
}
}
return out
}
// ---- system ----
func system(ctx context.Context, dmi []record) System {
sys := first(dmi, 1)
s := System{
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
s.Model = strings.TrimRight(readFile("/sys/firmware/devicetree/base/model"), "\x00")
}
if out, err := exec.CommandContext(ctx, "systemd-detect-virt").Output(); err == nil {
if v := strings.TrimSpace(string(out)); v != "" && v != "none" {
s.Virt = v
}
}
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 {
if p = strings.TrimSpace(p); p != "" && !unset(p) {
keep = append(keep, p)
}
}
// "GMKtec GMKtec" reads badly: drop a maker the product already names
if len(keep) == 2 && strings.HasPrefix(strings.ToLower(keep[1]), strings.ToLower(keep[0])) {
return keep[1]
}
return strings.Join(keep, " ")
}
func readFile(p string) string {
b, err := os.ReadFile(p)
if err != nil {
return ""
}
return strings.TrimSpace(string(b))
}
func runtimeArch() string {
if out, err := exec.Command("uname", "-m").Output(); err == nil {
return strings.TrimSpace(string(out))
}
return ""
}
// ---- processor ----
// worthShowing is the short list of processor features anyone actually asks about; the
// full flag list runs to hundreds and belongs in a terminal.
var worthShowing = []string{"aes", "avx", "avx2", "avx512f", "sha_ni", "vmx", "svm", "sse4_2", "rdrand", "hypervisor"}
func cpu(ctx context.Context, dmi []record) CPU {
c := CPU{}
f := lscpu(ctx)
c.Model = f["Model name"]
c.Vendor = f["Vendor ID"]
c.Arch = f["Architecture"]
c.Sockets = atoi(f["Socket(s)"])
c.ThreadsPerCore = atoi(f["Thread(s) per core"])
c.Threads = atoi(f["CPU(s)"])
c.Cores = atoi(f["Core(s) per socket"]) * maxInt(1, c.Sockets)
c.MinMHz = atof(f["CPU min MHz"])
c.MaxMHz = atof(f["CPU max MHz"])
c.Family, c.ModelID, c.Stepping = f["CPU family"], f["Model"], f["Stepping"]
c.Virtualization = f["Virtualization"]
for _, l := range []string{"L1d", "L1i", "L2", "L3"} {
if v := f[l+" cache"]; v != "" {
c.Caches = append(c.Caches, Cache{Level: l, Size: v})
}
}
have := map[string]bool{}
for _, fl := range strings.Fields(f["Flags"]) {
have[fl] = true
}
for _, fl := range worthShowing {
if have[fl] {
c.Flags = append(c.Flags, fl)
}
}
for _, p := range all(dmi, 4) {
if s := p.get("Socket Designation"); s != "" {
c.SocketNames = append(c.SocketNames, s)
}
if c.Model == "" {
c.Model = p.get("Version", "Family")
}
if c.MaxMHz == 0 {
if mhz := atof(strings.TrimSuffix(p.get("Max Speed"), " MHz")); mhz > 0 {
c.MaxMHz = mhz
}
}
}
if c.Sockets == 0 {
c.Sockets = len(c.SocketNames)
}
// A processor whose cores are not all alike — Apple's, Intel's P and E cores — is
// reported by lscpu as several groups, and "core(s) per socket" then counts one group
// rather than the chip. Without SMT every thread is a core, so the thread count is the
// honest answer.
if c.ThreadsPerCore == 1 && c.Threads > c.Cores {
c.Cores = c.Threads
}
// /proc/cpuinfo is the only place the running microcode revision is written down
if b, err := os.ReadFile("/proc/cpuinfo"); err == nil {
for _, line := range strings.Split(string(b), "\n") {
if k, v, ok := strings.Cut(line, ":"); ok && strings.TrimSpace(k) == "microcode" {
c.Microcode = strings.TrimSpace(v)
break
}
}
}
if c.Model == "" {
c.Model = "unknown processor"
}
return c
}
func lscpu(ctx context.Context) map[string]string {
f := map[string]string{}
out, err := exec.CommandContext(ctx, "lscpu").Output()
if err != nil {
// no util-linux: /proc/cpuinfo still names the thing and counts it
if b, err := os.ReadFile("/proc/cpuinfo"); err == nil {
n := 0
for _, line := range strings.Split(string(b), "\n") {
k, v, ok := strings.Cut(line, ":")
k, v = strings.TrimSpace(k), strings.TrimSpace(v)
switch {
case ok && strings.EqualFold(k, "model name"):
f["Model name"] = v
case ok && k == "processor":
n++
}
}
f["CPU(s)"] = strconv.Itoa(n)
}
return f
}
for _, line := range strings.Split(string(out), "\n") {
k, v, ok := strings.Cut(line, ":")
if !ok || strings.HasPrefix(line, " ") {
continue
}
f[strings.TrimSpace(k)] = strings.TrimSpace(v)
}
return f
}
func atoi(s string) int { n, _ := strconv.Atoi(strings.TrimSpace(s)); return n }
func atof(s string) float64 {
v, _ := strconv.ParseFloat(strings.TrimSpace(s), 64)
return v
}
func maxInt(a, b int) int {
if a > b {
return a
}
return b
}
// leadingInt reads the number off the front of "3200 MT/s".
func leadingInt(s string) int {
f := strings.Fields(s)
if len(f) == 0 {
return 0
}
return atoi(f[0])
}
// ---- memory ----
var sizeRe = regexp.MustCompile(`^(\d+)\s*(kB|KB|MB|GB|TB)$`)
// dmiSize turns dmidecode's "8 GB" / "16384 MB" into bytes.
func dmiSize(s string) uint64 {
m := sizeRe.FindStringSubmatch(strings.TrimSpace(s))
if m == nil {
return 0
}
n, _ := strconv.ParseUint(m[1], 10, 64)
switch strings.ToUpper(m[2]) {
case "KB":
return n << 10
case "MB":
return n << 20
case "GB":
return n << 30
case "TB":
return n << 40
}
return 0
}
func memory(dmi []record) Memory {
m := Memory{}
if b, err := os.ReadFile("/proc/meminfo"); err == nil {
for _, line := range strings.Split(string(b), "\n") {
if k, v, ok := strings.Cut(line, ":"); ok && k == "MemTotal" {
m.Total = uint64(atoi(strings.TrimSuffix(strings.TrimSpace(v), " kB"))) << 10
break
}
}
}
for _, a := range all(dmi, 16) {
m.MaxCapacity += dmiSize(a.get("Maximum Capacity"))
switch e := a.V["Error Correction Type"]; {
case e == "None":
m.ECC = "non-ECC" // the array says so outright; worth knowing, not worth hiding
case e != "" && !unset(e):
m.ECC = e
}
}
for _, d := range all(dmi, 17) {
mod := Module{
Locator: d.get("Locator"),
Bank: d.get("Bank Locator"),
Size: dmiSize(d.get("Size")),
}
m.Slots++
if mod.Locator == "" {
mod.Locator = "slot " + strconv.Itoa(m.Slots)
}
if mod.Size == 0 {
mod.Empty = true
m.Modules = append(m.Modules, mod)
continue
}
m.Filled++
mod.Type = d.get("Type")
mod.FormFactor = d.get("Form Factor")
mod.SpeedMTs = leadingInt(d.get("Speed"))
mod.RunningMTs = leadingInt(d.get("Configured Memory Speed", "Configured Clock Speed"))
mod.Maker = makerName(d.get("Manufacturer"), d.V["Module Manufacturer ID"])
mod.Part = d.get("Part Number")
mod.Serial = d.get("Serial Number")
mod.Rank = atoi(d.get("Rank"))
mod.Voltage = d.get("Configured Voltage")
m.Installed += mod.Size
m.Modules = append(m.Modules, mod)
}
return m
}
// jedec maps the memory makers who turn up in a homelab to their names: SPD carries a
// JEDEC id, and dmidecode prints the raw code when the module's string field is empty.
var jedec = map[string]string{
"0X2C00": "Micron", "0X802C": "Micron",
"0XCE00": "Samsung", "0X80CE": "Samsung", "0X00CE": "Samsung",
"0XAD00": "SK hynix", "0X80AD": "SK hynix",
"0X9801": "Kingston", "0X7F98": "Kingston", "0X0198": "Kingston",
"0X859B": "Crucial", "0X04CB": "A-DATA", "0X04CD": "G.Skill",
"0X029E": "Corsair", "0X0B45": "Wilk Elektronik", "0X830B": "Wilk Elektronik",
"0X0443": "Ramaxel", "0X0194": "Smart Modular",
}
// makerName prefers what the module says it is; a bare JEDEC id becomes a name when it is
// one we know, and stays the id when it is not — a wrong name is worse than a number.
func makerName(v, id string) string {
if v != "" && !unset(v) && !strings.HasPrefix(v, "0x") {
return v
}
code := v
if code == "" {
code = id
}
if n, ok := jedec[strings.ToUpper(code)]; ok {
return n
}
if v != "" && !unset(v) {
return v
}
return ""
}
// ---- drives ----
func drives(ctx context.Context) []Drive {
devs, err := storage.Devices(ctx)
if err != nil {
return nil
}
out := make([]Drive, 0, len(devs))
for _, d := range devs {
out = append(out, Drive{
Name: d.Name, Kind: d.Kind, Model: d.Model, Vendor: d.Vendor, Serial: d.Serial,
Tran: d.Tran, Size: d.Size, Rotational: d.Rotational, Removable: d.Removable,
Firmware: firmwareOf(d.Name),
})
}
return out
}
// firmwareOf reads a drive's firmware revision. An NVMe device is folded to its
// controller ("nvme0"), which has no /sys/block entry of its own — the revision is on the
// controller in /sys/class/nvme.
func firmwareOf(name string) string {
return firstNonEmpty(
readFile("/sys/block/"+name+"/device/firmware_rev"),
readFile("/sys/block/"+name+"/device/rev"),
readFile("/sys/class/nvme/"+name+"/firmware_rev"),
)
}
func firstNonEmpty(v ...string) string {
for _, s := range v {
if s != "" {
return s
}
}
return ""
}
// ---- graphics ----
// lspci -mm quotes every field, so a device name can hold the spaces and brackets it
// likes: `"VGA compatible controller [0300]" "Intel Corporation [8086]" "Alder Lake-N …"`.
var quoted = regexp.MustCompile(`"([^"]*)"`)
// stripID drops the trailing PCI id from `Alder Lake-N [UHD Graphics] [46d1]`.
func stripID(s string) string {
if i := strings.LastIndex(s, " ["); i > 0 && strings.HasSuffix(s, "]") {
return strings.TrimSpace(s[:i])
}
return s
}
func gpus(ctx context.Context) []GPU {
var out []GPU
seen := map[string]bool{} // PCI addresses already accounted for
if _, err := exec.LookPath("lspci"); err == nil {
drivers := pciDrivers(ctx)
if b, err := exec.CommandContext(ctx, "lspci", "-D", "-mm", "-nn").Output(); err == nil {
for _, line := range strings.Split(string(b), "\n") {
slot, _, _ := strings.Cut(line, " ")
fields := quoted.FindAllStringSubmatch(line, -1)
if len(fields) < 3 {
continue
}
kind := displayClass(fields[0][1])
if kind == "" {
continue
}
seen[slot] = true
g := GPU{Slot: slot, Class: kind, Vendor: stripID(fields[1][1]), Model: stripID(fields[2][1]), Driver: drivers[slot]}
g.Node, g.Memory = drmFor(slot)
out = append(out, g)
}
}
}
// A GPU that is not on a PCI bus at all — an Apple Silicon Mac, a Pi, anything with a
// device tree — is only visible as a kernel display device.
cards, _ := filepath.Glob("/sys/class/drm/card[0-9]")
for _, c := range cards {
dev, err := filepath.EvalSymlinks(c + "/device")
if err != nil || seen[filepath.Base(dev)] {
continue
}
g := GPU{Node: filepath.Base(c), Class: "Display"}
if drv, err := filepath.EvalSymlinks(dev + "/driver"); err == nil {
g.Driver = filepath.Base(drv)
}
g.Model = deviceTreeName(dev)
if g.Model == "" && g.Driver != "" {
g.Model = g.Driver + " display"
}
if g.Model == "" {
continue
}
g.Memory = vram(dev)
out = append(out, g)
}
sort.SliceStable(out, func(i, j int) bool { return out[i].Slot < out[j].Slot })
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]"):
return "VGA"
case strings.Contains(c, "[0302]"):
return "3D"
case strings.Contains(c, "[0301]"), strings.Contains(c, "[0380]"):
return "Display"
}
return ""
}
// pciDrivers maps a PCI address to the kernel module driving it.
func pciDrivers(ctx context.Context) map[string]string {
m := map[string]string{}
out, err := exec.CommandContext(ctx, "lspci", "-D", "-k").Output()
if err != nil {
return m
}
slot := ""
for _, line := range strings.Split(string(out), "\n") {
if !strings.HasPrefix(line, "\t") && !strings.HasPrefix(line, " ") {
slot, _, _ = strings.Cut(line, " ")
continue
}
if v, ok := strings.CutPrefix(strings.TrimSpace(line), "Kernel driver in use:"); ok && slot != "" {
m[slot] = strings.TrimSpace(v)
}
}
return m
}
// drmFor finds the kernel display device for a PCI address, and its video memory when the
// driver publishes it (amdgpu does; Intel's integrated graphics have none of their own).
func drmFor(slot string) (string, uint64) {
cards, _ := filepath.Glob("/sys/class/drm/card[0-9]")
for _, c := range cards {
dev, err := filepath.EvalSymlinks(c + "/device")
if err != nil || filepath.Base(dev) != slot {
continue
}
return filepath.Base(c), vram(dev)
}
return "", 0
}
func vram(dev string) uint64 {
for _, f := range []string{"mem_info_vram_total", "mem_info_vis_vram_total"} {
if v := readFile(dev + "/" + f); v != "" {
n, _ := strconv.ParseUint(v, 10, 64)
return n
}
}
return 0
}
// deviceTreeName reads what a device-tree machine calls its graphics: the compatible
// string, tidied — "apple,agx-g13g" becomes "Apple agx-g13g".
func deviceTreeName(dev string) string {
c := readFile(dev + "/of_node/compatible")
if c == "" {
return ""
}
c = strings.TrimRight(strings.Split(c, "\x00")[0], "\x00")
maker, rest, ok := strings.Cut(c, ",")
if !ok {
return c
}
if maker != "" {
maker = strings.ToUpper(maker[:1]) + maker[1:]
}
return strings.TrimSpace(maker + " " + rest)
}