Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
75dd63b56f | ||
|
|
64475dbf5e | ||
|
|
76b02b840c | ||
|
|
afbc0e5e23 | ||
|
|
4f9946bd17 | ||
|
|
bf9852c345 |
No files matched your search
@@ -1,7 +0,0 @@
|
||||
[submodule "device/components/esp-idf-rc522"]
|
||||
path = device/components/esp-idf-rc522
|
||||
url = git@github.com:sweidac/esp-idf-rc522.git
|
||||
|
||||
[submodule "device/components/arduino-esp32"]
|
||||
path = device/components/arduino-esp32
|
||||
url = https://github.com/espressif/arduino-esp32.git
|
||||
@@ -1,76 +1,135 @@
|
||||
# Kidsbox
|
||||
|
||||
This is a battery-powered rfid-tag-controlled audio player meant to be used by kids.
|
||||
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.
|
||||
A battery-powered, RFID/NFC-tag-controlled audio player for kids.
|
||||
Place an NFC tag on the box — the mapped audio file or stream plays automatically.
|
||||
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|
|
||||
|-|-|
|
||||
|||
|
||||
|
||||
## Todo
|
||||
---
|
||||
|
||||
- Volume buttons
|
||||
- Playlists
|
||||
- Pausing
|
||||
- ... I have a lot of ideas :)
|
||||
## Quick Start
|
||||
|
||||
## Setup
|
||||
### 1. Install the development environment
|
||||
|
||||
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).
|
||||
2. Clone this repository and `cd` into the repository directory.
|
||||
3. `git submodule init --recursive`
|
||||
3. `cd device`
|
||||
4. Make sure that you have `idf.py` available in your environment. This is described in the install guide.
|
||||
5. Execute `idf.py build` to compile, `idf.py build flash monitor` to flash it to your board.
|
||||
```bash
|
||||
./install.sh
|
||||
```
|
||||
|
||||
This installs **ESP-IDF v5.2** and **ESP-ADF v2.7** into `~/esp/` and writes a `.env` file.
|
||||
Existing installations are detected automatically (via `$IDF_PATH` / `$ADF_PATH`).
|
||||
|
||||
### 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).
|
||||
|
||||

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

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