V1 Unpatched Switch ESP32-S3 payload injector

  • Thread starter Thread starter AmeliaFox
  • Start date Start date
  • Views Views 7,851
  • Replies Replies 51
  • Likes Likes 8
thanks for the detailed response. i have the amoled 1.8 V1...will have to lookup the waveshare examples at somepoint in the future. on another note, i used the latest code on a seeed studio esp32-s3 and am able to get into the web interface and load payloads. i added a battery to the device (it has battery terminals onboard) and can access the web interface when on battery but plugging it into my switch does not inject the payload. i confirmed im on RCM by connecting to my PC and checking TegraRCMGui. is there anything special that needs to be done to inject the payload?
 
thanks for the detailed response. i have the amoled 1.8 V1...will have to lookup the waveshare examples at somepoint in the future. on another note, i used the latest code on a seeed studio esp32-s3 and am able to get into the web interface and load payloads. i added a battery to the device (it has battery terminals onboard) and can access the web interface when on battery but plugging it into my switch does not inject the payload. i confirmed im on RCM by connecting to my PC and checking TegraRCMGui. is there anything special that needs to be done to inject the payload?
I had a look at the pinouts for that board and there's d+ and d- pinouts on the back of the board and also the d+ and d- that are used to program it, I don't know how they are mapped so you need to check the data sheet for that board to see how they are wired. There's loads of different versions of esp32-s3 so you'll need to make sure you are using the correct boards file for the version you are using. If you soldered to the d+ and d- on the back of the board to inject, then you can plug the board into a pc at the same time and check the console debug logs to see what's happening. (you might need to temp recompile with enable cdc on boot to see the logs - this should be set to off (disabled) normaly or injection won't work).

See here for boards file: https://wiki.seeedstudio.com/xiao_esp32s3_getting_started/
 
Last edited by snuffbot1,
  • Love
Reactions: impeeza
is this normal even though i set the USB CDC on Boot setting to disabled?:
1787502116516.png
 
is this normal even though i set the USB CDC on Boot setting to disabled?:
View attachment 587373
Yes don't worry about that, it depends on the board. Original code was done on a dev board with 2 usb ports and the code below works fine. On some boards it works differently (your boards file probably doesn't define this - ARDUINO_USB_MODE
), as long as when you compiled you make sure you disabled cdc it stops you sending serial data which would interfere with payload injection bytes. CDC enabled is only useful for debugging, which breaks injection code from working so make sure it's disabled when flashing the esp32-s3.

You can alter the logic in this code section, depending on what your board code uses, but it's not needed as it's only for UI stuff which doesn't effect injection at all.
Code:
#ifdef ARDUINO_USB_MODE
        bool usb_cdc_enabled = ARDUINO_USB_MODE;
    #else
        bool usb_cdc_enabled = false;
    #endif

    #ifdef USBCON
        usb_cdc_enabled = true;
    #endif
    #if !CONFIG_IDF_TARGET_ESP32S2
        doc["usb"]["cdc_enabled"] = usb_cdc_enabled;
        doc["usb"]["serial_connected"] = Serial ? true : false;
        doc["usb"]["vid"] = USB_VID ? USB_VID : 0x303a;
        doc["usb"]["pid"] = USB_PID ? USB_PID : 0x1001;
    #endif

Here's what's wrong with the code and how to fix it:

USBCON Defined whenever the board/variant has any native USB support compiled in. It does not mean CDC is enabled.

Your code does this:
Sets usb_cdc_enabled = ARDUINO_USB_MODE → correctly gets 0 or 1
Then #ifdef USBCON → overwrites it with true regardless of the actual mode So on any board where the variant defines USBCON (most ESP32-S3 boards do), you always report CDC enabled even when the board is in OTG mode (ARDUINO_USB_MODE=0).

Why it varies by board
Different board definitions (in boards.txt / variants/) set these macros differently:
Some define USBCON in the variant headers
Some don't

The "USB Mode" menu in Arduino IDE (or your PlatformIO build_flags) controls ARDUINO_USB_MODE
So boards from one manufacturer may define USBCON while others don't, causing the inconsistent behavior you're seeing.

The fix
Use ARDUINO_USB_MODE as the authoritative source when it exists, and only fall back to USBCON when it doesn't:
Code:
bool usb_cdc_enabled = false;

#ifdef ARDUINO_USB_MODE
    usb_cdc_enabled = (ARDUINO_USB_MODE == 1);
#elif defined(USBCON)
    usb_cdc_enabled = true;
#endif

If you also want to account for the "CDC on Boot" setting specifically, you can add:
Code:
#ifdef ARDUINO_USB_CDC_ON_BOOT
    usb_cdc_enabled = usb_cdc_enabled && ARDUINO_USB_CDC_ON_BOOT;
#endif

Bottom line: USBCON means "USB is present," not "CDC is enabled." Don't let it override ARDUINO_USB_MODE.

So to summerize, this is what your fixed code section should look like:
Code:
doc["mac"]["ap"] = macStr2;
    doc["mac"]["base"] = baseMacStr;
 
    bool usb_cdc_enabled = false;
 
    #ifdef ARDUINO_USB_MODE
    usb_cdc_enabled = (ARDUINO_USB_MODE == 1);
  #elif defined(USBCON)
    usb_cdc_enabled = true;
  #endif
 
  #ifdef ARDUINO_USB_CDC_ON_BOOT
    usb_cdc_enabled = usb_cdc_enabled && ARDUINO_USB_CDC_ON_BOOT;
  #endif

    #if !CONFIG_IDF_TARGET_ESP32S2
        doc["usb"]["cdc_enabled"] = usb_cdc_enabled;
        doc["usb"]["serial_connected"] = Serial ? true : false;
        doc["usb"]["vid"] = USB_VID ? USB_VID : 0x303a;
        doc["usb"]["pid"] = USB_PID ? USB_PID : 0x1001;
    #endif

Settings should look like this:
settings.jpg


If you are still having issues after this, you probably have an issue with the usb cable/wires you are using either being soldered wrongly or not thick enough. So try a differen't usb cable and make sure to that gnd wire is connected between your chip and usb gnd on the switch.
 
Last edited by snuffbot1,
  • Love
Reactions: impeeza
Thanks for the info @snuffbot1 ! I wasn't even aware of this new VUE thing yet, so is it worth it when compared to exfathax?
As for the dongle here, I haven't had luck actually with the supermini working on its own, it doens't turn on by itself when in RCM mode (I tried the Basic array code without all bells and whistles to try to lower the power requirement and still didn't work. - I was expecting the RCM mode to provide enough power to turn the board on from some other comments in the thread) Any suggestions when it comes to that? My next alternative is gonna be wiring a 5V to the board and cutting the power power line between the Switch and the board, and connecting it to a USB charger, since I've very sporadically need this and normally don't have an Android phone with Rekado to do this process. I won't use it as a modchip so just a dongle whenever for some reason the Switch is stuck in Auto RCM mode.
Thanks
 
  • Like
Reactions: impeeza
Thanks for the info @snuffbot1 ! I wasn't even aware of this new VUE thing yet, so is it worth it when compared to exfathax?
As for the dongle here, I haven't had luck actually with the supermini working on its own, it doens't turn on by itself when in RCM mode (I tried the Basic array code without all bells and whistles to try to lower the power requirement and still didn't work. - I was expecting the RCM mode to provide enough power to turn the board on from some other comments in the thread) Any suggestions when it comes to that? My next alternative is gonna be wiring a 5V to the board and cutting the power power line between the Switch and the board, and connecting it to a USB charger, since I've very sporadically need this and normally don't have an Android phone with Rekado to do this process. I won't use it as a modchip so just a dongle whenever for some reason the Switch is stuck in Auto RCM mode.
Thanks
You should ask in the ps4 forum/threads about VUE, it's very fast and has a very good success rate. There's also CSS font face which works pretty good, you can run that from any esp32/esp8266 as a website and that works pretty good as well, but VUE is the best in my opinion. https://github.com/Vuemony/vue-after-free. https://github.com/ntfargo/CSSFontFace-Exploit

As for a supermini - you need to supply the correct voltage and amperage for it to work, you can't just use the switch usb port to power it, it doesn't work like that. There's quite a few posts on how to wire a modchip and there's lots of diagrams using google images. BASIC minimal wiring is this; D+ / D- V+ (5volts) and GND. Then some way to put the switch into RCM mode, or use hekate to enable autorcm if you don't want to wire this. For testing your chip do this:

Get a 5volt phone charger and wire an old usb cable to vbat/5v pin and also wire the GND pin - this will power the chip. Then plug the usb phone charger in to power up the chip. At this point go to the chip web interface via wifi (esp32) and upload the payload you want to inject and make sure to select that as your default payload, Next get a usb c cable where both ends are usb c male, I just use a normal usb c cable and usb otg adapter to make a usbc-usbc cable. Plug one end into your chip usb port and the other into the switch usb port. Put the switch into RCM mode power off and then on the chip and the payload should get sent. Once you know the chip is working then you can mess about with the menus and settings and stuff until you get everything as you want it. Then you can hard wire into the switch using a chip install guide, or get someone that knows how to do this for you.
 
Thanks for the info @snuffbot1 , that's basically my idea, however, having the USB C cable connected to the Switch while the 5V is going into the esp32 voltage regulator wouldn't cause a clash of voltages frying the chip? I thought about snipping the current wire for the USB C connecting the chip to the Switch just in case since I don't know how much voltage and current the port supplies while in rcm mode, but anyways I'd assume it goes to full power when Hekate loads.
Thanks again here,
 
Thanks for the info @snuffbot1 , that's basically my idea, however, having the USB C cable connected to the Switch while the 5V is going into the esp32 voltage regulator wouldn't cause a clash of voltages frying the chip? I thought about snipping the current wire for the USB C connecting the chip to the Switch just in case since I don't know how much voltage and current the port supplies while in rcm mode, but anyways I'd assume it goes to full power when Hekate loads.
Thanks again here,
You need to power the supermini for it to work, the switch doesn't power it via the usb cable and I'm pretty sure the switch usb circuit has a diode in the circuit so it can't send voltage out from the switch usb into the chip. Your chip works at 3.3v but you need to supply it with 5v via the 5v pin, this 5v then goes through a diode on your supermini board which drops the voltage by 0.6v and then it goes to a voltage regulator which drops it to 3.3v and supplys the en gpio on the esp32s3 to tell it to power up. Everything coming out of the chip is 3.3v. So that's D+ and D- in this case which are the bits being sent and received (the payload). There's no power from the chip being sent and there's no power from the switch being sent to the chip which is going to cause any issues.

Here's the power circuit for your chip: The VBUS is the 5v pin where you solder the 5v from the phone charger usb cable. VBAT is where you use a battery like an 18650 or 3.7v lipo if you want to use a battery instead, that line goes though a mosfet which also has a built in diode.
power.jpg
 
Last edited by snuffbot1,
bool usb_cdc_enabled = false; #ifdef ARDUINO_USB_MODE usb_cdc_enabled = (ARDUINO_USB_MODE == 1); #elif defined(USBCON) usb_cdc_enabled = true; #endif #ifdef ARDUINO_USB_CDC_ON_BOOT usb_cdc_enabled = usb_cdc_enabled && ARDUINO_USB_CDC_ON_BOOT; #endif
i was able to make this update and upload the code to the following:
seeed studio board XIAO ESP32S3 (8MB Flash/8MB PSRAM)
waveshare board ESP32S3-Tiny N8R8 (8MB Flash/8MB PSRAM)
waveshare ESP32-S3-Touch-AMOLED-1.8 (16MB Flash/8MB PSRAM) with built in battery (i know the code doesnt have screen functions)
the CDC enabled reflects properly now (thanks!)
payload injection did not work on any of them.

Testing performed:
Board Selection: ESP32S3 Dev Module (for AMOLED, i tested both ESP32S3 Dev Module and the option for the actual board)
Settings (adjusted Flash size based on device, copied correct partition csv to same folder as INO file):
1787592884754.png


seeed studio:
  • tested with battery soldered to +-, device powers on and i can access the web interface and upload payloads and set a default. used the same cable i use on my pc to inject the payload from tegraRCMGui. Nothing happens
  • tested with OTG cable (Power Source->>ESP32>>Nintendo Switch). nothing happens
Waveshare Tiny:
  • tested with OTG cable (Power Source->>ESP32>>Nintendo Switch). nothing happens
Waveshare AMOLED:
  • tested as is, nothing happens
does anyone have any of these same devices that they have tested the latest firmware posted here? not sure what I'm doing wrong, any additional help would be appreciated :)
 
i was able to make this update and upload the code to the following:
seeed studio board XIAO ESP32S3 (8MB Flash/8MB PSRAM)
waveshare board ESP32S3-Tiny N8R8 (8MB Flash/8MB PSRAM)
waveshare ESP32-S3-Touch-AMOLED-1.8 (16MB Flash/8MB PSRAM) with built in battery (i know the code doesnt have screen functions)
the CDC enabled reflects properly now (thanks!)
payload injection did not work on any of them.

Testing performed:
Board Selection: ESP32S3 Dev Module (for AMOLED, i tested both ESP32S3 Dev Module and the option for the actual board)
Settings (adjusted Flash size based on device, copied correct partition csv to same folder as INO file):
View attachment 587552

seeed studio:
  • tested with battery soldered to +-, device powers on and i can access the web interface and upload payloads and set a default. used the same cable i use on my pc to inject the payload from tegraRCMGui. Nothing happens
  • tested with OTG cable (Power Source->>ESP32>>Nintendo Switch). nothing happens
Waveshare Tiny:
  • tested with OTG cable (Power Source->>ESP32>>Nintendo Switch). nothing happens
Waveshare AMOLED:
  • tested as is, nothing happens
does anyone have any of these same devices that they have tested the latest firmware posted here? not sure what I'm doing wrong, any additional help would be appreciated :)
I've just compiled for waveshare amoled 1.8 v1 and tested it and it works fine. Do you want me to send you the bin files so you can flash them. Did you remember and go into the web interface and upload your payloads and set one as the default?
 
yeah, first thing i did was connect to the web interface and upload payload/set default payload.

as far as bin files, sure i can give that a go. ive been using the ino and compiling that. is there anything special needed to flash the bin files? is that just through cli using esptool?

for reference, here is the code from the INO file im using (should be the same as the RCM_FULL_FIXED file posted previously with the code for the CDC enabled flag changed:

Code:
/*
 * RCM Injector + ESP32 OS
 * Arduino ESP32 Boards (3.2.1)
 *
 * Program designed for ESP32-S3 boards with PSRAM
 * Boot Sequence: (see included rcm.png file)
 * POWER OFF: Hardware → forces RCM LOW
 * POWER ON: Hardware → still LOW → RCM triggered
 * MCU BOOTS: GPIO HIGH → disables grounding → RCM HIGH
 * SLEEP: GPIO floats → circuit holds HIGH → no effect
 * POWER OFF: Everything resets → back to LOW
*/

//#define Serial Serial1
//#define DEBUG_SERIAL

#include <Arduino.h>
#include <ArduinoJson.h>
#define CONFIG_ASYNC_TCP_STACK_SIZE 4096
#include <AsyncTCP.h>
#include <DNSServer.h>
#include <ESPAsyncWebServer.h>
#include <FFat.h>
#include <HTTPClient.h>
#include <Update.h>
#include <WiFi.h>
#include <WiFiClient.h>
#include <WiFiClientSecure.h>
#include "include/ESPmDNS.h"
#include <cctype>
#include <esp32-hal-psram.h>
#include <esp_ota_ops.h>
#include <esp_wifi.h>
#include "esp_heap_caps.h"
#include "esp_mac.h"
#include "esp_system.h"
#include "esp_sleep.h"
#include <esp_cpu.h>
#include "esp_task_wdt.h"
#include "esp_vfs_fat.h"
#include "include/index_gz.h"
#include "include/editor_gz.h"
#include "include/config.h"
#include "include/firmware_update_html_gz.h"
#include "include/tar_gz.h"
#include "include/ButtonHandler.h"
#include "include/ESPWebFileManager.h"
#include "include/SimpleFTPServer.h"
#include "USB.h"
#include "USBMSC.h"
#include "include/NeoPixelEffects.h"

// === Bluetooth ===
#include <BLEDevice.h>
#include <BLEServer.h>
#include <BLEUtils.h>
#include <BLE2902.h>

BLECharacteristic* ipCharacteristic;
BLECharacteristic* commandCharacteristic;
BLECharacteristic* responseCharacteristic;
#define SERVICE_UUID "8f41ca5d-f679-45bb-a603-9bc1c5eedefc"
#define CHARACTERISTIC_UUID "432954d8-eef6-41b4-9686-749f35e6fa6d"
#define COMMAND_UUID   "0000abcd-0000-1000-8000-00805f9b34fb"
#define RESPONSE_UUID  "0000dcba-0000-1000-8000-00805f9b34fb"
static bool listInProgress = false;
static unsigned long lastCommandTime = 0;
const unsigned long COMMAND_DEBOUNCE_MS = 400;  // 400ms debounce

// ==================== RCM INJECTOR INCLUDES ====================
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "esp_err.h"
#include "usb/usb_host.h"
#include "wear_levelling.h"

// ==================== RCM INJECTOR DEFINES ====================
#define TAG "RCM_INJECTOR"
#define APX_VID 0x0955
#define APX_PID 0x7321
#define MAX_LENGTH 0x30298
#define RCM_PAYLOAD_ADDR 0x40010000
#define INTERMEZZO_LOCATION 0x4001F000
#define PAYLOAD_LOAD_BLOCK 0x40020000
#define SEND_CHUNK_SIZE 0x1000
#define RCM_PIN 4
uint8_t rcmPin = RCM_PIN;

// RCM Logging macros (redirect to Serial)
#define RCM_LOG_I(fmt, ...) Serial.printf("[RCM][INFO] " fmt "\n", ##__VA_ARGS__)
#define RCM_LOG_E(fmt, ...) Serial.printf("[RCM][ERROR] " fmt "\n", ##__VA_ARGS__)
#define RCM_LOG_W(fmt, ...) Serial.printf("[RCM][WARN] " fmt "\n", ##__VA_ARGS__)

// Intermezzo binary (required for RCM exploit)
static const uint8_t intermezzo_bin[] PROGMEM = {
    0x44, 0x00, 0x9F, 0xE5, 0x01, 0x11, 0xA0, 0xE3, 0x40, 0x20, 0x9F, 0xE5, 0x00, 0x20, 0x42, 0xE0,
    0x08, 0x00, 0x00, 0xEB, 0x01, 0x01, 0xA0, 0xE3, 0x10, 0xFF, 0x2F, 0xE1, 0x00, 0x00, 0xA0, 0xE1,
    0x2C, 0x00, 0x9F, 0xE5, 0x2C, 0x10, 0x9F, 0xE5, 0x02, 0x28, 0xA0, 0xE3, 0x01, 0x00, 0x00, 0xEB,
    0x20, 0x00, 0x9F, 0xE5, 0x10, 0xFF, 0x2F, 0xE1, 0x04, 0x30, 0x90, 0xE4, 0x04, 0x30, 0x81, 0xE4,
    0x04, 0x20, 0x52, 0xE2, 0xFB, 0xFF, 0xFF, 0x1A, 0x1E, 0xFF, 0x2F, 0xE1, 0x20, 0xF0, 0x01, 0x40,
    0x5C, 0xF0, 0x01, 0x40, 0x00, 0x00, 0x02, 0x40, 0x00, 0x00, 0x01, 0x40
};

// ==================== RCM INJECTOR GLOBALS ====================
static usb_host_client_handle_t rcm_client_hdl = NULL;
static usb_device_handle_t rcm_dev_hdl = NULL;
static volatile bool rcm_device_connected = false;
static volatile bool rcm_injection_done = false;
static volatile bool rcm_injection_active = false;  // prevents multiple injections
static TaskHandle_t rcm_usb_task_handle = NULL;
static TaskHandle_t rcm_injection_task_handle = NULL;
// ==================== ORIGINAL BASIC.INO GLOBALS ====================
#include <SD.h>
#define CS_PIN 10
bool sdMounted = false;

String firmwareVersion = "v1.0.1";
bool mdnsRunning = false;
unsigned int bootTime = 0;
uint16_t TIME2SLEEP = 10;
bool autosleep = true;
bool injectionsleep = false;

// Touch stuff
uint32_t touchValue;
uint32_t threshold = 60;
uint8_t touchpin = 1; //GPIO 1-14
uint32_t average = 0;
uint8_t averagecount = 0;
uint32_t averageval = 0;
bool allowTouch = true;

bool is_s2 = false;
#if CONFIG_IDF_TARGET_ESP32S2
is_s2 = true;
#endif

#define LED_NUMBER 1
#define MAX_BRIGHTNESS 32 //Goes to 255 but keep number low to prevent led heat build up
static uint8_t pin;
Adafruit_NeoPixel* strip = nullptr;

//
#define LEDPIN 38
bool removeconf = false;
bool stopled = false;
bool startled = false;
bool ws212b = true;
uint8_t ledchoice = 2;
uint8_t ledorder = 2;

TaskHandle_t ftpTaskHandle = NULL;
TaskHandle_t dnsTaskHandle = NULL;
TaskHandle_t otaTaskHandle = NULL;

USBMSC dev;

const char* ALLOWED_EXTENSIONS[] PROGMEM = { "txt", "html", "js", "mjs", "css", "cache" };
const int ALLOWED_EXT_COUNT = sizeof ALLOWED_EXTENSIONS / sizeof ALLOWED_EXTENSIONS[0];

WiFiClient otaClient;
HTTPClient otaHttp;
WiFiClient* otaStream = nullptr;
WiFiClientSecure otaSecureClient;
WiFiClient otaInsecureClient;

int otaContentLength = 0;
int otaTotalWritten = 0;
bool otaInProgress = false;
String ota_url = "";
bool doOta = false;
AsyncEventSource otaEvents("/ota-progress");
volatile const char* formatStatus = "IDLE";

IPAddress Local_IP(192, 168, 0, 1);
IPAddress Gateway(192, 168, 0, 1);
IPAddress Subnet_Mask(255, 255, 255, 0);
String ipStr;

AsyncWebServer server(80);
AsyncWebSocket ws("/ws");
bool hasIndexFile = false;
bool AP_Running = false;
bool activeOperation = false;

#define FILESYS FFat
ESPWebFileManager fileManager;

DNSServer dnsServer;
FtpServer ftpSrv;
bool allowFTP = false;

size_t totalPSRAM = 0;
size_t availablePSRAM = 0;
bool psramAvailable = false;
bool monitorPSRAM = false;

char* fileChunk = nullptr;
size_t currentChunk = 0;
size_t totalChunks = 0;
size_t fileSize = 0;
String currentFilename;

int megabytesToBytes(int mb) {
    return mb * 1024 * 1024;
}
int megabytes = 8;
int bytes = megabytesToBytes(megabytes);
size_t MAX_FILE_SIZE = 0;
#define CHUNK_SIZE 32768

enum DownloadState {
    IDLE,
    DOWNLOADING,
    EXTRACTING,
    COMPLETE,
    ERROR
};

volatile DownloadState currentState = IDLE;
String downloadUrl = "";
uint8_t* tar_data = nullptr;
size_t tar_size = 0;

volatile size_t download_progress = 0;
size_t download_total = 0;
size_t extracted_files = 0;
size_t total_files = 0;
String current_file = "";

// ==================== RCM INJECTOR FUNCTIONS ====================
// Dummy callback - required but we don't rely on it
static void rcm_dummy_cb(usb_transfer_t* transfer) {}

void cleanup_rcm_tasks() {
    if (rcm_injection_active) {
        // Wait a bit if injection is still happening
        while (rcm_injection_active) vTaskDelay(pdMS_TO_TICKS(10));
    }

        #ifdef DEBUG_SERIAL
    Serial.println("Cleaning up RCM USB tasks...");
        #endif

    // Properly uninstall USB host first
    if (rcm_client_hdl) {
        usb_host_client_deregister(rcm_client_hdl);
        rcm_client_hdl = NULL;
    }

    // Uninstall the USB host library
    usb_host_uninstall();

        // Now safe to delete tasks
    if (rcm_usb_task_handle) {
        vTaskDelete(rcm_usb_task_handle);
        rcm_usb_task_handle = NULL;
    }
        
    if (rcm_injection_task_handle) {
        vTaskDelete(rcm_injection_task_handle);
        rcm_injection_task_handle = NULL;
    }
        
        #ifdef DEBUG_SERIAL
    Serial.println("RCM USB cleanup completed");
        #endif
}

static void rcm_usb_event_cb(const usb_host_client_event_msg_t* event, void* arg) {
    if (event->event == USB_HOST_CLIENT_EVENT_NEW_DEV) {
        usb_device_handle_t test_hdl;
        esp_err_t err = usb_host_device_open(rcm_client_hdl, event->new_dev.address, &test_hdl);
        if (err == ESP_OK) {
            const usb_device_desc_t* dev_desc;
            err = usb_host_get_device_descriptor(test_hdl, &dev_desc);
            if (err == ESP_OK && dev_desc->idVendor == APX_VID && dev_desc->idProduct == APX_PID) {
                RCM_LOG_I("*** SWITCH RCM DETECTED ***");
                rcm_dev_hdl = test_hdl;
                rcm_device_connected = true;
                return;
            }
            usb_host_device_close(rcm_client_hdl, test_hdl);
        }
    } else if (event->event == USB_HOST_CLIENT_EVENT_DEV_GONE) {
        rcm_device_connected = false;
        rcm_dev_hdl = NULL;
    }
}

static bool rcm_wait_for_transfer(usb_transfer_t* xfer, uint32_t timeout_ms, size_t expected_bytes) {
    uint32_t waited = 0;
    while (waited < timeout_ms) {
        if (xfer->status != 0 || xfer->actual_num_bytes >= expected_bytes) {
            RCM_LOG_I("Transfer done: status=%d, actual=%d", xfer->status, xfer->actual_num_bytes);
            return true;
        }
        vTaskDelay(pdMS_TO_TICKS(1));
        waited++;
    }
    RCM_LOG_W("Timeout: status=%d, actual=%d", xfer->status, xfer->actual_num_bytes);
    return false;
}

static bool rcm_read_device_id(void) {
    RCM_LOG_I("Reading Device ID...");

    usb_transfer_t* xfer = NULL;
    if (usb_host_transfer_alloc(64, 0, &xfer) != ESP_OK) return false;

    xfer->device_handle = rcm_dev_hdl;
    xfer->bEndpointAddress = 0x81;
    xfer->callback = rcm_dummy_cb;
    xfer->timeout_ms = 3000;
    xfer->num_bytes = 64;

    if (usb_host_transfer_submit(xfer) != ESP_OK) {
        usb_host_transfer_free(xfer);
        return false;
    }

    bool done = rcm_wait_for_transfer(xfer, 3000, 16);
    bool success = false;
    if (done && xfer->actual_num_bytes >= 16) {
        char ascii_buf[33] = { 0 };
        for (int i = 0; i < 16; i++) sprintf(&ascii_buf[i * 2], "%02x", xfer->data_buffer[i]);
        RCM_LOG_I("Device ID: %s", ascii_buf);
        success = true;
    }

    usb_host_transfer_free(xfer);
    return success || done;
}

static bool rcm_send_chunk(uint8_t* data, size_t len) {
    usb_transfer_t* xfer = NULL;
    if (usb_host_transfer_alloc(len, 0, &xfer) != ESP_OK) return false;

    memcpy(xfer->data_buffer, data, len);
    xfer->device_handle = rcm_dev_hdl;
    xfer->bEndpointAddress = 0x01;
    xfer->callback = rcm_dummy_cb;
    xfer->timeout_ms = 5000;
    xfer->num_bytes = len;

    if (usb_host_transfer_submit(xfer) != ESP_OK) {
        usb_host_transfer_free(xfer);
        return false;
    }

    bool done = rcm_wait_for_transfer(xfer, 5000, len);
    bool success = (done && (xfer->status == USB_TRANSFER_STATUS_COMPLETED || xfer->actual_num_bytes == len));

    usb_host_transfer_free(xfer);
    return success;
}

static void rcm_delay_2ms(void) {
    for (int i = 0; i < 480000; ++i) {}
}

static bool rcm_send_payload(uint8_t* payload_buf, uint32_t payload_len) {
    RCM_LOG_I("Sending %" PRIu32 " bytes...", payload_len);

    int chunks = 0;
    for (uint32_t offset = 0; offset < payload_len; offset += SEND_CHUNK_SIZE) {
        if (!rcm_send_chunk(&payload_buf[offset], SEND_CHUNK_SIZE)) {
            RCM_LOG_E("Failed at chunk %d", chunks);
            break;
        }
        chunks++;
        if (chunks % 50 == 0) RCM_LOG_I("Sent %d chunks", chunks);
        rcm_delay_2ms();
    }

    RCM_LOG_I("Sent %d chunks", chunks);

    if ((chunks % 2) != 1) {
        uint8_t zero[SEND_CHUNK_SIZE] = { 0 };
        rcm_send_chunk(zero, SEND_CHUNK_SIZE);
    }

    return chunks > 0;
}

static void rcm_smash_stack(void) {
    RCM_LOG_I("Smashing stack...");

    size_t total_size = 8 + 0x7000;
    uint8_t* buffer = (uint8_t*)heap_caps_aligned_alloc(64, total_size, MALLOC_CAP_DMA);
    if (!buffer) return;

    buffer[0] = 0x82;
    buffer[1] = 0x00;
    buffer[2] = 0x00;
    buffer[3] = 0x00;
    buffer[4] = 0x00;
    buffer[5] = 0x00;
    buffer[6] = 0x00;
    buffer[7] = 0x70;
    memset(buffer + 8, 0, 0x7000);

    usb_transfer_t* xfer = NULL;
    if (usb_host_transfer_alloc(total_size, 0, &xfer) != ESP_OK) {
        heap_caps_free(buffer);
        return;
    }

    memcpy(xfer->data_buffer, buffer, total_size);
    heap_caps_free(buffer);

    xfer->device_handle = rcm_dev_hdl;
    xfer->bEndpointAddress = 0;
    xfer->callback = rcm_dummy_cb;
    xfer->timeout_ms = 1000;
    xfer->num_bytes = 0x7008;

    if (usb_host_transfer_submit_control(rcm_client_hdl, xfer) != ESP_OK) {
        RCM_LOG_E("Submit failed");
        usb_host_transfer_free(xfer);
        return;
    }

    rcm_wait_for_transfer(xfer, 1000, 0);
    RCM_LOG_I("Smash result: status=%d (error/timeout expected)", xfer->status);
    usb_host_transfer_free(xfer);
}

static bool rcm_inject_payload(void) {
    RCM_LOG_I("=== INJECTION START ===");
    rcm_injection_active = true;

    if (usb_host_interface_claim(rcm_client_hdl, rcm_dev_hdl, 0, 0) != ESP_OK) {
        RCM_LOG_E("Claim failed");
        rcm_injection_active = false;
        return false;
    }
    RCM_LOG_I("Interface claimed");
    vTaskDelay(pdMS_TO_TICKS(100));

    rcm_read_device_id();

    // Try PSRAM first, fall back to DMA-capable memory if needed
    uint8_t* payload_buf = NULL;

    // Check if we have enough PSRAM
    size_t freePsram = ESP.getFreePsram();
    RCM_LOG_I("Free PSRAM: %d bytes", freePsram);

    if (freePsram > MAX_LENGTH) {
        // Use PSRAM - allocate with 64-byte alignment for USB DMA
        payload_buf = (uint8_t*)heap_caps_aligned_alloc(64, MAX_LENGTH, MALLOC_CAP_SPIRAM | MALLOC_CAP_DMA);
        RCM_LOG_I("Allocated payload buffer in PSRAM");
    }

    // If PSRAM allocation failed or not enough PSRAM, try internal RAM
    if (!payload_buf) {
        payload_buf = (uint8_t*)heap_caps_aligned_alloc(64, MAX_LENGTH, MALLOC_CAP_DMA);
        RCM_LOG_I("Allocated payload buffer in internal RAM");
    }

    if (!payload_buf) {
        RCM_LOG_E("Failed to allocate payload buffer (%d bytes)", MAX_LENGTH);
        usb_host_interface_release(rcm_client_hdl, rcm_dev_hdl, 0);
        rcm_injection_active = false;
        return false;
    }

    memset(payload_buf, 0, MAX_LENGTH);
    *(uint32_t*)payload_buf = MAX_LENGTH;
    uint32_t idx = 0x2a8;

    // Build the RCM spray (stack overwrite)
    uint32_t* spray = (uint32_t*)&payload_buf[idx];
    for (uint32_t addr = RCM_PAYLOAD_ADDR; addr < INTERMEZZO_LOCATION; addr += 4) {
        *spray++ = INTERMEZZO_LOCATION;
        idx += 4;
    }

    // Copy intermezzo bootloader
    memcpy(&payload_buf[idx], intermezzo_bin, sizeof(intermezzo_bin));
    idx += sizeof(intermezzo_bin);

    // Set index to payload load block
    idx = (PAYLOAD_LOAD_BLOCK - RCM_PAYLOAD_ADDR) + 0x2a8;

    // Try to load payload from SD or FFAT
    String payloadPath = getDefaultPayload();
    bool payloadLoaded = false;

    if (payloadPath.length() > 0) {
        RCM_LOG_I("Loading payload: %s", payloadPath.c_str());

        // Check if file exists first
        bool fileExists = false;
        if (sdMounted) {
            fileExists = SD.exists(payloadPath);
        } else {
            fileExists = FILESYS.exists(payloadPath);
        }

        if (fileExists) {
            File f;
            if (sdMounted) {
                f = SD.open(payloadPath, "rb");
            } else {
                f = FILESYS.open(payloadPath, "rb");
            }

            if (f) {
                size_t fsize = f.size();
                RCM_LOG_I("Payload file size: %d bytes", fsize);

                if (fsize > 0 && (idx + fsize) < MAX_LENGTH) {
                    size_t bytesRead = f.read(&payload_buf[idx], fsize);
                    if (bytesRead == fsize) {
                        idx += fsize;
                        payloadLoaded = true;
                        RCM_LOG_I("Payload loaded successfully: %d bytes", fsize);
                    } else {
                        RCM_LOG_E("Failed to read payload file, read %d of %d bytes", bytesRead, fsize);
                    }
                } else {
                    RCM_LOG_E("Payload too large or empty: %zu bytes (max: %" PRIu32 ")", fsize, MAX_LENGTH - idx);
                }
                f.close();
            } else {
                RCM_LOG_E("Failed to open payload file");
            }
        } else {
            RCM_LOG_E("Payload file not found: %s", payloadPath.c_str());
        }
    } else {
        RCM_LOG_W("No default payload configured");
    }

    if (!payloadLoaded) {
        RCM_LOG_W("No payload loaded, sending intermezzo only");
    }

    RCM_LOG_I("Total payload size to send: %" PRIu32 " bytes", idx);

    // Send the payload
    bool sendSuccess = rcm_send_payload(payload_buf, idx);
    if (!sendSuccess) {
        RCM_LOG_E("Payload send failed");
    }

    heap_caps_free(payload_buf);

    // Perform stack smash to trigger execution
    rcm_smash_stack();

    RCM_LOG_I("=== INJECTION COMPLETE ===");
    usb_host_interface_release(rcm_client_hdl, rcm_dev_hdl, 0);
    rcm_injection_done = true;
    rcm_injection_active = false;
    return true;
}

static void rcm_usb_host_task(void* arg) {
    while (1) {
        usb_host_lib_handle_events(portMAX_DELAY, NULL);
    }
}

static void rcm_injection_task(void* arg) {
    usb_host_client_config_t cfg = {
        .is_synchronous = false,
        .max_num_event_msg = 5,
        .async = { .client_event_callback = rcm_usb_event_cb, .callback_arg = NULL }
    };

    esp_err_t err = usb_host_client_register(&cfg, &rcm_client_hdl);
    if (err != ESP_OK) {
        RCM_LOG_E("USB client registration failed: %d", err);
        vTaskDelete(NULL);
        return;
    }
    RCM_LOG_I("USB Client registered, waiting for events...");

    while (1) {
        usb_host_client_handle_events(rcm_client_hdl, portMAX_DELAY);
        if (rcm_device_connected && !rcm_injection_done) {
            if (rcm_inject_payload()) {
                //Turn off rcm gpio.
                digitalWrite(rcmPin, LOW);        // First drive it low
                pinMode(rcmPin, INPUT);           // Then make it floating (best practice)
            }
            
            // Wait for disconnect before allowing next injection
            while (rcm_device_connected) {
                vTaskDelay(pdMS_TO_TICKS(100));
            }
            rcm_injection_done = false;  // Reset
        }
    }
}

// ==================== ORIGINAL Non-RCM FUNCTIONS ====================

void createDirectories(String path) {
    if (!path.startsWith("/")) path = "/" + path;
    int i = 1;
    while (i < path.length()) {
        int slashIndex = path.indexOf('/', i);
        if (slashIndex == -1) slashIndex = path.length();
        String subDir = path.substring(0, slashIndex);

        if (sdMounted) {
            if (!SD.exists(subDir)) SD.mkdir(subDir);
        } else {
            if (!FILESYS.exists(subDir)) FILESYS.mkdir(subDir);
        }
        i = slashIndex + 1;
    }
}

void onWebSocketEvent(AsyncWebSocket* server, AsyncWebSocketClient* client,
                      AwsEventType type, void* arg, uint8_t* data, size_t len) {
    if (type == WS_EVT_CONNECT) sendProgressUpdate();
}

void sendProgressUpdate() {
    String json = String() + "{\"download\":" + String(download_progress) + ",\"total\":" + String(download_total) + ",\"extracted\":" + String(extracted_files) + ",\"total_files\":" + String(total_files) + ",\"current_file\":\"" + current_file + "\"" + ",\"downloading\":" + (currentState != IDLE ? "true" : "false") + "}";
    ws.textAll(json);
    vTaskDelay(pdMS_TO_TICKS(50));
}

bool downloadTarToPSRAM(const char* url, size_t* out_size) {
    HTTPClient http;
    WiFiClient client;
    WiFiClientSecure secureClient;
    String formatted_url = encodeUrlSpacesAndTabs(String(url));

    if (formatted_url.indexOf("bit.ly") != -1 && formatted_url.startsWith("http://")) {
        formatted_url = "https://" + formatted_url.substring(7);
    }

    bool isHttps = formatted_url.startsWith("https://");
    bool onlineURL = formatted_url.startsWith("https://") || formatted_url.startsWith("http://");

    if (onlineURL) {
        if (!isUrlReachable(formatted_url)) {
            current_file = "Error: URL unreachable";
            if (isHttps) secureClient.stop();
            else client.stop();
            return false;
        }
    }

    const char* final_url = formatted_url.c_str();

    if (isHttps) secureClient.setInsecure();

    http.setTimeout(10000);
    http.setFollowRedirects(HTTPC_STRICT_FOLLOW_REDIRECTS);
    http.addHeader("User-Agent", "Mozilla/5.0");
    http.addHeader("Connection", "close");

    if (!http.begin(isHttps ? secureClient : client, final_url)) {
        current_file = "Failed to begin HTTP connection";
        return false;
    }

    int httpCode = http.GET();
    if (httpCode != HTTP_CODE_OK) {
        current_file = String("HTTP GET failed: ") + String(httpCode);
        http.end();
        if (isHttps) secureClient.stop();
        else client.stop();
        return false;
    }

    *out_size = http.getSize();
    if (*out_size <= 0) {
        http.end();
        if (isHttps) secureClient.stop();
        else client.stop();
        return false;
    }

    tar_data = (uint8_t*)ps_malloc(*out_size);
    if (!tar_data) {
        http.end();
        if (isHttps) secureClient.stop();
        else client.stop();
        return false;
    }

    WiFiClient* stream = http.getStreamPtr();
    size_t total_read = 0;
    while (http.connected() && total_read < *out_size) {
        size_t available = stream->available();
        if (available) {
            int bytes = stream->readBytes(tar_data + total_read, min(available, *out_size - total_read));
            total_read += bytes;
            download_progress = total_read;
            download_total = *out_size;
            sendProgressUpdate();
            vTaskDelay(pdMS_TO_TICKS(50));
        }
        vTaskDelay(pdMS_TO_TICKS(10));
    }

    http.end();
    if (isHttps) secureClient.stop();
    else client.stop();

    if (total_read != *out_size) {
        free(tar_data);
        tar_data = nullptr;
        return false;
    }
    return true;
}

void extractTar() {
    if (!tar_data || tar_size == 0) {
        currentState = ERROR;
        current_file = "No data to extract";
        sendProgressUpdate();
        return;
    }

    extracted_files = 0;
    total_files = 0;

    for (size_t offset = 0; offset + 512 <= tar_size;) {
        const char* header = (const char*)(tar_data + offset);
        if (header[0] == '\0') break;

        char filename[101] = { 0 };
        strncpy(filename, header, 100);

        if (filename[strlen(filename) - 1] != '/') total_files++;

        char size_str[12] = { 0 };
        strncpy(size_str, header + 124, 11);
        size_t file_size = strtol(size_str, NULL, 8);
        offset += 512 + ((file_size + 511) / 512) * 512;
    }

    sendProgressUpdate();

    current_file = "Extracting files...";
    for (size_t offset = 0; offset + 512 <= tar_size;) {
        const char* header = (const char*)(tar_data + offset);
        if (header[0] == '\0') break;

        char filename[100 + 1] = { 0 };
        strncpy(filename, header, 100);
        current_file = String(filename);

        char size_str[12] = { 0 };
        strncpy(size_str, header + 124, 11);
        size_t file_size = strtol(size_str, NULL, 8);

        if (filename[strlen(filename) - 1] != '/') {
            int lastSlash = current_file.lastIndexOf('/');
            if (lastSlash > 0) {
                String dirPath = current_file.substring(0, lastSlash);
                createDirectories(dirPath);
            }

            File f;
            if (sdMounted) f = SD.open("/" + current_file, "w");
            else f = FILESYS.open("/" + current_file, "w");

            if (f) {
                f.write(tar_data + offset + 512, file_size);
                f.close();
                extracted_files++;
            }
        } else {
            createDirectories(current_file);
        }

        size_t total_block = ((file_size + 511) / 512) * 512;
        offset += 512 + total_block;
        sendProgressUpdate();
        vTaskDelay(pdMS_TO_TICKS(50));
    }

    current_file = "Extraction complete!";
    free(tar_data);
    tar_data = nullptr;
    currentState = COMPLETE;
    sendProgressUpdate();
}

void handleDownloadState() {
    static uint32_t lastStateChange = 0;
    const uint32_t stateDelay = 100;

    if (millis() - lastStateChange < stateDelay) return;
    lastStateChange = millis();

    switch (currentState) {
        case IDLE:
            current_file = "Waiting for files";
            sendProgressUpdate();
            break;
        case DOWNLOADING:
            if (downloadTarToPSRAM(downloadUrl.c_str(), &tar_size)) {
                current_file = "Extracting files...";
                sendProgressUpdate();
                currentState = EXTRACTING;
            }
            break;
        case EXTRACTING:
            extractTar();
            break;
        case COMPLETE:
            current_file = "All files extracted";
            sendProgressUpdate();
            activeOperation = false;
            break;
        case ERROR:
            downloadUrl = "";
            current_file = "Unable to extract TAR";
            sendProgressUpdate();
            currentState = IDLE;
            activeOperation = false;
            break;
    }
}

bool isUrlReachable(String url) {
    bool reachable = false;
    WiFiClient* client = getOTAClient(url);
    if (!client) return false;

    HTTPClient otaHttp;
    otaHttp.setFollowRedirects(HTTPC_STRICT_FOLLOW_REDIRECTS);
    otaHttp.setRedirectLimit(10);
    otaHttp.addHeader("User-Agent", "Mozilla/5.0");

    if (otaHttp.begin(*client, url)) {
        int httpCode = otaHttp.GET();
        String httpCodeStr = String(httpCode);
        otaEvents.send(httpCodeStr, "htmlstatus", millis());
        if (httpCode >= 200 && httpCode <= 399) reachable = true;
        otaHttp.end();
    }
    return reachable;
}

struct FTPSettings {
    const char* username;
    const char* password;
    const char* motd;
};

FTPSettings ftpSettings = {
    "ftp-user",
    "ftp-pass",
    "Welcome To PS4-Hack FTP Server"
};

struct Config {
    String wifi_ssid;
    String wifi_password;
    bool use_wifi;
    bool use_bluetooth;
    String ap_ssid;
    String ap_password;
    bool allow_ftp;
    String ftp_username;
    String ftp_password;
    bool use_led;
    bool use_ws212b;
    String led_gpio;
    String gpio_rcm;
    String button_gpio;
    String defaultAnimation;
    bool use_sdcard;
    bool auto_sleep;
    String cs_gpio;
    String delay_minutes;
    String rgb_order;
    String hostname;
    bool use_station;
    bool use_mdns;
    bool sleep_injection;
    bool is_s2;
    String touch_gpio;
    String threshold_val;
    bool use_touch;
};

Config config;

bool saveConfiguration() {
    // Validation helper - ensures string is printable ASCII, no control chars
    auto validateString = [](const String& val, size_t maxLen, bool allowEmpty = true) -> bool {
        if (val.length() > maxLen) return false;
        if (!allowEmpty && val.length() == 0) return false;
        for (size_t i = 0; i < val.length(); i++) {
            char c = val[i];
            if (c < 32 || c > 126) return false;  // Only printable ASCII
        }
        return true;
    };

    // Validation helper for int strings (no floats, no negatives if min >= 0)
    auto validateIntString = [](const String& val, int minVal, int maxVal) -> bool {
        if (val.length() == 0 || val.length() > 10) return false;

        // Check all characters are digits (or minus sign at start if minVal < 0)
        for (size_t i = 0; i < val.length(); i++) {
            if (i == 0 && val[i] == '-' && minVal < 0) continue;
            if (!isdigit(val[i])) return false;
        }

        // Check range
        char* endptr;
        long num = strtol(val.c_str(), &endptr, 10);
        if (*endptr != '\0') return false;
        return (num >= minVal && num <= maxVal);
    };

    // Validation helper for valid hostname
    auto validateHostname = [](const String& val) -> bool {
        if (val.length() == 0 || val.length() > 32) return false;
        if (val[0] == '-' || val[val.length() - 1] == '-') return false;
        for (size_t i = 0; i < val.length(); i++) {
            char c = val[i];
            if (!isalnum(c) && c != '-') return false;
        }
        return true;
    };

    // Validate all config values before saving
    if (!validateString(config.wifi_ssid, 32)) config.wifi_ssid = "ESP32";
    if (!validateString(config.wifi_password, 64)) config.wifi_password = "";
    if (!validateString(config.ap_ssid, 32)) config.ap_ssid = "ESP32";
    if (!validateString(config.ap_password, 64)) config.ap_password = "";
    if (!validateString(config.ftp_username, 32)) config.ftp_username = "ftp-user";
    if (!validateString(config.ftp_password, 32)) config.ftp_password = "ftp-pass";

    // GPIO pins must be valid ESP32 pins (0-48 typically)
    if (!validateIntString(config.led_gpio, 0, 48)) config.led_gpio = String(LEDPIN);
    if (!validateIntString(config.gpio_rcm, 1, 48)) config.gpio_rcm = String(RCM_PIN);
    if (!validateIntString(config.button_gpio, 0, 48)) config.button_gpio = "0";
    if (!validateIntString(config.touch_gpio, 1, 15)) config.touch_gpio = "1";
    if (!validateIntString(config.threshold_val, 40, 500)) config.threshold_val = "60";

    // Animation choice: 1-3
    if (!validateIntString(config.defaultAnimation, 1, 3)) config.defaultAnimation = String(ledchoice);

    // RGB order: 1-2
    if (!validateIntString(config.rgb_order, 1, 2)) config.rgb_order = String(ledorder);

    // Delay minutes: 0-65535 (uint16_t range)
    if (!validateIntString(config.delay_minutes, 0, 65535)) config.delay_minutes = "10";

    // Hostname validation
    if (!validateHostname(config.hostname)) config.hostname = "esp32";

    // Now proceed with saving
    FILESYS.remove("/config.json");

    File file = FILESYS.open("/config.json", "w");
    if (!file) {
        return false;
    }

    JsonDocument doc;
    doc["wifi_ssid"] = config.wifi_ssid;
    doc["wifi_password"] = config.wifi_password;
    doc["use_wifi"] = config.use_wifi;
    doc["use_bluetooth"] = config.use_bluetooth;
    doc["ap_ssid"] = config.ap_ssid;
    doc["ap_password"] = config.ap_password;
    doc["allow_ftp"] = config.allow_ftp;
    doc["use_touch"] = config.use_touch;
    doc["ftp_username"] = config.ftp_username;
    doc["ftp_password"] = config.ftp_password;
    doc["use_led"] = config.use_led;
    doc["use_ws212b"] = config.use_ws212b;
    doc["led_gpio"] = config.led_gpio;
    doc["gpio_rcm"] = config.gpio_rcm;
    doc["button_gpio"] = config.button_gpio;
    doc["defaultAnimation"] = config.defaultAnimation;
    doc["use_sdcard"] = config.use_sdcard;
    doc["auto_sleep"] = config.auto_sleep;
    doc["cs_gpio"] = config.cs_gpio;
    doc["delay_minutes"] = config.delay_minutes;
    doc["rgb_order"] = config.rgb_order;
    doc["hostname"] = config.hostname;
    doc["use_station"] = config.use_station;
    doc["use_mdns"] = config.use_mdns;
    doc["sleep_injection"] = config.sleep_injection;
    doc["touch_gpio"] = config.touch_gpio;
    doc["threshold_val"] = config.threshold_val;

    if (serializeJson(doc, file) == 0) {
        file.close();
        return false;
    }

    file.close();
    return true;
}

bool loadConfiguration() {
    // Set defaults first
    config.wifi_ssid = "ESP32";
    config.wifi_password = "ESP32";
    config.use_wifi = false;
    config.use_bluetooth = false;
    config.ap_ssid = "ESP32";
    config.ap_password = "";
    config.allow_ftp = false;
    config.use_touch = false;
    config.ftp_username = "ftp-user";
    config.ftp_password = "ftp-pass";
    config.use_led = true;
    config.use_ws212b = ws212b;
    config.led_gpio = String(LEDPIN);
    config.gpio_rcm = String(RCM_PIN);
    config.button_gpio = "0";
    config.defaultAnimation = String(ledchoice);
    config.use_sdcard = false;
    config.auto_sleep = autosleep;
    config.cs_gpio = "10";
    config.delay_minutes = String(TIME2SLEEP);
    config.rgb_order = String(ledorder);
    config.hostname = "esp32";
    config.use_station = false;
    config.use_mdns = true;
    config.sleep_injection = false;
    config.is_s2 = is_s2; //gets this automatically, no need to save
    config.touch_gpio = "touch_gpio";
    config.threshold_val = "threshold_val";

    // Check if file exists
    if (!FILESYS.exists("/config.json")) {
        saveConfiguration();
        return false;
    }

    File file = FILESYS.open("/config.json", "r");
    if (!file || file.isDirectory()) {
        FILESYS.remove("/config.json");
        saveConfiguration();
        return false;
    }

    size_t size = file.size();
    if (size == 0 || size > 8192) {
        file.close();
        FILESYS.remove("/config.json");
        saveConfiguration();
        return false;
    }

    std::unique_ptr<char[]> buf(new char[size + 1]);
    file.readBytes(buf.get(), size);
    file.close();
    buf[size] = '\0';

    JsonDocument doc;
    DeserializationError error = deserializeJson(doc, buf.get());
    if (error) {
        FILESYS.remove("/config.json");
        saveConfiguration();
        return false;
    }

    // STRICT validation helper for int strings - REJECTS floats completely
    auto safeIntString = [&doc](const char* key, String& target, int minVal, int maxVal) {
        if (doc[key].isNull()) return;

        JsonVariant var = doc[key];

        // REJECT float/double types completely
        if (var.is<double>() || var.is<float>()) {
            return;  // Keep default, don't accept floats
        }

        long val = 0;
        bool valid = false;
        const char* str = nullptr;

        if (var.is<int>() || var.is<long>()) {
            val = var.as<long>();
            valid = true;
        } else if (var.is<const char*>()) {
            str = var.as<const char*>();
            if (str && strlen(str) > 0) {
                // Check for decimal point (reject floats disguised as strings)
                for (size_t i = 0; i < strlen(str); i++) {
                    if (str[i] == '.' || str[i] == ',') return;  // Reject floats
                }

                // Check all characters are digits (or minus at start)
                for (size_t i = 0; i < strlen(str); i++) {
                    if (i == 0 && str[i] == '-' && minVal < 0) continue;
                    if (!isdigit((unsigned char)str[i])) return;  // Invalid char
                }

                char* endptr;
                val = strtol(str, &endptr, 10);
                if (*endptr == '\0') valid = true;
            }
        }

        // Final range check
        if (valid && val >= minVal && val <= maxVal) {
            target = String(val);
        }
        // Else keep default
    };

    // Helper for strings with max length and printable ASCII check
    auto safeString = [&doc](const char* key, String& target, size_t maxLen) {
        if (doc[key].isNull()) return;

        const char* val = doc[key].as<const char*>();
        if (!val) return;

        size_t len = strlen(val);
        if (len > maxLen) return;

        // Check printable ASCII only
        for (size_t i = 0; i < len; i++) {
            if (val[i] < 32 || val[i] > 126) return;
        }

        target = val;
    };

    // Helper for boolean (accepts bool, int 0/1, or strings)
    auto safeBool = [&doc](const char* key, bool& target) {
        if (doc[key].isNull()) return;

        JsonVariant var = doc[key];
        if (var.is<bool>()) {
            target = var.as<bool>();
        } else if (var.is<int>()) {
            target = var.as<int>() != 0;
        } else if (var.is<const char*>()) {
            const char* str = var.as<const char*>();
            if (strcasecmp(str, "true") == 0 || strcmp(str, "1") == 0) {
                target = true;
            } else if (strcasecmp(str, "false") == 0 || strcmp(str, "0") == 0) {
                target = false;
            }
        }
    };

    // Load with strict validation
    safeString("wifi_ssid", config.wifi_ssid, 32);
    safeString("wifi_password", config.wifi_password, 64);
    safeBool("use_wifi", config.use_wifi);
    safeBool("use_bluetooth", config.use_bluetooth);
    safeString("ap_ssid", config.ap_ssid, 32);
    safeString("ap_password", config.ap_password, 64);
    safeBool("allow_ftp", config.allow_ftp);
    safeBool("use_touch", config.use_touch);
    safeString("ftp_username", config.ftp_username, 32);
    safeString("ftp_password", config.ftp_password, 32);
    safeBool("use_led", config.use_led);
    safeBool("use_ws212b", config.use_ws212b);

    // GPIO pins 0-48 (ESP32 range)
    safeIntString("led_gpio", config.led_gpio, 0, 48);
    safeIntString("gpio_rcm", config.gpio_rcm, 1, 48);
    safeIntString("button_gpio", config.button_gpio, 0, 48);
    safeIntString("cs_gpio", config.cs_gpio, 0, 48);
    safeIntString("touch_gpio", config.touch_gpio, 1, 15);
    safeIntString("threshold_val", config.threshold_val, 40, 500);

    // Animation 1-3
    safeIntString("defaultAnimation", config.defaultAnimation, 1, 3);

    // RGB order 1-2
    safeIntString("rgb_order", config.rgb_order, 1, 2);

    // CRITICAL: delay_minutes must be 1-65535 (NOT 0, to prevent instant sleep)
    safeIntString("delay_minutes", config.delay_minutes, 1, 65535);

    safeBool("use_sdcard", config.use_sdcard);
    safeBool("auto_sleep", config.auto_sleep);
    safeBool("use_station", config.use_station);
    safeBool("use_mdns", config.use_mdns);
    safeBool("sleep_injection", config.sleep_injection);

    // Hostname validation
    if (doc["hostname"].is<const char*>()) {
        const char* val = doc["hostname"];
        if (val && strlen(val) > 0 && strlen(val) <= 32) {
            bool valid = true;
            if (val[0] == '-' || val[strlen(val) - 1] == '-') valid = false;
            for (size_t i = 0; i < strlen(val); i++) {
                if (!isalnum((unsigned char)val[i]) && val[i] != '-') {
                    valid = false;
                    break;
                }
            }
            if (valid) config.hostname = val;
        }
    }

    return true;
}

void taskmaster(String state) {
    if (state == "suspend") {
        if (dnsTaskHandle) vTaskSuspend(dnsTaskHandle);
        dnsServer.stop();
    }
    if (state == "resume") {
        if (dnsTaskHandle) vTaskResume(dnsTaskHandle);
        dnsServer.start(53, "*", Local_IP);
    }
}

void hardReset() {
    if (startled) strip->fill(strip->Color(0, 0, 0));
    esp_deep_sleep(1);
}

void startAccessPoint() {
    if (config.ap_ssid.length() == 0) config.ap_ssid = "ESP32-AP";

    if (config.use_station && WiFi.status() == WL_CONNECTED) WiFi.mode(WIFI_AP_STA);
    else WiFi.mode(WIFI_AP);

    WiFi.softAPConfig(Local_IP, Gateway, Subnet_Mask);
    WiFi.softAP(config.ap_ssid.c_str(), config.ap_password.c_str());

    esp_wifi_set_ps(WIFI_PS_NONE);
    esp_wifi_set_protocol(WIFI_IF_AP, WIFI_PROTOCOL_11B | WIFI_PROTOCOL_11G | WIFI_PROTOCOL_11N);

    dnsServer.setTTL(30);
    dnsServer.setErrorReplyCode(DNSReplyCode::ServerFailure);
    dnsServer.start(53, "*", Local_IP);

    AP_Running = true;
}

void startWiFi() {
    if (config.use_wifi && config.wifi_ssid.length() > 0) {
        WiFi.begin(config.wifi_ssid.c_str(), config.wifi_password.c_str());
        unsigned long startAttemptTime = millis();
        while (WiFi.status() != WL_CONNECTED && millis() - startAttemptTime < 10000) delay(500);

        if (WiFi.status() == WL_CONNECTED) {
            if (config.use_station) {
                startAccessPoint();
                allowFTP = config.allow_ftp;
            }
            return;
        }
    }
    startAccessPoint();
    allowFTP = config.allow_ftp;
}

String formatBytes(uint64_t bytes) {
    if (bytes < 1024) return String(bytes) + " B";
    else if (bytes < (1024 * 1024)) return String(bytes / 1024.0, 2) + " KB";
    else if (bytes < (1024ull * 1024 * 1024)) return String(bytes / 1024.0 / 1024.0, 2) + " MB";
    else return String(bytes / 1024.0 / 1024.0 / 1024.0, 2) + " GB";
}

bool removeAllFilesInDir(File dir) {
    while (true) {
        File entry = dir.openNextFile();
        if (!entry) break;
        String path = entry.path();

        if (entry.isDirectory()) {
            if (path.endsWith("/.") || path.endsWith("/..")) {
                entry.close();
                continue;
            }
            removeAllFilesInDir(entry);
            entry.close();
            if (!SD.rmdir(path.c_str())) return false;
        } else {
            entry.close();
            if (path != "/config.json") {
                if (!SD.remove(path.c_str())) return false;
            }
        }
    }
    return true;
}

void formatFileSystem() {
    if (sdMounted) {
        File root = SD.open("/");
        bool success = removeAllFilesInDir(root);
        if (success) root.close();
    } else {
        FILESYS.end();
        bool formatted = FILESYS.format();
        if (formatted) FILESYS.begin();
    }
}

void startFormattingInBackground() {
    formatStatus = "IN_PROGRESS";
    xTaskCreate([](void*) {
        formatFileSystem();
        formatStatus = "DONE";
        vTaskDelete(NULL);
    },
                "FormatTask", 4096, NULL, 1, NULL);
}

void initMemorySystem() {
    psramAvailable = hasPSRAM();
    if (psramAvailable) {
        totalPSRAM = ESP.getPsramSize();
        availablePSRAM = ESP.getFreePsram();
    }
}

void* allocPSRAM(size_t size, const char* purpose = "") {
    if (!psramAvailable) return nullptr;
    void* buffer = psram_malloc(size);
    if (buffer) availablePSRAM -= size;
    return buffer;
}

void freePSRAM(void* buffer, size_t size) {
    if (buffer && psramAvailable) {
        free(buffer);
        availablePSRAM += size;
    }
}

bool hasPSRAM() {
    #if CONFIG_SPIRAM
    return psramFound();
    #else
    return false;
    #endif
}

void* psram_malloc(size_t size) {
    if (!hasPSRAM()) return nullptr;
    return heap_caps_malloc(size, MALLOC_CAP_SPIRAM);
}

void freeChunk() {
    if (fileChunk) {
        memset(fileChunk, 0, CHUNK_SIZE);
        freePSRAM(fileChunk, CHUNK_SIZE);
        fileChunk = nullptr;
    }
    currentChunk = 0;
    totalChunks = 0;
    fileSize = 0;
    currentFilename = "";
}

bool isAllowedExtension(String filename) {
    for (int i = 0; i < ALLOWED_EXT_COUNT; i++) {
        if (filename.endsWith(ALLOWED_EXTENSIONS[i])) return true;
    }
    return false;
}

void listFilesRecursive(File dir, String parentPath, String& output) {
    while (File entry = dir.openNextFile()) {
        String entryName = entry.name();
        if (entryName.startsWith(".")) {
            entry.close();
            continue;
        }
        String path = (parentPath == "/" ? "/" : parentPath + "/") + entryName;
        if (entry.isDirectory()) listFilesRecursive(entry, path, output);
        else if (isAllowedExtension(entryName)) {
            if (output.length() > 1) output += ",";
            output += "\"" + path + "\"";
        }
        entry.close();
    }
}

uint64_t getUsedBytes(fs::FS& fs, const char* path = "/") {
    uint64_t total = 0;
    File root = fs.open(path);
    if (!root || !root.isDirectory()) return 0;
    File file = root.openNextFile();
    while (file) {
        if (file.isDirectory()) total += getUsedBytes(fs, file.path());
        else total += file.size();
        file = root.openNextFile();
    }
    return total;
}

void handleSystemInfo(AsyncWebServerRequest* request) {
    JsonDocument doc;

    if (sdMounted) {
        uint64_t total = SD.cardSize();
        uint64_t used = getUsedBytes(SD);
        uint64_t free = total > used ? total - used : 0;
        doc["fs"]["total"] = formatBytes(total).c_str();
        doc["fs"]["used"] = formatBytes(used).c_str();
        doc["fs"]["free"] = formatBytes(free).c_str();
    } else {
        doc["fs"]["total"] = formatBytes(FILESYS.totalBytes());
        doc["fs"]["used"] = formatBytes(FILESYS.usedBytes());
        doc["fs"]["free"] = formatBytes(FILESYS.totalBytes() - FILESYS.usedBytes());
    }

    doc["memory"]["psram"]["total"] = formatBytes(ESP.getPsramSize());
    doc["memory"]["psram"]["free"] = formatBytes(ESP.getFreePsram());
    doc["memory"]["psram"]["max_alloc"] = formatBytes(ESP.getMaxAllocPsram());
    doc["memory"]["heap"]["total"] = formatBytes(ESP.getHeapSize());
    doc["memory"]["heap"]["free"] = formatBytes(ESP.getFreeHeap());
    doc["memory"]["heap"]["max_alloc"] = formatBytes(ESP.getMaxAllocHeap());

    doc["sketch"]["size"] = formatBytes(ESP.getSketchSize());
    doc["sketch"]["free_space"] = formatBytes(ESP.getFreeSketchSpace());
    doc["sketch"]["md5"] = ESP.getSketchMD5();

    doc["system"]["compile_date"] = String(__DATE__) + " " + String(__TIME__);
    doc["system"]["chip_model"] = ESP.getChipModel();
    doc["system"]["chip_revision"] = ESP.getChipRevision();
    doc["system"]["cpu_freq"] = String(ESP.getCpuFreqMHz()) + " MHz";
    doc["system"]["sdk_version"] = ESP.getSdkVersion();
    doc["system"]["flash_size"] = formatBytes(ESP.getFlashChipSize());
    doc["chip"]["cores"] = ESP.getChipCores();

    const char* flashMode;
    switch (ESP.getFlashChipMode()) {
        case FM_QIO: flashMode = "QIO"; break;
        case FM_QOUT: flashMode = "QOUT"; break;
        case FM_DIO: flashMode = "DIO"; break;
        case FM_DOUT: flashMode = "DOUT"; break;
        case FM_FAST_READ: flashMode = "FAST_READ"; break;
        case FM_SLOW_READ: flashMode = "SLOW_READ"; break;
        case FM_UNKNOWN:
        default: flashMode = "UNKNOWN"; break;
    }
    doc["chip"]["flash_mode"] = flashMode;

    uint8_t baseMac[6];
    esp_read_mac(baseMac, ESP_MAC_WIFI_STA);
    char macStr[18];
    snprintf(macStr, sizeof(macStr), "%02X:%02X:%02X:%02X:%02X:%02X",
             baseMac[0], baseMac[1], baseMac[2], baseMac[3], baseMac[4], baseMac[5]);

    esp_read_mac(baseMac, ESP_MAC_WIFI_SOFTAP);
    char macStr2[18];
    snprintf(macStr2, sizeof(macStr2), "%02X:%02X:%02X:%02X:%02X:%02X",
             baseMac[0], baseMac[1], baseMac[2], baseMac[3], baseMac[4], baseMac[5]);

    esp_read_mac(baseMac, ESP_MAC_ETH);
    char baseMacStr[18];
    snprintf(baseMacStr, sizeof(baseMacStr), "%02X:%02X:%02X:%02X:%02X:%02X",
             baseMac[0], baseMac[1], baseMac[2], baseMac[3], baseMac[4], baseMac[5]);

    doc["mac"]["wifi"] = macStr;
    doc["mac"]["ap"] = macStr2;
    doc["mac"]["base"] = baseMacStr;

 bool usb_cdc_enabled = false;
 
  #ifdef ARDUINO_USB_MODE
    usb_cdc_enabled = (ARDUINO_USB_MODE == 1);
  #elif defined( USBCON)
    usb_cdc_enabled = true;
  #endif
 
  #ifdef ARDUINO_USB_CDC_ON_BOOT
    usb_cdc_enabled = usb_cdc_enabled && ARDUINO_USB_CDC_ON_BOOT;
  #endif

    #if !CONFIG_IDF_TARGET_ESP32S2
        doc["usb"]["cdc_enabled"] = usb_cdc_enabled;
        doc["usb"]["serial_connected"] = Serial ? true : false;
        doc["usb"]["vid"] = USB_VID ? USB_VID : 0x303a;
        doc["usb"]["pid"] = USB_PID ? USB_PID : 0x1001;
    #endif

    #if defined(USB_PRODUCT)
        doc["usb"]["product_name"] = USB_PRODUCT;
    #else
        doc["usb"]["product_name"] = "ESP32 USB Device";
    #endif

    #if defined(USB_MANUFACTURER)
        doc["usb"]["manufacturer"] = USB_MANUFACTURER;
    #else
        doc["usb"]["manufacturer"] = "Espressif";
    #endif

    String ipStr;
    if (WiFi.status() == WL_CONNECTED) {
        ipStr = WiFi.localIP().toString();  // Prioritize WiFi IP when connected
    } else if (AP_Running) {
        ipStr = WiFi.softAPIP().toString();  // Fall back to AP IP
    } else {
        ipStr = "0.0.0.0";  // No connection
    }

    const char* ipCStr = ipStr.c_str();
    doc["server"]["ip"] = ipCStr;
    doc["rcm"]["active"] = rcm_injection_active;
    doc["rcm"]["device_connected"] = rcm_device_connected;

    String json;
    serializeJson(doc, json);
    request->send(200, "application/json", json);
}

void serveCompressedHTML(AsyncWebServerRequest* request) {
    AsyncWebServerResponse* response = request->beginResponse(
      200, "text/html", firmware_update_html_gz, firmware_update_html_gz_len);
    response->addHeader("Content-Encoding", "gzip");
    request->send(response);
}

void handleVersion(AsyncWebServerRequest* request) {
    if (sdMounted) {
        if (SD.exists("/version.txt")) {
            File versionFile = SD.open("/version.txt", "r");
            if (versionFile) {
                String version = versionFile.readString();
                versionFile.close();
                request->send(200, "text/plain", version);
                return;
            }
        }
    } else {
        if (FILESYS.exists("/version.txt")) {
            File versionFile = FILESYS.open("/version.txt", "r");
            if (versionFile) {
                String version = versionFile.readString();
                versionFile.close();
                request->send(200, "text/plain", version);
                return;
            }
        }
    }
    request->send(200, "text/plain", firmwareVersion);
}

void handleUpload(AsyncWebServerRequest* request) {
    request->send(200, "text/plain", "Upload started");
}

void handleFirmwareUpload(AsyncWebServerRequest* request, String filename,
                          size_t index, uint8_t* data, size_t len, bool final) {
    if (!index) {
        if (!Update.begin(UPDATE_SIZE_UNKNOWN)) {}
        otaEvents.send("0", "progress", millis());
    }

    if (Update.write(data, len) != len) {
        otaEvents.send("Update write failed", "error", millis());
        return;
    }

    if (final) {
        if (Update.end(true)) {
            otaEvents.send("100", "progress", millis());
            otaEvents.send("complete", "complete", millis());
        } else {
            otaEvents.send("Update failed", "error", millis());
        }
    }
}

void handleFlashRequest(AsyncWebServerRequest* request) {
    request->send(200, "text/plain", "Flashing now...");
}

String encodeUrlSpacesAndTabs(const String& url) {
    String encodedUrl = "";
    for (int i = 0; i < url.length(); i++) {
        char c = url.charAt(i);
        if (c == ' ') encodedUrl += "%20";
        else if (c == '\t') encodedUrl += "%09";
        else encodedUrl += c;
    }
    return encodedUrl;
}

WiFiClient* getOTAClient(const String& url) {
    if (url.isEmpty()) return nullptr;
    if (url.startsWith("https://")) {
        otaSecureClient.setInsecure();
        otaSecureClient.setTimeout(15000);
        return &otaSecureClient;
    } else if (url.startsWith("http://")) {
        return &otaClient;
    }
    return nullptr;
}

void startOTA(String url) {
    if (ESP.getFreeHeap() < 20000) return;

    if (url.indexOf("bit.ly") != -1 && url.startsWith("http://")) {
        url = "https://" + url.substring(7);
    }

    url = encodeUrlSpacesAndTabs(url);

    if (!isUrlReachable(url)) {
        otaEvents.send("unreachable", "unreachable", millis());
        return;
    }

    WiFiClient* client = getOTAClient(url);
    if (!client) return;

    otaTotalWritten = 0;
    otaHttp.setTimeout(15000);
    otaHttp.setConnectTimeout(10000);
    otaHttp.setRedirectLimit(10);
    otaHttp.setFollowRedirects(HTTPC_STRICT_FOLLOW_REDIRECTS);
    otaHttp.addHeader("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36");
    otaHttp.addHeader("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8");
    otaHttp.addHeader("Accept-Language", "en-US,en;q=0.5");
    otaHttp.addHeader("Connection", "close");

    if (otaHttp.begin(*client, url)) {
        int httpCode = otaHttp.GET();
        if (httpCode == HTTP_CODE_OK) {
            otaContentLength = otaHttp.getSize();
            if (otaContentLength <= 0) {
                String contentLength = otaHttp.header("Content-Length");
                if (contentLength.length() > 0) otaContentLength = contentLength.toInt();
                else {
                    otaEvents.send("Update failed: Could not determine file size", "error", millis());
                    otaHttp.end();
                    return;
                }
            }

            if (!Update.begin(otaContentLength)) {
                otaEvents.send("Update failed: Not enough space", "error", millis());
                otaHttp.end();
                if (url.startsWith("https://")) otaSecureClient.stop();
                else otaClient.stop();
                return;
            }

            otaStream = otaHttp.getStreamPtr();
            otaInProgress = true;
        } else {
            otaHttp.end();
            if (url.startsWith("https://")) otaSecureClient.stop();
            else otaClient.stop();
        }
    } else {
        otaEvents.send("Update failed: HTTP begin failed", "error", millis());
        return;
    }
}

void processOTA() {
    if (!otaInProgress || otaContentLength <= 0 || !otaStream) return;

    uint8_t buff[1024];
    size_t available = otaStream->available();
    if (available) {
        int read = otaStream->readBytes(buff, std::min((int)sizeof(buff), otaContentLength));
        if (read <= 0) {
            otaInProgress = false;
            otaHttp.end();
            if (!mdnsRunning && is_s2) Start_mdns_service();
            return;
        }

        if (Update.write(buff, read) != (size_t)read) {
            otaInProgress = false;
            otaHttp.end();
            if (!mdnsRunning && is_s2) Start_mdns_service();
            return;
        }

        otaContentLength -= read;
        otaTotalWritten += read;
        yield();

        int progress = (otaTotalWritten * 100) / (otaTotalWritten + otaContentLength);
        otaEvents.send(String(progress).c_str(), "progress", millis());
    }

    if (otaContentLength <= 0) {
        if (Update.end() && Update.isFinished()) {
            otaEvents.send("100", "progress", millis());
            otaEvents.send("complete", "complete", millis());
            hardReset();
        }
        otaInProgress = false;
        otaHttp.end();
    }
}

bool startftpserver() {
    allowFTP = true;
    if (config.use_mdns) MDNS.addService("ftp", "tcp", 21);
    if (sdMounted) ftpSrv.setFileSystem(SD);
    ftpSrv.begin(config.ftp_username.c_str(), config.ftp_password.c_str(), ftpSettings.motd);
    xTaskCreate(ftpServerTask, "FTPServer", 4096, NULL, 1, &ftpTaskHandle);
    return allowFTP;
}

bool stopftpserver() {
    if (ftpTaskHandle == NULL) return true;
    allowFTP = false;
    xTaskNotify(ftpTaskHandle, 0, eNoAction);
    const TickType_t timeout = pdMS_TO_TICKS(1000);
    const TickType_t startTime = xTaskGetTickCount();
    while (eTaskGetState(ftpTaskHandle) != eDeleted && (xTaskGetTickCount() - startTime) < timeout) {
        vTaskDelay(pdMS_TO_TICKS(10));
    }
    if (eTaskGetState(ftpTaskHandle) != eDeleted) vTaskDelete(ftpTaskHandle);
    ftpTaskHandle = NULL;
    return true;
}

void ftpServerTask(void* pvParameters) {
    while (true) {
        if (allowFTP) ftpSrv.handleFTP();
        uint32_t notificationValue;
        if (xTaskNotifyWait(0, ULONG_MAX, &notificationValue, 0) == pdTRUE) {
            ftpSrv.end();
            vTaskDelete(NULL);
            return;
        }
        vTaskDelay(pdMS_TO_TICKS(10));
    }
}

void dnsServerTask(void* pvParameters) {
    while (true) {
        dnsServer.processNextRequest();
        vTaskDelay(pdMS_TO_TICKS(10));
    }
}

void otaUpdateTask(void* pvParameters) {
    while (true) {
        if (otaInProgress) processOTA();
        vTaskDelay(100 / portTICK_PERIOD_MS);
    }
    vTaskDelete(NULL);
}

void setupServer() {
    if (sdMounted) hasIndexFile = SD.exists("/index.html");
    else hasIndexFile = FILESYS.exists("/index.html");

    server.on("/config.json", HTTP_GET, [](AsyncWebServerRequest* request) {
        request->redirect("/index.html");
    });

    server.on("/", HTTP_GET, [](AsyncWebServerRequest* request) {
        AsyncWebServerResponse* response = request->beginResponse(200, "text/html", index_gz, index_gz_len);
        response->addHeader("Content-Encoding", "gzip");
        request->send(response);
    });

    server.on("/esp32.local", HTTP_GET, [](AsyncWebServerRequest* request) {
        request->redirect("/");
    });

    if (sdMounted) server.serveStatic("/", SD, "/");
    else server.serveStatic("/", FILESYS, "/");

    server.onNotFound([](AsyncWebServerRequest* request) {
        if (hasIndexFile) request->redirect("/index.html");
        else request->redirect("/file");
    });

    server.on("/edit", HTTP_GET, [](AsyncWebServerRequest* request) {
        AsyncWebServerResponse* response = request->beginResponse(200, "text/html", editor_gz, editor_gz_len);
        response->addHeader("Content-Encoding", "gzip");
        request->send(response);
    });

    server.on("/psram-list-files", HTTP_GET, [](AsyncWebServerRequest* request) {
        String jsonResponse = "[";
        File root;
        if (sdMounted) root = SD.open("/");
        else root = FILESYS.open("/");
        String output = "";
        listFilesRecursive(root, "", output);
        root.close();
        jsonResponse += output;
        jsonResponse += "]";
        AsyncWebServerResponse* response = request->beginChunkedResponse("application/json",
                                                                         [jsonResponse](uint8_t* buffer, size_t maxLen, size_t index) -> size_t {
                                                                             if (index >= jsonResponse.length()) return 0;
                                                                             size_t len = std::min(maxLen, jsonResponse.length() - index);
                                                                             memcpy(buffer, jsonResponse.c_str() + index, len);
                                                                             return len;
                                                                         });
        request->send(response);
    });

    server.on("/psram-read", HTTP_GET, [](AsyncWebServerRequest* request) {
        if (!request->hasParam("file")) {
            request->send(400, "text/plain", "Missing file parameter");
            return;
        }
        String filename = request->getParam("file")->value();
        if (filename.startsWith("/")) filename = filename.substring(1);

        File file;
        if (sdMounted) file = SD.open("/" + filename, "r");
        else file = FILESYS.open("/" + filename, "r");

        if (!file || file.isDirectory()) {
            request->send(404, "text/plain", "File not found");
            return;
        }

        size_t fileSize = file.size();
        if (fileSize > MAX_FILE_SIZE) {
            file.close();
            request->send(413, "text/plain", "File too large");
            return;
        }

        char* buffer = (char*)allocPSRAM(fileSize + 1, "file read");
        if (!buffer) {
            buffer = (char*)malloc(fileSize + 1);
            if (!buffer) {
                file.close();
                request->send(500, "text/plain", "Memory allocation failed");
                return;
            }
        }

        size_t bytesRead = file.readBytes(buffer, fileSize);
        buffer[bytesRead] = '\0';
        file.close();
        request->send(200, "text/plain", buffer);

        if (psramAvailable) freePSRAM(buffer, fileSize + 1);
        else free(buffer);
    });

    server.on("/psram-file-size", HTTP_GET, [](AsyncWebServerRequest* request) {
        if (!request->hasParam("file")) {
            request->send(400, "text/plain", "Missing file parameter");
            return;
        }
        String filename = request->getParam("file")->value();
        if (!filename.startsWith("/")) filename = "/" + filename;
        if (!isAllowedExtension(filename)) {
            request->send(403, "text/plain", "File type not allowed");
            return;
        }

        File file;
        if (sdMounted) file = SD.open(filename, "r");
        else file = FILESYS.open(filename, "r");

        if (!file || file.isDirectory()) {
            request->send(404, "text/plain", "File not found");
            return;
        }

        JsonDocument doc;
        doc["name"] = filename;
        doc["size"] = file.size();
        file.close();

        String json;
        serializeJson(doc, json);
        request->send(200, "application/json", json);
    });

    server.on(
      "/psram-save", HTTP_POST, [](AsyncWebServerRequest* request) {}, NULL,
      [](AsyncWebServerRequest* request, uint8_t* data, size_t len, size_t index, size_t total) {
          static String buffer;
          if (index == 0) {
              size_t safeSize = min(total, (size_t)(psramAvailable ? availablePSRAM / 2 : 16384));
              buffer.reserve(safeSize);
              buffer = "";
          }
          buffer.concat((char*)data, len);
          if (index + len != total) return;

          JsonDocument doc;
          DeserializationError error = deserializeJson(doc, buffer);
          if (error) {
              request->send(400, "text/plain", String("JSON error: ") + error.c_str());
              return;
          }

          String filename = doc["filename"].as<String>();
          String content = doc["content"].as<String>();
          if (!filename.startsWith("/")) filename = "/" + filename;

          File file;
          if (sdMounted) file = SD.open(filename, "w");
          else file = FILESYS.open(filename, "w");

          if (!file) {
              request->send(500, "text/plain", "Failed to open file");
              return;
          }

          size_t bytesWritten = file.print(content);
          file.close();
          request->send(200, "text/plain",
                        bytesWritten == content.length() ? "Saved successfully" : "Incomplete write");
      });

    server.on("/esp32-startftp", HTTP_POST, [](AsyncWebServerRequest* request) {
        if (allowFTP) request->send(200, "text/plain", "RUNNING");
        else {
            bool success = startftpserver();
            if (success) request->send(200, "text/plain", "OK");
            else request->send(500, "text/plain", "FAIL");
        }
    });

    server.on("/esp32-stopftp", HTTP_POST, [](AsyncWebServerRequest* request) {
        if (!allowFTP) request->send(200, "text/plain", "NOTRUNNING");
        else {
            bool stopped = stopftpserver();
            if (stopped) request->send(200, "text/plain", "OK");
            else request->send(500, "text/plain", "FAIL");
        }
    });

    server.on("/esp32-format", HTTP_POST, [](AsyncWebServerRequest* request) {
        startFormattingInBackground();
        request->send(200, "text/plain", "OK");
    });

    server.on("/esp32-status", HTTP_GET, [](AsyncWebServerRequest* request) {
        request->send(200, "text/plain", (const char*)formatStatus);
    });

    server.on("/usboff", HTTP_POST, [](AsyncWebServerRequest* request) {
        ESP.restart();
    });

    server.on("/system-info", HTTP_GET, [](AsyncWebServerRequest* request) {
        handleSystemInfo(request);
    });

    server.on("/ota", HTTP_GET, serveCompressedHTML);
    server.on("/esp32-version", HTTP_GET, handleVersion);
    server.on("/upload-ota", HTTP_POST, handleUpload, handleFirmwareUpload);
    server.on("/esp32-update-local", HTTP_POST, handleFlashRequest);
    server.on("/reboot", HTTP_GET, [](AsyncWebServerRequest* request) {
        request->send(200, "text/plain", "Rebooting...");
        request->onDisconnect([]() {
            delay(100);
            hardReset();
        });
    });

    server.on(
      "/esp32-update", HTTP_POST, [](AsyncWebServerRequest* request) {}, NULL,
      [](AsyncWebServerRequest* request, uint8_t* data, size_t len, size_t index, size_t total) {
          JsonDocument doc;
          DeserializationError error = deserializeJson(doc, data, len);
          if (error || !doc["url"].is<String>()) {
              request->send(400, "text/plain", "Invalid JSON");
              return;
          }
          ota_url = doc["url"].as<String>();
          request->send(200, "text/plain", "OTA started");
          static unsigned long waitforreply = 0;
          if (millis() - waitforreply > 500) {
              doOta = true;
              waitforreply = millis();
          }
      });


    server.on("/get-config", HTTP_GET, [](AsyncWebServerRequest* request) {
        JsonDocument doc;
        doc["use_wifi"] = config.use_wifi;
        doc["use_bluetooth"] = config.use_bluetooth;
        doc["wifi_ssid"] = config.wifi_ssid;
        doc["wifi_password"] = config.wifi_password;
        doc["ap_ssid"] = config.ap_ssid;
        doc["ap_password"] = config.ap_password;
        doc["allow_ftp"] = config.allow_ftp;
        doc["use_touch"] = config.use_touch;
        doc["ftp_username"] = config.ftp_username;
        doc["ftp_password"] = config.ftp_password;
        doc["use_led"] = config.use_led;
        doc["use_ws212b"] = config.use_ws212b;
        doc["led_gpio"] = config.led_gpio;
        doc["gpio_rcm"] = config.gpio_rcm;
        doc["button_gpio"] = config.button_gpio;
        doc["defaultAnimation"] = config.defaultAnimation;
        doc["use_sdcard"] = config.use_sdcard;
        doc["auto_sleep"] = config.auto_sleep;
        doc["cs_gpio"] = config.cs_gpio;
        doc["delay_minutes"] = config.delay_minutes;
        doc["rgb_order"] = config.rgb_order;
        doc["hostname"] = config.hostname;
        doc["use_station"] = config.use_station;
        doc["use_mdns"] = config.use_mdns;
        doc["sleep_injection"] = config.sleep_injection;
        doc["is_s2"] = config.is_s2;
        doc["touch_gpio"] = config.touch_gpio;
        doc["threshold_val"] = config.threshold_val;

        String json;
        serializeJson(doc, json);
        request->send(200, "application/json", json);
    });

    server.on(
      "/save-config", HTTP_POST, [](AsyncWebServerRequest* request) {}, NULL,
      [](AsyncWebServerRequest* request, uint8_t* data, size_t len, size_t index, size_t total) {
          static String jsonBody;
          if (index == 0) jsonBody = "";
          jsonBody.concat((char*)data, len);
          if (index + len == total) {
              JsonDocument doc;
              DeserializationError error = deserializeJson(doc, jsonBody);
              if (error) {
                  String errMsg = "{\"error\":\"JSON parse failed: " + String(error.c_str()) + "\"}";
                  request->send(400, "application/json", errMsg);
                  return;
              }

              config.use_wifi = doc["use_wifi"] | config.use_wifi;
              config.use_bluetooth = doc["use_bluetooth"] | config.use_bluetooth;
              config.wifi_ssid = doc["wifi_ssid"] | config.wifi_ssid;
              config.wifi_password = doc["wifi_password"] | config.wifi_password;
              config.ap_ssid = doc["ap_ssid"] | config.ap_ssid;
              config.ap_password = doc["ap_password"] | config.ap_password;
              config.allow_ftp = doc["allow_ftp"] | config.allow_ftp;
              config.use_touch = doc["use_touch"] | config.use_touch;
              config.ftp_username = doc["ftp_username"] | config.ftp_username;
              config.ftp_password = doc["ftp_password"] | config.ftp_password;
              config.use_led = doc["use_led"] | config.use_led;
              config.use_ws212b = doc["use_ws212b"] | config.use_ws212b;
              config.led_gpio = doc["led_gpio"] | config.led_gpio;
              config.gpio_rcm = doc["gpio_rcm"] | config.gpio_rcm;
              config.button_gpio = doc["button_gpio"] | config.button_gpio;
              config.defaultAnimation = doc["defaultAnimation"] | config.defaultAnimation;
              config.use_sdcard = doc["use_sdcard"] | config.use_sdcard;
              config.auto_sleep = doc["auto_sleep"] | config.auto_sleep;
              config.cs_gpio = doc["cs_gpio"] | config.cs_gpio;
              config.delay_minutes = doc["delay_minutes"] | config.delay_minutes;
              config.rgb_order = doc["rgb_order"] | config.rgb_order;
              config.hostname = doc["hostname"] | config.hostname;
              config.use_station = doc["use_station"] | config.use_station;
              config.use_mdns = doc["use_mdns"] | config.use_mdns;
              config.sleep_injection = doc["sleep_injection"] | config.sleep_injection;
              config.touch_gpio = doc["touch_gpio"] | config.touch_gpio;
              config.threshold_val = doc["threshold_val"] | config.threshold_val;

              if (saveConfiguration()) request->send(200, "application/json", "{\"status\":\"success\"}");
              else request->send(500, "application/json", "{\"error\":\"Filesystem error\"}");
          }
      });

    server.on("/config", HTTP_GET, [](AsyncWebServerRequest* request) {
        AsyncWebServerResponse* response = request->beginResponse(200, "text/html", config_html_gz, config_html_gz_len);
        response->addHeader("Content-Encoding", "gzip");
        request->send(response);
    });

    server.on("/downloader", HTTP_GET, [](AsyncWebServerRequest* request) {
        AsyncWebServerResponse* response = request->beginResponse(200, "text/html", tar_gz, tar_gz_len);
        response->addHeader("Content-Encoding", "gzip");
        request->send(response);
    });

    server.on("/start-download", HTTP_POST, [](AsyncWebServerRequest* request) {
        if (currentState != IDLE) {
            request->send(400, "text/plain", "Download already in progress");
            return;
        }
        activeOperation = true;
        if (request->hasParam("url", true)) {
            downloadUrl = request->getParam("url", true)->value();
            download_progress = 0;
            download_total = 0;
            extracted_files = 0;
            total_files = 0;
            current_file = "Starting download...";
            currentState = DOWNLOADING;
            current_file = "Downloading...";
            sendProgressUpdate();
            request->send(200, "text/plain", "Download started");
        } else {
            request->send(400, "text/plain", "URL parameter missing");
        }
    });

    server.on(
      "/upload-tar", HTTP_POST, [](AsyncWebServerRequest* request) {
          request->send(200);
      },
      [](AsyncWebServerRequest* request, String filename, size_t index, uint8_t* data, size_t len, bool final) {
          static uint8_t* upload_buffer = nullptr;
          static size_t upload_size = 0;
          static size_t total_received = 0;
          static uint32_t last_progress_update = 0;

          if (!index) {
              activeOperation = true;
              if (upload_buffer) {
                  free(upload_buffer);
                  upload_buffer = nullptr;
              }
              currentState = DOWNLOADING;
              download_progress = 0;
              download_total = request->contentLength();
              extracted_files = 0;
              total_files = 0;
              total_received = 0;
              current_file = "Receiving upload...";
              last_progress_update = millis();

              if (download_total > 0) {
                  upload_buffer = (uint8_t*)ps_malloc(download_total);
                  if (!upload_buffer) {
                      request->send(500, "text/plain", "Memory allocation failed");
                      return;
                  }
              }
          }

          if (len > 0) {
              if (!upload_buffer) {
                  uint8_t* new_buffer = (uint8_t*)ps_realloc(upload_buffer, total_received + len);
                  if (!new_buffer) {
                      request->send(500, "text/plain", "Memory allocation failed");
                      return;
                  }
                  upload_buffer = new_buffer;
              }
              memcpy(upload_buffer + index, data, len);
              total_received += len;

              if (millis() - last_progress_update > 200 || final) {
                  download_progress = total_received;
                  sendProgressUpdate();
                  last_progress_update = millis();
              }
          }

          if (final) {
              upload_size = total_received;
              current_file = "Extracting uploaded files...";
              sendProgressUpdate();
              if (upload_buffer && upload_size > 0) {
                  activeOperation = true;
                  tar_data = upload_buffer;
                  tar_size = upload_size;
                  currentState = EXTRACTING;
              } else {
                  if (upload_buffer) free(upload_buffer);
                  upload_buffer = nullptr;
                  currentState = ERROR;
                  current_file = "Upload failed - no data";
                  sendProgressUpdate();
              }
          }
      });

    otaEvents.onConnect([](AsyncEventSourceClient* client) {
        client->send("0", "progress", millis());
        client->send("waiting", "waiting", millis());
    });

    server.begin();
    ws.onEvent(onWebSocketEvent);
    server.addHandler(&ws);
    server.addHandler(&otaEvents);
}

void leddontshow() {
    stopled = false;
    startled = false;
    if (config.use_led && config.use_ws212b && strip != nullptr) {
        setColor(strip, LED_NUMBER - 1, 0, 0, 0);
    }
}

void onSinglePress() {
    stopled = true;
}

void ledshow(uint8_t command) {
    if (config.use_led && config.use_ws212b && strip != nullptr) {
        switch (command) {
            case 1: colorCycle(strip, 80); break;
            case 2: colorCyclePulse(strip, 30); break;
            case 3:
                cleanup(&strip, 29180);
                redblue(strip, 500);
                break;
            default: setColor(strip, LED_NUMBER - 1, 0, 255, 0); break;
        }
    }
}

void onDoublePress() {
    startled = true;
}

void removeconfig() {
    if (FILESYS.remove("/config.json")) hardReset();
}

void onLongPress() {
    removeconf = true;
}

void set_WS212B() {
    pin = config.led_gpio.toInt();
    ledorder = config.rgb_order.toInt();
    uint8_t order = NEO_GRB;
    if (ledorder == 2) order = NEO_RGB;
    strip = new (ps_malloc(sizeof(Adafruit_NeoPixel))) Adafruit_NeoPixel(LED_NUMBER, pin, order + NEO_KHZ800);
    strip->begin();
    strip->setBrightness(MAX_BRIGHTNESS);
}

void Start_mdns_service() {
    if (config.use_mdns) {
        if (!MDNS.begin(config.hostname.c_str())) return;
        else {
            MDNS.addService("http", "tcp", 80);
            mdnsRunning = true;
        }
    }
}

void Stop_mdns_service() {
    if (mdnsRunning) {
        MDNS.end();
        mdnsRunning = false;
    }
}

void shutdownWireless() {
    esp_wifi_stop();
    esp_wifi_deinit();
}

void deepsleep() {
    leddontshow();

        // Clean up RCM / USB before sleep
    if (rcm_usb_task_handle || rcm_injection_task_handle) {
        cleanup_rcm_tasks();
    }
        
        #ifdef DEBUG_SERIAL
    Serial.println("Preparing for deep sleep...");
        #endif

    // === Shutdown Bluetooth cleanly ===
    if (config.use_bluetooth && !is_s2) {
            #ifdef DEBUG_SERIAL
            Serial.println("Disabling Bluetooth before sleep...");
            #endif
      if (BLEDevice::getInitialized()) {
                BLEDevice::deinit(true);   // Full cleanup
      }
    }

    // Shutdown WiFi
    shutdownWireless();

    delay(100);
    #ifdef DEBUG_SERIAL
        Serial.flush();
    Serial.println("Entering deep sleep now...");
        #endif
    esp_deep_sleep_start();
}

String getDefaultPayload() {
    if (sdMounted) {
        if (SD.exists("/payload.txt")) {
            File file = SD.open("/payload.txt", "r");
            String defaultPayload = file.readString();
            file.close();
            defaultPayload.trim();
            if (defaultPayload.length() > 0 && FILESYS.exists("/payloads/" + defaultPayload)) {
                return "/payloads/" + defaultPayload;
            }
        }
    } else {
        if (FILESYS.exists("/payload.txt")) {
            File file = FILESYS.open("/payload.txt", "r");
            String defaultPayload = file.readString();
            file.close();
            defaultPayload.trim();
            if (defaultPayload.length() > 0 && FILESYS.exists("/payloads/" + defaultPayload)) {
                return "/payloads/" + defaultPayload;
            }
        }
    }

    if (sdMounted) {
        if (SD.exists("/payloads/default.bin")) return "/payloads/default.bin";
    } else {
        if (FILESYS.exists("/payloads/default.bin")) return "/payloads/default.bin";
    }

    if (sdMounted) {
        File root = SD.open("/payloads");
        File file = root.openNextFile();
        while (file) {
            if (String(file.name()).endsWith(".bin")) {
                String path = String(file.name());
                file.close();
                root.close();
                return path;
            }
            file = root.openNextFile();
        }
    } else {
        File root = FILESYS.open("/payloads");
        File file = root.openNextFile();
        while (file) {
            if (String(file.name()).endsWith(".bin")) {
                String path = String(file.name());
                file.close();
                root.close();
                return path;
            }
            file = root.openNextFile();
        }
    }
    return "";
}

// Bluetooth File Handlers
void sendFileList() {
    // Clear any pending notifications first
  responseCharacteristic->setValue("");
 
  // Small delay to let stack clear
  vTaskDelay(pdMS_TO_TICKS(50));

    if (listInProgress) {
    RCM_LOG_W("List already in progress - ignoring duplicate request");
    return;
  }
  listInProgress = true;

  if (!responseCharacteristic) {
        #ifdef DEBUG_SERIAL
    Serial.println("Error: responseCharacteristic is null");
        #endif
    return;
  }

  // Try without trailing slash first
  File root = sdMounted ? SD.open("/payloads") : FILESYS.open("/payloads");
 
  // If failed, try with trailing slash
  if (!root) {
    root = sdMounted ? SD.open("/payloads/") : FILESYS.open("/payloads/");
  }

  // Auto-create if missing
  if (!root || !root.isDirectory()) {
        #ifdef DEBUG_SERIAL
    Serial.println("Payloads dir not openable, attempting to create...");
        #endif
    FILESYS.mkdir("/payloads");
    root = FILESYS.open("/payloads");
  }

  if (!root || !root.isDirectory()) {
        #ifdef DEBUG_SERIAL
    Serial.println("CRITICAL: Still cannot open /payloads after mkdir!");
        #endif
    responseCharacteristic->setValue("ERROR: NO FS");
    responseCharacteristic->notify();
    return;
  }

  #ifdef DEBUG_SERIAL
    Serial.println("Successfully opened /payloads directory for BLE listing");
    #endif

  File file = root.openNextFile();
  int fileCount = 0;

  while (file) {
    String name = file.name();
    if (name.endsWith(".bin")) {           // only show .bin files
            name = name + "\n";
      responseCharacteristic->setValue(name.c_str());
      responseCharacteristic->notify();
      Serial.printf("BLE sent: %s\n", name.c_str());
      fileCount++;
      vTaskDelay(pdMS_TO_TICKS(50));       // increased delay for stability
    }
    file = root.openNextFile();
  }

  responseCharacteristic->setValue("END");
  responseCharacteristic->notify();
    #ifdef DEBUG_SERIAL
  Serial.printf("BLE file list complete - %d files sent\n", fileCount);
    #endif

  root.close();
    listInProgress = false;
}

void handleFileSelection(String filename) {
  if (!responseCharacteristic) {
        #ifdef DEBUG_SERIAL
    Serial.println("Error: responseCharacteristic is null");
        #endif
    return;
  }
 
  filename.trim();
    #ifdef DEBUG_SERIAL
  Serial.println("Selecting file: [" + filename + "]");
    #endif
 
  File f;
    if (sdMounted) f = SD.open("/payload.txt", FILE_WRITE);
    else f = FILESYS.open("/payload.txt", FILE_WRITE);

  if (f) {
    f.println(filename);
    f.close();
    responseCharacteristic->setValue("OK");
        #ifdef DEBUG_SERIAL
    Serial.println("File selection saved");
        #endif
  } else {
    responseCharacteristic->setValue("ERROR");
        #ifdef DEBUG_SERIAL
    Serial.println("Failed to save selection");
        #endif
  }

  responseCharacteristic->notify();
}

void handleSendContent(String content) {
  if (!responseCharacteristic) {
        #ifdef DEBUG_SERIAL
    Serial.println("Error: responseCharacteristic is null");
        #endif
    return;
  }

  if (content.length() == 0) {
    responseCharacteristic->setValue("ERROR: EMPTY CONTENT");
    responseCharacteristic->notify();
    return;
  }

  File f;
    if (sdMounted) f = SD.open("/payload.txt", FILE_WRITE);
    else f = FILESYS.open("/payload.txt", FILE_WRITE);
 
    if (f) {
    f.print(content); // Use print() instead of println() to avoid extra newline if unwanted
    f.close();
        #ifdef DEBUG_SERIAL
    Serial.println("Content saved to /payload.txt (preserved case)");
        #endif
    responseCharacteristic->setValue("OK");
  } else {
        #ifdef DEBUG_SERIAL
    Serial.println("Failed to open /payload.txt for writing");
        #endif
    responseCharacteristic->setValue("ERROR: WRITE FAILED");
  }

  responseCharacteristic->notify();
}

// Bluetooth Callbacks
class MyCallbacks : public BLECharacteristicCallbacks {
  void onWrite(BLECharacteristic* pCharacteristic) {
    String value = pCharacteristic->getValue();
    value.trim(); // Remove leading/trailing whitespace/newlines
        
        // Debounce: ignore commands too close together
        unsigned long now = millis();
        if (now - lastCommandTime < COMMAND_DEBOUNCE_MS) {
            RCM_LOG_W("Command debounced - too soon");
            return;
        }
        lastCommandTime = now;

    #ifdef DEBUG_SERIAL
        Serial.printf("BLE Write Received (%d bytes): '%s'\n", value.length(), value.c_str());
        #endif

    // Make a copy for case-insensitive command detection
    String cmd = value;
    cmd.toUpperCase();

    if (cmd == "LIST") {
            #ifdef DEBUG_SERIAL
      Serial.println("Command: LIST → sending file list");
            #endif
      sendFileList();
    }
    else if (cmd.startsWith("PLD:")) {
      String filename = value.substring(4);   // Use ORIGINAL value (case preserved)
      filename.trim();
            #ifdef DEBUG_SERIAL
      Serial.println("Command: PLD: → " + filename);
            #endif
      handleFileSelection(filename);
    }
    else if (cmd.startsWith("SEND:")) {
      String content = value.substring(5);    // Use ORIGINAL value (case preserved)
      content.trim();
            #ifdef DEBUG_SERIAL
      Serial.println("Command: SEND → content: '" + content + "'");
            #endif
      handleSendContent(content);             // We'll create this function
    }
        else if (cmd.startsWith("GETIP")) {
            ipStr = ipStr + "\n";
            responseCharacteristic->setValue(ipStr);
      responseCharacteristic->notify();
    }
        else if (cmd.startsWith("REBOOT")) {
            ESP.restart();  //software reset
    }
    else {
            #ifdef DEBUG_SERIAL
      Serial.println("Unknown command: " + value);
            #endif
      if (responseCharacteristic) {
        responseCharacteristic->setValue("UNKNOWN_CMD");
        responseCharacteristic->notify();
      }
    }
  }
};

class MyServerCallbacks : public BLEServerCallbacks {
    void onConnect(BLEServer* pServer) {
        RCM_LOG_I("BLE Client Connected");
    }

    void onDisconnect(BLEServer* pServer) {
        RCM_LOG_I("BLE Client Disconnected - Restarting Advertising");
        // Restart advertising so the device shows up again
        pServer->getAdvertising()->start();
    }
};

// =============================================
// EARLIEST POSSIBLE EXECUTION - Runs on Core 1
// =============================================
void setup1() {
    // Set default RCM pin HIGH immediately when the chip boots (within 1–3 ms)
    pinMode(RCM_PIN, OUTPUT);
    digitalWrite(RCM_PIN, HIGH);
}

void loop1() {
    // Leave empty or put very light tasks here
    //vTaskDelay(pdMS_TO_TICKS(1000));
}

// ==================== SETUP AND LOOP - Runs on Core 0 ====================
void setup() {
    #ifdef DEBUG_SERIAL
    Serial.begin(115200);
    delay(1000);
    if (Serial.available() > 0) {
        Serial.println("USB Serial connected");
    }

    // Print wake-up reason
    esp_sleep_wakeup_cause_t wakeup_reason = esp_sleep_get_wakeup_cause();
    Serial.printf("Wakeup reason: %d ", wakeup_reason);
    
    switch (wakeup_reason) {
            case ESP_SLEEP_WAKEUP_TIMER:    Serial.println("(Timer)"); break;
            case ESP_SLEEP_WAKEUP_TOUCHPAD: Serial.println("(Touch)"); break;
            case ESP_SLEEP_WAKEUP_EXT0:     Serial.println("(EXT0)"); break;
            case ESP_SLEEP_WAKEUP_EXT1:     Serial.println("(EXT1)"); break;
            default:                        Serial.println("(Power-on or other)"); break;
    }

    Serial.println();
    Serial.println("================================");
    Serial.println("RCM Injector + ESP32 OS v1.0");
    Serial.printf("CPU: %" PRIu32 " MHz\n", ESP.getCpuFreqMHz());
    Serial.println("================================");
    #endif

    // Initialize filesystem first (needed for payload loading)
    if (!FILESYS.begin(true)) {
    #ifdef DEBUG_SERIAL
        Serial.println("FFat Mount Failed - attempting to format...");
    #endif
        formatFileSystem();
        if (!FILESYS.begin(true)) {
    #ifdef DEBUG_SERIAL
            Serial.println("FFat Mount STILL Failed - using minimal defaults");
    #endif
            config.ap_ssid = "ESP32-Config";
            startAccessPoint();
            setupServer();
            return;
        }
    }

    // Load configuration
    if (!loadConfiguration()) {
        #ifdef DEBUG_SERIAL
        Serial.println("Using default configuration");
        #endif
    }

    // === Apply the correct (or configured) RCM pin ===
    rcmPin = config.gpio_rcm.toInt();
    if (rcmPin < 0 || rcmPin > 48) rcmPin = RCM_PIN;  // Safety
    pinMode(rcmPin, OUTPUT);
    digitalWrite(rcmPin, HIGH);
    // If the configured pin is different from default, turn off the default one
    if (rcmPin != RCM_PIN) {
        pinMode(RCM_PIN, INPUT);   // Float it to avoid conflict
  }

    // Initialize SD card if enabled
    if (config.use_sdcard) {
        if (!SD.begin(config.cs_gpio.toInt())) {
            #ifdef DEBUG_SERIAL
            Serial.println("SD card initialization failed!");
            #endif
            File file = FILESYS.open("/config.json", "w");
            JsonDocument doc;
            doc["use_sdcard"] = false;
            file.close();
            sdMounted = false;
            hardReset();
            return;
        }
        uint8_t cardType = SD.cardType();
        if (cardType == CARD_NONE) {
            #ifdef DEBUG_SERIAL
            Serial.println("No SD card attached");
            #endif
            return;
        }
        sdMounted = true;
    }

    // ==================== INITIALIZE RCM INJECTOR ====================
    // This runs BEFORE WiFi to ensure USB host is ready immediately
    RCM_LOG_I("Initializing RCM Injector...");
    injectionsleep = config.sleep_injection;  //check if we need to deep sleep after payload injection

    usb_host_config_t host_config = {};  // All fields zeroed/false/NULL
    host_config.skip_phy_setup = false;
    host_config.intr_flags = ESP_INTR_FLAG_LEVEL1;

    esp_err_t err = usb_host_install(&host_config);
    if (err != ESP_OK) {
        RCM_LOG_E("USB Host install failed: %d", err);
    } else {
        RCM_LOG_I("USB Host installed successfully");

        // Create RCM tasks - higher priority than WiFi tasks
        xTaskCreatePinnedToCore(rcm_usb_host_task, "rcm_usb_host", 4096, NULL, 20, &rcm_usb_task_handle, 0);
        xTaskCreatePinnedToCore(rcm_injection_task, "rcm_inject", 8192, NULL, 19, &rcm_injection_task_handle, 0);

        RCM_LOG_I("RCM Injector ready - waiting for Nintendo Switch in RCM mode...");
    }

    // Initialize network (after RCM USB setup)
    if (config.use_wifi && config.wifi_ssid.length() > 0) {
        startWiFi();
        #ifdef DEBUG_SERIAL
        Serial.println("MAC Address: " + WiFi.macAddress());
        #endif
    } else {
        startAccessPoint();
    }

    if (WiFi.status() == WL_CONNECTED) {
        ipStr = WiFi.localIP().toString();
    }
    else if (AP_Running) {
    ipStr = WiFi.softAPIP().toString();  // Fall back to AP IP
    }
    else {
    ipStr = "0.0.0.0";  // No connection
    }

    #ifdef DEBUG_SERIAL
    Serial.println("IP Address: " + ipStr);
    #endif
    
    Start_mdns_service();
    allowFTP = config.allow_ftp;
    ftpSettings.username = config.ftp_username.c_str();
    ftpSettings.password = config.ftp_password.c_str();
    MAX_FILE_SIZE = heap_caps_get_free_size(MALLOC_CAP_SPIRAM);
    initMemorySystem();
    MAX_FILE_SIZE = ESP.getFreePsram();
    xTaskCreate(dnsServerTask, "DNSServer", 2048, NULL, 3, &dnsTaskHandle);
    freeChunk();
    setupServer();
    if (sdMounted) {
    if (!fileManager.begin(SD)) {
    #ifdef DEBUG_SERIAL
        Serial.println("Failed to initialize file system");
    #endif
    } else {
        fileManager.setServer(&server);
    }
    } else {
    if (!fileManager.begin(FILESYS)) {
        #ifdef DEBUG_SERIAL
        Serial.println("Failed to initialize file system");
        #endif
    } else {
        fileManager.setServer(&server);
    }
    }
    if (config.use_led && !config.use_ws212b) {
    pinMode(config.led_gpio.toInt(), OUTPUT);
    digitalWrite(config.led_gpio.toInt(), HIGH);
    }
    if (config.use_led && config.use_ws212b) {
    set_WS212B();
    ledchoice = config.defaultAnimation.toInt();
    startled = true;
    }
    int but = config.button_gpio.toInt();
    #ifdef DEBUG_SERIAL
    Serial.println("Button Pin: " + String(but));
    #endif
    setupButtonHandler(but);
    if (allowFTP) startftpserver();
    bootTime = millis();
    long get_time = config.delay_minutes.toInt();
    
    if (get_time >= 0 && get_time <= 65535) TIME2SLEEP = (uint16_t)get_time;
    autosleep = config.auto_sleep;
    String pl = getDefaultPayload();
    #ifdef DEBUG_SERIAL
    if (pl != "") Serial.printf("Default payload %s found\n", pl.c_str());
    else Serial.printf("No payloads found\n");
    #endif

    // ==================== BLE INITIALIZATION ====================
    if (config.use_bluetooth && !is_s2) {
        #ifdef DEBUG_SERIAL
        Serial.println("Initializing Bluetooth...");
        #endif

        String deviceName = "Modchip"; //keep this name or the Andoid app won't find the correct device to connect to.
        deviceName += " (" + ipStr + ")";

        // Make sure it's fully deinitialized first (important after wake-up)
        if (BLEDevice::getInitialized()) {
                BLEDevice::deinit(true);
        }

        BLEDevice::init(deviceName.c_str());

        // Set MTU here - applies to all connections
    BLEDevice::setMTU(512); //default is 20
        
        BLEServer* bleServer = BLEDevice::createServer();
        bleServer->setCallbacks(new MyServerCallbacks());

        BLEService* bleService = bleServer->createService(SERVICE_UUID);

        // IP Characteristic
        ipCharacteristic = bleService->createCharacteristic(
                CHARACTERISTIC_UUID,
                BLECharacteristic::PROPERTY_READ | BLECharacteristic::PROPERTY_NOTIFY);
        ipCharacteristic->addDescriptor(new BLE2902());
        ipCharacteristic->setValue(deviceName.c_str());

        // Command & Response Characteristics...
        commandCharacteristic = bleService->createCharacteristic(
                COMMAND_UUID, BLECharacteristic::PROPERTY_WRITE);
        commandCharacteristic->setCallbacks(new MyCallbacks());

        responseCharacteristic = bleService->createCharacteristic(
                RESPONSE_UUID, BLECharacteristic::PROPERTY_NOTIFY);
        responseCharacteristic->addDescriptor(new BLE2902());

        bleService->start();

        BLEAdvertising* advertising = BLEDevice::getAdvertising();
        advertising->addServiceUUID(SERVICE_UUID);
        advertising->setScanResponse(true);
        advertising->start();

        #ifdef DEBUG_SERIAL
        Serial.println("BLE Advertising started as: " + deviceName);
        #endif
    }

    // Configure touch pin to wake up from deepsleep
    // https://docs.espressif.com/projects/arduino-esp32/en/latest/api/touch.html
    allowTouch = config.use_touch;
    if (allowTouch) {
        touchpin = (uint8_t) config.touch_gpio.toInt();
        threshold = (uint32_t) config.threshold_val.toInt();
        touchSleepWakeUpEnable(touchpin, threshold);
    }
}

void loop() {
    if (rcm_injection_done && rcm_usb_task_handle != NULL) {
        cleanup_rcm_tasks(); //remove the rcm tasks to free up 12kb memory.
        // comment out the above if using on a dongle when we want multiple payload injections
        if (injectionsleep) {
            deepsleep();
        }
    }
    
    // OTA handling
    if (doOta) {
        doOta = false;
        if (mdnsRunning && is_s2) Stop_mdns_service();
        xTaskCreatePinnedToCore(otaUpdateTask, "OTA", 4096, NULL, 4, &otaTaskHandle, 0);
        startOTA(ota_url);
    }

    if (removeconf) removeconfig();
    if (startled) ledshow(ledchoice);
    if (stopled) leddontshow();

    if (activeOperation) {
        if (mdnsRunning && is_s2) Stop_mdns_service();
        handleDownloadState();
    } else {
        if (config.use_mdns && !mdnsRunning && is_s2) Start_mdns_service();
    }

    if (autosleep && TIME2SLEEP > 0) {
        if (millis() >= (bootTime + (TIME2SLEEP * 60000UL))) {
            deepsleep();
        }
    }

    static unsigned long lastCleanup = 0;
    unsigned long now = millis();
    
    // Cleanup every 1s
    if (now - lastCleanup > 1000) {
        lastCleanup = now;
        ws.cleanupClients();
    }
    
    if (allowTouch) {
        static unsigned long lastTouchRead = 0;
        static unsigned long touchStartTime = 0; // when the touch began
        static bool touchActive = false;          // whether touch is currently active
        // Touch read every 100ms
        if (now - lastTouchRead > 100) {
            lastTouchRead = now;
            // The longer you hold the touch button, the higher the value reaches, then resets when not touched.
            touchValue = touchRead(touchpin); // Touch pins between GPIO 1-14
            averagecount++;
            average = average+touchValue;
            if (averagecount == 5){
                averagecount = 0;
                average = (average/5);
                #ifdef DEBUG_SERIAL
                Serial.printf("Average Threshold - %d\n", average);
                Serial.printf("touchValue - %d\n", touchValue);
                #endif
                if (averageval == 0){
                    averageval = average;
                }
                average = 0;
            }

            if (touchValue > (averageval+threshold)) {
                // Touch detected
                if (!touchActive) {
                    touchActive = true;
                    touchStartTime = now; // mark when touch started
                }
                // Check if touch held for 5 seconds
                if (now - touchStartTime >= 5000) {
                    touchActive = false; // reset
                    #ifdef DEBUG_SERIAL
                    Serial.println("Long touch detected - running function!");
                    #endif
                    removeconfig();
                }
            }
            else {
                // Not touched, reset
                touchActive = false;
            }
        }
    }

    vTaskDelay(pdMS_TO_TICKS(5));
}
 
yeah, first thing i did was connect to the web interface and upload payload/set default payload.

as far as bin files, sure i can give that a go. ive been using the ino and compiling that. is there anything special needed to flash the bin files? is that just through cli using esptool?
I'll upload the full 16MB bin in a while as I'm doing code stuff for ps4 just now, I'll include the latest hekate and fusee bin files and set hekate as the default, then you can just flash it and test it. Once we have that working I'll do a dump of the waveshare tiny - what size flash has yours got - 4MB or 8MB? You should give me an hour or two and check back ok.
 

Site & Scene News

Popular threads in this forum