diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 98f659c..e82a063 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -11,8 +11,9 @@ jobs: build: runs-on: ubuntu-latest timeout-minutes: 15 - permissions: - contents: write + strategy: + matrix: + env: [esp32, matrixportal] steps: - name: Checkout @@ -23,7 +24,7 @@ jobs: - name: Cache uses: actions/cache@v4 with: - key: cache-${{ github.ref_name }} + key: cache-${{ matrix.env }}-${{ github.ref_name }} path: | ~/.cache/pip ~/.platformio/.cache @@ -50,20 +51,45 @@ jobs: cache-dependency-path: 'web/pnpm-lock.yaml' - name: Install C++ dependencies - run: pio pkg install + run: pio pkg install --environment ${{ matrix.env }} - name: Build firmware - run: pio run --environment stable + run: pio run --environment ${{ matrix.env }} - name: Build filesystem - run: pio run --target buildfs --environment stable + run: pio run --target buildfs --environment ${{ matrix.env }} + + - name: Upload build artifacts + uses: actions/upload-artifact@v4 + with: + name: build-${{ matrix.env }} + path: .pio/build/${{ matrix.env }}/ + + release: + needs: build + runs-on: ubuntu-latest + if: ${{ github.ref_type == 'tag' && startsWith(github.ref_name, 'v') }} + permissions: + contents: write + + steps: + - name: Download build artifacts + uses: actions/download-artifact@v4 + + - name: Rename artifacts for release + run: | + mv build-esp32/firmware.bin build-esp32/esp32-firmware.bin + mv build-esp32/littlefs.bin build-esp32/esp32-littlefs.bin + mv build-matrixportal/firmware.bin build-matrixportal/matrixportal-firmware.bin + mv build-matrixportal/littlefs.bin build-matrixportal/matrixportal-littlefs.bin - name: Release - if: ${{ github.ref_type == 'tag' && startsWith(github.ref_name, 'v') }} uses: softprops/action-gh-release@v2 with: make_latest: true generate_release_notes: true files: | - .pio/build/stable/firmware.bin - .pio/build/stable/littlefs.bin + build-esp32/esp32-firmware.bin + build-esp32/esp32-littlefs.bin + build-matrixportal/matrixportal-firmware.bin + build-matrixportal/matrixportal-littlefs.bin diff --git a/partitions-8mb.csv b/partitions-8mb.csv new file mode 100644 index 0000000..c0f1386 --- /dev/null +++ b/partitions-8mb.csv @@ -0,0 +1,7 @@ +# Name, Type, SubType, Offset, Size, Flags +nvs, data, nvs, 0x9000, 0x5000, +otadata, data, ota, 0xe000, 0x2000, +app0, app, ota_0, 0x10000, 0x200000, +app1, app, ota_1, 0x210000,0x200000, +spiffs, data, spiffs, 0x410000,0x3E0000, +coredump, data, coredump,0x7F0000,0x10000, diff --git a/platformio.ini b/platformio.ini index fd9c628..6d17062 100644 --- a/platformio.ini +++ b/platformio.ini @@ -8,10 +8,10 @@ ; Please visit documentation for the other options and examples ; https://docs.platformio.org/page/projectconf.html -[env:stable] -platform = espressif32 -board = esp32dev +; Shared settings +[env] framework = arduino +platform = platformio/espressif32@6.12.0 monitor_speed = 115200 board_build.filesystem = littlefs board_build.partitions = partitions.csv @@ -38,8 +38,6 @@ lib_deps = https://github.com/bitbank2/AnimatedGIF.git#2.2.0 ; Decode JPEGs https://github.com/bitbank2/JPEGDEC.git#1.8.3 -; MPU6050 gyro/accelerometer - jrowberg/I2Cdevlib-MPU6050 build_flags = ; esp32-idf library debug level ; -DCORE_DEBUG_LEVEL=3 @@ -49,3 +47,25 @@ build_flags = ; -DUSE_GFX_LITE=1 ; build without AdafruitGFX; no drawing, text or fonts! ; -DNO_GFX=1 + +; Reference ESP32 devkit board +[env:esp32] +board = esp32dev +lib_deps = + ${env.lib_deps} +; MPU6050 gyro/accelerometer + jrowberg/I2Cdevlib-MPU6050 + +; Adafruit MatrixPortal S3 +[env:matrixportal] +board = adafruit_matrixportal_esp32s3 +board_build.partitions = partitions-8mb.csv +board_build.arduino.memory_type = qio_qspi +; Disable flashing the TinyUF2 bootloader image +board_upload.arduino.flash_extra_images = +lib_deps = + ${env.lib_deps} +; on-board LIS3DH accelerometer + adafruit/Adafruit LIS3DH @ 1.3.0 +; on-board NeoPixel + adafruit/Adafruit NeoPixel @ ^1.12.0 diff --git a/src/main.cpp b/src/main.cpp index 53240d3..c0a99fa 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -1,5 +1,4 @@ #include "main.h" -#include "modules/monitor.h" HUB75_I2S_CFG::i2s_pins pins = {R1_PIN, G1_PIN, B1_PIN, R2_PIN, G2_PIN, B2_PIN, A_PIN, B_PIN, C_PIN, D_PIN, E_PIN, LAT_PIN, OE_PIN, CLK_PIN}; HUB75_I2S_CFG ledConfig( @@ -11,7 +10,7 @@ HUB75_I2S_CFG ledConfig( false, // DMA double buffer (double RAM) I2C_CLK, // i2s clock speed LAT_BLANKING, // how many clock cycles to blank before/after LAT change - true, // i2s clock phase (positive/negative edge) + CLK_PHASE, // i2s clock phase (positive/negative edge) 60, // minimum refresh rate PIXEL_COLOR_DEPTH_BITS // color depth ); @@ -30,6 +29,11 @@ ModeSelector selector; Config conf(updateConfig); Renderer *currentMode = nullptr; Monitor* monitor = nullptr; +StatusLed statusLed; +wl_status_t prevWifiStatus = WL_IDLE_STATUS; +wifi_mode_t prevWifiMode = WIFI_MODE_NULL; +bool otaWasUpdating = false; +ulong lastModePoll = 0; void updateQrText() { const char* url = wifi.getUrl(); @@ -87,6 +91,7 @@ bool changeMode(draw_mode newMode) { updateQrText(); } + statusLed.onModeChanged(newMode); return true; }; @@ -114,14 +119,19 @@ void updateConfig(JsonDocument& newConfig) { if (monitor != nullptr) { monitor->setEnabled(newConfig["monitor"]["enable"].as()); } + + statusLed.setEnabled(newConfig["statusled"]["enable"].as()); }; void setup() { Log::instance()->begin(115200); + statusLed.init(); + statusLed.onBoot(); // Initialize LittleFS if (!LittleFS.begin()) { Log::instance()->error("LittleFS error\n"); + statusLed.onFatalError(); return; } @@ -131,28 +141,45 @@ void setup() { // Configure logs Log::instance()->setLevel(conf.current["log"]["level"].as()); + statusLed.setEnabled((conf.current["statusled"]["enable"] | 1) != 0); // Initialize LED matrix display = setupLED(ledConfig); if (display == nullptr) { Log::instance()->error("Error setting up LED matrix\n"); + statusLed.onFatalError(); return; } display->setBrightness(conf.current["panel"]["brightness"].as()); - // Initialize gyroscope + // Initialize orientation sensor if (!selector.init()) { - return; + Log::instance()->error("Mode selector unavailable, staying in default mode\n"); } + // Start media as default mode + currentMode = &media; + currentMode->open(); + drawMode = DRAW_MODE_IMAGE; + // Initialize WiFi String ssid = conf.current["wifi"]["ssid"].as(); String password = conf.current["wifi"]["password"].as(); if (!wifi.connect(ssid.c_str(), password.c_str())) { + statusLed.onFatalError(); + return; } + if (wifi.getMode() & WIFI_MODE_AP) { + statusLed.onApStarted(); + } else { + statusLed.onWifiConnected(); + prevWifiStatus = WL_CONNECTED; + } + prevWifiMode = wifi.getMode(); + updateQrText(); // Initialize log WebSocket @@ -183,6 +210,11 @@ void setup() { status["heap"]["minFree"] = ESP.getMinFreeHeap(); status["heap"]["maxFreeBlock"] = ESP.getMaxAllocHeap(); status["flash"] = ESP.getFlashChipSize(); + status["psram"]["found"] = psramFound(); + status["psram"]["size"] = ESP.getPsramSize(); + status["psram"]["free"] = ESP.getFreePsram(); + status["psram"]["minFree"] = ESP.getMinFreePsram(); + status["psram"]["maxFreeBlock"] = ESP.getMaxAllocPsram(); status["firmware"]["size"] = ESP.getFreeSketchSpace(); // see: https://github.com/espressif/arduino-esp32/issues/3131 status["firmware"]["used"] = ESP.getSketchSize(); status["firmware"]["version"] = VERSION; // defined at build time, see pre_version.py @@ -205,17 +237,45 @@ void setup() { setupOk = true; }; -ulong lastModePoll = 0; - void loop() { if (!setupOk) { return; } ota.loop(); + statusLed.tick(); + + bool nowUpdating = ota.isUpdating(); + if (nowUpdating && !otaWasUpdating) { + statusLed.onOtaStart(); + } else if (!nowUpdating && otaWasUpdating) { + statusLed.onOtaEnd(); + } + otaWasUpdating = nowUpdating; if (millis() - lastModePoll >= 1000) { lastModePoll = millis(); + + wifi_mode_t wifiMode = wifi.getMode(); + if (wifiMode != prevWifiMode) { + if (wifiMode & WIFI_MODE_AP) { + statusLed.onApStarted(); + } + if (!(wifiMode & WIFI_MODE_STA)) { + prevWifiStatus = WL_IDLE_STATUS; + } + prevWifiMode = wifiMode; + } + if (wifiMode & WIFI_MODE_STA) { + wl_status_t wifiStatus = WiFi.status(); + if (wifiStatus != WL_CONNECTED && prevWifiStatus == WL_CONNECTED) { + statusLed.onWifiReconnecting(); + } else if (wifiStatus == WL_CONNECTED && prevWifiStatus != WL_CONNECTED) { + statusLed.onWifiConnected(); + } + prevWifiStatus = wifiStatus; + } + if (changeMode(selector.getMode())) { return; } diff --git a/src/main.h b/src/main.h index 77e2fbc..3f60f29 100644 --- a/src/main.h +++ b/src/main.h @@ -6,6 +6,8 @@ #include "modules/config.h" #include "modules/wifi.h" #include "modules/led.h" +#include "modules/monitor.h" +#include "modules/statusled.h" #include "modules/modeselector.h" #include "modes/media.h" #include "modes/map.h" @@ -14,7 +16,24 @@ #include "modules/ota.h" #include -// Pins +#if defined(ARDUINO_ADAFRUIT_MATRIXPORTAL_ESP32S3) +// Pins for the Adafruit MatrixPortal S3 board +#define R1_PIN 42 +#define G1_PIN 41 +#define B1_PIN 40 +#define R2_PIN 38 +#define G2_PIN 39 +#define B2_PIN 37 +#define A_PIN 45 +#define B_PIN 36 +#define C_PIN 48 +#define D_PIN 35 +#define E_PIN 21 +#define LAT_PIN 47 +#define OE_PIN 14 +#define CLK_PIN 2 +#else +// Pins for the esp32 board #define R1_PIN R1_PIN_DEFAULT #define G1_PIN G1_PIN_DEFAULT #define B1_PIN B1_PIN_DEFAULT @@ -22,22 +41,29 @@ #define G2_PIN G2_PIN_DEFAULT #define B2_PIN B2_PIN_DEFAULT #define A_PIN A_PIN_DEFAULT -#define B_PIN 22 // changed, was 19 +#define B_PIN 22 #define C_PIN C_PIN_DEFAULT #define D_PIN D_PIN_DEFAULT -#define E_PIN 32 // changed, was -1 +#define E_PIN 32 #define LAT_PIN LAT_PIN_DEFAULT #define OE_PIN OE_PIN_DEFAULT #define CLK_PIN CLK_PIN_DEFAULT +#endif // Matrix #define WIDTH MATRIX_WIDTH -#define HEIGHT 64 // changed, was 32 +#define HEIGHT 64 #define CHAIN CHAIN_LENGTH #define ROWS_IN_PARALLEL MATRIX_ROWS_IN_PARALLEL #define PIXEL_COLOR_DEPTH_BITS 8 #define LAT_BLANKING 2 -#define I2C_CLK HUB75_I2S_CFG::HZ_8M +#define I2C_CLK HUB75_I2S_CFG::HZ_20M + +#if defined(ARDUINO_ADAFRUIT_MATRIXPORTAL_ESP32S3) +#define CLK_PHASE false +#else +#define CLK_PHASE true +#endif // Config #define FPS 15 diff --git a/src/modules/modeselector.h b/src/modules/modeselector.h index eca90b0..25298a8 100644 --- a/src/modules/modeselector.h +++ b/src/modules/modeselector.h @@ -1,14 +1,13 @@ #ifndef _MODESELECTOR_H #define _MODESELECTOR_H -#include "log.h" +#include #include -#include "Wire.h" -#include "I2Cdev.h" -#include "MPU6050_6Axis_MotionApps612.h" +#include "log.h" -#define SDA_PIN 21 // GPIO21 -#define SCL_PIN 19 // GPIO19 +#ifndef M_3PI_4 +#define M_3PI_4 (3.0 * M_PI_4) +#endif // Running mode typedef enum { @@ -21,18 +20,20 @@ typedef enum { class ModeSelector { private: - MPU6050 mpu; - // MPU control/status vars - bool dmpReady = false; // set true if DMP init was successful - uint8_t deviceStatus; // return status after each device operation (0 = success, !0 = error) - uint16_t packetSize; // expected DMP packet size (default is 42 bytes) - uint8_t fifoBuffer[64]; // FIFO storage buffer - // orientation/motion vars - 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; + draw_mode modeFromAngle(float angle) { + if (-M_PI_4 <= angle && angle <= M_PI_4) { // up + return DRAW_MODE_IMAGE; + } else if (M_PI_4 < angle && angle <= M_3PI_4) { // right + return DRAW_MODE_LIFE; + } else if (-M_3PI_4 <= angle && angle < -M_PI_4) { // left + return DRAW_MODE_MAP; + } + + return DRAW_MODE_QR; // down + }; + public: ModeSelector(); ~ModeSelector(); diff --git a/src/modules/modeselector_lis3dh.cpp b/src/modules/modeselector_lis3dh.cpp new file mode 100644 index 0000000..30430dc --- /dev/null +++ b/src/modules/modeselector_lis3dh.cpp @@ -0,0 +1,51 @@ +#if defined(ARDUINO_ADAFRUIT_MATRIXPORTAL_ESP32S3) + +#include "modeselector.h" +#include + +#define LIS3DH_ADDRESS 0x19 + +static Adafruit_LIS3DH accel; +static bool ready = false; + +ModeSelector::ModeSelector() {}; + +ModeSelector::~ModeSelector() {}; + +bool ModeSelector::init() { + if (!accel.begin(LIS3DH_ADDRESS)) { + Log::instance()->error("LIS3DH connection failed\n"); + + return false; + } + + accel.setRange(LIS3DH_RANGE_2_G); + ready = true; + Log::instance()->info("LIS3DH initialized\n"); + + return true; +}; + +draw_mode ModeSelector::getMode() { + if (!ready) { + return DRAW_MODE_NONE; + } + + sensors_event_t event; + if (accel.getEvent(&event)) { + float angle = atan2f(event.acceleration.x, event.acceleration.y); + // Log::instance()->trace( + // "Current orientation: %d° (%.2f rad), accel x %.2f y %.2f z %.2f\n", + // static_cast(angle * 180 / M_PI), + // angle, + // event.acceleration.x, + // event.acceleration.y, + // event.acceleration.z + // ); + previousRead = modeFromAngle(angle); + } + + return previousRead; +}; + +#endif diff --git a/src/modules/modeselector.cpp b/src/modules/modeselector_mpu6050.cpp similarity index 65% rename from src/modules/modeselector.cpp rename to src/modules/modeselector_mpu6050.cpp index 310b0f5..4e8dbf4 100644 --- a/src/modules/modeselector.cpp +++ b/src/modules/modeselector_mpu6050.cpp @@ -1,9 +1,28 @@ +#if !defined(ARDUINO_ADAFRUIT_MATRIXPORTAL_ESP32S3) + #include "modeselector.h" +#include "Wire.h" +#include "I2Cdev.h" +#include "MPU6050_6Axis_MotionApps612.h" /** * Original code taken from https://github.com/jrowberg/i2cdevlib/blob/170bad1f4a97b074cd33238a5523c7382a3d8f1e/Arduino/MPU6050/examples/MPU6050_DMP6_using_DMP_V6.12/MPU6050_DMP6_using_DMP_V6.12.ino */ +#define SDA_PIN 21 +#define SCL_PIN 19 + +static MPU6050 mpu; +// MPU control/status vars +static bool dmpReady = false; // set true if DMP init was successful +static uint8_t deviceStatus; // return status after each device operation (0 = success, !0 = error) +static uint16_t packetSize; // expected DMP packet size (default is 42 bytes) +static uint8_t fifoBuffer[64]; // FIFO storage buffer +// orientation/motion vars +static Quaternion q; // [w, x, y, z] quaternion container +static VectorFloat gravity; // [x, y, z] gravity vector +static float ypr[3]; // [yaw, pitch, roll] yaw/pitch/roll in radians + ModeSelector::ModeSelector() {}; ModeSelector::~ModeSelector() {}; @@ -37,7 +56,7 @@ bool ModeSelector::init() { mpu.setDMPEnabled(true); dmpReady = true; packetSize = mpu.dmpGetFIFOPacketSize(); - Log::instance()->info("MP6050 initialized and calibrated\n"); + Log::instance()->info("MPU6050 initialized and calibrated\n"); return true; }; @@ -58,16 +77,10 @@ draw_mode ModeSelector::getMode() { // static_cast(ypr[2] * 180 / M_PI), // ypr[2] // ); - if (-M_PI_4 <= ypr[1] && ypr[1] <= M_PI_4) { // up - previousRead = DRAW_MODE_IMAGE; - } else if (M_PI_4 < ypr[1] && ypr[1] <= M_3PI_4) { // right - previousRead = DRAW_MODE_LIFE; - } else if (-M_3PI_4 <= ypr[1] && ypr[1] < -M_PI_4) { // left - previousRead = DRAW_MODE_MAP; - } else { // down - previousRead = DRAW_MODE_QR; - } + previousRead = modeFromAngle(ypr[1]); } return previousRead; }; + +#endif diff --git a/src/modules/ota.cpp b/src/modules/ota.cpp index 300b5e1..5cbb278 100644 --- a/src/modules/ota.cpp +++ b/src/modules/ota.cpp @@ -56,6 +56,7 @@ void OTA::initServer(AsyncWebServer* server) { return; } + updating = true; request->send(204); }); server->on("/ota", HTTP_POST, @@ -63,6 +64,7 @@ void OTA::initServer(AsyncWebServer* server) { Log::instance()->info("Update received\n"); if (Update.hasError()) { Log::instance()->error("Error completing update: %s\n", Update.errorString()); + updating = false; request->send(500, "text/plain", "error completing update"); return; @@ -77,6 +79,7 @@ void OTA::initServer(AsyncWebServer* server) { if (len > 0) { if (Update.write(data, len) != len) { Log::instance()->error("Error writing update chunk: %s\n", Update.errorString()); + updating = false; request->send(500, "text/plain", "error writing chunk"); return; @@ -84,6 +87,7 @@ void OTA::initServer(AsyncWebServer* server) { } if (index + len == total && !Update.end()) { Log::instance()->error("Error ending update: %s\n", Update.errorString()); + updating = false; request->send(500, "text/plain", "error ending update"); } } @@ -97,3 +101,7 @@ void OTA::loop() { ESP.restart(); } }; + +bool OTA::isUpdating() { + return updating; +}; diff --git a/src/modules/ota.h b/src/modules/ota.h index d7da12d..6b62fcb 100644 --- a/src/modules/ota.h +++ b/src/modules/ota.h @@ -19,12 +19,14 @@ class OTA { private: ulong reboot = 0; uint32_t rebootDelay = 1000; + bool updating = false; public: OTA(uint32_t); ~OTA(); void initServer(AsyncWebServer*); void loop(); + bool isUpdating(); }; #endif diff --git a/src/modules/statusled.h b/src/modules/statusled.h new file mode 100644 index 0000000..12b79fe --- /dev/null +++ b/src/modules/statusled.h @@ -0,0 +1,23 @@ +#ifndef _STATUSLED_H +#define _STATUSLED_H + +#include +#include "modeselector.h" + +class StatusLed { + public: + void init(); + void tick(); + void clear(); + void onBoot(); + void onWifiConnected(); + void onApStarted(); + void onWifiReconnecting(); + void onModeChanged(draw_mode mode); + void onOtaStart(); + void onOtaEnd(); + void onFatalError(); + void setEnabled(bool); +}; + +#endif diff --git a/src/modules/statusled_ledc.cpp b/src/modules/statusled_ledc.cpp new file mode 100644 index 0000000..0f5ebda --- /dev/null +++ b/src/modules/statusled_ledc.cpp @@ -0,0 +1,159 @@ +#if !defined(ARDUINO_ADAFRUIT_MATRIXPORTAL_ESP32S3) + +#include "statusled.h" +#include +#include + +#ifndef LED_BUILTIN +#define LED_BUILTIN 2 +#endif + +static const uint8_t LEDC_CHANNEL = 0; +static const uint32_t LEDC_FREQ_HZ = 5000; +static const uint8_t LEDC_RES_BITS = 8; // duty 0–255 +static const uint8_t BREATHE_UPDATE_MS = 20; // ~50 fps + +// Priority: FATAL > BLINK > OTA > WIFI + +static bool enabled = true; + +static bool fatalActive = false; + +static bool blinkActive = false; +static uint16_t blinkOnMs = 0; +static uint16_t blinkOffMs = 0; +static uint8_t blinkRemaining = 0; +static uint32_t blinkToggleAt = 0; +static bool blinkOn = false; + +static bool otaActive = false; +static uint16_t otaPeriodMs = 0; + +static bool wifiActive = false; +static uint16_t wifiPeriodMs = 0; + +static uint32_t lastBreathUpdate = 0; + +static void applyBreathing(uint16_t periodMs) { + float t = (float)(millis() % periodMs) / (float)periodMs; + float level = 0.5f * (1.0f - cosf(t * 2.0f * 3.14159265f)); + ledcWrite(LEDC_CHANNEL, (uint8_t)(level * 255.0f)); +} + +static void startBlinkN(uint16_t onMs, uint16_t offMs, uint8_t count) { + blinkActive = true; + blinkOnMs = onMs; + blinkOffMs = offMs; + blinkRemaining = count; + blinkOn = true; + blinkToggleAt = millis() + onMs; + ledcWrite(LEDC_CHANNEL, 255); +} + +void StatusLed::init() { + ledcSetup(LEDC_CHANNEL, LEDC_FREQ_HZ, LEDC_RES_BITS); + ledcAttachPin(LED_BUILTIN, LEDC_CHANNEL); + ledcWrite(LEDC_CHANNEL, 0); +} + +void StatusLed::setEnabled(bool val) { + enabled = val; + if (!enabled) { + fatalActive = false; + blinkActive = false; + otaActive = false; + wifiActive = false; + ledcWrite(LEDC_CHANNEL, 0); + } +} + +void StatusLed::tick() { + if (!enabled) return; + uint32_t now = millis(); + + if (fatalActive) return; + + if (blinkActive) { + if (now >= blinkToggleAt) { + if (blinkOn) { + ledcWrite(LEDC_CHANNEL, 0); + blinkOn = false; + if (--blinkRemaining == 0) { + blinkActive = false; + } else { + blinkToggleAt = now + blinkOffMs; + } + } else { + ledcWrite(LEDC_CHANNEL, 255); + blinkOn = true; + blinkToggleAt = now + blinkOnMs; + } + } + return; + } + + if (otaActive || wifiActive) { + if (now - lastBreathUpdate >= BREATHE_UPDATE_MS) { + applyBreathing(otaActive ? otaPeriodMs : wifiPeriodMs); + lastBreathUpdate = now; + } + } +} + +void StatusLed::clear() { + fatalActive = false; + blinkActive = false; + otaActive = false; + wifiActive = false; + ledcWrite(LEDC_CHANNEL, 0); +} + +void StatusLed::onBoot() { + if (!enabled) return; + startBlinkN(300, 100, 1); +} + +void StatusLed::onWifiConnected() { + if (!enabled) return; + wifiActive = true; + wifiPeriodMs = 3000; // slow +} + +void StatusLed::onApStarted() { + if (!enabled) return; + wifiActive = true; + wifiPeriodMs = 1500; // moderate +} + +void StatusLed::onWifiReconnecting() { + if (!enabled) return; + wifiActive = true; + wifiPeriodMs = 600; // fast +} + +void StatusLed::onModeChanged(draw_mode) { + if (!enabled) return; + startBlinkN(150, 100, 2); +} + +void StatusLed::onOtaStart() { + if (!enabled) return; + otaActive = true; + otaPeriodMs = 1000; +} + +void StatusLed::onOtaEnd() { + if (!enabled) return; + otaActive = false; + if (!wifiActive) { + ledcWrite(LEDC_CHANNEL, 0); + } +} + +void StatusLed::onFatalError() { + if (!enabled) return; + fatalActive = true; + ledcWrite(LEDC_CHANNEL, 255); +} + +#endif diff --git a/src/modules/statusled_neopixel.cpp b/src/modules/statusled_neopixel.cpp new file mode 100644 index 0000000..11bb898 --- /dev/null +++ b/src/modules/statusled_neopixel.cpp @@ -0,0 +1,180 @@ +#if defined(ARDUINO_ADAFRUIT_MATRIXPORTAL_ESP32S3) + +#include "statusled.h" +#include +#include + +#define NEOPIXEL_BRIGHTNESS 30 + +static const uint16_t BREATHE_PERIOD_MS = 3000; +static const uint8_t BREATHE_UPDATE_MS = 20; // ~50 fps + +static Adafruit_NeoPixel strip(1, PIN_NEOPIXEL, NEO_GRB + NEO_KHZ800); + +// Priority: FATAL > BLINK > OTA > WIFI + +static bool enabled = true; + +static bool fatalActive = false; + +static bool blinkActive = false; +static uint32_t blinkColor = 0; +static uint16_t blinkOnMs = 0; +static uint16_t blinkOffMs = 0; +static uint8_t blinkRemaining = 0; +static uint32_t blinkToggleAt = 0; +static bool blinkOn = false; + +static bool otaActive = false; +static uint32_t otaColor = 0; + +static bool wifiActive = false; +static uint32_t wifiColor = 0; + +static uint32_t lastBreathUpdate = 0; + +static uint32_t applyBreathing(uint32_t color) { + float t = (float)(millis() % BREATHE_PERIOD_MS) / (float)BREATHE_PERIOD_MS; + float level = 0.5f * (1.0f - cosf(t * 2.0f * 3.14159265f)); + uint8_t r = (uint8_t)(((color >> 16) & 0xFF) * level); + uint8_t g = (uint8_t)(((color >> 8) & 0xFF) * level); + uint8_t b = (uint8_t)(( color & 0xFF) * level); + return Adafruit_NeoPixel::Color(r, g, b); +} + +static void startBlinkN(uint32_t color, uint16_t onMs, uint16_t offMs, uint8_t count) { + blinkActive = true; + blinkColor = color; + blinkOnMs = onMs; + blinkOffMs = offMs; + blinkRemaining = count; + blinkOn = true; + blinkToggleAt = millis() + onMs; + strip.setPixelColor(0, color); + strip.show(); +} + +void StatusLed::init() { + strip.begin(); + strip.setBrightness(NEOPIXEL_BRIGHTNESS); + strip.clear(); + strip.show(); +} + +void StatusLed::setEnabled(bool val) { + enabled = val; + if (!enabled) { + fatalActive = false; + blinkActive = false; + otaActive = false; + wifiActive = false; + strip.clear(); + strip.show(); + } +} + +void StatusLed::tick() { + if (!enabled) return; + uint32_t now = millis(); + + if (fatalActive) return; + + if (blinkActive) { + if (now >= blinkToggleAt) { + if (blinkOn) { + strip.clear(); + strip.show(); + blinkOn = false; + if (--blinkRemaining == 0) { + blinkActive = false; + } else { + blinkToggleAt = now + blinkOffMs; + } + } else { + strip.setPixelColor(0, blinkColor); + strip.show(); + blinkOn = true; + blinkToggleAt = now + blinkOnMs; + } + } + return; + } + + if (otaActive || wifiActive) { + if (now - lastBreathUpdate >= BREATHE_UPDATE_MS) { + uint32_t color = otaActive ? otaColor : wifiColor; + strip.setPixelColor(0, applyBreathing(color)); + strip.show(); + lastBreathUpdate = now; + } + } +} + +void StatusLed::clear() { + fatalActive = false; + blinkActive = false; + otaActive = false; + wifiActive = false; + strip.clear(); + strip.show(); +} + +void StatusLed::onBoot() { + if (!enabled) return; + startBlinkN(Adafruit_NeoPixel::Color(255, 200, 100), 300, 100, 1); // warm white +} + +void StatusLed::onWifiConnected() { + if (!enabled) return; + wifiActive = true; + wifiColor = Adafruit_NeoPixel::Color(0, 255, 0); // green +} + +void StatusLed::onApStarted() { + if (!enabled) return; + wifiActive = true; + wifiColor = Adafruit_NeoPixel::Color(0, 127, 255); // light blue +} + +void StatusLed::onWifiReconnecting() { + if (!enabled) return; + wifiActive = true; + wifiColor = Adafruit_NeoPixel::Color(255, 160, 0); // amber +} + +void StatusLed::onModeChanged(draw_mode mode) { + if (!enabled) return; + uint32_t color = 0; + switch (mode) { + case DRAW_MODE_IMAGE: color = Adafruit_NeoPixel::Color(255, 140, 0); break; // gold + case DRAW_MODE_LIFE: color = Adafruit_NeoPixel::Color(139, 0, 255); break; // violet + case DRAW_MODE_MAP: color = Adafruit_NeoPixel::Color( 0, 255, 255); break; // cyan + case DRAW_MODE_QR: color = Adafruit_NeoPixel::Color(255, 255, 255); break; // white + default: return; + } + startBlinkN(color, 150, 100, 2); +} + +void StatusLed::onOtaStart() { + if (!enabled) return; + otaActive = true; + otaColor = Adafruit_NeoPixel::Color(0, 255, 255); // cyan +} + +void StatusLed::onOtaEnd() { + if (!enabled) return; + otaActive = false; + if (!wifiActive) { + strip.clear(); + strip.show(); + } +} + +void StatusLed::onFatalError() { + if (!enabled) return; + fatalActive = true; + strip.setPixelColor(0, Adafruit_NeoPixel::Color(255, 0, 0)); + strip.show(); +} + +#endif diff --git a/web/index.html b/web/index.html index ecaa741..fc1bafc 100644 --- a/web/index.html +++ b/web/index.html @@ -291,6 +291,17 @@

Debug

+ +
+ +
+ +
+ Error saving config +
@@ -308,6 +319,10 @@

Debug

Firmware +
diff --git a/web/src/config.js b/web/src/config.js index ea6746b..3220cff 100644 --- a/web/src/config.js +++ b/web/src/config.js @@ -17,6 +17,10 @@ const submitMonitor = document.getElementById('submit-monitor'); setDisabled(submitMonitor, true); const feedbackMonitorMessage = document.getElementById('submit-monitor-message'); resetMessage(feedbackMonitorMessage); +const submitStatusLed = document.getElementById('submit-statusled'); +setDisabled(submitStatusLed, true); +const feedbackStatusLedMessage = document.getElementById('submit-statusled-message'); +resetMessage(feedbackStatusLedMessage); const ssidInput = document.getElementById('ssid'); const pwdInput = document.getElementById('password'); const pwdToggle = document.getElementById('toggle-password'); @@ -25,6 +29,7 @@ const logSelect = document.getElementById('log-level'); const monitorCheckbox = document.getElementById('monitor-enable'); const monitorMessages = document.getElementById('monitor-messages'); const refreshMonitor = document.getElementById('refresh-monitor'); +const statusLedCheckbox = document.getElementById('statusled-enable'); /** * Initialize config module. @@ -43,6 +48,7 @@ export const config = (state) => { brightnessInput.value = config.panel.brightness || 127; logSelect.value = config.log.level || 2; // info monitorCheckbox.checked = (config.monitor?.enable ?? 0) === 1; + statusLedCheckbox.checked = (config.statusled?.enable ?? 1) === 1; }); // Handle password toggle @@ -67,6 +73,10 @@ export const config = (state) => { resetMessage(feedbackMonitorMessage); setDisabled(submitMonitor, false); }); + statusLedCheckbox.addEventListener('change', () => { + resetMessage(feedbackStatusLedMessage); + setDisabled(submitStatusLed, false); + }); // Handle submit to server const handleSubmit = (submitElem, feedbackElem) => { @@ -115,6 +125,22 @@ export const config = (state) => { showMessage(feedbackMonitorMessage, 'Configuration saved', 'is-success'); }); + submitStatusLed.addEventListener('click', async () => { + setDisabled(submitStatusLed, true); + showMessage(feedbackStatusLedMessage, 'Saving...', 'is-primary'); + const res = await state.saveConfig({ + statusled: { enable: statusLedCheckbox.checked ? 1 : 0 }, + }); + if (!res) { + showMessage(feedbackStatusLedMessage, 'Error saving configuration', 'is-error'); + setDisabled(submitStatusLed, false); + + return; + } + + showMessage(feedbackStatusLedMessage, 'Configuration saved', 'is-success'); + }); + refreshMonitor.addEventListener('click', async () => { try { const res = await fetch('/monitor'); diff --git a/web/src/state.js b/web/src/state.js index 173b81d..cbdb854 100644 --- a/web/src/state.js +++ b/web/src/state.js @@ -30,6 +30,7 @@ import { handleFetch } from './utils.js'; * @property {{ ssid: string, password: string }} wifi * @property {{ brightness: number }} panel * @property {{ level: number }} log + * @property {{ enable: number }} statusled * @property {MapConfiguration} map */ diff --git a/web/src/system.js b/web/src/system.js index 01d51fa..b02a534 100644 --- a/web/src/system.js +++ b/web/src/system.js @@ -4,6 +4,7 @@ import { humanFileSize, setProgress } from './utils'; * @typedef SystemStatus * @property {{size: number, free: number, minFree: number, maxFreeBlock: number}} heap * @property {number} flash + * @property {{found: boolean, size: number, free: number, minFree: number, maxFreeBlock: number}} psram * @property {{size: number, used: number, version: string}} firmware * @property {{size: number, used: number}} filesystem * @property {{model: string, revision: string, cores: number, clock: number}} chip @@ -15,9 +16,12 @@ const swMsg = document.getElementById('software-message'); const heap = document.getElementById('heap-perc'); const fs = document.getElementById('fs-perc'); const fw = document.getElementById('firmware-perc'); +const psramLabel = document.getElementById('psram-label'); +const psram = document.getElementById('psram-perc'); const heapInfo = document.getElementById('heap-info'); const fsInfo = document.getElementById('fs-info'); const fwInfo = document.getElementById('fw-info'); +const psramInfo = document.getElementById('psram-info'); const refreshButton = document.getElementById('refresh-resources'); /** @@ -45,6 +49,15 @@ const updateStatus = async (state) => { 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)})`; + + if (status.psram.found) { + const psramPerc = (100 * (status.psram.size - status.psram.free)) / status.psram.size; + setProgress(psram, psramPerc); + psramInfo.innerText = `(${humanFileSize(status.psram.size - status.psram.free)} / ${humanFileSize(status.psram.size)})`; + psramLabel.classList.remove('is-hidden'); + } else { + psramLabel.classList.add('is-hidden'); + } }; /**