package main import ( "fmt" "regexp" "strings" ) // parseSmartText parses `smartctl -a` PLAIN TEXT output. This is the path used // on CentOS 6/7 where smartmontools (5.x/6.x) predates `--json` (7.0, 2019). // Handles ATA/SATA, NVMe, and SAS/SCSI layouts. func parseSmartText(text string, d *Drive) { if strings.TrimSpace(text) == "" { return } lines := strings.Split(text, "\n") d.Rotation = "SSD" val := func(prefix string) string { for _, ln := range lines { if i := strings.Index(ln, prefix); i >= 0 { return strings.TrimSpace(ln[i+len(prefix):]) } } return "" } // ---- Identity ---- d.Model = first(val("Device Model:"), val("Model Number:"), val("Product:"), val("Model Family:")) d.Serial = first(val("Serial Number:"), val("Serial number:")) d.Firmware = first(val("Firmware Version:"), val("Revision:")) if uc := val("User Capacity:"); uc != "" { // "1,920,383,410,176 bytes [1.92 TB]" if m := regexp.MustCompile(`([\d,]+)\s*bytes`).FindStringSubmatch(uc); m != nil { if n, ok := parseIntLoose(m[1]); ok && n > 0 { d.Capacity = fmt.Sprintf("%.2f TB", float64(n)/1e12) } } } if rr := val("Rotation Rate:"); rr != "" { if strings.Contains(strings.ToLower(rr), "solid state") { d.Rotation = "SSD" } else if m := regexp.MustCompile(`(\d+)\s*rpm`).FindStringSubmatch(strings.ToLower(rr)); m != nil { d.Rotation = m[1] + " rpm" } } isNVMe := false for _, ln := range lines { if strings.Contains(ln, "NVMe Log") || strings.Contains(ln, "SMART/Health Information (NVMe") { isNVMe = true break } } if isNVMe { d.Rotation = "NVMe" } // ---- SMART overall health ---- // ATA: "SMART overall-health self-assessment test result: PASSED" // SCSI: "SMART Health Status: OK" if h := val("self-assessment test result:"); h != "" { up := strings.ToUpper(h) if strings.Contains(up, "PASS") { d.SmartHealth = "PASSED" } else if strings.Contains(up, "FAIL") { d.SmartHealth = "FAILED" } else { d.SmartHealth = "UNKNOWN" } } else if h := val("SMART Health Status:"); h != "" { if strings.Contains(strings.ToUpper(h), "OK") { d.SmartHealth = "PASSED" } else { d.SmartHealth = "FAILED" } } else { d.SmartHealth = "UNKNOWN" } if isNVMe { parseNVMeText(val, d) return } if attrs := parseATAAttrTable(lines); len(attrs) > 0 { applyATAAttrs(attrs, d) return } // SAS/SCSI fallback fields. parseSCSIText(val, d) parseSCSIErrors(lines, d) } // parseATAAttrTable parses the "Vendor Specific SMART Attributes" table: // // ID# ATTRIBUTE_NAME FLAG VALUE WORST THRESH TYPE UPDATED WHEN_FAILED RAW_VALUE // 5 Reallocated_Sector_Ct 0x0033 100 100 010 Pre-fail Always - 0 func parseATAAttrTable(lines []string) map[int]ataAttr { attrs := map[int]ataAttr{} inTable := false for _, ln := range lines { if strings.Contains(ln, "ATTRIBUTE_NAME") && strings.Contains(ln, "RAW_VALUE") { inTable = true continue } if !inTable { continue } if strings.TrimSpace(ln) == "" { break } f := strings.Fields(ln) if len(f) < 10 { continue } id, ok := firstInt(f[0]) if !ok { continue } ta := ataAttr{whenFailed: f[8]} if v, ok := firstInt(f[3]); ok { ta.value = &v } if w, ok := firstInt(f[4]); ok { ta.worst = &w } // RAW_VALUE is the remainder from field 9 onward; take leading int. if r, ok := firstInt(strings.Join(f[9:], " ")); ok { ta.raw = &r } attrs[id] = ta } return attrs } // applyATAAttrs maps the parsed ATA attribute table onto d: the health-by- // attribute verdict when the overall result was unknown, the shared defect/wear/ // reserve fields, and the text-path-only power-cycle and temperature fallbacks. func applyATAAttrs(attrs map[int]ataAttr, d *Drive) { // Health-by-attribute when the overall verdict was unknown. if d.SmartHealth == "UNKNOWN" && len(attrs) > 0 { if attrsFailed(attrs) { d.SmartHealth = "FAILED" } else { d.SmartHealth = "PASSED_BY_ATTR" } } // Defect, wear, reserve, and host-write fields shared with the JSON path. applyAtaCounters(attrs, d) // Text-path fallbacks: JSON reads these from dedicated fields instead. if d.PowerCycleCount == nil { d.PowerCycleCount = attrRaw(attrs, 12) } if d.TempC == nil { d.TempC = attrRaw(attrs, 194) } } // parseNVMeText fills NVMe health fields from the "SMART/Health Information // (NVMe Log)" section using the shared val() prefix lookup. func parseNVMeText(val func(string) string, d *Drive) { if cw := val("Critical Warning:"); cw != "" { // "0x00" if n, ok := parseHexOrInt(cw); ok { d.NvmeCriticalWarning = &n } } if as := val("Available Spare:"); as != "" { if n, ok := firstInt(as); ok { d.NvmeAvailSpare = &n } } if at := val("Available Spare Threshold:"); at != "" { if n, ok := firstInt(at); ok { d.NvmeAvailSpareThresh = &n } } if pu := val("Percentage Used:"); pu != "" { if n, ok := firstInt(pu); ok { d.WearPctConsumed = &n d.WearPctRemaining = pInt(100 - n) d.WearSrc = "NVMe/percentage_used" } } if me := val("Media and Data Integrity Errors:"); me != "" { if n, ok := parseIntLoose(me); ok { d.NvmeMediaErrors = &n if d.Uncorrectable == nil { d.Uncorrectable = &n } } } if d.PowerOnHours == nil { if n, ok := parseIntLoose(val("Power On Hours:")); ok { d.PowerOnHours = &n } } if d.PowerCycleCount == nil { if n, ok := parseIntLoose(val("Power Cycles:")); ok { d.PowerCycleCount = &n } } if d.TempC == nil { if n, ok := firstInt(val("Temperature:")); ok { d.TempC = &n } } } // parseSCSIText fills SAS/SCSI fields (temperature, grown defect list, // endurance, power-on hours) that smartctl prints as "Label: value" lines. func parseSCSIText(val func(string) string, d *Drive) { if t := val("Current Drive Temperature:"); t != "" { if n, ok := firstInt(t); ok { d.TempC = &n } } if g := val("Elements in grown defect list:"); g != "" { if n, ok := parseIntLoose(g); ok { d.Reallocated = &n } } if e := val("Percentage used endurance indicator:"); e != "" { if n, ok := firstInt(e); ok { d.WearPctConsumed = &n d.WearPctRemaining = pInt(100 - n) d.WearSrc = "SCSI/endurance" } } if h := val("number of hours powered up"); h != "" { // "= 12345.67" if m := regexp.MustCompile(`([\d,]+)`).FindString(h); m != "" { if n, ok := parseIntLoose(m); ok { d.PowerOnHours = &n } } } // Newer smartctl prints "Accumulated power on time, hours:minutes 2487:44". if d.PowerOnHours == nil { if h := val("Accumulated power on time, hours:minutes"); h != "" { if n, ok := firstInt(h); ok { d.PowerOnHours = &n } } } } // parseSCSIErrors handles the SAS "Error counter log" and pending-defect count, // the SAS analog of ATA uncorrectable/pending sectors. These need the full line // list (the error log is a multi-line table), not just the val() prefix lookup. func parseSCSIErrors(lines []string, d *Drive) { if d.Uncorrectable == nil { // Each of read:/write:/verify: ends in a "total uncorrected errors" count. sum, any := 0, false for _, ln := range lines { f := strings.Fields(ln) if len(f) < 7 { continue } switch f[0] { case "read:", "write:", "verify:": if n, ok := parseIntLoose(f[len(f)-1]); ok { any = true sum += n } } } if any { d.Uncorrectable = pInt(sum) } } if d.Pending == nil { // " Pending defect count:0 Pending Defects" (no space after the colon). for _, ln := range lines { if i := strings.Index(ln, "Pending defect count:"); i >= 0 { if n, ok := firstInt(ln[i+len("Pending defect count:"):]); ok { d.Pending = pInt(n) } break } } } } // parseHexOrInt parses s as hex when it carries a 0x/0X prefix, otherwise as a // loose decimal int. The NVMe critical-warning field arrives as "0x00". func parseHexOrInt(s string) (int, bool) { s = strings.TrimSpace(s) if strings.HasPrefix(s, "0x") || strings.HasPrefix(s, "0X") { var n int if _, err := fmt.Sscanf(s, "0x%x", &n); err == nil { return n, true } if _, err := fmt.Sscanf(s, "0X%x", &n); err == nil { return n, true } return 0, false } return parseIntLoose(s) }