Skip to content

EFWob/RetroRadio

Repository files navigation

Latest changes

20220907

  • Text substitution is now possible for command arguments.
  • That allows for more sophisticated things, for example for the text to speech-interface which will turn your radio in a talking clock (example for this feature added)

20220906 Summer is almost over, time to restart programming. Included in this update:

  • announcements/alerts can now be used to read texts using Google Text-to-speech API.
  • An even better fix for the stack problem.
  • Some cosmetic changes. Most notable: if (in preferences) a station name is defined for any preset_xx- or fav_xx-key (like preset_0=url#This is number 1!) that station name ("This is number 1!" in our example) will always be used (for display) and will not be overriden by the icyname included in the metadata of the stream.

20220518

20220516

  • Bugfix: Will again compile, if build flag BLUETOOTH is not set in platformio.ini environment
  • I have learned that platformio will use partition tables found in the root directory of the project. Therefore radio4MB_default.csv and radio4MB_bigApp.csv (needed for Bluetooth-support) are now added to the root directory of the repository.

20220511

  • BIG NEW FEATURE: you can now use the radio as Bluetooth audio player.

20220509

  • Hot fix for new Espressif platform Espressiv 32 v4.1.0. This version comes with some updated library references that break the compile. The solution here is simple by forcing Espressif 32 v3.3 (see platformio.ini)

20220225

20220214

  • Bug fix: memory leak around caching genre-playlists removed.
  • Stack issue of main task: if commands (by scripting) are nested too much, radio might crash due to stack overflow. I have somewhat solved this by not executing commands direct but using an (internal) backlog. However, it might still happen and then currently the only solution is to edit the core as described here: https://community.platformio.org/t/esp32-stack-configuration-reloaded/20994
  • For debugging it is now possible to mirror the Serial output to MQTT if DEBUG=1 AND DEBUGMQTT=1 The output will be mirrored to the topic MQTT-Prefix/debug

20220126

  • BIG NEW FEATURE: you can interrupt the current stream to play announcements/alerts from mp3-URLs. I use this to relay the doorbell or to play voice mail sent to a Messenger.
  • Receiving MQTT-Messages outside the name scope of the radio (set by mqtt_prefix in the preferences) is now possible.
  • Some commands (favorite, genre, ram, nvs) now return a value that can be used from http-interface
  • Bugfix for setting NVS-preferences from the command-interface.

20220122

  • You can now send and receive MQTT-Messages using the command-interface
  • When defining Inputs, the properties are no longer case sensitive (so info, Info or iNFo are all treated the same) (the propery parameters are still case sensitive)

20220119

  • list of all namespaces in NVS can be shown with command lsnamespaces
  • listing/copying/moving preferences of/from another namespaces is limited to namespaces that contain only string type data
  • the #define ETHERNET can be set as build_flag in platformio.ini

20220117

  • preferences can be copied (moved) by command line if the NAME of the radio has changed
  • Bugfix: compile error "'EthernetFound' was not declared in this scope" removed from non-Ethernet-environments

20220116

  • the NAME of the radio can be defined as build flag in platformio.ini see below
  • ESP-Now can be used to send command(s) to the radio

Introduction to Retroradio

Summary

This project is based on the ESP32-Radio by Edzelf.

This project extends the capabilities of the ESP32-Radio, so you will need to familiarise with this project first.

The current state is WorkInProgress. This README is up to date, but the source code documentation is not. Some refactoring is needed, and also some rework. The plan is however, to keep the functionality described here as is.
I also try to commit only working versions to the master branch.

The project called Retroradio, because the basic idea was to use an existing "retro radio" as amplifier and front-end for the ESP32-radio.

These type of radio usally have:

  • a power LED
  • a switch for selecting AM/FM/AUX
  • a volume knob (variable resistor)
  • a tuning knob (variable capacitor)

In the original version that type of inputs and outputs are not supported directly. This project now supports:

  • analog input (i. e. to control volume by turning the variable resistor attached to the volume knob. One additional line in the preferences settings will achieve that.)
  • extended touch input (i. e. to evaluate the position of a variable capacitor to switch presets accordingly). Also one additional line only)
  • TODO: led output. With PWM capability / blink pattern support
  • the functionality of inputs can be extended to define reactions to double-click like events (or longpress)
  • the capabilities of of the commands/preference settings have been extended to include (a limited) scripting 'programming language'. You can react on events (changing inputs or changing system variables). Silly example: if the user does not like the current program he might turn the volume down to 0. You can have a 'hook' linked to the internal variable that holds the current volume level. If by that hook, you identify that '0' is reached, you can switch the preset to a station that hopefully plays something better. As that hook can be linked to the internal variable, it does not matter which input channel is used to set the volume to zero...

All of these options can be configured by the preference settings. So if you compile this version and use your standard preference settings as before you should notice no difference.

There are also some enhancements which are not necessarily associated with the extended input capabilities. Those are found in the section Generic additions below.

Considerations for migration

If you have the ESP32 radio installed and preferences set up, so it is up and running, the RetroRadio software should run out of the box with a few things to notice:

  • This version needs some more NVS-entries. If you are using already much space in NVS (namely if you have a lot of presets defined) you might need to increase the size of the NVS-partition.
  • You can use the command test to check if there are still entries in NVS available. If the number of free entries is close to zero, or you see something like nvssetstr failed! in Serial output, or if entries have vanished from your preference settings, you are probably out of NVS-space.
  • for 4MB (standard) flash sizes I use the partion-table radio4MB_default.csv that can be found at the root folder in this repository. This layout is not compatible to the default partition, notably for the NVS section. That means you must reload the default preferences (and edit the wifi credentials) if you flash using this partition table.
  • Currently it is tested in platformio-environment only. The platformio.ini file has already some entries. Mainly to maintain different radios (with differen MCUs and pinouts). For starters, you can try the environment plain devkit, which uses the default partition table and thus should keep your NVS-settings.

Potential stack issue

The stack of the main task might be too small if a lot of (nested) scripting is used.

Unfortunately, the size of the task (8kB) can only be changed by changing the core is changed as described here: https://community.platformio.org/t/esp32-stack-configuration-reloaded/20994

There are of course drawbacks:

  • This change affects to all projects that will be compiled using this core and (even worse)
  • This change will be undone if a new core gets installed (you have to change the new core again)

Therefore I implemented a diffent solution: in platformio.ini a build flag can be defined as:

build_flags = 
	... other stuff ...
	-DLOOPTASKSTACK=10000

If that flag is not defined, (or below 9216), stack size will be set at 9126, which is sufficent for simple commands. That stack size is used for the additional task (named looptask) that will handle the main loop for command handling (among other stuff).

You can also define the following setting in NVS preferences:

stack=12000

If such an entry is found in preferences, the stack size will be set (to 12.000 in this example) overriding the build-flag define above. Even though the setting in preferences can be lower than the build-flag setting, it can not be lower than 9126.

In the original implementation there was a task called spftask that is now gone. The task will be executed in the context of the maintask now (which handled the main loop originally).

You can use the command test to observe the maximum stack usage for the task looptask (the number shown is the lowest number of unused bytes in the stack that has been seen until now).

This approach should be the preferred solution as it does remove the need to mess around with the core sources and it can be easily adopted to different stack sizes for different build environments. The only drawback is that the RAM allocated for the main task is more or less unused (and thus wasted). However, RAM is not really a problem for the project as a whole.

Generic additions

Summary

Generic additions are not specific to the Retro radio idea but can be used in general with the ESP32 Radio. You can skip to section Extended Input Handling if you are not interested to use the following features for now:

  • The radio can be used as Bluetooth audio player.
  • Ethernet can be used (I had to place one radio at a spot with weak WLAN reception)
  • Philips RC5 protocol is implemented to be used in addition/instead of to the NEC protocol.
  • A channel concept is introduced to simplify a re-mapping of presets.
  • A favorite concept is introduced to allow users to maintain a list of favorite stations in addition to the standard preset list.
  • Radio can now play from genre playlists that can be downloaded from a internet radio database server.
  • IR remote handling has been extended to recognise longpress and release events
  • When assigning commands to an event (IR pressed, touch pressed or new events like tune knob turned) you can execute not only a single command, but command sequences also. A command sequence is a list of commands separated by ;
  • when a value for a command is evaluated, you can dereference to a RAM/NVS entry by @key. Simple example: if the command volume = @def_volume is encountered, then RAM/NVS are searched for key def_volume which will be used as paramter (if not found, will be substituted by empty string)
  • the NAME of the radio can be defined as build flag in platformio.ini see below
  • ESP-Now can be used to send command(s) to the radio
  • you can interrupt the current stream to play announcements/alerts from mp3-URLs.

Storing values into RAM (or NVS)

In the original version, key-value-pairs are stored into NVS. There is now a way to store such pairs into RAM as well. So from now on, if the text references to key or value associated to key, keep in mind that the actual storage location can either be in NVS as usual or in RAM. (If a specific key exists in both NVS and RAM, the one in RAM will be used. This allows to simply override a 'default setting' in NVS by storing the same key in RAM).

NVS keys can be defined in the preferences as usual.

  • There is a command nvs now to set an NVS-Entry. Syntax is nvs.key = value. That will set the preference entry key to the specified value (or create it, if it did not exist before). NVS (or RAM entries) can be dereferenced to be used as arguments for commands. For details see summary below, for now it is sufficient to know that @key, if used as an argument of a command, will replaced by the value of that key stored in RAM, or, if it does not exist in RAM, with the value assigned to key in the preferences, or the empty string "", if defined in neither RAM nor NVS.
  • There is a command ram now to set an RAM-Entry. Syntax is equivalent to the nvs-command above.
  • In both cases, if key exists the value associated to set key will be replaced. Otherwise the key-value-pair will be created and stored.
  • Example:
    • ram.tst1 = 42 will create a RAM-Entry with key == tst1 and value == '42'
    • ram.tst2 = @tst1 will create entry tst2 in RAM with the value == '42'
    • The susbtitution for the dereferenced key @tst1 is done at command execution time. If tst1 will change in value later, tst2 will still stay at '42'.
  • you can list the RAM/NVS entries with the commands nvs?=Argument or ram?=Argument. Argument is optional, if set only keys that contain Argument as substring are listed. Try ram?=tst for example.
  • RAM or NVS entries can be deleted using the command nvs- = key or ram- = key which will delete key and the associated value from RAM/NVS.
    • WARNING: you probably do not want to delete your presets from NVS. There is no "Are you sure"-popup. The entry will be deleted.

Command handling enhacements

When a command is to be executed as a result of an event, you can now not just execute one command but a sequence of command. Commands in a sequence must be seperated by ';'. Commands in a sequence are executed from left to right. Consider a "limp home" button on the IR-remote, which brings you to your favorite station (preset_0) played at a convenient volume level (volume = 75). So you can define in the preferences ir_XXXX = volume = 75; preset = 0 to achieve that. Command values can also be referenced to other preference settings. So if you have a setting limp_home = volume = 75; preset = 0 in the NVS-preferences, you can reference that by using "@": ir_XXXX = @limp_home (spaces between "@" and the key name, here limp_home, are not permitted). If the given key does not exist, an empty string is used as replacement.

The call-command

The call-command takes the form call = key-name. If key-name is defined (RAM/NVS with RAM overriding NVS) the associated value is retrieved and executed as commands-list (whith each command being seperated by semicolon).

Setup event

If in NVS-preferences the key-value-pair ::setup = key-name is defined, the value associated key-name is retrieved from NVS and executed as commands-list. If in NVS-preferences the key-value-pair ::setup = commands-list is defined, then the given commands-list is executed at startup, just after everything else in setup() is finished just before the first call to loop() (So network is up and running, preferences are read, VS module is initialized. I. e. ready to play).

If you set ::setup = volume = 70;preset = 0 in the preferences, then at start the radio will always tune to preset_0 and set the volume to level 70. (If one line is not enough, you can also use ::setup0 to ::setup9 for executing additional command-sequences, see below) .

Loop event

If the key-value-pair ::loop = key-name is defined, the associated contents of key-name are retrieved from NVS and executed as command-list. If in NVS-preferences the key-value-pair ::loop = commands-list is defined, then the given commands-list is at the end of every execution of the main loop() of the application.

This is called frequently (directly from main loop() of the program). Activities here must be short to avoid delays in playing the stream. For convenience (if more then one line is needed) ::loop0 to ::loog9 can be used in addition. (Then also the overhead will be split: only one of the given ::loop commands list will be executed at each loop(), one after the other).

More on this (and on control flow in general) will be introduced if we get along with the examples and is summarized below.

Bootmode

  • Normally, the radio starts in radio mode.
  • If you want to start in a different mode (currently only ap and bt are defined in addition to radio) you have to set the NVS-key bootmode in preferences before performing a restart of the radio (by either PowerOn-, SW- or HW-reset)
bootmode Meaning
radio Radio will start in default internet radio mode
bt Radio will start in Bluetooth-mode (if BT support has been compiled, see section Bluetooth, otherwise bootmode radio will be assumed)
ap Radio will start Accesspoint (as for initial configuration) and will not play radio or bluetooth.The radio will switch to this bootmode if the VS1053 module could not be installed properly!
any other Radio will start with bootmode set to radio
  • From the command interface, the process of setting the key bootmode in NVS is simplified by the extension of the command reset. This command takes now an argument and sets the bootmode key according to the argument, i. e. reset=ap will set the key bootmode to ap and resets the radio resulting in a restart in accesspoint-mode.
  • The bootmode key (if found in preferences) will be erased after every (re-)start. That makes by default at the time of the next reset no bootmode key will be found in preferences and therefore the radio will start in radio mode.

Bluetooth

BT limitations

The ESP32 does not seem to support both network access and bluetooth at the same time. As a result, you can not access the Web-interface in BT mode. The decision to switch to BT (or to default radio) is done at startup (after either Power On, HW- or SW-reset). To switch from one mode to another requires also a reset. Mechanisms have been implemented to make that switch as smooth as possible.

The next limitation is that the BT libraries require quite some flash space. As a result, you have to change the partition layout to increase the flash size for the app. I have one example in this repository (see ./radio4MB_bigApp.csv) that keeps the NVS- and FS-partition and doubles the size of the app by getting rid of the space used for OTA updates (compared with ./radio4MB_default.csv)

To compile the BT support, you have to define the build-flag -DBLUETOOTH in the respective environment in platformio.ini!

The BT-name will be the same as the radio name.

BT support is in an experimental stage. The only available command interface is Serial input, the command set is reduced to volume, mute, test and reset. Ideas for further improvements are the possiblity to control the BT source by radio buttons and feeding the display with information from the BT-stream.

Enter BT mode

  • At startup, NVS is searched if the key bootmode exists. If the value of that key is bt the radio will enter BT mode.
  • The key bootmode will be deleted immediately after evaluation. So a setting bootmode will only control the next reset.
  • To simplify the context switch, the command reset does now accept an argument that will be stored to NVS using the key bootmode before the actual reset is performed. So when in radio mode, the command reset=bt will store bt to NVS-key bootmode before performing the reset. After the reset, this NVS key will be evaluated and as a result radio will start in BT-mode.

You can also attach a momentary switch to a pin to decide the bootmode at reset time

  • In NVS preferences, you can set bt_pin=xx to define a GPIO (identified by number xx) to be evaluated at startup. If the defined pin is LOW at startup, the radio will switch to BT-Mode (or otherwise start radio mode as usual)
  • The pin is evaluated roundabout 1 second after reset.
  • The pin can be overloaded with some radio functionality as it is only evaluated at startup. I. e. if you have a MUTE-button, you can use this button to evaluate at startup if BT-Mode should be entered.
  • Note that the bootmode flag will override the pin-reading: if bootmode=radio or bootmode=bt are set in NVS preferences, the radio will start in radio/BT-mode no matter what.

If you defined such a pin, you can also use this pin to switch from BT to radio mode:

  • In NVS preferences, you can set bt_off=nn to define a timeout nn (a number between 1 and 127) in tenth of seconds the defined pin needs to be pulled to LOW in BT-mode to reset to radio mode. (There is no default mechanism to switch the radio from radio mode to BT mode using this pin. You can define this self in preferences by defining an specific Input.)

You can also attach a permanent switch (like a slide switch) to a pin. The only change to the setup for the momentary switch is to set bt_off to switch in NVS preferences. (i. e. bt_off=switch). In this case, the pin is not only evaluated at reset time but permanently:

  • At start, the radio will enter BT-mode if the pin reads LOW, else radio mode.
  • If the pin state changes in either mode (i. e. to LOW in radio-mode or to HIGH in BT-mode) the radio will reset to the other mode and again stay in this mode until the pin state changes again.
  • If using this mechanism, the bootmode flag will be ignored at startup. The radio mode is decided by state the switch.

Some details:

  • If you want to automatically connect to a (the last) known BT source if within reach, you can set the key bt_auto=1 in NVS preferences (defaults to 0, i. e. not automatic reconnect)
  • The volume level will be controlled by the BT-source. If you want to limit the maximum volume, you can define bt_vol=nn in NVS preferences (defaults to 100) to set the maximum volume level of the VS1053 module. (this setting does not affect the volume setting of the BT-source)

Ethernet support

Caveat Emptor

Ethernet support is the most experimental feature. It is just tested with one specific board, namely the Olimex Esp POE. I am pretty sure it will work the same for the Olimex Esp32 POE ISO. I have not tested it for any other hardware configurations, but it should work with any hardware combination that is based on the native ethernet implementation of the Esp32 chip.

USE AT YOUR OWN RISK!

Note that the initial setup (of the preferences) must be done through the access point as usual. Please do not connect the ethernet to the LAN as then the access point will not show and you cannot run the initial configuration.

Compile time settings for Ethernet

There is a define in AddRR.h that reads #define ETHERNET 2

  • this will compile with Ethernet support, if the selected board is known to have Ethernet capabilities. As of now, that is true for Olimex PoE/PoE ISO only
  • if changed to #define ETHERNET 0 (or any value different from '1' or '2'), support for Ethernet is not compiled
  • if changed to #define ETHERNET 1, support for Ethernet is compiled regardless of the board setting
  • if this line is deleted/commented out, Ethernet support will not be compiled in.
  • if compiled with Ethernet support by the above rules, Ethernet can then be configured at runtime by preferences setting.
  • (If ETHERNET is defined as build_flag in platformio.ini, then this entry will override the #define ETHERNET x in AddRR.h)
  • if compiled with Ethernet support, there is another define that controls the ethernet connection: #define ETHERNET_CONNECT_TIMEOUT 5 that defines how long the radio should wait for an ethernet connection to be established (in seconds). If no IP connection over ethernet is established in that timeframe, WiFi will be used. That value can be increased by preference settings (but not lowered). In my experience 4 seconds is too short. If the connection succeds earlier, the radio will commence earlier (and will not wait to consume the full timeframe defined by 'ETHERNET_CONNECT_TIMEOUT').
  • the following defines are used. They are set to default values in 'ETH.h' (and 'pins_ardunio.h' for ethernet boards). If you need to change those (not for OLIMEX ESP32-PoE...), you need to re-define them before '#include ETH.h' (search in addRR.h) or set them in the preferences (see below):
    • ETH_PHY_ADDR
    • ETH_PHY_POWER
    • ETH_PHY_MDC
    • ETH_PHY_MDIO
    • ETH_PHY_TYPE
    • ETH_CLK_MODE

Preference settings for Ethernet

This section is only valid if you compiled with Ethernet support as described in the paragraph above. If not, all preference settings in this paragraph are ignored.

The easy part here is the preference setting of eth_timeout:

  • if eth_timeout = 0 is found, ethernet connection will not be used
  • if any other setting eth_timeout = x is found, x is evaluated as number and that number will be used as timeout (in seconds) to wait for an IP connection to be established over Ethernet.
    • If that is not a valid number, '0' will be assumed as that number.
    • If that number is below the value defined by ETHERNET_CONNECT_TIMEOUT it will be set to ETHERNET_CONNECT_TIMEOUT.
    • If that number is bigger than two times ETHERNET_CONNECT_TIMEOUT a debug warning will be issued (but the value would be used anyway).

The other Ethernet settings that can be configured are:

  1. "eth_phy_addr" to override the #define for ETH_PHY_ADDR
  2. "eth_phy_power" to override the #define for ETH_PHY_POWER
  3. "eth_phy_mdc" to override the #define for ETH_PHY_MDC
  4. "eth_phy_mdio" to override the #define for ETH_PHY_MDIO
  5. "eth_phy_type" to override the #define for ETH_PHY_TYPE
  6. "eth_clk_mode" to override the #define for ETH_CLK_MODE

The problem here is, that eth_phy_type and eth_clk_mode are enums. They are defined in the sdk include file esp_eth.h. In the preferences they are expected as int-type. For the current core 1.0.6 implementation the mapping is defined as follow:

typedef enum {
    ETH_CLOCK_GPIO0_IN = 0,   /*!< RMII clock input to GPIO0 */ 
    ETH_CLOCK_GPIO0_OUT = 1,  /*!< RMII clock output from GPIO0 */
    ETH_CLOCK_GPIO16_OUT = 2, /*!< RMII clock output from GPIO16 */
    ETH_CLOCK_GPIO17_OUT = 3  /*!< RMII clock output from GPIO17 */
} eth_clock_mode_t;

and

typedef enum {
    ETH_MODE_RMII = 0, /*!< RMII mode */
    ETH_MODE_MII,      /* equals 1 !< MII mode */
} eth_mode_t;

For future/different core versions that assignment might change.

Channel concept

The channel concept allows a simple re-mapping of the presets in preferences. There are two new commands to implement the channel concept. (as usual, the commands can be defined either by preference settings or through the available command interfaces at runtime, i. e. Serial input).

  • channels = comma-delimited-integer-list will (re-)define the channel list. In the list numbers (decimal) are expected which are treated as reference to preset_X in the preferences. So channels = 1, 2, 10, 11, 12, 13, 14, 15, 16 defines 9 channels in total with Channel1 mapped to preset_1 up to Channel9 mapped to preset_16
  • That assignment does not change the current preset. To use that channel-list (i. e. to switch channel), the command channel = Argument must be used.
  • Argument can be:
    • A number between '1' and 'max' whith 'max' being the number of channels as defined by the command channels above. In our example '9'. This will change the preset accordingly (but only if different from current channel).
    • The word 'any': chose a random preset from the channellist. (It is guaranteed that the chosen preset will be different from current preset.)
    • The word 'up': if the current preset is not the last channel in channel-list, tune to next preset in channel-list. (if the current preset is not in the channel list, i. e. if tuned to by other means, tune to Channel1).
    • The word 'down': if the current preset is not the first channel in the channe-list, tune to previous preset in channel-list. (if the current preset is not in the channel-list, i. e. if tuned to by other means, tune to ChannelMax).
    • The number -1. This will not do any change to the current playing, but can be useful to force the radio to switch to a specific channel by a later channel=x-command (which would not happen if that channel was the same that has been set by the previous call to the channel-command).

Genre playlists

General idea

The main idea is to use a public radio database server (https://www.radio-browser.info/#/). This database has more than 27,000 stations organised in different categories (by reagion or genre tags). With this addition, you can download stations from that radio database as given by their genre-tag. As a result you can have more station lists (in addition to the default preset list in NVS) to chose from. By now, that lists are organized by genre tag only. So you can create lists like "Pop", "Rock", etc. but not for instance by country.

Creating genre playlists

A specific playlist can have any number of stations, as long as there is free flash left in the flash file system. Unlike the preset list, the station URLs are not entered direct, but are downloaded from the database above. For maintaining the genre playlist, you need to open the URL http://RADIO-IP/genre.html. The first time you do so you should see the following. Web API for genre

There are three main parts on that page:

  • on top (currently empty) is the list of genres that are already loaded into the radio.
  • the center section (between two bar lines) allows to transfer the genre lists from another radio to this radio.
  • the bottom section is the interface to the internet radio database.

You first should start with the interface to the radio database. In the leftmost input, enter (part) of a genre tag name, e. g. "rock". Enter a minimum number of stations that should be returned for each list (if the number of stations is less than that number, that specific list is not returned). Can be left empty, try 20 for now. If you press "Apply Filter" (ignore the right input field for now) you should see the result of the database request after a few moments:

Web API for genre

The result list at the bottom just shows the result of the request, the lists are not yet loaded to the radio. In the dropdown at the right side of the result list you can choose which entries you want to "Load" to the radio. When decided, press the button labelled "HERE" at the bottom of the page. Only then the station lists selected will be loaded into the radio. The website will show the progress:

Web API for genre

While you see that page, do not reload the page or load any other page into this tab as otherwise transfer will be cancelled. If you accidentially cancel the tansfer, the radio will still be in a consistent state. Just the list(s) of stations will be truncated/incomplete. After the transfer is completed, the page will automatically reload and should look like this:

Web API for genre

More often than not, you will notice that the stations of your interest are distributed over several genre groups on the database. Like in our example "indie rock", "pop rock" and "progressive rock" we just loaded would be an example of "subgenres" for rock.

As you will see later, you can only select one of those genres. You can however cluster the result list from the database into a "Cluster" called "Rock". There is only requirement for the clustername: it must start with a letter. And that first letter will be converted into uppercase automatically. All genre names in the database are returned in always lowercase letters. That way, you can have a cluster called "Rock" that can be distinguished from the "native" genre "rock".

So instead of loading 3 seperate genres, you could chose the Action "Add to:" for the genres of choice. You must enter the desired cluster name ("Rock" in our example) into the input field right of the button "Apply Filter".

Web API for genre

Clusters must not be created in one step, you can always add more stations from another database request later. You can however not delete a subgenre from a cluster (but only delete the whole cluster). If you press on the station number of a Cluster in the page section "Maintain loaded Genres" a popup will show which genres from the database are clustered into.

Web API for genre

In the maintenance section you can also Delete or Refresh any or all of the genres loaded into the database (a cyclic refresh is needed to throw out station URLS that got nonoperational in between).

If you click on a genre name on the left side in this list, the radio will play a (random) station from that genre. (If you press again, another random station from that list will be played). That is currently the only way to play from a genre using the web-interface.

Some details: Each genre name (either if coming direct from the database or a user defined clustername) must be unique. From the database that is guaranteed. The API does not allow to download the same genre twice. Clusternames are different from loaded genre names because they always start with an uppercase letter. You can use the same cluster name again in further database requests to add more genres into it. You cannot add the same genre twice to the same cluster, but you can add the same genre to more than one cluster. You cannot edit the resulting playlist any further. You can not add single station URLs "by hand", you can not delete a genre from a cluster. You can only delete the full cluster (or a whole genre).

With the default partition the file system is big enough to support (estimate) between 10,000 and 12,000 stations in total. (With the radio4MB_default partition it is still somewhat in between 8,000 and 10,000). For instance the radio that I just use for debugging has a total of 3182 stations stored which consumes 360,448 of 1,114,112 bytes of the flash file system leaving more than 66 per cent free for further playlists.

The interface can hande extended unicode characters. The only thing thats not working currently is if the genre name of the database contains the '/'-character. That I have seen on one genre so far and already forgotten again what it was, so it is currently no priority...

Web API for genre

Once you have one radio up and running with the playlists to your liking, you can simply transfer that settings to a new radio by opening http://NEWRADIO-IP/genre.html and entering the OLDRADIO-IP in the input field in the middle section and press the button Synchronize. If that connection was successfull, a dialog will pop up to verify you really want to transfer the settings. If you press OK you can just wait to see the magic unfold.

Using genre playlists

A playlist can be selected by the command interface by using the command genre=Rock to play one genre playlist. If that genre exists (as a cluster in this example, but could also be a native genre name from a direct download). Remeber that genre playlist names are case sensitive. Genres loaded from database direct will always have lowercase letters only, while cluster names start with an uppercase letter (followed by only lowercase letters). So, Rock is a valid name for a cluster, rock is a valid name for a genre tag from the internet radio database, but constucts like ROCK or rOcK are invalid. When in doubt, copy the name from the web API.

If a valid genre name has been assigned (and that genre exists in SPIFFS), the radio will start to play a random station from that genre. If the same command is issued again (with the same name), another (random) station from that genre will play.

You can switch to a station direct using the command

gpreset=Number

where Number can be any number. If this number (lets call it n) is between 0 and 'number of stations in that genre - 1', the n-th entry of that list is selected. N can also be greater than the number of stations (or even below zero), modulo function is used in that case to map n always between 0 and 'number of stations in that genre' - 1.

If no genre has been set by the genre=XXXX command above, gpreset=n has no effect.

So selecting a station is still "fishing in the dark", but it is at least reproducible (if you find that favorite station of yours at index 4711).

If you issue command genre=Anothername with another genre name, the radio will switch to that genre.

If you issue command with no name (empty), the genre playmode will be stopped. The current station will continue to play (until another preset or channel is selected). gpreset=n however will have no effect. You can also issue the command genre with parameter genre=--stop to achieve the same if you prefer a more clear reading.

You can also switch stations within a genre using the channel command from above. To do so, a channel-list must be defined. The preset-numbers assigned to the channel entries are ignored, just the size of the channel list is important. In the channel example above we defined a channel list with 9 channels. If you switch to genre-playmode, that channel list can be used to change stations within that genre by the following algorithm:

  • each channel has a random number between 0 and 'number of channels in that genre - 1' assigned.
  • one of the channel entries is the current channel (the one last selected by channel=n)
  • the distance between two channel stations is the same for all neighbors (and wrapped around between channel 9 and 1) in our example. So if in our case we would have a genre list with 90 stations, the distance between two channels would be 10.
  • example list in that case could be Channel 1: 72, Channel 2: 82, Channel 3: 2, Channel 4: 12 .... Channel 9: 62
  • that assignment stays stable until:
    • a new random station is forced by the command genre = Xxxxxx, this will result in a completely different list.
    • a new station is forced by the command gpreset = n
    • the command channe=up is issued. The numbers associated to the channels are increased by one (as well as the currently playing station from the genre will be switched to next in list)
    • the command channel=down is issued. The numbers in the channellist are decreased by 1 (also for the current station)
    • the command channel=any is issued. This will result in playing another (random) station and will also result in a completely different list.

Configuring anything around genres

To configure genre settings use the command

gcfg.subcommand=value

The command can be used from command line or from the preference settings in NVS. If not set in the preferences (NVS) all settings are set to the defaults described below.

The following commands (including subcommands) are defined:

  • gcfg.path=/root/path All playlist information is stored in Flash (using LITTLEFS) or on SD-Card. If path value does start with 'SD:' (case ignored), the genre lists will be stored on SD card using the path following the token 'SD:'. If not, the genre information is stored in Flash, using LITTLEFS with path '/root/path' in this example. No validity checking, if the given path does not exists or is invalid, genre playlists will be dysfunctional. (For historic reason, defaults to '/____gen.res/genres'). Must not end with '/'! Can be changed at any time (to allow for different genre playlists for different users).
  • gcfg.host=hostURL Set the host to RDBS. Defaults to 'de1.api.radio-browser.info' if not set. 'de', 'nl', 'fr' can be used (as short cuts) to address 'de1.api.radio-browser.info', 'nl1.api.radio-browser.info' or 'fr1.api.radio-browser.info' respectively. Otherwise full server name must be given.
  • gcfg.nonames=x if x is nonZero, station names will be stored in genre playlists (not just URLs). Currently, station names from genre playlists are not used at all but might be useful in future versions. When short on storage, set to '1'. Defaults to 0, so station names will be stored.

Considerations (and limitations) around using genre playlists

The total number of genre lists is limited (to 1000). This is a compile time limitation that can not be changed by a command or a preference setting.

For faster access, some information is cached. For caching, PSRAM is preferred. If PSRAM is not available, normal heap is used. PSRAM should be plenty, however, if there is no sufficient heap, operation might be slower. (Use command test from the Serial input. If the reported Free Memory is below 100.000, it is likely that RAM caching is not available.)

On a board with 16MB flash I was able to load around 33.000 stations within 740 genres before the flash was fully used. With SD-card even more will be possible. However, SD access is slower. It is using shared access with the SPI. You will notice quite a few debug messages saying "SPI semaphore not taken...". That is annoying but still fine. It is probably advisable to stop radio playback (using stop from the Serial command line) to limit the access conflicts on the SPI bus if you maintain the genre playlists (adding/deleting/reloading).

If the radio stream stalls, the radio has a fallback strategy to switch to another preset. If that happens when playing from a genre playlist, the radio would fall back to a preset from the preset list in preferences. You will notice that in genre playlist mode this can happen (for remote stations with a bad connection). In a next step, this fallback needs to be recoded to use another station from the current genre playlist. If you want to avoid the fallback to a preset from NVS, use the command preset=--stop. This will block fallback to a station from presets until a station from presets is requested (for instance by preset=n or channel=m if genre play mode has stopped).

When in genre play mode, you can still issue a preset=n command and the radio will play the according preset from the preferences. However, genre playlist mode will not be stopped: the command gpreset=n as well as channel=m (if channellist is defined) will still operate on the current genre playlist.

Favorite concept

General idea

Especially with the genre playlists you have virtually thousands of stations to play from. However, selecting a station is random. If you want to recall a currently playing station later, you can add that station to your favorites.

The Favorites are another playlist in addition to the Preset-list. The main difference is, that the favorite list can be maintained using the command interface (no need to add them through the Webpage).

In total 100 favorites can be stored, numbered from 1 to 100.

The favorites are stored in nvs with the prefix "fav_", followed by a number between "01" to "100" (the leading "0" must be given if number is below 10).

The syntax is the same as with presets:

fav_08 = stream.laut.fm/the-blitz-kids#The Blitz Kids

defines the favorite with index 8 to have the URL http://stream.laut.fm/the-blitz-kids and the name "The Blitz Kids"

Preferably, Favorites should not be defined by the config settings, but using the command interface via Serial, HTTP or MQTT.

Command interface for favorites

Maintenance/using the favorite list is done by the (new) command "favorite"

In its simplest form, favorite takes a number (between 1 and 100) to play that favorite (if defined).

The definition of a favorite (if not done from the configuration page via nvs) is always done from the current station.

To add the current station to favorites, use

favorite=+

If DEBUG is on, you can see where this favorite is stored. If the current station is already in the favorite list, it will not be added again.

To remove the current station from favorites, use

favorite=-

If the current station is not in favorites, this command will have no effect. If the current station is stored to more then one favorite, all matching favorites will be deleted.

Normally, both commands will be applied to the whole range of favorites (1-100). If you want to limit it to a subrange, you can define that subrange by adding a range specification after '+' or '-':

  • ranges are defined as one or two numbers
  • if no number is given, the range is 1-100
  • if one number is given, the range is thatNumber to thatNumber
  • if two numbers are given, the range is 1stNumber to 2ndNumber
  • numbers must be separated by whitespace. You can (or in addition) add a dash '-' for reading convenience.
  • again: if no number is given at all, the range will be assumed to be 1-100
  • if either the upper (100) or the lower (1) limit are exceeded by either parameter, or if the 2ndNumber is lower than the 1stNumber, the command will be ignored

If you want to store the current station to a specifc number, use the command

favorite=s number

whith number being in the range of 1 to 100. If a favorite has already been stored by that number, it will be overwritten. This command will store the current station to the given number, even if it has been stored to another favorite before.

If you want to delete a favorite with a specific number, use the command

favorite=d number

whith number being in the range of 1 to 100. If a favorite by that number is not known, the command will have no effect.

If you want to list the stored favorites on Serial, use the command

favorite=l

that will list all favorites (from 1 to 100) on Serial (if DEBUG=1). This command also accepts a range setting, hence

favorite=l 10-20

will limit the listings to favorites 10 to 20.

The listing will be in JSON-Style with the following parameters:

  • "idx": the number of the favorite
  • "url": the url of the favorite
  • "name": the station name of the favorite
  • "play": set to 1 if currently playing that station, 0 if not

Especially for use with http-requests you can use two commands:

favorite=status
favorite=jsonlist

favorite=status will return an JSON-Object with two elements "play" and "version":

  • Both are numbers
  • "play" is 0 if none of the stored favorites is currently playing. If it is between 1 and 100, that is the number of the currently playing favorite (if the same station is stored in the favorites list more than once, the lowest index number will be returned for "play").
  • "version" is an arbritary version number that will increase by one whenever the favorite list changes by either adding or deleting one favorite. After a POR "version" will always be 1. A client can observe this number to decide if the favorite list must be reloaded.

favorite=jsonlist will return an JSON-Object with two elements "version" and "list":

  • "version" is the same as just described
  • "list" is an array containing all non-empty entries from the favorite list. Each entry in this list is a JSON-Object with two fields:
    • "i": the index number of the favorite
    • "n": the name of the favorite station, base64 encoded (the name will be truncated to 50 characters which should be sufficient for most stations). (If you use this command from the Serial monitor, output will be truncated due to internal print buffer limitations).

Both favorite=status and favorite=jsonlist operate on the whole range from favorite 1 to 100.

At the bottom of the main "Control"-Webpage of the radio is now another drop-down list that allows to select and play a favorite from the list. Above this drop-down-input is a button to Add the current station to the favorite list or, if the current playing station is already in the favorite list, to Remove it from that list again.

(Due to polling, it can take a few seconds until the button changes from Add to Remove (or vice versa) and the favorite list changes after an update).

IR remote enhancements

Added support for RC5 remotes (Philips)

Now also RC5 remotes (Philips protocol) can be decoded. RC5 codes are 14 bits, where the highest bit (b13) is always 1. Bit b11 is a toggle bit that will change with every press/release cycle of a certain key. That will make each key to generate two different codes. To keep things simple that bit will always be set to 0.

So if you have a RC5 remote and and pin_ir_rc5 (or pin_ir_necrc5, see below) is defined in preferences and a VS1838B IR receiver is connected to that pin, you should see some output on Serial if a key on the remote is pressed (and debug is set to 1 of course). The code reported should be in the range 0x2000 to 0x37FF. (Also you should see "(RC5)" mentioned in the output. You can attach commands to the observed codes with the usual ir_XXXX scheme (or the extended repeat schemes below).

If you want your radio to react on NEC or RC5 codes only, change the pin-setting in the preferences as follows:

  • use pin_ir = x to use only NEC-decoder
  • use pin_ir_rc5 = x to use only RC5-decoder
  • use pin_ir_necrc5 = x if you want to use both protocols at the same time. Be aware that I have seen occasional crashes during "wild monkey testing" the remote, so use at your own risk.
  • only one of pin_ir, pin_ir_rc5, pin_ir_necrc5 should be defined. If more than one is defined, the preference is pin_ir, pin_ir_rc5 and pin_ir_necrc5. (so if for instance pin_ir and pin_ir_rc5 are defined, the setting for pin_ir_rc5 is ignored and only NEC protocol is decoded using the pin specified by pin_ir)

Added support for longpress and release on IR-Remotes

Sometimes it is desirable to have the radio to react on longpress of an IR remote key (Vol+/Vol- for instance). As implemented in the original version, you would get only one ir_XXXX event on pressing a certain key, no matter how long the key is pressed.

Therefore a few additional events are generated on longpress events. The first press is always reported as ir_XXXX. Longpress events follwoing that initial press can be catched by the following:

  • ir_XXXXr will be called repeatedly as long as the key is pressed. So for the Vol+ key the setting could be: _ir_XXXXr = upvolume = 1. For both protocols this repeat events will be generated with a distance of about 100ms (could be longer)
  • it is guaranteed that no ir_XXXXr event will be generated, if the leading ir_XXXX event has not been evaluated before. That does not mean, that the ir_XXXX event must be defined in preferences. It means that if both ir_XXXX and ir_XXXXr are defined that there will be no execution of ir_XXXXr without previous execution of ir_XXXX.
  • if you want to catch a specific repeat event, you can define ir_XXXXrY where Y is any number >= 1 (decimal, leading 0 not needed) that will fire on the Yth repeat of the keypress repeat indicator. That is roughly Y * 0.1 sec. So ir_XXXXr10 = mute will mute the radio if key XXXX is pressed for about 1 second. If both ir_XXXXr and ir_XXXXrY are defined, only ir_XXXXrY is executed if the repeat counter reaches Y.
  • the order of sequence is guaranteed, so it will always be ir_XXXX as start followed by ir_XXXXr1 and ir_XXXXr2 and so on.
  • if you define an event with the pattern ir_XXXXRZ where Z is again a decimal number and R is in fact the uppercase R you define a recurring event. The repeat counter will reset to 0. So in the Vol+ example above: if you define ir_XXXXR3 = upvolume = 1 the command upvolume = 1 will be called at slower rate (roughly every 0.3 seconds instead every 0.1 seconds). This really resets the repeat counter. That means any command with a repeat counter > 3 in this example will never get executed. If ir_XXXXRZ is defined, neither ir_XXXXrZ (if defined) nor ir_XXXXr (if defined) are executed.
  • If there are no more keypress repeats detected the entry ir_XXXXx will be searched in preferences and (if found) its value will be executed as command sequence. As said above, the order will always be maintained, so ir_XXXXx will always be last in a key press sequence.
  • Key release is detected after timeout. This timeout is set to 500ms which counts from the last valid key information reported to scanIR().

Detailed IR-Example

For this example, I use a cheap generic ebay-IR from China. The following buttons will be supported by assigned:

  • (CH-) to switch channel down, (CH) to tune to another (random) channel, (CH+) to tune one channel up.
  • (Vol-) and (Vol+) to decrease/increase the volume
  • (Play/Pause) to toggle mute
  • (PREV) and (NEXT) will be used to alter the channel assignment for 2 different users.
  • Number keys (1) to (9) switch to Channel1..Channel9 (A few buttons are not assigned in this example).

Lets start with the (CHANNEL)-keys. Here channel = down is executed, if (CH-) is pressed on remote, channel = up if (CH+) has been pressed and channel = any if (CH) has been pressed. If (CH) is hold for about 2 seconds, the command channel = any is executed again. And again if the key is still pressed 2 seconds later... (If the channels are linked to reliable streams, that should give some time to listen before switching to the next channel). For (CH-) and (CH+) no longpress events are defined.

ir_A25D = channel = down    # (CH-) pressed
ir_629D = channel = any     # (CH)  pressed
ir_629DR22 = channel = any  # (CH)  (repeatedly) longpressed for about 2 seconds
ir_E21D = channel = up      # (CH+) pressed

For the volume keys (and (Play/Pause)), the settings are similar:

ir_E01F = downvolume = 2    # (Vol-) pressed
ir_E01Fr = downvolume = 1   # (Vol-) longpressed
ir_A857 = upvolume = 2      # (Vol+) pressed
ir_A857r = upvolume = 1     # (Vol+) longpressed
ir_C23D = mute              # (Play/Pause)

So if (Vol-) or (Vol+) is pressed, volume will be decreased/increased by 2 on initial press and decreased/increased by 1 as long as the key is being pressed at roughly 100ms intervals.

The setting for the 'Number-Keys' (1)..(9) is obvious (only 3 examples shown). There are no longpress-events used.

...
ir_42BD = channel = 7 # (7)
ir_4AB5 = channel = 8 # (8)
ir_52AD = channel = 9 # (9)

The 'tricky' part is the switching the user (and hence the channel assignment). I use a separate (NVS)-entry to store the commands list to be executed if switching between users. That way, the same sequence can be re-used later if the switch is happening by another input event. (By my convention, I use the ':' as starting character for NVS-Keys that store for later execution.)

:user1 = channels=0,1,2,3,4,5,6,7,10;channel=1;volume=70
:user2 = channels=1,2,10,11,12,13,14,15,16;channel=1;

So if these command sequences are executed, for both users different presets would be assigned to Channel1 to Channel9, but in both cases the preset referenced by Channel1 would be tuned to (i. e. preset_00 for :user1 and preset_1 for :user2). For :user1 the volume would also be set to 70 (but will stay at current value if switching to :user2).

With these two settings the user switch by the intended remote-keys (PREV) and (NEXT) can be achieved:

ir_22DD = call = :user1 # (PREV)
ir_02FD = call = :user2 # (NEXT)

By the call command, the contents of the value linked to :user1 or :user2 are executed as command sequence (If either key is defined both in RAM and NVS-Preferences, the RAM setting will override the NVS setting).

Now there is only one thing left to do: at start, no channels are defined. You should use ::setup to define channel settings. The most obvious way is to force user1 at start by adding the following entry to the NVS preferences:

::setup = call = :user1

If this assignment would not have been made, all (CH)-keys and the (1) to (9) keys would have no effect (until a user setting would have been done by pressing (PREV) or (NEXT)).

Defining the radio name

In the original version of the ESP32-Radio the name of the radio is set by a line in header-file that reads #define NAME "ESP32Radio". To have the possibility to assign differrent names to different radios without editing that headerfile, the name can now be defined and changed by setting a build flag in the platformio.ini file. Here an example, that sets the name to testradio

[env:esp32-poe]
board = esp32-poe
board_build.partitions = radio4MB_default.csv
build_flags = 
	-DNAME=testradio

Some considerations:

  • the name must not contain whitespaces. Letters and digits are safe.
  • the name of the radio also specifies the namespace that is used for storing NVS-entries (preferences). Therefore the length of the name should not exceed 15.
  • When the name is changed to a new name, the preferences will be empty again. There are two new commands to copy or move the preferences from an existing name now:
    • cpprefsfrom=oldname will copy the prefrences that have been stored using the "old" NAME oldname
    • mvprevsfrom=oldname will move the preferences that have been stored using oldname. This means that all entries in the namespace "oldname" will be deleted.
    • cpprefsfrom and mvorefsfrom will be cancelled, if the source namespace (given as argument) will contain no entries at all or at least one entry of non-string-type (radio preferences are string type only)
  • you can list all available namespaces in NVS with the command lsnamespaces (this command will just list the names and will not give any insights on number/type of entries)
  • you can list the contents of a namespace with the command lsprefsfrom=namespace. (As with cpprefsfrom/mvprefsfrom this operation will only list the entries if all entries are of string-type).
  • Of course you can also copy-paste the preferences using the config-webinterface (and store them in a text file). There is no function to list existing (unused) namespaces. Best solution to remove "old" namespaces is to erase the flash completely and restart from scratch (both preferences and genre information stored to LITTLEFS will be wiped out that way, however).

Using ESP-Now

ESP-Now can be used to send commands to the radio. That is a one-way-communication, a client can send commands to the radio but the radio will not send back any information.

To use ESP-Now, the radio must be connected to WiFi (and not to Ethernet). The "protocol" is defined in such a way, that neither the client nor the radio need to now each others mac address (broadcasts can be used).

The data send to the radio must be formatted as follows:

  • Message has two parts, a prefix, followed by the payload
  • Both prefix-length and payload-length are variable (but can not exceed the combined total of 250 bytes as given by the ESP-Now-specification)
  • first byte of the prefix is the remaining length of the prefix (so excluding this first byte)
  • from the remaining bytes of the prefix all but the last one specify (typically as ASCII-Sequence) the intended receiver of the message:
    • it can be 'R', 'A', 'D', 'I', 'O' if the message shall be processed by any radio that receives it or
    • a specific name like 'E', 'S', 'P', '3', '2', 'R', 'a', 'd', 'i', 'o', if the message is intended for the radio with the name ESP32Radio (note that the ASCII-Sequences must not be terminated with 0)
  • the last byte of the prefix is a "message-counter" that is used by the radio to identify duplicate messages. The intention is as follows:
    • The delivery of a message is not guaranteed, the client should simply repeat a message 3 or 4 times to increase the chances of a successful transmission.
    • The radio will reject any identical messages received within a timeframe of 500 msec. Identical means, that it came from the same client and has the identical content (of the whole message including prefix and payload).
    • For some use cases, like repeated upvolume=1 to smoothly increase volume over time, that timeout might be to long. Then the client can change that last byte of the prefix to make the radio treat the next message as a different one.
    • The change can be abritrary, it is not expected that the change follows any specific pattern (easiest implementation on client side is normally to increase by one for each new message).
  • After the prefix, the remaining bytes of the message are the payload that contains (as ASCII-String) the command(s) (command sequences are allowed) to be executed. That String can be 0 terminated (not needed, if the message ends after the payload direct with no stray/random bytes attached).

So to send the command to set the preset to 1 to any radio that is listening to ESP-Now, the message (15 bytes) could be:

{6, 'R', 'A', 'D', 'I', 'O', 0x, 'p', 'r', 'e', 's', 'e', 't', '=', '1'}

The first byte (6) indicates the remaining prefix-length, of which all but the last contain the addressee ("RADIO" in our case). The last byte can be any number as discussed above, indicated here as 0x.

After this the payload follows, that reads as "preset=1".

The command espnowmode=n can be used to control, how the radio behaves upon reception of ESP-Now-messages. The parameter n can be a number between 0..3 with the following meaning:

  • 0: the radio ignores all ESP-Now-messages
  • 1: the radio only recognises ESP-Now-messages with addressee set to the radio name.
  • 2: the radio only recognises ESP-Now-messages with addressee set to "RADIO"
  • 3: the radio recognises ESP-Now-messages with addressee set to either the radio name or to "RADIO".
  • if called with no (or illegal) parameter, the espnowmode will not be changed but the current value will be shown. If the value is shown as -1, ESP-Now could not be initialized (hence no messages will be received).

A simple client to send serial input over espnow to the radio can be found at RetroRadio/espnow/serialtoespnow/.

Play Announcements or Alerts

From URLs

Announcements (or Alerts) will interrupt the current stream to play some info. Announcements/Alerts can be played from any URL that can be played by the radio. Currently that means "http"-type URLs (not https://). And I tested with mp3 so far only.

Basic usage:

  • announce=URL will play the URL if this is a URL for a mp3-file and will return to the station played before.
  • the URL must not contain any whitespace
  • URL must be given without leading http://
  • if the URL is not available/not a valid file, the radio will return to the previous station after a few seconds
  • while the announcement is playing, certain commands that change the stream are not available (preset, channel, genre, favorite etc.)
  • mute is turned off before the announcement starts (but volume level is not changed)

Without any judgement on the quality, here is an example for a URL that was returned by searchengine after searching for "free mp3 ringtones"

announce=2u039f-a.akamaihd.net/downloads/ringtones/files/mp3/9164d87259ad4d3883b9b2e8e0b4a365-welike-2-57877.mp3

You might have noticed that the Station Name has changed to "Announcement" while playing. There are two possibilities to change that assignment:

  • you can override the default text by setting $announceinfo in RAM or NVS (preferences)
  • by adding any text after the URL in the command (that will override the $announceinfo-setting in RAM/NVS)
announce=2u039f-a.akamaihd.net/downloads/ringtones/files/mp3/9164d87259ad4d3883b9b2e8e0b4a365-welike-2-57877.mp3 Just an example!

When playing an announcement, you can not change to a "standard-stream" (preset, favorite). You can stop the playout of the announcement using the command stop. While playing the announcement, you can however cancel the current announcement by starting a new announcement (if an announcement was cancelled by another announcement, the radio will not return to the interrupted announcement but to the last "regular" stream that played before).

The announcement will be played at the same volume-setting the radio is currently on. If the volume is set to Zero, nothing will be heared. To ensure audible output, the command alert can be used.

The command alert is similar but allows for some more control, if needed:

  • if alert starts, RAM/NVS are searched for the key ::alertstart. If that key has any value assigned, the defined value of that key will be executed as commands-sequence before the stream starts (that way it is possible i. e. to set a defined value for volume)
  • if alert ends, RAM/NVS are searched for the key ::alertdone
  • if ::alertdone is not defined, the radio will restore the previous mute-state, the previous volume and the previous station
  • if you define ::alertdone, you are responsible to recover self (if needed) from system variables
    • the previous station using ~url_before
    • the previous mute state using ~mute_before
    • the previous volume using ~volume_before

For the command alert the Station Name changes to "Alert!" while playing. There are two possibilities to change that assignment:

  • you can override the default text by setting $alertinfo in RAM or NVS (preferences)
  • by adding any text after the URL in the command (that will override the $alertinfo-setting in RAM/NVS)

A general word of warning: this feature might cause unexpected behaviour if called with illegal parameters. One known issue is that the command stop, when issued in "announcement-mode" will cancel the announcement but will continue with the previous stream. I found it working reliable with valid URLs but only have limited experience with invalid input data. Please let me know if you were able to crash the radio using this feature.

Using Text to Speech

The commands alert and announcement can also be used with Text-to-Speech synthesis. The basic idea was taken from https://github.com/horihiro/esp8266-google-tts. As such, Google-TTS-API is used.

To use TTS simply add the suffix .t to the command and give the text to read as argument. Try announce.t=Hello world! as an example.

The Google-API is somewhat undocumented. So may be it is in experimental stage and might be gone in the future. For now it works.

You can also chose the language of the voice by adding a language suffix. Again, the API is undefined, but I found the following to work (there will be more):

  • en for English (that is also the default if no language suffix is given)
  • de for German
  • nl for Dutch
  • fr for French
  • ja for Japanese
  • hu for Hungarian

So if you want to try "Hello World!" in German, use alert.tde=Hallo Welt!

This functionality can be used for instance for "headless" units to read the current stream title using alert.t=~icystreamtitle

As a more sofisticated example, you can use a MQTT-Input to react on incoming MQTT-Messages. Suppose you have an doorbell that sends a message to the topic door/bell whenever someone rings your doorbell. In that case, you can define the input as:

_in.mqttbell=src=m /door/bell,onchange={alert.t=There is someone at the door!},start_ 

to get notified if that happens.

With text substitution (please make sure to read and understand this section first) you can teach your radio to say the current time for you. The most simple example is:

alert.t="It is now ~hour o'clock and ~minute minutes.

This works pretty well, but has a shortcomming: if the current minute is 0, the minutes should not be announced at all and if the current minute is one the announcement should read "1 minute" (and not "1 minutes"). That can be improved with the following line:

if(~minute)={if(~minute>1)={._mtxt=and ~minute minutes}{._mtxt=and 1 minute}}{._mtxt=};alert.t="It is now ~hour o'clock ._mtxt

Whats happening here is that (depending on the current minute) the RAM-variable _mtxt will be set:

  • to the empty string, if current minute is 0
  • to "and 1 minute", if the current minute is 1
  • to "and ~minute minutes" in any other case.

That will then used to feed the command alert.t with the argument:

alert.t="It is now ~hour o'clock ._mtxt

Which will be evaluated to:

  • It is now x o'clock (if ~minute is at 0)
  • It is now x o'clock and 1 minute. (if ~minute is at 1)
  • It is now x o'clock and n minutes. (if ~minute is between 2 and 59)

You can try self to further improve the announcements to include noon/midnight/A.M./P.M. if you whish so.

Extended Input Handling

General

  • Inputs can be generated from Digital Pins, Analog Pins, Touch Pins, (some) internal variables and MQTT-Messages.
  • For each of these Inputs, the handling is identical, the difference is just in the value range used:
    • Digital Input pins return 0 or 1
    • Analog Input pins return 0..4095
    • Touch Pins return 0..1023 (in theory, in practice the real values will be somewhere in between (For touch pins the native Espressif-IDE is used, that increases the granularity and accuracy of the readout by magnitudes)
    • For internal variables, the possible return range is -32768..32767 (int16 is used)
    • For MQTT-Messages, Strings are returned
  • The Inputs could either be read cyclic in the loop() or just on request
  • In 'cyclic mode' a three different change events can be assigned that are executed on any change, if the Input goes to zero or if the Input leaves zero.
  • The 'physical readout' of an Input can be mapped to a different value range.
  • Each Input can be configured and reconfigured freely during runtime.

First Input example Volume

We will assume the following setting for the example:

  • A variable resistor is attached to an analog pin (lets say 39) in such a way, that it applies a voltage between 0 (if turned to low) and VCC (if turned to max).
  • Accordingly, we want to change the Volume between 0 and 100.
  • However, since we know that with our device a volume setting between 1..49 is effectively not audible we would prefer the following mapping
    • Volume = 0 if resistor is turned to low (or close to low), say if the readout of the analog pin is between 0 and 100
    • Volume should be between 50..100 if the readout of the analog pin is between 101 and 4095

That can be achieved with the following to settings in the preferences:

in.vol = src=a39, map=(101..4095=50..100)(0..100=0), onchange={volume=?}, delta = 2, start

You can also just try this at runtime by entering the above line from Serial input (but then the functionality will be gone on next reset of the radio).

This line contains the new command 'in'.

  • The command 'in' sets or updates the properies of a specific Input.
  • Each Input has a name, that name is indicated by the string following the '.' after in
  • In our example we have defined an Input with the name 'vol'
  • The in command allows to set or change the "properties" of an Input.
    • If more than one property is defined, they are separated by ','. If more than one property is set in this list, they are evaluated from left to right.
    • most properties take an argument, that argument is assigned as usual with property = argument
    • The argument of a property is dereferenced to RAM/NVS if it starts with '@', to RAM only if it starts with '.', to NVS only if it starts with '&'.
      • If an argument is dereferenced, it is used when the in-command is executed. If it is changed later, the Input will not change.
    • If argument is not provided, it is assumed to be the empty String ''.
    • Some properties (start or stop for instance) do not require an argument (in that case, argument will be ignored if set)
  • The property 'src' specifies the link to an input
    • Here 'a39' is set. This means "use pin39 as analog input" There is no checking, if that is a valid (analog) pin. In practice, this assignment will result in an analogRead(39). (The setting in.vol=src=a42 would not result in any complaints, just render the Input useless, as analogRead(42) will always return 0.)
  • mode is a property that details a few aspects of the physical input.
    • It is specific (has a different meaning) to a specific Input type.
    • mode is bit-coded, and for analog Input b0-b4 are evaluated (28 from the example is 0b11100 in binary):
    • b0: if set, readout is inverted (so 4095 if 0V are applied to Input pin and 0 is returned if pin is at Vcc). Not inverted in our example.
    • b1: if set, pin is configured as Input with internal PullUp (pinMode(INPUT_PULLUP)), otherwise (as in this example) just as Input (pinMode(INPUT)).
    • b2..b4: set a filter size from 0 to 7 to smoothen out glitches/noise on the input line. The higher the filter is set (max 7 as in our example) the less noise will be on readout with the drawback that actual changes (user change of variable resistor for instance) will be less instantaneous but will take some time to propagate to the readout. For volume settings this is not a bad thing at all, and the maximum size of 7 is still fine with that usecase.
    • all other bits are reserved for future ideas and should for now be set to '0'.
    • So in our case the Input is configured with the maximum filter size 7, not inverted, internal PullUp not set.
  • The property map allows a re-mapping of the physical read input value to an arbitrary different output range
    • a map can contain any number of entries
    • each entry must be surrounded by paranthesis '(x1..x2 = y1..y2)'. x1..x2 is (a part) of the input range that will be transformed into the output range y1..y2

      In our example, if analogRead(39) returns something between 101 or 4095, the Input itself will return something between 50 to 100. If analogRead() returns less, (100 or less), the Input will yield 0.

    • The arduino map() function is used internally, so reversing lower and upper bounds or using negative numbers is no problem as long its within the limits of int16
    • map-entries can overlap both on the x and the y side. When actually performing the mapping, the entries are evaluated from left to right.
    • if a matching entry is found, that entry will be used for mapping. Further entries (to the right) are no longer considered.
    • '..y2' and/or '..x2' can be omnited. (so you can have just one number on either side).

      If there is only one entry in the y-part, all of the input range from 'x1..x2' will be mapped to that single y-Value (see second entry in the example with '(0..100=0)'. If there is only one entry in the x-part, that value will be mapped to y (a setting of y2 will be ignored in that case).

    • A single map entry can be dereferenced to RAM/NVS using either of '@/./&key-name.

      So '(@x1..@x2=0)' would search for the keys x1 and x2 in RAM/NVS and would use this values at the time the 'in' command is executed. The resulting map-Entry will keep these values, even if later x1 or x2 are changed.

  • The property onchange defines either a inline command sequence or a key to RAM/NVS that is searched for if the Input (after mapping) has changed since last check.
    • if the argument is enclosed in '{}', it es executed as command-sequence if a change on the Input is detected.
    • if the argument is not enclosed in curly braces, it is expected to be a key name that exists in either RAM/NVS. If the Input changes, that key name will be searched for in RAM/NVS and (if found) the value associated to that key is assumed to be a commands sequence that will be executed.
    • In the commands sequence, every occurance of '?' will be replaced by the current value of the Input.
    • (to generate onchange-events, you need to explicitely start cyclic polling of the Input, see below.)
  • The property delta defines the magnitude of change that is required on the (mapped) Input for any change event to be triggered.
    • this allows, especially for analog input, to eleminate noise on the line that would lead to alternate readings and hence events on every other readout. Must be one (any change in the input would lead to an event) or above. Will be forced to 1 if set by user to 0 or below.
  • The property start starts the cylic polling of the Input. Only if polling is started, any of the events (click and/or change) will be generated.
    • start results in a direct readout of the Input. If onchange is defined, when start is set (it is in our example), that event will be called direct (to do an initial setting of volume at startup depending of the position of the variable resistor in our case). If that is not desired, start must be called before onchange is set.
  • The property stop (not used in the example) is the opposite of start: it stops the cyclic polling (or does just nothing if cyclic polling has not yet started).

There are a few more properties. However, this explanation should be enough to understand what is needed to translate the changes on a variable resistor to an appropriate volume setting. If everything is connected correctly and if there were no typos in entering the commands (or the preference settings) above, it should already work.

If not, it is time for some debugging. There are two properties, that are concerned with debugging:

  • The property info shows the settings of the Input (no argument needed).
  • The property show = TimeInSeconds starts a cyclic output of the readout of the Input. TimeInSeconds is the distance between two observations in seconds. If TimeInSeconds is 0 or not given, the output on Serial will stop.

    The output will show regardless of the setting of DEBUG. If you want to concentrate on the Input-information, you probably want to stop all other output by entering the command debug=0 first. If show is not zero, information will also show if events are happening (changes or clicks). So show=3000 can also be useful, if you want to debug for events. show=1 is useful if you want to monitor an Input continuously (e. g. for deriving a map for that Input).

If you enter the command in.vol=info you should read:

D: Info for Input "in.vol":
D:  * Src: Analog Input, pin: 39, Inverted: 0, PullUp: 0, Filter: 7
D:  * Cyclic polling is active
D:  * Value map contains 2 entries:
D:       1: (101..4095 = 50..100)
D:       2: (0..100 = 0..0)
D:  * Delta: 2
D:  * Show: 0 (seconds)
D:  --Event-Info--
D:  * onchange-event: "volume=?" (inline)
D:  * There are no click-event(s) defined.
D:  --Timing-Info--
D:  * t-debounce:     0 ms
D:  * t-click   :   300 ms (wait after short click)
D:  * t-long    :   500 ms (detect first longpress)
D:  * t-repeat  :   500 ms (repeated longpress)

If your output is different, you probably had a typo somewhere. Please check and correct the settings.

If all is expected, to have more insight, you should run the in-command with the property show set, for instance to 1, i. e. in.vol=show=1. That should result in somewhat like this:

D: Input "in.vol" (is running) physRead= 2047 ( mapped to:   74)
D: Input "in.vol" (is running) physRead= 2049 ( mapped to:   74)
D: Input "in.vol" (is running) physRead= 2046 ( mapped to:   74)
D: Input "in.vol": change to 76 from 74, checking for events!
D: Executing onchange: 'volume=76'
D: Command: volume with parameter 76
D: Input "in.vol": change to 78 from 76, checking for events!
D: Executing onchange: 'volume=78'
D: Command: volume with parameter 78
D: Input "in.vol" (is running) physRead= 2342 ( mapped to:   78)
D: Input "in.vol": change to 80 from 78, checking for events!
D: Executing onchange: 'volume=80'
D: Command: volume with parameter 80
D: Input "in.vol": change to 82 from 80, checking for events!
D: Executing onchange: 'volume=82'
D: Command: volume with parameter 82
D: Input "in.vol" (is running) physRead= 2643 ( mapped to:   82)
D: Input "in.vol": change to 84 from 82, checking for events!
D: Executing onchange: 'volume=84'
D: Command: volume with parameter 84
D: Input "in.vol" (is running) physRead= 2799 ( mapped to:   84)
D: Input "in.vol" (is running) physRead= 2768 ( mapped to:   83)
D: Input "in.vol": change to 82 from 84, checking for events!
D: Executing onchange: 'volume=82'
D: Command: volume with parameter 82
D: Input "in.vol": change to 80 from 82, checking for events!
D: Executing onchange: 'volume=80'
D: Command: volume with parameter 80
D: Input "in.vol" (is running) physRead= 2445 ( mapped to:   79)
D: Input "in.vol": change to 78 from 80, checking for events!
D: Executing onchange: 'volume=78'
D: Command: volume with parameter 78
D: Input "in.vol": change to 76 from 78, checking for events!
D: Executing onchange: 'volume=76'
D: Command: volume with parameter 76
D: Input "in.vol" (is running) physRead= 2194 ( mapped to:   76)
D: Input "in.vol": change to 74 from 76, checking for events!
D: Executing onchange: 'volume=74'
D: Command: volume with parameter 74
D: Input "in.vol" (is running) physRead= 2054 ( mapped to:   74)
D: Input "in.vol" (is running) physRead= 2058 ( mapped to:   74)
D: Input "in.vol" (is running) physRead= 2036 ( mapped to:   74)

Here, for the first 3 seconds the volume changing resistor has not been touched. The volume is then changed up to 84 (by changing the variable resistor) and then back to 74 again.

While you have that output up and running, you can play around with the properties setting to understand some effects. Try in.vol=mode=0 to see how much noise is then on the input before going back to filter=7 again by in.vol=mode=28:

D: Command: in.vol with parameter mode=0
D: Input "in.vol" (is running) physRead= 2209 ( mapped to:   76)
D: Input "in.vol": change to 76 from 78, checking for events!
D: Executing onchange: 'volume=76'
D: Command: volume with parameter 76
D: Input "in.vol" (is running) physRead= 2196 ( mapped to:   76)
D: Input "in.vol" (is running) physRead= 2180 ( mapped to:   76)
D: Input "in.vol" (is running) physRead= 2225 ( mapped to:   77)
D: Input "in.vol": change to 78 from 76, checking for events!
D: Executing onchange: 'volume=78'
D: Command: volume with parameter 78
D: Input "in.vol" (is running) physRead= 2203 ( mapped to:   76)
D: Input "in.vol": change to 76 from 78, checking for events!
D: Executing onchange: 'volume=76'
D: Command: volume with parameter 76
D: Input "in.vol" (is running) physRead= 2223 ( mapped to:   77)
D: Input "in.vol" (is running) physRead= 2253 ( mapped to:   77)
D: Input "in.vol" (is running) physRead= 2237 ( mapped to:   77)
D: Command: in.vol with parameter mode=28
D: Input "in.vol" (is running) physRead= 2237 ( mapped to:   77)
D: Input "in.vol" (is running) physRead= 2231 ( mapped to:   77)
D: Input "in.vol" (is running) physRead= 2240 ( mapped to:   77)
D: Input "in.vol" (is running) physRead= 2207 ( mapped to:   76)
D: Input "in.vol" (is running) physRead= 2231 ( mapped to:   77)
D: Input "in.vol" (is running) physRead= 2225 ( mapped to:   77)
D: Input "in.vol" (is running) physRead= 2230 ( mapped to:   77)
D: Input "in.vol" (is running) physRead= 2230 ( mapped to:   77)
D: Input "in.vol" (is running) physRead= 2241 ( mapped to:   77)
D: Input "in.vol" (is running) physRead= 2220 ( mapped to:   77)

Whith mode=0 (means filter=0) the readout at the observation points was distributed between 2180 and 2253 (distance 73) and also some (unwanted) volume change events occured. With mode=28 the distance was lower at 34 (2241 - 2207) and no volume change event has been generated. So mode=28 is probably the better setting.

While we are at it, play with b0 of mode. As effect, the physical readout should inverse, and the direction of increasing/decreasing voltage should reverse. Enter in.vol=mode=29 to see (hear) the difference. Switch back to 'normal' using the command in.vol=mode=28.

You can change every property of an Input at any time. Whenever a property is changed, the others are not affected: a change to mode as just done will not affect the map (or any other property). You could even switch to a different input-pin, if this makes any sense from application perspective. (Remember that if an argument is not given for a property, that argument is assumed to be the empty string '', that reads 0 if a numeric property is set).

Try: in.vol=map=(100..2000=50..100)(2100..4000=100..50)(2000..2100=100)(0..4095=0)

This will make that the volume at either end of the tuning range (from physical read 0..100 and 4000..4095) is 0 and will increase to max volume of 100 in the middle if turned to the other side and will go down to 0 from the middle again. Is this a useful feature? Probably not, but its cool to be able to do this.

Second example: tuning

If everything is up and running, we can also change the Input to have a totally different meaning: not to change the volume, but to change the preset. That is normally achieved by a variable capacitor, but since a variable resistor is already at hand and only two Input modifications are required (change from analog pin to touch pin and use a different mapping), we can use the Input with the variable resistor as simulation.

First, stop the volume Input using the command in.vol=stop

Then, change the "onchange" event of the Input: in.vol=onchange={preset=?} Next, simplify the mapping to: in.vol=map=(0..4095=0..2) to just tune between presets 0 and 2. Then, set delta to 1 and re-start the cyclic polling of the (and the debug show) Input with the new mapping and event: in.vol=start, delta=1, show=1 Verify the settings with in.vol=info, output on Serial should be this:

D: Info for Input "in.vol":
D:  * Src: Analog Input, pin: 39, Inverted: 0, PullUp: 0, Filter: 7
D:  * Cyclic polling is active
D:  * Value map contains 1 entries:
D:       1: (0..4095 = 0..2)
D:  * Delta: 1
D:  * Show: 1 (seconds)
D:  --Event-Info--
D:  * onchange-event: "preset=?" (inline)
D:  * There are no click-event(s) defined.
D:  --Timing-Info--
D: .....

If you play around, you will notice: it works in principle, but not quite. The first thing you will notice is that the mapped input value will be 0 for the physical readings of 0 to 1023, 1 for 1024 to 3073 and 2 for 3074 and up. So the map is skewed (the catching range for 1 is 2048 wide, but only 1024 wide for 0 and 2. So the map() function is skewed.

The next problem is that the presets will jitter if the reading is just between two presets. To increase the problem, set the filter to 0 by using the command in.vol=mode=0. Then try to tune close to a readout of 1024. You will notice a constant change between presets.

To solve this problems, Gaps are permitted in the value map. If the current physical read falls within a gap, the Input is considered to be unchanged. For the first start, if the physical read is within a gap, the next matching value in the mapping entries closest to the physical readout is assumed.

in.vol=map=(0..1200=0)(1448..2648=1)(2894..4095=2) will do the trick.

Using touch input (also to read a variable capacitor)

General precondition for reading variable capacitor values

The touch inputs of ESP32 can be used to read variable capacitors. The typical AM radios use variable capacitors in the range of around 300 pF. These can be read through the touch pins. If using the native Espressif-IDF APIs for touch sensors the readings are stable and precise enough to give a reading to distinguish for at least 10 positions (hence "tunable" stations) of the tuning knob.

One terminal of the capacitor must be connected to the pin direct, the other to ground (when recycling an old radio make sure any other connection to the capacitor is cut off, especially be sure that the coil of the oscillator circuit is no longer connected in parallel to the capacitor).

As a rule of thumb, the wire between capacitor and input pin should be as short as possible and as freely running as possible to improve the readings. Especially avoid twisting with other wires if possible.

Calibrating the Input

The touch pin could return any reading between 0 and 1023 in theory. In practice, if a variable capacitor is connected, the reading would be somewhere in between. The reading will also change depending on the wiring (length and path) between capacitor and ESP32. So you probably will have different readings using the same capacitor on the breadboard and in the final product.

For undisturbed reading during calibration it is recommended to stop any other output to Serial by entering DEBUG = 0 on the Serial command input.

For our example we assume the capacitor to be connected to T9. To read the capacitor we can use the 'in' command and link the source to the touch pin T9 with the follwing line from the serial input:

in.tune=src=t9,show=1

That should give an output on Serial like this when tuning from highest to lowest capacity (or lowest to highest frequency on the associated radio scale):

Input "in.tune" (is stopped) physRead=  103
Input "in.tune" (is stopped) physRead=  115
Input "in.tune" (is stopped) physRead=  131
Input "in.tune" (is stopped) physRead=  144
Input "in.tune" (is stopped) physRead=  184
Input "in.tune" (is stopped) physRead=  230
Input "in.tune" (is stopped) physRead=  294
Input "in.tune" (is stopped) physRead=  372
Input "in.tune" (is stopped) physRead=  422
Input "in.tune" (is stopped) physRead=  467

The numbers are abritrary of course, your mileage may vary. In this example the reading range is between 103 and 467. You should observe a rather stable reading if the position of the tuning knob is not changed (jitter by one only, no matter what the tuning position is). You should also observe that the change is nonlinear. The higher the reading the less you have to turn for a change in the Input readout.

For instance on my scale I have the the frequencies 530, 600, 800, 1200 and 1600 written on the AM scale. The corresponding readings are 113, 140, 212, 355 and 460 in my example. A possible tuning map (with some fishing range and gaps as discussed above) for 5 'channels' mapped to that positions could be defined as: in.tune=map=(0..120=1)(130..160=2)(180..250=3)(330..390=4)(420..600=5)

The reading on Serial monitor should change to something like this (depending on mapping and the position of the capacitor):

Input "in.tune" (is stopped) physRead=  118 ( mapped to:    1)
Input "in.tune" (is stopped) physRead=  126 (nearest is:    2)
Input "in.tune" (is stopped) physRead=  139 ( mapped to:    2)
Input "in.tune" (is stopped) physRead=  174 (nearest is:    3)
Input "in.tune" (is stopped) physRead=  218 ( mapped to:    3)
Input "in.tune" (is stopped) physRead=  282 (nearest is:    3)
Input "in.tune" (is stopped) physRead=  334 ( mapped to:    4)
Input "in.tune" (is stopped) physRead=  377 ( mapped to:    4)
Input "in.tune" (is stopped) physRead=  460 ( mapped to:    5)

If everything is as expected, the calibration output could be stopped by issuing the command: in.tune=show=0

Now the mapped reading can be used to switch presets. All what is needed to use this Input to change presets is to start the cyclic polling of the preset and assign a onchange-event:

_in.tune=onchange={preset=?}_

Check the settings by entering the command: in.tune=info

The resulting printout on Serial should be:

D: Info for Input "in.tune":
D:  * Src: Touch Input: T9, Digital use: 0, Auto: 0 (pin value is used direct w/o calibration)
D:  * Input is not started (no cyclic polling)
D:  * Value map contains 5 entries:
D:       1: (0..120 = 1..1)
D:       2: (130..160 = 2..2)
D:       3: (180..250 = 3..3)
D:       4: (330..390 = 4..4)
D:       5: (420..600 = 5..5)
D:  * Delta: 1
D:  * Show: 0 (seconds)
D:  --Event-Info--
D:  * onchange-event: "preset=?" (inline)
D:  * There are no click-event(s) defined.
D:  --Timing-Info--
D:  * t-debounce:     0 ms
D:  * t-click   :   300 ms (wait after short click)
D:  * t-long    :   500 ms (detect first longpress)
D:  * t-repeat  :   500 ms (repeated longpress)

To actually make the radio change the preset by changing the tuning knob you need to start cyclic polling of the Input:

in.tune=start

So if you are satisfied with those settings, the final entries in the NVS preferences could be given as follows to take effect at every start of the radio:

$tunemap =      (0..120 = 1)(130..160 = 2)(180..250 = 3)(330..390 = 4)(420..600 = 5)
# Arbritary key to store the map for later use

in.tune = src=t9,map=@$tunemap, onchange={preset=?}, start
# Use touch T9 as Input, using the predefined $tunemap, execute 'preset=?' on change of Input and start cyclic polling of the Input 

Or you could use the variable capacitor for different thinks, like changing the volume: in.tune=src=t9,map=(110..475=50..100)(0..109=0)(476..500=100),onchange={volume=?},start

Together with the example above it is thus possible to use the frequency knob to change volume and the volume knob to change presets. What a crazy world!

Of course it is also possible to change the direction of volume increase/decrease by changing the Input map: in.tune=map=(110..475=100..50)(0..109=100)(476..500=0)

Classic touch reading

If you just want to detect touch input, you can also use the new Input mechanism. We assume the touch input is T8. So define a touch Input like this:

in.touch=src=t8,show=1

This will result in an output on Serial like such:

D: Input "in.touch" (is stopped) physRead=  848
D: Input "in.touch" (is stopped) physRead=  842
D: Input "in.touch" (is stopped) physRead=  223
D: Input "in.touch" (is stopped) physRead=  190
D: Input "in.touch" (is stopped) physRead=  847
D: Input "in.touch" (is stopped) physRead=  777
D: Input "in.touch" (is stopped) physRead=  283
D: Input "in.touch" (is stopped) physRead=  518
D: Input "in.touch" (is stopped) physRead=  846

The higher readings (above 800 in this example) are while not touched (open), the lower readings (below 600) when touched. To convert the readings into digital information you could apply a map to the Input: in.touch=map=(600..1023=1)(=0)

With this map the Input will read 1 if untouched and 0 if touched:

D: Input "in.touch" (is stopped) physRead=  856 ( mapped to:    1)
D: Input "in.touch" (is stopped) physRead=  176 ( mapped to:    0)
D: Input "in.touch" (is stopped) physRead=  135 ( mapped to:    0)
D: Input "in.touch" (is stopped) physRead=  854 ( mapped to:    1)
D: Input "in.touch" (is stopped) physRead=  855 ( mapped to:    1)
D: Input "in.touch" (is stopped) physRead=  136 ( mapped to:    0)
D: Input "in.touch" (is stopped) physRead=  815 ( mapped to:    1)
D: Input "in.touch" (is stopped) physRead=  856 ( mapped to:    1)

There is also a simplified way using the mode-property of the touch Input. If bit b0 is set, the Input is automatically converted to a binary reading. In our example, first delete the Input-map by assigning an 'empty' string as map: in.touch=map=

And then set bit b0 in the mode-property: in.touch=mode=1

And the result will be something like this (Input is touched/untouched in this example):

D: Input "in.touch" (is stopped) physRead=    1
D: Input "in.touch" (is stopped) physRead=    0
D: Input "in.touch" (is stopped) physRead=    1
D: Input "in.touch" (is stopped) physRead=    0
D: Input "in.touch" (is stopped) physRead=    1
D: Input "in.touch" (is stopped) physRead=    0
D: Input "in.touch" (is stopped) physRead=    0
D: Input "in.touch" (is stopped) physRead=    1

Using the mode-bit b0 should be the preferred option if a touch-Input shall be used as binary Input (touched/not touched).

For reacting on Input changes, using the onchange event is problematic, as it triggers both if the Input is changing from 1 to 0 (touch detected) and if the Input changes from 0 to 1 (touch released). There are two more change-properties that can be used (on any other type of Input as well, of course):

  • on0 will trigger if the Input changes from not Zero to Zero. If that happens the command sequence associated to on0 will be executed.
  • onnot0 will triggered on change of the Input from Zero to not Zero.
  • on0 and onnot0 can be used on any Input, also on analog reads.
  • onchange will always trigger before either of on0 or onnot0 triggers (if appropriate).
  • the value associated to on0 or onnot0 can either be a command sequence direct (enclosed in {}) or a key-name that is searched for in RAM/NVS. (value substituation is still done for "?", though in case of on0 it will always be "0").
  • to be clear: onnot0 will only trigger if the last read was 0. So if an analog Input changes from 0 to 1, onchange and onnot0 will trigger. If later the Input changes to 2, onchange will trigger again, but not onnot0

As a simple example, to use the touch pin T8 to toggle mute, the command could be:

in.touch=src=t8,mode=1,on0={mute},start

That way, also the touch_XX settings from preferences are translated to the following at startup:

in.touch_XX = src=tXX, mode=1, on0=touch_XX, start

Using digital input (GPIO)

The handling is quite similar. Consider for instance that we have the following entry in the preferences:

gpio_33 = mute

This will translate into:

in.gpio_33 = src=d33, mode=2, on0=gpio_33, start

Nothing spectacular here, just notice:

  • src=d33 makes this a digital Input linked to GPIO33 (again, no checking if this is a valid pin-nr and also no checking if this pin has not been reserved before).
  • for the mode property bits b0 and b1 are evaluated:
    • b0: if set, the readout is inversed (not inversed in our case)
    • b1: if set (it is in our case), GPIO is configured with INPUT_PULLUP
    • all other bits are reseved for future ideas and should be 0 for now.
  • on0 holds the key (to NVS) that contains mute as command.
  • start activates the Input that is now listening on GPIO33

Now the radio should toggle mute whenever GPIO33 goes low. If this does not work, use property show=1 for debugging.

The good thing is, you can now easily change the assignment of the action that the GPIO performs. Try:

in.gpio_33 = on0={uppreset=1}

Click-events

Any Input can be used to react to click-events. A Input is considered pressed if it reads 0, unpressed if it reads something other then zero. Depending on the pressing sequence, the following events can be used:

  • on1click the Input has been short pressed once.
  • on2click the Input has been short pressed twice.
  • on3click the Input has been short pressed thrice.
  • onlong the Input is longpressed.
  • on1long the Input is longpressed after a single click.
  • on2long the Input is longpressed after a double click.

If you want the radio to toggle mute on a shortpress, and to run uppreset=1 on doubleclick, all you need to do is to enter:

in.gpio_33 = on0=       # "delete" the old event {uppreset=1}
in.gpio_33 = on1click={mute}, on2click={uppreset=1}

You could (in addition) also add some functionality to increase/decrease volume on longpress (decrease) or longpress after 1 shortclick (increase):

in.gpio_33 = onlong={downvolume=1}, on1long={upvolume=1}

You will notice that the up/down-volume is working, albeit slow. There are a few properties that control the timing behaviour of the click-events:

  • t-long=500 (default setting is 500): if Input is pressed, how long to wait before "longpress" is detected (in ms)?
  • t-repeat=500 distance between longpress events (in ms)
  • t-click=300 after Input has been released (and that release was before t-long was reached, how long to wait for a next button press to decide if it was just a single click or a start of a double-click.

Especially t-click is somewhat critical: if set too high, it takes to long till the click-event is evaluated. If set too low, a double-click will get lost and two single clicks will be reported instead. If you start debug-Output for the Input again with the show-property set to a high number, you will see what is going on with the click-events. Take the longpress for volume-down as example (with default timings:)

in.gpio_33=show=3000

If the GPIO is pulled low, Serial should read now:

17:34:08.815 -> D: Input "in.gpio_33": change to 0 from 1, checking for events!
17:34:09.312 -> D: Click event for Input in.gpio_33: long (Parameter: 1)
17:34:09.312 -> D: in.gpio_33 onlong='downvolume=1'
17:34:09.345 -> D: Command: downvolume with parameter 1
17:34:09.809 -> D: Click event for Input in.gpio_33: long (Parameter: 2)
17:34:09.842 -> D: in.gpio_33 onlong='downvolume=1'
17:34:09.842 -> D: Command: downvolume with parameter 1
17:34:10.339 -> D: Click event for Input in.gpio_33: long (Parameter: 3)
17:34:10.339 -> D: in.gpio_33 onlong='downvolume=1'
17:34:10.339 -> D: Command: downvolume with parameter 1
17:34:10.835 -> D: Click event for Input in.gpio_33: long (Parameter: 4)
17:34:10.835 -> D: in.gpio_33 onlong='downvolume=1'
17:34:10.868 -> D: Command: downvolume with parameter 1
17:34:11.365 -> D: Click event for Input in.gpio_33: long (Parameter: 5)
17:34:11.365 -> D: in.gpio_33 onlong='downvolume=1'
17:34:11.365 -> D: Command: downvolume with parameter 1
17:34:11.597 -> D: Input "in.gpio_33": change to 1 from 0, checking for events!
17:34:11.597 -> D: Click event for Input in.gpio_33: long (Parameter: 0)
17:34:11.597 -> D: in.gpio_33 onlong='downvolume=1'
17:34:11.597 -> D: Command: downvolume with parameter 1

So falling edge of the Input was detected at 17:34:08.815, around 500ms (give or take a few ms for jitter on the sampling timescale) later (t-long-time) at 17:34:09.312, the first long press event was triggered and then a few more in again 500ms distance (now t-repeat-time) until at 17:34:11.597 the GPIO was released and a last longpress event has been triggered. Note that long-press-events have a parameter that can be evaluated by substituting "?" in the associated commands sequence (not used in this example). If the parameter is "1", it is the first long press detected, the parameter will be increased by one for any additional event, and will be 0 at the end to indicate that the Input has been released.

If you repeat again with

in.gpio_33=t-repeat=100

And repeat the longpress-testing, the result on Serial should be something like:

17:43:48.826 -> D: Input "in.gpio_33": change to 0 from 1, checking for events!
17:43:49.355 -> D: Click event for Input in.gpio_33: long (Parameter: 1)
17:43:49.355 -> D: in.gpio_33 onlong='downvolume=1'
17:43:49.355 -> D: Command: downvolume with parameter 1
17:43:49.455 -> D: Click event for Input in.gpio_33: long (Parameter: 2)
17:43:49.455 -> D: in.gpio_33 onlong='downvolume=1'
17:43:49.455 -> D: Command: downvolume with parameter 1
17:43:49.554 -> D: Click event for Input in.gpio_33: long (Parameter: 3)
17:43:49.554 -> D: in.gpio_33 onlong='downvolume=1'
17:43:49.554 -> D: Command: downvolume with parameter 1
17:43:49.687 -> D: Click event for Input in.gpio_33: long (Parameter: 4)
17:43:49.687 -> D: in.gpio_33 onlong='downvolume=1'
17:43:49.687 -> D: Command: downvolume with parameter 1
17:43:49.786 -> D: Click event for Input in.gpio_33: long (Parameter: 5)
17:43:49.786 -> D: in.gpio_33 onlong='downvolume=1'
17:43:49.786 -> D: Command: downvolume with parameter 1
17:43:49.886 -> D: Click event for Input in.gpio_33: long (Parameter: 6)
17:43:49.886 -> D: in.gpio_33 onlong='downvolume=1'
17:43:49.886 -> D: Command: downvolume with parameter 1
17:43:50.018 -> D: Click event for Input in.gpio_33: long (Parameter: 7)
17:43:50.018 -> D: in.gpio_33 onlong='downvolume=1'
17:43:50.018 -> D: Command: downvolume with parameter 1
17:43:50.118 -> D: Click event for Input in.gpio_33: long (Parameter: 8)
17:43:50.118 -> D: in.gpio_33 onlong='downvolume=1'
17:43:50.118 -> D: Command: downvolume with parameter 1
17:43:50.217 -> D: Click event for Input in.gpio_33: long (Parameter: 9)
17:43:50.250 -> D: in.gpio_33 onlong='downvolume=1'

You will notice, that the first longpress is still reportet after 500ms (t-long has not been changed and is still 500), but all the following events are coming at a shorter distance of around 100ms as expected. If not, you should debug the Input by running:

in.gpio_33=info

The output should be:

D: Command: in.gpio_33 with parameter info
D: Info for Input "in.gpio_33":
D:  * Src: Digital Input, pin: 33, Inverted: 0, PullUp: 1
D:  * Cyclic polling is active
D:  * Value map is NOT set!
D:  * Delta: 1
D:  * Show: 3000 (seconds)
D:  --Event-Info--
D:  * there are no change-event(s) defined.
D:  * on1click-event: "mute" (inline)
D:  * on2click-event: "uppreset=1" (inline)
D:  * onlong-event: "downvolume=1" (inline)
D:  * on1long-event: "upvolume=1" (inline)
D:  --Timing-Info--
D:  * t-debounce:     0 ms
D:  * t-click   :   300 ms (wait after short click)
D:  * t-long    :   500 ms (detect first longpress)
D:  * t-repeat  :   100 ms (repeated longpress)

Using Inputs to read internal variables

Some internal variables can be read through Inputs. The list of available variables changes. You can check the current implementation by entering just the "~" as sole character. The output should be like this:

23:11:17.173 -> D: Known system variables (8 entries)
23:11:17.173 -> D:  0: 'volume' = 70
23:11:17.173 -> D:  1: 'preset' = 0
23:11:17.173 -> D:  2: 'toneha' = 5
23:11:17.173 -> D:  3: 'tonehf' = 3
23:11:17.173 -> D:  4: 'tonela' = 15
23:11:17.173 -> D:  5: 'tonelf' = 12
23:11:17.173 -> D:  6: 'channel' = 1
23:11:17.173 -> D:  7: 'channels' = 9

These variables can be used (read only!) if the name is preceeded by "~". This is also possible for using those in an Input to the RetroRadio. That way, we are able to do some neat things. Like solving the problem "if volume is below 50 I do not hear anything". We have partly solved that for the analog Input of a variable resistor, but that will only prevent volume settings between 1 and 49 from the "volume knob" attached to the analog Input. Here is the solution to limit volume settings to 50 to 100 (or 0) only, from any source:

in.minvol=src=~volume,map=(50..100=1)(0=2)(=0),onchange=:v_limit,start
ram.:v_limit=if(?)={.lastv = ~volume}{if(.lastv)={volume=0}{volume=50}}

The first line defines the Input that observes the internal value "~volume" (which equals the ini_block.volume setting). The second line stores the command-sequence to RAM that will be executed if the volume changes. The Input will read as 1 if the volume setting is between 50 and 100, it will read 2 if volume setting is 0 and it will read 0 on any other value. So we have the following possible transitions:

  • volume is 0, and goes to 1..49: the Input readout will change from 2 to 0
  • volume is 0, and it goes to 50..100: readout will change from 2 to 1
  • volume is between 50..100 and goes to 0: readout will change from 1 to 2
  • volume is between 50..100 and goes to 1..49: readout will change from 1 to 0 We do not want to be in the range of 1..49. If we enter that range, we want to go either to 0 (if coming from >= 50) or 50 (if coming from 0). Unfortunately, with the onchange event, we do only get the current readout. So we need to store the last state, which is done in the assigned onchange-event ":v_limit". Lets assume the first readout was within one of the desired ranges. Then the Input will call the :vlimit with either '1' or '2'. So if(?) evaluates to true, _RAM .lastv stores the current volume (Either '0' or anything between '50' and '100'). if later a change into the undesired zone happens, .lastv will be checked. If it is "0", the command volume=50 is executed, otherwise volume=0. This will in return change the Input readout again, thus calling :v_limit again direct which will again store .lastv as either "0" or "50".

Using Inputs to read MQTT-Messages

  • An Input can be used to react on MQTT-Messages sent to a specific topic.
  • The syntax is as follows:
in.mqttecho=src=m echo,onchange={mqttpub reply=?},start
  • This example defines an Input with the name mqttecho
  • The property src is set to m echo which defines it as source MQTT and the (sub-) topic echo. This subtopic will be attached to the default mqtt-prefix of the radio (i. e. ESP32Radio) to form the full topic that the input will listen to (i. e. ESP32Radio/echo).
  • If you want to react to messages outside the default "scope" of the radio, add a '/'-sign before the topic name (So src=m /echo will listen to the topic echo and not to ESP32Radio/echo). Note that the leading '/' will be deleted before subsribing to that topic.
  • The property onchange defines the reaction that will be executed whenever a new message arrives (note that it will also fire if the message content of the new message is identical to the last message). In our case it would simply publish the message received to MQTT using the sub-topic reply (to be extended to the full topic, i. e. ESP32Radio/reply in our example)
  • start will activate the input (stop would halt it).
  • the property info can be used as usual to display information on the input
  • all other properties (like map, on0 etc.) are not used for the MQTT input.
  • You can subscribe to as many topics as needed for your application. You have to define an Input for each individual message.
  • You can not use (override) the command input topic (i. e. ESP32Radio/command in our example)

Sending MQTT-Messages

  • You can send MQTT-messages with the command
  mqttpub hello=world
  • This will send the message world to the sub-topic hello The full topic will be the default MQTT-Prefix (i. e. ESP32Radio) extended by the given sub-topic (i. e. ESP32Radio/hello in this example)
  • The message will not be sent direct but added to a "backlog". The order of messages sent by mqttpub will be retained, exact timing is not guaranteed.

Scripting Summary

Storing and retrieving values

  • values can be stored to NVS (known as preferences) and now also to RAM
  • in both cases, values are identified by a key-name, which can hold a value (assigned to by "=" in preferences in case of NVS).
    • example from preferences: pin_ir = 5 defines the value of pin_ir to be 5.
  • there is some mixup between commands and preferences. For instance you will find volume = 75 in preferences. This line is (at startup) interpreted as command to set the volume to 75, but it might also change during runtime to store the current value of volume setting.
  • the value associated to a key can be any arbritrary string constant.

We will start with RAM, as this is more safe. Accidentially deleting a vital NVS entry while playing around might cause trouble. The basic commands to manipulate RAM and NVS entries are similar. RAM entries can be used to store and retrieve information that can be used for commands. They are only available through the current power cycle of course.

Using command ram to list RAM entries:

  • you can list the contents of RAM using the command
	ram?[ = key-name-part ]
  • this lists the current entries in RAM. If key-name-part is given, only entries that have key-name-part as substring in their key are listed.
  • listing is shown on Serial (even if DEBUG=0).
  • if nothing has been set by your scripting so far, ram? should list nothing

Using command ram to add/update a RAM entry:

	ram.key-name = value
  • this sets the value of key-name in RAM to value. If key-name does not exist, it is created by that command.

Using command ram to delete a RAM-entry

  • you can delete an entry in RAM using the command
	ram- = key-name
  • this deletes the entry associated to key-name from RAM. There will be no complaints if key-name does not exist in RAM.
  • full key-name must be specified, no pattern matching, so only one entry can be deleted by this command.
  • from Serial, try:
	DEBUG = 0
	ram? = test
	ram.test = 77
	ram? = test
	ram.test = 0
	ram? = test	
	ram- = test
	ram? = test

The dot "." can be used as shortcut for the ram-command:

  • .? = key-name-part is the same as ram? = key-name-part
  • .key-name = value is equivalent to ram.key-name = value
  • .- = key-name is equivalent to ram- = key-name

The dot is also used to dereference RAM keys to use them in commands, for instance as argument. Example:

	DEBUG = 1
	.test = 77
	volume = .test

This sequence will store 77 to RAM entry named "test" and this value is then passed as argument to the volume command. As a result, radio will play at volume level 77.

Handling/using of NVS entries is similar. Whenever possible, RAM entries should be preferred to store information. Use NVS entries only if your usecase requires the data to be persistent over power cycles.

Using the nvs command:

  • you can list the contents of NVS using the command nvs?[ = key-name-part ] (shortcut: &? = key-name-part)
  • you can add/update an NVS entry using the command nvs.key-name = value (shortcut: &key-name = value)
  • you can remove an NVS entry using the command nvs-=key-name (shortcut: &- = key-name). It should be clear that deleting NVS entries has the possibility to push you into danger zone again, if for instance pin entries are deleted).

The ampersand "&" can also be used to dereference NVS-entries to be used as argument to commands. For demonstration purposes, try:

	DEBUG = 1
	volume = 80
	&test = 52
	volume = &test

You can also dereference a key by using "@". Try:

	volume = @test

The logic for dereferencing @key-name is as follows: if key-name exists in RAM, this value is used. If not, the value from NVS is used. If key-name also does not exist in NVS, empty string (evaluated to "0" if number is expected) is used. So volume should be set to 77 if you followed the steps so far, as there still should be a RAM-entry test with value "77".

If you do not want the argument to be evaluated, but just store a string constant, you have to surround this string constant by round brackets. The brackets will be removed before the command is executed. In our example, try:

	DEBUG = 1
	volume = ( @test )

That should result in an output on Serial like: Command: volume with parameter @test which means: the brackets have been removed and parameter passed to command volume is "@test". As volume expects a number (as string of digits) that will fail. If converted to a number using atoi("@test") the result would be "0". So say good-bye to whatever you just heared.

This is also important to understand if you want to assign for instance a command-sequence to a RAM (or NVS) key. Consider the following idea: you want to go "home", where "home" means: preset=1 and volume = 70 To be available for later calls, you want to store that command sequence into RAM using the key :home. Entering .:home=preset=1;volume=70 will not do what is wanted. Output on Serial would be something like this:

23:58:59.026 -> D: Command: .:home with parameter preset=1
23:58:59.026 -> D: Command: volume with parameter 70
23:58:59.026 -> D: Executed 2 command(s) from sequence .:home=preset=1

If you run the command .?=home you will see that the value for RAM-key :home is just preset = 1. The reason is that in fact the line :home=preset=1;volume=70 is interpreted as two commands:

	.:home = preset = 1
	volume = 70

To achieve what was wanted, you have to use round brackets in this case:

	.:home = (preset = 1; volume = 70)

You can verify by listening the content of the key using .?=:home Or you can just use that entry by call = :home

Text substitution for command arguments

Besides the "simple" dereferencing of RAM/NVS keys, you can also do a more complex substitution of a value passed to a command. If (the argument) to any comments start with an double quotation mark:

  • the remainder of the argument is searched (from left to right) for identifiers pointing either to NVS (if preceeded by '&'), RAM (preceeded by '.'), RAM or NVS (preceeded by '@') or a system variable (preceeded by '~').
  • the identifier following the preceeding character must only contain letters, digits, the '$'-sign or the underscore character '_'.
  • if no valid identifier is found, no substitution will take place.
  • if the same character is found again after the preceeding character, it will be reduced to one remaining character (i. e. "@@" will be replaced by "@", "~~" by "~" etc.)
  • if a valid identifier has been found, the appropriate content will be inserted into the argument string (empty string if identifier is not known in the specified context)

As an example, if you want to store a String in RAM that contains the current time into RAM using the identifier clock, you could use:

	.clock = "It is now ~hour o'clock and ~minute minutes.

Verify the result by entering:

	.?=clock

Control flow

Word of warning: there are internal limits (like NVS keylen or NVS content len or NVS size). There is no protection against buffer overruns or creating endless loops. So keep that in mind with your design. In theory you can deeply nest control structures (calling a subroutine in an else case of another if in a while loop). In practice that is error prone, difficult to read and maintain. Better solution is probably to define and use flags to flatten the nesting.

Another word of warning: the "RadioScriptingLanguage" was not planned but has happened (was developed on the go). The language grammer might be not be bullet prove, so observe whats happening and dont be surprised if some scripting constructs are not behaving as expected (well, I am surprised sometimes). There are no "compiler errors" generated nor is there any form of "exception handling". Best case it will just not work, worse it will show an unexpected behaviour or worst it might crash. The examples in this README or the defaultprefs.h do work, though.

The advantage is, that you can try scripting using the Serial interface. So you can test your ideas on the fly, and if you came up with a working solution you can integrate that solution to preferences. There are some debug facilities (like verbose command execution) included in the scripting language.

  • Sequences are allowed. Several commands can be separated using the semicolon ';' Commands will be executed from left to right. Last command does not need to be terminated by semicolon.
  • So on serial, you can still just enter one command without any change for the known commands. Or you can enter a sequence on serial that will be executed directly, for instance: preset=1;volume=70 to set both preset and volume.

Executing commands at startup:

  • At startup NVS is searched for key ::setup. The content of this key is expected do be a command sequence that will be executed. At this point in time, the radio is about to start player task. So there is no audio yet, and you can still alter the settings (for instance for preset or volume) to override the "defaults" based on your usecase.
  • For convenience, (if one line is not enough), after ::setup has been searched (and executed if found), ::setup0 to ::setup9 are searched and executed in that order. Gaps dont matter. So it is perfectly legal, if ::setup7 is the only entry, it will be executed even though ::setup (or any other of ::setup0 to ::setup6) does not exist.

Executing commands during runtime:

  • At every loop() of the software, NVS is searched for key ::loop. The content of that key are expected do be a command sequence and will be executed.
  • For convenience, (if one line is not enough), after ::loop has been searched (and executed if found), ::loop0 to ::loop9 are seearched and executed (if defined) in that order. Gaps dont matter. So it is perfectly legal, if ::loop7 is the only entry, it will be executed even though ::loop (or any other of ::loop0 to ::loop6) does not exist.
  • To speed things up, ::loopx is not retrieved from NVS every time, but a RAM copy will be taken at startup. Therefore if you change any ::loop-key that change will only take effect after next start of the radio.
  • DEBUG is set to 0 before executing any ::loop key to avoid flooding of Serial output. If you need it within a ::loop-Sequence, you need to turn it on again from within the sequence.

If-Command:

  • is implemented as follows:
	if[.result-key](condition)= {if-command-sequence}[{else-command-sequence}]
  • parts in square brackets (.result_key and {else-command-sequence} are optional.
  • condition is an unary or binary expression. Can be empty and is evaluated as false (=0) then. A unary (rvalue only) is true, if the rvalue is not zero.
  • an rvalue can be a number, a reference to NVS-key (&key), a reference to RAM-key (.key), a reference to either RAM (if defined there) or NVS (if not defined in RAM) using @key or a system variable (~variable). Undefined rvalues are assumed to be 0.
  • numbers are calculated on int_16 base, so range is limited to -32768..32767. There is no protection against over/underruns. Only decimal numbers can be used (no hex or binary).
  • two rvalues can be combined by a binary operator. The following operators are permitted:
    • "==", "!=", "<=", ".." , ">=", "<", "+", "^", "*", "/", "%", "&&", "&", "||", "|", "-", ">"
  • work exactly as in C, with the exception of "..". With op1 .. op2 a random number is generated within the range of op1 (included) up to op2 (op2 is also included). So if(1 .. 3) will generate numbers between 1 and 3 (hence never 0 or always true for if) whereas so if(0..3) will generate numbers between 0 and 3 (and will therefore be true in 3 out of 4 cases only).
  • the result of the expression is stored into RAM using key "result-key" if the key (preceeded by '.') is placed after if but before the condition. Result-key can be any string that is suitable as key into RAM and is purely optional. So if.xyz(0..3)={} will store a number between 0 and 3 to RAM-key "xyz" (and do nothing else as the if-command-sequence is empty). (with the command .?=xyz you can list the contents of RAM for all keys that contain "xyz" in the key name. Or you can chain if.xyz(0..4)={};.?=xyz; The result of the calculation is already stored into RAM and available within the if- or else- command sequence (whichever fires).
  • The result of the calculation will also be used to substitute all occurances of the question mark "?" within the if/else command sequence before execution. This is a strict textual replacement, "?" will be replaced by the resulting number without any leading/trailing spaces or "0"-characters. That allows for a "simulation of array data structures": if(1..4)={ram.test??=????};.?=test will store xxxx into RAM-key testxx (with x being one of the digits between '1' and '4').
  • Both if- and else- command sequence must be surrounded by curly braces, even if they contain only 1 (or 0) commands. else-Sequence is optional.
  • For convenience, there is also the ifnot command, that inverts the logical evaluation. So in cases where you do not need the if but the else part only you can avoid writing empty if-sequence by using ifnot: ifnot.xyz(0..4)={.xyz=88};.?=xyz will xyz to 1..4 (due to calculation of condition) or 88 if the condition was calculated to be 0.
  • For debugging, you can add the letter 'v' (verbose) to the command word (so ifv or ifnotv) that will output some (hopefully usefull) information on the serial monitor (also if DEBUG=0)

A note on substituting question marks: the scope of the question mark is respected. So for instance if if-commands are nested, question marks in each command-sequence will be substituted correctly. For instance: ifv.x(7) = {ifv(.x == 7) = { .x = ? }};.?=x will set x to 1. As the verbose command option has been used, Serial output gives some details on what happened:

18:54:34.379 -> ParseResult: if(7)={ifv(.x == 7) = { .x = ? }}; Ramkey=x
18:54:34.379 -> Start if(7)="{ifv(.x == 7) = { .x = ? }}"
18:54:34.379 -> Calculate: ("7" [-1] "")
18:54:34.379 -> Caclulation result=7
18:54:34.379 -> Calculation result set to ram.x=7
18:54:34.413 ->   IfCommand = "ifv(.x == 7) = { .x = ? }"
18:54:34.413 -> ElseCommand = ""
18:54:34.413 -> Condition is TRUE (7)
18:54:34.413 -> Running "if(true)" with (substituted) command "ifv(.x == 7) = { .x = ? }"
18:54:34.413 -> ParseResult: if(.x == 7)={ .x = ? }; Ramkey=
18:54:34.413 -> Start if(.x == 7)="{ .x = ? }"
18:54:34.413 -> Calculate: ("7" [0] "7")
18:54:34.413 -> Caclulation result=1
18:54:34.413 ->   IfCommand = ".x = ?"
18:54:34.413 -> ElseCommand = ""
18:54:34.413 -> Condition is TRUE (1)
18:54:34.446 -> Running "if(true)" with (substituted) command ".x = 1"
18:54:34.446 -> RAM content (3 entries)
18:54:34.446 ->   1: 'x' = '1'

In a nutshell: condition of the left if() was evaluated first to be "7", that "7" has been stored to RAM using key x. So expression is true, and the if-command-sequence gets executed: ifv( .x == ?) = { .x = ? } Before execution, only the "?" in the condition expression is substituted but not the second. And that is as it should be, as the second is part of another if()-command. This has the condition (after substitution) as: ( 7 == 7 ) which is "1", and this is then used to substitute the "?" in the associated if-command-sequence to yield .x = 1 which finally sets x to 1. (.?=x is a command to list all RAM-entrys which have "x" as part of their key-name, which is only 'x' in our case).

While-Command:

  • is implemented as follows:
	while[.result-key](condition)= {while-command-sequence}
  • condition is evaluated as with if command. As there, the result of the calculation can be stored to RAM using key .result_key after keyword while and before condition.
  • inverted version (whilenot()) and verbose version (whilev() or whilenotv()) can be used.
  • while-command-sequence is executed as long as the condition is true (so must in fact change the conditions such that it will evaluate to "0" and hence terminate the the loop. So while(1)={} is a bad idea, the loop will idle forever, and other tasks (like playing music or listening on Serial) will not work. Only way to terminate is a HW- or power-on reset.
  • in general, use this command with special caution.

Calc-Command:

  • is implemented as follows:
	calc[.result-key](expression) [= {calc-command-sequence}]
  • this is not exactly a command that influences the control flow (calc-command-sequence will always be executed, if given), just the syntax is identical to if. (you can consider this as if with identical if and else sequence.
  • expression is evaluated same way as for if-condition. The result can be stored to RAM using the given result-key or used in the (optional) calc-command-sequence. Both result_key and calc-command-sequence are optional, however if none is given, the command takes no effect. is evaluated as with if command. As there, the result of the calculation can be stored to RAM using key .result_key after keyword calc and before the expression.
  • the result of the calculation can be used for '?'-substitution within the calc-command-sequence
  • calcv() for verbose version can be used.

Idx-Command:

  • is implemented as follows:
	idx[.result-key](list-index, list) [= {idx-command-sequence}]
  • this command does also not influences the control flow (idx-command-sequence will always be executed, if given). - list-index is expected to be an integer. If the integer is greater or equal to 1 (so 'n') the n-th element of the following (comma-delimited) list beginning from the leftmost list-element.
  • list_index must be either a literal or can be dereferenced (by @, &, . or ~)
  • if list-index is the literal "0", the idx-command will return the number of elements in the list
  • if list-index is bigger than the number of elements (or below 0), the empty string "" is returned.
  • if the optional result-key is given, the result will be stored into RAM using result-key.
  • the result can be used for '?'-substitution within the idx-command-sequence
  • list element can be any type of string (not only numbers) but must not contain ',' or ')'.
  • each list element can be dereferenced to RAM/NVS using the usual @key-notation. Every dereferenced value can be either a single value or a (sub-) list in itself.
  • idxv() for verbose version can be used.
.list=(@list1, @list2)      # define list in RAM holding a list with two entries @list1, @list2 (references to list1 and list2)
.list1=(@list11, @list12)   # define list1 in RAM holding a list with two entries @list11, @list12 (references)
.list11=entry1, entry2    # define list11 in RAM with two list-items entry1, entry2
.list12=entry3             
.list2=entry4              # list2 holds just entry4

For convenience, here all of the above as command sequence in one line to copy it to Serial input direct:

.list=(@list1, @list2);.list1=(@list11, @list12);.list11=entry1, entry2;.list12=entry3;.list2=entry4

so if evaluated in idx-command, @list will finally expand to ((Entry 1, Entry 2), Entry 3), Entry 4 (Paranthesis are just for clarification to indicate the bounderies of each key in RAM).

For example, you can load a random element of that list into RAM key entry by using

calc(1..4)={idx.entry(?, @list)}

Call-Command:

  • is implemented as follows:
	call[( parameter )] = key-name
  • key-name is used to search for in first RAM and if not found there in NVS. If a match is found, the content of the RAM/NVS entry are expected to be a command-sequence and executed.
  • when this sequence is finished (all commands of the sequence have been executed or a return command was found), the sequence the call() command is in is continued after the call() command.
  • if optional parameter is given, everything between '(' and ')' is used as a string to replace any "?" using the known mechanism in the command-sequence given by key-name. If parameter is given, leading and trailing spaces are removed: call ( param ) = would substitute any "?" by "param".
  • again danger zone here: do not create loops. This innocent looking sequence will crash the radio: .x = call = y; .y = call = x; call = x
    • with the first command, a RAM entry is created with name "x" that holds "call y"
    • second command creates RAM entry "y" with "call x"
    • the third command starts the call cycle "x->y->x->y..." that will trigger the stack watchdog eventually.

Return-Command:

  • is implemented as follows:
	return[.return-key] [= value ]
  • this command will cancel the execution of the current sequence. If that sequence was called from another sequence, execution will commence there (or just stop if there was no caller, like from serial).
  • if return-key is given, value will be stored to that RAM-key. (The caller "must know" the RAM-key to evaluate it)

About

No description, website, or topics provided.

Resources

Stars

Watchers

Forks

Packages

 
 
 

Contributors