diff --git a/CLAUDE.md b/CLAUDE.md index de02fb5..c99c16c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -55,3 +55,16 @@ All High = reference (insertion loss only). All Low = 31.5 dB. Any combination sums. +## Development Notes + +### Serial Port Access +**NEVER use `stty` commands** — they corrupt terminal settings and break Claude Code's UI/history. Use these instead: +- **mcserial MCP** (preferred) — `mcp__mcserial__*` tools for all serial operations +- **Python** — `pyserial` for scripted serial work + +### ESP32-S3 Serial Configuration +The ESP32-S3-DevKitC-1 has two serial interfaces: +- **Serial0** (UART0) — Hardware UART via CH343 bridge (GPIO43/44) → `/dev/ttyACM*` +- **Serial** (USB CDC) — Native USB OTG port → requires `USB.begin()` + +This project uses **Serial0** for all debug output since we connect via the CH343 UART bridge. diff --git a/firmware/Makefile b/firmware/Makefile index 634716c..5aa056c 100644 --- a/firmware/Makefile +++ b/firmware/Makefile @@ -1,12 +1,12 @@ .PHONY: build buildfs upload uploadfs monitor clean ota flash erase -# Serial port — override with: make upload PORT=/dev/ttyACM0 -PORT ?= /dev/ttyACM2 +# Serial port — override with: make upload PORT=/dev/ttyACM1 +PORT ?= /dev/ttyACM0 BAUD ?= 921600 -BUILD_DIR = .pio/build/lolin_s2_mini +BUILD_DIR = .pio/build/esp32-s3-devkitc-1 -# ESP32-S2 bootloader lives at 0x1000 (not 0x0 like S3/C3) -BOOTLOADER_OFFSET = 0x1000 +# ESP32-S3 bootloader lives at 0x0 +BOOTLOADER_OFFSET = 0x0 # LittleFS partition offset (from partition table) FS_OFFSET = 0x290000 @@ -20,14 +20,14 @@ buildfs: pio run -t buildfs upload: build - $(ESPTOOL) --chip esp32s2 --port $(PORT) --baud $(BAUD) \ + $(ESPTOOL) --chip esp32s3 --port $(PORT) --baud $(BAUD) \ write_flash \ $(BOOTLOADER_OFFSET) $(BUILD_DIR)/bootloader.bin \ 0x8000 $(BUILD_DIR)/partitions.bin \ 0x10000 $(BUILD_DIR)/firmware.bin uploadfs: buildfs - $(ESPTOOL) --chip esp32s2 --port $(PORT) --baud $(BAUD) \ + $(ESPTOOL) --chip esp32s3 --port $(PORT) --baud $(BAUD) \ write_flash $(FS_OFFSET) $(BUILD_DIR)/littlefs.bin flash: upload uploadfs @@ -36,7 +36,7 @@ monitor: pio device monitor --port $(PORT) erase: - $(ESPTOOL) --chip esp32s2 --port $(PORT) erase_flash + $(ESPTOOL) --chip esp32s3 --port $(PORT) erase_flash clean: pio run -t clean diff --git a/firmware/include/config.h b/firmware/include/config.h index 6f29e7e..75a424f 100644 --- a/firmware/include/config.h +++ b/firmware/include/config.h @@ -9,14 +9,22 @@ // --- WiFi Credentials --- // Override via build_flags or edit directly #ifndef WIFI_SSID -#define WIFI_SSID "your-ssid" +#define WIFI_SSID "tsunami4" #endif #ifndef WIFI_PASS -#define WIFI_PASS "your-password" +#define WIFI_PASS "2089916341" #endif #define WIFI_TIMEOUT_MS 15000 +// WiFi TX power level (dBm) +// Lower = less RF interference on bench, but shorter range +// Valid: WIFI_POWER_2dBm to WIFI_POWER_19_5dBm +// Default 8 dBm is a good balance for bench use (~6 mW, ~10m range) +#ifndef WIFI_TX_POWER_DBM +#define WIFI_TX_POWER_DBM WIFI_POWER_8_5dBm +#endif + // --- HMC472A Control Pins (active-low) --- // Optimized mapping: GPIO number = step bit position + 1 // Enables single-instruction bitwise ops instead of loop @@ -46,6 +54,13 @@ static constexpr uint32_t ATTEN_PIN_MASK = 0x7E; // --- Status LED --- static constexpr uint8_t PIN_LED = 15; // Built-in LED, active HIGH +// --- OLED Display (SSD1306 128x64 I2C) --- +static constexpr uint8_t PIN_SDA = 8; +static constexpr uint8_t PIN_SCL = 9; +static constexpr uint8_t OLED_ADDR = 0x3C; // 7-bit address (0x78 >> 1) +static constexpr uint8_t OLED_WIDTH = 128; +static constexpr uint8_t OLED_HEIGHT = 64; + // --- Attenuator Limits --- static constexpr float DB_MIN = 0.0f; static constexpr float DB_MAX = 31.5f; diff --git a/firmware/platformio.ini b/firmware/platformio.ini index 8607a68..5144950 100644 --- a/firmware/platformio.ini +++ b/firmware/platformio.ini @@ -1,11 +1,15 @@ -[env:lolin_s2_mini] +[env:esp32-s3-devkitc-1] platform = espressif32 -board = lolin_s2_mini +board = esp32-s3-devkitc-1 framework = arduino +board_build.arduino.memory_type = qio_opi ; Use octal PSRAM lib_deps = mathieucarbou/ESPAsyncWebServer @ ^3.6.0 bblanchon/ArduinoJson @ ^7.3.0 + adafruit/Adafruit SSD1306 @ ^2.5.13 + adafruit/Adafruit GFX Library @ ^1.11.11 monitor_speed = 115200 board_build.filesystem = littlefs build_flags = - -DCORE_DEBUG_LEVEL=3 + -DCORE_DEBUG_LEVEL=0 + -Os diff --git a/firmware/src/attenuator.cpp b/firmware/src/attenuator.cpp index a3d2fae..8c731b2 100644 --- a/firmware/src/attenuator.cpp +++ b/firmware/src/attenuator.cpp @@ -1,5 +1,4 @@ #include "attenuator.h" -#include #include Attenuator::Attenuator() : _step(0) {} @@ -14,7 +13,7 @@ void Attenuator::begin() { _step = loadFromNVS(); applyToGPIO(); - Serial.printf("[Attenuator] Initialized, restored step=%u (%.1f dB)\n", _step, getDB()); + Serial0.printf("[Attenuator] Initialized, restored step=%u (%.1f dB)\n", _step, getDB()); } float Attenuator::setDB(float db) { @@ -36,7 +35,7 @@ uint8_t Attenuator::setStep(uint8_t step) { applyToGPIO(); saveToNVS(); - Serial.printf("[Attenuator] Set step=%u (%.1f dB)\n", _step, getDB()); + Serial0.printf("[Attenuator] Set step=%u (%.1f dB)\n", _step, getDB()); return _step; } diff --git a/firmware/src/display.cpp b/firmware/src/display.cpp new file mode 100644 index 0000000..90b69fc --- /dev/null +++ b/firmware/src/display.cpp @@ -0,0 +1,113 @@ +#include "display.h" +#include "config.h" + +#include +#include +#include + +static Adafruit_SSD1306 display(OLED_WIDTH, OLED_HEIGHT, &Wire, -1); +static bool displayAvailable = false; + +bool initDisplay() { + Wire.begin(PIN_SDA, PIN_SCL); + + if (!display.begin(SSD1306_SWITCHCAPVCC, OLED_ADDR)) { + Serial0.println("[Display] SSD1306 not found"); + displayAvailable = false; + return false; + } + + displayAvailable = true; + display.clearDisplay(); + display.setTextColor(SSD1306_WHITE); + display.display(); + + Serial0.println("[Display] SSD1306 initialized"); + return true; +} + +bool isDisplayAvailable() { + return displayAvailable; +} + +void showSplash(const char* version) { + if (!displayAvailable) return; + + display.clearDisplay(); + + // Title + display.setTextSize(2); + display.setCursor(10, 8); + display.print("HMC472A"); + + // Subtitle + display.setTextSize(1); + display.setCursor(10, 32); + display.print("RF Attenuator"); + + // Version + display.setCursor(10, 48); + display.print("v"); + display.print(version); + + display.display(); +} + +void updateDisplay(float db, uint8_t step, int rssi, bool sweeping, bool wifiConnected) { + if (!displayAvailable) return; + + display.clearDisplay(); + + // --- Attenuation bar (top) --- + // Bar spans full width, height 10px + int barWidth = (int)((db / 31.5f) * (OLED_WIDTH - 4)); + display.drawRect(0, 0, OLED_WIDTH, 12, SSD1306_WHITE); + display.fillRect(2, 2, barWidth, 8, SSD1306_WHITE); + + // --- Main dB readout (large, centered) --- + display.setTextSize(3); + char dbStr[8]; + if (db == (int)db) { + snprintf(dbStr, sizeof(dbStr), "%d", (int)db); + } else { + snprintf(dbStr, sizeof(dbStr), "%.1f", db); + } + + // Calculate centering + int textWidth = strlen(dbStr) * 18; // Size 3 = 18px per char + int xPos = (OLED_WIDTH - textWidth - 36) / 2; // -36 for " dB" + + display.setCursor(xPos, 18); + display.print(dbStr); + + // "dB" suffix (smaller) + display.setTextSize(2); + display.print(" dB"); + + // --- Status line (bottom) --- + display.setTextSize(1); + display.setCursor(0, 56); + + // Step number + display.print("S:"); + display.print(step); + + // Sweep indicator + if (sweeping) { + display.print(" [SWEEP]"); + } + + // WiFi status (right-aligned) + if (wifiConnected) { + char rssiStr[12]; + snprintf(rssiStr, sizeof(rssiStr), "%ddBm", rssi); + int rssiWidth = strlen(rssiStr) * 6; + display.setCursor(OLED_WIDTH - rssiWidth, 56); + display.print(rssiStr); + } else { + display.setCursor(OLED_WIDTH - 36, 56); + display.print("NO WiFi"); + } + + display.display(); +} diff --git a/firmware/src/display.h b/firmware/src/display.h new file mode 100644 index 0000000..80cab6d --- /dev/null +++ b/firmware/src/display.h @@ -0,0 +1,16 @@ +#pragma once + +#include + +// Initialize the OLED display +// Returns true if display found, false otherwise +bool initDisplay(); + +// Update display with current attenuator state +void updateDisplay(float db, uint8_t step, int rssi, bool sweeping, bool wifiConnected); + +// Show startup splash screen +void showSplash(const char* version); + +// Check if display is available +bool isDisplayAvailable(); diff --git a/firmware/src/main.cpp b/firmware/src/main.cpp index ff37788..3670564 100644 --- a/firmware/src/main.cpp +++ b/firmware/src/main.cpp @@ -1,5 +1,4 @@ #include -#include #include #include #include @@ -8,10 +7,15 @@ #include "config.h" #include "attenuator.h" #include "web_server.h" +#include "display.h" // --- Global instances --- Attenuator attenuator; +// --- Display update tracking --- +static uint32_t lastDisplayUpdate = 0; +static const uint32_t DISPLAY_UPDATE_MS = 100; // Update every 100ms + // --- LED State Machine --- enum class LEDState { Off, // Headless mode (no WiFi) @@ -69,14 +73,14 @@ void startSweep(bool up, uint32_t dwellMs) { sweepRunning = true; lastSweepStep = millis(); setLEDState(LEDState::FastBlink); - Serial.printf("[Sweep] Started, direction=%s, dwell=%u ms\n", + Serial0.printf("[Sweep] Started, direction=%s, dwell=%u ms\n", up ? "up" : "down", sweepDwellMs); } void stopSweep() { sweepRunning = false; setLEDState(LEDState::Solid); - Serial.println("[Sweep] Stopped"); + Serial0.println("[Sweep] Stopped"); } bool isSweeping() { @@ -110,13 +114,34 @@ void updateSweep() { attenuator.setStep(newStep); } +// --- WiFi TX Power Control --- +static wifi_power_t wifiTxPower = WIFI_TX_POWER_DBM; + +void setWiFiTxPower(wifi_power_t power) { + wifiTxPower = power; + WiFi.setTxPower(power); + Serial0.printf("[WiFi] TX power set to %d (quarter dBm units)\n", power); +} + +wifi_power_t getWiFiTxPower() { + return wifiTxPower; +} + +// Convert wifi_power_t enum to approximate dBm float +float wifiPowerToDbm(wifi_power_t power) { + // wifi_power_t values are in quarter-dBm units + return power / 4.0f; +} + // --- WiFi Connection --- bool connectWiFi() { - Serial.printf("[WiFi] Connecting to %s...\n", WIFI_SSID); + Serial0.printf("[WiFi] Connecting to %s...\n", WIFI_SSID); setLEDState(LEDState::SlowBlink); WiFi.mode(WIFI_STA); WiFi.setHostname(FW_HOSTNAME); + WiFi.setTxPower(wifiTxPower); + Serial0.printf("[WiFi] TX power: %.1f dBm\n", wifiPowerToDbm(wifiTxPower)); WiFi.begin(WIFI_SSID, WIFI_PASS); uint32_t startAttempt = millis(); @@ -126,13 +151,13 @@ bool connectWiFi() { esp_task_wdt_reset(); if (millis() - startAttempt > WIFI_TIMEOUT_MS) { - Serial.println("[WiFi] Connection timeout, continuing without network"); + Serial0.println("[WiFi] Connection timeout, continuing without network"); setLEDState(LEDState::Off); return false; } } - Serial.printf("[WiFi] Connected! IP: %s\n", WiFi.localIP().toString().c_str()); + Serial0.printf("[WiFi] Connected! IP: %s\n", WiFi.localIP().toString().c_str()); setLEDState(LEDState::Solid); return true; } @@ -141,9 +166,9 @@ bool connectWiFi() { void setupMDNS() { if (MDNS.begin(FW_HOSTNAME)) { MDNS.addService("http", "tcp", WEB_PORT); - Serial.printf("[mDNS] Registered as %s.local\n", FW_HOSTNAME); + Serial0.printf("[mDNS] Registered as %s.local\n", FW_HOSTNAME); } else { - Serial.println("[mDNS] Failed to start"); + Serial0.println("[mDNS] Failed to start"); } } @@ -157,26 +182,27 @@ void enableOTA() { ArduinoOTA.onStart([]() { stopSweep(); setLEDState(LEDState::FastBlink); - Serial.println("[OTA] Update starting..."); + Serial0.println("[OTA] Update starting..."); }); ArduinoOTA.onEnd([]() { - Serial.println("[OTA] Update complete, rebooting..."); + Serial0.println("[OTA] Update complete, rebooting..."); }); ArduinoOTA.onProgress([](unsigned int progress, unsigned int total) { - Serial.printf("[OTA] Progress: %u%%\r", (progress / (total / 100))); + esp_task_wdt_reset(); // Keep watchdog happy during long OTA + Serial0.printf("[OTA] Progress: %u%%\r", (progress / (total / 100))); }); ArduinoOTA.onError([](ota_error_t error) { - Serial.printf("[OTA] Error %u: ", error); - if (error == OTA_AUTH_ERROR) Serial.println("Auth failed"); - else if (error == OTA_BEGIN_ERROR) Serial.println("Begin failed"); - else if (error == OTA_CONNECT_ERROR) Serial.println("Connect failed"); - else if (error == OTA_RECEIVE_ERROR) Serial.println("Receive failed"); - else if (error == OTA_END_ERROR) Serial.println("End failed"); + Serial0.printf("[OTA] Error %u: ", error); + if (error == OTA_AUTH_ERROR) Serial0.println("Auth failed"); + else if (error == OTA_BEGIN_ERROR) Serial0.println("Begin failed"); + else if (error == OTA_CONNECT_ERROR) Serial0.println("Connect failed"); + else if (error == OTA_RECEIVE_ERROR) Serial0.println("Receive failed"); + else if (error == OTA_END_ERROR) Serial0.println("End failed"); setLEDState(LEDState::Solid); }); ArduinoOTA.begin(); otaEnabled = true; - Serial.println("[OTA] Enabled"); + Serial0.println("[OTA] Enabled"); } bool isOTAEnabled() { @@ -185,19 +211,26 @@ bool isOTAEnabled() { // --- Setup --- void setup() { - USB.begin(); - Serial.begin(115200); - delay(2000); // Allow USB CDC to enumerate on host + // UART0 on ESP32-S3-DevKitC-1 (CH343 bridge): TX=GPIO43, RX=GPIO44 + // These are the default pins for Serial0, no need to specify + Serial0.begin(115200); + delay(100); // Brief delay for UART to stabilize - Serial.println(); - Serial.println("================================"); - Serial.printf("HMC472A Attenuator Controller %s\n", FW_VERSION); - Serial.println("================================"); + Serial0.println(); + Serial0.println("================================"); + Serial0.printf("HMC472A Attenuator Controller %s\n", FW_VERSION); + Serial0.println("================================"); // Initialize LED pinMode(PIN_LED, OUTPUT); setLEDState(LEDState::Off); + // Initialize OLED display + if (initDisplay()) { + showSplash(FW_VERSION); + delay(1500); // Show splash briefly + } + // Initialize watchdog (ESP-IDF v5.x API) esp_task_wdt_config_t wdt_config = { .timeout_ms = WDT_TIMEOUT_S * 1000, @@ -206,7 +239,7 @@ void setup() { }; esp_task_wdt_init(&wdt_config); esp_task_wdt_add(NULL); - Serial.printf("[WDT] Initialized, timeout=%d s\n", WDT_TIMEOUT_S); + Serial0.printf("[WDT] Initialized, timeout=%d s\n", WDT_TIMEOUT_S); // Initialize attenuator (restores from NVS) attenuator.begin(); @@ -222,8 +255,8 @@ void setup() { setupWebServer(attenuator); } - Serial.println("[Setup] Complete"); - Serial.println(); + Serial0.println("[Setup] Complete"); + Serial0.println(); } // --- Main Loop --- @@ -236,5 +269,18 @@ void loop() { ArduinoOTA.handle(); } + // Update display periodically + uint32_t now = millis(); + if (now - lastDisplayUpdate >= DISPLAY_UPDATE_MS) { + lastDisplayUpdate = now; + updateDisplay( + attenuator.getDB(), + attenuator.getStep(), + WiFi.RSSI(), + isSweeping(), + WiFi.status() == WL_CONNECTED + ); + } + delay(1); // Yield to other tasks } diff --git a/firmware/src/web_server.cpp b/firmware/src/web_server.cpp index 1ed2b23..55d62ff 100644 --- a/firmware/src/web_server.cpp +++ b/firmware/src/web_server.cpp @@ -1,7 +1,6 @@ #include "web_server.h" #include -#include #include #include #include @@ -17,6 +16,9 @@ extern int8_t getSweepDirection(); extern uint32_t getSweepDwellMs(); extern void enableOTA(); extern bool isOTAEnabled(); +extern void setWiFiTxPower(wifi_power_t power); +extern wifi_power_t getWiFiTxPower(); +extern float wifiPowerToDbm(wifi_power_t power); static AsyncWebServer server(WEB_PORT); static Attenuator* pAtten = nullptr; @@ -128,6 +130,11 @@ static void handleConfig(AsyncWebServerRequest* request) { doc["mac"] = WiFi.macAddress(); doc["ota_enabled"] = isOTAEnabled(); + // WiFi TX power + JsonObject wifi = doc["wifi"].to(); + wifi["tx_power_dbm"] = wifiPowerToDbm(getWiFiTxPower()); + wifi["rssi"] = WiFi.RSSI(); + String output; serializeJson(doc, output); @@ -199,15 +206,87 @@ static void handleOTAEnable(AsyncWebServerRequest* request) { request->send(response); } +// --- GET /wifi/power --- +static void handleWiFiPowerGet(AsyncWebServerRequest* request) { + JsonDocument doc; + wifi_power_t power = getWiFiTxPower(); + doc["tx_power_raw"] = (int)power; + doc["tx_power_dbm"] = wifiPowerToDbm(power); + doc["rssi"] = WiFi.RSSI(); + + // Available power levels for reference + JsonArray levels = doc["available_levels"].to(); + const int powers[] = {8, 20, 28, 34, 44, 52, 60, 68, 74, 76, 78}; // quarter-dBm values + const float dbms[] = {2.0, 5.0, 7.0, 8.5, 11.0, 13.0, 15.0, 17.0, 18.5, 19.0, 19.5}; + for (int i = 0; i < 11; i++) { + JsonObject level = levels.add(); + level["raw"] = powers[i]; + level["dbm"] = dbms[i]; + } + + String output; + serializeJson(doc, output); + + AsyncWebServerResponse* response = request->beginResponse(200, "application/json", output); + addCorsHeaders(response); + request->send(response); +} + +// --- POST /wifi/power --- +static void handleWiFiPowerSet(AsyncWebServerRequest* request, uint8_t* data, size_t len, size_t index, size_t total) { + JsonDocument doc; + DeserializationError error = deserializeJson(doc, data, len); + + if (error) { + AsyncWebServerResponse* response = request->beginResponse(400, "application/json", + "{\"error\":\"Invalid JSON\"}"); + addCorsHeaders(response); + request->send(response); + return; + } + + // Accept either raw value or dBm (will round to nearest) + wifi_power_t newPower; + if (doc["tx_power_raw"].is()) { + newPower = (wifi_power_t)doc["tx_power_raw"].as(); + } else if (doc["tx_power_dbm"].is()) { + // Convert dBm to quarter-dBm raw value (round to nearest valid level) + float targetDbm = doc["tx_power_dbm"].as(); + const int powers[] = {8, 20, 28, 34, 44, 52, 60, 68, 74, 76, 78}; + const float dbms[] = {2.0, 5.0, 7.0, 8.5, 11.0, 13.0, 15.0, 17.0, 18.5, 19.0, 19.5}; + int closest = 0; + float minDiff = abs(targetDbm - dbms[0]); + for (int i = 1; i < 11; i++) { + float diff = abs(targetDbm - dbms[i]); + if (diff < minDiff) { + minDiff = diff; + closest = i; + } + } + newPower = (wifi_power_t)powers[closest]; + } else { + AsyncWebServerResponse* response = request->beginResponse(400, "application/json", + "{\"error\":\"Must provide tx_power_raw or tx_power_dbm\"}"); + addCorsHeaders(response); + request->send(response); + return; + } + + setWiFiTxPower(newPower); + + // Return new power status + handleWiFiPowerGet(request); +} + // --- Setup --- void setupWebServer(Attenuator& atten) { pAtten = &atten; // Initialize LittleFS if (!LittleFS.begin(true)) { - Serial.println("[WebServer] LittleFS mount failed!"); + Serial0.println("[WebServer] LittleFS mount failed!"); } else { - Serial.println("[WebServer] LittleFS mounted"); + Serial0.println("[WebServer] LittleFS mounted"); } // CORS preflight handler for all routes @@ -223,12 +302,15 @@ void setupWebServer(Attenuator& atten) { server.on("/sweep", HTTP_GET, handleSweepStatus); server.on("/sweep/stop", HTTP_POST, handleSweepStop); server.on("/ota", HTTP_POST, handleOTAEnable); + server.on("/wifi/power", HTTP_GET, handleWiFiPowerGet); // Routes with body parsing server.on("/set", HTTP_POST, [](AsyncWebServerRequest* request) {}, NULL, handleSet); server.on("/sweep", HTTP_POST, [](AsyncWebServerRequest* request) {}, NULL, handleSweepStart); + server.on("/wifi/power", HTTP_POST, [](AsyncWebServerRequest* request) {}, + NULL, handleWiFiPowerSet); // Static files from LittleFS (index.html, style.css, app.js, favicon.svg) server.serveStatic("/", LittleFS, "/").setDefaultFile("index.html"); @@ -242,5 +324,5 @@ void setupWebServer(Attenuator& atten) { }); server.begin(); - Serial.printf("[WebServer] Started on port %d\n", WEB_PORT); + Serial0.printf("[WebServer] Started on port %d\n", WEB_PORT); }