Metadata-Version: 2.4
Name: vendingzets-agent
Version: 0.2.0
Summary: IoT agent for vending machines: listens to the MDB bus and reports every sale to Vending Zets
Project-URL: Homepage, https://vending.zets.pro
Project-URL: Repository, https://github.com/manasesortez/vending.zets
Project-URL: Issues, https://github.com/manasesortez/vending.zets/issues
Author: Alberto Turcios
License-File: LICENSE
Keywords: iot,mdb,raspberry-pi,vending
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Manufacturing
Classifier: License :: Other/Proprietary License
Classifier: Operating System :: POSIX :: Linux
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: System :: Hardware
Requires-Python: >=3.11
Requires-Dist: pyserial>=3.5
Requires-Dist: requests>=2.31
Provides-Extra: dev
Requires-Dist: pytest>=8.0; extra == 'dev'
Description-Content-Type: text/markdown

# 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

```bash
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:

```bash
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`:

```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:

```bash
export VENDINGZETS_API_KEY="vz_live_..."
```

## Usage

```bash
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):

```bash
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:

```bash
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

```bash
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

```bash
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.
