Compare commits

3 Commits
Author SHA1 Message Date
Claude afbc0e5e23 Remove arduino-esp32 dependency, modernise WiFi and SPI compat
- Replace Arduino WiFi.h in Networking.cpp with native esp_wifi/esp_netif;
  uses event-driven connect with FreeRTOS EventGroup and 30 s timeout
- Remove initArduino() / Arduino.h from main.cpp
- Remove deprecated arduino-esp32 submodule (was uninitialized and marked
  deprecated since it was first added in Dec 2021)
- rc522.h: guard VSPI_HOST → SPI3_HOST for ESP-IDF 5.x compatibility
- WebInterface.cpp: drop redundant esp_wifi.h (esp_netif.h is sufficient)
- CMakeLists.txt: add esp_wifi + nvs_flash to REQUIRES

https://claude.ai/code/session_01MhAnh68DPNnTjcKqjNYDmk
2026-06-12 20:34:52 +00:00
Claude 4f9946bd17 Vendor esp-idf-rc522 as plain source instead of submodule
The library is modified (NDEF reading, cascade anticollision) so vendoring
it directly avoids needing push access to the upstream fork.

https://claude.ai/code/session_01MhAnh68DPNnTjcKqjNYDmk
2026-06-12 19:46:21 +00:00
Claude bf9852c345 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
2026-06-12 19:44:54 +00:00
27 changed files with 2022 additions and 247 deletions

No files matched your search

-7
View File
@@ -1,7 +0,0 @@
[submodule "device/components/esp-idf-rc522"]
path = device/components/esp-idf-rc522
url = git@github.com:sweidac/esp-idf-rc522.git
[submodule "device/components/arduino-esp32"]
path = device/components/arduino-esp32
url = https://github.com/espressif/arduino-esp32.git
@@ -0,0 +1,4 @@
idf_component_register(
SRCS "rc522.c"
INCLUDE_DIRS "."
)
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2019 Alija Bobija
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
@@ -0,0 +1 @@
COMPONENT_ADD_INCLUDEDIRS := .
+746
View File
@@ -0,0 +1,746 @@
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "esp_system.h"
#include "driver/spi_master.h"
#include "soc/gpio_struct.h"
#include "driver/gpio.h"
#include "esp_timer.h"
#include "esp_log.h"
#include "rc522.h"
static const char* TAG = "ESP-RC522";
struct rc522 {
bool running;
rc522_config_t* config;
spi_device_handle_t spi;
TaskHandle_t task_handle;
bool scan_started;
uint8_t* lastSerialNumber;
};
typedef struct rc522* rc522_handle_t;
static rc522_handle_t hndl = NULL;
#define rc522_fw_version() rc522_read(0x37)
bool rc522_is_inited() {
return hndl != NULL;
}
static esp_err_t rc522_spi_init() {
if(! hndl || ! hndl->config) {
ESP_LOGE(TAG, "Fail to init SPI. Invalid handle");
return ESP_ERR_INVALID_STATE;
}
if(hndl->spi) {
ESP_LOGW(TAG, "SPI already initialized");
return ESP_ERR_INVALID_STATE;
}
spi_bus_config_t buscfg = {
.miso_io_num = hndl->config->miso_io,
.mosi_io_num = hndl->config->mosi_io,
.sclk_io_num = hndl->config->sck_io,
.quadwp_io_num = -1,
.quadhd_io_num = -1
};
spi_device_interface_config_t devcfg = {
.clock_speed_hz = 5000000,
.mode = 0,
.spics_io_num = hndl->config->sda_io,
.queue_size = 7,
.flags = SPI_DEVICE_HALFDUPLEX
};
esp_err_t err = spi_bus_initialize(hndl->config->spi_host_id, &buscfg, 0);
if(err != ESP_OK) {
return err;
}
err = spi_bus_add_device(hndl->config->spi_host_id, &devcfg, &hndl->spi);
if(err != ESP_OK) {
spi_bus_free(hndl->config->spi_host_id);
hndl->spi = NULL;
}
return err;
}
static esp_err_t rc522_write_n(uint8_t addr, uint8_t n, uint8_t *data) {
uint8_t* buffer = (uint8_t*) malloc(n + 1);
buffer[0] = (addr << 1) & 0x7E;
for (uint8_t i = 1; i <= n; i++) {
buffer[i] = data[i-1];
}
spi_transaction_t t;
memset(&t, 0, sizeof(t));
t.length = 8 * (n + 1);
t.tx_buffer = buffer;
esp_err_t ret = spi_device_transmit(hndl->spi, &t);
free(buffer);
return ret;
}
static esp_err_t rc522_write(uint8_t addr, uint8_t val) {
return rc522_write_n(addr, 1, &val);
}
static uint8_t* rc522_read_n(uint8_t addr, uint8_t n) {
if (n <= 0) {
return NULL;
}
spi_transaction_t t;
memset(&t, 0, sizeof(t));
uint8_t* buffer = (uint8_t*) malloc(n);
t.flags = SPI_TRANS_USE_TXDATA;
t.length = 8;
t.tx_data[0] = ((addr << 1) & 0x7E) | 0x80;
t.rxlength = 8 * n;
t.rx_buffer = buffer;
esp_err_t ret = spi_device_transmit(hndl->spi, &t);
assert(ret == ESP_OK);
return buffer;
}
static uint8_t rc522_read(uint8_t addr) {
uint8_t* buffer = rc522_read_n(addr, 1);
uint8_t res = buffer[0];
free(buffer);
return res;
}
static esp_err_t rc522_set_bitmask(uint8_t addr, uint8_t mask) {
return rc522_write(addr, rc522_read(addr) | mask);
}
static esp_err_t rc522_clear_bitmask(uint8_t addr, uint8_t mask) {
return rc522_write(addr, rc522_read(addr) & ~mask);
}
static esp_err_t rc522_antenna_on() {
esp_err_t ret;
if(~ (rc522_read(0x14) & 0x03)) {
ret = rc522_set_bitmask(0x14, 0x03);
if(ret != ESP_OK) {
return ret;
}
}
return rc522_write(0x26, 0x60); // 43dB gain
}
static void rc522_task(void* arg);
esp_err_t rc522_init(rc522_config_t* config) {
if(! config) {
return ESP_ERR_INVALID_ARG;
}
if(hndl) {
ESP_LOGW(TAG, "Already initialized");
return ESP_ERR_INVALID_STATE;
}
if(! (hndl = calloc(1, sizeof(struct rc522)))) {
return ESP_ERR_NO_MEM;
}
if(! (hndl->config = calloc(1, sizeof(rc522_config_t)))) {
rc522_destroy();
return ESP_ERR_NO_MEM;
}
// copy config considering defaults
hndl->config->callback = config->callback;
hndl->config->ndef_callback = config->ndef_callback;
hndl->config->miso_io = config->miso_io == 0 ? RC522_DEFAULT_MISO : config->miso_io;
hndl->config->mosi_io = config->mosi_io == 0 ? RC522_DEFAULT_MOSI : config->mosi_io;
hndl->config->sck_io = config->sck_io == 0 ? RC522_DEFAULT_SCK : config->sck_io;
hndl->config->sda_io = config->sda_io == 0 ? RC522_DEFAULT_SDA : config->sda_io;
hndl->config->spi_host_id = config->spi_host_id == 0 ? RC522_DEFAULT_SPI_HOST : config->spi_host_id;
hndl->config->scan_interval_ms = config->scan_interval_ms < 50 ? RC522_DEFAULT_SCAN_INTERVAL_MS : config->scan_interval_ms;
hndl->config->task_stack_size = config->task_stack_size == 0 ? RC522_DEFAULT_TACK_STACK_SIZE : config->task_stack_size;
hndl->config->task_priority = config->task_priority == 0 ? RC522_DEFAULT_TACK_STACK_PRIORITY : config->task_priority;
esp_err_t err = rc522_spi_init();
if(err != ESP_OK) {
rc522_destroy();
return err;
}
// ---------- RW test ------------
const uint8_t test_addr = 0x24, test_val = 0x25;
for(uint8_t i = test_val; i < test_val + 2; i++) {
if((err = rc522_write(test_addr, i)) != ESP_OK || rc522_read(test_addr) != i) {
ESP_LOGE(TAG, "RW test fail");
rc522_destroy();
return err;
}
}
// ------- End of RW test --------
rc522_write(0x01, 0x0F);
rc522_write(0x2A, 0x8D);
rc522_write(0x2B, 0x3E);
rc522_write(0x2D, 0x1E);
rc522_write(0x2C, 0x00);
rc522_write(0x15, 0x40);
rc522_write(0x11, 0x3D);
rc522_antenna_on();
hndl->running = true;
if (xTaskCreate(rc522_task, "rc522_task", hndl->config->task_stack_size, NULL, hndl->config->task_priority, &hndl->task_handle) != pdTRUE) {
ESP_LOGE(TAG, "Fail to create rc522 task");
rc522_destroy();
return err;
}
if(err != ESP_OK) {
ESP_LOGE(TAG, "Fail to create timer");
rc522_destroy();
return err;
}
ESP_LOGI(TAG, "Initialized (firmware: 0x%x)", rc522_fw_version());
return ESP_OK;
}
uint64_t rc522_sn_to_u64(uint8_t* sn) {
if(!sn) {
return 0;
}
uint64_t result = 0;
for(int i = 4; i >= 0; i--) {
result |= ((uint64_t) sn[i] << (i * 8));
}
return result;
}
/* Returns pointer to dynamically allocated array of two element */
static uint8_t* rc522_calculate_crc(uint8_t *data, uint8_t n) {
rc522_clear_bitmask(0x05, 0x04);
rc522_set_bitmask(0x0A, 0x80);
rc522_write_n(0x09, n, data);
rc522_write(0x01, 0x03);
uint8_t i = 255;
uint8_t nn = 0;
for(;;) {
nn = rc522_read(0x05);
i--;
if(! (i != 0 && ! (nn & 0x04))) {
break;
}
}
uint8_t* res = (uint8_t*) malloc(2);
res[0] = rc522_read(0x22);
res[1] = rc522_read(0x21);
return res;
}
static uint8_t* rc522_card_write(uint8_t cmd, uint8_t *data, uint8_t n, uint8_t* res_n) {
uint8_t *result = NULL;
uint8_t irq = 0x00;
uint8_t irq_wait = 0x00;
uint8_t last_bits = 0;
uint8_t nn = 0;
if(cmd == 0x0E) {
irq = 0x12;
irq_wait = 0x10;
}
else if(cmd == 0x0C) {
irq = 0x77;
irq_wait = 0x30;
}
rc522_write(0x02, irq | 0x80);
rc522_clear_bitmask(0x04, 0x80);
rc522_set_bitmask(0x0A, 0x80);
rc522_write(0x01, 0x00);
rc522_write_n(0x09, n, data);
rc522_write(0x01, cmd);
if(cmd == 0x0C) {
rc522_set_bitmask(0x0D, 0x80);
}
uint16_t i = 1000;
for(;;) {
nn = rc522_read(0x04);
i--;
if(! (i != 0 && (((nn & 0x01) == 0) && ((nn & irq_wait) == 0)))) {
break;
}
}
rc522_clear_bitmask(0x0D, 0x80);
if(i != 0) {
if((rc522_read(0x06) & 0x1B) == 0x00) {
if(cmd == 0x0C) {
nn = rc522_read(0x0A);
last_bits = rc522_read(0x0C) & 0x07;
if (last_bits != 0) {
*res_n = (nn - 1) + last_bits;
} else {
*res_n = nn;
}
result = (uint8_t*) malloc(*res_n);
for(i = 0; i < *res_n; i++) {
result[i] = rc522_read(0x09);
}
}
}
}
return result;
}
static uint8_t* rc522_request(uint8_t* res_n) {
uint8_t* result = NULL;
rc522_write(0x0D, 0x07);
uint8_t req_mode = 0x26;
result = rc522_card_write(0x0C, &req_mode, 1, res_n);
if(*res_n * 8 != 0x10) {
free(result);
return NULL;
}
return result;
}
static uint8_t* rc522_anticoll() {
uint8_t res_n;
rc522_write(0x0D, 0x00);
uint8_t* result = rc522_card_write(0x0C, (uint8_t[]) { 0x93, 0x20 }, 2, &res_n);
if(result && res_n != 5) { // all cards/tags serial numbers is 5 bytes long (?)
free(result);
return NULL;
}
return result;
}
/* Send HALT and clear crypto bit. Card will only respond to WUPA after this. */
static void rc522_halt_card() {
uint8_t buf[] = { 0x50, 0x00, 0x00, 0x00 };
uint8_t* crc = rc522_calculate_crc(buf, 2);
buf[2] = crc[0];
buf[3] = crc[1];
free(crc);
uint8_t res_n;
uint8_t* res = rc522_card_write(0x0C, buf, 4, &res_n);
free(res);
rc522_clear_bitmask(0x08, 0x08);
}
static uint8_t* rc522_get_tag() {
uint8_t* result = NULL;
uint8_t* res_data = NULL;
uint8_t res_data_n;
res_data = rc522_request(&res_data_n);
if(res_data != NULL) {
free(res_data);
result = rc522_anticoll();
/* NOTE: card is NOT halted here — caller must call rc522_halt_card() */
}
return result;
}
/* URI prefix table for NDEF URI records (RFC 3986 shortened forms) */
static const char* const RC522_URI_PREFIXES[] = {
"", /* 0x00 no prefix */
"http://www.", /* 0x01 */
"https://www.", /* 0x02 */
"http://", /* 0x03 */
"https://", /* 0x04 */
"tel:", /* 0x05 */
"mailto:", /* 0x06 */
"ftp://anonymous:anonymous@", /* 0x07 */
"ftp://ftp.", /* 0x08 */
"ftps://", /* 0x09 */
"sftp://", /* 0x0A */
"smb://", /* 0x0B */
"nfs://", /* 0x0C */
"ftp://", /* 0x0D */
"dav://", /* 0x0E */
"news:", /* 0x0F */
"telnet://", /* 0x10 */
"imap:", /* 0x11 */
"rtsp://", /* 0x12 */
"urn:", /* 0x13 */
"pop:", /* 0x14 */
"sip:", /* 0x15 */
"sips:", /* 0x16 */
"tftp:", /* 0x17 */
"btspp://", /* 0x18 */
"btl2cap://", /* 0x19 */
"btgoep://", /* 0x1A */
"tcpobex://", /* 0x1B */
"irdaobex://", /* 0x1C */
"file://", /* 0x1D */
"urn:epc:id:", /* 0x1E */
"urn:epc:tag:", /* 0x1F */
"urn:epc:pat:", /* 0x20 */
"urn:epc:raw:", /* 0x21 */
"urn:epc:", /* 0x22 */
"urn:nfc:", /* 0x23 */
};
#define RC522_URI_PREFIX_COUNT (sizeof(RC522_URI_PREFIXES) / sizeof(RC522_URI_PREFIXES[0]))
/* Parse an NDEF message TLV block and extract the first URI record URL.
* Returns heap-allocated null-terminated URL string, or NULL if not found. */
static char* rc522_parse_ndef_url(const uint8_t* data, int len) {
int i = 0;
while(i < len) {
uint8_t tlv_type = data[i++];
if(tlv_type == 0xFE) break; /* Terminator TLV */
if(tlv_type == 0x00) continue; /* Null TLV — no length byte */
if(i >= len) break;
/* Read TLV length (1 or 3 bytes) */
uint16_t tlv_len;
if(data[i] == 0xFF) {
i++;
if(i + 1 >= len) break;
tlv_len = ((uint16_t)data[i] << 8) | data[i + 1];
i += 2;
} else {
tlv_len = data[i++];
}
if(tlv_type != 0x03 || tlv_len == 0 || i + (int)tlv_len > len) {
i += tlv_len;
continue;
}
/* Parse NDEF record header */
uint8_t header = data[i++];
uint8_t tnf = header & 0x07;
bool sr = (header & 0x10) != 0; /* Short Record */
bool il = (header & 0x08) != 0; /* ID Length present */
if(i >= len) break;
uint8_t type_len = data[i++];
uint32_t payload_len;
if(sr) {
if(i >= len) break;
payload_len = data[i++];
} else {
if(i + 3 >= len) break;
payload_len = ((uint32_t)data[i] << 24) | ((uint32_t)data[i+1] << 16)
| ((uint32_t)data[i+2] << 8) | data[i+3];
i += 4;
}
uint8_t id_len = 0;
if(il) {
if(i >= len) break;
id_len = data[i++];
}
/* Only handle Well-Known URI records (TNF=0x01, Type='U') */
if(tnf == 0x01 && type_len == 1 && i < len && data[i] == 0x55) {
i++; /* skip type byte 'U' */
i += id_len; /* skip optional ID */
if(payload_len > 0 && i < len) {
uint8_t prefix_code = data[i++];
payload_len--;
const char* prefix = (prefix_code < RC522_URI_PREFIX_COUNT) ? RC522_URI_PREFIXES[prefix_code] : "";
size_t prefix_len = strlen(prefix);
size_t body_len = (payload_len < (uint32_t)(len - i)) ? payload_len : (uint32_t)(len - i);
if(prefix_len + body_len > 255) body_len = 255 - prefix_len;
char* url = (char*) malloc(prefix_len + body_len + 1);
if(!url) return NULL;
memcpy(url, prefix, prefix_len);
memcpy(url + prefix_len, data + i, body_len);
url[prefix_len + body_len] = '\0';
return url;
}
}
/* Skip over this record */
i += type_len + id_len + (int)payload_len;
}
return NULL;
}
/* Wake a halted card with WUPA (0x52), perform cascade anticollision (CL1 + optional CL2),
* SELECT the card, read pages 4–15 of user memory (NDEF area), parse, then halt.
* Returns heap-allocated URL string or NULL. Card is always left in HALT state on return. */
static char* rc522_read_ndef_url() {
uint8_t res_n;
/* WUPA: wake halted card — identical bit-framing to REQA but byte is 0x52 */
rc522_write(0x0D, 0x07);
uint8_t wupa_cmd = 0x52;
uint8_t* atqa = rc522_card_write(0x0C, &wupa_cmd, 1, &res_n);
if(!atqa || res_n * 8 != 0x10) {
free(atqa);
return NULL;
}
free(atqa);
/* CL1 anti-collision */
rc522_write(0x0D, 0x00);
uint8_t* cl1 = rc522_card_write(0x0C, (uint8_t[]){ 0x93, 0x20 }, 2, &res_n);
if(!cl1 || res_n != 5) {
free(cl1);
rc522_halt_card();
return NULL;
}
bool cascade = (cl1[0] == 0x88); /* cascade tag byte indicates 7-byte UID */
/* CL1 SELECT */
uint8_t sel1[9] = { 0x93, 0x70, cl1[0], cl1[1], cl1[2], cl1[3], cl1[4] };
uint8_t* crc = rc522_calculate_crc(sel1, 7);
sel1[7] = crc[0]; sel1[8] = crc[1]; free(crc);
uint8_t sel_n;
uint8_t* sak1 = rc522_card_write(0x0C, sel1, 9, &sel_n);
free(cl1);
if(!sak1) {
rc522_halt_card();
return NULL;
}
free(sak1);
if(cascade) {
/* CL2 anti-collision */
rc522_write(0x0D, 0x00);
uint8_t* cl2 = rc522_card_write(0x0C, (uint8_t[]){ 0x95, 0x20 }, 2, &res_n);
if(!cl2 || res_n != 5) {
free(cl2);
rc522_halt_card();
return NULL;
}
/* CL2 SELECT */
uint8_t sel2[9] = { 0x95, 0x70, cl2[0], cl2[1], cl2[2], cl2[3], cl2[4] };
crc = rc522_calculate_crc(sel2, 7);
sel2[7] = crc[0]; sel2[8] = crc[1]; free(crc);
uint8_t* sak2 = rc522_card_write(0x0C, sel2, 9, &sel_n);
free(cl2);
if(!sak2) {
rc522_halt_card();
return NULL;
}
free(sak2);
}
/* Card is now SELECTED. Verify NDEF capability container at page 3.
* For NTAG/MIFARE Ultralight the NDEF magic byte is 0xE1 at CC[0]. */
uint8_t read_cc[4] = { 0x30, 0x03 };
crc = rc522_calculate_crc(read_cc, 2);
read_cc[2] = crc[0]; read_cc[3] = crc[1]; free(crc);
uint8_t* cc_data = rc522_card_write(0x0C, read_cc, 4, &res_n);
if(!cc_data || res_n < 4 || cc_data[0] != 0xE1) {
free(cc_data);
rc522_halt_card();
return NULL;
}
free(cc_data);
/* Read NDEF user pages 4–15 (12 pages × 4 bytes = 48 bytes).
* READ command (0x30) returns 16 bytes (4 pages) per call. */
uint8_t ndef_buf[48];
int ndef_len = 0;
for(uint8_t page = 4; page <= 12; page += 4) { /* 3 reads × 16 bytes = 48 bytes */
uint8_t read_cmd[4] = { 0x30, page };
crc = rc522_calculate_crc(read_cmd, 2);
read_cmd[2] = crc[0]; read_cmd[3] = crc[1]; free(crc);
uint8_t* page_data = rc522_card_write(0x0C, read_cmd, 4, &res_n);
if(!page_data || res_n < 16) {
free(page_data);
break;
}
int copy = (48 - ndef_len < 16) ? 48 - ndef_len : 16;
memcpy(ndef_buf + ndef_len, page_data, copy);
ndef_len += copy;
free(page_data);
}
rc522_halt_card();
if(ndef_len == 0) return NULL;
return rc522_parse_ndef_url(ndef_buf, ndef_len);
}
esp_err_t rc522_start(rc522_start_args_t start_args) {
esp_err_t err = rc522_init(&start_args);
return err != ESP_OK ? err : rc522_start2();
}
esp_err_t rc522_start2() {
if(! hndl) { return ESP_ERR_INVALID_STATE; }
hndl->scan_started = true;
return ESP_OK;
}
esp_err_t rc522_pause() {
if(! hndl) { return ESP_ERR_INVALID_STATE; }
if(! hndl->scan_started) {
return ESP_OK;
}
hndl->scan_started = false;
return ESP_OK;
}
void rc522_destroy() {
if(! hndl) { return; }
rc522_pause(); // stop timer
hndl->running = false; // task will delete itself
if(hndl->spi) {
spi_bus_remove_device(hndl->spi);
spi_bus_free(hndl->config->spi_host_id);
hndl->spi = NULL;
}
free(hndl->config);
hndl->config = NULL;
free(hndl);
hndl = NULL;
}
static bool isSameSerialNumber(uint8_t* one, uint8_t* other) {
if (one == NULL && other == NULL) {
return true;
}
if (one == NULL || other == NULL) {
return false;
}
return one[0] == other[0]
&& one[1] == other[1]
&& one[2] == other[2]
&& one[3] == other[3]
&& one[4] == other[4];
}
static void rc522_task(void* arg) {
while(hndl->running) {
if(!hndl->scan_started) {
vTaskDelay(100 / portTICK_PERIOD_MS);
continue;
}
uint8_t* serial_no = rc522_get_tag(); /* card NOT halted yet */
if(!isSameSerialNumber(serial_no, hndl->lastSerialNumber)) {
rc522_tag_callback_t cb = hndl->config->callback;
rc522_ndef_callback_t ndef_cb = hndl->config->ndef_callback;
if(serial_no == NULL) {
free(hndl->lastSerialNumber);
hndl->lastSerialNumber = NULL;
if(cb) cb(NULL);
if(ndef_cb) ndef_cb(NULL, NULL);
} else {
if(!hndl->lastSerialNumber) {
hndl->lastSerialNumber = malloc(5);
}
memcpy(hndl->lastSerialNumber, serial_no, 5);
if(cb) cb(serial_no);
if(ndef_cb) {
/* rc522_read_ndef_url() handles HALT internally after reading */
char* url = rc522_read_ndef_url();
ndef_cb(serial_no, url);
if(url) free(url);
} else {
rc522_halt_card();
}
}
} else if(serial_no != NULL) {
/* Same card still present — halt it again to prevent continuous REQA responses */
rc522_halt_card();
}
int delay_ms = hndl->config->scan_interval_ms;
if(serial_no != NULL) {
free(serial_no);
serial_no = NULL;
delay_ms *= 2;
}
vTaskDelay(delay_ms / portTICK_PERIOD_MS);
}
vTaskDelete(NULL);
}
+98
View File
@@ -0,0 +1,98 @@
#pragma once
#ifdef __cplusplus
extern "C" {
#endif
#include "driver/spi_master.h"
#define RC522_DEFAULT_MISO (25)
#define RC522_DEFAULT_MOSI (23)
#define RC522_DEFAULT_SCK (19)
#define RC522_DEFAULT_SDA (22)
/* VSPI_HOST was renamed to SPI3_HOST in ESP-IDF 5.0 */
#ifdef VSPI_HOST
# define RC522_DEFAULT_SPI_HOST (VSPI_HOST)
#else
# define RC522_DEFAULT_SPI_HOST (SPI3_HOST)
#endif
#define RC522_DEFAULT_SCAN_INTERVAL_MS (125)
#define RC522_DEFAULT_TACK_STACK_SIZE (4 * 1024)
#define RC522_DEFAULT_TACK_STACK_PRIORITY (4)
typedef void(*rc522_tag_callback_t)(uint8_t*);
/* Called when a tag is detected (url != NULL) or removed (sn == NULL, url == NULL).
* url is the NDEF URI record content, or NULL if no NDEF was found on the tag.
* The caller must NOT free sn or url — they are freed by the library. */
typedef void(*rc522_ndef_callback_t)(uint8_t* sn, const char* url);
typedef struct {
int miso_io; /*<! MFRC522 MISO gpio (Default: 25) */
int mosi_io; /*<! MFRC522 MOSI gpio (Default: 23) */
int sck_io; /*<! MFRC522 SCK gpio (Default: 19) */
int sda_io; /*<! MFRC522 SDA gpio (Default: 22) */
spi_host_device_t spi_host_id; /*<! Default VSPI_HOST (SPI3) */
rc522_tag_callback_t callback; /*<! Called with 5-byte serial number, or NULL when tag removed */
rc522_ndef_callback_t ndef_callback; /*<! Called with (sn, url) on tag change; url may be NULL */
uint16_t scan_interval_ms; /*<! How fast will ESP32 scan for nearby tags, in miliseconds. Default: 125ms */
size_t task_stack_size; /*<! Stack size of rc522 task (Default: 4 * 1024) */
uint8_t task_priority; /*<! Priority of rc522 task (Default: 4) */
} rc522_config_t;
typedef rc522_config_t rc522_start_args_t;
/**
* @brief Initialize RC522 module.
* To start scanning tags - call rc522_resume or rc522_start2 function.
* @param config Configuration
* @return ESP_OK on success
*/
esp_err_t rc522_init(rc522_config_t* config);
/**
* @brief Convert serial number (array of 5 bytes) to uint64_t number
* @param sn Serial number
* @return Serial number in number representation. If fail, 0 will be retured
*/
uint64_t rc522_sn_to_u64(uint8_t* sn);
/**
* @brief Check if RC522 is inited
* @return true if RC522 is inited
*/
bool rc522_is_inited();
/**
* @brief This function will call rc522_init function and immediately start to scan tags by calling rc522_resume function.
* @param start_args Configuration
* @return ESP_OK on success
*/
esp_err_t rc522_start(rc522_start_args_t start_args);
/**
* @brief Start to scan tags. If already started, ESP_OK will just be returned.
* @return ESP_OK on success
*/
esp_err_t rc522_start2();
/**
* @brief Start to scan tags. If already started, ESP_OK will just be returned.
* @return ESP_OK on success
*/
#define rc522_resume() rc522_start2()
/**
* @brief Pause scan tags. If already paused, ESP_OK will just be returned.
* @return ESP_OK on success
*/
esp_err_t rc522_pause();
/**
* @brief Destroy RC522 and free all resources
*/
void rc522_destroy();
#ifdef __cplusplus
}
#endif
+45 -10
View File
@@ -1,24 +1,40 @@
#include "AudioPlayer.hpp" #include "AudioPlayer.hpp"
#include <iostream>
#include <esp_player_wrapper.h> #include <esp_player_wrapper.h>
#include "audio_setup.h" #include "audio_setup.h"
#include <string> #include <esp_log.h>
static const char* TAG = "AudioPlayer";
AudioPlayer::AudioPlayer() { AudioPlayer::AudioPlayer() {
playerHandle = setup_player(NULL, NULL); playerHandle = setup_player(NULL, NULL);
esp_player_init(playerHandle); esp_player_init((esp_audio_handle_t)playerHandle);
esp_audio_vol_set((esp_audio_handle_t)playerHandle, 50);
esp_audio_vol_set(playerHandle, 50);
} }
void AudioPlayer::play(std::string uri) { bool AudioPlayer::isHttpUri(const std::string& uri) const {
return uri.rfind("http://", 0) == 0 || uri.rfind("https://", 0) == 0;
}
bool AudioPlayer::isLastUri(const std::string& uri) const {
return lastUri == uri;
}
void AudioPlayer::play(const std::string& uri) {
if(isLastUri(uri)) { if(isLastUri(uri)) {
esp_player_music_resume(); esp_player_music_resume();
} else { return;
}
esp_player_music_stop(); esp_player_music_stop();
lastUri = uri;
if(isHttpUri(uri)) {
ESP_LOGI(TAG, "Streaming: %s", uri.c_str());
esp_player_http_music_play(uri.c_str(), 0);
} else {
ESP_LOGI(TAG, "Playing local: %s", uri.c_str());
esp_player_sdcard_music_play(uri.c_str(), 0); esp_player_sdcard_music_play(uri.c_str(), 0);
lastUri = std::string(uri);
} }
} }
@@ -26,6 +42,25 @@ void AudioPlayer::pause() {
esp_player_music_pause(); esp_player_music_pause();
} }
bool AudioPlayer::isLastUri(std::string uri) { void AudioPlayer::stop() {
return lastUri == uri; esp_player_music_stop();
lastUri.clear();
}
int AudioPlayer::getVolume() {
int vol = 0;
esp_player_vol_get(&vol);
return vol;
}
void AudioPlayer::setVolume(int vol) {
if(vol < 0) vol = 0;
if(vol > 100) vol = 100;
esp_audio_vol_set((esp_audio_handle_t)playerHandle, vol);
}
bool AudioPlayer::isPlaying() const {
esp_audio_state_t state;
if(esp_player_state_get(&state) != ESP_OK) return false;
return state.status == AUDIO_STATUS_RUNNING;
} }
+15 -2
View File
@@ -1,6 +1,7 @@
#pragma once #pragma once
#include <string> #include <string>
#include <esp_player_wrapper.h>
class AudioPlayer { class AudioPlayer {
void* playerHandle; void* playerHandle;
@@ -8,9 +9,21 @@ class AudioPlayer {
public: public:
AudioPlayer(); AudioPlayer();
void play(std::string uri);
/* Play URI. http:// / https:// URIs stream via HTTP; anything else plays from SD card.
* Re-placing the same URI resumes rather than restarting. */
void play(const std::string& uri);
void pause(); void pause();
void stop();
int getVolume();
void setVolume(int vol);
bool isPlaying() const;
const std::string& getCurrentUri() const { return lastUri; }
private: private:
bool isLastUri(std::string uri); bool isHttpUri(const std::string& uri) const;
bool isLastUri(const std::string& uri) const;
}; };
+25
View File
@@ -0,0 +1,25 @@
#include "BluetoothInterface.hpp"
#include <esp_log.h>
static const char* TAG = "Bluetooth";
BluetoothInterface::BluetoothInterface() {
ESP_LOGI(TAG, "BT interface created (stub — not yet enabled)");
}
bool BluetoothInterface::setEnabled(bool on) {
if(on == enabled) return enabled;
if(on) {
/* TODO: initialize Bluedroid stack, register A2DP sink callbacks,
* switch audio pipeline output from I2S codec to BT stream.
* Requires CONFIG_BT_ENABLED=y and esp_bt / esp_a2dp components. */
ESP_LOGW(TAG, "BT enable requested — not yet implemented");
enabled = false; /* keep false until implemented */
} else {
ESP_LOGI(TAG, "BT disable requested");
enabled = false;
}
return enabled;
}
+19
View File
@@ -0,0 +1,19 @@
#pragma once
/* Stub for Bluetooth A2DP speaker mode.
* When enabled the ESP32 advertises itself as a BT audio sink so a phone
* can pair and route music through the Kidsbox speaker.
* Full implementation requires the esp_bt + esp_a2dp components to be
* added to the build and CONFIG_BT_ENABLED=y in sdkconfig. */
class BluetoothInterface {
public:
BluetoothInterface();
bool isEnabled() const { return enabled; }
/* Toggle BT speaker mode on/off. Returns whether BT is now enabled. */
bool setEnabled(bool on);
private:
bool enabled = false;
};
+12 -2
View File
@@ -1,2 +1,12 @@
idf_component_register(SRCS "Networking.cpp" "main.cpp" "WebInterface.cpp" "RFIDInterface.cpp" "audio_setup.c" "AudioPlayer.cpp" "TagPlayerControl.cpp" idf_component_register(SRCS
INCLUDE_DIRS ".") "main.cpp"
"Networking.cpp"
"WebInterface.cpp"
"RFIDInterface.cpp"
"AudioPlayer.cpp"
"TagPlayerControl.cpp"
"CacheManager.cpp"
"BluetoothInterface.cpp"
"audio_setup.c"
INCLUDE_DIRS "."
REQUIRES esp_http_client esp_netif esp_wifi nvs_flash)
+205
View File
@@ -0,0 +1,205 @@
#include "CacheManager.hpp"
#include <esp_log.h>
#include <esp_http_client.h>
#include <freertos/FreeRTOS.h>
#include <freertos/task.h>
#include <sys/stat.h>
#include <dirent.h>
#include <stdio.h>
#include <string.h>
#include <errno.h>
#include <string>
#include <functional>
static const char* TAG = "CacheManager";
struct DownloadArgs {
std::string url;
std::function<void(bool)> onComplete;
};
CacheManager::CacheManager() {
mkdir(CACHE_DIR, 0777);
}
/* djb2 hash of the URL, returned as 8-char hex string */
std::string CacheManager::urlToKey(const std::string& url) const {
uint32_t hash = 5381;
for(char c : url) {
hash = ((hash << 5) + hash) ^ (uint8_t)c;
}
char buf[9];
snprintf(buf, sizeof(buf), "%08lx", (unsigned long)hash);
return std::string(buf);
}
std::string CacheManager::urlExtension(const std::string& url) const {
/* Strip query/fragment before looking for extension */
size_t qs = url.find('?');
std::string path = (qs != std::string::npos) ? url.substr(0, qs) : url;
size_t dot = path.rfind('.');
size_t slash = path.rfind('/');
if(dot != std::string::npos && (slash == std::string::npos || dot > slash)) {
std::string ext = path.substr(dot); /* includes the dot */
if(ext.size() <= 5) return ext; /* ignore suspiciously long "extensions" */
}
return ".mp3"; /* sensible default */
}
std::string CacheManager::keyToPath(const std::string& key, const std::string& ext) const {
return std::string(CACHE_DIR) + "/" + key + ext;
}
/* Find any file in CACHE_DIR whose name starts with key */
std::string CacheManager::findFileByKey(const std::string& key) const {
DIR* dir = opendir(CACHE_DIR);
if(!dir) return "";
struct dirent* entry;
while((entry = readdir(dir)) != NULL) {
std::string name(entry->d_name);
if(name.rfind(key, 0) == 0) {
closedir(dir);
return std::string(CACHE_DIR) + "/" + name;
}
}
closedir(dir);
return "";
}
std::string CacheManager::getCachedPath(const std::string& url) const {
if(url.empty()) return "";
std::string key = urlToKey(url);
std::string path = findFileByKey(key);
if(!path.empty()) {
ESP_LOGI(TAG, "Cache hit: %s -> %s", url.c_str(), path.c_str());
}
return path;
}
void CacheManager::downloadTask(void* arg) {
auto* dl = static_cast<DownloadArgs*>(arg);
ESP_LOGI(TAG, "Downloading: %s", dl->url.c_str());
CacheManager cm;
std::string key = cm.urlToKey(dl->url);
std::string ext = cm.urlExtension(dl->url);
std::string path = cm.keyToPath(key, ext);
esp_http_client_config_t cfg = {};
cfg.url = dl->url.c_str();
cfg.timeout_ms = 30000;
cfg.buffer_size = 4096;
esp_http_client_handle_t client = esp_http_client_init(&cfg);
esp_err_t err = esp_http_client_open(client, 0);
bool success = false;
if(err == ESP_OK) {
int content_len = esp_http_client_fetch_headers(client);
(void)content_len;
FILE* f = fopen(path.c_str(), "wb");
if(f) {
char buf[4096];
int read_len;
while((read_len = esp_http_client_read(client, buf, sizeof(buf))) > 0) {
fwrite(buf, 1, read_len, f);
}
fclose(f);
success = (read_len == 0); /* 0 = clean EOF */
if(!success) {
remove(path.c_str());
ESP_LOGE(TAG, "Download error, partial file deleted");
} else {
ESP_LOGI(TAG, "Cached to %s", path.c_str());
}
} else {
ESP_LOGE(TAG, "Cannot open cache file: %s (errno %d)", path.c_str(), errno);
}
} else {
ESP_LOGE(TAG, "HTTP open failed: %s", esp_err_to_name(err));
}
esp_http_client_cleanup(client);
if(dl->onComplete) dl->onComplete(success);
delete dl;
vTaskDelete(NULL);
}
void CacheManager::downloadAsync(const std::string& url, std::function<void(bool)> onComplete) {
if(url.empty()) return;
auto* args = new DownloadArgs{ url, onComplete };
xTaskCreate(downloadTask, "cache_dl", 8192, args, 3, NULL);
}
std::vector<CacheEntry> CacheManager::listEntries() const {
std::vector<CacheEntry> entries;
DIR* dir = opendir(CACHE_DIR);
if(!dir) return entries;
struct dirent* entry;
while((entry = readdir(dir)) != NULL) {
if(entry->d_name[0] == '.') continue;
std::string name(entry->d_name);
std::string full = std::string(CACHE_DIR) + "/" + name;
struct stat st;
size_t size = 0;
if(stat(full.c_str(), &st) == 0) size = st.st_size;
/* Key is filename without extension */
size_t dot = name.rfind('.');
std::string key = (dot != std::string::npos) ? name.substr(0, dot) : name;
entries.push_back({ key, /*url unknown*/"", full, size });
}
closedir(dir);
return entries;
}
bool CacheManager::deleteEntry(const std::string& key) {
std::string path = findFileByKey(key);
if(path.empty()) return false;
return remove(path.c_str()) == 0;
}
void CacheManager::deleteAll() {
DIR* dir = opendir(CACHE_DIR);
if(!dir) return;
struct dirent* entry;
while((entry = readdir(dir)) != NULL) {
if(entry->d_name[0] == '.') continue;
std::string full = std::string(CACHE_DIR) + "/" + entry->d_name;
remove(full.c_str());
}
closedir(dir);
ESP_LOGI(TAG, "Cache cleared");
}
size_t CacheManager::totalBytes() const {
size_t total = 0;
DIR* dir = opendir(CACHE_DIR);
if(!dir) return 0;
struct dirent* entry;
while((entry = readdir(dir)) != NULL) {
if(entry->d_name[0] == '.') continue;
std::string full = std::string(CACHE_DIR) + "/" + entry->d_name;
struct stat st;
if(stat(full.c_str(), &st) == 0) total += st.st_size;
}
closedir(dir);
return total;
}
+46
View File
@@ -0,0 +1,46 @@
#pragma once
#include <string>
#include <vector>
#include <functional>
struct CacheEntry {
std::string key; /* hex hash of the URL */
std::string url;
std::string path; /* full SD card path */
size_t size; /* file size in bytes, 0 if unknown */
};
class CacheManager {
public:
CacheManager();
/* Returns the SD-card path for url if cached, or empty string if not. */
std::string getCachedPath(const std::string& url) const;
/* Enqueue an async FreeRTOS task that downloads url and caches it.
* onComplete is called (from the download task) when done or on error. */
void downloadAsync(const std::string& url, std::function<void(bool success)> onComplete = nullptr);
/* List all cached entries. */
std::vector<CacheEntry> listEntries() const;
/* Delete a single cache entry by key (hex hash). Returns true if deleted. */
bool deleteEntry(const std::string& key);
/* Delete the entire cache directory. */
void deleteAll();
/* Total bytes used by cache. */
size_t totalBytes() const;
private:
static constexpr const char* CACHE_DIR = "/sdcard/cache";
std::string urlToKey(const std::string& url) const;
std::string urlExtension(const std::string& url) const;
std::string keyToPath(const std::string& key, const std::string& ext) const;
std::string findFileByKey(const std::string& key) const;
static void downloadTask(void* arg);
};
+82 -8
View File
@@ -1,15 +1,89 @@
#include "Networking.hpp" #include "Networking.hpp"
#include "WiFi.h" #include <esp_log.h>
#include <esp_wifi.h>
#include <esp_event.h>
#include <esp_netif.h>
#include <nvs_flash.h>
#include <freertos/FreeRTOS.h>
#include <freertos/event_groups.h>
#include <string.h>
static const char* TAG = "Networking";
static EventGroupHandle_t s_wifi_event_group;
static const int CONNECTED_BIT = BIT0;
static int s_retries = 0;
static void wifi_event_handler(void*, esp_event_base_t base, int32_t id, void* data) {
if(base == WIFI_EVENT && id == WIFI_EVENT_STA_START) {
esp_wifi_connect();
} else if(base == WIFI_EVENT && id == WIFI_EVENT_STA_DISCONNECTED) {
if(s_retries < CONFIG_ESP_MAXIMUM_RETRY) {
esp_wifi_connect();
s_retries++;
ESP_LOGI(TAG, "Reconnect attempt %d/%d", s_retries, CONFIG_ESP_MAXIMUM_RETRY);
} else {
ESP_LOGW(TAG, "Could not connect after %d attempts", CONFIG_ESP_MAXIMUM_RETRY);
}
} else if(base == IP_EVENT && id == IP_EVENT_STA_GOT_IP) {
ip_event_got_ip_t* ev = (ip_event_got_ip_t*)data;
ESP_LOGI(TAG, "Connected. IP: " IPSTR, IP2STR(&ev->ip_info.ip));
s_retries = 0;
xEventGroupSetBits(s_wifi_event_group, CONNECTED_BIT);
}
}
void Networking::connectWifi() { void Networking::connectWifi() {
WiFi.begin(CONFIG_ESP_WIFI_SSID, CONFIG_ESP_WIFI_PASSWORD); /* NVS is required by the WiFi driver */
esp_err_t err = nvs_flash_init();
while (WiFi.status() != WL_CONNECTED) { if(err == ESP_ERR_NVS_NO_FREE_PAGES || err == ESP_ERR_NVS_NEW_VERSION_FOUND) {
delay(1000); nvs_flash_erase();
Serial.println("Waiting for connection..."); nvs_flash_init();
} }
Serial.println("Connected."); err = esp_netif_init();
Serial.println(WiFi.localIP()); if(err != ESP_OK && err != ESP_ERR_INVALID_STATE) {
ESP_LOGE(TAG, "esp_netif_init: %s", esp_err_to_name(err));
}
err = esp_event_loop_create_default();
if(err != ESP_OK && err != ESP_ERR_INVALID_STATE) {
ESP_LOGE(TAG, "esp_event_loop_create_default: %s", esp_err_to_name(err));
}
esp_netif_create_default_wifi_sta();
wifi_init_config_t cfg = WIFI_INIT_CONFIG_DEFAULT();
esp_wifi_init(&cfg);
s_wifi_event_group = xEventGroupCreate();
s_retries = 0;
esp_event_handler_instance_t inst_any, inst_ip;
esp_event_handler_instance_register(WIFI_EVENT, ESP_EVENT_ANY_ID, wifi_event_handler, NULL, &inst_any);
esp_event_handler_instance_register(IP_EVENT, IP_EVENT_STA_GOT_IP, wifi_event_handler, NULL, &inst_ip);
wifi_config_t wifi_cfg = {};
strncpy((char*)wifi_cfg.sta.ssid, CONFIG_ESP_WIFI_SSID, sizeof(wifi_cfg.sta.ssid) - 1);
strncpy((char*)wifi_cfg.sta.password, CONFIG_ESP_WIFI_PASSWORD, sizeof(wifi_cfg.sta.password) - 1);
wifi_cfg.sta.threshold.authmode = WIFI_AUTH_WPA2_PSK;
esp_wifi_set_mode(WIFI_MODE_STA);
esp_wifi_set_config(WIFI_IF_STA, &wifi_cfg);
esp_wifi_start();
EventBits_t bits = xEventGroupWaitBits(s_wifi_event_group, CONNECTED_BIT,
pdFALSE, pdFALSE, pdMS_TO_TICKS(30000));
if(!(bits & CONNECTED_BIT)) {
ESP_LOGW(TAG, "WiFi not connected after 30 s — continuing without network");
}
vEventGroupDelete(s_wifi_event_group);
s_wifi_event_group = NULL;
}
bool Networking::isConnected() const {
wifi_ap_record_t ap;
return esp_wifi_sta_get_ap_info(&ap) == ESP_OK;
} }
+5 -4
View File
@@ -1,9 +1,10 @@
#ifndef _NETWORKING_HPP #pragma once
#define _NETWORKING_HPP
class Networking { class Networking {
public: public:
/* Connects to the WiFi configured in Kconfig (CONFIG_ESP_WIFI_SSID / _PASSWORD).
* Blocks up to 30 s, then returns regardless of connection state. */
void connectWifi(); void connectWifi();
};
#endif bool isConnected() const;
};
+46 -20
View File
@@ -10,35 +10,58 @@
static const char* TAG = "rfid"; static const char* TAG = "rfid";
static char* lastTagId = (char*)malloc(26 * sizeof(char)); static char lastTagId[26];
static std::function<void(char*)> callback; static std::function<void(char*)> legacyCallback;
static std::function<void(const char*, const char*)> ndefCallback;
static char* getLastTagId() { static char* getLastTagId() {
return lastTagId; return lastTagId;
} }
static void onUidCallback(uint8_t* sn) {
if(sn == NULL) {
ESP_LOGI(TAG, "Tag removed");
lastTagId[0] = '\0';
if(legacyCallback) legacyCallback(NULL);
} else {
snprintf(lastTagId, sizeof(lastTagId),
"%#x_%#x_%#x_%#x_%#x", sn[0], sn[1], sn[2], sn[3], sn[4]);
ESP_LOGI(TAG, "Tag UID: %s", lastTagId);
if(legacyCallback) legacyCallback(lastTagId);
}
}
static void onNdefCallback(uint8_t* sn, const char* url) {
if(!ndefCallback) return;
if(sn == NULL) {
ndefCallback(NULL, NULL);
return;
}
/* Format UID same way as above */
char uid[26];
snprintf(uid, sizeof(uid),
"%#x_%#x_%#x_%#x_%#x", sn[0], sn[1], sn[2], sn[3], sn[4]);
if(url) {
ESP_LOGI(TAG, "NDEF URL on %s: %s", uid, url);
} else {
ESP_LOGI(TAG, "No NDEF on %s", uid);
}
ndefCallback(uid, url);
}
RFIDInterface::RFIDInterface() { RFIDInterface::RFIDInterface() {
startConfig = (rc522_start_args_t){ startConfig = (rc522_start_args_t){
.miso_io = 12, .miso_io = 12,
.mosi_io = 13, .mosi_io = 13,
.sck_io = 23, .sck_io = 23,
.sda_io = 18, .sda_io = 18,
.callback = [](uint8_t* sn) { .spi_host_id = (spi_host_device_t)0,
if (sn == NULL) { .callback = onUidCallback,
ESP_LOGI(TAG, "Tag removed"); .ndef_callback = onNdefCallback,
if (callback) {
callback(NULL);
}
} else { // serial number is always 5 bytes long
sprintf(lastTagId, "%#x_%#x_%#x_%#x_%#x", sn[0], sn[1], sn[2], sn[3], sn[4]);
ESP_LOGI(TAG, "tag id: %s", lastTagId);
if (callback) {
callback(getLastTagId());
}
}
}
}; };
} }
@@ -55,10 +78,13 @@ void RFIDInterface::registerWebResources(WebInterface* interface) {
return ESP_OK; return ESP_OK;
} }
}; };
interface->registerResource(&config); interface->registerResource(&config);
} }
void RFIDInterface::registerTagCallback(std::function<void(const char*, const char*)> cb) {
ndefCallback = cb;
}
void RFIDInterface::registerTagChangeCallback(std::function<void(char*)> cb) { void RFIDInterface::registerTagChangeCallback(std::function<void(char*)> cb) {
callback = cb; legacyCallback = cb;
} }
+9 -1
View File
@@ -12,7 +12,15 @@ class RFIDInterface {
RFIDInterface(); RFIDInterface();
void start(); void start();
void registerWebResources(WebInterface* interface); void registerWebResources(WebInterface* interface);
void registerTagChangeCallback(std::function<void(char*)>);
/* Callback receives (uid_string, url_or_null):
* uid == NULL → tag removed
* url == NULL → tag placed but no NDEF URI found (use uid for local lookup)
* url != NULL → tag placed with NDEF URI */
void registerTagCallback(std::function<void(const char* uid, const char* url)> cb);
/* Legacy: only fires with UID string (NULL on removal). Kept for compatibility. */
void registerTagChangeCallback(std::function<void(char*)> cb);
}; };
#endif #endif
+52 -43
View File
@@ -1,62 +1,71 @@
#include "TagPlayerControl.hpp" #include "TagPlayerControl.hpp"
#include "AudioPlayer.hpp" #include <esp_log.h>
#include <iostream>
#include <fstream> #include <fstream>
#include <string> #include <string>
#include <memory>
#include <esp_log.h>
static const char* TAG = "TagPlayerControl"; static const char* TAG = "TagPlayerControl";
TagPlayerControl::TagPlayerControl(std::shared_ptr<AudioPlayer> player) TagPlayerControl::TagPlayerControl(std::shared_ptr<AudioPlayer> player,
: audioPlayer(player) { std::shared_ptr<CacheManager> cache)
} : audioPlayer(player), cacheManager(cache) {}
void TagPlayerControl::onTagChanged(char* tagId) { void TagPlayerControl::onTagChanged(const char* uid, const char* url) {
if (tagId == NULL) { if(uid == NULL) {
// tag has been removed. stop playback ESP_LOGI(TAG, "Tag removed — pausing");
audioPlayer->pause(); audioPlayer->pause();
} else { lastUid.clear();
playTag(tagId);
}
}
void TagPlayerControl::playTag(char* tagId) {
std::string filePath = "/sdcard/internal/";
filePath.append(tagId);
std::fstream file(filePath);
if (! file) {
ESP_LOGE(TAG, "Error opening file for tag %s", tagId);
char* error_name;
switch (errno)
{
case EACCES:
ESP_LOGE(TAG, "EACCESS");
break;
case ENOENT:
ESP_LOGE(TAG, "ENOENT");
break;
default:
ESP_LOGE(TAG, "not parsed");
}
return; return;
} }
std::string line; lastUid = std::string(uid);
std::string content;
if(url != NULL && url[0] != '\0') {
ESP_LOGI(TAG, "Tag %s -> URL: %s", uid, url);
playUrl(std::string(url));
} else {
ESP_LOGI(TAG, "Tag %s -> no NDEF, trying local mapping", uid);
playLocalTag(std::string(uid));
}
}
void TagPlayerControl::playUrl(const std::string& url) {
/* Check cache first; if found play local file to avoid re-downloading */
std::string cached = cacheManager->getCachedPath(url);
if(!cached.empty()) {
audioPlayer->play(cached);
return;
}
/* Stream via HTTP and cache in background for future plays */
audioPlayer->play(url);
cacheManager->downloadAsync(url, [url](bool success) {
if(success) {
ESP_LOGI("TagPlayerControl", "Cached for next play: %s", url.c_str());
}
});
}
void TagPlayerControl::playLocalTag(const std::string& uid) {
std::string mappingPath = std::string("/sdcard/internal/") + uid;
std::fstream file(mappingPath);
if(!file) {
ESP_LOGW(TAG, "No mapping file for tag %s at %s", uid.c_str(), mappingPath.c_str());
return;
}
std::string line, content;
while(getline(file, line)) { while(getline(file, line)) {
content.append(line); content.append(line);
} }
file.close();
if(content.empty()) {
ESP_LOGW(TAG, "Empty mapping file for tag %s", uid.c_str());
return;
}
audioPlayer->play(content); audioPlayer->play(content);
file.close();
} }
+12 -3
View File
@@ -1,16 +1,25 @@
#pragma once #pragma once
#include <memory> #include <memory>
#include <string>
#include "AudioPlayer.hpp" #include "AudioPlayer.hpp"
#include "CacheManager.hpp"
class TagPlayerControl { class TagPlayerControl {
std::shared_ptr<AudioPlayer> audioPlayer; std::shared_ptr<AudioPlayer> audioPlayer;
std::shared_ptr<CacheManager> cacheManager;
std::string lastUid;
public: public:
TagPlayerControl(std::shared_ptr<AudioPlayer> player); TagPlayerControl(std::shared_ptr<AudioPlayer> player,
std::shared_ptr<CacheManager> cache);
void onTagChanged(char* tagId); /* Called by RFIDInterface. uid == NULL means tag removed.
* url is the NDEF URI from the tag, or NULL if no NDEF record was found. */
void onTagChanged(const char* uid, const char* url);
private: private:
void playTag(char* tagId); void playUrl(const std::string& url);
void playLocalTag(const std::string& uid);
}; };
+223 -115
View File
@@ -1,79 +1,69 @@
#include "WebInterface.hpp" #include "WebInterface.hpp"
#include "AudioPlayer.hpp"
#include "CacheManager.hpp"
#include <esp_http_server.h> #include <esp_http_server.h>
#include <esp_log.h> #include <esp_log.h>
#include "esp_vfs.h" #include <esp_vfs.h>
#include <board.h> #include <board.h>
#include "periph_sdcard.h" #include <periph_sdcard.h>
#include <errno.h> #include <esp_system.h>
#include <esp_netif.h>
#include <fcntl.h> #include <fcntl.h>
#include "esp_system.h" #include <errno.h>
#include <string.h>
#include <dirent.h>
#include <sys/stat.h>
#include <string>
#include <sstream>
static const char* TAG = "WebInterface"; static const char* TAG = "WebInterface";
#define FILE_PATH_MAX (ESP_VFS_PATH_MAX + 128) #define FILE_PATH_MAX (ESP_VFS_PATH_MAX + 128)
#define SCRATCH_BUFSIZE (10240) #define SCRATCH_BUFSIZE (10240)
#define CHECK_FILE_EXTENSION(f, ext) (strcasecmp(&(f)[strlen(f) - strlen(ext)], (ext)) == 0)
#define CHECK_FILE_EXTENSION(filename, ext) (strcasecmp(&filename[strlen(filename) - strlen(ext)], ext) == 0) typedef struct {
typedef struct rest_server_context {
char base_path[ESP_VFS_PATH_MAX + 1]; char base_path[ESP_VFS_PATH_MAX + 1];
char scratch[SCRATCH_BUFSIZE]; char scratch[SCRATCH_BUFSIZE];
} rest_server_context_t; } rest_server_context_t;
WebInterface::WebInterface() { /* ── helpers ──────────────────────────────────────────────────────────── */
} static esp_err_t set_content_type(httpd_req_t* req, const char* filepath) {
void WebInterface::start() {
initSD();
server = start_webserver();
}
void WebInterface::finishResourceRegistrations() {
registerFileServer();
}
void WebInterface::initSD() {
esp_periph_config_t periph_cfg = DEFAULT_ESP_PERIPH_SET_CONFIG();
esp_periph_set_handle_t set = esp_periph_set_init(&periph_cfg);
// Initialize SD Card peripheral
if (audio_board_sdcard_init(set, SD_MODE_1_LINE) == ESP_FAIL) {
ESP_LOGE(TAG, "Sdcard not mounted");
} else {
ESP_LOGI(TAG, "SDCard mounted");
}
}
/* Set HTTP response content type according to file extension */
esp_err_t set_content_type_from_file(httpd_req_t *req, const char *filepath)
{
const char* type = "text/plain"; const char* type = "text/plain";
if (CHECK_FILE_EXTENSION(filepath, ".html")) { if(CHECK_FILE_EXTENSION(filepath, ".html")) type = "text/html";
type = "text/html"; else if(CHECK_FILE_EXTENSION(filepath, ".js")) type = "application/javascript";
} else if (CHECK_FILE_EXTENSION(filepath, ".js")) { else if(CHECK_FILE_EXTENSION(filepath, ".css")) type = "text/css";
type = "application/javascript"; else if(CHECK_FILE_EXTENSION(filepath, ".png")) type = "image/png";
} else if (CHECK_FILE_EXTENSION(filepath, ".css")) { else if(CHECK_FILE_EXTENSION(filepath, ".ico")) type = "image/x-icon";
type = "text/css"; else if(CHECK_FILE_EXTENSION(filepath, ".svg")) type = "image/svg+xml";
} else if (CHECK_FILE_EXTENSION(filepath, ".png")) { else if(CHECK_FILE_EXTENSION(filepath, ".json"))type = "application/json";
type = "image/png";
} else if (CHECK_FILE_EXTENSION(filepath, ".ico")) {
type = "image/x-icon";
} else if (CHECK_FILE_EXTENSION(filepath, ".svg")) {
type = "text/xml";
}
return httpd_resp_set_type(req, type); return httpd_resp_set_type(req, type);
} }
/* Send HTTP response with the contents of the requested file */ static void send_json(httpd_req_t* req, const std::string& json) {
esp_err_t rest_common_get_handler(httpd_req_t *req) httpd_resp_set_type(req, "application/json");
{ httpd_resp_set_hdr(req, "Access-Control-Allow-Origin", "*");
char filepath[FILE_PATH_MAX]; httpd_resp_sendstr(req, json.c_str());
}
rest_server_context_t *rest_context = (rest_server_context_t *)req->user_ctx; static void send_json_ok(httpd_req_t* req) {
strlcpy(filepath, rest_context->base_path, sizeof(filepath)); send_json(req, "{\"ok\":true}");
}
static void send_json_error(httpd_req_t* req, const char* msg) {
std::string body = std::string("{\"error\":\"") + msg + "\"}";
httpd_resp_set_status(req, "400 Bad Request");
send_json(req, body);
}
/* ── static file handler ──────────────────────────────────────────────── */
static esp_err_t rest_common_get_handler(httpd_req_t* req) {
char filepath[FILE_PATH_MAX];
rest_server_context_t* ctx = (rest_server_context_t*)req->user_ctx;
strlcpy(filepath, ctx->base_path, sizeof(filepath));
if(req->uri[strlen(req->uri) - 1] == '/') { if(req->uri[strlen(req->uri) - 1] == '/') {
strlcat(filepath, "/index.html", sizeof(filepath)); strlcat(filepath, "/index.html", sizeof(filepath));
@@ -83,100 +73,218 @@ esp_err_t rest_common_get_handler(httpd_req_t *req)
int fd = open(filepath, O_RDONLY); int fd = open(filepath, O_RDONLY);
if(fd == -1) { if(fd == -1) {
ESP_LOGE(TAG, "Failed to open file : %s. Errno: %d", filepath, errno); ESP_LOGE(TAG, "Failed to open %s (errno %d)", filepath, errno);
/* Respond with 500 Internal Server Error */ httpd_resp_send_err(req, HTTPD_404_NOT_FOUND, "File not found");
char responseError[255];
char* error_name;
switch (errno)
{
case EACCES:
error_name = "EACCESS";
break;
case ENOENT:
error_name = "ENOENT";
break;
default:
error_name = "not parsed";
}
sprintf(responseError, "Failed to read existing file. %d => %s", errno, error_name);
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, responseError);
return ESP_FAIL; return ESP_FAIL;
} }
set_content_type_from_file(req, filepath); set_content_type(req, filepath);
char *chunk = rest_context->scratch; char* chunk = ctx->scratch;
ssize_t read_bytes; ssize_t n;
do { while((n = read(fd, chunk, SCRATCH_BUFSIZE)) > 0) {
/* Read file in chunks into the scratch buffer */ if(httpd_resp_send_chunk(req, chunk, n) != ESP_OK) {
read_bytes = read(fd, chunk, SCRATCH_BUFSIZE);
if (read_bytes == -1) {
ESP_LOGE(TAG, "Failed to read file : %s", filepath);
} else if (read_bytes > 0) {
/* Send the buffer contents as HTTP response chunk */
if (httpd_resp_send_chunk(req, chunk, read_bytes) != ESP_OK) {
close(fd); close(fd);
ESP_LOGE(TAG, "File sending failed!");
/* Abort sending file */
httpd_resp_sendstr_chunk(req, NULL); httpd_resp_sendstr_chunk(req, NULL);
/* Respond with 500 Internal Server Error */ httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "Send error");
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "Failed to send file");
return ESP_FAIL; return ESP_FAIL;
} }
} }
} while (read_bytes > 0);
/* Close file after sending complete */
close(fd); close(fd);
ESP_LOGI(TAG, "File sending complete");
/* Respond with an empty chunk to signal HTTP response completion */
httpd_resp_send_chunk(req, NULL, 0); httpd_resp_send_chunk(req, NULL, 0);
return ESP_OK; return ESP_OK;
} }
/* ── API handlers (use shared_ptrs captured from WebInterface) ──────── */
httpd_handle_t WebInterface::start_webserver(void) { /* GET /api/info — device info JSON */
httpd_handle_t server = NULL; static AudioPlayer* g_audio = nullptr;
static CacheManager* g_cache = nullptr;
static esp_err_t api_info_handler(httpd_req_t* req) {
/* Get device IP */
esp_netif_ip_info_t ip_info = {};
esp_netif_t* netif = esp_netif_get_handle_from_ifkey("WIFI_STA_DEF");
if(netif) esp_netif_get_ip_info(netif, &ip_info);
char ip_str[16] = "0.0.0.0";
snprintf(ip_str, sizeof(ip_str), IPSTR, IP2STR(&ip_info.ip));
int vol = g_audio ? g_audio->getVolume() : 0;
bool playing = g_audio ? g_audio->isPlaying() : false;
size_t cache_bytes = g_cache ? g_cache->totalBytes() : 0;
/* Free heap */
uint32_t free_heap = esp_get_free_heap_size();
std::ostringstream json;
json << "{"
<< "\"ip\":\"" << ip_str << "\","
<< "\"volume\":" << vol << ","
<< "\"playing\":" << (playing ? "true" : "false") << ","
<< "\"cacheBytes\":" << cache_bytes << ","
<< "\"freeHeap\":" << free_heap
<< "}";
send_json(req, json.str());
return ESP_OK;
}
/* GET /api/storage — list cached files as JSON array */
static esp_err_t api_storage_get_handler(httpd_req_t* req) {
if(!g_cache) { send_json(req, "[]"); return ESP_OK; }
auto entries = g_cache->listEntries();
std::ostringstream json;
json << "[";
for(size_t i = 0; i < entries.size(); i++) {
if(i > 0) json << ",";
json << "{\"key\":\"" << entries[i].key << "\","
<< "\"size\":" << entries[i].size << "}";
}
json << "]";
send_json(req, json.str());
return ESP_OK;
}
/* DELETE /api/storage?key=<hex> — delete one entry, or all if no key */
static esp_err_t api_storage_delete_handler(httpd_req_t* req) {
if(!g_cache) { send_json_ok(req); return ESP_OK; }
char key_buf[32] = {};
if(httpd_req_get_url_query_str(req, key_buf, sizeof(key_buf)) == ESP_OK) {
char key[32] = {};
if(httpd_query_key_value(key_buf, "key", key, sizeof(key)) == ESP_OK && key[0]) {
bool ok = g_cache->deleteEntry(std::string(key));
if(!ok) { send_json_error(req, "key not found"); return ESP_OK; }
} else {
g_cache->deleteAll();
}
} else {
g_cache->deleteAll();
}
send_json_ok(req);
return ESP_OK;
}
/* POST /api/playback body: action=play|pause|stop|volume&value=<0-100> */
static esp_err_t api_playback_handler(httpd_req_t* req) {
char buf[128] = {};
int len = httpd_req_recv(req, buf, sizeof(buf) - 1);
if(len <= 0) { send_json_error(req, "empty body"); return ESP_OK; }
buf[len] = '\0';
char action[16] = {};
httpd_query_key_value(buf, "action", action, sizeof(action));
if(!g_audio) { send_json_error(req, "audio not ready"); return ESP_OK; }
if(strcmp(action, "pause") == 0) {
g_audio->pause();
} else if(strcmp(action, "stop") == 0) {
g_audio->stop();
} else if(strcmp(action, "volume") == 0) {
char val_str[8] = {};
httpd_query_key_value(buf, "value", val_str, sizeof(val_str));
int vol = atoi(val_str);
g_audio->setVolume(vol);
} else {
send_json_error(req, "unknown action");
return ESP_OK;
}
send_json_ok(req);
return ESP_OK;
}
/* GET /api/bluetooth — returns BT status (stub) */
static esp_err_t api_bluetooth_get_handler(httpd_req_t* req) {
send_json(req, "{\"enabled\":false,\"note\":\"bluetooth not yet implemented\"}");
return ESP_OK;
}
/* POST /api/bluetooth body: enabled=true|false (stub) */
static esp_err_t api_bluetooth_post_handler(httpd_req_t* req) {
send_json(req, "{\"ok\":false,\"note\":\"bluetooth not yet implemented\"}");
return ESP_OK;
}
/* ── WebInterface ────────────────────────────────────────────────────── */
WebInterface::WebInterface() {}
void WebInterface::start() {
initSD();
server = start_webserver();
}
void WebInterface::finishResourceRegistrations() {
registerApiHandlers();
registerFileServer();
}
void WebInterface::initSD() {
esp_periph_config_t periph_cfg = DEFAULT_ESP_PERIPH_SET_CONFIG();
esp_periph_set_handle_t set = esp_periph_set_init(&periph_cfg);
if(audio_board_sdcard_init(set, SD_MODE_1_LINE) == ESP_FAIL) {
ESP_LOGE(TAG, "SDCard not mounted");
} else {
ESP_LOGI(TAG, "SDCard mounted");
}
}
httpd_handle_t WebInterface::start_webserver() {
httpd_config_t config = HTTPD_DEFAULT_CONFIG(); httpd_config_t config = HTTPD_DEFAULT_CONFIG();
config.lru_purge_enable = true; config.lru_purge_enable = true;
config.uri_match_fn = httpd_uri_match_wildcard; config.uri_match_fn = httpd_uri_match_wildcard;
config.max_uri_handlers = 16;
// Start the httpd server httpd_handle_t srv = NULL;
ESP_LOGI(TAG, "Starting server on port: '%d'", config.server_port); ESP_LOGI(TAG, "Starting server on port %d", config.server_port);
if (httpd_start(&server, &config) == ESP_OK) { if(httpd_start(&srv, &config) == ESP_OK) return srv;
return server;
}
ESP_LOGE(TAG, "Failed to start server");
ESP_LOGI(TAG, "Error starting server!");
return NULL; return NULL;
} }
void WebInterface::registerResource(httpd_uri_t* config) { void WebInterface::registerResource(httpd_uri_t* config) {
ESP_LOGI(TAG, "Register resource on path %s", config->uri); if(!server) return;
ESP_LOGI(TAG, "Register %s", config->uri);
httpd_register_uri_handler(server, config); httpd_register_uri_handler(server, config);
} }
void WebInterface::registerFileServer() { void WebInterface::registerApiHandlers() {
rest_server_context_t *fileContext = (rest_server_context_t*)calloc(1, sizeof(rest_server_context_t)); /* Expose singletons to the static C handlers */
g_audio = audioPlayer.get();
g_cache = cacheManager.get();
if (! fileContext) { httpd_uri_t info = { "/api/info", HTTP_GET, api_info_handler, nullptr };
ESP_LOGE(TAG, "No memory for rest context"); httpd_uri_t stg_g = { "/api/storage", HTTP_GET, api_storage_get_handler, nullptr };
return; httpd_uri_t stg_d = { "/api/storage", HTTP_DELETE, api_storage_delete_handler, nullptr };
httpd_uri_t pb = { "/api/playback", HTTP_POST, api_playback_handler, nullptr };
httpd_uri_t bt_g = { "/api/bluetooth", HTTP_GET, api_bluetooth_get_handler, nullptr };
httpd_uri_t bt_p = { "/api/bluetooth", HTTP_POST, api_bluetooth_post_handler, nullptr };
registerResource(&info);
registerResource(&stg_g);
registerResource(&stg_d);
registerResource(&pb);
registerResource(&bt_g);
registerResource(&bt_p);
} }
strlcpy(fileContext->base_path, "/sdcard/www", sizeof(fileContext->base_path)); void WebInterface::registerFileServer() {
rest_server_context_t* ctx = (rest_server_context_t*)calloc(1, sizeof(rest_server_context_t));
if(!ctx) { ESP_LOGE(TAG, "No memory for file server context"); return; }
/* URI handler for getting web server files */ strlcpy(ctx->base_path, "/sdcard/www", sizeof(ctx->base_path));
httpd_uri_t common_get_uri = {
httpd_uri_t file_uri = {
.uri = "/*", .uri = "/*",
.method = HTTP_GET, .method = HTTP_GET,
.handler = rest_common_get_handler, .handler = rest_common_get_handler,
.user_ctx = fileContext .user_ctx = ctx
}; };
registerResource(&file_uri);
registerResource(&common_get_uri);
} }
+12 -1
View File
@@ -2,12 +2,23 @@
#define _WEB_INTERFACE_HPP #define _WEB_INTERFACE_HPP
#include <esp_http_server.h> #include <esp_http_server.h>
#include <memory>
#include <functional>
class AudioPlayer;
class CacheManager;
class WebInterface { class WebInterface {
httpd_handle_t server = NULL; httpd_handle_t server = NULL;
std::shared_ptr<AudioPlayer> audioPlayer;
std::shared_ptr<CacheManager> cacheManager;
public: public:
WebInterface(); WebInterface();
void setAudioPlayer(std::shared_ptr<AudioPlayer> p) { audioPlayer = p; }
void setCacheManager(std::shared_ptr<CacheManager> c) { cacheManager = c; }
void start(); void start();
void finishResourceRegistrations(); void finishResourceRegistrations();
void registerResource(httpd_uri_t* config); void registerResource(httpd_uri_t* config);
@@ -16,7 +27,7 @@ class WebInterface {
private: private:
httpd_handle_t start_webserver(); httpd_handle_t start_webserver();
void registerFileServer(); void registerFileServer();
//esp_err_t hello_get_handler(httpd_req_t *req); void registerApiHandlers();
}; };
#endif #endif
+23 -14
View File
@@ -1,12 +1,11 @@
#include "Arduino.h"
#include "Networking.hpp" #include "Networking.hpp"
#include "WebInterface.hpp" #include "WebInterface.hpp"
#include "RFIDInterface.hpp" #include "RFIDInterface.hpp"
#include "AudioPlayer.hpp" #include "AudioPlayer.hpp"
#include "TagPlayerControl.hpp" #include "TagPlayerControl.hpp"
#include "CacheManager.hpp"
#include "BluetoothInterface.hpp"
#include <iostream>
#include <memory> #include <memory>
extern "C" { extern "C" {
@@ -16,29 +15,39 @@ extern "C" {
Networking networking; Networking networking;
WebInterface webInterface; WebInterface webInterface;
RFIDInterface rfid; RFIDInterface rfid;
BluetoothInterface bluetooth;
std::shared_ptr<AudioPlayer> audioPlayer; // initialize here, because the ctor does init stuff that fails when executed before `app_main()` /* Shared objects — must be heap-allocated after app_main() starts the scheduler */
std::shared_ptr<AudioPlayer> audioPlayer;
std::shared_ptr<CacheManager> cacheManager;
std::shared_ptr<TagPlayerControl> tagPlayerControl; std::shared_ptr<TagPlayerControl> tagPlayerControl;
void app_main(void) { void app_main(void) {
initArduino(); /* SD card must be mounted first — other components depend on it */
webInterface.initSD(); webInterface.initSD();
/* networking.connectWifi(); /* Network — non-blocking with 30 s timeout */
webInterface.start(); networking.connectWifi();
/* Audio pipeline — must run after scheduler is up */
audioPlayer = std::make_shared<AudioPlayer>();
cacheManager = std::make_shared<CacheManager>();
/* Wire web interface to audio/cache for API endpoints */
webInterface.setAudioPlayer(audioPlayer);
webInterface.setCacheManager(cacheManager);
/* Start HTTP server and register API + static-file handlers */
webInterface.start();
rfid.registerWebResources(&webInterface); rfid.registerWebResources(&webInterface);
webInterface.finishResourceRegistrations(); webInterface.finishResourceRegistrations();
*/
audioPlayer = std::make_shared<AudioPlayer>(); tagPlayerControl = std::make_shared<TagPlayerControl>(audioPlayer, cacheManager);
tagPlayerControl = std::make_shared<TagPlayerControl>(audioPlayer);
/* RFID must start after the audio player (hardware init order constraint) */
rfid.start(); rfid.start();
rfid.registerTagChangeCallback([tagPlayerControl](char* tagId) { rfid.registerTagCallback([](const char* uid, const char* url) {
tagPlayerControl->onTagChanged(tagId); tagPlayerControl->onTagChanged(uid, url);
}); });
} }
+49 -7
View File
@@ -1,17 +1,59 @@
<html> <!DOCTYPE html>
<html lang="en">
<head> <head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Kidsbox</title> <title>Kidsbox</title>
<link rel="stylesheet" src="styles.css"> <link rel="stylesheet" href="styles.css">
</head> </head>
<body> <body>
<h1>Hi, I'm a File!</h1> <header>
<h1>&#127911; Kidsbox</h1>
</header>
<section class="rfid"> <main>
<h3>RFID-ID</h3> <section class="card" id="section-playback">
<div class="rfid-id"></div> <h2>Now Playing</h2>
<button class="rfid-refresh">refresh</button> <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> </section>
<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> <script src="main.js"></script>
</body> </body>
</html> </html>
+147 -8
View File
@@ -1,13 +1,152 @@
console.log('hi'); 'use strict';
const elements = {}; /* ── helpers ──────────────────────────────────────────────────────────── */
elements.rfidId = document.querySelector('.rfid-id'); function fmt_bytes(n) {
elements.rfidRefresh = document.querySelector('.rfid-refresh'); 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 () => { async function api(method, path, body) {
const response = await fetch('/rfid'), const opts = { method };
rfidId = await response.text(); 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; }