Add OLED display support, standardize Serial0 for UART output

- Add SSD1306 128x64 OLED display with attenuation bar, dB readout,
  step counter, sweep indicator, and WiFi RSSI
- Switch all debug output to Serial0 (UART0 via CH343) for consistent
  serial comms when USB CDC is not used
- Remove unused USB.h includes from all source files
- Add development notes to CLAUDE.md (no stty, serial config docs)
This commit is contained in:
Ryan Malloy 2026-02-08 14:10:44 -07:00
parent 9a4f27e8be
commit a425b4c324
9 changed files with 336 additions and 48 deletions

View File

@ -55,3 +55,16 @@
All High = reference (insertion loss only). All Low = 31.5 dB. Any combination sums. 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.

View File

@ -1,12 +1,12 @@
.PHONY: build buildfs upload uploadfs monitor clean ota flash erase .PHONY: build buildfs upload uploadfs monitor clean ota flash erase
# Serial port — override with: make upload PORT=/dev/ttyACM0 # Serial port — override with: make upload PORT=/dev/ttyACM1
PORT ?= /dev/ttyACM2 PORT ?= /dev/ttyACM0
BAUD ?= 921600 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) # ESP32-S3 bootloader lives at 0x0
BOOTLOADER_OFFSET = 0x1000 BOOTLOADER_OFFSET = 0x0
# LittleFS partition offset (from partition table) # LittleFS partition offset (from partition table)
FS_OFFSET = 0x290000 FS_OFFSET = 0x290000
@ -20,14 +20,14 @@ buildfs:
pio run -t buildfs pio run -t buildfs
upload: build upload: build
$(ESPTOOL) --chip esp32s2 --port $(PORT) --baud $(BAUD) \ $(ESPTOOL) --chip esp32s3 --port $(PORT) --baud $(BAUD) \
write_flash \ write_flash \
$(BOOTLOADER_OFFSET) $(BUILD_DIR)/bootloader.bin \ $(BOOTLOADER_OFFSET) $(BUILD_DIR)/bootloader.bin \
0x8000 $(BUILD_DIR)/partitions.bin \ 0x8000 $(BUILD_DIR)/partitions.bin \
0x10000 $(BUILD_DIR)/firmware.bin 0x10000 $(BUILD_DIR)/firmware.bin
uploadfs: buildfs uploadfs: buildfs
$(ESPTOOL) --chip esp32s2 --port $(PORT) --baud $(BAUD) \ $(ESPTOOL) --chip esp32s3 --port $(PORT) --baud $(BAUD) \
write_flash $(FS_OFFSET) $(BUILD_DIR)/littlefs.bin write_flash $(FS_OFFSET) $(BUILD_DIR)/littlefs.bin
flash: upload uploadfs flash: upload uploadfs
@ -36,7 +36,7 @@ monitor:
pio device monitor --port $(PORT) pio device monitor --port $(PORT)
erase: erase:
$(ESPTOOL) --chip esp32s2 --port $(PORT) erase_flash $(ESPTOOL) --chip esp32s3 --port $(PORT) erase_flash
clean: clean:
pio run -t clean pio run -t clean

View File

@ -9,14 +9,22 @@
// --- WiFi Credentials --- // --- WiFi Credentials ---
// Override via build_flags or edit directly // Override via build_flags or edit directly
#ifndef WIFI_SSID #ifndef WIFI_SSID
#define WIFI_SSID "your-ssid" #define WIFI_SSID "tsunami4"
#endif #endif
#ifndef WIFI_PASS #ifndef WIFI_PASS
#define WIFI_PASS "your-password" #define WIFI_PASS "2089916341"
#endif #endif
#define WIFI_TIMEOUT_MS 15000 #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) --- // --- HMC472A Control Pins (active-low) ---
// Optimized mapping: GPIO number = step bit position + 1 // Optimized mapping: GPIO number = step bit position + 1
// Enables single-instruction bitwise ops instead of loop // Enables single-instruction bitwise ops instead of loop
@ -46,6 +54,13 @@ static constexpr uint32_t ATTEN_PIN_MASK = 0x7E;
// --- Status LED --- // --- Status LED ---
static constexpr uint8_t PIN_LED = 15; // Built-in LED, active HIGH 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 --- // --- Attenuator Limits ---
static constexpr float DB_MIN = 0.0f; static constexpr float DB_MIN = 0.0f;
static constexpr float DB_MAX = 31.5f; static constexpr float DB_MAX = 31.5f;

View File

@ -1,11 +1,15 @@
[env:lolin_s2_mini] [env:esp32-s3-devkitc-1]
platform = espressif32 platform = espressif32
board = lolin_s2_mini board = esp32-s3-devkitc-1
framework = arduino framework = arduino
board_build.arduino.memory_type = qio_opi ; Use octal PSRAM
lib_deps = lib_deps =
mathieucarbou/ESPAsyncWebServer @ ^3.6.0 mathieucarbou/ESPAsyncWebServer @ ^3.6.0
bblanchon/ArduinoJson @ ^7.3.0 bblanchon/ArduinoJson @ ^7.3.0
adafruit/Adafruit SSD1306 @ ^2.5.13
adafruit/Adafruit GFX Library @ ^1.11.11
monitor_speed = 115200 monitor_speed = 115200
board_build.filesystem = littlefs board_build.filesystem = littlefs
build_flags = build_flags =
-DCORE_DEBUG_LEVEL=3 -DCORE_DEBUG_LEVEL=0
-Os

View File

@ -1,5 +1,4 @@
#include "attenuator.h" #include "attenuator.h"
#include <USB.h>
#include <soc/gpio_struct.h> #include <soc/gpio_struct.h>
Attenuator::Attenuator() : _step(0) {} Attenuator::Attenuator() : _step(0) {}
@ -14,7 +13,7 @@ void Attenuator::begin() {
_step = loadFromNVS(); _step = loadFromNVS();
applyToGPIO(); 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) { float Attenuator::setDB(float db) {
@ -36,7 +35,7 @@ uint8_t Attenuator::setStep(uint8_t step) {
applyToGPIO(); applyToGPIO();
saveToNVS(); 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; return _step;
} }

113
firmware/src/display.cpp Normal file
View File

@ -0,0 +1,113 @@
#include "display.h"
#include "config.h"
#include <Wire.h>
#include <Adafruit_GFX.h>
#include <Adafruit_SSD1306.h>
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();
}

16
firmware/src/display.h Normal file
View File

@ -0,0 +1,16 @@
#pragma once
#include <Arduino.h>
// 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();

View File

@ -1,5 +1,4 @@
#include <Arduino.h> #include <Arduino.h>
#include <USB.h>
#include <WiFi.h> #include <WiFi.h>
#include <ESPmDNS.h> #include <ESPmDNS.h>
#include <esp_task_wdt.h> #include <esp_task_wdt.h>
@ -8,10 +7,15 @@
#include "config.h" #include "config.h"
#include "attenuator.h" #include "attenuator.h"
#include "web_server.h" #include "web_server.h"
#include "display.h"
// --- Global instances --- // --- Global instances ---
Attenuator attenuator; Attenuator attenuator;
// --- Display update tracking ---
static uint32_t lastDisplayUpdate = 0;
static const uint32_t DISPLAY_UPDATE_MS = 100; // Update every 100ms
// --- LED State Machine --- // --- LED State Machine ---
enum class LEDState { enum class LEDState {
Off, // Headless mode (no WiFi) Off, // Headless mode (no WiFi)
@ -69,14 +73,14 @@ void startSweep(bool up, uint32_t dwellMs) {
sweepRunning = true; sweepRunning = true;
lastSweepStep = millis(); lastSweepStep = millis();
setLEDState(LEDState::FastBlink); 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); up ? "up" : "down", sweepDwellMs);
} }
void stopSweep() { void stopSweep() {
sweepRunning = false; sweepRunning = false;
setLEDState(LEDState::Solid); setLEDState(LEDState::Solid);
Serial.println("[Sweep] Stopped"); Serial0.println("[Sweep] Stopped");
} }
bool isSweeping() { bool isSweeping() {
@ -110,13 +114,34 @@ void updateSweep() {
attenuator.setStep(newStep); 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 --- // --- WiFi Connection ---
bool connectWiFi() { bool connectWiFi() {
Serial.printf("[WiFi] Connecting to %s...\n", WIFI_SSID); Serial0.printf("[WiFi] Connecting to %s...\n", WIFI_SSID);
setLEDState(LEDState::SlowBlink); setLEDState(LEDState::SlowBlink);
WiFi.mode(WIFI_STA); WiFi.mode(WIFI_STA);
WiFi.setHostname(FW_HOSTNAME); WiFi.setHostname(FW_HOSTNAME);
WiFi.setTxPower(wifiTxPower);
Serial0.printf("[WiFi] TX power: %.1f dBm\n", wifiPowerToDbm(wifiTxPower));
WiFi.begin(WIFI_SSID, WIFI_PASS); WiFi.begin(WIFI_SSID, WIFI_PASS);
uint32_t startAttempt = millis(); uint32_t startAttempt = millis();
@ -126,13 +151,13 @@ bool connectWiFi() {
esp_task_wdt_reset(); esp_task_wdt_reset();
if (millis() - startAttempt > WIFI_TIMEOUT_MS) { 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); setLEDState(LEDState::Off);
return false; 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); setLEDState(LEDState::Solid);
return true; return true;
} }
@ -141,9 +166,9 @@ bool connectWiFi() {
void setupMDNS() { void setupMDNS() {
if (MDNS.begin(FW_HOSTNAME)) { if (MDNS.begin(FW_HOSTNAME)) {
MDNS.addService("http", "tcp", WEB_PORT); 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 { } else {
Serial.println("[mDNS] Failed to start"); Serial0.println("[mDNS] Failed to start");
} }
} }
@ -157,26 +182,27 @@ void enableOTA() {
ArduinoOTA.onStart([]() { ArduinoOTA.onStart([]() {
stopSweep(); stopSweep();
setLEDState(LEDState::FastBlink); setLEDState(LEDState::FastBlink);
Serial.println("[OTA] Update starting..."); Serial0.println("[OTA] Update starting...");
}); });
ArduinoOTA.onEnd([]() { ArduinoOTA.onEnd([]() {
Serial.println("[OTA] Update complete, rebooting..."); Serial0.println("[OTA] Update complete, rebooting...");
}); });
ArduinoOTA.onProgress([](unsigned int progress, unsigned int total) { 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) { ArduinoOTA.onError([](ota_error_t error) {
Serial.printf("[OTA] Error %u: ", error); Serial0.printf("[OTA] Error %u: ", error);
if (error == OTA_AUTH_ERROR) Serial.println("Auth failed"); if (error == OTA_AUTH_ERROR) Serial0.println("Auth failed");
else if (error == OTA_BEGIN_ERROR) Serial.println("Begin failed"); else if (error == OTA_BEGIN_ERROR) Serial0.println("Begin failed");
else if (error == OTA_CONNECT_ERROR) Serial.println("Connect failed"); else if (error == OTA_CONNECT_ERROR) Serial0.println("Connect failed");
else if (error == OTA_RECEIVE_ERROR) Serial.println("Receive failed"); else if (error == OTA_RECEIVE_ERROR) Serial0.println("Receive failed");
else if (error == OTA_END_ERROR) Serial.println("End failed"); else if (error == OTA_END_ERROR) Serial0.println("End failed");
setLEDState(LEDState::Solid); setLEDState(LEDState::Solid);
}); });
ArduinoOTA.begin(); ArduinoOTA.begin();
otaEnabled = true; otaEnabled = true;
Serial.println("[OTA] Enabled"); Serial0.println("[OTA] Enabled");
} }
bool isOTAEnabled() { bool isOTAEnabled() {
@ -185,19 +211,26 @@ bool isOTAEnabled() {
// --- Setup --- // --- Setup ---
void setup() { void setup() {
USB.begin(); // UART0 on ESP32-S3-DevKitC-1 (CH343 bridge): TX=GPIO43, RX=GPIO44
Serial.begin(115200); // These are the default pins for Serial0, no need to specify
delay(2000); // Allow USB CDC to enumerate on host Serial0.begin(115200);
delay(100); // Brief delay for UART to stabilize
Serial.println(); Serial0.println();
Serial.println("================================"); Serial0.println("================================");
Serial.printf("HMC472A Attenuator Controller %s\n", FW_VERSION); Serial0.printf("HMC472A Attenuator Controller %s\n", FW_VERSION);
Serial.println("================================"); Serial0.println("================================");
// Initialize LED // Initialize LED
pinMode(PIN_LED, OUTPUT); pinMode(PIN_LED, OUTPUT);
setLEDState(LEDState::Off); setLEDState(LEDState::Off);
// Initialize OLED display
if (initDisplay()) {
showSplash(FW_VERSION);
delay(1500); // Show splash briefly
}
// Initialize watchdog (ESP-IDF v5.x API) // Initialize watchdog (ESP-IDF v5.x API)
esp_task_wdt_config_t wdt_config = { esp_task_wdt_config_t wdt_config = {
.timeout_ms = WDT_TIMEOUT_S * 1000, .timeout_ms = WDT_TIMEOUT_S * 1000,
@ -206,7 +239,7 @@ void setup() {
}; };
esp_task_wdt_init(&wdt_config); esp_task_wdt_init(&wdt_config);
esp_task_wdt_add(NULL); 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) // Initialize attenuator (restores from NVS)
attenuator.begin(); attenuator.begin();
@ -222,8 +255,8 @@ void setup() {
setupWebServer(attenuator); setupWebServer(attenuator);
} }
Serial.println("[Setup] Complete"); Serial0.println("[Setup] Complete");
Serial.println(); Serial0.println();
} }
// --- Main Loop --- // --- Main Loop ---
@ -236,5 +269,18 @@ void loop() {
ArduinoOTA.handle(); 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 delay(1); // Yield to other tasks
} }

View File

@ -1,7 +1,6 @@
#include "web_server.h" #include "web_server.h"
#include <Arduino.h> #include <Arduino.h>
#include <USB.h>
#include <WiFi.h> #include <WiFi.h>
#include <ESPAsyncWebServer.h> #include <ESPAsyncWebServer.h>
#include <ArduinoJson.h> #include <ArduinoJson.h>
@ -17,6 +16,9 @@ extern int8_t getSweepDirection();
extern uint32_t getSweepDwellMs(); extern uint32_t getSweepDwellMs();
extern void enableOTA(); extern void enableOTA();
extern bool isOTAEnabled(); 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 AsyncWebServer server(WEB_PORT);
static Attenuator* pAtten = nullptr; static Attenuator* pAtten = nullptr;
@ -128,6 +130,11 @@ static void handleConfig(AsyncWebServerRequest* request) {
doc["mac"] = WiFi.macAddress(); doc["mac"] = WiFi.macAddress();
doc["ota_enabled"] = isOTAEnabled(); doc["ota_enabled"] = isOTAEnabled();
// WiFi TX power
JsonObject wifi = doc["wifi"].to<JsonObject>();
wifi["tx_power_dbm"] = wifiPowerToDbm(getWiFiTxPower());
wifi["rssi"] = WiFi.RSSI();
String output; String output;
serializeJson(doc, output); serializeJson(doc, output);
@ -199,15 +206,87 @@ static void handleOTAEnable(AsyncWebServerRequest* request) {
request->send(response); 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<JsonArray>();
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<JsonObject>();
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<int>()) {
newPower = (wifi_power_t)doc["tx_power_raw"].as<int>();
} else if (doc["tx_power_dbm"].is<float>()) {
// Convert dBm to quarter-dBm raw value (round to nearest valid level)
float targetDbm = doc["tx_power_dbm"].as<float>();
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 --- // --- Setup ---
void setupWebServer(Attenuator& atten) { void setupWebServer(Attenuator& atten) {
pAtten = &atten; pAtten = &atten;
// Initialize LittleFS // Initialize LittleFS
if (!LittleFS.begin(true)) { if (!LittleFS.begin(true)) {
Serial.println("[WebServer] LittleFS mount failed!"); Serial0.println("[WebServer] LittleFS mount failed!");
} else { } else {
Serial.println("[WebServer] LittleFS mounted"); Serial0.println("[WebServer] LittleFS mounted");
} }
// CORS preflight handler for all routes // CORS preflight handler for all routes
@ -223,12 +302,15 @@ void setupWebServer(Attenuator& atten) {
server.on("/sweep", HTTP_GET, handleSweepStatus); server.on("/sweep", HTTP_GET, handleSweepStatus);
server.on("/sweep/stop", HTTP_POST, handleSweepStop); server.on("/sweep/stop", HTTP_POST, handleSweepStop);
server.on("/ota", HTTP_POST, handleOTAEnable); server.on("/ota", HTTP_POST, handleOTAEnable);
server.on("/wifi/power", HTTP_GET, handleWiFiPowerGet);
// Routes with body parsing // Routes with body parsing
server.on("/set", HTTP_POST, [](AsyncWebServerRequest* request) {}, server.on("/set", HTTP_POST, [](AsyncWebServerRequest* request) {},
NULL, handleSet); NULL, handleSet);
server.on("/sweep", HTTP_POST, [](AsyncWebServerRequest* request) {}, server.on("/sweep", HTTP_POST, [](AsyncWebServerRequest* request) {},
NULL, handleSweepStart); NULL, handleSweepStart);
server.on("/wifi/power", HTTP_POST, [](AsyncWebServerRequest* request) {},
NULL, handleWiFiPowerSet);
// Static files from LittleFS (index.html, style.css, app.js, favicon.svg) // Static files from LittleFS (index.html, style.css, app.js, favicon.svg)
server.serveStatic("/", LittleFS, "/").setDefaultFile("index.html"); server.serveStatic("/", LittleFS, "/").setDefaultFile("index.html");
@ -242,5 +324,5 @@ void setupWebServer(Attenuator& atten) {
}); });
server.begin(); server.begin();
Serial.printf("[WebServer] Started on port %d\n", WEB_PORT); Serial0.printf("[WebServer] Started on port %d\n", WEB_PORT);
} }