Preview build — page text is a draft awaiting human review. Code badges come from real compiles. The flasher is real but not yet hardware-tested; the Playground is still a preview.
Canteen Prepaid Card: the finished build
Nano ESP32India utility

School canteen prepaid NFC card system — tap, pick an item, pay (Nano ESP32 + PN532)

No change, no queue arguments — tap the card, press the samosa button, done.

Difficulty
Medium
Build time
3 h
Parts cost
₹3,465 · $41
Verified — core esp32 3.3.12, Adafruit PN532 1.3.4, 3 Nov 2026Compile-only

Community draft — report a mistake

Parts list

PartQtyApprox.Buy
Arduino Nano ESP321₹1,700Buy Arduino Nano ESP32
PN532 NFC module V31₹450Buy PN532 NFC module V3
0.96" SSD1306 I2C OLED1₹220Buy 0.96" SSD1306 I2C OLED
16 mm panel buttons with labels3₹90Buy 16 mm panel buttons with labels
Active buzzer module1₹25Buy Active buzzer module
MIFARE Classic / NTAG cards (pack of 50)1₹600Buy MIFARE Classic / NTAG cards (pack of 50)
5 V USB adapter + USB-C cable1₹180Buy 5 V USB adapter + USB-C cable
Counter-top box1₹200Buy Counter-top box
Total₹3,465

Affiliate disclosure: “Buy” links may earn FlashReady a small commission at no extra cost to you. Prices are indicative.

Wiring

Arduino Nano ESP32Only the pins used are shown · 3.3 V logicPN532 NFC / RFID module (13.56 MHz, V3)SSD1306 OLED 0.96″ (128×64, I2C)Push button (6 mm tactile switch)Push button (6 mm tactile switch) #2Push button (6 mm tactile switch) #3Active buzzer module (high-level trigger)3V3A4A5GNDD7D6D5D4D3D2GNDVCCSDASCLSCK (not connected)MISO (not connected)MOSI (not connected)SS (not connected)IRQRSTOGNDVCCSCLSDA121212I/OVCCGND
Every connection
Arduino Nano ESP32Part · pinWireNotes
A4PN532 NFC / RFID module (13.56 MHz, V3) · SDAblueA4 (GPIO11) — the Nano ESP32’s default I2C SDA, shared with the OLED
A5PN532 NFC / RFID module (13.56 MHz, V3) · SCLyellowA5 (GPIO12) — default I2C SCL
D2PN532 NFC / RFID module (13.56 MHz, V3) · IRQgreen
D3PN532 NFC / RFID module (13.56 MHz, V3) · RSTObrown
3V3PN532 NFC / RFID module (13.56 MHz, V3) · VCCredPN532 V3 at 3.3 V: its I2C pull-ups then match the Nano ESP32. DIP switches: 1 ON, 2 OFF (I2C)
GNDPN532 NFC / RFID module (13.56 MHz, V3) · GNDblack
A4SSD1306 OLED 0.96″ (128×64, I2C) · SDAblue
A5SSD1306 OLED 0.96″ (128×64, I2C) · SCLyellow
3V3SSD1306 OLED 0.96″ (128×64, I2C) · VCCred
GNDSSD1306 OLED 0.96″ (128×64, I2C) · GNDblack
D4Push button (6 mm tactile switch) · 1greenRs 10 item (tea)
GNDPush button (6 mm tactile switch) · 2black
D5Push button (6 mm tactile switch) #2 · 1yellowRs 20 item (samosa)
GNDPush button (6 mm tactile switch) #2 · 2black
D6Push button (6 mm tactile switch) #3 · 1orangeRs 30 item (meal)
GNDPush button (6 mm tactile switch) #3 · 2black
D7Active buzzer module (high-level trigger) · I/Opurple
3V3Active buzzer module (high-level trigger) · VCCred
GNDActive buzzer module (high-level trigger) · GNDblack

Board quirks: Balances are kept on the Nano ESP32 (in flash, one entry per card UID), not on the cards. A lost card loses nothing — the manager can move its balance by hand. Nothing is ever written to a card. Card UIDs can be copied with the right gear, so keep this for small amounts (snacks and tea), use only cards the school issues, and check the CSV log for odd patterns. The admin (top-up) card is a setting: tap the manager’s card once, copy the UID printed on the Serial Monitor into the FlashReady flasher (or <code>ADMIN_UID</code>). Without it, top-ups are switched off. Every top-up and sale prints a CSV line (<code>time_s,card,item_rs,balance_rs</code>); keep a laptop on USB at the end of the day, or add an SD card. D2–D7 keep their Nano names; the PN532 and OLED share the Nano ESP32’s default I2C pins A4/A5 at 3.3 V, so no level shifting is needed.

Code

canteen-prepaid-card.inoArduino C++Open in PlaygroundOpen in Playground
// School canteen prepaid card — Arduino Nano ESP32 + PN532 NFC reader + OLED + 3 item buttons.
// Each student gets an NFC card. Tap it: the OLED shows the balance. Press the item button
// (Rs 10 / 20 / 30) within 6 s to pay; the balance is stored on the Nano ESP32 (in flash,
// keyed by the card's UID), never on the card. The canteen manager's own card is the admin
// card: tap it, then a student card, to add Rs 100. Every sale is printed as a CSV line.
// Only use cards the school issues; the UID is read, nothing is ever written to a card.
// PN532 (I2C mode): SDA A4, SCL A5, IRQ D2, RSTO D3, VCC 3V3. OLED on the same A4/A5.
// Item buttons: D4 (Rs 10), D5 (Rs 20), D6 (Rs 30) to GND. Active buzzer I/O: D7.
#include <Wire.h>
#include <Adafruit_PN532.h>
#include <Adafruit_GFX.h>
#include <Adafruit_SSD1306.h>
#include <Preferences.h>
#include "fr_settings.h"

const char* ADMIN_UID = "";  // the manager's card UID, e.g. "04A2B3C4D5E680" (tap it and copy from Serial)
FrSetting SETTINGS[] = {{"admin_uid", &ADMIN_UID, false}};

constexpr int PN532_IRQ = D2, PN532_RST = D3, BUZZER = D7;
constexpr int BTN[3] = {D4, D5, D6};
constexpr int PRICE[3] = {10, 20, 30};
constexpr int TOPUP = 100;

Adafruit_PN532 nfc(PN532_IRQ, PN532_RST, &Wire);
Adafruit_SSD1306 oled(128, 64, &Wire, -1);
Preferences bal;  // "bal" namespace: UID -> balance in rupees
bool readerOk = false;
long salesToday = 0;

void show(const char* a, const char* b, const char* c = "") {
  oled.clearDisplay();
  oled.setTextColor(SSD1306_WHITE);
  oled.setTextSize(1);
  oled.setCursor(0, 0);
  oled.print(a);
  oled.setTextSize(2);
  oled.setCursor(0, 20);
  oled.print(b);
  oled.setTextSize(1);
  oled.setCursor(0, 52);
  oled.print(c);
  oled.display();
}

void beep(int ms) {
  digitalWrite(BUZZER, HIGH);
  delay(ms);
  digitalWrite(BUZZER, LOW);
}

bool readCard(char* hex, uint16_t timeoutMs) {
  uint8_t uid[7], len = 0;
  if (!nfc.readPassiveTargetID(PN532_MIFARE_ISO14443A, uid, &len, timeoutMs)) return false;
  hex[0] = 0;
  for (uint8_t i = 0; i < len && i < 7; i++) sprintf(hex + 2 * i, "%02X", uid[i]);
  return true;
}

void setup() {
  Serial.begin(115200);
  frSettingsBegin(SETTINGS);
  for (int b : BTN) pinMode(b, INPUT_PULLUP);
  pinMode(BUZZER, OUTPUT);
  Wire.begin();  // A4 / A5
  if (!oled.begin(SSD1306_SWITCHCAPVCC, 0x3C)) Serial.println("OLED not found at 0x3C");
  bal.begin("bal", false);
  nfc.begin();
  if (!nfc.getFirmwareVersion()) {
    Serial.println("PN532 not found - check A4/A5 and the I2C DIP switches");
    show("Card reader", "missing");
    return;
  }
  nfc.SAMConfig();
  readerOk = true;
  if (!*ADMIN_UID) Serial.println("No admin card set - top-ups are off. Set admin_uid in the flasher or the code.");
  Serial.println("time_s,card,item_rs,balance_rs");
  show("Canteen", "Tap card");
}

void loop() {
  if (!readerOk) return;
  char uid[15];
  if (!readCard(uid, 200)) return;
  beep(40);
  if (*ADMIN_UID && strcmp(uid, ADMIN_UID) == 0) {  // manager: top up the next card
    show("TOP-UP +Rs 100", "Tap card", "within 10 s");
    char kid[15];
    uint32_t t0 = millis();
    while (millis() - t0 < 10000) {
      if (readCard(kid, 200) && strcmp(kid, ADMIN_UID) != 0) {
        int b = bal.getInt(kid, 0) + TOPUP;
        bal.putInt(kid, b);
        char s[16];
        snprintf(s, sizeof s, "Rs %d", b);
        show("Topped up", s, kid);
        Serial.printf("%lu,%s,+%d,%d\n", millis() / 1000, kid, TOPUP, b);
        beep(300);
        break;
      }
    }
    delay(2000);
    show("Canteen", "Tap card");
    return;
  }
  int b = bal.getInt(uid, 0);
  Serial.printf("card %s balance %d\n", uid, b);
  char s[16];
  snprintf(s, sizeof s, "Rs %d", b);
  show("Balance - pick item", s, "10 / 20 / 30");
  uint32_t t0 = millis();
  while (millis() - t0 < 6000) {
    for (int i = 0; i < 3; i++) {
      if (digitalRead(BTN[i]) != LOW) continue;
      if (b < PRICE[i]) {
        show("Not enough", s, "Please top up");
        beep(600);
      } else {
        b -= PRICE[i];
        bal.putInt(uid, b);
        salesToday += PRICE[i];
        snprintf(s, sizeof s, "Rs %d", b);
        show("Paid - left", s, "Thank you");
        Serial.printf("%lu,%s,%d,%d\n", millis() / 1000, uid, PRICE[i], b);
        beep(120);
      }
      delay(2000);
      show("Canteen", "Tap card");
      return;
    }
    delay(10);
  }
  show("Canteen", "Tap card");
}

Arduino IDE: keep fr_settings.h in the same folder as the .ino (tab menu → New Tab, same name). Flashing from this page doesn’t need it.

fr_settings.hArduino C++
// fr_settings.h — FlashReady on-board settings (Wi-Fi name, phone number, ...) over USB.
//
// Why: the pre-built file on flashready.in is the same for everyone, so your own settings
// can't be inside it. Instead the board asks for them over USB serial and keeps them in
// its EEPROM. If you use Arduino IDE you can still type them into the constants in the
// sketch — this file then changes nothing except a 2-second pause at start-up.
//
// The protocol, in plain words (one text line each way, ending in \n, at 115200 baud):
//  1. Right after Serial.begin(115200) the board prints
//       FR?SETTINGS v1 keys=wifi_ssid,wifi_pass missing=wifi_pass
//     "missing" lists the required settings that still have no real value.
//  2. If nothing is missing it listens for 2 seconds and then carries on. If something is
//     missing it keeps listening, printing the line again every 2 seconds, for up to
//     30 seconds; then it carries on anyway and the sketch skips whatever needs the
//     missing value (an alarm still sounds, it just can't send the SMS).
//  3. The flasher answers   FR!SET wifi_ssid=My%20Home&wifi_pass=secret
//     (values URL-encoded). The board stores them and answers FR!OK, then carries on with
//     the new values. Errors: FR!ERR unknown <key>, FR!ERR long <key> (nothing is stored).
//     A key that is left out keeps its stored value; "key=" (empty) removes it.
//     "FR!SET" with nothing after it means "carry on now": FR!OK, nothing is stored.
//  4. FR!CLEAR wipes everything that was stored, answers FR!OK and keeps listening.
//  Other lines are ignored.
//
// A stored value wins over the constant in the sketch. A constant that is still a
// placeholder ("YOUR_SSID", "your-wifi", "+91XXXXXXXXXX") or empty counts as not set.
//
// EEPROM block at FR_SETTINGS_ADDR: 'F' 'R' 0x01, length (uint16, low byte first), then
// "key=value\n" lines. Uses FR_SETTINGS_SIZE bytes; move FR_SETTINGS_ADDR with a #define
// before the #include if your sketch uses EEPROM for something else.
//
// Use in a sketch:
//   const char* WIFI_SSID = "YOUR_SSID";
//   FrSetting SETTINGS[] = { {"wifi_ssid", &WIFI_SSID, true} };   // key, constant, required
//   void setup() { Serial.begin(115200); frSettingsBegin(SETTINGS); ... WiFi.begin(WIFI_SSID, ...); }
// After frSettingsBegin() each constant points at the value to use ("" if none is set).
#pragma once
#include <Arduino.h>
#include <EEPROM.h>
#include <string.h>

#ifndef FR_SETTINGS_ADDR
#define FR_SETTINGS_ADDR 0
#endif
#ifndef FR_SETTINGS_SIZE
#if defined(__AVR__)
#define FR_SETTINGS_SIZE 96  // 2 KB of RAM on an Uno: a phone number fits easily
#else
#define FR_SETTINGS_SIZE 512
#endif
#endif
#ifndef FR_LINE_MAX
#if defined(__AVR__)
#define FR_LINE_MAX 128
#else
#define FR_LINE_MAX 1024
#endif
#endif
#define FR_MAX_KEYS 8
#define FR_WAIT_MAX_MS 30000UL  // never wait longer than this for missing settings
#define FR_DATA_MAX (FR_SETTINGS_SIZE - 5)

struct FrSetting {
  const char* key;     // [a-z0-9_], as in settings.json
  const char** value;  // the sketch's constant; replaced by the value to use
  bool required;
};

// Stored settings in RAM as "key=value\0key=value\0" (only non-empty values).
static char frData[FR_DATA_MAX + 1];
static uint16_t frLen = 0;

static inline void frEepromBegin() {
#if defined(ESP32) || defined(ESP8266)
  static bool started = false;
  if (!started) EEPROM.begin(FR_SETTINGS_ADDR + FR_SETTINGS_SIZE);
  started = true;
#endif
}

static inline void frLoad() {
  frEepromBegin();
  frLen = 0;
  frData[0] = 0;
  if (EEPROM.read(FR_SETTINGS_ADDR) != 'F' || EEPROM.read(FR_SETTINGS_ADDR + 1) != 'R' || EEPROM.read(FR_SETTINGS_ADDR + 2) != 1) return;
  uint16_t n = EEPROM.read(FR_SETTINGS_ADDR + 3) | (EEPROM.read(FR_SETTINGS_ADDR + 4) << 8);
  if (n > FR_DATA_MAX) return;
  for (uint16_t i = 0; i < n; i++) {
    char c = EEPROM.read(FR_SETTINGS_ADDR + 5 + i);
    frData[i] = c == '\n' ? '\0' : c;
  }
  frData[n] = 0;
  frLen = n;
}

static inline void frPut(int addr, uint8_t b) {
#if defined(__AVR__)
  EEPROM.update(addr, b);
#else
  EEPROM.write(addr, b);
#endif
}

static inline void frSave() {
  frEepromBegin();
  frPut(FR_SETTINGS_ADDR, 'F');
  frPut(FR_SETTINGS_ADDR + 1, 'R');
  frPut(FR_SETTINGS_ADDR + 2, 1);
  frPut(FR_SETTINGS_ADDR + 3, frLen & 0xFF);
  frPut(FR_SETTINGS_ADDR + 4, frLen >> 8);
  for (uint16_t i = 0; i < frLen; i++) frPut(FR_SETTINGS_ADDR + 5 + i, frData[i] ? frData[i] : '\n');
#if defined(ESP32) || defined(ESP8266)
  EEPROM.commit();
#endif
}

// The stored value for key, or nullptr.
static inline const char* frStored(const char* key) {
  size_t kl = strlen(key);
  for (uint16_t i = 0; i < frLen; i += strlen(frData + i) + 1) {
    if (strncmp(frData + i, key, kl) == 0 && frData[i + kl] == '=') return frData + i + kl + 1;
  }
  return nullptr;
}

// Empty, "your_…" / "YOUR-…" / "your …", or a "+91XXXX…" phone number.
static inline bool frIsPlaceholder(const char* v) {
  if (!v || !*v) return true;
  if (strncasecmp(v, "your", 4) == 0 && (v[4] == '_' || v[4] == '-' || v[4] == ' ')) return true;
  return v[0] == '+' && strstr(v, "XXXX") != nullptr;
}

// The stored value for key, else the fallback constant; "" if that is a placeholder.
static inline const char* frSettingsGet(const char* key, const char* fallback) {
  const char* v = frStored(key);
  if (!v) v = fallback;
  return frIsPlaceholder(v) ? "" : v;
}

static inline bool frIsMissing(const FrSetting& s) {
  return s.required && !*frSettingsGet(s.key, *s.value);
}

static inline void frRequest(FrSetting* s, uint8_t n) {
  Serial.print(F("FR?SETTINGS v1 keys="));
  for (uint8_t i = 0; i < n; i++) {
    if (i) Serial.print(',');
    Serial.print(s[i].key);
  }
  Serial.print(F(" missing="));
  bool first = true;
  for (uint8_t i = 0; i < n; i++) {
    if (!frIsMissing(s[i])) continue;
    if (!first) Serial.print(',');
    Serial.print(s[i].key);
    first = false;
  }
  Serial.print('\n');
}

static inline void frReply(const __FlashStringHelper* what, const char* key) {
  Serial.print(what);
  Serial.print(key);
  Serial.print('\n');
}

static inline int8_t frHex(char c) {
  if (c >= '0' && c <= '9') return c - '0';
  c |= 0x20;
  return (c >= 'a' && c <= 'f') ? c - 'a' + 10 : -1;
}

// Percent-decodes in place. Drops NUL, CR and LF: they can't be stored.
static inline void frDecode(char* s) {
  char* o = s;
  for (; *s; s++) {
    char c = *s;
    if (c == '%' && frHex(s[1]) >= 0 && frHex(s[2]) >= 0) {
      c = (char)(frHex(s[1]) << 4 | frHex(s[2]));
      s += 2;
    }
    if (c != '\0' && c != '\n' && c != '\r') *o++ = c;
  }
  *o = 0;
}

// "k1=v1&k2=v2" (cut short if the line overflowed). Saves and returns true, or replies with an error.
static inline bool frSet(FrSetting* s, uint8_t n, char* p, bool overflow) {
  const char* upd[FR_MAX_KEYS] = {};
  while (*p) {
    char* amp = strchr(p, '&');
    bool last = !amp;
    if (amp) *amp = 0;
    char* eq = strchr(p, '=');
    if (eq) *eq = 0;
    if (*p) {
      if (last && overflow) {
        frReply(F("FR!ERR long "), p);
        return false;
      }
      uint8_t i = 0;
      while (i < n && strcmp(s[i].key, p) != 0) i++;
      if (i == n) {
        frReply(F("FR!ERR unknown "), p);
        return false;
      }
      char* v = eq ? eq + 1 : p + strlen(p);  // "key" alone = "key="
      frDecode(v);
      upd[i] = v;
    }
    if (last) break;
    p = amp + 1;
  }
  // Build the new block: for each key, the new value or the one already stored.
  char next[FR_DATA_MAX + 1];
  uint16_t len = 0;
  for (uint8_t i = 0; i < n; i++) {
    const char* v = upd[i] ? upd[i] : frStored(s[i].key);
    if (!v || !*v) continue;
    size_t kl = strlen(s[i].key), vl = strlen(v);
    if (len + kl + vl + 2 > FR_DATA_MAX) {
      frReply(F("FR!ERR long "), s[i].key);
      return false;
    }
    memcpy(next + len, s[i].key, kl);
    next[len + kl] = '=';
    memcpy(next + len + kl + 1, v, vl + 1);
    len += kl + vl + 2;
  }
  memcpy(frData, next, len);
  frData[len] = 0;
  frLen = len;
  frSave();
  return true;
}

// Handles one received line. Returns true once settings were saved (boot carries on).
static inline bool frCommand(FrSetting* s, uint8_t n, char* line, bool overflow) {
  if (strcmp(line, "FR!CLEAR") == 0) {
    frLen = 0;
    frData[0] = 0;
    frSave();
    Serial.print(F("FR!OK\n"));
    return false;
  }
  if (strncmp(line, "FR!SET", 6) != 0 || (line[6] != ' ' && line[6] != 0)) return false;  // not for us
  char* p = line + 6;
  while (*p == ' ') p++;
  if (*p && !frSet(s, n, p, overflow)) return false;  // empty FR!SET = carry on, store nothing
  Serial.print(F("FR!OK\n"));
  return true;
}

// Call right after Serial.begin(115200). Prints the request, waits as described at the top
// (30 s at most), then points every constant in the list at the value to use ("" = not set).
static inline void frSettingsBegin(FrSetting* s, uint8_t n) {
  frLoad();
  char line[FR_LINE_MAX];
  uint16_t len = 0;
  bool overflow = false;
  frRequest(s, n);
  unsigned long start = millis(), quietSince = start;
  while (millis() - start < FR_WAIT_MAX_MS) {
    int c = Serial.read();
    if (c >= 0) {
      quietSince = millis();
      if (c == '\r') continue;
      if (c != '\n') {
        if (len < FR_LINE_MAX - 1) line[len++] = (char)c;
        else overflow = true;
        continue;
      }
      line[len] = 0;
      bool saved = frCommand(s, n, line, overflow);
      len = 0;
      overflow = false;
      if (saved) break;
      continue;
    }
    if (millis() - quietSince < 2000) {
      delay(1);  // also keeps the ESP8266 watchdog fed
      continue;
    }
    len = 0;  // drop half a line
    overflow = false;
    bool missing = false;
    for (uint8_t i = 0; i < n; i++) missing |= frIsMissing(s[i]);
    if (!missing) break;
    frRequest(s, n);
    quietSince = millis();
  }
  for (uint8_t i = 0; i < n; i++) *s[i].value = frSettingsGet(s[i].key, *s[i].value);
}

template <size_t N>
static inline void frSettingsBegin(FrSetting (&s)[N]) {
  static_assert(N <= FR_MAX_KEYS, "fr_settings.h: at most FR_MAX_KEYS settings");
  frSettingsBegin(s, (uint8_t)N);
}

Compiled for nanoesp32 on esp32 3.3.12 with Adafruit PN532 1.3.4 · 3 Nov 2026

How it works

A school or college canteen handles hundreds of tiny cash payments every break: ₹10 for tea, ₹20 for a samosa, change that never quite adds up. A prepaid card removes the cash. Parents top up at the office, and children tap and buy.

Each student gets an inexpensive NFC card. When a card is tapped on the PN532 reader, the Arduino Nano ESP32 reads its UID — the card’s unique serial number — and looks up that UID’s balance in its flash memory, then shows it on the OLED. The student (or the canteen staff) presses one of three item buttons, ₹10, ₹20 or ₹30, within six seconds. If there is enough balance the price is subtracted, saved, and a short beep confirms; if not, a long beep and “Not enough”. Every sale is printed on the Serial Monitor as a CSV line.

Top-ups use the canteen manager’s own card as an admin card: tap it, then a student card within ten seconds, and ₹100 is added. The admin card’s UID is a setting, so it isn’t in the code anyone can read on GitHub.

Balances are stored with Preferences (the ESP32’s NVS key-value store, which spreads writes to protect the flash), keyed by the UID string. The Nano ESP32 keeps the familiar Nano layout — A4/A5 for I2C, D2–D7 for the reader’s IRQ/reset, buttons and buzzer — while giving far more flash and RAM than a classic Nano for storing hundreds of balances.

Troubleshooting

“PN532 not found”

Set the PN532’s DIP switches to I2C (1 ON, 2 OFF), check A4/A5 and VCC on 3V3, and wire IRQ and RSTO.

A card shows Rs 0 after top-up

The top-up went to a different card: after tapping the admin card you have 10 s to tap the student card. The Serial Monitor shows which UID got the money.

Two items are charged for one press

After a sale the box waits 2 s and needs a fresh tap, so this usually means the card was tapped twice. Check the log times.

Upgrades

  • Show the student’s name by adding a UID → name table.
  • Send each sale to a Google Sheet over Wi-Fi for parents to see.
  • Add a daily spending limit per card.

↑↓ to move · Enter to open