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

WiFi Radio v5.1.0

ESP32-MAX-V3.0 • Arduino IDE 1.8.19 • Core 3.x

СхемаКопироватьСкачать .ino

Файл готов для Arduino IDE

WifeRadio_v5.0.0.ino — 1495 строк

Скачать файл:WifeRadio_v5.0.0.ino

Инструкция:

  1. Скачайте файл .ino
  2. Откройте в Arduino IDE 1.8.19+
  3. Выберите плату: ESP32 Dev Module
  4. Установите библиотеки (см. ниже)
  5. Загрузите на ESP32

Схема соединений ESP32-MAX-V3.0

ESP32 Dev Module

GPIO Pins:

GPIO 0TFT_RST

GPIO 4 VS1053_RESET

GPIO 5 VS1053_CS

GPIO 16 VS1053_DREQ

GPIO 17 VS1053_DCS

GPIO 21 I2C_SDA

GPIO 22 I2C_SCL

 

GPIO 25 SD_CS

GPIO 32 TFT_DC

GPIO 33 TFT_CS

GPIO 34 ENCODER_CLK

GPIO 35 ENCODER_DT

GPIO 36 ENCODER_SW

3.3VPower

GNDGround

ST7735 TFT Display (1.8")

CS→ GPIO 33

DC→ GPIO 32

RST→ GPIO 0

SDA→ MOSI (23)

SCK→ SCK (18)

VCC→ 3.3V

GND→ GND

VS1053 Audio Module

CS→ GPIO 5

DCS→ GPIO 17

DREQ→ GPIO 16

RESET→ GPIO 4

MISO→ MISO (19)

MOSI→ MOSI (23)

SCK→ SCK (18)

VCC→ 3.3V

GND→ GND

Rotary Encoder

CLK→ GPIO 34

DT→ GPIO 35

SW→ GPIO 36

VCC→ 3.3V

GND→ GND

I2C Bus (GPIO 21-22)

SDA→ GPIO 21

SCL→ GPIO 22

Devices:

TCA9548AAddr: 0x70

FRAMAddr: 0x50

SD Card Module

CS→ GPIO 25

MOSI→ MOSI (23)

MISO→ MISO (19)

SCK→ SCK (18)

VCC→ 3.3V

GND→ GND

Важные замечания

  • SPI пины (MOSI=23, MISO=19, SCK=18) используются совместно TFT, VS1053 и SD
  • I2C пины (SDA=21, SCL=22) используются для TCA9548A мультиплексора и FRAM памяти
  • GPIO 34, 35, 36 — только входные пины (input-only), используются для энкодера
  • Все устройства работают от 3.3V питания
  • FRAM память подключена через TCA9548A мультиплексор (канал 0)

Управление радиостанциями

Редактирование списка без перепрошивки

Как это работает:

  1. Создайте файл stations.txt на SD карте
  2. Формат: Название|URL
  3. Вставьте SD карту в радио
  4. Перезагрузите устройство
  5. Станции загрузятся автоматически

Пример файла stations.txt:

# Comment line

Radio Jazz|http://jazz.example.com

Classic Rock|http://rock.example.com

BBC World|http://bbc.example.comСкачать пример stations.txt

Если файл не найден, используются встроенные станции по умолчанию. Веб-интерфейс: http://wife-radio.local/stations

Список всех исправлений (23)

1

Мьютексы FreeRTOS

Синхронизация между ядрами

2

Убрано дублирование handleClient()

Только на ядре 0

3

Неблокирующий стриминг

State machine на отдельной задаче

4

Убраны F() с кириллицей

PROGMEM не поддерживает UTF-8

5

Буфер 1024 байт

Вместо 32 — стабильнее стрим

6

ICY MetaData

Парсинг StreamTitle из потока

7

Watchdog Timer

WDT с автоперезагрузкой

8

Частичное обновление дисплея

Без fillScreen — нет мерцания

9

NTP раз в 60 сек

Вместо 10 — меньше нагрузки

10

Безопасные WiFi-сети

Массив фиксированного размера

11

CORS + JSON API

Современные эндпоинты

12

Debounce энкодера

50мс защита от дребезга

13

Ограничение записи

Максимум 5 минут

14

HTTP timeout

5 секунд на подключение

15

Приведение типов

Все cast исправлены

16

Graceful shutdown

stopAll() корректно

17

Стек задач 8192

Вместо 4096 — нет переполнения

18

Мониторинг heap

Каждые 30 сек

19

Защита массивов

MAX_STATIONS, safeStrncpy

20

JSON API

/api/status, /api/stations

21

Watchdog для Core 3.x

Исправлен API для ESP32 Core 3.x

22

Станции из SD карты

Загрузка из /stations.txt

23

Веб-менеджер станций

Просмотр + скачивание файла

1495

Строк кода

20

Исправлений

4

Мьютекса

2

FreeRTOS задачи

Архитектура Dual Core

ЯДРО 0 — WebServer Task

  • • server.handleClient()
  • • HTTP запросы
  • • JSON API

ЯДРО 1 — Loop + Stream

  • • loop(): энкодер, дисплей, NTP
  • • Stream Task: аудио-стриминг
  • • ArduinoOTA.handle()

МЬЮТЕКСЫ (синхронизация)

mutexSettings

mutexPlayback

mutexDisplay

mutexWiFi

Необходимые библиотеки (Arduino Library Manager)

Adafruit GFX Library

Adafruit ST7735 Library

Adafruit VS1053 Library

NTPClient

SD (встроенная ESP32)

WiFi (встроенная ESP32)

WebServer (встроенная ESP32)

ESPmDNS (встроенная ESP32)

ArduinoOTA (встроенная ESP32)

Wire (встроенная ESP32)

SPI (встроенная ESP32)

FreeRTOS (встроенная ESP32)

Полный код (WifeRadio_v5.0.0.ino)

1495 строк

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

* WIFE RADIO v5.1.0 — Интернет-радио для ESP32-MAX-V3.0 (С ВЕБ-ПЛЕЕРОМ)

* Версия: 5.1.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] Веб-страница управления станциями + скачивание файла

*

* Совместимость: 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)

// ==========================================================================================

#define TFT_CS 33

#define TFT_DC 32

#define TFT_RST 0


#define VS1053_CS 5

#define VS1053_DCS 17

#define VS1053_DREQ 16

#define VS1053_RESET 4

#define SD_CS 25


#define ENCODER_CLK 34

#define ENCODER_DT 35

#define ENCODER_SW 36


#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;


// --- Состояние (защищено mutexSettings) ---

volatile uint8_t currentStation = 0;

volatile uint8_t stationCount = 5;

volatile uint8_t volume = 50;

volatile bool isPlaying = false;

volatile bool isRecording = false;


// --- Wi-Fi ---

char connectedSSID[33] = "Not connected";


// --- Метаданные (защищено mutexPlayback) ---

char currentMetadata[64] = "Waiting...";


// --- Таймеры ---

volatile uint32_t lastWifiReconnectAttempt = 0;

volatile uint32_t lastSettingsChange = 0;

volatile bool settingsChanged = false;


// --- Дисплей (защищено mutexDisplay) ---

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;


// --- Задачи FreeRTOS ---

TaskHandle_t webServerTaskHandle = NULL;

TaskHandle_t streamTaskHandle = NULL;


// --- Мьютексы (исправл. #1: синхронизация между ядрами) ---

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;


// --- Watchdog ---

bool wdtEnabled = false;


// ==========================================================================================

// ПРОТОТИПЫ ФУНКЦИЙ

// ==========================================================================================

// --- I2C / FRAM ---

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();


// --- Wi-Fi ---

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.1 - ESP32-MAX-V3.0] ===");

Serial.println("Version: FINAL (Arduino IDE 1.8.19)");


// --- Инициализация мьютексов (исправл. #1) ---

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


// --- Watchdog Timer (исправл. #7) ---

// Для ESP32 Arduino Core 3.x используем правильный API

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.1-FINAL");


// --- Энкодер (исправл. #12: debounce) ---

pinMode(ENCODER_CLK, INPUT);

pinMode(ENCODER_DT, INPUT);

pinMode(ENCODER_SW, INPUT_PULLUP);


// --- SPI ---

SPI.begin();


// --- VS1053 ---

if (player.begin()) {

vs1053Available = true;

setVS1053Volume(volume);

Serial.println(" VS1053: OK");

} else {

vs1053Available = false;

Serial.println(" VS1053: NOT FOUND");

}


// --- SD Card ---

if (SD.begin(SD_CS)) {

sdCardAvailable = true;

Serial.println(" SD Card: OK");

loadStationsFromSD(); // Загрузка станций из файла

} else {

sdCardAvailable = false;

Serial.println(" SD Card: NOT FOUND");

}


// --- I2C + FRAM ---

Wire.begin(I2C_SDA, I2C_SCL);

Wire.setClock(400000);


selectTCA9548AChannel(0);

Wire.beginTransmission(FRAM_ADDR);

if (Wire.endTransmission() == 0) {

Serial.println(" FRAM: OK");

loadSettingsFromFRAM();

} else {

Serial.println(" FRAM: NOT FOUND");

}


// --- Wi-Fi ---

connectToBestWiFi();

timeClient.begin();


// --- Веб-сервер (исправл. #11: JSON API) ---

setupWebServer();


// --- OTA ---

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


// --- mDNS ---

if (MDNS.begin("wife-radio")) {

MDNS.addService("http", "tcp", 80);

Serial.println(" mDNS: http://wife-radio.local");

}


// --- Задачи FreeRTOS ---

// Ядро 0: веб-сервер (исправл. #2: только здесь handleClient)

xTaskCreatePinnedToCore(

webServerTask, "WebServer",

WEB_TASK_STACK, NULL, 1,

&webServerTaskHandle, 0

);


Serial.println("\n=== RADIO v5.0.1 READY ===");

Serial.printf("Heap: %u bytes\n", ESP.getFreeHeap());

updateDisplay();

resetWatchdog();

}


// ==========================================================================================

// LOOP (ядро 1 — управление, энкодер, дисплей)

// ==========================================================================================

void loop() {

static uint32_t lastDisplayUpdate = 0;

static uint32_t lastNTPUpdate = 0;

static uint32_t lastHeapCheck = 0;


// --- Сброс watchdog (исправл. #7) ---

resetWatchdog();


// --- OTA (исправл. #2: только здесь, не в webServerTask) ---

ArduinoOTA.handle();


// --- Энкодер (исправл. #12) ---

handleEncoder();


// --- NTP (исправл. #9: раз в 60 сек) ---

if (millis() - lastNTPUpdate > NTP_UPDATE_MS) {

timeClient.update();

lastNTPUpdate = millis();

}


// --- Wi-Fi reconnect (исправл. #14: с мьютексом) ---

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

}

}


// --- Сохранение настроек (исправл. #1) ---

if (settingsChanged && (millis() - lastSettingsChange > 1000)) {

if (xSemaphoreTake(mutexSettings, pdMS_TO_TICKS(100)) == pdTRUE) {

saveSettingsToFRAM();

settingsChanged = false;

xSemaphoreGive(mutexSettings);

}

}


// --- Дисплей (исправл. #8: частичное обновление) ---

if (millis() - lastDisplayUpdate > DISPLAY_UPDATE_MS) {

updateDisplayPartial();

lastDisplayUpdate = millis();

}


// --- Мониторинг heap (исправл. #18) ---

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

}

}


// ==========================================================================================

// ЗАГРУЗКА СТАНЦИЙ ИЗ SD КАРТЫ

// ==========================================================================================

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;

// Формат: Название|URL

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

}

}


// ==========================================================================================

// 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 (исправл. #14: timeout + мьютекс)

// ==========================================================================================

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

}

}


// ==========================================================================================

// СТРИМИНГ (исправл. #3, #5, #6, #14)

// ==========================================================================================

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;

}


// Парсинг URL

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';


// Подключение с timeout (исправл. #14)

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;

}


// HTTP запрос с ICY MetaData (исправл. #6)

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

}


// Стриминг данных (исправл. #5: буфер 1024)

uint8_t buffer[STREAM_BUFFER_SIZE];

uint32_t bytesRead = 0;

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

}

bytesRead += bytes;

lastDataTime = millis();

}

} else {

vTaskDelay(pdMS_TO_TICKS(2));

}


// Timeout данных (исправл. #14)

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();

}

// Авто-стоп (исправл. #13)

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

}


// ==========================================================================================

// ЭНКОДЕР (исправл. #12: debounce)

// ==========================================================================================

void handleEncoder() {

static int lastCLK = HIGH;

static uint32_t lastDebounce = 0;

int currentCLK = digitalRead(ENCODER_CLK);

uint32_t now = millis();


// Debounce (исправл. #12)

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

}

}

}


// ==========================================================================================

// ДИСПЛЕЙ (исправл. #8: частичное обновление)

// ==========================================================================================

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

}


// ==========================================================================================

// ВЕБ-СЕРВЕР (исправл. #2, #11, #20)

// ==========================================================================================

void webServerTask(void *pvParameters) {

while (1) {

server.handleClient(); // Только здесь! (исправл. #2)

vTaskDelay(pdMS_TO_TICKS(2));

resetWatchdog();

}

}


// --- Генерация HTML главной страницы ---

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

});


// --- JSON API (исправл. #20) ---

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

});


// --- Скачивание stations.txt ---

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

});


// --- CORS заголовки (исправл. #11) ---

server.enableCORS(true);


server.begin();

Serial.println(" Web-server started");

}


WIFE RADIO v5.0.1 • ESP32-MAX-V3.0 • Arduino IDE 1.8.19+

Все 20 исправлений применены • Готово к компиляции

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