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
+61 -3
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,6 +337,7 @@ class LinuxDriver(OSDriver):
}
# Bare-metal: prefer product_version when it reads like a marketing name
if sys_vendor or product_name:
pv_usable = product_ver and product_ver != product_name and " " in product_ver
return {
"vendor": sys_vendor,
@@ -300,6 +346,18 @@ class LinuxDriver(OSDriver):
"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."""
hostname = self._send("hostname -s 2>/dev/null || hostname")
+101 -2
View File
@@ -2,7 +2,7 @@
import pytest
from unittest.mock import MagicMock, patch
from napalm_linux.linux import LinuxDriver
from napalm_linux.linux import LinuxDriver, _arm_vendor_from_model
# ---------------------------------------------------------------------------
@@ -368,8 +368,18 @@ def _dmi_output(
product_serial: str,
product_uuid: str,
detect_virt: str,
dt_model: str = "",
cpuinfo_serial: str = "",
cpuinfo_model: str = "",
) -> str:
return "\n".join([sys_vendor, product_name, product_version, product_serial, product_uuid, detect_virt])
# Mirrors the real shell output: DMIBEGIN sentinel followed by 9 fields.
# The sentinel prevents _send()'s .strip() from eating leading blank lines
# (which occur on ARM where all DMI files are absent).
return "\n".join([
"DMIBEGIN",
sys_vendor, product_name, product_version, product_serial,
product_uuid, detect_virt, dt_model, cpuinfo_serial, cpuinfo_model,
])
class TestCollectPlatformInfo:
@@ -482,6 +492,95 @@ class TestCollectPlatformInfo:
assert info["model"] == ""
assert info["is_vm"] is False
def test_arm_device_tree_fallback(self, driver):
raw = _dmi_output(
"", "", "", "", "", "none",
dt_model="Raspberry Pi 4 Model B Rev 1.4",
cpuinfo_serial="100000002a6d96dc",
cpuinfo_model="Raspberry Pi 4 Model B Rev 1.4",
)
with patch.object(driver, "_send", return_value=raw):
info = driver._collect_platform_info()
assert info["vendor"] == "Raspberry Pi Foundation"
assert info["model"] == "Raspberry Pi 4 Model B Rev 1.4"
assert info["serial"] == "100000002a6d96dc"
assert info["is_vm"] is False
def test_arm_cpuinfo_model_when_no_dt(self, driver):
raw = _dmi_output(
"", "", "", "", "", "none",
dt_model="",
cpuinfo_serial="0000000012345678",
cpuinfo_model="Raspberry Pi 3 Model B Plus Rev 1.3",
)
with patch.object(driver, "_send", return_value=raw):
info = driver._collect_platform_info()
assert info["vendor"] == "Raspberry Pi Foundation"
assert info["model"] == "Raspberry Pi 3 Model B Plus Rev 1.3"
assert info["serial"] == "0000000012345678"
assert info["is_vm"] is False
def test_arm_no_fallback_data_returns_empty(self, driver):
raw = _dmi_output("", "", "", "", "", "none")
with patch.object(driver, "_send", return_value=raw):
info = driver._collect_platform_info()
assert info["vendor"] == ""
assert info["model"] == ""
assert info["is_vm"] is False
def test_bare_metal_detect_virt_exit1_does_not_shift_arm_fields(self, driver):
# systemd-detect-virt exits 1 on bare metal, old "|| echo none" caused
# d="none\nnone" which shifted subsequent lines off by one.
# With the ${d:-none} fix this no longer happens.
raw = _dmi_output(
"", "", "", "", "", "none",
dt_model="",
cpuinfo_serial="0000000012345678",
cpuinfo_model="Raspberry Pi 3 Model B Rev 1.2",
)
with patch.object(driver, "_send", return_value=raw):
info = driver._collect_platform_info()
assert info["vendor"] == "Raspberry Pi Foundation"
assert info["model"] == "Raspberry Pi 3 Model B Rev 1.2"
assert info["serial"] == "0000000012345678"
def test_arm_fallback_ignored_when_dmi_present(self, driver):
raw = _dmi_output(
"Dell Inc.", "PowerEdge R720", "Not Specified", "XYZ999",
"8a2e3f00-dead-beef-0000-123456789abc", "none",
dt_model="some-dt-model",
cpuinfo_serial="deadbeef",
cpuinfo_model="some cpuinfo model",
)
with patch.object(driver, "_send", return_value=raw):
info = driver._collect_platform_info()
assert info["vendor"] == "Dell Inc."
assert info["model"] == "PowerEdge R720"
assert info["serial"] == "XYZ999"
# ---------------------------------------------------------------------------
# _arm_vendor_from_model
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("model,expected", [
("Raspberry Pi 4 Model B Rev 1.4", "Raspberry Pi Foundation"),
("Raspberry Pi 3 Model B Rev 1.2", "Raspberry Pi Foundation"),
("ODROID-N2L", "Hardkernel"),
("NVIDIA Jetson Nano Developer Kit", "NVIDIA"),
("Rock Pi 4C", "Radxa"),
("Orange Pi 5 Plus", "Xunlong Software"),
("Banana Pi BPI-R3", "SinoVoip"),
("NanoPi R4S", "FriendlyElec"),
("PINE64 RockPro64", "Pine64"),
("BeagleBone Black", "BeagleBoard.org"),
("Unknown Board 1.0", "Unknown Board"),
("SomeSingleWordBoard", "SomeSingleWordBoard"),
])
def test_arm_vendor_from_model(model, expected):
assert _arm_vendor_from_model(model) == expected
# ---------------------------------------------------------------------------
# get_facts uses _collect_platform_info