Skip to content

Commit 5ca00f7

Browse files
committed
v0.4.0: mock agent, SMART FAILED fix, attention reasons entity, dynamic icons, NVMe sensor coverage
1 parent 6267167 commit 5ca00f7

6 files changed

Lines changed: 282 additions & 6 deletions

File tree

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -273,6 +273,7 @@ Binaries output to `agent/build/`.
273273
| Doc | Description |
274274
|-----|-------------|
275275
| [Attention Severity Logic](docs/attention-severity-logic.md) | State machine, classification rules, notification lifecycle |
276+
| [Trigger → Entity Map](docs/attention-trigger-entity-map.md) | Every attention trigger mapped to its sensor entity and icon |
276277
| [Early Warning Attributes](docs/early-warning-attributes.md) | Which SMART attributes predict failure and why |
277278
| [Attribute Name Variants](docs/smart-attribute-name-variants.md) | Manufacturer-specific `smartctl` name mapping research |
278279
| [Mock Agent](docs/mock-agent.md) | Testing tool — fake agent with controllable drives |
@@ -284,6 +285,7 @@ Binaries output to `agent/build/`.
284285
- [ ] Integration icons for HA integrations page
285286
- [ ] MQTT agent mode
286287
- [ ] Custom Lovelace card
288+
- [ ] Temperature-based attention triggers (absolute threshold + trend over time)
287289
- [ ] Configurable alert thresholds via options flow
288290
- [ ] SAS/SCSI drive support
289291

custom_components/smart_sniffer/attention.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,13 @@ def evaluate_attention(
127127
critical_reasons: list[str] = []
128128
warning_reasons: list[str] = []
129129

130+
# ------------------------------------------------------------------
131+
# SMART overall status (applies to all protocols)
132+
# ------------------------------------------------------------------
133+
status = smart_data.get("smart_status", {})
134+
if isinstance(status, dict) and status.get("passed") is False:
135+
critical_reasons.append("SMART overall status: FAILED")
136+
130137
# ------------------------------------------------------------------
131138
# NVMe evaluation
132139
# ------------------------------------------------------------------

custom_components/smart_sniffer/coordinator.py

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -109,9 +109,10 @@ def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None:
109109
update_interval=timedelta(seconds=interval),
110110
)
111111

112-
# Track the last known attention state per drive for transition detection.
113-
# None = drive not yet seen (first poll baseline).
112+
# Track the last known attention state and reasons per drive for
113+
# transition detection. None = drive not yet seen (first poll baseline).
114114
self._prev_state: dict[str, str | None] = {}
115+
self._prev_reasons: dict[str, list[str]] = {}
115116

116117
@property
117118
def _base_url(self) -> str:
@@ -177,10 +178,14 @@ async def _handle_attention_notifications(
177178
drive_id, state,
178179
)
179180
self._prev_state[drive_id] = state
181+
self._prev_reasons[drive_id] = reasons
180182
continue
181183

182-
if state == prev:
183-
continue # No change.
184+
prev_reasons = self._prev_reasons.get(drive_id, [])
185+
reasons_changed = sorted(reasons) != sorted(prev_reasons)
186+
187+
if state == prev and not reasons_changed:
188+
continue # No change in state or reasons.
184189

185190
notif_id = _notif_id(drive_id)
186191

@@ -203,8 +208,10 @@ async def _handle_attention_notifications(
203208
notification_id=notif_id)
204209

205210
elif state in (STATE_MAYBE, STATE_YES):
206-
# Attention needed — fire or escalate/de-escalate.
207-
if prev == STATE_NO:
211+
# Attention needed — fire, escalate/de-escalate, or refresh reasons.
212+
if state == prev and reasons_changed:
213+
action = "reasons updated"
214+
elif prev == STATE_NO:
208215
action = "now requires attention"
209216
elif prev == STATE_MAYBE and state == STATE_YES:
210217
action = "ESCALATED to critical"
@@ -224,10 +231,12 @@ async def _handle_attention_notifications(
224231
notification_id=notif_id)
225232

226233
self._prev_state[drive_id] = state
234+
self._prev_reasons[drive_id] = reasons
227235

228236
# Clean up state for drives that disappeared (e.g., USB unplugged).
229237
removed = set(self._prev_state.keys()) - current_drive_ids
230238
for drive_id in removed:
231239
_LOGGER.debug("SMART Sniffer: %s no longer present, cleaning up", drive_id)
232240
del self._prev_state[drive_id]
241+
self._prev_reasons.pop(drive_id, None)
233242
pn_dismiss(self.hass, _notif_id(drive_id))

custom_components/smart_sniffer/sensor.py

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,8 @@
5454
})
5555

5656
NVME_ONLY_KEYS: frozenset[str] = frozenset({
57+
"critical_warning",
58+
"media_errors",
5759
"available_spare",
5860
"available_spare_threshold",
5961
})
@@ -157,6 +159,20 @@
157159
),
158160

159161
# --- NVMe only ---
162+
SensorEntityDescription(
163+
key="critical_warning",
164+
name="Critical Warning",
165+
state_class=SensorStateClass.MEASUREMENT,
166+
icon="mdi:alert-decagram",
167+
entity_category=EntityCategory.DIAGNOSTIC,
168+
),
169+
SensorEntityDescription(
170+
key="media_errors",
171+
name="Media Errors",
172+
state_class=SensorStateClass.TOTAL_INCREASING,
173+
icon="mdi:alert-decagram-outline",
174+
entity_category=EntityCategory.DIAGNOSTIC,
175+
),
160176
SensorEntityDescription(
161177
key="available_spare",
162178
name="Available Spare",
@@ -211,6 +227,8 @@ def _extract_attribute(drive_data: dict[str, Any], key: str) -> Any | None:
211227
"power_cycle_count": lambda: nvme_log.get("power_cycles"),
212228
"wear_leveling_count": lambda: nvme_log.get("percentage_used"),
213229
"reported_uncorrectable_errors":lambda: nvme_log.get("media_errors"),
230+
"critical_warning": lambda: nvme_log.get("critical_warning"),
231+
"media_errors": lambda: nvme_log.get("media_errors"),
214232
"available_spare": lambda: nvme_log.get("available_spare"),
215233
"available_spare_threshold": lambda: nvme_log.get("available_spare_threshold"),
216234
}
@@ -341,13 +359,44 @@ async def async_setup_entry(
341359
SmartSnifferAttentionSensor(coordinator, drive_id, drive_data)
342360
)
343361

362+
# --- Attention Reasons sensor (one per drive, always created) ---
363+
entities.append(
364+
SmartSnifferAttentionReasonsSensor(coordinator, drive_id, drive_data)
365+
)
366+
344367
async_add_entities(entities, update_before_add=False)
345368

346369

347370
# ---------------------------------------------------------------------------
348371
# SMART attribute sensor
349372
# ---------------------------------------------------------------------------
350373

374+
# Sensor keys whose non-zero values trigger critical attention (YES).
375+
_CRITICAL_SENSOR_KEYS: frozenset[str] = frozenset({
376+
"reallocated_sector_count",
377+
"current_pending_sector_count",
378+
"reported_uncorrectable_errors",
379+
"critical_warning",
380+
"media_errors",
381+
})
382+
383+
# Sensor keys whose non-zero values trigger warning attention (MAYBE).
384+
_WARNING_SENSOR_KEYS: frozenset[str] = frozenset({
385+
"reallocated_event_count",
386+
"spin_retry_count",
387+
"command_timeout",
388+
})
389+
390+
# NVMe sensors with threshold-based triggers (not simple non-zero).
391+
# These are handled specially in the icon property.
392+
_NVME_SPARE_KEY = "available_spare"
393+
_NVME_WEAR_KEY = "wear_leveling_count"
394+
395+
# Alert icons — used when a diagnostic sensor is actively triggering attention.
396+
_ALERT_ICON_CRITICAL = "mdi:alert-octagon"
397+
_ALERT_ICON_WARNING = "mdi:alert-circle"
398+
399+
351400
class SmartSnifferSensor(CoordinatorEntity[SmartSnifferCoordinator], SensorEntity):
352401
"""Representation of a single SMART attribute as a HA sensor."""
353402

@@ -363,6 +412,7 @@ def __init__(
363412
super().__init__(coordinator)
364413
self.entity_description = description
365414
self._drive_id = drive_id
415+
self._default_icon = description.icon
366416

367417
model = drive_data.get("model", "Unknown Drive")
368418
serial = drive_data.get("serial", drive_id)
@@ -378,6 +428,40 @@ def __init__(
378428
"serial_number": serial,
379429
}
380430

431+
@property
432+
def icon(self) -> str | None:
433+
"""Dynamic icon — switches to alert icon when this sensor triggers attention."""
434+
key = self.entity_description.key
435+
value = self.native_value
436+
437+
# Non-zero triggers (ATA + NVMe critical_warning/media_errors).
438+
if value is not None and isinstance(value, (int, float)) and value > 0:
439+
if key in _CRITICAL_SENSOR_KEYS:
440+
return _ALERT_ICON_CRITICAL
441+
if key in _WARNING_SENSOR_KEYS:
442+
return _ALERT_ICON_WARNING
443+
444+
# NVMe available_spare — threshold-based (needs drive's own threshold).
445+
if key == _NVME_SPARE_KEY and value is not None and isinstance(value, (int, float)):
446+
# Get the threshold from the same drive's data.
447+
drive_data = self.coordinator.data.get(self._drive_id, {})
448+
threshold = _extract_attribute(drive_data, "available_spare_threshold")
449+
if threshold is not None and value <= threshold:
450+
return _ALERT_ICON_CRITICAL
451+
if value < 20:
452+
return _ALERT_ICON_WARNING
453+
454+
# NVMe percentage_used (mapped to wear_leveling_count) — ≥90% = warning.
455+
if key == _NVME_WEAR_KEY and value is not None and isinstance(value, (int, float)):
456+
if value >= 90:
457+
return _ALERT_ICON_WARNING
458+
459+
# SMART Status — FAILED = critical.
460+
if key == "smart_status" and value == "FAILED":
461+
return _ALERT_ICON_CRITICAL
462+
463+
return self._default_icon
464+
381465
@property
382466
def native_value(self) -> Any | None:
383467
drive_data = self.coordinator.data.get(self._drive_id)
@@ -475,6 +559,77 @@ def extra_state_attributes(self) -> dict[str, Any]:
475559
}
476560

477561

562+
# ---------------------------------------------------------------------------
563+
# Attention Reasons sensor (text: human-readable trigger summary)
564+
# ---------------------------------------------------------------------------
565+
566+
class SmartSnifferAttentionReasonsSensor(
567+
CoordinatorEntity[SmartSnifferCoordinator], SensorEntity
568+
):
569+
"""Text sensor showing human-readable reasons for the current attention state.
570+
571+
Provides an at-a-glance answer to "why does this drive need attention?"
572+
directly on the device page, without needing to inspect entity attributes.
573+
574+
When attention is NO: "No issues detected"
575+
When attention is UNSUPPORTED: "No usable SMART data"
576+
When attention is MAYBE/YES: Semicolon-separated list of trigger reasons.
577+
"""
578+
579+
_attr_has_entity_name = True
580+
_attr_name = "Attention Reasons"
581+
_attr_icon = "mdi:text-box-search-outline"
582+
_attr_entity_category = EntityCategory.DIAGNOSTIC
583+
584+
def __init__(
585+
self,
586+
coordinator: SmartSnifferCoordinator,
587+
drive_id: str,
588+
drive_data: dict[str, Any],
589+
) -> None:
590+
super().__init__(coordinator)
591+
self._drive_id = drive_id
592+
593+
model = drive_data.get("model", "Unknown Drive")
594+
serial = drive_data.get("serial", drive_id)
595+
596+
self._attr_unique_id = (
597+
f"{coordinator.config_entry.entry_id}_{drive_id}_attention_reasons"
598+
)
599+
self._attr_device_info = {
600+
"identifiers": {(DOMAIN, drive_id)},
601+
"name": f"{model} ({serial})",
602+
"manufacturer": _guess_manufacturer(model),
603+
"model": model,
604+
"serial_number": serial,
605+
}
606+
607+
@property
608+
def native_value(self) -> str:
609+
drive_data = self.coordinator.data.get(self._drive_id)
610+
if drive_data is None:
611+
return "Drive data unavailable"
612+
state, _, reasons = evaluate_attention(drive_data)
613+
if state == STATE_NO:
614+
return "No issues detected"
615+
if state == STATE_UNSUPPORTED:
616+
return "No usable SMART data"
617+
return "; ".join(reasons)
618+
619+
@property
620+
def icon(self) -> str:
621+
"""Dynamic icon matching the attention state."""
622+
drive_data = self.coordinator.data.get(self._drive_id)
623+
if drive_data is None:
624+
return "mdi:text-box-search-outline"
625+
state, _, _ = evaluate_attention(drive_data)
626+
if state == STATE_YES:
627+
return "mdi:alert-octagon"
628+
if state == STATE_MAYBE:
629+
return "mdi:alert-circle-outline"
630+
return "mdi:text-box-search-outline"
631+
632+
478633
# ---------------------------------------------------------------------------
479634
# Helpers
480635
# ---------------------------------------------------------------------------
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
# Attention Trigger → Entity Map
2+
3+
Every attribute that can trigger an attention state change must have a visible sensor entity on the HA device page, with a dynamic icon that reflects whether it's currently contributing to the alert. This document maps each trigger to its entity and tracks coverage.
4+
5+
Last updated: v0.3.1
6+
7+
---
8+
9+
## ATA / SATA Triggers
10+
11+
### Critical (→ YES)
12+
13+
| smartctl Attribute | Sensor Entity | Key | Dynamic Icon |
14+
|---|---|---|---|
15+
| `Reallocated_Sector_Ct` | Reallocated Sector Count | `reallocated_sector_count` | `mdi:alert-octagon` when > 0 |
16+
| `Current_Pending_Sector` / `Current_Pending_Sector_Ct` / `Total_Pending_Sectors` | Current Pending Sector Count | `current_pending_sector_count` | `mdi:alert-octagon` when > 0 |
17+
| `Offline_Uncorrectable` / `Reported_Uncorrect` / `Uncorrectable_Error_Cnt` / `Total_Offl_Uncorrectabl` | Reported Uncorrectable Errors | `reported_uncorrectable_errors` | `mdi:alert-octagon` when > 0 |
18+
19+
### Warning (→ MAYBE)
20+
21+
| smartctl Attribute | Sensor Entity | Key | Dynamic Icon |
22+
|---|---|---|---|
23+
| `Reallocated_Event_Count` | Reallocated Event Count | `reallocated_event_count` | `mdi:alert-circle` when > 0 |
24+
| `Spin_Retry_Count` | Spin Retry Count | `spin_retry_count` | `mdi:alert-circle` when > 0 |
25+
| `Command_Timeout` | Command Timeout | `command_timeout` | `mdi:alert-circle` when > 0 |
26+
27+
---
28+
29+
## NVMe Triggers
30+
31+
### Critical (→ YES)
32+
33+
| NVMe Health Log Field | Sensor Entity | Key | Dynamic Icon |
34+
|---|---|---|---|
35+
| `critical_warning` (bitmask ≠ 0) | Critical Warning | `critical_warning` | `mdi:alert-octagon` when ≠ 0 |
36+
| `media_errors` (≥ 1) | Media Errors | `media_errors` | `mdi:alert-octagon` when > 0 |
37+
| `available_spare``available_spare_threshold` | Available Spare | `available_spare` | `mdi:alert-octagon` when ≤ threshold |
38+
39+
### Warning (→ MAYBE)
40+
41+
| NVMe Health Log Field | Sensor Entity | Key | Dynamic Icon |
42+
|---|---|---|---|
43+
| `available_spare` < 20% | Available Spare | `available_spare` | `mdi:alert-circle` when < 20 |
44+
| `percentage_used` ≥ 90% | Wear Leveling / Percentage Used | `wear_leveling_count` | `mdi:alert-circle` when ≥ 90 |
45+
46+
---
47+
48+
## Universal Triggers
49+
50+
| Condition | Sensor Entity | Key | Dynamic Icon |
51+
|---|---|---|---|
52+
| `smart_status.passed` = false | SMART Status | `smart_status` | `mdi:alert-octagon` when "FAILED" |
53+
54+
---
55+
56+
## Summary Entities (per drive, always created)
57+
58+
| Entity | Type | Description |
59+
|---|---|---|
60+
| **Attention Needed** | Enum sensor | Primary state: NO / MAYBE / YES / UNSUPPORTED. Icon changes per state. Attributes include `severity`, `reasons` list, and `issue_count`. |
61+
| **Attention Reasons** | Text sensor (diagnostic) | Human-readable semicolon-separated list of what's triggering the alert. Shows "No issues detected" when clean, "No usable SMART data" for UNSUPPORTED. Icon matches attention severity. |
62+
63+
---
64+
65+
## Icon Behavior
66+
67+
All diagnostic sensors that participate in attention evaluation have dynamic icons:
68+
69+
- **Normal state (value is 0 or within safe range):** Default icon from `SensorEntityDescription` (varies per sensor — thermometer, clock, harddisk, etc.).
70+
- **Warning trigger active (MAYBE):** Switches to `mdi:alert-circle` (filled circle with exclamation).
71+
- **Critical trigger active (YES):** Switches to `mdi:alert-octagon` (octagon stop sign with exclamation).
72+
73+
Icons revert to default automatically when the triggering value returns to safe range.
74+
75+
The Attention Needed sensor itself has state-based icons: `mdi:check-circle-outline` (NO), `mdi:alert-circle-outline` (MAYBE), `mdi:alert-octagon` (YES), `mdi:help-circle-outline` (UNSUPPORTED).
76+
77+
---
78+
79+
## Notification Behavior
80+
81+
Persistent notifications are managed by the coordinator and fire on state or reason transitions:
82+
83+
| Transition | Action |
84+
|---|---|
85+
| First poll | Silent baseline — no notification |
86+
| NO → MAYBE | Fire warning notification |
87+
| NO → YES | Fire critical notification |
88+
| MAYBE → YES | Update notification (escalate) |
89+
| YES → MAYBE | Update notification (de-escalate) |
90+
| YES/MAYBE → NO | Dismiss notification |
91+
| * → UNSUPPORTED | Fire informational notification (once) |
92+
| Same state, reasons changed | Update notification with new reason list |
93+
94+
Notification IDs are stable per drive: `smart_sniffer_attention_{drive_id}`. Each notification lists all active trigger reasons as bullet points.
95+
96+
---
97+
98+
## Known Gaps (Future Work)
99+
100+
- **Temperature triggers** — not yet implemented. Planned: absolute threshold (e.g., > 55°C → MAYBE) and trend-over-time detection (sustained rise over multiple polls). Requires historical storage in the coordinator.
101+
- **SAS/SCSI triggers**`scsi_grown_defect_list` and SCSI error counters are not yet parsed. SAS drives currently show as UNSUPPORTED.
102+
- **NVMe `critical_warning` bitmask decoding** — currently treated as a single flag (≠ 0 → YES). Future: decode individual bits (spare below threshold, temperature exceeded, reliability degraded, read-only mode, volatile memory backup failed) into separate reasons.

0 commit comments

Comments
 (0)