Implement full NFC-URL streaming architecture

- rc522: add NDEF URL reading via WUPA+cascade anticoll+SELECT+TLV parse
- AudioPlayer: route http:// URIs to esp_player_http_music_play()
- CacheManager: download URLs async to /sdcard/cache/{hash}.{ext}
- TagPlayerControl: NDEF URL → cache-or-stream; fallback to UID local mapping
- RFIDInterface: wire ndef_callback, expose combined uid+url callback
- WebInterface: add /api/info, /api/storage, /api/playback, /api/bluetooth
- BluetoothInterface: stub for A2DP speaker mode
- Networking: WiFi connect with 30s timeout instead of blocking forever
- main.cpp: re-enable WiFi + web server, fix init order, add CacheManager
- www/: full SPA — playback controls, device info, storage, BT pairing
- .gitmodules: switch rc522 submodule URL to HTTPS

https://claude.ai/code/session_01MhAnh68DPNnTjcKqjNYDmk
This commit is contained in:
Claude committed 2026-06-12 19:44:54 +00:00
1 parent 98872d75df
commit bf9852c345
19 files changed
+1172 -318

No files matched your search

+57 -15
View File
@@ -1,17 +1,59 @@
<html>
<head>
<title>Kidsbox</title>
<link rel="stylesheet" src="styles.css">
</head>
<body>
<h1>Hi, I'm a File!</h1>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Kidsbox</title>
<link rel="stylesheet" href="styles.css">
</head>
<body>
<header>
<h1>&#127911; Kidsbox</h1>
</header>
<section class="rfid">
<h3>RFID-ID</h3>
<div class="rfid-id"></div>
<button class="rfid-refresh">refresh</button>
</section>
<main>
<section class="card" id="section-playback">
<h2>Now Playing</h2>
<div class="track-uri" id="track-uri">—</div>
<div class="playback-controls">
<button class="btn btn-primary" id="btn-pause" disabled>Pause</button>
<button class="btn btn-secondary" id="btn-stop" disabled>Stop</button>
</div>
<div class="volume-row">
<label for="volume-slider">Volume</label>
<input type="range" id="volume-slider" min="0" max="100" value="50">
<span id="volume-label">50</span>
</div>
</section>
<script src="main.js"></script>
</body>
</html>
<section class="card" id="section-info">
<h2>Device Info</h2>
<table class="info-table">
<tr><th>IP Address</th><td id="info-ip">—</td></tr>
<tr><th>Status</th><td id="info-playing">—</td></tr>
<tr><th>Cache Used</th><td id="info-cache">—</td></tr>
<tr><th>Free Heap</th><td id="info-heap">—</td></tr>
</table>
<button class="btn btn-secondary" id="btn-refresh-info">Refresh</button>
</section>
<section class="card" id="section-storage">
<h2>Storage</h2>
<div class="storage-header">
<span id="storage-summary">Loading…</span>
<button class="btn btn-danger" id="btn-clear-cache">Clear All</button>
</div>
<ul class="cache-list" id="cache-list"></ul>
</section>
<section class="card" id="section-bluetooth">
<h2>Bluetooth Speaker</h2>
<p class="bt-status" id="bt-status">Disabled</p>
<button class="btn btn-secondary" id="btn-bt-toggle">Enable</button>
<p class="note">Pair your phone to play music through the Kidsbox speaker.</p>
</section>
</main>
<script src="main.js"></script>
</body>
</html>
+148 -9
View File
@@ -1,13 +1,152 @@
console.log('hi');
'use strict';
const elements = {};
/* ── helpers ──────────────────────────────────────────────────────────── */
elements.rfidId = document.querySelector('.rfid-id');
elements.rfidRefresh = document.querySelector('.rfid-refresh');
function fmt_bytes(n) {
if (n < 1024) return n + ' B';
if (n < 1024 * 1024) return (n / 1024).toFixed(1) + ' KB';
return (n / (1024 * 1024)).toFixed(1) + ' MB';
}
elements.rfidRefresh.addEventListener('click', async () => {
const response = await fetch('/rfid'),
rfidId = await response.text();
async function api(method, path, body) {
const opts = { method };
if (body) {
opts.headers = { 'Content-Type': 'application/x-www-form-urlencoded' };
opts.body = body;
}
const res = await fetch(path, opts);
return res.json();
}
elements.rfidId.innerText = rfidId;
});
/* ── device info ──────────────────────────────────────────────────────── */
async function refreshInfo() {
try {
const d = await api('GET', '/api/info');
document.getElementById('info-ip').textContent = d.ip ?? '—';
document.getElementById('info-playing').textContent = d.playing ? 'Playing' : 'Stopped';
document.getElementById('info-cache').textContent = fmt_bytes(d.cacheBytes ?? 0);
document.getElementById('info-heap').textContent = fmt_bytes(d.freeHeap ?? 0);
document.getElementById('track-uri').textContent = d.currentUri || '—';
document.getElementById('btn-pause').disabled = !d.playing;
document.getElementById('btn-stop').disabled = !d.playing;
const vol = d.volume ?? 50;
document.getElementById('volume-slider').value = vol;
document.getElementById('volume-label').textContent = vol;
} catch (e) {
console.error('Failed to fetch /api/info', e);
}
}
/* ── storage ──────────────────────────────────────────────────────────── */
async function refreshStorage() {
try {
const entries = await api('GET', '/api/storage');
const list = document.getElementById('cache-list');
const summary = document.getElementById('storage-summary');
list.innerHTML = '';
if (!Array.isArray(entries) || entries.length === 0) {
summary.textContent = 'No cached files';
return;
}
let total = 0;
entries.forEach(e => {
total += e.size ?? 0;
const li = document.createElement('li');
const key = document.createElement('span');
key.className = 'cache-key';
key.textContent = e.key;
const sz = document.createElement('span');
sz.className = 'cache-size';
sz.textContent = fmt_bytes(e.size ?? 0);
const del = document.createElement('button');
del.className = 'btn btn-danger';
del.style.cssText = 'padding:.2rem .5rem;font-size:.75rem';
del.textContent = '✕';
del.addEventListener('click', () => deleteEntry(e.key));
li.append(key, sz, del);
list.appendChild(li);
});
summary.textContent =
`${entries.length} file${entries.length !== 1 ? 's' : ''} — ${fmt_bytes(total)}`;
} catch (e) {
console.error('Failed to fetch /api/storage', e);
}
}
async function deleteEntry(key) {
await api('DELETE', `/api/storage?key=${encodeURIComponent(key)}`);
refreshStorage();
}
async function clearCache() {
if (!confirm('Delete all cached files?')) return;
await api('DELETE', '/api/storage');
refreshStorage();
}
/* ── playback ─────────────────────────────────────────────────────────── */
async function sendPlayback(action, value) {
const body = value !== undefined
? `action=${action}&value=${encodeURIComponent(value)}`
: `action=${action}`;
await api('POST', '/api/playback', body);
refreshInfo();
}
/* ── bluetooth ────────────────────────────────────────────────────────── */
async function refreshBt() {
try {
const d = await api('GET', '/api/bluetooth');
const status = document.getElementById('bt-status');
const btn = document.getElementById('btn-bt-toggle');
if (d.enabled) {
status.textContent = 'Enabled';
status.style.color = '#48bb78';
btn.textContent = 'Disable';
} else {
status.textContent = 'Disabled';
status.style.color = '#a0aec0';
btn.textContent = 'Enable';
}
} catch (e) {
console.error('Failed to fetch /api/bluetooth', e);
}
}
async function toggleBt() {
const enabled = document.getElementById('bt-status').textContent === 'Enabled';
await api('POST', '/api/bluetooth', `enabled=${!enabled}`);
refreshBt();
}
/* ── init ─────────────────────────────────────────────────────────────── */
document.addEventListener('DOMContentLoaded', () => {
refreshInfo();
refreshStorage();
refreshBt();
document.getElementById('btn-refresh-info').addEventListener('click', refreshInfo);
document.getElementById('btn-clear-cache').addEventListener('click', clearCache);
document.getElementById('btn-bt-toggle').addEventListener('click', toggleBt);
document.getElementById('btn-pause').addEventListener('click', () => sendPlayback('pause'));
document.getElementById('btn-stop').addEventListener('click', () => sendPlayback('stop'));
const slider = document.getElementById('volume-slider');
const label = document.getElementById('volume-label');
slider.addEventListener('input', () => { label.textContent = slider.value; });
slider.addEventListener('change', () => sendPlayback('volume', slider.value));
/* Auto-refresh info every 5 s while page is visible */
setInterval(() => { if (!document.hidden) refreshInfo(); }, 5000);
});
+125
View File
@@ -0,0 +1,125 @@
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: #f0f4f8;
color: #1a202c;
min-height: 100vh;
}
header {
background: #2d3748;
color: #fff;
padding: 1rem 1.5rem;
}
header h1 {
font-size: 1.5rem;
font-weight: 700;
}
main {
max-width: 700px;
margin: 1.5rem auto;
padding: 0 1rem;
display: flex;
flex-direction: column;
gap: 1.25rem;
}
.card {
background: #fff;
border-radius: 10px;
box-shadow: 0 2px 8px rgba(0,0,0,.08);
padding: 1.25rem 1.5rem;
}
.card h2 {
font-size: 1rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: .05em;
color: #718096;
margin-bottom: 1rem;
}
/* Playback */
.track-uri {
font-size: .9rem;
color: #4a5568;
word-break: break-all;
min-height: 1.2em;
margin-bottom: .75rem;
}
.playback-controls {
display: flex;
gap: .5rem;
margin-bottom: 1rem;
}
.volume-row {
display: flex;
align-items: center;
gap: .75rem;
}
.volume-row label { font-size: .875rem; color: #718096; white-space: nowrap; }
.volume-row input { flex: 1; }
#volume-label { font-size: .875rem; width: 2rem; text-align: right; }
/* Buttons */
.btn {
padding: .45rem .9rem;
border: none;
border-radius: 6px;
font-size: .875rem;
font-weight: 600;
cursor: pointer;
transition: opacity .15s;
}
.btn:disabled { opacity: .45; cursor: not-allowed; }
.btn:not(:disabled):hover { opacity: .85; }
.btn-primary { background: #4299e1; color: #fff; }
.btn-secondary { background: #e2e8f0; color: #2d3748; }
.btn-danger { background: #fc8181; color: #fff; }
/* Device info */
.info-table { width: 100%; border-collapse: collapse; margin-bottom: .75rem; }
.info-table th, .info-table td { padding: .35rem .5rem; text-align: left; font-size: .875rem; }
.info-table th { color: #718096; width: 40%; }
.info-table tr:not(:last-child) th,
.info-table tr:not(:last-child) td { border-bottom: 1px solid #edf2f7; }
/* Storage */
.storage-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: .75rem;
font-size: .875rem;
color: #4a5568;
}
.cache-list { list-style: none; }
.cache-list li {
display: flex;
align-items: center;
justify-content: space-between;
padding: .4rem 0;
border-bottom: 1px solid #edf2f7;
font-size: .875rem;
}
.cache-list li:last-child { border-bottom: none; }
.cache-list .cache-key { font-family: monospace; color: #4a5568; }
.cache-list .cache-size { color: #a0aec0; }
/* Bluetooth */
.bt-status { font-weight: 600; margin-bottom: .5rem; }
#btn-bt-toggle { margin-bottom: .5rem; }
.note { font-size: .8rem; color: #a0aec0; }