feat(snmp): NAS presets for Synology and Asustor, reading every disk and volume (#5985, #5986)
CI / lint (push) Successful in 3s
CI / unit (push) Successful in 1m38s
CI / integration (push) Successful in 1m41s
CI / publish (push) Successful in 1m31s

A preset can now read a vendor's tables: one row per disk, volume, fan or
sensor. The scheduled poll discovers the rows by walking the device: on its
first poll, after a save, and then hourly, and only while it answers. Each row
becomes readings named by the device's own labels ("Disk 1 status"). Only the
columns a row answered are asked for.

A vendor status, whether a code (Synology) or a word (Asustor), is a state
reading. It is stored as a level (0 OK, 1 busy, 2 warning, 3 failed), so one
alert rule covers any vendor, and shown in the device's own word.

- Synology: system, power and fan status and temperature; per disk: status,
  health, temperature and bad sectors; per volume: RAID state (scrubbing,
  parity check, rebuild, degraded), free space and size.
- Asustor: per disk: status, SMART and temperature; per volume: status, free
  space and size; CPU and system temperature, free memory and fans.
  From ADM's ASUSTOR-DISK-MIB and ASUSTOR-SYSTEM-MIB.
- Linux server: plus each physical disk's 1-minute busy % (UCD-DISKIO-MIB).

Migration snmp_002_table_rows adds tables, tables_at and texts to
snmp_devices. Milestone 530.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-16 22:40:14 -04:00
co-authored by Claude Opus 5.5
parent 4ded8ecee4
commit d065cafd11
12 changed files with 593 additions and 48 deletions
+98 -5
View File
@@ -48,6 +48,13 @@ class PollResult:
readings: dict[str, float] = field(default_factory=dict)
error: str | None = None # the device didn't answer, and why
missing: list[str] = field(default_factory=list) # keys it answered without
texts: dict[str, str] = field(default_factory=dict) # keys answered in words, not a number
@dataclass
class TableScan:
rows: dict[str, list[dict]] = field(default_factory=dict) # table id -> [{"index", "label", "columns"}]
error: str | None = None
def _pysnmp_available() -> bool:
@@ -149,9 +156,10 @@ async def poll_device(address: str, port: int, creds: Credentials,
oids: list[tuple[str, str]]) -> PollResult:
"""GET every (oid, key) of a device, a few dozen to a request.
Returns the numeric answers by key, the keys the device answered without
(no such object, or not a number), and an error in words when it didn't
answer at all. Never raises: a broken device must not stop the others.
Returns the numeric answers by key, the answers in words (a vendor's
status), the keys the device answered without, and an error in words when
it didn't answer at all. Never raises: a broken device must not stop the
others.
"""
result = PollResult()
if not oids:
@@ -198,8 +206,12 @@ async def poll_device(address: str, port: int, creds: Credentials,
try:
result.readings[key] = float(val)
except (TypeError, ValueError):
result.missing.append(key)
if not result.readings and result.missing:
text = str(val).strip()
if text:
result.texts[key] = text
else:
result.missing.append(key)
if not result.readings and not result.texts and result.missing:
result.error = "The device answered but has none of these readings."
return result
except Exception as exc:
@@ -210,6 +222,87 @@ async def poll_device(address: str, port: int, creds: Credentials,
_close_engine(engine)
# Rows read of one table: enough for any NAS's disks and volumes, and a bound
# on a device that answers a walk forever.
ROWS_PER_TABLE = 64
async def _walk_column(api, engine, auth, transport, column: str) -> dict[str, object] | str:
"""{row index: value} for one column, or the error in words when the
device didn't answer. A column the device lacks is an empty dict."""
found: dict[str, object] = {}
async for ei, es, _, rows in api.walk(engine, auth, transport, api.hl.ContextData(),
api.hl.ObjectType(api.hl.ObjectIdentity(column)),
lexicographicMode=False, lookupMib=False):
if ei:
return str(ei)
if es:
break
for name, val in rows:
oid = str(name)
if type(val).__name__ in _ABSENT or not oid.startswith(column + "."):
continue
found[oid[len(column) + 1:]] = val
if len(found) >= ROWS_PER_TABLE:
break
return found
async def walk_tables(address: str, port: int, creds: Credentials, tables: list) -> TableScan:
"""Which rows each vendor table has: each row's index, its label, and the
columns it answered. A walk per column, so it is asked for by the
scheduled poll when a device's rows are unknown or an hour old, never on a
page load. Never raises."""
scan = TableScan()
if not tables:
return scan
if not _pysnmp_available():
scan.error = "pysnmp isn't installed in this image, so SNMP polling is off."
return scan
api = _api()
engine = api.hl.SnmpEngine()
try:
try:
transport = await _transport(api, address, port)
except Exception as exc:
scan.error = f"Can't reach {address}:{port}: {exc}"
return scan
auth = _auth_data(api, creds)
for table in tables:
labels: dict[str, object] = {}
if table.label:
got = await _walk_column(api, engine, auth, transport, table.label)
if isinstance(got, str):
scan.error = explain(got, address, port, creds)
return scan
labels = got
answered: dict[str, set[str]] = {}
for column in table.columns:
got = await _walk_column(api, engine, auth, transport, column.oid)
if isinstance(got, str):
scan.error = explain(got, address, port, creds)
return scan
for index in got:
answered.setdefault(index, set()).add(column.key)
indexes = labels if table.label else answered
rows = []
for index in sorted(indexes, key=lambda i: [int(p) for p in i.split(".") if p.isdigit()]):
label = str(labels.get(index, "")).strip()
if table.label and not table.keeps(label):
continue
columns = [c.key for c in table.columns if c.key in answered.get(index, ())]
if columns:
rows.append({"index": index, "label": label, "columns": columns})
scan.rows[table.id] = rows[:ROWS_PER_TABLE]
return scan
except Exception as exc:
logger.warning("SNMP table walk of %s:%s failed: %s", address, port, exc)
scan.error = f"SNMP table walk failed: {exc}"
return scan
finally:
_close_engine(engine)
SYS_DESCR, SYS_UPTIME, SYS_NAME = "1.3.6.1.2.1.1.1.0", "1.3.6.1.2.1.1.3.0", "1.3.6.1.2.1.1.5.0"
IF_DESCR, IF_NAME = "1.3.6.1.2.1.2.2.1.2", "1.3.6.1.2.1.31.1.1.1.1"
INTERFACES_LISTED = 128