Skip to content

webradio.tc

webradio.tc — Internet-radio player for an I2S audio board.

Source on GitHub

// 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();
}