ESP32-S3 Module Design Discussion
Preferences)
Table
| Data | Store in | Reason |
|---|---|---|
| Wi-Fi SSID / password | NVS | Small key/value, needed very early at boot |
| Display brightness | NVS | Simple scalar setting |
| Night mode / language | NVS | Simple flags / enums |
| Per-port enable flags | NVS | Small booleans |
| Last selected UI page | NVS | Small scalar |
| Complex module map JSON | LittleFS | Structured, may grow over time |
| UI layout / theme presets | LittleFS | Easier to version as JSON |
| Export/import config bundle | LittleFS | File-based workflow |
sys
cfg_verlangnightbrightlast_pagewifi
ssidpassports
p1_en, p2_en, p3_en, p4_en/config/modules.json/config/ui.json/config/export.jsonTable
| Function | GPIO | Schematic Net | Direction | Notes |
|---|---|---|---|---|
| USB D- | 19 | USB_D_N | USB | Native USB D- |
| USB D+ | 20 | USB_D_P | USB | Native USB D+ |
| BOOT / setup button | 0 | BOOT_IO0 | Input | Button pulls low for download mode; also used here for factory-reset request |
| I2C SDA | 8 | I2C_SDA | I/O | Shared by all module ports; 4.7k pull-up to host 3.3V |
| I2C SCL | 9 | I2C_SCL | Output | Shared by all module ports; 4.7k pull-up to host 3.3V |
| USB-C attach detect | 10 | USB_C_ATTACH | Input | STUSB4500 ATTACH open-drain output with 10k pull-up to 3.3V |
| Port 1 detect / IRQ | 4 | PORT1_DET | Input | Module port J2 pin 5 |
| Port 2 detect / IRQ | 5 | PORT2_DET | Input | Module port J3 pin 5 |
| Port 3 detect / IRQ | 6 | PORT3_DET | Input | Module port J4 pin 5 |
| Port 4 detect / IRQ | 7 | PORT4_DET | Input | Module port J5 pin 5 |
| Activity LED | 14 | LED_ACTIVITY | Output | Drives D4 via R10 |
| Status LED | 21 | LED_STATUS | Output | Drives D3 via R9 |
| Power LED | - | 3V3 | Hardware | D2 lights when host 3.3V rail is present |
Table
| Port Pin | Net | Purpose |
|---|---|---|
| 1 | VBUS | Protected 5V module power for smart display/sensor modules |
| 2 | GND | Ground return |
| 3 | I2C_SDA | Shared 3.3V I2C data |
| 4 | I2C_SCL | Shared 3.3V I2C clock |
| 5 | PORTx_DET | Detect or interrupt |
Important: The display module must regulate VBUS locally to 3.3V for its MCU/LCD logic. The host no longer exports a spare per-port GPIO; backlight brightness should be commanded over I2C and generated locally on the display module.
platformio.iniIni
[env:esp32-s3-modular-host] platform = espressif32 board = esp32-s3-devkitc-1 framework = arduino monitor_speed = 115200 upload_speed = 921600 build_flags = -DARDUINO_USB_MODE=1 -DARDUINO_USB_CDC_ON_BOOT=1 lib_deps = bblanchon/ArduinoJson@^7.0.4
src/main.cppCpp
#include <Arduino.h> #include <Wire.h> #include <WiFi.h> #include <ArduinoJson.h> #include <Preferences.h> #include <LittleFS.h> // ─── Pin Definitions from schematic ───────────────────── #define PIN_BOOT_BUTTON 0 #define PIN_PORT1_DET 4 #define PIN_PORT2_DET 5 #define PIN_PORT3_DET 6 #define PIN_PORT4_DET 7 #define PIN_I2C_SDA 8 #define PIN_I2C_SCL 9 #define PIN_USB_ATTACH 10 #define PIN_LED_ACTIVITY 14 #define PIN_LED_STATUS 21 // Native USB pins are fixed by ESP32-S3 hardware: // GPIO19 = USB_D_N, GPIO20 = USB_D_P // ─── Configuration constants ──────────────────────────── static const uint32_t I2C_FREQ_HZ = 400000; static const uint32_t REPORT_INTERVAL_MS = 2000; static const uint32_t SETTINGS_SAVE_DEBOUNCE_MS = 1500; static const uint32_t WIFI_RECONNECT_INTERVAL_MS = 10000; static const uint16_t CONFIG_VERSION = 1; static const char* MODULES_JSON_PATH = "/config/modules.json"; static const char* UI_JSON_PATH = "/config/ui.json"; struct ModulePort { uint8_t detectPin; const char* name; }; ModulePort ports[] = { {PIN_PORT1_DET, "PORT1"}, {PIN_PORT2_DET, "PORT2"}, {PIN_PORT3_DET, "PORT3"}, {PIN_PORT4_DET, "PORT4"}, }; struct AppConfig { uint16_t version; String wifiSsid; String wifiPassword; String language; bool nightMode; uint8_t brightness; // 0..100 uint8_t lastPage; bool portEnabled[4]; }; Preferences prefsSys; Preferences prefsWifi; Preferences prefsPorts; AppConfig config; unsigned long lastReportMs = 0; unsigned long lastSettingsDirtyMs = 0; bool settingsDirty = false; // ─── Helpers ───────────────────────────────────────────── uint8_t clampBrightness(int value) { if (value < 0) return 0; if (value > 100) return 100; return static_cast<uint8_t>(value); } void setDefaultConfig(AppConfig& cfg) { cfg.version = CONFIG_VERSION; cfg.wifiSsid = "YOUR_SSID"; cfg.wifiPassword = "YOUR_PASSWORD"; cfg.language = "zh-HK"; cfg.nightMode = false; cfg.brightness = 70; cfg.lastPage = 0; cfg.portEnabled[0] = true; cfg.portEnabled[1] = true; cfg.portEnabled[2] = true; cfg.portEnabled[3] = true; } void validateConfig(AppConfig& cfg) { if (cfg.version != CONFIG_VERSION) { AppConfig defaults; setDefaultConfig(defaults); // Preserve the most valuable user data during migration. defaults.wifiSsid = cfg.wifiSsid.length() ? cfg.wifiSsid : defaults.wifiSsid; defaults.wifiPassword = cfg.wifiPassword.length() ? cfg.wifiPassword : defaults.wifiPassword; defaults.language = cfg.language.length() ? cfg.language : defaults.language; defaults.nightMode = cfg.nightMode; defaults.brightness = clampBrightness(cfg.brightness); defaults.lastPage = cfg.lastPage; for (size_t i = 0; i < 4; ++i) { defaults.portEnabled[i] = cfg.portEnabled[i]; } cfg = defaults; } cfg.brightness = clampBrightness(cfg.brightness); if (cfg.language.length() == 0) cfg.language = "zh-HK"; } void markSettingsDirty() { settingsDirty = true; lastSettingsDirtyMs = millis(); } // ─── LittleFS helpers ──────────────────────────────────── bool ensureConfigDir() { if (LittleFS.exists("/config")) return true; return LittleFS.mkdir("/config"); } bool writeJsonFile(const char* path, JsonDocument& doc) { File f = LittleFS.open(path, FILE_WRITE); if (!f) return false; if (serializeJsonPretty(doc, f) == 0) { f.close(); return false; } f.close(); return true; } bool readJsonFile(const char* path, JsonDocument& doc) { if (!LittleFS.exists(path)) return false; File f = LittleFS.open(path, FILE_READ); if (!f) return false; DeserializationError err = deserializeJson(doc, f); f.close(); return !err; } void createDefaultModuleMapFile() { if (LittleFS.exists(MODULES_JSON_PATH)) return; StaticJsonDocument<384> doc; JsonArray arr = doc["ports"].to<JsonArray>(); for (size_t i = 0; i < 4; ++i) { JsonObject port = arr.add<JsonObject>(); port["index"] = i + 1; port["name"] = ports[i].name; port["enabled"] = config.portEnabled[i]; port["module_type"] = "unknown"; port["i2c_address"] = -1; } writeJsonFile(MODULES_JSON_PATH, doc); } void createDefaultUiFile() { if (LittleFS.exists(UI_JSON_PATH)) return; StaticJsonDocument<256> doc; doc["language"] = config.language; doc["night_mode"] = config.nightMode; doc["brightness"] = config.brightness; doc["last_page"] = config.lastPage; writeJsonFile(UI_JSON_PATH, doc); } void saveUiMirrorToLittleFS() { StaticJsonDocument<256> doc; doc["language"] = config.language; doc["night_mode"] = config.nightMode; doc["brightness"] = config.brightness; doc["last_page"] = config.lastPage; if (!writeJsonFile(UI_JSON_PATH, doc)) { Serial.println("[FS] Failed to write UI config mirror"); } } // ─── NVS helpers ───────────────────────────────────────── bool beginPreferences() { return prefsSys.begin("sys", false) && prefsWifi.begin("wifi", false) && prefsPorts.begin("ports", false); } void endPreferences() { prefsSys.end(); prefsWifi.end(); prefsPorts.end(); } void loadConfigFromNvs(AppConfig& cfg) { cfg.version = prefsSys.getUShort("cfg_ver", CONFIG_VERSION); cfg.language = prefsSys.getString("lang", "zh-HK"); cfg.nightMode = prefsSys.getBool("night", false); cfg.brightness = prefsSys.getUChar("bright", 70); cfg.lastPage = prefsSys.getUChar("last_page", 0); cfg.wifiSsid = prefsWifi.getString("ssid", "YOUR_SSID"); cfg.wifiPassword = prefsWifi.getString("pass", "YOUR_PASSWORD"); cfg.portEnabled[0] = prefsPorts.getBool("p1_en", true); cfg.portEnabled[1] = prefsPorts.getBool("p2_en", true); cfg.portEnabled[2] = prefsPorts.getBool("p3_en", true); cfg.portEnabled[3] = prefsPorts.getBool("p4_en", true); validateConfig(cfg); } void saveConfigToNvs(const AppConfig& cfg) { prefsSys.putUShort("cfg_ver", cfg.version); prefsSys.putString("lang", cfg.language); prefsSys.putBool("night", cfg.nightMode); prefsSys.putUChar("bright", cfg.brightness); prefsSys.putUChar("last_page", cfg.lastPage); prefsWifi.putString("ssid", cfg.wifiSsid); prefsWifi.putString("pass", cfg.wifiPassword); prefsPorts.putBool("p1_en", cfg.portEnabled[0]); prefsPorts.putBool("p2_en", cfg.portEnabled[1]); prefsPorts.putBool("p3_en", cfg.portEnabled[2]); prefsPorts.putBool("p4_en", cfg.portEnabled[3]); } void factoryResetStorage() { Serial.println("[CFG] Factory reset requested"); prefsSys.clear(); prefsWifi.clear(); prefsPorts.clear(); if (LittleFS.exists(MODULES_JSON_PATH)) LittleFS.remove(MODULES_JSON_PATH); if (LittleFS.exists(UI_JSON_PATH)) LittleFS.remove(UI_JSON_PATH); setDefaultConfig(config); saveConfigToNvs(config); createDefaultModuleMapFile(); createDefaultUiFile(); settingsDirty = false; } bool shouldFactoryResetAtBoot() { // Simple beginner-friendly path: // hold BOOT button during startup for ~3 seconds. if (digitalRead(PIN_BOOT_BUTTON) != LOW) return false; Serial.println("BOOT held at startup; hold for 3s to factory reset..."); unsigned long start = millis(); while (millis() - start < 3000) { if (digitalRead(PIN_BOOT_BUTTON) != LOW) { Serial.println("Factory reset canceled"); return false; } digitalWrite(PIN_LED_STATUS, !digitalRead(PIN_LED_STATUS)); delay(100); } digitalWrite(PIN_LED_STATUS, LOW); return true; } void flushSettingsIfNeeded() { if (!settingsDirty) return; if (millis() - lastSettingsDirtyMs < SETTINGS_SAVE_DEBOUNCE_MS) return; saveConfigToNvs(config); saveUiMirrorToLittleFS(); settingsDirty = false; Serial.println("[CFG] Settings saved"); } // ─── Settings API example ──────────────────────────────── void setBrightness(uint8_t percent) { uint8_t clamped = clampBrightness(percent); if (config.brightness == clamped) return; config.brightness = clamped; markSettingsDirty(); } void setNightMode(bool enabled) { if (config.nightMode == enabled) return; config.nightMode = enabled; markSettingsDirty(); } void setLanguage(const String& language) { if (config.language == language || language.length() == 0) return; config.language = language; markSettingsDirty(); } void setLastPage(uint8_t page) { if (config.lastPage == page) return; config.lastPage = page; markSettingsDirty(); } void setPortEnabled(size_t index, bool enabled) { if (index >= 4) return; if (config.portEnabled[index] == enabled) return; config.portEnabled[index] = enabled; markSettingsDirty(); } // ─── Runtime functions ─────────────────────────────────── void initWiFi() { WiFi.mode(WIFI_STA); WiFi.begin(config.wifiSsid.c_str(), config.wifiPassword.c_str()); Serial.print("Connecting to WiFi"); uint8_t retries = 0; while (WiFi.status() != WL_CONNECTED && retries < 20) { digitalWrite(PIN_LED_STATUS, !digitalRead(PIN_LED_STATUS)); delay(250); Serial.print('.'); retries++; } if (WiFi.status() == WL_CONNECTED) { digitalWrite(PIN_LED_STATUS, HIGH); Serial.printf("\nWiFi connected, IP=%s\n", WiFi.localIP().toString().c_str()); } else { digitalWrite(PIN_LED_STATUS, LOW); Serial.println("\nWiFi not connected; continuing offline."); } } void scanI2CBus() { Serial.println("I2C scan start"); uint8_t found = 0; for (uint8_t address = 1; address < 127; address++) { Wire.beginTransmission(address); uint8_t error = Wire.endTransmission(); if (error == 0) { Serial.printf(" Found I2C device at 0x%02X\n", address); found++; } } Serial.printf("I2C scan complete: %u device(s) found\n", found); } bool readI2CRegister8(uint8_t deviceAddr, uint8_t regAddr, uint8_t& value) { Wire.beginTransmission(deviceAddr); Wire.write(regAddr); if (Wire.endTransmission(false) != 0) return false; if (Wire.requestFrom(deviceAddr, (uint8_t)1) != 1) return false; value = Wire.read(); return true; } void initInputs() { pinMode(PIN_BOOT_BUTTON, INPUT_PULLUP); pinMode(PIN_USB_ATTACH, INPUT_PULLUP); for (const ModulePort& port : ports) { pinMode(port.detectPin, INPUT_PULLUP); } } void reportStateJson() { StaticJsonDocument<512> doc; doc["usb_attached"] = (digitalRead(PIN_USB_ATTACH) == LOW); doc["wifi_connected"] = (WiFi.status() == WL_CONNECTED); doc["language"] = config.language; doc["night_mode"] = config.nightMode; doc["brightness"] = config.brightness; doc["last_page"] = config.lastPage; JsonArray arr = doc["ports"].to<JsonArray>(); for (size_t i = 0; i < 4; ++i) { JsonObject item = arr.add<JsonObject>(); item["name"] = ports[i].name; item["enabled"] = config.portEnabled[i]; item["irq_or_present"] = (digitalRead(ports[i].detectPin) == LOW); } serializeJson(doc, Serial); Serial.println(); } void demoApplySettings() { // Replace these with real display-module commands later. Serial.printf("[CFG] brightness=%u, night=%s, language=%s, last_page=%u\n", config.brightness, config.nightMode ? "true" : "false", config.language.c_str(), config.lastPage); } void setupStorage() { if (!LittleFS.begin(true)) { Serial.println("[FS] LittleFS mount failed"); } else { ensureConfigDir(); } if (!beginPreferences()) { Serial.println("[NVS] Failed to open Preferences namespaces"); setDefaultConfig(config); return; } loadConfigFromNvs(config); createDefaultModuleMapFile(); createDefaultUiFile(); if (shouldFactoryResetAtBoot()) { factoryResetStorage(); } } void setup() { Serial.begin(115200); delay(500); Serial.println("ESP32-S3 Modular Host firmware starting..."); pinMode(PIN_LED_STATUS, OUTPUT); pinMode(PIN_LED_ACTIVITY, OUTPUT); digitalWrite(PIN_LED_STATUS, LOW); digitalWrite(PIN_LED_ACTIVITY, LOW); initInputs(); setupStorage(); Wire.begin(PIN_I2C_SDA, PIN_I2C_SCL, I2C_FREQ_HZ); Serial.printf("I2C initialized: SDA=GPIO%d, SCL=GPIO%d, freq=%lu Hz\n", PIN_I2C_SDA, PIN_I2C_SCL, (unsigned long)I2C_FREQ_HZ); Serial.printf("USB attach state at boot: %s\n", (digitalRead(PIN_USB_ATTACH) == LOW) ? "attached" : "not attached"); demoApplySettings(); initWiFi(); scanI2CBus(); // Example runtime changes to prove the settings API paths compile. setBrightness(config.brightness); setNightMode(config.nightMode); setLanguage(config.language); setLastPage(config.lastPage); } void loop() { const unsigned long now = millis(); if (now - lastReportMs >= REPORT_INTERVAL_MS) { lastReportMs = now; digitalWrite(PIN_LED_ACTIVITY, HIGH); reportStateJson(); digitalWrite(PIN_LED_ACTIVITY, LOW); } if (WiFi.status() != WL_CONNECTED) { static unsigned long lastReconnect = 0; if (now - lastReconnect > WIFI_RECONNECT_INTERVAL_MS) { lastReconnect = now; initWiFi(); } } flushSettingsIfNeeded(); }
Bash
pio run pio run -t upload pio device monitor
cfg_ver) is checked during load.setBrightness, setNightMode, setLanguage, setPortEnabled) as the only write path so delayed-save behavior stays consistent.Platform & Toolchain
Recommended Settings Storage Architecture
Layering
What to store where
Namespaces / file layout
Reliability rules
Pin Mapping
Module Port Pinout
`platformio.ini`
Complete Firmware Source — `src/main.cpp`
Build & Flash
How this example stores user settings
Bring-Up Checklist