NuttX port of the VMflow MDB cashless firmware for the ESP32-S3.
Status — bring-up. NuttX boots with an NSH console on the chip's native USB port (USB-Serial/JTAG). The SIM7080G comes up as a regular network interface (
ppp0) through pppd, so sockets, DNS and HTTP go over LTE-M/NB-IoT. Themdbengine answers the vending machine as an MDB cashless device, and any task opens sessions and approves vends through it. Thevmfloworchestrator ties them to the VMflow backend (signed MQTT RPC: credit, dex, info, ...; sales) and to the phone app over BLE (provisioning, app sessions, vend notifications). The WS2812 status LED and the buzzer follow the ESP-IDF firmware, anddexreads the machine's EVA-DTS audit. Not ported: Wi-Fi, OTA and the PAX counter.
Related projects:
- mdb-esp32-cashless: the ESP-IDF firmware this port follows, with the hardware (KiCad), backend and Android app.
- vmflow-cli: command line for the VMflow fleet (auth, device RPC over MQTT, sales, telemetry).
.
├── nuttx/ apache/nuttx (submodule, nuttx-13.1.0)
├── apps/ apache/nuttx-apps (submodule, nuttx-13.1.0)
├── firmware/
│ ├── apps/ VMflow applications, linked as apps/external
│ │ ├── include/ header-only helpers shared by the apps (daemon_status.h)
│ │ ├── vmflow/ orchestrator: MQTT RPC, BLE (NimBLE), LED, buzzer
│ │ ├── mdb/ MDB cashless engine and its client API (`mdb` command, mdb.h)
│ │ ├── sim7080g/ modem daemon: power, pppd, link supervision
│ │ ├── rgbled/ `rgbled` command for the WS2812 status LED
│ │ └── dex/ `dex` command: EVA-DTS audit over DDCMP or DEX/UCS
│ └── boards/vmflow-esp32s3/
│ ├── src/ board code (derived from esp32s3-devkit): bring-up, GPIOs,
│ │ MDB bus driver, init.rc
│ └── configs/
│ ├── vmflow/ default: NSH on USB-Serial/JTAG, SIM7080G + PPP, MDB, BLE
│ └── vmflow-otg/ alternative: NSH on USB-OTG CDC-ACM
├── docs/ board photo
├── docker/Dockerfile build environment (xtensa-esp-elf 14.2.0, esptool 5.2.0, kconfig)
└── Makefile build, flash and monitor through Docker
The firmware is split the way a Linux system would be: drivers own the hardware, daemons own a protocol each, and an optional orchestrator ties them to the outside world. Every piece talks to the next one through a POSIX interface (a device file, a message queue, a socket, a file or a command), never through another task's memory, so each runs, restarts and is tested on its own.
Control path
VMflow backend (MQTT) phone app (BLE)
│ │
┌─────────────┴───────────────────────┴─────────────┐
│ vmflow (optional orchestrator) │
└──┬────────────┬─────────────┬─────────────┬───────┘
│ mdb.h │ runs "dex" │ /dev/gpio0 │ /dev/leds0
│ (mqueues) │ (stdout) │ (buzzer) │ (status LED)
┌──────┴─────┐ ┌───┴───┐
│ mdb engine │ │ dex │
└──────┬─────┘ └───┬───┘
│ /dev/mdb0 │ /dev/ttyS2
│ │
VMC (MDB bus) VMC (DEX port)
Network path
vmflow (MQTT), ntpc ── sockets ── ppp0 ── pppd ── /dev/ttyS1 ── SIM7080G
└── sim7080g daemon: power (/dev/gpio1),
link supervision
vmflow (NimBLE) ── HCI socket ── bnep0 ── BLE controller
| Component | Kind | Owns | Interface to the rest |
|---|---|---|---|
/dev/mdb0 |
board driver | GPIO4/5, MDB 9-bit at 9600 baud | read/write of uint16_t words, poll |
mdb |
daemon (nxinit) | the MDB bus, reader state, session timers | mdb.h: /mdb_cmd queue in, /mdb_evt.<pid> queues out; mdb command |
sim7080g |
daemon (nxinit) | modem power, pppd, link supervision |
the ppp0 network interface; applications just open sockets |
ntpc |
NuttX service | wall clock | time() |
dex |
command | DEX port (/dev/ttyS2) for one read |
stdout (the audit), /tmp/dex.lock |
vmflow |
daemon (nxinit), optional | the MQTT session, the BLE GATT service | MQTT and BLE outside; mdb.h, dex, /dev/gpio0, /dev/leds0, /data/vmflow inside |
rgbled |
command | — | writes /dev/leds0 |
esp32s3_bringup(): procfs, tmpfs at/tmp, LittleFS at/data, GPIOs (/dev/gpio0,/dev/gpio1), RMT and WS2812 (/dev/leds0), BLE controller, MDB driver (/dev/mdb0), watchdog (/dev/watchdog0).- nxinit runs
/etc/init.d/init.rc: console (sh),ntpc,sim7080g,mdb,vmflow. Services are restarted when they exit (1 s formdb, 5 s for the others). - Each service waits for what it needs on its own:
vmflowfor its identity files and the network,ntpcand MQTT forppp0, the engine for the VMC (and answers its first poll with JUST RESET, so a VMC that already knew the reader sets it up again).
| Task | Priority | Stack | Notes |
|---|---|---|---|
btController |
253 | 4 KB | BLE controller (kernel) |
esp_timer |
252 | 4.5 KB | high-resolution timer (kernel) |
hpwork |
224 | 2 KB | high-priority work queue (kernel) |
mdb |
200 | 4 KB | answers the VMC within 5 ms; feeds the watchdog |
sim7080g, pppd |
100 | 4 KB | modem and PPP |
NTP_daemon |
100 | 4 KB | |
vmflow |
100 | 4 KB | MQTT session |
vmflow_mdb, vmflow_status, vmflow_ble, vmflow_hci |
100 | 3–4 KB | engine events, status file, NimBLE host and HCI socket |
ps shows the stack use of each task (stack coloration is on). About 155 KB of heap stay free.
| Path | Writer | Readers | Content |
|---|---|---|---|
/data/vmflow/subdomain, passkey |
you (echo), BLE provisioning |
vmflow |
device identity, persistent |
/data/vmflow/last_seq |
vmflow, on each credit with a seq |
vmflow |
highest remote-credit seq accepted (replay guard) |
/tmp/<daemon>.status |
mdb, sim7080g, vmflow, once a second |
<daemon> status, anyone |
pid, then the status lines |
/tmp/dex.lock |
dex while reading |
dex |
pid of the reader |
/mdb_cmd (mqueue) |
any client (mdb_request()) |
engine | commands |
/mdb_evt.<pid> (mqueue) |
engine | that subscriber | events, starting with a snapshot |
dmesg (RAM log) |
everyone (syslog) |
dmesg |
logs |
| GPIO | Use |
|---|---|
| 4 / 5 | MDB RX / TX (/dev/mdb0) |
| 8 / 9 | DEX RX / TX, UART2 (/dev/ttyS2) |
| 12 | buzzer (/dev/gpio0) |
| 14 | SIM7080G PWRKEY (/dev/gpio1) |
| 17 / 18 | SIM7080G TX / RX, UART1 (/dev/ttyS1) |
| 19 / 20 | USB-Serial/JTAG: console and flashing |
| 21 | WS2812 status LED (RMT, /dev/leds0) |
- One owner per resource. The bus belongs to the engine, the modem to
sim7080g, the DEX port to thedexrunning now. Others ask through the owner's interface. - No shared memory between tasks. Commands and events go through message queues, status through files, the network through sockets. Nothing relies on a flat build.
- Clients are headers.
mdb.handdaemon_status.hhold the whole client side, so an app builds whether the service it talks to is configured or not, and fails at run time withENOENT/ "not running" when it is absent. - Nothing waits forever. Every wait has a timeout (sessions, approvals, bus silence, pending credit, words within a frame, network retries), and the engine feeds a hardware watchdog.
- The orchestrator is optional. Without
vmflowthe machine still vends, the engine still protects itself, and credit can still be given from the shell (mdb credit 1.00). - Standard NuttX pieces first. GPIO, WS2812, watchdog and BLE drivers,
pppd,ntpclient, MQTT-C and NimBLE come from NuttX and nuttx-apps; the sources passtools/nxstyle.
Requires Docker. Everything else lives in the image.
git clone --recurse-submodules --shallow-submodules https://github.com/nodestark/vmflow-nuttx
cd vmflow-nuttx
make image # toolchain image (once)
make configure # apply firmware/boards/vmflow-esp32s3/configs/vmflow
make build # -> nuttx/nuttx.bin--shallow-submodules fetches only the pinned NuttX snapshot (~300 MB of git data) instead of the full
history. For a clone made without it:
git submodule update --init --depth 1The first build clones Espressif's esp-hal-3rdparty into the NuttX tree, so it needs
network access.
Behind a TLS-inspecting proxy, make image forwards the host CA store
(/usr/local/share/ca-certificates) into the image. Point EXTRA_CA_DIR elsewhere if
your certificates live in another directory.
make flash # PORT=/dev/ttyACM0 by default
make monitor # NSH console, exit with Ctrl+] (close it before flashing)nsh> uname -a
NuttX 13.1.0 2b5c0de5 Oct 23 2026 13:11:50 xtensa vmflow-esp32s3
nsh> ps # init.rc started ntpc, sim7080g, mdb and vmflow
nsh> mdb status
nsh> vmflow status
If the console stays silent after make flash, the chip did not leave download mode on
the final reset; replug the board or press RESET.
Flashing and the console share the chip's native USB port (GPIO19/20), which shows up as
303a:1001 Espressif USB JTAG/serial in both download mode and while NuttX runs.
On the USB-Serial/JTAG, DTR and RTS drive the chip's BOOT and RESET lines: RTS high with
DTR low resets the chip, DTR high with RTS low pulls BOOT low. Linux raises both when the
port opens, which is harmless, so make monitor leaves them there. A terminal that then
drops DTR before RTS (miniterm's --dtr 0 --rts 0) passes through the reset state and
reboots the board on every open.
make configure build flash CONFIG=vmflow-otgThe console moves to the USB-OTG peripheral as a CDC-ACM device (0525:a4a7 NuttX
CDC/ACM, marked experimental in NuttX). The port changes identity when NuttX starts, so
esptool can no longer reach the chip: to flash again, hold BOOT while plugging in. On a
VM, pass both USB IDs through.
Two layers, as in the ESP-IDF firmware's mdb_cashless_task(), split so the bus has a single
owner:
- Engine (
mdb, an nxinit service): the only task on the bus. It answers the VMC as a level 1 cashless device (address 0x10) and keeps the reader state. - Clients: any task, script or NSH command. They queue requests for the engine (open a session with credit, approve or deny a vend, cancel) and can follow what happens on the bus.
vmflow, scripts, `mdb credit 1.00` mdb_request() -> /mdb_cmd (mqueue)
mdb engine state machine -> /mdb_evt (mqueue)
/dev/mdb0 9-bit words, uint16_t per word, bit 8 = mode
GPIO4 RX / GPIO5 TX bit-banged 9600 baud, falling-edge interrupt
nsh> mdb status
engine : pid 7, up 53 s, /dev/mdb0, address 0x10, watchdog on
state : idle, vmc level 2
session : funds 100, 16 s, idle 16 s
pending : credit 50, 14 s
bus : 638 words, 274 frames for us, 0 bad, last 0 s ago, lost 0
totals : sessions 1, vends 0, failed 0, cash 0
timeouts: approve 0, credits expired 0 (session 60 s, approve 8 s, bus 5 s, credit 120 s)
currency: 0xffff, scale 1, decimals 2 (amounts in cents)
nsh> mdb events 30
state idle
session_begin price 1.00 item 0
state enabled
session_end price 1.00 item 0
| Command | Use |
|---|---|
mdb |
run the engine in the foreground (what init does) |
mdb start |
spawn it in the background, when autostart is off |
mdb status |
reader state, session, the vend waiting for an answer, pending credit, bus counters |
mdb credit <amount> |
begin a session with that much credit (1, 1.5, 1,50) |
mdb open |
begin a session where every vend waits for approve/deny (app flow) |
mdb close |
cancel the session; with none open, drop the pending credit |
mdb approve, mdb deny |
answer the vend the VMC is asking for |
mdb oos |
answer the next poll with "command out of sequence" |
mdb events [s] |
print the engine's events for s seconds (60) |
From C, include mdb.h (CFLAGS += ${INCDIR_PREFIX}$(APPDIR)/external/mdb) and call
mdb_request(MDB_CMD_SESSION_OPEN, 100); amounts are in cents, converted to the MDB scale
factor by the engine. The header is the whole client (the queues and structs are the contract),
so an app builds and runs whether the engine is configured or not; without it every call fails
with ENOENT.
Each process that wants events calls mdb_subscribe(): it creates /mdb_evt.<pid> and asks
the engine to copy every event there. The engine answers with a snapshot (state and session
funds), so a late or restarted client needs no history, then sends events as they happen.
Up to 4 subscribers; one whose queue is full misses events (counted as dropped in
mdb status), and one that exits is dropped. The engine forgets its subscribers when it
restarts, so a long-running client renews (MDB_CMD_SUBSCRIBE again) when its queue has been
quiet for a few seconds; each renewal is answered with a new snapshot.
How the engine behaves:
- Requests act on the VMC's next poll and only where the state allows: a session begins once
the reader is enabled, so a credit sent before that waits as
pending(credits add up). - With credit, the engine approves a vend that fits the funds and denies one that does not;
a failed vend gives the price back to the funds. An
opensession has no funds: each vend waits forapproveordeny. - On every (re)start the engine answers the first poll with JUST RESET, so a VMC that set the reader up before the reboot runs SETUP and READER ENABLE again.
session_endcarries the funds left unspent.- Events go to one reader. Nobody may be reading, so a full queue (16) drops its oldest event.
Nothing waits forever, so neither the machine nor the engine stays stuck:
| Timeout | Default | What happens |
|---|---|---|
SESSION_TIMEOUT |
60 s | a session with no vend activity is cancelled; each vend request and outcome restarts the count |
APPROVE_TIMEOUT |
8 s | a vend in an open session with no approve/deny is denied |
RESPONSE_TIME |
10 s | reported to the VMC in SETUP as the reader's maximum response time (ESP-IDF: 3 s); keep it above APPROVE_TIMEOUT |
BUS_TIMEOUT |
5 s | no frame for the reader (machine off, cable out): the session ends (session_end, bus_lost), the reader drops to inactive and answers the VMC's next poll with JUST RESET |
CREDIT_TIMEOUT |
120 s | a credit that found no session is dropped (credit_expired, so the sender can give it back); each new credit restarts the count |
| word gap | 20 ms | a word missing in the middle of a frame drops the frame instead of blocking on it |
WATCHDOG_TIMEOUT |
10 s | the engine feeds MWDT0 (/dev/watchdog0) at least once a second; if it stops and init does not bring it back in time, the board resets |
mdb status shows the running timers (session idle time, vend wait, pending age, last frame) and
how often each timeout fired.
The bus is bit-banged like the ESP-IDF firmware: each received word is sampled inside the
falling-edge interrupt (~1.1 ms), and each sent word goes out with interrupts off. The VMC wants
an answer within 5 ms, so the engine runs at priority 200, replies before it reads its queue or
posts events, and does not log on the bus path (what happens goes to the event queues and
mdb status).
Settings live under Application Configuration → VMflow → MDB cashless engine: autostart,
address (cashless #1/#2), currency, scale factor, decimal places, the timeouts above and the
watchdog. The pins
are board options (VMFLOW_MDB_RX_GPIO, VMFLOW_MDB_TX_GPIO).
The modem is a boot service: nxinit starts sim7080g from /etc/init.d/init.rc
(firmware/boards/vmflow-esp32s3/src/etc/init.d/init.rc) next to the console, and ppp0
is up about 40 s after power-on with no command typed. Applications just open sockets.
nsh> ps
4 4 0 100 RR Task - Waiting Semaphore ... init_main
5 5 4 100 RR Task - Running ... sh
6 6 4 100 RR Task - Waiting Signal ... sim7080g
7 7 6 100 RR Task - Waiting Semaphore ... pppd
nsh> sim7080g status
pppd : pid 7
link : up for 46 s
ip : 100.82.56.6
apn : lf.br, cmnb 3, tty /dev/ttyS1
nsh> nslookup mqtt.vmflow.xyz
Host: mqtt.vmflow.xyz Addr: 144.202.18.100
nsh> ping -c 2 google.com
56 bytes from 142.251.132.238: icmp_seq=0 time=190.0 ms
app (MQTT, HTTP, OTA) socket() as usual
NuttX TCP/IP routes, DNS
ppp0 TUN netdev (nuttx/net/tun.c)
pppd apps/netutils/pppd: AHDLC, LCP, PAP, IPCP
/dev/ttyS1 UART1, GPIO17 TX / GPIO18 RX, 115200
| Command | Use |
|---|---|
sim7080g |
run the daemon in the foreground (what init does) |
sim7080g start |
spawn it in the background, when autostart is off |
sim7080g status |
link state, address, drops, modem resets |
CONFIG_VMFLOW_SIM7080G_AUTOSTART (default on) adds the service to init.rc; turn it off for
boards without a modem or SIM. init restarts the daemon 5 s after it exits, and a restarted
daemon reuses the running pppd.
The daemon mirrors sim7080g_task() from the ESP-IDF firmware:
- Sends
+++(a modem left in data mode by a previous run) and probesAT. A silent modem gets a PWRKEY pulse (/dev/gpio1, GPIO14, 1.2 s). - Starts pppd in its own task. The connect script runs on every (re)connect:
ATE0,ATH(drops a stale data call),AT+CNMP=38,AT+CMNB=<mode>,AT+CGDCONT=1,"IP","<APN>", waits for+CGATT: 1, thenATD*99#. pppd retries it on its own until the modem is attached; IPCP sets the address, the default route and the operator's DNS servers. - Watches
ppp0. With no address forCONFIG_VMFLOW_SIM7080G_LINK_TIMEOUTseconds (300), it pulses PWRKEY to power-cycle a hung modem. The key toggles power, so a modem that was off comes back on the next window.
Settings live under Application Configuration → VMflow → SIM7080G (make menuconfig):
autostart, APN (lf.br), network mode (Cat-M / NB-IoT / both), serial device, link
timeout and PAP credentials. The PWRKEY pin is a board option (VMFLOW_SIM7080G_PWRKEY_GPIO).
Notes:
NET_TUNis a BSD-licensed NuttX component, henceCONFIG_ALLOW_BSD_COMPONENTS=y.- pppd only asks the peer for DNS servers when
IPCP_GET_PRI_DNS/IPCP_GET_SEC_DNSare defined.firmware/apps/sim7080g/Make.defsadds them for the pppd build only, so the submodule stays untouched. ppp0MTU is 1024 (CONFIG_NET_TUN_PKTSIZE), matching pppd's 1024-byte buffers.- pppd echoes the chat dialogue to the console; that output is hardwired in pppd.
killstops the daemon (CONFIG_SIG_DEFAULT, needed by NimBLE), and init starts it again 5 s later.- No CMUX: while PPP is up the modem takes no AT commands, same as the ESP-IDF firmware.
- pppd drops the link after sending
AHDLC_TX_OFFLINEframes with nothing received. Upstream uses 5, which a few back-to-back TCP segments hit within one LTE-M round trip;CONFIG_VMFLOW_SIM7080G_PPP_TX_OFFLINE(30) overrides it throughppp_tuning.h, force-included into the pppd build. - TCP write buffers are on (
NET_TCP_WRITE_BUFFERS, 64 IOBs). Without them every segment waits for its ACK and a non-blockingsend()returnsEAGAIN, which MQTT-C spins on.
vmflow ties the backend and the phone app to the rest of the system, as the ESP-IDF firmware's
main loop does. It owns no hardware and only uses public interfaces, so it is optional: without
it the MDB engine, the modem and dex keep working.
MQTT (mqtt.vmflow.xyz) BLE (phone app)
\ /
vmflow (nxinit service)
┌──────────┬────────┴───┬──────────────┬──────────────┐
mdb.h "dex" command /dev/gpio0 /dev/leds0 /data/vmflow/*
(engine) stdout -> file (buzzer) (status LED) (identity)
subdomain (the numeric device id, <subdomain>.vmflow.xyz) and passkey (RPC and BLE
signing key) are plain files on the LittleFS partition mounted at /data, in place of NVS:
nsh> mkdir /data/vmflow
nsh> echo 123 > /data/vmflow/subdomain
nsh> echo <passkey> > /data/vmflow/passkey
They are read whenever needed, so provisioning takes effect without a restart. The phone app can also provision them over BLE, which, like the ESP-IDF firmware, only writes a value that is not set yet. Until both exist the LED is white and MQTT does not connect. The passkey sits in plain text on the flash, as in the ESP-IDF firmware's unencrypted NVS.
MQTT-C over plain TCP, set up like esp-mqtt in the ESP-IDF firmware:
| broker | mqtt.vmflow.xyz:1883, anonymous, keepalive 120 s, clean session |
| client id | vmflow-<subdomain> |
| last will | offline, QoS 1, retained, on domain.vmflow.xyz/<subdomain>/status |
| on connect | publishes online (QoS 1, retained) there and subscribes <subdomain>.vmflow.xyz/# |
| reconnect | every 15 s; DNS or connect fail while ppp0 is down, so it just waits for the link |
LTE-M has stalls of several seconds (and occasionally tens). MQTT-C resends any request still
unacknowledged after 30 s, so both acks may come back; the second one matches nothing and MQTT-C
reports MQTT_ERROR_ACK_OF_UNKNOWN, which would end the session. The client drops that error and
counts it as late acks.
For a throwaway session from the shell, NuttX's MQTT-C examples are in the image. They open their own connection (no last will, no reconnect), and NSH cuts lines at 64 characters:
nsh> mqttc_pub -h mqtt.vmflow.xyz -t domain.vmflow.xyz/<sub>/t -m hi -q 1
nsh> mqttc_sub -h mqtt.vmflow.xyz -t domain.vmflow.xyz/<sub>/#
mqttc_sub reads stdin and exits on q. The broker ACL only lets anonymous clients use +/rpc,
+/credit and domain.vmflow.xyz/#.
Signed commands on <subdomain>.vmflow.xyz/rpc, same wire format as the ESP-IDF firmware
(and mdb-slave-esp32s3/tools/rpc.sh there):
<cmd>:<args>:<ts>:<hmac> hmac = HMAC-SHA256(passkey, "<cmd>:<args>:<ts>"), lowercase hex
ts must be within 10 s of the device clock, so nothing is accepted until NTP has set it.
Commands without an argument send -.
| Command | Action | Reply |
|---|---|---|
credit:<cents>[,<seq>] |
MDB session with that credit (mdb_request), beep; see below for seq |
.../rpc/confirm ok |
oos:- |
"command out of sequence" on the next poll | .../rpc/confirm ok |
dex:- |
runs dex, publishes its stdout |
.../rpc/confirm ok, then .../rpc/dex |
info:- |
device snapshot JSON (version of this firmware, nuttx release, uptime, heap, machine_state, last sale, ip_ppp) |
.../rpc/info |
echo:- |
liveness / RTT | .../rpc/echo <ts> |
buzzer:- |
1 s beep | .../rpc/confirm ok |
restart:- |
reboot after the ack is out | .../rpc/confirm ok |
Reply topics are under domain.vmflow.xyz/<subdomain>/. ota is not ported. A dex with no
answer from the machine publishes nothing, as in the ESP-IDF firmware.
From the engine's events, signed (<msg>:<hmac>) on domain.vmflow.xyz/<subdomain>/..., QoS 1:
| Event | Topic | Message |
|---|---|---|
cash_sale |
sale |
<cents>:<item>:<ts> |
first vend_success in a credit session |
vend_success |
<cents>:<item>[:<seq>]:<ts> |
vend_failure |
vend_fail |
<cents>,<item>[,<seq>]:<ts> |
Sessions follow the ESP-IDF firmware (upstream d862ae1):
- Credit sessions (MQTT
credit, ormdb credit): the backend records the sale as pending when it sends the credit, without the item.vend_successcarries the item, so the backend maps the product, debits the stock and marks the sale successful;vend_failmarks it failed. Only the first vend of the session is reported: further vends on leftover funds have no sale of their own. - seq: the backend's
sales.seq, sent ascredit:<cents>,<seq>, echoed invend_successandvend_failso the exact sale is settled. A seq not above/data/vmflow/last_seqis a replayed credit and is refused (logged, no ack). It stays invmflow: the engine only knows amounts, and the next session with fixed funds takes the credit's seq. Without a seq (older senders,mdb credit) the messages keep their old form. - App sessions (BLE
02, no fixed funds): the app reports the sale from the BLE notifications, so nothing is published here.
These are sent only while MQTT is connected; a report made during an outage is lost (the ESP-IDF firmware keeps them in the esp-mqtt outbox for up to an hour).
NimBLE peripheral over the HCI socket (NimBLE is built into vmflow), same GATT service and
characteristic as the ESP-IDF firmware (b2bbc642-46da-11ed-b878-0242ac120002 /
c9af9c76-46de-11ed-b878-0242ac120002, read/write/notify). It advertises as
<subdomain>.vmflow.xyz (0.vmflow.xyz until installed).
| Write | Action |
|---|---|
00 <subdomain> |
provisioning, only while none is set |
01 <passkey> |
provisioning, only while none is set |
02 |
MDB session with no fixed funds (every vend waits for an approval) |
03 <19-byte payload> |
approve the vend, signed and no older than 60 s |
04 |
cancel the session |
Notifications (19 bytes: cmd, price u32, item u16, time u32, 0 u32, HMAC[:4], big endian,
price in cents, HMAC-SHA256 with the passkey over the first 15 bytes): 0a vend request, 0b
vend success, 0c vend failure, 0d session complete. Wi-Fi provisioning (06/07) and the
PAX counter are not ported.
The BLE controller's network interface (bnep0) must be up for the HCI socket to carry
anything; vmflow brings it up before starting NimBLE.
| LED | Meaning |
|---|---|
| white | not installed (no subdomain/passkey) |
| green | MQTT connected and the VMC has the reader enabled |
| magenta | MQTT only |
| blue | MDB only |
| red | neither |
The buzzer is GPIO12 through the NuttX GPIO driver, /dev/gpio0 (gpio -o 1 /dev/gpio0);
vmflow beeps 1 s on credit and buzzer.
nsh> vmflow status
vmflow : NuttX 0.0.0, build 2b5c0de5 Oct 24 2026 16:15:13
device : 123
machine : enabled, last sale item 0 price 0, 0 failed vends
mqtt : connected for 44 s, broker mqtt.vmflow.xyz:1883
sessions: 1, failed attempts: 1
messages: rx 2, tx 3, late acks 0
ble : advertising, name 123.vmflow.xyz, no connection
| Command | Use |
|---|---|
vmflow |
run it in the foreground (what init does) |
vmflow start |
spawn it in the background, when autostart is off |
vmflow status |
identity, machine view, MQTT and BLE state |
Settings live under Application Configuration → VMflow → VMflow orchestrator: autostart,
identity directory, device paths, the dex command, broker, buffers and BLE. The MQTT send
buffer (10 KB) holds every message until it is acknowledged, so a DEX audit goes out whole up to
that size.
The board's addressable RGB LED (WS2812) sits on GPIO21. The bringup puts an RMT TX channel
on it and registers NuttX's WS2812 driver as /dev/leds0; the rgbled command drives it.
nsh> rgbled red # solid color
nsh> rgbled 00ff00 20 # RRGGBB or #RRGGBB, 20 % brightness
nsh> rgbled blink blue 400 3 # 400 ms period, 3 blinks
nsh> rgbled rainbow 5 50 # color wheel for 5 s at 50 %
nsh> rgbled off
Colors by name: off red green blue yellow cyan magenta white orange purple. The LED holds
the last color written. Applications can write it directly: open /dev/leds0 and write one
uint32_t per LED as 0x00RRGGBB (the driver reorders it to GRB on the wire).
The pin is a board option (VMFLOW_RGBLED_GPIO); the command lives under
Application Configuration → VMflow → RGB LED (WS2812).
dex reads the vending machine's audit through its DEX port, a port of eva-dts.c from the
ESP-IDF firmware. The port is UART2 on the same pins (GPIO9 TX, GPIO8 RX), /dev/ttyS2.
nsh> dex # DDCMP at 2400 baud, then DEX/UCS at 9600 if DDCMP got nothing
nsh> dex ddcmp # only DDCMP
nsh> dex ucs # only DEX/UCS
nsh> dex > /tmp/audit.txt # keep it in a file
nsh> cat /tmp/audit.txt
DXS*...
Only the audit goes to stdout; status lines (dex: <n> bytes over DEX/UCS,
dex: no answer from the machine) go to stderr and stay on the console when stdout is
redirected. /tmp is a tmpfs in RAM, cleared on reboot.
One read at a time: a dex started while another is running exits with
dex: busy, another read is in progress. The lock is a file holding the reader's pid
(/tmp/dex.lock, the serial port lock convention), so a dex that dies mid-read does not leave
it held.
Differences from the ESP-IDF version:
- No MQTT publish; the output is plain text to use as needed.
- DEX/UCS only runs when DDCMP returned nothing; the ESP-IDF firmware always runs both.
- A silent line ends the read (the ESP-IDF loop kept reusing the last byte after a timeout), and a DDCMP message longer than the 1 KB receive buffer is rejected instead of overflowing it.
Settings live under Application Configuration → VMflow → EVA-DTS audit reader: serial device and audit buffer size (8 KB).
Configured in the board's defconfig, used by the applications above:
/data |
LittleFS on the SPI flash (1 MB at 0x180000), formatted on first boot |
/tmp/<daemon>.status |
each daemon (mdb, sim7080g, vmflow) writes its status there, pid first; <daemon> status prints it and the pid tells whether it runs, so no command reads another task's memory |
| GPIO | numbered like any NuttX board: /dev/gpio0 buzzer, /dev/gpio1 SIM7080G PWRKEY |
| clock | before dialing, sim7080g reads the network time the modem keeps (NITZ, AT+CCLK; one read, no wait); then NTP (ntpcstart from init.rc) polls pool.ntp.org over ppp0. Some LTE-M/NB-IoT networks send no time (the bench SIM, APN lf.br, does not), and there only NTP sets it |
| versions | uname: the NuttX release (NUTTX_VERSION in the Makefile, since the shallow clone has no tags); vmflow status and the info RPC: this firmware's git describe |
| syslog | RAM buffer, read with dmesg (the USB-Serial/JTAG console has no low-level output, so the default channel showed nothing) |
| logging | daemons log with syslog, each line prefixed with the program (mdb:, sim7080g:, vmflow: mqtt: ...), LOG_ERR for failures, LOG_WARNING for rejections and recoveries, LOG_INFO for state changes; commands print results on stdout and errors on stderr; the MDB engine logs nothing on the bus path |
| watchdog | MWDT0 (/dev/watchdog0), started and fed by the MDB engine |
| assert | a crash in an interrupt or kernel thread reboots at once (BOARD_RESET_ON_ASSERT=1); an asserting task is killed and init restarts it |
Known issues:
- Twice during bring-up the USB-Serial/JTAG console did not come back after the watchdog reset
a board stuck in a crash loop (a stack overflow, before
BOARD_RESET_ON_ASSERT); a reset from esptool brought it back. A watchdog reset of a stalled engine (kill -STOPonmdb) does not show it: the console, the engine and PPP all come back. Crashes now reboot through a software reset instead of waiting for the watchdog. board_rmt_txinitialize() failed: -19indmesgis spurious: the NuttX function returns an uninitialized value whenDRIVERS_RCis off./dev/leds0is registered.ntpcstatusand the NTP daemon overflow the default 2 KB stack (a 1 KB host name buffer); both stacks are set to 4 KB.
make menuconfig # edit nuttx/.config
make savedefconfig # store it back into firmware/boards/vmflow-esp32s3/configs/<CONFIG>/defconfignuttx and apps are pinned to the same release tag. To move both to a new release:
for m in nuttx apps; do
git -C $m fetch --depth 1 origin tag nuttx-X.Y.Z
git -C $m checkout nuttx-X.Y.Z
done
# set NUTTX_VERSION := X.Y.Z in the Makefile (the shallow clone has no tags)
make configure build # rebuild and test on the board
git add nuttx apps Makefile && git commit -m "build: bump NuttX to X.Y.Z"Update the toolchain/esptool versions in docker/Dockerfile if the new release's
tools/ci/docker/linux/Dockerfile changed them.
MIT for the VMflow code. NuttX, nuttx-apps and the board code derived from
esp32s3-devkit (firmware/boards/vmflow-esp32s3) are Apache-2.0.

