/*==========================================================================================
* WIFE RADIO v5.0.0 — Интернет-радио для ESP32-MAX-V3.0
* Версия: 5.0.0-FINAL (исправленная для Arduino IDE 1.8.19 + ESP32 Core 3.x)
*
* ИСПРАВЛЕНИЯ v5.0:
* [1] Мьютексы для синхронизации между ядрами (FreeRTOS)
* [2] Убрано дублирование server.handleClient() — только на ядре 0
* [3] Неблокирующий стриминг через state machine на ядре 1
* [4] Убраны русские символы из F() (не поддерживается PROGMEM)
* [5] Буфер стриминга увеличен до 1024 байт
* [6] Парсинг ICY MetaData (StreamTitle)
* [7] Watchdog Timer (WDT) с автоперезагрузкой
* [8] Частичное обновление дисплея (без fillScreen)
* [9] NTP обновление раз в 60 секунд
* [10] Безопасное хранение WiFi-сетей
* [11] CORS + JSON API endpoints
* [12] Debounce энкодера 50 мс
* [13] Ограничение записи 5 минут + таймаут
* [14] HTTP timeout 5 секунд
* [15] Исправлены все приведения типов
* [16] Graceful shutdown (stopAll)
* [17] Размер стека задач увеличен до 8192
* [18] Мониторинг heap каждые 30 сек
* [19] Защита массивов станций от переполнения
* [20] JSON API endpoints
* [21] Исправлен Watchdog Timer для ESP32 Core 3.x (правильный API)
* [22] Загрузка станций из SD карты (/stations.txt)
* [23] Веб-страница управления станциями + скачивание файла
*
* ОБОРУДОВАНИЕ:
* - ESP32-MAX-V3.0
* - VS1053 MP3 Shield (с встроенным TF слотом)
* - ST7735 TFT Display (1.8")
* - Rotary Encoder
* - TCA9548A I2C Multiplexer
* - FRAM MB85 (через TCA9548A канал 0)
* - SD Card (встроенная в VS1053)
*
* РАСПИНОВКА:
* VS1053:
* CS → GPIO 5 (Command Select)
* XDCS → GPIO 17 (Data Select)
* DREQ → GPIO 16 (Data Request)
* RST → GPIO 4 (Reset)
* SDCS → GPIO 25 (SD Card Select - встроенный TF слот!)
* MOSI → GPIO 23
* MISO → GPIO 19
* SCLK → GPIO 18
* VCC → 5V (НЕ 3.3V!)
* ROUT → Аудио R
* LOUT → Аудио L
*
* TFT ST7735:
* CS → GPIO 33
* DC → GPIO 32
* RST → GPIO 0
* MOSI → GPIO 23
* SCK → GPIO 18
* VCC → 3.3V
*
* Rotary Encoder:
* CLK → GPIO 34
* DT → GPIO 35
* SW → GPIO 36
*
* I2C (TCA9548A + FRAM):
* SDA → GPIO 21
* SCL → GPIO 22
* TCA9548A: A0-A2 → GND (адрес 0x70)
* FRAM MB85: A0-A2 → GND (адрес 0x50), подключена к TCA9548A канал 0
*
* Совместимость: Arduino IDE 1.8.19+, ESP32 Arduino Core 2.x/3.x
*========================================================================================*/
#include <Arduino.h>
#include <Adafruit_GFX.h>
#include <Adafruit_ST7735.h>
#include <SPI.h>
#include <Wire.h>
#include <WiFi.h>
#include <WebServer.h>
#include <ESPmDNS.h>
#include <ArduinoOTA.h>
#include <NTPClient.h>
#include <WiFiUdp.h>
#include <SD.h>
#include <Adafruit_VS1053.h>
#include <freertos/FreeRTOS.h>
#include <freertos/task.h>
#include <freertos/semphr.h>
#include <esp_task_wdt.h>
// ==========================================================================================
// КОНФИГУРАЦИЯ ПИНОВ (ESP32-MAX-V3.0)
// ==========================================================================================
// VS1053 MP3 Shield
#define VS1053_CS 5 // CS - Command Select
#define VS1053_DCS 17 // XDCS - Data Select (аудиоданные)
#define VS1053_DREQ 16 // DREQ - Data Request (готовность)
#define VS1053_RESET 4 // RST - Reset
#define SD_CS 25 // SDCS - SD Card Select (встроенный TF слот на VS1053!)
// TFT ST7735 Display
#define TFT_CS 33
#define TFT_DC 32
#define TFT_RST 0
// Rotary Encoder
#define ENCODER_CLK 34
#define ENCODER_DT 35
#define ENCODER_SW 36
// I2C Bus
#define I2C_SDA 21
#define I2C_SCL 22
#define TCA9548A_ADDR 0x70
#define FRAM_ADDR 0x50
// ==========================================================================================
// КОНСТАНТЫ И ПАРАМЕТРЫ
// ==========================================================================================
#define MAX_STATIONS 10
#define MAX_WIFI_NETWORKS 4
#define STREAM_BUFFER_SIZE 1024
#define ENCODER_DEBOUNCE 50 // ms
#define DISPLAY_UPDATE_MS 500 // ms
#define NTP_UPDATE_MS 60000 // 60 sec
#define WIFI_RECONNECT_MS 5000
#define HTTP_TIMEOUT_MS 5000
#define HEAP_CHECK_MS 30000
#define HEAP_MIN_FREE 10000 // bytes
#define RECORD_MAX_SEC 300 // 5 min max
#define WEB_TASK_STACK 8192
#define STREAM_TASK_STACK 8192
#define WDT_TIMEOUT_S 30 // seconds
// ==========================================================================================
// СТРУКТУРЫ ДАННЫХ
// ==========================================================================================
struct WiFiNetwork {
const char* ssid;
const char* password;
};
struct Station {
char name[32];
char url[128];
};
// ==========================================================================================
// Wi-Fi СЕТИ
// ==========================================================================================
const WiFiNetwork wifiNetworks[MAX_WIFI_NETWORKS] = {
{"Keenetic-6391", "U7Y6hMeB"},
{"ASUS", "as485127sav"},
{"Galaxy_A12", "sang7164"},
{"MobileHotspot", "12345678"}
};
// ==========================================================================================
// РАДИОСТАНЦИИ (по умолчанию, если stations.txt не найден)
// ==========================================================================================
Station stations[MAX_STATIONS] = {
{"Radio Jazz", "http://jazz-wr06.ice.infomaniak.ch/jazz-wr06-128.mp3"},
{"Classic Rock", "http://listen.181fm.com/181-classicrock_128k.mp3"},
{"BBC World", "http://stream.live.vc.bbcmedia.co.uk/bbc_world_service"},
{"Vesti FM", "http://icecast.vgtrk.cdnvideo.ru/vestifm_mp3_128kbps"},
{"Mayak", "http://icecast.vgtrk.cdnvideo.ru/mayakfm_mp3_128kbps"},
{"", ""}, {"", ""}, {"", ""}, {"", ""}, {"", ""}
};
// ==========================================================================================
// ГЛОБАЛЬНЫЕ ОБЪЕКТЫ
// ==========================================================================================
Adafruit_ST7735 tft = Adafruit_ST7735(TFT_CS, TFT_DC, TFT_RST);
Adafruit_VS1053_FilePlayer player = Adafruit_VS1053_FilePlayer(
VS1053_RESET, VS1053_CS, VS1053_DCS, VS1053_DREQ
);
WebServer server(80);
WiFiUDP ntpUDP;
NTPClient timeClient(ntpUDP, "pool.ntp.org", 10800); // UTC+3
// ==========================================================================================
// ГЛОБАЛЬНЫЕ ПЕРЕМЕННЫЕ СОСТОЯНИЯ (защищены мьютексами!)
// ==========================================================================================
bool vs1053Available = false;
bool sdCardAvailable = false;
volatile uint8_t currentStation = 0;
volatile uint8_t stationCount = 5;
volatile uint8_t volume = 50;
volatile bool isPlaying = false;
volatile bool isRecording = false;
char connectedSSID[33] = "Not connected";
char currentMetadata[64] = "Waiting...";
volatile uint32_t lastWifiReconnectAttempt = 0;
volatile uint32_t lastSettingsChange = 0;
volatile bool settingsChanged = false;
char dispStation[21] = "";
char dispMeta[23] = "";
char dispSSID[16] = "";
char dispTime[9] = "00:00:00";
int16_t dispRSSI = 0;
uint8_t dispVolume = 0;
bool dispPlaying = false;
bool dispRecording = false;
TaskHandle_t webServerTaskHandle = NULL;
TaskHandle_t streamTaskHandle = NULL;
SemaphoreHandle_t mutexSettings = NULL;
SemaphoreHandle_t mutexPlayback = NULL;
SemaphoreHandle_t mutexDisplay = NULL;
SemaphoreHandle_t mutexWiFi = NULL;
volatile bool streamActive = false;
WiFiClient streamClient;
File currentFile;
volatile int32_t encoderPos = 0;
volatile uint32_t lastEncoderTick = 0;
volatile uint32_t lastButtonPress = 0;
bool wdtEnabled = false;
// ==========================================================================================
// ПРОТОТИПЫ ФУНКЦИЙ
// ==========================================================================================
void selectTCA9548AChannel(uint8_t channel);
bool framWriteBytes(uint16_t addr, const uint8_t* data, uint8_t len);
bool framReadBytes(uint16_t addr, uint8_t* data, uint8_t len);
void saveSettingsToFRAM();
void loadSettingsFromFRAM();
void connectToBestWiFi();
void loadStationsFromSD();
void startStreamTask(uint8_t stationIdx);
void stopStreamTask();
void streamTask(void *pvParameters);
void playSDFile(const char* filename);
void startRecording();
void stopAll();
void setVS1053Volume(uint8_t vol);
bool waitForDREQ(uint32_t timeout);
void handleEncoder();
void updateDisplay();
void updateDisplayPartial();
void setupWebServer();
void webServerTask(void *pvParameters);
void safeStrncpy(char* dst, const char* src, size_t dstSize);
void checkHeap();
void resetWatchdog();
// ==========================================================================================
// SETUP
// ==========================================================================================
void setup() {
Serial.begin(115200);
delay(500);
Serial.println("\n\n=== [WIFE RADIO v5.0.0 - ESP32-MAX-V3.0] ===");
Serial.println("Version: FINAL (Arduino IDE 1.8.19 + Core 3.x)");
mutexSettings = xSemaphoreCreateMutex();
mutexPlayback = xSemaphoreCreateMutex();
mutexDisplay = xSemaphoreCreateMutex();
mutexWiFi = xSemaphoreCreateMutex();
if (!mutexSettings || !mutexPlayback || !mutexDisplay || !mutexWiFi) {
Serial.println("!! Mutex creation error!");
while (1) { delay(1000); }
}
Serial.println(" Mutexes: OK");
esp_task_wdt_config_t twdt_config = {
.timeout_ms = WDT_TIMEOUT_S * 1000,
.idle_core_mask = (1 << portNUM_PROCESSORS) - 1,
.trigger_panic = true,
};
if (esp_task_wdt_init(&twdt_config) == ESP_OK) {
esp_task_wdt_add(NULL);
wdtEnabled = true;
Serial.println(" Watchdog Timer: OK");
} else {
Serial.println(" Watchdog Timer: ERROR (non-critical)");
}
tft.initR(INITR_BLACKTAB);
tft.setRotation(3);
tft.fillScreen(ST7735_BLACK);
tft.setTextColor(ST7735_YELLOW);
tft.setTextSize(1);
tft.setCursor(0, 0);
tft.println("Loading...");
tft.println("v5.0.0-FINAL");
pinMode(ENCODER_CLK, INPUT);
pinMode(ENCODER_DT, INPUT);
pinMode(ENCODER_SW, INPUT_PULLUP);
SPI.begin();
if (player.begin()) {
vs1053Available = true;
setVS1053Volume(volume);
Serial.println(" VS1053: OK");
} else {
vs1053Available = false;
Serial.println(" VS1053: NOT FOUND");
}
if (SD.begin(SD_CS)) {
sdCardAvailable = true;
Serial.println(" SD Card (VS1053 TF slot): OK");
loadStationsFromSD();
} else {
sdCardAvailable = false;
Serial.println(" SD Card: NOT FOUND");
}
Wire.begin(I2C_SDA, I2C_SCL);
Wire.setClock(400000);
selectTCA9548AChannel(0);
Wire.beginTransmission(FRAM_ADDR);
if (Wire.endTransmission() == 0) {
Serial.println(" FRAM MB85: OK");
loadSettingsFromFRAM();
} else {
Serial.println(" FRAM: NOT FOUND");
}
connectToBestWiFi();
timeClient.begin();
setupWebServer();
ArduinoOTA.setHostname("wife-radio");
ArduinoOTA.onStart([]() {
stopAll();
tft.fillScreen(ST7735_RED);
tft.setTextColor(ST7735_WHITE);
tft.setCursor(0, 0);
tft.println("OTA Update...");
});
ArduinoOTA.begin();
Serial.println(" OTA: OK");
if (MDNS.begin("wife-radio")) {
MDNS.addService("http", "tcp", 80);
Serial.println(" mDNS: http://wife-radio.local");
}
xTaskCreatePinnedToCore(
webServerTask, "WebServer",
WEB_TASK_STACK, NULL, 1,
&webServerTaskHandle, 0
);
Serial.println("\n=== RADIO v5.0.0 READY ===");
Serial.printf("Heap: %u bytes\n", ESP.getFreeHeap());
updateDisplay();
resetWatchdog();
}
// ==========================================================================================
// LOOP
// ==========================================================================================
void loop() {
static uint32_t lastDisplayUpdate = 0;
static uint32_t lastNTPUpdate = 0;
static uint32_t lastHeapCheck = 0;
resetWatchdog();
ArduinoOTA.handle();
handleEncoder();
if (millis() - lastNTPUpdate > NTP_UPDATE_MS) {
timeClient.update();
lastNTPUpdate = millis();
}
if (WiFi.status() != WL_CONNECTED &&
(millis() - lastWifiReconnectAttempt > WIFI_RECONNECT_MS)) {
if (xSemaphoreTake(mutexWiFi, pdMS_TO_TICKS(100)) == pdTRUE) {
Serial.println(" WiFi lost, reconnecting...");
connectToBestWiFi();
lastWifiReconnectAttempt = millis();
xSemaphoreGive(mutexWiFi);
}
}
if (settingsChanged && (millis() - lastSettingsChange > 1000)) {
if (xSemaphoreTake(mutexSettings, pdMS_TO_TICKS(100)) == pdTRUE) {
saveSettingsToFRAM();
settingsChanged = false;
xSemaphoreGive(mutexSettings);
}
}
if (millis() - lastDisplayUpdate > DISPLAY_UPDATE_MS) {
updateDisplayPartial();
lastDisplayUpdate = millis();
}
if (millis() - lastHeapCheck > HEAP_CHECK_MS) {
checkHeap();
lastHeapCheck = millis();
}
vTaskDelay(pdMS_TO_TICKS(10));
}
// ==========================================================================================
// УТИЛИТЫ
// ==========================================================================================
void safeStrncpy(char* dst, const char* src, size_t dstSize) {
if (dstSize == 0) return;
size_t i;
for (i = 0; i < dstSize - 1 && src[i] != '\0'; i++) {
dst[i] = src[i];
}
dst[i] = '\0';
}
void resetWatchdog() {
if (wdtEnabled) {
esp_task_wdt_reset();
}
}
void checkHeap() {
uint32_t freeHeap = ESP.getFreeHeap();
if (freeHeap < HEAP_MIN_FREE) {
Serial.printf("!! LOW HEAP: %u bytes\n", freeHeap);
}
}
// ==========================================================================================
// I2C / TCA9548A / FRAM
// ==========================================================================================
void selectTCA9548AChannel(uint8_t channel) {
if (channel > 7) return;
Wire.beginTransmission(TCA9548A_ADDR);
Wire.write(1 << channel);
Wire.endTransmission();
delay(1);
}
bool framWriteBytes(uint16_t addr, const uint8_t* data, uint8_t len) {
selectTCA9548AChannel(0);
Wire.beginTransmission(FRAM_ADDR);
Wire.write((addr >> 8) & 0xFF);
Wire.write(addr & 0xFF);
for (uint8_t i = 0; i < len; i++) {
Wire.write(data[i]);
}
return (Wire.endTransmission() == 0);
}
bool framReadBytes(uint16_t addr, uint8_t* data, uint8_t len) {
selectTCA9548AChannel(0);
Wire.beginTransmission(FRAM_ADDR);
Wire.write((addr >> 8) & 0xFF);
Wire.write(addr & 0xFF);
if (Wire.endTransmission() != 0) return false;
Wire.requestFrom((uint8_t)FRAM_ADDR, (uint8_t)len);
uint8_t i = 0;
while (Wire.available() && i < len) {
data[i++] = Wire.read();
}
return (i == len);
}
void saveSettingsToFRAM() {
uint8_t data[2] = {currentStation, volume};
if (framWriteBytes(0, data, 2)) {
Serial.println(" Settings saved to FRAM");
} else {
Serial.println(" FRAM write error");
}
}
void loadSettingsFromFRAM() {
uint8_t data[2];
if (framReadBytes(0, data, 2)) {
if (xSemaphoreTake(mutexSettings, pdMS_TO_TICKS(100)) == pdTRUE) {
currentStation = data[0];
volume = data[1];
if (currentStation >= stationCount) currentStation = 0;
if (volume > 100 || volume == 0) volume = 50;
xSemaphoreGive(mutexSettings);
}
Serial.printf(" Loaded: station %d, volume %d%%\n", currentStation, volume);
if (vs1053Available) setVS1053Volume(volume);
}
}
void setVS1053Volume(uint8_t vol) {
uint8_t vsVolume = 100 - vol;
if (vsVolume > 100) vsVolume = 0;
player.setVolume(vsVolume, vsVolume);
Serial.printf(" Volume: %d%%\n", vol);
}
// ==========================================================================================
// Wi-Fi
// ==========================================================================================
void connectToBestWiFi() {
WiFi.disconnect();
delay(100);
WiFi.mode(WIFI_STA);
Serial.println(" Scanning WiFi...");
int n = WiFi.scanNetworks();
if (n <= 0) {
safeStrncpy(connectedSSID, "No networks", sizeof(connectedSSID));
return;
}
int bestRSSI = -100;
uint8_t bestIndex = 0;
bool found = false;
for (int i = 0; i < n; i++) {
String scanned = WiFi.SSID(i);
int rssi = WiFi.RSSI(i);
for (uint8_t j = 0; j < MAX_WIFI_NETWORKS; j++) {
if (scanned == wifiNetworks[j].ssid && rssi > bestRSSI) {
bestRSSI = rssi;
bestIndex = j;
found = true;
}
}
}
if (!found) {
safeStrncpy(connectedSSID, "Network not found", sizeof(connectedSSID));
return;
}
Serial.printf(" Connecting to %s...\n", wifiNetworks[bestIndex].ssid);
WiFi.begin(wifiNetworks[bestIndex].ssid, wifiNetworks[bestIndex].password);
uint8_t attempts = 0;
while (WiFi.status() != WL_CONNECTED && attempts++ < 30) {
delay(100);
yield();
}
if (WiFi.status() == WL_CONNECTED) {
safeStrncpy(connectedSSID, wifiNetworks[bestIndex].ssid, sizeof(connectedSSID));
Serial.printf(" Connected! IP: %s\n", WiFi.localIP().toString().c_str());
} else {
safeStrncpy(connectedSSID, "Connection error", sizeof(connectedSSID));
}
}
// ==========================================================================================
// SD CARD - Загрузка станций
// ==========================================================================================
void loadStationsFromSD() {
if (!sdCardAvailable) {
Serial.println(" SD not available, using default stations");
return;
}
File file = SD.open("/stations.txt");
if (!file) {
Serial.println(" stations.txt not found, using default stations");
return;
}
uint8_t loadedCount = 0;
while (file.available() && loadedCount < MAX_STATIONS) {
String line = file.readStringUntil('\n');
line.trim();
if (line.length() == 0 || line.startsWith("#")) continue;
int sep = line.indexOf('|');
if (sep > 0) {
String name = line.substring(0, sep);
String url = line.substring(sep + 1);
name.trim();
url.trim();
if (name.length() > 0 && url.length() > 0) {
safeStrncpy(stations[loadedCount].name, name.c_str(), sizeof(stations[loadedCount].name));
safeStrncpy(stations[loadedCount].url, url.c_str(), sizeof(stations[loadedCount].url));
loadedCount++;
}
}
}
file.close();
if (loadedCount > 0) {
stationCount = loadedCount;
Serial.printf(" Loaded %d stations from /stations.txt\n", stationCount);
} else {
Serial.println(" No valid stations in file, using defaults");
}
}
// ==========================================================================================
// СТРИМИНГ
// ==========================================================================================
void startStreamTask(uint8_t stationIdx) {
stopStreamTask();
delay(50);
if (stationIdx >= stationCount || WiFi.status() != WL_CONNECTED) {
if (xSemaphoreTake(mutexPlayback, pdMS_TO_TICKS(100)) == pdTRUE) {
safeStrncpy(currentMetadata, "Error", sizeof(currentMetadata));
xSemaphoreGive(mutexPlayback);
}
return;
}
if (xSemaphoreTake(mutexSettings, pdMS_TO_TICKS(100)) == pdTRUE) {
currentStation = stationIdx;
xSemaphoreGive(mutexSettings);
}
streamActive = true;
if (xSemaphoreTake(mutexPlayback, pdMS_TO_TICKS(100)) == pdTRUE) {
safeStrncpy(currentMetadata, "Connecting...", sizeof(currentMetadata));
xSemaphoreGive(mutexPlayback);
}
xTaskCreatePinnedToCore(
streamTask, "Stream",
STREAM_TASK_STACK, (void*)(uint32_t)stationIdx, 2,
&streamTaskHandle, 1
);
}
void stopStreamTask() {
streamActive = false;
if (streamTaskHandle != NULL) {
vTaskDelay(pdMS_TO_TICKS(100));
vTaskDelete(streamTaskHandle);
streamTaskHandle = NULL;
}
if (streamClient.connected()) {
streamClient.stop();
}
if (xSemaphoreTake(mutexPlayback, pdMS_TO_TICKS(100)) == pdTRUE) {
isPlaying = false;
xSemaphoreGive(mutexPlayback);
}
}
void streamTask(void *pvParameters) {
uint8_t stationIdx = (uint8_t)(uint32_t)pvParameters;
if (stationIdx >= stationCount) {
vTaskDelete(NULL);
return;
}
const char* url = stations[stationIdx].url;
if (strncmp(url, "http://", 7) != 0) {
if (xSemaphoreTake(mutexPlayback, pdMS_TO_TICKS(100)) == pdTRUE) {
safeStrncpy(currentMetadata, "Invalid URL", sizeof(currentMetadata));
xSemaphoreGive(mutexPlayback);
}
vTaskDelete(NULL);
return;
}
const char* hostStart = url + 7;
const char* path = strchr(hostStart, '/');
if (!path) path = hostStart + strlen(hostStart);
char hostBuf[64];
size_t hostLen = min((size_t)(path - hostStart), sizeof(hostBuf) - 1);
strncpy(hostBuf, hostStart, hostLen);
hostBuf[hostLen] = '\0';
streamClient.setTimeout(HTTP_TIMEOUT_MS / 1000);
if (!streamClient.connect(hostBuf, 80)) {
if (xSemaphoreTake(mutexPlayback, pdMS_TO_TICKS(100)) == pdTRUE) {
safeStrncpy(currentMetadata, "Connection error", sizeof(currentMetadata));
xSemaphoreGive(mutexPlayback);
}
vTaskDelete(NULL);
return;
}
streamClient.print(String("GET ") + (path[0] ? path : "/") + " HTTP/1.1\r\n"
"Host: " + hostBuf + "\r\n"
"Icy-MetaData: 1\r\n"
"Connection: close\r\n\r\n");
int icyMetaInt = 0;
String stationName = "";
uint32_t headerTimeout = millis() + HTTP_TIMEOUT_MS;
while (streamClient.connected() && millis() < headerTimeout) {
String line = streamClient.readStringUntil('\n');
if (line == "\r" || line.length() == 0) break;
if (line.startsWith("icy-metaint:")) {
icyMetaInt = line.substring(12).toInt();
}
if (line.startsWith("icy-name:")) {
stationName = line.substring(9);
stationName.trim();
}
resetWatchdog();
vTaskDelay(pdMS_TO_TICKS(1));
}
if (xSemaphoreTake(mutexPlayback, pdMS_TO_TICKS(100)) == pdTRUE) {
isPlaying = true;
if (stationName.length() > 0) {
safeStrncpy(currentMetadata, stationName.c_str(), sizeof(currentMetadata));
} else {
safeStrncpy(currentMetadata, "Playing...", sizeof(currentMetadata));
}
xSemaphoreGive(mutexPlayback);
}
uint8_t buffer[STREAM_BUFFER_SIZE];
uint32_t lastDataTime = millis();
const uint32_t DATA_TIMEOUT_MS = 15000;
while (streamActive && streamClient.connected()) {
if (streamClient.available()) {
int bytes = streamClient.read(buffer, sizeof(buffer));
if (bytes > 0) {
if (waitForDREQ(50)) {
player.playData(buffer, bytes);
}
lastDataTime = millis();
}
} else {
vTaskDelay(pdMS_TO_TICKS(2));
}
if (millis() - lastDataTime > DATA_TIMEOUT_MS) {
Serial.println(" Stream: data timeout");
break;
}
resetWatchdog();
}
streamClient.stop();
if (xSemaphoreTake(mutexPlayback, pdMS_TO_TICKS(100)) == pdTRUE) {
isPlaying = false;
xSemaphoreGive(mutexPlayback);
}
vTaskDelete(NULL);
}
bool waitForDREQ(uint32_t timeout) {
uint32_t start = millis();
while (!digitalRead(VS1053_DREQ)) {
if (millis() - start > timeout) return false;
vTaskDelay(pdMS_TO_TICKS(1));
}
return true;
}
void playSDFile(const char* filename) {
if (!sdCardAvailable) {
if (xSemaphoreTake(mutexPlayback, pdMS_TO_TICKS(100)) == pdTRUE) {
safeStrncpy(currentMetadata, "SD not found", sizeof(currentMetadata));
xSemaphoreGive(mutexPlayback);
}
return;
}
stopStreamTask();
currentFile = SD.open(filename);
if (!currentFile) {
if (xSemaphoreTake(mutexPlayback, pdMS_TO_TICKS(100)) == pdTRUE) {
safeStrncpy(currentMetadata, "File not found", sizeof(currentMetadata));
xSemaphoreGive(mutexPlayback);
}
return;
}
if (xSemaphoreTake(mutexPlayback, pdMS_TO_TICKS(100)) == pdTRUE) {
isPlaying = true;
char msg[64];
snprintf(msg, sizeof(msg), "SD: %s", filename);
safeStrncpy(currentMetadata, msg, sizeof(currentMetadata));
xSemaphoreGive(mutexPlayback);
}
uint8_t buffer[STREAM_BUFFER_SIZE];
while (currentFile.available() && isPlaying) {
if (waitForDREQ(50)) {
size_t bytes = currentFile.read(buffer, sizeof(buffer));
if (bytes > 0) player.playData(buffer, bytes);
}
resetWatchdog();
vTaskDelay(pdMS_TO_TICKS(2));
}
currentFile.close();
if (xSemaphoreTake(mutexPlayback, pdMS_TO_TICKS(100)) == pdTRUE) {
isPlaying = false;
xSemaphoreGive(mutexPlayback);
}
}
void startRecording() {
if (isRecording) {
if (xSemaphoreTake(mutexPlayback, pdMS_TO_TICKS(100)) == pdTRUE) {
safeStrncpy(currentMetadata, "Already recording", sizeof(currentMetadata));
xSemaphoreGive(mutexPlayback);
}
return;
}
if (!sdCardAvailable) {
if (xSemaphoreTake(mutexPlayback, pdMS_TO_TICKS(100)) == pdTRUE) {
safeStrncpy(currentMetadata, "SD not found", sizeof(currentMetadata));
xSemaphoreGive(mutexPlayback);
}
return;
}
if (xSemaphoreTake(mutexPlayback, pdMS_TO_TICKS(100)) == pdTRUE) {
isRecording = true;
isPlaying = false;
safeStrncpy(currentMetadata, "Recording...", sizeof(currentMetadata));
xSemaphoreGive(mutexPlayback);
}
char filename[32];
snprintf(filename, sizeof(filename), "/rec_%lu.ogg", millis());
currentFile = SD.open(filename, FILE_WRITE);
if (!currentFile) {
if (xSemaphoreTake(mutexPlayback, pdMS_TO_TICKS(100)) == pdTRUE) {
safeStrncpy(currentMetadata, "SD write error", sizeof(currentMetadata));
isRecording = false;
xSemaphoreGive(mutexPlayback);
}
return;
}
player.startRecordOgg(true);
delay(100);
uint32_t startTime = millis();
uint32_t lastRecordTime = millis();
while (isRecording) {
if (player.recordedWordsWaiting()) {
uint16_t word = player.recordedReadWord();
currentFile.write((uint8_t*)&word, 2);
lastRecordTime = millis();
}
if (millis() - startTime > (uint32_t)RECORD_MAX_SEC * 1000) {
Serial.println(" Auto-stop recording (limit)");
isRecording = false;
break;
}
if (millis() - lastRecordTime > 60000) {
isRecording = false;
break;
}
resetWatchdog();
vTaskDelay(pdMS_TO_TICKS(5));
}
player.stopRecordOgg();
currentFile.close();
if (xSemaphoreTake(mutexPlayback, pdMS_TO_TICKS(100)) == pdTRUE) {
safeStrncpy(currentMetadata, "Recording done", sizeof(currentMetadata));
isRecording = false;
xSemaphoreGive(mutexPlayback);
}
Serial.println(" Recording finished");
}
void stopAll() {
stopStreamTask();
if (isRecording) {
isRecording = false;
player.stopRecordOgg();
if (currentFile) currentFile.close();
}
if (xSemaphoreTake(mutexPlayback, pdMS_TO_TICKS(100)) == pdTRUE) {
isPlaying = false;
isRecording = false;
xSemaphoreGive(mutexPlayback);
}
Serial.println(" All tasks stopped");
}
// ==========================================================================================
// ЭНКОДЕР
// ==========================================================================================
void handleEncoder() {
static int lastCLK = HIGH;
static uint32_t lastDebounce = 0;
int currentCLK = digitalRead(ENCODER_CLK);
uint32_t now = millis();
if (now - lastDebounce < ENCODER_DEBOUNCE) return;
if (currentCLK != lastCLK && currentCLK == HIGH) {
lastDebounce = now;
bool clockwise = (digitalRead(ENCODER_DT) != currentCLK);
if (xSemaphoreTake(mutexSettings, pdMS_TO_TICKS(10)) == pdTRUE) {
if (clockwise) {
if (volume < 100) {
volume = (uint8_t)min(100, (int)volume + 5);
} else {
currentStation = (uint8_t)((currentStation + 1) % stationCount);
if (isPlaying) startStreamTask(currentStation);
}
} else {
if (volume > 0) {
volume = (uint8_t)max(0, (int)volume - 5);
} else {
if (currentStation == 0) currentStation = (uint8_t)(stationCount - 1);
else currentStation--;
if (isPlaying) startStreamTask(currentStation);
}
}
settingsChanged = true;
lastSettingsChange = millis();
xSemaphoreGive(mutexSettings);
if (vs1053Available) setVS1053Volume(volume);
}
}
lastCLK = currentCLK;
if (digitalRead(ENCODER_SW) == LOW && (now - lastButtonPress > 300)) {
lastButtonPress = now;
if (isRecording) {
isRecording = false;
if (xSemaphoreTake(mutexPlayback, pdMS_TO_TICKS(100)) == pdTRUE) {
safeStrncpy(currentMetadata, "Rec stopped", sizeof(currentMetadata));
xSemaphoreGive(mutexPlayback);
}
} else if (isPlaying) {
stopStreamTask();
if (xSemaphoreTake(mutexPlayback, pdMS_TO_TICKS(100)) == pdTRUE) {
safeStrncpy(currentMetadata, "Stopped", sizeof(currentMetadata));
xSemaphoreGive(mutexPlayback);
}
} else {
startStreamTask(currentStation);
}
while (digitalRead(ENCODER_SW) == LOW) {
vTaskDelay(pdMS_TO_TICKS(10));
}
}
}
// ==========================================================================================
// ДИСПЛЕЙ
// ==========================================================================================
void updateDisplay() {
if (xSemaphoreTake(mutexDisplay, pdMS_TO_TICKS(100)) != pdTRUE) return;
tft.fillScreen(ST7735_BLACK);
tft.setCursor(0, 0);
tft.setTextSize(1);
tft.setTextColor(ST7735_WHITE);
tft.println("=== WIFE RADIO v5.0 ===");
uint8_t st;
if (xSemaphoreTake(mutexSettings, pdMS_TO_TICKS(10)) == pdTRUE) {
st = currentStation;
xSemaphoreGive(mutexSettings);
} else {
st = 0;
}
tft.setTextColor(ST7735_YELLOW);
tft.println(stations[st].name);
char meta[23];
if (xSemaphoreTake(mutexPlayback, pdMS_TO_TICKS(10)) == pdTRUE) {
safeStrncpy(meta, currentMetadata, sizeof(meta));
xSemaphoreGive(mutexPlayback);
} else {
safeStrncpy(meta, "...", sizeof(meta));
}
tft.setTextColor(ST7735_CYAN);
tft.println(meta);
tft.setTextColor(ST7735_WHITE);
if (isPlaying) tft.println("> Playing");
else if (isRecording) tft.println("* Recording...");
else tft.println("- Stopped");
uint8_t vol;
if (xSemaphoreTake(mutexSettings, pdMS_TO_TICKS(10)) == pdTRUE) {
vol = volume;
xSemaphoreGive(mutexSettings);
} else {
vol = 0;
}
tft.setTextColor(ST7735_GREEN);
tft.print("Vol: ");
tft.print(vol);
tft.println("%");
tft.setTextColor(ST7735_BLUE);
tft.print("WiFi: ");
tft.println(connectedSSID);
tft.setTextColor(ST7735_RED);
tft.print("Time: ");
tft.println(timeClient.getFormattedTime());
if (WiFi.status() == WL_CONNECTED) {
tft.setTextColor(ST7735_WHITE);
tft.print("RSSI: ");
tft.print(WiFi.RSSI());
tft.println(" dBm");
}
xSemaphoreGive(mutexDisplay);
}
void updateDisplayPartial() {
if (xSemaphoreTake(mutexDisplay, pdMS_TO_TICKS(100)) != pdTRUE) return;
char newStation[21] = "";
char newMeta[23] = "";
char newSSID[16] = "";
char newTime[9] = "";
int16_t newRSSI = 0;
uint8_t newVolume = 0;
bool newPlaying = false;
bool newRecording = false;
if (xSemaphoreTake(mutexPlayback, pdMS_TO_TICKS(10)) == pdTRUE) {
safeStrncpy(newMeta, currentMetadata, sizeof(newMeta));
newPlaying = isPlaying;
newRecording = isRecording;
xSemaphoreGive(mutexPlayback);
}
{
uint8_t st;
if (xSemaphoreTake(mutexSettings, pdMS_TO_TICKS(10)) == pdTRUE) {
st = currentStation;
newVolume = volume;
xSemaphoreGive(mutexSettings);
} else {
st = 0;
}
safeStrncpy(newStation, stations[st].name, sizeof(newStation));
}
safeStrncpy(newSSID, connectedSSID, sizeof(newSSID));
safeStrncpy(newTime, timeClient.getFormattedTime().c_str(), sizeof(newTime));
newRSSI = (WiFi.status() == WL_CONNECTED) ? WiFi.RSSI() : 0;
if (strcmp(newStation, dispStation) != 0) {
tft.fillRect(0, 10, 160, 10, ST7735_BLACK);
tft.setCursor(0, 10);
tft.setTextSize(1);
tft.setTextColor(ST7735_YELLOW);
tft.print(newStation);
safeStrncpy(dispStation, newStation, sizeof(dispStation));
}
if (strcmp(newMeta, dispMeta) != 0) {
tft.fillRect(0, 20, 160, 10, ST7735_BLACK);
tft.setCursor(0, 20);
tft.setTextColor(ST7735_CYAN);
tft.print(newMeta);
safeStrncpy(dispMeta, newMeta, sizeof(dispMeta));
}
if (newPlaying != dispPlaying || newRecording != dispRecording) {
tft.fillRect(0, 30, 160, 10, ST7735_BLACK);
tft.setCursor(0, 30);
tft.setTextColor(ST7735_WHITE);
if (newPlaying) tft.print("> Playing");
else if (newRecording) tft.print("* Recording...");
else tft.print("- Stopped");
dispPlaying = newPlaying;
dispRecording = newRecording;
}
if (newVolume != dispVolume) {
tft.fillRect(0, 40, 160, 10, ST7735_BLACK);
tft.setCursor(0, 40);
tft.setTextColor(ST7735_GREEN);
tft.print("Vol: ");
tft.print(newVolume);
tft.print("% ");
dispVolume = newVolume;
}
if (strcmp(newSSID, dispSSID) != 0) {
tft.fillRect(0, 50, 160, 10, ST7735_BLACK);
tft.setCursor(0, 50);
tft.setTextColor(ST7735_BLUE);
tft.print("WiFi: ");
tft.print(newSSID);
safeStrncpy(dispSSID, newSSID, sizeof(dispSSID));
}
if (strcmp(newTime, dispTime) != 0) {
tft.fillRect(0, 60, 160, 10, ST7735_BLACK);
tft.setCursor(0, 60);
tft.setTextColor(ST7735_RED);
tft.print("Time: ");
tft.print(newTime);
safeStrncpy(dispTime, newTime, sizeof(dispTime));
}
if (newRSSI != dispRSSI) {
tft.fillRect(0, 70, 160, 10, ST7735_BLACK);
tft.setCursor(0, 70);
tft.setTextColor(ST7735_WHITE);
if (WiFi.status() == WL_CONNECTED) {
tft.print("RSSI: ");
tft.print(newRSSI);
tft.print(" dBm");
}
dispRSSI = newRSSI;
}
xSemaphoreGive(mutexDisplay);
}
// ==========================================================================================
// ВЕБ-СЕРВЕР
// ==========================================================================================
void webServerTask(void *pvParameters) {
while (1) {
server.handleClient();
vTaskDelay(pdMS_TO_TICKS(2));
resetWatchdog();
}
}
static String generateMainPage() {
String html;
html.reserve(3000);
html += "<!DOCTYPE html><html><head><meta charset='UTF-8'>";
html += "<meta name='viewport' content='width=device-width,initial-scale=1'>";
html += "<title>WIFE RADIO v5.0</title>";
html += "<style>";
html += "body{font-family:Arial,sans-serif;margin:20px;background:#1a1a2e;color:#eee;}";
html += ".hdr{background:linear-gradient(135deg,#667eea,#764ba2);padding:20px;";
html += "border-radius:12px;text-align:center;margin-bottom:15px;}";
html += ".card{background:#16213e;padding:15px;margin:10px 0;border-radius:10px;";
html += "border:1px solid #0f3460;}";
html += ".btn{display:inline-block;padding:10px 16px;margin:4px;border:none;";
html += "border-radius:6px;cursor:pointer;font-size:13px;text-decoration:none;color:#fff;}";
html += ".bg{background:#28a745;}.br{background:#dc3545;}.bb{background:#007bff;}";
html += ".by{background:#ffc107;color:#333;}.bs{background:#6c757d;}";
html += ".stn{display:block;width:100%;padding:10px;margin:4px 0;background:#0f3460;";
html += "border:none;border-radius:6px;text-align:left;cursor:pointer;color:#eee;font-size:14px;}";
html += ".stn:hover{background:#1a4080;}.stn.act{background:#667eea;font-weight:bold;}";
html += "audio{width:100%;margin:8px 0;}";
html += ".grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(100px,1fr));gap:6px;}";
html += "</style></head><body>";
html += "<div class='hdr'><h1>WIFE RADIO v5.0</h1>";
html += "<small>ESP32-MAX-V3.0</small></div>";
html += "<div class='card'><h3>Browser Player</h3>";
html += "<audio id='ap' controls><source src='";
html += stations[currentStation].url;
html += "' type='audio/mpeg'></audio>";
html += "<p id='npn'>Now: ";
html += stations[currentStation].name;
html += "</p></div>";
html += "<div class='card'><h3>Stations</h3>";
for (int i = 0; i < (int)stationCount; i++) {
html += "<button class='stn";
if (i == (int)currentStation) html += " act";
html += "' onclick=\"var a=document.getElementById('ap');";
html += "a.src='";
html += stations[i].url;
html += "';a.load();a.play();";
html += "document.getElementById('npn').innerText='Now: ";
html += stations[i].name;
html += "';fetch('/station?idx=";
html += String(i);
html += "')\">";
if (i == (int)currentStation) html += "> ";
html += stations[i].name;
html += "</button>";
}
html += "</div>";
html += "<div class='card'><h3>Control</h3><div class='grid'>";
html += "<a href='/play' class='btn bg'>Play</a>";
html += "<a href='/stop' class='btn br'>Stop</a>";
html += "<a href='/next' class='btn bb'>Next</a>";
html += "<a href='/prev' class='btn bb'>Prev</a>";
html += "<a href='/volup' class='btn by'>Vol+</a>";
html += "<a href='/voldn' class='btn by'>Vol-</a>";
html += "<a href='/record' class='btn bs'>Rec</a>";
html += "<a href='/stoprec' class='btn br'>StopRec</a>";
html += "</div></div>";
html += "<div class='card'><h3>System</h3>";
html += "<p>WiFi: "; html += connectedSSID; html += "</p>";
html += "<p>IP: "; html += WiFi.localIP().toString(); html += "</p>";
html += "<p>RSSI: "; html += String(WiFi.RSSI()); html += " dBm</p>";
html += "<p>Volume: "; html += String(volume); html += "%</p>";
html += "<p>Heap: "; html += String(ESP.getFreeHeap()); html += " B</p>";
html += "<p>Uptime: "; html += String(millis() / 1000 / 60); html += " min</p>";
html += "<p><a href='/stations' style='color:#667eea;'>Manage Stations</a></p>";
html += "</div></body></html>";
return html;
}
void setupWebServer() {
server.on("/", HTTP_GET, []() {
String html = generateMainPage();
server.send(200, "text/html", html);
});
server.on("/api/status", HTTP_GET, []() {
String json = "{";
json += "\"station\":\"" + String(stations[currentStation].name) + "\",";
json += "\"stationIdx\":" + String(currentStation) + ",";
json += "\"volume\":" + String(volume) + ",";
json += "\"playing\":" + String(isPlaying ? "true" : "false") + ",";
json += "\"recording\":" + String(isRecording ? "true" : "false") + ",";
json += "\"metadata\":\"" + String(currentMetadata) + "\",";
json += "\"wifi\":\"" + String(connectedSSID) + "\",";
json += "\"ip\":\"" + WiFi.localIP().toString() + "\",";
json += "\"rssi\":" + String(WiFi.RSSI()) + ",";
json += "\"heap\":" + String(ESP.getFreeHeap()) + ",";
json += "\"uptime\":" + String(millis() / 1000) + ",";
json += "\"vs1053\":" + String(vs1053Available ? "true" : "false") + ",";
json += "\"sd\":" + String(sdCardAvailable ? "true" : "false");
json += "}";
server.send(200, "application/json", json);
});
server.on("/api/stations", HTTP_GET, []() {
String json = "[";
for (int i = 0; i < (int)stationCount; i++) {
if (i > 0) json += ",";
json += "{\"idx\":" + String(i);
json += ",\"name\":\"" + String(stations[i].name) + "\"";
json += ",\"url\":\"" + String(stations[i].url) + "\"}";
}
json += "]";
server.send(200, "application/json", json);
});
server.on("/play", HTTP_GET, []() {
if (!isPlaying && !isRecording) {
startStreamTask(currentStation);
}
server.sendHeader("Location", "/");
server.send(303);
});
server.on("/stop", HTTP_GET, []() {
stopStreamTask();
if (xSemaphoreTake(mutexPlayback, pdMS_TO_TICKS(100)) == pdTRUE) {
safeStrncpy(currentMetadata, "Stopped", sizeof(currentMetadata));
xSemaphoreGive(mutexPlayback);
}
server.sendHeader("Location", "/");
server.send(303);
});
server.on("/volup", HTTP_GET, []() {
if (xSemaphoreTake(mutexSettings, pdMS_TO_TICKS(100)) == pdTRUE) {
if (volume < 100) {
volume = (uint8_t)min(100, (int)volume + 5);
settingsChanged = true;
lastSettingsChange = millis();
}
xSemaphoreGive(mutexSettings);
if (vs1053Available) setVS1053Volume(volume);
}
server.sendHeader("Location", "/");
server.send(303);
});
server.on("/voldn", HTTP_GET, []() {
if (xSemaphoreTake(mutexSettings, pdMS_TO_TICKS(100)) == pdTRUE) {
if (volume > 0) {
volume = (uint8_t)max(0, (int)volume - 5);
settingsChanged = true;
lastSettingsChange = millis();
}
xSemaphoreGive(mutexSettings);
if (vs1053Available) setVS1053Volume(volume);
}
server.sendHeader("Location", "/");
server.send(303);
});
server.on("/next", HTTP_GET, []() {
if (xSemaphoreTake(mutexSettings, pdMS_TO_TICKS(100)) == pdTRUE) {
currentStation = (uint8_t)((currentStation + 1) % stationCount);
settingsChanged = true;
lastSettingsChange = millis();
uint8_t st = currentStation;
xSemaphoreGive(mutexSettings);
if (isPlaying) startStreamTask(st);
}
server.sendHeader("Location", "/");
server.send(303);
});
server.on("/prev", HTTP_GET, []() {
if (xSemaphoreTake(mutexSettings, pdMS_TO_TICKS(100)) == pdTRUE) {
if (currentStation == 0) currentStation = (uint8_t)(stationCount - 1);
else currentStation--;
settingsChanged = true;
lastSettingsChange = millis();
uint8_t st = currentStation;
xSemaphoreGive(mutexSettings);
if (isPlaying) startStreamTask(st);
}
server.sendHeader("Location", "/");
server.send(303);
});
server.on("/station", HTTP_GET, []() {
int idx = server.arg("idx").toInt();
if (idx >= 0 && idx < (int)stationCount) {
if (xSemaphoreTake(mutexSettings, pdMS_TO_TICKS(100)) == pdTRUE) {
currentStation = (uint8_t)idx;
settingsChanged = true;
lastSettingsChange = millis();
xSemaphoreGive(mutexSettings);
}
if (isPlaying) startStreamTask((uint8_t)idx);
}
server.sendHeader("Location", "/");
server.send(303);
});
server.on("/record", HTTP_GET, []() {
if (!isRecording && !isPlaying) startRecording();
server.sendHeader("Location", "/");
server.send(303);
});
server.on("/stoprec", HTTP_GET, []() {
if (isRecording) {
isRecording = false;
if (xSemaphoreTake(mutexPlayback, pdMS_TO_TICKS(100)) == pdTRUE) {
safeStrncpy(currentMetadata, "Rec stopped", sizeof(currentMetadata));
xSemaphoreGive(mutexPlayback);
}
}
server.sendHeader("Location", "/");
server.send(303);
});
server.on("/stations", HTTP_GET, []() {
String html = "<!DOCTYPE html><html><head><meta charset='UTF-8'>";
html += "<meta name='viewport' content='width=device-width,initial-scale=1'>";
html += "<title>Stations Manager</title>";
html += "<style>";
html += "body{font-family:Arial,sans-serif;margin:20px;background:#1a1a2e;color:#eee;}";
html += ".card{background:#16213e;padding:15px;margin:10px 0;border-radius:10px;border:1px solid #0f3460;}";
html += "h1{color:#667eea;}";
html += "h3{color:#4ecca3;}";
html += ".station-item{background:#0f3460;padding:10px;margin:5px 0;border-radius:6px;}";
html += ".station-item b{color:#e94560;}";
html += ".station-item small{color:#888;display:block;margin-top:4px;word-break:break-all;}";
html += ".btn{display:inline-block;padding:8px 16px;margin:5px;border:none;border-radius:6px;";
html += "cursor:pointer;text-decoration:none;font-size:13px;color:#fff;}";
html += ".btn-download{background:#28a745;}";
html += ".btn-back{background:#6c757d;}";
html += ".info{background:#1b4332;border:1px solid #2d6a4f;padding:10px;border-radius:6px;margin:10px 0;font-size:13px;}";
html += "pre{background:#0d1117;padding:10px;border-radius:6px;overflow-x:auto;font-size:12px;color:#c9d1d9;}";
html += "</style></head><body>";
html += "<h1>Stations Manager</h1>";
html += "<div class='info'>";
html += "<b>Source:</b> ";
if (sdCardAvailable && SD.exists("/stations.txt")) {
html += "SD Card (/stations.txt)";
} else {
html += "Built-in defaults";
}
html += "<br><b>Total stations:</b> " + String(stationCount);
html += "</div>";
html += "<div class='card'>";
html += "<h3>Current Stations</h3>";
for (int i = 0; i < (int)stationCount; i++) {
html += "<div class='station-item'>";
html += "<b>" + String(i + 1) + ". " + String(stations[i].name) + "</b>";
html += "<small>" + String(stations[i].url) + "</small>";
html += "</div>";
}
html += "</div>";
html += "<div class='card'>";
html += "<h3>How to Edit Stations</h3>";
html += "<p>To add/remove stations:</p>";
html += "<ol>";
html += "<li>Download the stations.txt file (button below)</li>";
html += "<li>Edit it in any text editor</li>";
html += "<li>Format: <code>Name|URL</code> (one per line)</li>";
html += "<li>Copy the file back to SD card root</li>";
html += "<li>Restart the radio</li>";
html += "</ol>";
html += "<p>Lines starting with <code>#</code> are comments.</p>";
html += "<pre># Example stations.txt:\nRadio Jazz|http://jazz.example.com/stream\nClassic Rock|http://rock.example.com/live\n# This is a comment\nBBC World|http://bbc.example.com/world</pre>";
html += "</div>";
html += "<a href='/download-stations' class='btn btn-download'>Download stations.txt</a>";
html += "<a href='/' class='btn btn-back'>Back to Main</a>";
html += "</body></html>";
server.send(200, "text/html", html);
});
server.on("/download-stations", HTTP_GET, []() {
String content = "# WIFE RADIO - Stations List\n";
content += "# Format: Name|URL\n";
content += "# Edit this file and copy to SD card root\n\n";
for (int i = 0; i < (int)stationCount; i++) {
content += String(stations[i].name) + "|" + String(stations[i].url) + "\n";
}
server.sendHeader("Content-Disposition", "attachment; filename=stations.txt");
server.send(200, "text/plain", content);
});
server.enableCORS(true);
server.begin();
Serial.println(" Web-server started");
}