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
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
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
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
bfb88dc80eb253596482dec36a047014ed2d300f9c86ed238db9818b17de419c
|
|
| MD5 |
8b26cae1c806c0c459cd4a08bdace809
|
|
| BLAKE2b-256 |
fd944ea43a6e62c5bbc3ebc6ca075ba4f80bcfcf9a06a6a0bbcd2de929ad0c49
|
Provenance
The following attestation bundles were made for vendingzets_agent-0.2.0.tar.gz:
Publisher:
publish-agent.yml on manasesortez/vending.zets
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
vendingzets_agent-0.2.0.tar.gz -
Subject digest:
bfb88dc80eb253596482dec36a047014ed2d300f9c86ed238db9818b17de419c - Sigstore transparency entry: 2291501095
- Sigstore integration time:
-
Permalink:
manasesortez/vending.zets@3e3f32e103e4e35f52a6a64ebf4724d47cae527b -
Branch / Tag:
refs/tags/agent-v0.2.0 - Owner: https://github.com/manasesortez
-
Access:
private
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish-agent.yml@3e3f32e103e4e35f52a6a64ebf4724d47cae527b -
Trigger Event:
push
-
Statement type:
File details
Details for the file vendingzets_agent-0.2.0-py3-none-any.whl.
File metadata
- Download URL: vendingzets_agent-0.2.0-py3-none-any.whl
- Upload date:
- Size: 43.4 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
9c6498889c84708bf13dea47bcbd2859da8bb6c9cba78c74bc793707bf33269d
|
|
| MD5 |
cd05915c6758b2416df991a54c07c74e
|
|
| BLAKE2b-256 |
62f079b926276bdea38fb0d9c694f6ba8ddb54e5a2bd6e0fc16dde347e955f84
|
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
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
vendingzets_agent-0.2.0-py3-none-any.whl -
Subject digest:
9c6498889c84708bf13dea47bcbd2859da8bb6c9cba78c74bc793707bf33269d - Sigstore transparency entry: 2291501116
- Sigstore integration time:
-
Permalink:
manasesortez/vending.zets@3e3f32e103e4e35f52a6a64ebf4724d47cae527b -
Branch / Tag:
refs/tags/agent-v0.2.0 - Owner: https://github.com/manasesortez
-
Access:
private
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish-agent.yml@3e3f32e103e4e35f52a6a64ebf4724d47cae527b -
Trigger Event:
push
-
Statement type: