//go:build linux package system import ( "bufio" "fmt" "os" "path/filepath" "sort" "strings" "syscall" ) // GetInfo reads system memory, disk, CPU, load, and temperature info. // hddPath is the mount path for external HDD; if empty, HDD info is skipped. // cpuCollector provides the latest CPU usage sample; may be nil. func GetInfo(hddPath string, cpuCollector *CPUCollector) SystemInfo { info := SystemInfo{} // --- Memory from /proc/meminfo --- readMemInfo(&info) // --- Root filesystem disk usage --- readDiskUsage("/", &info.DiskTotalGB, &info.DiskUsedGB, &info.DiskAvailGB, &info.DiskPercent) // --- HDD disk usage (if configured) --- if hddPath != "" { info.HDDConfigured = true readDiskUsage(hddPath, &info.HDDTotalGB, &info.HDDUsedGB, &info.HDDAvailGB, &info.HDDPercent) } // --- Load average --- readLoadAvg(&info) // --- Temperature --- readTemperature(&info) // --- CPU from collector --- if cpuCollector != nil { info.CPUPercent = cpuCollector.CPUPercent() } return info } // GetTotalMemoryMB reads total system memory from /proc/meminfo. func GetTotalMemoryMB() (int, error) { info := SystemInfo{} readMemInfo(&info) if info.TotalMemMB == 0 { return 0, fmt.Errorf("could not read MemTotal from /proc/meminfo") } return int(info.TotalMemMB), nil } // GetMemoryMB returns total and used system memory in MB from /proc/meminfo. func GetMemoryMB() (totalMB, usedMB int, err error) { info := SystemInfo{} readMemInfo(&info) if info.TotalMemMB == 0 { return 0, 0, fmt.Errorf("could not read MemTotal from /proc/meminfo") } return int(info.TotalMemMB), int(info.UsedMemMB), nil } func readMemInfo(info *SystemInfo) { f, err := os.Open("/proc/meminfo") if err != nil { return } defer f.Close() var totalKB, availKB uint64 scanner := bufio.NewScanner(f) for scanner.Scan() { line := scanner.Text() switch { case strings.HasPrefix(line, "MemTotal:"): totalKB = parseMemLine(line) case strings.HasPrefix(line, "MemAvailable:"): availKB = parseMemLine(line) } if totalKB > 0 && availKB > 0 { break } } if totalKB > 0 { info.TotalMemMB = totalKB / 1024 info.AvailMemMB = availKB / 1024 info.UsedMemMB = info.TotalMemMB - info.AvailMemMB info.MemPercent = float64(info.UsedMemMB) / float64(info.TotalMemMB) * 100 } } // parseMemLine extracts the kB value from a /proc/meminfo line like "MemTotal: 16384000 kB" func parseMemLine(line string) uint64 { parts := strings.SplitN(line, ":", 2) if len(parts) < 2 { return 0 } valStr := strings.TrimSpace(parts[1]) valStr = strings.TrimSuffix(valStr, " kB") valStr = strings.TrimSpace(valStr) var val uint64 for _, c := range valStr { if c >= '0' && c <= '9' { val = val*10 + uint64(c-'0') } } return val } func readDiskUsage(path string, totalGB, usedGB, availGB *float64, percent *float64) { var stat syscall.Statfs_t if err := syscall.Statfs(path, &stat); err != nil { return } bsize := uint64(stat.Bsize) total := stat.Blocks * bsize avail := stat.Bavail * bsize used := total - (stat.Bfree * bsize) const gb = 1024 * 1024 * 1024 *totalGB = float64(total) / gb *usedGB = float64(used) / gb *availGB = float64(avail) / gb if total > 0 { *percent = float64(used) / float64(total) * 100 } } // readLoadAvg reads 1/5/15 minute load averages from /proc/loadavg. func readLoadAvg(info *SystemInfo) { data, err := os.ReadFile("/proc/loadavg") if err != nil { return } fmt.Sscanf(string(data), "%f %f %f", &info.LoadAvg1, &info.LoadAvg5, &info.LoadAvg15) } // readTemperature reads CPU/SoC temperature from thermal zones. // Tries /host/sys first (Docker mount), then /sys (native). func readTemperature(info *SystemInfo) { prefixes := []string{"/host/sys", "/sys"} for _, prefix := range prefixes { if readThermalZones(prefix, info) { return } } // Fallback: try hwmon for _, prefix := range prefixes { if readHwmon(prefix, info) { return } } } func readThermalZones(sysPrefix string, info *SystemInfo) bool { pattern := filepath.Join(sysPrefix, "class", "thermal", "thermal_zone*", "temp") matches, err := filepath.Glob(pattern) if err != nil || len(matches) == 0 { return false } sort.Strings(matches) var maxTemp float64 var maxSource string for _, tempPath := range matches { data, err := os.ReadFile(tempPath) if err != nil { continue } var milliDeg int64 if _, err := fmt.Sscanf(strings.TrimSpace(string(data)), "%d", &milliDeg); err != nil { continue } temp := float64(milliDeg) / 1000.0 // Read the type file for the label zoneDir := filepath.Dir(tempPath) typePath := filepath.Join(zoneDir, "type") typeData, err := os.ReadFile(typePath) source := strings.TrimSpace(string(typeData)) if err != nil || source == "" { source = filepath.Base(zoneDir) } if temp > maxTemp { maxTemp = temp maxSource = source } } if maxTemp > 0 { info.TemperatureCelsius = maxTemp info.TemperatureSource = maxSource return true } return false } func readHwmon(sysPrefix string, info *SystemInfo) bool { pattern := filepath.Join(sysPrefix, "class", "hwmon", "hwmon*", "temp1_input") matches, err := filepath.Glob(pattern) if err != nil || len(matches) == 0 { return false } var maxTemp float64 var maxSource string for _, tempPath := range matches { data, err := os.ReadFile(tempPath) if err != nil { continue } var milliDeg int64 if _, err := fmt.Sscanf(strings.TrimSpace(string(data)), "%d", &milliDeg); err != nil { continue } temp := float64(milliDeg) / 1000.0 source := filepath.Base(filepath.Dir(tempPath)) if temp > maxTemp { maxTemp = temp maxSource = source } } if maxTemp > 0 { info.TemperatureCelsius = maxTemp info.TemperatureSource = maxSource return true } return false }