homelab/services/device-inventory/src/client/discovery.cpp
Dan V 29440b68a9 fix: sudo dmidecode fallback when running without root
Without root, dmidecode exits 0 but outputs only a header comment
with no Handle blocks (DMI tables are root-only in sysfs).
The previous empty-string check never triggered the sudo retry.

Now checks for the presence of 'Handle ' lines: if absent, retries
transparently with sudo. Users with passwordless sudo get full hardware
detail (CPU slots, memory sticks/slots, cache, voltage) without needing
to explicitly invoke sudo themselves.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-06 23:45:35 +02:00

1584 lines
65 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#include "client/discovery.h"
#include "common/models.h"
#include "common/protocol.h"
#include <algorithm>
#include <cerrno>
#include <cstdio>
#include <cstring>
#include <iostream>
#include <sstream>
#include <sys/stat.h>
// ═════════════════════════════════════════════════════════════════════════════
// Low-level helpers
// ═════════════════════════════════════════════════════════════════════════════
// Run a command with popen, return trimmed stdout (at most 64 KB).
std::string Discovery::run_cmd(const std::string& cmd) {
FILE* pipe = popen(cmd.c_str(), "r");
if (!pipe) return "";
std::string result;
char buf[4096];
while (fgets(buf, sizeof(buf), pipe)) {
result += buf;
if (result.size() >= 65536) break;
}
pclose(pipe);
// Trim trailing whitespace
while (!result.empty() && std::isspace((unsigned char)result.back()))
result.pop_back();
return result;
}
// Parse dmidecode -t <type_num> output into a vector of key→value maps.
// Each map corresponds to one DMI block (e.g. one "Memory Device").
std::vector<std::map<std::string, std::string>>
Discovery::parse_dmi(const std::string& type_num) {
#ifdef __APPLE__
(void)type_num;
return {}; // dmidecode not available on macOS; callers check __APPLE__
#else
std::string output = run_cmd("dmidecode -t " + type_num + " 2>/dev/null");
// Without root, dmidecode outputs only a header comment with no Handle blocks.
// If no "Handle " lines appear, retry transparently with sudo.
if (output.find("Handle ") == std::string::npos)
output = run_cmd("sudo dmidecode -t " + type_num + " 2>/dev/null");
if (output.empty()) return {};
std::vector<std::map<std::string, std::string>> result;
std::map<std::string, std::string> current;
std::istringstream ss(output);
std::string line;
while (std::getline(ss, line)) {
if (!line.empty() && line.back() == '\r') line.pop_back();
// Handle empty — signals end of a block
if (line.empty()) {
if (!current.empty()) {
result.push_back(std::move(current));
current.clear();
}
continue;
}
// "Handle 0x…" starts a new block
if (line.size() >= 6 && line.substr(0, 6) == "Handle") {
if (!current.empty()) {
result.push_back(std::move(current));
current.clear();
}
continue;
}
// Tab-indented: " Key: Value" or just " Tag"
if (line[0] == '\t') {
std::string kv = line.substr(1);
size_t colon = kv.find(": ");
if (colon != std::string::npos) {
std::string k = kv.substr(0, colon);
std::string v = kv.substr(colon + 2);
while (!v.empty() && std::isspace((unsigned char)v.back()))
v.pop_back();
current[k] = v;
} else {
// Line with no colon is the block type header
while (!kv.empty() && std::isspace((unsigned char)kv.back()))
kv.pop_back();
if (!kv.empty()) current["__type__"] = kv;
}
} else if (line[0] != '#') {
// Non-indented, non-comment: block type header
while (!line.empty() && std::isspace((unsigned char)line.back()))
line.pop_back();
if (!line.empty()) current["__type__"] = line;
}
}
if (!current.empty()) result.push_back(std::move(current));
return result;
#endif
}
// ── Numeric / size helpers ────────────────────────────────────────────────────
namespace {
// "16384 MB", "16 GB", "No Module Installed", "" → MB as uint64_t
static uint64_t dmi_size_to_mb(const std::string& s) {
if (s.empty() || s.find("No Module") != std::string::npos) return 0;
size_t i = 0;
while (i < s.size() && (std::isdigit((unsigned char)s[i]) || s[i] == '.')) ++i;
if (i == 0) return 0;
double val = std::stod(s.substr(0, i));
std::string suffix = s.substr(i);
// trim leading whitespace from suffix
while (!suffix.empty() && std::isspace((unsigned char)suffix[0]))
suffix = suffix.substr(1);
if (suffix == "GB" || suffix == "GB" || suffix[0] == 'G') return static_cast<uint64_t>(val * 1024);
if (suffix == "TB" || suffix[0] == 'T') return static_cast<uint64_t>(val * 1024 * 1024);
return static_cast<uint64_t>(val); // assume MB
}
#ifndef __APPLE__
// "500G", "2T", "500M", "128K", raw bytes → GB as uint64_t
static uint64_t size_to_gb(const std::string& s) {
if (s.empty()) return 0;
size_t i = 0;
while (i < s.size() && (std::isdigit((unsigned char)s[i]) || s[i] == '.')) ++i;
if (i == 0) return 0;
double val = std::stod(s.substr(0, i));
std::string suffix = s.substr(i);
while (!suffix.empty() && std::isspace((unsigned char)suffix[0]))
suffix = suffix.substr(1);
char u = suffix.empty() ? '\0' : std::toupper((unsigned char)suffix[0]);
if (u == 'T') return static_cast<uint64_t>(val * 1024.0);
if (u == 'G') return static_cast<uint64_t>(val);
if (u == 'M') return static_cast<uint64_t>(val / 1024.0);
if (u == 'K') return static_cast<uint64_t>(val / (1024.0 * 1024.0));
// Assume raw bytes
return static_cast<uint64_t>(val / 1e9);
}
#endif // !__APPLE__
// Extract integer prefix from a string like "3600 MHz" → 3600
static uint64_t leading_uint(const std::string& s) {
size_t i = 0;
while (i < s.size() && std::isdigit((unsigned char)s[i])) ++i;
if (i == 0) return 0;
return std::stoull(s.substr(0, i));
}
#ifndef __APPLE__
// file-exists helper (Linux dhcp detection)
static bool file_exists(const std::string& path) {
struct stat st;
return stat(path.c_str(), &st) == 0;
}
#endif // !__APPLE__
// ── Minimal JSON helpers for lsblk / ip -j / system_profiler output ──────────
#ifndef __APPLE__
// Extract the value of a JSON string or scalar field by key.
// Returns "" for null/missing.
static std::string json_str(const std::string& json, const std::string& key) {
// Accept both "key":"v" and "key": "v"
for (auto sep : {std::string("\"") + key + "\":\"",
std::string("\"") + key + "\": \""}) {
size_t pos = json.find(sep);
if (pos == std::string::npos) continue;
pos += sep.size();
std::string result;
while (pos < json.size() && json[pos] != '"') {
if (json[pos] == '\\') {
++pos;
if (pos < json.size()) {
char c = json[pos];
if (c == 'n') result += '\n';
else if (c == 't') result += '\t';
else if (c == '\\') result += '\\';
else if (c == '"') result += '"';
else result += c;
}
} else {
result += json[pos];
}
++pos;
}
return result;
}
// Try null / numeric / bool (no quotes)
for (auto sep : {std::string("\"") + key + "\":",
std::string("\"") + key + "\": "}) {
size_t pos = json.find(sep);
if (pos == std::string::npos) continue;
pos += sep.size();
while (pos < json.size() && std::isspace((unsigned char)json[pos])) ++pos;
if (pos >= json.size() || json[pos] == '"') continue; // quoted handled above
if (json[pos] == 'n') return ""; // null
std::string result;
while (pos < json.size() && json[pos] != ',' && json[pos] != '}' &&
json[pos] != ']' && !std::isspace((unsigned char)json[pos]))
result += json[pos++];
return result;
}
return "";
}
// Split a JSON array of objects into individual object strings (outer array only).
static std::vector<std::string> json_array_objects(const std::string& json,
const std::string& array_key) {
std::string search = "\"" + array_key + "\":";
size_t pos = json.find(search);
if (pos == std::string::npos) {
search = "\"" + array_key + "\": ";
pos = json.find(search);
}
if (pos == std::string::npos) return {};
pos = json.find('[', pos);
if (pos == std::string::npos) return {};
++pos; // skip '['
std::vector<std::string> result;
int depth = 0;
size_t obj_start = std::string::npos;
bool in_string = false;
char prev = '\0';
for (size_t i = pos; i < json.size(); ++i) {
char c = json[i];
// Track string boundaries to avoid treating braces inside strings
if (c == '"' && prev != '\\') { in_string = !in_string; }
if (!in_string) {
if (c == '{') {
if (depth == 0) obj_start = i;
++depth;
} else if (c == '}') {
--depth;
if (depth == 0 && obj_start != std::string::npos) {
result.push_back(json.substr(obj_start, i - obj_start + 1));
obj_start = std::string::npos;
}
} else if (c == ']' && depth == 0) {
break;
}
}
prev = c;
}
return result;
}
// Extract all string values from a JSON array field (flat, one level).
static std::vector<std::string> json_array_strings(const std::string& json,
const std::string& key) {
std::string search = "\"" + key + "\":[";
size_t pos = json.find(search);
if (pos == std::string::npos) {
search = "\"" + key + "\": [";
pos = json.find(search);
}
if (pos == std::string::npos) return {};
pos = json.find('[', pos) + 1;
std::vector<std::string> result;
while (pos < json.size()) {
while (pos < json.size() && std::isspace((unsigned char)json[pos])) ++pos;
if (pos >= json.size() || json[pos] == ']') break;
if (json[pos] == '"') {
++pos;
std::string v;
while (pos < json.size() && json[pos] != '"') v += json[pos++];
if (pos < json.size()) ++pos; // skip closing '"'
result.push_back(v);
} else {
++pos; // skip other chars
}
}
return result;
}
#endif // !__APPLE__ (json_array_objects)
// Trim leading/trailing whitespace from a string.
static std::string trim(const std::string& s) {
size_t a = 0, b = s.size();
while (a < b && std::isspace((unsigned char)s[a])) ++a;
while (b > a && std::isspace((unsigned char)s[b-1])) --b;
return s.substr(a, b - a);
}
} // anonymous namespace
// ═════════════════════════════════════════════════════════════════════════════
// Public interface
// ═════════════════════════════════════════════════════════════════════════════
std::vector<DiscoveredPart> Discovery::discover_all() {
std::vector<DiscoveredPart> all;
for (auto& v : {discover_memory_sticks(), discover_memory_slots(),
discover_cpus(), discover_cpu_slots(),
discover_disks(), discover_nics(),
discover_gpus()}) {
all.insert(all.end(), v.begin(), v.end());
}
return all;
}
std::vector<DiscoveredPart> Discovery::discover(const std::string& type_name) {
if (type_name == PTYPE_MEMORY_STICK) return discover_memory_sticks();
if (type_name == PTYPE_MEMORY_SLOT) return discover_memory_slots();
if (type_name == PTYPE_CPU) return discover_cpus();
if (type_name == PTYPE_CPU_SLOT) return discover_cpu_slots();
if (type_name == PTYPE_DISK) return discover_disks();
if (type_name == PTYPE_NIC) return discover_nics();
if (type_name == PTYPE_GPU) return discover_gpus();
std::cerr << "discover: unknown type '" << type_name << "'\n";
return {};
}
// ═════════════════════════════════════════════════════════════════════════════
// Memory sticks
// ═════════════════════════════════════════════════════════════════════════════
std::vector<DiscoveredPart> Discovery::discover_memory_sticks() {
std::vector<DiscoveredPart> result;
#ifdef __APPLE__
// ── macOS: system_profiler SPMemoryDataType ───────────────────────────────
std::string out = run_cmd("system_profiler SPMemoryDataType 2>/dev/null");
if (out.empty()) {
std::cerr << "warning: system_profiler SPMemoryDataType returned no data\n";
return result;
}
// Each "BANK X/..." section is a slot; skip lines until we see "Size:"
// Parse simple indented key: value text
std::map<std::string, std::string> block;
std::string bank_name;
auto flush_block = [&]() {
if (block.empty()) return;
auto sit = block.find("Size");
// Skip empty/missing slots
if (sit == block.end() || sit->second == "Empty" || sit->second.empty()) {
block.clear(); return;
}
DiscoveredPart p;
p.type_name = PTYPE_MEMORY_STICK;
p.kv[K_SIZE_MB] = std::to_string(dmi_size_to_mb(sit->second));
if (block.count("Speed")) p.kv[K_SPEED_MHZ] = std::to_string(leading_uint(block["Speed"]));
if (block.count("Manufacturer")) p.kv[K_MANUFACTURER] = block["Manufacturer"];
if (block.count("Serial Number") && block["Serial Number"] != "-")
p.kv[K_SERIAL] = block["Serial Number"];
if (block.count("Type"))
p.kv[K_OTHER_INFO] = block["Type"];
// channel_config heuristic from bank name
if (!bank_name.empty()) {
std::string lname = bank_name;
std::transform(lname.begin(), lname.end(), lname.begin(), ::tolower);
if (lname.find("channel") != std::string::npos)
p.kv[K_CHANNEL_CONFIG] = "dual";
}
result.push_back(std::move(p));
block.clear();
};
std::istringstream ss(out);
std::string line;
while (std::getline(ss, line)) {
if (!line.empty() && line.back() == '\r') line.pop_back();
std::string t = trim(line);
if (t.empty()) continue;
// Bank header: e.g. " BANK 0/ChannelA-DIMM0:"
if (t.back() == ':' && t.find('/') != std::string::npos) {
flush_block();
bank_name = t.substr(0, t.size() - 1);
continue;
}
size_t colon = t.find(": ");
if (colon != std::string::npos)
block[t.substr(0, colon)] = t.substr(colon + 2);
}
flush_block();
#else
// ── Linux: dmidecode -t 17 ────────────────────────────────────────────────
auto blocks = parse_dmi("17");
if (blocks.empty()) {
std::cerr << "warning: dmidecode returned no Memory Device data "
"(may need root)\n";
return result;
}
for (auto& b : blocks) {
// Skip empty slots
if (!b.count("Size")) continue;
if (b["Size"] == "No Module Installed" || b["Size"].empty() ||
dmi_size_to_mb(b["Size"]) == 0) continue;
DiscoveredPart p;
p.type_name = PTYPE_MEMORY_STICK;
if (b.count("Serial Number") && b["Serial Number"] != "Not Specified" &&
b["Serial Number"] != "Unknown")
p.kv[K_SERIAL] = b["Serial Number"];
p.kv[K_SIZE_MB] = std::to_string(dmi_size_to_mb(b["Size"]));
if (b.count("Speed"))
p.kv[K_SPEED_MHZ] = std::to_string(leading_uint(b["Speed"]));
if (b.count("Manufacturer") && b["Manufacturer"] != "Not Specified")
p.kv[K_MANUFACTURER] = b["Manufacturer"];
// Channel config heuristic from Bank Locator
if (b.count("Bank Locator")) {
std::string bl = b["Bank Locator"];
std::transform(bl.begin(), bl.end(), bl.begin(), ::tolower);
if (bl.find("channel") != std::string::npos)
p.kv[K_CHANNEL_CONFIG] = "dual";
}
// other_info: Type + Form Factor
std::string other;
if (b.count("Type") && b["Type"] != "Unknown") other += b["Type"];
if (b.count("Form Factor") && b["Form Factor"] != "Unknown") {
if (!other.empty()) other += " ";
other += b["Form Factor"];
}
if (!other.empty()) p.kv[K_OTHER_INFO] = other;
if (b.count("Locator") && !b["Locator"].empty())
p.kv[K_LOCATOR] = b["Locator"];
// Extended memory fields
if (b.count("Type") && b["Type"] != "Unknown" && b["Type"] != "Not Specified")
p.kv[K_MEM_TYPE] = b["Type"];
if (b.count("Form Factor") && b["Form Factor"] != "Unknown" && b["Form Factor"] != "Not Specified")
p.kv[K_FORM_FACTOR] = b["Form Factor"];
if (b.count("Part Number")) {
std::string pn = trim(b["Part Number"]);
if (!pn.empty() && pn != "Unknown" && pn != "Not Specified")
p.kv[K_PART_NUMBER] = pn;
}
if (b.count("Rank")) {
uint32_t rank = static_cast<uint32_t>(leading_uint(b["Rank"]));
if (rank > 0) p.kv[K_RANK] = std::to_string(rank);
}
uint32_t data_width = 0;
if (b.count("Data Width")) {
data_width = static_cast<uint32_t>(leading_uint(b["Data Width"]));
if (data_width > 0) p.kv[K_DATA_WIDTH] = std::to_string(data_width);
}
// Compute theoretical bandwidth: speed_mhz × 2 × (data_width / 8) MB/s
if (data_width > 0 && p.kv.count(K_SPEED_MHZ)) {
uint64_t spd = std::stoull(p.kv[K_SPEED_MHZ]);
if (spd > 0) {
uint64_t bw = spd * 2ULL * (data_width / 8);
p.kv[K_BANDWIDTH_MBPS] = std::to_string(bw);
}
}
result.push_back(std::move(p));
}
#endif
return result;
}
// ═════════════════════════════════════════════════════════════════════════════
// Memory slots
// ═════════════════════════════════════════════════════════════════════════════
std::vector<DiscoveredPart> Discovery::discover_memory_slots() {
std::vector<DiscoveredPart> result;
#ifdef __APPLE__
// On macOS, use system_profiler to infer slot count from bank info
std::string out = run_cmd("system_profiler SPMemoryDataType 2>/dev/null");
if (out.empty()) return result;
uint64_t allowed_speed = 0;
int slot_count = 0;
std::istringstream ss(out);
std::string line;
while (std::getline(ss, line)) {
std::string t = trim(line);
if (t.back() == ':' && t.find('/') != std::string::npos) {
++slot_count;
}
if (t.rfind("Speed:", 0) == 0 && allowed_speed == 0) {
allowed_speed = leading_uint(t.substr(7));
}
}
for (int s = 0; s < slot_count; ++s) {
DiscoveredPart p;
p.type_name = PTYPE_MEMORY_SLOT;
if (allowed_speed) p.kv[K_ALLOWED_SPEED] = std::to_string(allowed_speed);
p.kv[K_INSTALLED_STICK] = "NULL";
result.push_back(std::move(p));
}
#else
// ── Linux: dmidecode -t 17 (all slots) + -t 16 for max capacity ───────────
auto blocks17 = parse_dmi("17");
if (blocks17.empty()) {
std::cerr << "warning: dmidecode returned no Memory Device data "
"(may need root)\n";
return result;
}
// Get max allowed size from dmidecode -t 16 (Physical Memory Array)
uint64_t max_size_mb = 0;
{
auto blocks16 = parse_dmi("16");
for (auto& b : blocks16) {
if (b.count("Maximum Capacity")) {
uint64_t mc = dmi_size_to_mb(b["Maximum Capacity"]);
if (mc > max_size_mb) max_size_mb = mc;
}
}
}
for (auto& b : blocks17) {
// Skip blocks without a Locator — these are malformed/phantom HP firmware entries
if (!b.count("Locator") || b["Locator"].empty()) continue;
DiscoveredPart p;
p.type_name = PTYPE_MEMORY_SLOT;
// Allowed speed
uint64_t spd = 0;
if (b.count("Configured Memory Speed"))
spd = leading_uint(b["Configured Memory Speed"]);
if (!spd && b.count("Speed"))
spd = leading_uint(b["Speed"]);
if (spd) p.kv[K_ALLOWED_SPEED] = std::to_string(spd);
// Allowed size (per-slot approximation: total max / slot count)
if (max_size_mb > 0 && !blocks17.empty())
p.kv[K_ALLOWED_SIZE] = std::to_string(
max_size_mb / static_cast<uint64_t>(blocks17.size()));
p.kv[K_INSTALLED_STICK] = "NULL";
if (b.count("Locator") && !b["Locator"].empty())
p.kv[K_LOCATOR] = b["Locator"];
result.push_back(std::move(p));
}
#endif
return result;
}
// ═════════════════════════════════════════════════════════════════════════════
// CPUs
// ═════════════════════════════════════════════════════════════════════════════
std::vector<DiscoveredPart> Discovery::discover_cpus() {
std::vector<DiscoveredPart> result;
#ifdef __APPLE__
std::string brand = run_cmd("sysctl -n machdep.cpu.brand_string 2>/dev/null");
std::string phys = run_cmd("sysctl -n hw.physicalcpu 2>/dev/null");
std::string logi = run_cmd("sysctl -n hw.logicalcpu 2>/dev/null");
std::string freq = run_cmd("sysctl -n hw.cpufrequency 2>/dev/null");
if (brand.empty()) {
std::cerr << "warning: sysctl cpu info unavailable\n";
return result;
}
DiscoveredPart p;
p.type_name = PTYPE_CPU;
p.kv[K_NAME] = brand;
// Try to extract speed from brand string e.g. "@ 2.60GHz"
double speed_ghz = 0.0;
auto at_pos = brand.find(" @ ");
if (at_pos != std::string::npos) {
std::string sp = brand.substr(at_pos + 3);
try { speed_ghz = std::stod(sp); } catch (...) {}
}
if (speed_ghz == 0.0 && !freq.empty()) {
try { speed_ghz = std::stoull(freq) / 1e9; } catch (...) {}
}
if (speed_ghz > 0.0) {
char buf[32];
snprintf(buf, sizeof(buf), "%.2f", speed_ghz);
p.kv[K_SPEED_GHZ] = buf;
}
if (!phys.empty()) p.kv[K_CORES] = phys;
if (!logi.empty()) p.kv[K_THREADS] = logi;
// Manufacturer heuristic
std::string lower_brand = brand;
std::transform(lower_brand.begin(), lower_brand.end(), lower_brand.begin(), ::tolower);
if (lower_brand.find("intel") != std::string::npos) p.kv[K_MANUFACTURER] = "Intel";
else if (lower_brand.find("amd") != std::string::npos) p.kv[K_MANUFACTURER] = "AMD";
else if (lower_brand.find("apple") != std::string::npos) p.kv[K_MANUFACTURER] = "Apple";
result.push_back(std::move(p));
#else
// ── Linux: dmidecode -t 4 ─────────────────────────────────────────────────
auto blocks = parse_dmi("4");
if (blocks.empty()) {
std::cerr << "warning: dmidecode returned no Processor data "
"(may need root)\n";
return result;
}
for (auto& b : blocks) {
// Skip phantom entries (iLO processor, "Other" type, absent Status)
if (!b.count("Status") || b["Status"].find("Populated") == std::string::npos) continue;
if (b.count("Type") && b["Type"].find("Central Processor") == std::string::npos) continue;
DiscoveredPart p;
p.type_name = PTYPE_CPU;
// Do NOT use dmidecode "ID" (CPUID family/model/stepping) as serial —
// it is identical across matching CPUs in multi-socket systems.
// Socket designation is the actual unique natural key.
if (b.count("Version") && b["Version"] != "Not Specified")
p.kv[K_NAME] = b["Version"];
if (b.count("Manufacturer") && b["Manufacturer"] != "Not Specified")
p.kv[K_MANUFACTURER] = b["Manufacturer"];
if (b.count("Current Speed")) {
double mhz = static_cast<double>(leading_uint(b["Current Speed"]));
if (mhz > 0) {
char buf[32];
snprintf(buf, sizeof(buf), "%.3f", mhz / 1000.0);
p.kv[K_SPEED_GHZ] = buf;
}
}
if (b.count("Core Count")) p.kv[K_CORES] = b["Core Count"];
if (b.count("Thread Count")) p.kv[K_THREADS] = b["Thread Count"];
if (b.count("Socket Designation") && b["Socket Designation"] != "Not Specified")
p.kv[K_SOCKET] = b["Socket Designation"];
// Extended CPU fields from dmidecode type 4
if (b.count("Max Speed")) {
double max_mhz = static_cast<double>(leading_uint(b["Max Speed"]));
if (max_mhz > 0) {
char buf[32];
snprintf(buf, sizeof(buf), "%.3f", max_mhz / 1000.0);
p.kv[K_MAX_SPEED_GHZ] = buf;
}
}
if (b.count("External Clock")) {
uint64_t bus_mhz = leading_uint(b["External Clock"]);
if (bus_mhz > 0) p.kv[K_BUS_MHZ] = std::to_string(bus_mhz);
}
if (b.count("Voltage")) {
std::string vs = b["Voltage"];
// trim " V" suffix
while (!vs.empty() && (vs.back() == 'V' || vs.back() == ' ')) vs.pop_back();
if (!vs.empty()) {
try {
double v = std::stod(vs);
char buf[32];
snprintf(buf, sizeof(buf), "%.1f", v);
p.kv[K_VOLTAGE_V] = buf;
} catch (...) {}
}
}
if (b.count("Upgrade") && b["Upgrade"] != "Unknown" && b["Upgrade"] != "Other") {
p.kv[K_SOCKET_TYPE] = b["Upgrade"];
}
result.push_back(std::move(p));
}
// Enrich CPUs with L1/L2/L3 cache sizes from sysfs
// Build a map: physical_package_id → first cpu index
{
std::map<int, int> pkg_to_first_cpu;
for (int ci = 0; ; ++ci) {
std::string pkg_path = "/sys/devices/system/cpu/cpu"
+ std::to_string(ci) + "/topology/physical_package_id";
FILE* pf = fopen(pkg_path.c_str(), "r");
if (!pf) break;
int pkg = -1;
fscanf(pf, "%d", &pkg);
fclose(pf);
if (pkg >= 0 && pkg_to_first_cpu.find(pkg) == pkg_to_first_cpu.end())
pkg_to_first_cpu[pkg] = ci;
}
// For each CPU entry in result (in order), pick socket 0, 1, 2 ...
int socket_idx = 0;
for (auto& p : result) {
if (p.type_name != PTYPE_CPU) { ++socket_idx; continue; }
auto it = pkg_to_first_cpu.find(socket_idx);
int cpu_idx = (it != pkg_to_first_cpu.end()) ? it->second : socket_idx;
// Walk cache indices for this cpu
uint32_t l1_kb = 0, l2_kb = 0, l3_kb = 0;
for (int idx = 0; idx < 8; ++idx) {
std::string base = "/sys/devices/system/cpu/cpu"
+ std::to_string(cpu_idx) + "/cache/index"
+ std::to_string(idx);
// Read level
int level = 0;
{
FILE* lf = fopen((base + "/level").c_str(), "r");
if (!lf) break;
fscanf(lf, "%d", &level);
fclose(lf);
}
// Read type
std::string cache_type;
{
FILE* tf = fopen((base + "/type").c_str(), "r");
if (tf) {
char tbuf[32] = {};
fscanf(tf, "%31s", tbuf);
fclose(tf);
cache_type = tbuf;
}
}
// Read size
std::string size_str;
{
FILE* sf = fopen((base + "/size").c_str(), "r");
if (sf) {
char sbuf[32] = {};
fscanf(sf, "%31s", sbuf);
fclose(sf);
size_str = sbuf;
}
}
if (size_str.empty()) continue;
// Parse size: "32K", "256K", "9216K", "6144K"
uint32_t kb = 0;
{
size_t ni = 0;
while (ni < size_str.size() && std::isdigit((unsigned char)size_str[ni])) ++ni;
if (ni > 0) {
kb = static_cast<uint32_t>(std::stoul(size_str.substr(0, ni)));
std::string unit = size_str.substr(ni);
if (!unit.empty() && (unit[0] == 'M' || unit[0] == 'm')) kb *= 1024;
}
}
if (kb == 0) continue;
if (level == 1 && cache_type == "Data") l1_kb += kb;
else if (level == 1 && cache_type == "Instruction") {} // skip icache
else if (level == 1 && cache_type == "Unified") l1_kb += kb;
else if (level == 2) l2_kb += kb;
else if (level == 3) l3_kb += kb;
}
if (l1_kb > 0) p.kv[K_CACHE_L1_KB] = std::to_string(l1_kb);
if (l2_kb > 0) p.kv[K_CACHE_L2_KB] = std::to_string(l2_kb);
if (l3_kb > 0) p.kv[K_CACHE_L3_KB] = std::to_string(l3_kb);
++socket_idx;
}
}
// /proc/cpuinfo fallback if dmidecode gave nothing
if (result.empty()) {
std::string cpuinfo = run_cmd("cat /proc/cpuinfo 2>/dev/null");
if (!cpuinfo.empty()) {
// Group by physical id
std::map<int, std::map<std::string,std::string>> by_phys;
std::map<std::string,std::string> cur_block;
int cur_phys = -1;
std::istringstream cs(cpuinfo);
std::string cl;
auto flush_cpuinfo = [&]() {
if (cur_phys < 0) return;
auto& target = by_phys[cur_phys];
for (auto& [k, v] : cur_block) {
if (target.find(k) == target.end())
target[k] = v;
}
};
while (std::getline(cs, cl)) {
if (cl.empty()) continue;
size_t colon = cl.find(':');
if (colon == std::string::npos) continue;
std::string key = trim(cl.substr(0, colon));
std::string val = trim(cl.substr(colon + 1));
if (key == "physical id") {
int phys = std::stoi(val);
if (phys != cur_phys) {
flush_cpuinfo();
cur_phys = phys;
cur_block.clear();
}
} else {
cur_block[key] = val;
}
}
flush_cpuinfo();
for (auto& [phys_id, fields] : by_phys) {
DiscoveredPart p;
p.type_name = PTYPE_CPU;
if (fields.count("model name")) p.kv[K_NAME] = fields["model name"];
if (fields.count("cpu MHz")) {
try {
double mhz = std::stod(fields["cpu MHz"]);
char buf[32];
snprintf(buf, sizeof(buf), "%.3f", mhz / 1000.0);
p.kv[K_SPEED_GHZ] = buf;
} catch (...) {}
}
if (fields.count("cpu cores")) p.kv[K_CORES] = fields["cpu cores"];
if (fields.count("siblings")) p.kv[K_THREADS] = fields["siblings"];
if (fields.count("cache size")) {
// "9216 KB"
uint32_t kb = static_cast<uint32_t>(leading_uint(fields["cache size"]));
if (kb > 0) p.kv[K_CACHE_L3_KB] = std::to_string(kb);
}
// Manufacturer from model name heuristic
if (fields.count("model name")) {
std::string lower = fields["model name"];
std::transform(lower.begin(), lower.end(), lower.begin(), ::tolower);
if (lower.find("intel") != std::string::npos) p.kv[K_MANUFACTURER] = "Intel";
else if (lower.find("amd") != std::string::npos) p.kv[K_MANUFACTURER] = "AMD";
}
// Use "Proc <N>" style socket designation based on physical id order
p.kv[K_SOCKET] = "Proc " + std::to_string(phys_id + 1);
result.push_back(std::move(p));
}
}
}
#endif
return result;
}
// ═════════════════════════════════════════════════════════════════════════════
// CPU slots
// ═════════════════════════════════════════════════════════════════════════════
std::vector<DiscoveredPart> Discovery::discover_cpu_slots() {
std::vector<DiscoveredPart> result;
#ifdef __APPLE__
// On macOS we can only infer 1 slot from sysctl
if (run_cmd("sysctl -n machdep.cpu.brand_string 2>/dev/null").empty())
return result;
DiscoveredPart p;
p.type_name = PTYPE_CPU_SLOT;
p.kv[K_FORM_FACTOR] = "unknown";
p.kv[K_INSTALLED_CPU] = "NULL";
result.push_back(std::move(p));
#else
auto blocks = parse_dmi("4");
if (blocks.empty()) {
std::cerr << "warning: dmidecode returned no Processor data "
"(may need root)\n";
return result;
}
for (auto& b : blocks) {
// Only real CPU sockets: must have a recognizable Status (Populated or Unpopulated)
// and must be of type Central Processor
if (!b.count("Status")) continue;
const std::string& status = b["Status"];
bool is_populated = status.find("Populated") != std::string::npos;
bool is_unpopulated = status.find("Unpopulated") != std::string::npos;
if (!is_populated && !is_unpopulated) continue; // Skip "Other", empty, or phantom entries
if (b.count("Type") && b["Type"].find("Central Processor") == std::string::npos) continue;
DiscoveredPart p;
p.type_name = PTYPE_CPU_SLOT;
if (b.count("Upgrade")) p.kv[K_FORM_FACTOR] = b["Upgrade"];
else p.kv[K_FORM_FACTOR] = "unknown";
p.kv[K_INSTALLED_CPU] = "NULL";
if (b.count("Socket Designation") && b["Socket Designation"] != "Not Specified")
p.kv[K_SOCKET] = b["Socket Designation"];
result.push_back(std::move(p));
}
#endif
return result;
}
// ═════════════════════════════════════════════════════════════════════════════
// Disks
// ═════════════════════════════════════════════════════════════════════════════
std::vector<DiscoveredPart> Discovery::discover_disks() {
std::vector<DiscoveredPart> result;
#ifdef __APPLE__
// ── macOS: diskutil info for each disk ────────────────────────────────────
// Enumerate whole disks via XML plist from diskutil list -plist
std::vector<std::string> all_disks;
{
std::string plist_out = run_cmd("diskutil list -plist 2>/dev/null");
if (!plist_out.empty()) {
// Parse <key>WholeDisks</key><array><string>disk0</string>...</array>
size_t key_pos = plist_out.find("<key>WholeDisks</key>");
if (key_pos != std::string::npos) {
size_t arr_start = plist_out.find("<array>", key_pos);
size_t arr_end = plist_out.find("</array>", key_pos);
if (arr_start != std::string::npos && arr_end != std::string::npos) {
std::string arr_content = plist_out.substr(arr_start, arr_end - arr_start);
size_t p = 0;
while ((p = arr_content.find("<string>", p)) != std::string::npos) {
p += 8;
size_t e = arr_content.find("</string>", p);
if (e == std::string::npos) break;
all_disks.push_back(arr_content.substr(p, e - p));
p = e + 9;
}
}
}
}
if (all_disks.empty()) {
// fall back: text diskutil list output
std::string txt = run_cmd("diskutil list 2>/dev/null");
std::istringstream ss(txt);
std::string line;
while (std::getline(ss, line)) {
std::string t = trim(line);
if (t.rfind("/dev/disk", 0) == 0) {
size_t sp = t.find(' ');
std::string dev = (sp != std::string::npos) ? t.substr(5, sp - 5) : t.substr(5);
if (dev.find('s') == std::string::npos) all_disks.push_back(dev);
}
}
}
if (all_disks.empty()) {
std::cerr << "warning: could not enumerate disks via diskutil\n";
return result;
}
}
for (auto& disk_name : all_disks) {
std::string info = run_cmd("diskutil info -plist /dev/" + disk_name + " 2>/dev/null");
if (info.empty()) continue;
// Parse plist-style XML (very simplified)
// Extract key/value pairs from <key>K</key><string>V</string>
std::map<std::string, std::string> plist;
std::istringstream ss(info);
std::string line;
std::string cur_key;
while (std::getline(ss, line)) {
std::string t = trim(line);
auto tag_val = [&](const std::string& tag) -> std::string {
std::string open = "<" + tag + ">";
std::string close = "</" + tag + ">";
size_t p0 = t.find(open);
if (p0 == std::string::npos) return "";
p0 += open.size();
size_t p1 = t.find(close, p0);
if (p1 == std::string::npos) return "";
return t.substr(p0, p1 - p0);
};
std::string k = tag_val("key");
if (!k.empty()) { cur_key = k; continue; }
if (!cur_key.empty()) {
for (auto& vtag : {"string", "integer", "real"}) {
std::string v = tag_val(vtag);
if (!v.empty()) { plist[cur_key] = v; cur_key = ""; break; }
}
if (t == "<true/>") { plist[cur_key] = "true"; cur_key = ""; }
if (t == "<false/>") { plist[cur_key] = "false"; cur_key = ""; }
}
}
// Skip virtual disks
auto ct = plist.count("VirtualOrPhysical") ? plist["VirtualOrPhysical"] : "";
if (ct == "Virtual") continue;
DiscoveredPart p;
p.type_name = PTYPE_DISK;
if (plist.count("MediaName")) p.kv[K_MODEL] = plist["MediaName"];
if (plist.count("IORegistryEntryName") && !p.kv.count(K_MODEL))
p.kv[K_MODEL] = plist["IORegistryEntryName"];
// Size
if (plist.count("TotalSize"))
p.kv[K_DISK_SIZE] = std::to_string(
static_cast<uint64_t>(std::stoull(plist["TotalSize"]) / 1000000000ULL));
// Type
bool solid = false;
if (plist.count("SolidState")) solid = (plist["SolidState"] == "true");
std::string protocol = plist.count("BusProtocol") ? plist["BusProtocol"] : "";
std::string conn = protocol;
std::string dtype;
std::string lower_proto = protocol;
std::transform(lower_proto.begin(), lower_proto.end(), lower_proto.begin(), ::tolower);
if (lower_proto.find("nvme") != std::string::npos ||
lower_proto.find("pcie") != std::string::npos) {
dtype = "nvme"; conn = "NVMe";
} else if (solid) {
dtype = "ssd";
} else {
dtype = "hdd";
}
if (!dtype.empty()) p.kv[K_DISK_TYPE] = dtype;
if (!conn.empty()) p.kv[K_CONN_TYPE] = conn;
// Speed heuristic
uint64_t conn_speed = 0;
if (lower_proto.find("nvme") != std::string::npos) conn_speed = 3500;
else if (lower_proto.find("sata") != std::string::npos) conn_speed = 600;
else if (lower_proto.find("usb") != std::string::npos) conn_speed = 480;
if (conn_speed) p.kv[K_CONN_SPEED] = std::to_string(conn_speed);
p.kv[K_DISK_SPEED] = "0";
p.kv[K_AGE_YEARS] = "-1";
// Partitions — use diskutil list <disk> text output for partition names
std::string parts_txt = run_cmd("diskutil list /dev/" + disk_name + " 2>/dev/null");
std::vector<std::string> part_ids, part_sizes;
{
std::istringstream ps(parts_txt);
std::string pline;
while (std::getline(ps, pline)) {
std::string pt = trim(pline);
// lines containing "disk0s1" etc.
if (pt.find(disk_name + "s") != std::string::npos) {
// Extract partition dev name (last token-ish)
std::istringstream ts(pt);
std::string tok, last_tok;
while (ts >> tok) last_tok = tok;
if (!last_tok.empty() && last_tok.find(disk_name) != std::string::npos)
part_ids.push_back(last_tok);
}
}
}
if (!part_ids.empty()) {
std::string pid_str, psz_str;
for (size_t ki = 0; ki < part_ids.size(); ++ki) {
if (ki) { pid_str += LS; psz_str += LS; }
pid_str += part_ids[ki];
psz_str += "0";
}
p.kv[K_PARTITION_IDS] = pid_str;
p.kv[K_PARTITION_SIZES] = psz_str;
}
p.kv[K_VM_HOSTNAMES] = "";
p.kv[K_VM_SERVER_IDS] = "";
result.push_back(std::move(p));
}
#else
// ── Linux: lsblk -J ──────────────────────────────────────────────────────
std::string out = run_cmd(
"lsblk -J -o NAME,SIZE,TYPE,ROTA,SERIAL,MODEL,VENDOR,TRAN,"
"LOG-SEC,PHY-SEC,MOUNTPOINTS 2>/dev/null");
if (out.empty()) {
std::cerr << "warning: lsblk returned no data\n";
return result;
}
auto devices = json_array_objects(out, "blockdevices");
for (auto& dev : devices) {
// Only top-level disks
std::string type = json_str(dev, "type");
if (type != "disk") continue;
std::string dev_name = json_str(dev, "name");
DiscoveredPart p;
p.type_name = PTYPE_DISK;
std::string serial = json_str(dev, "serial");
std::string model = trim(json_str(dev, "model"));
std::string vendor = trim(json_str(dev, "vendor"));
std::string rota = json_str(dev, "rota");
std::string tran = json_str(dev, "tran");
std::string size_str = json_str(dev, "size");
if (!serial.empty() && serial != "null") p.kv[K_SERIAL] = serial;
if (!model.empty()) p.kv[K_MODEL] = model;
if (!vendor.empty()) p.kv[K_MANUFACTURER] = vendor;
// disk_type
std::string lower_tran = tran;
std::transform(lower_tran.begin(), lower_tran.end(), lower_tran.begin(), ::tolower);
std::string dtype;
if (lower_tran.find("nvme") != std::string::npos) dtype = "nvme";
else if (rota == "0") dtype = "ssd";
else dtype = "hdd";
p.kv[K_DISK_TYPE] = dtype;
p.kv[K_CONN_TYPE] = tran.empty() ? "unknown" : tran;
// connection speed heuristic
uint64_t conn_speed = 0;
if (lower_tran.find("nvme") != std::string::npos) conn_speed = 3500;
else if (lower_tran.find("sata") != std::string::npos) conn_speed = 600;
else if (lower_tran.find("usb") != std::string::npos) conn_speed = 480;
else if (lower_tran.find("sas") != std::string::npos) conn_speed = 1200;
p.kv[K_CONN_SPEED] = std::to_string(conn_speed);
p.kv[K_DISK_SPEED] = "0";
p.kv[K_DISK_SIZE] = std::to_string(size_to_gb(size_str));
p.kv[K_AGE_YEARS] = "-1";
// Partitions from "children" array
auto children = json_array_objects(dev, "children");
std::vector<std::string> part_ids, part_sizes;
for (auto& child : children) {
if (json_str(child, "type") == "part") {
part_ids.push_back(json_str(child, "name"));
part_sizes.push_back(std::to_string(size_to_gb(json_str(child, "size"))));
}
}
if (!part_ids.empty()) {
std::string pid_str, psz_str;
for (size_t ki = 0; ki < part_ids.size(); ++ki) {
if (ki) { pid_str += LS; psz_str += LS; }
pid_str += part_ids[ki];
psz_str += part_sizes[ki];
}
p.kv[K_PARTITION_IDS] = pid_str;
p.kv[K_PARTITION_SIZES] = psz_str;
}
p.kv[K_VM_HOSTNAMES] = "";
p.kv[K_VM_SERVER_IDS] = "";
// Handle serial collisions: if another disk in result already has the same serial,
// differentiate both by appending the device name to the serial.
if (!dev_name.empty()) {
p.kv[K_DEVICE_NAME] = dev_name;
auto ser_it = p.kv.find(K_SERIAL);
if (ser_it != p.kv.end()) {
for (auto& prev : result) {
auto prev_ser = prev.kv.find(K_SERIAL);
if (prev_ser != prev.kv.end() && prev_ser->second == ser_it->second) {
// Collision: suffix both with their device names
auto prev_dev = prev.kv.find(K_DEVICE_NAME);
std::string prev_name = (prev_dev != prev.kv.end()) ? prev_dev->second : "";
if (!prev_name.empty())
prev_ser->second += ":" + prev_name;
ser_it->second += ":" + dev_name;
break;
}
}
}
}
result.push_back(std::move(p));
}
#endif
return result;
}
// ═════════════════════════════════════════════════════════════════════════════
std::vector<DiscoveredPart> Discovery::discover_nics() {
std::vector<DiscoveredPart> result;
#ifdef __APPLE__
// ── macOS: networksetup + ifconfig ────────────────────────────────────────
// networksetup -listallhardwareports gives:
// Hardware Port: Ethernet
// Device: en0
// Ethernet Address: xx:xx:xx:xx:xx:xx
std::string ns_out = run_cmd("networksetup -listallhardwareports 2>/dev/null");
if (ns_out.empty()) {
std::cerr << "warning: networksetup not available\n";
return result;
}
struct NsPort { std::string port, device, mac; };
std::vector<NsPort> ports;
{
NsPort cur;
std::istringstream ss(ns_out);
std::string line;
while (std::getline(ss, line)) {
std::string t = trim(line);
auto extract = [&](const std::string& prefix) -> std::string {
if (t.rfind(prefix, 0) == 0) return trim(t.substr(prefix.size()));
return "";
};
auto pv = extract("Hardware Port: ");
if (!pv.empty()) { if (!cur.device.empty()) ports.push_back(cur); cur = {pv, "", ""}; continue; }
auto dv = extract("Device: ");
if (!dv.empty()) { cur.device = dv; continue; }
auto mv = extract("Ethernet Address: ");
if (!mv.empty()) { cur.mac = mv; continue; }
}
if (!cur.device.empty()) ports.push_back(cur);
}
for (auto& pt : ports) {
if (pt.device.empty()) continue;
// Skip loopback
if (pt.device == "lo0" || pt.device.rfind("lo", 0) == 0) continue;
DiscoveredPart p;
p.type_name = PTYPE_NIC;
p.kv[K_MAC] = pt.mac;
p.kv[K_MODEL] = pt.port;
// connection type
std::string lower_port = pt.port;
std::transform(lower_port.begin(), lower_port.end(), lower_port.begin(), ::tolower);
if (lower_port.find("wi-fi") != std::string::npos ||
lower_port.find("wifi") != std::string::npos ||
lower_port.find("wlan") != std::string::npos ||
lower_port.find("airport") != std::string::npos)
p.kv[K_CONN_TYPE] = "wifi";
else
p.kv[K_CONN_TYPE] = "ethernet";
// IPs via ifconfig
std::string ic_out = run_cmd("ifconfig " + pt.device + " 2>/dev/null");
std::vector<std::string> ips;
{
std::istringstream ss(ic_out);
std::string line;
while (std::getline(ss, line)) {
std::string t = trim(line);
if (t.rfind("inet ", 0) == 0 || t.rfind("inet6 ", 0) == 0) {
bool v6 = t[4] == '6';
std::string rest = t.substr(v6 ? 6 : 5);
// Extract addr and prefix/netmask
std::istringstream ts(rest);
std::string addr;
ts >> addr;
// Skip link-local for IPv6 unless it's the only address
if (v6 && addr.rfind("fe80", 0) == 0) continue;
std::string prefix_str;
std::string tok;
while (ts >> tok) {
if (tok == "prefixlen" || tok == "netmask") {
ts >> prefix_str;
break;
}
}
if (!prefix_str.empty() && prefix_str.rfind("0x", 0) == 0) {
// Convert hex netmask to prefix length
uint32_t mask = static_cast<uint32_t>(std::stoul(prefix_str, nullptr, 16));
int bits = 0;
while (mask) { bits += (mask & 1); mask >>= 1; }
prefix_str = std::to_string(bits);
}
std::string entry = addr;
if (!prefix_str.empty()) entry += "/" + prefix_str;
ips.push_back(entry);
}
}
}
if (!ips.empty()) {
std::string ips_str;
for (size_t ki = 0; ki < ips.size(); ++ki) {
if (ki) ips_str += LS;
ips_str += ips[ki];
}
p.kv[K_IPS] = ips_str;
}
p.kv[K_CONN_SPEED] = "0";
p.kv[K_AGE_YEARS] = "-1";
p.kv[K_DHCP] = "false";
result.push_back(std::move(p));
}
#else
// ── Linux: ip -j link show + ip -j addr show ──────────────────────────────
std::string link_out = run_cmd("ip -j link show 2>/dev/null");
if (link_out.empty()) {
std::cerr << "warning: ip -j link show failed\n";
return result;
}
auto ifaces = json_array_objects(link_out, ""); // top-level array
// ip -j link show returns a JSON array at the root level, not under a key
// Re-parse: split the root array
{
// json_array_objects looks for a named key; for root arrays we parse directly
ifaces.clear();
int depth = 0;
size_t obj_start = std::string::npos;
bool in_string = false;
char prev = '\0';
for (size_t i = 0; i < link_out.size(); ++i) {
char c = link_out[i];
if (c == '"' && prev != '\\') in_string = !in_string;
if (!in_string) {
if (c == '{') { if (depth == 0) obj_start = i; ++depth; }
else if (c == '}') {
--depth;
if (depth == 0 && obj_start != std::string::npos) {
ifaces.push_back(link_out.substr(obj_start, i - obj_start + 1));
obj_start = std::string::npos;
}
}
}
prev = c;
}
}
for (auto& iface : ifaces) {
std::string ifname = json_str(iface, "ifname");
std::string mac = json_str(iface, "address");
std::string link_type= json_str(iface, "link_type");
std::string flags_str= json_str(iface, "flags"); // may be array
if (ifname.empty()) continue;
// Only include physical NICs: those with a /device symlink pointing to a PCI entry.
// Virtual interfaces (bridges, veth, flannel, fwbr, vmbr, tap, etc.) have no device symlink.
{
std::string dev_path = "/sys/class/net/" + ifname + "/device";
struct stat st;
if (lstat(dev_path.c_str(), &st) != 0 || !S_ISLNK(st.st_mode)) continue;
}
// Enrich with PCI device description via lspci
std::string pci_model;
std::string pci_vendor;
{
std::string link = run_cmd("readlink /sys/class/net/" + ifname + "/device 2>/dev/null");
// link is like "../../../0000:01:00.0" — extract last path component
size_t last_slash = link.rfind('/');
if (last_slash != std::string::npos) link = link.substr(last_slash + 1);
// trim whitespace
while (!link.empty() && std::isspace((unsigned char)link.back())) link.pop_back();
if (!link.empty()) {
std::string lspci_out = run_cmd("lspci -s " + link + " 2>/dev/null");
// Format: "0000:01:00.0 Ethernet controller: Broadcom Corporation NetXtreme II..."
// Find first ": " after the address
size_t colon = lspci_out.find(": ");
if (colon != std::string::npos) {
std::string desc = lspci_out.substr(colon + 2);
while (!desc.empty() && std::isspace((unsigned char)desc.back())) desc.pop_back();
// Try to split "VendorName Description" — look for another ": " for sub-vendor
size_t sub = desc.find(": ");
if (sub != std::string::npos) {
pci_vendor = desc.substr(0, sub);
pci_model = desc.substr(sub + 2);
} else {
pci_model = desc;
}
}
}
}
DiscoveredPart p;
p.type_name = PTYPE_NIC;
p.kv[K_NAME] = ifname;
p.kv[K_MAC] = mac;
p.kv[K_MODEL] = pci_model.empty() ? link_type : pci_model;
if (!pci_vendor.empty()) p.kv[K_MANUFACTURER] = pci_vendor;
// connection type
std::string lower_ifname = ifname;
std::transform(lower_ifname.begin(), lower_ifname.end(),
lower_ifname.begin(), ::tolower);
std::string conn_type = "ethernet";
if (lower_ifname.find("wl") != std::string::npos ||
lower_ifname.find("wifi") != std::string::npos)
conn_type = "wifi";
p.kv[K_CONN_TYPE] = conn_type;
// IPs
std::string addr_out = run_cmd("ip -j addr show " + ifname + " 2>/dev/null");
std::vector<std::string> ips;
if (!addr_out.empty()) {
// find addr_info array inside the single interface object
std::string search = "\"addr_info\":";
size_t ai_pos = addr_out.find(search);
if (ai_pos != std::string::npos) {
std::string addr_slice = addr_out.substr(ai_pos);
auto addr_objs = json_array_objects(addr_slice, "addr_info");
for (auto& ao : addr_objs) {
std::string local = json_str(ao, "local");
std::string prefixlen = json_str(ao, "prefixlen");
if (local.empty()) continue;
// skip link-local IPv6
std::string ll = local;
std::transform(ll.begin(), ll.end(), ll.begin(), ::tolower);
if (ll.rfind("fe80", 0) == 0) continue;
std::string entry = local;
if (!prefixlen.empty()) entry += "/" + prefixlen;
ips.push_back(entry);
}
}
}
if (!ips.empty()) {
std::string ips_str;
for (size_t ki = 0; ki < ips.size(); ++ki) {
if (ki) ips_str += LS;
ips_str += ips[ki];
}
p.kv[K_IPS] = ips_str;
}
// DHCP detection
bool dhcp = file_exists("/run/dhclient." + ifname + ".pid") ||
file_exists("/run/dhcp-lease-" + ifname) ||
file_exists("/var/lib/dhcp/dhclient." + ifname + ".leases");
p.kv[K_DHCP] = dhcp ? "true" : "false";
// Link speed via sysfs
uint64_t speed = 0;
{
std::string speed_path = "/sys/class/net/" + ifname + "/speed";
FILE* sf = fopen(speed_path.c_str(), "r");
if (sf) {
int raw = 0;
if (fscanf(sf, "%d", &raw) == 1 && raw > 0) speed = static_cast<uint64_t>(raw);
fclose(sf);
}
}
p.kv[K_CONN_SPEED] = std::to_string(speed);
p.kv[K_AGE_YEARS] = "-1";
result.push_back(std::move(p));
}
#endif
return result;
}
// ═════════════════════════════════════════════════════════════════════════════
// GPUs
// ═════════════════════════════════════════════════════════════════════════════
std::vector<DiscoveredPart> Discovery::discover_gpus() {
std::vector<DiscoveredPart> result;
#ifdef __APPLE__
// macOS GPU discovery not implemented
return result;
#else
std::string mm_out = run_cmd("lspci -mm 2>/dev/null");
if (mm_out.empty()) return result;
// Parse lspci -mm output. Each line:
// slot "class" "vendor" "device" ...
// (fields separated by spaces, quoted with double quotes)
auto parse_lspci_mm_line = [](const std::string& line)
-> std::vector<std::string> {
std::vector<std::string> fields;
size_t i = 0;
while (i < line.size()) {
while (i < line.size() && std::isspace((unsigned char)line[i])) ++i;
if (i >= line.size()) break;
if (line[i] == '"') {
++i;
std::string tok;
while (i < line.size() && line[i] != '"') tok += line[i++];
if (i < line.size()) ++i;
fields.push_back(tok);
} else {
std::string tok;
while (i < line.size() && !std::isspace((unsigned char)line[i])) tok += line[i++];
fields.push_back(tok);
}
}
return fields;
};
std::istringstream ss(mm_out);
std::string line;
while (std::getline(ss, line)) {
if (!line.empty() && line.back() == '\r') line.pop_back();
if (line.empty()) continue;
auto fields = parse_lspci_mm_line(line);
if (fields.size() < 4) continue;
std::string slot = fields[0];
std::string cls = fields[1];
std::string vendor = fields[2];
std::string device = fields[3];
// Filter for GPU classes
bool is_gpu = (cls.find("VGA compatible controller") != std::string::npos ||
cls.find("3D controller") != std::string::npos ||
cls.find("Display controller") != std::string::npos);
if (!is_gpu) continue;
DiscoveredPart p;
p.type_name = PTYPE_GPU;
p.kv[K_MANUFACTURER] = vendor;
p.kv[K_MODEL] = device;
p.kv[K_SERIAL] = slot; // PCI slot as stable ID
// NVIDIA VRAM via nvidia-smi
std::string lower_vendor = vendor;
std::transform(lower_vendor.begin(), lower_vendor.end(), lower_vendor.begin(), ::tolower);
if (lower_vendor.find("nvidia") != std::string::npos) {
std::string nsmi = run_cmd(
"nvidia-smi --query-gpu=index,name,memory.total --format=csv,noheader 2>/dev/null");
if (!nsmi.empty()) {
std::istringstream ns(nsmi);
std::string nline;
while (std::getline(ns, nline)) {
if (!nline.empty() && nline.back() == '\r') nline.pop_back();
size_t last_comma = nline.rfind(',');
if (last_comma == std::string::npos) continue;
std::string mem_str = trim(nline.substr(last_comma + 1));
uint64_t vram_mb = leading_uint(mem_str);
if (vram_mb > 0) {
p.kv[K_VRAM_MB] = std::to_string(vram_mb);
break;
}
}
}
}
// Find DRM card for this PCI slot
std::vector<std::string> outputs;
for (int card_idx = 0; card_idx < 8; ++card_idx) {
std::string card_name = "card" + std::to_string(card_idx);
std::string dev_link_path = "/sys/class/drm/" + card_name + "/device";
std::string resolved = run_cmd("readlink -f " + dev_link_path + " 2>/dev/null");
while (!resolved.empty() && std::isspace((unsigned char)resolved.back()))
resolved.pop_back();
if (resolved.empty()) continue;
bool matches = (resolved.find(slot) != std::string::npos);
if (!matches && slot.size() > 5 && slot[4] == ':') {
std::string short_addr = slot.substr(5);
matches = (resolved.find(short_addr) != std::string::npos);
}
if (!matches) continue;
// Found the card; check AMD VRAM
if (p.kv.find(K_VRAM_MB) == p.kv.end()) {
std::string vram_path = "/sys/class/drm/" + card_name + "/device/mem_info_vram_total";
FILE* vf = fopen(vram_path.c_str(), "r");
if (vf) {
uint64_t vram_bytes = 0;
fscanf(vf, "%llu", (unsigned long long*)&vram_bytes);
fclose(vf);
if (vram_bytes > 0) {
p.kv[K_VRAM_MB] = std::to_string(vram_bytes / (1024 * 1024));
}
}
}
// Enumerate connectors
std::string ls_out = run_cmd("ls /sys/class/drm/ 2>/dev/null");
if (!ls_out.empty()) {
std::istringstream lss(ls_out);
std::string entry;
while (lss >> entry) {
std::string prefix = card_name + "-";
if (entry.rfind(prefix, 0) != 0) continue;
std::string connector_name = entry.substr(prefix.size());
std::string status_path = "/sys/class/drm/" + entry + "/status";
FILE* sf = fopen(status_path.c_str(), "r");
std::string status = "unknown";
if (sf) {
char sbuf[32] = {};
fscanf(sf, "%31s", sbuf);
fclose(sf);
status = sbuf;
}
outputs.push_back(connector_name + ":" + status);
}
}
break;
}
if (!outputs.empty()) {
std::string out_str;
for (size_t oi = 0; oi < outputs.size(); ++oi) {
if (oi) out_str += LS;
out_str += outputs[oi];
}
p.kv[K_DISPLAY_OUTPUTS] = out_str;
}
result.push_back(std::move(p));
}
return result;
#endif
}