From 1de22a166a2cbcec02bdcb3cfbaf482f3d0f41bb Mon Sep 17 00:00:00 2001 From: kisfenyo Date: Tue, 6 Oct 2026 22:00:44 +0200 Subject: [PATCH] R-330: the controller decodes SMART 187/188/199 from the agent (carried only, no verdict change) Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS --- CHANGELOG.md | 1 + controller/internal/agentapi/client.go | 7 +++ .../agentapi/r330_smart_counters_test.go | 61 +++++++++++++++++++ 3 files changed, 69 insertions(+) create mode 100644 controller/internal/agentapi/r330_smart_counters_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index b0ac56b..a3a4b5a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ - **R-542:** `GET /api/disks/candidates` no longer offers a drive that backs a REGISTERED storage path — not under `initialize` (the format wizard renders that list as-is, so a household's in-use data drive was offered for formatting) and not again under `attach`. The join is the guest's own mount table (a registered path's mount source is the host device). Fail-safe: an unreadable mount table empties `initialize`; `attach` passes through. Tests `TestR542_*` (two red-proofs observed). - **R-516 (the Go-literal leftover):** 22 messages that lived as Go string literals — the escrow handler (6: no password set, too many attempts, staging failed, a code already being made, a code not shown, no active job), the share-page password (2), the export upload size mismatch, the network-storage uid refusal and in-use removal, and the network-storage attach failures (10 categories + the Windows-name tip) — are bundle keys now (`api.escrow.*`, `api.share.*`, `api.export.*`, `api.netstorage.*`), in the te-form in Hungarian (14 of them were in the formal „ön" form: „próbálja", „állítson be", „várja meg", „indítsa", „töltse", „ellenőrizze", „használja", „kapcsolja be", „állítsa", „jelezze", „távolítsa el", „adjon meg"), with English. A failed attach job is rendered in the READER's language at the status read (it ran detached; it keeps its category, server and uid). The NAS app-namespace refusal says „Válassz" (stays a settings constant). Left on purpose: the setup wizard and `recovery-info.txt` (R-554), and the SMART and fill-watch texts — they are the hub event's message, mailed as is (wire text). Tests `TestR516_GoLiteralFormalFormsAreGone` (keys in both languages + a source scan with a positive control), `TestR516_NetAddFailureFollowsTheReader`; red-proofs `felhom.eu/documentation/audits/night-burndown-2026-10-06/ctrl/R-516-red*.txt`. +- **R-330 (disk health Phase 2, the wire only):** `agentapi.SmartSummary` decodes the three SATA counters agent v0.150.0+ sends — `reported_uncorrect` (187), `command_timeout` (188), `udma_crc_errors` (199); nil = unknown (an older agent or a device that does not report them), a measured 0 stays 0. **Carried only:** no verdict, banner or mail reads them yet (that would change what a household is told — the row's next slice). MinAgent unchanged: an older agent simply omits them. Tests `TestR330_DecodesTheThreeCounters` (red-proved), `TestR330_CountersChangeNoVerdictYet`. ## v0.301.0 — one short stop per backup tier; restores replay before the app starts; the family window reopens a command-closed sign-up (R-518, R-638, R-717; `09` §3 154, 156) (2026-10-06) diff --git a/controller/internal/agentapi/client.go b/controller/internal/agentapi/client.go index 75a6f8a..d967653 100644 --- a/controller/internal/agentapi/client.go +++ b/controller/internal/agentapi/client.go @@ -1065,6 +1065,13 @@ type SmartSummary struct { ReallocatedSectors *int `json:"reallocated_sectors"` PendingSectors *int `json:"pending_sectors"` OfflineUncorrectable *int `json:"offline_uncorrectable"` + // R-330 (agent v0.150.0+): three more SATA raw counters — 187 Reported_Uncorrect, 188 + // Command_Timeout (vendor-packed on some drives: carried as reported), 199 UDMA_CRC_Error_Count. + // nil = unknown (an older agent, an NVMe/USB device, or not reported), never zero. CARRIED ONLY: + // no verdict, banner or mail reads them yet — that would change what a household is told. + ReportedUncorrect *int64 `json:"reported_uncorrect,omitempty"` + CommandTimeout *int64 `json:"command_timeout,omitempty"` + UDMACRCErrors *int64 `json:"udma_crc_errors,omitempty"` // NVMe attributes. CriticalWarning *int `json:"critical_warning"` MediaErrors *int `json:"media_errors"` diff --git a/controller/internal/agentapi/r330_smart_counters_test.go b/controller/internal/agentapi/r330_smart_counters_test.go new file mode 100644 index 0000000..9a7751e --- /dev/null +++ b/controller/internal/agentapi/r330_smart_counters_test.go @@ -0,0 +1,61 @@ +package agentapi + +import ( + "encoding/json" + "reflect" + "testing" +) + +// R-330 (disk health Phase 2, the wire only). The agent (v0.150.0+) sends three more SATA raw +// counters; the controller must DECODE them (a field the struct lacks is silently dropped by +// encoding/json) and must not yet ACT on them. + +// The 2026-08-14 failing drive as the agent now reports it (values from +// felhom.eu/documentation/audits/fixtures/smart-ST3000VX010-failing-2026-08-14.json). +const r330AgentJSON = `{"health":"PASSED","temperature_c":40,"power_on_hours":null, + "reallocated_sectors":0,"pending_sectors":352,"offline_uncorrectable":352, + "reported_uncorrect":1001,"command_timeout":0,"udma_crc_errors":0, + "critical_warning":null,"media_errors":null,"percentage_used":null}` + +// COMPANION RED-PROOF (observed): rename the controller tag `reported_uncorrect` → `reported_uncorrectable` +// → this fails with "187 must decode from the agent's JSON (raw 1001); got ". Restored. +func TestR330_DecodesTheThreeCounters(t *testing.T) { + var s SmartSummary + if err := json.Unmarshal([]byte(r330AgentJSON), &s); err != nil { + t.Fatal(err) + } + if s.ReportedUncorrect == nil || *s.ReportedUncorrect != 1001 { + t.Fatalf("187 must decode from the agent's JSON (raw 1001); got %v", s.ReportedUncorrect) + } + // A measured ZERO stays a zero (not nil): 188 and 199 were reported as 0 by this drive. + if s.CommandTimeout == nil || *s.CommandTimeout != 0 || s.UDMACRCErrors == nil || *s.UDMACRCErrors != 0 { + t.Fatalf("188/199 reported as 0 must decode as a measured 0; got %v %v", s.CommandTimeout, s.UDMACRCErrors) + } + + // An older agent omits the keys → nil (unknown), never 0. + var old SmartSummary + if err := json.Unmarshal([]byte(`{"health":"PASSED","pending_sectors":0}`), &old); err != nil { + t.Fatal(err) + } + if old.ReportedUncorrect != nil || old.CommandTimeout != nil || old.UDMACRCErrors != nil { + t.Fatalf("an older agent's report must leave the counters nil (unknown); got %v %v %v", + old.ReportedUncorrect, old.CommandTimeout, old.UDMACRCErrors) + } +} + +// CARRIED ONLY: tonight the counters change no verdict, label or degraded-attribute list — reading +// them is a change to what a household is told and is not taken here (R-330's next slice). +func TestR330_CountersChangeNoVerdictYet(t *testing.T) { + with := realDrive() + big := int64(5000) + with.ReportedUncorrect, with.CommandTimeout, with.UDMACRCErrors = &big, &big, &big + without := realDrive() + for _, prior := range []DiskPrior{{}, {SawUncorrectable: true}} { + if a, b := DiskVerdictFor(with, prior), DiskVerdictFor(without, prior); a != b { + t.Fatalf("prior %+v: the R-330 counters changed the verdict (%v vs %v) — that is a household-facing change", prior, a, b) + } + } + if a, b := DegradedAttributes(with), DegradedAttributes(without); !reflect.DeepEqual(a, b) { + t.Fatalf("the R-330 counters changed the degraded-attribute list: %v vs %v", a, b) + } +}