1127 lines
30 KiB
Arduino
1127 lines
30 KiB
Arduino
#include <Adafruit_NeoPixel.h>
|
||
#include <WiFi.h>
|
||
#include <WebServer.h>
|
||
#include <ArduinoJson.h>
|
||
#include <HTTPClient.h>
|
||
#include <SPI.h>
|
||
#include <SD.h>
|
||
#include "animation.h"
|
||
#include "driver/temperature_sensor.h"
|
||
|
||
// #define PHILLIPS_HUE_URL "https://192.168.0.20/clip/v2/resource/light/5557eceb-fd62-4859-b587-28f67e948969"
|
||
#define PHILLIPS_HUE_URL "https://192.168.0.20/clip/v2/resource/light/"
|
||
|
||
#define PHILLIPS_HUE_WZ_MITTE "5557eceb-fd62-4859-b587-28f67e948969"
|
||
|
||
|
||
#define PHILLIPS_HUE_APPLICATION_KEY_NAME "hue-application-key"
|
||
#define PHILLIPS_HUE_APPLICATION_KEY_VALUE "Xs4bbYSXvlPlNR7nKmpLpsLnLCbz42C4SrvO93Fz"
|
||
|
||
#define PIN_NEO_PIXEL 2 // The ESP32 pin GPIO16 connected to NeoPixel
|
||
#define NUM_PIXELS 400 // The number of LEDs (pixels) on NeoPixel LED strip
|
||
#define COLLUMMS_PANEL 20
|
||
#define ROWS_PANEL 20
|
||
#define TARGET_UPDATE_RATE 1.0/30.0*1000.0 //Updaterate in ms
|
||
#define ANIMATION_CYCLE_SPEED_MS 2000 //Switch animation every 20s
|
||
#define FRAME_SIZE (COLLUMMS_PANEL * ROWS_PANEL * 3) // 1200 bytes per frame
|
||
|
||
#define SD_CS 7
|
||
#define SD_MOSI 6
|
||
#define SD_MISO 5
|
||
#define SD_SCK 4
|
||
#define BUTTON 3
|
||
|
||
#define WebServerOn
|
||
enum EntryType {
|
||
COUNT_FOLDERS,
|
||
COUNT_FILES
|
||
};
|
||
|
||
enum displayType {
|
||
DISPLAY_ANIMATION,
|
||
DISPLAY_VIDEO,
|
||
DISPLAY_PICTURE
|
||
};
|
||
|
||
Adafruit_NeoPixel NeoPixel(NUM_PIXELS, PIN_NEO_PIXEL, NEO_GRB + NEO_KHZ800);
|
||
String ServerResponseBody;
|
||
String ServerResponseBody2;
|
||
HTTPClient http;
|
||
StaticJsonDocument<20000> doc;
|
||
|
||
SPIClass spiSD(FSPI); // Use FSPI on ESP32
|
||
File myFile;
|
||
temperature_sensor_handle_t temp_sensor = NULL;
|
||
#ifdef WebServerOn
|
||
WebServer server(80);
|
||
#endif
|
||
|
||
// const char* ssid = "FRITZ!Box 6490 Cable";
|
||
// const char* password = "29644657346255273822";
|
||
|
||
int serverFrameOverride = 0;
|
||
|
||
const char* ssid = "Ununu-Schanze";
|
||
const char* password = "68838192089895491452";
|
||
|
||
|
||
void setup() {
|
||
Serial.begin(115200);
|
||
NeoPixel.begin(); // initialize NeoPixel strip object (REQUIRED)
|
||
WiFi.begin(ssid, password);
|
||
|
||
connectWifi(); //Connect to Wifi and show a progress bar on the matrix. Also returns IP-Address over Serial
|
||
startSDCard(); //Tries to look for SD-Card and start SPI communication with it
|
||
|
||
#ifdef WebServerOn //Activate WebServer and bind actions for requests (GET POST)
|
||
server.on("/", HTTP_GET, handleRoot);
|
||
server.on("/set", HTTP_POST, handleSet);
|
||
server.on("/normal", HTTP_POST, handleNormal);
|
||
server.begin();
|
||
#endif
|
||
|
||
NeoPixel.fill(NeoPixel.Color(0,0,0),0,99);
|
||
NeoPixel.setBrightness(50);
|
||
NeoPixel.show();
|
||
|
||
pinMode(BUTTON, INPUT);
|
||
|
||
// while(digitalRead(BUTTON)){
|
||
// displayAnimationFrame(millis(), 1, 0);
|
||
// }
|
||
|
||
// while(true){
|
||
// NeoPixel.fill(PhillipsHueColor(PHILLIPS_HUE_WZ_MITTE));
|
||
// NeoPixel.show();
|
||
// delay(200);
|
||
// }
|
||
// while(true){
|
||
// showManuRoom();
|
||
// }
|
||
}
|
||
//
|
||
void loop() {
|
||
// float bri = PhillipsHueBrightness(PHILLIPS_HUE_URL);
|
||
// int on = PhillipsHueOn(PHILLIPS_HUE_URL);
|
||
static int previousMillis = millis();
|
||
static displayType Type = DISPLAY_ANIMATION;
|
||
server.handleClient();;
|
||
if(!serverFrameOverride){
|
||
switch(Type){
|
||
case DISPLAY_ANIMATION:
|
||
if(displayAnimationFrame(millis(), 1, 0) == 3){Type = DISPLAY_PICTURE;}
|
||
break;
|
||
|
||
case DISPLAY_VIDEO:
|
||
if(updateVideoPlayback() == 3){Type = DISPLAY_ANIMATION;}
|
||
break;
|
||
|
||
case DISPLAY_PICTURE:
|
||
if(updatePicturePlayback() == 3){Type = DISPLAY_VIDEO;}
|
||
break;
|
||
|
||
default:
|
||
break;
|
||
}
|
||
}else{
|
||
parseJsonAndSetPixel();
|
||
}
|
||
}
|
||
|
||
int updatePicturePlayback() {
|
||
static unsigned long previousMillis = -20000;
|
||
static int maxFolders = -1;
|
||
static int folderIndex = 0;
|
||
static int frameIndex = 0;
|
||
static bool playing = false;
|
||
|
||
unsigned long currentMillis = millis();
|
||
|
||
// Respect update rate without blocking
|
||
if (currentMillis - previousMillis < TARGET_UPDATE_RATE*30*5) { //every 20sec
|
||
return 0;
|
||
}
|
||
previousMillis = currentMillis;
|
||
|
||
// Initialize folder count once
|
||
if (maxFolders == -1) {
|
||
maxFolders = countTopLevelEntries(SD, "/pictures", COUNT_FOLDERS);
|
||
folderIndex = 0;
|
||
frameIndex = 0;
|
||
playing = true;
|
||
}
|
||
|
||
|
||
if (folderIndex >= maxFolders) {
|
||
// Restart from first folder (or stop if preferred)
|
||
folderIndex = 0;
|
||
return 3;
|
||
}
|
||
|
||
String pathToRead = getEntryByIndex(SD, "/pictures", folderIndex, COUNT_FOLDERS);
|
||
|
||
// Try to read ONE frame only (non-blocking)
|
||
bool frameExists = readEntryByPathSetPixelFrameStream(
|
||
SD,
|
||
pathToRead + "/frame.bin",
|
||
frameIndex
|
||
);
|
||
|
||
if (frameExists) {
|
||
NeoPixel.show();
|
||
frameIndex++;
|
||
} else {
|
||
// Move to next folder when frames are done
|
||
frameIndex = 0;
|
||
folderIndex++;
|
||
}
|
||
|
||
// Button handling (non-blocking edge detect)
|
||
static bool lastButtonState = HIGH;
|
||
static int lastButtonStateTime = millis();
|
||
bool currentButtonState = digitalRead(BUTTON);
|
||
|
||
if (lastButtonState == HIGH && currentButtonState == LOW) {
|
||
// Button pressed
|
||
delay(20);
|
||
folderIndex++;
|
||
frameIndex = 0;
|
||
}
|
||
|
||
lastButtonState = currentButtonState;
|
||
lastButtonStateTime = millis();
|
||
}
|
||
|
||
|
||
int updateVideoPlayback() {
|
||
static unsigned long previousMillis = 0;
|
||
static int maxFolders = -1;
|
||
static int folderIndex = 0;
|
||
static int frameIndex = 0;
|
||
static bool playing = false;
|
||
|
||
unsigned long currentMillis = millis();
|
||
|
||
// Respect update rate without blocking
|
||
if (currentMillis - previousMillis < TARGET_UPDATE_RATE) {
|
||
return 0;
|
||
}
|
||
previousMillis = currentMillis;
|
||
|
||
// Initialize folder count once
|
||
if (maxFolders == -1) {
|
||
maxFolders = countTopLevelEntries(SD, "/videos", COUNT_FOLDERS);
|
||
folderIndex = 0;
|
||
frameIndex = 0;
|
||
playing = true;
|
||
}
|
||
|
||
|
||
if (folderIndex >= maxFolders) {
|
||
// Restart from first folder (or stop if preferred)
|
||
folderIndex = 0;
|
||
return 3;
|
||
}
|
||
|
||
String pathToRead = getEntryByIndex(SD, "/videos", folderIndex, COUNT_FOLDERS);
|
||
|
||
// Try to read ONE frame only (non-blocking)
|
||
bool frameExists = readEntryByPathSetPixelFrameStream(
|
||
SD,
|
||
pathToRead + "/frame.bin",
|
||
frameIndex
|
||
);
|
||
|
||
if (frameExists) {
|
||
NeoPixel.show();
|
||
frameIndex++;
|
||
} else {
|
||
// Move to next folder when frames are done
|
||
frameIndex = 0;
|
||
folderIndex++;
|
||
}
|
||
|
||
// Button handling (non-blocking edge detect)
|
||
static bool lastButtonState = HIGH;
|
||
static int lastButtonStateTime = millis();
|
||
bool currentButtonState = digitalRead(BUTTON);
|
||
|
||
if (lastButtonState == HIGH && currentButtonState == LOW) {
|
||
// Button pressed
|
||
delay(20);
|
||
folderIndex++;
|
||
frameIndex = 0;
|
||
}
|
||
|
||
lastButtonState = currentButtonState;
|
||
lastButtonStateTime = millis();
|
||
}
|
||
|
||
int displayAnimationFrame(int timeMs, int CycleAnimationOn, int AnimationIndex = 0){
|
||
uint32_t color;
|
||
static int animation = AnimationIndex;
|
||
static int prevtimeanimation = timeMs;
|
||
|
||
if(!CycleAnimationOn){
|
||
animation = AnimationIndex;
|
||
}
|
||
|
||
for (int y = 0; y < ROWS_PANEL; y++) {
|
||
for (int x = 0; x < COLLUMMS_PANEL; x++) {
|
||
switch(animation){
|
||
case 0:
|
||
animation++;
|
||
break;
|
||
case 1:
|
||
color = bubbles(x, y, timeMs);
|
||
break;
|
||
case 2:
|
||
color = galaxy(x, y, timeMs);
|
||
break;
|
||
case 3:
|
||
animation++;
|
||
break;
|
||
case 4:
|
||
color = flyingWireCubeHard(x, y, timeMs);
|
||
break;
|
||
case 5:
|
||
color = pacman(x, y, timeMs);
|
||
break;
|
||
case 6:
|
||
color = coolEffect(x, y, timeMs);
|
||
break;
|
||
case 7:
|
||
color = creeper(x, y, timeMs);
|
||
break;
|
||
case 8:
|
||
color = flappyBirdPixel(x, y, timeMs);
|
||
break;
|
||
case 9:
|
||
color = xwingDeathStarPixel(x, y, timeMs);
|
||
break;
|
||
case 10:
|
||
color = nyanCatPixel(x, y, timeMs);
|
||
break;
|
||
case 11:
|
||
color = bonfireMoonPixel(x, y, timeMs);
|
||
break;
|
||
case 12:
|
||
color = jumpingJackPixel(x, y, timeMs);
|
||
break;
|
||
case 13:
|
||
color = spinningDavidStarPixel(x, y, timeMs);
|
||
break;
|
||
case 14:
|
||
color = starrySky(x, y, timeMs);
|
||
break;
|
||
case 15:
|
||
color = purpleRain(x, y, timeMs);
|
||
break;
|
||
case 16:
|
||
color = amongUsPixel(x, y, timeMs);
|
||
break;
|
||
case 17:
|
||
color = minecraftPumpkin(x, y, timeMs);
|
||
break;
|
||
default:
|
||
animation = 0;
|
||
return 3;
|
||
break;
|
||
}
|
||
|
||
if(timeMs - prevtimeanimation >= ANIMATION_CYCLE_SPEED_MS){
|
||
animation++;
|
||
prevtimeanimation = timeMs;
|
||
}
|
||
|
||
NeoPixel.setPixelColor(getPixelByCoordinate(x,y), color);
|
||
}
|
||
}
|
||
|
||
NeoPixel.show();
|
||
return 0;
|
||
}
|
||
|
||
void showManuRoom(void){
|
||
for(int y = 0; y < ROWS_PANEL; y++){
|
||
for(int x = 0; x < COLLUMMS_PANEL; x++){
|
||
NeoPixel.setPixelColor(getPixelByCoordinate(x, y), NeoPixel.Color(imageManuBude[y][x] ? 255: 0, imageManuBude[y][x] ? 255: 0, imageManuBude[y][x] ? 255: 0));
|
||
}
|
||
}
|
||
NeoPixel.setPixelColor(getPixelByCoordinate(13,16), PhillipsHueColor("5557eceb-fd62-4859-b587-28f67e948969"));
|
||
NeoPixel.setPixelColor(getPixelByCoordinate(13,17), PhillipsHueColor("5ccff572-baaa-4fa0-8998-d43e595e28c5"));
|
||
NeoPixel.setPixelColor(getPixelByCoordinate(13,18), PhillipsHueColor("29cc7114-9f1f-44ba-baab-00718cd4d1f3"));
|
||
|
||
NeoPixel.setPixelColor(getPixelByCoordinate(0,17), PhillipsHueColor("ccc6bced-f859-4a3d-8af2-7885f79d166c"));
|
||
NeoPixel.setPixelColor(getPixelByCoordinate(2,17), PhillipsHueColor("76b2d794-657a-43ed-8f0e-f15c56e396e1"));
|
||
NeoPixel.setPixelColor(getPixelByCoordinate(4,17), PhillipsHueColor("ccc6bced-f859-4a3d-8af2-7885f79d166c"));
|
||
|
||
NeoPixel.setPixelColor(getPixelByCoordinate(11,7), PhillipsHueColor("e3c9d639-72df-415f-a579-2311dedbd2e7"));
|
||
NeoPixel.setPixelColor(getPixelByCoordinate(11,8), PhillipsHueColor("acec0a2e-0755-4e79-8c77-a4e972d11149"));
|
||
|
||
NeoPixel.setPixelColor(getPixelByCoordinate(10,4), PhillipsHueColorOnlyBrightness("563584d7-7252-45a0-9109-332f5714781d"));
|
||
NeoPixel.setPixelColor(getPixelByCoordinate(10,3), PhillipsHueColorOnlyBrightness("72f09c6e-0a5b-4836-8bda-7249248c1e01"));
|
||
|
||
NeoPixel.setPixelColor(getPixelByCoordinate(0,11), PhillipsHueColor("5a13afda-216a-4b41-bec2-96beaee94012"));
|
||
NeoPixel.setPixelColor(getPixelByCoordinate(0,12), PhillipsHueColor("ee8b00b0-4969-4274-8e5e-df291b06c9f7"));
|
||
|
||
NeoPixel.setPixelColor(getPixelByCoordinate(4,11), PhillipsHueColorOnlyBrightness("d95fa043-d067-48f5-8b3a-59ea650fe5c0"));
|
||
NeoPixel.setPixelColor(getPixelByCoordinate(4,12), PhillipsHueColor("ee63ef6a-72dd-4a8e-a2d3-7f446a241699"));
|
||
NeoPixel.setPixelColor(getPixelByCoordinate(4,13), PhillipsHueColorOnlyBrightness("eb7f0210-b132-46b1-a061-853efcc42cfb"));
|
||
NeoPixel.show();
|
||
}
|
||
|
||
int readEntryByPathSetPixelFrameStream(fs::FS &fs, const String &filePath, int frameIndex) {
|
||
static uint8_t frameBuffer[FRAME_SIZE]; // preallocated buffer
|
||
|
||
File file = fs.open(filePath);
|
||
if(!file) {
|
||
Serial.println("Could not open animation file on SD!");
|
||
return 0;
|
||
}
|
||
|
||
// Seek to the frame start
|
||
if(!file.seek(frameIndex * FRAME_SIZE)) {
|
||
Serial.println("Seek failed!");
|
||
file.close();
|
||
return 0;
|
||
}
|
||
|
||
// Read the frame into RAM
|
||
size_t bytesRead = file.read(frameBuffer, FRAME_SIZE);
|
||
file.close();
|
||
|
||
if(bytesRead != FRAME_SIZE) {
|
||
Serial.println("Frame read incomplete!");
|
||
return 0;
|
||
}
|
||
|
||
// Set pixels
|
||
int idx = 0;
|
||
for(int y = 0; y < ROWS_PANEL; y++) {
|
||
for(int x = 0; x < COLLUMMS_PANEL; x++) {
|
||
uint8_t r = frameBuffer[idx++];
|
||
uint8_t g = frameBuffer[idx++];
|
||
uint8_t b = frameBuffer[idx++];
|
||
uint32_t color = NeoPixel.Color(r, g, b);
|
||
NeoPixel.setPixelColor(getPixelByCoordinate(x, y), color);
|
||
}
|
||
}
|
||
return 1;
|
||
}
|
||
|
||
int readEntryByPathSetPixelFrame(fs::FS &fs, String path){
|
||
int x = 0;
|
||
int y = 0;
|
||
uint8_t frameBuffer[COLLUMMS_PANEL*ROWS_PANEL*3];
|
||
|
||
File file = fs.open(path);
|
||
if(!file){
|
||
Serial.println("Could not open file on SD!");
|
||
return 0;
|
||
}
|
||
|
||
size_t bytesRead = file.read(frameBuffer, COLLUMMS_PANEL*ROWS_PANEL*3);
|
||
file.close();
|
||
|
||
if(bytesRead != COLLUMMS_PANEL*ROWS_PANEL*3){
|
||
Serial.println("Frame size mismatch!");
|
||
return 0;
|
||
}
|
||
|
||
int idx = 0;
|
||
for(int y = 0; y < ROWS_PANEL; y++){
|
||
for(int x = 0; x < COLLUMMS_PANEL; x++){
|
||
uint8_t r = frameBuffer[idx++];
|
||
uint8_t g = frameBuffer[idx++];
|
||
uint8_t b = frameBuffer[idx++];
|
||
uint32_t color = NeoPixel.Color(r, g, b);
|
||
NeoPixel.setPixelColor(getPixelByCoordinate(x, y), color);
|
||
}
|
||
}
|
||
|
||
Serial.println("Read File!");
|
||
return 1;
|
||
}
|
||
|
||
int readEntryByPath(fs::FS &fs,String &data, String path){
|
||
File file = SD.open(path);
|
||
if(file){
|
||
Serial.println("Reading File...");
|
||
data = "";
|
||
while(file.available()){
|
||
int temp = file.read();
|
||
Serial.write(temp);
|
||
ServerResponseBody2 += (char)temp;
|
||
}
|
||
file.close();
|
||
Serial.println("Read File!");
|
||
return 1;
|
||
}else{
|
||
Serial.println("Could not read data from SD-Card");
|
||
return 0;
|
||
}
|
||
}
|
||
|
||
String getEntryByIndex(fs::FS &fs, String path, int index, EntryType type) {
|
||
File root = fs.open(path);
|
||
if (!root || !root.isDirectory()) return "";
|
||
|
||
int currentIndex = 0;
|
||
File file = root.openNextFile();
|
||
|
||
while (file) {
|
||
bool match =
|
||
(type == COUNT_FOLDERS && file.isDirectory()) ||
|
||
(type == COUNT_FILES && !file.isDirectory());
|
||
|
||
if (match) {
|
||
if (currentIndex == index) {
|
||
return String(path) + "/" + file.name();
|
||
}
|
||
currentIndex++;
|
||
}
|
||
|
||
file = root.openNextFile();
|
||
}
|
||
|
||
return ""; // index out of range
|
||
}
|
||
|
||
int countTopLevelEntries(fs::FS &fs, String path, EntryType type) {
|
||
File root = fs.open(path);
|
||
if (!root || !root.isDirectory()) return 0;
|
||
|
||
int count = 0;
|
||
File file = root.openNextFile();
|
||
while (file) {
|
||
if (type == COUNT_FOLDERS && file.isDirectory()) {
|
||
count++;
|
||
}
|
||
else if (type == COUNT_FILES && !file.isDirectory()) {
|
||
count++;
|
||
}
|
||
file = root.openNextFile();
|
||
}
|
||
|
||
return count;
|
||
}
|
||
|
||
void startSDCard(){
|
||
spiSD.begin(SD_SCK, SD_MISO, SD_MOSI, SD_CS);
|
||
for(int i = 0; i < 20; i++){
|
||
if (!SD.begin(SD_CS, spiSD, 80000000)) {
|
||
Serial.println("❌ SD Card Mount Failed " + String(i));
|
||
if (!SD.begin(SD_CS, spiSD, 80000000) && i == 19) {
|
||
return;
|
||
}
|
||
}
|
||
}
|
||
|
||
Serial.println("✅ SD Card initialized.");
|
||
}
|
||
|
||
void connectWifi(){
|
||
int wifiSymbol[7][9] = {{0,0,1,1,1,1,1,0,0},
|
||
{0,1,0,0,0,0,0,1,0},
|
||
{1,0,0,0,0,0,0,0,1},
|
||
{0,0,0,1,1,1,0,0,0},
|
||
{0,0,1,0,0,0,1,0,0},
|
||
{0,0,0,0,0,0,0,0,0},
|
||
{0,0,0,0,1,0,0,0,0}};
|
||
|
||
while (WiFi.status() != WL_CONNECTED) { //Connect to WIFI
|
||
static int cycles = 0;
|
||
delay(50);
|
||
Serial.print(".");
|
||
NeoPixel.fill(NeoPixel.Color(0,0,0));
|
||
for(int x = 0; x < 9; x++){
|
||
for(int y = 0; y < 7; y++){
|
||
if(wifiSymbol[y][x] && (cycles%10 > 7-y))
|
||
NeoPixel.setPixelColor(getPixelByCoordinate(x+5, y+6), NeoPixel.Color(255,100,0));
|
||
}
|
||
}
|
||
NeoPixel.show();
|
||
cycles++;
|
||
}
|
||
Serial.println("\nConnected!");
|
||
Serial.println(WiFi.localIP());
|
||
|
||
//Success Animation
|
||
for(int j = 0; j < 3; j++){ //3 Times
|
||
for(int x = 0; x < 9; x++){
|
||
for(int y = 0; y < 7; y++){
|
||
if(wifiSymbol[y][x])
|
||
NeoPixel.setPixelColor(getPixelByCoordinate(x+5, y+6), NeoPixel.Color(0,255,0));
|
||
}
|
||
}
|
||
NeoPixel.show();
|
||
delay(300);
|
||
|
||
NeoPixel.fill(NeoPixel.Color(0,0,0));
|
||
NeoPixel.show();
|
||
delay(300);
|
||
}
|
||
}
|
||
|
||
float PhillipsHueBrightness(String ServiceLightID){ //return float in % (0-100) of current lightbrightness input: id of light to read
|
||
http.begin(PHILLIPS_HUE_URL);
|
||
http.addHeader(PHILLIPS_HUE_APPLICATION_KEY_NAME, PHILLIPS_HUE_APPLICATION_KEY_VALUE); //Add security key only received when pressing the link button
|
||
Serial.println("Sending GET");
|
||
int ResponseCodeHttp = http.GET();
|
||
ServerResponseBody = http.getString();
|
||
// Serial.println("HTTP Response Code" + toString(ResponseCodeHttp));
|
||
deserializeJson(doc, ServerResponseBody);
|
||
return (float)doc["data"][0]["dimming"]["brightness"];
|
||
}
|
||
|
||
int PhillipsHueOn(String ServiceLightID){ //Returns 1 when light is switched on input: id of light to read
|
||
http.begin(PHILLIPS_HUE_URL);
|
||
http.addHeader(PHILLIPS_HUE_APPLICATION_KEY_NAME, PHILLIPS_HUE_APPLICATION_KEY_VALUE); //Add security key only received when pressing the link button
|
||
Serial.println("Sending GET");
|
||
int ResponseCodeHttp = http.GET();
|
||
ServerResponseBody = http.getString();
|
||
// Serial.println("HTTP Response Code" + toString(ResponseCodeHttp));
|
||
deserializeJson(doc, ServerResponseBody);
|
||
return doc["data"][0]["on"]["on"] == true ? 1 : 0;
|
||
}
|
||
|
||
uint32_t PhillipsHueColor(String ServiceLightID){
|
||
http.begin(PHILLIPS_HUE_URL+ServiceLightID);
|
||
http.addHeader(PHILLIPS_HUE_APPLICATION_KEY_NAME, PHILLIPS_HUE_APPLICATION_KEY_VALUE); //Add security key only received when pressing the link button
|
||
Serial.println("Sending GET");
|
||
int ResponseCodeHttp = http.GET();
|
||
ServerResponseBody = http.getString();
|
||
// Serial.println("HTTP Response Code" + toString(ResponseCodeHttp));
|
||
deserializeJson(doc, ServerResponseBody);
|
||
float x = (float)doc["data"][0]["color"]["xy"]["x"];
|
||
float y = (float)doc["data"][0]["color"]["xy"]["y"];
|
||
float brightness = (float)doc["data"][0]["dimming"]["brightness"];
|
||
doc["data"][0]["on"]["on"] == false ? brightness = 0 : brightness = brightness;
|
||
|
||
return ConvertHueToRGB888Neo(x, y, brightness);
|
||
}
|
||
|
||
uint32_t PhillipsHueColorOnlyBrightness(String ServiceLightID){
|
||
http.begin(PHILLIPS_HUE_URL+ServiceLightID);
|
||
http.addHeader(PHILLIPS_HUE_APPLICATION_KEY_NAME, PHILLIPS_HUE_APPLICATION_KEY_VALUE); //Add security key only received when pressing the link button
|
||
Serial.println("Sending GET");
|
||
int ResponseCodeHttp = http.GET();
|
||
ServerResponseBody = http.getString();
|
||
// Serial.println("HTTP Response Code" + toString(ResponseCodeHttp));
|
||
deserializeJson(doc, ServerResponseBody);
|
||
float brightness = (float)doc["data"][0]["dimming"]["brightness"];
|
||
doc["data"][0]["on"]["on"] == false ? brightness = 0 : brightness = brightness;
|
||
int bri = (int)(brightness * 255.0 / 100.0);
|
||
return NeoPixel.Color(bri,(int)(bri*0.8),(int)(bri*0.7));
|
||
}
|
||
|
||
uint32_t ConvertHueToRGB888Neo(float x, float y, float brightness){
|
||
float bri = brightness/100.0;
|
||
float Y = bri; // Luminance
|
||
float X = (Y / y) * x;
|
||
float Z = (Y / y) * (1.0 - x - y);
|
||
float r = 1.656492 * X - 0.354851 * Y - 0.255038 * Z;
|
||
float g = -0.707196 * X + 1.655397 * Y + 0.036152 * Z;
|
||
float b = 0.051713 * X - 0.121364 * Y + 1.011530 * Z;
|
||
r = gammaCorrect(r);
|
||
g = gammaCorrect(g);
|
||
b = gammaCorrect(b);
|
||
r = constrain(r, 0.0, 1.0);
|
||
g = constrain(g, 0.0, 1.0);
|
||
b = constrain(b, 0.0, 1.0);
|
||
|
||
uint8_t R = (uint8_t)(r * 255);
|
||
uint8_t G = (uint8_t)(g * 255);
|
||
uint8_t B = (uint8_t)(b * 255);
|
||
|
||
return NeoPixel.Color(R,G,B);
|
||
}
|
||
|
||
float gammaCorrect(float c) {
|
||
if (c <= 0.0031308)
|
||
return 12.92 * c;
|
||
else
|
||
return (1.0 + 0.055) * pow(c, 1.0 / 2.4) - 0.055;
|
||
}
|
||
|
||
//Used for WebApp
|
||
void parseJsonAndSetPixel(){
|
||
int prevmil = millis();
|
||
deserializeJson(doc, ServerResponseBody2);
|
||
Serial.println(millis()-prevmil);
|
||
for(int frame = 0; frame < doc.size(); frame++){
|
||
for(int y = 0; y < 20/*doc[0].size()*/; y++){
|
||
for(int x = 0; x < 20/*doc[0][y].size()*/; x++){
|
||
NeoPixel.setPixelColor(getPixelByCoordinate(x, y), NeoPixel.Color(doc[frame][y][x][0], doc[frame][y][x][1], doc[frame][y][x][2]));
|
||
Serial.print("Pixel ");
|
||
Serial.print(getPixelByCoordinate(x, y));
|
||
Serial.print(" Red ");
|
||
Serial.println((int)doc[frame][y][x][0]);
|
||
}
|
||
}
|
||
NeoPixel.show();
|
||
delay(1);
|
||
}
|
||
}
|
||
|
||
int getPixelByCoordinate(int x, int y){ //Starts from top left 0,0
|
||
int pixel = x+y*COLLUMMS_PANEL;
|
||
|
||
if(x < COLLUMMS_PANEL/2 && y < ROWS_PANEL/2){ //TOPLEFT
|
||
return pixel = x+y*COLLUMMS_PANEL/2;
|
||
}
|
||
else if (x >= COLLUMMS_PANEL/2 && y < ROWS_PANEL/2){ //TOPRIGHT
|
||
return pixel = (x-10)+y*COLLUMMS_PANEL/2+100;
|
||
}
|
||
else if(x < COLLUMMS_PANEL/2 && y >= ROWS_PANEL/2){ //BOTLEFT
|
||
return pixel = x+(y-10)*COLLUMMS_PANEL/2 + 300;
|
||
}
|
||
else{ //BOTRIGHT
|
||
return pixel = (x-10)+(y-10)*COLLUMMS_PANEL/2 + 200;
|
||
}
|
||
}
|
||
|
||
#ifdef WebServerOn
|
||
void handleSet() {
|
||
if (server.hasArg("plain") == false) {
|
||
server.send(400, "text/plain", "No ServerResponseBody received");
|
||
return;
|
||
}
|
||
serverFrameOverride = 1;
|
||
ServerResponseBody2 = server.arg("plain"); // RAW POST ServerResponseBody
|
||
server.send(200, "application/json", "Server recieved data!");
|
||
Serial.println(ServerResponseBody2);
|
||
parseJsonAndSetPixel();
|
||
}
|
||
|
||
void handleNormal(){
|
||
serverFrameOverride = 0;
|
||
server.send(200, "application/json", "Server recieved data!");
|
||
}
|
||
#endif
|
||
|
||
#ifdef WebServerOn
|
||
void handleRoot() {
|
||
server.send(200, "text/html", R"rawliteral(
|
||
<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<title>Multi-Frame RGB Pixel Editor</title>
|
||
|
||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
|
||
|
||
<style>
|
||
body {
|
||
font-family: sans-serif;
|
||
margin: 8px;
|
||
text-align: center;
|
||
}
|
||
|
||
h2 {
|
||
margin-bottom: 8px;
|
||
}
|
||
|
||
.toolbar {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 8px;
|
||
justify-content: center;
|
||
margin-bottom: 10px;
|
||
}
|
||
|
||
.toolbar button,
|
||
.toolbar input,
|
||
.toolbar label {
|
||
font-size: 16px;
|
||
}
|
||
|
||
.toolbar button {
|
||
padding: 10px 14px;
|
||
min-width: 44px;
|
||
}
|
||
|
||
input[type="number"] {
|
||
width: 80px;
|
||
padding: 6px;
|
||
}
|
||
|
||
input[type="color"] {
|
||
width: 44px;
|
||
height: 44px;
|
||
padding: 0;
|
||
border: 2px solid black;
|
||
}
|
||
|
||
/* MAIN CANVAS — BIG */
|
||
#canvas {
|
||
border: 1px solid black;
|
||
image-rendering: pixelated;
|
||
touch-action: none;
|
||
width: min(95vw, 520px);
|
||
height: min(95vw, 520px);
|
||
}
|
||
|
||
/* VIEWPORT — SMALL */
|
||
#viewport {
|
||
border: 1px solid black;
|
||
image-rendering: pixelated;
|
||
width: 120px;
|
||
height: 120px;
|
||
}
|
||
|
||
.viewports {
|
||
display: flex;
|
||
justify-content: center;
|
||
align-items: center;
|
||
gap: 14px;
|
||
margin-top: 10px;
|
||
}
|
||
|
||
.viewport-container {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
font-size: 13px;
|
||
opacity: 0.85;
|
||
}
|
||
|
||
.status {
|
||
margin-top: 8px;
|
||
font-size: 14px;
|
||
}
|
||
|
||
#eraseIndicator {
|
||
position: fixed;
|
||
top: 300px;
|
||
right: 16px;
|
||
font-size: 36px;
|
||
pointer-events: none;
|
||
opacity: 0;
|
||
transform: scale(0.8);
|
||
transition: opacity 0.15s ease, transform 0.15s ease;
|
||
}
|
||
</style>
|
||
</head>
|
||
|
||
<body>
|
||
|
||
<h2>Multi-Frame RGB Pixel Editor</h2>
|
||
|
||
<div class="toolbar">
|
||
<label>
|
||
Size
|
||
<input type="number" id="sizeInput" value="20" min="1" max="128">
|
||
</label>
|
||
<button onclick="applySize()">Apply</button>
|
||
|
||
<input type="color" id="colorPicker" value="#ff0000">
|
||
|
||
<button onclick="prevFrame()">◀</button>
|
||
<span id="frameLabel">Frame 1 / 1</span>
|
||
<button onclick="nextFrame()">▶</button>
|
||
|
||
<button onclick="addFrame()">➕</button>
|
||
<button onclick="deleteFrame()">🗑️</button>
|
||
|
||
<label>
|
||
<input type="checkbox" id="copyPrev" checked>
|
||
Copy
|
||
</label>
|
||
|
||
<button onclick="exportFrames()">Export</button>
|
||
<button onclick="activateNormalCycle()">Activate normal cycle</button>
|
||
|
||
<label>
|
||
Import
|
||
<input type="file" id="pngInput" accept="image/png">
|
||
</label>
|
||
</div>
|
||
|
||
<!-- BIG DRAWING AREA -->
|
||
<canvas id="canvas"></canvas>
|
||
|
||
<!-- SMALL PREVIOUS FRAME VIEW -->
|
||
<div class="viewports">
|
||
<div class="viewport-container">
|
||
<div>Previous</div>
|
||
<canvas id="viewport"></canvas>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="status" id="pixelStatus">Pixel: —</div>
|
||
<div id="eraseIndicator">🧽</div>
|
||
|
||
<script>
|
||
const CANVAS_PX = 512;
|
||
const VIEWPORT_PX = 128;
|
||
const ERASE_DELAY = 500; // ms
|
||
|
||
const canvas = document.getElementById("canvas");
|
||
const ctx = canvas.getContext("2d");
|
||
const viewport = document.getElementById("viewport");
|
||
const vctx = viewport.getContext("2d");
|
||
|
||
const colorPicker = document.getElementById("colorPicker");
|
||
const sizeInput = document.getElementById("sizeInput");
|
||
const frameLabel = document.getElementById("frameLabel");
|
||
const pixelStatus = document.getElementById("pixelStatus");
|
||
const copyPrevCheckbox = document.getElementById("copyPrev");
|
||
const test = document.getElementById("test");
|
||
const eraseIndicator = document.getElementById("eraseIndicator");
|
||
|
||
let logicalSize = 20;
|
||
let scale, vscale;
|
||
let isDrawing = false;
|
||
|
||
let frames = [];
|
||
let currentFrame = 0;
|
||
|
||
/* gesture state */
|
||
let touchStartTime = 0;
|
||
let hasMoved = false;
|
||
let mode = null; // "paint" | "erase" | null
|
||
let isTouchActive = false;
|
||
|
||
canvas.addEventListener("contextmenu", e => e.preventDefault());
|
||
|
||
function createEmptyFrame(size) {
|
||
return Array.from({ length: size }, () =>
|
||
Array.from({ length: size }, () => [0, 0, 0])
|
||
);
|
||
}
|
||
|
||
function cloneFrame(frame) {
|
||
return frame.map(row => row.map(p => [...p]));
|
||
}
|
||
|
||
function applySize() {
|
||
logicalSize = parseInt(sizeInput.value);
|
||
scale = CANVAS_PX / logicalSize;
|
||
vscale = VIEWPORT_PX / logicalSize;
|
||
|
||
canvas.width = CANVAS_PX;
|
||
canvas.height = CANVAS_PX;
|
||
viewport.width = VIEWPORT_PX;
|
||
viewport.height = VIEWPORT_PX;
|
||
|
||
frames = [createEmptyFrame(logicalSize)];
|
||
currentFrame = 0;
|
||
updateFrameLabel();
|
||
draw();
|
||
}
|
||
|
||
function drawFrame(ctxToDraw, pixels, pixelSize) {
|
||
ctxToDraw.clearRect(0, 0, pixelSize * logicalSize, pixelSize * logicalSize);
|
||
ctxToDraw.strokeStyle = "#aaa";
|
||
|
||
for (let y = 0; y < logicalSize; y++) {
|
||
for (let x = 0; x < logicalSize; x++) {
|
||
const [r, g, b] = pixels[y][x];
|
||
ctxToDraw.fillStyle = `rgb(${r},${g},${b})`;
|
||
ctxToDraw.fillRect(x * pixelSize, y * pixelSize, pixelSize, pixelSize);
|
||
ctxToDraw.strokeRect(x * pixelSize, y * pixelSize, pixelSize, pixelSize);
|
||
}
|
||
}
|
||
}
|
||
|
||
function draw() {
|
||
drawFrame(ctx, frames[currentFrame], scale);
|
||
if (currentFrame > 0) {
|
||
drawFrame(vctx, frames[currentFrame - 1], vscale);
|
||
} else {
|
||
vctx.clearRect(0, 0, VIEWPORT_PX, VIEWPORT_PX);
|
||
}
|
||
}
|
||
|
||
function getPointerPos(e) {
|
||
const rect = canvas.getBoundingClientRect();
|
||
const p = e.touches ? e.touches[0] : e;
|
||
const scaleX = canvas.width / rect.width;
|
||
const scaleY = canvas.height / rect.height;
|
||
|
||
return {
|
||
x: Math.floor((p.clientX - rect.left) * scaleX / scale),
|
||
y: Math.floor((p.clientY - rect.top) * scaleY / scale)
|
||
};
|
||
}
|
||
|
||
function paint(e) {
|
||
e.preventDefault();
|
||
const { x, y } = getPointerPos(e);
|
||
if (x < 0 || y < 0 || x >= logicalSize || y >= logicalSize) return;
|
||
|
||
if (isErasing || e.buttons === 2) {
|
||
frames[currentFrame][y][x] = [0, 0, 0];
|
||
} else {
|
||
const hex = colorPicker.value;
|
||
frames[currentFrame][y][x] = [
|
||
parseInt(hex.slice(1,3),16),
|
||
parseInt(hex.slice(3,5),16),
|
||
parseInt(hex.slice(5,7),16)
|
||
];
|
||
}
|
||
|
||
pixelStatus.textContent = `Pixel: (${x}, ${y})`;
|
||
draw();
|
||
}
|
||
|
||
/* Mouse */
|
||
canvas.addEventListener("mousedown", e => {
|
||
isDrawing = true;
|
||
isErasing = e.button === 2;
|
||
paint(e);
|
||
});
|
||
canvas.addEventListener("mousemove", e => {
|
||
isDrawing && paint(e);
|
||
const { x, y } = getPointerPos(e);
|
||
pixelStatus.textContent = `Pixel: (${x}, ${y})`;
|
||
});
|
||
window.addEventListener("mouseup", () => {
|
||
isDrawing = false;
|
||
isErasing = false;
|
||
});
|
||
|
||
/* Touch logic */
|
||
canvas.addEventListener("touchstart", e => {
|
||
isTouchActive = true;
|
||
hasMoved = false;
|
||
mode = null;
|
||
touchStartTime = performance.now();
|
||
}, { passive: false });
|
||
|
||
canvas.addEventListener("touchmove", e => {
|
||
if (!isTouchActive) return;
|
||
e.preventDefault();
|
||
|
||
if (!hasMoved) {
|
||
hasMoved = true;
|
||
const elapsed = performance.now() - touchStartTime;
|
||
if (elapsed >= ERASE_DELAY) {
|
||
mode = "erase";
|
||
showEraseIndicator();
|
||
} else {
|
||
mode = "paint";
|
||
}
|
||
}
|
||
|
||
applyPixel(e);
|
||
}, { passive: false });
|
||
|
||
canvas.addEventListener("touchend", () => {
|
||
isTouchActive = false;
|
||
mode = null;
|
||
hideEraseIndicator();
|
||
});
|
||
|
||
function applyPixel(e) {
|
||
const { x, y } = getPointerPos(e);
|
||
if (x < 0 || y < 0 || x >= logicalSize || y >= logicalSize) return;
|
||
|
||
if (mode === "erase") {
|
||
frames[currentFrame][y][x] = [0, 0, 0];
|
||
} else if (mode === "paint") {
|
||
const hex = colorPicker.value;
|
||
frames[currentFrame][y][x] = [
|
||
parseInt(hex.slice(1,3),16),
|
||
parseInt(hex.slice(3,5),16),
|
||
parseInt(hex.slice(5,7),16)
|
||
];
|
||
}
|
||
|
||
pixelStatus.textContent = `Pixel: (${x}, ${y})`;
|
||
draw();
|
||
}
|
||
|
||
|
||
function addFrame() {
|
||
frames.push(copyPrevCheckbox.checked ? cloneFrame(frames[currentFrame]) : createEmptyFrame(logicalSize));
|
||
currentFrame = frames.length - 1;
|
||
updateFrameLabel();
|
||
draw();
|
||
}
|
||
|
||
function deleteFrame() {
|
||
if (frames.length === 1) return alert("Cannot delete last frame");
|
||
frames.splice(currentFrame, 1);
|
||
currentFrame = Math.max(0, currentFrame - 1);
|
||
updateFrameLabel();
|
||
draw();
|
||
}
|
||
|
||
function prevFrame() {
|
||
if (currentFrame > 0) currentFrame--;
|
||
updateFrameLabel();
|
||
draw();
|
||
}
|
||
|
||
function nextFrame() {
|
||
if (currentFrame < frames.length - 1) currentFrame++;
|
||
updateFrameLabel();
|
||
draw();
|
||
}
|
||
|
||
function updateFrameLabel() {
|
||
frameLabel.textContent = `Frame ${currentFrame + 1} / ${frames.length}`;
|
||
}
|
||
|
||
function exportFrames() {
|
||
fetch("/set", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify(frames)
|
||
}).then(r => r.text()).then(alert);
|
||
}
|
||
|
||
function activateNormalCycle() {
|
||
fetch("/normal", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: ""
|
||
});
|
||
}
|
||
|
||
function showEraseIndicator() {
|
||
eraseIndicator.style.opacity = "1";
|
||
eraseIndicator.style.transform = "scale(1)";
|
||
}
|
||
|
||
function hideEraseIndicator() {
|
||
eraseIndicator.style.opacity = "0";
|
||
eraseIndicator.style.transform = "scale(0.8)";
|
||
}
|
||
|
||
document.getElementById("pngInput").addEventListener("change", e => {
|
||
const file = e.target.files[0];
|
||
if (!file) return;
|
||
const img = new Image();
|
||
img.onload = () => {
|
||
const off = document.createElement("canvas");
|
||
off.width = logicalSize;
|
||
off.height = logicalSize;
|
||
const octx = off.getContext("2d");
|
||
octx.drawImage(img, 0, 0, logicalSize, logicalSize);
|
||
const d = octx.getImageData(0, 0, logicalSize, logicalSize).data;
|
||
|
||
for (let y = 0; y < logicalSize; y++) {
|
||
for (let x = 0; x < logicalSize; x++) {
|
||
const i = (y * logicalSize + x) * 4;
|
||
frames[currentFrame][y][x] = [d[i], d[i+1], d[i+2]];
|
||
}
|
||
}
|
||
draw();
|
||
};
|
||
img.src = URL.createObjectURL(file);
|
||
});
|
||
|
||
applySize();
|
||
</script>
|
||
|
||
</body>
|
||
</html>
|
||
|
||
)rawliteral");
|
||
}
|
||
#endif
|