// 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", "", "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) }