Forecast when a printer runs out, and count what it has been through

The toner report says what is empty now. It could not say what to order, and
nothing recorded how fast anything drains - every level read was cached for
five minutes and then discarded.

Zabbix has been keeping the history all along; we simply never asked. One
history.get gives both answers, because a cartridge only goes DOWN while it is
in use: a rise is a replacement. Count the rises and you have how many
cartridges a printer has been through; fit a slope to the readings SINCE the
last rise and you have days-to-empty. Fitting across a replacement averages a
spent cartridge with a fresh one and describes neither.

Sorted by days left, which is the point. A cartridge at 60% dropping 5% a day
needs ordering before one sitting at 8% that has not moved in months, and a
level-sorted list ranks those backwards.

It refuses to guess. Too few readings, a level that has not moved enough - many
printers report in 10% steps and sit on a plateau for a fortnight - or a recent
replacement each produce no estimate and say which. Those printers are listed
separately rather than sorted in as 0 or as 999, since a printer without an
estimate is neither urgent nor safe. Estimates show what they rest on, because
"9 days from 21 days of readings" and "9 days from 2 readings" are not the same
claim.

A separate report card, not an extension of the toner report: that one is an
exceptions list a tech acts on today, this is an ordering view read monthly,
and the history query is heavier than the live read it would have slowed down.

The analysis is pure arithmetic over a list of readings, so the 14 tests cover
the noise wobble, the plateau, the swap, junk rows and division by zero without
needing Zabbix. Zabbix being unreachable is reported as such rather than
rendering an empty table that reads as "nothing is due".
This commit is contained in:
cproudlock
2026-08-12 11:45:40 -04:00
parent 2fce81f33f
commit d109314123
8 changed files with 627 additions and 0 deletions

View File

@@ -19,6 +19,7 @@ Configuration (database Setting overrides env var):
"""
import logging
import time
from typing import Dict, List, Optional
import requests
@@ -219,6 +220,39 @@ class ZabbixService:
})
return supplies
def gethistory(self, itemids, days=90, limit=5000):
"""Raw level history for supply items: {itemid: [(clock, value), ...]}.
Zabbix keeps this already - we simply never asked for it. history=3 is
the unsigned-integer table, which is where a percent-remaining item
lands; a site that types the item as float would need history=0, so a
miss falls back rather than erroring.
Returns {} when Zabbix is off or unreachable, so callers degrade to
"no estimate" instead of failing.
"""
if not itemids:
return {}
timefrom = int(time.time()) - days * 86400
collected = {}
for historytype in (3, 0):
rows = self._apicall("history.get", {
"output": "extend",
"history": historytype,
"itemids": list(itemids),
"time_from": timefrom,
"sortfield": "clock",
"sortorder": "ASC",
"limit": limit,
}) or []
for row in rows:
collected.setdefault(str(row.get("itemid")), []).append(
(row.get("clock"), row.get("value")))
# Items live in one table or the other; stop once something answered.
if collected:
break
return collected
def getpingstatus(self, ip: str) -> str:
"""ICMP ping state for a printer: '1' up, '0' down, '-1' unknown."""
hostid = self.gethostidbyip(ip)