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>
This commit is contained in:
co-authored by
Claude Opus 5
parent
09dc846730
commit
776bcb236d
@@ -0,0 +1,742 @@
|
||||
// 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)
|
||||
}
|
||||
Reference in New Issue
Block a user