303 lines
8 KiB
Go
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)
|
|
}
|