Zabbix supply backend rebuild + data-driven model supplies

Rewrite the printer Zabbix integration (Bearer auth, host-by-IP, tag-based
supply lookup, ping) and replace the hardcoded toner table with a
modelsupplies table + CRUD + seed. Add mock Zabbix server, live test
harness, and the Playwright screenshot tooling.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
cproudlock
2026-06-26 08:35:02 -04:00
parent 9d78b363dc
commit f1b3b65532
14 changed files with 1411 additions and 103 deletions

View File

@@ -1,5 +1,19 @@
"""Printers plugin services."""
from .zabbix_service import ZabbixService
from .supply_parts import (
classifysupply,
derivesupplytype,
derivecolor,
lookupsupplies,
)
from .seed_supplies import seedsupplies
__all__ = ['ZabbixService']
__all__ = [
'ZabbixService',
'classifysupply',
'derivesupplytype',
'derivecolor',
'lookupsupplies',
'seedsupplies',
]

View File

@@ -0,0 +1,362 @@
"""Seed data for model -> supply mappings.
Corrected against official HP and Xerox sources (verification pass
2026-06-25). Replaces the old hardcoded supply_parts table, which had
roughly ten wrong part numbers, scrambled colors, and HP high-yield
cartridges mislabeled as metered.
Each family matches printer models by substring (matchkeys), the same way
the classic ASP report did, then attaches its supplies. seedsupplies()
finds or creates the vendor and a canonical model row, then inserts any
missing supply rows. Re-running is safe: existing part numbers are skipped.
Key facts encoded here:
- HP "X" = high yield, NOT metered. HP metered cartridges are contractual
"C-suffix" SKUs and exist only on Enterprise/Managed hardware, so the
Pro color families (M454/M479, M251/M252/M277, M254/M255) have none.
- Xerox uses distinct sold / metered / dmo part numbers per color.
"""
import logging
from shopdb.extensions import db
from shopdb.core.models import Vendor, Model
from shopdb.core.models.machine import MachineType
from ..models import ModelSupply
logger = logging.getLogger(__name__)
def _toner(color, tier, partnumber, marketingname, pageyield=None):
return {
'supplytype': 'toner', 'color': color, 'capacitytier': tier,
'partnumber': partnumber, 'marketingname': marketingname,
'pageyield': pageyield,
}
def _part(supplytype, partnumber, marketingname, notes=None):
return {
'supplytype': supplytype, 'color': 'none', 'capacitytier': 'standard',
'partnumber': partnumber, 'marketingname': marketingname,
'pageyield': None, 'notes': notes,
}
# Corrected supply catalog. Each entry: vendor, canonical model name,
# substring matchkeys, and the list of supplies.
SEED = [
# ----- HP color, Pro (no metered variant exists) -----
{
'vendor': 'HP', 'canonical': 'HP Color LaserJet Pro M454 / M479',
'matchkeys': ['M454', 'M479'],
'supplies': [
_toner('black', 'standard', 'W2020A', '414A Black', 2400),
_toner('black', 'high', 'W2020X', '414X Black', 7500),
_toner('cyan', 'standard', 'W2021A', '414A Cyan', 2100),
_toner('cyan', 'high', 'W2021X', '414X Cyan', 6000),
_toner('yellow', 'standard', 'W2022A', '414A Yellow', 2100),
_toner('yellow', 'high', 'W2022X', '414X Yellow', 6000),
_toner('magenta', 'standard', 'W2023A', '414A Magenta', 2100),
_toner('magenta', 'high', 'W2023X', '414X Magenta', 6000),
],
},
{
'vendor': 'HP', 'canonical': 'HP Color LaserJet Pro M251 / M252 / M277',
'matchkeys': ['M251', 'M252', 'M277', 'M274'],
'supplies': [
_toner('black', 'standard', 'CF400A', '201A Black', 1500),
_toner('black', 'high', 'CF400X', '201X Black', 2800),
_toner('cyan', 'standard', 'CF401A', '201A Cyan', 1400),
_toner('cyan', 'high', 'CF401X', '201X Cyan', 2500),
_toner('yellow', 'standard', 'CF402A', '201A Yellow', 1400),
_toner('yellow', 'high', 'CF402X', '201X Yellow', 2500),
_toner('magenta', 'standard', 'CF403A', '201A Magenta', 1400),
_toner('magenta', 'high', 'CF403X', '201X Magenta', 2500),
],
},
{
'vendor': 'HP', 'canonical': 'HP Color LaserJet Pro M254 / M255',
'matchkeys': ['M254', 'M255', 'M280', 'M281'],
'supplies': [
_toner('black', 'standard', 'CF500A', '202A Black', 1400),
_toner('black', 'high', 'CF500X', '202X Black', 3200),
_toner('cyan', 'standard', 'CF501A', '202A Cyan', 1300),
_toner('cyan', 'high', 'CF501X', '202X Cyan', 2500),
_toner('yellow', 'standard', 'CF502A', '202A Yellow', 1300),
_toner('yellow', 'high', 'CF502X', '202X Yellow', 2500),
_toner('magenta', 'standard', 'CF503A', '202A Magenta', 1300),
_toner('magenta', 'high', 'CF503X', '202X Magenta', 2500),
],
},
# ----- HP mono (X = high yield; XC/YC = contractual metered) -----
{
'vendor': 'HP', 'canonical': 'HP LaserJet M404 / M406 / M428 / M430',
'matchkeys': ['M404', 'M406', 'M428', 'M430'],
'supplies': [
_toner('black', 'standard', 'CF258A', '58A Black', 3000),
_toner('black', 'high', 'CF258X', '58X Black', 10000),
_toner('black', 'metered', 'CF258XC', '58X Black (Contract)', 10000),
],
},
{
'vendor': 'HP', 'canonical': 'HP LaserJet M607 / M608 / M609 / M631 / M632 / M633',
'matchkeys': ['M607', 'M608', 'M609', 'M631', 'M632', 'M633'],
'supplies': [
_toner('black', 'standard', 'CF237A', '37A Black', 11000),
_toner('black', 'high', 'CF237X', '37X Black', 25000),
# 37Y extra-high does NOT fit the M607
_toner('black', 'extrahigh', 'CF237Y', '37Y Black (not M607)', 41000),
_toner('black', 'metered', 'CF237YC', '37Y Black (Contract)', 41000),
],
},
{
'vendor': 'HP', 'canonical': 'HP LaserJet M506 / M527 / M501',
'matchkeys': ['M506', 'M527', 'M501'],
'supplies': [
_toner('black', 'standard', 'CF287A', '87A Black', 9000),
_toner('black', 'high', 'CF287X', '87X Black', 18000),
_toner('black', 'metered', 'CF287XC', '87X Black (Contract)', 18000),
],
},
{
'vendor': 'HP', 'canonical': 'HP LaserJet M602 / M603 / M4555',
'matchkeys': ['M602', 'M603', 'M4555'],
'supplies': [
_toner('black', 'standard', 'CE390A', '90A Black', 10000),
# 90X does NOT fit the M601
_toner('black', 'high', 'CE390X', '90X Black (not M601)', 24000),
_toner('black', 'metered', 'CE390XC', '90X Black (Contract)', 24000),
],
},
{
'vendor': 'HP', 'canonical': 'HP LaserJet P3015 / M521 / M525',
'matchkeys': ['P3015', 'M521', 'M525'],
'supplies': [
_toner('black', 'standard', 'CE255A', '55A Black', 6000),
_toner('black', 'high', 'CE255X', '55X Black', 12500),
_toner('black', 'metered', 'CE255XC', '55X Black (Contract)', 12500),
],
},
{
'vendor': 'HP', 'canonical': 'HP LaserJet 4250 / 4350',
'matchkeys': ['4250', '4350'],
'supplies': [
_toner('black', 'standard', 'Q5942A', '42A Black', 10000),
_toner('black', 'high', 'Q5942X', '42X Black', 20000),
],
},
{
'vendor': 'HP', 'canonical': 'HP LaserJet M610 / M611 / M612 / M634 / M635 / M636',
'matchkeys': ['M610', 'M611', 'M612', 'M634', 'M635', 'M636'],
'supplies': [
_toner('black', 'standard', 'W1470A', '147A Black', 10500),
_toner('black', 'high', 'W1470X', '147X Black', 25200),
],
},
{
'vendor': 'HP', 'canonical': 'HP LaserJet Pro 4001 / 4101 (148 series)',
'matchkeys': ['4001', '4101', '4002', '4102'],
'supplies': [
_toner('black', 'high', 'W1480X', '148X Black', 9500),
_toner('black', 'metered', 'W1020XC', '148 Black (Contract)', 9500),
],
},
# ----- Xerox VersaLink color -----
{
'vendor': 'Xerox', 'canonical': 'Xerox VersaLink C415',
'matchkeys': ['C415'],
'supplies': [
_toner('black', 'standard', '006R04677', 'C415 Black', 2400),
_toner('cyan', 'standard', '006R04678', 'C415 Cyan', 2000),
_toner('magenta', 'standard', '006R04679', 'C415 Magenta', 2000),
_toner('yellow', 'standard', '006R04680', 'C415 Yellow', 2000),
_toner('black', 'high', '006R04685', 'C415 Black (High)', 10500),
_toner('cyan', 'high', '006R04686', 'C415 Cyan (High)', 7000),
_toner('magenta', 'high', '006R04687', 'C415 Magenta (High)', 7000),
_toner('yellow', 'high', '006R04688', 'C415 Yellow (High)', 7000),
_toner('black', 'metered', '006R04693', 'C415 Black (Metered)', 15000),
_toner('cyan', 'metered', '006R04694', 'C415 Cyan (Metered)', 10000),
_toner('magenta', 'metered', '006R04695', 'C415 Magenta (Metered)', 10000),
_toner('yellow', 'metered', '006R04696', 'C415 Yellow (Metered)', 10000),
_part('drum', '013R00701', 'C415 Drum / Imaging Unit'),
_part('waste', '008R13325', 'C415 Waste Cartridge'),
],
},
{
'vendor': 'Xerox', 'canonical': 'Xerox VersaLink C405',
'matchkeys': ['C405', 'C400'],
'supplies': [
_toner('black', 'standard', '106R03500', 'C405 Black', 2500),
_toner('yellow', 'standard', '106R03501', 'C405 Yellow', 2500),
_toner('cyan', 'standard', '106R03502', 'C405 Cyan', 2500),
_toner('magenta', 'standard', '106R03503', 'C405 Magenta', 2500),
_toner('black', 'high', '106R03512', 'C405 Black (High)', 5000),
_toner('black', 'extrahigh', '106R03524', 'C405 Black (Extra High)', 10500),
_part('drum', '108R01121', 'C400 / C405 Drum'),
_part('waste', '108R01124', 'C400 / C405 Waste Cartridge'),
],
},
{
'vendor': 'Xerox', 'canonical': 'Xerox VersaLink C7100 / C7120 / C7125 / C7130',
'matchkeys': ['C7100', 'C7120', 'C7125', 'C7130'],
'supplies': [
_toner('black', 'standard', '006R01824', 'C7125 Black (Sold)', 31300),
_toner('cyan', 'standard', '006R01825', 'C7125 Cyan (Sold)', 18500),
_toner('magenta', 'standard', '006R01826', 'C7125 Magenta (Sold)', 18500),
_toner('yellow', 'standard', '006R01827', 'C7125 Yellow (Sold)', 18500),
_toner('black', 'metered', '006R01820', 'C7125 Black (Metered)', 22500),
_toner('cyan', 'metered', '006R01821', 'C7125 Cyan (Metered)', 15500),
_toner('magenta', 'metered', '006R01822', 'C7125 Magenta (Metered)', 15500),
_toner('yellow', 'metered', '006R01823', 'C7125 Yellow (Metered)', 15500),
_part('drum', '013R00688', 'C7125 Drum'),
_part('waste', '115R00129', 'C7000 / C7100 Waste Cartridge'),
],
},
{
'vendor': 'Xerox', 'canonical': 'Xerox VersaLink B7100 / B7125 / B7130 / B7135',
'matchkeys': ['B7100', 'B7125', 'B7130', 'B7135'],
'supplies': [
_toner('black', 'standard', '006R01818', 'B7125 Black (Sold/High)', 34300),
_toner('black', 'metered', '006R01817', 'B7125 Black (Metered)', 34300),
_toner('black', 'dmo', '006R01819', 'B7125 Black (DMO)', 34300),
_part('drum', '013R00687', 'B7125 Drum'),
_part('waste', '115R00129', 'B7000 / B7100 Waste Cartridge'),
],
},
{
'vendor': 'Xerox', 'canonical': 'Xerox VersaLink B400 / B405',
'matchkeys': ['B400', 'B405'],
'supplies': [
_toner('black', 'standard', '106R03580', 'B405 Black', 5900),
_toner('black', 'high', '106R03582', 'B405 Black (High)', 13900),
_toner('black', 'extrahigh', '106R03584', 'B405 Black (Extra High)', 24600),
_toner('black', 'metered', '106R03586', 'B405 Black (Metered)', 24600),
_part('drum', '101R00554', 'B400 / B405 Drum'),
# note: B405 has no waste cartridge
],
},
# ----- Xerox AltaLink color -----
{
'vendor': 'Xerox', 'canonical': 'Xerox AltaLink C8130 / C8135 / C8145 / C8155 / C8170',
'matchkeys': ['C8130', 'C8135', 'C8145', 'C8155', 'C8170'],
'supplies': [
_toner('black', 'standard', '006R01746', 'C8135 Black (Sold)'),
_toner('cyan', 'standard', '006R01747', 'C8135 Cyan (Sold)'),
_toner('magenta', 'standard', '006R01748', 'C8135 Magenta (Sold)'),
_toner('yellow', 'standard', '006R01749', 'C8135 Yellow (Sold)'),
_toner('black', 'metered', '006R01742', 'C8135 Black (Metered)'),
_toner('cyan', 'metered', '006R01743', 'C8135 Cyan (Metered)'),
_toner('magenta', 'metered', '006R01744', 'C8135 Magenta (Metered)'),
_toner('yellow', 'metered', '006R01745', 'C8135 Yellow (Metered)'),
_part('drum', '013R00681', 'C8135 Drum'),
_part('waste', '008R08101', 'C8135 Waste Cartridge'),
],
},
{
'vendor': 'Xerox',
'canonical': 'Xerox AltaLink C8030 / C8035 / C8045 / C8055 / EC8036',
'matchkeys': ['C8030', 'C8035', 'C8045', 'C8055', 'EC8036', 'EC8056', 'C8036'],
'supplies': [
_toner('black', 'standard', '006R01697', 'C8030 Black (Sold)'),
_toner('cyan', 'standard', '006R01698', 'C8030 Cyan (Sold)'),
_toner('magenta', 'standard', '006R01699', 'C8030 Magenta (Sold)'),
_toner('yellow', 'standard', '006R01700', 'C8030 Yellow (Sold)'),
_toner('black', 'metered', '006R01701', 'C8030 Black (Metered)'),
_toner('cyan', 'metered', '006R01702', 'C8030 Cyan (Metered)'),
_toner('magenta', 'metered', '006R01703', 'C8030 Magenta (Metered)'),
_toner('yellow', 'metered', '006R01704', 'C8030 Yellow (Metered)'),
# legacy WorkCentre 78xx cross-reference set, still compatible
_toner('black', 'high', '006R01509', 'WC7800 Black (legacy)'),
_toner('yellow', 'high', '006R01510', 'WC7800 Yellow (legacy)'),
_toner('magenta', 'high', '006R01511', 'WC7800 Magenta (legacy)'),
_toner('cyan', 'high', '006R01512', 'WC7800 Cyan (legacy)'),
_part('drum', '013R00662', 'C8030 / EC8036 Drum'),
_part('waste', '008R13061', 'C8030 / EC8036 Waste Cartridge'),
],
},
]
def _find_or_create_vendor(name):
vendor = Vendor.query.filter_by(vendor=name).first()
if not vendor:
vendor = Vendor(vendor=name)
db.session.add(vendor)
db.session.flush()
return vendor
def _matching_models(matchkeys, vendorid):
"""Existing models whose modelnumber contains any matchkey (this vendor)."""
found = []
for key in matchkeys:
rows = Model.query.filter(
Model.vendorid == vendorid,
Model.modelnumber.ilike(f'%{key}%'),
).all()
for row in rows:
if row not in found:
found.append(row)
return found
def seedsupplies():
"""Seed corrected model->supply data. Idempotent.
Returns a summary dict with counts. Attaches supplies to every existing
model that matches a family's keys; if a family matches no existing model,
creates a canonical model row so its toners are still available.
"""
printertype = MachineType.query.filter_by(category='Printer').first()
printertypeid = printertype.machinetypeid if printertype else None
models_touched = 0
supplies_added = 0
for family in SEED:
vendor = _find_or_create_vendor(family['vendor'])
targets = _matching_models(family['matchkeys'], vendor.vendorid)
if not targets:
model = Model(
modelnumber=family['canonical'],
vendorid=vendor.vendorid,
machinetypeid=printertypeid,
)
db.session.add(model)
db.session.flush()
targets = [model]
for model in targets:
models_touched += 1
existing = {
supply.partnumber
for supply in ModelSupply.query.filter_by(
modelnumberid=model.modelnumberid
).all()
}
for spec in family['supplies']:
if spec['partnumber'] in existing:
continue
db.session.add(ModelSupply(
modelnumberid=model.modelnumberid,
supplytype=spec['supplytype'],
color=spec['color'],
capacitytier=spec['capacitytier'],
partnumber=spec['partnumber'],
marketingname=spec.get('marketingname'),
pageyield=spec.get('pageyield'),
notes=spec.get('notes'),
))
supplies_added += 1
db.session.commit()
logger.info(
"Seeded printer supplies: %d models touched, %d supplies added",
models_touched, supplies_added
)
return {'modelstouched': models_touched, 'suppliesadded': supplies_added}

View File

@@ -0,0 +1,107 @@
"""Printer supply classification and part-number lookup.
Part numbers now live in the modelsupplies table (see seed_supplies.py),
managed through the API/UI. This module keeps the runtime logic that is not
per-model data: classifying a reported level into ok/low/critical (waste
cartridges invert), deriving supply type and color from a Zabbix item name,
and reading the matching part numbers out of the database.
"""
from typing import Dict, List, Optional
# alert thresholds (percent remaining)
CRITICAL_THRESHOLD = 5
LOW_THRESHOLD = 10
def derivesupplytype(name: str) -> str:
"""Map a Zabbix item name to a supply type."""
lowername = (name or "").lower()
if "waste" in lowername:
return "waste"
if "drum" in lowername or "imaging" in lowername:
return "drum"
if "maintenance" in lowername or "fuser" in lowername:
return "maintenance"
return "toner"
def derivecolor(name: str, tagcolor: Optional[str] = None) -> str:
"""Best-effort supply color from a Zabbix color tag, then the item name."""
color = (tagcolor or "").lower()
if "black" in color:
return "black"
if color in ("cyan", "magenta", "yellow"):
return color
if color in ("grey", "gray"):
return "gray"
lowername = (name or "").lower()
for candidate in ("cyan", "magenta", "yellow", "black"):
if candidate in lowername:
return candidate
return "none"
def classifysupply(level: float, name: str, vendor: Optional[str]) -> Dict:
"""Classify one supply item into ok/low/critical.
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.
"""
lowername = (name or "").lower()
iswaste = "waste" in lowername
isdrum = "drum" in lowername or "imaging" in lowername
isxerox = bool(vendor) and "xerox" in vendor.lower()
if iswaste and not isxerox:
remaining = 100 - level
else:
remaining = level
if remaining <= CRITICAL_THRESHOLD:
status = "critical"
elif remaining <= LOW_THRESHOLD:
status = "low"
else:
status = "ok"
return {
"status": status,
"remaining": round(remaining, 1),
"iswaste": iswaste,
"isdrum": isdrum,
}
def lookupsupplies(modelnumberid: Optional[int], color: str,
supplytype: str) -> List[Dict]:
"""Part-number options for a model + color + supply type, from the DB.
Returns every matching capacity tier (standard / high / metered / ...) so
the report can show all reorder options, like the classic report did.
"""
if not modelnumberid:
return []
from ..models import ModelSupply
query = ModelSupply.query.filter_by(
modelnumberid=modelnumberid,
supplytype=supplytype,
isactive=True,
)
# toners are color-specific; drum/waste/maintenance are not
if supplytype == 'toner' and color and color != 'none':
query = query.filter_by(color=color)
rows = query.order_by(ModelSupply.capacitytier).all()
return [{
'partnumber': row.partnumber,
'marketingname': row.marketingname,
'capacitytier': row.capacitytier,
'pageyield': row.pageyield,
} for row in rows]

View File

@@ -1,4 +1,22 @@
"""Zabbix service for real-time printer supply lookups."""
"""Zabbix service for real-time printer supply lookups.
Ports the classic ASP shopdb Zabbix integration (includes/zabbix.asp and
includes/zabbix_all_supplies.asp) to Python. Key behaviours preserved from
the live integration:
- Auth via an Authorization: Bearer <token> header (Zabbix 6.0+ / 7.0).
The old payload "auth" field is rejected by Zabbix 7.0.
- Hosts are named by IP address, so a host is located with
host.get filter {host: [ip]}, not by interface address.
- Supply levels come from items tagged component=supplies AND type=level,
not from a key_ substring search.
- Each level item carries a color tag used for display and part lookup.
Configuration (database Setting overrides env var):
ZABBIX_ENABLED: turn the integration on
ZABBIX_URL: base URL or full api_jsonrpc.php URL
ZABBIX_TOKEN: API token
"""
import logging
from typing import Dict, List, Optional
@@ -12,53 +30,59 @@ logger = logging.getLogger(__name__)
class ZabbixService:
"""
Zabbix API service for real-time printer supply lookups.
"""Zabbix API client for printer supply and ping lookups."""
Queries Zabbix by IP address to get current supply levels.
Use getsuppliesbyip_cached() for cached lookups or
getsuppliesbyip() for live data.
CACHE_TTL = 300 # 5 min, matches the classic Application cache
REACHABLE_CHECK_TTL = 60
Configuration:
ZABBIX_ENABLED: Set to True to enable Zabbix integration (default: False)
ZABBIX_URL: Zabbix API URL (e.g., http://zabbix.example.com:8080)
ZABBIX_TOKEN: Zabbix API authentication token
"""
# quick fail for the reachability probe
REACHABLE_TIMEOUT = 1.0
# (connect, read) for real API calls; item.get is slow, give it room
API_TIMEOUT = (3.0, 5.0)
CACHE_TTL = 600 # 10 minutes
REACHABLE_CHECK_TTL = 60 # Check reachability every 60 seconds
# supply-level item tags, mirrors zabbix.asp GetPrinterTonerLevels
SUPPLY_TAGS = [
{"tag": "component", "value": "supplies", "operator": 0},
{"tag": "type", "value": "level", "operator": 0},
]
def __init__(self):
self._url = None
self._token = None
self._enabled = None
# -- configuration -------------------------------------------------------
@property
def isenabled(self) -> bool:
"""Check if Zabbix integration is enabled."""
# Check database setting first, fall back to env var
"""Whether the integration is switched on."""
from shopdb.core.models import Setting
db_enabled = Setting.get('zabbix_enabled')
if db_enabled is not None:
return bool(db_enabled)
# Fall back to env var for backwards compatibility
return current_app.config.get('ZABBIX_ENABLED', False)
@property
def isconfigured(self) -> bool:
"""Check if Zabbix is enabled and configured."""
"""Enabled, and a URL plus token are present."""
if not self.isenabled:
return False
# Check database settings first, fall back to env vars
from shopdb.core.models import Setting
self._url = Setting.get('zabbix_url') or current_app.config.get('ZABBIX_URL')
self._token = Setting.get('zabbix_token') or current_app.config.get('ZABBIX_TOKEN')
return bool(self._url and self._token)
@property
def endpoint(self) -> str:
"""Full JSON-RPC endpoint. Accept a base URL or the full path."""
url = (self._url or "").rstrip("/")
if url.endswith("api_jsonrpc.php"):
return url
return f"{url}/api_jsonrpc.php"
@property
def isreachable(self) -> bool:
"""Check if Zabbix is reachable (cached for 60 seconds)."""
if not self.isenabled or not self.isconfigured:
"""Cheap connectivity probe, cached for 60s."""
if not self.isconfigured:
return False
cache_key = 'zabbix_reachable'
@@ -66,138 +90,165 @@ class ZabbixService:
if cached is not None:
return cached
# Quick connectivity check with 500ms timeout
try:
response = requests.get(
f"{self._url}/api_jsonrpc.php",
timeout=0.5
)
reachable = response.status_code in (200, 401, 403, 405)
response = requests.get(self.endpoint, timeout=self.REACHABLE_TIMEOUT)
# any non-5xx answer means the web tier responded, so the server is
# up. Zabbix 7.0 returns 412 to a bare GET on api_jsonrpc.php (it
# wants a POST with json-rpc content type); that still counts.
reachable = response.status_code < 500
except requests.RequestException:
reachable = False
cache.set(cache_key, reachable, timeout=self.REACHABLE_CHECK_TTL)
logger.debug(f"Zabbix reachability check: {reachable}")
logger.debug("Zabbix reachability: %s", reachable)
return reachable
def _apicall(self, method: str, params: Dict) -> Optional[Dict]:
"""Make a Zabbix API call."""
# -- low level call ------------------------------------------------------
def _apicall(self, method: str, params: Dict) -> Optional[object]:
"""One JSON-RPC call. Returns the result, or None on any error."""
if not self.isconfigured:
return None
payload = {
'jsonrpc': '2.0',
'method': method,
'params': params,
'auth': self._token,
'id': 1
"jsonrpc": "2.0",
"method": method,
"params": params,
"id": 1,
}
headers = {
"Content-Type": "application/json-rpc",
"Authorization": f"Bearer {self._token}",
}
try:
response = requests.post(
f"{self._url}/api_jsonrpc.php",
self.endpoint,
json=payload,
headers={'Content-Type': 'application/json'},
timeout=0.5 # 500ms timeout - fail fast if Zabbix is slow/unreachable
headers=headers,
timeout=self.API_TIMEOUT,
)
response.raise_for_status()
data = response.json()
if 'error' in data:
logger.error(f"Zabbix API error: {data['error']}")
return None
return data.get('result')
except requests.RequestException as e:
logger.error(f"Zabbix API request failed: {e}")
except (requests.RequestException, ValueError) as exc:
logger.error("Zabbix %s call failed: %s", method, exc)
return None
def gethostbyip(self, ip: str) -> Optional[Dict]:
"""Find a Zabbix host by IP address."""
result = self._apicall('host.get', {
'output': ['hostid', 'host', 'name'],
'filter': {'ip': ip},
'selectInterfaces': ['ip']
})
if "error" in data:
logger.error("Zabbix %s error: %s", method, data["error"])
return None
return data.get("result")
# -- host / item lookups -------------------------------------------------
def gethostidbyip(self, ip: str) -> Optional[str]:
"""Host id for a printer. Hosts are named by IP in this Zabbix."""
result = self._apicall("host.get", {
"output": ["hostid"],
"filter": {"host": [ip]},
})
if result:
return result[0] if result else None
return result[0].get("hostid")
return None
def getsuppliesbyip(self, ip: str) -> Optional[List[Dict]]:
"""
Get printer supply levels by IP address.
def _extract_color(self, item: Dict) -> str:
"""Pull and normalise the color tag, falling back to the item name."""
color = ""
for tag in item.get("tags", []) or []:
if tag.get("tag") == "color":
color = (tag.get("value") or "").lower()
break
if "black" in color:
color = "black"
elif color in ("grey", "gray"):
color = "gray"
Returns list of supplies with name and level percentage.
if not color:
name = (item.get("name") or "").lower()
for candidate in ("cyan", "magenta", "yellow", "black"):
if candidate in name:
color = candidate
break
if not color and ("gray" in name or "grey" in name):
color = "gray"
return color
def getsuppliesbyip(self, ip: str) -> Optional[List[Dict]]:
"""Current supply levels for a printer, by IP.
Returns a list of dicts {name, level, color, itemid, status, state},
or None if the host is not in Zabbix. Drum/maintenance items are kept
(callers decide what to surface); only disabled (status=1) and
unsupported (state=1) items are dropped, matching the classic report.
"""
# Find host by IP
host = self.gethostbyip(ip)
if not host:
logger.debug(f"No Zabbix host found for IP {ip}")
hostid = self.gethostidbyip(ip)
if not hostid:
logger.debug("No Zabbix host for IP %s", ip)
return None
hostid = host['hostid']
# Get supply-related items
items = self._apicall('item.get', {
'output': ['itemid', 'name', 'lastvalue', 'key_'],
'hostids': hostid,
'search': {
'key_': 'supply' # Common key pattern for printer supplies
},
'searchWildcardsEnabled': True
items = self._apicall("item.get", {
"output": ["itemid", "name", "lastvalue", "lastclock",
"units", "status", "state"],
"hostids": hostid,
"selectTags": "extend",
"evaltype": 0, # and
"tags": self.SUPPLY_TAGS,
"sortfield": "name",
"monitored": True,
})
if not items:
# Try alternate patterns
items = self._apicall('item.get', {
'output': ['itemid', 'name', 'lastvalue', 'key_'],
'hostids': hostid,
'search': {
'name': 'toner'
},
'searchWildcardsEnabled': True
})
if not items:
return []
supplies = []
for item in items:
# skip disabled or unsupported items
if str(item.get("status", "0")) != "0":
continue
if str(item.get("state", "0")) != "0":
continue
try:
level = int(float(item.get('lastvalue', 0)))
level = int(float(item.get("lastvalue", 0)))
except (ValueError, TypeError):
level = 0
supplies.append({
'name': item.get('name', 'Unknown'),
'level': level,
'itemid': item.get('itemid'),
'key': item.get('key_'),
"name": item.get("name", "Unknown"),
"level": level,
"color": self._extract_color(item),
"itemid": item.get("itemid"),
})
return supplies
def gethostid(self, ip: str) -> Optional[str]:
"""Get Zabbix host ID for an IP address."""
host = self.gethostbyip(ip)
return host['hostid'] if host else None
def getpingstatus(self, ip: str) -> str:
"""ICMP ping state for a printer: '1' up, '0' down, '-1' unknown."""
hostid = self.gethostidbyip(ip)
if not hostid:
return "-1"
items = self._apicall("item.get", {
"output": ["lastvalue"],
"hostids": hostid,
"search": {"key_": "icmpping"},
})
if items:
return str(items[0].get("lastvalue", "-1"))
return "-1"
# -- caching wrappers ----------------------------------------------------
def getsuppliesbyip_cached(self, ip: str) -> Optional[List[Dict]]:
"""Get printer supply levels with caching (10-minute TTL)."""
cache_key = f'zabbix_supplies_{ip}'
"""getsuppliesbyip with a 5-minute per-IP cache."""
cache_key = f"zabbix_supplies_{ip}"
result = cache.get(cache_key)
if result is not None:
return result
result = self.getsuppliesbyip(ip)
if result is not None:
cache.set(cache_key, result, timeout=self.CACHE_TTL)
return result
def clearcache(self, ip: str = None):
"""Clear cached supply data for one IP or all."""
"""Drop cached supply data for one IP, plus the low-supplies roll-up."""
if ip:
cache.delete(f'zabbix_supplies_{ip}')
cache.delete('printers_low_supplies')
cache.delete(f"zabbix_supplies_{ip}")
cache.delete("printers_low_supplies")
cache.delete("zabbix_reachable")