Skip to content

Commit 99ffb5b

Browse files
fhirschmannclaude
andcommitted
feat: RFID-tag syncing across a central server + ESPuino peers (P2P)
New RfidSync module keeps RFID-tag assignments in sync with a central PHP server and other ESPuinos simultaneously: - on learn: push the freshly assigned card to the server AND every configured peer - full sync: pull the server list, merge newest-wins per id (by timestamp), push all local tags back to server + peers. Trigger: button in the Sync settings tab, bindable command 187 (CMD_RFID_SYNC), or MQTT topic rfid_sync. - catch-up: one automatic full sync after coming online, so a player used offline/ outdoors (no WiFi/server) keeps working and uploads its offline-learned cards on reconnect. Cards learned while the clock was never set (ts=0) are healed to 'now' on the first online sync so they win the merge. - timestamps stored in a parallel NVS namespace (the tag string must stay 4 tokens). - the /rfid POST endpoint is the peer-push target: accepts an incoming 'timestamp' and does NOT re-push (no sync loops). Wire format matches the (existing) server: {id, timestamp, fileOrUrl, playMode} or {id, timestamp, modId}. Server is the dynamic server/manifest.php (also serves the file-sync manifest; GET ?type=rfid returns just the tag list, POST merges newest-wins). Settings (Sync tab): RFID server URL, peer list, push-on-learn toggle, full-sync button, and a link to manifest.php for self-hosting. Also in this change: - fix: restore the file-browser main tab label to Files/Dateien/Fichiers (the rfid-settings-tab commit had clobbered nav.rfid); the RFID settings sub-tab now uses nav.rfidcards. - fix: listening-time stats were invisible in the info dialog for durations under a minute (timestampToDuration with withSeconds=false returns '' -> row skipped); the daily stats now render with seconds. Verified end-to-end against the real server (espuinosync.locn.de / SWAG): full sync merged+pushed 3 cards with correct playMode/modId mapping; learn pushed a card to the server; peer-target /rfid stored an entry with its incoming timestamp; test data cleaned up afterwards. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent af6c8d3 commit 99ffb5b

14 files changed

Lines changed: 703 additions & 13 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,7 @@ neon logo that doubles as the SVG favicon ([`7be5254`](../../commit/7be5254)):
140140
| **Auto-save settings (General + Buttons)**: the **General** and **Buttons** settings sub-tabs no longer have a submit button — changes are persisted automatically (debounced ~0.6 s) as soon as you toggle/select or leave a field. Populating the form on load is suppressed so it never triggers a spurious save; the **Reset** button now applies the defaults right away. The credential/connection tabs (WiFi, security, MQTT, FTP, Bluetooth) keep their explicit submit button on purpose (half-typed passwords / reboot on Bluetooth-mode change). i18n de/en/fr | [`settings-autosave`](../../releases/tag/settings-autosave) |
141141
| **RFID settings sub-tab + learned-cards list**: the old inline RFID-assignment form (id field + play-mode + submit) is gone — music cards are learned from the file-browser context menu (scan popup). A new **RFID** settings sub-tab learns **modification cards** (pick a modification action → **Learn card** → scan popup) and lists **all learned cards** (music = path + play mode, modification = action) with a delete button each. i18n de/en/fr | [`rfid-settings-tab`](../../releases/tag/rfid-settings-tab) |
142142
| **Listening-time statistics**: tracks how long was actually listened per local calendar day in a 365-day ring buffer (persisted in NVS, ~1.5 KB). The info dialog (ℹ️) shows **today / yesterday / last 7 days / last 30 days** next to the existing total playtime. Seconds are accumulated by the audio loop while playing (clock from NTP/RTC; ignored until the clock is valid), flushed to NVS at most once a minute and on shutdown. New `/info` audio fields `playToday`/`playYesterday`/`play7d`/`play30d` (seconds). i18n de/en/fr | [`listening-stats`](../../releases/tag/listening-stats) |
143+
| **RFID-tag syncing (server + peer-to-peer)**: keeps RFID-tag assignments in sync across a central PHP server *and* other ESPuinos at the same time. On **learn** a freshly assigned card is pushed to the server and to every configured peer; a **full sync** (button in the Sync settings tab, bindable command **187**, or MQTT `rfid_sync`) pulls the server list, merges newest-wins per tag id (by timestamp) and pushes every local tag back to server + peers. A **catch-up** sync runs automatically once after the device comes online, so a player used **offline/outdoors** (no WiFi, no server) keeps working — cards learned offline are stored locally and pushed up on reconnect (assignments made while the clock was never set are healed to "now" on first sync so they win). Conflict resolution is **newest-wins** by timestamp. Server endpoint is the dynamic [`server/manifest.php`](server/manifest.php) (same script that serves the file-sync manifest; `?type=rfid` returns just the tag list); peers are a comma-separated list of other ESPuino hosts in the Sync settings tab. i18n de/en/fr | [`rfid-sync`](../../releases/tag/rfid-sync) |
143144
| **OpenAPI/Swagger spec updated**: the bundled `REST_API.yaml` (served at `/swagger.html`) now documents all fork-added endpoints (`/sync`, `/syncstop`, `/playlist`, `/githubupdate`, `/version`, `/login`, `/logout`, `/security`, `/eqrule(s)`, `/activeequalizer`, `/bluetooth*`, `/sdclean`) and declares the security schemes (`X-API-Key` header / `apikey` query param, or the `ESPUINO_SESSION` login cookie) so the Swagger "Authorize" button works. The web-interface footer links to the Swagger UI (`/swagger.html`) | [`f8298d9`](../../commit/f8298d9) |
144145

145146
#### Feature highlights

html/locales/de.json

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@
3030
"updateAvailable": "Update verfügbar",
3131
"clickToUpdate": "zum Installieren klicken",
3232
"control": "Steuerung",
33-
"rfid": "RFID",
33+
"rfid": "Dateien",
3434
"wifi": "WLAN",
3535
"mqtt": "MQTT",
3636
"ftp": "FTP",
@@ -40,7 +40,8 @@
4040
"settings": "Einstellungen",
4141
"buttons": "Tasten",
4242
"tools": "Tools",
43-
"help": "Hilfe"
43+
"help": "Hilfe",
44+
"rfidcards": "RFID"
4445
},
4546
"settingsex": {
4647
"title": "Erweiterte Einstellungen",
@@ -450,6 +451,22 @@
450451
"done": "Synchronisation abgeschlossen",
451452
"failed": "Synchronisation fehlgeschlagen",
452453
"stopped": "Synchronisation gestoppt"
454+
},
455+
"rfid": {
456+
"title": "RFID-Karten-Sync",
457+
"desc": "Synchronisiert RFID-Karten-Zuordnungen mit einem zentralen Server (manifest.php) und/oder anderen ESPuinos (P2P). Bei Konflikt gewinnt die zuletzt geänderte Zuordnung.",
458+
"url": "Server-URL (manifest.php)",
459+
"urlPlaceholder": "http://server/espuino/manifest.php",
460+
"peers": "Andere ESPuinos (Peers)",
461+
"peersPlaceholder": "192.168.1.34, 192.168.1.50",
462+
"peersHint": "Kommagetrennte IPs/Hostnamen. Beim Anlernen wird die Karte an jeden Peer geschickt.",
463+
"onLearn": "Beim Anlernen automatisch synchronisieren",
464+
"runTitle": "Voller RFID-Sync",
465+
"runDesc": "Holt alle Karten vom Server, führt sie mit den lokalen zusammen (neuestes gewinnt) und überträgt sie an Server und Peers.",
466+
"runBtn": "Jetzt synchronisieren",
467+
"started": "RFID-Sync gestartet…",
468+
"phpHint": "Server-Skript (dynamisch, erzeugt Manifest + RFID-Sync):",
469+
"phpLink": "manifest.php auf GitHub"
453470
}
454471
},
455472
"playlist": {

html/locales/en.json

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@
3030
"updateAvailable": "Update available",
3131
"clickToUpdate": "click to install",
3232
"control": "Control",
33-
"rfid": "RFID",
33+
"rfid": "Files",
3434
"wifi": "WiFi",
3535
"mqtt": "MQTT",
3636
"ftp": "FTP",
@@ -40,7 +40,8 @@
4040
"settings": "Settings",
4141
"buttons": "Buttons",
4242
"tools": "Tools",
43-
"help": "Help"
43+
"help": "Help",
44+
"rfidcards": "RFID"
4445
},
4546
"settingsex": {
4647
"title": "Advanced settings",
@@ -450,6 +451,22 @@
450451
"done": "Synchronization complete",
451452
"failed": "Synchronization failed",
452453
"stopped": "Synchronization stopped"
454+
},
455+
"rfid": {
456+
"title": "RFID card sync",
457+
"desc": "Syncs RFID card assignments with a central server (manifest.php) and/or other ESPuinos (P2P). On conflict the most recently changed assignment wins.",
458+
"url": "Server URL (manifest.php)",
459+
"urlPlaceholder": "http://server/espuino/manifest.php",
460+
"peers": "Other ESPuinos (peers)",
461+
"peersPlaceholder": "192.168.1.34, 192.168.1.50",
462+
"peersHint": "Comma-separated IPs/hostnames. On learn the card is pushed to each peer.",
463+
"onLearn": "Sync automatically on learn",
464+
"runTitle": "Full RFID sync",
465+
"runDesc": "Pulls all cards from the server, merges them with the local ones (newest wins) and pushes them to the server and peers.",
466+
"runBtn": "Sync now",
467+
"started": "RFID sync started…",
468+
"phpHint": "Server script (dynamic, generates the manifest + RFID sync):",
469+
"phpLink": "manifest.php on GitHub"
453470
}
454471
},
455472
"playlist": {

html/locales/fr.json

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@
3030
"updateAvailable": "Mise à jour disponible",
3131
"clickToUpdate": "cliquer pour installer",
3232
"control": "Contrôle",
33-
"rfid": "RFID",
33+
"rfid": "Fichiers",
3434
"wifi": "WiFi",
3535
"mqtt": "MQTT",
3636
"ftp": "FTP",
@@ -40,7 +40,8 @@
4040
"settings": "Paramètres",
4141
"buttons": "Boutons",
4242
"tools": "Outils",
43-
"help": "Aide"
43+
"help": "Aide",
44+
"rfidcards": "RFID"
4445
},
4546
"settingsex": {
4647
"title": "Paramètres avancés",
@@ -443,6 +444,22 @@
443444
"done": "Synchronisation terminée",
444445
"failed": "Échec de la synchronisation",
445446
"stopped": "Synchronisation arrêtée"
447+
},
448+
"rfid": {
449+
"title": "Sync des cartes RFID",
450+
"desc": "Synchronise les associations de cartes RFID avec un serveur central (manifest.php) et/ou d'autres ESPuinos (P2P). En cas de conflit, l'association la plus récente l'emporte.",
451+
"url": "URL du serveur (manifest.php)",
452+
"urlPlaceholder": "http://serveur/espuino/manifest.php",
453+
"peers": "Autres ESPuinos (pairs)",
454+
"peersPlaceholder": "192.168.1.34, 192.168.1.50",
455+
"peersHint": "IPs/noms d'hôte séparés par des virgules. À l'apprentissage, la carte est envoyée à chaque pair.",
456+
"onLearn": "Synchroniser automatiquement à l'apprentissage",
457+
"runTitle": "Sync RFID complète",
458+
"runDesc": "Récupère toutes les cartes du serveur, les fusionne avec les locales (la plus récente gagne) et les envoie au serveur et aux pairs.",
459+
"runBtn": "Synchroniser maintenant",
460+
"started": "Sync RFID démarrée…",
461+
"phpHint": "Script serveur (dynamique, génère le manifeste + sync RFID) :",
462+
"phpLink": "manifest.php sur GitHub"
446463
}
447464
},
448465
"playlist": {

html/management.html

Lines changed: 65 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2113,7 +2113,7 @@ <h5 class="modal-title" data-i18n="tools.nvs.erase.title"></h5>
21132113
<div class="nav nav-tabs justify-content-center" id="settings-subnav" role="tablist">
21142114
<a class="nav-item nav-link active" id="settings-general-subtab" data-bs-toggle="tab" data-bs-target="#settings-general" href="#settings-general" role="tab" aria-controls="settings-general" aria-selected="true"><i class="fas fa-sliders-h"></i> <span class="d-none d-md-inline" data-i18n="nav.general"></span></a>
21152115
<a class="nav-item nav-link" id="settings-buttons-subtab" data-bs-toggle="tab" data-bs-target="#settings-buttons" href="#settings-buttons" role="tab" aria-controls="settings-buttons"><i class="fas fa-keyboard"></i> <span class="d-none d-md-inline" data-i18n="nav.buttons"></span></a>
2116-
<a class="nav-item nav-link" id="settings-rfid-subtab" data-bs-toggle="tab" data-bs-target="#settings-rfid" href="#settings-rfid" role="tab" aria-controls="settings-rfid"><i class="fas fa-id-card"></i> <span class="d-none d-md-inline" data-i18n="nav.rfid"></span></a>
2116+
<a class="nav-item nav-link" id="settings-rfid-subtab" data-bs-toggle="tab" data-bs-target="#settings-rfid" href="#settings-rfid" role="tab" aria-controls="settings-rfid"><i class="fas fa-id-card"></i> <span class="d-none d-md-inline" data-i18n="nav.rfidcards"></span></a>
21172117
<a class="nav-item nav-link" id="nav-wifi-tab" data-bs-toggle="tab" data-bs-target="#nav-wifi" href="#nav-wifi" role="tab" aria-controls="nav-wifi"><i class="fas fa-wifi"></i> <span class="d-none d-md-inline" data-i18n="nav.wifi"></span></a>
21182118
<a class="nav-item nav-link" id="nav-mqtt-tab" data-visible="false" data-bs-toggle="tab" data-bs-target="#nav-mqtt" href="#nav-mqtt" role="tab" aria-controls="nav-mqtt"><i class="fas fa-network-wired"></i> <span class="d-none d-md-inline" data-i18n="nav.mqtt"></span></a>
21192119
<a class="nav-item nav-link" id="nav-ftp-tab" data-visible="false" data-bs-toggle="tab" data-bs-target="#nav-ftp" href="#nav-ftp" role="tab" aria-controls="nav-ftp"><i class="fas fa-folder"></i> <span class="d-none d-md-inline" data-i18n="nav.ftp"></span></a>
@@ -2916,12 +2916,43 @@ <h5 class="modal-title" data-i18n="tools.nvs.erase.title"></h5>
29162916
</div>
29172917
<small class="text-muted" data-i18n="sync.abortBtn.hint"></small>
29182918
</div>
2919+
<hr>
2920+
<div class="mb-3 col-md-12">
2921+
<legend data-i18n="sync.rfid.title"></legend>
2922+
<p class="small text-muted" data-i18n="[html]sync.rfid.desc"></p>
2923+
<label for="rfidSyncUrl" data-i18n="[prepend]sync.rfid.url">:</label>
2924+
<input type="url" class="form-control" id="rfidSyncUrl" name="rfidSyncUrl"
2925+
data-i18n="[placeholder]sync.rfid.urlPlaceholder" value="">
2926+
<small class="text-muted d-block mb-2">
2927+
<span data-i18n="sync.rfid.phpHint"></span>
2928+
<a href="https://github.com/fhirschmann/leoino/blob/master/server/manifest.php"
2929+
target="_blank" rel="noopener" data-i18n="sync.rfid.phpLink"></a>
2930+
</small>
2931+
<label for="rfidPeers" data-i18n="[prepend]sync.rfid.peers">:</label>
2932+
<input type="text" class="form-control" id="rfidPeers" name="rfidPeers"
2933+
data-i18n="[placeholder]sync.rfid.peersPlaceholder" value="" autocomplete="off">
2934+
<small class="text-muted" data-i18n="sync.rfid.peersHint"></small>
2935+
<div class="d-flex gap-2 mt-2">
2936+
<input type="checkbox" id="rfidSyncLearn" name="rfidSyncLearn" value="false">
2937+
<label for="rfidSyncLearn" data-i18n="sync.rfid.onLearn"></label>
2938+
</div>
2939+
</div>
29192940
<div class="text-center">
29202941
<button type="reset" class="btn btn-warning" data-i18n="reset"></button>&nbsp;<button
29212942
type="submit" class="btn btn-primary" data-i18n="submit"></button>
29222943
</div>
29232944
</form>
29242945
<hr>
2946+
<div class="col-md-12">
2947+
<legend data-i18n="sync.rfid.runTitle"></legend>
2948+
<p class="small text-muted" data-i18n="sync.rfid.runDesc"></p>
2949+
<div class="text-center">
2950+
<button type="button" class="btn btn-primary" onclick="startRfidSync()"
2951+
data-i18n="sync.rfid.runBtn"></button>
2952+
<span id="rfidSyncStatus" class="ms-2 small text-secondary"></span>
2953+
</div>
2954+
</div>
2955+
<hr>
29252956
<div class="col-md-12">
29262957
<legend data-i18n="sync.run.title"></legend>
29272958
<div data-i18n="sync.run.desc"></div>
@@ -4781,6 +4812,11 @@ <h5 class="modal-title" data-i18n="tools.nvs.erase.title"></h5>
47814812
if (syncSettingsData.abortOnButton !== undefined) {
47824813
document.getElementById("syncAbortBtn").checked = syncSettingsData.abortOnButton;
47834814
}
4815+
document.getElementById("rfidSyncUrl").value = syncSettingsData.rfidUrl || "";
4816+
document.getElementById("rfidPeers").value = syncSettingsData.rfidPeers || "";
4817+
if (syncSettingsData.rfidLearn !== undefined) {
4818+
document.getElementById("rfidSyncLearn").checked = syncSettingsData.rfidLearn;
4819+
}
47844820
}
47854821
// mqtt
47864822
let mqttSettings = settings.mqtt;
@@ -5261,7 +5297,10 @@ <h5 class="modal-title" data-i18n="tools.nvs.erase.title"></h5>
52615297
url: document.getElementById('syncUrl').value,
52625298
username: document.getElementById('syncUser').value,
52635299
password: document.getElementById('syncPwd').value,
5264-
abortOnButton: document.getElementById('syncAbortBtn').checked
5300+
abortOnButton: document.getElementById('syncAbortBtn').checked,
5301+
rfidUrl: document.getElementById('rfidSyncUrl').value,
5302+
rfidPeers: document.getElementById('rfidPeers').value,
5303+
rfidLearn: document.getElementById('rfidSyncLearn').checked
52655304
}
52665305
};
52675306
await fetch("http://" + host + "/settings", {
@@ -5272,6 +5311,24 @@ <h5 class="modal-title" data-i18n="tools.nvs.erase.title"></h5>
52725311
toaster.info(i18next.t("toast.saved"));
52735312
}
52745313

5314+
// Trigger a full bidirectional RFID-tag sync (server + peers) and poll its status.
5315+
let rfidSyncPollTimer = null;
5316+
async function startRfidSync() {
5317+
await fetch("http://" + host + "/rfidsync", { method: "POST" });
5318+
toaster.info(i18next.t("sync.rfid.started"));
5319+
clearInterval(rfidSyncPollTimer);
5320+
rfidSyncPollTimer = setInterval(async function () {
5321+
try {
5322+
const s = await (await fetch("http://" + host + "/rfidsync")).json();
5323+
const el = document.getElementById('rfidSyncStatus');
5324+
if (el) {
5325+
el.textContent = (s.status === 1 ? "… " : "") + (s.message || "");
5326+
}
5327+
if (s.status !== 1) { clearInterval(rfidSyncPollTimer); }
5328+
} catch (e) { clearInterval(rfidSyncPollTimer); }
5329+
}, 1500);
5330+
}
5331+
52755332
let syncPollTimer = null;
52765333
/* toggles the Sync now / Stop buttons depending on whether a sync is running */
52775334
function setSyncRunning(running) {
@@ -6227,11 +6284,12 @@ <h5 class="modal-title" data-i18n="tools.nvs.erase.title"></h5>
62276284
add('systeminfo.lblSince', secondsToDate(info.audio.firstStart));
62286285
add('systeminfo.lblPlayTotal', timestampToDuration(info.audio.playtimeTotal, false));
62296286
if (info.audio.playtimeSinceStart > 0) add('systeminfo.lblPlaySince', timestampToDuration(info.audio.playtimeSinceStart, true));
6230-
// daily listening-time statistics (values are in seconds -> ms for the formatter)
6231-
if (info.audio.playToday !== undefined) add('systeminfo.lblPlayToday', timestampToDuration(info.audio.playToday * 1000, false));
6232-
if (info.audio.playYesterday !== undefined) add('systeminfo.lblPlayYesterday', timestampToDuration(info.audio.playYesterday * 1000, false));
6233-
if (info.audio.play7d !== undefined) add('systeminfo.lblPlay7d', timestampToDuration(info.audio.play7d * 1000, false));
6234-
if (info.audio.play30d !== undefined) add('systeminfo.lblPlay30d', timestampToDuration(info.audio.play30d * 1000, false));
6287+
// daily listening-time statistics (values are in seconds -> ms for the formatter;
6288+
// withSeconds=true so short durations under a minute still render instead of "")
6289+
if (info.audio.playToday !== undefined) add('systeminfo.lblPlayToday', timestampToDuration(info.audio.playToday * 1000, true));
6290+
if (info.audio.playYesterday !== undefined) add('systeminfo.lblPlayYesterday', timestampToDuration(info.audio.playYesterday * 1000, true));
6291+
if (info.audio.play7d !== undefined) add('systeminfo.lblPlay7d', timestampToDuration(info.audio.play7d * 1000, true));
6292+
if (info.audio.play30d !== undefined) add('systeminfo.lblPlay30d', timestampToDuration(info.audio.play30d * 1000, true));
62356293
if (info.sdcard && info.sdcard.size) add('systeminfo.lblSd', (info.sdcard.free / 1024).toFixed(1) + ' / ' + (info.sdcard.size / 1024).toFixed(1) + ' GB');
62366294
if (info.battery) {
62376295
add('systeminfo.lblVoltage', info.battery.currVoltage.toFixed(2) + ' V');

0 commit comments

Comments
 (0)