Приєднуйся до нашої Telegram-групи — там я особисто відповідаю на запитання, ділюсь порадами, схемами, кодом і лайфхаками зі збирання та налаштування.
Перейти в Telegram

NEWS

Iнтернет-радіо на ESP32s2 mini.

Керівництво з налаштування та підключення

 Для забезпечення стабільної роботи вашого інтернет-радіо на базі ESP32s2 необхідно дотримуватися кількох важливих кроків. Одним із ключових аспектів є встановлення радіатора на мікроконтролер, щоб уникнути перегріву та нестабільної роботи. Під час тестування було виявлено, що без радіатора пристрій може працювати ненадійно.

Підготовка та запис веб-інтерфейсу

 Перед використанням інтернет-радіо необхідно записати файл index.html у пам'ять мікроконтролера. Цей файл є веб-інтерфейсом, через який ви зможете налаштовувати ім'я мережі та пароль, а також редагувати список радіостанцій. Дотримуючись інструкцій із попередньої статті, ви легко впораєтеся з цим завданням і одразу зможете використовувати веб-інтерфейс для налаштування пристрою..

Основні функції пристрою

 Після встановлення веб-інтерфейсу та завантаження коду ваш пристрій отримує наступні функції:

  • Регулювання гучності та вимкнення звуку кнопками: Зручне керування гучністю та можливість вимкнути звук натисканням кнопки.
  • Перемикання радіостанцій: Довге натискання (1 секунда) кнопки збільшення або зменшення гучності дозволяє перемикатися між радіостанціями.
  • Багатофункціональна кнопка на піні 12: Ця кнопка вимикає світлодіоди або, при тривалому натисканні, вимикає інтернет-радіо та переводить мікроконтролер у режим точки доступу для редагування списку радіостанцій або налаштування WiFi.
  • Стани увімкнення: Біле свічення / витяг налаштувань WiFi із пам'яті: Помаранчеве свічення / підключення до WiFi: Зелене свічення / Відтворення першої радіостанції зі списку: Ефект вогонь.
  • Увімкнення налаштування: Синє свічення / Запуск точки доступу: Ефект райдуги.


Компоненти для підключення

Для складання та роботи пристрою вам знадобляться такі компоненти:

  • ESP32s2 mini: Мікроконтролер, який керує всією системою.
  • Модуль підсилювача на MAX98357: Забезпечує якісне відтворення звуку.
  • LED-кільце на 12 адресних світлодіодів WS2812: Для візуальних ефектів та індикації стану.
  • Динамік 3 Вт (57 мм діаметр): Для відтворення звуку.
  • Чотири тактові кнопки 6х6х5 мм.
  • Акумуляторна батарея 18350: Забезпечує живлення пристрою та компактні розміри.
  • Модуль заряджання акумулятора TP4056 Type-C із функцією захисту: Для безпечного заряджання батареї.
  • Підвищувальний DC-DC модуль на 5 вольт: Забезпечує стабільну напругу для роботи пристрою.
  • Мікровимикач двопозиційний: Для увімкнення та вимкнення пристрою.
  • Контакти для батарей 18350.
  • Надрукувати корпус (файли доступні на сайті) або замовити вже надрукований на 3D-принтері корпус.

Підключення компонентів

 Підключення всіх компонентів не має викликати складнощів, оскільки теоретично розібратися з призначенням пінів за кодом буде нескладно. Ви також можете переназначити піни так, як вам буде зручніше.

 Зібравши всі компоненти та виконавши всі кроки з налаштування, ви отримаєте мініатюрне та функціональне інтернет-радіо, кероване мікроконтролером 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 запасом на майбутні варіанти візуалізації стану контролера.
  • Сподіваюся, код організований достатньо логічно, а функції мають зрозумілі назви, що має полегшити читання та розуміння.
  • Використовуються стандартні бібліотеки та компоненти, тож код має бути універсальним і зрозумілим для інших розробників.
  • Якщо у вас виникнуть додаткові питання або знадобиться допомога, дайте знати! Успіхів із вашим проєктом!