heyrick1973 -at- yahoo -dot- co -dot uk

You are not reading my b.log using HTTPS. You can switch to HTTPS by clicking here.

Rick's NetRadioシ

When I got myself an ESP32, I wasn't exactly sure what I wanted to do with it. However I realised fairly quickly that it was a device with flexible GPIO ports that could connect to the Internet via WiFi, and it had a reasonable amount of memory onboard and a fast enough processor that, may be... just maybe...

An on-line radio station that I like listening to is Eagle 80s (an offshoot of Eagle FM on 96.4 in the Guildford area, where I used to live in the UK). Now Eagle makes an app to make tuning in on Android quick and simple, but I don't like using the app. Not because there is anything wrong with the app, but simply because using my phone implies plugging it into the charger and that is the part I'm not so good at (which reminds me.....).

So I wondered, how does one build a net radio (or web radio)? Well, actually it's really easy...

 

But wait, isn't there already software to do this?

Yes. There's a simple ESP32_Web_Radio which looks to be as if the VS1053 streaming radio example was modified to talk to some sort of programmable TFT screen. It's very basic, otherwise. It doesn't support redirections or...

There is also an all-singing all-dancing ESP32-Radio that plays MP3s off SD card, supports playlists, has a web server for remote control, supports half a dozen types of display, has extremely flexible and controllable I/O setup, uses the second core for smoother playback, blah blah blah.
Unfortunately the SoftAP is bugged (it threw an error about unhandled event when I tried to connect), it's hard to configure correctly, and when I managed to get it running (failing to find the VS1053 audio chip), trying to edit the settings via the web server resulted in it instead deleting them and having it revert to the broken SoftAP mode.
Given that building ESP32-Radio took longer than building the entirety of RISC OS from scratch on a Raspberry Pi, sorry, life's too short...

Excerpt of serial trace when trying to connect to ESP32-Radio:
D: WiFi Failed!  Trying to setup AP with name ESP32Radio and password ESP32Radio.
D: IP = 192.168.4.1
D: Start server for commands
D: Rotary encoder is disabled (-1/-1/-1)
E (35716) event: mismatch or invalid event, id=63
E (35717) event: default event handler failed!

 

So here's Rick's NetRadioシ. It talks to a 1602 LCD (that's two lines of sixteen characters). Restricted, but sufficient. Right now, mine says:

Sweet Child o' M
Guns N' Roses
It's enough.
It offers two buttons. No more, no less. These are for changing volume (tap) or station (hold).

There's no other bull. Rick's NetRadioシ won't offer to suggest stations you might like, it won't make tea, and it won't tell you that your favourite genre sucks. It will offer what's necessary to be a simple and to-the-point webradio player, along with simple and to-the-point code should you feel like tweaking things.
In short, Rick's NetRadioシ was written to, simply, work.

 

Before we begin - what's the 'シ'?

It's Japanese katakana (a syllabary) for the sound "shi" (shee). This was added to the end of the application title because the standard LCD character set includes katakana, and it looks a little bit like a smiley.

 

Things you'll need

It'll cost maybe €15 to €40 depending on what you source and where from (mine cost €30, I explain why below).

  • An ESP32-WROOM-32 module - it'll cost around €8 on Amazon.
  • A VS1053 audio decoder card - that'll be around €13 on Amazon.
  • A backlit 1602 (16×2) LCD with IIC backpack - that's around €7 on Amazon.
  • A tupperware box to put it in (about €3 at a local supermarket).
Note that you can get all of this cheaper on Amazon, eBay, Alibaba, etc. I specifically chose parts that could be delivered by Amazon Prime so the things would arrive "the day after tomorrow" and not in a month or two from China... It was a little over €25 and I could build it this weekend, so paying a little extra for that was considered an acceptable compromise.

Other things you will require (assumed you'll have around):

  • Two push switches (SPST)
  • Connecting wires with header attached ("jumper wires")
  • Soldering iron (if necessary for connecting the switches and/or the LCD controller)
  • 2mm bolts (preferably nylon so non-conductive)
  • Something capable of drilling small holes in thin plastic (so not a domestic power drill!)

You might want this:

  • Two electrolytic capacitors in the range 330µF to 1000µF (16V)

And, of course:

  • A phone charger or something similar to power the module (at least 1A)
  • Some speakers to plug it in to
  • The Arduino IDE with ESP32 additions (see this if you're still using XP)

But not forgetting:

 

Okay, the requirements do sound like a lot, I'll give you that. So here's a picture from the back so you can see how it all fits together.

The ESP32 is on the left, the VS1053 is on the right. The glowing red indicator is the IIC backpack that controls the LCD. The wires going up are to the two switches on the top (one to the left, one to the right). The rest of the wires connect everything together.
The two clunky orange/transparent things on the right join the +5V wires and likewise for the 0V wires - taken from the supply pins of the ESP32 and shared to the LCD and the VS1053 (and also to the buttons in the case of 0V).

 

The first thing to do

Download the archive containing the sketch program and build it with the Arduino IDE, and upload it to your ESP32. Don't change anything in the program yet. Just build it and upload it.
You'll need to add two libraries to your IDE:

If you can't get as far as successfully building the code, you'll need to tinker with the IDE until it works.

Notes:

  • The IDE takes forever. On my 2.8GHz Pentium4 box, a build and upload cycle takes around 10 minutes. I'm happily enjoying Eagle '80s right now, which is why this is not an expletive filled comment pointing out that building code for the ESP32 is an extremely miserable experience, the lethargy is unbelievable. But you can't be sad listening to The King Of Rock'n'Roll, or a group called Prefab Sprout!
  • If it bombs out at the end with a failure to connect to the device, you probably need to hold down the button marked BOOT while the IDE is trying to connect to the device. It's supposed to happen automatically, but it seems that it usually doesn't...

After uploading is done, the ESP32 should reset. On the serial monitor (it's in Tools in the IDE) you should see messages saying that Rick's NetRadioシ is initialising, failing to connect to WiFi, and rebooting itself. That's okay, this is normal.

 

The hard part - wiring it up

Here's a diagram to follow:

Not all ESP32 modules are alike, so pay attention to the pin names and not the placement on the diagram.

The first thing you may need to do is to solder the IIC backpack to the LCD panel. This is done in such a way that the IIC connector on the backpack is to the side of the LCD and not in the middle.

The first thing to do is to plug a jumper cable into 0v (GND) and (using whatever method you like) split this into three wires.

Connect a jumper wire to VIN on the ESP32 and split this two wires.

Optional (but recommended): Find a suitable way to hook an electrolytic capacitor between the 5V and GND. Beware of polarity, you must hook it up the right way around (the stripe is -ve, which means GND).
The method I used was to just push it into the jumper wire headers, and use a multimeter to check it was connected.

You may also want to hook a capacitor to the 3.3V power in the same way. The way I did this was to take a jumper cable, cut off the header from each end, and insert the capacitor into that.

Why the capacitors? Because the ESP32 has some insanely erratic power requirements, so may notice the LCD flickering like crazy without the capacitors and if the voltage drops too low the processor will crash. This seems, in my experience, to be related to WiFi signal strength - if it's weak than the ESP32 has to shout louder and that takes more power...

 

Now... Let's hook up the VS1053 first as it's the complicated one. Because this is a high speed serial protocol, it's best to keep these connections short. You might not have much choice if your jumper wires are pre-built.

VS1053 ESP32Notes
XDCS D33 Sending data
XRST EN Reset
SCK D18 SPI
MISO D19 SPI
XCS D32 Sending command
DREQ D35 Data Request
MOSI D23 SPI
DGND GND From GND split
5V VIN From 5V split

 

Now for the LCD, this is simple:

LCD ESP32Notes
GND GND From GND split
5V VIN From 5V split
SCL D22 IIC clock
SDA D21 IIC data

 

We're almost done! Just this:

Hook a button to the final wire of the GND split.
Hook the second button to that, so both buttons are connected to GND.

Now meter to see which poles of the button are joined when the button is pressed (contrary to what may seem logical, it's probably going to be the legs closest to each other and not the legs farthest).

Hook one button to D13 on the ESP32. This will be the + button.
Hook the other button to D12 on the ESP32. This will be the - button.

Think you're done? Think again!
It's now time to go back to the start and check the wiring is correct.

Think you're done? Think again!
Double check your wiring.

Now you're done. ☺

 

Power up the ESP32. You should see:

Rick's NetRadioシ
Initialising...

Followed by:

WiFi connecting:
Your_AP_here!

Followed by:

Restarting...   
 
and that happening in a loop.

If you see this, you're good to go with modifying the program that your ESP32 will be running.

 

Setting up your access point

Load "RicksNetRadio" into the Arduino IDE and look just after all the #define lines to find this:

// Now hardwire the AP's SSID and password.
char ssid[] = "Your_AP_here!"; 
char pass[] = "your_password_here";
Set "ssid" to be the name of your WiFi access point, and set "pass" to be your WiFi password. Your router's handbook or a sticker on the back should provide you with this information.
If you're using WPA2/AES and your service provider are competent, your password will be a long list of numbers (which includes A-F as it is in base 16). Double-check it.

That done, you can now rebuild and upload the program to your ESP32. Let it start up.

 

First radio use

Now that your device knows your WiFi router's name and password, it should be able to connect. Don't panic if it fails the first time and restarts. It does that for me too. It should connect the second time, unless there's a problem with your WiFi signal.

You will briefly see a message about having connected to the WiFi router, see if you can catch the signal strength report.

Connect -83dBm  
(WiFiAPname)

It's the negative number followed by "dBm". Explaining what that actually means involves a pile of hard physics and scary equations, so I'll just give you a chart:

dBm Means...
-30 A perfect signal
-30 to -50 Excellent
-50 to -60 Good
-60 to -70 Acceptable
-70 to -80 Poor
-80 to -90 Rubbish
-90... Unlikely to work
You will need at least -70dBm for reasonable listening. It will work down to around -86dBm but reception may be interrupted if other devices are using the same WiFi. The WiFi in my room is rubbish, but then it does have to pass through a solid stone wall about a metre thick!

You will then see:

Connecting to   
Eagle '80s

Followed by:

Redirecting...  
 
Then:
Connecting to   
Eagle '80s

You may briefly see:

Eagle '80s      
128kbit MP3
before being replaced by the name of the current song and its artist, if the station is currently broadcasting this information (it usually does).

 

Take a moment to enjoy whatever Eagle '80s is playing. I was born in 1973 (you might have guessed that from my email address!) so I was a teenager in the '80s. This is the stuff of my childhood. I never made a mix tape for a girl as I went to an all-boys boarding school. Made some mix tapes for myself, recording off the radio. My school being near the south coast, the radio I used to listen to then was Radio Mercury (which in time was related to Eagle).

 

It goes wrong

Audio glitching? ESP32 crashes and doesn't respond to the buttons?
If the LCD is flickering a lot, then you'll either need to add capacitors, or you'll need bigger capacitors. They must be electrolytic (the can type).

Audio glitches but no or minimal LCD flickering?
Check the wires to the VS1053 are snug and not at all loose, especially the one between XRST and EN.

The display says "No data,retrying" several times.
Some stations may do this (BBC Radio 4 always does). The radio will wait for a moment and then try again. It might take two or three attempts.

There's no track name showing on the display.
Look at the debug information (serial monitor) when selecting the station. You should see something mentioning "StreamTitle".
If it reads: StreamTitle=' - '; this means that there is currently nothing to show (adverts? news program?).
However if it reads: StreamTitle=''; then this means that the station either isn't broadcasting metadata at the moment (if it's a station that you expect to show infomation), or doesn't broadcast metadata at all.

 

Radio controls

You have two buttons...

Tapping + will increase the volume. You'll briefly see a report of the current volume, and a set of bars to represent that pictorially.

Holding down + (for more than half a second) will change to the next station in the list.

Tapping/holding the - button decreases the volume or changes to the previous station.

The station list wraps, so pressing + at the end will wrap around to the beginning (and vice versa).

Your radio will receive regular bits of "metadata" to indicate what the current title/artist is. However if reception is poor and data is lost, the metadata synchronisation will be lost. If this happens, your radio will try to reconnect to the station (to resync). If this happens too often and moving to a better reception area is not possible, then simply briefly press both buttons at the same time. This will toggle whether or not metadata is requested.
If it is not being requested, you won't get track information.

 

The stations

"Out of the box", your radio will provide five stations:

Eagle '80s Music from the eigties (metadata, usually)
Eagle 96.4FM Local radio for the Guildford (UK) area (metadata, usually)
BBC Radio 4 FM The BBC's talky station (no metadata)
Alouette Contemporary (FRENCH) (no metadata)
JPopsuki Contemporary (JAPANESE) (metadata)

Note that JPopsuki often provides title/artist names in UTF-8 and written in Japanese. This is not something that can sensibly be translated into something readable, so you'll probably see something like this on the display:

This will likely apply to any station using a character set other than basic ASCII, and moreso if it's a non-Latin language.

 

Editing the stations

Look in the source code until you see:
struct stationdef station[5] =
{
   // First station - this is the station played at startup
   "Eagle '80s",
   "streaming.ukrd.com",  // may be updated if there's a redirection
   "/eagle-80s.mp3",      // may be updated if there's a redirection
   80,
It's a simple C struct. You will see that it is repeated five times with different information.

Simply edit the information as follows. The first line is the station name that will be shown on the LCD (16 chars max for the LCD). The second line is the host that supplies the stream. The third line is the path to the stream (note that some stations, such as Eagle) use a redirection. Do not store the redirection address as it may be time limited. The radio software understands redirections, so just give it the path quoted.
The final thing? This is the port. If no port is specified, assume this is 80.

If you have more or less than five stations, change the '5' in the station definition (it's in square brackets), and then look down a little in the source code until you see:

int  stationcount = 5;    // How many stations we have defined

Your ESP32 module has plenty of memory, you can add lots of stations, but note that the practical limitation of how many you can add will depend upon how many times you're willing to press the button to get to the one you want to listen to.

Your current station is not remembered from session to session. When the radio starts up, it will play the first station in the list. So put your favourite station there!

When you have defined all your favourite stations, rebuild the software and upload it to your ESP32. Job done!

 

But, wait, how to find stations?

If you live in the UK and wish to listen to UKRD channels (a lot of local radio stations), there is a list at http://streaming.ukrd.com/.
Pick the mp3 one and place your mouse over the RAW link.
Your browser's status bar should tell you where the link goes. In the case of Eagle '80s, it's:
http://streaming.ukrd.com/eagle-80s.mp3

The host is the bit after "http://" and before the next "/". In this case, "streaming.ukrd.com" and the path is everything after that, in other words "/eagle-80s.mp3".

For other stations, they may offer a WinAmp playlist (a pls file). Load this into a text editor and you'll see something like this:

[playlist]
numberofentries=1
File1=http://213.239.204.252:8000/stream
Title1=JPopsuki Radio
Length1=-1
version=2
The IP address is the same as the domain "jpopsuki.fm", so for this station:
The host is either "jpopsuki.fm" or "213.239.204.252". The name is preferable.
The path is /stream.
And you'll note something else - there's a number in there. That's the port to use (instead of the default 80). The port is, therefore, 8000.

How to tell if the IP address matches the domain?

Go to the command line and ping the domain...

*ping jpopsuki.fm
PING jpopsuki.fm (213.239.204.252): 56 data bytes
64 bytes from 213.239.204.252: icmp_seq=0 ttl=52 time=41.700 ms
[etc]
You can see it's translated the name to an IP address, and it's the same as the one given.

 

The finishing touch

Sticking it in a box. You've seen photos of mine. How you do it is entirely up to you.

 

Okay, if you aren't a geek, you can stop reading. You have a working netradio/webradio that will run autonomously and just get the job done.

 

I'm now going to take the software apart and explain how it works. So, seriously, if you aren't a geek, it's time to turn on your shiny new radio and turn off your browser. ☺

 

Source code - definitions and setup

Note that Rick's NetRadio is open source, provided under the EUPL licence version 1.1 - you can read the licence in the European language of your preference at http://ec.europa.eu/idabc/eupl.html.
For those unfamiliar with the EUPL, it is an OSI-approved open source licence, however it is properly "open", not that parody of restrictions that is the GPL, and it has no viral element (indeed, it is quite the opposite). EUPL is open, GPL is not.

/* Rick's little internet radio

   For the ESP32. This is a no-bull version. It will connect, play a station
   (supporting redirection), and handle displaying metadata on an LCD for
   title/artist.

   Version 0.04  1st February 2019

   http://www.heyrick.co.uk/blog/index.php?diary=20190203

   Licenced under the EUPL (v1.1 only).
*/


// Include some standard libraries
#include <WiFi.h>                   // WiFi support
#include <HTTPClient.h>             // Fetch stuff from the internet
#include <esp_wifi.h>               // ESP32 specific WiFi functions
#include <Wire.h>                   // IIC


// Now include libraries to work with the hardware we're using
//   VS1053 is from https://github.com/baldram/ESP_VS1053_Library
//     download it and install it from zip file
//   LiquidCrystal_PCF8574 is available in the library manager
//     it's the one by Matthias Hertel
#include <VS1053.h>                 // VS0153 based MP3 decoder board
#include <LiquidCrystal_PCF8574.h>  // IIC connected 1602 LCD
To reiterate, you'll need to install two libraries in order to build the code - one to talk to the audio decoder IC (on GitHub), and one to talk to the LCD (in library manager).

// We're using the standard SPI pins, but we will need to define the
// extra pins for CS, DCS, and DREQ.
#define VS1053_CS    32
#define VS1053_DCS   33
#define VS1053_DREQ  35

// The volume level goes from 0 (off) to 100 (max).
// This volume provides a reasonable level to give me  music without
// disturbing others when plugged into speakers beside my monitor.
// (I'll add volume control buttons later on)
#define INITVOLUME   82

// The 1602 LCD is controlled by a PCF8574 with a default base address of &27
#define IICADDR      0x27

// Buttons
//    Button           Short press    Long press
//      + (next)       Vol+           Next station
//      - (prev)       Vol-           Previous station
#define NEXTBUTTON   13
#define PREVBUTTON   12

#define ACTIONNONE   0
#define ACTIONPRESS  1
#define ACTIONLONGPRESS 2

Here is the WiFi access point configuration. This must be amended to match your router's credentials...

// Now hardwire the AP's SSID and password.
char ssid[] = "Your_AP_here!";
char pass[] = "your_password_here";


// Project ID (must be <16 chars for LCD)
char appname[] = "Rick's NetRadio\xBC"; // &BC is "shi" in Katakana, like a little smiley :)
char hostname[] = "RicksNetRadio";      // must be UNIQUE if you have more than one radio...

// Define radio stations
typedef struct stationdef
{
   char name[64];         // Textual name of station ("Eagle '80s")
   char host[128];        // Hostname ("streaming.ukrd.com")
   char path[128];        // Path to complete URI ("/eagle-80s.mp3")
   int port;              // Port number (80)
};

Here is the list of radio stations. There are five stations built in by default, you can have as many as you like...

struct stationdef station[5] =
{
   // First station - this is the station played at startup
   "Eagle '80s",
   "streaming.ukrd.com",  // may be updated if there's a redirection
   "/eagle-80s.mp3",      // may be updated if there's a redirection
   80,

   // Second station
   "Eagle 96.4FM",
   "streaming.ukrd.com",
   "/eagle.mp3",
   80,

   // Third station
   "BBC Radio 4 FM",
   "bbcmedia.ic.llnwd.net",
   "/stream/bbcmedia_radio4fm_mf_p",
   80,

   // Fourth station
   "Alouette",
   "broadcast.infomaniak.net",
   "/alouette-high.mp3",
   80,

   //// Fifth station
   //"RTE1",      <-- whatever this does on connect, it crashes the ESP!
   //"av.rasset.ie",
   //"/av/live/radio/adio1.m3u",
   //80,

   // Fifth station
   "JPopsuki Radio!",
   "jpopsuki.fm",
   "/stream",
   8000

   // Don't forget to update stationcount below
};

More things set up.

struct buttondef
{
   bool state;
   unsigned long press;
   unsigned long release;
   int  action;
} button[2];


// The globals
int  lcdpresent = 0;      // Set to '1' if LCD is detected
int  connected = 0;       // Set to '1' if connected to a station
int  bitrate = 0;         // The stream bitrate (in kbit)
int  metaint = 0;         // The interval of when metadata appears (usually 8192-32768)
int  bytecount = 0;       // The number of bytes to go until there's a metadata block
char metadata[4080];      // ICY metadata - global for speed
int  currentstation = 0;  // Currently selected station
int  stationcount = 5;    // How many stations we have defined
int  volume = INITVOLUME; // Current volume
char title[17] = "";      // Current song title
char artist[17] = "";     // Current song artist
int  wantmetadata = 1;    // Do we want metadata?
unsigned long lcddelay=0; // Delay for temporary messages on LCD (0=none, also is a timeout)
The only thing to note with the above is that stationcount should be changed to how many stations there are.

// Define an instance of the 16x2 LCD
LiquidCrystal_PCF8574 lcd(IICADDR);

// Define an instance of the VS1053, and give it an aligned 32 byte buffer
VS1053 audioplayer(VS1053_CS, VS1053_DCS, VS1053_DREQ);
__attribute__((aligned(4))) uint8_t buffer[32]; // word aligned for speed

// Define an instance of the WiFi client
WiFiClient webclient;

 

Source code - initialisation

The setup() function gets the system running. You will note that a lot of information is output to the serial port along the way. You could remove this if you wanted (anything beginning "Serial."), but it is useful to keep it around for debugging if you wish to fiddle with the code.

// Setup code
void setup ()
{
   char serinfo[64];

   // Set up serial port for tracing activity (debugging)
   Serial.begin(115200);
   Serial.print(appname);
   Serial.println(" starting up.");
   sprintf(serinfo, "Running on CPU %d at %dMHz, with %d memory free.",
           xPortGetCoreID(), ESP.getCpuFreqMHz(), ESP.getFreeHeap());
   Serial.println(serinfo);

The first job to do is see if there's an LCD connected. There should be, but better to specifically look for it.
Once the LCD is detected, it is initialised to a known state.

   // Is an LCD connected?
   Serial.print("Looking for LCD...");
   Wire.begin();
   Wire.beginTransmission(IICADDR);
   if ( Wire.endTransmission() == 0 )
   {
      Serial.println("found.");
      lcdpresent = 1;

      // Initialise the LCD (we can't assume anything about its state)
      lcd.begin(16, 2);
      lcd.setBacklight(127);
      lcd.home();
      lcd.clear();
      lcd.noBlink();
      lcd.noCursor();
      lcd.setCursor(0, 0);
      lcd.print(appname);
      lcd.setCursor(0, 1);
      lcd.print("initialising...");
   }
   else
   {
     Serial.println("not detected (is IICADDR correct?).");
   }

Now to set up some GPIO. There are three things that need to be done here. The first is to set GPIO2 to be an output, and set it to low. GPIO controls the blue LED which will light up when the radio is connected to a station.
That done, GPIO12 (previous) and GPIO13 (next) are set as inputs with pullup (because they are shorted to ground when the buttons are pressed).

   // Set up the blue LED (will light when connected to a station)
   pinMode(2, OUTPUT);
   digitalWrite(2, LOW); // default to off

   // Set up the previous/next buttons
   pinMode(NEXTBUTTON, INPUT_PULLUP);  // pulled up, so ground
   pinMode(PREVBUTTON, INPUT_PULLUP);  // to activate the button
   button[0].state = HIGH;
   button[1].state = HIGH;
   button[0].action = ACTIONNONE;
   button[1].action = ACTIONNONE;

Now we set up the SPI system, and call a function to set up the MP3 player.

   // Set up SPI
   SPI.begin();

   // Initialise the MP3 decoder - this takes time
   mp3_initialise();

We're almost done now, all that remains is to connect to the access point.

   // Now connect to the AP
   wifi_connect();

   // Done, we'll resume in loop...
}

For those of you used to traditional C coding, there is no main() function. In the Arduino way of working, we begin with setup(), and then execute loop() repeatedly. There are no 'programs', everything goes into the one big lump of code, as the devices in question have a flat addressing model, it's closer to firmware than application ware.
Some people like to break their project into tabs by placing plenty of code into header files, but I don't happen to believe header files are where code should be.

 

Source code - the main loop

This is called repeatedly, so if anything is to happen, it'll happen here.
void loop()
{
   // Round and round we go...
   //
   // Warning: Adding more to this loop will probably require
   //          some sort of buffering to be implemented...

   uint8_t bytes = 0;
   int  reply = 0;

As it says, because we fetch and play the data in 32 byte chunks, adding much more into loop() will likely require some sort of buffering to be implemented. A 32 byte buffer means that the radio is not tolerant of lag, hiccups, or irregular streaming speed. This could be greatly aided by a buffer in the order of 32K (which is only two seconds at 128kbit). Our 32 byte buffer is sufficient for 0.002s (a 500th of a second!).

If the radio is not connected, then loop until connected...

   // Connect?
   if ( !connected )
   {
      // Keep trying until connected (allows us to handle redirections)
      do
      {
         reply = station_connect();
         check_buttons(); // so user can choose another station
      } while ( reply == 0 );

      Serial.print("Connected to ");
      Serial.println(station[currentstation].name);
      bytecount = metaint;
   }

Now for a more complicated thing. If there is data available (there should be), then read it. Why it is complicated is because we must work in one of two modes. Either we're not reading metadata (in which case we can simply read 32 bytes and send them directly to the audio chip) or we are reading metadata.

If we are reading metadata, then it will appear in the stream at a server refined rate (usually something from 4096 bytes to 16000 bytes). The metadata does not have any header frame, it is usually a single byte with a value of zero (means no more data to follow), usually around four times per second. Because it is a single byte, we are obliged to count how many bytes have been received in order that we may handle the metadata at the right time. So what we do is read 32 bytes and write them to the audio chip until our remaining number of bytes is less than 32, in which case we read however many bytes remain. Then, when the remain counter is zero, we read the metadata before resetting the byte count for the next loop.

   // If there's data, handle it in 32 byte chunks, catering for metadata
   if (webclient.available() > 0)
   {
      // Read 32 bytes and throw them directly to the MP3 player
      // (this actualy works as long as nothing too complex happens in loop()!)

      if ( metaint != 0 )
      {
         // We have metadata, so we need to read the appropriate amounts

         if ( bytecount >= 32 )
         {
            // It's okay, we have data to go until metadata block
            bytes = webclient.read(buffer, 32);
            audioplayer.playChunk(buffer, bytes);

            // Subtract bytes from the bytecount
            bytecount -= bytes;
            if (bytecount == 0)
            {
               // If it's reached zero, check to see if there is information to read
               read_icy_metadata();
               bytecount = metaint; // reset byte count for next bit of metadata
            }
         }
         else
         {
            // We have less than 32 bytes before metadata, so read what's left
            bytes = webclient.read(buffer, bytecount);
            audioplayer.playChunk(buffer, bytes);

            // Subtract bytes from the bytecount (should equal zero!)
            bytecount -= bytes;
            if (bytecount == 0)
            {
               // If it's reached zero, check to see if there is information to read
               read_icy_metadata();
               bytecount = metaint; // reset byte count for next bit of metadata
            }
         }
      }
      else
      {
         // There is no metadata, so just read and play
         bytes = webclient.read(buffer, 32);
         audioplayer.playChunk(buffer, bytes);
      }
   }

After a chunk of data has been played, we check the state of the buttons.

   // Check the buttons
   check_buttons();

If there is a pop-up message on the LCD (like the current volume), it will have set a timeout value, so if this timeout has expired then restore the data originally on the LCD (either track/artist names, or station name).

   // LCD to twiddle?
   if ( lcddelay != 0 )
   {
      // Expired?
      if ( lcddelay < millis() )
      {
         // Yes, so restore what should normally be shown
         lcd_title();
         lcddelay = 0;
      }
   }

   // Warning: Adding more to this loop will probably require
   //          some sort of buffering to be implemented...
}

 

Source code - set up the MP3 player

This initialises the audio chip, checks it is there, sets it to MP3 mode (it starts up in MIDI mode!), and sets the default volume.
That long pause as the radio initialises? It's here...
void mp3_initialise()
{
   // Initialise the VS1053 to play in MP3 mode
   audioplayer.begin();
   if ( audioplayer.isChipConnected() == 0 )
   {
      lcd_output("VS1053 audio IC", "not responding");
      delay(2500); // nothing much we can do here...
   }
   audioplayer.switchToMp3Mode();
   audioplayer.setVolume(volume);
}

 

Source code - write to the LCD

This is simple, it takes two strings and writes them to the LCD...
void lcd_output(char *lineone, char *linetwo)
{
   // Write the two lines given to the LCD

   if ( !lcdpresent )
      return;

   lcd.clear();
   lcd.setCursor(0, 0);
   lcd.print(lineone);
   lcd.setCursor(0, 1);
   lcd.print(linetwo);

   return;
}

 

Source code - connect to WiFi

This simply starts a connection to the access point, and loops until it is connected.
The only thing of note here is that if it loops for three and a half seconds without any connection, it'll force a reset. This is because my ESP32 board does not connect to WiFi when switched on/powered up, but it will after a soft reset. No idea why, but it's an easy enough fix.
Setting the hostname doesn't work - I think I need a more complex connection routine as it would appear to need to be set at a very specific time...

void wifi_connect()
{
   // Try to connect to the AP
   int  timeout = 0;
   long sigstrength = 0;
   char sigreport[32] = "";

   lcd_output("WiFi connecting:", ssid);
   Serial.print("Trying to connect to ");
   Serial.println(ssid);

   // Kill off any previous behaviour
   WiFi.disconnect(true);

   // Start the WiFi system
   WiFi.mode(WIFI_STA);
   WiFi.begin(ssid, pass);
   WiFi.setHostname(hostname);

   // Wait while it connects
   while (WiFi.status() != WL_CONNECTED)
   {
      delay(350);
      Serial.print(".");

      // If it's taken too long, force a restart
      // (this usually works - no idea why)
      timeout += 1;
      if ( timeout > 10 )
      {
         Serial.println("Restarting...");
         lcd_output("Restarting...", "");
         delay(500);
         ESP.restart();
      }
   }

   // Report connected, and WiFi signal strength
   sigstrength = WiFi.RSSI();
   sprintf(sigreport, "Connect %lddBm", sigstrength);
   Serial.println(sigreport);
   lcd_output(sigreport, ssid);
   delay(1500); // delay so message can be seen
}

 

Source code - connecting to a station

The most complicated function by far...

First, set up the initial state, ensure the LED is off, etc.

int station_connect()
{
   // Connect to the current station, returns ZERO if did NOT connect.
   // Try again - host/path may have been updated if redirect.
   //
   String header;
   String newhost;
   char hdrbuf[128] = "";
   int  hdrposn = 0;
   int  twonewlines = 0;
   int  thisbyte = 0;
   int  lastbyte = 0;

   digitalWrite(2, LOW); // turn off blue LED
   lcd_output("Connecting to", station[currentstation].name);
   Serial.print("Connecting to ");
   Serial.print(station[currentstation].host);
   Serial.print(station[currentstation].path);
   sprintf(hdrbuf, " on port %d.", station[currentstation].port);
   Serial.println(hdrbuf);

   title[0] = '\0';
   artist[0] = '\0';
   metaint = 0;

Open a connection to the server...

   // Open a connection
   if ( webclient.connect(station[currentstation].host, station[currentstation].port) )
   {
      Serial.println("Port opened, sending request.");

Now send a GET request to the server to begin fetching the MP3 stream. If we want to use metadata, we must insert "" into the header to flag to the server that we're expecting metadata.
Metadata frames will be returned even if the station does not support or use metadata (because the client is obviously expecting to see them).

      if ( wantmetadata )
      {
         // We want Icy metadata
         webclient.print(String("GET ") + station[currentstation].path + " HTTP/1.1\r\n" +
                         "Host: " + station[currentstation].host + "\r\n" +
                         "Icy-MetaData:1\r\n" +
                         "Connection: close\r\n\r\n");
      }
      else
      {
         // We do NOT want Icy metadata
         webclient.print(String("GET ") + station[currentstation].path + " HTTP/1.1\r\n" +
                         "Host: " + station[currentstation].host + "\r\n" +
                         "Connection: close\r\n\r\n");
      }

Now we pause for half a second to give the server plenty of time to respond. If it does not, we mark that there was no reply and then pause for a second and a half before trying again.
Some stations (BBC Radio 4...) seem to need a few attempts before streaming works.

      // Now we need to read header lines and extract data
      delay(500); // wait for some data to come back
      if (webclient.available() == 0)
      {
         lcd_output(station[currentstation].name, "No data,retrying");
         delay(1500); // 1.5 sec delay before returning failed
         return 0;
      }

Now we read the header. The header is a number of textual lines (akin to the GET request) followed by a blank line. The content (MP3 data) will follow that.

      // Keep reading until we read two LF bytes in a row
      while ( !twonewlines )
      {
         // Read a line
         hdrposn = 0;
         hdrbuf[0] = '\0';
         do
         {
            // Read a byte
            thisbyte = webclient.read();
            if ( thisbyte > 31 )
            {
               // If a printable, add it to the buffer
               hdrbuf[hdrposn++] = thisbyte;
               hdrbuf[hdrposn] = '\0';
               lastbyte = thisbyte;

               // If too long, just loop back. It's probably just
               // overlong rubbish like a cookie line or somesuch...
               if ( hdrposn > 127 )
                  hdrposn = 0;
            }
            else
            {
               // It's a control character - is it an LF?
               if ( thisbyte == 10 )
               {
                  // If this byte is LF and so was the last one...
                  if ( lastbyte == thisbyte )
                     twonewlines = 1; // flag we've reached the end of the headers

                  lastbyte = thisbyte;
               }
            }
         } while ( thisbyte != 10 );
After this code, we have a null terminated string from the server that represents one line from the header.
         // We have a header line, so let's work out what it is
         Serial.println(hdrbuf);
         header = hdrbuf;

header is a String, hdrbuf is a char array. It is done like this as we want to poke bytes directly into the array when we're reading from the server, but now it would be good to use the more advanced String functions.

The first string functions we will use are to make the string lower case and then see if it is a redirection to another URI.
This is sometimes necessary for the purposes of load balancing or the like. For instance, right now http://streaming.ukrd.com/eagle-80s.mp3 will redirect to http://str2.sad.ukrd.com/eagle-80s.mp3?ts=1549204503&norequeue=1. The str2 server is a different IP address, and the "ts" parameter is some sort of timestamp (that usually expires in around an hour or so). It could be possible to fake the timestamp as it is simply "Unix time in the UK timezone" - compare with https://time.is/Unix_time_now.

         // Deal with "Location:" header for redirection
         header.toLowerCase();
         if ( header.startsWith("location: http://") )
         {
            String newhdr;
            String newhost;
            String newpath;
            int    newport = 80; // default to HTTP
            int    posn;

            // Work out where we're supposed to point to now
            Serial.print("Redirection -> ");
            header = hdrbuf;
            newhdr = header.substring(17);
            Serial.println(newhdr);

If a redirection is active, the reply will be in the form:
host_address/path_to_stream
or:
host_address:port/path_to_stream
so pull the information apart to get a host, a path, and a port (if not 80).

            // Look to split URI into host and path
            posn = newhdr.indexOf("/");
            if ( posn > 0 )
            {
               newpath = newhdr.substring( posn );
               newhost = newhdr.substring( 0, posn );
            }
            // Look to split host into host and port number
            posn = newhdr.indexOf(":");
            if ( posn > 0 )
            {
               newport = newhdr.substring( posn + 1 ).toInt();
               newhost = newhdr.substring( 0, posn );
            }

            Serial.println("Specifying new host " +
                           newhost +
                           " at " +
                           newpath);

            strncpy(station[currentstation].host, newhost.c_str(), 128);
            strncpy(station[currentstation].path, newpath.c_str(), 128);
            station[currentstation].port = newport;

            // Don't try closing the connection, it'll already have been done
            // and doing it ourselves will cause the device to hang (great code!)
            Serial.println("About to redirect");
            lcd_output("Redirecting...", "");
            delay(500); // half a second so message can be seen (briefly!)

            return 0;
         }
If we're redirecting, the new URI is pushed into the station array in place of the original data and then we return 0 to mean connection failed. The connection loop will retry, and we'll then connect to the redirected URI...

We don't bother to read the proper name of the station, but if this was desired we should look for a line beginning icy-name.

         // Read proper name of station
         // look for "icy-name:" and take substring(9)

We read the station bitrate (usually 128) to report under the station name when there's nothing else to show.

         // Read station bitrate
         if ( header.startsWith("icy-br:" ) )
            bitrate = header.substring(7).toInt();

If we have requested metadata, the interval is the number of bytes (of MP3 stream) that will occur between each metaata block.

         // Read ICY info interval
         if ( header.startsWith("icy-metaint:" ) )
            metaint = header.substring(12).toInt();
      }

Finally, show something on the LCD:

      // If we have a bitrate, write that to the LCD in line two
      if ( bitrate > 0 )
      {
         lcd_title(); // will put bitrate into second line as no title/artist info yet
      }
      else
      {
         // No bitrate given, so output the hostname in the second line
         lcd_output(station[currentstation].name, station[currentstation].host);
      }
   }

If it wasn't possible to open a connection to the server, note this, pause, then report connection failed. It'll retry, there's not a lot else we can do...

   else
   {
      Serial.println("Connection failed.");
      lcd_output(station[currentstation].name, "Connect failed!");
      delay(1500);
      return 0;
   }
If we come to here, we're connected, so flag this and turn the blue LED on.
   // Assume by now that we're connected
   connected = 1;
   digitalWrite(2, HIGH); // turn on blue LED

   return 1; // connected!
}

 

Source code - extracting the stream title metadata

Another long function.

The metadata begins with a single byte (0 to 255) that indicates how much data follows. A zero byte means no data, this is the most usual case as it isn't necessary to send song titles until the song changes.
If there is data, the actual data length is multiplied by 16, to allow a single length byte to indicate up to 4080 bytes of inline data.

The data ought to be plain text (padded with spaces or nulls?), so if we see any bytes in the range 1-8 then we can assume that synchonisation was lost. If this happens, we'll flag the station as not connected and turn off the LED. This will cause the radio to reconnect, which will resync the counters. This only happens if data is lost, which may happen as a result of a really bad connection or WiFi saturation.

void read_icy_metadata()
{
   // Read the metadata, look for a title

   int  mdatlen = 0;
   int  recurse = 0;
   int  corrupt = 0;
   char *start = NULL;
   char *end = NULL;

   // Read length byte
   mdatlen = webclient.read();

   // No data?
   if ( mdatlen == 0 )
      return;

   // The size is actually multiplied by 16, to allow up to 4080 bytes from a single size byte
   // (the payload size does not include the size byte)
   mdatlen = mdatlen * 16;

   // Read the data
   for ( recurse = 0; recurse < mdatlen; recurse++ )
   {
      metadata[recurse] = webclient.read();

      // I don't know if TAB is valid in metadata, but bytes 1-8 aren't, so if
      // we see any of those, assume the data has been corrupted and that we
      // can no longer look for metadata as we're out of sync.
      // (a poor WiFi signal can cause data loss which will trigger this)
      if ( (metadata[recurse] > 0) && (metadata[recurse] < 9) )
         corrupt = 1;
   }

   metadata[mdatlen] = '\0';
   Serial.print("Metadata \"");
   Serial.print(metadata);
   Serial.println("\"");

   // If corrupt, note this and abort
   if ( corrupt )
   {
      // There's no possible recovery, metadata does not have any distinctive
      // header, so just flag us as not connected so we can reconnect.
      lcd_output(station[currentstation].name, "Metadata SyncErr");
      connected = 0;
      digitalWrite(2, LOW); // turn off blue LED

      return;
   }

If we come here, there will be metadata. Usually, it'll be something saying StreamTitle='xxx'; with information in place of "xxx". What we must now do is sort out the artist name (given first) and the track name (given second). If we can't find the " - " separation, we'll just take the line as-is and display it under the station name.

   // Okay, now let's look for the title and artist names
   // We will see something like:
   //   StreamTitle='Kirsty MacColl - Days';
   // or:
   //   StreamTitle=' - ';
   // The latter being in between songs, during adverts, etc.
   // There may be other metadata, such as "StreamUrl='';".
   // May also be:
   //   StreamTitle='';
   // if the channel doesn't bother with information
   // (but has to support it as the client will be expecting it)

   start = strstr(metadata, "StreamTitle");
   if ( start != NULL )
   {
      // We have found the streamtitle
      start += 12; // skip over "StreamTitle="

      // Now look for the end marker
      end = strstr(start, ";");
      if ( end == NULL )
      {
         // No end, so just set it as the string length
         end = (char *)start + strlen(start);
      }

      // Quoted string?
      if ( start[0] == '\'')
      {
         // It seems as if quotes may be optional, so handle them.
         start += 1;
         end -= 1;
      }

      // Terminull the part we want
      end[0] = '\0';

      // Now start should point to a string like:
      //   "Bananarama - Venus"
      // Or maybe just " - ", or even just "".

      // Trap empty metadata
      if ( strcmp(start, " - ") == 0 )
      {
         // No metadata right now, so display the station name
         title[0] = '\0';
         artist[0] = '\0';
         lcd_title();
         return;
      }

      // Okay, sort out what's the title and what's the artist
      end = strstr(start, " - ");
      if ( end == NULL )
      {
         // Separator not found, output station title and this string
         title[0] = '\0';
         strncpy(artist, start, 16);
         artist[16] = '\0';
         lcd_title();
      }
      else
      {
         // First part is artist, second part is title
         end[0] = '\0'; // Terminate artist part
         end += 3; // Skip to start of title
         strncpy(title, end, 16);
         title[16] = '\0';
         strncpy(artist, start, 16);
         artist[16] = '\0';
         lcd_title();

         // TODO:
         // As I write this JPopsuki has just given me:
         //   StreamTitle='キャプテンストライダム - バースデー';
         // So I think in the future it might be useful
         // to detect UTF-8 and copy across as '?' or
         // something for the things that can't be shown.
         //
      }
   }
}
The actual source code contains a list of available characters in Unicode at the end here, but RISC OS (that I'm writing this on) doesn't support Unicode, and translating Latin1 gibberish from a custom character set into HTML entities is more trouble than it's worth...

 

Source code - what to display on the LCD

The comments in the code should be enough to explain what's going on here. I'll stick in some examples.
void lcd_title()
{
   // Output something on the LCD to reflect title, or failing
   // that, the station.
   char report[32] = "";

   if ( strlen(title) == 0 )
   {
      // We have no title

      if ( strlen(artist) == 0 )
      {
         // We have no artist either, so output station info
         sprintf(report, "%dkbit MP3", bitrate);
         lcd_output(station[currentstation].name, report);
Looks like:
Eagle '80s      
128kbit MP3
      }
      else
      {
         // We have an artist but no title (probably omething odd in StreamInfo)
         // so display station name and whatever is in artist.
         lcd_output(station[currentstation].name, artist);
Looks like:
StationName     
stream_meta_info
      }
   }
   else
   {
      // We have a title (and thus presumably an artist) so display it
      lcd_output(title, artist);
Looks like:
Sweet Dreams (Ar
Eurythmics
   }
}

 

Source code - button handling

The final function!

There are actually four button behaviours:

Left Right
Tap (< ½ sec) Volume - Volume +
Hold (~1 sec) Station - Station +
Long hold (> 2½ sec) SysInfo WiFi info
Tap both buttons Toggle metadata mode

We repeatedly check the buttons, note state change, and record time when button pressed (to know duration after release). It is done in this way so it doesn't need to make any use of interrupts. Button presses are not as important as to use interrupt behaviour (if anything will, it'll be the audio chip).

void check_buttons()
{
   // Check the buttons and perform actions on button release (unless BOTH pressed)

   bool level = 0;
   unsigned long ticker = 0;
   int  thisbutton = 0;
   char report[32];
   char volbar[17];
   long sigstrength = 0;

   // Deal with the buttons
   for ( thisbutton = 0; thisbutton < 2; thisbutton++ )
   {
      level = (digitalRead( (PREVBUTTON + thisbutton) ) == HIGH );
      if ( level != button[thisbutton].state )
      {
         if ( !level )
         {
            // HIGH to LOW - button press
            button[thisbutton].press = millis();
            button[thisbutton].state = level;

            // Detect if the other button has been pressed as well
            if ( ( button[thisbutton].state == LOW ) &&
                 ( button[(thisbutton ^ 1)].state == LOW ) )
            {
               // Both buttons pressed - toggle whether or not we want metadata
               wantmetadata = wantmetadata ^ 1;
               sigstrength = WiFi.RSSI();
               sprintf(report, "WiFi %lddBm", sigstrength);
               if ( wantmetadata == 1 )
                  lcd_output("MetadataEnabled", report);
               else
                  lcd_output("MetadataDisabled", report);
               connected = 0;
               digitalWrite(2, LOW); // turn off blue LED
               button[0].state = HIGH;
               button[1].state = HIGH;
               delay(1000);
               return;
            }
         }
         else
         {
           // LOW to HIGH - button release
           button[thisbutton].release = millis();
           button[thisbutton].state = level;

           // Is there an action to perform?
           ticker = button[thisbutton].release - button[thisbutton].press;

           if ( ticker > 2500 )
           {
              // Pressed for longer than two and a half seconds, display some info
              if ( thisbutton == 1)
              {
                 // +ve button, report WiFi status
                 sigstrength = WiFi.RSSI();
                 sprintf(report, "WiFi is %lddBm", sigstrength);
                 if (   sigstrength >  -30 )                           strcpy(volbar, "(perfect)");
                 if ( ( sigstrength >= -50 ) && ( sigstrength < -30) ) strcpy(volbar, "(excellent)");
                 if ( ( sigstrength >= -60 ) && ( sigstrength < -50) ) strcpy(volbar, "(good)");
                 if ( ( sigstrength >= -70 ) && ( sigstrength < -60) ) strcpy(volbar, "(acceptable)");
                 if ( ( sigstrength >= -80 ) && ( sigstrength < -70) ) strcpy(volbar, "(poor)");
                 if ( ( sigstrength >= -90 ) && ( sigstrength < -80) ) strcpy(volbar, "(very poor)");
                 lcd_output(report, volbar);
                 lcddelay = millis() + 3000; // wait for longer before going away (3 sec)
              }
              else
              {
                 // -ve button, report system status
                 // 1234567890123456
                 // Core x @ 123MHz
                 // MemFree: 123456
                 sprintf(report, "Core %d @ %dMHz", xPortGetCoreID(), ESP.getCpuFreqMHz());
                 sprintf(volbar, "MemFree: %d", ESP.getFreeHeap());
                 lcd_output(report, volbar);
                 lcddelay = millis() + 3000; // wait for longer before going away (3 sec)
              }
              return;
           }

           if ( ticker > 500 )
           {
              // Pressed for longer than half a second, it is a long press
              if ( thisbutton == 1 )
              {
                 // Next station
                 currentstation += 1;
                 if ( currentstation == stationcount )
                    currentstation = 0; // wrap around
              }
              else
              {
                 // Previous station
                 currentstation -= 1;
                 if ( currentstation < 0 )
                    currentstation = (stationcount - 1); // wrap around inverse
              }
              sprintf(report, "Station change to %d", currentstation);
              Serial.println(report);
              connected = 0; // to force connecting to new station
              digitalWrite(2, LOW); // turn off blue LED
           }
           else
           {
              if ( ticker > 50 )
              {
                 // Held for more than 5cs, it is a regular press
                 if ( thisbutton == 1 )
                 {
                    // Increase volume
                    volume += 3;
                    if ( volume > 98 )
                       volume = 98; // clip at 98
                 }
                 else
                 {
                    // Decrease volume
                    volume -= 3;
                    if ( volume < 0 )
                       volume = 0;
                 }
                 audioplayer.setVolume(volume);

                 // Construct the LCD report with volume bar
                 sprintf(report, "Volume %d", volume);
                 Serial.println(report);
                 for (int recurse = 0; recurse < 16; recurse++)
                    volbar[recurse] = ' ';
                 volbar[16] = '\0';
                 for (int recurse = 0; recurse < (volume / 6.25); recurse++)
                    volbar[recurse] = 255;
                 volbar[16] = '\0';
                 lcd_output(report, volbar);
                 lcddelay = millis() + 1500; // go away after one and a half seconds
              } // if ticker > 50 block
           } // if ticker > 500 block
         } // high to low or low to high block
      } // if level different to button block
   } // for thisbutton ... loop

}

That's it. We're done.

Now go put the kettle on. You know you want a cup of tea...

 

 

Your comments:

Please note that while I check this page every so often, I am not able to control what users write; therefore I disclaim all liability for unpleasant and/or infringing and/or defamatory material. Undesired content will be removed as soon as it is noticed. By leaving a comment, you agree not to post material that is illegal or in bad taste, and you should be aware that the time and your IP address are both recorded, should it be necessary to find out who you are. Oh, and don't bother trying to inline HTML. I'm not that stupid! ☺
 
You can now follow comment additions with the comment RSS feed. This is distinct from the b.log RSS feed, so you can subscribe to one or both as you wish.

Jeff Doggett, 4th February 2019, 20:24
It shoudn't be necessary to set the quantity of stations manually twice. The 5 in [] isn't needed. 
 
struct stationdef station[] = 
 
will work 
 
Use the following definition: 
 
#define ARRAY_SIZE(a) (sizeof(a)/sizeof(a[0])) 
 
 
Then  
 
int stationcount = ARRAY_SIZE(station); 
 
 
>> repeated five times 
 
No, it's repeated four times.
David Pilling, 5th February 2019, 23:03
Very good - internet radios are still quite expensive, and I could imagine someone (in China) selling this configuration - none to be found at the moment.

Add a comment (v0.09) [help?] . . . try the comment feed!
Your name
Your email (optional)
Validation Are you real? Please type 42104 backwards.
Your comment
Calendar
«   February 2019   »
MonTueWedThuFriSatSun
    12
456789
111213141516
18192021222324
25262728   

Japan - can you help?
Japanese Red Cross
日本 赤十字社

Earthquake relief donations have closed.

Read about the JRC
Make a general donation

Last 5 entries

List all b.log entries

Return to the site index

Search

Search Rick's b.log!

PS: Don't try to be clever.
It's a simple substring match.

Etc...

Thank you:
  • Fred
  • Bernard
  • Michael
  • David

Last read at 09:25 on 2019/05/26.

QR code


Valid HTML 4.01 Transitional
Valid CSS
Valid RSS 2.0

 

© 2019 Rick Murray
This web page is licenced for your personal, private, non-commercial use only. No automated processing by advertising systems is permitted.
RIPA notice: No consent is given for interception of page transmission.

 

Have you noticed the watermarks on pictures?
Next entry - 2019/02/10
Return to top of page