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
Инструкция:
- Скачайте файл .ino
- Откройте в Arduino IDE 1.8.19+
- Выберите плату: ESP32 Dev Module
- Установите библиотеки (см. ниже)
- Загрузите на 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)
Управление радиостанциями
Редактирование списка без перепрошивки
Как это работает:
- Создайте файл stations.txt на SD карте
- Формат: Название|URL
- Вставьте SD карту в радио
- Перезагрузите устройство
- Станции загрузятся автоматически
Пример файла 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 исправлений применены • Готово к компиляции