Everlasting Internet Radio

by BlackberryJamMan in Circuits > Audio

590 Views, 5 Favorites, 0 Comments

Everlasting Internet Radio

Banner.jpg

This Instructable will show you how to make an internet radio that never needs to have its batteries replaced....hence, an everlasting radio.

Demonstration here

Supplies

Item Quantity Specification

Microprocessor 1 DOIT Esp32 DevKit v1 (30 pins)

Audio Amplifier 1 MAX98357A I2S 3W Class D

OLED 1 0.91" OLED Display

Charging Module 1 TP5000 Module

Rotary Encoder & Switch 1 KY-040

Solar Panel 1 SOLAR-IXYS

Speaker 1 SPEAKER (Vibration type)

LiFePo4 2 18650 battery cell

Battery Holder 1 18650 x 2 (Parallel)

Switch 1 Single Pole Single Throw

Resistor 1 10kΩ

Resistor 1 100kΩ

Resistor 1 270kΩ

Capacitor 1 10μF

Diode 1 1N4001

Stripboard 1 20 columns x 23 rows

Wire Various colours

Solder Flux cored

Header pins (female) 2 x 15 pins For ESP32

Header pins (female) 1 x 4 pins For OLED

Header pins (female) 1 x 5 pins For Rotary encoder

Heatshrink tube Various

Plant Pot 2 Keter Beton Cube 11.5 cm

Sticky Tape 1 Reusable nano tape 30mm wide

A Single Use-case Radio?

Angel Fish Radio.png
Bush DAB Radio (1).jpg

I like to have a radio in my bathroom as I find it useful to keep up to date with the latest news and the regular time checks ensure I don’t linger when getting ready for work in the morning. Over the years I have had a series of different radios and each one has had its limitations. My simple AM shower radio in the shape of a fish was fun, but the suckers that held it onto the shower wall were unreliable and often chose to release their grip at the most inopportune time – usually in the small hours of the morning – the sudden crash provided an unpleasant way to wake up!

I decided to invest in a DAB radio. The reception was not great so I only had access to a limited range of stations, but more importantly, it seemed to devour the batteries that were powering it. This was such a problem, that I returned the radio to the shop to ask for a replacement, only to discover that the replacement was equally power hungry. This radio was moved to the kitchen, where there were a plentiful supply of power sockets.

I decided to build my own radio, with a Raspberry Pi at its heart. I installed the unit in the loft void along with some ceiling mounted speakers. This was a great success and it was left online for a period of several years without needing to be rebooted. I kept it connected to the network so that it was ready on demand as the boot up time was significant. It was controlled with an IR remote control and worked really well.

I recently upgraded my bathroom and removed the ceiling speakers, so I wanted to design a new radio that I could use in this space. This lead me to think about my design requirements. Most often, manufacturers want to build the most versatile device they can, with a range of features, so that it can be used in a variety of situations. Each use case is different for each user, so a ubiquitous design ensures higher sales figures.

Design Considerations

Solar Radio Fritzing stripboard_schem.png

In my case, I wanted to design a radio that would only be used in the bathroom. I would not be able to plug it in to the mains power supply for safety reasons, so it would need to be battery operated. I would not be listening to the radio all day long; in fact, I would only be using it for around 20 minutes each day – just enough time for my ablutions. I also tend to listen to the same radio station all the time, so although it would be good to have access to a range of stations, these did not need to be extensive. I thought it would be useful to have a display to show the station that was playing, as well as the volume setting, but it should be a minimalist design.

After some thought, I decided not to use a Raspberry Pi. The boot up time is quite long and it has significant power requirements if powered up, ready to play on demand. Instead, I chose an ESP32 module. It has a great range of features including a dual core processor, analogue inputs, non-volatile memory and an I2C bus – all of which, I planned to use in the design.

I needed to power the device with batteries but did not want to have to replace them frequently. I would prefer to not have to remove them at all, so I decided to have a go at recharging them with solar power. The 3.7 volt li-ion cells I had liberated from an old laptop were my regular choice, but they often require some form of voltage conversion down to 3.3 volts which can waste some power. Again, I was once woken in the night to a high pitched squealing sound as the buck voltage converter I had used, was just on the cusp of its ability to provide the correct voltage from the li-ion cells. A weird hiss and screech was emitted from the radio speaker as it cycled through its boot up sequence and instantly failed as the power dropped out. Instead, I found some LiFePO4 cells that were rechargeable but were nominally rated at 3.2 volts, with a maximum voltage of 3.6 volts when being charged. This should be within the limits to power a 3.3 volt rated device without the need for any voltage conversion. I also liked the other advantages of these batteries compared to li-ion cells including longer lifespan, greater depth of discharge and their ability to be completely recycled. As their chemistry is more stable, they are less likely to be a victim of thermal runaway, which may result in a battery fire – a big bonus for a battery that will be constantly on charge with a solar panel.

Breadboard Prototype

Testing the Breadboard Prototype
Solar Radio Fritzing 1_bb.png
Breadboard Radio 1.JPG

Speaker Case

Fit Pots Together
Testing the Vibration Speaker
Rotary Encoder on Bracket.jpg

I considered making a custom speaker case using 3D printing, but then I found a square plant pot made from recycled plastic that had the ability to stack. This meant I could hide the radio circuitry in a lower pot and stack another on top, sealing the lower compartment. The whole assembly could then be used as a real plant pot, housing the plant in the upper compartment. This design had a great advantage that the circuitry would be completely encased in a waterproof lower pot but I still had to overcome how I would generate a good sound output from this arrangement. I did consider drilling a hole in the side of the pot to mount a speaker on one of the faces. A series of holes in the form of a grid might afford the speaker cone some better protection. My research uncovered a vibration speaker. This device simply attaches to a surface and vibrates whatever it is attached to, to provide the sound waves. As the plant pot had some flex, I thought it was worth experimenting with this. An amplifier was used to drive the vibration speaker and I located it in different positions, trying to maximise the sound output. I had an early indicator of success when I first connected it up as the speaker was resting on my workbench. The whole bench resonated with the sound and it was crystal clear – these are very impressive little devices and there is no need to expose a traditional speaker cone to a wet environment.

Power Calculations

Current Measurements
Potential Divider.PNG

The radio used around 120mA when it was playing and a single LiFePO4 cell had a capacity of 1800 mAh. In theory, this might last for 15 hours, but I decided to design in a big safety factor and used two of the batteries in parallel.

The cells provided a voltage in the range 3.2 – 3.6 volts, around the ideal voltage of 3.3 volts to power the ESP32 board. I thought it would be useful to know what the voltage state of the batteries was. The ESP32 has an on-board analogue to digital converter (ADC) which can be used to monitor the battery voltage, but the input voltage cannot exceed 1 volt, so a potential divider reduces the input to an appropriate level. The calculation works out the percentage of power left.

In trials so far, the radio has performed brilliantly. I have been monitoring the percentage charge and it usually starts around 95% and drops to 90% for 20 minutes of playing time. This suggests a theoretical playing time of 6 hours.

The solar panel is rated at 5v 100mA and this is used to trickle charge the batteries throughout the day (as the radio is located on the windowsill of my bathroom, it has strong sunlight in the morning). A TP5000 module is used to charge the batteries as it has the option to charge LiFePO4 cells up to the maximum of 3.6 volts (just leave the contact pads unsoldered - see the picture).

I was conscious to remove any drain on the batteries to preserve their maximum capacity. I may have been over cautious, but I did not include the illumination on the on/off switch for this reason. Also, the TP5000 included a bi-colour LED to show when the batteries were charging or when they had reached the correct voltage. This was useful during the testing period, but I decided to leave it out of the final design as I knew that the charging module was working and I was able to measure the battery voltage directly using the ADC to see the state of charge.

State Machine Design

State Machine.PNG

I used a rotary encoder and switch to control my radio interface.

The user interface was based on a simple state machine that cycled through displaying the current radio station, the volume level and the battery level using the push switch. The state is changed by clicking the button, the settings are changed by rotating the radio knob.

Dual Core Processor

The microcontroller I selected was an ESP32 Devkit V1. These are cheap yet powerful devices that have dual core processors. As I was experimenting with the radio, I managed to stream the radio show over the internet and play it through the speaker. I also wanted to drive an OLED display to show useful information, such as the current station. This posed a problem as the microcontroller was working hard to download the radio stream and decode it. This process could not be interrupted to drive the OLED display as it would result in choppy playback of the radio stream. Fortunately, the ESP32 has dual core processors, so the radio streaming was allocated to one core and the display driving to the other. There was plenty of processing power, so it was possible to include some fancy scrolling text to fit long messages onto the tiny 0.91” display. I was pleased with this arrangement as it provided the required information from a tiny display screen, although this was clearly visible as the whole display area (128 x 32) was used to show the largest font that would fit in the height of the display.

Flash Memory

It is quite useful for the radio to remember the latest settings for the radio station and volume level. The device is programmed to store these values permanently in flash memory and it retrieves them when booting up. Each time a new station or volume setting is changed, the old values are over-written. This means the last recorded value will be loaded when the device is booted. You just switch it on….and it works!

Stripboard Manufacture

Solar Radio Fritzing stripboard_bb.png
Solar Radio Fritzing stripboard breaks_bb.png
TP5000 3v6.png
IMG_8415.JPG
IMG_8417.JPG
IMG_8418.JPG
IMG_8421.JPG
IMG_8423.JPG
IMG_8426.JPG

The stripboard (20 columns x 23 rows) needs to have cuts made to isolate each of the components. The easiest way to do this is to use a 3.5mm drill to gently break the track. You can twist the drill between your fingers to ensure you only cut the track as far as necessary. The images above has a red star on the corner as a point of reference.

Software

I used VS Code to program the ESP32. Learning how to use this IDE is beyond the scope of this instructable, but I will include all of my source code for you to experiment with. If you are happier using the Arduino IDE, you should be able to get it to work with the main.cpp code, once you have installed all the libraries.

This is the platformio.ini file

[env:esp32doit-devkit-v1]
platform = espressif32@3.5.0
board = esp32doit-devkit-v1
framework = arduino
monitor_speed = 115200
lib_deps =
    esphome/ESP32-audioI2S@^2.0.6
    igorantolic/Ai Esp32 Rotary Encoder@^1.4
    olikraus/U8g2@^2.34.17

The main code (main.cpp)


#include <Arduino.h>
#include <WiFi.h>
#include <Audio.h>
#include <Preferences.h>
#include <AiEsp32RotaryEncoder.h>
#include <U8g2lib.h>
#include <Wire.h>
#include <string>
#define ENCODER_A_PIN 32  // dt
#define ENCODER_B_PIN 33  // clk
#define ENCODER_SWITCH 35 // sw
#define ENCODER_STEPS 4
#define I2S_DOUT 25
#define I2S_BCLK 26
#define I2S_LRC 27
#define ADC_PIN 36


String ssid = "Your WiFi SSID";
String password = "Router Password";
String hostname = "ESP32 Solar Radio";
String radio_station;
String temp_radio_station;
String current_track =" ";
String leading_spaces = "      ";
String stream_meta_data;


const int delay_time = 500;
const char *station[] = {// station array stores url of stream
  "http://ice-the.musicradio.com/LBCUK",
  "http://vis.media-ice.musicradio.com/CapitalMP3",
  "http://media-ice.musicradio.com/ClassicFMMP3", //128K stream
  "http://77.68.84.201/stream/3/", // relaxing jazz
  "https://media-ssl.musicradio.com/Gold",
  "https://media-ssl.musicradio.com/Heart70s",
  "http://media-ice.musicradio.com/Heart80sMP3", //128K stream
  "https://media-ssl.musicradio.com/HeartDance",
  "http://media-ice.musicradio.com/RadioX-M-90s",
  "http://media-ice.musicradio.com/RadioX-M-00s",
  "https://media-ssl.musicradio.com/SmoothChill",
};


int vol_min = 0;
int vol_max = 30;
volatile int current_volume; // volume range 0 - 30
volatile int previous_volume = current_volume;
volatile int current_station; // station stored in NV ram
volatile int previous_station = current_station;
volatile byte state = 0; //starting state
int number_of_stations = (sizeof(station)/sizeof(station[0]))-1;
int adc_reading = 0;
float battery_voltage;
int battery_percentage;


Audio audio;
Preferences preferences;
U8G2_SSD1306_128X32_UNIVISION_1_HW_I2C u8g2(U8G2_R0,-1);// -1 refers to reset pin
u8g2_uint_t offset;     // current offset for the scrolling text
u8g2_uint_t width;      // pixel width of the scrolling text (must be lesser than 128 unless U8G2_16BIT is defined - it is)
u8g2_uint_t station_width;
u8g2_uint_t track_width;


AiEsp32RotaryEncoder rotaryEncoder = AiEsp32RotaryEncoder(ENCODER_A_PIN, ENCODER_B_PIN, ENCODER_SWITCH, -1, ENCODER_STEPS);
TaskHandle_t task1;
TaskHandle_t task2;


void IRAM_ATTR readEncoderISR(){
  rotaryEncoder.readEncoder_ISR();
}


float get_encoder_reading(){
  return (float)rotaryEncoder.readEncoder();
}


void rotary_onButtonClick(){
  Serial.print("current_station: ");Serial.print(current_station);Serial.print(" ");Serial.println(radio_station);
  static unsigned long lastTimePressed = 0;
  if (millis() - lastTimePressed < 200)
    return;
  lastTimePressed = millis();
  state ++;
  if (state == 3){
    state = 0;
  }
  switch (state){
    // 0 = Adjust Station, 1 = Adjust Volume, 2 = Battery Status
    case 0:
      Serial.print("Adjust Station: ");
      rotaryEncoder.setBoundaries(0, number_of_stations, false); //built in circular queue does not seem to work
      rotaryEncoder.setEncoderValue(current_station);
      Serial.print("Station:");Serial.print(current_station);Serial.print(" ");Serial.println(radio_station);
      //u8g2.setFont(u8g2_font_inb21_mr);
      break;


    case 1:
      Serial.print("Adjust Volume: ");
      rotaryEncoder.setBoundaries(vol_min, vol_max, false); //volume range - not circular
      rotaryEncoder.setEncoderValue(current_volume);
      Serial.print("Volume: ");Serial.println(current_volume);
      break;


    case 2:
      Serial.print("Battery Status: ");
      adc_reading = analogRead(ADC_PIN);
      battery_voltage = adc_reading * 7.9 / 3050;
      Serial.print("ADC Reading: ");Serial.println(adc_reading);
      Serial.print("Battery Voltage: ");Serial.println(battery_voltage);
      break;
  }
}


void rotary_encoder_loop(){
  if (rotaryEncoder.encoderChanged()){//dont do anything unless value changed
    switch (state){
      case 0:
        current_station = get_encoder_reading();
        //Serial.print("Station: ");Serial.print(current_station);Serial.print(" ");Serial.println(station[current_station]);
        audio.connecttohost(station[current_station]);
        if (previous_station != current_station){
          preferences.putInt("stored_sta",current_station); // store the current station index in EEPROM
        }
        break;


      case 1:
        current_volume = get_encoder_reading();
        audio.setVolume(current_volume);
        if (current_volume != previous_volume){
          preferences.putInt("stored_vol",current_volume); // store the current volume in EEPROM
        }
        Serial.print("Volume: ");Serial.println(current_volume);
        break;


      case 2:
        // rotary setting does nothing for battery measurement
        break;
    }
  }
  if (rotaryEncoder.isEncoderButtonClicked()){
    rotary_onButtonClick();
  }
  previous_volume = current_volume;
  previous_station = current_station;
}


void audio_showstation(const char *info){
  Serial.print("Station: ");Serial.print(current_station);Serial.print(" ");
  radio_station = info;
  Serial.println(info);
  station_width = u8g2.getUTF8Width(info);
}


void audio_showstreamtitle(const char *info){
  Serial.print("streaminfo:  ");
  Serial.println(info);
  current_track = info;
  track_width = u8g2.getUTF8Width(info);    // calculate the pixel width of the text
}


void update_display(void *pvParameters){
  Serial.print("Scrolling message running on core ");
  Serial.println(xPortGetCoreID());
 
  for(;;){
    switch (state){
      case 0:
        stream_meta_data = leading_spaces + radio_station + " " + current_track; // add some leading spaces
        // pad out the message with some spaces so it scrolls with a gap in between
        // width of 6 leading spaces + 1 is 133 pixels - width of display is 128
        width = 171 + station_width + track_width;
        u8g2_uint_t x;
        u8g2.firstPage();
        do {
          // draw the scrolling text at current offset
          x = offset;
          do {                // repeated drawing of the scrolling text...
            u8g2.setCursor(x,26);
            u8g2.print(stream_meta_data);
            x += width;           // add the pixel width of the scrolling text
          } while( x < u8g2.getDisplayWidth() );    // draw again until the complete display is filled
        } while ( u8g2.nextPage() );
       
        offset-=1;              // scroll by one pixel
        if ( (u8g2_uint_t)offset < (u8g2_uint_t)-width )  
          offset = 0;             // start over again
        delay(1);             // wait some small delay
        break;


        case 1:
        u8g2_ClearBuffer;
        u8g2_ClearDisplay;
        u8g2.firstPage();
        do{
          if (current_volume==0){
            u8g2.setCursor(0,24);
            u8g2.print("Muted");
          }else{
            u8g2.setCursor(0,24);
            u8g2.print("Vol: ");
            u8g2.setCursor(80,24);
            u8g2.print(current_volume); // volume range will be 0 to 30
          }
        }while (u8g2.nextPage());
        break;


        case 2:
          u8g2_ClearBuffer;
          u8g2_ClearDisplay;
          u8g2.firstPage();
          do{
              u8g2.setCursor(0,24);
              u8g2.print("Bat");
              u8g2.setCursor(70,24);
              battery_percentage = (battery_voltage/3.0) * 99; // fully charged is 3.3v
              // display up to 99% so it fits in the OLED
              if (battery_percentage > 99){
                battery_percentage = 99;
              }
              u8g2.print(battery_percentage);
              u8g2.setCursor(109,24);
              u8g2.print("%");
          }while (u8g2.nextPage());
          break;
    }
  }
}


void play_the_radio(void *pvParameters){
  Serial.print("Radio stream running on core ");
  Serial.println(xPortGetCoreID());
  for(;;){
    audio.loop();
    rotary_encoder_loop();
  }
}


void setup(){
  Serial.begin(115200);
  u8g2.begin();
  u8g2.setFont(u8g2_font_inb21_mr);
  u8g2.setFontMode(0);    // enable transparent mode, which is faster
  preferences.begin("settings", false); // false means read/write access
  current_volume = preferences.getInt("stored_vol",5); // volume default 5 if none found
  current_volume = constrain(current_volume, 0, vol_max);
  current_station = preferences.getInt("stored_sta",0); // first station is default if nothing found
  current_station = constrain(current_station, 0, number_of_stations);
  rotaryEncoder.begin();
  rotaryEncoder.setup(readEncoderISR);
  rotaryEncoder.setEncoderValue(current_station); //set default
  rotaryEncoder.setBoundaries(0, number_of_stations, false); //minValue, maxValue, circleValues true|false
  Serial.print("Loaded from Preferences: Volume ");Serial.print(current_volume);Serial.print(", Station ");Serial.println(current_station);
  Serial.println("Internet Radio starting...");
  WiFi.disconnect();
  WiFi.mode(WIFI_STA);
  WiFi.setHostname(hostname.c_str());
  WiFi.begin(ssid.c_str(), password.c_str());
  while (WiFi.status() != WL_CONNECTED){
    delay(1500);
    Serial.print(".");
  }
  Serial.print("Connected to Wifi");
  Serial.print(WiFi.SSID());
  Serial.print("   IP Address: ");
  Serial.println(WiFi.localIP());


  audio.setPinout(I2S_BCLK, I2S_LRC, I2S_DOUT);
  audio.setVolume(current_volume);
  audio.connecttohost(station[current_station]);//set radio playing
  Serial.print("Number of Stations: ");
  Serial.println(number_of_stations);


  //create a task that will be executed in the Task1code() function, with priority 1 and executed on core 0
  xTaskCreatePinnedToCore(
    update_display,   // Task function.
    "Task1",          // name of task.
    20000,            // Stack size of task
    NULL,             // parameter of the task
    1,                // priority of the task
    &task1,           // Task handle to keep track of created task
    1);               // pin task to core 1 - the default core


  xTaskCreatePinnedToCore(
    play_the_radio,   // Task function.
    "Task2",          // name of task.
    20000,            // Stack size of task
    NULL,             // parameter of the task
    1,                // priority of the task
    &task2,           // Task handle to keep track of created task
    0);               // pin task to core 0
}

void loop(){// all tasks pinned to a core - don't do anything here
}

Downloads

Plant Pot Preparation

Fitting Out the Plant Pot Enclosure
IMG_8355.JPG
IMG_8357.JPG

Fitting the components to the plant pot was straightforward. The pot was marked out and holes drilled for the OLED display, rotary switch and on/off switch. A square hole was filed out to accommodate the OLED display, which was secured in place with some hot melt from a glue gun. The vibration speaker was attached to the side of the pot using sticky gel nano-tape.

Conclusion

YouTube Thumbnail.jpg

This project has been a real success. I have built the circuitry on stripboard, and it is housed out of sight within the box. The boot time takes less than two seconds to connect to the internet and start playing the radio stream which I think is acceptable performance.

As the prototype has worked really well, I am feeling inspired to create a PCB for a more permanent arrangement. There is always another design tweak that can be made – do we every really finish a project?

Although my design needs have been fully met, this radio can of course be used in different settings – it will in fact, fit more than one use case. It can be used in the garden or taken into a dark workshop and it will happily operate. The only requirements are that it should be able to regularly recharge its batteries with a strong supply of sunlight and it should be within range of a decent Wi-Fi signal. Perhaps I could also build in a USB charging jack, so it doesn’t need to rely on the solar panel…and so the inevitable feature creep starts to raise its ugly head.