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>
743 lines
22 KiB
Go
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)
|
|
}
|