webradio.tc¶
webradio.tc — Internet-radio player for an I2S audio board.
// webradio.tc — Internet-radio player for an I2S audio board.
//
// Port of Tasmota Scripter web-radio to TinyC. Streams MP3/AAC internet
// radio through the I2SAUDIO BinPlugin and offers a web UI with a station list,
// a volume slider and a Stop button.
//
// ── What the device needs ────────────────────────────────────────────────────
// * a TinyC firmware (e.g. tasmota32s3-tinyc) on the audio board
// * the I2SAUDIO BinPlugin (the audio driver) MUST be installed on /modu and
// its pins set (DOUT/BCK/WS/CODEC) — nothing plays without it. It supplies
// the I2SWR <url>=play / I2SWR=stop / I2SVol <0..100> commands this app
// drives, and performs all codec init (so NO i2cWrite codec poking is needed).
// * a station file /webradios.txt with one name<TAB>url line per station.
// Upload it via the file manager. Edit it, then re-run the slot to reload.
//
// ── Supported streams (current limits) ───────────────────────────────────────
// * HTTP only. https:// is not supported here (it connects but stutters over
// WAN distance) — use plain http:// station URLs.
// * 48 kHz sample-rate family only (48 / 24 / 12 kHz — e.g. DLF, Bayern 3).
// This board's audio clock is locked to a 48 kHz crystal (12.288 MHz), so
// 44.1 kHz-family streams (44.1 / 22.05 kHz — many commercial stations) come
// out pitch-shifted / distorted. Where a station offers both, pick its
// 48 kHz variant. (MP3 / AAC, as decoded by the I2SAUDIO plugin.)
//
// ── Design (important) ───────────────────────────────────────────────────────
// Every side-effecting Tasmota command goes through tasmDefer() so it runs on the
// MAIN task. The web UI only sets watch-int flags (sel_req / loud / offreq); the
// VM-task EverySecond() reads those flags and defers the commands. We NEVER invoke
// the command dispatcher from the VM task (e.g. tasmCmd("Status 10")) — doing that
// while a stream is starting on the main task watchdog-resets the device.
//
// The live ICY title (path "StatusSNS#WebRadio#Title") is intentionally NOT polled
// here for the same reason; it needs a VM-safe read (deferred) — a v2 item.
// ─────────────────────────────────────────────────────────────────────────────
#define STATION_FILE "/webradios.txt"
// web-settable state ("watch" = the web UI can write it via seva())
watch int sel_req = 0; // a station button writes its number here (1..N)
watch int loud = 40; // volume slider 0..100 -> I2SGain
watch int offreq = 0; // the Stop button writes 1 here
int sel = 0; // station currently playing (0 = none) -> highlight
int last_loud = 40; // change-detect for the gain (set to -1 to force re-apply)
int nstations = 0; // station count parsed from the file
int idx_sel = 0; // cached global index of sel_req (for seva HTML)
int idx_off = 0; // cached global index of offreq
int pending = 0; // station to start AFTER a switch-stop drains (0 = none)
char fbuf[4096]; // the whole /webradios.txt, kept in RAM
char curr[48]; // current station name
char buf[420]; // scratch (HTML / log)
// ── seed a minimal /webradios.txt with 3 known-good 48 kHz streams when none
// exists yet, so the app is usable the first time it runs (the user then
// edits the file to taste and re-runs the slot to reload) ──
void writeDefaultStations() {
int h = fileOpen(STATION_FILE, 1); // 1 = write (create / truncate)
if (h < 0) { addLog("webradio: cannot create /webradios.txt"); return; }
strcpy(fbuf, "Bayern3\thttp://dispatcher.rndfnk.com/br/br3/live/mp3/low\n");
strcat(fbuf, "Bayern Klassik\thttp://dispatcher.rndfnk.com/br/brklassik/live/mp3/low\n");
strcat(fbuf, "Deutschlandfunk\thttp://st01.sslstream.dlf.de/dlf/01/128/mp3/stream.mp3\n");
fileWrite(h, fbuf, strlen(fbuf));
fileClose(h);
addLog("webradio: wrote default /webradios.txt (3 stations)");
}
// ── load /webradios.txt into fbuf and count the stations ──
void loadStations() {
char line[200];
char nm[64];
nstations = 0;
int h = fileOpen(STATION_FILE, 0); // 0 = read
if (h < 0) { // no list yet -> seed a default + retry
addLog("webradio: /webradios.txt missing -> writing default");
writeDefaultStations();
h = fileOpen(STATION_FILE, 0);
if (h < 0) { addLog("webradio: /webradios.txt unavailable"); return; }
}
int n = fileRead(h, fbuf, 4090);
fileClose(h);
if (n < 0) { n = 0; }
fbuf[n] = 0;
int i = 1;
while (i <= 60) {
strToken(line, fbuf, 10, i); // 10 = '\n' -> line i
if (line[0] == 0) { break; }
strToken(nm, line, 9, 1); // 9 = '\t' -> name field
if (nm[0] == 0) { break; }
nstations = i;
i = i + 1;
}
sprintf(buf, "webradio: %d stations loaded", nstations);
addLog(buf);
}
// ── name (field 1) + url (field 2) of station n (1-based) from the in-RAM file ──
void station(int n, char nm[], char url[]) {
char line[200];
nm[0] = 0; url[0] = 0;
strToken(line, fbuf, 10, n);
strToken(nm, line, 9, 1);
strToken(url, line, 9, 2);
}
// NOTE: the firmware's deferred-command buffer holds exactly ONE command and
// drops any second one queued before the first drains. So we defer at most ONE
// command per VM callback (EverySecond enforces this with early returns).
void playStation(int n) {
char url[220];
char cmd[240];
station(n, curr, url); // fills curr (name) + url
if (url[0] == 0) { return; }
sprintf(cmd, "I2SWR %s", url); tasmDefer(cmd); // start the stream on the main task
last_loud = -1; // force EverySecond to apply the gain on the
sel = n; // next tick (once the stream output exists)
sprintf(buf, "webradio: play %d = %s", n, curr);
addLog(buf);
}
void stopRadio() {
char cmd[24];
strcpy(cmd, "I2SWR"); tasmDefer(cmd); // empty arg -> stop
sel = 0; curr[0] = 0;
addLog("webradio: stopped");
}
int main() {
curr[0] = 0;
loadStations();
idx_sel = varIdx(sel_req);
idx_off = varIdx(offreq);
last_loud = loud; // don't disturb the current volume on (re)deploy
addLog("webradio: ready");
return 0;
}
// At most ONE tasmDefer() per tick (the deferred buffer holds one command).
// Priority: a station click, then a pending switch-start, then stop, then
// volume. Each path returns so a second command is never queued before the
// first drains. Switching stations is STOP-THEN-START: starting a new stream
// while the old one is still live blocks the I2S on the main task and the
// device goes unresponsive, so we tear the old stream down one tick first.
void EverySecond() {
if (sel_req != 0) {
int n = sel_req; sel_req = 0;
if (n < 1 || n > nstations) { return; }
if (n == sel) { return; } // already playing this one
if (sel != 0) { // a station is playing -> stop it,
stopRadio(); // (defers I2SWR stop, sel -> 0)
pending = n; // then start n on the next tick
return;
}
if (pending != 0) { pending = n; return; } // mid-switch -> just retarget
playStation(n); // idle -> start now
return;
}
if (pending != 0) { // the switch-stop has drained -> start
int n = pending; pending = 0;
playStation(n);
return;
}
if (offreq != 0) { offreq = 0; pending = 0; stopRadio(); return; } // defers I2SWR (stop)
// volume: apply on change, and ~1s after a station starts (last_loud == -1)
if (loud != last_loud) {
char cmd[24];
sprintf(cmd, "I2SVol %d", loud); // I2SAUDIO plugin volume cmd (0..100); I2SGain is dead
tasmDefer(cmd);
last_loud = loud;
}
}
// WebCall() renders into Tasmota's /?m=1 sensor area. We emit a fixed-width,
// light-grey HTML table for the station list (own layout, survives the AJAX
// redraw) and keep the Tasmota slider widget for volume above it.
void WebCall() {
char nm[64];
char url[220];
char cls[8];
char lbl[48];
// volume slider (Tasmota widget) with a live-value label
sprintf(lbl, "Lautstärke: %d", loud);
webSliderV(loud, 0, 100, lbl);
// Styles ONCE as classes — keeps each station row tiny (~70 B) so the web
// content buffer never overflows (24 rows of full inline style truncated it).
// .n = narrow Nr cell; .wr button = idle; .sel = playing; .stp = Stop.
// 3 stations per row (a 3-column grid of station buttons). Each cell is one
// station; the playing one is highlighted (.sel). Styles ONCE as classes so
// each cell stays tiny and the web content buffer never overflows.
webSend("<style>.wr{width:560px;max-width:97%;margin:8px auto;border-collapse:collapse;background:#d9d9d9;color:#111;border:1px solid #999}.wr td{width:33%;border-top:1px solid #c8c8c8;padding:3px}.wr button{box-sizing:border-box;width:100%;border:0;border-radius:0.3rem;padding:6px 3px;color:#fff;background:#607d8b;font-size:0.82rem}.wr .sel{background:#2e7d32}.wr .stp{background:#b71c1c}</style>");
webSend("<table class='wr'>");
webSend("<tr><th colspan='3' style='padding:6px;background:#c2c2c2;font-size:1.1rem;text-align:center'>Web-Radio</th></tr>");
int i = 1;
int col = 0; // 0..2 within the current row
int fl = 0;
while (i <= nstations) {
station(i, nm, url);
if (col == 0) { webSend("<tr>"); } // open a new row every 3 stations
if (i == sel) { strcpy(cls, "sel"); } else { cls[0] = 0; }
sprintf(buf, "<td><button onclick='seva(%d,%d)' class='%s'>%s</button></td>",
i, idx_sel, cls, nm);
webSend(buf);
col = col + 1;
if (col >= 3) { // row full -> close it
webSend("</tr>"); col = 0;
fl = fl + 1;
if (fl >= 2) { webFlush(); fl = 0; } // flush every 2 rows -> no buffer overflow
}
i = i + 1;
}
if (col != 0) { // pad + close a partial last row
while (col < 3) { webSend("<td></td>"); col = col + 1; }
webSend("</tr>");
}
sprintf(buf, "<tr><td colspan='3' style='text-align:center;padding:5px'><button onclick='seva(1,%d)' class='stp' style='width:auto;padding:4px 28px'>Stop</button></td></tr>",
idx_off);
webSend(buf);
webSend("</table>");
webFlush();
}