Build Sheets

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
}