Hardware components | ||||||
| × | 1 | ||||
| × | 1 | ||||
| × | 1 | ||||
| × | 1 | ||||
| × | 1 | ||||
| × | 1 | ||||
| × | 1 | ||||
| × | 1 | ||||
| × | 1 | ||||
Software apps and online services | ||||||
![]() |
| |||||
| ||||||
Hand tools and fabrication machines | ||||||
![]() |
| |||||
![]() |
| |||||
| ||||||
There are two objects on the table. A small 3D-printed pyramid with a round display for an eye and an mmWave radar behind its face. And, a meter away, an Art Deco sphinx sitting on its paws — which is really a printed shell over a Zebra LP 2824 Plus thermal label printer from roughly the last century.
You walk up. The eye is breathing a slow blue. Inside about 90 cm it turns amber and quickens — the Oracle is reading your aura, which is a polite way of saying the LD2410C has a target. Hold your hand up to the pyramid’s face for half a second; a ring closes around the iris; the screen flashes white: Your destiny has been written. The sphinx whirrs, a ticket comes out of its mouth, and you tear it off on its teeth.
That is the whole project. Everything below is how, and what went wrong.
This is a Zoltar-esque fortune-telling machine. I know. It's the type of project that sits on the very short list of Things Every Maker Eventually Builds, somewhere between a word clock and a Nixie tube thing. I have no defense except that it was enormously fun, my dog was extremely patient about it, and so far nobody has walked away from the sphinx without smiling or smirking.
Fair question, asked by everyone. Three answers, in ascending order of honesty:
- The printer is worth keeping. The LP 2824 Plus is a beast and a fine vintage machine. It speaks ZPL over RS-232, and it will tell you its paper-out and pause flags if you ask (
~HS) — so a fortune is never fired into a dead printer. The little Bluetooth cat printers are charming and I own one, but a link that drops halfway through a fortune in a crowded room is not a link. - It hides better. With the print head in its own body, the pyramid stays thin and clean, cooler, and nothing runs between them but air.
- I wanted an excuse to use ESP-NOW. There it is.
Two ESP32-S3 boards, no wire between them.
Node A — the Oracle Mirror, in the pyramid: GC9A01 1.28-inch round display, HLK-LD2410C mmWave radar on UART, one button. The presentation layer.
Node B — the Sphinx, in the sphinx: ESP32-S3 → MAX3232 → DB9 → the Zebra. It listens, and it prints.
They talk over ESP-NOW on a pinned channel — roughly 2 ms a packet, no Wi-Fi join, no router, no cloud, no NTP, no OTA. The 82 fortunes are compiled into the Sphinx’s flash and the label art lives in the printer’s own flash. The whole thing runs in a basement, at a show, in a field. This mattered more than I expected: on debut day the venue’s Wi-Fi turned out to be entirely irrelevant.
The five things that actually cost me time1. The pins in my own notes were wrong. My first display plan used GPIO 22/23/25. Those exist on the classic ESP32 and not on the S3. Remapped to 11/10/4. Check the variant, not the family.
2. The radar’s TX is too tall. Metered about 3.7 V idle — above the S3’s 3.6 V absolute max. It now runs through a 1 kΩ / 6.8 kΩ divider into GPIO 16. I started with 1 k / 2 k, which puts the tap at about 2.47 V — exactly the datasheet VIH floor. That is not a margin, it is a coincidence. Also: read the radar over UART at 256000 baud and parse its target frames. Do not use “a byte arrived, therefore presence” — the LD2410C streams continuously, so that reads as always present, forever.
3. The devkit’s 5V pin is an input. With nothing attached, my two boards metered 2.99 V and 1.54 V on that pin — phantom readings through a reverse-biased diode. It cannot source a milliamp. The radar now feeds from a panel USB-C inlet’s VBUS, ahead of that diode, with a 1000 µF bulk cap on a small perfboard power block where inlet, board and radar all meet. Nearly every “the firmware is broken” symptom I had in July was a power symptom. (Related: a genuine S3 devkit has the 5.1 kΩ CC pull-downs; some clones do not, and a USB-C PD source will simply refuse to turn on for them. A plain USB-A charger always delivers 5 V and makes a good fallback.)
4. Backlit but black. The display lit up and showed nothing for a week. It was the order of the calls: SPI.begin(18, -1, 11, 5) has to run beforetft.begin(), or the library clocks the S3’s default pin instead. Not the peripheral, not the wiring.
5. The paper. Die-cut labels photographed beautifully and tore terribly. I switched to 56 mm continuous receipt stock and moved the tear to serrated teeth set in the sphinx’s own mouth, with the next ticket’s header pre-printed into the dead length so nothing is wasted. Hard-won ZPL: do not hard-set ^LL on gap media (let ^MNW find the edge); in receipt mode do not send ~TA at all (this printer answers by feeding about 15 mm and skipping the same at the next job); do send ~JSO and ^XB so the paper stops with the last row under the head and the next job carries straight on. The distance from there to the teeth is one constant, MOUTH_MM, measured with a ruler the firmware prints for you.
The first version said “Your destiny has been written” whether or not the Sphinx was even powered on. That is a bad lie for an oracle to tell. Now the Mirror waits for the radio ACK, then for the Sphinx’s verdict, and says one of:
- Your destiny has been written · No. 42 — it printed
- The Sphinx is silent — summon the keeper — paper out, or the printer is paused
- The Sphinx sleeps — nothing answered on the radio
- The Oracle is resting — you are inside the cooldown
Every packet carries a magic word (0x5641, VA) and anything else is ignored. A tech showcase is exactly where somebody else’s ESP-NOW project is chattering, and the cost of a stray packet is a wasted ticket.
The deck is shuffled: no fortune repeats until all 82 have printed, so friends comparing tickets get different ones. That detail gets more reaction than anything electrical.
Modeled in Onshape as a parametric FeatureScript feature — eleven parameters, defaults set to caliper measurements of the printer, so the shell can be re-cut for a different machine. Five printed parts (body, rump, head, two paws), about 1.3 kg of silver-grey PLA and forty hours.
One rule was non-negotiable: the fortune comes out of the mouth. The sphinx speaks what the Oracle transmits. Every piece of the geometry — head depth, the 44 mm mouth, the tear-strip lintel above the paper — falls out of that one rule, and it cost three head revisions to get right. My son reviewed it in Onshape, told me to fillet the nose and caught an asymmetry in the hind legs. He was right on both.
STLs for the sphinx parts, the pyramid and its base are attached.
Hardware — 2 × Espressif ESP32-S3-WROOM-1 (N16R8) dev board · 1 × GC9A01 1.28-inch 240×240 round SPI LCD · 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 male-to-female straight-through serial cable · 1 × tactile momentary button · 1 kΩ and 6.8 kΩ quarter-watt resistors (radar TX divider) · 2 × 1000 µF electrolytic capacitor (bulk, one per node) · 2 × USB-C panel inlet breakout with 5.1 kΩ CC pull-downs · 56 mm continuous thermal receipt roll, 10 mm core · about 1.3 kg silver PLA.
Software — Arduino IDE · arduino-esp32 core 3.3.11 · Adafruit GFX 1.12.6 · Adafruit BusIO 1.17.4 · Adafruit GC9A01A 1.1.1 · ld2410 0.2.2 (ncmreynolds) · ESP-NOW · ZPL II · Onshape FeatureScript.
Tools — FDM 3D printer · soldering iron · digital calipers · multimeter.
The Oracle’s first public job was Burque ByteFest 2026 in Albuquerque — a first-annual car show for computers, where people bring interesting, vintage and homemade systems. The right room, I thought, for a vintage label printer wearing a costume.
The Oracle belongs to Alice Vortex, a small studio of handmade and printed objects with a fictional world attached — which is where the fortunes get their voice. If you want the rest of it: alicevortex.com.
One other thing, unrelated to fortune-telling. I also keep patternregistry.org — an early, deliberately non-commercial effort toward an independent, tamper-evident registry for AI incidents and welfare patterns. I build with AI every day, this project included, and I think the record of how these systems are treated ought to be kept somewhere nobody with an interest in it owns.
- Claude (Anthropic) — a genuine collaborator here: the firmware, the FeatureScript, the build spec, and a great many debugging arguments. I'm honored to work with such intelligent engines!
- The CNM IoT program, who taught me the hardware and invited the sphinx to a party.
- My kid, one of the harshest critics of them all.
- Frank, my dog, who lay under the bench through all of it and was very patient, as long as the occasional treat was involved.
Node A — the Oracle Mirror (the pyramid)
Arduino/* ============================================================
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/* ============================================================
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# 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 textTHE 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.










Comments