drive-health-metrics/smart_text.go
James Coleman ddafa90a02
Some checks failed
Go package / build (push) Has been cancelled
first commit
2026-06-22 17:16:34 -05:00

303 lines
8 KiB
Go

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)
}