mirror of
https://github.com/sweidac/kidsbox.git
synced 2026-10-28 06:52:57 +01:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
afbc0e5e23 | ||
|
|
4f9946bd17 | ||
|
|
bf9852c345 |
No files matched your search
@@ -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
|
|
||||||
Submodule device/components/arduino-esp32 deleted from 16a9cf781f.
Submodule device/components/esp-idf-rc522 deleted from beb7e87877.
@@ -0,0 +1,4 @@
|
|||||||
|
idf_component_register(
|
||||||
|
SRCS "rc522.c"
|
||||||
|
INCLUDE_DIRS "."
|
||||||
|
)
|
||||||
@@ -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 := .
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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
|
||||||
+42
-15
@@ -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, int position = 0) {
|
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();
|
||||||
esp_player_sdcard_music_play(uri.c_str(), position);
|
lastUri = uri;
|
||||||
lastUri = std::string(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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -26,14 +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::getPosition() {
|
int AudioPlayer::getVolume() {
|
||||||
int position;
|
int vol = 0;
|
||||||
|
esp_player_vol_get(&vol);
|
||||||
|
return vol;
|
||||||
|
}
|
||||||
|
|
||||||
esp_player_pos_get(&position);
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
return position;
|
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;
|
||||||
}
|
}
|
||||||
@@ -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;
|
||||||
@@ -9,10 +10,20 @@ class AudioPlayer {
|
|||||||
public:
|
public:
|
||||||
AudioPlayer();
|
AudioPlayer();
|
||||||
|
|
||||||
void play(std::string uri, int position);
|
/* 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();
|
||||||
int getPosition();
|
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;
|
||||||
};
|
};
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
};
|
||||||
@@ -1,2 +1,12 @@
|
|||||||
idf_component_register(SRCS "Networking.cpp" "main.cpp" "WebInterface.cpp" "RFIDInterface.cpp" "audio_setup.c" "AudioPlayer.cpp" "TagPlayerControl.cpp" "Tag.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)
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
@@ -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;
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
@@ -1,199 +0,0 @@
|
|||||||
#include "Tag.hpp"
|
|
||||||
|
|
||||||
#include <iostream>
|
|
||||||
#include <fstream>
|
|
||||||
#include <string>
|
|
||||||
|
|
||||||
#include <cJSON.h>
|
|
||||||
|
|
||||||
#define BASE_PATH "/sdcard/internal/";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* {
|
|
||||||
* tagId : "xa5_x1f_x13_xaa_x00",
|
|
||||||
* playlist : [
|
|
||||||
* "file://sdcard/music_intro.mp3",
|
|
||||||
* "file://sdcard/audiobook_chapter1.mp3",
|
|
||||||
* "file://sdcard/audiobook_chapter2.mp3"
|
|
||||||
* ],
|
|
||||||
* currentIndex : 0,
|
|
||||||
* currentPosition : 1234 // the position as returned by the player. can be frames, can be ms or anything
|
|
||||||
* }
|
|
||||||
*/
|
|
||||||
|
|
||||||
Tag::Tag() {}
|
|
||||||
|
|
||||||
Tag::Tag(char* tagId): Tag(std::string(tagId)) {
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
Tag::Tag(std::string tagId): tagId(tagId) {
|
|
||||||
this->playlist = std::vector<std::string>();
|
|
||||||
}
|
|
||||||
|
|
||||||
void Tag::setCurrentPosition(uint32_t fileIndex, uint32_t position) {
|
|
||||||
this->currentFileIndex = fileIndex;
|
|
||||||
this->currentFilePosition = position;
|
|
||||||
}
|
|
||||||
|
|
||||||
int Tag::getCurrentPosition() {
|
|
||||||
return this->currentFilePosition;
|
|
||||||
}
|
|
||||||
|
|
||||||
std::string Tag::getNextLink() {
|
|
||||||
return this->playlist.at(this->currentFileIndex);
|
|
||||||
}
|
|
||||||
|
|
||||||
bool Tag::isLinked() {
|
|
||||||
return ! this->playlist.empty();
|
|
||||||
}
|
|
||||||
|
|
||||||
void Tag::read() {
|
|
||||||
std::string contents;
|
|
||||||
|
|
||||||
try {
|
|
||||||
contents = readFileContents();
|
|
||||||
} catch (std::invalid_argument& e){
|
|
||||||
std::cout << "Execption while reading tag-file: " << e.what() << "\n";
|
|
||||||
|
|
||||||
write();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
cJSON* data = cJSON_Parse(contents.c_str());
|
|
||||||
|
|
||||||
char* tagId = cJSON_GetObjectItem(data, "tagId")->valuestring;
|
|
||||||
|
|
||||||
int currentIndex = cJSON_GetObjectItem(data, "currentIndex")->valueint;
|
|
||||||
|
|
||||||
if (currentIndex < 0) {
|
|
||||||
std::cout << "Bad file index, must be >= 0, was: " << currentIndex << "\n";
|
|
||||||
currentIndex = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
this->currentFileIndex = currentIndex;
|
|
||||||
|
|
||||||
int currentFilePosition = cJSON_GetObjectItem(data, "currentPosition")->valueint;
|
|
||||||
|
|
||||||
if (currentFilePosition < 0) {
|
|
||||||
std::cout << "Bad file position, must be >= 0, was: " << currentFilePosition << "\n";
|
|
||||||
currentFilePosition = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
this->currentFilePosition = 0;
|
|
||||||
|
|
||||||
cJSON* playlist = cJSON_GetObjectItem(data, "playlist");
|
|
||||||
|
|
||||||
if (playlist) {
|
|
||||||
int playlistLength = cJSON_GetArraySize(playlist);
|
|
||||||
|
|
||||||
for (int i = 0; i < playlistLength; i++) {
|
|
||||||
cJSON* item = cJSON_GetArrayItem(playlist, i);
|
|
||||||
|
|
||||||
// each item is just an URL
|
|
||||||
this->playlist.push_back(std::string(item->valuestring));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
cJSON_Delete(data);
|
|
||||||
}
|
|
||||||
|
|
||||||
void Tag::write() {
|
|
||||||
std::string contents = createFileContents();
|
|
||||||
|
|
||||||
std::cout << contents << std::endl;
|
|
||||||
|
|
||||||
std::string filePath = getFilePath();
|
|
||||||
|
|
||||||
std::ofstream file(filePath);
|
|
||||||
|
|
||||||
if (! file.is_open()) {
|
|
||||||
std::cout << "Error opening file for tag " << tagId << " before writing";
|
|
||||||
|
|
||||||
char* errorName;
|
|
||||||
|
|
||||||
switch (errno) {
|
|
||||||
case EACCES:
|
|
||||||
errorName = "EACCESS";
|
|
||||||
break;
|
|
||||||
case ENOENT:
|
|
||||||
errorName = "ENOENT";
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
errorName = "not parsed";
|
|
||||||
}
|
|
||||||
|
|
||||||
std::cout << errorName << "\n";
|
|
||||||
}
|
|
||||||
|
|
||||||
file << contents << std::endl;
|
|
||||||
|
|
||||||
file.close();
|
|
||||||
}
|
|
||||||
|
|
||||||
std::string Tag::createFileContents() {
|
|
||||||
cJSON* root = cJSON_CreateObject();
|
|
||||||
cJSON* playlist;
|
|
||||||
|
|
||||||
cJSON_AddStringToObject(root, "tagId", this->tagId.c_str());
|
|
||||||
cJSON_AddNumberToObject(root, "currentIndex", this->currentFileIndex);
|
|
||||||
cJSON_AddNumberToObject(root, "currentPosition", this->currentFilePosition);
|
|
||||||
playlist = cJSON_AddArrayToObject(root, "playlist");
|
|
||||||
|
|
||||||
for (auto& link: this->playlist) {
|
|
||||||
cJSON_AddItemToArray(playlist, cJSON_CreateString(link.c_str()));
|
|
||||||
}
|
|
||||||
|
|
||||||
char* json = cJSON_Print(root);
|
|
||||||
cJSON_Delete(root); // playlist is also free'd
|
|
||||||
|
|
||||||
std::cout << json << std::endl;
|
|
||||||
|
|
||||||
return std::string(json);
|
|
||||||
}
|
|
||||||
|
|
||||||
std::string Tag::readFileContents() {
|
|
||||||
std::string filePath = getFilePath();
|
|
||||||
|
|
||||||
std::fstream file(filePath);
|
|
||||||
|
|
||||||
if (! file) {
|
|
||||||
std::cout << "Error opening file for tag " << tagId;
|
|
||||||
|
|
||||||
char* errorName;
|
|
||||||
|
|
||||||
switch (errno) {
|
|
||||||
case EACCES:
|
|
||||||
errorName = "EACCESS";
|
|
||||||
break;
|
|
||||||
case ENOENT:
|
|
||||||
errorName = "ENOENT";
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
errorName = "not parsed";
|
|
||||||
}
|
|
||||||
|
|
||||||
std::cout << errorName << "\n";
|
|
||||||
|
|
||||||
file.close();
|
|
||||||
throw std::invalid_argument(errorName);
|
|
||||||
}
|
|
||||||
|
|
||||||
std::string line;
|
|
||||||
std::string content;
|
|
||||||
|
|
||||||
while(getline(file, line)) {
|
|
||||||
content.append(line);
|
|
||||||
}
|
|
||||||
|
|
||||||
file.close();
|
|
||||||
|
|
||||||
return content;
|
|
||||||
}
|
|
||||||
|
|
||||||
std::string Tag::getFilePath() {
|
|
||||||
std::string filePath = BASE_PATH;
|
|
||||||
filePath.append(tagId);
|
|
||||||
|
|
||||||
return filePath;
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
#pragma once
|
|
||||||
|
|
||||||
#include <string>
|
|
||||||
#include <vector>
|
|
||||||
|
|
||||||
class Tag {
|
|
||||||
private:
|
|
||||||
std::string tagId{""};
|
|
||||||
std::vector<std::string> playlist;
|
|
||||||
uint32_t currentFileIndex{0};
|
|
||||||
uint32_t currentFilePosition{0};
|
|
||||||
|
|
||||||
public:
|
|
||||||
Tag();
|
|
||||||
Tag(std::string tagId);
|
|
||||||
Tag(char* tagId);
|
|
||||||
void read();
|
|
||||||
void write();
|
|
||||||
bool isLinked();
|
|
||||||
std::string getNextLink();
|
|
||||||
void setCurrentPosition(uint32_t fileIndex, uint32_t position);
|
|
||||||
int getCurrentPosition();
|
|
||||||
|
|
||||||
private:
|
|
||||||
std::string createFileContents();
|
|
||||||
std::string readFileContents();
|
|
||||||
std::string getFilePath();
|
|
||||||
};
|
|
||||||
@@ -1,41 +1,71 @@
|
|||||||
#include "TagPlayerControl.hpp"
|
#include "TagPlayerControl.hpp"
|
||||||
|
|
||||||
#include "AudioPlayer.hpp"
|
#include <esp_log.h>
|
||||||
#include "Tag.hpp"
|
|
||||||
|
|
||||||
#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();
|
||||||
storePosition();
|
lastUid.clear();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
lastUid = std::string(uid);
|
||||||
|
|
||||||
|
if(url != NULL && url[0] != '\0') {
|
||||||
|
ESP_LOGI(TAG, "Tag %s -> URL: %s", uid, url);
|
||||||
|
playUrl(std::string(url));
|
||||||
} else {
|
} else {
|
||||||
playTag(tagId);
|
ESP_LOGI(TAG, "Tag %s -> no NDEF, trying local mapping", uid);
|
||||||
|
playLocalTag(std::string(uid));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void TagPlayerControl::playTag(char* tagId) {
|
void TagPlayerControl::playUrl(const std::string& url) {
|
||||||
currentTag = Tag(tagId);
|
/* Check cache first; if found play local file to avoid re-downloading */
|
||||||
currentTag.read();
|
std::string cached = cacheManager->getCachedPath(url);
|
||||||
|
if(!cached.empty()) {
|
||||||
if (currentTag.isLinked()) {
|
audioPlayer->play(cached);
|
||||||
audioPlayer->play(currentTag.getNextLink(), currentTag.getCurrentPosition());
|
return;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void TagPlayerControl::storePosition() {
|
/* Stream via HTTP and cache in background for future plays */
|
||||||
currentTag.setCurrentPosition(0, audioPlayer->getPosition());
|
audioPlayer->play(url);
|
||||||
currentTag.write();
|
|
||||||
|
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)) {
|
||||||
|
content.append(line);
|
||||||
|
}
|
||||||
|
file.close();
|
||||||
|
|
||||||
|
if(content.empty()) {
|
||||||
|
ESP_LOGW(TAG, "Empty mapping file for tag %s", uid.c_str());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
audioPlayer->play(content);
|
||||||
}
|
}
|
||||||
@@ -1,19 +1,25 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
#include <memory>
|
#include <memory>
|
||||||
|
#include <string>
|
||||||
#include "AudioPlayer.hpp"
|
#include "AudioPlayer.hpp"
|
||||||
#include "Tag.hpp"
|
#include "CacheManager.hpp"
|
||||||
|
|
||||||
class TagPlayerControl {
|
class TagPlayerControl {
|
||||||
std::shared_ptr<AudioPlayer> audioPlayer;
|
std::shared_ptr<AudioPlayer> audioPlayer;
|
||||||
Tag currentTag;
|
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 storePosition();
|
void playLocalTag(const std::string& uid);
|
||||||
};
|
};
|
||||||
+223
-115
@@ -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);
|
|
||||||
}
|
}
|
||||||
@@ -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
@@ -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
@@ -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>🎧 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
@@ -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
@@ -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; }
|
||||||
Reference in new issue
Block a user