Metadata-Version: 2.4
Name: vendingzets-agent
Version: 0.1.0
Summary: Agente IoT para maquinas expendedoras: escucha el bus MDB y reporta cada venta a 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

Agente IoT para máquinas expendedoras. Corre en una Raspberry Pi conectada al
bus **MDB** de la máquina, escucha las ventas y las reporta al sistema Vending
Zets, que descuenta el stock del slot correspondiente.

Solo **lee** el bus. Nunca escribe: si la Pi se apaga o falla, la máquina sigue
vendiendo exactamente igual.

## Instalación

```bash
pip install vendingzets-agent
```

Requiere Python 3.11 o superior (Raspberry Pi OS Lite *bookworm* ya lo trae).

El paquete incluye el unit de systemd, el dispatcher de NetworkManager y las
dos configuraciones de ejemplo. Para extraerlos:

```bash
vendingzets-agent files                 # lista qué trae y a dónde va cada uno
vendingzets-agent files --copy /tmp/vz  # los saca con sus permisos correctos
```

## Configuración

La API key se genera en el panel, en el detalle de la máquina, y **se muestra
una sola vez**. Identifica a esa máquina: el agente no manda ningún otro
identificador.

`/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"

# Número de selección del VMC -> código de slot en el sistema.
[slots]
1 = "A1"
2 = "A2"
3 = "B1"

# Solo para ventas en EFECTIVO, donde el bus no dice qué producto se eligió
# (ver "Limitación conocida"). Sirve si cada precio identifica un único slot.
[prices]
"0.75" = "A1"
"1.00" = "B1"
```

La key va por entorno, no en el archivo:

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

## Uso

```bash
vendingzets-agent check              # ¿la credencial sirve? ¿llega al backend?
vendingzets-agent run                # escucha el bus y reporta ventas
vendingzets-agent run --simulate     # ventas sintéticas, sin hardware
vendingzets-agent queue              # estado de la cola local
```

`--simulate` permite instalar la Pi, validar credencial, cola y mapeo de slots
**antes** de tener el HAT MDB. Ojo: las ventas simuladas se registran de verdad
en el sistema, así que conviene usarlo contra una máquina de prueba.

## Cómo no se pierden ventas

Toda venta detectada se escribe **primero en SQLite** y recién después se
intenta enviar. Si no hay internet, se queda en la cola y se reintenta.

Cada venta lleva un `id` generado por el agente que viaja en el POST. El backend
lo usa como clave de idempotencia: reintentar la misma venta mil veces nunca la
duplica (`201` la primera vez, `200` en los reintentos).

El POST también lleva `sold_at`: la hora en que ocurrió la venta **en la
máquina**, no la del envío. Importa en máquinas sin internet permanente, donde
la cola puede vaciarse días después — sin ese campo el backend fecha todo con
su propio `NOW()` y una semana de ventas aterriza en el mismo minuto, con el
stock correcto pero los reportes por día y hora inservibles.

El backend rechaza un `sold_at` del futuro o de más de 90 días atrás, que es lo
que manda una Pi con el reloj corrido (sin RTC y sin red toma la hora del último
apagado). En ese caso el agente **no descarta la venta**: la reenvía sin fecha y
deja el problema de reloj en el log. Se pierde la hora real, no la venta.

Con más de una venta pendiente, el envío va **en lote** (`POST /agent/sales/batch`,
hasta `batch_size` por request). Con una sola, va por el endpoint de a una: el de
lote tiene un rate limit más bajo, pensado para pocas llamadas grandes, y el goteo
de una máquina con internet permanente lo agotaría.

El lote responde el veredicto de **cada** venta, y ahí está su valor: sin eso el
agente no sabría cuáles sacar de la cola y cuáles descartar. Si el request entero
falla no se da por enviada ninguna — las que sí se hayan aplicado del otro lado
vuelven como `duplicate` en el reintento, por el `id` que genera el agente.

Los errores se tratan distinto según si tienen arreglo:

| Respuesta | Qué significa | Qué hace el agente |
|---|---|---|
| `201` / `200` | registrada / ya existía | la saca de la cola |
| `404` / `409` / `422` | slot inexistente, sin stock, payload inválido | la descarta (reintentar no cambia nada) |
| `401` / `403` | credencial mala o sin scope | la conserva y reintenta |
| `429`, `5xx`, red | temporal | la conserva y reintenta |

En lote, el `status` de cada item dice lo mismo: `created`/`duplicate` salen de la
cola, `rejected` se descarta, `failed` se conserva para el próximo intento.

## Máquinas sin internet propio

Cuando la máquina no tiene conexión y alguien pasa cada tanto a conectarla
(hotspot del teléfono), poné `sync_mode = "opportunistic"` y `heartbeat_interval = 0`.
El agente deja de reintentar cada 5 segundos las 24 horas — el intervalo se
duplica solo hasta `offline_max_interval` — y en el panel hay que marcar esa
máquina como *Se sincroniza por visitas*, para que el aviso de "sin actividad"
use un plazo de días en vez de los 30 minutos por defecto.

Dos piezas hacen que la visita no sea a ciegas:

**1. Sincronizar apenas hay red.** El script `90-vendingzets-sync` le manda
`SIGUSR1` al agente cuando NetworkManager levanta una interfaz, y el agente
vacía la cola en el acto en vez de esperar su backoff (hasta 5 minutos con la
persona parada al lado de la máquina):

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

El archivo tiene que ser de root y no escribible por otros: si no, NetworkManager
lo ignora **en silencio**.

Conviene además dejar guardada en cada Pi la misma red de flota, así cualquier
técnico solo prende su hotspot y la máquina engancha sola:

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

**2. Ver si funcionó.** El agente sirve una página de estado en el puerto
`status_port` (8099 por defecto) con las ventas pendientes, la hora del último
envío, el último error y un botón *Sincronizar ahora*. Desde el mismo teléfono
que da el hotspot:

```
http://<hostname>.local:8099
```

Para que ese nombre resuelva: `sudo apt install avahi-daemon` y un hostname por
máquina (`sudo hostnamectl set-hostname vzets-a12`). Sin avahi, por IP.

La página no pide autenticación — expone conteos de cola, nunca la API key ni
datos de venta, y su alcance es la red local del momento (el hotspot del propio
técnico). En una máquina conectada a una red que no controlás, `status_port = 0`
la desactiva.

**Reloj**: una Pi sin RTC arranca con la hora del último apagado, y esa hora
viaja en `sold_at`. Poné un RTC (DS3231) en las máquinas oportunistas, o al
menos verificá que `fake-hwclock` esté activo.

## Limitación conocida: ventas en efectivo

El número de selección viaja por el bus **solo cuando el pago pasa por el lector
cashless** (tarjeta): ahí el VMC emite un `VEND REQUEST` con el ítem y el
precio, y luego un `VEND SUCCESS`.

Con pago en **efectivo** el VMC nunca publica qué ítem se eligió — el monedero y
el billetero solo reportan dinero entrando. Es una limitación del protocolo MDB,
no de este agente. Por eso existe `[prices]`: si en esa máquina cada precio
corresponde a un único slot, el monto alcanza para identificarlo. Si dos slots
comparten precio, la venta queda registrada en el log como no atribuible y no se
envía, porque el backend descuenta stock por `slot_code`.

## Servicio del sistema

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

El unit trae `ExecStart=/usr/local/bin/vendingzets-agent`: ajustá esa ruta a
donde haya quedado el ejecutable (`which vendingzets-agent`), que depende de si
instalaste con pip global, con un venv o con pipx.

## Desarrollo

```bash
pip install -e ".[dev]"
pytest
```

Todo el protocolo y la cola se prueban sin hardware: el decodificador recibe
bytes y emite eventos, así que una máquina expendedora se reemplaza por una
lista de enteros.
