Skip to main content

vendingzets-agent

IoT agent for vending machines. It runs on a Raspberry Pi wired to the machine's MDB bus, listens for sales and reports them to Vending Zets, which decrements the stock of the matching slot.

It only reads the bus. It never writes: if the Pi dies or is powered off, the machine keeps selling exactly the same.

Installation

pip install vendingzets-agent

Requires Python 3.11 or newer (Raspberry Pi OS Lite bookworm already ships it).

The package bundles the systemd unit, the NetworkManager dispatcher and the two sample configurations. To extract them:

vendingzets-agent files                 # lists what it ships and where each file goes
vendingzets-agent files --copy /tmp/vz  # extracts them with the right permissions

Configuration

The API key is created in the dashboard, on the machine detail page, and is shown only once. It identifies that machine: the agent sends no other identifier.

/etc/vendingzets/agent.toml:

[agent]
api_base_url = "https://vendingzets-production.up.railway.app/api/v1"
serial_port  = "/dev/ttyAMA0"
queue_path   = "/var/lib/vendingzets/queue.db"

# VMC selection number -> slot code in the system.
[slots]
1 = "A1"
2 = "A2"
3 = "B1"

# CASH sales only, where the bus never says which product was picked
# (see "Known limitation"). Useful when each price identifies a single slot.
[prices]
"0.75" = "A1"
"1.00" = "B1"

The key goes in the environment, not in the file:

export VENDINGZETS_API_KEY="vz_live_..."

Usage

vendingzets-agent check              # is the credential valid? does it reach the backend?
vendingzets-agent run                # listen to the bus and report sales
vendingzets-agent run --simulate     # synthetic sales, no hardware needed
vendingzets-agent queue              # state of the local queue
vendingzets-agent recover            # retry the quarantined sales

queue and recover do not ask for the API key: they are exactly the commands you run standing in front of the machine to see whether anything is still unsent.

--simulate lets you install the Pi and validate credential, queue and slot mapping before the MDB HAT is in place. Careful: simulated sales are recorded for real in the system, so point it at a test machine.

How sales are never lost

Every detected sale is written to SQLite first and only then sent. With no internet it stays in the queue and is retried.

Each sale carries an id generated by the agent and sent in the POST. The backend uses it as an idempotency key: retrying the same sale a thousand times never duplicates it (201 the first time, 200 on retries).

The POST also carries sold_at: the time the sale happened at the machine, not the time it was uploaded. This matters on machines without permanent internet, where the queue may drain days later — without that field the backend timestamps everything with its own NOW() and a week of sales lands in the same minute, with correct stock but useless per-day and per-hour reports.

The backend rejects a sold_at in the future or older than 90 days, which is what a Pi with a drifted clock sends (no RTC and no network means it boots with the time of its last shutdown). In that case the agent does not drop the sale: it resends without the date and leaves the clock problem in the log. You lose the real time, not the sale.

With more than one sale pending, uploads go in batch (POST /agent/sales/batch, up to batch_size per request). With a single one it uses the one-at-a-time endpoint: the batch endpoint has a lower rate limit, meant for a few large calls, and the steady drip of a machine with permanent internet would exhaust it.

The batch response carries the verdict of every sale, and that is its value: without it the agent could not tell which ones to remove from the queue and which to keep. If the whole request fails, none is considered sent — the ones that did get applied on the other side come back as duplicate on the retry, thanks to the id the agent generates.

Errors are treated differently depending on whether they are fixable:

Response What it means What the agent does
201 / 200 recorded / already existed removes it from the queue
404 / 409 / 422 slot does not exist, out of stock, invalid payload quarantines it (see below): out of the queue so it does not block it, but never deleted
401 / 403 bad credential or missing scope keeps it and retries
429, 5xx, network temporary keeps it and retries

In a batch, each item's status says the same: created/duplicate leave the queue, failed is kept for the next attempt, and rejected is resent on its own through the single-sale endpoint — the batch response carries the detail but not the HTTP code, and without it "this slot does not exist" is indistinguishable from "out of stock".

Quarantine: no sale is ever discarded

A sale detected on the bus never disappears. If the product left the machine, the sale exists even if it cannot be reported today. There are three cases where it cannot be, and all three end up in the quarantined_sales table of the local queue instead of in a log.warning:

Case What is stored How it is recovered
The selection is missing from [slots] (or two slots share a cash price) the selection number and the amount the bus reported fix the TOML: the agent rescues it by itself on the next upload
The backend answered 404 (slot code mistyped in [slots]) plus the slot_code that was attempted fix the TOML; changing the mapping retries it right away
The backend answered 409 (it believes the slot is empty) same refill the machine and it goes through on its own: this one is retried every quarantine_retry_interval (1 h), up to quarantine_max_retries (24)
VEND SUCCESS with no VEND REQUEST (the agent started mid-sale) that a sale happened, with no item and no amount cannot be reassigned automatically: it stays listed so you can reconcile against the dashboard

When the retry ceiling is reached the sale is not deleted: it stops retrying on its own and stays visible in vendingzets-agent queue and on the status page, in red. A quarantined sale is a sale that happened and the dashboard does not have, so "0 pending" without that number next to it would read as "everything was uploaded".

The rescue keeps the original id (idempotency: if the sale had in fact been applied on the other side, the retry comes back as duplicate instead of being counted twice) and the sale's own date, not the date of the rescue.

Frame delimiting and the ninth bit

The MDB bus is 9N1: the ninth bit marks whether a byte is an address or data. The HAT is read in 8 bits, so that bit never arrives, and a data byte 0x10 is identical to address 0x10 of the cashless reader.

That is why the decoder cuts frames by length (once address + command + subcommand are read, the length is known) and not by looking for address bytes. Cutting by value silently lost every sale whose item or price contained 0x08, 0x10 or 0x30: item 8, item 16, item 48, and any price between 20.48 and 23.03. Measured with the simulator before the change: 3 sales lost out of 22.

Machines with no internet of their own

When the machine has no connection and someone drops by every so often to hook it up (phone hotspot), set sync_mode = "opportunistic" and heartbeat_interval = 0. The agent stops retrying every 5 seconds around the clock — the interval doubles on its own up to offline_max_interval — and in the dashboard that machine has to be marked as syncs on visits, so the "no activity" warning uses a window of days instead of the default 30 minutes.

Two pieces keep the visit from being blind:

1. Sync the moment there is a network. The 90-vendingzets-sync script sends SIGUSR1 to the agent when NetworkManager brings an interface up, and the agent drains the queue immediately instead of waiting out its backoff (up to 5 minutes with a person standing next to the machine):

vendingzets-agent files --copy /tmp/vz
sudo install -m 755 -o root -g root \
  /tmp/vz/90-vendingzets-sync /etc/NetworkManager/dispatcher.d/

The file must be owned by root and not writable by others: otherwise NetworkManager ignores it silently.

It also pays to store the same fleet network on every Pi, so any technician just turns on their hotspot and the machine latches on by itself:

sudo nmcli connection add type wifi con-name vzets-field ssid vzets-field \
  wifi-sec.key-mgmt wpa-psk wifi-sec.psk 'PASSWORD' \
  connection.autoconnect yes connection.autoconnect-priority 20

2. See whether it worked. The agent serves a status page on status_port (8099 by default) with pending sales, the time of the last upload, the last error and a Sync now button. From the same phone providing the hotspot:

http://<hostname>.local:8099

For that name to resolve: sudo apt install avahi-daemon and one hostname per machine (sudo hostnamectl set-hostname vzets-a12). Without avahi, use the IP.

The page requires no authentication — it exposes queue counts, never the API key nor sale data, and its reach is the local network of the moment (the technician's own hotspot). On a machine attached to a network you do not control, status_port = 0 disables it.

Clock: a Pi with no RTC boots with the time of its last shutdown, and that time travels in sold_at. Fit an RTC (DS3231) on opportunistic machines, or at least make sure fake-hwclock is enabled.

Known limitation: cash sales

The selection number travels on the bus only when payment goes through the cashless reader (card): there the VMC emits a VEND REQUEST with the item and the price, and then a VEND SUCCESS.

With cash the VMC never publishes which item was picked — the coin mech and the bill validator only report money coming in. That is a limitation of the MDB protocol, not of this agent. That is what [prices] is for: if every price on that machine maps to a single slot, the amount is enough to identify it. If two slots share a price the sale cannot be sent (the backend decrements stock by slot_code), but it is not discarded: it stays quarantined with the amount the bus reported, and goes through by itself as soon as [prices] can resolve it.

System service

vendingzets-agent files --copy /tmp/vz
sudo cp /tmp/vz/vendingzets-agent.service /etc/systemd/system/
sudo systemctl enable --now vendingzets-agent
journalctl -u vendingzets-agent -f

The unit ships with ExecStart=/usr/local/bin/vendingzets-agent: adjust that path to wherever the executable ended up (which vendingzets-agent), which depends on whether you installed with global pip, a venv or pipx.

Development

pip install -e ".[dev]"
pytest

The whole protocol and the queue are tested without hardware: the decoder takes bytes and emits events, so a vending machine is replaced by a list of integers.

Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

vendingzets_agent-0.2.0.tar.gz (45.2 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

vendingzets_agent-0.2.0-py3-none-any.whl (43.4 kB view details)

Uploaded Python 3

File details

Details for the file vendingzets_agent-0.2.0.tar.gz.

File metadata

  • Download URL: vendingzets_agent-0.2.0.tar.gz
  • Upload date:
  • Size: 45.2 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for vendingzets_agent-0.2.0.tar.gz
Algorithm Hash digest
SHA256 bfb88dc80eb253596482dec36a047014ed2d300f9c86ed238db9818b17de419c
MD5 8b26cae1c806c0c459cd4a08bdace809
BLAKE2b-256 fd944ea43a6e62c5bbc3ebc6ca075ba4f80bcfcf9a06a6a0bbcd2de929ad0c49

See more details on using hashes here.

Provenance

The following attestation bundles were made for vendingzets_agent-0.2.0.tar.gz:

Publisher: publish-agent.yml on manasesortez/vending.zets

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file vendingzets_agent-0.2.0-py3-none-any.whl.

File metadata

File hashes

Hashes for vendingzets_agent-0.2.0-py3-none-any.whl
Algorithm Hash digest
SHA256 9c6498889c84708bf13dea47bcbd2859da8bb6c9cba78c74bc793707bf33269d
MD5 cd05915c6758b2416df991a54c07c74e
BLAKE2b-256 62f079b926276bdea38fb0d9c694f6ba8ddb54e5a2bd6e0fc16dde347e955f84

See more details on using hashes here.

Provenance

The following attestation bundles were made for vendingzets_agent-0.2.0-py3-none-any.whl:

Publisher: publish-agent.yml on manasesortez/vending.zets

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page