melamelamelamela
Published © MIT

The Vortex Oracle: a sphinx that prints your fortune

A pyramid notices you. Wave at it, and a 3D-printed sphinx across the table prints your fortune on a vintage Zebra label printer.

BeginnerFull instructions provided12 hours66
The Vortex Oracle: a sphinx that prints your fortune

Things used in this project

Hardware components

ESP32-S3-WROOM-1 N16R8 dev board
×1
GC9A01 1.28 inch round SPI LCD, 240x240
×1
HLK-LD2410C 24 GHz mmWave presence radar
×1
MAX3232 TTL to RS-232 module
×1
Zebra LP 2824 Plus thermal label printer (203 dpi, ZPL II)
×1
DB9 serial cable, male to female straight-through
×1
USB-C panel-mount inlet breakout (5.1k CC pull-downs)
×1
56 mm continuous thermal receipt roll, 10 mm core
×1
Silver PLA filament (about 1.3 kg for the sphinx)
×1

Software apps and online services

Arduino IDE
Arduino IDE
Onshape

Hand tools and fabrication machines

3D Printer (generic)
3D Printer (generic)
Soldering iron (generic)
Soldering iron (generic)
Multimeter

Story

Read more

Custom parts and enclosures

Oracle pyramid

Oracle pyramid base

Sphinx head

Sphinx body

Sphinx L paw

Sphinx R paw

Sphinx rump

Schematics

Wiring Diagrams

For Node A (Oracle) and Node B (Sphinx)

Code

Node A — the Oracle Mirror (the pyramid)

Arduino
Radar presence gating, the eye animation, wave-to-consult, and the ESP-NOW sender that waits for the Sphinx's verdict before it says anything. Every tuning knob is at the top of the file.
/*  ============================================================
    THE VORTEX ORACLE  ::  NODE A  ::  THE ORACLE MIRROR
    ------------------------------------------------------------
    A 3D-printed pyramid with a round display for an eye and an
    mmWave radar behind its face. It notices a visitor, lets them
    consult the Oracle with a wave or a button, and asks Node B
    (the Sphinx) over ESP-NOW to print their fortune.

    Alice Vortex, Albuquerque, NM
    Written with Claude (Anthropic), July-September 2026.
    Debuted at Burque ByteFest, 19 September 2026.
    alicevortex.com

    HARDWARE
      ESP32-S3-WROOM-1 (N16R8) dev board
      GC9A01 1.28" 240x240 round SPI display
      HLK-LD2410C 24 GHz mmWave presence radar (UART, 256000 baud)
      One momentary button to GND

    LIBRARIES        Adafruit GFX, Adafruit BusIO, Adafruit GC9A01A,
                     ld2410 (ncmreynolds). arduino-esp32 core 3.x.
    BOARD SETTINGS   ESP32S3 Dev Module - USB CDC On Boot: Enabled -
                     PSRAM: Disabled - Flash 16MB - 16M partition.

    FULLY OFFLINE. Neither node joins a network. ESP-NOW is radio to
    radio; both sit unconnected in station mode on ESPNOW_CHANNEL.
    No router, no cloud, no NTP, no OTA.

    THE EYE
      AMBIENT   slow blue/purple pulse - nobody within reach
      PRESENCE  faster amber pulse - someone inside TRIGGER_DISTANCE_CM
      COOLDOWN  dim teal - resting, inputs ignored

    TO CONSULT IT
      Wave: hold a hand within HAND_TRIGGER_CM of the face while the
      eye is amber. Or tap the button. Or hold the button for
      LONG_PRESS_MS to force a reading with no radar at all (a demo,
      or the fallback if the radar dies at an event). A ring closes
      around the iris while you hold, either way.

    IT TELLS THE TRUTH. The Mirror waits for the radio ACK and then
    for the Sphinx's verdict before it says anything:
      printed            "Your destiny has been written - No. 42"
      paper out / paused "The Sphinx is silent - summon the keeper"
      no reply           "The Sphinx sleeps"
      inside cooldown    "The Oracle is resting"

    BEFORE FLASHING
      1. PEER_MAC below must be the MAC the Sphinx prints at boot.
      2. Flash BOTH nodes from the same release - the packet format
         has to match on the two sides.
    ============================================================ */

#include <Adafruit_GFX.h>
#include <Adafruit_GC9A01A.h>
#include <SPI.h>
#include <WiFi.h>
#include <esp_wifi.h>
#include <esp_now.h>
#include <esp_idf_version.h>
#include <math.h>
#include <ld2410.h>   // Library Manager: "ld2410" by ncmreynolds

// ============================================================
//  KNOBS  -- everything worth tuning lives here
// ============================================================
#define ESPNOW_CHANNEL          1        // MUST match Node B
const uint16_t      TRIGGER_DISTANCE_CM = 90;      // "approach me" radius (~3 ft). The radar itself reads to ~6 m.
const unsigned long PRESENCE_HOLD_MS    = 1500;    // stay in PRESENCE this long after the last near reading (stops edge flicker)
const unsigned long COOLDOWN_MS         = 15000;   // visitor cooldown after a fortune
const unsigned long RETRY_COOLDOWN_MS   = 4000;    // shorter rest after a failed reading (Sphinx asleep / paper out)
const unsigned long LONG_PRESS_MS       = 1000;    // hold the button this long = forced reading; the ring closes here
const unsigned long HOLD_RING_MS        = 250;     // ring starts drawing after this much hold (a tap never shows it)
const unsigned long ACK_WAIT_MS         = 500;     // radio ACK normally lands in < 10 ms
const unsigned long REPLY_WAIT_MS       = 2500;    // the Sphinx checks the printer (~0.6 s) before it answers
#define HEARTBEAT_MS            2000     // serial heartbeat period; 0 = off
#define TFT_HW_SPI              1        // 1 = display on the S3's SPI peripheral; 0 = bit-banged fallback (slow but sure)
#define TFT_SPI_HZ              20000000 // hardware SPI clock. 40 MHz is the library default and fine on short leads.
#define EYE_CANVAS              1        // 1 = compose each frame in a hidden 115 KB buffer, push it whole (no overdraw flicker)
#define FRAME_PAUSE_MS          10       // breather at the end of each loop pass
const unsigned long BTN_BOUNCE_MS       = 25;      // contact-bounce lockout after each accepted button edge
const uint16_t      HAND_TRIGGER_CM     = 30;      // WAVE: a target this close while the eye is amber = consult. 0 = off, button only.
const unsigned long HAND_HOLD_MS        = 400;     // the hand must stay inside that radius this long (~4 radar frames)
const unsigned long HAND_REARM_MS       = 1500;    // the zone must be empty this long before another wave can fire
const unsigned long HAND_GRACE_MS       = 250;     // one jittery radar frame outside the radius does not end a hold

// ---- Display pins ----
// NOTE: the classic ESP32's 22/23/25 do NOT exist on the S3. Check the variant, not the family.
#define TFT_SCK    18    // SCL  (clock)
#define TFT_MOSI   11    // SDA  (data)
#define TFT_DC     21    // DC
#define TFT_CS      5    // CS
#define TFT_RST    10    // RST

// ---- Interactive pins ----
#define BUTTON_PIN    4   // button to GND, INPUT_PULLUP. A 4-leg tactile switch wants DIAGONAL legs.

// ---- mmWave radar over UART ----
// The radar's TX idles above the S3's 3.6 V absolute max, so it reaches GPIO 16 through a
// 1k / 6.8k divider. The radar's OUT pin is not used: we parse UART target frames instead,
// which reports distance and sees stationary people too.
#define RADAR_RX     16   // ESP RX2  <- radar TX (via the divider)
#define RADAR_TX     17   // ESP TX2  -> radar RX
ld2410 radar;
bool   radarOk = false;

// SPI.begin(pins) MUST run before tft.begin(), or the library's own SPI.begin() claims the S3's
// default pins and the clock goes to an empty one -- the display lights up and stays black.
#if TFT_HW_SPI
Adafruit_GC9A01A tft = Adafruit_GC9A01A(TFT_CS, TFT_DC, TFT_RST);
#else
Adafruit_GC9A01A tft = Adafruit_GC9A01A(TFT_CS, TFT_DC, TFT_MOSI, TFT_SCK, TFT_RST, -1);
#endif

// The eye is composed off-screen. `eye` points at the hidden canvas when one could be allocated
// (240 x 240 x 16-bit = 115,200 bytes), else straight at the panel. Text screens always write to
// the panel; the next pushed frame covers them.
GFXcanvas16*  canvas     = nullptr;
Adafruit_GFX* eye        = &tft;
bool          frameDirty = false;

// ---- ESP-NOW: Node B's MAC address (the Sphinx prints it on boot) ----
uint8_t PEER_MAC[] = { 0xE0, 0x72, 0xA1, 0xD5, 0x22, 0xB0 };

// ============================================================
//  PACKET FORMATS  (must match Node B exactly -- packed, 5 bytes)
// ============================================================
#define ORACLE_MAGIC 0x5641            // 'VA' -- anything without this word is somebody else's project
enum : uint8_t { CMD_PRINT = 1 };
enum : uint8_t {                        // Sphinx -> Mirror verdicts
  ST_PRINTED       = 2,
  ST_PAPER_OUT     = 3,
  ST_PAUSED        = 4,
  ST_RESTING       = 5,
  ST_PRINTED_BLIND = 6
};

typedef struct __attribute__((packed)) {
  uint16_t magic;
  uint8_t  command;       // CMD_PRINT
  int16_t  fortuneIndex;  // -1 = let the Sphinx deal
} OracleMessage;

typedef struct __attribute__((packed)) {
  uint16_t magic;
  uint8_t  status;        // ST_*
  int16_t  fortuneIndex;  // index printed, or -1
} OracleReply;
static_assert(sizeof(OracleMessage) == 5 && sizeof(OracleReply) == 5, "wire format must stay 5 bytes on both nodes");

volatile int8_t  sendStatus   = 0;      // 0 = pending, 1 = radio ACK, -1 = no ACK
volatile bool    replyArrived = false;
volatile uint8_t replyStatus  = 0;
volatile int16_t replyIndex   = -1;

// ============================================================
//  STATE
// ============================================================
enum OracleState { AMBIENT, PRESENCE, COOLDOWN };
OracleState   state = AMBIENT;
unsigned long cooldownStart = 0, cooldownLen = COOLDOWN_MS;
bool          announcedPresence = false;

unsigned long lastNearMs = 0;  bool everNear = false;      // presence hold
bool          handInZone = false, handArmed = false;        // wave to consult
unsigned long handInSince = 0, handOutSince = 0, handLastInMs = 0;
bool          btnDown = false; unsigned long pressStart = 0; bool longFired = false;   // the loop's view of the button
unsigned long lastHeartbeat = 0;

// The interrupt's view of the button. A frame of the eye takes tens of milliseconds to draw and a
// visitor's tap can be 50 ms, so polling once per loop misses taps. The interrupt timestamps every
// edge the instant it happens and leaves the timestamps here for loop() to judge.
volatile bool          isrDown        = false;   // level after the last accepted edge (true = pressed)
volatile unsigned long isrEdgeMs      = 0;       // when that edge was
volatile unsigned long isrPressMs     = 0;       // when the button last went down
volatile unsigned long isrReleaseMs   = 0;       // when it last came up
volatile bool          isrReleaseFlag = false;   // a release loop() hasn't consumed yet
portMUX_TYPE           btnMux = portMUX_INITIALIZER_UNLOCKED;

void IRAM_ATTR onButtonEdge() {                  // runs on every edge of BUTTON_PIN; keep it tiny
  unsigned long t = millis();
  bool down = (digitalRead(BUTTON_PIN) == LOW);
  portENTER_CRITICAL_ISR(&btnMux);
  if (t - isrEdgeMs >= BTN_BOUNCE_MS) {          // edges inside the lockout are contact bounce
    isrEdgeMs = t; isrDown = down;
    if (down) isrPressMs = t;
    else      { isrReleaseMs = t; isrReleaseFlag = true; }
  }
  portEXIT_CRITICAL_ISR(&btnMux);
}
uint16_t      lastNearestCm = 0xFFFF;

// ============================================================
//  COLOUR / DRAWING HELPERS
// ============================================================
uint16_t rgb565(uint8_t r, uint8_t g, uint8_t b) {
  return ((r & 0xF8) << 8) | ((g & 0xFC) << 3) | (b >> 3);
}
uint16_t dim(uint8_t r, uint8_t g, uint8_t b, float k) {
  return rgb565((uint8_t)(r * k), (uint8_t)(g * k), (uint8_t)(b * k));
}

// Five discs painted over each other, brightness on a sine. Into the hidden canvas this is free;
// straight onto the panel every intermediate state shows and the eye flickers.
void drawEye(uint8_t r, uint8_t g, uint8_t b, float periodMs) {
  float phase = (sinf((float)(millis() % (uint32_t)periodMs) * 2.0f * PI / periodMs) * 0.5f) + 0.5f;
  float k = 0.35f + 0.65f * phase;
  if (canvas) canvas->fillScreen(GC9A01A_BLACK);            // wipe the hidden frame, never the panel
  eye->fillCircle(120, 120, 116, dim(r, g, b, k * 0.25f));  // outer halo
  eye->fillCircle(120, 120,  82, dim(r, g, b, k * 0.55f));  // iris
  eye->fillCircle(120, 120,  46, dim(r, g, b, k));          // inner glow
  eye->fillCircle(120, 120,  22, GC9A01A_BLACK);            // pupil
  eye->fillCircle(104, 104,   7, dim(255, 255, 255, k));    // highlight
  frameDirty = true;
}

// Send the composed frame to the panel in one pass: every pixel written once, in its final colour.
void pushFrame() {
  if (!canvas || !frameDirty) return;
  tft.drawRGBBitmap(0, 0, canvas->getBuffer(), 240, 240);
  frameDirty = false;
}

void centerText(const char* msg, int y, uint8_t size, uint16_t color) {
  int16_t x1, y1; uint16_t w, h;
  tft.setTextSize(size);
  tft.setTextColor(color);
  tft.getTextBounds(msg, 0, y, &x1, &y1, &w, &h);
  tft.setCursor((240 - w) / 2, y);
  tft.print(msg);
}

// Hold-to-consult ring: 60 dots just outside the halo, clockwise from 12 o'clock. progress 0..1.
// Pass black to erase (only needed when painting straight onto the panel).
int ringDrawn = -1;
void drawRing(float progress, uint16_t color) {
  int n = (int)(progress * 60.0f + 0.5f); if (n > 60) n = 60;
  for (int i = 0; i < n; i++) {
    float a = -PI / 2 + i * (2.0f * PI / 60.0f);
    eye->fillCircle(120 + (int)(118 * cosf(a)), 120 + (int)(118 * sinf(a)), 2, color);
  }
  frameDirty = true;
}

// Forget button activity that piled up while the Oracle was busy (a reading takes seconds and the
// interrupt kept listening). A button still held from a long press keeps tracking but can't re-fire.
void flushButton() {
  portENTER_CRITICAL(&btnMux);
  isrReleaseFlag = false;
  bool held = isrDown;
  portEXIT_CRITICAL(&btnMux);
  btnDown = held; pressStart = millis(); longFired = true; ringDrawn = -1;
}

void enterCooldown(unsigned long ms) {
  tft.fillScreen(GC9A01A_BLACK);
  flushButton();
  handArmed = false;                       // the zone has to clear before the next wave
  state = COOLDOWN;
  cooldownStart = millis();
  cooldownLen = ms;
  announcedPresence = false;
}

// ============================================================
//  ESP-NOW
// ============================================================
// The send callback's signature changed in ESP-IDF 5.5 (arduino-esp32 core 3.3.x).
#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 5, 0)
void onSent(const esp_now_send_info_t *info, esp_now_send_status_t status)
#else
void onSent(const uint8_t *mac, esp_now_send_status_t status)
#endif
{
  sendStatus = (status == ESP_NOW_SEND_SUCCESS) ? 1 : -1;
}

void onRecv(const esp_now_recv_info_t *info, const uint8_t *data, int len) {
  if (len != sizeof(OracleReply)) return;
  OracleReply r;
  memcpy(&r, data, sizeof(r));
  if (r.magic != ORACLE_MAGIC) return;
  replyStatus  = r.status;
  replyIndex   = r.fortuneIndex;
  replyArrived = true;
}

void sendTrigger(int16_t index) {
  OracleMessage msg = { ORACLE_MAGIC, CMD_PRINT, index };
  sendStatus = 0; replyArrived = false;
  esp_err_t e = esp_now_send(PEER_MAC, (const uint8_t*)&msg, sizeof(msg));
  Serial.println(e == ESP_OK ? "ESP-NOW: trigger queued." : "ESP-NOW: send FAILED (peer not registered?).");
  if (e != ESP_OK) sendStatus = -1;
}

// ============================================================
//  THE TRIGGER RITUAL
// ============================================================
void triggerOracleReading(bool forced) {
  Serial.println(forced ? "TRIGGER (long press / demo mode)" : "TRIGGER (presence + button)");
  announcedPresence = false;
  tft.fillScreen(GC9A01A_WHITE);       // dramatic flash
  delay(120);
  tft.fillScreen(GC9A01A_BLACK);
  centerText("READING", 95,  3, GC9A01A_WHITE);
  centerText("YOUR AURA", 130, 2, rgb565(180, 140, 255));

  unsigned long t0 = millis();
  sendTrigger(-1);

  // 1. Did the Sphinx's radio even hear us?
  while (sendStatus == 0 && millis() - t0 < ACK_WAIT_MS) delay(5);
  if (sendStatus < 0) {
    Serial.println("ESP-NOW: NO ACK -- is the Sphinx powered, on ESPNOW_CHANNEL, and is PEER_MAC right?");
    while (millis() - t0 < 1500) delay(5);
    tft.fillScreen(GC9A01A_BLACK);
    centerText("The Sphinx", 100, 2, rgb565(255, 60, 60));
    centerText("sleeps.",    130, 2, rgb565(255, 60, 60));
    delay(2500);
    enterCooldown(RETRY_COOLDOWN_MS);
    return;
  }

  // 2. Wait for its verdict (it asks the printer first), keeping the drama on screen >= 2.5 s.
  while (!replyArrived && millis() - t0 < REPLY_WAIT_MS) delay(5);
  while (millis() - t0 < 2500) delay(5);

  uint8_t verdict = replyArrived ? replyStatus : (uint8_t)ST_PRINTED_BLIND;
  if (!replyArrived) Serial.println("Sphinx ACKed but sent no verdict -- is Node B running v1.3? Assuming it printed.");
  else Serial.printf("Sphinx verdict=%d fortune=%d\n", replyStatus, replyIndex);

  tft.fillScreen(GC9A01A_BLACK);
  switch (verdict) {
    case ST_PRINTED:
    case ST_PRINTED_BLIND: {
      centerText("Your destiny", 100, 2, GC9A01A_WHITE);
      centerText("has been written.", 130, 2, GC9A01A_WHITE);
      if (replyArrived && replyIndex >= 0) {
        char num[16]; snprintf(num, sizeof(num), "No. %d", replyIndex + 1);   // matches the card deck numbering
        centerText(num, 165, 1, rgb565(120, 120, 120));
      }
      delay(2500);
      tft.fillScreen(GC9A01A_BLACK);
      centerText("Goodbye...", 110, 2, rgb565(120, 200, 200));
      centerText("for now.",   140, 2, rgb565(120, 200, 200));
      delay(2000);
      enterCooldown(COOLDOWN_MS);
      break;
    }
    case ST_PAPER_OUT:
    case ST_PAUSED: {
      Serial.println(verdict == ST_PAPER_OUT ? ">>> ZEBRA: PAPER OUT -- load a roll." : ">>> ZEBRA: PAUSED -- un-pause it.");
      centerText("The Sphinx", 95, 2, rgb565(255, 60, 60));
      centerText("is silent.", 125, 2, rgb565(255, 60, 60));
      centerText("summon the keeper", 165, 1, rgb565(200, 120, 120));
      delay(3000);
      enterCooldown(RETRY_COOLDOWN_MS);
      break;
    }
    case ST_RESTING:
    default: {
      centerText("The Oracle", 100, 2, rgb565(120, 200, 200));
      centerText("is resting.", 130, 2, rgb565(120, 200, 200));
      delay(2000);
      enterCooldown(COOLDOWN_MS);
      break;
    }
  }
}

// ============================================================
//  RADAR: nearest target in cm (0xFFFF = nobody)
// ============================================================
uint16_t nearestTargetCm() {
  uint16_t d = 0xFFFF;
  if (!radar.presenceDetected()) return d;
  if (radar.movingTargetDetected()     && radar.movingTargetDistance()     < d) d = radar.movingTargetDistance();
  if (radar.stationaryTargetDetected() && radar.stationaryTargetDistance() < d) d = radar.stationaryTargetDistance();
  return d;
}

// ============================================================
//  SETUP / LOOP
// ============================================================
void setup() {
  Serial.begin(115200);
  unsigned long t0 = millis();
  while (!Serial && millis() - t0 < 3000) delay(10);  // wait for USB CDC to re-enumerate (max 3 s)
  delay(250);
  Serial.println("\n=== VORTEX ORACLE :: NODE A :: MIRROR v1.3.4 ONLINE ===");

  pinMode(BUTTON_PIN, INPUT_PULLUP);
  attachInterrupt(digitalPinToInterrupt(BUTTON_PIN), onButtonEdge, CHANGE);   // every press and release, timestamped

  // The LD2410C streams target frames at 256000 baud (its factory default).
  Serial2.begin(256000, SERIAL_8N1, RADAR_RX, RADAR_TX);
  radarOk = radar.begin(Serial2);
  Serial.println(radarOk ? "Radar connected." : "Radar NOT found -- check 256000 baud, divider on TX->GPIO16, RX->GPIO17. (Long press still works.)");

  SPI.begin(TFT_SCK, -1, TFT_MOSI, TFT_CS);   // MUST come before tft.begin() -- see the note at the constructor
#if TFT_HW_SPI
  tft.begin(TFT_SPI_HZ);
#else
  tft.begin();
#endif
  tft.setRotation(0);
  tft.fillScreen(GC9A01A_BLACK);
  centerText("ORACLE", 95,  3, GC9A01A_WHITE);
  centerText("v1.3.4", 135, 2, rgb565(160, 160, 160));

#if EYE_CANVAS
  canvas = new GFXcanvas16(240, 240);                    // 115,200 bytes from the heap, before Wi-Fi takes its share
  if (canvas && canvas->getBuffer()) {
    eye = canvas;
    Serial.printf("Eye canvas: 115 KB back buffer ready (free heap now %lu bytes).\n", (unsigned long)ESP.getFreeHeap());
  } else {
    delete canvas; canvas = nullptr; eye = &tft;
    Serial.println("Eye canvas: NO RAM for the back buffer -- painting straight onto the panel (expect the flicker).");
  }
#endif
  delay(1500);
  tft.fillScreen(GC9A01A_BLACK);

  // ESP-NOW: station mode, deliberately NOT connected to any AP, pinned to one channel, radio never sleeps.
  WiFi.mode(WIFI_STA);
  WiFi.disconnect(false, true);                          // forget any SSID a previous sketch left in flash
  WiFi.setSleep(false);
  esp_wifi_set_channel(ESPNOW_CHANNEL, WIFI_SECOND_CHAN_NONE);
  Serial.print("My MAC: "); Serial.print(WiFi.macAddress()); Serial.printf("   channel %d\n", ESPNOW_CHANNEL);

  if (esp_now_init() != ESP_OK) {
    Serial.println("ESP-NOW init FAILED");
  } else {
    esp_now_register_send_cb(onSent);
    esp_now_register_recv_cb(onRecv);
    esp_now_peer_info_t peer = {};
    memcpy(peer.peer_addr, PEER_MAC, 6);
    peer.channel = 0;          // 0 = the channel we're on
    peer.ifidx   = WIFI_IF_STA;
    peer.encrypt = false;
    if (esp_now_add_peer(&peer) == ESP_OK) Serial.println("Sphinx peer registered.");
    else Serial.println("Failed to add peer -- check PEER_MAC.");
  }
  Serial.printf("Trigger radius %u cm, cooldown %lu s, long press %lu ms.\n", TRIGGER_DISTANCE_CM, COOLDOWN_MS / 1000, LONG_PRESS_MS);
}

void loop() {
  unsigned long now = millis();

  // ---- Radar, with the distance gate and its hold ----
  radar.read();
  lastNearestCm = nearestTargetCm();
  if (lastNearestCm <= TRIGGER_DISTANCE_CM) { lastNearMs = now; everNear = true; }
  bool present = everNear && (now - lastNearMs < PRESENCE_HOLD_MS);

  // ---- Wave to consult: a hand held close while the eye is amber ----
  // Physics caveat: the LD2410C's range gates are 0.75 m wide by default, so a torso at 60 cm and a
  // hand at 20 cm can land in the same gate. Watch the heartbeat while tuning HAND_TRIGGER_CM; the
  // radar app's "distance resolution -> 0.2 m" is the lever if the two won't separate.
  if (HAND_TRIGGER_CM > 0) {
    bool readingIn = (lastNearestCm <= HAND_TRIGGER_CM);         // nothing detected reads 0xFFFF, so this is false
    if (readingIn) handLastInMs = now;
    bool handNow = readingIn || (handInZone && now - handLastInMs < HAND_GRACE_MS);   // one jittery frame doesn't end a hold
    if (handNow != handInZone) {
      handInZone = handNow;
      if (handNow) { handInSince = now; Serial.printf("Hand: in zone (%u cm)%s\n", lastNearestCm, handArmed ? "" : " -- not armed yet"); }
      else {
        handOutSince = now; Serial.println("Hand: out of zone");
        if (ringDrawn > 0 && !btnDown) { drawRing(1.0f, GC9A01A_BLACK); ringDrawn = -1; }   // panel-mode erase; a no-op with the canvas
      }
    }
    if (!handInZone && !handArmed && now - handOutSince >= HAND_REARM_MS) handArmed = true;
    if (handInZone && handArmed && state == PRESENCE && now - handInSince >= HAND_HOLD_MS) {
      handArmed = false;
      Serial.printf("Hand: held %lu ms at %u cm -> reading\n", now - handInSince, lastNearestCm);
      triggerOracleReading(false);
      return;
    }
  }

  // ---- Button: the interrupt captured the edges with timestamps; judge them here ----
  // A short tap acts on release (so it can be told from a hold). A tap that began AND ended since
  // the last pass still counts.
  portENTER_CRITICAL(&btnMux);
  bool          pinDown = (digitalRead(BUTTON_PIN) == LOW);
  bool          bDown = isrDown, bRel = isrReleaseFlag;  isrReleaseFlag = false;
  unsigned long bPress = isrPressMs, bRelMs = isrReleaseMs;
  if (pinDown != bDown && now - isrEdgeMs > BTN_BOUNCE_MS) {   // the ISR missed an edge (shorter than the lockout): believe the pin
    isrDown = bDown = pinDown; isrEdgeMs = now;
    if (pinDown) isrPressMs = bPress = now;                     // a missed press starts now; a missed release is not a tap
  }
  portEXIT_CRITICAL(&btnMux);
  now = millis();                                          // re-read: an ISR timestamp can be newer than the `now` taken at the top

  if (!btnDown && (bDown || bRel)) {                       // a press the loop hadn't seen yet (it may already be over)
    btnDown = true; pressStart = bPress; longFired = false;
  }
  if (btnDown && !bDown && !bRel) {                        // let go without a proper release edge: drop it quietly
    btnDown = false;
    if (ringDrawn > 0) { drawRing(1.0f, GC9A01A_BLACK); ringDrawn = -1; }
  }
  if (btnDown && bRel) {                                   // released
    btnDown = false;
    if (!longFired) {                                      // short tap (or an early release of a hold)
      Serial.printf("Button: tap %lu ms\n", (bRelMs >= pressStart) ? bRelMs - pressStart : 0UL);
      if (ringDrawn > 0) { drawRing(1.0f, GC9A01A_BLACK); ringDrawn = -1; }   // erase a half-drawn ring
      if (state == COOLDOWN) {
        centerText("the Oracle rests", 222, 1, rgb565(90, 140, 140));        // feedback instead of silence
        delay(600);
        tft.fillRect(0, 214, 240, 12, GC9A01A_BLACK);
      } else if (present) {
        triggerOracleReading(false);
        return;
      } else {
        tft.fillScreen(GC9A01A_BLACK);                       // for the curious who press before they arrive
        centerText("APPROACH ME", 110, 2, rgb565(255, 40, 40));
        delay(1200);
        tft.fillScreen(GC9A01A_BLACK);
      }
    }
  }
  if (btnDown && !longFired && now - pressStart >= LONG_PRESS_MS) {
    Serial.printf("Button: held %lu ms -> forced reading\n", now - pressStart);
    longFired = true; ringDrawn = -1;
    if (state != COOLDOWN) { triggerOracleReading(true); return; }
    centerText("the Oracle rests", 222, 1, rgb565(90, 140, 140));
    delay(600);
    tft.fillRect(0, 214, 240, 12, GC9A01A_BLACK);
  }

  // ---- Heartbeat: what it thinks, every 2 s, whenever the monitor attaches ----
#if HEARTBEAT_MS
  if (now - lastHeartbeat >= HEARTBEAT_MS) {
    lastHeartbeat = now;
    const char* s = state == AMBIENT ? "AMBIENT" : state == PRESENCE ? "PRESENCE" : "COOLDOWN";
    if (lastNearestCm == 0xFFFF)
      Serial.printf("[hb] %-8s radar=%s nobody            present=%d btn=%s\n", s, radarOk ? "ok" : "MISSING", present, btnDown ? "DOWN" : "up");
    else
      Serial.printf("[hb] %-8s radar=%s nearest=%4u cm (mov %u / stat %u) present=%d btn=%s\n", s, radarOk ? "ok" : "MISSING",
                    lastNearestCm, radar.movingTargetDistance(), radar.stationaryTargetDistance(), present, btnDown ? "DOWN" : "up");
  }
#endif

  // ---- State + animation ----
  switch (state) {
    case COOLDOWN:
      drawEye(40, 120, 120, 5000);    // slow dim teal sleep
      if (now - cooldownStart > cooldownLen) {
        state = AMBIENT;
        tft.fillScreen(GC9A01A_BLACK);
      }
      break;

    case PRESENCE:
      if (!present) { state = AMBIENT; announcedPresence = false; tft.fillScreen(GC9A01A_BLACK); break; }
      if (!announcedPresence) {       // greet once on arrival
        tft.fillScreen(GC9A01A_BLACK);
        centerText("The Oracle", 95, 2, rgb565(255, 200, 120));
        centerText("recognizes", 120, 2, rgb565(255, 200, 120));
        centerText("your aura...", 145, 2, rgb565(255, 200, 120));
        delay(1600);
        tft.fillScreen(GC9A01A_BLACK);
        announcedPresence = true;
      }
      drawEye(255, 150, 40, 750);     // fast warm amber pulse
      break;

    case AMBIENT:
    default:
      if (present) { state = PRESENCE; break; }
      drawEye(150, 90, 255, 3200);    // slow blue/purple pulse
      break;
  }

  // ---- The hold-to-consult ring, over whatever the eye is doing: the button's hold, or the hand's ----
  float ringP = -1.0f;
  if (btnDown && !longFired && state != COOLDOWN && now - pressStart >= HOLD_RING_MS)
    ringP = (float)(now - pressStart - HOLD_RING_MS) / (float)(LONG_PRESS_MS - HOLD_RING_MS);
  else if (handInZone && handArmed && state == PRESENCE)
    ringP = (float)(now - handInSince) / (float)HAND_HOLD_MS;
  if (ringP >= 0.0f) {
    drawRing(ringP, rgb565(255, 220, 120));
    ringDrawn = 1;
  }

  pushFrame();                     // the composed eye (+ ring) goes to the panel in one pass
  delay(FRAME_PAUSE_MS);
}

Node B — the Sphinx (the printer node)

Arduino
ESP-NOW receiver, the shuffled 82-fortune deck, the ZPL job builder for continuous receipt stock, a printer status check before every print, and a serial console for tuning at the table. Needs vortex_emblem.h, or set USE_EMBLEM 0.
/*  ============================================================
    THE VORTEX ORACLE  ::  NODE B  ::  THE SPHINX
    ------------------------------------------------------------
    The printer node, hidden inside a 3D-printed Art Deco sphinx.
    It listens for the Mirror over ESP-NOW, deals a fortune from a
    shuffled deck, checks the printer is actually able to print,
    sends the ticket, and tells the Mirror what happened.

    Alice Vortex, Albuquerque, NM
    Written with Claude (Anthropic), July-September 2026.
    Debuted at Burque ByteFest, 19 September 2026.
    alicevortex.com

    HARDWARE
      ESP32-S3-WROOM-1 (N16R8) dev board
      MAX3232 TTL <-> RS-232 module (3.3 V logic)
      DB9 male-to-female straight-through cable
      Zebra LP 2824 Plus thermal printer, 203 dpi, ZPL II, 9600 8N1
      56 mm continuous thermal receipt roll

    WIRING          MAX3232 VCC -> 3V3, GND -> GND (tie to DB9 pin 5),
                    TXD -> GPIO 18 (ESP receives), RXD -> GPIO 17
                    (ESP transmits). CTS/RTS left open.
    NEEDS           vortex_emblem.h (the label frame, generated).
                    Set USE_EMBLEM 0 to compile and run without it.
    BOARD SETTINGS  ESP32S3 Dev Module - USB CDC On Boot: Enabled -
                    PSRAM: Disabled - Flash 16MB - 16M partition.

    FULLY OFFLINE. No router, no internet, no web server. ESP-NOW is
    radio to radio; both nodes sit unconnected in station mode on the
    same channel. The 82 fortunes live in this firmware; the frame art
    lives in the printer's own flash.

    HOW A TICKET IS BUILT
      The paper is continuous, and the Sphinx's upper lip carries the
      serrated teeth the guest tears against. So every job prints TWO
      blocks: the brand block (masthead, QR, tagline) and the fortune
      block (frame, fortune, "No. x of 82", LIFT TO TEAR, dashes).
      After a job the paper rests with its last printed row under the
      head, so exactly MOUTH_MM of the image sits between the head and
      the teeth -- and the tear lands on the dashes. Nothing is wasted
      and there are no blank leaders: each ticket carries the brand
      block that the previous job laid into the mouth.

    SETTING MOUTH_MM (once, per printer and head)
      Press 'r' twice. The two ruler strips should meet, 0 against 150,
      no white between them. On the second, the number level with the
      teeth, plus 2, is MOUTH_MM. Reflash. Too long only adds a blank
      strip; too short leaves LIFT TO TEAR inside the mouth.

    PRINTER QUIRKS WORTH KNOWING (this LP 2824 Plus)
      - ~JSO (BACKFEED_OFF) stops it reversing thin receipt paper back
        over the head before a job, which stalls.
      - ^XB (NO_FEED) stops the feed to the tear bar afterwards, so the
        next job carries straight on with nothing skipped.
      - In receipt mode send NO ~TA at all: here it causes a ~15 mm feed
        and an equal skip at the start of the next job. Label mode keeps
        its ~TA090.
      - ^PO is persistent and the command does not match the manual:
        with no ^PO this printer puts the LAST image row out first, which
        is what the hanging-readable layout wants. PO_MODE / console 'x'
        cycles it live if a printer's mood differs.
      - It sometimes skips ~10 mm at the start of a job; HEAD_MM 12 puts
        blank paper there so the skip eats paper, not the frame.

    MEDIA_RECEIPT 0 gives back die-cut 2.25 x 1.25" label behaviour.

    SERIAL COMMANDS (USB monitor @115200)
        p  print a random fortune now (ignores the roll guard)
        t  simulate a Mirror trigger (respects the roll guard)
        f  print a brand-block "prime" job (fills the mouth)
        r  print the RULER ticket -- see SETTING MOUTH_MM above
        x  cycle the ^PO mode live (0 none / 1 ^POI / 2 ^PON)
        o  4 cm orientation test with ^POI
        u  4 cm orientation test with ^PON
        c  parsed readiness check (~HS): paper / pause
        e  is the frame stored in the printer? (^HW)
        L  (re)load the frame into E: flash (~30 s at 9600 baud)
        d  raw directory listing of E:
        i  ~HI  printer identification (model + language)
        h  ~HS  raw printer host status
        s  hot-swap RX/TX pins, if you ever suspect reversal

    Flash BOTH nodes from the same release -- the packet format has to
    match on the two sides.
    ============================================================ */

#include <Arduino.h>
#include <WiFi.h>
#include <esp_wifi.h>
#include <esp_now.h>
#include "esp_mac.h"
#include "vortex_emblem.h"     // VORTEX_EMBLEM_ZPL / VORTEX_EMBLEM_NAME (generated, do not edit)

// ============================================================
//  KNOBS  -- everything worth tuning lives here
// ============================================================
#define ESPNOW_CHANNEL        1        // MUST match Node A. Change both if the venue's channel 1 is hopeless.
#define USE_EMBLEM            1        // 1 = recall the frame graphic on every label; 0 = plain text (and no vortex_emblem.h needed)
#define AUTO_LOAD_EMBLEM      1        // 1 = at boot, load the frame into the printer's E: flash if it isn't there
#define MEDIA_RECEIPT         1        // 1 = continuous 56 mm receipt tickets; 0 = die-cut 2.25 x 1.25" labels
#define RECEIPT_PW_DOTS       448      // print width in dots: 56 mm = 448 = this printhead's maximum (203 dpi, 8 dots/mm)
#define TICKET_INVERT         1        // 1 = [brand][fortune], for a printer that puts the LAST row out first; 0 = [fortune][brand]
#define PO_MODE               0        // ^PO with every job: 0 = none, 1 = ^POI, 2 = ^PON. Console 'x' cycles it live.
#define MOUTH_MM              82.0f    // last printed row (under the head) -> the Sphinx's teeth, in mm. See SETTING MOUTH_MM above.
#define NO_FEED               1        // 1 = ^XB: no feed to the tear bar after a job, so no backfeed, no skip, nothing unprinted
#define HEAD_MM               12.0f    // blank left at the START of every job, so a printer that skips there eats paper, not the frame
#define TAIL_MM               0.0f     // blank kept at the END of a job -- only needed if the two rulers show white between them
#define PRIME_AT_BOOT         1        // 1 = lay a brand block into the mouth at boot, so the very first ticket is complete
#define RECEIPT_DARKNESS      0        // 0 = leave the printer's darkness alone; 1..30 = ~SD value sent at boot
#define BACKFEED_OFF          1        // 1 = ~JSO: the paper only ever moves OUT of the mouth
#define HEADER_URL            "https://alicevortex.com"   // the QR code
#define HEADER_LINE1          "ALICE VORTEX"                // masthead (44-dot)
#define HEADER_LINE2          "The Vortex Oracle"           // under the masthead
#define HEADER_LINE3          "Burque ByteFest 2026"        // the occasion, above the QR
#define HEADER_LINE4          "alicevortex.com"             // under the QR
#define HEADER_TAGLINE        "The Sphinx speaks what the Vortex transmits."   // 18-dot: 44 chars fit in 448
#if MEDIA_RECEIPT
#define TEAR_OFF_ADJUST       0        // receipt: no ~TA at all -- on this printer it causes a feed, not a trim
#else
#define TEAR_OFF_ADJUST       90       // label mode: ~TA dots (-120..120), where the gap parks after a print.
                                       // 90 is about +11 mm past the bar, so the label rests at the Sphinx's tear strip.
#endif
const unsigned long ROLL_GUARD_MS = 8000;   // minimum gap between wireless prints (the Mirror's visitor cooldown is longer)

// ---- Serial2 pins to the MAX3232 ----
int RX2_PIN = 18;   // ESP receives  (MAX3232 TXD / printer -> ESP)
int TX2_PIN = 17;   // ESP transmits (ESP -> MAX3232 RXD / printer)

// ============================================================
//  PACKET FORMATS  (must match Node A exactly -- packed, 5 bytes)
// ============================================================
#define ORACLE_MAGIC 0x5641            // 'VA' -- only packets wearing this word are ours
enum : uint8_t { CMD_PRINT = 1 };
enum : uint8_t {                        // Sphinx -> Mirror verdicts
  ST_PRINTED       = 2,                 // fortune sent to the printer, printer reported ready
  ST_PAPER_OUT     = 3,                 // printer says paper out -- nothing printed
  ST_PAUSED        = 4,                 // printer paused -- nothing printed
  ST_RESTING       = 5,                 // roll guard active -- nothing printed
  ST_PRINTED_BLIND = 6                  // printer gave no status reply; fortune sent anyway
};

typedef struct __attribute__((packed)) {
  uint16_t magic;         // ORACLE_MAGIC
  uint8_t  command;       // CMD_PRINT
  int16_t  fortuneIndex;  // -1 = deal from the deck, else print this index
} OracleMessage;

typedef struct __attribute__((packed)) {
  uint16_t magic;         // ORACLE_MAGIC
  uint8_t  status;        // ST_*
  int16_t  fortuneIndex;  // index actually printed, or -1
} OracleReply;
static_assert(sizeof(OracleMessage) == 5 && sizeof(OracleReply) == 5, "wire format must stay 5 bytes on both nodes");

// ---- ESP-NOW deferred trigger (the callback runs on the Wi-Fi task; printing happens in loop()) ----
volatile bool    pendingPrint = false;
volatile int16_t pendingIndex = -1;
uint8_t          mirrorMac[6] = {0};    // learned from the first valid packet; replies go here
volatile bool    mirrorKnown  = false;

unsigned long lastPrintTime = 0;
uint32_t      printsThisBoot = 0;

// ============================================================
//  THE FORTUNES  (index 0..81; the printed card number is index + 1)
// ============================================================
const char* fortunes[] = {
  "The vortex turns in your favor. Say yes to the thing you keep circling.",
  "A door you thought was painted shut will swing wide this week.",
  "The coin you almost spent is the one that was meant to find you.",
  "Spirals always return, and so will the person you are thinking of.",
  "Trust the detour. The straight road was never going your way.",
  "What feels like an ending is the vortex catching its breath.",
  "Speak the wish aloud. The universe is terrible at reading minds.",
  "The lucky number is the one you have been avoiding.",
  "You will find it exactly where you stopped looking.",
  "A small kindness you forgot will spiral back tenfold.",
  "The storm you fear is mostly weather. Walk through it.",
  "Your next bold idea arrives disguised as an inconvenience.",
  "The answer is yes. It was always yes. Stop asking the mirror.",
  "Fortune favors the curious, and you, my friend, are nosy.",
  "Let go of the rope. You were never actually falling.",
  "A stranger holds the missing piece. Be easy to talk to.",
  "The vortex has marked you for an unreasonable amount of joy.",
  "Plant the seed today. Patience is the only fertilizer that works.",
  "That recurring dream is a memo, not a movie. Read it again.",
  "Money flows toward those who stop clenching. Unclench.",
  "The shortcut is a trap. The long way is the story you will tell.",
  "You are closer than you think and braver than you feel.",
  "Someone is about to say the thing you needed to hear.",
  "Take the leap. The vortex installs the net on the way down.",
  "The thing you lost was clearing space for the thing you wanted.",
  "A yes you give too quickly becomes a no you regret slowly.",
  "The cards say rest. Even the moon takes the month off in pieces.",
  "Your patience is about to be rewarded, so do not spoil it now.",
  "An old friend reenters the spiral. Answer the call.",
  "The risk you keep rehearsing is smaller than the regret you avoid.",
  "Wear the good shoes today. You will be glad you did.",
  "What you tend in secret will bloom in public.",
  "The vortex whispers: buy the ticket, take the ride.",
  "A debt owed to you returns in a shape you did not expect.",
  "Stop polishing the plan. Start the thing.",
  "The number seven smiles upon you before the week is out.",
  "Someone underestimates you. This is a gift. Use it.",
  "The locked box opens for the patient hand, not the clever one.",
  "Good news travels toward you wearing a clever disguise.",
  "The vortex favors bold letters and brave first sentences.",
  "You have outgrown the worry. Set it down and walk on.",
  "A meal shared this week mends more than hunger.",
  "The path forward is the one that scares you a sensible amount.",
  "Luck is a muscle. You are about to flex it.",
  "Say the kind thing before the moment spins away.",
  "The vortex has read your list and approves of item three.",
  "Trust the second instinct. The first one was just nerves.",
  "What you water grows. Choose your buckets wisely.",
  "A small purchase brings disproportionate delight. Permit it.",
  "The door marked maybe is actually marked yes.",
  "You are nearer the summit than the long climb made you believe.",
  "Forgive the past you. They were doing their clumsy best.",
  "The vortex turns once more, and this turn carries your name.",
  "You keep waiting for permission. Consider it hereby granted.",
  "The worry you packed for the trip will not be needed. Leave it.",
  "A quiet talent of yours is about to become loud and useful.",
  "The right people are already walking toward you. Be findable.",
  "That hunch is not random. It is data wearing a costume.",
  "Spend the good candle. The occasion is that you are here.",
  "The no you dread will feel like a window, not a wall.",
  "Your future self is grateful you started the boring part today.",
  "The map is wrong in your favor. The treasure moved closer.",
  "A laugh you share this week becomes a story you tell for years.",
  "The thing you are too proud to ask for is yours for the asking.",
  "Rest is not the reward for the work. It is part of the work.",
  "The vortex collects worriers and returns them as wonderers.",
  "You will dodge a small disaster by being five minutes late.",
  "The compliment you almost swallowed should be spoken aloud.",
  "What looks like luck is mostly you, showing up again.",
  "The old grudge is heavier than the apology. Set it down.",
  "A song will find you at the exact moment you need its nerve.",
  "The vortex says: order the dessert and the second coffee.",
  "You are allowed to change your mind. That is what minds are for.",
  "The hardest door has the lightest latch. Push, do not shove.",
  "Someone remembers your kindness far longer than you do.",
  "The idea you think is too strange is the one worth keeping.",
  "Tomorrow tips in your direction. Sleep like you mean it.",
  "The fork in the road is friendlier than it looks. Pick one.",
  "You have been brave so quietly that even you missed it.",
  "The vortex rewards the curious cat and skips the careful one.",
  "A small yes today unlocks a large door in a month.",
  "The thing you are searching for is also searching for you."
};
const int NUM_FORTUNES = sizeof(fortunes) / sizeof(fortunes[0]);

// ---- Shuffled deck: no repeats until every fortune has been dealt ----
uint8_t deck[sizeof(fortunes) / sizeof(fortunes[0])];
int     deckPos = NUM_FORTUNES;         // "empty" -> reshuffle on the first deal

void shuffleDeck() {
  for (int i = 0; i < NUM_FORTUNES; i++) deck[i] = i;
  for (int i = NUM_FORTUNES - 1; i > 0; i--) {   // Fisher-Yates
    int j = random(i + 1);
    uint8_t t = deck[i]; deck[i] = deck[j]; deck[j] = t;
  }
  deckPos = 0;
}
int dealFortune() {
  if (deckPos >= NUM_FORTUNES) shuffleDeck();
  return deck[deckPos++];
}

// ============================================================
//  PRINTER LINK
// ============================================================
void startSerial2() {
  Serial2.end();
  Serial2.begin(9600, SERIAL_8N1, RX2_PIN, TX2_PIN);
  Serial.printf("Serial2 up @9600 8N1  RX=GPIO%d  TX=GPIO%d\n", RX2_PIN, TX2_PIN);
}

void flushPrinterRx() { while (Serial2.available()) Serial2.read(); }

// Collect whatever the printer says for up to `waitMs` (stops early when `stopAt` is seen; pass nullptr for none).
// No default arguments here on purpose -- the Arduino prototype generator trips over them.
size_t readPrinterReply(char* buf, size_t cap, unsigned long waitMs, const char* stopAt) {
  size_t n = 0;
  unsigned long t0 = millis();
  buf[0] = 0;
  while (millis() - t0 < waitMs && n < cap - 1) {
    while (Serial2.available() && n < cap - 1) { buf[n++] = (char)Serial2.read(); buf[n] = 0; t0 = millis(); }
    if (stopAt && strstr(buf, stopAt)) break;
    delay(5);
  }
  return n;
}

// ---- Label geometry: the text sits in the frame graphic's open area ----
const int LBL_W      = 448;   // 2.2" printable (this printer's maximum; the media is 2.25")
const int LBL_H      = 254;   // 1.25"
const int FONT_H     = 24;    // fortune font height, dots
const int LINE_GAP   = 4;
const int ZONE_W     = 388;   // text block width
const int ZONE_CY    = 163;   // vertical centre of the open area below the medallion
const int ZONE_X     = (LBL_W - ZONE_W) / 2;
const int MAX_CHARS  = (int)(ZONE_W / (FONT_H * 0.52f));   // ~31 characters per line

// Rough line count, for vertical centring: the same word-wrap estimate the label generator uses.
int estimateLines(const char* text) {
  int lines = 1, cur = 0;
  const char* p = text;
  while (*p) {
    const char* e = p; while (*e && *e != ' ') e++;      // one word
    int wl = e - p;
    if (cur == 0)                 cur = wl;
    else if (cur + 1 + wl <= MAX_CHARS) cur += 1 + wl;
    else { lines++; cur = wl; }
    p = (*e == ' ') ? e + 1 : e;
  }
  return lines;
}

// The frame plus the centred fortune, with the frame's top-left at (0, y0). Shared by labels and tickets.
void zplFrameAndText(int y0, const char* text) {
  int n = min(4, estimateLines(text));
  int y = y0 + max(116, ZONE_CY - n * (FONT_H + LINE_GAP) / 2);
#if USE_EMBLEM
  Serial2.printf("^FO0,%d^XG%s,1,1^FS", y0, VORTEX_EMBLEM_NAME);  // a missing graphic prints nothing -- the text still comes out
#endif
  Serial2.printf("^CF0,%d", FONT_H);
  Serial2.printf("^FO%d,%d^A0N,%d,%d^FB%d,4,%d,C,0^FD", ZONE_X, y, FONT_H, FONT_H, ZONE_W, LINE_GAP);
  Serial2.print(text);                                      // fortunes must not contain ^ or ~ (ZPL control characters)
  Serial2.print("^FS");
}

// ---- Die-cut labels: one label = the frame + the fortune ----
// On gap media let the gap sensor end the label (^MNW) and do not hard-set the length: forcing a
// length that doesn't match the physical label is what makes text drift and blanks feed.
// Calibrate the printer once (hold FEED) so it has measured the stock.
void sendLabelZPL(const char* text) {
  Serial2.print("^XA");
  Serial2.print("^MNW");                                    // web/gap sensing
  Serial2.printf("^PW%d^LL%d^LH0,0", LBL_W, LBL_H);
  zplFrameAndText(0, text);
  Serial2.print("^XZ\r\n");
}

// ---- Receipt tickets ----
// After a job the paper rests with its last row at the head (no feed to the bar), so the last
// MOUTH_MM of the image sits between the head and the teeth and the rest hangs past them.
//   inverted (1): image = [brand block][fortune block]. The brand block is the trailing end, so it
//                 stays in the mouth; the guest tears at the teeth and takes LIFT TO TEAR, the frame,
//                 the fortune, No. x, then the PREVIOUS job's brand block as a footer.
//   upright  (0): image = [fortune block][brand block]. The brand block heads the NEXT ticket.
const int   MOUTH_DOTS   = (int)(MOUTH_MM * 8.0f + 0.5f);                      // 8 dots / mm
const int   DEAD_RES     = (int)(HEAD_MM * 8.0f + 0.5f);      // blank at the fortune block's leading end
const int   TAIL_RES     = (int)(TAIL_MM * 8.0f + 0.5f);      // rows at the end of a job the printer never prints (0 with ^XB)
const int   NO_DOTS      = 22;                                // "No. xx of 82" under the frame
const int   FORTUNE_DOTS = 332 + DEAD_RES;                    // both layouts: ~41.5 mm
int         lastPrimed   = 0;                                 // a brand block has been laid into the mouth since boot
int         poMode       = PO_MODE;                           // live copy of PO_MODE ('x' cycles it)

void zplTicketHeader(int lengthDots) {
  Serial2.print("^XA^MNN^MMT");                              // continuous media, tear-off mode
#if NO_FEED
  Serial2.print("^XB");                                      // no feed to the tear position: the next job carries straight on
#endif
  if (poMode == 1) Serial2.print("^POI");                    // see PO_MODE -- on this printer the command and the result don't match the manual
  else if (poMode == 2) Serial2.print("^PON");
  Serial2.printf("^PW%d^LL%d^LH0,0", RECEIPT_PW_DOTS, lengthDots);
}

// The brand block, rows y0 .. y0 + MOUTH_DOTS. Nothing here may contain ^ or ~.
void zplBrandBlock(int y0) {
#if TICKET_INVERT
  // Read as a footer, right after "No. x of 82": rule, event, QR, url, masthead, subtitle, tagline,
  // rule, then blank to the tear -- the stub the teeth land in. Needs MOUTH_MM >= 63.
  int y = y0 + 14;
  Serial2.printf("^FO0,%d^GB%d,2,2^FS", y, RECEIPT_PW_DOTS);                                         y += 2 + 16;
  Serial2.printf("^FO0,%d^A0N,26,26^FB%d,1,0,C,0^FD%s^FS", y, RECEIPT_PW_DOTS, HEADER_LINE3);       y += 26 + 18;
  Serial2.printf("^FO%d,%d^BQN,2,8^FDMA,%s^FS", (RECEIPT_PW_DOTS - 200) / 2, y, HEADER_URL);         y += 200 + 28;   // QR: error correction M, 25 modules x 8
  Serial2.printf("^FO0,%d^A0N,22,22^FB%d,1,0,C,0^FD%s^FS", y, RECEIPT_PW_DOTS, HEADER_LINE4);       y += 22 + 24;
  Serial2.printf("^FO0,%d^A0N,44,44^FB%d,1,0,C,0^FD%s^FS", y, RECEIPT_PW_DOTS, HEADER_LINE1);       y += 44 + 10;
  Serial2.printf("^FO0,%d^A0N,28,28^FB%d,1,0,C,0^FD%s^FS", y, RECEIPT_PW_DOTS, HEADER_LINE2);       y += 28 + 18;
  Serial2.printf("^FO0,%d^A0N,18,18^FB%d,1,0,C,0^FD%s^FS", y, RECEIPT_PW_DOTS, HEADER_TAGLINE);     y += 18 + 14;
  Serial2.printf("^FO0,%d^GB%d,2,2^FS", y, RECEIPT_PW_DOTS);
#else
  // A header for the NEXT ticket, compact (395 rows) and centred in the printable part of the mouth:
  // rule, masthead, subtitle, rule, event, QR, url, tagline, rule. Needs MOUTH_MM >= 54 + TAIL_MM.
  const int C = 395;
  int top = ((MOUTH_DOTS - TAIL_RES) - C) / 2; if (top < 16) top = 16;
  int y = y0 + top;
  Serial2.printf("^FO0,%d^GB%d,2,2^FS", y, RECEIPT_PW_DOTS);                                         y += 2 + 10;
  Serial2.printf("^FO0,%d^A0N,44,44^FB%d,1,0,C,0^FD%s^FS", y, RECEIPT_PW_DOTS, HEADER_LINE1);       y += 44 + 8;
  Serial2.printf("^FO0,%d^A0N,24,24^FB%d,1,0,C,0^FD%s^FS", y, RECEIPT_PW_DOTS, HEADER_LINE2);       y += 24 + 10;
  Serial2.printf("^FO0,%d^GB%d,2,2^FS", y, RECEIPT_PW_DOTS);                                         y += 2 + 12;
  Serial2.printf("^FO0,%d^A0N,24,24^FB%d,1,0,C,0^FD%s^FS", y, RECEIPT_PW_DOTS, HEADER_LINE3);       y += 24 + 12;
  Serial2.printf("^FO%d,%d^BQN,2,7^FDMA,%s^FS", (RECEIPT_PW_DOTS - 175) / 2, y, HEADER_URL);         y += 175 + 12;   // QR: error correction M, 25 modules x 7
  Serial2.printf("^FO0,%d^A0N,20,20^FB%d,1,0,C,0^FD%s^FS", y, RECEIPT_PW_DOTS, HEADER_LINE4);       y += 20 + 12;
  Serial2.printf("^FO0,%d^A0N,16,16^FB%d,1,0,C,0^FD%s^FS", y, RECEIPT_PW_DOTS, HEADER_TAGLINE);     y += 16 + 12;
  Serial2.printf("^FO0,%d^GB%d,2,2^FS", y, RECEIPT_PW_DOTS);
#endif
}

// The fortune block: rows y0 .. y0 + FORTUNE_DOTS.
void zplFortuneBlock(int y0, const char* text, int index) {
#if TICKET_INVERT
  // LIFT TO TEAR at the teeth, then the frame, the fortune, No. x, and the blank lead-in last.
  int y = y0 + 10;
  for (int x = 0; x < RECEIPT_PW_DOTS; x += 16) Serial2.printf("^FO%d,%d^GB8,2,2^FS", x, y);         y += 2 + 6;   // the tear line
  Serial2.printf("^FO0,%d^A0N,16,16^FB%d,1,0,C,0^FDLIFT TO TEAR^FS", y, RECEIPT_PW_DOTS);            y += 16 + 10;
  zplFrameAndText(y, text);                                                                          y += LBL_H + 4;
  Serial2.printf("^FO0,%d^A0N,18,18^FB%d,1,0,C,0^FDNo. %d of %d^FS", y, RECEIPT_PW_DOTS, index + 1, NUM_FORTUNES);
#else
  // Blank lead-in, frame, fortune, No. x, LIFT TO TEAR, the dashes.
  int y = y0 + DEAD_RES + 8;
  zplFrameAndText(y, text);                                                                          y += LBL_H + 4;
  Serial2.printf("^FO0,%d^A0N,18,18^FB%d,1,0,C,0^FDNo. %d of %d^FS", y, RECEIPT_PW_DOTS, index + 1, NUM_FORTUNES);   y += NO_DOTS + 8;
  Serial2.printf("^FO0,%d^A0N,16,16^FB%d,1,0,C,0^FDLIFT TO TEAR^FS", y, RECEIPT_PW_DOTS);            y += 16 + 6;
  for (int x = 0; x < RECEIPT_PW_DOTS; x += 16) Serial2.printf("^FO%d,%d^GB8,2,2^FS", x, y);         // the tear line
#endif
}

// One job = [brand block][this fortune] inverted, or [this fortune][brand block for the next] upright.
void sendTicketZPL(const char* text, int index) {
  zplTicketHeader(FORTUNE_DOTS + MOUTH_DOTS);
#if TICKET_INVERT
  zplBrandBlock(0);
  zplFortuneBlock(MOUTH_DOTS, text, index);
#else
  zplFortuneBlock(0, text, index);
  zplBrandBlock(FORTUNE_DOTS);
#endif
  Serial2.print("^XZ\r\n");
  lastPrimed = 1;
}

// Brand block only: lays the next ticket's block into the mouth (at boot, or after a roll change).
void sendPrime() {
  zplTicketHeader(MOUTH_DOTS);
  zplBrandBlock(0);
  Serial2.print("^XZ\r\n");
  lastPrimed = 1;
  Serial.println("Prime job sent: the next ticket's footer now fills the mouth; tear off whatever hangs past the teeth.");
}

// The ruler ticket: a 150 mm strip, a tick every mm and a number every 5 mm, counted from the job's
// trailing end -- which is where the paper rests. See SETTING MOUTH_MM at the top of the file.
void sendRuler() {
  const int L = 1200;                                        // 150 mm
  zplTicketHeader(L);
#if TICKET_INVERT
  Serial2.print("^FO16,980^A0N,16,16^FB316,5,2,L,0^FDRULER (print two). Number level with the teeth + 2 = MOUTH_MM. The two rulers should meet 0 to 150; if there is white between them: lowest number on the first = TAIL_MM, 150 minus the highest on the second = HEAD_MM.^FS");
#else
  Serial2.print("^FO16,200^A0N,16,16^FB316,5,2,L,0^FDRULER (print two). Number level with the teeth + 1 = MOUTH_MM. The two rulers should meet 0 to 150; if there is white between them: lowest number on the first = TAIL_MM, 150 minus the highest on the second = HEAD_MM.^FS");
#endif
  for (int mm = 0; mm <= 150; mm++) {
    int y = mm * 8;
#if TICKET_INVERT
    int label = mm;                                          // row 0 is the trailing end when inverted
#else
    int label = 150 - mm;                                    // row 0 is the leading end upright
#endif
    if (mm % 5 == 0) {
      int ly = y - 10; if (ly < 0) ly = 0; if (ly > L - 22) ly = L - 22;
      Serial2.printf("^FO%d,%d^GB48,2,2^FS", RECEIPT_PW_DOTS - 48, y);
      Serial2.printf("^FO%d,%d^A0N,20,20^FD%d^FS", RECEIPT_PW_DOTS - 104, ly, label);
    } else {
      Serial2.printf("^FO%d,%d^GB20,1,1^FS", RECEIPT_PW_DOTS - 20, y);
    }
  }
  Serial2.print("^XZ\r\n");
  Serial.println("Ruler sent -- print a second one. Teeth: the number level with them (+1 upright / +2 inverted) = MOUTH_MM. The rulers should meet 0-to-150; white between them = TAIL_MM (lowest number on the first) and HEAD_MM (150 - highest on the second).");
}

// Orientation test: a 4 cm strip with TOP at the first ZPL row and BOTTOM at the last.
// BOTTOM out first = the printer puts the last row first (what TICKET_INVERT 1 expects).
void sendOrientationTest(const char* po) {
  Serial2.printf("^XA^MNN^MMT^XB%s^PW%d^LL320^LH0,0", po, RECEIPT_PW_DOTS);
  Serial2.printf("^FO0,20^A0N,40,40^FB%d,1,0,C,0^FDTOP (row 0)^FS", RECEIPT_PW_DOTS);
  Serial2.printf("^FO0,260^A0N,40,40^FB%d,1,0,C,0^FDBOTTOM (row 300)^FS", RECEIPT_PW_DOTS);
  Serial2.print("^XZ\r\n");
  Serial.printf("Orientation test sent%s%s. BOTTOM out first = last row first (what TICKET_INVERT 1 needs); TOP out first = first row first.\n", *po ? " with " : " with no ^PO", po);
}

void sendZPL(const char* text, int index) {
#if MEDIA_RECEIPT
  if (!lastPrimed) sendPrime();                              // first print after a boot that found the printer off: fill the mouth first
  sendTicketZPL(text, index);
#else
  sendLabelZPL(text);
#endif
}

// Ask the printer ~HS and parse status string 1: field 2 = paper out, field 3 = pause.
bool getPrinterStatus(bool &paperOut, bool &paused) {
  flushPrinterRx();
  Serial2.print("~HS\r\n");
  char resp[160];
  size_t n = readPrinterReply(resp, sizeof(resp), 600, "\x03");
  char* stx = (char*)memchr(resp, 0x02, n);
  char* etx = stx ? (char*)memchr(stx + 1, 0x03, n - (size_t)(stx - resp) - 1) : nullptr;
  if (!stx || !etx) return false;
  char* c1 = (char*)memchr(stx + 1, ',', etx - stx - 1);
  if (!c1) return false;
  char* c2 = (char*)memchr(c1 + 1, ',', etx - c1 - 1);
  if (!c2) return false;
  paperOut = (*(c1 + 1) == '1');
  paused   = (*(c2 + 1) == '1');
  delay(150); flushPrinterRx();                            // ~HS sends two more status strings; keep them off the console
  return true;
}

// ---- The frame graphic, in printer flash ----
// Ask for a directory of E:*.GRF rather than the exact name: the reply's header line echoes the
// pattern you asked for, so a name query would always appear to match.
// Returns 1 = stored, 0 = missing, -1 = the printer didn't answer with a directory at all.
int emblemStored() {
  flushPrinterRx();
  Serial2.print("^XA^HWE:*.GRF^XZ\r\n");
  char resp[512];
  readPrinterReply(resp, sizeof(resp), 1500, "bytes free");
  if (!strstr(resp, "DIR") && !strstr(resp, "bytes free")) return -1;   // no directory listing came back
  return strstr(resp, "VORTEX.GRF") ? 1 : 0;
}

// Storing it in E: (flash) rather than R: (RAM) is what makes it survive a printer power-off --
// so there is no computer and no setup utility to bring to an event.
void loadEmblem() {
  Serial.printf("Loading emblem into %s (%u bytes of ZPL at 9600 baud, ~%u s)...\n",
                VORTEX_EMBLEM_NAME, (unsigned)strlen(VORTEX_EMBLEM_ZPL), (unsigned)(strlen(VORTEX_EMBLEM_ZPL) / 960 + 1));
  const char* p = VORTEX_EMBLEM_ZPL;
  size_t left = strlen(p);
  while (left) {                                           // write() blocks while the TX ring is full -- nothing is dropped
    size_t chunk = left > 256 ? 256 : left;
    Serial2.write((const uint8_t*)p, chunk);
    p += chunk; left -= chunk;
  }
  Serial2.print("\r\n");
  Serial2.flush();
  delay(1500);                                             // let the printer commit it to flash
  int s = emblemStored();
  if (s == 1)       Serial.println("Emblem stored in E:. Labels will carry the frame.");
  else if (s == 0)  Serial.println("Emblem NOT found after load -- check ZPL mode (i), the cable, and E: free space (d).");
  else              Serial.println("Load sent, but the printer doesn't answer ^HW here -- print one (p) and look for the frame.");
}

// The printer keeps ~TA and ~JSO only until power-off, so both are sent at boot AND before every
// job (a few bytes, no flash writes). That way it doesn't matter which of the two was switched on first.
void sendBackfeed(bool verbose) {
#if BACKFEED_OFF
  Serial2.print("~JSO\r\n");
  if (verbose) Serial.println("Backfeed: ~JSO (off) -- paper moves one way only; re-sent before every job.");
#endif
}

void sendTearOffAdjust(bool verbose) {
  int ta = TEAR_OFF_ADJUST;
  if (ta == 0) return;
  if (ta < 0) Serial2.printf("~TA-%03d\r\n", -ta); else Serial2.printf("~TA%03d\r\n", ta);   // ~TA wants a sign and three digits
#if MEDIA_RECEIPT
  if (verbose) Serial.printf("Tear-off adjust: ~TA%d -- re-sent before every job.\n", ta);
#else
  if (verbose) Serial.printf("Tear-off adjust: ~TA%d (label gap parks %+d dots from the bar) -- re-sent before every label.\n", ta, ta);
#endif
}

// Media settings the printer keeps until power-off, sent at boot.
void sendMediaSetup() {
#if MEDIA_RECEIPT
#if RECEIPT_DARKNESS
  Serial2.printf("~SD%02d\r\n", RECEIPT_DARKNESS);
  Serial.printf("Darkness: ~SD%02d\n", RECEIPT_DARKNESS);
#endif
  if (TEAR_OFF_ADJUST == 0) Serial.println("No ~TA sent (receipt mode). If an earlier boot sent one, power-cycle the Zebra once -- it keeps the last ~TA until power-off.");
  Serial.printf("Receipt mode (%s layout, %s%s): %d dots wide, mouth %d dots (%.1f mm), fortune block %d dots -> ticket %.0f mm; head allowance %.1f mm, tail allowance %.1f mm.\n",
                TICKET_INVERT ? "last-row-first" : "first-row-first", poMode == 0 ? "no ^PO" : poMode == 1 ? "^POI" : "^PON", NO_FEED ? ", ^XB no-feed" : "", RECEIPT_PW_DOTS, MOUTH_DOTS, MOUTH_MM, FORTUNE_DOTS, (MOUTH_DOTS + FORTUNE_DOTS) / 8.0f, HEAD_MM, TAIL_MM);
  if (!TICKET_INVERT && MOUTH_DOTS - TAIL_RES < 395 + 32) Serial.println("WARNING: MOUTH_MM is too short for the header (needs >= 54 mm + TAIL_MM) -- the masthead will run into the tear.");
  if (TICKET_INVERT && MOUTH_DOTS - TAIL_RES < 486 + 16)  Serial.println("WARNING: MOUTH_MM is too short for the footer (needs >= 63 mm + TAIL_MM) -- the bottom rule will run into the tear.");
#endif
}

void ensureEmblem() {
  int s = emblemStored();
  if (s == 1) { Serial.println("Emblem present in printer E: flash."); return; }
  if (s < 0)  { Serial.println("Printer gave no directory reply (off? EPL mode? cable?). Not auto-loading -- press L once it's talking."); return; }
  Serial.println("Emblem missing from printer.");
#if AUTO_LOAD_EMBLEM
  loadEmblem();
#else
  Serial.println("AUTO_LOAD_EMBLEM is off -- press L to load it.");
#endif
}

// ============================================================
//  PRINT + REPLY
// ============================================================
// force = true bypasses the roll guard (the manual 'p' test).
// Returns the ST_* verdict; printedIndex receives the fortune that went to the printer (-1 if none).
uint8_t printFortune(int index, bool force, int16_t &printedIndex) {
  printedIndex = -1;
  unsigned long now = millis();
  if (!force && lastPrintTime != 0 && (now - lastPrintTime < ROLL_GUARD_MS)) {
    Serial.printf("Roll guard (%lus left). Ignoring trigger.\n", (ROLL_GUARD_MS - (now - lastPrintTime)) / 1000);
    return ST_RESTING;
  }

  // Pre-flight: never fire a fortune into an empty or paused printer.
  bool paperOut = false, paused = false, gotStatus = getPrinterStatus(paperOut, paused);
  if (gotStatus && paperOut) {
    lastPrimed = 0;                                        // the block in the mouth leaves with the old roll: the next fortune primes itself
    Serial.println("PRINTER: PAPER OUT -- nothing printed. Load a roll; the next fortune lays its own header first.");
    return ST_PAPER_OUT;
  }
  if (gotStatus && paused)   { Serial.println("PRINTER: PAUSED -- nothing printed. Press FEED/pause on the Zebra."); return ST_PAUSED; }
  if (!gotStatus)            Serial.println("PRINTER: no status reply (off? unplugged? EPL mode?) -- sending anyway.");

  if (index < 0 || index >= NUM_FORTUNES) index = dealFortune();
  printsThisBoot++;
  Serial.printf("Printing fortune #%d (card No. %d) [%lu this boot]: \"%s\"\n", index, index + 1, printsThisBoot, fortunes[index]);
  sendTearOffAdjust(false);                              // survives a printer that was powered up after the Sphinx
  sendBackfeed(false);
  sendZPL(fortunes[index], index);
  lastPrintTime = now;
  printedIndex = index;
  return gotStatus ? ST_PRINTED : ST_PRINTED_BLIND;
}

void replyToMirror(uint8_t status, int16_t index) {
  if (!mirrorKnown) return;
  if (!esp_now_is_peer_exist(mirrorMac)) {
    esp_now_peer_info_t peer = {};
    memcpy(peer.peer_addr, mirrorMac, 6);
    peer.channel = 0;                    // 0 = whatever channel we're on
    peer.ifidx   = WIFI_IF_STA;
    peer.encrypt = false;
    if (esp_now_add_peer(&peer) != ESP_OK) { Serial.println("ESP-NOW: could not add the Mirror as a peer."); return; }
  }
  OracleReply r = { ORACLE_MAGIC, status, index };
  esp_err_t e = esp_now_send(mirrorMac, (const uint8_t*)&r, sizeof(r));
  Serial.printf("ESP-NOW: reply status=%d idx=%d -> %s\n", status, index, e == ESP_OK ? "queued" : "send FAILED");
}

// ============================================================
//  ESP-NOW RECEIVE   (arduino-esp32 core 3.x signature)
// ============================================================
// The Sphinx learns the Mirror's MAC from the first valid packet, so there is no address to type here.
void onDataRecv(const esp_now_recv_info_t *info, const uint8_t *data, int len) {
  if (len != sizeof(OracleMessage)) return;              // wrong size -- not ours (flash both nodes together!)
  OracleMessage msg;
  memcpy(&msg, data, sizeof(msg));
  if (msg.magic != ORACLE_MAGIC) return;                 // somebody else's ESP-NOW project -- ignore
  memcpy(mirrorMac, info->src_addr, 6);
  mirrorKnown = true;
  if (msg.command == CMD_PRINT) { pendingIndex = msg.fortuneIndex; pendingPrint = true; }
}

// ============================================================
//  SETUP / LOOP
// ============================================================
void setup() {
  Serial.begin(115200);
  delay(1500);
  Serial.println("\n========================================");
  Serial.println(" VORTEX ORACLE :: NODE B :: SPHINX v1.4.8 ONLINE (receipt edition, hanging-readable, no ^PO, no-feed, 12 mm lead-in)");
  Serial.println("========================================");

  startSerial2();
  randomSeed(esp_random());
  shuffleDeck();

  // ESP-NOW: station mode, deliberately NOT connected to any AP, pinned to one channel, radio never sleeps.
  WiFi.mode(WIFI_STA);
  WiFi.disconnect(false, true);                          // forget any SSID a previous sketch left in flash
  WiFi.setSleep(false);
  esp_wifi_set_channel(ESPNOW_CHANNEL, WIFI_SECOND_CHAN_NONE);
  uint8_t mac[6];
  esp_read_mac(mac, ESP_MAC_WIFI_STA);
  Serial.printf("This Sphinx MAC (Node A's PEER_MAC): %02X:%02X:%02X:%02X:%02X:%02X   channel %d\n",
                mac[0], mac[1], mac[2], mac[3], mac[4], mac[5], ESPNOW_CHANNEL);

  if (esp_now_init() != ESP_OK) {
    Serial.println("ESP-NOW init FAILED");
  } else {
    esp_now_register_recv_cb(onDataRecv);
    Serial.println("ESP-NOW ready. Waiting for the Mirror...");
  }

  Serial.printf("%d fortunes loaded (shuffled deck).\n", NUM_FORTUNES);
#if USE_EMBLEM
  ensureEmblem();
#endif
  sendTearOffAdjust(true);
  sendBackfeed(true);
  sendMediaSetup();
#if MEDIA_RECEIPT && PRIME_AT_BOOT
  {
    bool po = false, ps = false;
    if (getPrinterStatus(po, ps) && !po && !ps) sendPrime();
    else Serial.println("Printer not ready at boot -- the first fortune will lay its own header first (or press f once it is on).");
  }
#endif
  Serial.println("Commands: p=print  t=sim trigger  f=prime(footer)  r=ruler  x=cycle ^PO mode  o/u=orientation test (^POI / ^PON)  c=status  e=emblem?  L=load emblem  d=dir  i=~HI  h=~HS  s=swap pins");
}

void loop() {
  // A Mirror trigger, deferred out of the radio callback -- then tell the Mirror what happened.
  if (pendingPrint) {
    pendingPrint = false;
    int16_t printed = -1;
    uint8_t st = printFortune(pendingIndex, false, printed);
    replyToMirror(st, printed);
  }

  // Echo anything the printer says back, for bench visibility.
  while (Serial2.available()) {
    int b = Serial2.read();
    Serial.printf("RX<- 0x%02X '%c'\n", b, (b >= 32 && b < 127) ? b : '.');
  }

  // Bench console -- see the command list at the top of the file.
  if (Serial.available()) {
    char c = Serial.read();
    if      (c == 'p' || c == 'P') { int16_t i; printFortune(-1, true, i); }
    else if (c == 't' || c == 'T') { int16_t i; uint8_t st = printFortune(-1, false, i); Serial.printf("verdict=%d idx=%d\n", st, i); }
    else if (c == 'c' || c == 'C') {
      bool po = false, ps = false;
      if (getPrinterStatus(po, ps)) Serial.printf("STATUS: paperOut=%d paused=%d -> %s\n", po, ps, (!po && !ps) ? "READY" : "NOT READY");
      else Serial.println("STATUS: no reply.");
    }
    else if (c == 'f' || c == 'F') {
#if MEDIA_RECEIPT
      sendPrime();
#else
      Serial.println("MEDIA_RECEIPT is 0 -- labels have no header to prime.");
#endif
    }
    else if (c == 'r' || c == 'R') {
#if MEDIA_RECEIPT
      sendRuler(); lastPrimed = 0;                          // the ruler is not a header: the next fortune primes first
#else
      Serial.println("MEDIA_RECEIPT is 0 -- the ruler is for receipt tickets.");
#endif
    }
    else if (c == 'x' || c == 'X') { poMode = (poMode + 1) % 3; Serial.printf("PO_MODE now %d (%s). Press f, then look: LIFT TO TEAR must be at the teeth.\n", poMode, poMode == 0 ? "no ^PO" : poMode == 1 ? "^POI" : "^PON"); }
    else if (c == 'u' || c == 'U') {
#if MEDIA_RECEIPT
      sendOrientationTest("^PON"); lastPrimed = 0;
#else
      Serial.println("MEDIA_RECEIPT is 0 -- the orientation test is for receipt tickets.");
#endif
    }
    else if (c == 'o' || c == 'O') {
#if MEDIA_RECEIPT
      sendOrientationTest("^POI"); lastPrimed = 0;
#else
      Serial.println("MEDIA_RECEIPT is 0 -- the orientation test is for receipt tickets.");
#endif
    }
    else if (c == 'e' || c == 'E') { int s = emblemStored(); Serial.println(s == 1 ? "Emblem: STORED in E:" : s == 0 ? "Emblem: MISSING (press L)" : "Emblem: UNKNOWN -- printer gave no directory reply"); }
    else if (c == 'L')             loadEmblem();
    else if (c == 'd' || c == 'D') { flushPrinterRx(); Serial2.print("^XA^HWE:*.*^XZ\r\n"); char r[512]; readPrinterReply(r, sizeof(r), 1500, "bytes free"); Serial.println(r); }
    else if (c == 'i' || c == 'I') { Serial.println("TX-> ~HI"); Serial2.print("~HI\r\n"); }
    else if (c == 'h' || c == 'H') { Serial.println("TX-> ~HS"); Serial2.print("~HS\r\n"); }
    else if (c == 's' || c == 'S') { int t = RX2_PIN; RX2_PIN = TX2_PIN; TX2_PIN = t; startSerial2(); }
  }
}

The 82 fortunes

Plain text
One per line; blank lines and # comments are ignored. They are compiled into Node B and dealt from a shuffled deck, so none repeats until all 82 have printed. Swap in your own.
# Vortex Oracle fortunes -- one fortune per line. Blank lines and #comments ignored.
# Keep each to ~3 short lines so it fits the sticker. Edit freely, then re-run generate_fortunes_zpl.py
The vortex turns in your favor. Say yes to the thing you keep circling.
A door you thought was painted shut will swing wide this week.
The coin you almost spent is the one that was meant to find you.
Spirals always return -- and so will the person you are thinking of.
Trust the detour. The straight road was never going your way.
What feels like an ending is the vortex catching its breath.
Speak the wish aloud. The universe is terrible at reading minds.
The lucky number is the one you have been avoiding.
You will find it exactly where you stopped looking.
A small kindness you forgot will spiral back tenfold.
The storm you fear is mostly weather. Walk through it.
Your next bold idea arrives disguised as an inconvenience.
The answer is yes. It was always yes. Stop asking the mirror.
Fortune favors the curious -- and you, my friend, are nosy.
Let go of the rope. You were never actually falling.
A stranger holds the missing piece. Be easy to talk to.
The vortex has marked you for an unreasonable amount of joy.
Plant the seed today. Patience is the only fertilizer that works.
That recurring dream is a memo, not a movie. Read it again.
Money flows toward those who stop clenching. Unclench.
The shortcut is a trap. The long way is the story you will tell.
You are closer than you think and braver than you feel.
Someone is about to say the thing you needed to hear.
Take the leap. The vortex installs the net on the way down.
The thing you lost was clearing space for the thing you wanted.
A yes you give too quickly becomes a no you regret slowly.
The cards say rest. Even the moon takes the month off in pieces.
Your patience is about to be rewarded, so do not spoil it now.
An old friend reenters the spiral. Answer the call.
The risk you keep rehearsing is smaller than the regret you avoid.
Wear the good shoes today. You will be glad you did.
What you tend in secret will bloom in public.
The vortex whispers: buy the ticket, take the ride.
A debt owed to you returns in a shape you did not expect.
Stop polishing the plan. Start the thing.
The number seven smiles upon you before the week is out.
Someone underestimates you. This is a gift. Use it.
The locked box opens for the patient hand, not the clever one.
Good news travels toward you wearing a clever disguise.
The vortex favors bold letters and brave first sentences.
You have outgrown the worry. Set it down and walk on.
A meal shared this week mends more than hunger.
The path forward is the one that scares you a sensible amount.
Luck is a muscle. You are about to flex it.
Say the kind thing before the moment spins away.
The vortex has read your list and approves of item three.
Trust the second instinct. The first one was just nerves.
What you water grows. Choose your buckets wisely.
A small purchase brings disproportionate delight. Permit it.
The door marked maybe is actually marked yes.
You are nearer the summit than the long climb made you believe.
Forgive the past you. They were doing their clumsy best.
The vortex turns once more, and this turn carries your name.
You keep waiting for permission. Consider it hereby granted.
The worry you packed for the trip will not be needed. Leave it.
A quiet talent of yours is about to become loud and useful.
The right people are already walking toward you. Be findable.
That hunch is not random. It is data wearing a costume.
Spend the good candle. The occasion is that you are here.
The no you dread will feel like a window, not a wall.
Your future self is grateful you started the boring part today.
The map is wrong in your favor. The treasure moved closer.
A laugh you share this week becomes a story you tell for years.
The thing you are too proud to ask for is yours for the asking.
Rest is not the reward for the work. It is part of the work.
The vortex collects worriers and returns them as wonderers.
You will dodge a small disaster by being five minutes late.
The compliment you almost swallowed should be spoken aloud.
What looks like luck is mostly you, showing up again.
The old grudge is heavier than the apology. Set it down.
A song will find you at the exact moment you need its nerve.
The vortex says: order the dessert and the second coffee.
You are allowed to change your mind. That is what minds are for.
The hardest door has the lightest latch. Push, do not shove.
Someone remembers your kindness far longer than you do.
The idea you think is too strange is the one worth keeping.
Tomorrow tips in your direction. Sleep like you mean it.
The fork in the road is friendlier than it looks. Pick one.
You have been brave so quietly that even you missed it.
The vortex rewards the curious cat and skips the careful one.
A small yes today unlocks a large door in a month.
The thing you are searching for is also searching for you.

readme.txt

Plain text
Flash BOTH nodes from the same release -- the radio packet format has to match on the two sides. Put the Sphinx's MAC (it prints it at boot) into PEER_MAC in Node A. Nothing here joins a network. ESP-NOW is radio to radio; both nodes sit unconnected in station mode on the same channel.
THE VORTEX ORACLE -- firmware
Alice Vortex (alicevortex.com), Albuquerque, NM
Written with Claude (Anthropic), July-September 2026.
Debuted at Burque ByteFest, 19 September 2026.

  Oracle_mirror_node.ino    Node A -- the pyramid: radar, eye, wave-to-consult,
                            ESP-NOW sender. Needs Adafruit GFX + BusIO +
                            GC9A01A and ld2410 (ncmreynolds).

  Sphinx_printer_node.ino   Node B -- the sphinx: ESP-NOW receiver, shuffled
                            82-fortune deck, ZPL job builder, printer status.
                            Needs vortex_emblem.h in the same folder, or set
                            USE_EMBLEM 0 for plain-text tickets.

  vortex_emblem.h           The "THE VORTEX SPEAKS" label frame, 448 x 254
                            dots, as a ZPL download into the printer's own
                            flash. Generated -- do not edit by hand.

  fortunes.txt              The 82 fortunes, one per line. They are compiled
                            into Node B; this is the readable source.

Both nodes are ESP32-S3. arduino-esp32 core 3.x. Board settings:
ESP32S3 Dev Module, USB CDC On Boot enabled, PSRAM disabled, Flash 16MB,
partition 16M Flash (3MB APP / 9.9MB FATFS).

Flash BOTH nodes from the same release -- the radio packet format has to
match on the two sides. Put the Sphinx's MAC (it prints it at boot) into
PEER_MAC in Node A.

Nothing here joins a network. ESP-NOW is radio to radio; both nodes sit
unconnected in station mode on the same channel.

Credits

melamelamelamela
5 projects • 4 followers
IoT fanatic, designer, tinkerer, mom, and plant nerd

Comments