#include <Arduino.h>
#include <WiFi.h>
#include <WiFiClientSecure.h>
#include <WebServer.h>
#include <DNSServer.h>
#include <Preferences.h>
#include <Wire.h>
#include <ArduinoJson.h>
#include <TFT_eSPI.h>
#include <TJpg_Decoder.h>
#include "driver/i2s.h"
// ==========================================
// NETWORK + PROVISIONING CONFIGURATION
// ==========================================
const char* serverHost = "stickerbox.up.railway.app";
const int serverPort = 443;
constexpr bool ENABLE_DEV_WIFI_FALLBACK = false;
const char* DEV_WIFI_SSID = "YOUR_WIFI_SSID";
const char* DEV_WIFI_PASSWORD = "YOUR_WIFI_PASSWORD";
constexpr uint32_t WIFI_CONNECT_TIMEOUT_MS = 15000;
constexpr uint32_t SETUP_TIMEOUT_MS = 10UL * 60UL * 1000UL;
constexpr uint32_t BUTTON_DEBOUNCE_MS = 50;
constexpr uint32_t HOLD_THRESHOLD_MS = 300;
constexpr uint32_t CLICK_MAX_INTERVAL_MS = 1000;
const char* PREF_NAMESPACE = "wifi";
const char* PREF_KEY_SSID = "ssid";
const char* PREF_KEY_PASS = "pass";
const IPAddress SETUP_AP_IP(4, 3, 2, 1);
const IPAddress SETUP_AP_GATEWAY(4, 3, 2, 1);
const IPAddress SETUP_AP_SUBNET(255, 255, 255, 0);
// ==========================================
// PIN DEFINITIONS (Configurati per Lilygo T-Display V1.1)
// ==========================================
// Pulsante su GPIO 0 (BOOT button DevKitC, pullup esterno 10kΩ)
#define BUTTON_PIN 33
#define DEBOUNCE_SAMPLES 5
// I2S Microphone (INMP441) - Usiamo pin sicuri della riga destra
#define I2S_WS 25
#define I2S_SCK 26
#define I2S_SD 27
// Stampante Termica (Serial2) - RX/TX. Usiamo pin della riga sinistra
// Evitiamo GPIO16 (DC dello schermo) e GPIO4 (Backlight dello schermo)
#define PRINTER_BAUDRATE 9600
#define PRINTER_RX 15
#define PRINTER_TX 13
// ==========================================
// TFT SPECIFICATIONS (ILI9341 external SPI display)
// ==========================================
#define SCREEN_WIDTH 240
#define SCREEN_HEIGHT 320
#define MAX_DISPLAY_JPG_SIZE 102400
TFT_eSPI tft = TFT_eSPI();
bool hasTFT = false;
// JPEG decoder callback
bool tft_output(int16_t x, int16_t y, uint16_t w, uint16_t h, uint16_t* bitmap) {
if (y >= tft.height()) return false;
tft.pushImage(x, y, w, h, bitmap);
return true;
}
// ==========================================
// AUDIO RECORDING CONFIGURATION
// ==========================================
#define I2S_PORT I2S_NUM_0
#define MAX_AUDIO_SIZE 96000 // Ridotto a 96KB (3 secondi di audio 16kHz 16-bit mono) per allinearsi al blocco contiguo massimo di SRAM (~110KB) su ESP32
#define I2S_CHUNK_SIZE 1024
uint8_t* audioBuffer = nullptr;
uint32_t recordedBytes = 0;
// ==========================================
// PROVISIONING SERVICES
// ==========================================
Preferences preferences;
DNSServer dnsServer;
WebServer webServer(80);
bool setupServicesRunning = false;
unsigned long setupStartedAtMs = 0;
String setupApSsid;
String setupApPassword;
String pendingSsid;
String pendingPassword;
bool setupCredentialsPending = false;
// ==========================================
// BUTTON STATE TRACKING
// ==========================================
bool buttonWasPressed = false;
unsigned long buttonPressStartMs = 0;
unsigned long buttonReleaseTimeMs = 0;
int clickCount = 0;
// ==========================================
// STATE MACHINE
// ==========================================
enum SystemState {
STATE_IDLE,
STATE_LISTENING,
STATE_WAITING,
STATE_DISPLAY_RESULTS,
STATE_SETUP_PORTAL,
STATE_SETUP_CONNECTING
};
SystemState currentState = STATE_IDLE;
unsigned long resultsDisplayStartTime = 0;
// Async job tracking
String currentJobId;
unsigned long lastPollTime = 0;
int waitingRetries = 0;
int waitingProgress = 0;
// Transcription display animation
String currentTranscription;
int dotPhase = 0;
// ==========================================
// FUNCTION DECLARATIONS
// ==========================================
void initWiFi();
void initTFT();
void initI2S();
bool startRecording();
void recordAudio();
void stopRecording();
void displayStatus(const char* text);
void displayError(const char* title, const char* details);
void writeWavHeader(uint8_t* header, uint32_t wavDataSize);
String getSetupApSsid();
String getSetupApPassword();
bool hasStoredCredentials();
bool loadStoredCredentials(String& ssid, String& password);
bool saveStoredCredentials(const String& ssid, const String& password);
void clearStoredCredentials();
bool connectToWiFi(const String& ssid, const String& password, uint32_t timeoutMs);
bool attemptNormalWiFiConnection();
void enterSetupMode(const char* reason);
bool startSetupServices();
void stopSetupServices();
void handleSetupPortalRoutes();
void processSetupPortal();
void processSetupConnection();
void handleIdleButtonActions(bool buttonPressed);
void handleWaitingButtonActions(bool buttonPressed);
bool isIpAddress(const String& str);
bool uploadAudioAndGetJob();
bool pollJobStatus(String& status, int& progress, String& reason, String& transcription);
bool fetchJobResult();
void sendCancelRequest();
void displayProgress(const char* text, int progressPercent);
// ==========================================
// ==========================================
// SETUP & LOOP
// ==========================================
void setup() {
Serial.begin(115200);
delay(1000);
Serial.println("\n=============================================");
Serial.println(" STICKERBOX SYSTEM INITIALIZING ");
Serial.println("=============================================");
Serial.println("[SYSTEM] Avvio dei test diagnostici dei componenti...");
Serial.printf("[DBG] Free heap: %u, Max alloc: %u\n", ESP.getFreeHeap(), ESP.getMaxAllocHeap());
// Allocazione unica di boot per audioBuffer
Serial.printf("[MEMORIA] Allocazione pre-boot buffer audio (%d byte)... ", MAX_AUDIO_SIZE);
audioBuffer = (uint8_t*)malloc(MAX_AUDIO_SIZE);
if (audioBuffer == nullptr) {
Serial.println("FALLITA!");
Serial.println("Fatal: Impossibile allocare la memoria di boot per la registrazione audio. Arresto critico.");
while (true) {
delay(1000);
}
}
memset(audioBuffer, 0, MAX_AUDIO_SIZE);
Serial.println("RIUSCITA! Memoria contigua riservata.");
pinMode(BUTTON_PIN, INPUT_PULLUP);
Serial.printf("[SYSTEM] Pulsante BUTTON_PIN configurato (GPIO %d, INPUT_PULLUP).\n", BUTTON_PIN);
Serial2.begin(PRINTER_BAUDRATE, SERIAL_8N1, PRINTER_RX, PRINTER_TX);
Serial.printf("[SYSTEM] Serial2 inizializzata per stampante (Baudrate: %d, RX: %d, TX: %d).\n", PRINTER_BAUDRATE, PRINTER_RX, PRINTER_TX);
Serial.println("[DBG] initTFT start...");
initTFT();
Serial.println("[DBG] initTFT OK");
Serial.println("[DBG] initI2S start...");
initI2S();
Serial.println("[DBG] initI2S OK");
Serial.println("[DBG] initWiFi start...");
initWiFi();
Serial.println("[DBG] initWiFi OK");
Serial.println("\n=============================================");
Serial.println(" Inizializzazione completata con successo! ");
Serial.println(" Il 'Cervello' di Stickerbox e' in funzione. ");
Serial.println("=============================================\n");
}
bool debounceButton() {
static bool debouncedPressed = false;
static int stableCount = 0;
static bool lastRaw = HIGH;
bool raw = digitalRead(BUTTON_PIN);
if (raw == lastRaw) {
if (stableCount < DEBOUNCE_SAMPLES) {
stableCount++;
}
if (stableCount >= DEBOUNCE_SAMPLES) {
debouncedPressed = (raw == LOW);
}
} else {
stableCount = 0;
lastRaw = raw;
}
return debouncedPressed;
}
void loop() {
static SystemState lastState = (SystemState)-1;
if (currentState != lastState) {
const char* stateNames[] = {
"STATE_IDLE (Attesa)",
"STATE_LISTENING (Registrazione audio)",
"STATE_WAITING (In attesa del server)",
"STATE_DISPLAY_RESULTS (Risultati su schermo)",
"STATE_SETUP_PORTAL (AP Portale configurazione)",
"STATE_SETUP_CONNECTING (Verifica credenziali WiFi)"
};
Serial.printf("[STATO COGNITIVO ESP32] Cambiato stato: %s -> %s\n",
(lastState == (SystemState)-1) ? "AVVIO" : stateNames[lastState],
stateNames[currentState]);
lastState = currentState;
}
bool buttonPressed = debounceButton();
if (currentState == STATE_WAITING) {
handleWaitingButtonActions(buttonPressed);
} else {
handleIdleButtonActions(buttonPressed);
}
switch (currentState) {
case STATE_IDLE:
displayStatus("Premi per\nparlare");
break;
case STATE_LISTENING:
displayStatus("Ascolto...");
recordAudio();
if (!buttonPressed || recordedBytes >= MAX_AUDIO_SIZE) {
stopRecording();
currentJobId = "";
waitingRetries = 0;
waitingProgress = 0;
currentState = STATE_WAITING;
}
break;
case STATE_WAITING: {
// Animate transcription dots independently of poll timing
if (!currentJobId.isEmpty() && currentTranscription.length() > 0) {
int newDotPhase = (millis() / 500) % 4;
if (newDotPhase != dotPhase) {
dotPhase = newDotPhase;
String dots = "";
for (int i = 0; i < dotPhase; i++) dots += ".";
displayProgress((currentTranscription + dots).c_str(), waitingProgress);
}
}
if (currentJobId.isEmpty()) {
// First entry: upload audio and create job
currentTranscription = "";
dotPhase = 0;
if (uploadAudioAndGetJob()) {
lastPollTime = millis();
waitingRetries = 0;
waitingProgress = 10;
displayProgress("Sto generando, premi 3x per annullare", 10);
} else {
waitingRetries++;
Serial.printf("[ERRORE] Upload fallito. Tentativo %d di 3.\n", waitingRetries);
if (waitingRetries >= 3) {
Serial.println("[ERRORE] Raggiunto il limite massimo di 3 tentativi. Ritorno a IDLE.");
waitingRetries = 0;
displayError("Errore Invio", "Riprova piu' tardi");
delay(2000);
currentState = STATE_IDLE;
} else {
displayProgress("Riprovo...", 0);
delay(1500);
}
}
} else {
// Have a jobId: poll status every 500ms
if (millis() - lastPollTime >= 500) {
lastPollTime = millis();
String status;
int progress;
String reason;
String transcription;
if (pollJobStatus(status, progress, reason, transcription)) {
waitingRetries = 0;
waitingProgress = progress;
if (transcription.length() > 0) {
currentTranscription = transcription;
}
if (status == "complete") {
currentTranscription = "";
dotPhase = 0;
if (fetchJobResult()) {
currentState = STATE_DISPLAY_RESULTS;
resultsDisplayStartTime = millis();
} else {
displayError("Errore", "Download KO");
delay(2000);
currentState = STATE_IDLE;
}
currentJobId = "";
} else if (status == "error") {
currentTranscription = "";
dotPhase = 0;
if (reason == "low_confidence") {
displayError("Scusa, non", "ho capito");
} else {
displayError("Errore", "Generazione KO");
}
delay(2000);
currentState = STATE_IDLE;
currentJobId = "";
} else if (status == "cancelled") {
currentTranscription = "";
dotPhase = 0;
displayError("Annullato", "");
delay(2000);
currentState = STATE_IDLE;
currentJobId = "";
} else {
// Still processing
if (currentTranscription.length() == 0) {
displayProgress("Sto generando, premi 3x per annullare", waitingProgress);
}
}
} else {
waitingRetries++;
Serial.printf("[ERRORE] Poll fallito. Tentativo %d di 5.\n", waitingRetries);
if (waitingRetries >= 5) {
displayError("Errore", "Server KO");
delay(2000);
currentState = STATE_IDLE;
currentJobId = "";
waitingRetries = 0;
}
}
}
}
break;
}
case STATE_DISPLAY_RESULTS:
if (millis() - resultsDisplayStartTime >= 8000) {
currentState = STATE_IDLE;
}
break;
case STATE_SETUP_PORTAL:
processSetupPortal();
break;
case STATE_SETUP_CONNECTING:
processSetupConnection();
break;
}
delay(10);
}
// ==========================================
// HARDWARE INITIALIZATION
// ==========================================
void initTFT() {
Serial.println("[TFT] Inizializzazione schermo TFT ILI9341 (SPI)...");
tft.init();
tft.setRotation(2); // Rotazione verticale
tft.fillScreen(TFT_BLACK);
tft.setTextSize(2);
tft.setTextColor(TFT_WHITE, TFT_BLACK);
tft.setCursor(0, 0);
tft.println("Avvio...");
hasTFT = true;
Serial.println("[TFT] Schermo TFT inizializzato con successo!");
// Configura TJpg_Decoder
TJpgDec.setJpgScale(1);
TJpgDec.setCallback(tft_output);
}
void initWiFi() {
Serial.println("[WIFI] Avvio inizializzazione connessione Wi-Fi...");
if (attemptNormalWiFiConnection()) {
Serial.println("[WIFI] Connessione normale riuscita.");
currentState = STATE_IDLE;
return;
}
Serial.println("[WIFI] Connessione normale fallita o non configurata. Entro in Setup Mode.");
enterSetupMode("No WiFi configured");
}
void initI2S() {
Serial.println("[I2S] Inizializzazione modulo I2S (Microfono INMP441)...");
i2s_config_t i2s_config = {
.mode = (i2s_mode_t)(I2S_MODE_MASTER | I2S_MODE_RX),
.sample_rate = 16000,
.bits_per_sample = I2S_BITS_PER_SAMPLE_16BIT,
.channel_format = I2S_CHANNEL_FMT_ONLY_LEFT,
.communication_format = (i2s_comm_format_t)(I2S_COMM_FORMAT_STAND_I2S),
.intr_alloc_flags = ESP_INTR_FLAG_LEVEL1,
.dma_buf_count = 8,
.dma_buf_len = 64,
.use_apll = false
};
i2s_pin_config_t pin_config = {
.bck_io_num = I2S_SCK,
.ws_io_num = I2S_WS,
.data_out_num = I2S_PIN_NO_CHANGE,
.data_in_num = I2S_SD
};
esp_err_t err_drv = i2s_driver_install(I2S_PORT, &i2s_config, 0, NULL);
if (err_drv == ESP_OK) {
Serial.println("[I2S] Driver I2S installati con successo.");
} else {
Serial.printf("[I2S] ERROR: Installazione driver I2S fallita con codice %d\n", err_drv);
}
esp_err_t err_pin = i2s_set_pin(I2S_PORT, &pin_config);
if (err_pin == ESP_OK) {
Serial.printf("[I2S] Pin I2S associati (SCK:%d, WS:%d, SD:%d) con successo.\n", I2S_SCK, I2S_WS, I2S_SD);
} else {
Serial.printf("[I2S] ERROR: Associazione PIN I2S fallita con codice %d\n", err_pin);
}
i2s_stop(I2S_PORT);
Serial.println("[I2S] Driver I2S inseriti in standby.");
}
// ==========================================
// BUTTON HANDLING
// ==========================================
void handleIdleButtonActions(bool buttonPressed) {
// Il pulsante deve rispondere ai 5-click sia in STATE_IDLE sia in STATE_SETUP_PORTAL
if (currentState != STATE_IDLE && currentState != STATE_SETUP_PORTAL) {
buttonWasPressed = buttonPressed;
return;
}
unsigned long now = millis();
if (buttonPressed) {
if (!buttonWasPressed) {
buttonPressStartMs = now;
Serial.printf("[PULSANTE] Rilevato pulsante PREMUTO (GPIO %d = LOW)\n", BUTTON_PIN);
} else {
// Se siamo in IDLE e teniamo premuto, avviamo la registrazione
if (currentState == STATE_IDLE && (now - buttonPressStartMs >= HOLD_THRESHOLD_MS)) {
Serial.printf("[PULSANTE] Raggiunta soglia di pressione costante (%d ms). Avvio registrazione...\n", HOLD_THRESHOLD_MS);
if (startRecording()) {
currentState = STATE_LISTENING;
clickCount = 0; // Reset consecutive clicks
}
}
}
} else { // !buttonPressed
if (buttonWasPressed) {
// Button was just released
unsigned long pressDuration = now - buttonPressStartMs;
Serial.printf("[PULSANTE] Rilevato pulsante RILASCIATO (GPIO %d = HIGH) dopo %d ms\n", BUTTON_PIN, pressDuration);
if (pressDuration >= BUTTON_DEBOUNCE_MS && pressDuration < HOLD_THRESHOLD_MS) {
// This is a quick click!
if (now - buttonReleaseTimeMs <= CLICK_MAX_INTERVAL_MS) {
clickCount++;
} else {
clickCount = 1;
}
buttonReleaseTimeMs = now;
Serial.printf("[PULSANTE] Click rapido rilevato! N. click consecutivi: %d (premi 5 volte per togglare Setup Mode)\n", clickCount);
if (clickCount >= 5) {
clickCount = 0;
if (currentState == STATE_SETUP_PORTAL) {
Serial.println("[PULSANTE] Rilevati 5 click consecutivi in Setup Mode. Disattivo portale e ritorno in IDLE.");
stopSetupServices();
currentState = STATE_IDLE;
} else {
Serial.println("[PULSANTE] Rilevati 5 click consecutivi in IDLE. Forzo apertura portale di Setup.");
enterSetupMode("5 consecutive clicks");
}
}
}
}
}
buttonWasPressed = buttonPressed;
}
void handleWaitingButtonActions(bool buttonPressed) {
unsigned long now = millis();
if (buttonPressed) {
if (!buttonWasPressed) {
buttonPressStartMs = now;
}
} else {
if (buttonWasPressed) {
unsigned long pressDuration = now - buttonPressStartMs;
if (pressDuration >= BUTTON_DEBOUNCE_MS && pressDuration < HOLD_THRESHOLD_MS) {
// Quick click
if (now - buttonReleaseTimeMs <= CLICK_MAX_INTERVAL_MS) {
clickCount++;
} else {
clickCount = 1;
}
buttonReleaseTimeMs = now;
Serial.printf("[PULSANTE] Click rapido in WAITING! N. click consecutivi: %d (premi 3 volte per annullare)\n", clickCount);
if (clickCount >= 3) {
clickCount = 0;
if (!currentJobId.isEmpty()) {
Serial.println("[PULSANTE] Rilevati 3 click in WAITING. Invio richiesta di annullamento...");
sendCancelRequest();
}
}
}
}
}
buttonWasPressed = buttonPressed;
}
// ==========================================
// CREDENTIAL STORAGE + CONNECTION HELPERS
// ==========================================
bool hasStoredCredentials() {
String ssid;
String password;
return loadStoredCredentials(ssid, password);
}
bool loadStoredCredentials(String& ssid, String& password) {
preferences.begin(PREF_NAMESPACE, true);
ssid = preferences.getString(PREF_KEY_SSID, "");
password = preferences.getString(PREF_KEY_PASS, "");
preferences.end();
ssid.trim();
return ssid.length() > 0 && password.length() > 0;
}
bool saveStoredCredentials(const String& ssid, const String& password) {
if (ssid.length() == 0 || password.length() == 0) {
return false;
}
preferences.begin(PREF_NAMESPACE, false);
bool ok1 = preferences.putString(PREF_KEY_SSID, ssid) > 0;
bool ok2 = preferences.putString(PREF_KEY_PASS, password) > 0;
preferences.end();
return ok1 && ok2;
}
void clearStoredCredentials() {
preferences.begin(PREF_NAMESPACE, false);
preferences.remove(PREF_KEY_SSID);
preferences.remove(PREF_KEY_PASS);
preferences.end();
}
bool connectToWiFi(const String& ssid, const String& password, uint32_t timeoutMs) {
if (ssid.length() == 0 || password.length() == 0) {
return false;
}
displayStatus("Connessione\nWiFi...");
Serial.print("Connecting to SSID: ");
Serial.println(ssid);
WiFi.mode(WIFI_STA);
WiFi.disconnect(false, false);
delay(100);
WiFi.begin(ssid.c_str(), password.c_str());
unsigned long start = millis();
while (WiFi.status() != WL_CONNECTED && (millis() - start) < timeoutMs) {
delay(250);
Serial.print(".");
}
if (WiFi.status() == WL_CONNECTED) {
Serial.print("\nConnected! IP address: ");
Serial.println(WiFi.localIP());
return true;
}
Serial.println("\nWiFi connection failed.");
WiFi.disconnect(false, false);
return false;
}
bool attemptNormalWiFiConnection() {
String storedSsid;
String storedPassword;
if (loadStoredCredentials(storedSsid, storedPassword)) {
Serial.println("Found stored Wi-Fi credentials.");
if (connectToWiFi(storedSsid, storedPassword, WIFI_CONNECT_TIMEOUT_MS)) {
return true;
}
Serial.println("Stored Wi-Fi credentials failed validation.");
} else {
Serial.println("No stored Wi-Fi credentials found.");
}
if (ENABLE_DEV_WIFI_FALLBACK) {
String devSsid = String(DEV_WIFI_SSID);
String devPassword = String(DEV_WIFI_PASSWORD);
devSsid.trim();
if (devSsid.length() > 0 && devPassword.length() > 0) {
if (connectToWiFi(devSsid, devPassword, WIFI_CONNECT_TIMEOUT_MS)) {
Serial.println("Connected with development fallback credentials.");
return true;
}
}
}
return false;
}
// ==========================================
// SETUP MODE + CAPTIVE PORTAL
// ==========================================
String getSetupApSsid() {
uint64_t chipId = ESP.getEfuseMac();
uint16_t suffix = (uint16_t)(chipId & 0xFFFF);
char buffer[32];
snprintf(buffer, sizeof(buffer), "Stickerbox-Setup-%04X", suffix);
return String(buffer);
}
String getSetupApPassword() {
return "icalziniditopolino";
}
bool isIpAddress(const String& str) {
for (size_t i = 0; i < str.length(); i++) {
char c = str.charAt(i);
if (c != '.' && !isDigit(c)) {
return false;
}
}
return true;
}
void handleSetupPortalRoutes() {
webServer.on("/", HTTP_GET, []() {
String html =
"<!doctype html><html><head><meta name='viewport' content='width=device-width,initial-scale=1'>"
"<title>Stickerbox Setup</title><style>body{font-family:Arial;margin:20px;}"
"input,button{font-size:16px;padding:10px;width:100%;margin:6px 0;}"
"small{color:#666;} .wrap{max-width:420px;margin:0 auto;}</style></head><body><div class='wrap'>"
"<h2>Stickerbox Wi-Fi Setup</h2>"
"<p>Inserisci le credenziali Wi-Fi del telefono/router.</p>"
"<form method='POST' action='/save'>"
"<label>SSID</label><input name='ssid' maxlength='32' required>"
"<label>Password</label><input name='password' type='password' maxlength='64' required>"
"<button type='submit'>Salva e verifica</button></form>"
"<small>L'ESP32 prova la connessione prima di salvare.</small></div></body></html>";
webServer.send(200, "text/html", html);
});
webServer.on("/save", HTTP_POST, []() {
String ssid = webServer.arg("ssid");
String password = webServer.arg("password");
ssid.trim();
if (ssid.length() == 0 || password.length() == 0) {
webServer.send(400, "text/plain", "SSID e password sono obbligatori.");
return;
}
pendingSsid = ssid;
pendingPassword = password;
setupCredentialsPending = true;
webServer.send(200, "text/html",
"<html><body><h3>Credenziali ricevute</h3>"
"<p>Verifica in corso... controlla lo schermo del dispositivo.</p>"
"</body></html>");
});
webServer.onNotFound([]() {
String host = webServer.hostHeader();
if (!isIpAddress(host)) {
webServer.sendHeader("Location", String("http://") + SETUP_AP_IP.toString(), true);
webServer.send(302, "text/plain", "Redirecting to captive portal");
return;
}
webServer.sendHeader("Location", "/", true);
webServer.send(302, "text/plain", "Redirecting to setup");
});
}
bool startSetupServices() {
if (setupServicesRunning) {
return true;
}
setupApSsid = getSetupApSsid();
setupApPassword = getSetupApPassword();
WiFi.mode(WIFI_AP_STA);
WiFi.softAPdisconnect(true);
delay(100);
WiFi.softAPConfig(SETUP_AP_IP, SETUP_AP_GATEWAY, SETUP_AP_SUBNET);
if (!WiFi.softAP(setupApSsid.c_str(), setupApPassword.c_str())) {
Serial.println("Failed to start setup AP.");
return false;
}
dnsServer.start(53, "*", SETUP_AP_IP);
handleSetupPortalRoutes();
webServer.begin();
setupServicesRunning = true;
setupStartedAtMs = millis();
setupCredentialsPending = false;
Serial.println("Setup mode active.");
Serial.print("AP SSID: ");
Serial.println(setupApSsid);
Serial.print("AP Password: ");
Serial.println(setupApPassword);
if (hasTFT) {
tft.fillScreen(TFT_BLACK);
tft.setTextSize(2);
tft.setTextColor(TFT_WHITE, TFT_BLACK);
tft.setCursor(0, 0);
tft.println("--- SETUP WIFI ---");
tft.println("\nConnetti telefono:");
tft.setTextColor(TFT_GREEN, TFT_BLACK);
tft.println(setupApSsid);
tft.setTextColor(TFT_WHITE, TFT_BLACK);
tft.println("\nPass:");
tft.setTextColor(TFT_GREEN, TFT_BLACK);
tft.println(setupApPassword);
tft.setTextColor(TFT_WHITE, TFT_BLACK);
tft.println("\nApri nel browser:");
tft.setTextColor(TFT_YELLOW, TFT_BLACK);
tft.println("4.3.2.1");
} else {
Serial.println("[SETUP AP] Schermo non presente. Collegati al Wi-Fi per configurare l'indirizzo 4.3.2.1 tramite browser.");
}
return true;
}
void stopSetupServices() {
if (!setupServicesRunning) {
return;
}
dnsServer.stop();
webServer.stop();
WiFi.softAPdisconnect(true);
WiFi.mode(WIFI_STA);
setupServicesRunning = false;
setupCredentialsPending = false;
pendingSsid = "";
pendingPassword = "";
}
void enterSetupMode(const char* reason) {
Serial.print("Entering setup mode: ");
Serial.println(reason);
if (!startSetupServices()) {
displayError("Setup Error", "AP non avviato");
currentState = STATE_IDLE;
return;
}
currentState = STATE_SETUP_PORTAL;
}
void processSetupPortal() {
if (!setupServicesRunning) {
if (!startSetupServices()) {
displayError("Setup Error", "Riprova reboot");
currentState = STATE_IDLE;
return;
}
}
dnsServer.processNextRequest();
webServer.handleClient();
if (setupCredentialsPending) {
currentState = STATE_SETUP_CONNECTING;
return;
}
if (millis() - setupStartedAtMs >= SETUP_TIMEOUT_MS) {
displayError("Setup Timeout", "Riprovo WiFi");
stopSetupServices();
if (attemptNormalWiFiConnection()) {
currentState = STATE_IDLE;
return;
}
enterSetupMode("Timeout fallback");
}
}
void processSetupConnection() {
String candidateSsid = pendingSsid;
String candidatePassword = pendingPassword;
setupCredentialsPending = false;
stopSetupServices();
displayStatus("Verifica\nWiFi...");
bool connected = connectToWiFi(candidateSsid, candidatePassword, WIFI_CONNECT_TIMEOUT_MS);
if (connected) {
if (saveStoredCredentials(candidateSsid, candidatePassword)) {
displayStatus("WiFi salvato\nOK");
delay(1200);
currentState = STATE_IDLE;
return;
}
displayError("NVS Error", "Salvataggio KO");
delay(1500);
enterSetupMode("NVS save failed");
return;
}
displayError("WiFi KO", "Riprova setup");
delay(1200);
enterSetupMode("Credential validation failed");
}
// ==========================================
// AUDIO RECORDING AND BUFFERING
// ==========================================
bool startRecording() {
Serial.println("\n>>> Starting Audio Capture via I2S INMP441...");
recordedBytes = 0;
// Verifichiamo la memoria libera
uint32_t freeHeapBefore = ESP.getFreeHeap();
uint32_t maxAllocHeap = ESP.getMaxAllocHeap();
Serial.printf("[MEMORIA] Heap libero iniziale: %u byte, Blocco contiguo massimo allocabile: %u byte.\n", freeHeapBefore, maxAllocHeap);
// Verifichiamo la validità del buffer pre-allocato
if (audioBuffer == nullptr) {
Serial.println("Fatal: Buffer audio globale non pre-allocato!");
displayError("SRAM Error", "Buffer assente");
return false;
}
// Clear memory buffer
memset(audioBuffer, 0, MAX_AUDIO_SIZE);
Serial.println("[MEMORIA] Buffer audio pre-allocato ripulito e pronto.");
i2s_start(I2S_PORT);
return true;
}
void recordAudio() {
if (audioBuffer == nullptr) return;
size_t bytesRead = 0;
// Read chunk directly from hardware I2S registers
esp_err_t err = i2s_read(I2S_PORT,
audioBuffer + recordedBytes,
I2S_CHUNK_SIZE,
&bytesRead,
portMAX_DELAY);
if (err == ESP_OK && bytesRead > 0) {
recordedBytes += bytesRead;
}
}
void stopRecording() {
i2s_stop(I2S_PORT);
Serial.printf(">>> Audio Capture finished. Total captured bytes: %d\n", recordedBytes);
}
// ==========================================
// ASYNC JOB HTTP CLIENT FUNCTIONS
// ==========================================
bool uploadAudioAndGetJob() {
if (audioBuffer == nullptr || recordedBytes == 0) {
displayError("Errore Audio", "Nessun dato");
return false;
}
if (WiFi.status() != WL_CONNECTED) {
Serial.println("Wi-Fi disconnected. Attempting reconnect...");
if (!attemptNormalWiFiConnection()) {
displayError("WiFi Error", "Apri setup");
enterSetupMode("WiFi disconnected");
return false;
}
}
WiFiClientSecure client;
client.setInsecure();
Serial.printf("[UPLOAD] Connecting to %s:%d\n", serverHost, serverPort);
if (!client.connect(serverHost, serverPort)) {
Serial.println("[UPLOAD] Connection failed.");
displayError("Errore Server", "Offline");
return false;
}
String boundary = "----StickerboxBoundary123456789";
String head = "--" + boundary + "\r\n" +
"Content-Disposition: form-data; name=\"audio\"; filename=\"audio.wav\"\r\n" +
"Content-Type: audio/wav\r\n\r\n";
String tail = "\r\n--" + boundary + "--\r\n";
uint32_t totalLength = head.length() + 44 + recordedBytes + tail.length();
client.print(String("POST /upload HTTP/1.1\r\n") +
"Host: " + serverHost + ":" + String(serverPort) + "\r\n" +
"Content-Type: multipart/form-data; boundary=" + boundary + "\r\n" +
"Content-Length: " + String(totalLength) + "\r\n" +
"Connection: close\r\n\r\n");
client.print(head);
uint8_t wavHeader[44];
writeWavHeader(wavHeader, recordedBytes);
client.write(wavHeader, sizeof(wavHeader));
size_t bytesSent = 0;
while (bytesSent < recordedBytes) {
size_t chunkWrite = min((size_t)1024, (size_t)(recordedBytes - bytesSent));
client.write(audioBuffer + bytesSent, chunkWrite);
bytesSent += chunkWrite;
}
client.print(tail);
client.flush();
Serial.println("[UPLOAD] Audio sent. Reading response...");
boolean okStatus = false;
while (client.connected()) {
String line = client.readStringUntil('\n');
if (line.startsWith("HTTP/1.1 200") || line.startsWith("HTTP/1.1 201")) {
okStatus = true;
}
if (line == "\r" || line.length() <= 0) {
break;
}
}
if (!okStatus) {
Serial.println("[UPLOAD] Server returned bad status.");
displayError("Errore API", "Risposta KO");
client.stop();
return false;
}
String jsonBody;
while (client.connected() || client.available()) {
if (client.available()) {
jsonBody += client.readString();
}
delay(5);
}
client.stop();
Serial.printf("[UPLOAD] Response body: %s\n", jsonBody.c_str());
StaticJsonDocument<256> doc;
DeserializationError error = deserializeJson(doc, jsonBody);
if (error) {
Serial.printf("[UPLOAD] JSON parse failed: %s\n", error.c_str());
displayError("Errore", "JSON KO");
return false;
}
const char* jobId = doc["jobId"];
if (!jobId || strlen(jobId) == 0) {
Serial.println("[UPLOAD] No jobId in response.");
displayError("Errore", "No jobId");
return false;
}
currentJobId = String(jobId);
Serial.printf("[UPLOAD] Job created: %s\n", currentJobId.c_str());
return true;
}
bool pollJobStatus(String& status, int& progress, String& reason, String& transcription) {
if (currentJobId.isEmpty()) return false;
WiFiClientSecure client;
client.setInsecure();
client.setTimeout(5);
if (!client.connect(serverHost, serverPort)) {
Serial.println("[POLL] Connection failed.");
return false;
}
String path = "/job/" + currentJobId + "/status";
client.print(String("GET ") + path + " HTTP/1.1\r\n" +
"Host: " + serverHost + "\r\n" +
"Connection: close\r\n\r\n");
boolean okStatus = false;
while (client.connected()) {
String line = client.readStringUntil('\n');
if (line.startsWith("HTTP/1.1 200")) {
okStatus = true;
}
if (line == "\r" || line.length() <= 0) {
break;
}
}
if (!okStatus) {
client.stop();
return false;
}
String jsonBody;
while (client.connected() || client.available()) {
if (client.available()) {
jsonBody += client.readString();
}
delay(5);
}
client.stop();
StaticJsonDocument<512> doc;
DeserializationError error = deserializeJson(doc, jsonBody);
if (error) {
return false;
}
status = doc["status"] | "";
progress = doc["progress"] | 0;
reason = doc["reason"] | "";
transcription = doc["transcription"] | "";
return true;
}
bool fetchJobResult() {
if (currentJobId.isEmpty()) return false;
WiFiClientSecure client;
client.setInsecure();
if (!client.connect(serverHost, serverPort)) {
Serial.println("[RESULT] Connection failed.");
return false;
}
String path = "/job/" + currentJobId + "/result";
client.print(String("GET ") + path + " HTTP/1.1\r\n" +
"Host: " + serverHost + "\r\n" +
"Connection: close\r\n\r\n");
boolean okStatus = false;
while (client.connected()) {
String line = client.readStringUntil('\n');
if (line.startsWith("HTTP/1.1 200")) {
okStatus = true;
}
if (line == "\r" || line.length() <= 0) {
break;
}
}
if (!okStatus) {
client.stop();
return false;
}
uint8_t sizeBytes[4];
size_t bytesRead = client.readBytes(sizeBytes, 4);
if (bytesRead != 4) {
client.stop();
return false;
}
uint32_t displayLength = ((uint32_t)sizeBytes[0] << 24) |
((uint32_t)sizeBytes[1] << 16) |
((uint32_t)sizeBytes[2] << 8) |
((uint32_t)sizeBytes[3]);
if (displayLength > MAX_DISPLAY_JPG_SIZE || displayLength == 0) {
Serial.printf("[RESULT] Lunghezza JPEG display non valida o troppo grande: %u byte\n", displayLength);
client.stop();
return false;
}
uint8_t* jpgBuf = (uint8_t*)malloc(displayLength);
if (jpgBuf == nullptr) {
Serial.println("[RESULT] Errore allocazione buffer JPEG temporaneo!");
client.stop();
return false;
}
size_t jpgReadBytes = client.readBytes(jpgBuf, displayLength);
if (jpgReadBytes != displayLength) {
Serial.println("[RESULT] Ricezione JPEG incompleta!");
free(jpgBuf);
client.stop();
return false;
}
if (hasTFT) {
tft.fillScreen(TFT_BLACK);
TJpgDec.drawJpg(0, 0, jpgBuf, displayLength);
} else {
Serial.println("[GRAPHICS] Disegno rendering JPEG TFT saltato.");
}
free(jpgBuf);
uint8_t printBuffer[128];
uint32_t totalPrintBytesSpooled = 0;
while (client.connected() || client.available()) {
int avail = client.available();
if (avail > 0) {
size_t chunkSz = client.readBytes(printBuffer, min((size_t)avail, sizeof(printBuffer)));
if (chunkSz > 0) {
Serial2.write(printBuffer, chunkSz);
totalPrintBytesSpooled += chunkSz;
}
}
delay(5);
}
Serial.printf("[RESULT] Printed %d bytes\n", totalPrintBytesSpooled);
client.stop();
return true;
}
void sendCancelRequest() {
if (currentJobId.isEmpty()) return;
WiFiClientSecure client;
client.setInsecure();
client.setTimeout(5);
if (!client.connect(serverHost, serverPort)) {
Serial.println("[CANCEL] Connection failed.");
return;
}
String path = "/job/" + currentJobId + "/cancel";
client.print(String("POST ") + path + " HTTP/1.1\r\n" +
"Host: " + serverHost + "\r\n" +
"Content-Length: 0\r\n" +
"Connection: close\r\n\r\n");
client.stop();
Serial.println("[CANCEL] Cancel request sent.");
}
void displayProgress(const char* text, int progressPercent) {
static String lastText = "";
static int lastProgress = -1;
String currentText = String(text);
if (currentText == lastText && progressPercent == lastProgress) {
return;
}
lastText = currentText;
lastProgress = progressPercent;
Serial.printf("[DISPLAY] %s (%d%%)\n", text, progressPercent);
if (!hasTFT) return;
tft.fillScreen(TFT_BLACK);
// Adaptive font sizing: size 2 for short text, size 1 for long text
int textSize = (currentText.length() <= 40) ? 2 : 1;
tft.setTextSize(textSize);
tft.setTextColor(TFT_WHITE, TFT_BLACK);
tft.setTextWrap(true);
tft.setCursor(10, 40);
tft.print(text);
int barY = SCREEN_HEIGHT - 40;
int barWidth = SCREEN_WIDTH - 20;
int barHeight = 15;
int fillWidth = (barWidth * constrain(progressPercent, 0, 100)) / 100;
tft.drawRect(10, barY, barWidth, barHeight, TFT_WHITE);
if (fillWidth > 0) {
tft.fillRect(10, barY, fillWidth, barHeight, TFT_GREEN);
}
}
// ==========================================
// AUXILIARY HELPER DEFINITIONS
// ==========================================
void writeWavHeader(uint8_t* header, uint32_t wavDataSize) {
uint32_t totalFileSize = wavDataSize + 36;
uint32_t sampleRate = 16000;
uint32_t byteRate = sampleRate * 1 * 2; // sampleRate * numChannels * bitsPerSample/8
uint16_t blockAlign = 1 * 2; // numChannels * bitsPerSample/8
uint16_t bitsPerSample = 16;
header[0] = 'R'; header[1] = 'I'; header[2] = 'F'; header[3] = 'F';
header[4] = (totalFileSize & 0xff);
header[5] = ((totalFileSize >> 8) & 0xff);
header[6] = ((totalFileSize >> 16) & 0xff);
header[7] = ((totalFileSize >> 24) & 0xff);
header[8] = 'W'; header[9] = 'A'; header[10] = 'V'; header[11] = 'E';
header[12] = 'f'; header[13] = 'm'; header[14] = 't'; header[15] = ' ';
header[16] = 16; header[17] = 0; header[18] = 0; header[19] = 0; // Subchunk1Size
header[20] = 1; header[21] = 0; // AudioFormat (PCM=1)
header[22] = 1; header[23] = 0; // NumChannels (Mono=1)
header[24] = (sampleRate & 0xff);
header[25] = ((sampleRate >> 8) & 0xff);
header[26] = ((sampleRate >> 16) & 0xff);
header[27] = ((sampleRate >> 24) & 0xff);
header[28] = (byteRate & 0xff);
header[29] = ((byteRate >> 8) & 0xff);
header[30] = ((byteRate >> 16) & 0xff);
header[31] = ((byteRate >> 24) & 0xff);
header[32] = (blockAlign & 0xff);
header[33] = ((blockAlign >> 8) & 0xff);
header[34] = 16; header[35] = 0; // BitsPerSample (16)
header[36] = 'd'; header[37] = 'a'; header[38] = 't'; header[39] = 'a';
header[40] = (wavDataSize & 0xff);
header[41] = ((wavDataSize >> 8) & 0xff);
header[42] = ((wavDataSize >> 16) & 0xff);
header[43] = ((wavDataSize >> 24) & 0xff);
}
void displayStatus(const char* text) {
static String lastText = "";
String currentText = String(text);
if (currentText == lastText) {
return;
}
lastText = currentText;
Serial.printf("[DISPLAY STATUS] Stato Schermo: %s\n", text);
if (!hasTFT) {
return;
}
tft.fillScreen(TFT_BLACK);
tft.setTextSize(3); // Bel testo grande per gli stati in attesa
tft.setTextColor(TFT_CYAN, TFT_BLACK);
tft.setCursor(10, 80);
tft.print(text);
}
void displayError(const char* title, const char* details) {
Serial.printf("[DISPLAY ERROR] %s - %s\n", title, details);
if (!hasTFT) {
return;
}
tft.fillScreen(TFT_BLACK);
tft.setTextSize(2);
tft.setTextColor(TFT_RED, TFT_BLACK);
tft.setCursor(10, 20);
tft.println("--- ERRORE ---");
tft.println("");
tft.setTextSize(3);
tft.setTextColor(TFT_WHITE, TFT_BLACK);
tft.println(title);
tft.println("");
tft.setTextSize(2);
tft.setTextColor(TFT_LIGHTGREY, TFT_BLACK);
tft.println(details);
}