Skip to content

Commit 9da9191

Browse files
committed
Add device exclusion and installer drive picker (Issue #28)
Agent: exclude_devices config field with symlink-aware matching, runtime filter in Refresh(), and [excluded by config] annotation in --discover. Installer: drive picker auto-detects transport type via lsblk, color-codes green (sata/nvme/usb/sas) vs yellow (iscsi/fc/unknown), pre-excludes remote storage. Skipped on macOS (no lsblk). Warns when all drives excluded. Summary shows red 'all excluded' state. Docs: README agent config section, Proxmox guide iSCSI troubleshooting. Response draft for Issue #28 (hessel-a).
1 parent e049ad4 commit 9da9191

6 files changed

Lines changed: 381 additions & 20 deletions

File tree

README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,8 @@ token: "your-secret-token" # optional -- omit to disable auth
155155
scan_interval: 60s
156156
standby_mode: standby # optional -- never, standby, sleep, or idle
157157
advertise_interface: eth0 # optional -- restrict mDNS to this interface
158+
exclude_devices: # optional -- set by installer's drive picker
159+
- /dev/sdb
158160
filesystems: # optional -- set by installer's disk usage picker
159161
- path: /
160162
uuid: a1b2c3d4-5678-90ab-cdef-1234567890ab
@@ -164,6 +166,8 @@ filesystems: # optional -- set by installer's disk usage picke
164166
165167
All options can also be set via CLI flags: `--port`, `--token`, `--scan-interval`, `--interface`, `--config`.
166168

169+
**Exclude devices:** Device paths listed in `exclude_devices` are skipped during every scan. The agent resolves symlinks at startup, so `/dev/disk/by-id/...` paths and their `/dev/sdX` equivalents both match. The installer's drive picker sets this automatically when it detects iSCSI, Fibre Channel, or other remote-storage transports that don't support SMART passthrough. You can also add paths manually and restart the service. If a device appears in both `exclude_devices` and `device_overrides`, the exclusion wins and a warning is logged.
170+
167171
**Scan interval:** Uses Go duration syntax -- `30s`, `5m`, `1h`, `24h` are all valid. When `standby_mode` is set, the agent skips sleeping drives and serves cached data, so the interval does not cause unnecessary wake-ups. When `standby_mode` is `never` (the default), each poll wakes any drive that is spun down. This is the *agent-side* read cadence and is separate from the HA Poll Interval entity, which reflects how often Home Assistant pulls fresh data from the agent itself.
168172

169173
**Standby mode:** Controls whether the agent avoids waking sleeping drives during polling. Set to `standby`, `sleep`, or `idle` to match your drives' power management (these correspond to `smartctl -n` modes). When set, the agent passes `-n <mode>` to smartctl on each poll -- if a drive is in that power state, smartctl exits without waking it and the agent serves the last cached SMART data with an `in_standby` flag. The default is `never`, which wakes drives on every poll. On the very first poll after startup, the agent always wakes all drives regardless of this setting to collect a SMART baseline (serial number, model, attributes). This one-time wake ensures every drive is registered with a stable identity from the start. Subsequent polls honor the standby setting normally.

agent/config.go

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,10 @@ package main
33
import (
44
"flag"
55
"fmt"
6+
"log"
67
"net"
78
"os"
9+
"path/filepath"
810
"strings"
911
"time"
1012

@@ -39,9 +41,11 @@ type Config struct {
3941
Filesystems []FilesystemConfig `yaml:"filesystems"` // empty = disk usage monitoring disabled
4042
StandbyMode string `yaml:"standby_mode"` // never, standby, sleep, idle (default: never)
4143
DeviceOverrides []DeviceOverride `yaml:"device_overrides"` // manual protocol overrides per device path
44+
ExcludeDevices []string `yaml:"exclude_devices"` // device paths to skip during scan
4245
Discover bool `yaml:"-"` // set by --discover flag; not read from config file
4346
NoWrite bool `yaml:"-"` // set by --no-write flag; skips config write in discover mode
4447
SmartctlPath string `yaml:"-"` // resolved path to smartctl binary; set by resolveSmartctlPath()
48+
excludeSet map[string]bool // normalized set built once at load time (unexported, not serialized)
4549
}
4650

4751
// defaultConfig returns sane defaults.
@@ -238,9 +242,58 @@ func LoadConfig() (*Config, error) {
238242
}
239243
}
240244

245+
// Validate and normalize exclude_devices.
246+
// Build a resolved set once so Refresh() does a simple map lookup per poll.
247+
cfg.excludeSet = make(map[string]bool, len(cfg.ExcludeDevices))
248+
seen := make(map[string]bool, len(cfg.ExcludeDevices))
249+
for i, raw := range cfg.ExcludeDevices {
250+
if !strings.HasPrefix(raw, "/dev/") && !strings.HasPrefix(raw, `\\.\`) {
251+
return nil, fmt.Errorf("exclude_devices[%d]: %q is not a valid device path", i, raw)
252+
}
253+
if seen[raw] {
254+
log.Printf("WARNING: duplicate entry in exclude_devices: %s", raw)
255+
}
256+
seen[raw] = true
257+
258+
// Resolve symlinks so /dev/disk/by-id/... and /dev/sdX both match.
259+
resolved, err := filepath.EvalSymlinks(raw)
260+
if err != nil {
261+
log.Printf("WARNING: excluded device %s not found: %v (will still exclude if it appears later)", raw, err)
262+
cfg.excludeSet[raw] = true
263+
continue
264+
}
265+
cfg.excludeSet[resolved] = true
266+
if resolved != raw {
267+
cfg.excludeSet[raw] = true // match on either form
268+
}
269+
}
270+
271+
// Warn on exclude + override conflicts.
272+
for _, ov := range cfg.DeviceOverrides {
273+
if cfg.excludeSet[ov.Device] {
274+
log.Printf("WARNING: %s is in both exclude_devices and device_overrides; excluding", ov.Device)
275+
}
276+
}
277+
241278
return &cfg, nil
242279
}
243280

281+
// IsDeviceExcluded returns true if the given device path (or its symlink
282+
// target) is in the exclude_devices set. Safe to call with a nil Config.
283+
func (c *Config) IsDeviceExcluded(devPath string) bool {
284+
if c == nil || len(c.excludeSet) == 0 {
285+
return false
286+
}
287+
if c.excludeSet[devPath] {
288+
return true
289+
}
290+
// Resolve the scanned path in case it's a symlink not in the raw set.
291+
if resolved, err := filepath.EvalSymlinks(devPath); err == nil && resolved != devPath {
292+
return c.excludeSet[resolved]
293+
}
294+
return false
295+
}
296+
244297
// MDNSEnabled returns true if mDNS advertisement is enabled (default: true).
245298
func (c *Config) MDNSEnabled() bool {
246299
if c.MDNS == nil {

agent/discover.go

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ func RunDiscover(cfg *Config, noWrite bool) error {
5858
for _, dev := range scanResult.Devices {
5959
r := probeOneDrive(cfg.SmartctlPath, dev.Name, dev.Protocol)
6060
results = append(results, r)
61-
printDriveResult(r)
61+
printDriveResult(r, cfg)
6262
}
6363
}
6464

@@ -92,7 +92,7 @@ func RunDiscover(cfg *Config, noWrite bool) error {
9292

9393
r := probeOneDrive(cfg.SmartctlPath, path, "sat")
9494
results = append(results, r)
95-
printDriveResult(r)
95+
printDriveResult(r, cfg)
9696
}
9797
} else if platform == "qnap" {
9898
fmt.Println()
@@ -230,8 +230,13 @@ func probeOneDrive(smartctlPath, path, protocol string) discoverDriveResult {
230230
}
231231

232232
// printDriveResult prints a single drive's discover result to stdout.
233-
func printDriveResult(r discoverDriveResult) {
234-
fmt.Printf("\n %s\n", r.path)
233+
// If the drive is in the config's exclude list, an annotation is appended.
234+
func printDriveResult(r discoverDriveResult, cfg *Config) {
235+
excludeTag := ""
236+
if cfg.IsDeviceExcluded(r.path) {
237+
excludeTag = " [excluded by config]"
238+
}
239+
fmt.Printf("\n %s%s\n", r.path, excludeTag)
235240

236241
if r.satRetried {
237242
fmt.Printf(" Scan protocol: %s\n", r.scanProto)

agent/main.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,9 @@ func RunAgent(ctx context.Context, ready chan<- struct{}) error {
103103
if cfg.StandbyMode != "never" {
104104
log.Printf("Standby mode: %s", cfg.StandbyMode)
105105
}
106+
if len(cfg.ExcludeDevices) > 0 {
107+
log.Printf("Excluding %d device(s): %s", len(cfg.ExcludeDevices), strings.Join(cfg.ExcludeDevices, ", "))
108+
}
106109

107110
// --- Cache / background scanner ---
108111
cache := NewDriveCache(cfg)
@@ -673,6 +676,18 @@ func (dc *DriveCache) Refresh() {
673676
}
674677
}
675678

679+
// Filter excluded devices before any smartctl -a calls.
680+
if dc.cfg != nil && len(dc.cfg.excludeSet) > 0 {
681+
var filtered []scanDevice
682+
for _, dev := range scanResult.Devices {
683+
if dc.cfg.IsDeviceExcluded(dev.Name) {
684+
continue
685+
}
686+
filtered = append(filtered, dev)
687+
}
688+
scanResult.Devices = filtered
689+
}
690+
676691
newDrives := make(map[string]DriveInfo, len(scanResult.Devices))
677692
var order []string
678693

docs/guides/proxmox.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,19 @@ That's expected. The App (SMART Sniffer App from the HA add-on store) runs smart
145145

146146
Yes. If you pass the physical SATA or NVMe controller through to the VM via Proxmox's PCI passthrough (IOMMU), the VM gets direct hardware access. SMART data will work, and you could run the agent inside the VM. But this ties the physical controller to one VM and is more complex to set up. The agent-on-host approach is simpler and is how most Proxmox users run SMART Sniffer.
147147

148+
### iSCSI or network-attached storage shows warnings in the log
149+
150+
If your Proxmox host mounts iSCSI LUNs, Ceph RBDs, or NFS datastores, smartctl will attempt to read SMART data from those block devices and fail -- iSCSI targets don't support SMART passthrough (the SCSI commands hit the target daemon, not a physical drive). This causes repeated log warnings and exit code 4 errors.
151+
152+
The fix is to exclude those devices. If you're running the installer fresh, the drive picker detects iSCSI and other remote transports automatically and pre-excludes them. For existing installs, add the device paths to your config:
153+
154+
```yaml
155+
exclude_devices:
156+
- /dev/sdb # iSCSI LUN
157+
```
158+
159+
Then restart: `sudo systemctl restart smartha-agent`. Run `smartha-agent --discover` to confirm which drives are local and which are remote.
160+
148161
## Example config
149162

150163
A typical Proxmox host `config.yaml` at `/etc/smartha-agent/config.yaml`:
@@ -153,6 +166,8 @@ A typical Proxmox host `config.yaml` at `/etc/smartha-agent/config.yaml`:
153166
port: 9099
154167
scan_interval: 120
155168
advertise_interface: vmbr0
169+
exclude_devices:
170+
- /dev/sdb # iSCSI LUN -- no SMART passthrough
156171
```
157172

158173
No `device_overrides` needed unless you have a hardware RAID controller. Standard SATA and NVMe drives are detected automatically.

0 commit comments

Comments
 (0)