A supply nobody could read is unknown, not empty

This commit is contained in:
2026-08-21 11:44:10 -04:00
parent 65312c0502
commit bbda5cbfe9
6 changed files with 277 additions and 12 deletions

View File

@@ -68,19 +68,34 @@ def derivecolor(name: str, tagcolor: Optional[str] = None) -> str:
return "none"
def classifysupply(level: float, name: str, vendor: Optional[str]) -> Dict:
"""Classify one supply item into ok/low/critical.
def classifysupply(level: Optional[float], name: str,
vendor: Optional[str]) -> Dict:
"""Classify one supply item into ok/low/critical, or unknown.
Waste cartridge fill is inverted vs a toner level: a full waste cartridge
is bad. Standard vendors report waste as percent FULL (high = bad). Xerox
EC/AltaLink series report waste as percent capacity REMAINING (low = bad),
same direction as toner. Normalise everything to percent remaining first.
A level of None means Zabbix has no usable reading; the status is 'unknown'
and remaining is None. Callers must not treat that as a low supply.
"""
lowername = (name or "").lower()
iswaste = "waste" in lowername
isdrum = "drum" in lowername or "imaging" in lowername
isxerox = bool(vendor) and "xerox" in vendor.lower()
# No reading is its own answer, not a level. It cannot be classified ok,
# low or critical without asserting something nobody measured - and every
# one of those three is a claim the reader would act on.
if level is None:
return {
"status": "unknown",
"remaining": None,
"iswaste": iswaste,
"isdrum": isdrum,
}
if iswaste and not isxerox:
remaining = 100 - level
else:

View File

@@ -208,18 +208,64 @@ class ZabbixService:
continue
if str(item.get("state", "0")) != "0":
continue
try:
level = int(float(item.get("lastvalue", 0)))
except (ValueError, TypeError):
level = 0
level, lastseen = self._readlevel(item)
supplies.append({
"name": item.get("name", "Unknown"),
"level": level,
"lastseen": lastseen,
"color": self._extract_color(item),
"itemid": item.get("itemid"),
})
return supplies
# How old the newest reading may be and still be presented as the level.
# Generous on purpose: supply items are polled in hours, not seconds, and
# calling a slow poller "unknown" would be its own lie. This exists for the
# host that stopped answering, not for one that answers rarely.
STALE_AFTER_SECONDS = 24 * 3600
@classmethod
def _readlevel(cls, item):
"""(level, lastseen) for one supply item; level is None when unknown.
An item that has never collected returns lastvalue as an EMPTY STRING,
and one whose host went quiet keeps a lastvalue that is arbitrarily old.
Both used to become 0 - the empty string because float('') raises and
the handler substituted zero, the stale one because nothing ever read
lastclock, which was fetched and then ignored.
Zero is not a safe stand-in for "no reading". It is a legitimate value
meaning the cartridge is spent, so the substitution was indistinguishable
from a real empty - and it hit every item on the host at once, which is
what a printer that has gone dark looks like. Downstream that reached
`level <= EMPTY_LEVEL`, so the forecast gave it daysleft 0 and the order
list asked purchasing to buy a full set of cartridges for a printer
nobody had measured.
None says "unknown" and every consumer already handles it: the detail
page renders N/A, the alert loop skips it, and analyse() falls back to
the last real level in history rather than inventing one.
"""
raw = item.get("lastvalue")
if raw is None or str(raw).strip() == "":
return None, None
try:
level = int(float(raw))
except (ValueError, TypeError):
return None, None
try:
lastseen = int(item.get("lastclock") or 0)
except (ValueError, TypeError):
lastseen = 0
if lastseen <= 0:
# A value with no clock cannot be aged. Trust the value and say the
# age is unknown rather than discarding a reading that may be fine.
return level, None
if time.time() - lastseen > cls.STALE_AFTER_SECONDS:
return None, lastseen
return level, lastseen
# Per item, per call. Zabbix applies `limit` to the WHOLE result set, not
# per item, so the budget is multiplied by the number of items asked for.
HISTORY_PER_ITEM = 2000