Source
subspace_lamp.ino
// ============================================================================
// LCARS Subspace Beacon Lamp — Lower Decks (Epic L: L2 mirror + L3 control)
// Target: ESP32-DevKitC ESP32-32U (WROOM-32U — EXTERNAL ANTENNA REQUIRED)
// Board setting in Arduino IDE: "ESP32 Dev Module"
// Libraries: FastLED — install via Library Manager
//
// HTTP is a raw WiFiClient POST on purpose: HTTPClient links the whole TLS
// stack even for plain-http URLs and blew past the default app partition.
//
// Behavior:
// Ring = beacon state (colors match the dashboard's SubspaceBeaconBoard).
// Encoder rotate = preview a state on the ring; encoder press = commit it
// to the server (POST /api/beacons/{id}/set).
// Button = local display on/off toggle. State and display are independent:
// a dark lamp still holds its server state. A *remote* state
// change always turns the display back on.
// RGB pip = connection status: blue=boot, green=OK, amber=heartbeat
// failing, red=WiFi down.
// Every 5s: POST /api/beacons/{id}/heartbeat — refreshes the dashboard's
// online chip and returns the current state in the same call.
// ============================================================================
#include <WiFi.h>
#include <FastLED.h>
#include "config.h"
#define DEBUG 1
#if DEBUG
#define DBG(...) Serial.printf(__VA_ARGS__)
#else
#define DBG(...)
#endif
// --- Pins (fixed: every lamp is wired identically) -------------------------
constexpr int PIN_RING = 16; // WS2812 DIN, through 330R
constexpr int PIN_PIP_R = 17; // RGB pip anodes through 330R each,
constexpr int PIN_PIP_G = 18; // common cathode to GND
constexpr int PIN_PIP_B = 19;
constexpr int PIN_ENC_CLK = 32; // KY-040 powered at 3V3 (never 5V)
constexpr int PIN_ENC_DT = 33;
constexpr int PIN_ENC_SW = 27; // commit — INPUT_PULLUP, active low
constexpr int PIN_BUTTON = 26; // display toggle — INPUT_PULLUP, active low
constexpr int NUM_LEDS = 24;
// --- Beacon states (order = encoder cycle order) ----------------------------
constexpr int NUM_STATES = 5;
const char* STATE_NAMES[NUM_STATES] = {"off", "available", "busy", "away", "alert"};
const CRGB STATE_COLORS[NUM_STATES] = {
CRGB::Black,
CRGB(0x3E, 0xC2, 0x6A), // available — dashboard green
CRGB(0x3B, 0x8F, 0xF0), // busy — dashboard blue
CRGB(0xE8, 0xC5, 0x3B), // away — dashboard yellow
CRGB(0xEF, 0x4A, 0x4A), // alert — dashboard red, pulses
};
int stateIndex(const char* name) {
for (int i = 0; i < NUM_STATES; i++)
if (strcmp(name, STATE_NAMES[i]) == 0) return i;
return -1;
}
// --- Runtime state ----------------------------------------------------------
CRGB leds[NUM_LEDS];
int liveState = 0; // server truth
bool displayOn = true; // local-only, never synced
bool selecting = false;
int candidate = 0;
// Timers use the subtraction idiom (millis() - t >= dt) — the lamp runs 24/7,
// so the 49-day millis() rollover is a real event.
unsigned long selectStart = 0;
unsigned long lastHeartbeat = 0;
int heartbeatFails = 0;
// --- Encoder (quadrature decode in ISR so detents survive blocking HTTP) ----
volatile int8_t encAccum = 0;
volatile uint8_t encPrev = 0;
const int8_t ENC_TABLE[16] = {0, -1, 1, 0, 1, 0, 0, -1, -1, 0, 0, 1, 0, 1, -1, 0};
void IRAM_ATTR encIsr() {
uint8_t cur = (digitalRead(PIN_ENC_CLK) << 1) | digitalRead(PIN_ENC_DT);
encAccum += ENC_TABLE[(encPrev << 2) | cur];
encPrev = cur;
}
// One KY-040 detent = 4 quadrature steps.
int takeDetents() {
noInterrupts();
int8_t a = encAccum;
int detents = a / 4;
encAccum = a - detents * 4;
interrupts();
return detents;
}
// --- RGB status pip (common cathode: PWM high = lit) ------------------------
void pip(uint8_t r, uint8_t g, uint8_t b) {
analogWrite(PIN_PIP_R, r);
analogWrite(PIN_PIP_G, g);
analogWrite(PIN_PIP_B, b);
}
void pipBoot() { pip(0, 0, 80); }
void pipOk() { pip(0, 40, 0); }
void pipDegraded() { pip(60, 25, 0); }
void pipOffline() { pip(80, 0, 0); }
void updatePip() {
if (WiFi.status() != WL_CONNECTED) pipOffline();
else if (heartbeatFails > 0) pipDegraded();
else pipOk();
}
// --- HTTP -------------------------------------------------------------------
// Raw socket POST; blocks up to ~2s on a dead server. The encoder ISR keeps
// detents, and a rare swallowed button press just gets pressed again.
String apiPost(const char* path, const char* body) {
WiFiClient client;
if (!client.connect(LCARS_HOST, LCARS_PORT)) return "";
client.printf(
"POST %s HTTP/1.1\r\nHost: %s\r\nConnection: close\r\n"
"Content-Type: application/json\r\nContent-Length: %d\r\n\r\n%s",
path, LCARS_HOST, (int)strlen(body), body);
String resp;
unsigned long t0 = millis();
while ((client.connected() || client.available()) && millis() - t0 < 2000) {
while (client.available()) resp += (char)client.read();
delay(5);
}
client.stop();
return resp; // status line + headers + JSON body; parsed by indexOf below
}
// Heartbeat stamps last_seen AND returns current state — the lamp's only read.
bool heartbeat() {
String resp = apiPost("/api/beacons/" BEACON_ID "/heartbeat", "");
int i = resp.indexOf("\"state\":\"");
int j = (i < 0) ? -1 : resp.indexOf('"', i + 9);
int idx = (j < 0) ? -1 : stateIndex(resp.substring(i + 9, j).c_str());
if (idx < 0) return false;
if (idx != liveState) {
DBG("state %s -> %s\n", STATE_NAMES[liveState], STATE_NAMES[idx]);
liveState = idx;
displayOn = true; // a remote change always relights the lamp
}
return true;
}
// Fire-and-forget: the state is always one of our own names, and if the POST
// is lost the next heartbeat reverts the ring to server truth within 5s.
void commitState(int idx) {
String body = String("{\"state\":\"") + STATE_NAMES[idx] + "\"}";
apiPost("/api/beacons/" BEACON_ID "/set", body.c_str());
}
// --- Ring rendering ----------------------------------------------------------
void render() {
if (selecting) {
// Preview: candidate color on alternate pixels — clearly not a live state.
for (int i = 0; i < NUM_LEDS; i++)
leds[i] = (i % 2 == 0) ? STATE_COLORS[candidate] : CRGB::Black;
} else if (!displayOn) {
fill_solid(leds, NUM_LEDS, CRGB::Black);
} else {
CRGB c = STATE_COLORS[liveState];
if (liveState == 4) c.nscale8(beatsin8(30, 60, 255)); // alert pulses
fill_solid(leds, NUM_LEDS, c);
}
FastLED.show();
}
// --- Debounced falling-edge check for the two switches -----------------------
bool pressed(int pin, unsigned long& lastEdge, int& lastLevel) {
int level = digitalRead(pin);
bool fell = (level == LOW && lastLevel == HIGH && millis() - lastEdge > 40);
if (level != lastLevel) lastEdge = millis();
lastLevel = level;
return fell;
}
void setup() {
Serial.begin(115200);
pinMode(PIN_ENC_CLK, INPUT_PULLUP);
pinMode(PIN_ENC_DT, INPUT_PULLUP);
pinMode(PIN_ENC_SW, INPUT_PULLUP);
pinMode(PIN_BUTTON, INPUT_PULLUP);
encPrev = (digitalRead(PIN_ENC_CLK) << 1) | digitalRead(PIN_ENC_DT);
attachInterrupt(PIN_ENC_CLK, encIsr, CHANGE);
attachInterrupt(PIN_ENC_DT, encIsr, CHANGE);
FastLED.addLeds<WS2812, PIN_RING, GRB>(leds, NUM_LEDS);
FastLED.setBrightness(RING_BRIGHTNESS);
FastLED.setMaxPowerInVoltsAndMilliamps(5, 800);
fill_solid(leds, NUM_LEDS, CRGB::Black);
FastLED.show();
pipBoot();
WiFi.mode(WIFI_STA);
WiFi.setAutoReconnect(true);
WiFi.begin(WIFI_SSID, WIFI_PASS);
while (WiFi.status() != WL_CONNECTED) delay(250);
// After the connect wait so it can't scroll away in boot noise.
Serial.printf("WiFi up: %s | MAC for DHCP reservation: %s\n",
WiFi.localIP().toString().c_str(), WiFi.macAddress().c_str());
heartbeatFails = heartbeat() ? 0 : 1; // learn color on boot
updatePip();
}
void loop() {
static unsigned long btnEdge = 0, swEdge = 0;
static int btnLevel = HIGH, swLevel = HIGH;
// Encoder: enter/extend select mode.
int detents = takeDetents();
if (detents != 0) {
if (!selecting) {
selecting = true;
candidate = liveState;
}
candidate = ((candidate + detents) % NUM_STATES + NUM_STATES) % NUM_STATES;
selectStart = millis();
DBG("preview: %s\n", STATE_NAMES[candidate]);
}
if (selecting && millis() - selectStart >= SELECT_TIMEOUT_MS) selecting = false;
// Encoder press: commit the previewed state.
if (pressed(PIN_ENC_SW, swEdge, swLevel) && selecting) {
selecting = false;
commitState(candidate);
liveState = candidate; // optimistic; heartbeat reconciles within 5s
displayOn = true;
DBG("committed: %s\n", STATE_NAMES[candidate]);
}
// Button: local display toggle; state is untouched.
if (pressed(PIN_BUTTON, btnEdge, btnLevel)) {
displayOn = !displayOn;
DBG("display %s\n", displayOn ? "on" : "off");
}
if (millis() - lastHeartbeat >= HEARTBEAT_INTERVAL_MS) {
lastHeartbeat = millis();
if (WiFi.status() == WL_CONNECTED && heartbeat()) heartbeatFails = 0;
else heartbeatFails++;
updatePip();
}
// Show only on change (alert animates continuously) — a 60fps show() of an
// unchanged frame just churns the RMT driver and floods its serial logging.
bool animating = displayOn && !selecting && liveState == 4;
int frame = selecting ? 100 + candidate : (displayOn ? liveState : -1);
static int lastFrame = -2;
if (frame != lastFrame || animating) {
render();
lastFrame = frame;
}
delay(16); // ~60fps poll; keeps the alert pulse smooth
}