Ayushmaan
Published © MIT

PrismCast: a Custom-Built Ambilight Backlight

It captures every color on your screen and recreates it behind your monitor in real time, making games and movies feel way more immersive.

IntermediateFull instructions providedOver 1 day61
PrismCast: a Custom-Built Ambilight Backlight

Things used in this project

Hardware components

ESP32 WROOM development board
×1
WS2812B addressable LED strips, 60 LEDs
×2
2.4-inch ILI9341 320 × 240 SPI TFT display
×1
Cherry MX Red mechanical keyboard switches
×2
HW-688 DC-DC buck converter
×1
Perfboard
×1

Software apps and online services

Fusion
Autodesk Fusion
Arduino IDE
Arduino IDE
VS Code
Microsoft VS Code
lopaka

Hand tools and fabrication machines

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

Story

Read more

Schematics

circuit

Code

prismcast_connected.ino

C/C++
esp 32 firmware
/*
 * PrismCast  phosphor-terminal desk clock + LED controller + display sync
 * ==========================================================================
 * A Fallout / Pip-Boy inspired retro-terminal UI whose phosphor colour FOLLOWS
 * the active LED mode (cyan for Solid, pink for Breathe, green for Wave, etc.),
 * so the screen and the strip always match. WiFi + WebSocket let the PrismCast
 * web app stream live display-sync colours to the strip.
 *
 * SYNC: when the web app streams binary frames they drive the strip directly and
 *       the UI shows "SYNC" (in cyan); when streaming stops the device resumes
 *       its local mode automatically.
 *
 * STABILITY: TFT is re-initialised after WiFi connects (brownout recovery), and
 *            LED vs TFT updates run on separate timers (SPI/RMT contention fix).
 *
 * CONTROLS
 *   HOME : NEXT open menu | SELECT brightness | hold BACK set time
 *   MENU : NEXT next mode | SELECT colour | BACK home
 *   SET  : NEXT +hour | SELECT +min | BACK save
 *
 * Web app connects to  ws://<device-ip>:81/   (IP shown on boot + Serial)
 * Libraries: Adafruit_GFX, Adafruit_ILI9341, NeoPixelBus (Makuna),
 *            WebSockets (Links2004), ArduinoJson
 */

#include <SPI.h>
#include <Adafruit_GFX.h>
#include <Adafruit_ILI9341.h>
#include <NeoPixelBus.h>
#include <WiFi.h>
#include <WebSocketsServer.h>
#include <ArduinoJson.h>

// ---------------- config ----------------
const char* WIFI_SSID = "S24fe";
const char* WIFI_PASS = "123456789";

#define TFT_CS   5
#define TFT_DC   2
#define TFT_RST  4
#define LED_PIN   27
#define NUM_LEDS  120
#define SW_NEXT   32
#define SW_SELECT 33
#define SW_BACK   25
#define WS_PORT   81
// ----------------------------------------

Adafruit_ILI9341 tft(TFT_CS, TFT_DC, TFT_RST);
NeoPixelBus<NeoGrbFeature, NeoEsp32Rmt0Ws2812xMethod> strip(NUM_LEDS, LED_PIN);
WebSocketsServer webSocket(WS_PORT);

const int SCR_W = 320, SCR_H = 240;
#define C_BG   0x0000
#define C_TXT  0xFFFF
#define C_DIM  0x7BEF
#define C_MUTE 0x4208

// ---------------- modes ----------------
enum Mode { M_SOLID, M_BREATHE, M_WAVE, M_COMET, M_PULSE, M_COUNT };
struct ModeInfo { const char* name; uint16_t accent; };
ModeInfo modeInfo[M_COUNT] = {
  { "SOLID",   0x07FF },   // cyan
  { "BREATHE", 0xFB56 },   // pink
  { "WAVE",    0x2FEC },   // green
  { "COMET",   0xFD20 },   // orange
  { "PULSE",   0x9D3F },   // periwinkle
};
struct Pal { const char* name; uint8_t r, g, b; };
Pal palette[] = {
  { "CYAN",0,180,255 },{ "MAGENTA",255,40,200 },{ "AMBER",255,130,0 },
  { "GREEN",40,230,90 },{ "VIOLET",150,80,255 },{ "RED",255,40,40 },{ "WHITE",255,200,160 },
};
const int PAL_COUNT = sizeof(palette) / sizeof(palette[0]);

Mode    gMode = M_WAVE;
int     gPalIndex = 0;
uint8_t gBrightness = 160;
uint8_t gR = 0, gG = 180, gB = 255;

volatile bool gSyncActive = false;
unsigned long lastSyncMs = 0;

// the live phosphor colour = active mode accent (or cyan while syncing)
uint16_t themeColor() { return gSyncActive ? 0x07FF : modeInfo[gMode].accent; }

// ---------------- fake clock + calendar ----------------
unsigned long clkBaseMs; long clkStartSec = 10*3600+30*60; long lastDays = 0;
int gY=2026,gMo=7,gD=21,gDOW=1;
const char* WD[7]={"SUN","MON","TUE","WED","THU","FRI","SAT"};
const char* MON[12]={"JAN","FEB","MAR","APR","MAY","JUN","JUL","AUG","SEP","OCT","NOV","DEC"};
int daysInMonth(int y,int m){ if(m==2) return ((y%4==0&&y%100!=0)||y%400==0)?29:28; if(m==4||m==6||m==9||m==11) return 30; return 31; }
void advanceDay(){ gD++; if(gD>daysInMonth(gY,gMo)){gD=1;gMo++;if(gMo>12){gMo=1;gY++;}} gDOW=(gDOW+1)%7; }
void clockNow(int&h,int&m,int&s){ unsigned long el=(millis()-clkBaseMs)/1000UL; long total=clkStartSec+(long)el; long sod=total%86400; if(sod<0)sod+=86400; h=sod/3600; m=(sod%3600)/60; s=sod%60; }
void tickCalendar(){ unsigned long el=(millis()-clkBaseMs)/1000UL; long days=(clkStartSec+(long)el)/86400; while(days>lastDays){advanceDay();lastDays++;} }
void setTimeHM(int h,int m,int s){ clkStartSec=(long)h*3600+m*60+s; clkBaseMs=millis(); lastDays=0; }

// ---------------- buttons ----------------
// update() is called once per loop; it sets `clicked` (on press) and `held`
// (after a long hold). Because the only hold action (BACK on home) is on a view
// where BACK has no click action, firing click on press causes no conflict.
struct Button {
  uint8_t pin; bool state=HIGH; unsigned long lastChange=0, downAt=0; bool holdFired=false;
  bool clicked=false, held=false;
  void update() {
    clicked=false; held=false;
    bool raw=digitalRead(pin);
    if (raw != state && millis()-lastChange > 30) {
      lastChange=millis(); state=raw;
      if (raw==LOW) { downAt=millis(); holdFired=false; clicked=true; }
    }
    if (state==LOW && !holdFired && millis()-downAt > 650) { holdFired=true; held=true; }
  }
};
Button btnNext{SW_NEXT}, btnSelect{SW_SELECT}, btnBack{SW_BACK};

// ---------------- helpers ----------------
RgbColor scale(uint8_t r,uint8_t g,uint8_t b){ float f=gBrightness/255.0; return RgbColor(r*f,g*f,b*f); }
uint16_t dim565(uint16_t c,float f){ uint8_t r=(c>>11)&0x1F,g=(c>>5)&0x3F,b=c&0x1F; return ((uint16_t)(r*f)<<11)|((uint16_t)(g*f)<<5)|(uint16_t)(b*f); }
void printAt(const char* t,int x,int y,uint8_t sz,uint16_t col){ tft.setTextSize(sz);tft.setTextColor(col); tft.setCursor(x,y); tft.print(t); }
void printCentered(const char* t,int y,uint8_t sz,uint16_t col){ tft.setTextSize(sz);tft.setTextColor(col); int16_t x1,y1;uint16_t w,h; tft.getTextBounds(t,0,0,&x1,&y1,&w,&h); tft.setCursor((SCR_W-w)/2,y); tft.print(t); }
RgbColor hsvToRgb(float h,float s,float v){ s/=100.0;v/=100.0; float c=v*s,x=c*(1-fabs(fmod(h/60.0,2)-1)),m=v-c; float r=0,g=0,b=0; if(h<60){r=c;g=x;}else if(h<120){r=x;g=c;}else if(h<180){g=c;b=x;}else if(h<240){g=x;b=c;}else if(h<300){r=x;b=c;}else{r=c;b=x;} return RgbColor((r+m)*255,(g+m)*255,(b+m)*255); }

// scanline overlay across a region (draws dim lines every 3px)  the CRT look
void scanlines(int x,int y,int w,int h,uint16_t col){ for(int yy=y;yy<y+h;yy+=3) tft.drawFastHLine(x,yy,w,col); }

// =================================================================================
//  LEDs
// =================================================================================
void renderLeds(){
  if(gSyncActive){ if(millis()-lastSyncMs>600) gSyncActive=false; else return; }
  uint32_t t=millis();
  switch(gMode){
    case M_SOLID:{ RgbColor c=scale(gR,gG,gB); for(int i=0;i<NUM_LEDS;i++) strip.SetPixelColor(i,c); break; }
    case M_BREATHE:{ float lv=0.12+(sin(t/750.0)+1)/2*0.88; RgbColor c=scale(gR*lv,gG*lv,gB*lv); for(int i=0;i<NUM_LEDS;i++) strip.SetPixelColor(i,c); break; }
    case M_WAVE:{ for(int i=0;i<NUM_LEDS;i++){ float hue=fmod((float)i/NUM_LEDS+t/4000.0,1.0); strip.SetPixelColor(i,HsbColor(hue,1.0,(gBrightness/255.0)*0.6)); } break; }
    case M_COMET:{ float head=fmod(t/18.0,NUM_LEDS); for(int i=0;i<NUM_LEDS;i++){ float d=head-i; if(d<0)d+=NUM_LEDS; float b=d<14?(1.0-d/14.0):0.0; b*=b; strip.SetPixelColor(i,scale(gR*b,gG*b,gB*b)); } break; }
    case M_PULSE:{ float lv=pow((sin(t/500.0)+1)/2,3); RgbColor c=scale(gR*lv,gG*lv,gB*lv); for(int i=0;i<NUM_LEDS;i++) strip.SetPixelColor(i,c); break; }
    default: break;
  }
  strip.Show();
}

// =================================================================================
//  7-seg digits (with a faint ghost + dim glow copy behind, for the phosphor feel)
// =================================================================================
const uint8_t SEG[10]={0x3F,0x06,0x5B,0x4F,0x66,0x6D,0x7D,0x07,0x7F,0x6F};
void hSeg(int x,int y,int len,int t,uint16_t c){ int hh=t/2; tft.fillRect(x+hh,y,len-2*hh,t,c); tft.fillTriangle(x,y+hh,x+hh,y,x+hh,y+t-1,c); tft.fillTriangle(x+len,y+hh,x+len-hh,y,x+len-hh,y+t-1,c); }
void vSeg(int x,int y,int len,int t,uint16_t c){ int hh=t/2; tft.fillRect(x,y+hh,t,len-2*hh,c); tft.fillTriangle(x+hh,y,x,y+hh,x+t-1,y+hh,c); tft.fillTriangle(x+hh,y+len,x,y+len-hh,x+t-1,y+len-hh,c); }
void drawDigit(int x,int y,int w,int h,int val,uint16_t on,uint16_t off){
  uint8_t s=(val>=0&&val<=9)?SEG[val]:0; int t=w*0.20; int halfH=(h-t)/2;
  hSeg(x,y,w,t,(s&0x01)?on:off); vSeg(x,y,halfH+t,t,(s&0x20)?on:off); vSeg(x+w-t,y,halfH+t,t,(s&0x02)?on:off);
  hSeg(x,y+halfH,w,t,(s&0x40)?on:off); vSeg(x,y+halfH,halfH+t,t,(s&0x10)?on:off); vSeg(x+w-t,y+halfH,halfH+t,t,(s&0x04)?on:off);
  hSeg(x,y+2*halfH,w,t,(s&0x08)?on:off);
}

// =================================================================================
//  views   phosphor-terminal styling
// =================================================================================
enum View { VIEW_HOME, VIEW_MENU, VIEW_SET };
View view = VIEW_HOME;
const int DW=46,DH=84,DY=78,DGAP=6,COLW=22;
int DX[4],colonX;
void computeClockGeom(){ int row=DW+DGAP+DW+COLW+DW+DGAP+DW; int sx=(SCR_W-row)/2; DX[0]=sx; DX[1]=DX[0]+DW+DGAP; colonX=DX[1]+DW; DX[2]=colonX+COLW; DX[3]=DX[2]+DW+DGAP; }

// the tabbed HUD frame + notched corner (drawn in the theme colour, dimmed)
void drawFrame(uint16_t col){
  uint16_t d=dim565(col,0.55);
  tft.drawRect(10,10,SCR_W-20,SCR_H-20,d);
  tft.drawRect(11,11,SCR_W-22,SCR_H-22,d);
  // notch tab on the left edge
  tft.fillRect(8,34,6,34,C_BG);
  tft.drawFastVLine(8,34,34,d); tft.drawFastHLine(8,34,6,d); tft.drawFastHLine(8,68,6,d);
}

void drawTabs(uint16_t col,int active){ // 0 clock,1 modes,2 set
  const char* T[3]={"CLOCK","MODES","SET"};
  int x=22,y=20,w=(SCR_W-44)/3;
  uint16_t d=dim565(col,0.6);
  for(int i=0;i<3;i++){
    int tx=x+i*w;
    tft.drawFastHLine(tx,y+16,w-6,d);
    if(i==active){ tft.fillRect(tx,y,w-6,16,col); tft.setTextColor(C_BG); }
    else tft.setTextColor(d);
    tft.setTextSize(1);
    int16_t x1,y1;uint16_t tw,th; tft.getTextBounds(T[i],0,0,&x1,&y1,&tw,&th);
    tft.setCursor(tx+(w-6-tw)/2, y+5); tft.print(T[i]);
  }
}

int ld[4]={-1,-1,-1,-1},lastSecBar=-1,lastColon=-1;
Mode lastShownMode=(Mode)-1; int lastShownDay=-1; bool lastSyncShown=false; uint16_t lastTheme=0;

void drawHomeStatic(){
  uint16_t col=themeColor();
  tft.fillScreen(C_BG);
  drawFrame(col);
  drawTabs(col,0);
  // side labels
  uint16_t d=dim565(col,0.7);
  printAt("MODE",26,64,1,d);
  printAt(gSyncActive?"SYNC":modeInfo[gMode].name,26,74,1,col);
  char b[8]; sprintf(b,"PWR %d%%",(int)(gBrightness/255.0*100));
  int16_t x1,y1;uint16_t w,h; tft.setTextSize(1); tft.getTextBounds(b,0,0,&x1,&y1,&w,&h);
  printAt(b,SCR_W-26-w,74,1,col);
  // date
  char dt[20]; sprintf(dt,"%s %02d %s %d",WD[gDOW],gD,MON[gMo-1],gY);
  printCentered(dt,196,2,col);
  // status line
  printAt("* ONLINE",26,220,1,d);
  const char* os="PRISMCAST OS v1";
  tft.getTextBounds(os,0,0,&x1,&y1,&w,&h); printAt(os,SCR_W-26-w,220,1,d);
  for(int i=0;i<4;i++)ld[i]=-1; lastSecBar=-1;lastColon=-1;
  lastShownMode=gMode; lastShownDay=gD; lastSyncShown=gSyncActive; lastTheme=col;
}

void drawHome(){
  uint16_t col=themeColor();
  // full repaint if the theme colour changed (mode switched / sync toggled)
  if(col!=lastTheme || gMode!=lastShownMode || gSyncActive!=lastSyncShown){ drawHomeStatic(); }
  uint16_t on=col, off=dim565(col,0.10);
  int h,m,s; clockNow(h,m,s); int digs[4]={h/10,h%10,m/10,m%10};
  for(int i=0;i<4;i++) if(digs[i]!=ld[i]){ drawDigit(DX[i],DY,DW,DH,digs[i],on,off); ld[i]=digs[i]; }
  int colonOn=(millis()/500)%2;
  if(colonOn!=lastColon){ uint16_t cc=colonOn?on:C_BG; int cy=DY+DH/2; tft.fillCircle(colonX+COLW/2,cy-18,4,cc); tft.fillCircle(colonX+COLW/2,cy+18,4,cc); lastColon=colonOn; }
  int barX=DX[0],barW=(DX[3]+DW)-DX[0],barY=DY+DH+12;
  if(s!=lastSecBar){
    tft.drawRect(barX,barY,barW,8,dim565(col,0.5));
    tft.fillRect(barX+1,barY+1,barW-2,6,C_BG);
    int fw=(int)((barW-2)*(s/59.0));
    for(int xx=barX+1;xx<barX+1+fw;xx+=4) tft.fillRect(xx,barY+1,2,6,on);  // dashed fill
    lastSecBar=s;
  }
  if(gD!=lastShownDay){ drawHomeStatic(); }
}

// animated vector icon per mode (old-style), drawn in the theme colour
void drawMenuIcon(int cx,int cy,uint16_t col,float phase){
  switch(gMode){
    case M_SOLID: tft.fillCircle(cx,cy,26,col); tft.drawCircle(cx,cy,26,C_TXT); break;
    case M_BREATHE:{ int r=14+(int)(16*(0.6+0.4*sin(phase))); tft.drawCircle(cx,cy,r,col); tft.drawCircle(cx,cy,r-4>2?r-4:2,col); break; }
    case M_WAVE:{ int pv=cy; for(int i=-42;i<=42;i++){ int py=cy+(int)(16*sin((i/42.0)*6.2832+phase)); if(i>-42)tft.drawLine(cx+i-1,pv,cx+i,py,col); pv=py; } break; }
    case M_COMET:{ for(int k=0;k<10;k++){ float a=phase-k*0.18; int px=cx+30*cos(a),py=cy+20*sin(a); int rr=5-k/2; if(rr<1)rr=1; tft.fillCircle(px,py,rr,col);} break; }
    case M_PULSE:{ float k=pow((sin(phase)+1)/2,3); int r=10+(int)(20*k); tft.fillCircle(cx,cy,r,col); break; }
    default: break;
  }
}

bool menuDirty=true;
Mode lastMenuMode=(Mode)-1; int lastMenuPal=-1;
const int MICON_CY=120;
void drawMenu(){
  uint16_t col=themeColor();
  // full static repaint on entry or when the theme colour changed
  if(menuDirty || col!=lastTheme){
    tft.fillScreen(C_BG); drawFrame(col); drawTabs(col,1);
    printAt("// SELECT MODE",26,48,2,col);
    uint16_t d=dim565(col,0.6);
    tft.drawFastHLine(22,SCR_H-30,SCR_W-44,d);
    printAt("NEXT:MODE  SELECT:COLOUR  BACK:HOME",26,SCR_H-24,1,d);
    menuDirty=false; lastTheme=col; lastMenuMode=(Mode)-1; lastMenuPal=-1;
  }
  // mode indicator dots (redraw only when mode changes)
  if(gMode!=lastMenuMode){
    int gap=26,tot=(M_COUNT-1)*gap,sx=SCR_W/2-tot/2, dy=72;
    tft.fillRect(0,dy-6,SCR_W,14,C_BG);
    for(int i=0;i<M_COUNT;i++){ int cx=sx+i*gap; if(i==gMode) tft.fillCircle(cx,dy,5,modeInfo[i].accent); else tft.fillCircle(cx,dy,3,dim565(col,0.5)); }
  }
  // animated icon (redraw every tick within its box)
  tft.fillRect(SCR_W/2-46, MICON_CY-34, 92, 70, C_BG);
  drawMenuIcon(SCR_W/2, MICON_CY, col, millis()/240.0);
  // mode name (redraw on change)
  if(gMode!=lastMenuMode){
    tft.fillRect(0,164,SCR_W,26,C_BG);
    printCentered(modeInfo[gMode].name,166,3,C_TXT);
    lastMenuMode=gMode;
  }
  // colour swatch + name (redraw on change)
  if(gPalIndex!=lastMenuPal){
    tft.fillRect(0,196,SCR_W,18,C_BG);
    int bx=SCR_W/2-58;
    printAt("COLOUR",bx,200,1,dim565(col,0.7));
    tft.fillRect(bx+44,197,20,12,((gR&0xF8)<<8)|((gG&0xFC)<<3)|(gB>>3));
    printAt(palette[gPalIndex].name,bx+72,200,1,col);
    lastMenuPal=gPalIndex;
  }
}

bool setDirty=true;
void drawSet(){
  uint16_t col=0x07FF; // set-time always cyan
  if(setDirty || col!=lastTheme){ tft.fillScreen(C_BG); drawFrame(col); drawTabs(col,2); printCentered("// SET TIME",56,2,col); printAt("NEXT:+HR  SELECT:+MIN  BACK:SAVE",26,SCR_H-24,1,dim565(col,0.6)); for(int i=0;i<4;i++)ld[i]=-1; setDirty=false; lastTheme=col; }
  int h,m,s; clockNow(h,m,s); int digs[4]={h/10,h%10,m/10,m%10}; uint16_t on=col,off=dim565(col,0.10);
  for(int i=0;i<4;i++) if(digs[i]!=ld[i]){drawDigit(DX[i],DY,DW,DH,digs[i],on,off);ld[i]=digs[i];}
  int cy=DY+DH/2; tft.fillCircle(colonX+COLW/2,cy-18,4,on); tft.fillCircle(colonX+COLW/2,cy+18,4,on);
}

// =================================================================================
//  WebSocket
// =================================================================================
const char* modeToStr(Mode m){ switch(m){case M_SOLID:return "solid";case M_BREATHE:return "breathe";case M_WAVE:return "wave";case M_COMET:return "comet";case M_PULSE:return "pulse";default:return "solid";} }
Mode strToMode(const String& s){ if(s=="breathe")return M_BREATHE; if(s=="wave")return M_WAVE; if(s=="comet")return M_COMET; if(s=="pulse")return M_PULSE; return M_SOLID; }
void broadcastState(){ StaticJsonDocument<192> d; d["mode"]=modeToStr(gMode); d["brightness"]=gBrightness; String o; serializeJson(d,o); webSocket.broadcastTXT(o); }
void onWsEvent(uint8_t id,WStype_t type,uint8_t* payload,size_t len){
  switch(type){
    case WStype_CONNECTED: Serial.printf("[ws] %u connected\n",id); broadcastState(); break;
    case WStype_DISCONNECTED: Serial.printf("[ws] %u disconnected\n",id); break;
    case WStype_BIN:{ uint16_t n=len/3; if(n>NUM_LEDS)n=NUM_LEDS; for(int i=0;i<n;i++) strip.SetPixelColor(i,RgbColor(payload[i*3],payload[i*3+1],payload[i*3+2])); strip.Show(); gSyncActive=true; lastSyncMs=millis(); static uint32_t fc=0; if(fc++%30==0) Serial.printf("[ws] BIN frame #%u len=%u (%u leds)\n",fc,(unsigned)len,n); break; }
    case WStype_TEXT:{ Serial.printf("[ws] TEXT: %.*s\n",(int)len,(char*)payload); StaticJsonDocument<512> d; if(deserializeJson(d,payload,len)==DeserializationError::Ok){ if(d.containsKey("mode")){String ms=(const char*)d["mode"]; if(ms=="sync"||ms=="custom"){gSyncActive=true;lastSyncMs=millis();} else gMode=strToMode(ms);} if(d.containsKey("brightness"))gBrightness=d["brightness"]; if(d.containsKey("solidColor")){RgbColor c=hsvToRgb(d["solidColor"]["h"],d["solidColor"]["s"],d["solidColor"]["v"]);gR=c.R;gG=c.G;gB=c.B;} } break; }
    default: break;
  }
}

// =================================================================================
//  input
// =================================================================================
unsigned long lastInteract=0;
void goHome(){ view=VIEW_HOME; drawHomeStatic(); }
void goMenu(){ view=VIEW_MENU; menuDirty=true; }
void goSet(){ view=VIEW_SET; setDirty=true; }
void handleButtons(){
  btnNext.update(); btnSelect.update(); btnBack.update();
  bool e=false;
  if(view==VIEW_HOME){
    if(btnNext.clicked){ goMenu(); e=true; }
    if(btnSelect.clicked){ uint8_t st[4]={64,128,191,255}; static int bi=2; bi=(bi+1)%4; gBrightness=st[bi]; drawHomeStatic(); broadcastState(); e=true; }
    if(btnBack.clicked){ goSet(); e=true; }   // short press enters set-time
  } else if(view==VIEW_MENU){
    if(btnNext.clicked){ gMode=(Mode)((gMode+1)%M_COUNT); gSyncActive=false; broadcastState(); e=true; }
    if(btnSelect.clicked){ gPalIndex=(gPalIndex+1)%PAL_COUNT; Pal p=palette[gPalIndex]; gR=p.r; gG=p.g; gB=p.b; e=true; }
    if(btnBack.clicked){ goHome(); e=true; }
  } else { // VIEW_SET
    int h,m,s; clockNow(h,m,s);
    if(btnNext.clicked){ setTimeHM((h+1)%24,m,s); e=true; }
    if(btnSelect.clicked){ setTimeHM(h,(m+1)%60,0); e=true; }
    if(btnBack.clicked){ goHome(); e=true; }
  }
  if(e)lastInteract=millis();
  if(view!=VIEW_HOME&&millis()-lastInteract>9000) goHome();
}

// =================================================================================
//  setup / loop
// =================================================================================
void setup(){
  Serial.begin(115200); delay(300);
  pinMode(SW_NEXT,INPUT_PULLUP); pinMode(SW_SELECT,INPUT_PULLUP); pinMode(SW_BACK,INPUT_PULLUP);

  tft.begin(); tft.setRotation(3); tft.fillScreen(C_BG);
  printCentered("PRISMCAST OS",96,2,0x07FF); printCentered("booting terminal...",128,1,dim565(0x07FF,0.7));

  strip.Begin(); strip.ClearTo(RgbColor(0,0,0)); strip.Show();

  WiFi.mode(WIFI_STA); WiFi.begin(WIFI_SSID,WIFI_PASS);
  WiFi.setSleep(false);   // disable WiFi power-saving  fixes laggy/dropped WS connections
  unsigned long start=millis();
  while(WiFi.status()!=WL_CONNECTED && millis()-start<12000) delay(400);

  delay(150); tft.begin(); tft.setRotation(3); tft.fillScreen(C_BG);   // brownout recovery
  if(WiFi.status()==WL_CONNECTED){
    Serial.print("Connected. IP: "); Serial.println(WiFi.localIP());
    printCentered("PRISMCAST OS",92,2,0x07FF);
    String ip="ws://"+WiFi.localIP().toString()+":81";
    printCentered(ip.c_str(),126,1,0x07FF);
    printCentered("> enter in web app",146,1,dim565(0x07FF,0.7));
  } else {
    Serial.println("WiFi failed - offline clock only.");
    printCentered("PRISMCAST OS",104,2,0x07FF); printCentered("offline mode",134,1,dim565(0x07FF,0.7));
  }
  delay(1800);

  webSocket.begin(); webSocket.onEvent(onWsEvent);
  computeClockGeom(); clkBaseMs=millis();
  Pal p=palette[gPalIndex]; gR=p.r; gG=p.g; gB=p.b;
  renderLeds(); goHome();
  Serial.println("Ready.");
}

void loop(){
  webSocket.loop(); handleButtons(); tickCalendar();
  static unsigned long lastLed=0,lastUi=0; unsigned long now=millis();
  if(now-lastLed>40){lastLed=now;renderLeds();}
  if(now-lastUi>120){lastUi=now; if(view==VIEW_HOME)drawHome(); else if(view==VIEW_MENU)drawMenu(); else drawSet();}
  delay(2);
}

Credits

Ayushmaan
1 project • 1 follower
Building DIY tech that blends electronics, embedded systems, CAD, and software into polished, functional products.

Comments