Files
hamlab/internal/hardware/hardware.go
T
Mitchell-JohnsonandClaude Opus 5 776bcb236d A Hardware tab: what the machine is made of
Per machine, like every other page. Four tiles — processor, memory, storage,
graphics — over a card for each.

Memory is the part that had nowhere to live before: every slot, with the
module's size, type, form factor, rated and running speed, maker, part
number, serial, rank and voltage, and the empty slots marked empty. A module
whose maker is only a JEDEC id gets a name when it is one we know and keeps
the number when it is not, because a wrong name is worse than a code.

Everything is read from what Debian already has: dmidecode for the parts only
the firmware knows, lscpu, lspci, sysfs. None of it changes under a running
kernel, so it is read once and kept for ten minutes; Re-read is for the one
thing that does change, a drive plugged in.

Every reader is allowed to come back empty. A machine with no dmidecode, no
root to read it with, or no PCI bus at all still gets a page, with a note
saying what was missing and why. Checked against the Mac mini, which is all
three cases at once: no SMBIOS memory table, a GPU only the device tree knows
about, and cores that are not all alike — lscpu counts one cluster as "cores
per socket" there, so without SMT the thread count is the honest answer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-25 21:41:40 -07:00

743 lines
22 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"`
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
}
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"`
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.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
}
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", "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") || strings.HasPrefix(line, "\t\t") {
if strings.TrimSpace(line) == "" {
cur = nil
}
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, board, bios, chassis := first(dmi, 1), first(dmi, 2), first(dmi, 0), first(dmi, 3)
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 = ""
}
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
}
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
}
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)
}