Skip to content

Commit 8527f5e

Browse files
fhirschmannclaude
andcommitted
feat: one-click OTA firmware update from GitHub releases
Add a GitHub Actions workflow (release-firmware.yml) that builds the complete firmware on every push to master and publishes/refreshes a rolling 'latest' GitHub Release with firmware.bin + version.json (commit sha + date) for stable download URLs. In the Tools tab, a 'check for updates' button compares the device's built git revision (from /info) against the release's version.json and, if newer, downloads firmware.bin in the browser and pushes it to the existing /update OTA endpoint (no extra firmware/TLS code). The manual firmware upload remains as a fallback. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 26e8cf8 commit 8527f5e

12 files changed

Lines changed: 355 additions & 21 deletions

File tree

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
name: release-firmware
2+
run-name: Build & publish rolling firmware release
3+
on:
4+
workflow_dispatch:
5+
push:
6+
branches:
7+
- master
8+
paths:
9+
- "src/**"
10+
- "html/**"
11+
- "**.ini"
12+
- "**.py"
13+
- "**.csv"
14+
- ".github/workflows/release-firmware.yml"
15+
16+
# needed so the workflow can create/update the rolling GitHub Release
17+
permissions:
18+
contents: write
19+
20+
jobs:
21+
build-and-release:
22+
name: Build complete firmware and publish "latest" release
23+
runs-on: ubuntu-latest
24+
timeout-minutes: 60
25+
steps:
26+
- uses: actions/checkout@v5
27+
with:
28+
fetch-depth: 0 # full history + tags so `git describe` matches the on-device revision
29+
30+
- name: Cache PlatformIO
31+
uses: actions/cache@v4
32+
with:
33+
path: ~/.platformio
34+
key: ${{ runner.os }}-pio-${{ hashFiles('**/platformio.ini', '**/dependencies.lock') }}
35+
restore-keys: |
36+
${{ runner.os }}-pio-
37+
38+
- uses: actions/setup-python@v5
39+
with:
40+
python-version: "3.13"
41+
42+
- name: Install PlatformIO Core
43+
run: pip install -U platformio
44+
45+
- name: Build complete firmware
46+
run: platformio run -e complete
47+
48+
- name: Stage release assets
49+
run: |
50+
mkdir -p release
51+
cp .pio/build/complete/firmware.bin release/firmware.bin
52+
# `describe` must match what the firmware embeds (gitVersion.py uses the same command),
53+
# so the device can tell whether it already runs this build.
54+
DESCRIBE="$(git describe --always --dirty)"
55+
printf '{"version":"%s","short":"%s","describe":"%s","date":"%s","ref":"%s"}\n' \
56+
"$GITHUB_SHA" "${GITHUB_SHA:0:7}" "$DESCRIBE" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$GITHUB_REF_NAME" \
57+
> release/version.json
58+
cat release/version.json
59+
60+
- name: Publish / refresh rolling "latest" release
61+
env:
62+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
63+
run: |
64+
# create the rolling release once, then keep replacing its assets
65+
gh release view latest >/dev/null 2>&1 || \
66+
gh release create latest \
67+
--title "Latest firmware (rolling)" \
68+
--prerelease \
69+
--notes "Rolling 'complete' firmware build from master. See version.json for the exact commit."
70+
gh release upload latest release/firmware.bin release/version.json --clobber

‎README.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ neon logo that doubles as the SVG favicon ([`8d9725c`](../../commit/8d9725c)):
6666
| --- | --- |
6767
| PWA support: web app manifest + app icon, "add to home screen" with proper icon and name | [`a59fe0b`](../../commit/a59fe0b) |
6868
| Full backup: export/import of all settings + RFID assignments as JSON, WiFi credentials optional | [`7f2e4ae`](../../commit/7f2e4ae) |
69+
| One-click OTA update: GitHub Actions publishes a rolling `latest` release (`firmware.bin`); a Tools-tab button makes the device pull that firmware from GitHub over HTTPS and flash it via OTA, then reboot. Also triggerable via the bindable command **186** (button/RFID modifier) and the MQTT command-topic `firmware_update` (`ON`/`update`); the state-topic reports `idle`/`updating`/`up_to_date`/`failed` | [`06ac13d`](../../commit/06ac13d) |
6970
| Equalizer profiles: dropdown presets (Flat / Music / Audiobook-Speech / Deep voices / Custom) on top of the 3-band tone control; speech presets cut bass and lift mids/highs so deep narrator voices stay intelligible, persisted in NVS. Profiles can also be assigned per file or directory (right-click in the file browser) — e.g. set the speech profile for all Bibi Blocksberg episodes at once; the RFID tab shows the active profile for the highlighted file (or "No EQ set"). The active profile can be cycled with the bindable command **154** (button/RFID modifier) and set/reported via the MQTT topic `equalizer` (`flat`/`music`/`speech`/`voiceBoost`) | [`300b9dd`](../../commit/300b9dd) |
7071
| Blinking "OK" indicator next to the battery replaces the generic "action successful" toast | [`0870ccc`](../../commit/0870ccc) |
7172
| Blinking "connection lost" icon in the navbar replaces the connection-lost toast | [`c45402b`](../../commit/c45402b) |

‎html/locales/de.json‎

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -267,6 +267,7 @@
267267
"152": "Uhrzeit ansagen",
268268
"153": "Ambient Light umschalten",
269269
"154": "Equalizer-Profil durchschalten",
270+
"186": "Firmware aktualisieren (GitHub)",
270271
"0": "Lösche Zuordnung",
271272
"170": "Play/Pause",
272273
"171": "Vorheriger Titel",
@@ -543,7 +544,18 @@
543544
},
544545
"fwupdate": {
545546
"title": "Firmware-Update",
546-
"desc": "Hier kann ein Firmware-Update durchgeführt werden."
547+
"desc": "Hier kann ein Firmware-Update durchgeführt werden.",
548+
"manual": "Oder eine firmware.bin manuell hochladen:",
549+
"github": {
550+
"desc": "Das Gerät lädt die neueste Firmware direkt von GitHub (HTTPS) und installiert sie selbst.",
551+
"apply": "Von GitHub aktualisieren",
552+
"confirm": "Neueste Firmware von GitHub laden und installieren? Das Gerät startet danach automatisch neu.",
553+
"checking": "Prüfe Version…",
554+
"uptodate": "Bereits aktuell – kein Update nötig.",
555+
"flashing": "Lade & installiere…",
556+
"started": "Gerät installiert die Firmware und startet neu – die Seite lädt gleich automatisch neu…",
557+
"error": "Update fehlgeschlagen (siehe Konsole/Log)."
558+
}
547559
}
548560
},
549561
"help": {

‎html/locales/en.json‎

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -268,6 +268,7 @@
268268
"152": "Announce current time",
269269
"153": "Toggle Ambient Light",
270270
"154": "Cycle equalizer profile",
271+
"186": "Update firmware (GitHub)",
271272
"0": "Remove assignment",
272273
"170": "Toggle Play/Pause",
273274
"171": "Previous track",
@@ -545,7 +546,18 @@
545546
},
546547
"fwupdate": {
547548
"title": "Firmware-Update",
548-
"desc": "Firmware can be updated right here."
549+
"desc": "Firmware can be updated right here.",
550+
"manual": "Or upload a firmware.bin manually:",
551+
"github": {
552+
"desc": "The device downloads the latest firmware straight from GitHub (HTTPS) and installs it itself.",
553+
"apply": "Update from GitHub",
554+
"confirm": "Download and install the latest firmware from GitHub? The device will restart afterwards.",
555+
"checking": "Checking version…",
556+
"uptodate": "Already up to date – no update needed.",
557+
"flashing": "Downloading & installing…",
558+
"started": "Device is installing the firmware and will restart – the page will reload shortly…",
559+
"error": "Update failed (see console/log)."
560+
}
549561
}
550562
},
551563
"help": {

‎html/locales/fr.json‎

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -261,6 +261,7 @@
261261
"0": "Supprimer l'affectation",
262262
"153": "Basculement de la lumière ambiante",
263263
"154": "Changer de profil d'égaliseur",
264+
"186": "Mettre à jour le firmware (GitHub)",
264265
"170": "Basculer Lecture/Pause",
265266
"171": "Piste précédente",
266267
"172": "Piste suivante",
@@ -532,7 +533,18 @@
532533
},
533534
"fwupdate": {
534535
"title": "Mise à jour du micrologiciel",
535-
"desc": "La mise à jour du micrologiciel peut être effectuée ici."
536+
"desc": "La mise à jour du micrologiciel peut être effectuée ici.",
537+
"manual": "Ou téléverser un firmware.bin manuellement :",
538+
"github": {
539+
"desc": "L'appareil télécharge le dernier micrologiciel directement depuis GitHub (HTTPS) et l'installe lui-même.",
540+
"apply": "Mettre à jour depuis GitHub",
541+
"confirm": "Télécharger et installer le dernier micrologiciel depuis GitHub ? L'appareil redémarrera ensuite.",
542+
"checking": "Vérification de la version…",
543+
"uptodate": "Déjà à jour – aucune mise à jour nécessaire.",
544+
"flashing": "Téléchargement et installation…",
545+
"started": "L'appareil installe le micrologiciel et va redémarrer – la page se rechargera sous peu…",
546+
"error": "Échec de la mise à jour (voir la console/le journal)."
547+
}
536548
}
537549
},
538550
"help": {

‎html/management.html‎

Lines changed: 72 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2563,6 +2563,21 @@ <h5 class="modal-title" data-i18n="tools.nvs.erase.title"></h5>
25632563
</div>
25642564
<div class="container" id="httpUpdate">
25652565
<legend id="legendfwupdate" data-i18n="tools.fwupdate.title"></legend>
2566+
<div id="githubUpdate" class="mb-3">
2567+
<p data-i18n="tools.fwupdate.github.desc"></p>
2568+
<button type="button" class="btn btn-primary" id="applyUpdateBtn"
2569+
onclick="applyGithubUpdate()" data-i18n="tools.fwupdate.github.apply"></button>
2570+
<span id="updateStatus" class="ms-2" style="font-family: 'Share Tech Mono', monospace;"></span>
2571+
<div id="githubUpdateProgress" class="progress upload-progress-bar position-relative mt-2"
2572+
style="display: none;">
2573+
<div class="progress-bar" role="progressbar"></div>
2574+
<div
2575+
class="label d-flex position-absolute flex-column align-self-center text-center w-100 text-nowrap text-light">
2576+
</div>
2577+
</div>
2578+
</div>
2579+
<hr>
2580+
<p data-i18n="tools.fwupdate.manual"></p>
25662581
<form id="firmwareUploadForm" method="POST" enctype="multipart/form-data" action="/update">
25672582
<div class="mb-3">
25682583
<label for="firmwareUpload" data-i18n="tools.fwupdate.desc"></label><br><br>
@@ -2979,6 +2994,61 @@ <h5 class="modal-title" data-i18n="tools.nvs.erase.title"></h5>
29792994
tryRedirect();
29802995
}, 2000);
29812996
} /* restart */
2997+
/* GitHub firmware update — the device pulls the latest release over HTTPS itself
2998+
(browser-side download is blocked by CORS on GitHub release assets). The browser
2999+
triggers it and then polls the device for progress/result. */
3000+
async function applyGithubUpdate() {
3001+
const status = document.getElementById('updateStatus');
3002+
const btn = document.getElementById('applyUpdateBtn');
3003+
const prog = $('#githubUpdateProgress');
3004+
if (!confirm(i18next.t("tools.fwupdate.github.confirm"))) return;
3005+
btn.disabled = true;
3006+
prog.hide();
3007+
status.textContent = i18next.t("tools.fwupdate.github.checking");
3008+
try {
3009+
const resp = await fetch("http://" + host + "/githubupdate", { method: "POST" });
3010+
if (!resp.ok) throw new Error("HTTP " + resp.status);
3011+
} catch (e) {
3012+
console.error("github update trigger failed", e);
3013+
status.textContent = i18next.t("tools.fwupdate.github.error");
3014+
btn.disabled = false;
3015+
return;
3016+
}
3017+
let sawProgress = false;
3018+
const poll = async function () {
3019+
let st;
3020+
try {
3021+
st = await (await fetch("http://" + host + "/githubupdate", { cache: "no-store" })).json();
3022+
} catch (e) {
3023+
// connection lost while downloading -> device is rebooting after a successful flash
3024+
if (sawProgress) {
3025+
status.textContent = i18next.t("tools.fwupdate.github.started");
3026+
setTimeout(function () { location.reload(); }, 30000);
3027+
return;
3028+
}
3029+
setTimeout(poll, 2000);
3030+
return;
3031+
}
3032+
if (st.status === 1) { // running / downloading
3033+
sawProgress = true;
3034+
prog.show();
3035+
$(".progress-bar", prog).css('width', (st.progress || 0) + "%");
3036+
$(".label", prog).text(i18next.t("tools.fwupdate.github.flashing") + " " + (st.progress || 0) + "%");
3037+
setTimeout(poll, 1000);
3038+
} else if (st.status === 2) { // already up to date
3039+
prog.hide();
3040+
status.textContent = i18next.t("tools.fwupdate.github.uptodate");
3041+
btn.disabled = false;
3042+
} else if (st.status === 3) { // failed
3043+
prog.hide();
3044+
status.textContent = i18next.t("tools.fwupdate.github.error") + (st.message ? " (" + st.message + ")" : "");
3045+
btn.disabled = false;
3046+
} else {
3047+
setTimeout(poll, 1500);
3048+
}
3049+
};
3050+
setTimeout(poll, 1000);
3051+
}
29823052
/* Firmware Upload (OTA) */
29833053
$('#firmwareUploadForm').submit(function (e) {
29843054
e.preventDefault();
@@ -4513,7 +4583,7 @@ <h5 class="modal-title" data-i18n="tools.nvs.erase.title"></h5>
45134583
device = device.replace(/<MAC>/gi, macAddress);
45144584

45154585
// Static list of known topics (mirrors firmware settings.h)
4516-
var commandTopics = ['sleep', 'rfid', 'trackcontrol', 'loudness', 'sleep_timer', 'lock_controls', 'repeatmode', 'led_brightness', 'ambient_light', 'equalizer'];
4586+
var commandTopics = ['sleep', 'rfid', 'trackcontrol', 'loudness', 'sleep_timer', 'lock_controls', 'repeatmode', 'led_brightness', 'ambient_light', 'equalizer', 'firmware_update'];
45174587
var stateTopics = ['track', 'cover_changed', 'state', 'ipv4', 'pauseplay', 'playmode', 'wifi_rssi', 'software_revision', 'battery_voltage', 'battery_soc'];
45184588

45194589
// Combine command and state topics
@@ -5355,7 +5425,7 @@ <h5 class="modal-title" data-i18n="tools.nvs.erase.title"></h5>
53555425
const cmdElem = document.createElement('select');
53565426
const cmdNothing = addOption(cmdElem, 0);
53575427
cmdNothing.setAttribute('data-i18n', "settingsex.buttons.noaction");
5358-
const cmds = [170, 171, 172, 173, 174, 184, 185, 175, 176, 177, 178, 179, 180, 181, 182, 183, 199, 130, 140, 141, 142, 143, 144, 150, 151, 152, 153, 154, 120, 100, 101, 102, 103, 104, 105, 106, 107, 110, 111, 241, 242, 243, 244, 245, 246, 247, 248, 249, 250];
5428+
const cmds = [170, 171, 172, 173, 174, 184, 185, 175, 176, 177, 178, 179, 180, 181, 182, 183, 186, 199, 130, 140, 141, 142, 143, 144, 150, 151, 152, 153, 154, 120, 100, 101, 102, 103, 104, 105, 106, 107, 110, 111, 241, 242, 243, 244, 245, 246, 247, 248, 249, 250];
53595429
for (const cmd of cmds) {
53605430
const opt = addOption(cmdElem, cmd);
53615431
if ([140, 141, 142, 143, 144].includes(cmd)) {

‎src/Cmd.cpp‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
#include "Queues.h"
1414
#include "Rfid.h"
1515
#include "System.h"
16+
#include "Web.h"
1617
#include "Wlan.h"
1718

1819
static void Cmd_HandleSleepAction(bool enable, const char *enLogMsg, const char *enMqttMsg) {
@@ -398,6 +399,12 @@ void Cmd_Action(const uint16_t mod) {
398399
break;
399400
}
400401

402+
case CMD_FIRMWARE_UPDATE: {
403+
Web_TriggerGithubOta();
404+
System_IndicateOk();
405+
break;
406+
}
407+
401408
case CMD_SEEK_FORWARDS: {
402409
gPlayProperties.seekmode = SEEK_FORWARDS;
403410
break;

‎src/Mqtt.cpp‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
#include "MemX.h"
1010
#include "Queues.h"
1111
#include "System.h"
12+
#include "Web.h"
1213
#include "Wlan.h"
1314
#include "mqtt_client.h"
1415
#include "revision.h"
@@ -354,6 +355,9 @@ void mqtt_event_handler(void *handler_args, esp_event_base_t base, int32_t event
354355
// Equalizer-profile
355356
esp_mqtt_client_subscribe(client, Mqtt_GetCommandTopic(topicEqualizer), qos);
356357

358+
// Firmware-update (GitHub OTA)
359+
esp_mqtt_client_subscribe(client, Mqtt_GetCommandTopic(topicFirmwareUpdate), qos);
360+
357361
// Publish current state
358362
publishMqtt(topicState, "Online", false);
359363
publishMqtt(topicTrack, gPlayProperties.title, false);
@@ -375,6 +379,7 @@ void mqtt_event_handler(void *handler_args, esp_event_base_t base, int32_t event
375379
publishMqtt(topicCurrentIPv4IP, Wlan_GetIpAddress().c_str(), false);
376380
publishMqtt(topicRepeatMode, static_cast<uint32_t>(AudioPlayer_GetRepeatMode()), false);
377381
publishMqtt(topicEqualizer, AudioPlayer_GetEqualizerProfile().c_str(), false);
382+
publishMqtt(topicFirmwareUpdate, Web_GetGithubOtaStatusText(), false);
378383

379384
char revBuf[16];
380385
strncpy(revBuf, softwareRevision + 19, sizeof(revBuf) - 1);
@@ -639,6 +644,15 @@ void Mqtt_ClientCallback(const char *topic_buf, uint32_t topic_length, const cha
639644
}
640645
}
641646

647+
// Trigger GitHub firmware-update (OTA)
648+
else if (reduced_topic_str == topicFirmwareUpdate) {
649+
if (payload_str == "ON" || payload_str == "1" || payload_str == "update" || payload_str == "check") {
650+
Web_TriggerGithubOta();
651+
publishMqtt(topicFirmwareUpdate, Web_GetGithubOtaStatusText(), false);
652+
System_IndicateOk();
653+
}
654+
}
655+
642656
// Requested something that isn't specified?
643657
else {
644658
Log_Printf(LOGLEVEL_ERROR, noValidTopic, topic_str.c_str());

0 commit comments

Comments
 (0)