Compare commits

...
Author SHA1 Message Date
Claude 75dd63b56f Add parametric 3D-printable enclosure (OpenSCAD)
A printable reimagining of the wooden Kidsbox: rectangular cabinet with two
front speakers, removable top lid (NFC tag rests there, reader mounts beneath),
and swappable speaker mounts.

- kidsbox.scad: master model + part dispatcher (body/lid/speaker_adapter/grill)
- params.scad: all tunable dimensions in one place
- lib.scad: helper modules (rounded shell, bosses, bolt circles)
- Fixed front-panel speaker interface + swappable adapter so any driver fits
  by printing a new adapter (edit spk_* params, re-export)
- Internal mounts: 2x18650 cradle, ESP32-LyraT standoffs, RC522 under lid,
  3 buttons on lid, USB/charge + power-switch side ports, back-wall vents
- Snap-in speaker grills
- Makefile to export STLs and render previews (headless via xvfb)
- Exported STLs and preview renders included
- Link enclosure from main README

Dimensions are sensible defaults from the reference photos and common parts;
README flags the fit-critical values to verify before mass-printing.

https://claude.ai/code/session_01MhAnh68DPNnTjcKqjNYDmk
2026-06-13 10:39:55 +00:00
Claude 64475dbf5e Fix install.sh and setup.sh for macOS compatibility
install.sh:
- Add SCRIPT_DIR so .env is written to the repo root regardless of cwd
- Detect OS (macOS/Linux) and show platform-appropriate missing-tool hints
- Check for Xcode Command Line Tools on macOS (required by IDF install.sh)
- Handle pip3 absence by falling back to python3 -m pip
- Remove ninja from hard prerequisite list (IDF bundles its own)

setup.sh:
- Show macOS serial port hint (/dev/cu.usbserial-*) vs Linux (/dev/ttyUSB0)
- Show macOS SD card mount hint (/Volumes/) vs Linux (/media/$USER/)

https://claude.ai/code/session_01MhAnh68DPNnTjcKqjNYDmk
2026-06-13 07:34:36 +00:00
Claude 76b02b840c Add install.sh, setup.sh, and update README
install.sh: install ESP-IDF v5.2 + ESP-ADF v2.7, detect existing env,
  write .env file with IDF_PATH/ADF_PATH
setup.sh: prompt for WiFi creds, write sdkconfig.defaults, build,
  optionally flash and prepare SD card
README: rewrite to reflect current architecture (NFC URL streaming,
  web UI, no arduino-esp32), add roadmap and quick start

https://claude.ai/code/session_01MhAnh68DPNnTjcKqjNYDmk
2026-06-13 07:10:28 +00:00
19 changed files with 170613 additions and 46 deletions

No files matched your search

+105 -46
View File
@@ -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|
|-|-|
|![Frontal View](docs/img/front.jpeg)|![Side View](docs/img/side.jpeg)|
## 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).
![Enclosure](enclosure/img/hero.png)
---
## 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)
+46
View File
@@ -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
+145
View File
@@ -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.
![Assembly](img/hero.png)
> Modelled in [OpenSCAD](https://openscad.org). Everything is driven by
> [`params.scad`](params.scad) — change a few numbers, re-export, and print.
---
## What's in the box
| Part | Qty | File | Notes |
|---|---|---|---|
| Body / cabinet | 1 | `stl/body.stl` | Shell with all internal mounts |
| Lid | 1 | `stl/lid.stl` | Buttons + NFC reader mount underneath |
| Speaker adapter | 2 | `stl/speaker_adapter.stl` | **Swappable** — print to fit your driver |
| Speaker grill | 2 | `stl/grill.stl` | Snaps into the sound opening |
The body carries mounts for everything else:
- **2× 18650 battery holder** — open-top cradle with retaining lips (`batt_*` params)
- **ESP32-LyraT v4.3** — four floor standoffs (`lyrat_*` params)
- **RC522 NFC reader** — pocket on the lid underside, so a tag placed on top reads
through the thin (`lid_t = 2.4 mm`) PLA window
- **3 buttons** (vol−, play/pause, vol+) — 12 mm holes in a row near the lid's front edge
- **Speaker interface** — a fixed bolt pattern the swappable adapters mount to
- **Side ports** — USB/charging cutout and a power-switch hole
- **Vents** on the back wall
| Body | Lid (underside) |
|---|---|
| ![Body](img/body.png) | ![Lid](img/lid.png) |
---
## Printing
| Setting | Recommendation |
|---|---|
| Material | PLA or PETG (PLA is fine; keep metal away from the NFC window) |
| Layer height | 0.2 mm |
| Walls / perimeters | 3+ |
| Infill | 15–20 % |
| Supports | **Body:** none (print open-side up). **Lid:** none. **Adapter/Grill:** none. |
| Orientation | Body open-top up; lid top-face down; adapter & grill flat |
The body is ~190 × 115 × 110 mm, so it fits a 220 × 220 bed (Ender-3 class) and larger.
---
## Assembly
1. **Speakers** → bolt each driver to a `speaker_adapter` using the adapter's inner
screw holes, then bolt the adapter to the four bosses inside the front wall (M3).
2. **Grills** → press a `grill` into each sound opening from the front.
3. **Electronics** → screw the ESP32-LyraT onto the floor standoffs; drop the 18650
holder into the cradle; route the USB/charge lead to the side cutout.
4. **NFC reader** → screw the RC522 to the four posts under the lid.
5. **Buttons** → fit the three 12 mm momentary switches into the lid holes.
6. **Close up** → lower the lid (the lip locates it) and drive four M3 screws into the
corner bosses.
---
## Swapping speakers (the whole point)
The front wall never changes — it has a **fixed interface**: a `spk_sound_d` opening
and four bosses on a `spk_bolt_d` circle. Every speaker bolts to the box through an
**adapter** that you print to match *your* driver. To make a new adapter, edit the
adapter block in [`params.scad`](params.scad):
```scad
// ---- swappable speaker ADAPTER (edit for YOUR speaker) ----
spk_frame_d = 66; // your speaker's outer frame diameter
spk_cutout_d = 58; // cone clearance (a bit less than the cone surround)
spk_screw_count = 4; // your speaker's mounting-hole count
spk_screw_bolt_d = 60; // your speaker's mounting-hole bolt circle
```
Then re-export just that part:
```bash
openscad -D 'part="speaker_adapter"' -o stl/speaker_adapter.stl kidsbox.scad
```
Leave the `spk_sound_d` / `spk_bolt_d` (panel) values alone and your new adapter will
drop straight onto the same box.
| Speaker adapter (swappable) | Grill |
|---|---|
| ![Adapter](img/speaker_adapter.png) | ![Grill](img/grill.png) |
---
## Regenerating everything
```bash
make stl # export all STLs into stl/
make img # render preview PNGs (needs a display, or xvfb on a server)
make all
```
Or one part at a time:
```bash
openscad -D 'part="body"' -o stl/body.stl kidsbox.scad
openscad -D 'part="lid"' -o stl/lid.stl kidsbox.scad
openscad -D 'part="speaker_adapter"' -o stl/speaker_adapter.stl kidsbox.scad
openscad -D 'part="grill"' -o stl/grill.stl kidsbox.scad
```
---
## ⚠️ Verify before mass-printing
These dimensions are sensible defaults derived from the reference photos and common
parts, **not** from calipers on your exact hardware. Before printing the whole set,
print a quick test of the fit-critical features and confirm against your parts:
- ESP32-LyraT mounting-hole rectangle (`lyrat_hole_dx`, `lyrat_hole_dy`)
- RC522 mounting-hole rectangle (`bx`, `by` in the `rc522_mount()` module)
- Your 18650 holder footprint (`batt_holder_*`)
- Speaker frame/cone/bolt-circle (`spk_frame_d`, `spk_cutout_d`, `spk_screw_bolt_d`)
A cheap way to validate the speaker fit is to print one `speaker_adapter` first.
---
## Files
```
enclosure/
├── kidsbox.scad master model + part dispatcher
├── params.scad all tunable dimensions ← edit here
├── lib.scad helper modules
├── Makefile export/render targets
├── stl/ exported STLs
└── img/ rendered previews
```
Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

+275
View File
@@ -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();
+53
View File
@@ -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);
}
+96
View File
@@ -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;
+78850
View File
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+50682
View File
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
Executable
+149
View File
@@ -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 ""
Executable
+140
View File
@@ -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 ""