Compare commits

2 Commits
Author SHA1 Message Date
Simon Weidacher 648bbe53e6 Wip. 2022-01-03 23:33:23 +01:00
Simon Weidacher e104256985 Extracts handling the Tag-file structure into an own class. 2022-01-03 23:30:17 +01:00
48 changed files with 606 additions and 172729 deletions

No files matched your search

+7
View File
@@ -0,0 +1,7 @@
[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
+51 -110
View File
@@ -1,135 +1,76 @@
# Kidsbox # Kidsbox
A battery-powered, RFID/NFC-tag-controlled audio player for kids. This is a battery-powered rfid-tag-controlled audio player meant to be used by kids.
Place an NFC tag on the box — the mapped audio file or stream plays automatically. You can bundle audio-files to rfid tags and let your kids choose the music or audiobook of their favour by placing the corresponding tag atop of the box.
Tags are programmed from a smartphone using any NFC writing app (e.g. NFC Tools).
The box streams from the URL written to the tag, caches it locally for offline play,
and exposes a web interface for playback control and storage management.
|Front|Side| |Front|Side|
|-|-| |-|-|
|![Frontal View](docs/img/front.jpeg)|![Side View](docs/img/side.jpeg)| |![Frontal View](docs/img/front.jpeg)|![Side View](docs/img/side.jpeg)|
--- ## Todo
## Quick Start - Volume buttons
- Playlists
- Pausing
- ... I have a lot of ideas :)
### 1. Install the development environment ## Setup
```bash 1. Install ESP-ADF and ESP-IDF according to the [install guide](https://docs.espressif.com/projects/esp-adf/en/latest/get-started/index.html#installation-step-by-step).
./install.sh 2. Clone this repository and `cd` into the repository directory.
``` 3. `git submodule init --recursive`
3. `cd device`
This installs **ESP-IDF v5.2** and **ESP-ADF v2.7** into `~/esp/` and writes a `.env` file. 4. Make sure that you have `idf.py` available in your environment. This is described in the install guide.
Existing installations are detected automatically (via `$IDF_PATH` / `$ADF_PATH`). 5. Execute `idf.py build` to compile, `idf.py build flash monitor` to flash it to your board.
### 2. Configure and build
```bash
source .env && source $IDF_PATH/export.sh
./setup.sh
```
`setup.sh` will:
- Prompt for your WiFi SSID and password
- Build the firmware (`idf.py build`)
- Optionally flash to the device
- Optionally prepare an SD card (create directories, copy web UI)
---
## How It Works
1. Write an audio URL to an NFC tag with a smartphone app (e.g. **NFC Tools**).
2. Place the tag on the box → the box reads the URL, streams the audio, and caches it.
3. Subsequent plays of the same tag use the local cache (no internet needed).
4. Removing the tag pauses playback; replacing it resumes.
Without an NDEF URL on a tag, the box falls back to a local mapping file at
`/sdcard/internal/<tag-id>` (plain text, one URL or file path per file).
---
## Web Interface
Once connected to WiFi, open `http://<device-ip>` in a browser.
| Section | What it does |
|---|---|
| Now Playing | Track URI, play/pause/stop, volume slider |
| Device Info | IP address, playback status, cache size, free heap |
| Storage | List and delete cached files |
| Bluetooth | Toggle BT speaker mode (stub — in progress) |
---
## Enclosure
A parametric, fully 3D-printable enclosure lives in [`enclosure/`](enclosure/) —
a rectangular cabinet with two front speakers and a removable top where the NFC tag
rests, with **swappable speaker mounts** so you can print an adapter to fit your
drivers. See [`enclosure/README.md`](enclosure/README.md).
![Enclosure](enclosure/img/hero.png)
---
## Hardware ## Hardware
### Parts ### Parts
| Part | Links | _TBD_
|---|---|
| ESP32-LyraT 4.3 | [Mouser](https://www.mouser.de/ProductDetail/Espressif-Systems/ESP32-LyraT?qs=MLItCLRbWsxPzPCja546ZA%3D%3D) |
| RC522 RFID/NFC module | — |
| SD card (FAT32, any size) | — |
### DIP-Switch (LyraT 4.3) |Part|Links|
|-|-|
|ESP32-LyraT 4.3|[Mouser](https://www.mouser.de/ProductDetail/Espressif-Systems/ESP32-LyraT?qs=MLItCLRbWsxPzPCja546ZA%3D%3D)|
| Switch | Position | Purpose |
|---|---|---|
| 1 | OFF | Disconnects GPIO 12 from SD-D2 |
| 2 | OFF | Disconnects GPIO 13 from SD-D3 |
| 3 | OFF | Connects GPIO 15 to SD CMD |
| 4 | ON | Connects GPIO 13 to JTAG header |
| 5 | ON | Connects GPIO 12 to JTAG header |
| 6 | OFF | Connects GPIO 13 to SD CLK |
| 7 | OFF | Disconnects GPIO 12 from AUX-in detection |
| 8 | OFF | — |
### RC522 Wiring ### Hardware Setup
> MISO/MOSI pin names are **not** inverted between host and chip on this board. The peripherals must be configured for:
| RC522 Pin | LyraT v4.3 Pin | GPIO | - SD Card
|---|---|---| - RFID-module
| 3.3V | 3.3V | — | - Vol+/- buttons
| RST | 3.3V | — |
| GND | GND | — |
| SDA | I2C SDA | 18 |
| SCK | I2C SCL | 23 |
| MOSI | JTAG MTCK | 13 |
| MISO | JTAG MTDI | 12 |
### SD Card Layout The ESP32-LyraT board has only a few exposed GPIO pins. To save pins, the SD-Card reader runs only in 1-Wire mode. I have actually no idea what the limitations and differences are, but I didn't encounter any problems yet.
``` #### DIP-Switch
/sdcard/
www/ ← web interface files (copied by setup.sh)
internal/ ← tag-id → URL/path mapping files (manual)
cache/ ← downloaded audio files (managed automatically)
```
--- The switches must be exatly in the positions outlined in the table.
## Roadmap |Number|Setting|Purpose|
|-|-|-|
|1|OFF|Disconnects GPIO 12 from SD-D2|
|2|OFF|Disconnects GPIO 13 from SD-D3|
|3|OFF|Connects GPIO 15 to SD CMD|
|4|ON|Connects GPIO 13 to the JTAG header|
|5|ON|Connects GPIO 12 to the JTAG header|
|6|OFF|Connects GPIO 13 to SD CLK|
|7|OFF|Disconnects GPIO 12 from the AUX-in-detection|
|8|OFF| - |
- [x] RFID/NFC tag detection #### RFID
- [x] NDEF URL reading from NFC tags
- [x] HTTP audio streaming _The MISO/MOSI pins are not inverted on host/chip_
- [x] Local file cache
- [x] WiFi (native ESP-IDF, no Arduino dependency) Connection Table:
- [x] Web interface — playback controls, storage, device info
- [ ] Volume buttons (hardware) |RC522 Pin|ESP32 Lyrat v4.3 Pin|GPIO Number|
- [ ] Playlists |-|-|-|
- [ ] Bluetooth speaker mode (A2DP sink) |3.3v|3.3v|-|
|RST|3.3v|-|
|GND|GND|-|
|SDA|I2C SDA|18|
|SCK|I2C SCL|23|
|MOSI|JTAG MTCK|13|
|MISO|JTAG MTDI|12|
@@ -1,4 +0,0 @@
idf_component_register(
SRCS "rc522.c"
INCLUDE_DIRS "."
)
-21
View File
@@ -1,21 +0,0 @@
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.
@@ -1 +0,0 @@
COMPONENT_ADD_INCLUDEDIRS := .
-746
View File
@@ -1,746 +0,0 @@
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "esp_system.h"
#include "driver/spi_master.h"
#include "soc/gpio_struct.h"
#include "driver/gpio.h"
#include "esp_timer.h"
#include "esp_log.h"
#include "rc522.h"
static const char* TAG = "ESP-RC522";
struct rc522 {
bool running;
rc522_config_t* config;
spi_device_handle_t spi;
TaskHandle_t task_handle;
bool scan_started;
uint8_t* lastSerialNumber;
};
typedef struct rc522* rc522_handle_t;
static rc522_handle_t hndl = NULL;
#define rc522_fw_version() rc522_read(0x37)
bool rc522_is_inited() {
return hndl != NULL;
}
static esp_err_t rc522_spi_init() {
if(! hndl || ! hndl->config) {
ESP_LOGE(TAG, "Fail to init SPI. Invalid handle");
return ESP_ERR_INVALID_STATE;
}
if(hndl->spi) {
ESP_LOGW(TAG, "SPI already initialized");
return ESP_ERR_INVALID_STATE;
}
spi_bus_config_t buscfg = {
.miso_io_num = hndl->config->miso_io,
.mosi_io_num = hndl->config->mosi_io,
.sclk_io_num = hndl->config->sck_io,
.quadwp_io_num = -1,
.quadhd_io_num = -1
};
spi_device_interface_config_t devcfg = {
.clock_speed_hz = 5000000,
.mode = 0,
.spics_io_num = hndl->config->sda_io,
.queue_size = 7,
.flags = SPI_DEVICE_HALFDUPLEX
};
esp_err_t err = spi_bus_initialize(hndl->config->spi_host_id, &buscfg, 0);
if(err != ESP_OK) {
return err;
}
err = spi_bus_add_device(hndl->config->spi_host_id, &devcfg, &hndl->spi);
if(err != ESP_OK) {
spi_bus_free(hndl->config->spi_host_id);
hndl->spi = NULL;
}
return err;
}
static esp_err_t rc522_write_n(uint8_t addr, uint8_t n, uint8_t *data) {
uint8_t* buffer = (uint8_t*) malloc(n + 1);
buffer[0] = (addr << 1) & 0x7E;
for (uint8_t i = 1; i <= n; i++) {
buffer[i] = data[i-1];
}
spi_transaction_t t;
memset(&t, 0, sizeof(t));
t.length = 8 * (n + 1);
t.tx_buffer = buffer;
esp_err_t ret = spi_device_transmit(hndl->spi, &t);
free(buffer);
return ret;
}
static esp_err_t rc522_write(uint8_t addr, uint8_t val) {
return rc522_write_n(addr, 1, &val);
}
static uint8_t* rc522_read_n(uint8_t addr, uint8_t n) {
if (n <= 0) {
return NULL;
}
spi_transaction_t t;
memset(&t, 0, sizeof(t));
uint8_t* buffer = (uint8_t*) malloc(n);
t.flags = SPI_TRANS_USE_TXDATA;
t.length = 8;
t.tx_data[0] = ((addr << 1) & 0x7E) | 0x80;
t.rxlength = 8 * n;
t.rx_buffer = buffer;
esp_err_t ret = spi_device_transmit(hndl->spi, &t);
assert(ret == ESP_OK);
return buffer;
}
static uint8_t rc522_read(uint8_t addr) {
uint8_t* buffer = rc522_read_n(addr, 1);
uint8_t res = buffer[0];
free(buffer);
return res;
}
static esp_err_t rc522_set_bitmask(uint8_t addr, uint8_t mask) {
return rc522_write(addr, rc522_read(addr) | mask);
}
static esp_err_t rc522_clear_bitmask(uint8_t addr, uint8_t mask) {
return rc522_write(addr, rc522_read(addr) & ~mask);
}
static esp_err_t rc522_antenna_on() {
esp_err_t ret;
if(~ (rc522_read(0x14) & 0x03)) {
ret = rc522_set_bitmask(0x14, 0x03);
if(ret != ESP_OK) {
return ret;
}
}
return rc522_write(0x26, 0x60); // 43dB gain
}
static void rc522_task(void* arg);
esp_err_t rc522_init(rc522_config_t* config) {
if(! config) {
return ESP_ERR_INVALID_ARG;
}
if(hndl) {
ESP_LOGW(TAG, "Already initialized");
return ESP_ERR_INVALID_STATE;
}
if(! (hndl = calloc(1, sizeof(struct rc522)))) {
return ESP_ERR_NO_MEM;
}
if(! (hndl->config = calloc(1, sizeof(rc522_config_t)))) {
rc522_destroy();
return ESP_ERR_NO_MEM;
}
// copy config considering defaults
hndl->config->callback = config->callback;
hndl->config->ndef_callback = config->ndef_callback;
hndl->config->miso_io = config->miso_io == 0 ? RC522_DEFAULT_MISO : config->miso_io;
hndl->config->mosi_io = config->mosi_io == 0 ? RC522_DEFAULT_MOSI : config->mosi_io;
hndl->config->sck_io = config->sck_io == 0 ? RC522_DEFAULT_SCK : config->sck_io;
hndl->config->sda_io = config->sda_io == 0 ? RC522_DEFAULT_SDA : config->sda_io;
hndl->config->spi_host_id = config->spi_host_id == 0 ? RC522_DEFAULT_SPI_HOST : config->spi_host_id;
hndl->config->scan_interval_ms = config->scan_interval_ms < 50 ? RC522_DEFAULT_SCAN_INTERVAL_MS : config->scan_interval_ms;
hndl->config->task_stack_size = config->task_stack_size == 0 ? RC522_DEFAULT_TACK_STACK_SIZE : config->task_stack_size;
hndl->config->task_priority = config->task_priority == 0 ? RC522_DEFAULT_TACK_STACK_PRIORITY : config->task_priority;
esp_err_t err = rc522_spi_init();
if(err != ESP_OK) {
rc522_destroy();
return err;
}
// ---------- RW test ------------
const uint8_t test_addr = 0x24, test_val = 0x25;
for(uint8_t i = test_val; i < test_val + 2; i++) {
if((err = rc522_write(test_addr, i)) != ESP_OK || rc522_read(test_addr) != i) {
ESP_LOGE(TAG, "RW test fail");
rc522_destroy();
return err;
}
}
// ------- End of RW test --------
rc522_write(0x01, 0x0F);
rc522_write(0x2A, 0x8D);
rc522_write(0x2B, 0x3E);
rc522_write(0x2D, 0x1E);
rc522_write(0x2C, 0x00);
rc522_write(0x15, 0x40);
rc522_write(0x11, 0x3D);
rc522_antenna_on();
hndl->running = true;
if (xTaskCreate(rc522_task, "rc522_task", hndl->config->task_stack_size, NULL, hndl->config->task_priority, &hndl->task_handle) != pdTRUE) {
ESP_LOGE(TAG, "Fail to create rc522 task");
rc522_destroy();
return err;
}
if(err != ESP_OK) {
ESP_LOGE(TAG, "Fail to create timer");
rc522_destroy();
return err;
}
ESP_LOGI(TAG, "Initialized (firmware: 0x%x)", rc522_fw_version());
return ESP_OK;
}
uint64_t rc522_sn_to_u64(uint8_t* sn) {
if(!sn) {
return 0;
}
uint64_t result = 0;
for(int i = 4; i >= 0; i--) {
result |= ((uint64_t) sn[i] << (i * 8));
}
return result;
}
/* Returns pointer to dynamically allocated array of two element */
static uint8_t* rc522_calculate_crc(uint8_t *data, uint8_t n) {
rc522_clear_bitmask(0x05, 0x04);
rc522_set_bitmask(0x0A, 0x80);
rc522_write_n(0x09, n, data);
rc522_write(0x01, 0x03);
uint8_t i = 255;
uint8_t nn = 0;
for(;;) {
nn = rc522_read(0x05);
i--;
if(! (i != 0 && ! (nn & 0x04))) {
break;
}
}
uint8_t* res = (uint8_t*) malloc(2);
res[0] = rc522_read(0x22);
res[1] = rc522_read(0x21);
return res;
}
static uint8_t* rc522_card_write(uint8_t cmd, uint8_t *data, uint8_t n, uint8_t* res_n) {
uint8_t *result = NULL;
uint8_t irq = 0x00;
uint8_t irq_wait = 0x00;
uint8_t last_bits = 0;
uint8_t nn = 0;
if(cmd == 0x0E) {
irq = 0x12;
irq_wait = 0x10;
}
else if(cmd == 0x0C) {
irq = 0x77;
irq_wait = 0x30;
}
rc522_write(0x02, irq | 0x80);
rc522_clear_bitmask(0x04, 0x80);
rc522_set_bitmask(0x0A, 0x80);
rc522_write(0x01, 0x00);
rc522_write_n(0x09, n, data);
rc522_write(0x01, cmd);
if(cmd == 0x0C) {
rc522_set_bitmask(0x0D, 0x80);
}
uint16_t i = 1000;
for(;;) {
nn = rc522_read(0x04);
i--;
if(! (i != 0 && (((nn & 0x01) == 0) && ((nn & irq_wait) == 0)))) {
break;
}
}
rc522_clear_bitmask(0x0D, 0x80);
if(i != 0) {
if((rc522_read(0x06) & 0x1B) == 0x00) {
if(cmd == 0x0C) {
nn = rc522_read(0x0A);
last_bits = rc522_read(0x0C) & 0x07;
if (last_bits != 0) {
*res_n = (nn - 1) + last_bits;
} else {
*res_n = nn;
}
result = (uint8_t*) malloc(*res_n);
for(i = 0; i < *res_n; i++) {
result[i] = rc522_read(0x09);
}
}
}
}
return result;
}
static uint8_t* rc522_request(uint8_t* res_n) {
uint8_t* result = NULL;
rc522_write(0x0D, 0x07);
uint8_t req_mode = 0x26;
result = rc522_card_write(0x0C, &req_mode, 1, res_n);
if(*res_n * 8 != 0x10) {
free(result);
return NULL;
}
return result;
}
static uint8_t* rc522_anticoll() {
uint8_t res_n;
rc522_write(0x0D, 0x00);
uint8_t* result = rc522_card_write(0x0C, (uint8_t[]) { 0x93, 0x20 }, 2, &res_n);
if(result && res_n != 5) { // all cards/tags serial numbers is 5 bytes long (?)
free(result);
return NULL;
}
return result;
}
/* Send HALT and clear crypto bit. Card will only respond to WUPA after this. */
static void rc522_halt_card() {
uint8_t buf[] = { 0x50, 0x00, 0x00, 0x00 };
uint8_t* crc = rc522_calculate_crc(buf, 2);
buf[2] = crc[0];
buf[3] = crc[1];
free(crc);
uint8_t res_n;
uint8_t* res = rc522_card_write(0x0C, buf, 4, &res_n);
free(res);
rc522_clear_bitmask(0x08, 0x08);
}
static uint8_t* rc522_get_tag() {
uint8_t* result = NULL;
uint8_t* res_data = NULL;
uint8_t res_data_n;
res_data = rc522_request(&res_data_n);
if(res_data != NULL) {
free(res_data);
result = rc522_anticoll();
/* NOTE: card is NOT halted here — caller must call rc522_halt_card() */
}
return result;
}
/* URI prefix table for NDEF URI records (RFC 3986 shortened forms) */
static const char* const RC522_URI_PREFIXES[] = {
"", /* 0x00 no prefix */
"http://www.", /* 0x01 */
"https://www.", /* 0x02 */
"http://", /* 0x03 */
"https://", /* 0x04 */
"tel:", /* 0x05 */
"mailto:", /* 0x06 */
"ftp://anonymous:anonymous@", /* 0x07 */
"ftp://ftp.", /* 0x08 */
"ftps://", /* 0x09 */
"sftp://", /* 0x0A */
"smb://", /* 0x0B */
"nfs://", /* 0x0C */
"ftp://", /* 0x0D */
"dav://", /* 0x0E */
"news:", /* 0x0F */
"telnet://", /* 0x10 */
"imap:", /* 0x11 */
"rtsp://", /* 0x12 */
"urn:", /* 0x13 */
"pop:", /* 0x14 */
"sip:", /* 0x15 */
"sips:", /* 0x16 */
"tftp:", /* 0x17 */
"btspp://", /* 0x18 */
"btl2cap://", /* 0x19 */
"btgoep://", /* 0x1A */
"tcpobex://", /* 0x1B */
"irdaobex://", /* 0x1C */
"file://", /* 0x1D */
"urn:epc:id:", /* 0x1E */
"urn:epc:tag:", /* 0x1F */
"urn:epc:pat:", /* 0x20 */
"urn:epc:raw:", /* 0x21 */
"urn:epc:", /* 0x22 */
"urn:nfc:", /* 0x23 */
};
#define RC522_URI_PREFIX_COUNT (sizeof(RC522_URI_PREFIXES) / sizeof(RC522_URI_PREFIXES[0]))
/* Parse an NDEF message TLV block and extract the first URI record URL.
* Returns heap-allocated null-terminated URL string, or NULL if not found. */
static char* rc522_parse_ndef_url(const uint8_t* data, int len) {
int i = 0;
while(i < len) {
uint8_t tlv_type = data[i++];
if(tlv_type == 0xFE) break; /* Terminator TLV */
if(tlv_type == 0x00) continue; /* Null TLV — no length byte */
if(i >= len) break;
/* Read TLV length (1 or 3 bytes) */
uint16_t tlv_len;
if(data[i] == 0xFF) {
i++;
if(i + 1 >= len) break;
tlv_len = ((uint16_t)data[i] << 8) | data[i + 1];
i += 2;
} else {
tlv_len = data[i++];
}
if(tlv_type != 0x03 || tlv_len == 0 || i + (int)tlv_len > len) {
i += tlv_len;
continue;
}
/* Parse NDEF record header */
uint8_t header = data[i++];
uint8_t tnf = header & 0x07;
bool sr = (header & 0x10) != 0; /* Short Record */
bool il = (header & 0x08) != 0; /* ID Length present */
if(i >= len) break;
uint8_t type_len = data[i++];
uint32_t payload_len;
if(sr) {
if(i >= len) break;
payload_len = data[i++];
} else {
if(i + 3 >= len) break;
payload_len = ((uint32_t)data[i] << 24) | ((uint32_t)data[i+1] << 16)
| ((uint32_t)data[i+2] << 8) | data[i+3];
i += 4;
}
uint8_t id_len = 0;
if(il) {
if(i >= len) break;
id_len = data[i++];
}
/* Only handle Well-Known URI records (TNF=0x01, Type='U') */
if(tnf == 0x01 && type_len == 1 && i < len && data[i] == 0x55) {
i++; /* skip type byte 'U' */
i += id_len; /* skip optional ID */
if(payload_len > 0 && i < len) {
uint8_t prefix_code = data[i++];
payload_len--;
const char* prefix = (prefix_code < RC522_URI_PREFIX_COUNT) ? RC522_URI_PREFIXES[prefix_code] : "";
size_t prefix_len = strlen(prefix);
size_t body_len = (payload_len < (uint32_t)(len - i)) ? payload_len : (uint32_t)(len - i);
if(prefix_len + body_len > 255) body_len = 255 - prefix_len;
char* url = (char*) malloc(prefix_len + body_len + 1);
if(!url) return NULL;
memcpy(url, prefix, prefix_len);
memcpy(url + prefix_len, data + i, body_len);
url[prefix_len + body_len] = '\0';
return url;
}
}
/* Skip over this record */
i += type_len + id_len + (int)payload_len;
}
return NULL;
}
/* Wake a halted card with WUPA (0x52), perform cascade anticollision (CL1 + optional CL2),
* SELECT the card, read pages 4–15 of user memory (NDEF area), parse, then halt.
* Returns heap-allocated URL string or NULL. Card is always left in HALT state on return. */
static char* rc522_read_ndef_url() {
uint8_t res_n;
/* WUPA: wake halted card — identical bit-framing to REQA but byte is 0x52 */
rc522_write(0x0D, 0x07);
uint8_t wupa_cmd = 0x52;
uint8_t* atqa = rc522_card_write(0x0C, &wupa_cmd, 1, &res_n);
if(!atqa || res_n * 8 != 0x10) {
free(atqa);
return NULL;
}
free(atqa);
/* CL1 anti-collision */
rc522_write(0x0D, 0x00);
uint8_t* cl1 = rc522_card_write(0x0C, (uint8_t[]){ 0x93, 0x20 }, 2, &res_n);
if(!cl1 || res_n != 5) {
free(cl1);
rc522_halt_card();
return NULL;
}
bool cascade = (cl1[0] == 0x88); /* cascade tag byte indicates 7-byte UID */
/* CL1 SELECT */
uint8_t sel1[9] = { 0x93, 0x70, cl1[0], cl1[1], cl1[2], cl1[3], cl1[4] };
uint8_t* crc = rc522_calculate_crc(sel1, 7);
sel1[7] = crc[0]; sel1[8] = crc[1]; free(crc);
uint8_t sel_n;
uint8_t* sak1 = rc522_card_write(0x0C, sel1, 9, &sel_n);
free(cl1);
if(!sak1) {
rc522_halt_card();
return NULL;
}
free(sak1);
if(cascade) {
/* CL2 anti-collision */
rc522_write(0x0D, 0x00);
uint8_t* cl2 = rc522_card_write(0x0C, (uint8_t[]){ 0x95, 0x20 }, 2, &res_n);
if(!cl2 || res_n != 5) {
free(cl2);
rc522_halt_card();
return NULL;
}
/* CL2 SELECT */
uint8_t sel2[9] = { 0x95, 0x70, cl2[0], cl2[1], cl2[2], cl2[3], cl2[4] };
crc = rc522_calculate_crc(sel2, 7);
sel2[7] = crc[0]; sel2[8] = crc[1]; free(crc);
uint8_t* sak2 = rc522_card_write(0x0C, sel2, 9, &sel_n);
free(cl2);
if(!sak2) {
rc522_halt_card();
return NULL;
}
free(sak2);
}
/* Card is now SELECTED. Verify NDEF capability container at page 3.
* For NTAG/MIFARE Ultralight the NDEF magic byte is 0xE1 at CC[0]. */
uint8_t read_cc[4] = { 0x30, 0x03 };
crc = rc522_calculate_crc(read_cc, 2);
read_cc[2] = crc[0]; read_cc[3] = crc[1]; free(crc);
uint8_t* cc_data = rc522_card_write(0x0C, read_cc, 4, &res_n);
if(!cc_data || res_n < 4 || cc_data[0] != 0xE1) {
free(cc_data);
rc522_halt_card();
return NULL;
}
free(cc_data);
/* Read NDEF user pages 4–15 (12 pages × 4 bytes = 48 bytes).
* READ command (0x30) returns 16 bytes (4 pages) per call. */
uint8_t ndef_buf[48];
int ndef_len = 0;
for(uint8_t page = 4; page <= 12; page += 4) { /* 3 reads × 16 bytes = 48 bytes */
uint8_t read_cmd[4] = { 0x30, page };
crc = rc522_calculate_crc(read_cmd, 2);
read_cmd[2] = crc[0]; read_cmd[3] = crc[1]; free(crc);
uint8_t* page_data = rc522_card_write(0x0C, read_cmd, 4, &res_n);
if(!page_data || res_n < 16) {
free(page_data);
break;
}
int copy = (48 - ndef_len < 16) ? 48 - ndef_len : 16;
memcpy(ndef_buf + ndef_len, page_data, copy);
ndef_len += copy;
free(page_data);
}
rc522_halt_card();
if(ndef_len == 0) return NULL;
return rc522_parse_ndef_url(ndef_buf, ndef_len);
}
esp_err_t rc522_start(rc522_start_args_t start_args) {
esp_err_t err = rc522_init(&start_args);
return err != ESP_OK ? err : rc522_start2();
}
esp_err_t rc522_start2() {
if(! hndl) { return ESP_ERR_INVALID_STATE; }
hndl->scan_started = true;
return ESP_OK;
}
esp_err_t rc522_pause() {
if(! hndl) { return ESP_ERR_INVALID_STATE; }
if(! hndl->scan_started) {
return ESP_OK;
}
hndl->scan_started = false;
return ESP_OK;
}
void rc522_destroy() {
if(! hndl) { return; }
rc522_pause(); // stop timer
hndl->running = false; // task will delete itself
if(hndl->spi) {
spi_bus_remove_device(hndl->spi);
spi_bus_free(hndl->config->spi_host_id);
hndl->spi = NULL;
}
free(hndl->config);
hndl->config = NULL;
free(hndl);
hndl = NULL;
}
static bool isSameSerialNumber(uint8_t* one, uint8_t* other) {
if (one == NULL && other == NULL) {
return true;
}
if (one == NULL || other == NULL) {
return false;
}
return one[0] == other[0]
&& one[1] == other[1]
&& one[2] == other[2]
&& one[3] == other[3]
&& one[4] == other[4];
}
static void rc522_task(void* arg) {
while(hndl->running) {
if(!hndl->scan_started) {
vTaskDelay(100 / portTICK_PERIOD_MS);
continue;
}
uint8_t* serial_no = rc522_get_tag(); /* card NOT halted yet */
if(!isSameSerialNumber(serial_no, hndl->lastSerialNumber)) {
rc522_tag_callback_t cb = hndl->config->callback;
rc522_ndef_callback_t ndef_cb = hndl->config->ndef_callback;
if(serial_no == NULL) {
free(hndl->lastSerialNumber);
hndl->lastSerialNumber = NULL;
if(cb) cb(NULL);
if(ndef_cb) ndef_cb(NULL, NULL);
} else {
if(!hndl->lastSerialNumber) {
hndl->lastSerialNumber = malloc(5);
}
memcpy(hndl->lastSerialNumber, serial_no, 5);
if(cb) cb(serial_no);
if(ndef_cb) {
/* rc522_read_ndef_url() handles HALT internally after reading */
char* url = rc522_read_ndef_url();
ndef_cb(serial_no, url);
if(url) free(url);
} else {
rc522_halt_card();
}
}
} else if(serial_no != NULL) {
/* Same card still present — halt it again to prevent continuous REQA responses */
rc522_halt_card();
}
int delay_ms = hndl->config->scan_interval_ms;
if(serial_no != NULL) {
free(serial_no);
serial_no = NULL;
delay_ms *= 2;
}
vTaskDelay(delay_ms / portTICK_PERIOD_MS);
}
vTaskDelete(NULL);
}
-98
View File
@@ -1,98 +0,0 @@
#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
+17 -44
View File
@@ -1,40 +1,24 @@
#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 <esp_log.h> #include <string>
static const char* TAG = "AudioPlayer";
AudioPlayer::AudioPlayer() { AudioPlayer::AudioPlayer() {
playerHandle = setup_player(NULL, NULL); playerHandle = setup_player(NULL, NULL);
esp_player_init((esp_audio_handle_t)playerHandle); esp_player_init(playerHandle);
esp_audio_vol_set((esp_audio_handle_t)playerHandle, 50);
esp_audio_vol_set(playerHandle, 50);
} }
bool AudioPlayer::isHttpUri(const std::string& uri) const { void AudioPlayer::play(std::string uri, int position = 0) {
return uri.rfind("http://", 0) == 0 || uri.rfind("https://", 0) == 0; if (isLastUri(uri)) {
}
bool AudioPlayer::isLastUri(const std::string& uri) const {
return lastUri == uri;
}
void AudioPlayer::play(const std::string& uri) {
if(isLastUri(uri)) {
esp_player_music_resume(); esp_player_music_resume();
return;
}
esp_player_music_stop();
lastUri = uri;
if(isHttpUri(uri)) {
ESP_LOGI(TAG, "Streaming: %s", uri.c_str());
esp_player_http_music_play(uri.c_str(), 0);
} else { } else {
ESP_LOGI(TAG, "Playing local: %s", uri.c_str()); esp_player_music_stop();
esp_player_sdcard_music_play(uri.c_str(), 0); esp_player_sdcard_music_play(uri.c_str(), position);
lastUri = std::string(uri);
} }
} }
@@ -42,25 +26,14 @@ void AudioPlayer::pause() {
esp_player_music_pause(); esp_player_music_pause();
} }
void AudioPlayer::stop() { bool AudioPlayer::isLastUri(std::string uri) {
esp_player_music_stop(); return lastUri == uri;
lastUri.clear();
} }
int AudioPlayer::getVolume() { int AudioPlayer::getPosition() {
int vol = 0; int position;
esp_player_vol_get(&vol);
return vol;
}
void AudioPlayer::setVolume(int vol) { esp_player_pos_get(&position);
if(vol < 0) vol = 0;
if(vol > 100) vol = 100;
esp_audio_vol_set((esp_audio_handle_t)playerHandle, vol);
}
bool AudioPlayer::isPlaying() const { return position;
esp_audio_state_t state; }
if(esp_player_state_get(&state) != ESP_OK) return false;
return state.status == AUDIO_STATUS_RUNNING;
}
+10 -21
View File
@@ -1,29 +1,18 @@
#pragma once #pragma once
#include <string> #include <string>
#include <esp_player_wrapper.h>
class AudioPlayer { class AudioPlayer {
void* playerHandle; void* playerHandle;
std::string lastUri; std::string lastUri;
public: public:
AudioPlayer(); AudioPlayer();
/* Play URI. http:// / https:// URIs stream via HTTP; anything else plays from SD card. void play(std::string uri, int position);
* Re-placing the same URI resumes rather than restarting. */ void pause();
void play(const std::string& uri); int getPosition();
void pause(); private:
void stop(); bool isLastUri(std::string uri);
};
int getVolume();
void setVolume(int vol);
bool isPlaying() const;
const std::string& getCurrentUri() const { return lastUri; }
private:
bool isHttpUri(const std::string& uri) const;
bool isLastUri(const std::string& uri) const;
};
-25
View File
@@ -1,25 +0,0 @@
#include "BluetoothInterface.hpp"
#include <esp_log.h>
static const char* TAG = "Bluetooth";
BluetoothInterface::BluetoothInterface() {
ESP_LOGI(TAG, "BT interface created (stub — not yet enabled)");
}
bool BluetoothInterface::setEnabled(bool on) {
if(on == enabled) return enabled;
if(on) {
/* TODO: initialize Bluedroid stack, register A2DP sink callbacks,
* switch audio pipeline output from I2S codec to BT stream.
* Requires CONFIG_BT_ENABLED=y and esp_bt / esp_a2dp components. */
ESP_LOGW(TAG, "BT enable requested — not yet implemented");
enabled = false; /* keep false until implemented */
} else {
ESP_LOGI(TAG, "BT disable requested");
enabled = false;
}
return enabled;
}
-19
View File
@@ -1,19 +0,0 @@
#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;
};
+2 -12
View File
@@ -1,12 +1,2 @@
idf_component_register(SRCS idf_component_register(SRCS "Networking.cpp" "main.cpp" "WebInterface.cpp" "RFIDInterface.cpp" "audio_setup.c" "AudioPlayer.cpp" "TagPlayerControl.cpp" "Tag.cpp"
"main.cpp" INCLUDE_DIRS ".")
"Networking.cpp"
"WebInterface.cpp"
"RFIDInterface.cpp"
"AudioPlayer.cpp"
"TagPlayerControl.cpp"
"CacheManager.cpp"
"BluetoothInterface.cpp"
"audio_setup.c"
INCLUDE_DIRS "."
REQUIRES esp_http_client esp_netif esp_wifi nvs_flash)
-205
View File
@@ -1,205 +0,0 @@
#include "CacheManager.hpp"
#include <esp_log.h>
#include <esp_http_client.h>
#include <freertos/FreeRTOS.h>
#include <freertos/task.h>
#include <sys/stat.h>
#include <dirent.h>
#include <stdio.h>
#include <string.h>
#include <errno.h>
#include <string>
#include <functional>
static const char* TAG = "CacheManager";
struct DownloadArgs {
std::string url;
std::function<void(bool)> onComplete;
};
CacheManager::CacheManager() {
mkdir(CACHE_DIR, 0777);
}
/* djb2 hash of the URL, returned as 8-char hex string */
std::string CacheManager::urlToKey(const std::string& url) const {
uint32_t hash = 5381;
for(char c : url) {
hash = ((hash << 5) + hash) ^ (uint8_t)c;
}
char buf[9];
snprintf(buf, sizeof(buf), "%08lx", (unsigned long)hash);
return std::string(buf);
}
std::string CacheManager::urlExtension(const std::string& url) const {
/* Strip query/fragment before looking for extension */
size_t qs = url.find('?');
std::string path = (qs != std::string::npos) ? url.substr(0, qs) : url;
size_t dot = path.rfind('.');
size_t slash = path.rfind('/');
if(dot != std::string::npos && (slash == std::string::npos || dot > slash)) {
std::string ext = path.substr(dot); /* includes the dot */
if(ext.size() <= 5) return ext; /* ignore suspiciously long "extensions" */
}
return ".mp3"; /* sensible default */
}
std::string CacheManager::keyToPath(const std::string& key, const std::string& ext) const {
return std::string(CACHE_DIR) + "/" + key + ext;
}
/* Find any file in CACHE_DIR whose name starts with key */
std::string CacheManager::findFileByKey(const std::string& key) const {
DIR* dir = opendir(CACHE_DIR);
if(!dir) return "";
struct dirent* entry;
while((entry = readdir(dir)) != NULL) {
std::string name(entry->d_name);
if(name.rfind(key, 0) == 0) {
closedir(dir);
return std::string(CACHE_DIR) + "/" + name;
}
}
closedir(dir);
return "";
}
std::string CacheManager::getCachedPath(const std::string& url) const {
if(url.empty()) return "";
std::string key = urlToKey(url);
std::string path = findFileByKey(key);
if(!path.empty()) {
ESP_LOGI(TAG, "Cache hit: %s -> %s", url.c_str(), path.c_str());
}
return path;
}
void CacheManager::downloadTask(void* arg) {
auto* dl = static_cast<DownloadArgs*>(arg);
ESP_LOGI(TAG, "Downloading: %s", dl->url.c_str());
CacheManager cm;
std::string key = cm.urlToKey(dl->url);
std::string ext = cm.urlExtension(dl->url);
std::string path = cm.keyToPath(key, ext);
esp_http_client_config_t cfg = {};
cfg.url = dl->url.c_str();
cfg.timeout_ms = 30000;
cfg.buffer_size = 4096;
esp_http_client_handle_t client = esp_http_client_init(&cfg);
esp_err_t err = esp_http_client_open(client, 0);
bool success = false;
if(err == ESP_OK) {
int content_len = esp_http_client_fetch_headers(client);
(void)content_len;
FILE* f = fopen(path.c_str(), "wb");
if(f) {
char buf[4096];
int read_len;
while((read_len = esp_http_client_read(client, buf, sizeof(buf))) > 0) {
fwrite(buf, 1, read_len, f);
}
fclose(f);
success = (read_len == 0); /* 0 = clean EOF */
if(!success) {
remove(path.c_str());
ESP_LOGE(TAG, "Download error, partial file deleted");
} else {
ESP_LOGI(TAG, "Cached to %s", path.c_str());
}
} else {
ESP_LOGE(TAG, "Cannot open cache file: %s (errno %d)", path.c_str(), errno);
}
} else {
ESP_LOGE(TAG, "HTTP open failed: %s", esp_err_to_name(err));
}
esp_http_client_cleanup(client);
if(dl->onComplete) dl->onComplete(success);
delete dl;
vTaskDelete(NULL);
}
void CacheManager::downloadAsync(const std::string& url, std::function<void(bool)> onComplete) {
if(url.empty()) return;
auto* args = new DownloadArgs{ url, onComplete };
xTaskCreate(downloadTask, "cache_dl", 8192, args, 3, NULL);
}
std::vector<CacheEntry> CacheManager::listEntries() const {
std::vector<CacheEntry> entries;
DIR* dir = opendir(CACHE_DIR);
if(!dir) return entries;
struct dirent* entry;
while((entry = readdir(dir)) != NULL) {
if(entry->d_name[0] == '.') continue;
std::string name(entry->d_name);
std::string full = std::string(CACHE_DIR) + "/" + name;
struct stat st;
size_t size = 0;
if(stat(full.c_str(), &st) == 0) size = st.st_size;
/* Key is filename without extension */
size_t dot = name.rfind('.');
std::string key = (dot != std::string::npos) ? name.substr(0, dot) : name;
entries.push_back({ key, /*url unknown*/"", full, size });
}
closedir(dir);
return entries;
}
bool CacheManager::deleteEntry(const std::string& key) {
std::string path = findFileByKey(key);
if(path.empty()) return false;
return remove(path.c_str()) == 0;
}
void CacheManager::deleteAll() {
DIR* dir = opendir(CACHE_DIR);
if(!dir) return;
struct dirent* entry;
while((entry = readdir(dir)) != NULL) {
if(entry->d_name[0] == '.') continue;
std::string full = std::string(CACHE_DIR) + "/" + entry->d_name;
remove(full.c_str());
}
closedir(dir);
ESP_LOGI(TAG, "Cache cleared");
}
size_t CacheManager::totalBytes() const {
size_t total = 0;
DIR* dir = opendir(CACHE_DIR);
if(!dir) return 0;
struct dirent* entry;
while((entry = readdir(dir)) != NULL) {
if(entry->d_name[0] == '.') continue;
std::string full = std::string(CACHE_DIR) + "/" + entry->d_name;
struct stat st;
if(stat(full.c_str(), &st) == 0) total += st.st_size;
}
closedir(dir);
return total;
}
-46
View File
@@ -1,46 +0,0 @@
#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);
};
+9 -83
View File
@@ -1,89 +1,15 @@
#include "Networking.hpp" #include "Networking.hpp"
#include <esp_log.h> #include "WiFi.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() {
/* NVS is required by the WiFi driver */ WiFi.begin(CONFIG_ESP_WIFI_SSID, CONFIG_ESP_WIFI_PASSWORD);
esp_err_t err = nvs_flash_init();
if(err == ESP_ERR_NVS_NO_FREE_PAGES || err == ESP_ERR_NVS_NEW_VERSION_FOUND) { while (WiFi.status() != WL_CONNECTED) {
nvs_flash_erase(); delay(1000);
nvs_flash_init(); Serial.println("Waiting for connection...");
} }
err = esp_netif_init(); Serial.println("Connected.");
if(err != ESP_OK && err != ESP_ERR_INVALID_STATE) { Serial.println(WiFi.localIP());
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;
}
+6 -7
View File
@@ -1,10 +1,9 @@
#pragma once #ifndef _NETWORKING_HPP
#define _NETWORKING_HPP
class Networking { class Networking {
public: public:
/* Connects to the WiFi configured in Kconfig (CONFIG_ESP_WIFI_SSID / _PASSWORD). void connectWifi();
* Blocks up to 30 s, then returns regardless of connection state. */
void connectWifi();
bool isConnected() const;
}; };
#endif
+29 -55
View File
@@ -10,81 +10,55 @@
static const char* TAG = "rfid"; static const char* TAG = "rfid";
static char lastTagId[26]; static char* lastTagId = (char*)malloc(26 * sizeof(char));
static std::function<void(char*)> legacyCallback; static std::function<void(char*)> callback;
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,
.spi_host_id = (spi_host_device_t)0, .callback = [](uint8_t* sn) {
.callback = onUidCallback, if (sn == NULL) {
.ndef_callback = onNdefCallback, ESP_LOGI(TAG, "Tag removed");
};
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());
}
}
}
};
} }
void RFIDInterface::start() { void RFIDInterface::start() {
rc522_start(startConfig); rc522_start(startConfig);
} }
void RFIDInterface::registerWebResources(WebInterface* interface) { void RFIDInterface::registerWebResources(WebInterface* interface) {
httpd_uri_t config = { httpd_uri_t config = {
.uri = "/rfid", .uri = "/rfid",
.method = HTTP_GET, .method = HTTP_GET,
.handler = [](httpd_req_t* req) { .handler = [](httpd_req_t *req) {
httpd_resp_send(req, getLastTagId(), HTTPD_RESP_USE_STRLEN); httpd_resp_send(req, getLastTagId(), HTTPD_RESP_USE_STRLEN);
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) {
legacyCallback = cb; callback = cb;
} }
+7 -15
View File
@@ -6,21 +6,13 @@
#include "WebInterface.hpp" #include "WebInterface.hpp"
class RFIDInterface { class RFIDInterface {
rc522_start_args_t startConfig; rc522_start_args_t startConfig;
public: public:
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
+199
View File
@@ -0,0 +1,199 @@
#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;
}
+28
View File
@@ -0,0 +1,28 @@
#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();
};
+28 -58
View File
@@ -1,71 +1,41 @@
#include "TagPlayerControl.hpp" #include "TagPlayerControl.hpp"
#include <esp_log.h> #include "AudioPlayer.hpp"
#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)
std::shared_ptr<CacheManager> cache) : audioPlayer(player) {
: audioPlayer(player), cacheManager(cache) {}
void TagPlayerControl::onTagChanged(const char* uid, const char* url) {
if(uid == NULL) {
ESP_LOGI(TAG, "Tag removed — pausing");
audioPlayer->pause();
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 {
ESP_LOGI(TAG, "Tag %s -> no NDEF, trying local mapping", uid);
playLocalTag(std::string(uid));
}
} }
void TagPlayerControl::playUrl(const std::string& url) { void TagPlayerControl::onTagChanged(char* tagId) {
/* Check cache first; if found play local file to avoid re-downloading */ if (tagId == NULL) {
std::string cached = cacheManager->getCachedPath(url); // tag has been removed. stop playback
if(!cached.empty()) { audioPlayer->pause();
audioPlayer->play(cached); storePosition();
return; } else {
} playTag(tagId);
}
/* Stream via HTTP and cache in background for future plays */
audioPlayer->play(url);
cacheManager->downloadAsync(url, [url](bool success) {
if(success) {
ESP_LOGI("TagPlayerControl", "Cached for next play: %s", url.c_str());
}
});
} }
void TagPlayerControl::playLocalTag(const std::string& uid) { void TagPlayerControl::playTag(char* tagId) {
std::string mappingPath = std::string("/sdcard/internal/") + uid; currentTag = Tag(tagId);
std::fstream file(mappingPath); currentTag.read();
if(!file) { if (currentTag.isLinked()) {
ESP_LOGW(TAG, "No mapping file for tag %s at %s", uid.c_str(), mappingPath.c_str()); audioPlayer->play(currentTag.getNextLink(), currentTag.getCurrentPosition());
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);
} }
void TagPlayerControl::storePosition() {
currentTag.setCurrentPosition(0, audioPlayer->getPosition());
currentTag.write();
}
+11 -17
View File
@@ -1,25 +1,19 @@
#pragma once #pragma once
#include <memory> #include <memory>
#include <string>
#include "AudioPlayer.hpp" #include "AudioPlayer.hpp"
#include "CacheManager.hpp" #include "Tag.hpp"
class TagPlayerControl { class TagPlayerControl {
std::shared_ptr<AudioPlayer> audioPlayer; std::shared_ptr<AudioPlayer> audioPlayer;
std::shared_ptr<CacheManager> cacheManager; Tag currentTag;
std::string lastUid; public:
TagPlayerControl(std::shared_ptr<AudioPlayer> player);
public: void onTagChanged(char* tagId);
TagPlayerControl(std::shared_ptr<AudioPlayer> player,
std::shared_ptr<CacheManager> cache); private:
void playTag(char* tagId);
/* Called by RFIDInterface. uid == NULL means tag removed. void storePosition();
* 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:
void playUrl(const std::string& url);
void playLocalTag(const std::string& uid);
};
+147 -255
View File
@@ -1,290 +1,182 @@
#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 <esp_system.h>
#include <esp_netif.h>
#include <fcntl.h>
#include <errno.h> #include <errno.h>
#include <string.h>
#include <dirent.h>
#include <sys/stat.h>
#include <string>
#include <sstream>
static const char* TAG = "WebInterface"; #include <fcntl.h>
#include "esp_system.h"
#define FILE_PATH_MAX (ESP_VFS_PATH_MAX + 128) static const char *TAG = "WebInterface";
#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)
typedef struct { #define CHECK_FILE_EXTENSION(filename, ext) (strcasecmp(&filename[strlen(filename) - strlen(ext)], ext) == 0)
char base_path[ESP_VFS_PATH_MAX + 1];
char scratch[SCRATCH_BUFSIZE]; typedef struct rest_server_context {
char base_path[ESP_VFS_PATH_MAX + 1];
char scratch[SCRATCH_BUFSIZE];
} rest_server_context_t; } rest_server_context_t;
/* ── helpers ──────────────────────────────────────────────────────────── */ WebInterface::WebInterface() {
static esp_err_t set_content_type(httpd_req_t* req, const char* filepath) {
const char* type = "text/plain";
if(CHECK_FILE_EXTENSION(filepath, ".html")) type = "text/html";
else if(CHECK_FILE_EXTENSION(filepath, ".js")) type = "application/javascript";
else if(CHECK_FILE_EXTENSION(filepath, ".css")) type = "text/css";
else if(CHECK_FILE_EXTENSION(filepath, ".png")) type = "image/png";
else if(CHECK_FILE_EXTENSION(filepath, ".ico")) type = "image/x-icon";
else if(CHECK_FILE_EXTENSION(filepath, ".svg")) type = "image/svg+xml";
else if(CHECK_FILE_EXTENSION(filepath, ".json"))type = "application/json";
return httpd_resp_set_type(req, type);
} }
static void send_json(httpd_req_t* req, const std::string& json) {
httpd_resp_set_type(req, "application/json");
httpd_resp_set_hdr(req, "Access-Control-Allow-Origin", "*");
httpd_resp_sendstr(req, json.c_str());
}
static void send_json_ok(httpd_req_t* req) {
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] == '/') {
strlcat(filepath, "/index.html", sizeof(filepath));
} else {
strlcat(filepath, req->uri, sizeof(filepath));
}
int fd = open(filepath, O_RDONLY);
if(fd == -1) {
ESP_LOGE(TAG, "Failed to open %s (errno %d)", filepath, errno);
httpd_resp_send_err(req, HTTPD_404_NOT_FOUND, "File not found");
return ESP_FAIL;
}
set_content_type(req, filepath);
char* chunk = ctx->scratch;
ssize_t n;
while((n = read(fd, chunk, SCRATCH_BUFSIZE)) > 0) {
if(httpd_resp_send_chunk(req, chunk, n) != ESP_OK) {
close(fd);
httpd_resp_sendstr_chunk(req, NULL);
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "Send error");
return ESP_FAIL;
}
}
close(fd);
httpd_resp_send_chunk(req, NULL, 0);
return ESP_OK;
}
/* ── API handlers (use shared_ptrs captured from WebInterface) ──────── */
/* GET /api/info — device info JSON */
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() { void WebInterface::start() {
initSD(); initSD();
server = start_webserver(); server = start_webserver();
} }
void WebInterface::finishResourceRegistrations() { void WebInterface::finishResourceRegistrations() {
registerApiHandlers(); registerFileServer();
registerFileServer();
} }
void WebInterface::initSD() { void WebInterface::initSD() {
esp_periph_config_t periph_cfg = DEFAULT_ESP_PERIPH_SET_CONFIG(); esp_periph_config_t periph_cfg = DEFAULT_ESP_PERIPH_SET_CONFIG();
esp_periph_set_handle_t set = esp_periph_set_init(&periph_cfg); esp_periph_set_handle_t set = esp_periph_set_init(&periph_cfg);
if(audio_board_sdcard_init(set, SD_MODE_1_LINE) == ESP_FAIL) { // Initialize SD Card peripheral
ESP_LOGE(TAG, "SDCard not mounted"); if (audio_board_sdcard_init(set, SD_MODE_1_LINE) == ESP_FAIL) {
} else { ESP_LOGE(TAG, "Sdcard not mounted");
ESP_LOGI(TAG, "SDCard mounted"); } else {
} ESP_LOGI(TAG, "SDCard mounted");
}
} }
httpd_handle_t WebInterface::start_webserver() { /* Set HTTP response content type according to file extension */
httpd_config_t config = HTTPD_DEFAULT_CONFIG(); esp_err_t set_content_type_from_file(httpd_req_t *req, const char *filepath)
config.lru_purge_enable = true; {
config.uri_match_fn = httpd_uri_match_wildcard; const char *type = "text/plain";
config.max_uri_handlers = 16; if (CHECK_FILE_EXTENSION(filepath, ".html")) {
type = "text/html";
} else if (CHECK_FILE_EXTENSION(filepath, ".js")) {
type = "application/javascript";
} else if (CHECK_FILE_EXTENSION(filepath, ".css")) {
type = "text/css";
} else if (CHECK_FILE_EXTENSION(filepath, ".png")) {
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);
}
httpd_handle_t srv = NULL; /* Send HTTP response with the contents of the requested file */
ESP_LOGI(TAG, "Starting server on port %d", config.server_port); esp_err_t rest_common_get_handler(httpd_req_t *req)
if(httpd_start(&srv, &config) == ESP_OK) return srv; {
char filepath[FILE_PATH_MAX];
ESP_LOGE(TAG, "Failed to start server"); rest_server_context_t *rest_context = (rest_server_context_t *)req->user_ctx;
return NULL; strlcpy(filepath, rest_context->base_path, sizeof(filepath));
if (req->uri[strlen(req->uri) - 1] == '/') {
strlcat(filepath, "/index.html", sizeof(filepath));
} else {
strlcat(filepath, req->uri, sizeof(filepath));
}
int fd = open(filepath, O_RDONLY);
if (fd == -1) {
ESP_LOGE(TAG, "Failed to open file : %s. Errno: %d", filepath, errno);
/* Respond with 500 Internal Server Error */
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;
}
set_content_type_from_file(req, filepath);
char *chunk = rest_context->scratch;
ssize_t read_bytes;
do {
/* Read file in chunks into the scratch buffer */
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);
ESP_LOGE(TAG, "File sending failed!");
/* Abort sending file */
httpd_resp_sendstr_chunk(req, NULL);
/* Respond with 500 Internal Server Error */
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "Failed to send file");
return ESP_FAIL;
}
}
} while (read_bytes > 0);
/* Close file after sending complete */
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);
return ESP_OK;
}
httpd_handle_t WebInterface::start_webserver(void) {
httpd_handle_t server = NULL;
httpd_config_t config = HTTPD_DEFAULT_CONFIG();
config.lru_purge_enable = true;
config.uri_match_fn = httpd_uri_match_wildcard;
// Start the httpd server
ESP_LOGI(TAG, "Starting server on port: '%d'", config.server_port);
if (httpd_start(&server, &config) == ESP_OK) {
return server;
}
ESP_LOGI(TAG, "Error starting server!");
return NULL;
} }
void WebInterface::registerResource(httpd_uri_t* config) { void WebInterface::registerResource(httpd_uri_t* config) {
if(!server) return; ESP_LOGI(TAG, "Register resource on path %s", config->uri);
ESP_LOGI(TAG, "Register %s", config->uri); httpd_register_uri_handler(server, config);
httpd_register_uri_handler(server, config);
}
void WebInterface::registerApiHandlers() {
/* Expose singletons to the static C handlers */
g_audio = audioPlayer.get();
g_cache = cacheManager.get();
httpd_uri_t info = { "/api/info", HTTP_GET, api_info_handler, nullptr };
httpd_uri_t stg_g = { "/api/storage", HTTP_GET, api_storage_get_handler, nullptr };
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);
} }
void WebInterface::registerFileServer() { void WebInterface::registerFileServer() {
rest_server_context_t* ctx = (rest_server_context_t*)calloc(1, sizeof(rest_server_context_t)); rest_server_context_t *fileContext = (rest_server_context_t*)calloc(1, sizeof(rest_server_context_t));
if(!ctx) { ESP_LOGE(TAG, "No memory for file server context"); return; }
strlcpy(ctx->base_path, "/sdcard/www", sizeof(ctx->base_path)); if (! fileContext) {
ESP_LOGE(TAG, "No memory for rest context");
return;
}
httpd_uri_t file_uri = { strlcpy(fileContext->base_path, "/sdcard/www", sizeof(fileContext->base_path));
.uri = "/*",
.method = HTTP_GET, /* URI handler for getting web server files */
.handler = rest_common_get_handler, httpd_uri_t common_get_uri = {
.user_ctx = ctx .uri = "/*",
}; .method = HTTP_GET,
registerResource(&file_uri); .handler = rest_common_get_handler,
} .user_ctx = fileContext
};
registerResource(&common_get_uri);
}
+12 -23
View File
@@ -2,32 +2,21 @@
#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 start();
void finishResourceRegistrations();
void registerResource(httpd_uri_t* config);
void initSD();
void setAudioPlayer(std::shared_ptr<AudioPlayer> p) { audioPlayer = p; } private:
void setCacheManager(std::shared_ptr<CacheManager> c) { cacheManager = c; } httpd_handle_t start_webserver();
void registerFileServer();
void start(); //esp_err_t hello_get_handler(httpd_req_t *req);
void finishResourceRegistrations();
void registerResource(httpd_uri_t* config);
void initSD();
private:
httpd_handle_t start_webserver();
void registerFileServer();
void registerApiHandlers();
}; };
#endif #endif
+17 -26
View File
@@ -1,53 +1,44 @@
#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" {
void app_main(); void app_main();
} }
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) {
/* SD card must be mounted first — other components depend on it */ initArduino();
webInterface.initSD(); webInterface.initSD();
/* Network — non-blocking with 30 s timeout */ /* networking.connectWifi();
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(); webInterface.start();
rfid.registerWebResources(&webInterface); rfid.registerWebResources(&webInterface);
webInterface.finishResourceRegistrations(); webInterface.finishResourceRegistrations();
*/
tagPlayerControl = std::make_shared<TagPlayerControl>(audioPlayer, cacheManager); audioPlayer = std::make_shared<AudioPlayer>();
tagPlayerControl = std::make_shared<TagPlayerControl>(audioPlayer);
/* RFID must start after the audio player (hardware init order constraint) */
rfid.start(); rfid.start();
rfid.registerTagCallback([](const char* uid, const char* url) { rfid.registerTagChangeCallback([tagPlayerControl](char* tagId) {
tagPlayerControl->onTagChanged(uid, url); tagPlayerControl->onTagChanged(tagId);
}); });
} }
-46
View File
@@ -1,46 +0,0 @@
# Regenerate Kidsbox enclosure STLs and preview images from the OpenSCAD source.
#
# make stl export all printable STLs into stl/
# make img render preview PNGs into img/ (needs a display or xvfb)
# make all both
# make clean remove generated STLs and images
#
# Override the renderer if openscad isn't on PATH: make OPENSCAD=/path/to/openscad
OPENSCAD ?= openscad
SRC = kidsbox.scad params.scad lib.scad
PARTS = body lid speaker_adapter grill
STL_FILES = $(addprefix stl/,$(addsuffix .stl,$(PARTS)))
# Software GL so it works headless under xvfb on a server.
RENDER_ENV = LIBGL_ALWAYS_SOFTWARE=1 GALLIUM_DRIVER=llvmpipe
XVFB = xvfb-run -a -s "-screen 0 1280x960x24"
IMGOPTS = --viewall --autocenter --imgsize=1280,960 --colorscheme=Tomorrow
.PHONY: all stl img clean
all: stl img
stl: $(STL_FILES)
stl/%.stl: $(SRC)
@mkdir -p stl
$(OPENSCAD) -D 'part="$*"' -o $@ kidsbox.scad
img: $(SRC)
@mkdir -p img
$(RENDER_ENV) $(XVFB) $(OPENSCAD) -D 'part="assembly"' -D 'explode=0' \
--camera=0,0,0,64,0,28,0 $(IMGOPTS) -o img/hero.png kidsbox.scad
$(RENDER_ENV) $(XVFB) $(OPENSCAD) -D 'part="assembly"' \
--camera=0,0,0,62,0,28,0 $(IMGOPTS) -o img/assembly.png kidsbox.scad
$(RENDER_ENV) $(XVFB) $(OPENSCAD) -D 'part="body"' \
--camera=0,0,0,60,0,25,0 $(IMGOPTS) -o img/body.png kidsbox.scad
$(RENDER_ENV) $(XVFB) $(OPENSCAD) -D 'part="lid"' \
--camera=0,0,0,60,0,200,0 $(IMGOPTS) -o img/lid_top.png kidsbox.scad
$(RENDER_ENV) $(XVFB) $(OPENSCAD) -D 'part="speaker_adapter"' \
--camera=0,0,0,60,0,20,0 $(IMGOPTS) -o img/speaker_adapter.png kidsbox.scad
$(RENDER_ENV) $(XVFB) $(OPENSCAD) -D 'part="grill"' \
--camera=0,0,0,55,0,20,0 $(IMGOPTS) -o img/grill.png kidsbox.scad
clean:
rm -f stl/*.stl img/*.png
-145
View File
@@ -1,145 +0,0 @@
# Kidsbox — 3D-printable enclosure
A parametric, fully 3D-printable reimagining of the original wooden Kidsbox.
The design keeps the look of the hand-built box — a rectangular cabinet with two
front-firing speakers and a removable top where the NFC tag rests — but makes the
**speaker mount swappable** so you can print an adapter to fit whatever drivers you
have on hand.
![Assembly](img/hero.png)
> Modelled in [OpenSCAD](https://openscad.org). Everything is driven by
> [`params.scad`](params.scad) — change a few numbers, re-export, and print.
---
## What's in the box
| Part | Qty | File | Notes |
|---|---|---|---|
| Body / cabinet | 1 | `stl/body.stl` | Shell with all internal mounts |
| Lid | 1 | `stl/lid.stl` | Buttons + NFC reader mount underneath |
| Speaker adapter | 2 | `stl/speaker_adapter.stl` | **Swappable** — print to fit your driver |
| Speaker grill | 2 | `stl/grill.stl` | Snaps into the sound opening |
The body carries mounts for everything else:
- **2× 18650 battery holder** — open-top cradle with retaining lips (`batt_*` params)
- **ESP32-LyraT v4.3** — four floor standoffs (`lyrat_*` params)
- **RC522 NFC reader** — pocket on the lid underside, so a tag placed on top reads
through the thin (`lid_t = 2.4 mm`) PLA window
- **3 buttons** (vol−, play/pause, vol+) — 12 mm holes in a row near the lid's front edge
- **Speaker interface** — a fixed bolt pattern the swappable adapters mount to
- **Side ports** — USB/charging cutout and a power-switch hole
- **Vents** on the back wall
| Body | Lid (underside) |
|---|---|
| ![Body](img/body.png) | ![Lid](img/lid.png) |
---
## Printing
| Setting | Recommendation |
|---|---|
| Material | PLA or PETG (PLA is fine; keep metal away from the NFC window) |
| Layer height | 0.2 mm |
| Walls / perimeters | 3+ |
| Infill | 15–20 % |
| Supports | **Body:** none (print open-side up). **Lid:** none. **Adapter/Grill:** none. |
| Orientation | Body open-top up; lid top-face down; adapter & grill flat |
The body is ~190 × 115 × 110 mm, so it fits a 220 × 220 bed (Ender-3 class) and larger.
---
## Assembly
1. **Speakers** → bolt each driver to a `speaker_adapter` using the adapter's inner
screw holes, then bolt the adapter to the four bosses inside the front wall (M3).
2. **Grills** → press a `grill` into each sound opening from the front.
3. **Electronics** → screw the ESP32-LyraT onto the floor standoffs; drop the 18650
holder into the cradle; route the USB/charge lead to the side cutout.
4. **NFC reader** → screw the RC522 to the four posts under the lid.
5. **Buttons** → fit the three 12 mm momentary switches into the lid holes.
6. **Close up** → lower the lid (the lip locates it) and drive four M3 screws into the
corner bosses.
---
## Swapping speakers (the whole point)
The front wall never changes — it has a **fixed interface**: a `spk_sound_d` opening
and four bosses on a `spk_bolt_d` circle. Every speaker bolts to the box through an
**adapter** that you print to match *your* driver. To make a new adapter, edit the
adapter block in [`params.scad`](params.scad):
```scad
// ---- swappable speaker ADAPTER (edit for YOUR speaker) ----
spk_frame_d = 66; // your speaker's outer frame diameter
spk_cutout_d = 58; // cone clearance (a bit less than the cone surround)
spk_screw_count = 4; // your speaker's mounting-hole count
spk_screw_bolt_d = 60; // your speaker's mounting-hole bolt circle
```
Then re-export just that part:
```bash
openscad -D 'part="speaker_adapter"' -o stl/speaker_adapter.stl kidsbox.scad
```
Leave the `spk_sound_d` / `spk_bolt_d` (panel) values alone and your new adapter will
drop straight onto the same box.
| Speaker adapter (swappable) | Grill |
|---|---|
| ![Adapter](img/speaker_adapter.png) | ![Grill](img/grill.png) |
---
## Regenerating everything
```bash
make stl # export all STLs into stl/
make img # render preview PNGs (needs a display, or xvfb on a server)
make all
```
Or one part at a time:
```bash
openscad -D 'part="body"' -o stl/body.stl kidsbox.scad
openscad -D 'part="lid"' -o stl/lid.stl kidsbox.scad
openscad -D 'part="speaker_adapter"' -o stl/speaker_adapter.stl kidsbox.scad
openscad -D 'part="grill"' -o stl/grill.stl kidsbox.scad
```
---
## ⚠️ Verify before mass-printing
These dimensions are sensible defaults derived from the reference photos and common
parts, **not** from calipers on your exact hardware. Before printing the whole set,
print a quick test of the fit-critical features and confirm against your parts:
- ESP32-LyraT mounting-hole rectangle (`lyrat_hole_dx`, `lyrat_hole_dy`)
- RC522 mounting-hole rectangle (`bx`, `by` in the `rc522_mount()` module)
- Your 18650 holder footprint (`batt_holder_*`)
- Speaker frame/cone/bolt-circle (`spk_frame_d`, `spk_cutout_d`, `spk_screw_bolt_d`)
A cheap way to validate the speaker fit is to print one `speaker_adapter` first.
---
## Files
```
enclosure/
├── kidsbox.scad master model + part dispatcher
├── params.scad all tunable dimensions ← edit here
├── lib.scad helper modules
├── Makefile export/render targets
├── stl/ exported STLs
└── img/ rendered previews
```
Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

-275
View File
@@ -1,275 +0,0 @@
// ============================================================================
// Kidsbox — parametric 3D-printable enclosure
// ---------------------------------------------------------------------------
// Open in OpenSCAD and set `part` (top-right) or export from the CLI:
//
// openscad -D 'part="body"' -o stl/body.stl kidsbox.scad
// openscad -D 'part="lid"' -o stl/lid.stl kidsbox.scad
// openscad -D 'part="speaker_adapter"' -o stl/speaker_adapter.stl kidsbox.scad
// openscad -D 'part="grill"' -o stl/grill.stl kidsbox.scad
//
// `part="assembly"` shows everything together (for preview only).
// All tunables live in params.scad.
// ============================================================================
include <params.scad>
use <lib.scad>
part = "assembly"; // body | lid | speaker_adapter | grill | assembly
// ---------------------------------------------------------------------------
// Front-panel speaker interface (the FIXED "mount for the speaker mount")
// ---------------------------------------------------------------------------
// Subtractive: the sound opening + an outer recess for the grill flange.
module speaker_cut(cx, cz) {
translate([cx, 0, cz]) rotate([-90, 0, 0]) {
// sound opening straight through the front wall
translate([0, 0, -1]) cylinder(h = wall + 2, d = spk_sound_d);
// grill flange recess on the OUTSIDE face
translate([0, 0, -0.01]) cylinder(h = grill_t + 0.01, d = spk_sound_d + 8);
}
}
// Additive: 4 bosses on the INSIDE so a swappable adapter can bolt on.
module speaker_bosses(cx, cz) {
translate([cx, 0, cz]) rotate([-90, 0, 0])
translate([0, 0, wall - 0.5])
bolt_circle(spk_bolt_d, spk_bolt_count, 45)
boss(spk_boss_d, spk_boss_h + 0.5, 2.6); // self-tap for M3
}
// ---------------------------------------------------------------------------
// Button holes through the lid (row near the front edge)
// ---------------------------------------------------------------------------
module button_cuts() {
for (i = [0 : btn_count - 1]) {
x = box_w/2 + (i - (btn_count - 1) / 2) * btn_pitch;
translate([x, btn_row_y, box_h - 1])
cylinder(h = lid_t + 2, d = btn_d);
}
}
// ---------------------------------------------------------------------------
// Side ports: USB/charge (right wall) and power switch (left wall)
// ---------------------------------------------------------------------------
module side_ports() {
// USB on +X wall
translate([box_w - wall - 1, box_d * 0.30, usb_center_z])
cube([wall + 2, usb_w, usb_h], center = false);
// power switch on -X wall
translate([-1, box_d * 0.30, sw_center_z]) rotate([0, 90, 0])
cylinder(h = wall + 2, d = sw_d);
}
// ---------------------------------------------------------------------------
// Ventilation slots on the back wall (+Y)
// ---------------------------------------------------------------------------
module vents() {
total = (vent_count - 1) * (vent_slot_w + 4);
for (i = [0 : vent_count - 1]) {
x = box_w/2 - total/2 + i * (vent_slot_w + 4);
translate([x, box_d - wall - 1, box_h * 0.55])
cube([vent_slot_w, wall + 2, vent_slot_len]);
}
}
// ---------------------------------------------------------------------------
// Internal mounts
// ---------------------------------------------------------------------------
// Corner posts the lid screws into (fused into the wall corners, top-anchored).
module lid_bosses() {
boss_len = lid_rim_h + 10;
for (sx = [lid_boss_inset, box_w - lid_boss_inset],
sy = [lid_boss_inset, box_d - lid_boss_inset])
translate([sx, sy, box_h - boss_len])
boss(lid_boss_d, boss_len, lid_screw_d - 0.6); // self-tap
}
// ESP32-LyraT standoffs (board lies flat on the floor, toward the back).
lyrat_cx = box_w/2;
lyrat_cy = box_d - lyrat_d/2 - 10;
module lyrat_standoffs() {
for (sx = [-lyrat_hole_dx/2, lyrat_hole_dx/2],
sy = [-lyrat_hole_dy/2, lyrat_hole_dy/2])
translate([lyrat_cx + sx, lyrat_cy + sy, floor_t])
boss(lyrat_standoff_d, lyrat_standoff_h, lyrat_screw_d);
}
// 2x 18650 battery cradle (open-top tray with end stops + clips), front-left.
batt_cx = box_w/2;
batt_cy = wall + batt_holder_d/2 + 4;
module battery_cradle() {
ow = batt_holder_w + 2*batt_wall;
od = batt_holder_d + 2*batt_wall;
translate([batt_cx - ow/2, batt_cy - od/2, floor_t])
difference() {
cube([ow, od, batt_holder_h]);
// hollow pocket, open top
translate([batt_wall, batt_wall, batt_wall])
cube([batt_holder_w, batt_holder_d, batt_holder_h]);
// cable notch in the front wall of the cradle
translate([ow/2 - 6, -1, batt_holder_h - 8])
cube([12, batt_wall + 2, 10]);
}
if (batt_clip) {
// two retaining lips along the long sides
for (sy = [batt_cy - od/2 + batt_wall/2, batt_cy + od/2 - batt_wall/2])
translate([batt_cx - batt_holder_w/3, sy, floor_t + batt_holder_h])
rotate([0, 0, 0])
cube([batt_holder_w*2/3, batt_wall, 2.5], center = false);
}
}
// ---------------------------------------------------------------------------
// BODY
// ---------------------------------------------------------------------------
module body() {
spk_l = box_w/2 - spk_spacing/2;
spk_r = box_w/2 + spk_spacing/2;
difference() {
union() {
shell([box_w, box_d, box_h], corner_r, wall, floor_t);
speaker_bosses(spk_l, spk_center_z);
speaker_bosses(spk_r, spk_center_z);
lid_bosses();
lyrat_standoffs();
battery_cradle();
}
speaker_cut(spk_l, spk_center_z);
speaker_cut(spk_r, spk_center_z);
side_ports();
vents();
}
}
// ---------------------------------------------------------------------------
// LID (NFC reader sits underneath; print thin-side up)
// ---------------------------------------------------------------------------
// RC522 pocket posts on the lid underside.
module rc522_mount() {
// board centred, posts hang DOWN from the lid underside (local -Z here)
bx = 54; by = 34; // RC522 mounting-hole rectangle (verify)
for (sx = [-bx/2, bx/2], sy = [-by/2, by/2])
translate([sx, sy, -8])
cylinder(h = 8, d = 6);
// pilot holes
for (sx = [-bx/2, bx/2], sy = [-by/2, by/2])
translate([sx, sy, -8.5])
cylinder(h = 9, d = 2.4);
}
module lid() {
difference() {
union() {
// top plate sitting on the wall tops
translate([box_w/2, box_d/2, box_h])
linear_extrude(lid_t)
rrect(box_w, box_d, corner_r);
// locating lip dropping into the interior
translate([box_w/2, box_d/2, box_h - lid_rim_h])
linear_extrude(lid_rim_h)
difference() {
rrect(inner_w - 2*fit, inner_d - 2*fit, max(0.1, corner_r - wall));
rrect(inner_w - 2*fit - 2*lid_rim_t,
inner_d - 2*fit - 2*lid_rim_t,
max(0.1, corner_r - wall - lid_rim_t));
}
// RC522 mount, centred under the lid
translate([box_w/2, box_d/2, box_h + lid_t]) rc522_mount();
}
// corner screw holes (countersunk) through the plate
for (sx = [lid_boss_inset, box_w - lid_boss_inset],
sy = [lid_boss_inset, box_d - lid_boss_inset]) {
translate([sx, sy, box_h - 0.5])
cylinder(h = lid_t + 1, d = lid_screw_d);
translate([sx, sy, box_h + lid_t - 1.4])
cylinder(h = 1.6, d1 = lid_screw_d, d2 = lid_screw_d + 3.5);
}
// button holes near the front edge
button_cuts();
}
}
// ---------------------------------------------------------------------------
// SWAPPABLE SPEAKER ADAPTER (print one per speaker model)
// ---------------------------------------------------------------------------
module speaker_adapter() {
difference() {
union() {
cylinder(h = adapter_t, d = adapter_outer_d);
// frame seat ring raised on the back so the speaker locates
translate([0, 0, adapter_t])
ring(spk_frame_d + 6, spk_cutout_d, spk_frame_seat);
}
// cone clearance
translate([0, 0, -0.5]) cylinder(h = adapter_t + spk_frame_seat + 1, d = spk_cutout_d);
// speaker frame recess on the back
translate([0, 0, adapter_t - 0.01])
cylinder(h = spk_frame_seat + 0.02, d = spk_frame_d);
// mounting holes to the panel bosses (FIXED interface)
bolt_circle(spk_bolt_d, spk_bolt_count, 45)
translate([0, 0, -0.5]) cylinder(h = adapter_t + spk_frame_seat + 1, d = spk_bolt_hole_d);
// the speaker's OWN screw holes (swappable per model)
bolt_circle(spk_screw_bolt_d, spk_screw_count, 0)
translate([0, 0, -0.5]) cylinder(h = adapter_t + spk_frame_seat + 1, d = spk_screw_d);
}
}
// ---------------------------------------------------------------------------
// SPEAKER GRILL (snaps into the sound opening)
// ---------------------------------------------------------------------------
module grill() {
r_out = spk_sound_d/2;
union() {
// outer flange that seats in the panel recess
ring(spk_sound_d + 8, spk_sound_d - 4, grill_t);
// friction lip into the hole
translate([0, 0, -grill_lip_h])
ring(spk_sound_d - 2*fit, spk_sound_d - 2*fit - 3, grill_lip_h + grill_t);
// concentric rings
for (i = [1 : grill_rings])
ring(2*r_out * i/(grill_rings+1) + 1.4,
2*r_out * i/(grill_rings+1) - 1.4, grill_t);
// central hub
cylinder(h = grill_t, d = 6);
// radial spokes
for (i = [0 : grill_spokes - 1])
rotate([0, 0, i * 360/grill_spokes])
translate([0, -1.0, 0]) cube([r_out, 2.0, grill_t]);
}
}
// ---------------------------------------------------------------------------
// ASSEMBLY PREVIEW
// ---------------------------------------------------------------------------
explode = 45; // lid lift for the exploded preview (0 = closed)
module assembly() {
spk_l = box_w/2 - spk_spacing/2;
spk_r = box_w/2 + spk_spacing/2;
color("WhiteSmoke") body();
color("Gainsboro") translate([0, 0, explode]) lid();
for (cx = [spk_l, spk_r]) {
// adapter just inside the front wall
color("SteelBlue")
translate([cx, wall + adapter_t, spk_center_z]) rotate([90, 0, 0])
speaker_adapter();
// grill in the opening
color("DimGray")
translate([cx, grill_t, spk_center_z]) rotate([-90, 0, 0])
grill();
}
}
// ---------------------------------------------------------------------------
// DISPATCH
// ---------------------------------------------------------------------------
if (part == "body") body();
else if (part == "lid") lid();
else if (part == "speaker_adapter") speaker_adapter();
else if (part == "grill") grill();
else assembly();
-53
View File
@@ -1,53 +0,0 @@
// ============================================================================
// Kidsbox enclosure — helper modules
// ============================================================================
// A box with rounded VERTICAL edges only (flat top & bottom for clean FDM).
// size = [x, y, z], r = corner radius.
module rbox(size, r) {
x = size[0]; y = size[1]; z = size[2];
hull() {
for (sx = [r, x - r], sy = [r, y - r])
translate([sx, sy, 0]) cylinder(h = z, r = r);
}
}
// Hollow shell: outer rbox minus inner rbox, open at the top (+Z).
// Floor thickness `ft`, wall `w`.
module shell(size, r, w, ft) {
x = size[0]; y = size[1]; z = size[2];
difference() {
rbox([x, y, z], r);
translate([w, w, ft])
rbox([x - 2*w, y - 2*w, z - ft + 1], max(0.1, r - w));
}
}
// A screw boss (solid post with a pilot hole) standing in +Z.
module boss(od, h, hole_d) {
difference() {
cylinder(h = h, d = od);
translate([0, 0, -0.5]) cylinder(h = h + 1, d = hole_d);
}
}
// A flat ring.
module ring(od, id, h) {
difference() {
cylinder(h = h, d = od);
translate([0, 0, -0.5]) cylinder(h = h + 1, d = id);
}
}
// Bolt circle: place children at `count` points on diameter `d`.
module bolt_circle(d, count, start = 0) {
for (i = [0 : count - 1])
rotate([0, 0, start + i * 360 / count])
translate([d/2, 0, 0]) children();
}
// A rounded-rectangle plate (2D) for linear_extrude.
module rrect(x, y, r) {
hull() for (sx = [r, x - r], sy = [r, y - r])
translate([sx - x/2, sy - y/2]) circle(r = r);
}
-96
View File
@@ -1,96 +0,0 @@
// ============================================================================
// Kidsbox enclosure — parameters
// All dimensions in millimetres. Edit here, then re-export the STLs.
// See README.md for the export commands.
// ============================================================================
// ---- global print settings ------------------------------------------------
$fn = 64; // facet count for curves (raise for final render)
wall = 3.0; // shell wall thickness
floor_t = 3.0; // bottom thickness
corner_r = 6.0; // outer vertical-edge radius
fit = 0.30; // general clearance for mating parts (per side)
// ---- outer box dimensions (interior is derived) ---------------------------
box_w = 190; // width (X) — roughly one banana, per reference photo
box_h = 110; // height (Z)
box_d = 115; // depth (Y)
// derived interior
inner_w = box_w - 2*wall;
inner_d = box_d - 2*wall;
inner_h = box_h - floor_t; // open-top shell, lid closes it
// ---- lid -------------------------------------------------------------------
lid_t = 2.4; // thin enough for 13.56 MHz NFC to read through PLA
lid_rim_h = 6.0; // depth of the lip that drops into the box
lid_rim_t = 1.6; // thickness of that lip
lid_screw_d = 3.2; // M3 clearance
lid_boss_d = 9.0; // corner screw-boss outer diameter
lid_boss_inset = 9.0; // boss centre inset from interior corner
// ---- speaker panel interface (FIXED) --------------------------------------
// This is the standard "mount for the speaker mount". Every swappable
// speaker adapter bolts to THIS pattern, so the box never changes.
spk_sound_d = 66; // sound opening through the front panel
spk_bolt_d = 80; // bolt circle for the adapter screws
spk_bolt_count = 4; // number of adapter screws
spk_bolt_hole_d = 3.2; // M3 clearance through the panel
spk_boss_d = 8.0; // screw boss diameter on the inside of the panel
spk_boss_h = 5.0; // screw boss height (into the box)
spk_spacing = 94; // centre-to-centre of the two speakers (X)
spk_center_z = 48; // speaker centre height from the box floor (Z)
// ---- swappable speaker ADAPTER (edit for YOUR speaker) ---------------------
// Print one adapter per speaker model. Defaults suit a common 3" driver.
adapter_t = 3.0; // adapter plate thickness
adapter_outer_d = 88; // outer diameter of the adapter disc
spk_frame_d = 66; // speaker outer frame diameter (seat recess)
spk_frame_seat = 1.5; // depth the frame sinks into the adapter
spk_cutout_d = 58; // cone clearance hole through the adapter
spk_screw_count = 4; // the SPEAKER's own mounting holes
spk_screw_bolt_d = 60; // bolt circle of the speaker's mounting holes
spk_screw_d = 3.0; // speaker screw clearance
// ---- speaker grill (snaps into the sound opening) -------------------------
grill_t = 2.4; // grill face thickness
grill_rings = 3; // number of concentric rings
grill_spokes = 6; // number of radial spokes
grill_lip_h = 5.0; // snap lip depth into the sound hole
grill_standoff = 3.0; // gap the grill holds off the cone
// ---- buttons (top lid, row near the front edge) ---------------------------
// Kept off the front face (which stays clean like the reference box) and
// clear of the central NFC reader, which sits under the lid centre.
btn_d = 12.2; // panel hole for a 12 mm momentary switch
btn_count = 3; // vol-, play/pause, vol+
btn_pitch = 26; // spacing between buttons along X
btn_row_y = 18; // distance of the button row from the front wall
// ---- side ports ------------------------------------------------------------
usb_w = 12; // micro-USB / charge access cutout (W)
usb_h = 7; // (H)
usb_center_z = 22; // height of the port from the floor
sw_d = 7; // power slide-switch hole diameter
sw_center_z = 22;
// ---- ESP32-LyraT v4.3 mount (verify against your board) -------------------
lyrat_w = 99; // board long edge
lyrat_d = 57; // board short edge
lyrat_hole_dx = 92; // mounting-hole rectangle, long axis (VERIFY)
lyrat_hole_dy = 50; // mounting-hole rectangle, short axis (VERIFY)
lyrat_standoff_h = 6; // standoff height off the floor
lyrat_standoff_d = 6; // standoff outer diameter
lyrat_screw_d = 2.6; // self-tap for M2.5/M3
// ---- 2x 18650 battery holder cradle ---------------------------------------
batt_holder_w = 78; // typical 2-cell holder footprint (X)
batt_holder_d = 39; // (Y)
batt_holder_h = 21; // (Z)
batt_wall = 2.0; // cradle wall
batt_clip = true; // add retaining clips
// ---- ventilation -----------------------------------------------------------
vent_slot_w = 3;
vent_slot_len = 28;
vent_count = 5;
-78850
View File
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
-50682
View File
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
-149
View File
@@ -1,149 +0,0 @@
#!/usr/bin/env bash
# install.sh — set up the Kidsbox development environment
# Installs ESP-IDF v5.2 + ESP-ADF v2.7, or validates an existing installation.
# Supports: Linux (Debian/Ubuntu/Arch) and macOS (with Homebrew)
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
BOLD='\033[1m'; GREEN='\033[0;32m'; YELLOW='\033[0;33m'; RED='\033[0;31m'; NC='\033[0m'
info() { echo -e "${GREEN}[install]${NC} $*"; }
warn() { echo -e "${YELLOW}[warn]${NC} $*"; }
die() { echo -e "${RED}[error]${NC} $*" >&2; exit 1; }
heading() { echo -e "\n${BOLD}$*${NC}"; }
IDF_VERSION="v5.2.3"
ADF_VERSION="v2.7"
IDF_DEFAULT="$HOME/esp/esp-idf"
ADF_DEFAULT="$HOME/esp/esp-adf"
# ── detect OS ─────────────────────────────────────────────────────────────────
OS="$(uname -s)"
if [[ "$OS" == "Darwin" ]]; then
info "macOS detected"
MISSING_HINT=' Install missing tools with Homebrew: brew install git python3 cmake ninja'
elif [[ "$OS" == "Linux" ]]; then
info "Linux detected"
MISSING_HINT=' Install missing tools: sudo apt-get install git python3 python3-pip cmake ninja-build'
else
die "Unsupported OS: $OS"
fi
# ── prerequisites ─────────────────────────────────────────────────────────────
heading "Checking prerequisites"
MISSING=()
for cmd in git python3 cmake ninja; do
if command -v "$cmd" &>/dev/null; then
info "$cmd: $(command -v $cmd)"
else
MISSING+=("$cmd")
warn "$cmd: NOT FOUND"
fi
done
if [[ ${#MISSING[@]} -gt 0 ]]; then
echo ""
echo -e "${MISSING_HINT}"
if [[ "$OS" == "Darwin" ]]; then
echo " If Homebrew is not installed: https://brew.sh"
fi
echo ""
die "Missing prerequisites: ${MISSING[*]}"
fi
# pip3 may not exist as a standalone command on newer Python installs; try both
if command -v pip3 &>/dev/null; then
info "pip3: $(command -v pip3)"
elif python3 -m pip --version &>/dev/null 2>&1; then
info "pip: available via python3 -m pip"
else
echo -e "${MISSING_HINT}"
die "pip not found (tried pip3 and python3 -m pip)"
fi
PYTHON_VERSION=$(python3 -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")')
python3 -c 'import sys; sys.exit(0 if sys.version_info >= (3,8) else 1)' \
|| die "Python >= 3.8 required (found $PYTHON_VERSION)"
info "Python $PYTHON_VERSION: OK"
# macOS: Xcode Command Line Tools are required by ESP-IDF's install script
if [[ "$OS" == "Darwin" ]]; then
if ! xcode-select -p &>/dev/null; then
die "Xcode Command Line Tools not found.\n Run: xcode-select --install\n Then re-run this script."
fi
info "Xcode CLT: $(xcode-select -p)"
fi
# ── ESP-IDF ───────────────────────────────────────────────────────────────────
heading "ESP-IDF ($IDF_VERSION)"
if [[ -n "${IDF_PATH:-}" && -f "$IDF_PATH/export.sh" ]]; then
INSTALLED_TAG=$(git -C "$IDF_PATH" describe --tags 2>/dev/null || echo "unknown")
info "Found existing ESP-IDF at \$IDF_PATH=$IDF_PATH (tag: $INSTALLED_TAG)"
warn "Skipping IDF install. To use a fresh install, unset \$IDF_PATH and re-run."
else
IDF_PATH="${IDF_DEFAULT}"
info "Installing ESP-IDF $IDF_VERSION to $IDF_PATH"
mkdir -p "$(dirname "$IDF_PATH")"
if [[ -d "$IDF_PATH/.git" ]]; then
git -C "$IDF_PATH" fetch --tags
git -C "$IDF_PATH" checkout "$IDF_VERSION"
else
git clone --depth 1 --branch "$IDF_VERSION" \
https://github.com/espressif/esp-idf.git "$IDF_PATH"
fi
git -C "$IDF_PATH" submodule update --init --recursive
info "Running ESP-IDF install script (downloads toolchain — may take a while)"
"$IDF_PATH/install.sh" esp32
info "ESP-IDF installed."
fi
# ── ESP-ADF ───────────────────────────────────────────────────────────────────
heading "ESP-ADF ($ADF_VERSION)"
if [[ -n "${ADF_PATH:-}" && -d "$ADF_PATH" ]]; then
INSTALLED_TAG=$(git -C "$ADF_PATH" describe --tags 2>/dev/null || echo "unknown")
info "Found existing ESP-ADF at \$ADF_PATH=$ADF_PATH (tag: $INSTALLED_TAG)"
warn "Skipping ADF install. To use a fresh install, unset \$ADF_PATH and re-run."
else
ADF_PATH="${ADF_DEFAULT}"
info "Installing ESP-ADF $ADF_VERSION to $ADF_PATH"
mkdir -p "$(dirname "$ADF_PATH")"
if [[ -d "$ADF_PATH/.git" ]]; then
git -C "$ADF_PATH" fetch --tags
git -C "$ADF_PATH" checkout "$ADF_VERSION"
else
git clone --depth 1 --branch "$ADF_VERSION" \
https://github.com/espressif/esp-adf.git "$ADF_PATH"
fi
git -C "$ADF_PATH" submodule update --init --recursive
info "ESP-ADF installed."
fi
# ── write .env ────────────────────────────────────────────────────────────────
heading "Writing .env"
ENV_FILE="$SCRIPT_DIR/.env"
cat > "$ENV_FILE" <<EOF
# Generated by install.sh — source this before building:
# source .env && source \$IDF_PATH/export.sh
export IDF_PATH="${IDF_PATH:-$IDF_DEFAULT}"
export ADF_PATH="${ADF_PATH:-$ADF_DEFAULT}"
EOF
info "Wrote $ENV_FILE"
# ── done ──────────────────────────────────────────────────────────────────────
heading "Done!"
echo ""
echo " Next steps:"
echo " 1. Source the environment:"
echo " source .env && source \$IDF_PATH/export.sh"
echo " 2. Configure WiFi and build:"
echo " ./setup.sh"
echo ""
-140
View File
@@ -1,140 +0,0 @@
#!/usr/bin/env bash
# setup.sh — configure, build, and optionally flash/deploy Kidsbox
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
DEVICE_DIR="$SCRIPT_DIR/device"
WWW_DIR="$SCRIPT_DIR/www"
BOLD='\033[1m'; GREEN='\033[0;32m'; YELLOW='\033[0;33m'; RED='\033[0;31m'; NC='\033[0m'
info() { echo -e "${GREEN}[setup]${NC} $*"; }
warn() { echo -e "${YELLOW}[warn]${NC} $*"; }
die() { echo -e "${RED}[error]${NC} $*" >&2; exit 1; }
heading() { echo -e "\n${BOLD}$*${NC}"; }
prompt() { echo -e "${YELLOW}$*${NC}"; }
# ── check environment ──────────────────────────────────────────────────────────
heading "Checking environment"
[[ -f "$SCRIPT_DIR/.env" ]] && source "$SCRIPT_DIR/.env"
[[ -n "${IDF_PATH:-}" ]] || die "IDF_PATH not set. Run: source .env && source \$IDF_PATH/export.sh"
[[ -n "${ADF_PATH:-}" ]] || die "ADF_PATH not set. Run: source .env && source \$IDF_PATH/export.sh"
command -v idf.py &>/dev/null || die "idf.py not found. Run: source \$IDF_PATH/export.sh"
info "IDF_PATH: $IDF_PATH"
info "ADF_PATH: $ADF_PATH"
# ── WiFi credentials ───────────────────────────────────────────────────────────
heading "WiFi configuration"
SDKCONFIG_DEFAULTS="$DEVICE_DIR/sdkconfig.defaults"
if [[ -f "$SDKCONFIG_DEFAULTS" ]] \
&& grep -q "CONFIG_ESP_WIFI_SSID" "$SDKCONFIG_DEFAULTS" 2>/dev/null; then
CURRENT_SSID=$(grep 'CONFIG_ESP_WIFI_SSID' "$SDKCONFIG_DEFAULTS" | cut -d'"' -f2)
info "WiFi already configured (SSID: $CURRENT_SSID)"
prompt "Re-enter credentials? [y/N] "
read -r RECONFIGURE
[[ "$RECONFIGURE" =~ ^[Yy]$ ]] || SKIP_WIFI=1
fi
if [[ -z "${SKIP_WIFI:-}" ]]; then
prompt "WiFi SSID: "
read -r WIFI_SSID
prompt "WiFi Password: "
read -rs WIFI_PASSWORD
echo ""
[[ -n "$WIFI_SSID" ]] || die "SSID cannot be empty"
[[ -n "$WIFI_PASSWORD" ]] || die "Password cannot be empty"
cat > "$SDKCONFIG_DEFAULTS" <<EOF
CONFIG_ESP_WIFI_SSID="${WIFI_SSID}"
CONFIG_ESP_WIFI_PASSWORD="${WIFI_PASSWORD}"
CONFIG_ESP_MAXIMUM_RETRY=5
CONFIG_ESP_ADF_BOARD_LYRAT_V4_3=y
CONFIG_IDF_TARGET="esp32"
EOF
info "Wrote $SDKCONFIG_DEFAULTS"
fi
# ── set target & build ─────────────────────────────────────────────────────────
heading "Building firmware"
cd "$DEVICE_DIR"
idf.py set-target esp32
info "Running idf.py build (this takes a few minutes on first run)"
idf.py build
info "Build complete."
# ── flash ──────────────────────────────────────────────────────────────────────
heading "Flash to device"
prompt "Flash firmware to device now? [y/N] "
read -r DO_FLASH
if [[ "$DO_FLASH" =~ ^[Yy]$ ]]; then
if [[ "$(uname -s)" == "Darwin" ]]; then
PORT_HINT="/dev/cu.usbserial-* or /dev/cu.SLAB_USBtoUART"
else
PORT_HINT="/dev/ttyUSB0 or /dev/ttyACM0"
fi
prompt "Serial port (e.g. $PORT_HINT, leave blank to auto-detect): "
read -r SERIAL_PORT
if [[ -n "$SERIAL_PORT" ]]; then
idf.py -p "$SERIAL_PORT" flash monitor
else
idf.py flash monitor
fi
fi
# ── SD card setup ──────────────────────────────────────────────────────────────
heading "SD card setup"
if [[ "$(uname -s)" == "Darwin" ]]; then
SD_HINT="/Volumes/KIDSBOX"
else
SD_HINT="/media/\$USER/KIDSBOX"
fi
prompt "Mount path of SD card (e.g. $SD_HINT, leave blank to skip): "
read -r SD_MOUNT
if [[ -n "$SD_MOUNT" ]]; then
[[ -d "$SD_MOUNT" ]] || die "Mount path not found: $SD_MOUNT"
info "Creating directory structure on SD card"
mkdir -p "$SD_MOUNT/www"
mkdir -p "$SD_MOUNT/internal"
mkdir -p "$SD_MOUNT/cache"
info "Copying web interface"
cp -r "$WWW_DIR/." "$SD_MOUNT/www/"
info "SD card contents:"
ls "$SD_MOUNT/"
echo ""
echo " SD card is ready."
echo " To configure a tag: create a file at $SD_MOUNT/internal/<tag-id>"
echo " containing either:"
echo " - An http:// URL (will be streamed and cached)"
echo " - A local path (e.g. /sdcard/music/song.mp3)"
echo " Tag IDs are shown in the serial monitor when a tag is scanned."
fi
# ── done ───────────────────────────────────────────────────────────────────────
heading "Done!"
echo ""
echo " Once running, open http://<device-ip> in your browser."
echo ""
+15 -57
View File
@@ -1,59 +1,17 @@
<!DOCTYPE html> <html>
<html lang="en"> <head>
<head> <title>Kidsbox</title>
<meta charset="UTF-8"> <link rel="stylesheet" src="styles.css">
<meta name="viewport" content="width=device-width, initial-scale=1"> </head>
<title>Kidsbox</title> <body>
<link rel="stylesheet" href="styles.css"> <h1>Hi, I'm a File!</h1>
</head>
<body>
<header>
<h1>&#127911; Kidsbox</h1>
</header>
<main> <section class="rfid">
<section class="card" id="section-playback"> <h3>RFID-ID</h3>
<h2>Now Playing</h2> <div class="rfid-id"></div>
<div class="track-uri" id="track-uri">—</div> <button class="rfid-refresh">refresh</button>
<div class="playback-controls"> </section>
<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 class="card" id="section-info"> <script src="main.js"></script>
<h2>Device Info</h2> </body>
<table class="info-table"> </html>
<tr><th>IP Address</th><td id="info-ip">—</td></tr>
<tr><th>Status</th><td id="info-playing">—</td></tr>
<tr><th>Cache Used</th><td id="info-cache">—</td></tr>
<tr><th>Free Heap</th><td id="info-heap">—</td></tr>
</table>
<button class="btn btn-secondary" id="btn-refresh-info">Refresh</button>
</section>
<section class="card" id="section-storage">
<h2>Storage</h2>
<div class="storage-header">
<span id="storage-summary">Loading…</span>
<button class="btn btn-danger" id="btn-clear-cache">Clear All</button>
</div>
<ul class="cache-list" id="cache-list"></ul>
</section>
<section class="card" id="section-bluetooth">
<h2>Bluetooth Speaker</h2>
<p class="bt-status" id="bt-status">Disabled</p>
<button class="btn btn-secondary" id="btn-bt-toggle">Enable</button>
<p class="note">Pair your phone to play music through the Kidsbox speaker.</p>
</section>
</main>
<script src="main.js"></script>
</body>
</html>
+9 -148
View File
@@ -1,152 +1,13 @@
'use strict'; console.log('hi');
/* ── helpers ──────────────────────────────────────────────────────────── */ const elements = {};
function fmt_bytes(n) { elements.rfidId = document.querySelector('.rfid-id');
if (n < 1024) return n + ' B'; elements.rfidRefresh = document.querySelector('.rfid-refresh');
if (n < 1024 * 1024) return (n / 1024).toFixed(1) + ' KB';
return (n / (1024 * 1024)).toFixed(1) + ' MB';
}
async function api(method, path, body) { elements.rfidRefresh.addEventListener('click', async () => {
const opts = { method }; const response = await fetch('/rfid'),
if (body) { rfidId = await response.text();
opts.headers = { 'Content-Type': 'application/x-www-form-urlencoded' };
opts.body = body;
}
const res = await fetch(path, opts);
return res.json();
}
/* ── device info ──────────────────────────────────────────────────────── */ elements.rfidId.innerText = rfidId;
});
async function refreshInfo() {
try {
const d = await api('GET', '/api/info');
document.getElementById('info-ip').textContent = d.ip ?? '—';
document.getElementById('info-playing').textContent = d.playing ? 'Playing' : 'Stopped';
document.getElementById('info-cache').textContent = fmt_bytes(d.cacheBytes ?? 0);
document.getElementById('info-heap').textContent = fmt_bytes(d.freeHeap ?? 0);
document.getElementById('track-uri').textContent = d.currentUri || '—';
document.getElementById('btn-pause').disabled = !d.playing;
document.getElementById('btn-stop').disabled = !d.playing;
const vol = d.volume ?? 50;
document.getElementById('volume-slider').value = vol;
document.getElementById('volume-label').textContent = vol;
} catch (e) {
console.error('Failed to fetch /api/info', e);
}
}
/* ── storage ──────────────────────────────────────────────────────────── */
async function refreshStorage() {
try {
const entries = await api('GET', '/api/storage');
const list = document.getElementById('cache-list');
const summary = document.getElementById('storage-summary');
list.innerHTML = '';
if (!Array.isArray(entries) || entries.length === 0) {
summary.textContent = 'No cached files';
return;
}
let total = 0;
entries.forEach(e => {
total += e.size ?? 0;
const li = document.createElement('li');
const key = document.createElement('span');
key.className = 'cache-key';
key.textContent = e.key;
const sz = document.createElement('span');
sz.className = 'cache-size';
sz.textContent = fmt_bytes(e.size ?? 0);
const del = document.createElement('button');
del.className = 'btn btn-danger';
del.style.cssText = 'padding:.2rem .5rem;font-size:.75rem';
del.textContent = '✕';
del.addEventListener('click', () => deleteEntry(e.key));
li.append(key, sz, del);
list.appendChild(li);
});
summary.textContent =
`${entries.length} file${entries.length !== 1 ? 's' : ''} — ${fmt_bytes(total)}`;
} catch (e) {
console.error('Failed to fetch /api/storage', e);
}
}
async function deleteEntry(key) {
await api('DELETE', `/api/storage?key=${encodeURIComponent(key)}`);
refreshStorage();
}
async function clearCache() {
if (!confirm('Delete all cached files?')) return;
await api('DELETE', '/api/storage');
refreshStorage();
}
/* ── playback ─────────────────────────────────────────────────────────── */
async function sendPlayback(action, value) {
const body = value !== undefined
? `action=${action}&value=${encodeURIComponent(value)}`
: `action=${action}`;
await api('POST', '/api/playback', body);
refreshInfo();
}
/* ── bluetooth ────────────────────────────────────────────────────────── */
async function refreshBt() {
try {
const d = await api('GET', '/api/bluetooth');
const status = document.getElementById('bt-status');
const btn = document.getElementById('btn-bt-toggle');
if (d.enabled) {
status.textContent = 'Enabled';
status.style.color = '#48bb78';
btn.textContent = 'Disable';
} else {
status.textContent = 'Disabled';
status.style.color = '#a0aec0';
btn.textContent = 'Enable';
}
} catch (e) {
console.error('Failed to fetch /api/bluetooth', e);
}
}
async function toggleBt() {
const enabled = document.getElementById('bt-status').textContent === 'Enabled';
await api('POST', '/api/bluetooth', `enabled=${!enabled}`);
refreshBt();
}
/* ── init ─────────────────────────────────────────────────────────────── */
document.addEventListener('DOMContentLoaded', () => {
refreshInfo();
refreshStorage();
refreshBt();
document.getElementById('btn-refresh-info').addEventListener('click', refreshInfo);
document.getElementById('btn-clear-cache').addEventListener('click', clearCache);
document.getElementById('btn-bt-toggle').addEventListener('click', toggleBt);
document.getElementById('btn-pause').addEventListener('click', () => sendPlayback('pause'));
document.getElementById('btn-stop').addEventListener('click', () => sendPlayback('stop'));
const slider = document.getElementById('volume-slider');
const label = document.getElementById('volume-label');
slider.addEventListener('input', () => { label.textContent = slider.value; });
slider.addEventListener('change', () => sendPlayback('volume', slider.value));
/* Auto-refresh info every 5 s while page is visible */
setInterval(() => { if (!document.hidden) refreshInfo(); }, 5000);
});
-125
View File
@@ -1,125 +0,0 @@
*, *::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; }