Skip to content

Commit 7b8e303

Browse files
fhirschmannclaude
andcommitted
feat: RFID learn via explorer context menu (scan-after-select) + document virtual cards
After reverting the misguided web-generate/-trigger approach, implement the intuitive learn flow the way the existing feature is meant to be used: - Context menu 'RFID anlernen' -> pick a play mode -> the NEXT card laid on the reader is assigned to that path (armRfidLearn/completeRfidLearn, 60s timeout). Works with physical cards and the existing virtual cards (CMD_VIRTUAL_RFID_CARD_* -> ids 900000000001..010) alike; no tab switch / submit. Remove-assignment entry kept. i18n de/en/fr. - README: new 'Virtual RFID cards' section documenting the existing (upstream) CMD_VIRTUAL_RFID_CARD_* mechanism and how to learn cards from the file browser. Verified on the test device via Playwright: arm learn -> fire CMD_VIRTUAL_RFID_CARD_01 (lays 900000000001) -> assignment saved with correct path/mode + badge -> cleaned up, no console errors. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 41ca290 commit 7b8e303

5 files changed

Lines changed: 87 additions & 38 deletions

File tree

‎README.md‎

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -135,7 +135,7 @@ neon logo that doubles as the SVG favicon ([`7be5254`](../../commit/7be5254)):
135135
| **HTTP file sync**: pull audio files from a web server (e.g. nginx) onto the SD card. A new **Sync** settings sub-tab takes a manifest URL plus optional HTTP Basic Auth (user/password); the device fetches a JSON manifest (`{"files":[{"path","size"},…]}`) and downloads every listed file that is missing locally or differs in size, streaming each in small chunks straight to SD. Additive only (never deletes), runs in a background task, and shows live progress including the file currently being downloaded plus a stop button to cancel a running sync. The **Sync** settings sub-tab holds the config (manifest URL + Basic Auth) and documents the manifest format/nginx setup; the actual run button and live progress live in the **Tools** tab, with a shortcut button in the Sync sub-tab that jumps there. If audio is playing when a sync starts, playback is paused for the SD-writing phase and resumes automatically when the sync finishes; pressing play (or skipping a track) cancels the running sync and resumes playback immediately. Any button press (or triggered command) also aborts a running sync — toggleable in the Sync settings sub-tab (default on). Reloading the web UI reconnects to a sync still running in the background instead of showing an idle state. A stalled download (connection open but no data for 20 s) is aborted and marked failed so one bad/slow file can't hang the whole sync. While a sync runs the NeoPixel indicator LEDs light solid blue (a single transmission while the LED task is paused, so there is no repeated refresh to starve WiFi/TLS). A running sync also keeps the device awake: since the sync runs in a background task and pauses playback, it no longer refreshes the activity timer on its own, so the inactivity timeout / sleep-timer is suppressed while a sync is in progress — preventing the device from going to deep-sleep mid-transfer (which would tear down the SD card and abort the sync); the timer resumes normally once the sync finishes | [`ac24bbc`](../../commit/ac24bbc) |
136136
| **Fully offline web interface**: all third-party libraries (jQuery, jQuery-UI, Bootstrap JS/CSS, jstree + themes, bootstrap-slider, Font Awesome + webfonts, i18next) and the Orbitron/Rajdhani/Share Tech Mono fonts are now vendored and served gzipped from the device instead of public CDNs. This fixes the ~2-minute first-load hang with empty (untranslated) text after a cold boot/flash, and makes the management UI work without any internet access (incl. AP mode). `processHtml.py` embeds the assets into flash (build size grows accordingly) and now derives unique header symbol names from the full asset path. The libs are concatenated into a single JS and a single CSS bundle (`tools/bundle_assets.py`) so the page makes only a handful of requests — many parallel requests to the ESP32's async web server made some browsers (Safari/Chrome) stall for minutes while Firefox coped | [`c618191`](../../commit/c618191) |
137137
| **M3U playlist builder**: a "Create playlist" button in the Files tab opens a two-pane builder — browse the SD card on the left, an ordered (drag-to-reorder) track list on the right — to assemble an `.m3u` from SD files, whole folders, and webradio URLs (which `LOCAL_M3U` plays interchangeably). Saving writes `/Playlists/<name>.m3u` via a new `POST /playlist` endpoint; the file can then be assigned to an RFID tag with play mode 11. i18n de/en/fr | [`af041fc`](../../commit/af041fc) |
138-
| **File browser: assignment badges + context-menu RFID learn**: in the Files-tab tree each row is badged (right-aligned, after the name) when that exact path has an **RFID tag** assigned (tag icon) and/or a **per-path EQ rule** (sliders icon), so you can see at a glance what is mapped where. The explorer **context menu** now also learns/removes RFID assignments directly: place a card (its id fills the field), then right-click a file/folder → **RFID** → pick a play mode to assign it to that card on the spot (no tab switch / submit), or **remove** an existing assignment. Mirrors the existing EQ-profile submenu. Badges refresh when a folder is (re)opened and live after assigning/removing. i18n de/en/fr | [`PENDING`](../../commit/PENDING) |
138+
| **File browser: assignment badges + context-menu RFID learn (scan-after-select)**: in the Files-tab tree each row is badged (right-aligned, after the name) when that exact path has an **RFID tag** assigned (tag icon) and/or a **per-path EQ rule** (sliders icon), so you can see at a glance what is mapped where. The explorer **context menu** learns/removes RFID assignments directly: right-click a file/folder → **RFID anlernen** → pick a play mode, then **lay the card** — the next scanned card is assigned to that path (no tab switch / submit); or **remove** an existing assignment. Mirrors the existing EQ-profile submenu. Works with physical cards and the [virtual cards](#virtual-rfid-cards) (`CMD_VIRTUAL_RFID_CARD_*`) alike. Badges refresh when a folder is (re)opened and live after assigning/removing. i18n de/en/fr | [`PENDING`](../../commit/PENDING) |
139139
| **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) |
140140

141141
#### Feature highlights
@@ -200,6 +200,28 @@ neon logo that doubles as the SVG favicon ([`7be5254`](../../commit/7be5254)):
200200
| Support for a 6th button | [`b116151`](../../commit/b116151) |
201201
| OLED display support (SH1106/SSD1306 128×64 over I2C): boot splash, idle screen with IP + READY, now-playing title (up to 3 lines, scrolling) with battery/time/wifi status bar, and a volume bar | [`8ce8104`](../../commit/8ce8104) |
202202

203+
### Virtual RFID cards
204+
205+
This is an existing ESPuino feature (not a fork addition) that is easy to miss, so it is documented
206+
here for clarity. Action-to-button assignment in general works as described in the upstream
207+
*"dynamic button layout"* documentation.
208+
209+
**Normal card.** A physical RFID card has a fixed, pre-programmed number. When the card is placed on
210+
the reader the ESP32 reads that number (it is also pushed into the web interface). You then *learn*
211+
the card — i.e. you link its number to an action. The next time the card is placed, the ESP32 looks
212+
up that mapping in its flash and runs the matching action.
213+
214+
**Virtual card.** You bind a button press (short or long) or a two-button combination to an action
215+
in `settings.h` as usual — but the actions `CMD_VIRTUAL_RFID_CARD_01` … `CMD_VIRTUAL_RFID_CARD_10`
216+
are special: they *simulate placing a card*. Running `CMD_VIRTUAL_RFID_CARD_01` is exactly the same
217+
as placing a physical card with the number `900000000001` (…`_10` → `900000000010`). You learn that
218+
number in the web interface like any normal card, and pressing the bound button then has the same
219+
effect as placing that card. The fixed ids live in [`src/values.h`](src/values.h) and are enqueued
220+
in [`src/Cmd.cpp`](src/Cmd.cpp). MQTT can lay the same ids via the `rfid` command topic.
221+
222+
> Tip: to learn a (physical or virtual) card to a file/folder, right-click it in the file browser →
223+
> **RFID anlernen** → pick a play mode, then lay the card (or press the button bound to its virtual id).
224+
203225
## // Flashing
204226

205227
```bash

‎html/locales/de.json‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -333,6 +333,11 @@
333333
"249": "Spiele virtuelle RFID-Karte 09",
334334
"250": "Spiele virtuelle RFID-Karte 10"
335335
}
336+
},
337+
"learn": {
338+
"waiting": "Bitte jetzt die RFID-Karte auflegen…",
339+
"done": "Karte {{rfidId}} zugewiesen",
340+
"timeout": "Anlernen abgebrochen (keine Karte aufgelegt)"
336341
}
337342
}
338343
},

‎html/locales/en.json‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -333,6 +333,11 @@
333333
"249": "Play virtual RFID-card 09",
334334
"250": "Play virtual RFID-card 10"
335335
}
336+
},
337+
"learn": {
338+
"waiting": "Now place the RFID card on the reader…",
339+
"done": "Card {{rfidId}} assigned",
340+
"timeout": "Learning cancelled (no card was placed)"
336341
}
337342
}
338343
},

‎html/locales/fr.json‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -326,6 +326,11 @@
326326
"249": "Lire la carte RFID virtuelle 09",
327327
"250": "Lire la carte RFID virtuelle 10"
328328
}
329+
},
330+
"learn": {
331+
"waiting": "Posez maintenant la carte RFID sur le lecteur…",
332+
"done": "Carte {{rfidId}} associée",
333+
"timeout": "Apprentissage annulé (aucune carte posée)"
329334
}
330335
}
331336
},

‎html/management.html‎

Lines changed: 49 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -4027,33 +4027,23 @@ <h5 class="modal-title" data-i18n="tools.nvs.erase.title"></h5>
40274027
icon: "fas fa-sliders-h",
40284028
submenu: eqSubmenu
40294029
};
4030-
/* RFID: learn the currently scanned card onto this file/folder (pick a play
4031-
mode = assign directly, no tab switch / submit) or remove its assignment. */
4030+
/* RFID: learn a card onto this file/folder. Pick a play mode -> the next card
4031+
laid on the reader gets assigned to this path (scan-after-select flow). */
40324032
var rfidSubmenu = {};
4033-
var scannedId = document.getElementById('rfidIdMusic').value;
4034-
var hasCard = /^[0-9]{12}$/.test(scannedId || "");
4035-
if (hasCard) {
4036-
$('#playMode option').each(function () {
4037-
var cls = this.className || "";
4038-
var ok = node.data.directory
4039-
? /option-folder|option-file-and-folder/.test(cls)
4040-
: (node.type === "playlist"
4041-
? /option-file\b|option-file-and-folder|option-stream/.test(cls)
4042-
: /option-file\b|option-file-and-folder/.test(cls));
4043-
if (!ok) return;
4044-
var val = this.value, label = (this.textContent || "").trim() || ("Mode " + val);
4045-
rfidSubmenu["m" + val] = {
4046-
label: label,
4047-
action: (function (v) { return function () { assignRfidToPath(node.data.path, v, nodeId); }; })(val)
4048-
};
4049-
});
4050-
} else {
4051-
rfidSubmenu.nocard = {
4052-
label: () => i18next.t("files.context.rfidNoCard"),
4053-
_disabled: true,
4054-
action: function () { }
4033+
$('#playMode option').each(function () {
4034+
var cls = this.className || "";
4035+
var ok = node.data.directory
4036+
? /option-folder|option-file-and-folder/.test(cls)
4037+
: (node.type === "playlist"
4038+
? /option-file\b|option-file-and-folder|option-stream/.test(cls)
4039+
: /option-file\b|option-file-and-folder/.test(cls));
4040+
if (!ok) return;
4041+
var val = this.value, label = (this.textContent || "").trim() || ("Mode " + val);
4042+
rfidSubmenu["m" + val] = {
4043+
label: label,
4044+
action: (function (v) { return function () { armRfidLearn(node.data.path, v, nodeId); }; })(val)
40554045
};
4056-
}
4046+
});
40574047
if (hasRfidAssignment(node.data.path)) {
40584048
rfidSubmenu.remove = {
40594049
label: () => i18next.t("files.context.rfidRemove"),
@@ -4319,9 +4309,14 @@ <h5 class="modal-title" data-i18n="tools.nvs.erase.title"></h5>
43194309
console.log(socketMsg);
43204310
if (socketMsg.rfidId != null) {
43214311
document.getElementById('rfidIdMusic').value = socketMsg.rfidId;
4322-
toaster.info(i18next.t("toast.rfidDetect", {
4323-
rfidId: socketMsg.rfidId
4324-
}));
4312+
// If a "learn" was armed from the explorer context menu, complete it with this card.
4313+
if (pendingRfidLearn) {
4314+
completeRfidLearn(socketMsg.rfidId);
4315+
} else {
4316+
toaster.info(i18next.t("toast.rfidDetect", {
4317+
rfidId: socketMsg.rfidId
4318+
}));
4319+
}
43254320
$("#rfidIdMusic").effect("highlight", {
43264321
color: "#abf5af"
43274322
}, 3000);
@@ -6217,19 +6212,36 @@ <h5 class="modal-title" data-i18n="tools.nvs.erase.title"></h5>
62176212
}
62186213
// Assign the currently scanned RFID card to a file/folder path with the given play mode
62196214
// (the elegant path: triggered from the explorer context menu, no tab switch / submit).
6220-
async function assignRfidToPath(path, playMode, nodeId) {
6221-
var id = document.getElementById('rfidIdMusic').value;
6222-
if (!/^[0-9]{12}$/.test(id || "")) {
6223-
toaster.warning(i18next.t("files.context.rfidNoCard"));
6224-
return;
6225-
}
6215+
// Pending "learn" request: armed from the explorer context menu, completed by the next
6216+
// physically laid card. { path, playMode, nodeId, timer } or null.
6217+
var pendingRfidLearn = null;
6218+
// Arm learning: remember the target path + play mode, then wait for the next card scan.
6219+
function armRfidLearn(path, playMode, nodeId) {
6220+
if (pendingRfidLearn && pendingRfidLearn.timer) { clearTimeout(pendingRfidLearn.timer); }
6221+
pendingRfidLearn = {
6222+
path: path, playMode: String(playMode), nodeId: nodeId,
6223+
timer: setTimeout(function () {
6224+
pendingRfidLearn = null;
6225+
toaster.warning(i18next.t("files.rfid.learn.timeout"));
6226+
}, 60000)
6227+
};
6228+
toaster.info(i18next.t("files.rfid.learn.waiting"));
6229+
}
6230+
function cancelRfidLearn() {
6231+
if (pendingRfidLearn && pendingRfidLearn.timer) { clearTimeout(pendingRfidLearn.timer); }
6232+
pendingRfidLearn = null;
6233+
}
6234+
// Complete a pending learn with a just-scanned card id (called from the websocket handler).
6235+
async function completeRfidLearn(scannedId) {
6236+
var p = pendingRfidLearn;
6237+
cancelRfidLearn();
62266238
await fetch("http://" + host + "/settings", {
62276239
method: "POST",
62286240
headers: { "Content-Type": "application/json" },
6229-
body: JSON.stringify({ rfidAssign: { rfidIdMusic: id, fileOrUrl: removeTrSlash(path), playMode: String(playMode) } })
6241+
body: JSON.stringify({ rfidAssign: { rfidIdMusic: scannedId, fileOrUrl: removeTrSlash(p.path), playMode: p.playMode } })
62306242
});
6231-
toaster.info(i18next.t("toast.saved"));
6232-
refreshRfidBadgesAround(nodeId);
6243+
toaster.info(i18next.t("files.rfid.learn.done", { rfidId: scannedId }));
6244+
refreshRfidBadgesAround(p.nodeId);
62336245
}
62346246
// Remove every RFID tag assigned to this exact path (usually one) straight from the explorer.
62356247
async function removeRfidForPath(path, nodeId) {

0 commit comments

Comments
 (0)