diff --git a/plugins/printers/api/asset_routes.py b/plugins/printers/api/asset_routes.py index dac2769..c979609 100644 --- a/plugins/printers/api/asset_routes.py +++ b/plugins/printers/api/asset_routes.py @@ -1482,3 +1482,102 @@ def dashboard_supplies(): rows.sort(key=lambda r: (not r['iscritical'], r['printername'])) return success_response(rows) + + +# ============================================================================= +# Supply forecast +# +# The toner report answers "what is empty now". This answers "what will be, and +# what have we been getting through" - a purchasing question, on a different +# cadence, off data Zabbix has been keeping all along. +# ============================================================================= + +@printers_asset_bp.route('/supplies/forecast', methods=['GET']) +@jwt_required(optional=True) +def supplies_forecast(): + """Days-to-empty and replacement counts per printer. + + ?days=90 how far back to read (Zabbix retention is the real ceiling) + + Printers sort by their soonest supply. Anything without an honest estimate + is returned separately with the reason, rather than sorted as though it + were fine or dropped as though it did not exist. + """ + from ..services.supply_history import analyse, soonest + + try: + days = max(1, min(365, int(request.args.get('days', 90)))) + except (TypeError, ValueError): + days = 90 + + service = ZabbixService() + if not service.isconfigured or not service.isreachable: + return success_response({ + 'printers': [], 'unestimated': [], 'days': days, + 'available': False, + 'reason': 'Zabbix is not configured or not reachable', + }) + + rows = ( + db.session.query(Printer, Asset, Communication, Vendor) + .join(Asset, Asset.assetid == Printer.assetid) + .join(Communication, Communication.assetid == Asset.assetid) + .outerjoin(Vendor, Vendor.vendorid == Printer.vendorid) + .filter(Asset.isactive == True, + Communication.ipaddress.isnot(None), + Communication.ipaddress != '') + .all() + ) + + seen = set() + estimated, unestimated = [], [] + + for printer, asset, comm, vendor in rows: + if printer.printerid in seen: + continue + seen.add(printer.printerid) + + supplies = service.getsuppliesbyip_cached(comm.ipaddress) + if not supplies: + continue + itemids = [s['itemid'] for s in supplies if s.get('itemid')] + history = service.gethistory(itemids, days=days) + + analysed = [] + for supply in supplies: + points = history.get(str(supply.get('itemid')), []) + detail = analyse(points) + detail['name'] = supply.get('name') + detail['color'] = supply.get('color') + # Trust the live read for the level; history can lag a poll behind. + detail['currentlevel'] = supply.get('level', detail['currentlevel']) + analysed.append(detail) + + entry = { + 'printerid': printer.printerid, + 'printername': asset.name or printer.hostname or '', + 'assetnumber': asset.assetnumber or '', + 'ipaddress': comm.ipaddress, + 'vendor': vendor.vendor if vendor else None, + 'supplies': analysed, + 'daysleft': soonest(analysed), + 'replacements': sum(s['replacements'] for s in analysed), + } + (estimated if entry['daysleft'] is not None else unestimated).append(entry) + + # Soonest first: the point of the report is what to order next. + estimated.sort(key=lambda p: p['daysleft']) + unestimated.sort(key=lambda p: p['printername']) + + return success_response({ + 'printers': estimated, + 'unestimated': unestimated, + 'days': days, + 'available': True, + 'summary': { + 'estimated': len(estimated), + 'unestimated': len(unestimated), + 'replacements': sum(p['replacements'] for p in estimated + unestimated), + 'duewithin30': sum(1 for p in estimated if p['daysleft'] <= 30), + }, + }) diff --git a/plugins/printers/frontend/routes.js b/plugins/printers/frontend/routes.js index a4c20b7..83b4f23 100644 --- a/plugins/printers/frontend/routes.js +++ b/plugins/printers/frontend/routes.js @@ -8,6 +8,12 @@ export default [ component: () => import('./views/TonerReport.vue'), meta: { plugin: 'printers' } }, + { + path: 'reports/toner-forecast', + name: 'toner-forecast', + component: () => import('./views/TonerForecast.vue'), + meta: { plugin: 'printers' } + }, { path: 'settings/printertypes', name: 'printer-types', diff --git a/plugins/printers/frontend/views/TonerForecast.vue b/plugins/printers/frontend/views/TonerForecast.vue new file mode 100644 index 0000000..e42ac0f --- /dev/null +++ b/plugins/printers/frontend/views/TonerForecast.vue @@ -0,0 +1,182 @@ + + + + + diff --git a/plugins/printers/plugin.py b/plugins/printers/plugin.py index 96c684d..4e6b075 100644 --- a/plugins/printers/plugin.py +++ b/plugins/printers/plugin.py @@ -358,6 +358,17 @@ class PrintersPlugin(BasePlugin): 'category': 'printers', 'route': '/reports/toner', }, + { + # Separate from the toner report on purpose: that one is an + # exceptions list a tech acts on today, this is an ordering + # view read monthly, and it runs a heavier history query. + 'id': 'tonerforecast', + 'name': 'Toner Forecast', + 'description': 'Estimated days until each printer runs out, ' + 'and how many cartridges it has been through', + 'category': 'printers', + 'route': '/reports/toner-forecast', + }, ] def get_permissions(self) -> List: diff --git a/plugins/printers/services/__init__.py b/plugins/printers/services/__init__.py index 10cde9a..ffe803b 100644 --- a/plugins/printers/services/__init__.py +++ b/plugins/printers/services/__init__.py @@ -9,6 +9,7 @@ from .supply_parts import ( alerttier, ) from .supply_alerts import check_supplies +from .supply_history import analyse, soonest from .seed_supplies import seedsupplies __all__ = [ @@ -19,5 +20,7 @@ __all__ = [ 'lookupsupplies', 'alerttier', 'check_supplies', + 'analyse', + 'soonest', 'seedsupplies', ] diff --git a/plugins/printers/services/supply_history.py b/plugins/printers/services/supply_history.py new file mode 100644 index 0000000..15b1966 --- /dev/null +++ b/plugins/printers/services/supply_history.py @@ -0,0 +1,162 @@ +"""Read a supply's level history and say what it means. + +Two questions come out of the same series of readings: + + * how fast is it draining, and when does it hit empty + * how many times has it been replaced + +Both hang on one observation: a cartridge only ever goes DOWN while it is in +use, so a rise is a replacement. Everything here is built on locating those +rises and treating each stretch between them as one cartridge's life. + +The maths is deliberately kept away from Zabbix so it can be tested against a +list of numbers. The service layer fetches; this decides. +""" + +from datetime import datetime, timezone + +# A reading can wobble by a point without anything happening - SNMP rounding, +# a gauge settling after a power cycle. A rise has to clear this to count as a +# new cartridge rather than noise. +REPLACEMENT_RISE = 10 + +# Below this many readings a slope is arithmetic, not evidence. Two points +# through a coarse gauge can "prove" any rate at all. +MIN_POINTS_FOR_ESTIMATE = 4 + +# Many printers report in 10% steps, so a fortnight can pass on one plateau. +# Without a minimum observed drop the slope reads as zero and the forecast +# says "never", which is worse than saying nothing. +MIN_DROP_FOR_ESTIMATE = 2 + + +def _asfloat(value): + try: + return float(value) + except (TypeError, ValueError): + return None + + +def normalise(points): + """[(clock, value)] -> sorted [(datetime, float)], junk dropped. + + Zabbix returns clock as a unix string and value as a string; a history + table can also carry the odd unparseable row. + """ + out = [] + for clock, value in points: + seconds = _asfloat(clock) + level = _asfloat(value) + if seconds is None or level is None: + continue + out.append((datetime.fromtimestamp(seconds, tz=timezone.utc), level)) + out.sort(key=lambda p: p[0]) + return out + + +def find_replacements(points, rise=REPLACEMENT_RISE): + """Timestamps where the level jumped up - one per cartridge change. + + Returns [] for a series that only falls. A rise smaller than `rise` is + treated as noise, not a replacement. + """ + replacements = [] + for (_, previous), (when, current) in zip(points, points[1:]): + if current - previous >= rise: + replacements.append(when) + return replacements + + +def current_run(points, rise=REPLACEMENT_RISE): + """The readings since the last replacement. + + Fitting across a replacement averages a spent cartridge with a fresh one + and produces a slope that describes neither. + """ + if not points: + return [] + start = 0 + for index in range(1, len(points)): + if points[index][1] - points[index - 1][1] >= rise: + start = index + return points[start:] + + +def burn_rate(points): + """Percent consumed per day over these readings, or None. + + None means "no honest estimate": too few readings, no elapsed time, or a + drop too small to distinguish from a gauge that has not moved yet. + """ + if len(points) < MIN_POINTS_FOR_ESTIMATE: + return None + first_when, first_level = points[0] + last_when, last_level = points[-1] + days = (last_when - first_when).total_seconds() / 86400 + if days <= 0: + return None + drop = first_level - last_level + if drop < MIN_DROP_FOR_ESTIMATE: + return None + return drop / days + + +def analyse(points, rise=REPLACEMENT_RISE): + """Everything the report needs about one supply. + + Returns: + currentlevel latest reading, or None + daysleft at the current rate, or None when there is no estimate + burnrateperday percent per day, or None + reason why there is no estimate - shown rather than hidden, so + a missing forecast is explained instead of looking broken + replacements how many times it has been changed in this window + lastreplaced when, or None + basisdays span of readings the estimate rests on + points the run since the last replacement, for the chart + """ + points = normalise(points) + result = { + 'currentlevel': None, 'daysleft': None, 'burnrateperday': None, + 'reason': None, 'replacements': 0, 'lastreplaced': None, + 'basisdays': 0, 'points': [], + } + if not points: + result['reason'] = 'no history' + return result + + replacements = find_replacements(points, rise=rise) + result['replacements'] = len(replacements) + result['lastreplaced'] = replacements[-1].isoformat() if replacements else None + + run = current_run(points, rise=rise) + result['currentlevel'] = run[-1][1] + result['points'] = [(when.isoformat(), level) for when, level in run] + if len(run) >= 2: + result['basisdays'] = round( + (run[-1][0] - run[0][0]).total_seconds() / 86400, 1) + + rate = burn_rate(run) + if rate is None: + # Say which of the three it is; "no estimate" alone invites a bug report. + if len(run) < MIN_POINTS_FOR_ESTIMATE: + result['reason'] = ('replaced recently' if replacements + else 'not enough history yet') + else: + result['reason'] = 'level has not moved enough to estimate' + return result + + result['burnrateperday'] = round(rate, 2) + result['daysleft'] = max(0, int(run[-1][1] / rate)) + return result + + +def soonest(supplies): + """Days-left of the supply that runs out first, or None if none estimate. + + The report sorts printers by this: a printer is as urgent as its most + pressing cartridge, and listing it once per supply would scatter it down + the page. + """ + days = [s['daysleft'] for s in supplies if s.get('daysleft') is not None] + return min(days) if days else None diff --git a/plugins/printers/services/zabbix_service.py b/plugins/printers/services/zabbix_service.py index ddd8e60..e49db03 100644 --- a/plugins/printers/services/zabbix_service.py +++ b/plugins/printers/services/zabbix_service.py @@ -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) diff --git a/tests/test_plugins/test_supply_history.py b/tests/test_plugins/test_supply_history.py new file mode 100644 index 0000000..5f82b92 --- /dev/null +++ b/tests/test_plugins/test_supply_history.py @@ -0,0 +1,130 @@ +"""Tests for toner burn-rate and replacement counting. + +The analysis is pure arithmetic over a list of readings, so it is tested that +way - no Zabbix, no fixtures. What matters is that it refuses to guess: the +cases where an estimate would be dishonest are the ones most likely to reach a +purchasing decision. +""" + +from datetime import datetime, timedelta, timezone + +from plugins.printers.services.supply_history import ( + analyse, burn_rate, current_run, find_replacements, normalise, soonest, +) + +START = datetime(2026, 6, 1, tzinfo=timezone.utc) + + +def series(levels, hours=24): + """[(clock, value)] one reading per `hours`, as Zabbix returns them.""" + return [(str(int((START + timedelta(hours=i * hours)).timestamp())), str(v)) + for i, v in enumerate(levels)] + + +def test_counts_one_replacement_per_upward_step(): + points = normalise(series([80, 60, 40, 20, 100, 80, 60, 95, 70])) + + assert len(find_replacements(points)) == 2 + + +def test_ignores_a_wobble_that_is_not_a_replacement(): + """SNMP rounding and a gauge settling both nudge a reading upward.""" + points = normalise(series([60, 58, 61, 57, 55])) + + assert find_replacements(points) == [] + + +def test_estimate_uses_only_the_current_cartridge(): + """Fitting across a swap averages a spent cartridge with a fresh one.""" + points = normalise(series([90, 60, 30, 100, 90, 80, 70])) + + run = current_run(points) + + assert [level for _, level in run] == [100, 90, 80, 70] + + +def test_days_left_from_a_steady_drain(): + # 10 points a day apart, 5% a day, ending at 55% + result = analyse(series([100, 95, 90, 85, 80, 75, 70, 65, 60, 55])) + + assert result['burnrateperday'] == 5.0 + assert result['daysleft'] == 11 # 55 / 5 + assert result['reason'] is None + assert result['basisdays'] == 9.0 + + +def test_no_estimate_from_two_readings(): + """Two points through a coarse gauge can prove any rate at all.""" + result = analyse(series([100, 90])) + + assert result['daysleft'] is None + assert result['reason'] == 'not enough history yet' + + +def test_no_estimate_while_the_gauge_has_not_moved(): + """A printer reporting in 10% steps sits on a plateau for a fortnight.""" + result = analyse(series([70, 70, 70, 70, 70, 70])) + + assert result['daysleft'] is None + assert result['reason'] == 'level has not moved enough to estimate' + + +def test_a_recent_replacement_says_so_rather_than_guessing(): + result = analyse(series([40, 30, 20, 10, 100, 98])) + + assert result['daysleft'] is None + assert result['reason'] == 'replaced recently' + assert result['replacements'] == 1 + assert result['lastreplaced'] is not None + + +def test_empty_history_is_reported_not_crashed(): + result = analyse([]) + + assert result['reason'] == 'no history' + assert result['currentlevel'] is None + assert result['replacements'] == 0 + + +def test_junk_rows_are_dropped_not_fatal(): + points = normalise([('notaclock', '50'), ('1780000000', 'n/a'), + ('1780000000', '50')]) + + assert len(points) == 1 + + +def test_days_left_never_goes_negative(): + result = analyse(series([20, 15, 10, 5, 0])) + + assert result['daysleft'] == 0 + + +def test_printer_sorts_by_its_soonest_supply(): + """A colour MFP is as urgent as its most pressing cartridge.""" + supplies = [{'daysleft': 40}, {'daysleft': 6}, {'daysleft': None}] + + assert soonest(supplies) == 6 + + +def test_soonest_is_none_when_nothing_can_be_estimated(): + assert soonest([{'daysleft': None}, {'daysleft': None}]) is None + + +def test_burn_rate_needs_elapsed_time(): + """Several readings in the same second is not a rate.""" + clock = str(int(START.timestamp())) + points = normalise([(clock, '90'), (clock, '80'), (clock, '70'), (clock, '60')]) + + assert burn_rate(points) is None + + +def test_forecast_endpoint_says_so_when_zabbix_is_absent(client, db): + """A dashboard must be told the data is missing, not shown an empty list + that reads as 'nothing runs out soon'.""" + response = client.get('/api/printers/supplies/forecast') + + assert response.status_code == 200 + body = response.get_json()['data'] + assert body['available'] is False + assert 'not configured' in body['reason'] + assert body['printers'] == []