Эта статья является частью проекта «Интернет-радио на ESP32-S2 Mini»
Руководство по настройке и подключению
Для обеспечения стабильной работы вашего интернет-радио на базе ESP32s2 необходимо соблюдать несколько важных шагов. Один из ключевых аспектов – это установка радиатора на микроконтроллер, чтобы избежать перегрева и нестабильной работы. В процессе тестирования было выявлено, что без радиатора устройство может работать нестабильно.
Подготовка и запись веб-интерфейса
Прежде чем приступить к использованию интернет-радио, необходимо записать файл index.html в память микроконтроллера. Этот файл является веб-интерфейсом, через который вы сможете настраивать имя сети и пароль, а также редактировать список радиостанций. Следуя инструкциям в попереднiй, вы легко справитесь с этой задачей и сразу же сможете использовать веб-интерфейс для настройки устройства.
Основные функции устройства
После установки веб-интерфейса и загрузки кода, ваше устройство обретает следующие функции:
- Регулировка громкости и выключение звука кнопками: Удобное управление громкостью и возможность выключить звук по нажатию кнопки.
- Переключение радиостанций: Долгое нажатие (1 секунда) на кнопку увеличения или уменьшения громкости позволяет переключаться между радиостанциями.
- Многофункциональная кнопка на пине 12: Эта кнопка отключает светодиоды или, при долгом нажатии, отключает интернет-радио и переводит микроконтроллер в режим точки доступа для редактирования списка радиостанций или настройки WiFi.
- Включение: Белое свечение / извлечение настроек вайфай из памяти: Оранжевое свечение / подключение к WiFi: Зелёное свечение / Воспроизведение первой радиостанции из списка: Эффект огонь
- Включение: Белое свечение / настройки не правильные или не найдены: Синее свечение / Запуски точки доступа: Эффект радуга
Компоненты и подключение
Для сборки и работы устройства вам понадобятся следующие компоненты:
- ESP32s2 mini: Микроконтроллер, управляющий всей системой.
- Модуль усилителя на MAX98357: Обеспечивает высококачественное воспроизведение звука.
- LED-кольцо на 12 адресных светодиодов WS2812: Для визуальных эффектов и индикации состояния.
- Динамик 3 Вт (57мм диаметр): Для воспроизведения звука.
- Четыре тактовые кнопки 6х6х5мм
- Акумуляторная батарея 18350: Обеспечивает питание устройства т компактные размеры.
- Модуль зарядки аккумулятора TP4056 Type-C с функцией защиты: Для безопасной зарядки батареи.
- Повышающий DC DC модуль на 5 вольт: Обеспечивает стабильное напряжение для работы устройства.
- Микро выключатель двухпозиционный: Для включения и выключения устройства.
- Контакты для батарей 18650
- Напечатать корпус (файлы доступны на сайте) или заказать уже напечатанный на 3Д принтере корпус.
Подключение компонентов
Подключение всех компонентов не должно вызвать затруднений, так как теоретически разобраться с назначением пинов по коду не составит труда. Вы также можете переназначить пины так, как вам будет удобней.
Собрав все компоненты и выполнив все шаги по настройке, вы получите миниатюрное и функциональное интернет-радио, управляемое микроконтроллером ESP32s2mini. Наслаждайтесь своим новым устройством и делитесь результатами!
#include <WiFi.h>
#include <LittleFS.h>
#include <Preferences.h>
#include <ArduinoJson.h>
#include <WebServer.h>
#include "AudioFileSourceHTTPStream.h"
#include "AudioFileSourceBuffer.h"
#include "AudioGeneratorMP3.h"
#include "AudioOutputI2S.h"
#include <FastLED.h>
#include <vector>
const char* defaultStreamUrl = "http://zt01.cdn.eurozet.pl/zet-net.mp3";
std::vector<String> radioStations;
int currentStationIndex = 0;
// Объекты для работы с Wi-Fi и памятью
Preferences preferences;
WebServer server(80);
AudioGeneratorMP3 *mp3;
AudioFileSourceHTTPStream *file;
AudioFileSourceBuffer *buff;
AudioOutputI2S *out;
#define MUTE_PIN 35
#define VOL_UP 37
#define VOL_DOWN 33
#define BLU_PIN 15
#define LED_PIN 11
#define RES_PIN 12
#define NUM_LEDS 12
#define BRIGHTNESS 30
CRGB leds[NUM_LEDS];
unsigned long lastReconnectAttempt = 0;
unsigned long lastLedUpdate = 0;
unsigned long lastBlinkTime = 0;
bool ledOn = true;
bool wifiStatus = false;
bool isMuted = false;
float currentVolume = 0.09;
unsigned long lastButtonPressTime = 0;
bool enableLedEffect = true;
String ledMode = "Blue";
void reconnectStream() {
if (radioStations.empty()) {
Serial.println("No radio stations available.");
return;
}
String stationUrl = radioStations[currentStationIndex];
Serial.println("Starting playback of station: " + stationUrl);
if (mp3) {
mp3->stop();
delete mp3;
mp3 = nullptr;
}
if (buff) {
delete buff;
buff = nullptr;
}
if (file) {
delete file;
file = nullptr;
}
file = new AudioFileSourceHTTPStream(stationUrl.c_str());
size_t bufferSize = ESP.getFreeHeap() / 4;
if (bufferSize < 1024) bufferSize = 1024;
buff = new AudioFileSourceBuffer(file, bufferSize);
mp3 = new AudioGeneratorMP3();
mp3->begin(buff, out);
ledMode = "Fire";
}
void connectToWiFi(String ssid, String password) {
WiFi.begin(ssid.c_str(), password.c_str());
unsigned long startAttemptTime = millis();
while (WiFi.status() != WL_CONNECTED && millis() - startAttemptTime < 10000) {
delay(500);
}
wifiStatus = (WiFi.status() == WL_CONNECTED);
if (wifiStatus) {
Serial.println("WiFi connected!");
fill_solid(leds, NUM_LEDS, CRGB::Green);
FastLED.show();
delay (1000);
} else {
Serial.println("WiFi connection failed.");
fill_solid(leds, NUM_LEDS, CRGB::Red);
FastLED.show();
delay (1000);
startAccessPoint();
}
}
void loadWiFiCredentials() {
preferences.begin("WiFi", true);
String savedSSID = preferences.getString("wifiSSID", "");
String savedPassword = preferences.getString("wifiPassword", "");
preferences.end();
if (savedSSID.length() > 0 && savedPassword.length() > 0) {
fill_solid(leds, NUM_LEDS, CRGB::Orange);
FastLED.show();
delay(1000);
connectToWiFi(savedSSID, savedPassword);
} else {
fill_solid(leds, NUM_LEDS, CRGB::Blue);
FastLED.show();
delay (1000);
startAccessPoint();
}
}
void loadStations() {
if (LittleFS.exists("/stations.json")) {
File file = LittleFS.open("/stations.json", "r");
if (file) {
StaticJsonDocument<1024> doc;
DeserializationError error = deserializeJson(doc, file);
if (!error) {
JsonArray stationArray = doc.as<JsonArray>();
for (JsonVariant v : stationArray) {
if (v.containsKey("url")) {
String url = v["url"].as<String>();
Serial.println("Found station: " + url);
radioStations.push_back(url);
}
}
if (radioStations.empty()) {
Serial.println("Station list is empty, using default.");
radioStations.push_back(defaultStreamUrl); // Добавляем дефолтный URL, если список пуст
}
Serial.println("Loaded radio stations.");
} else {
Serial.print("Failed to parse stations.json: ");
Serial.println(error.c_str());
radioStations.push_back(defaultStreamUrl); // Добавляем дефолтный URL
}
file.close();
} else {
Serial.println("Failed to open stations.json, using default.");
radioStations.push_back(defaultStreamUrl); // Добавляем дефолтный URL
}
} else {
Serial.println("stations.json not found, using default.");
radioStations.push_back(defaultStreamUrl); // Добавляем дефолтный URL
}
// Выводим все загруженные станции для проверки
Serial.println("Radio Stations:");
for (size_t i = 0; i < radioStations.size(); i++) {
Serial.printf("Station %d: %s\n", i + 1, radioStations[i].c_str());
}
}
// Функция для запуска точки доступа
void startAccessPoint() {
setSolidColor(CRGB::Blue);
delay (500);
WiFi.softAP("esp32radio", "123456");
Serial.println("Access Point mode enabled.");
server.on("/", handleRoot);
server.on("/saveWiFi", handleSaveWiFi);
server.on("/addStation", handleAddStation);
server.on("/getStations", handleGetStations);
server.on("/deleteStation", handleDeleteStation);
server.on("/restart", handleRestart);
server.begin();
Serial.println("HTTP сервер запущен.");
Serial.print("ESP32 IP Address: ");
Serial.println(WiFi.softAPIP());
delay (500);
}
void handleRoot() {
Serial.println("Handling root request...");
File file = LittleFS.open("/index.html", "r");
if (!file) {
server.send(500, "text/plain", "Error opening index file");
return;
}
server.streamFile(file, "text/html");
file.close();
Serial.println("Index file sent to client.");
}
void handleGetStations() {
File file = LittleFS.open("/stations.json", "r");
if (!file) {
server.send(500, "text/plain", "Error opening stations file");
return;
}
StaticJsonDocument<1024> doc;
DeserializationError error = deserializeJson(doc, file);
file.close();
if (error) {
server.send(500, "text/plain", "Error parsing stations file");
return;
}
String response;
serializeJson(doc, response);
server.send(200, "application/json", response);
}
void handleSaveWiFi() {
if (server.hasArg("plain") == false) {
server.send(400, "text/plain", "Body not received");
return;
}
String body = server.arg("plain");
Serial.println("Received body: " + body);
StaticJsonDocument<200> doc;
DeserializationError error = deserializeJson(doc, body);
if (error) {
server.send(400, "text/plain", "Invalid JSON");
return;
}
const char* ssid = doc["ssid"];
const char* password = doc["password"];
Serial.println("Received WiFi settings: SSID=" + String(ssid) + ", Password=" + String(password));
preferences.begin("WiFi", false);
preferences.putString("wifiSSID", ssid);
preferences.putString("wifiPassword", password);
server.send(200, "text/plain", "Wi-Fi settings saved");
}
void handleAddStation() {
if (server.hasArg("plain") == false) {
server.send(400, "text/plain", "Body not received");
return;
}
String body = server.arg("plain");
Serial.println("Received body: " + body);
StaticJsonDocument<200> doc;
DeserializationError error = deserializeJson(doc, body);
if (error) {
server.send(400, "text/plain", "Invalid JSON");
return;
}
if (!doc.containsKey("url")) {
server.send(400, "text/plain", "Missing URL");
return;
}
const char* url = doc["url"];
Serial.println("Received station URL: " + String(url));
File file = LittleFS.open("/stations.json", "r");
StaticJsonDocument<1024> stationsDoc;
JsonArray stations;
// Если файл существует и валиден
if (file) {
DeserializationError readError = deserializeJson(stationsDoc, file);
file.close();
if (readError) {
Serial.println("Error parsing stations file, creating new one.");
stations = stationsDoc.to<JsonArray>();
} else {
stations = stationsDoc.as<JsonArray>();
}
} else {
Serial.println("No stations file found, creating new one.");
stations = stationsDoc.to<JsonArray>();
}
// Проверка на количество станций <= 8
if (stations.size() >= 8) {
server.send(400, "text/plain", "Station list is full (maximum 8 stations)");
return;
}
// Добавление новой станции
JsonObject newStation = stations.createNestedObject();
newStation["url"] = url;
// Сохранение обновленного списка в файл
file = LittleFS.open("/stations.json", "w");
if (!file) {
server.send(500, "text/plain", "Error opening stations file for writing");
return;
}
serializeJson(stationsDoc, file);
file.close();
server.send(200, "text/plain", "Station added");
}
void handleDeleteStation() {
if (!server.hasArg("index")) {
server.send(400, "text/plain", "Index not provided");
return;
}
int index = server.arg("index").toInt();
File file = LittleFS.open("/stations.json", "r");
if (!file) {
server.send(500, "text/plain", "Error opening stations file");
return;
}
StaticJsonDocument<1024> stationsDoc;
deserializeJson(stationsDoc, file);
file.close();
JsonArray stations = stationsDoc.as<JsonArray>();
if (index < 0 || index >= stations.size()) {
server.send(400, "text/plain", "Invalid index");
return;
}
stations.remove(index);
file = LittleFS.open("/stations.json", "w");
if (!file) {
server.send(500, "text/plain", "Error writing stations file");
return;
}
serializeJson(stationsDoc, file);
file.close();
server.send(200, "text/plain", "Station deleted");
}
void handleRestart() {
Serial.println("Handling restart request...");
server.send(200, "text/plain", "Restarting...");
delay(1000); // Даем время отправить ответ клиенту
ESP.restart();
}
void setup() {
Serial.begin(9600);
pinMode(BLU_PIN, OUTPUT);
pinMode(MUTE_PIN, INPUT_PULLUP);
pinMode(VOL_UP, INPUT_PULLUP);
pinMode(VOL_DOWN, INPUT_PULLUP);
pinMode(RES_PIN, INPUT_PULLUP);
digitalWrite(BLU_PIN, HIGH);
delay (1000);
FastLED.addLeds<WS2812, LED_PIN, GRB>(leds, NUM_LEDS);
FastLED.setBrightness(BRIGHTNESS);
fill_solid(leds, NUM_LEDS, CRGB::White);
FastLED.show();
delay (1000);
// Инициализация LittleFS
if (!LittleFS.begin()) {
Serial.println("Failed to mount LittleFS!");
return;
}
loadWiFiCredentials();
if (wifiStatus) {
loadStations();
delay(500);
out = new AudioOutputI2S();
out->SetPinout(5, 3, 7);
out->SetGain(currentVolume);
delay(500);
reconnectStream();
}
digitalWrite(BLU_PIN, LOW);
}
void setSolidColor(const CRGB& color) {
fill_solid(leds, NUM_LEDS, color);
FastLED.show();
}
void updateLeds() {
// Обновление каждые 100 мс
if (millis() - lastLedUpdate < 100) return;
lastLedUpdate = millis();
// Если эффект выключен, гасим светодиоды и выходим
if (!enableLedEffect) {
fill_solid(leds, NUM_LEDS, CRGB::Black);
FastLED.show();
return;
}
if (isMuted) {
setSolidColor(CRGB::Green); // Логика светодиодов при выключении звука
return;
}
if (ledMode == "White") {
setSolidColor(CRGB::White);
} else if (ledMode == "Blue") {
setSolidColor(CRGB::Blue);
} else if (ledMode == "Green") {
setSolidColor(CRGB::Green);
} else if (ledMode == "Red") {
setSolidColor(CRGB::Red);
} else if (ledMode == "Fire") {
static uint8_t heat[NUM_LEDS];
for (int i = 0; i < NUM_LEDS; i++) {
heat[i] = qsub8(heat[i], random(0, 50));
}
for (int i = NUM_LEDS - 1; i >= 2; i--) {
heat[i] = (heat[i - 1] + heat[i - 2] + heat[i - 2]) / 3;
}
if (random(0, 10) > 7) {
int sparkIndex = random(0, 3);
heat[sparkIndex] = qadd8(heat[sparkIndex], random(160, 255));
}
for (int i = 0; i < NUM_LEDS; i++) {
leds[i] = HeatColor(heat[i]);
}
} else if (ledMode == "Rainbow") {
static uint8_t hue = 0;
fill_rainbow(leds, NUM_LEDS, hue, 7); // 7 - шаг изменения цвета
hue += 1; // Смещение для анимации
} else if (ledMode == "Strobe") {
static uint32_t lastBlinkTime = 0;
static bool ledOn = false;
if (millis() - lastBlinkTime >= 100) { // Интервал мигания 100 мс
lastBlinkTime = millis();
ledOn = !ledOn;
setSolidColor(ledOn ? CRGB::White : CRGB::Black);
}
return; // Strobe уже обновил FastLED
}
// Обновляем светодиоды в конце (если не вернулись раньше)
FastLED.show();
}
void blinkRedLeds() {
while (true) {
// Обновление каждые 100 мс
if (millis() - lastLedUpdate >= 100) {
lastLedUpdate = millis();
// Интервал мигания 500 мс
if (millis() - lastBlinkTime >= 500) {
lastBlinkTime = millis();
ledOn = !ledOn;
fill_solid(leds, NUM_LEDS, ledOn ? CRGB::Red : CRGB::Black);
FastLED.show();
}
}
}
}
void handleStationChange(int direction) {
if (direction > 0) {
currentStationIndex = (currentStationIndex + 1) % radioStations.size();
} else {
currentStationIndex = (currentStationIndex - 1 + radioStations.size()) % radioStations.size();
}
Serial.println("Switched to station: " + radioStations[currentStationIndex]);
reconnectStream();
}
void handleButtons() {
static unsigned long volUpPressTime = 0;
static unsigned long volDownPressTime = 0;
static unsigned long resPressTime = 0;
int muteState = digitalRead(MUTE_PIN);
int nextState = digitalRead(VOL_UP);
int prevState = digitalRead(VOL_DOWN);
int resState = digitalRead(RES_PIN); // Считываем состояние пина RES_PIN
unsigned long currentTime = millis();
// Обработка кнопки MUTE
if (muteState == LOW && currentTime - lastButtonPressTime >= 300) {
isMuted = !isMuted;
if (isMuted) {
enableLedEffect = true;
ledMode = "Green";
out->SetGain(0);
reconnectStream();
} else {
enableLedEffect = true;
ledMode = "RGB";
out->SetGain(currentVolume);
reconnectStream();
}
lastButtonPressTime = currentTime;
}
// Удержание кнопки VOL_UP
if (nextState == LOW) {
if (volUpPressTime == 0) volUpPressTime = currentTime; // Запоминаем время начала удержания
if (currentTime - volUpPressTime >= 1000) { // Если удерживаем более 1 секунды
handleStationChange(1); // Переключаем на следующую радиостанцию
volUpPressTime = 0; // Сбрасываем время удержания
lastButtonPressTime = currentTime; // Сбрасываем задержку для дребезга
}
} else if (volUpPressTime > 0) { // Если кнопку отпустили
if (currentTime - volUpPressTime < 1000) { // Если удержание меньше 1 секунды
if (isMuted) {
isMuted = false;
ledMode = "RGB";
out->SetGain(currentVolume);
reconnectStream();
}
if (currentVolume < 1.0) {
currentVolume += 0.03;
if (currentVolume > 1.0) currentVolume = 1.0;
out->SetGain(currentVolume);
}
}
volUpPressTime = 0;
}
// Удержание кнопки VOL_DOWN
if (prevState == LOW) {
if (volDownPressTime == 0) volDownPressTime = currentTime; // Запоминаем время начала удержания
if (currentTime - volDownPressTime >= 1000) { // Если удерживаем более 1 секунды
handleStationChange(-1); // Переключаем на предыдущую радиостанцию
volDownPressTime = 0; // Сбрасываем время удержания
lastButtonPressTime = currentTime; // Сбрасываем задержку для дребезга
}
} else if (volDownPressTime > 0) { // Если кнопку отпустили
if (currentTime - volDownPressTime < 1000) { // Если удержание меньше 1 секунды
if (isMuted) {
isMuted = false;
ledMode = "RGB";
out->SetGain(currentVolume);
reconnectStream();
}
if (currentVolume > 0.0) {
currentVolume -= 0.03;
if (currentVolume < 0.0) currentVolume = 0.0;
out->SetGain(currentVolume);
}
}
volDownPressTime = 0; // Сбрасываем время удержания
}
// Обработка RES_PIN
if (resState == LOW) {
if (resPressTime == 0) resPressTime = currentTime; // Запоминаем время начала удержания
if (currentTime - resPressTime >= 1000) { // Если удерживаем более 1 секунды
stopAudioStream(); // Остановка воспроизведения аудиопотока
WiFi.disconnect(); // Отключение от Wi-Fi
WiFi.mode(WIFI_OFF);
wifiStatus = false;
startAccessPoint(); // Запуск точки доступа
Serial.println("Access Point mode activated.");
resPressTime = 0; // Сбрасываем время удержания
}
} else if (resPressTime > 0) { // Если кнопку отпустили
if (currentTime - resPressTime < 1000) { // Короткое нажатие
if (!enableLedEffect) {
enableLedEffect = true;
Serial.println("LED effects enabled.");
} else {
enableLedEffect = false;
Serial.println("LED effects disabled.");
}
}
resPressTime = 0; // Сбрасываем время удержания
}
}
void stopAudioStream() {
// Останавливаем MP3 генератор
if (mp3) {
if (mp3->isRunning()) {
mp3->stop(); // Останавливаем воспроизведение
}
delete mp3; // Удаляем объект
mp3 = nullptr; // Сбрасываем указатель
}
// Очищаем буфер
if (buff) {
delete buff; // Удаляем объект буфера
buff = nullptr; // Сбрасываем указатель
}
// Очищаем источник данных
if (file) {
delete file; // Удаляем объект источника данных
file = nullptr; // Сбрасываем указатель
}
// Сбрасываем громкость на выходе
if (out) {
out->SetGain(0); // Устанавливаем громкость в 0
out->stop(); // Останавливаем выходной поток
}
// Логируем завершение
Serial.println("Audio stream stopped successfully.");
}
void loop() {
if (wifiStatus) {
if (mp3 && mp3->isRunning() && !isMuted) {
if (!mp3->loop()) {
setSolidColor(CRGB::Red);
delay(250);
reconnectStream();
}
} else {
if (ledMode != "Fire") {
ledMode = "Fire";
}
}
handleButtons();
} else {
if (ledMode != "Blue") {
ledMode = "Blue";
}
server.handleClient(); // Обработка входящих HTTP-запросов
delay(10);
}
updateLeds();
}
- В коде для управления светодиодами прописано несколько эффектов, к примеру FIRE, его легко можно помеянть на другой режим изменив 2 строчки в цикле loop. Эффекты и режимы работы LED с запасом на будущие варианты визуализации состояния контроллера.
- Надеюсь код организован достаточно логично, функции имеют понятные названия, что должно облегчить чтение и понимание.
- Используются стандартные библиотеки и компоненты, надеюсь получился код универсальный и понятный для других разработчиков.
- Если у вас возникнут дополнительные вопросы или потребуется помощь, дайте знать! Удачи с вашим проектом!.