/*==========================================================================================
* WIFE RADIO v4.0.0 — Полностью функциональное радио с веб-интерфейсом
* ----------------------------------------------------------------------------------------
* Веб-интерфейс соответствует скриншоту:
* - Статус VS1053 и SD
* - IP, Wi-Fi, RSSI, громкость
* - Кнопки: Play, Stop, Volume +/-, Record, Next, Scan, Info, Test
*
* Платформа: ESP32-MAX-V3.0
* FRAM: MB85RC256V через TCA9548A
* I2C мультиплексор: TCA9548A (адрес 0x70)
* VS1053 Breakout v4
* TFT ST7735 1.8"
* Энкодер KY-040
*========================================================================================*/
#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>
// === ПИНЫ ===
#define TFT_CS 15
#define TFT_DC 2
#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 12
#define ENCODER_DT 14
#define ENCODER_SW 13
#define I2C_SDA 21
#define I2C_SCL 22
#define TCA9548A_ADDR 0x70
#define FRAM_ADDR 0x50
// === ГЛОБАЛЬНЫЕ ОБЪЕКТЫ ===
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);
// === СТАТУС УСТРОЙСТВ ===
bool vs1053Available = false;
bool sdCardAvailable = false;
// === Wi-Fi сети ===
struct WiFiNetwork {
const char* ssid;
const char* password;
};
WiFiNetwork wifiNetworks[] = {
{"Keenetic-6391", "U7Y6hMeB"},
{"ASUS", "as485127sav"},
{"Galaxy_A12", "sang7164"},
{"MobileHotspot", "12345678"}
};
const uint8_t WIFI_NETWORK_COUNT = 4;
String connectedSSID = "Не подключено";
// === Радиостанции ===
struct Station {
char name[32];
char url[128];
};
Station stations[10] = {
{"Радио Джаз", "http://jazz-wr06.ice.infomaniak.ch/jazz-wr06-128.mp3"},
{"Классический рок", "http://listen.181fm.com/181-classicrock_128k.mp3"},
{"BBC World", "http://stream.live.vc.bbcmedia.co.uk/bbc_world_service"},
{"", ""}
};
uint8_t stationCount = 3;
uint8_t currentStation = 0;
// === Настройки ===
uint8_t volume = 50;
bool isPlaying = false;
String currentMetadata = "Ожидание...";
bool isRecording = false;
File currentFile;
// === Функции ===
void selectTCA9548AChannel(uint8_t channel);
bool framWriteBytes(uint16_t addr, uint8_t* data, uint8_t len);
bool framReadBytes(uint16_t addr, uint8_t* data, uint8_t len);
void saveSettingsToFRAM();
void loadSettingsFromFRAM();
void connectToBestWiFi();
void setupWebServer();
void handleEncoder();
void playStation(uint8_t index);
void playSDFile(const char* filename);
void startRecording();
void stopPlaying();
void updateDisplay();
bool waitForDREQ(unsigned long timeout = 500);
// === SETUP ===
void setup() {
Serial.begin(115200);
delay(500);
Serial.println("\n\n=== [WIFE RADIO v4.0.0 — ESP32-MAX-V3.0] ===");
// TFT
tft.initR(INITR_BLACKTAB);
tft.setRotation(3);
tft.fillScreen(ST7735_BLACK);
tft.setTextColor(ST7735_YELLOW);
tft.println("Загрузка...");
// Энкодер
pinMode(ENCODER_CLK, INPUT_PULLUP);
pinMode(ENCODER_DT, INPUT_PULLUP);
pinMode(ENCODER_SW, INPUT_PULLUP);
// SPI
SPI.begin();
// VS1053
if (player.begin()) {
vs1053Available = true;
player.setVolume(volume, volume);
Serial.println("✅ VS1053 найден");
} else {
vs1053Available = false;
Serial.println("❌ VS1053 не найден");
}
// SD карта
if (SD.begin(SD_CS)) {
sdCardAvailable = true;
Serial.println("✅ SD готова");
} else {
sdCardAvailable = false;
Serial.println("❌ SD не найдена");
}
// I2C
Wire.begin(I2C_SDA, I2C_SCL);
Wire.setClock(400000);
// FRAM через TCA9548A
selectTCA9548AChannel(0);
Wire.beginTransmission(FRAM_ADDR);
if (Wire.endTransmission() == 0) {
Serial.println("✅ FRAM обнаружена");
loadSettingsFromFRAM();
} else {
Serial.println("⚠️ FRAM не найдена");
}
// Wi-Fi
connectToBestWiFi();
// NTP
timeClient.begin();
// Веб-сервер
setupWebServer();
// OTA
ArduinoOTA.setHostname("wife-radio");
ArduinoOTA.onStart([]() {
Serial.println("Начало OTA...");
tft.fillScreen(ST7735_RED);
tft.setTextColor(ST7735_WHITE);
tft.println("OTA Обновление...");
});
ArduinoOTA.begin();
Serial.println("OTA готов");
// mDNS
if (MDNS.begin("wife-radio")) {
MDNS.addService("http", "tcp", 80);
Serial.println("mDNS: http://wife-radio.local");
}
Serial.println("ГОТОВО! 📻");
}
// === LOOP ===
void loop() {
static uint32_t lastUpdate = 0;
ArduinoOTA.handle();
handleEncoder();
timeClient.update();
if (millis() - lastUpdate > 200) {
updateDisplay();
lastUpdate = millis();
}
if (WiFi.status() != WL_CONNECTED) {
connectToBestWiFi();
delay(2000);
}
server.handleClient();
delay(1);
}
// === TCA9548A ===
void selectTCA9548AChannel(uint8_t channel) {
if (channel > 7) return;
Wire.beginTransmission(TCA9548A_ADDR);
Wire.write(1 << channel);
Wire.endTransmission();
}
// === FRAM ===
bool framWriteBytes(uint16_t addr, 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};
framWriteBytes(0, data, 2);
}
void loadSettingsFromFRAM() {
uint8_t data[2];
if (framReadBytes(0, data, 2)) {
currentStation = data[0];
volume = data[1];
if (currentStation >= stationCount) currentStation = 0;
if (volume > 100) volume = 50;
}
}
// === Wi-Fi ===
void connectToBestWiFi() {
WiFi.disconnect();
delay(100);
WiFi.mode(WIFI_STA);
int n = WiFi.scanNetworks();
if (n <= 0) {
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 < WIFI_NETWORK_COUNT; j++) {
if (scanned == wifiNetworks[j].ssid && rssi > bestRSSI) {
bestRSSI = rssi;
bestIndex = j;
found = true;
}
}
}
if (!found) {
connectedSSID = "Сеть не найдена";
return;
}
WiFi.begin(wifiNetworks[bestIndex].ssid, wifiNetworks[bestIndex].password);
uint8_t attempts = 0;
while (WiFi.status() != WL_CONNECTED && attempts++ < 20) delay(500);
if (WiFi.status() == WL_CONNECTED) {
connectedSSID = wifiNetworks[bestIndex].ssid;
} else {
connectedSSID = "Ошибка подключения";
}
}
// === АУДИО ===
void playStation(uint8_t index) {
if (index >= stationCount || WiFi.status() != WL_CONNECTED) return;
stopPlaying();
delay(100);
const char* url = stations[index].url;
if (strncmp(url, "http://", 7) != 0) return;
const char* host = url + 7;
const char* path = strchr(host, '/');
if (!path) path = host + strlen(host);
char hostBuf[64];
size_t len = min((size_t)(path - host), sizeof(hostBuf) - 1);
strncpy(hostBuf, host, len);
hostBuf[len] = '\0';
WiFiClient client;
if (!client.connect(hostBuf, 80)) {
currentMetadata = "Ошибка подключения";
return;
}
client.print(String("GET ") + (path[0] ? path : "/") + " HTTP/1.1\r\n"
"Host: " + hostBuf + "\r\n"
"User-Agent: ESP32/WifeRadio\r\n"
"Icy-MetaData: 1\r\n"
"Connection: close\r\n\r\n");
while (client.connected()) {
String line = client.readStringUntil('\n');
if (line == "\r") break;
}
isPlaying = true;
currentStation = index;
currentMetadata = "Подключение...";
uint8_t buffer[32];
while (client.connected() && isPlaying) {
if (client.available()) {
int bytes = client.read(buffer, sizeof(buffer));
if (bytes > 0 && waitForDREQ(10)) {
player.playData(buffer, bytes);
}
}
if (!isPlaying) break;
delay(1);
}
client.stop();
stopPlaying();
}
void playSDFile(const char* filename) {
stopPlaying();
currentFile = SD.open(filename);
if (!currentFile) return;
isPlaying = true;
currentMetadata = String("SD: ") + filename;
while (currentFile.available() && isPlaying) {
if (waitForDREQ(10)) {
uint8_t buffer[32];
size_t bytes = currentFile.read(buffer, sizeof(buffer));
player.playData(buffer, bytes);
}
delay(1);
}
currentFile.close();
stopPlaying();
}
void startRecording() {
if (isRecording) return;
isRecording = true;
isPlaying = false;
currentMetadata = "Запись...";
String filename = "/rec_";
filename += millis();
filename += ".mp3";
currentFile = SD.open(filename.c_str(), FILE_WRITE);
if (!currentFile) {
currentMetadata = "Ошибка записи";
isRecording = false;
return;
}
player.startRecordOgg(true);
delay(100);
while (isRecording) {
if (player.recordedWordsWaiting()) {
uint16_t buffer[32];
uint16_t words = player.recordedRead(buffer, 32);
currentFile.write((uint8_t*)buffer, words * 2);
}
delay(10);
}
player.stopRecord();
currentFile.close();
currentMetadata = "Запись завершена";
}
void stopPlaying() {
player.stopPlaying();
isPlaying = false;
isRecording = false;
if (currentFile) currentFile.close();
currentMetadata = "Остановлено";
}
bool waitForDREQ(unsigned long timeout) {
unsigned long start = millis();
while (!digitalRead(VS1053_DREQ)) {
if (millis() - start > timeout) return false;
delay(1);
}
return true;
}
// === ЭНКОДЕР ===
void handleEncoder() {
static int lastCLK = digitalRead(ENCODER_CLK);
int currentCLK = digitalRead(ENCODER_CLK);
if (currentCLK != lastCLK) {
bool clockwise = (digitalRead(ENCODER_DT) != currentCLK);
if (clockwise) {
if (volume < 100) volume += 5;
else {
currentStation = (currentStation + 1) % stationCount;
if (isPlaying) playStation(currentStation);
}
} else {
if (volume > 0) volume -= 5;
else {
if (currentStation == 0) currentStation = stationCount - 1;
else currentStation--;
if (isPlaying) playStation(currentStation);
}
}
player.setVolume(volume, volume);
saveSettingsToFRAM();
lastCLK = currentCLK;
}
if (digitalRead(ENCODER_SW) == LOW) {
delay(50);
if (digitalRead(ENCODER_SW) == LOW) {
if (isRecording) {
isRecording = false;
} else if (isPlaying) {
stopPlaying();
} else {
playStation(currentStation);
}
while (digitalRead(ENCODER_SW) == LOW) delay(10);
}
}
}
// === ДИСПЛЕЙ ===
void updateDisplay() {
tft.fillScreen(ST7735_BLACK);
tft.setCursor(0, 0);
tft.setTextSize(1);
// Источник
tft.setTextColor(ST7735_WHITE);
tft.println("Источник: Радио");
// Станция
tft.setTextColor(ST7735_YELLOW);
tft.println(stations[currentStation].name);
// Метаданные
tft.setTextColor(ST7735_CYAN);
tft.println(currentMetadata.substring(0, 32));
// Статус
tft.setTextColor(ST7735_WHITE);
tft.println(isPlaying ? "▶ Воспроизведение" : isRecording ? "● Запись" : "⏸ Остановлено");
// Громкость
tft.setTextColor(ST7735_GREEN);
tft.print("Громкость: ");
tft.print(volume);
tft.println("%");
// Сеть
tft.setTextColor(ST7735_BLUE);
tft.print("Wi-Fi: ");
tft.println(connectedSSID.substring(0, 20));
// IP и время
tft.setTextColor(ST7735_MAGENTA);
tft.print("IP: ");
tft.println(WiFi.localIP());
tft.setTextColor(ST7735_RED);
tft.print("Время: ");
tft.println(timeClient.getFormattedTime());
}
// === ВЕБ-СЕРВЕР ===
void setupWebServer() {
server.on("/", HTTP_GET, []() {
String html = "<!DOCTYPE html><html><head><title>Wife Radio</title>";
html += "<style>body{font-family:Arial;margin:20px;background:#f5f5f5;} .status{padding:10px;margin:10px 0;border-radius:5px;}";
html += ".ok{background:#d4edda;color:#155724;border:1px solid #c3e6cb;} .error{background:#f8d7da;color:#721c24;border:1px solid #f5c6cb;}</style></head><body>";
// Device Status
html += "<div class='status ";
if (vs1053Available) html += "ok'>✅ VS1053: Available"; else html += "error'>❌ VS1053: Not Available";
html += "</div>";
html += "<div class='status ";
if (sdCardAvailable) html += "ok'>✅ SD Card: Available"; else html += "error'>❌ SD Card: Not Available";
html += "</div>";
// System Status
html += "<div style='background:#e3f2fd;padding:15px;margin:20px 0;border-radius:5px;'>";
html += "<h3>📊 System Status</h3>";
html += "<p>📶 WiFi: " + connectedSSID + "</p>";
html += "<p>🌐 IP Address: " + WiFi.localIP().toString() + "</p>";
html += "<p>📡 Signal: " + String(WiFi.RSSI()) + " dBm</p>";
html += "<p>🔊 Volume: " + String(volume) + "%</p>";
html += "<p>📡 Status: " + (vs1053Available ? "Ready" : "VS1053 Not Found") + "</p>";
html += "<p>🎵 Source: Radio</p>";
html += "</div>";
// Radio Stations
html += "<h3>📻 Radio Stations</h3>";
for (int i = 0; i < stationCount; i++) {
html += "<button onclick=\"location.href='/station?idx=" + String(i) + "'\" style='margin:5px;padding:10px;'>Station " + String(i+1) + "</button>";
}
// Controls
html += "<h3>🕹 Controls</h3>";
html += "<button onclick=\"location.href='/play'\" style='background:#ffc107;margin:5px;padding:10px;'>▶ Play Radio</button>";
html += "<button onclick=\"location.href='/stop'\" style='background:#007bff;margin:5px;padding:10px;'>⏸ Stop</button>";
html += "<button onclick=\"location.href='/volup'\" style='background:#28a745;margin:5px;padding:10px;'>🔊 Volume +</button>";
html += "<button onclick=\"location.href='/voldn'\" style='background:#007bff;margin:5px;padding:10px;'>🔉 Volume -</button><br>";
html += "<button onclick=\"location.href='/record'\" style='background:#ffc107;margin:5px;padding:10px;'>● Start Record</button>";
html += "<button onclick=\"location.href='/stoprecord'\" style='background:#dc3545;margin:5px;padding:10px;'>■ Stop Record</button>";
html += "<button onclick=\"location.href='/next'\" style='background:#ffc107;margin:5px;padding:10px;'>⏭ Next Station</button><br>";
html += "<button onclick=\"location.href='/scan'\" style='background:#007bff;margin:5px;padding:10px;'>🔍 Scan Networks</button>";
html += "<button onclick=\"location.href='/info'\" style='background:#007bff;margin:5px;padding:10px;'>ℹ️ System Info</button>";
html += "<button onclick=\"location.href='/test'\" style='background:#007bff;margin:5px;padding:10px;'>🔧 Test Functions</button>";
html += "</body></html>";
server.send(200, "text/html", html);
});
server.on("/play", HTTP_GET, []() {
if (!isPlaying && !isRecording) playStation(currentStation);
server.sendHeader("Location", "/");
server.send(303);
});
server.on("/stop", HTTP_GET, []() {
stopPlaying();
server.sendHeader("Location", "/");
server.send(303);
});
server.on("/volup", HTTP_GET, []() {
if (volume < 100) volume += 5;
player.setVolume(volume, volume);
saveSettingsToFRAM();
server.sendHeader("Location", "/");
server.send(303);
});
server.on("/voldn", HTTP_GET, []() {
if (volume > 0) volume -= 5;
player.setVolume(volume, volume);
saveSettingsToFRAM();
server.sendHeader("Location", "/");
server.send(303);
});
server.on("/record", HTTP_GET, []() {
if (!isRecording && !isPlaying) startRecording();
server.sendHeader("Location", "/");
server.send(303);
});
server.on("/stoprecord", HTTP_GET, []() {
isRecording = false;
stopPlaying();
server.sendHeader("Location", "/");
server.send(303);
});
server.on("/next", HTTP_GET, []() {
currentStation = (currentStation + 1) % stationCount;
if (isPlaying) playStation(currentStation);
saveSettingsToFRAM();
server.sendHeader("Location", "/");
server.send(303);
});
server.on("/station", HTTP_GET, []() {
String idxStr = server.arg("idx");
int idx = idxStr.toInt();
if (idx >= 0 && idx < stationCount) {
currentStation = idx;
if (isPlaying) playStation(currentStation);
saveSettingsToFRAM();
}
server.sendHeader("Location", "/");
server.send(303);
});
server.on("/scan", HTTP_GET, []() {
connectToBestWiFi();
server.sendHeader("Location", "/");
server.send(303);
});
server.on("/info", HTTP_GET, []() {
String html = "<h1>System Info</h1>";
html += "<p>ESP32 Chip ID: " + String(ESP.getEfuseMac(), HEX) + "</p>";
html += "<p>Free Heap: " + String(ESP.getFreeHeap()) + " bytes</p>";
html += "<p>Sketch Size: " + String(ESP.getSketchSize()) + " bytes</p>";
html += "<p>SDK Version: " + String(ESP.getSdkVersion()) + "</p>";
server.send(200, "text/html", html);
});
server.on("/test", HTTP_GET, []() {
String html = "<h1>Test Functions</h1>";
html += "<p>Testing VS1053: " + (vs1053Available ? "OK" : "FAIL") + "</p>";
html += "<p>Testing SD: " + (sdCardAvailable ? "OK" : "FAIL") + "</p>";
html += "<p>Testing FRAM: " + (framReadBytes(0, new uint8_t[2], 2) ? "OK" : "FAIL") + "</p>";
server.send(200, "text/html", html);
});
server.begin();
}