MyTetra Share
Делитесь знаниями!
Время создания: 10.09.2026 12:34
Автор: alensav
Текстовые метки: WiFi Radio
Раздел: ESP32_MAX_V-3.0
Запись: alensav/MyTetra2/main/base/1789032848czsun8098r/text.html на raw.githubusercontent.com

/*==========================================================================================

* 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");

}


Так же в этом разделе:
 
MyTetra Share v.0.67
Яндекс индекс цитирования