diff --git a/.vscode/settings.json b/.vscode/settings.json
index 7a048e5..f2b0ded 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -33,14 +33,17 @@
"utility": "cpp",
"fstream": "cpp"
},
- "[css]": {
- "editor.defaultFormatter": "esbenp.prettier-vscode"
+ "[css]": {
+ "editor.defaultFormatter": "esbenp.prettier-vscode",
+ "editor.formatOnSave": true
},
"[html]": {
- "editor.defaultFormatter": "esbenp.prettier-vscode"
+ "editor.defaultFormatter": "esbenp.prettier-vscode",
+ "editor.formatOnSave": true
},
"[javascript]": {
- "editor.defaultFormatter": "esbenp.prettier-vscode"
+ "editor.defaultFormatter": "esbenp.prettier-vscode",
+ "editor.formatOnSave": true
},
"[json]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
diff --git a/README.md b/README.md
index f3b60c9..d04eeeb 100644
--- a/README.md
+++ b/README.md
@@ -1,5 +1,52 @@
This is a personal project to control a LED matrix from an ESP32 microcontroller.
+
+
+
+ Font
+
+
+
+ Back
+
+
+
+ Inside overview
+
+
+
+
+
+ GY-521 glued to the inner border of the panel frame
+
+
+
+ ESP32 wires
+
+
+
+ Under the LED panel
+
+
+
+
+
+ Image rendering
+
+
+
+ GIF rendering
+
+
+
+ Rotation
+
+
+
+
+
+Some LEDs have been harmed in the development process. Our thoughts go to all the dead pixels out there~
+
# Components
My LED panel is 19,2x19,2cm, so I'm using a 25x25x3cm frame with a 3D-printed passepartout. I've screwed 4 screws through the frame's backpanel into the LED panel's screw holes, with spacers to keep the panel away from the backpanel and pressed against the glass frame.
@@ -8,7 +55,9 @@ The gyroscope is glued inside with the chip facing upwards (on the inner border
For powering it I'm using a random USB charger (5V 3A output): I've cut the mini-USB head and wired the esp32 board and LED panel (plus the gyroscope's ground because the esp32's were full). The panel actually uses up to 4-5A (depending on "brand", size and brightness) and the esp32 uses 0.5-1A, so I'm looking for a small power supply that can accomodate that, but it's currently working so ¯\\_(ツ)_/¯
-See [gallery](#gallery) for photos.
+Wiring/PIN layout
+
+
## Hardware
@@ -24,7 +73,7 @@ See [gallery](#gallery) for photos.
- [FastLED](https://github.com/FastLED/FastLED), for color conversion functions and other utilities
- the ESP32Async project and their [AsyncTCP](https://github.com/ESP32Async/AsyncTCP)/[ESPAsyncWebServer](https://github.com/ESP32Async/ESPAsyncWebServer) libraries
- [ArduinoJSON](https://github.com/bblanchon/ArduinoJson), for JSON de/serialization
-- [AnimatedGIF](https://github.com/bitbank2/AnimatedGIF), for decoding and drawing GIFs
+- [AnimatedGIF](https://github.com/bitbank2/AnimatedGIF) and [JPEGDEC](https://github.com/bitbank2/JPEGDEC), for decoding GIFs and JPEGs
- project Nayuki's [qr-code-generator](https://github.com/nayuki/QR-Code-generator), for generating QR codes (included in `lib/` directory)
- [i2cdevlib](https://github.com/jrowberg/i2cdevlib), for reading the MPU6050 sensor (included in `lib/` directory)
- [NES.css](https://github.com/nostalgic-css/NES.css), for the NES-styled theme
@@ -40,7 +89,7 @@ This is why the `pathExists()` method exists.
# Usage
-On first boot the esp32 creates a WiFi network with name `esp32-led-matrix` and password `daftpunk` (hardcoded in `src/wifi.h`). Connect to it, turn the frame upside down and scan the QR code to visit the web UI, where the WiFi name and password can be configured (stored in plain JSON in the flash memory). The esp32 will then connect to the provided WiFi and show a different QR code to visit the web UI on that network.
+On first boot the esp32 creates a WiFi network with name `esp32-led-matrix` and password `daftpunk` (hardcoded in `src/wifi.h`). Connect to it, turn the frame upside down and scan the QR code to visit the web UI, where the WiFi SSID and password can be configured (stored in plain JSON in the flash memory). The esp32 will then connect to the provided WiFi and show a different QR code to visit the web UI on that network.
## Display modes
@@ -48,77 +97,24 @@ The LED panel has 4 display modes, depending on the frame orientation:
- ↑ shows image or GIF uploaded through the web UI
- → shows a [Conway's Game of Life](https://en.wikipedia.org/wiki/Conway's_Game_of_Life) game
- ↓ shows the QR code
-- ← shows nothing, TBD what to do
+- ← shows a position on a map
## Web UI
- WiFi settings
- brightness (0-255)
-- upload image or GIF, with preview of the scaled-down version; this file will be stored in the flash memory and loaded on boot, so the size is limited depending on the model of esp32 used (mine has 1.4MB and I'm reserving 0.4MB for the project's static data)
+- upload image or GIF, with preview of the scaled-down version; this file will be stored in the flash memory and loaded on boot, so the size is limited depending on the model of esp32 used (mine has 1.4MB and I'm reserving some for the project's static data)
+- map settings (upload map image, set position, use remote API)
- upload firmware updates (OTA)
- application logs (by default the logs are sent only if the web UI is loaded by at least 1 user, use the compile variable `LOG_QUEUE` to enable a queue of up to 50 messages that will be delivered once a user connects)
-
-# Gallery
-
-Wiring/PIN layout
-
-
-
-Some LEDs have been harmed in the development process. Our thoughts go to all the dead pixels out there~
-
-
-
-
- Font
-
-
-
- Back
-
-
-
-
-
- Inside overview
-
-
-
- GY-521 glued to the inner border of the panel frame
-
-
-
-
-
- ESP32 wires
-
-
-
- Under the LED panel
-
-
-
-
-
- Image rendering
-
-
-
- GIF rendering
-
-
-
-
-
- Rotation
-
-
-
-
+- resource usage and system info
# TODOs
- add FPS and other Life config
-- send current GIF
-- modeselector: prevent random wrong reads, use multiple reads in the span of some seconds
-- a button to delete stored GIF and/or map image to free up space
- music visualizer (needs mic)
+- setting for map remote API pull time (15m, 30m, 1h)
+- map:
+ - zoom (need scaling on esp32)
+ - other position centering methods (center on point, use a margin to move the view before the position reaches the panel border, ...)
+ - store previous positions and show route
diff --git a/data/config.json b/data/config.json
index 69b8cde..efea729 100644
--- a/data/config.json
+++ b/data/config.json
@@ -1 +1 @@
-{"wifi":{"ssid":"","password":""},"panel":{"brightness": 63},"log":{"level":2}}
+{"wifi":{"ssid":"","password":""},"panel":{"brightness":63},"log":{"level":2},"map":{"latitude":"","longitude":"","url":"","method":"GET","regex":"","body":"","headers":"","pointSize":2,"pointColor":63488,"trackColor":31,"positions":[]}}
diff --git a/partitions.csv b/partitions.csv
index 960469b..573ec02 100644
--- a/partitions.csv
+++ b/partitions.csv
@@ -1,7 +1,7 @@
# Name, Type, SubType, Offset, Size, Flags
nvs, data, nvs, 0x9000, 0x5000,
otadata, data, ota, 0xe000, 0x2000,
-app0, app, ota_0, 0x10000, 0x140000,
-app1, app, ota_1, 0x150000,0x140000,
-spiffs, data, spiffs, 0x290000,0x160000,
+app0, app, ota_0, 0x10000, 0x170000,
+app1, app, ota_1, 0x180000,0x170000,
+spiffs, data, spiffs, 0x2F0000,0x100000,
coredump, data, coredump,0x3F0000,0x10000,
diff --git a/platformio.ini b/platformio.ini
index 3d48ab3..081b441 100644
--- a/platformio.ini
+++ b/platformio.ini
@@ -34,9 +34,13 @@ lib_deps =
https://github.com/bblanchon/ArduinoJson#v7.3.0
; Play GIFs
https://github.com/bitbank2/AnimatedGIF.git#2.2.0
+; Decode JPEGs
+ https://github.com/bitbank2/JPEGDEC.git#1.8.3
build_flags =
; esp32-idf library debug level
; -DCORE_DEBUG_LEVEL=3
+; WiFi client debug level
+; -ARDUHAL_LOG_LEVEL=4
; build with slimmed version of Adafruit GFX + FastLED
; -DUSE_GFX_LITE=1
; build without AdafruitGFX; no drawing, text or fonts!
diff --git a/pre_buildfs.py b/pre_buildfs.py
index 72b14e2..c723a7d 100644
--- a/pre_buildfs.py
+++ b/pre_buildfs.py
@@ -25,7 +25,8 @@ def pre_gzip_static(source, target, env):
static_build_path = os.path.join(env.get('PROJECT_DIR'), 'web/dist')
www_dir_path = os.path.join(env.get('PROJECT_DIR'), 'data', 'www')
print('Building JS app in "web" to "web/dist"')
- env.Execute("pnpm -C web release")
+ if env.Execute("pnpm -C web release") > 0:
+ raise ValueError('JS app build failed')
print('Compressing files from "web/dist" to "data/www" directory')
if os.path.exists(www_dir_path):
shutil.rmtree(www_dir_path)
diff --git a/src/decoders/decoder.cpp b/src/decoders/decoder.cpp
new file mode 100644
index 0000000..f969350
--- /dev/null
+++ b/src/decoders/decoder.cpp
@@ -0,0 +1,5 @@
+#include "decoder.h"
+
+bool Decoder::isOpen() {
+ return isOpen_;
+}
diff --git a/src/decoders/decoder.h b/src/decoders/decoder.h
new file mode 100644
index 0000000..862f420
--- /dev/null
+++ b/src/decoders/decoder.h
@@ -0,0 +1,24 @@
+#ifndef _DECODER_H
+#define _DECODER_H
+
+#include "../filesystem.h"
+#include
+#include
+
+class Decoder {
+ protected:
+ bool isOpen_ = false;
+
+ public:
+ Decoder() {};
+ virtual ~Decoder() {};
+ virtual bool isOpen();
+ virtual bool deleteFile() = 0;
+ virtual bool writeFile(uint8_t*, size_t) = 0;
+ virtual bool sendFile(AsyncWebServerRequest*) = 0;
+ virtual bool open() = 0;
+ virtual void close() = 0;
+ virtual void renderFrame(MatrixPanel_I2S_DMA*) = 0;
+};
+
+#endif
diff --git a/src/gif.cpp b/src/decoders/gif.cpp
similarity index 71%
rename from src/gif.cpp
rename to src/decoders/gif.cpp
index 0e1640f..54a5257 100644
--- a/src/gif.cpp
+++ b/src/decoders/gif.cpp
@@ -4,37 +4,60 @@
* Original code taken from https://github.com/mrcodetastic/ESP32-HUB75-MatrixPanel-DMA/blob/fb3499fb66fb4941af8a818eb9be0548fe4b7a60/examples/AnimatedGIFPanel_LittleFS/AnimatedGIFPanel_LittleFS.ino
*/
-GIF::GIF() {
- gif.begin(GIF_PALETTE_RGB565_LE);
+DecoderGIF::DecoderGIF() {};
+
+DecoderGIF::~DecoderGIF() {
+ close();
};
-GIF::~GIF() {
- gif.close();
+bool DecoderGIF::fileExists() {
+ return Filesystem::pathExists(GIF_FILE_PATH);
+};
+
+bool DecoderGIF::deleteFile() {
+ return Filesystem::deleteFile(GIF_FILE_PATH);
+};
+
+bool DecoderGIF::writeFile(uint8_t *data, size_t len) {
+ return Filesystem::writeBytes(GIF_FILE_PATH, data, len);
};
-bool GIF::open(const char *path) {
- if (playing) {
+bool DecoderGIF::sendFile(AsyncWebServerRequest *request) {
+ if (!fileExists()) {
+ return false;
+ }
+
+ Log::instance()->info("Sending current GIF\n");
+ request->send(LittleFS, GIF_FILE_PATH, GIF_FILE_TYPE);
+
+ return true;
+};
+
+bool DecoderGIF::open() {
+ if (isOpen_) {
return true;
}
- playing = true;
+ isOpen_ = true;
+ gif.begin(GIF_PALETTE_RGB565_LE);
- return gif.open(path, openFile, closeFile, readFile, seekFile, drawLine);
+ return gif.open(GIF_FILE_PATH, openFile, closeFile, readFile, seekFile, drawLine);
};
-void GIF::close() {
- if (!playing) {
+void DecoderGIF::close() {
+ if (!isOpen_) {
return;
}
- playing = false;
+ isOpen_ = false;
+ delay(50); // wait for possible renderFrame() execution to finish
gif.close();
frameDelay = 0;
lastGifFrameRender = 0;
};
-void GIF::renderFrame(MatrixPanel_I2S_DMA *display) {
- if (!playing || millis() - lastGifFrameRender < frameDelay) {
+void DecoderGIF::renderFrame(MatrixPanel_I2S_DMA *display) {
+ if (!isOpen_ || millis() - lastGifFrameRender < frameDelay) {
return;
}
@@ -45,12 +68,12 @@ void GIF::renderFrame(MatrixPanel_I2S_DMA *display) {
break;
case -1:
- Log::instance()->error("Error decoding GIF frame\n");
+ Log::instance()->error("Error decoding GIF frame: %d\n", gif.getLastError());
break;
}
};
-void* GIF::openFile(const char *fname, int32_t *pSize) {
+void* DecoderGIF::openFile(const char *fname, int32_t *pSize) {
File *file = new File();
*file = LittleFS.open(fname);
if (file) {
@@ -59,20 +82,17 @@ void* GIF::openFile(const char *fname, int32_t *pSize) {
return static_cast(file);
}
- delete file;
-
return NULL;
};
-void GIF::closeFile(void *pHandle) {
+void DecoderGIF::closeFile(void *pHandle) {
File *f = static_cast(pHandle);
if (f != NULL) {
f->close();
- delete f;
}
};
-int32_t GIF::readFile(GIFFILE *pFile, uint8_t *pBuf, int32_t iLen) {
+int32_t DecoderGIF::readFile(GIFFILE *pFile, uint8_t *pBuf, int32_t iLen) {
int32_t iBytesRead;
iBytesRead = iLen;
File *f = static_cast(pFile->fHandle);
@@ -90,19 +110,16 @@ int32_t GIF::readFile(GIFFILE *pFile, uint8_t *pBuf, int32_t iLen) {
return iBytesRead;
};
-int32_t GIF::seekFile(GIFFILE *pFile, int32_t iPosition) {
- int i = micros();
+int32_t DecoderGIF::seekFile(GIFFILE *pFile, int32_t iPosition) {
File *f = static_cast(pFile->fHandle);
f->seek(iPosition);
pFile->iPos = (int32_t)f->position();
- i = micros() - i;
- // Log::instance()->trace("Seek time = %d us\n", i);
return pFile->iPos;
};
// Draw a line of image directly on the LED Matrix
-void GIF::drawLine(GIFDRAW *pDraw) {
+void DecoderGIF::drawLine(GIFDRAW *pDraw) {
MatrixPanel_I2S_DMA *display = static_cast(pDraw->pUser);
uint8_t *s;
uint16_t *d, *usPalette, usTemp[320];
@@ -117,11 +134,11 @@ void GIF::drawLine(GIFDRAW *pDraw) {
y = pDraw->iY + pDraw->y; // current line
s = pDraw->pPixels;
// restore to background color
- if (pDraw->ucDisposalMethod == 2){
- for (x = 0; x < iWidth; x++)
- {
- if (s[x] == pDraw->ucTransparent)
+ if (pDraw->ucDisposalMethod == 2) {
+ for (x = 0; x < iWidth; x++) {
+ if (s[x] == pDraw->ucTransparent) {
s[x] = pDraw->ucBackground;
+ }
}
pDraw->ucHasTransparency = 0;
}
diff --git a/src/decoders/gif.h b/src/decoders/gif.h
new file mode 100644
index 0000000..7ec8096
--- /dev/null
+++ b/src/decoders/gif.h
@@ -0,0 +1,36 @@
+#ifndef _GIF_H
+#define _GIF_H
+
+#include "../modules/log.h"
+#include "../filesystem.h"
+#include "decoder.h"
+#include
+#include
+
+#define GIF_FILE_PATH "/image.gif"
+#define GIF_FILE_TYPE "image/gif"
+
+class DecoderGIF : public Decoder {
+ private:
+ AnimatedGIF gif;
+ int frameDelay = 0;
+ ulong lastGifFrameRender = 0;
+ static void drawLine(GIFDRAW*);
+ static void* openFile(const char*, int32_t*);
+ static void closeFile(void*);
+ static int32_t readFile(GIFFILE*, uint8_t*, int32_t);
+ static int32_t seekFile(GIFFILE*, int32_t);
+
+ public:
+ DecoderGIF();
+ virtual ~DecoderGIF();
+ static bool fileExists();
+ virtual bool deleteFile();
+ virtual bool writeFile(uint8_t*, size_t);
+ virtual bool sendFile(AsyncWebServerRequest*);
+ virtual bool open();
+ virtual void close();
+ virtual void renderFrame(MatrixPanel_I2S_DMA*);
+};
+
+#endif
diff --git a/src/decoders/jpeg.cpp b/src/decoders/jpeg.cpp
new file mode 100644
index 0000000..413d0df
--- /dev/null
+++ b/src/decoders/jpeg.cpp
@@ -0,0 +1,125 @@
+#include "jpeg.h"
+
+DecoderJPEG::DecoderJPEG() {};
+
+DecoderJPEG::~DecoderJPEG() {
+ close();
+};
+
+bool DecoderJPEG::fileExists() {
+ return Filesystem::pathExists(JPEG_FILE_PATH);
+};
+
+bool DecoderJPEG::deleteFile() {
+ return Filesystem::deleteFile(JPEG_FILE_PATH);
+};
+
+bool DecoderJPEG::writeFile(uint8_t *data, size_t len) {
+ return Filesystem::writeBytes(JPEG_FILE_PATH, data, len);
+};
+
+bool DecoderJPEG::sendFile(AsyncWebServerRequest *request) {
+ if (!fileExists()) {
+ return false;
+ }
+
+ Log::instance()->info("Sending current JPEG\n");
+ request->send(LittleFS, JPEG_FILE_PATH, JPEG_FILE_TYPE);
+
+ return true;
+};
+
+bool DecoderJPEG::open() {
+ if (isOpen_) {
+ return true;
+ }
+
+
+ lastRender = 0;
+ isOpen_ = true;
+
+ return jpeg.open(JPEG_FILE_PATH, openFile, closeFile, readFile, seekFile, draw);
+};
+
+void DecoderJPEG::close() {
+ if (!isOpen_) {
+ return;
+ }
+
+ isOpen_ = false;
+ delay(50); // wait for possible renderFrame() execution to finish
+ jpeg.close();
+ lastRender = 0;
+};
+
+void DecoderJPEG::renderFrame(MatrixPanel_I2S_DMA *display) {
+ // Render once
+ if (!isOpen_ || lastRender > 0) {
+ return;
+ }
+
+ lastRender = millis();
+ jpeg.setUserPointer(static_cast(display));
+ if (!jpeg.decode(0, 0, 0)) {
+ Log::instance()->error("Error decoding JPEG: %d\n", jpeg.getLastError());
+ }
+};
+
+void* DecoderJPEG::openFile(const char *path, int32_t *size) {
+ File *file = new File();
+ *file = LittleFS.open(path);
+ if (file) {
+ *size = file->size();
+
+ return static_cast(file);
+ }
+
+ return NULL;
+};
+
+void DecoderJPEG::closeFile(void *pHandle) {
+ File *f = static_cast(pHandle);
+ if (f != NULL) {
+ f->close();
+ }
+};
+
+int32_t DecoderJPEG::readFile(JPEGFILE *pFile, uint8_t *pBuf, int32_t iLen) {
+ int32_t iBytesRead;
+ iBytesRead = iLen;
+ File *f = static_cast(pFile->fHandle);
+ // Note: If you read a file all the way to the last byte, seek() stops working
+ if ((pFile->iSize - pFile->iPos) < iLen) {
+ iBytesRead = pFile->iSize - pFile->iPos - 1; // <-- ugly work-around
+ }
+ if (iBytesRead <= 0) {
+ return 0;
+ }
+
+ iBytesRead = (int32_t)f->read(pBuf, iBytesRead);
+ pFile->iPos = f->position();
+
+ return iBytesRead;
+};
+
+int32_t DecoderJPEG::seekFile(JPEGFILE *pFile, int32_t iPosition) {
+ File *f = static_cast(pFile->fHandle);
+ f->seek(iPosition);
+ pFile->iPos = (int32_t)f->position();
+
+ return pFile->iPos;
+};
+
+int DecoderJPEG::draw(JPEGDRAW *pDraw) {
+ MatrixPanel_I2S_DMA *display = static_cast(pDraw->pUser);
+ uint16_t *s = pDraw->pPixels;
+ int lastX = pDraw->x + pDraw->iWidth;
+ int lastY = pDraw->y + pDraw->iHeight;
+ for (int y = pDraw->y; y < lastY; y++) {
+ for (int x = pDraw->x; x < lastX; x++) {
+ display->drawPixel(x, y, *s++);
+ }
+ }
+
+ return 1;
+};
diff --git a/src/decoders/jpeg.h b/src/decoders/jpeg.h
new file mode 100644
index 0000000..b910bc4
--- /dev/null
+++ b/src/decoders/jpeg.h
@@ -0,0 +1,35 @@
+#ifndef _JPEG_H
+#define _JPEG_H
+
+#include "../modules/log.h"
+#include "../filesystem.h"
+#include "decoder.h"
+#include
+#include
+
+#define JPEG_FILE_PATH "/image.jpeg"
+#define JPEG_FILE_TYPE "image/jpeg"
+
+class DecoderJPEG : public Decoder {
+ private:
+ JPEGDEC jpeg;
+ ulong lastRender = 0;
+ static int draw(JPEGDRAW*);
+ static void* openFile(const char*, int32_t*);
+ static void closeFile(void*);
+ static int32_t readFile(JPEGFILE*, uint8_t*, int32_t);
+ static int32_t seekFile(JPEGFILE*, int32_t);
+
+ public:
+ DecoderJPEG();
+ virtual ~DecoderJPEG();
+ static bool fileExists();
+ virtual bool deleteFile();
+ virtual bool writeFile(uint8_t*, size_t);
+ virtual bool sendFile(AsyncWebServerRequest*);
+ virtual bool open();
+ virtual void close();
+ virtual void renderFrame(MatrixPanel_I2S_DMA*);
+};
+
+#endif
diff --git a/src/filesystem.cpp b/src/filesystem.cpp
index cf34f9a..644ba12 100644
--- a/src/filesystem.cpp
+++ b/src/filesystem.cpp
@@ -1,24 +1,58 @@
#include "filesystem.h"
+void Filesystem::tree(const char *path, uint8_t depth) {
+ File root = LittleFS.open(path);
+
+ if (!root) {
+ Log::instance()->error("error opening path: %s\n", path);
+
+ return;
+ }
+
+ if (!root.isDirectory()) {
+ Log::instance()->error("path is not a directory: %s\n", path);
+ root.close();
+
+ return;
+ }
+
+ Log::instance()->info("%s + %s\n", std::string(depth > 0 ? depth - 1 : depth, ' ').c_str(), root.name());
+ File file = root.openNextFile();
+ while (file) {
+ if (file.isDirectory()) {
+ Filesystem::tree(file.path(), depth + 1);
+ } else {
+ Log::instance()->info("%s |- %s\n", std::string(depth, ' ').c_str(), file.name());
+ }
+
+ file.close();
+ file = root.openNextFile();
+ }
+
+ root.close();
+};
+
// see readme "Note on PlatformIO"
-bool pathExists(const char *path) {
+bool Filesystem::pathExists(const char *path) {
static struct stat pathStat;
+ char fullPath[64];
+ snprintf(fullPath, sizeof(fullPath), "/littlefs%s", path);
- return stat((String("/littlefs")+String(path)).c_str(), &pathStat) == 0;
+ return stat(fullPath, &pathStat) == 0;
};
-bool deleteFile(const char *path) {
- if (!pathExists(path)) {
+bool Filesystem::deleteFile(const char *path) {
+ if (!Filesystem::pathExists(path)) {
return true;
}
return LittleFS.remove(path);
};
-bool writeBytes(const char *path, uint8_t *buf, size_t size) {
+bool Filesystem::writeBytes(const char *path, uint8_t *buf, size_t size) {
File file = LittleFS.open(path, FILE_APPEND);
if (!file) {
- Log::instance()->error("Error opening file: %s\n", path);
+ Log::instance()->error("Error opening file for write: %s\n", path);
return false;
}
@@ -37,15 +71,14 @@ bool writeBytes(const char *path, uint8_t *buf, size_t size) {
return true;
};
-bool readBytes(const char *path, uint8_t *buf, size_t size) {
- if (!pathExists(path)) {
+bool Filesystem::readBytes(const char *path, uint8_t *buf, size_t size) {
+ if (!Filesystem::pathExists(path)) {
return false;
}
-
File file = LittleFS.open(path);
if (!file) {
- Log::instance()->error("Error opening file: %s\n", path);
+ Log::instance()->error("Error opening file for read: %s\n", path);
return false;
}
@@ -64,10 +97,10 @@ bool readBytes(const char *path, uint8_t *buf, size_t size) {
return true;
};
-bool writeConfig(const char *path, JsonDocument cfg) {
+bool Filesystem::writeConfig(const char *path, JsonDocument& cfg) {
File file = LittleFS.open(path, FILE_WRITE);
if (!file) {
- Log::instance()->error("Error opening file: %s\n", path);
+ Log::instance()->error("Error opening config for write: %s\n", path);
return false;
}
@@ -84,9 +117,9 @@ bool writeConfig(const char *path, JsonDocument cfg) {
return true;
};
-JsonDocument readConfig(const char *path) {
+JsonDocument Filesystem::readConfig(const char *path) {
JsonDocument cfg;
- if (!pathExists(path)) {
+ if (!Filesystem::pathExists(path)) {
Log::instance()->error("Config file does not exist: %s\n", path);
return cfg;
@@ -94,7 +127,7 @@ JsonDocument readConfig(const char *path) {
File file = LittleFS.open(path);
if (!file) {
- Log::instance()->error("Error opening file: %s\n", path);
+ Log::instance()->error("Error opening config for read: %s\n", path);
return cfg;
}
diff --git a/src/filesystem.h b/src/filesystem.h
index 6548b7b..723de97 100644
--- a/src/filesystem.h
+++ b/src/filesystem.h
@@ -1,18 +1,21 @@
#ifndef _FILESYSTEM_H
#define _FILESYSTEM_H
-#include "log.h"
+#include "modules/log.h"
#include
-// #include
#include
-#define MAX_GIF_SIZE 1048576 // 1 MiB
+#define MAX_FILE_SIZE 1048576 // 1 MiB
-bool pathExists(const char*);
-bool writeBytes(const char*, uint8_t*, size_t);
-bool readBytes(const char*, uint8_t*, size_t);
-bool deleteFile(const char*);
-bool writeConfig(const char*, JsonDocument);
-JsonDocument readConfig(const char*);
+class Filesystem {
+ public:
+ static void tree(const char*, uint8_t);
+ static bool pathExists(const char*);
+ static bool deleteFile(const char*);
+ static bool writeBytes(const char*, uint8_t*, size_t);
+ static bool readBytes(const char*, uint8_t*, size_t);
+ static bool writeConfig(const char*, JsonDocument&);
+ static JsonDocument readConfig(const char*);
+};
#endif
diff --git a/src/gif.h b/src/gif.h
deleted file mode 100644
index f804ad1..0000000
--- a/src/gif.h
+++ /dev/null
@@ -1,29 +0,0 @@
-#ifndef _GIF_H
-#define _GIF_H
-
-#include "log.h"
-#include "filesystem.h"
-#include
-#include
-
-class GIF {
- private:
- AnimatedGIF gif;
- int frameDelay = 0;
- ulong lastGifFrameRender = 0;
- bool playing = false;
- static void drawLine(GIFDRAW*);
- static void* openFile(const char*, int32_t*);
- static void closeFile(void*);
- static int32_t readFile(GIFFILE*, uint8_t*, int32_t);
- static int32_t seekFile(GIFFILE*, int32_t);
-
- public:
- GIF();
- ~GIF();
- bool open(const char*);
- void renderFrame(MatrixPanel_I2S_DMA*);
- void close();
-};
-
-#endif
diff --git a/src/image.cpp b/src/image.cpp
deleted file mode 100644
index cf8fd8e..0000000
--- a/src/image.cpp
+++ /dev/null
@@ -1,260 +0,0 @@
-#include "image.h"
-
-Image::Image(uint8_t w, uint8_t h) {
- width = w;
- height = h;
- size = width * height * sizeof(uint8_t) * 3;
- image = (uint8_t*)malloc(size);
- gif = new GIF();
-};
-
-Image::~Image() {
- free(image);
- delete image;
- gif->close();
-};
-
-void Image::print() {
- Log::instance()->trace("Image bytes:\n");
- for (uint8_t y = 0; y < height; y++) {
- for (uint8_t x = 0; x < width; x++) {
- Log::instance()->trace("%02X%02X%02X ", image[x + y * width], image[1 + (x + y * width)], image[2 + (x + y * width)]);
- }
-
- Log::instance()->trace("\n");
- }
-};
-
-bool Image::processImageChunk(AsyncWebServerRequest *request, uint8_t *data, size_t len, size_t index, size_t total) {
- if (index == 0) {
- hasMedia = MEDIA_TYPE_NONE;
- Log::instance()->debug("Receiving image: %d bytes\n", total);
- if (total > size) {
- Log::instance()->error("Image is bigger than max size: %d bytes\n", size);
- request->send(400, "text/plain", "image is too big");
-
- return false;
- }
- if (total > LittleFS.totalBytes() - LittleFS.usedBytes()) {
- Log::instance()->error("Image size %d is within limit, but disk has not enough free space (%d)\n", total, LittleFS.totalBytes() - LittleFS.usedBytes());
- request->send(500, "text/plain", "not enough free space");
-
- return false;
- }
-
- gif->close();
- if (!deleteFile(GIF_PATH)) {
- Log::instance()->warning("Error deleting previous GIF\n");
- request->send(500, "text/plain", "error deleting previous GIF");
-
- return false;
- }
- if (!deleteFile(IMAGE_PATH)) {
- Log::instance()->warning("Error deleting previous image\n");
- request->send(500, "text/plain", "error deleting previous image");
-
- return false;
- }
-
- Log::instance()->debug("Previous image and GIF deleted or not present\n");
- }
- if (index + len > size) {
- Log::instance()->error("Data chunk exceeds image size boundaries: chunk start %d, end %d\n", index, index + len);
- request->send(400, "text/plain", "chunk exceeds image size boundaries");
-
- return false;
- }
-
- Log::instance()->trace("Received image chunk: %d-%d (%d bytes)\n", index, index + len, len);
- memcpy(&image[index], data, len);
- if (!writeBytes(IMAGE_PATH, data, len)) {
- Log::instance()->error("Error writing image chunk\n");
- request->send(500, "text/plain", "error writing image");
-
- return false;
- }
-
- return true;
-};
-
-bool Image::processGIFChunk(AsyncWebServerRequest *request, uint8_t *data, size_t len, size_t index, size_t total) {
- if (index == 0) {
- hasMedia = MEDIA_TYPE_NONE;
- Log::instance()->debug("Receiving GIF: %d bytes\n", total);
- if (total > MAX_GIF_SIZE) {
- Log::instance()->debug("GIF size %d exceeds maximum size %d\n", total, MAX_GIF_SIZE);
- request->send(400, "text/plain", "GIF too big");
-
- return false;
- }
- if (total > LittleFS.totalBytes() - LittleFS.usedBytes()) {
- Log::instance()->error("GIF size %d is within limit, but disk has not enough free space (%d)\n", total, LittleFS.totalBytes() - LittleFS.usedBytes());
- request->send(500, "text/plain", "not enough free space");
-
- return false;
- }
-
- gif->close();
- if (!deleteFile(GIF_PATH)) {
- Log::instance()->error("Error deleting previous GIF\n");
- request->send(500, "text/plain", "error deleting previous GIF");
-
- return false;
- }
- if (!deleteFile(IMAGE_PATH)) {
- Log::instance()->error("Error deleting previous image\n");
- request->send(500, "text/plain", "error deleting previous image");
-
- return false;
- }
-
- Log::instance()->debug("Previous image and GIF deleted or not present\n");
- }
-
- Log::instance()->trace("Received GIF chunk: %d-%d (%d bytes)\n", index, index + len, len);
- if (!writeBytes(GIF_PATH, data, len)) {
- Log::instance()->error("Error writing GIF chunk\n");
- request->send(500, "text/plain", "error writing GIF");
-
- return false;
- }
-
- return true;
-};
-
-void Image::initServer(AsyncWebServer* server) {
- server->on("/drawer", HTTP_GET, [&](AsyncWebServerRequest *request) {
- if (hasMedia == MEDIA_TYPE_NONE) {
- Log::instance()->info("No image currently stored\n");
- request->send(204);
-
- return;
- }
- if (hasMedia == MEDIA_TYPE_IMAGE) {
- Log::instance()->info("Sending current image\n");
- AsyncResponseStream *response = request->beginResponseStream("application/octet-stream");
- response->addHeader("Content-Length", size);
- response->write(image, size);
- request->send(response);
-
- return;
- }
- if (hasMedia == MEDIA_TYPE_GIF) {
- request->send(204);
-
- return;
- }
- });
- server->on("/drawer", HTTP_POST,
- [&](AsyncWebServerRequest *request) {
- Log::instance()->info("Media received\n");
- const AsyncWebHeader *contentType = request->getHeader("Content-Type");
- if (contentType == nullptr) {
- Log::instance()->error("File upload has no content-type header");
- request->send(400, "text/plain", "no content-type header");
-
- return;
- }
- if (contentType->value() == "application/octet-stream") {
- drawNewImage = true;
- hasMedia = MEDIA_TYPE_IMAGE;
- Log::instance()->info("Image stored\n");
- }
- if (contentType->value() == "image/gif") {
- Log::instance()->info("GIF stored\n");
- hasMedia = MEDIA_TYPE_GIF;
- }
-
- request->send(204);
- },
- nullptr,
- [&](AsyncWebServerRequest *request, uint8_t *data, size_t len, size_t index, size_t total) {
- const AsyncWebHeader *contentType = request->getHeader("Content-Type");
- if (contentType == nullptr) {
- Log::instance()->error("File upload has no content-type header");
- request->send(400, "text/plain", "no content-type header");
-
- return;
- }
- if (contentType->value() == "application/octet-stream") {
- processImageChunk(request, data, len, index, total);
-
- return;
- }
- if (contentType->value() == "image/gif") {
- processGIFChunk(request, data, len, index, total);
-
- return;
- }
-
- request->send(400, "text/plain", "unknown content type");
- }
- );
-};
-
-void Image::loadMedia() {
- if (readBytes(IMAGE_PATH, image, width * height * 3)) {
- Log::instance()->info("Loaded image from FLASH\n");
- hasMedia = MEDIA_TYPE_IMAGE;
- drawNewImage = true;
-
- return;
- }
- if (pathExists(GIF_PATH)) {
- Log::instance()->info("Using GIF from FLASH\n");
- hasMedia = MEDIA_TYPE_GIF;
- if (!gif->open(GIF_PATH)) {
- Log::instance()->error("Error opening GIF from FLASH\n");
- hasMedia = MEDIA_TYPE_NONE;
- }
-
- return;
- }
-
- Log::instance()->info("No media stored in FLASH\n");
-};
-
-void Image::render(MatrixPanel_I2S_DMA *display) {
- if (hasMedia == MEDIA_TYPE_NONE) {
- return;
- }
- // GIF handles timing internally
- if (hasMedia == MEDIA_TYPE_GIF) {
- // Call to open is here because trying to open in webserver endpoint (right after finished writing) causes a panic and reboot
- if (!gif->open(GIF_PATH)) {
- Log::instance()->error("Error opening GIF from FLASH\n");
- hasMedia = MEDIA_TYPE_NONE;
-
- return;
- }
-
- gif->renderFrame(display);
-
- return;
- }
- // Render image once a second
- if (millis() - lastRender < 1000) {
- return;
- }
- // Clear screen only if a new image was uploaded.
- if (drawNewImage) {
- display->clearScreen();
- drawNewImage = false;
- }
-
- // Keep redrawing whether the images has changed or not so that it gets redrawn when the picture frame is rotated.
- lastRender = millis();
- uint16_t index;
- for (uint8_t x = 0; x < width; x++) {
- for (uint8_t y = 0; y < height; y++) {
- index = 3 * (x + y * width);
- display->drawPixelRGB888(
- x,
- y,
- image[index],
- image[1 + index],
- image[2 + index]
- );
- }
- }
-};
diff --git a/src/image.h b/src/image.h
deleted file mode 100644
index 587a693..0000000
--- a/src/image.h
+++ /dev/null
@@ -1,38 +0,0 @@
-#ifndef _IMAGE_H
-#define _IMAGE_H
-
-#include "log.h"
-#include "renderer.h"
-#include
-#include "filesystem.h"
-#include "gif.h"
-#include
-
-#define IMAGE_PATH "/image.bin"
-#define GIF_PATH "/media.gif"
-
-class Image : public Renderer {
- private:
- typedef enum {
- MEDIA_TYPE_NONE = 0,
- MEDIA_TYPE_IMAGE = 1,
- MEDIA_TYPE_GIF = 2,
- } media_type;
- uint8_t *image;
- bool drawNewImage = false;
- ulong lastRender = 0;
- GIF *gif;
- media_type hasMedia = MEDIA_TYPE_NONE;
- bool processImageChunk(AsyncWebServerRequest*, uint8_t*, size_t, size_t, size_t);
- bool processGIFChunk(AsyncWebServerRequest*, uint8_t*, size_t, size_t, size_t);
-
- public:
- Image(uint8_t, uint8_t);
- virtual ~Image();
- virtual void initServer(AsyncWebServer*);
- void loadMedia();
- virtual void render(MatrixPanel_I2S_DMA*);
- virtual void print();
-};
-
-#endif
diff --git a/src/main.cpp b/src/main.cpp
index 6eb5ee0..d4ea30a 100644
--- a/src/main.cpp
+++ b/src/main.cpp
@@ -20,37 +20,74 @@ OTA ota(1000);
draw_mode drawMode = DRAW_MODE_NONE;
WiFiController wifi;
Life life(WIDTH, HEIGHT);
-Image image(WIDTH, HEIGHT);
+Media media(WIDTH, HEIGHT);
+Map mapp(WIDTH, HEIGHT);
QRCode qr(WIDTH, HEIGHT);
AsyncWebServer server(80);
ModeSelector selector;
Config conf(updateConfig);
+Renderer *currentMode = nullptr;
void updateQrText() {
- String url = wifi.getUrl();
- Log::instance()->info("QR code URL: %s\n", url.c_str());
- if (url != "" && !qr.setText(url)) {
+ const char* url = wifi.getUrl();
+ Log::instance()->info("QR code URL: %s\n", url);
+ if (url[0] != '\0' && !qr.setText(url)) {
Log::instance()->error("Error encoding IP to QR code\n");
}
};
-void changeMode(draw_mode newMode) {
+bool changeMode(draw_mode newMode) {
if (newMode == drawMode) {
- return;
+ return false;
+ }
+
+ if (currentMode != nullptr) {
+ currentMode->close();
+ }
+
+ switch (newMode) {
+ case DRAW_MODE_LIFE:
+ currentMode = &life;
+ break;
+ case DRAW_MODE_QR:
+ currentMode = &qr;
+ break;
+ case DRAW_MODE_IMAGE:
+ currentMode = &media;
+ break;
+ case DRAW_MODE_MAP:
+ currentMode = &mapp;
+ break;
+
+ default:
+ currentMode = nullptr;
+ break;
+ }
+
+ if (currentMode != nullptr) {
+ currentMode->open();
}
drawMode = newMode;
Log::instance()->info("Changed mode to %d\n", drawMode);
display->clearScreen();
+
if (newMode == DRAW_MODE_QR) {
updateQrText();
}
+
+ return true;
};
-void updateConfig(JsonDocument newConfig) {
- if (newConfig["wifi"]["ssid"].as() != conf.current["wifi"]["ssid"].as() || newConfig["wifi"]["password"].as() != conf.current["wifi"]["password"].as()) {
+void updateConfig(JsonDocument& newConfig) {
+ const char* newSsid = newConfig["wifi"]["ssid"].as();
+ const char* newPassword = newConfig["wifi"]["password"].as();
+ const char* currentSsid = conf.current["wifi"]["ssid"].as();
+ const char* currentPassword = conf.current["wifi"]["password"].as();
+
+ if (strcmp(newSsid, currentSsid) != 0 || strcmp(newPassword, currentPassword) != 0) {
Log::instance()->info("WiFi configuration changed, reconnecting\n");
- wifi.connect(newConfig["wifi"]["ssid"].as(), newConfig["wifi"]["password"].as());
+ wifi.connect(newSsid, newPassword);
}
if (newConfig["panel"]["brightness"].as() != conf.current["panel"]["brightness"].as()) {
Log::instance()->info("Panel brightness changed: %d\n", newConfig["panel"]["brightness"].as());
@@ -58,8 +95,10 @@ void updateConfig(JsonDocument newConfig) {
}
if (newConfig["log"]["level"].as() != conf.current["log"]["level"].as()) {
Log::instance()->setLevel(newConfig["log"]["level"].as());
- Log::instance()->info("Log level changed: %s\n", Log::instance()->stringLevel());
+ Log::instance()->info("Log level changed: %s\n", Log::instance()->stringLevel().c_str());
}
+
+ mapp.setConfig(newConfig["map"].as());
};
void setup() {
@@ -91,7 +130,7 @@ void setup() {
}
// Initialize WiFi
- if (!wifi.connect(conf.current["wifi"]["ssid"].as(), conf.current["wifi"]["password"].as())) {
+ if (!wifi.connect(conf.current["wifi"]["ssid"].as(), conf.current["wifi"]["password"].as())) {
return;
}
updateQrText();
@@ -103,8 +142,9 @@ void setup() {
ota.initServer(&server);
// Initialize modules
- image.initServer(&server);
- image.loadMedia();
+ media.initServer(&server);
+ mapp.initServer(&server);
+ mapp.setConfig(conf.current["map"].as());
life.setFPS(FPS);
conf.initServer(&server);
@@ -138,6 +178,7 @@ void setup() {
server.serveStatic("/", LittleFS, "/www/").setDefaultFile("index.html");
server.begin();
+ // Filesystem::tree("/", 0);
};
ulong lastModePoll = 0;
@@ -147,19 +188,12 @@ void loop() {
if (lastModePoll + 1000 < millis()) {
lastModePoll = millis();
- changeMode(selector.getMode());
+ if (changeMode(selector.getMode())) {
+ return;
+ }
}
- switch (drawMode) {
- case DRAW_MODE_LIFE:
- life.render(display);
- break;
-
- case DRAW_MODE_IMAGE:
- image.render(display);
- break;
- case DRAW_MODE_QR:
- qr.render(display);
- break;
+ if (currentMode != nullptr) {
+ currentMode->render(display);
}
};
diff --git a/src/main.h b/src/main.h
index 8cfc2e6..77e2fbc 100644
--- a/src/main.h
+++ b/src/main.h
@@ -2,15 +2,16 @@
#define _MAIN_H
#include
-#include "log.h"
-#include "config.h"
-#include "wifi.h"
-#include "led.h"
-#include "modeselector.h"
-#include "image.h"
-#include "life.h"
-#include "qrcode.h"
-#include "ota.h"
+#include "modules/log.h"
+#include "modules/config.h"
+#include "modules/wifi.h"
+#include "modules/led.h"
+#include "modules/modeselector.h"
+#include "modes/media.h"
+#include "modes/map.h"
+#include "modes/life.h"
+#include "modes/qrcode.h"
+#include "modules/ota.h"
#include
// Pins
@@ -41,6 +42,6 @@
// Config
#define FPS 15
-void updateConfig(JsonDocument);
+void updateConfig(JsonDocument& newConfig);
#endif
diff --git a/src/life.cpp b/src/modes/life.cpp
similarity index 86%
rename from src/life.cpp
rename to src/modes/life.cpp
index 02ca622..c39cd5e 100644
--- a/src/life.cpp
+++ b/src/modes/life.cpp
@@ -3,14 +3,37 @@
Life::Life(uint8_t w, uint8_t h) {
width = w;
height = h;
- size = width * height * sizeof(Cell);
- world = (Cell*)malloc(size);
};
Life::~Life() {
- free(world);
+ close();
};
+bool Life::open() {
+ if (isOpen) {
+ return true;
+ }
+
+ world = (Cell*)malloc(width * height * sizeof(Cell));
+ generation = 0;
+ isOpen = true;
+
+ return true;
+}
+
+bool Life::close() {
+ if (!isOpen) {
+ return true;
+ }
+
+ isOpen = false;
+ delay(50); // wait for possible render() execution to finish
+ free(world);
+ generation = 0;
+
+ return true;
+}
+
void Life::setFPS(uint8_t f) {
fps = f;
};
@@ -49,12 +72,13 @@ void Life::print() {
void Life::initServer(AsyncWebServer* server) {};
void Life::render(MatrixPanel_I2S_DMA* display) {
+ if (!isOpen || millis() - lastRender < 1000 / fps) {
+ return;
+ }
+
if (generation == 0) {
populate();
}
- if (millis() - lastRender < 1000 / fps) {
- return;
- }
lastRender = millis();
diff --git a/src/life.h b/src/modes/life.h
similarity index 87%
rename from src/life.h
rename to src/modes/life.h
index 3af1591..079494a 100644
--- a/src/life.h
+++ b/src/modes/life.h
@@ -1,7 +1,7 @@
#ifndef _LIFE_H
#define _LIFE_H
-#include "log.h"
+#include "../modules/log.h"
#include "renderer.h"
#include
#include
@@ -22,6 +22,7 @@ class Life : public Renderer {
ulong lastRender = 0;
CHSV hsv = CHSV(0, 255, 255);
CRGB rgb = CRGB(0, 0, 0);
+ bool isOpen = false;
virtual void populate();
virtual uint8_t neighbors(uint8_t, uint8_t);
@@ -33,6 +34,8 @@ class Life : public Renderer {
virtual void initServer(AsyncWebServer*);
virtual void render(MatrixPanel_I2S_DMA*);
virtual void print();
+ virtual bool open();
+ virtual bool close();
};
#endif
diff --git a/src/modes/map.cpp b/src/modes/map.cpp
new file mode 100644
index 0000000..2518d1c
--- /dev/null
+++ b/src/modes/map.cpp
@@ -0,0 +1,445 @@
+#include "map.h"
+
+Map::Map(uint8_t w, uint8_t h) {
+ width = w;
+ height = h;
+};
+
+Map::~Map() {
+ close();
+};
+
+bool Map::open() {
+ if (isOpen) {
+ return true;
+ }
+
+ if (!Filesystem::pathExists(MAP_PATH)) {
+ Log::instance()->trace("No map stored\n");
+
+ return false;
+ }
+
+ if (!jpeg.open(MAP_PATH, openFile, closeFile, readFile, seekFile, draw)) {
+ Log::instance()->error("Error opening map\n");
+
+ return false;
+ }
+
+ lastRender = 0;
+ isOpen = true;
+ Log::instance()->info("Loaded map\n");
+
+ return true;
+};
+
+bool Map::close() {
+ if (!isOpen) {
+ return true;
+ }
+
+ isOpen = false;
+ delay(50); // wait for possible renderFrame() execution to finish
+ jpeg.close();
+ lastRender = 0;
+
+ return true;
+};
+
+void Map::print() {};
+
+void Map::setConfig(JsonVariantConst c) {
+ config.clear();
+ config.set(c);
+ forceCropUpdate = true;
+ Log::instance()->info("Updated map configuration\n");
+};
+
+void Map::initServer(AsyncWebServer *server) {
+ server->on("/map", HTTP_GET, [&](AsyncWebServerRequest *request) {
+ if (!Filesystem::pathExists(MAP_PATH)) {
+ Log::instance()->info("No map currently stored\n");
+ request->send(204);
+
+ return;
+ }
+
+ Log::instance()->info("Sending current map\n");
+ request->send(LittleFS, MAP_PATH, "image/jpeg");
+ });
+ server->on("/map", HTTP_DELETE, [&](AsyncWebServerRequest *request) {
+ close();
+ Log::instance()->info("Deleting map\n");
+ if (!Filesystem::deleteFile(MAP_PATH)) {
+ Log::instance()->error("Error deleting map\n");
+ request->send(500, "text/plain", "error deleting map");
+
+ return;
+ }
+
+ request->send(204);
+ });
+ server->on("/map", HTTP_POST,
+ [&](AsyncWebServerRequest *request) {
+ Log::instance()->info("Map received\n");
+ const AsyncWebHeader *contentType = request->getHeader("Content-Type");
+ if (contentType->value() != "image/jpeg") {
+ Log::instance()->error("Uploaded map has unhandled content-type: %s\n", contentType->value());
+ request->send(400, "text/plain", "unhandled content type");
+
+ return;
+ }
+
+ isUpdating = false;
+ forceCropUpdate = true;
+ request->send(204);
+ },
+ nullptr,
+ [&](AsyncWebServerRequest *request, uint8_t *data, size_t len, size_t index, size_t total) {
+ const AsyncWebHeader *contentType = request->getHeader("Content-Type");
+ if (contentType->value() != "image/jpeg") {
+ Log::instance()->error("Uploaded map has unhandled content-type: %s\n", contentType->value());
+ request->send(400, "text/plain", "unhandled content type");
+
+ return;
+ }
+ if (index == 0) {
+ Log::instance()->debug("Receiving map: %d bytes\n", total);
+ if (total > MAX_FILE_SIZE) {
+ Log::instance()->error("Map size %d exceeds maximum size %d\n", total, MAX_FILE_SIZE);
+ request->send(400, "text/plain", "map is too big");
+
+ return;
+ }
+ if (total > LittleFS.totalBytes() - LittleFS.usedBytes()) {
+ Log::instance()->error("Map size %d is within limit, but disk has not enough free space (%d)\n", total, LittleFS.totalBytes() - LittleFS.usedBytes());
+ request->send(500, "text/plain", "not enough free space");
+
+ return;
+ }
+
+ isUpdating = true;
+ close();
+ if (!Filesystem::deleteFile(MAP_PATH)) {
+ Log::instance()->warning("Error deleting previous map\n");
+ request->send(500, "text/plain", "error deleting previous map");
+
+ return;
+ }
+
+ Log::instance()->debug("Previous map deleted or not present\n");
+ }
+
+ Log::instance()->trace("Received map chunk: %d-%d (%d bytes)\n", index, index + len, len);
+ if (!Filesystem::writeBytes(MAP_PATH, data, len)) {
+ Log::instance()->error("Error writing map chunk\n");
+ request->send(500, "text/plain", "error writing map");
+
+ return;
+ }
+ }
+ );
+};
+
+bool Map::setHeaders(HTTPClient &client, const char *headers) {
+ JsonDocument h;
+ DeserializationError err = deserializeJson(h, headers);
+ if (err) {
+ Log::instance()->error("Error deserializing headers: %s\n", err.c_str());
+
+ return false;
+ }
+ if (!h.is()) {
+ Log::instance()->error("Header is not a JSON object\n");
+
+ return false;
+ }
+
+ for (JsonPairConst kvp : h.as()) {
+ const char* key = kvp.key().c_str();
+ std::string value = kvp.value().as();
+ if (strcasecmp(key, "Authorization") == 0) {
+ size_t i = value.find(' ');
+ if (i == std::string::npos) {
+ Log::instance()->error("Missing header authorization type or value\n");
+
+ return false;
+ }
+
+ client.setAuthorizationType(value.substr(0, i).c_str());
+ client.setAuthorization(value.substr(i + 1).c_str());
+ continue;
+ }
+ if (strcasecmp(key, "User-Agent") == 0) {
+ client.setUserAgent(value.c_str());
+ continue;
+ }
+ if (strcasecmp(key, "Connection") == 0) {
+ client.setReuse(strcmp(value.c_str(), "close") != 0);
+ continue;
+ }
+ if (strcasecmp(key, "Host") == 0) {
+ continue;
+ }
+
+ client.addHeader(key, value.c_str());
+ }
+
+ return true;
+};
+
+std::array Map::matchRegex(const char* res, const char *r) {
+ std::array pos = { 0, 0 };
+ std::regex regex(r);
+ std::smatch matches;
+ std::string str = std::string(res);
+ if (!std::regex_search(str, matches, regex)) {
+ Log::instance()->error("Regex %s did not match position API response:\n%s\n\n", r, str.c_str());
+
+ return pos;
+ }
+ if (matches.size() != 3) {
+ Log::instance()->error("Regex %s found %d matches in position API response, need 2:\n%s\n\n", r, matches.size() - 1, str.c_str());
+
+ return pos;
+ }
+
+ pos[0] = strtod(matches.str(1).c_str(), NULL);
+ pos[1] = strtod(matches.str(2).c_str(), NULL);
+
+ return pos;
+};
+
+std::array Map::getPositionFromAPI() {
+ std::array pos = { 0, 0 };
+ WiFiClientSecure client;
+ client.setInsecure(); // TODO: load a CA certificates bundle
+ HTTPClient http;
+ http.setReuse(false);
+ http.setConnectTimeout(10000);
+ http.setTimeout(60000);
+ const char *u = config["url"].as();
+ const char *m = config["method"].as();
+ const char *r = config["regex"].as();
+ const char *b = config["body"].as();
+
+ if (u == nullptr || strcmp(u, "") == 0) {
+ Log::instance()->error("Position API URL is not set\n");
+
+ return pos;
+ }
+ if (r == nullptr || strcmp(r, "") == 0) {
+ Log::instance()->error("Position API regexp is not set\n");
+
+ return pos;
+ }
+ if (m == nullptr || strcmp(m, "") == 0) {
+ m = "GET";
+ }
+
+ // Apply query params for GET requests
+ if (strcasecmp(m, "GET") == 0 && b != nullptr && strcmp(b, "") != 0) {
+ char urlBuffer[128];
+
+ // Need a "/" after hostname, otherwise the query parameters end up in the DNS query
+ size_t urlLen = strlen(u);
+ if (u[urlLen - 1] == '/') {
+ snprintf(urlBuffer, sizeof(urlBuffer), "%s%s", u, b);
+ } else {
+ snprintf(urlBuffer, sizeof(urlBuffer), "%s/%s", u, b);
+ }
+
+ u = urlBuffer;
+ }
+
+ http.begin(client, u);
+
+ // Apply headers
+ const char *hs = config["headers"].as();
+ if (hs != nullptr && strcmp(hs, "") != 0 && !setHeaders(http, hs)) {
+ return pos;
+ }
+
+ // Send request
+ int code;
+ if (strcasecmp(m, "POST") == 0) {
+ code = http.POST((b != nullptr && strcmp(b, "") != 0) ? b : "");
+ } else {
+ code = http.GET();
+ }
+ if (code < 0) {
+ Log::instance()->error("Error in API request: %d %s\n", code, http.errorToString(code).c_str());
+
+ return pos;
+ }
+
+ // Match regex
+ pos = matchRegex(http.getString().c_str(), r);
+
+ return pos;
+};
+
+std::array Map::getPosition() {
+ std::array pos = { 0, 0 };
+ if (
+ strcmp(config["url"].as(), "") != 0
+ && strcmp(config["method"].as(), "") != 0
+ && strcmp(config["regex"].as(), "") != 0
+ ) {
+ pos = getPositionFromAPI();
+ Log::instance()->info("Position from API (%s): %f, %f\n", config["url"].as(), pos[0], pos[1]);
+ }
+ // default to static position, if available
+ if (
+ pos[0] == 0 && pos[1] == 0
+ && strcmp(config["latitude"].as(), "") != 0
+ && strcmp(config["longitude"].as(), "") != 0
+ ) {
+ pos[0] = strtod(config["latitude"].as(), NULL);
+ pos[1] = strtod(config["longitude"].as(), NULL);
+ Log::instance()->info("Position from configuration: %f, %f\n", pos[0], pos[1]);
+ }
+
+ return pos;
+};
+
+void Map::asyncUpdateCrop(void *p) {
+ Map *m = (Map*) p;
+ Log::instance()->debug("Updating crop asynchronously\n");
+ m->updateCrop();
+ m->lastCropUpdate = millis();
+ m->forceCropUpdate = false;
+ // Close to re-render in next render() call
+ m->close();
+ m->isUpdating = false;
+ vTaskDelete(NULL);
+};
+
+void Map::updateCrop() {
+ if (!isOpen) {
+ return;
+ }
+
+ Log::instance()->info("Updating map crop area and position\n");
+ // Crop area
+ int imgW = jpeg.getWidth();
+ int imgH = jpeg.getHeight();
+ std::array position = getPosition();
+ double x = ((position[1] + 180.0) / 360.0) * imgW;
+ double y = ((90.0 - position[0]) / 180.0) * imgH;
+ int col = static_cast(x / width);
+ int row = static_cast(y / height);
+ cropArea[0] = width * col;
+ cropArea[1] = height * row;
+ // Position
+ cropPosition[0] = x - cropArea[0];
+ cropPosition[1] = y - cropArea[1];
+ // Offset position to fit the point in the panel
+ cropPosition[0] -= std::max(0, (cropPosition[0] + config["pointSize"].as()) - width);
+ cropPosition[1] -= std::max(0, (cropPosition[1] + config["pointSize"].as()) - height);
+};
+
+void Map::render(MatrixPanel_I2S_DMA *display) {
+ if (isUpdating) {
+ return;
+ }
+
+ // TODO: test if this is still true
+ // Call to open is here because trying to open in webserver endpoint (right after finished writing) causes a panic and reboot
+ if (!open()) {
+ return;
+ }
+
+ // Update crop area (and position) once every 15 minutes
+ if (forceCropUpdate || millis() - lastCropUpdate > UPDATE_CROP_AREA) {
+ isUpdating = true;
+ xTaskCreate(asyncUpdateCrop, "Update crop area", 8192, this, 0, NULL);
+
+ return;
+ }
+
+ // Render once
+ if (lastRender > 0) {
+ return;
+ }
+
+ lastRender = millis();
+ drawUserData data = {
+ display,
+ cropPosition,
+ config["pointSize"].as(),
+ config["pointColor"].as(),
+ config["trackColor"].as()
+ };
+ jpeg.setUserPointer(static_cast(&data));
+ jpeg.setCropArea(cropArea[0], cropArea[1], width, height);
+ if (!jpeg.decode(0, 0, 0)) {
+ Log::instance()->error("Error decoding JPEG: %d\n", jpeg.getLastError());
+ }
+};
+
+void* Map::openFile(const char *path, int32_t *size) {
+ File *file = new File();
+ *file = LittleFS.open(path);
+ if (file) {
+ *size = file->size();
+
+ return static_cast(file);
+ }
+
+ return NULL;
+};
+
+void Map::closeFile(void *pHandle) {
+ File *f = static_cast(pHandle);
+ if (f != NULL) {
+ f->close();
+ }
+};
+
+int32_t Map::readFile(JPEGFILE *pFile, uint8_t *pBuf, int32_t iLen) {
+ int32_t iBytesRead;
+ iBytesRead = iLen;
+ File *f = static_cast(pFile->fHandle);
+ // Note: If you read a file all the way to the last byte, seek() stops working
+ if ((pFile->iSize - pFile->iPos) < iLen) {
+ iBytesRead = pFile->iSize - pFile->iPos - 1; // <-- ugly work-around
+ }
+ if (iBytesRead <= 0) {
+ return 0;
+ }
+
+ iBytesRead = (int32_t)f->read(pBuf, iBytesRead);
+ pFile->iPos = f->position();
+
+ return iBytesRead;
+};
+
+int32_t Map::seekFile(JPEGFILE *pFile, int32_t iPosition) {
+ File *f = static_cast(pFile->fHandle);
+ f->seek(iPosition);
+ pFile->iPos = (int32_t)f->position();
+
+ return pFile->iPos;
+};
+
+int Map::draw(JPEGDRAW *pDraw) {
+ drawUserData *data = static_cast(pDraw->pUser);
+ uint16_t *s = pDraw->pPixels;
+ int lastX = pDraw->x + pDraw->iWidth;
+ int lastY = pDraw->y + pDraw->iHeight;
+ for (int y = pDraw->y; y < lastY; y++) {
+ for (int x = pDraw->x; x < lastX; x++) {
+ data->display->drawPixel(x, y, *s++);
+ }
+ }
+
+ // Draw position
+ for (int y = 0; y < data->size; y++) {
+ for (int x = 0; x < data->size; x++) {
+ data->display->drawPixel(data->position[0] + x, data->position[1] + y, data->color);
+ }
+ }
+
+ return 1;
+};
diff --git a/src/modes/map.h b/src/modes/map.h
new file mode 100644
index 0000000..6f26e6b
--- /dev/null
+++ b/src/modes/map.h
@@ -0,0 +1,58 @@
+#ifndef _MAP_H
+#define _MAP_H
+
+#include
+#include
+#include "../modules/log.h"
+#include "renderer.h"
+#include "filesystem.h"
+#include
+#include
+#include
+#include
+
+#define MAP_PATH "/map.jpeg"
+#define UPDATE_CROP_AREA 1000 * 60 * 15
+
+class Map : public Renderer {
+ private:
+ typedef struct drawUserData {
+ MatrixPanel_I2S_DMA *display;
+ uint16_t *position;
+ uint8_t size;
+ uint16_t color;
+ uint16_t track;
+ } drawUserData;
+ JPEGDEC jpeg;
+ bool forceCropUpdate = false;
+ ulong lastRender = 0;
+ ulong lastCropUpdate = 0;
+ uint16_t cropArea[2];
+ uint16_t cropPosition[2];
+ bool isUpdating = false;
+ bool isOpen = false;
+ JsonDocument config;
+ static void asyncUpdateCrop(void*);
+ static int draw(JPEGDRAW*);
+ static void* openFile(const char*, int32_t*);
+ static void closeFile(void*);
+ static int32_t readFile(JPEGFILE*, uint8_t*, int32_t);
+ static int32_t seekFile(JPEGFILE*, int32_t);
+ bool setHeaders(HTTPClient&, const char*);
+ std::array matchRegex(const char*, const char*);
+ std::array getPositionFromAPI();
+ std::array getPosition();
+ void updateCrop();
+
+ public:
+ Map(uint8_t, uint8_t);
+ virtual ~Map();
+ virtual void initServer(AsyncWebServer*);
+ void setConfig(JsonVariantConst);
+ virtual void render(MatrixPanel_I2S_DMA*);
+ virtual void print();
+ virtual bool open();
+ virtual bool close();
+};
+
+#endif
diff --git a/src/modes/media.cpp b/src/modes/media.cpp
new file mode 100644
index 0000000..4531a9e
--- /dev/null
+++ b/src/modes/media.cpp
@@ -0,0 +1,208 @@
+#include "media.h"
+
+Media::Media(uint8_t w, uint8_t h) {
+ width = w;
+ height = h;
+};
+
+Media::~Media() {
+ close();
+};
+
+bool Media::open() {
+ if (current != nullptr && current->isOpen()) {
+ return true;
+ }
+
+ if (current != nullptr) {
+ delete current;
+ }
+
+ if (DecoderJPEG::fileExists()) {
+ current = new DecoderJPEG();
+ if (!current->open()) {
+ Log::instance()->error("Error opening JPEG\n");
+ mediaType = MEDIA_TYPE_NONE;
+
+ return false;
+ }
+
+ Log::instance()->info("Loaded JPEG\n");
+ mediaType = MEDIA_TYPE_IMAGE;
+
+ return true;
+ }
+
+ if (DecoderGIF::fileExists()) {
+ current = new DecoderGIF();
+ if (!current->open()) {
+ Log::instance()->error("Error opening GIF\n");
+ mediaType = MEDIA_TYPE_NONE;
+
+ return false;
+ }
+
+ Log::instance()->info("Loaded GIF\n");
+ mediaType = MEDIA_TYPE_GIF;
+
+ return true;
+ }
+
+ Log::instance()->trace("No media stored\n");
+ mediaType = MEDIA_TYPE_NONE;
+
+ return false;
+};
+
+bool Media::close() {
+ if (current == nullptr) {
+ return true;
+ }
+
+
+ delete current;
+ current = nullptr;
+
+ return true;
+};
+
+void Media::print() {
+ // TODO
+};
+
+bool Media::checkSize(AsyncWebServerRequest *request, size_t size) {
+ if (size > MAX_FILE_SIZE) {
+ Log::instance()->error("File size %d exceeds maximum size %d\n", size, MAX_FILE_SIZE);
+ request->send(400, "text/plain", "file is too big");
+
+ return false;
+ }
+
+ if (size > LittleFS.totalBytes() - LittleFS.usedBytes()) {
+ Log::instance()->error("File size %d is within limit, but disk has not enough free space (%d)\n", size, LittleFS.totalBytes() - LittleFS.usedBytes());
+ request->send(500, "text/plain", "not enough free space");
+
+ return false;
+ }
+
+ return true;
+};
+
+void Media::initServer(AsyncWebServer *server) {
+ server->on("/drawer", HTTP_GET, [&](AsyncWebServerRequest *request) {
+ if (current != nullptr && current->sendFile(request)) {
+ return;
+ }
+
+ Log::instance()->info("No media currently stored\n");
+ request->send(204);
+ });
+ server->on("/drawer", HTTP_DELETE, [&](AsyncWebServerRequest *request) {
+ if (current == nullptr) {
+ Log::instance()->info("No media to delete\n");
+ request->send(204);
+
+ return;
+ }
+
+ mediaType = MEDIA_TYPE_NONE;
+ current->close();
+ Log::instance()->info("Deleting current media\n");
+
+ if (!current->deleteFile()) {
+ Log::instance()->error("Error deleting file\n");
+ request->send(500, "text/plain", "error deleting file");
+
+ return;
+ }
+
+ delete current;
+ current = nullptr;
+ request->send(204);
+ });
+ server->on("/drawer", HTTP_POST,
+ [&](AsyncWebServerRequest *request) {
+ Log::instance()->info("Media received\n");
+ const AsyncWebHeader *contentType = request->getHeader("Content-Type");
+ if (contentType == nullptr) {
+ Log::instance()->error("File upload has no content-type header");
+ request->send(400, "text/plain", "no content-type header");
+
+ return;
+ }
+ if (contentType->value() == "image/jpeg") {
+ Log::instance()->info("Image stored\n");
+ mediaType = MEDIA_TYPE_IMAGE;
+ } else if (contentType->value() == "image/gif") {
+ Log::instance()->info("GIF stored\n");
+ mediaType = MEDIA_TYPE_GIF;
+ }
+
+ request->send(204);
+ },
+ nullptr,
+ [&](AsyncWebServerRequest *request, uint8_t *data, size_t len, size_t index, size_t total) {
+ if (index == 0) {
+ Log::instance()->debug("Receiving file: %d bytes\n", total);
+ const AsyncWebHeader *contentType = request->getHeader("Content-Type");
+
+ if (contentType->value() != "image/jpeg" && contentType->value() != "image/gif") {
+ Log::instance()->error("Uploaded file has unhandled content-type: %s\n", contentType->value());
+ request->send(400, "text/plain", "unhandled content type");
+
+ return;
+ }
+
+ if (!checkSize(request, total)) {
+ return;
+ }
+
+ mediaType = MEDIA_TYPE_NONE;
+
+ if (current != nullptr) {
+ current->close();
+
+ if (!current->deleteFile()) {
+ Log::instance()->warning("Error deleting previous file\n");
+ request->send(500, "text/plain", "error deleting previous file");
+
+ return;
+ }
+
+ Log::instance()->debug("Previous media deleted or not present\n");
+ delete current;
+ current = nullptr;
+ }
+
+ if (contentType->value() == "image/jpeg") {
+ current = new DecoderJPEG();
+ } else if (contentType->value() == "image/gif") {
+ current = new DecoderGIF();
+ } // else... checked before
+ }
+
+ Log::instance()->trace("Received file chunk: %d-%d (%d bytes)\n", index, index + len, len);
+
+ if (current == nullptr || !current->writeFile(data, len)) {
+ Log::instance()->error("Error writing file chunk\n");
+ request->send(500, "text/plain", "error writing file");
+ }
+ }
+ );
+};
+
+void Media::render(MatrixPanel_I2S_DMA *display) {
+ if (mediaType == MEDIA_TYPE_NONE || current == nullptr) {
+ display->clearScreen();
+
+ return;
+ }
+
+ // TODO: test if this is still true
+ // Call to open is here because trying to open in webserver endpoint (right after finished writing) causes a panic and reboot
+ if (!open()) {
+ return;
+ }
+
+ current->renderFrame(display);
+};
diff --git a/src/modes/media.h b/src/modes/media.h
new file mode 100644
index 0000000..c315d39
--- /dev/null
+++ b/src/modes/media.h
@@ -0,0 +1,36 @@
+#ifndef _MEDIA_H
+#define _MEDIA_H
+
+#include "../modules/log.h"
+#include "renderer.h"
+#include
+#include "filesystem.h"
+#include "../decoders/jpeg.h"
+#include "../decoders/gif.h"
+#include
+
+#define IMAGE_PATH "/image.jpeg"
+#define GIF_PATH "/media.gif"
+
+class Media : public Renderer {
+ private:
+ typedef enum {
+ MEDIA_TYPE_NONE = 0,
+ MEDIA_TYPE_IMAGE = 1,
+ MEDIA_TYPE_GIF = 2,
+ } media_type;
+ Decoder *current;
+ media_type mediaType = MEDIA_TYPE_NONE;
+ bool checkSize(AsyncWebServerRequest*, size_t);
+
+ public:
+ Media(uint8_t, uint8_t);
+ virtual ~Media();
+ virtual void initServer(AsyncWebServer*);
+ virtual void render(MatrixPanel_I2S_DMA*);
+ virtual void print();
+ virtual bool open();
+ virtual bool close();
+};
+
+#endif
diff --git a/src/qrcode.cpp b/src/modes/qrcode.cpp
similarity index 68%
rename from src/qrcode.cpp
rename to src/modes/qrcode.cpp
index 2c92912..ab389d2 100644
--- a/src/qrcode.cpp
+++ b/src/modes/qrcode.cpp
@@ -3,26 +3,42 @@
QRCode::QRCode(uint8_t w, uint8_t h) {
width = w;
height = h;
+ text[0] = '\0';
};
QRCode::~QRCode() {};
-bool QRCode::setText(String str) {
- uint8_t tempBuffer[qrcodegen_BUFFER_LEN_FOR_VERSION(2)];
+bool QRCode::open() {
+ return true;
+};
+
+bool QRCode::close() {
+ return true;
+};
+
+bool QRCode::setText(const char* str) {
+ if (strlen(str) >= QR_TEXT_MAX_LENGTH) {
+ Log::instance()->error("QR text too long: %d instead of %d\n", strlen(str), QR_TEXT_MAX_LENGTH);
+
+ return false;
+ }
+
+ uint8_t tempBuffer[qrcodegen_BUFFER_LEN_FOR_VERSION(QR_VERSION)];
if (!qrcodegen_encodeText(
- str.c_str(),
+ str,
tempBuffer,
qr0,
qrcodegen_Ecc_QUARTILE,
qrcodegen_VERSION_MIN,
- 2,
+ QR_VERSION,
qrcodegen_Mask_AUTO,
true
)) {
return false;
}
- text = str;
+ strncpy(text, str, QR_TEXT_MAX_LENGTH - 1);
+ text[QR_TEXT_MAX_LENGTH - 1] = '\0';
changed = true;
return true;
diff --git a/src/qrcode.h b/src/modes/qrcode.h
similarity index 50%
rename from src/qrcode.h
rename to src/modes/qrcode.h
index 0e2666f..7eca534 100644
--- a/src/qrcode.h
+++ b/src/modes/qrcode.h
@@ -1,15 +1,17 @@
#ifndef _QRCODE_H
#define _QRCODE_H
-#include "log.h"
+#include "../modules/log.h"
#include "renderer.h"
#include "qrcodegen.h"
#define QR_BORDER_PX 4
+#define QR_VERSION 3
+#define QR_TEXT_MAX_LENGTH 32 // version 3, binary data, quartile error correction (see https://www.qrcode.com/en/about/version.html)
class QRCode : public Renderer {
private:
- uint8_t qr0[qrcodegen_BUFFER_LEN_FOR_VERSION(2)];
- String text;
+ uint8_t qr0[qrcodegen_BUFFER_LEN_FOR_VERSION(QR_VERSION)];
+ char text[QR_TEXT_MAX_LENGTH];
bool changed = true;
public:
@@ -18,7 +20,9 @@ class QRCode : public Renderer {
virtual void initServer(AsyncWebServer*);
virtual void render(MatrixPanel_I2S_DMA*);
virtual void print();
- virtual bool setText(String);
+ virtual bool setText(const char*);
+ virtual bool open();
+ virtual bool close();
};
#endif
diff --git a/src/renderer.h b/src/modes/renderer.h
similarity index 87%
rename from src/renderer.h
rename to src/modes/renderer.h
index fdb7180..6ae8395 100644
--- a/src/renderer.h
+++ b/src/modes/renderer.h
@@ -8,7 +8,6 @@ class Renderer {
protected:
uint8_t width;
uint8_t height;
- uint16_t size;
public:
Renderer() : Renderer(0, 0) {};
@@ -17,6 +16,8 @@ class Renderer {
virtual void initServer(AsyncWebServer*) = 0;
virtual void render(MatrixPanel_I2S_DMA*) = 0;
virtual void print() = 0;
+ virtual bool open() = 0;
+ virtual bool close() = 0;
};
#endif
diff --git a/src/config.cpp b/src/modules/config.cpp
similarity index 63%
rename from src/config.cpp
rename to src/modules/config.cpp
index 3d3bbf0..fe2214d 100644
--- a/src/config.cpp
+++ b/src/modules/config.cpp
@@ -10,47 +10,45 @@ Config::~Config() {
};
bool Config::save(const char *path) {
- return writeConfig(path, current);
+ return Filesystem::writeConfig(path, current);
};
bool Config::load(const char *path) {
- current = readConfig(path);
+ current = Filesystem::readConfig(path);
return !current.isNull();
};
-// Asynchronously update configuration
-void Config::taskRunner(void *p) {
+void Config::asyncUpdateConfig(void *p) {
Config *t = (Config*) p;
+ // Merge new config with current for partial updates
+ JsonDocument merged;
+ merged.set(t->current);
+ Config::merge(merged.as(), t->newCfg.as());
if (t->configChangeHandler) {
- t->configChangeHandler(t->newCfg);
+ t->configChangeHandler(merged);
}
- t->current.clear();
- Config::merge(t->current, t->newCfg);
t->newCfg.clear();
+ t->current.clear();
+ t->current.set(merged);
t->save();
-
vTaskDelete(NULL);
};
void Config::merge(JsonVariant dst, JsonVariantConst src) {
- if (src.is()) {
- for (JsonPairConst kvp : src.as()) {
- if (dst[kvp.key()]) {
- Config::merge(dst[kvp.key()], kvp.value());
-
- return;
- }
-
- dst[kvp.key()] = kvp.value();
- }
+ if (!src.is()) {
+ dst.set(src);
return;
}
- if (!src.isNull() && strcmp(src.as(), "") != 0) {
- dst.set(src);
+ for (JsonPairConst kvp : src.as()) {
+ if (dst[kvp.key()]) {
+ Config::merge(dst[kvp.key()], kvp.value());
+ } else {
+ dst[kvp.key()] = kvp.value();
+ }
}
};
@@ -63,7 +61,7 @@ void Config::initServer(AsyncWebServer *server) {
request->send(response);
});
AsyncCallbackJsonWebHandler* handler = new AsyncCallbackJsonWebHandler("/config", [&](AsyncWebServerRequest *request, JsonVariant &json) {
- if (request->methodToString() != "POST") {
+ if (strcasecmp(request->methodToString(), "POST") != 0) {
request->send(405);
return;
@@ -71,9 +69,8 @@ void Config::initServer(AsyncWebServer *server) {
Log::instance()->info("Config JSON received\n");
request->send(204);
- JsonObject obj = json.as();
- newCfg.set(obj);
- xTaskCreate(taskRunner, "Update config", 8192, this, 0, NULL);
+ newCfg.set(json);
+ xTaskCreate(asyncUpdateConfig, "Update config", 8192, this, 0, NULL);
});
server->addHandler(handler);
};
diff --git a/src/config.h b/src/modules/config.h
similarity index 79%
rename from src/config.h
rename to src/modules/config.h
index a3837ff..253c694 100644
--- a/src/config.h
+++ b/src/modules/config.h
@@ -1,20 +1,20 @@
#ifndef _CONFIG_H
#define _CONFIG_H
-#include "log.h"
+#include "../modules/log.h"
#include "filesystem.h"
#include
#include
#include
-typedef std::function ConfigChangeHandler;
+typedef std::function ConfigChangeHandler;
class Config {
private:
JsonDocument newCfg;
ConfigChangeHandler configChangeHandler;
static void merge(JsonVariant, JsonVariantConst);
- static void taskRunner(void*);
+ static void asyncUpdateConfig(void*);
public:
JsonDocument current;
diff --git a/src/led.cpp b/src/modules/led.cpp
similarity index 100%
rename from src/led.cpp
rename to src/modules/led.cpp
diff --git a/src/led.h b/src/modules/led.h
similarity index 82%
rename from src/led.h
rename to src/modules/led.h
index f073571..1c743b5 100644
--- a/src/led.h
+++ b/src/modules/led.h
@@ -1,7 +1,7 @@
#ifndef _LED_H
#define _LED_H
-#include "log.h"
+#include "../modules/log.h"
#include
MatrixPanel_I2S_DMA* setupLED(HUB75_I2S_CFG);
diff --git a/src/log.cpp b/src/modules/log.cpp
similarity index 100%
rename from src/log.cpp
rename to src/modules/log.cpp
diff --git a/src/log.h b/src/modules/log.h
similarity index 100%
rename from src/log.h
rename to src/modules/log.h
diff --git a/src/modeselector.cpp b/src/modules/modeselector.cpp
similarity index 91%
rename from src/modeselector.cpp
rename to src/modules/modeselector.cpp
index d6ee13f..310b0f5 100644
--- a/src/modeselector.cpp
+++ b/src/modules/modeselector.cpp
@@ -59,15 +59,15 @@ draw_mode ModeSelector::getMode() {
// ypr[2]
// );
if (-M_PI_4 <= ypr[1] && ypr[1] <= M_PI_4) { // up
- return DRAW_MODE_IMAGE;
+ previousRead = DRAW_MODE_IMAGE;
} else if (M_PI_4 < ypr[1] && ypr[1] <= M_3PI_4) { // right
- return DRAW_MODE_LIFE;
+ previousRead = DRAW_MODE_LIFE;
} else if (-M_3PI_4 <= ypr[1] && ypr[1] < -M_PI_4) { // left
- return DRAW_MODE_TBD;
+ previousRead = DRAW_MODE_MAP;
} else { // down
- return DRAW_MODE_QR;
+ previousRead = DRAW_MODE_QR;
}
}
- return DRAW_MODE_NONE;
+ return previousRead;
};
diff --git a/src/modeselector.h b/src/modules/modeselector.h
similarity index 93%
rename from src/modeselector.h
rename to src/modules/modeselector.h
index afa9df0..eca90b0 100644
--- a/src/modeselector.h
+++ b/src/modules/modeselector.h
@@ -15,7 +15,7 @@ typedef enum {
DRAW_MODE_NONE = 0,
DRAW_MODE_IMAGE = 1,
DRAW_MODE_LIFE = 2,
- DRAW_MODE_TBD = 3, // TODO: the other orientation, TBD on what to do
+ DRAW_MODE_MAP = 3,
DRAW_MODE_QR = 4,
} draw_mode;
@@ -31,6 +31,7 @@ class ModeSelector {
Quaternion q; // [w, x, y, z] quaternion container
VectorFloat gravity; // [x, y, z] gravity vector
float ypr[3]; // [yaw, pitch, roll] yaw/pitch/roll in radians
+ draw_mode previousRead = DRAW_MODE_NONE;
public:
ModeSelector();
diff --git a/src/ota.cpp b/src/modules/ota.cpp
similarity index 87%
rename from src/ota.cpp
rename to src/modules/ota.cpp
index b0c902f..121731c 100644
--- a/src/ota.cpp
+++ b/src/modules/ota.cpp
@@ -16,10 +16,10 @@ void OTA::initServer(AsyncWebServer* server) {
}
uint8_t mode = U_FLASH;
- String modeParam = request->getParam("mode")->value();
- if (modeParam == "filesystem") {
+ const char* modeParam = request->getParam("mode")->value().c_str();
+ if (strcasecmp(modeParam, "filesystem") == 0) {
mode = U_SPIFFS;
- } else if (modeParam == "firmware") {
+ } else if (strcasecmp(modeParam, "firmware") == 0) {
mode = U_FLASH;
} else {
Log::instance()->error("Unknown update mode %s\n", modeParam);
@@ -29,9 +29,9 @@ void OTA::initServer(AsyncWebServer* server) {
}
if (request->hasParam("md5")) {
- String hash = request->getParam("md5")->value();
+ const char* hash = request->getParam("md5")->value().c_str();
Log::instance()->info("Setting MD5 hash: %s\n", hash);
- if (!Update.setMD5(hash.c_str())) {
+ if (!Update.setMD5(hash)) {
Log::instance()->error("Error setting MD5 update hash\n");
request->send(500, "text/plain", "error setting MD5 hash");
@@ -48,8 +48,8 @@ void OTA::initServer(AsyncWebServer* server) {
}
size_t size = UPDATE_SIZE_UNKNOWN;
- String sizeParam = request->getParam("size")->value();
- sscanf(sizeParam.c_str(), "%zu", &size); // string to size_t
+ const char* sizeParam = request->getParam("size")->value().c_str();
+ sscanf(sizeParam, "%zu", &size); // string to size_t
if (!Update.begin(size, mode) || Update.hasError()) {
Log::instance()->error("Error starting update: %s\n", Update.errorString());
request->send(500, "text/plain", "error starting update");
diff --git a/src/ota.h b/src/modules/ota.h
similarity index 100%
rename from src/ota.h
rename to src/modules/ota.h
diff --git a/src/wifi.cpp b/src/modules/wifi.cpp
similarity index 64%
rename from src/wifi.cpp
rename to src/modules/wifi.cpp
index 27636e2..1509952 100644
--- a/src/wifi.cpp
+++ b/src/modules/wifi.cpp
@@ -1,14 +1,17 @@
#include "wifi.h"
-WiFiController::WiFiController() {};
+WiFiController::WiFiController() {
+ urlBuffer[0] = '\0';
+ ipBuffer[0] = '\0';
+};
WiFiController::~WiFiController() {};
-bool WiFiController::connect(String ssid, String password) {
+bool WiFiController::connect(const char* ssid, const char* password) {
if (!disconnect()) {
return false;
}
- if (ssid == "" || password == "") {
+ if (ssid[0] == '\0' || password[0] == '\0') {
Log::instance()->info("Starting WiFi in AP mode\n");
if (!WiFi.softAP(SETUP_AP_SSID, SETUP_AP_PASS)) {
@@ -22,7 +25,7 @@ bool WiFiController::connect(String ssid, String password) {
return true;
}
- Log::instance()->info("Stating WiFi in STA mode\n");
+ Log::instance()->info("Starting WiFi in STA mode\n");
if (!WiFi.mode(WIFI_STA)) {
Log::instance()->error("Error setting STA mode\n");
@@ -30,7 +33,7 @@ bool WiFiController::connect(String ssid, String password) {
}
WiFi.setAutoReconnect(true);
- WiFi.begin(ssid.c_str(), password.c_str());
+ WiFi.begin(ssid, password);
Log::instance()->info("Connecting to WiFi\n");
wl_status_t s;
uint8_t t = 0;
@@ -71,35 +74,42 @@ bool WiFiController::disconnect() {
return true;
};
-String WiFiController::getIp() {
+const char* WiFiController::getIp() {
+ ipBuffer[0] = '\0';
+
if (WiFi.getMode() & WIFI_MODE_AP) {
- return WiFi.softAPIP().toString();
- }
- if (WiFi.getMode() & WIFI_MODE_STA) {
+ strncpy(ipBuffer, WiFi.softAPIP().toString().c_str(), MAX_IP_LENGTH);
+ } else if (WiFi.getMode() & WIFI_MODE_STA) {
if (WiFi.status() != WL_CONNECTED) {
- return String("");
+ Log::instance()->debug("Unable to get IP, WiFi not connected\n");
+
+ return ipBuffer;
}
- return WiFi.localIP().toString();
+ strncpy(ipBuffer, WiFi.localIP().toString().c_str(), MAX_IP_LENGTH);
+ } else {
+ Log::instance()->warning("No IP availble because not connected to a nerwork\n");
}
- Log::instance()->warning("No IP availble because not connected to a nerwork\n");
+ ipBuffer[MAX_IP_LENGTH - 1] = '\0';
- return String("");
+ return ipBuffer;
};
wifi_mode_t WiFiController::getMode() {
return WiFi.getMode();
};
-String WiFiController::getUrl() {
- String ip = getIp();
- if (ip == "") {
- return ip;
+const char* WiFiController::getUrl() {
+ const char* ip = getIp();
+
+ if (ip[0] == '\0') {
+ urlBuffer[0] = '\0';
+
+ return urlBuffer;
}
- String url = "http://";
- url.concat(ip);
+ snprintf(urlBuffer, MAX_URL_LENGTH, "http://%s/", ip);
- return url;
+ return urlBuffer;
};
diff --git a/src/wifi.h b/src/modules/wifi.h
similarity index 54%
rename from src/wifi.h
rename to src/modules/wifi.h
index 1f0bec5..98c03c2 100644
--- a/src/wifi.h
+++ b/src/modules/wifi.h
@@ -7,16 +7,22 @@
#define SETUP_AP_SSID "esp32-led-matrix"
#define SETUP_AP_PASS "daftpunk"
#define MAX_TRIES 15
+#define MAX_URL_LENGTH 32
+#define MAX_IP_LENGTH 15 // IPv4
class WiFiController {
+ private:
+ char urlBuffer[MAX_URL_LENGTH];
+ char ipBuffer[MAX_IP_LENGTH];
+
public:
WiFiController();
~WiFiController();
- bool connect(String = "", String = "");
+ bool connect(const char* = "", const char* = "");
bool disconnect();
- String getIp();
+ const char* getIp();
wifi_mode_t getMode();
- String getUrl();
+ const char* getUrl();
};
#endif
diff --git a/web/index.html b/web/index.html
index d922616..90f5669 100644
--- a/web/index.html
+++ b/web/index.html
@@ -1,169 +1,321 @@
-
+
-
-
- LED matrix
-
-
-
-
- Image
-
-
-
-
- Select file
-
-
- X
-
-
-
-
-
Preview
-
-
-
-
-
Upload
-
-
-
-
-
- LED Panel
-
-
- Brightness
-
-
-
-
Save
-
-
-
-
-
- WiFi
-
-
- SSID
-
-
-
-
-
Password
-
-
- show
-
-
-
-
Save
-
-
-
-
-
- Update
-
-
-
-
- Select file
-
-
- X
-
-
-
-
- Mode
-
-
- firmware
-
-
-
- filesystem
-
-
-
-
Upload
-
-
-
-
-
- Debug
-
-
-
Level
-
-
-
-
- error
- warning
- info
- debug
- trace
-
-
-
Save
-
-
-
Error saving config
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+ LED matrix
+
+
+
+
+ Image
+
+
+
+
+ Select file
+
+
+ X
+
+
+
+
+
Preview
+
+
+
+
+
Upload
+
Delete media
+
+
+
+
+
+
+
+
+
+ Map
+
+
Currently supports only equirectangular world maps
+
+
+
+
+ Select file
+
+
+ X
+
+
+
+
+
+
+ Show grid
+
+
+
+
+
+
+
+
+
+
+
+
+ Remote API
+ ADVANCED: Periodically call a remote API to get position.
+ If all required fields are set, this has precedence over static location.
+ URL and method are required.
+
+ Regex is required, uses the
+ Modified ECMAScript grammar .
+
+ Body is optional, must be a query string if the request is GET or stringified JSON if POST.
+ Headers are optional, must be a stringified JSON.
+
+
+
+
+ Body
+
+
+
+ Headers
+
+
+
+
+ Test
+
+
+
+
Upload and save
+
Delete current map
+
+
+
+
+
+
+
+
+
+ LED Panel
+
+
+ Brightness
+
+
+
+
Save
+
+
+
+
+
+ WiFi
+
+
+ SSID
+
+
+
+
+
Password
+
+
+ show
+
+
+
+
Save
+
+
+
+
+
+ Update
+
+
+
+
+ Select file
+
+
+ X
+
+
+
+
+ Mode
+
+
+ firmware
+
+
+
+ filesystem
+
+
+
+
Upload
+
+
+
+
+
+
+ Debug
+
+
+
Level
+
+
+
+
+ error
+ warning
+ info
+ debug
+ trace
+
+
+
Save
+
+
+
Error saving config
+
+
+
+
+
+
+
+
+
+
+
+ Memory
+
+
+
+ Filesystem
+
+
+
+ Firmware
+
+
+
+
+
Refresh
+
+
+
+
+
+
-
+
diff --git a/web/src/config.js b/web/src/config.js
index 5005774..a9c5657 100644
--- a/web/src/config.js
+++ b/web/src/config.js
@@ -1,4 +1,4 @@
-import { resetMessage, setDisabled, showMessage } from './utils.js';
+import { resetMessage, setDisabled, showMessage, handleFetch } from './utils.js';
let hidePwd = true;
const submitWifi = document.getElementById('submit-wifi');
@@ -19,84 +19,71 @@ const pwdToggle = document.getElementById('toggle-password');
const brightnessInput = document.getElementById('brightness');
const logSelect = document.getElementById('log-level');
-export const config = (baseUrl) => {
- // Get current config
- const url = new URL('config', baseUrl);
- fetch(url, {
- method: 'GET',
- headers: { 'Accept': 'application/json' },
- }).then(async (res) => {
- const data = await res.text();
- if (!res.ok || res.status != 200) {
- console.error(`Error receiving configuration, status code ${res.status}: ${data}`);
+/**
+ * Initialize config module.
+ *
+ * @param {import('./state.js').State} state
+ */
+export const config = (state) => {
+ // Get current config
+ state.config().then((config) => {
+ if (!config) {
+ return;
+ }
- return;
- }
+ ssidInput.value = config.wifi.ssid;
+ pwdInput.value = config.wifi.password;
+ brightnessInput.value = config.panel.brightness || 127;
+ logSelect.value = config.log.level || 2; // info
+ });
- const json = JSON.parse(data);
- ssidInput.value = json?.wifi?.ssid;
- pwdInput.value = json?.wifi?.password;
- brightnessInput.value = json?.panel?.brightness || 127;
- logSelect.value = json?.log?.level || 2; // info
- });
+ // Handle password toggle
+ pwdToggle.addEventListener('click', () => {
+ hidePwd = !hidePwd;
+ pwdToggle.innerText = hidePwd ? 'show' : 'hide';
+ pwdInput.type = hidePwd ? 'password' : 'text';
+ });
- // Handle password toggle
- pwdToggle.addEventListener('click', () => {
- hidePwd = !hidePwd;
- pwdToggle.innerText = hidePwd ? 'show' : 'hide';
- pwdInput.type = hidePwd ? 'password' : 'text';
- });
+ // Enable submit on value changes
+ const handleInput = (inputElem, submitElem, feedbackElem) => {
+ inputElem.addEventListener('input', () => {
+ resetMessage(feedbackElem);
+ setDisabled(submitElem, false);
+ });
+ };
+ handleInput(ssidInput, submitWifi, feedbackWifiMessage);
+ handleInput(pwdInput, submitWifi, feedbackWifiMessage);
+ handleInput(brightnessInput, submitBrightness, feedbackBrightnessMessage);
+ handleInput(logSelect, submitLog, feedbackLogMessage);
- // Enable submit on value changes
- const handleInput = (inputElem, submitElem, feedbackElem) => {
- inputElem.addEventListener('input', () => {
- resetMessage(feedbackElem);
- setDisabled(submitElem, false);
- });
- };
- handleInput(ssidInput, submitWifi, feedbackWifiMessage);
- handleInput(pwdInput, submitWifi, feedbackWifiMessage);
- handleInput(brightnessInput, submitBrightness, feedbackBrightnessMessage);
- handleInput(logSelect, submitLog, feedbackLogMessage);
+ // Handle submit to server
+ const handleSubmit = (submitElem, feedbackElem) => {
+ submitElem.addEventListener('click', async () => {
+ setDisabled(submitElem, true);
+ showMessage(feedbackElem, 'Saving...', 'is-primary');
+ const res = await state.saveConfig({
+ wifi: {
+ ssid: ssidInput.value,
+ password: pwdInput.value,
+ },
+ panel: {
+ brightness: Math.min(255, Math.max(0, brightnessInput.value)),
+ },
+ log: {
+ level: parseInt(logSelect.value, 10),
+ },
+ });
+ if (!res) {
+ showMessage(feedbackElem, 'Error saving configuration', 'is-error');
+ setDisabled(submitElem, false);
- // Handle submit to server
- const handleSubmit = (submitElem, feedbackElem) => {
- submitElem.addEventListener('click', async () => {
- setDisabled(submitElem, true);
- showMessage(feedbackElem, 'Saving...', 'is-primary');
- const config = {
- wifi: {
- ssid: ssidInput.value,
- password: pwdInput.value,
- },
- panel: {
- brightness: Math.min(255, Math.max(0, brightnessInput.value)),
- },
- log: {
- level: parseInt(logSelect.value, 10)
- }
- };
+ return;
+ }
- try {
- const url = new URL('config', baseUrl);
- const response = await fetch(url, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify(config),
- });
- if (!response.ok || response.status != 204) {
- throw new Error(`status code ${response.status}: ${await response.text()}`);
- }
-
- showMessage(feedbackElem, 'Configuration saved', 'is-success');
- } catch (e) {
- console.error('Error sending configuration:', e);
- showMessage(feedbackElem, 'Error saving configuration', 'is-error');
- setDisabled(submitElem, false);
- }
- });
- };
- handleSubmit(submitWifi, feedbackWifiMessage);
- handleSubmit(submitBrightness, feedbackBrightnessMessage);
- handleSubmit(submitLog, feedbackLogMessage);
+ showMessage(feedbackElem, 'Configuration saved', 'is-success');
+ });
+ };
+ handleSubmit(submitWifi, feedbackWifiMessage);
+ handleSubmit(submitBrightness, feedbackBrightnessMessage);
+ handleSubmit(submitLog, feedbackLogMessage);
};
diff --git a/web/src/firmware.js b/web/src/firmware.js
index 151ebba..7fe408b 100644
--- a/web/src/firmware.js
+++ b/web/src/firmware.js
@@ -1,9 +1,10 @@
-import { resetMessage, setDisabled, showMessage } from './utils.js';
+import { resetMessage, setDisabled, setProgress, showMessage } from './utils.js';
const submitUpdate = document.getElementById('submit-update');
setDisabled(submitUpdate, true);
const feedbackMessage = document.getElementById('submit-update-message');
resetMessage(feedbackMessage);
+const uploadProgress = document.getElementById('submit-update-progress');
const updateFileInput = document.getElementById('update-file');
updateFileInput.value = null;
const updateFileLabel = updateFileInput.parentElement.querySelector('span');
@@ -11,84 +12,78 @@ const initialFileLabelText = updateFileLabel.innerText;
const fileRemove = document.getElementById('remove-update-file');
fileRemove.classList.add('is-hidden');
-export const firmware = (baseUrl) => {
- // Clear selected file
- fileRemove.addEventListener('click', () => {
- updateFileInput.value = null;
- updateFileLabel.innerText = initialFileLabelText;
- fileRemove.classList.add('is-hidden');
- setDisabled(submitUpdate, true);
- resetMessage(feedbackMessage);
- });
+/**
+ * Initialize firmware module.
+ *
+ * @param {import('./state.js').State} state
+ */
+export const firmware = (state) => {
+ // Clear selected file
+ fileRemove.addEventListener('click', () => {
+ updateFileInput.value = null;
+ updateFileLabel.innerText = initialFileLabelText;
+ fileRemove.classList.add('is-hidden');
+ setDisabled(submitUpdate, true);
+ resetMessage(feedbackMessage);
+ });
- // Enable/disable submit on input change
- updateFileInput.addEventListener('change', (e) => {
- resetMessage(feedbackMessage);
- if (e.target.files.length === 0) {
- updateFileLabel.innerText = initialFileLabelText;
- setDisabled(submitUpdate, true);
- fileRemove.classList.add('is-hidden');
+ // Enable/disable submit on input change
+ updateFileInput.addEventListener('change', (e) => {
+ resetMessage(feedbackMessage);
+ if (e.target.files.length === 0) {
+ updateFileLabel.innerText = initialFileLabelText;
+ setDisabled(submitUpdate, true);
+ fileRemove.classList.add('is-hidden');
- return;
- }
+ return;
+ }
- updateFileLabel.innerText = e.target.files[0].name;
- setDisabled(submitUpdate, false);
- fileRemove.classList.remove('is-hidden');
- });
- document.querySelectorAll('.upload-update input[name="update-mode"]')
- .forEach((input) => {
- input.addEventListener('click', () => {
- resetMessage(feedbackMessage);
- if (updateFileInput.files.length) {
- setDisabled(submitUpdate, false);
- }
- });
- });
+ updateFileLabel.innerText = e.target.files[0].name;
+ setDisabled(submitUpdate, false);
+ fileRemove.classList.remove('is-hidden');
+ });
+ document.querySelectorAll('.upload-update input[name="update-mode"]').forEach((input) => {
+ input.addEventListener('click', () => {
+ resetMessage(feedbackMessage);
+ if (updateFileInput.files.length) {
+ setDisabled(submitUpdate, false);
+ }
+ });
+ });
- // Upload update
- submitUpdate.addEventListener('click', async (e) => {
- setDisabled(submitUpdate, true);
- showMessage(feedbackMessage, 'Uploading...', 'is-primary');
- const file = updateFileInput.files?.[0];
- if (!file) {
- console.error('Update file not selected');
- showMessage(feedbackMessage, 'File not selected', 'is-warning');
+ // Upload update
+ submitUpdate.addEventListener('click', async (e) => {
+ setDisabled(submitUpdate, true);
+ showMessage(feedbackMessage, 'Uploading...', 'is-primary');
+ const file = updateFileInput.files?.[0];
+ if (!file) {
+ console.error('Update file not selected');
+ showMessage(feedbackMessage, 'File not selected', 'is-warning');
- return;
- }
+ return;
+ }
- const mode = document.querySelector('.upload-update input[name="update-mode"]:checked')?.value;
- if (!mode) {
- console.error('Update mode not selected');
- showMessage(feedbackMessage, 'Mode not selected', 'is-warning');
+ const mode = document.querySelector('.upload-update input[name="update-mode"]:checked')?.value;
+ if (!mode) {
+ console.error('Update mode not selected');
+ showMessage(feedbackMessage, 'Mode not selected', 'is-warning');
- return;
- }
+ return;
+ }
- let url = new URL('ota', baseUrl);
- url.searchParams.set('mode', mode);
- url.searchParams.set('size', file.size);
- try {
- let response = await fetch(url);
- if (!response.ok || response.status >= 300) {
- throw new Error(`error initializing update: ${await response.text()}`);
- }
+ uploadProgress.classList.remove('is-hidden');
+ const success = await state.uploadOTA(file, mode, (progress) =>
+ setProgress(uploadProgress, (100 * progress.current) / progress.total, 'is-primary'),
+ );
+ uploadProgress.classList.add('is-hidden');
+ setProgress(uploadProgress, undefined);
+ if (!success) {
+ showMessage(feedbackMessage, 'Error applying update', 'is-error');
+ setDisabled(submitUpdate, false);
- url = new URL('ota', baseUrl);
- response = await fetch(url, {
- method: 'POST',
- body: file,
- });
- if (!response.ok || response.status >= 300) {
- throw new Error(`'error uploading file: ${await response.text()}'`);
- }
+ return;
+ }
- showMessage(feedbackMessage, 'Update applyed, rebooting', 'is-success');
- } catch (e) {
- console.error('Error applying the update:', e);
- setDisabled(submitUpdate, false);
- showMessage(feedbackMessage, 'Error applying update', 'is-error');
- }
- });
+ showMessage(feedbackMessage, 'Update applyed, rebooting', 'is-success');
+ });
};
diff --git a/web/src/image.js b/web/src/image.js
deleted file mode 100644
index 7de74d3..0000000
--- a/web/src/image.js
+++ /dev/null
@@ -1,362 +0,0 @@
-import { humanFileSize, resetMessage, setDisabled, showMessage } from './utils.js';
-import { parseGIF, decompressFrames } from 'gifuct-js';
-import { quantize, applyPalette, prequantize, GIFEncoder, nearestColorIndexWithDistance } from 'gifenc';
-
-/**
- * Original code taken from https://github.com/javl/image2cpp
- */
-
-const submitFile = document.getElementById('submit-file');
-setDisabled(submitFile, true);
-const feedbackMessage = document.getElementById('submit-file-message');
-resetMessage(feedbackMessage);
-const fileInput = document.getElementById('file');
-fileInput.value = null;
-const fileLabel = fileInput.parentElement.querySelector('span');
-const initialFileLabelText = fileLabel.innerText;
-const fileRemove = document.getElementById('remove-file');
-fileRemove.classList.add('is-hidden');
-const gifPreview = document.getElementById('gif-preview');
-gifPreview.classList.add('is-hidden');
-/** @type {HTMLCanvasElement} */
-const canvas = document.getElementById('drawer-preview');
-canvas.width = 64;
-canvas.height = 64;
-/** @type {CanvasRenderingContext2D} */
-const context = canvas.getContext('2d');
-context.fillStyle = '#000000ff';
-/** @type {Uint8Array|null} */
-let result = null;
-
-// GIF stuff
-const tmpCanvas = new OffscreenCanvas(canvas.width, canvas.height);
-const tmpContext = tmpCanvas.getContext('2d');
-/** @type {ImageData|undefined} */
-let frameImageData;
-/** @type {import('gifuct-js').ParsedFrame|undefined} */
-let previousFrameData;
-/** @type {ImageData|undefined} */
-let previousImageData;
-/** @type {number} pixel ratio (canvas / GIF) */
-let ratio = 1;
-let offsetX, offsetY;
-let gifEncoder;
-/** @type {Uint8Array|undefined} */
-let gifData;
-let maxSize = 1024 * 1024; // 1 MiB, overridden if disk free size is lower
-
-/**
- * @param {File} file
- */
-const loadGif = async (file) => {
- const start = Date.now();
- showMessage(feedbackMessage, 'Generating GIF...', 'is-primary');
- const gif = parseGIF(await file.arrayBuffer());
- const frames = decompressFrames(gif, true);
- // Get pixel ratio of first frame
- ratio = Math.min(canvas.width / frames[0].dims.width, canvas.height / frames[0].dims.height);
- const scaledGifW = gif.lsd.width * ratio;
- const scaledGifH = gif.lsd.height * ratio;
- offsetX = scaledGifW < canvas.width
- ? ((canvas.width - scaledGifW) / 2)
- : 0;
- offsetY = scaledGifH < canvas.height
- ? ((canvas.height - scaledGifH) / 2)
- : 0;
- gifEncoder = new GIFEncoder({ auto: true });
- console.log(`Rendering GIF: ${frames.length} frames, ${(frames.reduce((tot, frm) => tot + frm.delay, 0) / 1000).toPrecision(2)}s, ${(file.size / 1024)|0} KiB`);
- for (let i = 0; i < frames.length; i++) {
- const frameData = renderFrame(frames[i], i === 0);
- encodeFrame(frameData, frames[i].delay);
- }
-
- gifData = finishScaledGif();
- console.log(`Rendering took ${(Date.now() - start) / 1000}s, result file is ${humanFileSize(gifData.byteLength)}`);
- if (gifData.byteLength > maxSize) {
- console.error(`GIF size is ${(gifData.byteLength / 1024)|0} KiB, max size allowed is ${humanFileSize(maxSize)}`);
- showMessage(feedbackMessage, `Scaled down GIF exceeds maximum size of ${humanFileSize(maxSize)}`, 'is-error');
-
- return;
- }
-
- resetMessage(feedbackMessage);
- setDisabled(submitFile, false);
-};
-
-const resetGif = () => {
- frameImageData = undefined;
- previousFrameData = undefined;
- previousImageData = undefined;
- gifData = undefined;
- gifEncoder = undefined;
-};
-
-/**
- * Adapted from:
- * - https://github.com/deanm/omggif/pull/31/files
- * - https://github.com/matt-way/gifuct-js/blob/55cecc4d1ca5ea073f962a73ec31582e392fcdde/demo/demo.js
- */
-const renderFrame = (frame, first = false) => {
- if (previousFrameData) {
- switch (previousFrameData.disposalType) {
- case 0: // "No disposal specified"
- case 1: // "Do not dispose"
- // do nothing, we draw over the existing canvas
- break;
-
- case 2: // "Restore to background"
- // fill with black, because LED panel is black
- // NOTE: only the frame's area, not the whole canvas
- context.fillRect(
- (previousFrameData.dims.left * ratio) + offsetX,
- (previousFrameData.dims.top * ratio) + offsetY,
- previousFrameData.dims.width * ratio,
- previousFrameData.dims.height * ratio
- );
- break;
-
- case 3: // "Restore to previous"
- // revert back to most recent frame that was not set to "Restore to previous", or frame 0
- if (previousImageData) {
- context.putImageData(previousImageData, 0, 0);
- }
- break;
-
- default:
- console.error(`Unknown disposal method: ${previousFrameData.disposalType}`);
- }
- }
- if (first || frame.disposalType < 2) {
- previousImageData = context.getImageData(0, 0, canvas.width, canvas.height);
- }
-
- if (!frameImageData || frame.dims.width != frameImageData.width || frame.dims.height != frameImageData.height) {
- tmpCanvas.width = frame.dims.width;
- tmpCanvas.height = frame.dims.height;
- frameImageData = tmpContext.createImageData(tmpCanvas.width, tmpCanvas.height);
- }
-
- frameImageData.data.set(frame.patch);
- tmpContext.putImageData(frameImageData, 0, 0);
-
- // Center and scale frame
- const dstW = frame.dims.width * ratio;
- const dstH = frame.dims.height * ratio;
- const dstX = (frame.dims.left * ratio) + offsetX;
- const dstY = (frame.dims.top * ratio) + offsetY;
-
- context.drawImage(tmpCanvas, dstX, dstY, dstW, dstH);
- previousFrameData = frame;
-
- return context.getImageData(0, 0, canvas.width, canvas.height);
-};
-
-/**
- * @param {ImageData} imageData
- * @param {number} delay
- */
-const encodeFrame = (imageData, delay) => {
- const { data, width, height } = imageData;
- const palette = quantize(data, 256, { format: 'rgb565' });
- const index = applyPalette(data, palette, 'rgb565');
- gifEncoder.writeFrame(index, width, height, { palette, delay });
-};
-
-/**
- *
- * @return {Uint8Array}
- */
-const finishScaledGif = () => {
- gifEncoder.finish();
- const blob = new Blob([gifEncoder.bytes()], { type: 'image/gif' });
- const reader = new FileReader();
- reader.addEventListener('load', () => {
- gifPreview.src = reader.result;
- canvas.classList.add('is-hidden');
- gifPreview.classList.remove('is-hidden');
- });
- reader.readAsDataURL(blob);
-
- return gifEncoder.bytes();
-};
-
-export const image = (baseUrl) => {
- // Fill canvas with black, because the LED panel is black
- context.fillRect(0, 0, canvas.width, canvas.height);
-
- // Get current image
- let url = new URL('drawer', baseUrl);
- fetch(url).then(async (res) => {
- if (!res.ok) {
- console.error(`Error receiving current image, status code ${res.status}: ${await res.text()}`);
-
- return;
- }
-
- const imageData = context.getImageData(0, 0, canvas.width, canvas.height);
- const data = await res.bytes();
- let readIndex = 0;
- // Add alpha values for ImageData
- for (let i = 0; i < imageData.data.length; i++) {
- if ((i + 1) % 4 === 0) {
- imageData.data[i] = 255
- continue;
- }
-
- imageData.data[i] = data[readIndex];
- readIndex++;
- }
-
- context.putImageData(imageData, 0, 0);
- });
-
- // Get current free disk
- url = new URL('status', baseUrl);
- fetch(url).then(async (res) => {
- if (!res.ok || res.status != 200) {
- console.error(`Error getting current status, status code ${res.status}: ${await res.text()}`);
-
- return;
- }
-
- try {
- const { filesystem: { size, used } } = await res.json();
- const free = size - used;
- if (free < maxSize) {
- maxSize = (free * 0.9)|0; // leave some space
- }
- } catch (e) {
- console.error(`Error fetching status`, e);
- }
- });
-
- // Clear selected file and canvas
- fileRemove.addEventListener('click', () => {
- fileInput.value = null;
- fileLabel.innerText = initialFileLabelText;
- fileRemove.classList.add('is-hidden');
- result = null;
- resetGif();
- // Fill canvas with black, because the LED panel is black
- context.fillRect(0, 0, canvas.width, canvas.height);
- setDisabled(submitFile, true);
- resetMessage(feedbackMessage);
- canvas.classList.remove('is-hidden');
- gifPreview.classList.add('is-hidden');
- });
-
- // File selected
- fileInput.addEventListener('change', (e) => {
- if (e.target.files.length === 0) {
- fileLabel.innerText = initialFileLabelText;
-
- return;
- }
-
- // Fill canvas with black, because the LED panel is black
- context.fillRect(0, 0, canvas.width, canvas.height);
- setDisabled(submitFile, true);
- resetMessage(feedbackMessage);
- resetGif();
- result = null;
- canvas.classList.remove('is-hidden');
- gifPreview.classList.add('is-hidden');
- const file = e.target.files[0];
- fileLabel.innerText = file.name;
- fileRemove.classList.remove('is-hidden');
- if (file.name.endsWith('.gif')) {
- loadGif(file);
-
- return;
- }
-
- // Convert image to array of 8bit values
- const reader = new FileReader();
- reader.onload = (file) => {
- const img = new Image();
- img.onload = () => {
- // Center and scale image to canvas size keeping ratio
- const imgW = img.width;
- const imgH = img.height;
- const ratio = Math.min(canvas.width / imgW, canvas.height / imgH);
- const dstW = (imgW * ratio) |0;
- const dstH = (imgH * ratio) |0;
- const dstX = dstW < canvas.width
- ? ((canvas.width - dstW) / 2) |0
- : 0
- const dstY = dstH < canvas.height
- ? ((canvas.height - dstH) / 2) |0
- : 0;
-
- // Draw preview
- context.drawImage(img, 0, 0, imgW, imgH, dstX, dstY, dstW, dstH);
- // Prepare data
- const imageData = context.getImageData(0, 0, canvas.width, canvas.height);
- // Remove alpha values from ImageData
- result = new Uint8Array(canvas.width * canvas.height * 3);
- let resultIndex = 0;
- for (let i = 0; i < imageData.data.length; i++) {
- if ((i + 1) % 4 === 0) {
- continue;
- }
-
- result[resultIndex] = imageData.data[i];
- resultIndex++;
- }
-
- if (result.byteLength > maxSize) {
- console.error(`Not enough space for storing image (${humanFileSize(result.byteLength)}), max size allowed is ${humanFileSize(maxSize)}`);
- showMessage(feedbackMessage, `Converted image exceeds maximum size of ${humanFileSize(maxSize)}`, 'is-error');
-
- return;
- }
-
- resetMessage(feedbackMessage);
- setDisabled(submitFile, false);
- console.log(`Image data is ${result.byteLength} bytes`/* , [...result].map(b => b.toString(16).padStart(2, '0')).join(', ') */);
- };
- img.src = file.target.result;
- };
- reader.readAsDataURL(file);
- });
-
- // Send file bytes to server
- submitFile.addEventListener('click', async () => {
- setDisabled(submitFile, true);
- showMessage(feedbackMessage, 'Uploading...', 'is-primary');
- try {
- let url = new URL('drawer', baseUrl);
- let data, contentType, length;
- if (result) {
- console.log('uploading image');
- data = result;
- contentType = 'application/octet-stream';
- length = result.byteLength;
- } else if (gifData) {
- console.log('uploading GIF');
- data = gifData;
- contentType = 'image/gif';
- length = gifData.byteLength;
- } else {
- console.error('Neither image or gif selected');
-
- return;
- }
-
- const response = await fetch(url, {
- method: 'POST',
- headers: { 'Content-Type': contentType, 'Content-Length': length },
- body: data,
- });
- if (!response.ok || response.status != 204) {
- throw new Error(`status code ${response.status}: ${await response.text()}`);
- }
-
- showMessage(feedbackMessage, 'File uploaded', 'is-success');
- } catch (e) {
- console.error('Error uploading file:', e);
- showMessage(feedbackMessage, 'Error uploading file', 'is-error');
- setDisabled(submitFile, false);
- }
- });
-};
diff --git a/web/src/main.js b/web/src/main.js
index ec20455..322abf5 100644
--- a/web/src/main.js
+++ b/web/src/main.js
@@ -1,15 +1,20 @@
import './style.css';
-import { image } from './image.js';
+import { State } from './state.js';
+import { media } from './media.js';
+import { map } from './map.js';
import { config } from './config.js';
import { firmware } from './firmware.js';
import { websocket } from './websocket.js';
import { system } from './system.js';
-(() => {
- const baseUrl = new URL(window.location.origin);
- image(baseUrl);
- config(baseUrl);
- firmware(baseUrl);
- websocket(baseUrl);
- system(baseUrl);
+(async () => {
+ const baseUrl = new URL(window.location.origin);
+ const state = new State(baseUrl);
+ await state.preload(false);
+ media(state);
+ map(state);
+ config(state);
+ firmware(state);
+ websocket(baseUrl);
+ system(state);
})();
diff --git a/web/src/map.js b/web/src/map.js
new file mode 100644
index 0000000..be46a1c
--- /dev/null
+++ b/web/src/map.js
@@ -0,0 +1,423 @@
+import {
+ handleFetch,
+ humanFileSize,
+ resetMessage,
+ setDisabled,
+ setProgress,
+ showMessage,
+ readImage,
+ canvasToImage,
+ hexToRgb,
+ rgbToHex,
+} from './utils.js';
+
+const submitFile = document.getElementById('submit-map-file');
+setDisabled(submitFile, true);
+const feedbackMessage = document.getElementById('submit-map-file-message');
+resetMessage(feedbackMessage);
+const uploadProgress = document.getElementById('submit-map-progress');
+const deleteMap = document.getElementById('delete-map');
+const deleteMapConfirm = document.getElementById('delete-map-confirm');
+const fileInput = document.getElementById('map-file');
+fileInput.value = null;
+const fileLabel = fileInput.parentElement.querySelector('span');
+const initialFileLabelText = fileLabel.innerText;
+const fileRemove = document.getElementById('remove-map-file');
+fileRemove.classList.add('is-hidden');
+const showGrid = document.getElementById('show-grid');
+showGrid.checked = false;
+const zoomInput = document.getElementById('map-zoom');
+zoomInput.value = 1;
+const pointSizeInput = document.getElementById('location-size');
+pointSizeInput.value = 2;
+const locationColorInput = document.getElementById('location-color');
+locationColorInput.value = '#ff0000';
+const routeColorInput = document.getElementById('location-route-color');
+routeColorInput.value = '#0000ff';
+const locationLatitude = document.getElementById('location-latitude');
+const locationLongitude = document.getElementById('location-longitude');
+const locationUrl = document.getElementById('location-url');
+const locationMethod = document.getElementById('location-method');
+const locationRegex = document.getElementById('location-regex');
+const locationBody = document.getElementById('location-body');
+const locationHeaders = document.getElementById('location-headers');
+const locationTest = document.getElementById('location-test');
+const locationTestMessage = document.getElementById('location-test-message');
+resetMessage(locationTestMessage);
+
+/** @type {HTMLCanvasElement} */
+const canvas = document.getElementById('map-preview');
+canvas.width = 64 * 36;
+canvas.height = 64 * 18;
+/** @type {CanvasRenderingContext2D} */
+const context = canvas.getContext('2d');
+context.fillStyle = '#000000ff';
+/** @type {HTMLCanvasElement} */
+const canvasPanel = document.getElementById('panel-preview');
+canvasPanel.width = 64;
+canvasPanel.height = 64;
+/** @type {CanvasRenderingContext2D} */
+const contextPanel = canvasPanel.getContext('2d');
+contextPanel.fillStyle = '#000000ff';
+let maxSize = 1024 * 1024; // 1 MiB, overridden if disk free size is lower
+/** @type {File|undefined} */
+let currentFile = undefined;
+/** @type {Uint8Array|undefined} */
+let imageData;
+let positions = [];
+
+const drawGrid = (cellW, cellH) => {
+ // Draw latitude and longitude lines on preview
+ context.strokeStyle = '#ffffff';
+ context.lineWidth = 1;
+ // Longitude (vertical)
+ for (let x = cellW; x < canvas.width; x += cellW) {
+ context.beginPath();
+ context.moveTo(x, 0);
+ context.lineTo(x, canvas.height);
+ context.stroke();
+ }
+ // Latitude (horizontal)
+ for (let y = cellH; y < canvas.height; y += cellH) {
+ context.beginPath();
+ context.moveTo(0, y);
+ context.lineTo(canvas.width, y);
+ context.stroke();
+ }
+};
+
+const handleFile = async (file) => {
+ const img = await readImage(file);
+ canvas.width = canvasPanel.width * 36;
+ canvas.height = canvasPanel.height * 18;
+ // Scale to canvas size and show preview
+ context.drawImage(img, 0, 0, img.width, img.height, 0, 0, canvas.width, canvas.height);
+ imageData = await canvasToImage(canvas).then((blob) => blob.bytes());
+ if (imageData.byteLength > maxSize) {
+ console.error(
+ `Not enough space for storing map (${humanFileSize(imageData.byteLength)}), max size allowed is ${humanFileSize(maxSize)}`,
+ );
+ showMessage(feedbackMessage, `Resized map exceeds maximum size of ${humanFileSize(maxSize)}`, 'is-error');
+
+ return;
+ }
+
+ const cellW = canvasPanel.width * zoomInput.value;
+ const cellH = canvasPanel.height * zoomInput.value;
+ if (showGrid.checked) {
+ drawGrid(cellW, cellH);
+ }
+
+ setDisabled(submitFile, false);
+ console.log(`Image is ${humanFileSize(imageData.byteLength)}`);
+ const [lat, lon] = (await getLatLon()) ?? [];
+ if (!lat || !lon) {
+ console.log('Skipping position draw');
+
+ return;
+ }
+
+ const x = ((lon + 180) / 360) * canvas.width;
+ const y = ((90 - lat) / 180) * canvas.height;
+ const col = (x / cellW) | 0;
+ const row = (y / cellH) | 0;
+ const cellX = cellW * col;
+ const cellY = cellH * row;
+ // Draw panel preview
+ contextPanel.drawImage(canvas, cellX, cellY, cellW, cellH, 0, 0, canvasPanel.width, canvasPanel.height);
+ // Draw position
+ context.fillStyle = locationColorInput.value;
+ context.fillRect(x - 4, y - 4, 8, 8);
+ context.fillStyle = '#000000'; // restore
+ // Visualize a border around the cell on the map preview
+ context.strokeStyle = locationColorInput.value;
+ context.lineWidth = 4;
+ context.strokeRect(cellX, cellY, cellW, cellH);
+ context.strokeStyle = '#ffffff'; // restore
+ // Scale and draw position on panel preview
+ const pX = (x - cellX) * (canvasPanel.width / cellW);
+ const pY = (y - cellY) * (canvasPanel.height / cellH);
+ contextPanel.fillStyle = locationColorInput.value;
+ contextPanel.fillRect(pX - 2, pY - 2, 4, 4);
+ contextPanel.fillStyle = '#000000'; // restore
+ // TODO: use `routeColorInput` to draw `positions`
+};
+
+const getLatLon = async () => {
+ let pos = undefined;
+ if (locationUrl.value && locationMethod.value && locationRegex.value) {
+ const api = await getFromAPI();
+ if (api?.length === 2) {
+ console.log('Got position from API', api);
+ pos = api;
+ }
+ }
+
+ if (!pos?.every(Boolean) && locationLatitude.value.trim() && locationLongitude.value.trim()) {
+ pos = [parseFloat(locationLatitude.value.trim()), parseFloat(locationLongitude.value.trim())];
+ console.log('Got position from config', pos);
+ }
+
+ return pos;
+};
+
+const getFromAPI = async () => {
+ if (!locationUrl.value || !locationMethod.value || !locationRegex.value) {
+ return undefined;
+ }
+
+ const url = new URL(locationUrl.value);
+ const method = (locationMethod.value ?? 'GET').toUpperCase();
+ let body = undefined;
+ if (locationBody.value) {
+ if (method === 'GET') {
+ url.search = locationBody.value;
+ } else {
+ body = locationBody.value;
+ }
+ }
+
+ let headers = new Headers();
+ if (locationHeaders.value) {
+ headers = new Headers(JSON.parse(locationHeaders.value));
+ }
+ if (url.username && url.password) {
+ const auth = `${url.username}:${url.password}`;
+ const b64 = btoa(String.fromCharCode(...new TextEncoder().encode(auth)));
+ headers.set('Authorization', `Basic ${b64}`);
+ url.username = '';
+ url.password = '';
+ }
+
+ showMessage(locationTestMessage, 'Sending request...', 'is-primary');
+ const res = await handleFetch(url, { method, headers, body });
+ if (!res) {
+ showMessage(locationTestMessage, 'Error in API request', 'is-error');
+
+ return undefined;
+ }
+
+ const text = await res.text();
+ const re = new RegExp(locationRegex.value);
+ const matches = re.exec(text);
+ if (!matches || matches.length === 0) {
+ showMessage(locationTestMessage, 'Regex did not match', 'is-error');
+
+ return undefined;
+ }
+
+ matches.shift();
+ showMessage(locationTestMessage, `Regex matched: ${matches}`, 'is-success');
+
+ return matches.map((m) => parseFloat(m.trim()));
+};
+
+/**
+ * Initialize map module.
+ *
+ * @param {import('./state.js').State} state
+ */
+export const map = (state) => {
+ // Fill canvas with black, because the LED panel is black
+ context.fillRect(0, 0, canvas.width, canvas.height);
+
+ // Get current image
+ state.map().then((map) => {
+ if (!map) {
+ return;
+ }
+
+ handleFile(map.file);
+ });
+
+ // Get current config
+ state.config().then((config) => {
+ if (!config) {
+ return;
+ }
+
+ locationLatitude.value = config.map.latitude;
+ locationLongitude.value = config.map.longitude;
+ locationUrl.value = config.map.url;
+ locationMethod.value = (config.map.method || 'GET').toUpperCase();
+ locationRegex.value = config.map.regex;
+ locationBody.value = config.map.body;
+ locationHeaders.value = config.map.headers;
+ pointSizeInput.value = config.map.pointSize || 2;
+ locationColorInput.value = rgbToHex(config.map.pointColor || 63488); // red
+ routeColorInput.value = rgbToHex(config.map.trackColor || 31); // blue
+ positions = config.map.positions || [];
+ });
+
+ // Get current free disk
+ state.status().then((status) => {
+ if (!status) {
+ return;
+ }
+
+ const {
+ filesystem: { size, used },
+ } = status;
+ const free = size - used;
+ if (free < maxSize) {
+ maxSize = (free * 0.9) | 0; // leave some space
+ }
+ });
+
+ // Clear selected file and canvas
+ fileRemove.addEventListener('click', () => {
+ fileInput.value = null;
+ fileLabel.innerText = initialFileLabelText;
+ fileRemove.classList.add('is-hidden');
+ currentFile = undefined;
+ imageData = undefined;
+ // Fill canvas with black, because the LED panel is black
+ context.fillRect(0, 0, canvas.width, canvas.height);
+ contextPanel.fillRect(0, 0, canvasPanel.width, canvasPanel.height);
+ setDisabled(submitFile, true);
+ resetMessage(feedbackMessage);
+ });
+
+ // Enable submit and redraw map
+ showGrid.addEventListener('change', () => {
+ if (!currentFile) {
+ return;
+ }
+
+ handleFile(currentFile);
+ });
+ [
+ zoomInput,
+ pointSizeInput,
+ locationColorInput,
+ routeColorInput,
+ locationLatitude,
+ locationLongitude,
+ locationUrl,
+ locationMethod,
+ locationRegex,
+ locationBody,
+ ].forEach((elem) =>
+ elem.addEventListener('change', () => {
+ setDisabled(
+ submitFile,
+ !(locationLatitude.value && locationLongitude.value) &&
+ !(locationUrl.value && locationMethod.value && locationRegex.value),
+ );
+ if (!currentFile) {
+ return;
+ }
+
+ handleFile(currentFile);
+ }),
+ );
+
+ // Test location API
+ locationTest.addEventListener('click', () => {
+ if (currentFile) {
+ handleFile(currentFile);
+
+ return;
+ }
+
+ getLatLon();
+ });
+
+ // File selected
+ fileInput.addEventListener('change', (e) => {
+ setDisabled(submitFile, true);
+ if (e.target.files.length === 0) {
+ fileLabel.innerText = initialFileLabelText;
+
+ return;
+ }
+
+ const file = e.target.files[0];
+
+ if (!file.type.startsWith('image/') || file.type === 'image/gif') {
+ console.error(`Selected file not supported: ${file.type}`);
+ showMessage(feedbackMessage, 'Only image files are supported', 'is-error');
+
+ return;
+ }
+
+ // Fill canvas with black, because the LED panel is black
+ context.fillRect(0, 0, canvas.width, canvas.height);
+ resetMessage(feedbackMessage);
+ currentFile = file;
+ fileLabel.innerText = currentFile.name;
+ fileRemove.classList.remove('is-hidden');
+ handleFile(currentFile);
+ });
+
+ // Send map image and config to server
+ submitFile.addEventListener('click', async () => {
+ setDisabled(submitFile, true);
+ if (currentFile && imageData) {
+ showMessage(feedbackMessage, 'Uploading map...', 'is-primary');
+
+ uploadProgress.classList.remove('is-hidden');
+ const success = await state.uploadMap(imageData, 'image/jpeg', (progress) =>
+ setProgress(uploadProgress, (100 * progress.current) / progress.total, 'is-primary'),
+ );
+ uploadProgress.classList.add('is-hidden');
+ setProgress(uploadProgress, undefined);
+ if (!success) {
+ showMessage(feedbackMessage, 'Error uploading map', 'is-error');
+ setDisabled(submitFile, false);
+
+ return;
+ }
+
+ showMessage(feedbackMessage, 'Map uploaded', 'is-success');
+ }
+
+ showMessage(feedbackMessage, 'Saving...', 'is-primary');
+ const res = await state.saveConfig({
+ map: {
+ latitude: locationLatitude.value,
+ longitude: locationLongitude.value,
+ url: locationUrl.value,
+ method: (locationMethod.value || 'GET').toUpperCase(),
+ regex: locationRegex.value,
+ body: locationBody.value,
+ headers: locationHeaders.value,
+ pointSize: pointSizeInput.value,
+ pointColor: hexToRgb(locationColorInput.value || '#ff0000'),
+ trackColor: hexToRgb(routeColorInput.value || '#0000ff'),
+ },
+ });
+ if (!res) {
+ showMessage(feedbackMessage, 'Error saving configuration', 'is-error');
+ setDisabled(submitFile, false);
+
+ return;
+ }
+
+ showMessage(feedbackMessage, 'Saved', 'is-success');
+ });
+
+ // Delete map
+ deleteMap.addEventListener('click', () => deleteMapConfirm.showModal());
+ deleteMapConfirm.addEventListener('close', async () => {
+ const confirmed = parseInt(deleteMapConfirm.returnValue, 10);
+ if (!confirmed) {
+ return;
+ }
+
+ setDisabled(submitFile, true);
+ setDisabled(deleteMap, true);
+ showMessage(feedbackMessage, 'Deleting map...', 'is-primary');
+ setDisabled(deleteMap, false);
+ if (!(await state.deleteMap())) {
+ showMessage(feedbackMessage, 'Error deleting map', 'is-error');
+
+ return;
+ }
+
+ showMessage(feedbackMessage, 'Map deleted', 'is-success');
+ if (imageData?.byteLength) {
+ setDisabled(submitFile, false);
+ }
+ });
+};
diff --git a/web/src/media.js b/web/src/media.js
new file mode 100644
index 0000000..815991f
--- /dev/null
+++ b/web/src/media.js
@@ -0,0 +1,370 @@
+import {
+ humanFileSize,
+ resetMessage,
+ setDisabled,
+ showMessage,
+ readImage,
+ canvasToImage,
+ setProgress,
+} from './utils.js';
+import { parseGIF, decompressFrames } from 'gifuct-js';
+import { quantize, applyPalette, GIFEncoder } from 'gifenc';
+
+/**
+ * Original code taken from https://github.com/javl/image2cpp
+ */
+
+const submitFile = document.getElementById('submit-file');
+setDisabled(submitFile, true);
+const feedbackMessage = document.getElementById('submit-file-message');
+resetMessage(feedbackMessage);
+const uploadProgress = document.getElementById('submit-file-progress');
+const deleteMedia = document.getElementById('delete-media');
+const deleteMediaConfirm = document.getElementById('delete-media-confirm');
+const fileInput = document.getElementById('file');
+fileInput.value = null;
+const fileLabel = fileInput.parentElement.querySelector('span');
+const initialFileLabelText = fileLabel.innerText;
+const fileRemove = document.getElementById('remove-file');
+fileRemove.classList.add('is-hidden');
+const gifPreview = document.getElementById('gif-preview');
+gifPreview.classList.add('is-hidden');
+/** @type {HTMLCanvasElement} */
+const canvas = document.getElementById('drawer-preview');
+canvas.width = 64;
+canvas.height = 64;
+/** @type {CanvasRenderingContext2D} */
+const context = canvas.getContext('2d');
+context.fillStyle = '#000000ff';
+
+// GIF stuff
+const tmpCanvas = new OffscreenCanvas(canvas.width, canvas.height);
+const tmpContext = tmpCanvas.getContext('2d');
+/** @type {ImageData|undefined} */
+let frameImageData;
+/** @type {import('gifuct-js').ParsedFrame|undefined} */
+let previousFrameData;
+/** @type {ImageData|undefined} */
+let previousImageData;
+/** @type {number} pixel ratio (canvas / GIF) */
+let ratio = 1;
+let offsetX, offsetY;
+let gifEncoder;
+/** @type {Uint8Array|undefined} */
+let gifData;
+/** @type {Uint8Array|undefined} */
+let imageData;
+let maxSize = 1024 * 1024; // 1 MiB, overridden if disk free size is lower
+
+/**
+ * @param {File} file
+ */
+const loadImage = async (file) => {
+ const img = await readImage(file);
+ // Center and scale image to canvas size keeping ratio
+ const imgW = img.width;
+ const imgH = img.height;
+ const ratio = Math.min(canvas.width / imgW, canvas.height / imgH);
+ const dstW = (imgW * ratio) | 0;
+ const dstH = (imgH * ratio) | 0;
+ const dstX = dstW < canvas.width ? ((canvas.width - dstW) / 2) | 0 : 0;
+ const dstY = dstH < canvas.height ? ((canvas.height - dstH) / 2) | 0 : 0;
+
+ // Draw preview
+ context.drawImage(img, 0, 0, imgW, imgH, dstX, dstY, dstW, dstH);
+ imageData = await canvasToImage(canvas).then((blob) => blob.bytes());
+ if (imageData.byteLength > maxSize) {
+ console.error(
+ `Not enough space for storing image (${humanFileSize(imageData.byteLength)}), max size allowed is ${humanFileSize(maxSize)}`,
+ );
+ showMessage(feedbackMessage, `Resized image exceeds maximum size of ${humanFileSize(maxSize)}`, 'is-error');
+
+ return;
+ }
+
+ setDisabled(submitFile, false);
+ console.log(`Image is ${humanFileSize(imageData.byteLength)}`);
+};
+
+/**
+ * @param {File} file
+ */
+const loadGif = async (file) => {
+ const start = Date.now();
+ showMessage(feedbackMessage, 'Generating GIF...', 'is-primary');
+ const gif = parseGIF(await file.arrayBuffer());
+ const frames = decompressFrames(gif, true);
+ // Get pixel ratio of first frame
+ ratio = Math.min(canvas.width / frames[0].dims.width, canvas.height / frames[0].dims.height);
+ const scaledGifW = gif.lsd.width * ratio;
+ const scaledGifH = gif.lsd.height * ratio;
+ offsetX = scaledGifW < canvas.width ? (canvas.width - scaledGifW) / 2 : 0;
+ offsetY = scaledGifH < canvas.height ? (canvas.height - scaledGifH) / 2 : 0;
+ gifEncoder = new GIFEncoder({ auto: true });
+ console.log(
+ `Rendering GIF: ${frames.length} frames, ${(frames.reduce((tot, frm) => tot + frm.delay, 0) / 1000).toPrecision(2)}s, ${humanFileSize(file.size)}`,
+ );
+ for (let i = 0; i < frames.length; i++) {
+ const frameData = renderFrame(frames[i], i === 0);
+ encodeFrame(frameData, frames[i].delay);
+ }
+
+ gifData = finishScaledGif();
+ console.log(`Rendering took ${(Date.now() - start) / 1000}s, result file is ${humanFileSize(gifData.byteLength)}`);
+ if (gifData.byteLength > maxSize) {
+ console.error(`GIF size is ${humanFileSize(gifData.byteLength)}, max size allowed is ${humanFileSize(maxSize)}`);
+ showMessage(feedbackMessage, `Scaled down GIF exceeds maximum size of ${humanFileSize(maxSize)}`, 'is-error');
+
+ return;
+ }
+
+ resetMessage(feedbackMessage);
+ setDisabled(submitFile, false);
+};
+
+const resetGif = () => {
+ frameImageData = undefined;
+ previousFrameData = undefined;
+ previousImageData = undefined;
+ gifData = undefined;
+ gifEncoder = undefined;
+};
+
+/**
+ * Adapted from:
+ * - https://github.com/deanm/omggif/pull/31/files
+ * - https://github.com/matt-way/gifuct-js/blob/55cecc4d1ca5ea073f962a73ec31582e392fcdde/demo/demo.js
+ */
+const renderFrame = (frame, first = false) => {
+ if (previousFrameData) {
+ switch (previousFrameData.disposalType) {
+ case 0: // "No disposal specified"
+ case 1: // "Do not dispose"
+ // do nothing, we draw over the existing canvas
+ break;
+
+ case 2: // "Restore to background"
+ // fill with black, because LED panel is black
+ // NOTE: only the frame's area, not the whole canvas
+ context.fillRect(
+ previousFrameData.dims.left * ratio + offsetX,
+ previousFrameData.dims.top * ratio + offsetY,
+ previousFrameData.dims.width * ratio,
+ previousFrameData.dims.height * ratio,
+ );
+ break;
+
+ case 3: // "Restore to previous"
+ // revert back to most recent frame that was not set to "Restore to previous", or frame 0
+ if (previousImageData) {
+ context.putImageData(previousImageData, 0, 0);
+ }
+ break;
+
+ default:
+ console.error(`Unknown disposal method: ${previousFrameData.disposalType}`);
+ }
+ }
+ if (first || frame.disposalType < 2) {
+ previousImageData = context.getImageData(0, 0, canvas.width, canvas.height);
+ }
+
+ if (!frameImageData || frame.dims.width != frameImageData.width || frame.dims.height != frameImageData.height) {
+ tmpCanvas.width = frame.dims.width;
+ tmpCanvas.height = frame.dims.height;
+ frameImageData = tmpContext.createImageData(tmpCanvas.width, tmpCanvas.height);
+ }
+
+ frameImageData.data.set(frame.patch);
+ tmpContext.putImageData(frameImageData, 0, 0);
+
+ // Center and scale frame
+ const dstW = frame.dims.width * ratio;
+ const dstH = frame.dims.height * ratio;
+ const dstX = frame.dims.left * ratio + offsetX;
+ const dstY = frame.dims.top * ratio + offsetY;
+
+ context.drawImage(tmpCanvas, dstX, dstY, dstW, dstH);
+ previousFrameData = frame;
+
+ return context.getImageData(0, 0, canvas.width, canvas.height);
+};
+
+/**
+ * @param {ImageData} imageData
+ * @param {number} delay
+ */
+const encodeFrame = (imageData, delay) => {
+ const { data, width, height } = imageData;
+ const palette = quantize(data, 256, { format: 'rgb565' });
+ const index = applyPalette(data, palette, 'rgb565');
+ gifEncoder.writeFrame(index, width, height, { palette, delay });
+};
+
+/**
+ *
+ * @return {Uint8Array}
+ */
+const finishScaledGif = () => {
+ gifEncoder.finish();
+ const blob = new Blob([gifEncoder.bytes()], { type: 'image/gif' });
+ const reader = new FileReader();
+ reader.addEventListener('load', () => {
+ gifPreview.src = reader.result;
+ canvas.classList.add('is-hidden');
+ gifPreview.classList.remove('is-hidden');
+ });
+ reader.readAsDataURL(blob);
+
+ return gifEncoder.bytes();
+};
+
+/**
+ * Initialize media module.
+ *
+ * @param {import('./state.js').State} state
+ */
+export const media = (state) => {
+ // Fill canvas with black, because the LED panel is black
+ context.fillRect(0, 0, canvas.width, canvas.height);
+
+ // Get current image
+ state.media().then(async (media) => {
+ if (!media) {
+ return;
+ }
+
+ if (media.contentType === 'image/gif') {
+ await loadGif(media.file);
+ } else if (media.contentType.startsWith('image/')) {
+ await loadImage(media.file);
+ } else {
+ console.error(`Unhandled content type for current image: ${media.contentType}`);
+ }
+
+ setDisabled(submitFile, true);
+ });
+
+ // Get current free disk
+ state.status().then((status) => {
+ if (!status) {
+ return;
+ }
+
+ const {
+ filesystem: { size, used },
+ } = status;
+ const free = size - used;
+ if (free < maxSize) {
+ maxSize = (free * 0.9) | 0; // leave some space
+ }
+ });
+
+ // Clear selected file and canvas
+ fileRemove.addEventListener('click', () => {
+ fileInput.value = null;
+ fileLabel.innerText = initialFileLabelText;
+ fileRemove.classList.add('is-hidden');
+ resetGif();
+ imageData = undefined;
+ // Fill canvas with black, because the LED panel is black
+ context.fillRect(0, 0, canvas.width, canvas.height);
+ setDisabled(submitFile, true);
+ resetMessage(feedbackMessage);
+ canvas.classList.remove('is-hidden');
+ gifPreview.classList.add('is-hidden');
+ });
+
+ // File selected
+ fileInput.addEventListener('change', async (e) => {
+ setDisabled(submitFile, true);
+ if (e.target.files.length === 0) {
+ fileLabel.innerText = initialFileLabelText;
+
+ return;
+ }
+
+ const file = e.target.files[0];
+
+ if (!file.type.startsWith('image/')) {
+ console.error(`Selected media is not an image: ${e.target.files[0].type}`);
+ showMessage(feedbackMessage, 'Only image files are supported', 'is-error');
+
+ return;
+ }
+
+ // Fill canvas with black, because the LED panel is black
+ context.fillRect(0, 0, canvas.width, canvas.height);
+ resetMessage(feedbackMessage);
+ resetGif();
+ imageData = undefined;
+ canvas.classList.remove('is-hidden');
+ gifPreview.classList.add('is-hidden');
+ fileLabel.innerText = file.name;
+ fileRemove.classList.remove('is-hidden');
+ if (file.type === 'image/gif') {
+ loadGif(file);
+
+ return;
+ }
+
+ loadImage(file);
+ });
+
+ // Send file bytes to server
+ submitFile.addEventListener('click', async () => {
+ setDisabled(submitFile, true);
+ showMessage(feedbackMessage, 'Uploading...', 'is-primary');
+ let data, contentType;
+ if (imageData) {
+ data = imageData;
+ contentType = 'image/jpeg';
+ } else if (gifData) {
+ data = gifData;
+ contentType = 'image/gif';
+ } else {
+ console.error('Neither image or gif selected');
+
+ return;
+ }
+
+ uploadProgress.classList.remove('is-hidden');
+ const success = await state.uploadMedia(data, contentType, (progress) =>
+ setProgress(uploadProgress, (100 * progress.current) / progress.total, 'is-primary'),
+ );
+ uploadProgress.classList.add('is-hidden');
+ setProgress(uploadProgress, undefined);
+ if (!success) {
+ showMessage(feedbackMessage, 'Error uploading file', 'is-error');
+ setDisabled(submitFile, false);
+
+ return;
+ }
+
+ showMessage(feedbackMessage, 'File uploaded', 'is-success');
+ });
+
+ // Delete image/gif
+ deleteMedia.addEventListener('click', () => deleteMediaConfirm.showModal());
+ deleteMediaConfirm.addEventListener('close', async () => {
+ const confirmed = parseInt(deleteMediaConfirm.returnValue, 10);
+ if (!confirmed) {
+ return;
+ }
+
+ setDisabled(submitFile, true);
+ setDisabled(deleteMedia, true);
+ showMessage(feedbackMessage, 'Deleting media...', 'is-primary');
+ setDisabled(deleteMedia, false);
+ if (!(await state.deleteMedia())) {
+ showMessage(feedbackMessage, 'Error deleting media', 'is-error');
+
+ return;
+ }
+
+ showMessage(feedbackMessage, 'Media deleted', 'is-success');
+ if (imageData?.byteLength || gifData?.byteLength) {
+ setDisabled(submitFile, false);
+ }
+ });
+};
diff --git a/web/src/state.js b/web/src/state.js
new file mode 100644
index 0000000..173b81d
--- /dev/null
+++ b/web/src/state.js
@@ -0,0 +1,341 @@
+import { handleFetch } from './utils.js';
+
+/**
+ * @typedef SystemStatus
+ * @property {{ size: number, free: number, minFree: number, maxFreeBlock: number }} heap
+ * @property {number} flash
+ * @property {{ size: number, used: number, version: string }} firmware
+ * @property {{ size: number, used: number }} filesystem
+ * @property {{ model: string, revision: string, cores: number, clock: number }} chip
+ * @property {string} sdk
+ */
+
+/**
+ * @typedef MapConfiguration
+ * @property {string} latitude
+ * @property {string} longitude
+ * @property {string} url
+ * @property {string} method
+ * @property {string} regex
+ * @property {string} body
+ * @property {string} headers
+ * @property {string} pointSize
+ * @property {number} pointColor
+ * @property {number} trackColor
+ * @property {{ latitude: string, longitude: string }[]} positions
+ */
+
+/**
+ * @typedef Configuration
+ * @property {{ ssid: string, password: string }} wifi
+ * @property {{ brightness: number }} panel
+ * @property {{ level: number }} log
+ * @property {MapConfiguration} map
+ */
+
+export class State {
+ #baseUrl;
+ #statusPromise = null;
+ #configPromise = null;
+ #mediaPromise = null;
+ #mapPromise = null;
+
+ constructor(baseUrl) {
+ this.#baseUrl = baseUrl;
+ }
+
+ /**
+ * Get a File from a response.
+ *
+ * @param {Response} response
+ * @returns {Promise}
+ */
+ static async #fileFromResponse(response) {
+ const contentType = response.headers.get('Content-Type');
+ let file;
+ switch (contentType) {
+ case 'image/jpeg':
+ file = new File([await response.blob()], 'media.jpg', { type: 'image/jpeg' });
+ break;
+
+ case 'image/gif':
+ file = new File([await response.blob()], 'media.gif', { type: 'image/gif' });
+ break;
+
+ default:
+ console.error(`Unhandled media type ${contentType}`);
+
+ return undefined;
+ }
+
+ return file;
+ }
+
+ /**
+ * Clear stored state.
+ */
+ clear() {
+ this.#statusPromise = null;
+ this.#configPromise = null;
+ this.#mediaPromise = null;
+ this.#mapPromise = null;
+ }
+
+ /**
+ * Preload state. Pass `false` to reduce load on the server.
+ *
+ * @param {boolean} parallel
+ * @returns {Promise<[SystemStatus | undefined, Configuration | undefined, { file: File, contentType: string } | undefined, { file: File, contentType: string } | undefined]>}
+ */
+ preload(parallel = true) {
+ if (parallel) {
+ return Promise.all([this.status(), this.config(), this.media(), this.map()]);
+ }
+
+ return [this.status, this.config, this.media, this.map].reduce(
+ (p, f) =>
+ p.then((r) =>
+ f
+ .bind(this)()
+ .then((s) => [...r, s])
+ .catch((e) => {
+ console.error(`Error preloading data: ${e.toString()}`);
+
+ return r;
+ }),
+ ),
+ Promise.resolve([]),
+ );
+ }
+
+ /**
+ * Upload a file with progress callback.
+ *
+ * Uses XMLHttpRequest because fetch with streamable responses requires HTTP 2 (for half mutex), but the server does not support it.
+ *
+ * @param {URL} url
+ * @param {Uint8Array} data
+ * @param {string} type
+ * @param {({ total: number, current: number }) => void} progress
+ * @returns {Promise}
+ */
+ #uploadFile(url, data, type, progress = undefined) {
+ return new Promise((resolve, reject) => {
+ const req = new XMLHttpRequest();
+ req.upload.addEventListener('progress', (event) => {
+ if (!event.lengthComputable) {
+ return;
+ }
+
+ progress?.({ total: event.total, current: event.loaded });
+ });
+ req.addEventListener('load', () => {
+ resolve(req.readyState === 4 && req.status >= 200 && req.status < 300);
+ });
+ req.addEventListener('error', () => {
+ reject('Error uploading media');
+ });
+
+ req.open('POST', url);
+ req.setRequestHeader('Content-Type', type);
+ req.send(data);
+ });
+ }
+
+ /**
+ * Get system status.
+ *
+ * @property {boolean} refresh
+ * @returns {Promise}
+ */
+ status(refresh = false) {
+ if (this.#statusPromise && !refresh) {
+ return this.#statusPromise;
+ }
+
+ console.log('Fetching status');
+
+ return (this.#statusPromise = handleFetch(new URL('status', this.#baseUrl)).then(async (res) => {
+ try {
+ const text = await res.text();
+ return JSON.parse(text);
+ } catch (_) {
+ return undefined;
+ }
+ }));
+ }
+
+ /**
+ * Get configuration.
+ *
+ * @property {boolean} refresh
+ * @returns {Promise}
+ */
+ config(refresh = false) {
+ if (this.#configPromise && !refresh) {
+ return this.#configPromise;
+ }
+
+ console.log('Fetching config');
+
+ return (this.#configPromise = handleFetch(new URL('config', this.#baseUrl)).then(async (res) => {
+ try {
+ const text = await res.text();
+ return JSON.parse(text);
+ } catch (_) {
+ return undefined;
+ }
+ }));
+ }
+
+ /**
+ * Save configuration.
+ *
+ * @param {Partial} config
+ * @returns {Promise}
+ */
+ async saveConfig(config) {
+ const res = await handleFetch(new URL('config', this.#baseUrl), {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(config),
+ });
+
+ if (!res) {
+ return undefined;
+ }
+
+ return this.config(true);
+ }
+
+ /**
+ * Get media.
+ *
+ * @property {boolean} refresh
+ * @returns {Promise<{ file: File, contentType: string } | undefined>}
+ */
+ media(refresh = false) {
+ if (this.#mediaPromise && !refresh) {
+ return this.#mediaPromise;
+ }
+
+ console.log('Fetching media');
+
+ return (this.#mediaPromise = handleFetch(new URL('drawer', this.#baseUrl), {
+ headers: { Accept: 'image/jpeg' },
+ }).then(async (res) => {
+ if (!res || res.status === 204 || res.headers.get('Content-Length') === '0') {
+ // no media stored
+ return undefined;
+ }
+
+ const file = await State.#fileFromResponse(res);
+ if (!file) {
+ return undefined;
+ }
+
+ return {
+ file,
+ contentType: res.headers.get('Content-Type'),
+ };
+ }));
+ }
+
+ /**
+ * Upload media file.
+ *
+ * @param {Uint8Array} data
+ * @param {string} type
+ * @param {({ total: number, current: number }) => void} progress
+ * @returns {Promise}
+ */
+ uploadMedia(data, type, progress = undefined) {
+ return this.#uploadFile(new URL('drawer', this.#baseUrl), data, type, progress);
+ }
+
+ /**
+ * Delete media file.
+ *
+ * @returns {Promise}
+ */
+ async deleteMedia() {
+ return (await handleFetch(new URL('drawer', this.#baseUrl), { method: 'DELETE' })) !== undefined;
+ }
+
+ /**
+ * Get map.
+ *
+ * @property {boolean} refresh
+ * @returns {Promise<{ file: File, contentType: string } | undefined>}
+ */
+ map(refresh = false) {
+ if (this.#mapPromise && !refresh) {
+ return this.#mapPromise;
+ }
+
+ console.log('Fetching map');
+
+ return (this.#mapPromise = handleFetch(new URL('map', this.#baseUrl), { headers: { Accept: 'image/jpeg' } }).then(
+ async (res) => {
+ if (!res || res.status === 204 || res.headers.get('Content-Length') === '0') {
+ // no media stored
+ return undefined;
+ }
+
+ const file = await State.#fileFromResponse(res);
+ if (!file) {
+ return undefined;
+ }
+
+ return {
+ file,
+ contentType: res.headers.get('Content-Type'),
+ };
+ },
+ ));
+ }
+
+ /**
+ * Upload map file.
+ *
+ * @param {Uint8Array} data
+ * @param {string} type
+ * @param {({ total: number, current: number }) => void} progress
+ * @returns {Promise}
+ */
+ async uploadMap(data, type, progress = undefined) {
+ return this.#uploadFile(new URL('map', this.#baseUrl), data, type, progress);
+ }
+
+ /**
+ * Delete map file.
+ *
+ * @returns {Promise}
+ */
+ async deleteMap() {
+ return (await handleFetch(new URL('map', this.#baseUrl), { method: 'DELETE' })) !== undefined;
+ }
+
+ /**
+ * Upload OTA file.
+ *
+ * @param {File} file
+ * @param {string} mode
+ * @param {({ total: number, current: number }) => void} progress
+ * @returns {Promise}
+ */
+ async uploadOTA(file, mode, progress = undefined) {
+ const url = new URL('ota', this.#baseUrl);
+ url.searchParams.set('mode', mode);
+ url.searchParams.set('size', file.size);
+ if (!(await handleFetch(url))) {
+ return false;
+ }
+
+ if (!(await this.#uploadFile(new URL('ota', this.#baseUrl), file, 'application/octet-stream', progress))) {
+ return false;
+ }
+
+ return true;
+ }
+}
diff --git a/web/src/style.css b/web/src/style.css
index d3ac0b8..2a78cd6 100644
--- a/web/src/style.css
+++ b/web/src/style.css
@@ -1,74 +1,112 @@
@import 'nes.css/css/nes.min.css';
@font-face {
- font-family: "Press Start 2P";
- src: url(/PressStart2P-Regular.ttf) format("truetype");
+ font-family: 'Press Start 2P';
+ src: url(/PressStart2P-Regular.ttf) format('truetype');
}
html * {
- font-family: "Press Start 2P", system-ui;
- font-weight: 400;
- font-style: normal;
+ font-family: 'Press Start 2P', system-ui;
+ font-weight: 400;
+ font-style: normal;
}
.container {
- display: flex;
- flex-direction: column;
- gap: 5vh;
- padding: 3vw 5vh;
+ display: flex;
+ flex-direction: column;
+ gap: 5vh;
+ padding: 5vh 3vw;
}
section.nes-container.with-title > div > * {
- margin-bottom: 1.5rem;
+ margin-bottom: 1.5rem;
}
section.nes-container.with-title > div > button {
- max-width: fit-content;
+ max-width: fit-content;
}
.logs textarea {
- height: 200px;
- max-height: 500px;
- overflow: scroll;
+ height: 200px;
+ max-height: 500px;
+ overflow: scroll;
}
.is-hidden {
- display: none;
+ display: none;
}
.row-field {
- display: flex;
- gap: 1vw;
+ display: flex;
+ gap: 1vw;
}
canvas {
- border: 2px solid #000;
+ border: 2px solid #000;
}
/* enlarge preview with CSS, actual size remains unchanged */
canvas#drawer-preview,
-img#gif-preview {
- width: 256px;
- height: 256px;
+img#gif-preview,
+canvas#panel-preview {
+ width: 256px;
+ height: 256px;
+}
+
+canvas#map-preview {
+ max-width: 80vw;
+}
+
+progress#submit-file-progress,
+progress#submit-map-progress,
+progress#submit-update-progress {
+ max-width: 50%;
}
/* taken from NES.CSS examples */
.message-list > .message.-left {
- align-self: flex-start;
+ align-self: flex-start;
}
.message-list > .message.-right {
- align-self: flex-end;
+ align-self: flex-end;
}
.message-list > .message {
- display: flex;
- margin-top: 2rem;
+ display: flex;
+ margin-top: 2rem;
}
.message-list > .message.-left i {
- margin-right: 2rem;
+ margin-right: 2rem;
}
.message-list > .message.-right i {
- margin-left: 2rem;
+ margin-left: 2rem;
}
.message-list > .message i {
- align-self: flex-end;
+ align-self: flex-end;
+}
+
+/* taken from NES.CSS code */
+.pointer {
+ cursor:
+ url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAAzElEQVRYR+2X0Q6AIAhF5f8/2jYXZkwEjNSVvVUjDpcrGgT7FUkI2D9xRfQETwNIiWO85wfINfQUEyxBG2ArsLwC0jioGt5zFcwF4OYDPi/mBYKm4t0U8ATgRm3ThFoAqkhNgWkA0jJLvaOVSs7j3qMnSgXWBMiWPXe94QqMBMBc1VZIvaTu5u5pQewq0EqNZvIEMCmxAawK0DNkay9QmfFNAJUXfgGgUkLaE7j/h8fnASkxHTz0DGIBMCnBeeM7AArpUd3mz2x3C7wADglA8BcWMZhZAAAAAElFTkSuQmCC)
+ 14 0,
+ pointer;
+}
+
+@media screen and (width < 768px) {
+ html,
+ body {
+ font-size: 12px;
+ }
+
+ .container {
+ gap: 3vh;
+ }
+
+ /* enlarge preview with CSS, actual size remains unchanged */
+ canvas#drawer-preview,
+ img#gif-preview,
+ canvas#panel-preview {
+ width: 512px;
+ height: 512px;
+ }
}
diff --git a/web/src/system.js b/web/src/system.js
index 96fdf53..01d51fa 100644
--- a/web/src/system.js
+++ b/web/src/system.js
@@ -1,4 +1,4 @@
-import { humanFileSize } from "./utils";
+import { humanFileSize, setProgress } from './utils';
/**
* @typedef SystemStatus
@@ -10,7 +10,6 @@ import { humanFileSize } from "./utils";
* @property {string} sdk
*/
-let pullTimer = undefined;
const hwMsg = document.getElementById('hardware-message');
const swMsg = document.getElementById('software-message');
const heap = document.getElementById('heap-perc');
@@ -19,53 +18,42 @@ const fw = document.getElementById('firmware-perc');
const heapInfo = document.getElementById('heap-info');
const fsInfo = document.getElementById('fs-info');
const fwInfo = document.getElementById('fw-info');
+const refreshButton = document.getElementById('refresh-resources');
/**
- * Get current status.
+ * Update current status.
*
- * @param {string} baseUrl API URL
- * @returns {Promise}
+ * @param {import('./state.js').State} state
*/
-const getStatus = async (baseUrl) => {
- const url = new URL('status', baseUrl);
- const res = await fetch(url)
- if (!res.ok || res.status != 200) {
- throw new Error(`Error getting current status, status code ${res.status}: ${await res.text()}`);
- }
-
- return await res.json();
-};
-
-const setProgress = (elem, progress) => {
- elem.value = progress;
- ['success', 'warning', 'error', 'pattern'].forEach((c) => elem.classList.remove(`is-${c}`));
- if (progress <= 50) {
- elem.classList.add('is-success');
- } else if (progress <= 75) {
- elem.classList.add('is-warning');
- } else {
- elem.classList.add('is-error');
- }
+const updateStatus = async (state) => {
+ const status = await state.status(true);
+ if (!status) {
+ return;
+ }
+
+ // Update hardware/software info
+ hwMsg.innerText = `Running on a ${status.chip.model} (rev. ${status.chip.revision}) with ${status.chip.cores} CPU clocking at ${status.chip.clock} MHz`;
+ swMsg.innerText = `Firmware version ${status.firmware.version}, SDK version ${status.sdk}`;
+
+ // Update resources usage
+ const heapPerc = (100 * (status.heap.size - status.heap.free)) / status.heap.size;
+ const fsPerc = (100 * status.filesystem.used) / status.filesystem.size;
+ const fwPerc = (100 * status.firmware.used) / status.firmware.size;
+ setProgress(heap, heapPerc);
+ setProgress(fs, fsPerc);
+ setProgress(fw, fwPerc);
+ heapInfo.innerText = `(${humanFileSize(status.heap.size - status.heap.free)} / ${humanFileSize(status.heap.size)})`;
+ fsInfo.innerText = `(${humanFileSize(status.filesystem.used)} / ${humanFileSize(status.filesystem.size)})`;
+ fwInfo.innerText = `(${humanFileSize(status.firmware.used)} / ${humanFileSize(status.firmware.size)})`;
};
-const loop = async (baseUrl) => {
- const status = await getStatus(baseUrl);
-
- // Update hardware/software info
- hwMsg.innerText = `Running on a ${status.chip.model} (rev. ${status.chip.revision}) with ${status.chip.cores} CPU clocking at ${status.chip.clock} MHz`;
- swMsg.innerText = `Firmware version ${status.firmware.version}, SDK version ${status.sdk}`;
+/**
+ * Initialize system module.
+ *
+ * @param {import('./state.js').State} state
+ */
+export const system = (state) => {
+ updateStatus(state);
- // Update resources usage
- const heapPerc = 100 * (status.heap.size - status.heap.free) / status.heap.size;
- const fsPerc = 100 * status.filesystem.used / status.filesystem.size;
- const fwPerc = 100 * status.firmware.used / status.firmware.size;
- setProgress(heap, heapPerc);
- setProgress(fs, fsPerc);
- setProgress(fw, fwPerc);
- heapInfo.innerText = `(${humanFileSize(status.heap.size - status.heap.free)} / ${humanFileSize(status.heap.size)})`;
- fsInfo.innerText = `(${humanFileSize(status.filesystem.used)} / ${humanFileSize(status.filesystem.size)})`;
- fwInfo.innerText = `(${humanFileSize(status.firmware.used)} / ${humanFileSize(status.firmware.size)})`;
+ refreshButton.addEventListener('click', () => updateStatus(state));
};
-
-export const system = (baseUrl) =>
- loop(baseUrl).then(() => pullTimer = setTimeout(() => loop(baseUrl), 60_000));
diff --git a/web/src/utils.js b/web/src/utils.js
index f3500cc..d2cb629 100644
--- a/web/src/utils.js
+++ b/web/src/utils.js
@@ -1,10 +1,16 @@
+/**
+ * Disable or enable an input.
+ *
+ * @param {HTMLInputElement} elem Element
+ * @param {boolean} disabled Whether to disable or enable
+ */
export const setDisabled = (elem, disabled = false) => {
- elem.disabled = disabled;
- if (disabled) {
- elem.classList.add('is-disabled');
- } else {
- elem.classList.remove('is-disabled');
- }
+ elem.disabled = disabled;
+ if (disabled) {
+ elem.classList.add('is-disabled');
+ } else {
+ elem.classList.remove('is-disabled');
+ }
};
/**
@@ -17,14 +23,14 @@ export const setDisabled = (elem, disabled = false) => {
* @returns {void}
*/
export const showMessage = (elem, message, className = null, delay = null) => {
- resetMessage(elem, false);
- elem.innerText = message;
- if (className !== null) {
- elem.classList.add(className);
- }
- if (delay !== null) {
- setTimeout(() => resetMessage(elem), delay);
- }
+ resetMessage(elem, false);
+ elem.innerText = message;
+ if (className !== null) {
+ elem.classList.add(className);
+ }
+ if (delay !== null) {
+ setTimeout(() => resetMessage(elem), delay);
+ }
};
/**
@@ -34,11 +40,11 @@ export const showMessage = (elem, message, className = null, delay = null) => {
* @returns {void}
*/
export const resetMessage = (elem, hidden = true) => {
- elem.className = 'nes-text';
- elem.innerText = '';
- if (hidden) {
- elem.classList.add('is-hidden');
- }
+ elem.className = 'nes-text';
+ elem.innerText = '';
+ if (hidden) {
+ elem.classList.add('is-hidden');
+ }
};
/**
@@ -48,8 +54,144 @@ export const resetMessage = (elem, hidden = true) => {
* @returns {string}
*/
export const humanFileSize = (size) => {
- size = parseInt(size, 10);
+ size = parseInt(size, 10);
const i = size === 0 ? 0 : Math.floor(Math.log(size) / Math.log(1024));
return `${Number((size / Math.pow(1024, i)).toFixed(2))} ${['B', 'kB', 'MB', 'GB', 'TB'][i]}`;
};
+
+/**
+ * Wait some time.
+ *
+ * @param {number} ms Milliseconds
+ * @returns {Promise}
+ */
+export const wait = (ms) => new Promise((res) => setTimeout(res, ms));
+
+/**
+ * Wrapper for fetch() that handles errors by logging and returning undefined.
+ *
+ * @param {URL} url
+ * @param {RequestInit} options
+ * @returns {Promise}
+ */
+export const handleFetch = async (url, options = {}) => {
+ try {
+ const resp = await fetch(url, options);
+ if (!resp.ok) {
+ throw new Error(`Status code ${resp.status}`, { cause: { response: resp } });
+ }
+
+ return resp;
+ } catch (e) {
+ console.error(`Error fetching ${url.toString()}: ${e.message}`, e);
+
+ return undefined;
+ }
+};
+
+/**
+ * Read a blob into an Image.
+ *
+ * @param {Blob} data
+ * @returns {Promise}
+ */
+export const readImage = (data) =>
+ new Promise((res, rej) => {
+ const objUrl = window.URL.createObjectURL(data);
+ const img = new Image();
+ img.onerror = (e) => rej(e);
+ img.onload = () => res(img);
+ img.src = objUrl;
+ });
+
+/**
+ * Convert the canvas to an image blob.
+ *
+ * @param {HTMLCanvasElement} canvas
+ * @param {string} type
+ * @returns {Promise}
+ */
+export const canvasToImage = (canvas, type = 'image/jpeg') =>
+ new Promise((res, rej) =>
+ canvas.toBlob((blob) => {
+ if (!blob) {
+ rej('Error generating scaled image');
+
+ return;
+ }
+
+ res(blob);
+ }, type),
+ );
+
+/**
+ * Convert hex string to rgb565.
+ *
+ * @param {string} hex Hexadecimal color
+ * @returns {number}
+ */
+export const hexToRgb = (hex) => {
+ const rgb = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/.exec(hex);
+ if (rgb === null) {
+ console.error(`Hex to rgb regex failed: ${hex}`);
+
+ return null;
+ }
+
+ const r = parseInt(rgb[1], 16);
+ const g = parseInt(rgb[2], 16);
+ const b = parseInt(rgb[3], 16);
+
+ return ((r & 0xf8) << 8) | ((g & 0xfc) << 3) | (b >> 3);
+};
+
+/**
+ * Convert rgb565 to hex string.
+ *
+ * @param {number} rgb RGB565 color
+ * @returns {string}
+ */
+export const rgbToHex = (rgb) => {
+ const rgb565 = [(rgb & 0xf800) >> 11, (rgb & 0x07e0) >> 5, rgb & 0x001f];
+ const rgb888 = [
+ Math.floor((rgb565[0] * 255) / 31 + 0.5),
+ Math.floor((rgb565[1] * 255) / 63 + 0.5),
+ Math.floor((rgb565[2] * 255) / 31 + 0.5),
+ ];
+
+ return `#${rgb888.map((b) => b.toString(16).padStart(2, '0')).join('')}`;
+};
+
+/**
+ * Set progress value and corresponding CSS class to the progress bar.
+ *
+ * @param {HTMLProgressElement} elem
+ * @param {number|string} progress
+ * @param {string} force Force a "is-*" class
+ */
+export const setProgress = (elem, progress = undefined, force = undefined) => {
+ if (progress === undefined) {
+ elem.removeAttribute('value');
+ } else {
+ elem.value = progress;
+ }
+
+ ['success', 'warning', 'error', 'pattern'].forEach((c) => elem.classList.remove(`is-${c}`));
+
+ if (force !== undefined) {
+ elem.classList.add(force);
+
+ return;
+ }
+
+ if (progress === undefined) {
+ elem.classList.add('is-pattern');
+ } else if (progress <= 50) {
+ elem.classList.add('is-success');
+ } else if (progress <= 75) {
+ elem.classList.add('is-warning');
+ } else {
+ elem.classList.add('is-error');
+ }
+};