feat: ARM hardware detection via device tree and /proc/cpuinfo

On ARM boards (Raspberry Pi, ODROID, etc.) /sys/class/dmi/id/ does not
exist. _collect_platform_info() now falls back to:
  - /sys/firmware/devicetree/base/model  (preferred)
  - /proc/cpuinfo Model: / Serial:       (fallback)

Three bugs fixed in the process:

1. systemd-detect-virt exits 1 on bare metal, so the old
   "|| echo none" pattern produced d="none\nnone" (two lines),
   shifting all subsequent fields by one. Fixed with ${d:-none}.

2. _send() calls .strip() on output, silently eating the five leading
   blank lines that represent empty DMI fields on ARM. Fixed by
   prefixing the printf output with a DMIBEGIN sentinel so the parser
   can locate field 0 regardless of leading whitespace.

3. Vendor was always empty for ARM, falling back to the generic "Linux"
   constant. Added _ARM_VENDOR_PREFIXES lookup table and
   _arm_vendor_from_model() to derive the canonical vendor name from
   the model string (e.g. "Raspberry Pi Foundation" for any RPi board).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Christian Manivong
2026-06-24 12:32:01 +02:00
co-authored by Claude Sonnet 4.6
parent 450aa193ba
commit 1bfc8dd245
2 changed files with 169 additions and 12 deletions
+68 -10
View File
@@ -85,6 +85,36 @@ _VM_DMI_VENDORS: frozenset[str] = frozenset({
"parallels software international inc.",
})
# Known ARM board model prefixes → canonical vendor name
_ARM_VENDOR_PREFIXES: list[tuple[str, str]] = [
("Raspberry Pi", "Raspberry Pi Foundation"),
("NVIDIA Jetson", "NVIDIA"),
("ODROID", "Hardkernel"),
("Hardkernel", "Hardkernel"),
("Rock Pi", "Radxa"),
("ROCK Pi", "Radxa"),
("Radxa", "Radxa"),
("Orange Pi", "Xunlong Software"),
("Banana Pi", "SinoVoip"),
("NanoPi", "FriendlyElec"),
("PINE64", "Pine64"),
("BeagleBone", "BeagleBoard.org"),
]
def _arm_vendor_from_model(model: str) -> str:
"""Extract vendor from an ARM device tree / cpuinfo model string."""
for prefix, vendor in _ARM_VENDOR_PREFIXES:
if model.startswith(prefix):
return vendor
# Generic fallback: words before the first numeric token
brand = []
for word in model.split():
if word[0].isdigit():
break
brand.append(word)
return " ".join(brand)
class LinuxDriver(OSDriver):
"""NAPALM driver for generic Linux systems.
@@ -235,11 +265,23 @@ class LinuxDriver(OSDriver):
"r=$(cat /sys/class/dmi/id/product_version 2>/dev/null); "
"s=$(cat /sys/class/dmi/id/product_serial 2>/dev/null); "
"u=$(cat /sys/class/dmi/id/product_uuid 2>/dev/null); "
"d=$(systemd-detect-virt 2>/dev/null || echo none); "
"printf '%s\\n%s\\n%s\\n%s\\n%s\\n%s\\n' \"$v\" \"$n\" \"$r\" \"$s\" \"$u\" \"$d\""
"d=$(systemd-detect-virt 2>/dev/null); d=${d:-none}; "
"dt=$(tr -d '\\0' </sys/firmware/devicetree/base/model 2>/dev/null); "
"cs=$(grep '^Serial' /proc/cpuinfo 2>/dev/null | head -1 | cut -d: -f2 | xargs 2>/dev/null); "
"cm=$(grep '^Model' /proc/cpuinfo 2>/dev/null | head -1 | cut -d: -f2 | xargs 2>/dev/null); "
# DMIBEGIN sentinel: _send() strips leading blank lines (ARM has no DMI
# files, so fields 0-4 are empty). The sentinel anchors the output so
# splitlines()[start+N] always maps to the correct field index.
"printf 'DMIBEGIN\\n%s\\n%s\\n%s\\n%s\\n%s\\n%s\\n%s\\n%s\\n%s\\n' "
"\"$v\" \"$n\" \"$r\" \"$s\" \"$u\" \"$d\" \"$dt\" \"$cs\" \"$cm\""
)
try:
lines = self._send(dmi_cmd).splitlines()
raw_lines = self._send(dmi_cmd).splitlines()
try:
start = raw_lines.index("DMIBEGIN") + 1
except ValueError:
start = 0
lines = raw_lines[start:]
except Exception:
return {"vendor": "", "model": "", "serial": "", "is_vm": False}
@@ -253,6 +295,9 @@ class LinuxDriver(OSDriver):
product_ser = _clean(3)
product_uuid = _clean(4)
detect_virt = lines[5].strip().lower() if len(lines) > 5 else "none"
dt_model = _clean(6)
cpuinfo_ser = _clean(7)
cpuinfo_mdl = _clean(8)
is_container = detect_virt in _CONTAINER_VIRT
is_vm = (
@@ -292,13 +337,26 @@ class LinuxDriver(OSDriver):
}
# Bare-metal: prefer product_version when it reads like a marketing name
pv_usable = product_ver and product_ver != product_name and " " in product_ver
return {
"vendor": sys_vendor,
"model": product_ver if pv_usable else product_name,
"serial": product_ser,
"is_vm": False,
}
if sys_vendor or product_name:
pv_usable = product_ver and product_ver != product_name and " " in product_ver
return {
"vendor": sys_vendor,
"model": product_ver if pv_usable else product_name,
"serial": product_ser,
"is_vm": False,
}
# ARM/embedded fallback: no DMI, try device tree and /proc/cpuinfo
arm_model = dt_model or cpuinfo_mdl
if arm_model:
return {
"vendor": _arm_vendor_from_model(arm_model),
"model": arm_model,
"serial": cpuinfo_ser,
"is_vm": False,
}
return {"vendor": "", "model": "", "serial": "", "is_vm": False}
def get_facts(self) -> dict[str, Any]:
"""Return basic system facts."""