# Printer supplies API

Reference for the toner and supply endpoints in ShopDB, and a working single-page dashboard you can lift straight out of this file.

Where the numbers come from. ShopDB does not poll printers itself. Levels are read from Zabbix by IP address, so every endpoint below returns empty results unless `zabbix_enabled`, `zabbix_url` and `zabbix_token` are set under Settings > Integrations. That failure is deliberately soft: you get `200` with an empty list, never an error, so a wall dashboard does not blow up when Zabbix is down.

## Endpoints

Method and path | Auth | What it returns
--- | --- | ---
`GET /api/printers/lowsupplies` | optional | Every printer with at least one supply below threshold, plus a summary. The toner report. Cached 5 minutes.
`GET /api/printers/<printerid>/supplies` | optional | Live levels for one printer, read from Zabbix on request. Not cached.
`POST /api/printers/supplies/refresh` | required + `printers.create` | Clears the cache so the next read is fresh. Backs the report's Refresh button.
`GET /api/printers/supplies/meta` | optional | Allowed supply types, colors and capacity tiers.
`GET /api/printers/models/<modelnumberid>/supplies` | optional | Cartridge part numbers for a model.
`GET /api/printers/lookup?ip=` or `?fqdn=` | optional | Resolve an IP or hostname to a printer id.

"optional" means the endpoint accepts a token but does not need one: a logged-out page can read it. Only the refresh endpoint requires a token, and the account behind it needs the `printers.create` permission.

## Response shape

Every ShopDB response is wrapped. Your data is under `data`:

    {
      "status": "success",
      "data": { ... },
      "meta": { "requestid": "6d9d0bb6", "timestamp": "2026-08-11T13:47:41.250953Z" }
    }

### GET /api/printers/lowsupplies

    {
      "printers": [
        {
          "printerid": 42,
          "printername": "Printer-10-129-22-15",
          "assetnumber": "PRN-0042",
          "ipaddress": "10.129.22.15",
          "vendor": "Xerox",
          "model": "AltaLink C8145",
          "location": "Building 1 - Cell 4",
          "supplies": [
            {
              "name": "Cyan Toner Cartridge",
              "level": 8,
              "remaining": 8.0,
              "status": "low",
              "color": "cyan",
              "supplytype": "toner",
              "iswaste": false,
              "isdrum": false,
              "partnumbers": ["006R01737"]
            }
          ]
        }
      ],
      "summary": { "total_checked": 37, "low": 4, "critical": 1 }
    }

Only printers with something below threshold appear in `printers`. `total_checked` counts every printer Zabbix answered for, so "37 checked, 5 listed" means 32 are healthy. A printer is counted once in the summary, as `critical` if any of its supplies is critical, otherwise as `low`.

### GET /api/printers/<printerid>/supplies

    {
      "ipaddress": "10.129.22.15",
      "pingstatus": "1",
      "supplies": [ /* same supply objects as above */ ]
    }

`pingstatus` is Zabbix's reachability value, `"-1"` when Zabbix is unconfigured or unreachable. This endpoint hits Zabbix on every call, so poll it for one printer on a detail view, not for forty on a dashboard - use `lowsupplies` for that.

## The supply object

Field | Meaning
--- | ---
`name` | Raw name from the printer, e.g. `"Black Toner Cartridge"`.
`level` | Raw value as reported. For most waste cartridges this is percent full.
`remaining` | Normalised percent remaining. Use this one for bars and text.
`status` | ok low critical
`color` | `black`, `cyan`, `magenta`, `yellow`, `none`.
`supplytype` | `toner`, `drum`, `waste`, `maintenance`.
`iswaste`, `isdrum` | Convenience flags; a waste cartridge is usually worth showing differently.
`partnumbers` | Order codes from the `modelsupplies` table. Empty until someone fills them in for that model.

Do not compute status from `level` yourself. Thresholds are `remaining <= 5` critical, `<= 10` low. The catch is direction: a full waste cartridge is bad, so for waste ShopDB converts to `100 - level` - except on Xerox, which already reports waste as capacity remaining. That vendor rule is why `remaining` and `status` exist. Read them, do not re-derive them.

## Before you build

### Base path

Instances are served under a subpath, so the API is at `https://shopdb.example.net/shopdb/api/...` - production on this server - or `/ops/api/...` for the dev instance beside it, not at the domain root. Make the base a variable; do not hardcode `/api`.

### Same origin, or CORS

The simplest deployment is to drop your HTML file into the instance's web root so it is served from the same origin - then `fetch` just works. A page served from anywhere else is a cross-origin request, and the browser will block it unless that origin is in the server's `CORS_ORIGINS` allowlist (an env var; production refuses to start with a wildcard).

### Caching and polling

`lowsupplies` is cached for 5 minutes server-side, so polling every 30 seconds gets you the same payload nine times out of ten and buys nothing. Poll every 2 to 5 minutes. If you need a manual Refresh button, call the refresh endpoint first - that one needs a token.

## Minimal example

    const BASE = 'https://shopdb.example.net/shopdb';   // no trailing /api

    async function lowSupplies() {
      const response = await fetch(`${BASE}/api/printers/lowsupplies`);
      if (!response.ok) throw new Error(`HTTP ${response.status}`);
      const body = await response.json();
      return body.data;                                  // { printers, summary }
    }

    lowSupplies().then(data => {
      console.log(`${data.summary.critical} critical, ${data.summary.low} low`);
      for (const printer of data.printers) {
        for (const supply of printer.supplies) {
          if (supply.status === 'ok') continue;
          console.log(`${printer.printername}: ${supply.name} ${supply.remaining}%`);
        }
      }
    });

## Polling, with the failure cases handled

    const BASE = 'https://shopdb.example.net/shopdb';
    const POLL_MS = 3 * 60 * 1000;      // server caches 5 min; faster buys nothing

    async function tick() {
      try {
        const response = await fetch(`${BASE}/api/printers/lowsupplies`, {
          headers: { 'Accept': 'application/json' }
        });
        if (!response.ok) throw new Error(`HTTP ${response.status}`);
        const { data } = await response.json();

        // Zabbix off or unreachable: 200 with nothing in it. Say so, rather than
        // rendering "0 low" as if every printer were healthy.
        if (data.summary.total_checked === 0) {
          render.unavailable();
        } else {
          render.board(data);
        }
      } catch (err) {
        // A wall display must survive a blip: keep the last good render.
        console.error('supply poll failed', err);
        render.stale(err);
      } finally {
        setTimeout(tick, POLL_MS);      // chained, so a slow response cannot pile up
      }
    }
    tick();

## Refreshing on demand (needs a token)

Create a Personal Access Token under Settings > API Tokens for an account with `printers.create`. Treat it as a credential: a token embedded in a page anyone can open is readable by anyone who opens it, so use this on an operator page behind login, not on a lobby display.

    async function refreshAndReload(token) {
      await fetch(`${BASE}/api/printers/supplies/refresh`, {
        method: 'POST',
        headers: { 'Authorization': `Bearer ${token}` }
      });
      return lowSupplies();            // next read pulls fresh values from Zabbix
    }

## One printer, live

    // Skips the cache and asks Zabbix now. Fine for a detail page, wrong for a loop.
    async function printerSupplies(printerid) {
      const response = await fetch(`${BASE}/api/printers/${printerid}/supplies`);
      const { data } = await response.json();
      if (data.pingstatus === '-1') return { offline: true, supplies: [] };
      return data;
    }

    // Have an IP but not an id? Resolve it first.
    async function printerIdByIp(ip) {
      const response = await fetch(`${BASE}/api/printers/lookup?ip=${encodeURIComponent(ip)}`);
      const { data } = await response.json();
      return data?.printerid ?? null;
    }

## Live dashboard

The section below is the real thing, running in this page. Point it at an instance and it will render. Served from a different origin than the API, it will fail on CORS - that is the browser doing its job, and the fix is to host the file on the instance itself.

Enter a base URL and press Load.

## Notes for a wall display

- Show the supplies that need action as rows, and collapse the healthy ones into a single muted line. The endpoint returns every supply for a listed printer, so this is the client's call: a colour laser reports six, and rendering all six buries the empty one. Keep the healthy summary rather than dropping it - a cartridge at 12% is not flagged today but is the same walk.
- Chain the poll with `setTimeout` in a `finally`, not `setInterval`: a slow or hung request cannot then stack up behind itself.
- Keep the last good render when a poll fails. A display that blanks on one dropped request is worse than one showing five-minute-old numbers.
- Distinguish "nothing is low" from "nothing was checked". They both look like an empty list, and only one of them is good news.
- The board reloads itself if you serve it as a ShopDB display; otherwise add a daily `location.reload()` so a deployed fix reaches a screen nobody touches.