81 Commits

Author SHA1 Message Date
aa41334ba7 feat: Add SD multiblock write support
All checks were successful
Build RPi Pico firmware image / Build-Firmware (push) Successful in 4m45s
Check code formatting / Check-C-Format (push) Successful in 6s
Check code formatting / Check-Python-Flake8 (push) Successful in 10s
Check code formatting / Check-Bash-Shellcheck (push) Successful in 5s
Run unit tests on host / Run-Unit-Tests (push) Successful in 8s
Run pytests / Check-Pytest (push) Successful in 10s
Add support for CMD25 to write multiple sequential blocks in one go, in
an attempt to speed up uploads. It still needs to be benchmarked if this
actually results in a meaningful speedup.

Also fix logging from SD driver to be visible even when running under
micropython.

Signed-off-by: Matthias Blankertz <matthias@blankertz.org>
2026-01-13 22:41:34 +01:00
3537a2f1bb fix: Reduce file upload chunk size to 4k
All checks were successful
Build RPi Pico firmware image / Build-Firmware (push) Successful in 4m42s
Check code formatting / Check-C-Format (push) Successful in 7s
Check code formatting / Check-Python-Flake8 (push) Successful in 10s
Check code formatting / Check-Bash-Shellcheck (push) Successful in 5s
Run unit tests on host / Run-Unit-Tests (push) Successful in 8s
Run pytests / Check-Pytest (push) Successful in 11s
After seeing occasional memory allocation failures on file upload,
reduce chunk size to 4k again.

Signed-off-by: Matthias Blankertz <matthias@blankertz.org>
2026-01-13 22:38:37 +01:00
39a9c68aae Merge pull request 'misc-features' (#62) from misc-features into main
All checks were successful
Build RPi Pico firmware image / Build-Firmware (push) Successful in 4m45s
Check code formatting / Check-C-Format (push) Successful in 8s
Check code formatting / Check-Python-Flake8 (push) Successful in 10s
Check code formatting / Check-Bash-Shellcheck (push) Successful in 5s
Run unit tests on host / Run-Unit-Tests (push) Successful in 9s
Run pytests / Check-Pytest (push) Successful in 11s
Reviewed-on: #62
Reviewed-by: Stefan Kratochwil <kratochwil-la@gmx.de>
2026-01-13 21:31:18 +00:00
6dee7fff7e feat: Allow configuring WiFi security
All checks were successful
Build RPi Pico firmware image / Build-Firmware (push) Successful in 4m44s
Check code formatting / Check-C-Format (push) Successful in 7s
Check code formatting / Check-Python-Flake8 (push) Successful in 10s
Check code formatting / Check-Bash-Shellcheck (push) Successful in 4s
Run unit tests on host / Run-Unit-Tests (push) Successful in 9s
Run pytests / Check-Pytest (push) Successful in 11s
Allow choosing between the three security modes exposed by the
micropython cyw43 wifi driver. Also allow setting up security in AP
mode.

Improve the WiFi section of the configuration UI.

Signed-off-by: Matthias Blankertz <matthias@blankertz.org>
2026-01-06 14:59:12 +01:00
6976aa6963 fix[player]: Don't latch tag if no playlist exists
When the device is in 'tagstartstop' tag mode, and the user presents a
new tag to get the serial number and create a playlist using the web UI,
the playerapp still remembered the tag as the currently playing tag even
though no playlist was found and no playback is running. After the user
saves the playlist in the UI and puts the tag on the device again, they
expect the playback to start with the new playlist. Instead, nothing
happens, because this is counted as the 'stop' event of the tagstartstop
mode. The user would have to remove the tag and present it again (after
waiting for the tagtimeout) to play the new playlist.

Fix this unexpected behaviour by not storing the current tag into the
playing_tag field if no playlist existed for the tag.

Signed-off-by: Matthias Blankertz <matthias@blankertz.org>
2026-01-06 14:59:12 +01:00
763305c659 fix[frontend]: Reset upload UI elements, don't expand tree view
- Make sure the upload progess bar and file choser are reset when
  loading the file browser screen.
- Don't expand the directories in the tree view by default.

Signed-off-by: Matthias Blankertz <matthias@blankertz.org>
2026-01-06 14:59:12 +01:00
6d18437863 fix: Remove directory based db creation
Previously, if no tonberry.db existed on the SD card, the database was
initialized with a playlist for each directory containing mp3 files,
with the tag serial number matching the directory name. This was used
during development before the web UI to edit the playlist db existed.
It is no longer necessary, and confusing to the user when unusable
playlists are created when albums are preloaded onto the SD card before
putting it in the tonberry device.

Signed-off-by: Matthias Blankertz <matthias@blankertz.org>
2026-01-06 14:59:12 +01:00
2cf88b26ee fix: button 0 to shutdown device when in error state
All checks were successful
Build RPi Pico firmware image / Build-Firmware (push) Successful in 4m42s
Check code formatting / Check-C-Format (push) Successful in 7s
Check code formatting / Check-Python-Flake8 (push) Successful in 10s
Check code formatting / Check-Bash-Shellcheck (push) Successful in 5s
Run unit tests on host / Run-Unit-Tests (push) Successful in 8s
Run pytests / Check-Pytest (push) Successful in 10s
Signed-off-by: Matthias Blankertz <matthias@blankertz.org>
2025-12-27 15:23:48 +01:00
73da134a12 fix: webserver: file uploading
- Fix upload of files <= Request.max_body_length
  File uploads smaller than the limit were not given to the handler as a
  stream by microdot, instead the content is directly stored in the
  request.body.

- Refactor stream to file copy into a helper method

- Increase the copy buffer size to 16k

- Call app.reset_idle_timeout() periodically during file uploads to
  avoid the device turning off when uploading large files while on
  battery.

Signed-off-by: Matthias Blankertz <matthias@blankertz.org>
2025-12-27 15:23:48 +01:00
5c8a61eb27 feat: Allow configuring volume set at startup
Signed-off-by: Matthias Blankertz <matthias@blankertz.org>
2025-12-27 15:23:48 +01:00
4c85683fcb feat: Allow limiting LED brightness
Signed-off-by: Matthias Blankertz <matthias@blankertz.org>
2025-12-27 15:23:48 +01:00
355a8bd345 fix: allow 'reboot' to application when on on battery
Having to press the power button again to wake up the device is less
annoying to the user than not being able to apply settings when the
device is on battery.

Signed-off-by: Matthias Blankertz <matthias@blankertz.org>
2025-12-27 15:23:48 +01:00
f46c045589 feat: Allow limiting max. volume
Signed-off-by: Matthias Blankertz <matthias@blankertz.org>
2025-12-27 15:23:48 +01:00
fe1c1eadf7 feat: Add names to playlists
Fixes #63.

Signed-off-by: Matthias Blankertz <matthias@blankertz.org>
2025-12-27 15:23:48 +01:00
743188e1a4 fix: frontend: Replace generic 'Device admin' title
Signed-off-by: Matthias Blankertz <matthias@blankertz.org>
2025-12-27 15:23:48 +01:00
cd5939f4ee feat: Enable dualstack IPv4/IPv6 for microdot
Signed-off-by: Matthias Blankertz <matthias@blankertz.org>
2025-12-27 15:23:48 +01:00
02954cd87c feat: Visual feedback on LEDs during startup
Set the LEDs to a fixed color during bootup to show the different modes:
- Orange when the device is booting
- Red when button 0 is held and the device goes to a shell
- Blue when button 1 is held and the device goes to AP mode instead of
  joining the configured WiFi network
- Red blinking when the run() method throws an exception for any reason

Signed-off-by: Matthias Blankertz <matthias@blankertz.org>
2025-12-27 15:23:48 +01:00
58f8526d7e feat: Join an existing WiFi network
Add ssid and passphrase to config to configure the network to join. If
empty SSID is configured, it will create the "TonberryPicoAP..." network
in AP mode as before.

Holding the button 1 during startup will revert to AP mode regardless of
the current configuration.

Signed-off-by: Matthias Blankertz <matthias@blankertz.org>
2025-12-27 15:23:48 +01:00
d3aef1be32 fix: frontend: don't convert text that looks like an integer to integers
Signed-off-by: Matthias Blankertz <matthias@blankertz.org>
2025-12-23 12:00:05 +01:00
b9baa1c7d5 microdot: Update to v2.5.1
The important fix is an urldecode bug causing umlauts to be decoded
incorrectly from url arguments. This was fixed in v2.2.0, but let's just
update to the latest version.

Signed-off-by: Matthias Blankertz <matthias@blankertz.org>
2025-12-23 12:00:05 +01:00
67d7650923 fix: webserver: Catch and report IO errors on upload
Signed-off-by: Matthias Blankertz <matthias@blankertz.org>
2025-12-23 12:00:05 +01:00
43fd68779c feat: store git version in fw and show in web ui
Signed-off-by: Matthias Blankertz <matthias@blankertz.org>
2025-12-23 12:00:05 +01:00
704951074b feat: Long press VOL_DOWN button to shutdown/reset device
Signed-off-by: Matthias Blankertz <matthias@blankertz.org>
2025-12-21 18:31:13 +01:00
cac61f924f Merge branch 'file-upload' into mbl-next
All checks were successful
Build RPi Pico firmware image / Build-Firmware (push) Successful in 4m43s
Check code formatting / Check-C-Format (push) Successful in 7s
Check code formatting / Check-Python-Flake8 (push) Successful in 10s
Check code formatting / Check-Bash-Shellcheck (push) Successful in 5s
Run unit tests on host / Run-Unit-Tests (push) Successful in 9s
Run pytests / Check-Pytest (push) Successful in 11s
2025-12-21 18:31:04 +01:00
9320a3cff2 fix: Show reboot request response in UI
All checks were successful
Build RPi Pico firmware image / Build-Firmware (push) Successful in 4m40s
Check code formatting / Check-C-Format (push) Successful in 7s
Check code formatting / Check-Python-Flake8 (push) Successful in 9s
Check code formatting / Check-Bash-Shellcheck (push) Successful in 4s
Run unit tests on host / Run-Unit-Tests (push) Successful in 8s
Run pytests / Check-Pytest (push) Successful in 11s
Also make response from api more understandable for non-technical users.

Signed-off-by: Matthias Blankertz <matthias@blankertz.org>
2025-12-21 18:30:45 +01:00
65efebc5c2 feat: allow reboot commands only if usb cable is inserted 2025-12-21 18:30:45 +01:00
040ae4a731 fix: fix flake8 complaint
Signed-off-by: Matthias Blankertz <matthias@blankertz.org>
2025-12-21 18:30:45 +01:00
9cf044bc80 feat: frontend: Add reboot to bootloader button (for updates)
Some checks failed
Build RPi Pico firmware image / Build-Firmware (push) Successful in 4m47s
Check code formatting / Check-C-Format (push) Successful in 6s
Check code formatting / Check-Python-Flake8 (push) Failing after 9s
Check code formatting / Check-Bash-Shellcheck (push) Successful in 5s
Run unit tests on host / Run-Unit-Tests (push) Successful in 8s
Run pytests / Check-Pytest (push) Successful in 10s
Signed-off-by: Matthias Blankertz <matthias@blankertz.org>
2025-12-21 17:07:09 +01:00
da9843adb9 feat: /api/v1/reboot/[bootloader|application], confirm with pink LED pattern
Some checks failed
Build RPi Pico firmware image / Build-Firmware (push) Successful in 4m43s
Check code formatting / Check-C-Format (push) Successful in 8s
Check code formatting / Check-Python-Flake8 (push) Failing after 10s
Check code formatting / Check-Bash-Shellcheck (push) Successful in 5s
Run unit tests on host / Run-Unit-Tests (push) Successful in 8s
Run pytests / Check-Pytest (push) Successful in 11s
2025-12-21 16:57:37 +01:00
02150aec42 fix: frontend: Improve navigation on playlist edit screen
All checks were successful
Build RPi Pico firmware image / Build-Firmware (push) Successful in 4m43s
Check code formatting / Check-C-Format (push) Successful in 7s
Check code formatting / Check-Python-Flake8 (push) Successful in 9s
Check code formatting / Check-Bash-Shellcheck (push) Successful in 5s
Run unit tests on host / Run-Unit-Tests (push) Successful in 7s
Run pytests / Check-Pytest (push) Successful in 10s
Signed-off-by: Matthias Blankertz <matthias@blankertz.org>
2025-12-21 15:36:53 +01:00
7be038d0d1 feat: Allow deleting files and directories
All checks were successful
Build RPi Pico firmware image / Build-Firmware (push) Successful in 4m40s
Check code formatting / Check-C-Format (push) Successful in 7s
Check code formatting / Check-Python-Flake8 (push) Successful in 10s
Check code formatting / Check-Bash-Shellcheck (push) Successful in 4s
Run unit tests on host / Run-Unit-Tests (push) Successful in 8s
Run pytests / Check-Pytest (push) Successful in 10s
Signed-off-by: Matthias Blankertz <matthias@blankertz.org>
2025-12-21 15:12:31 +01:00
d96350c1a7 feat: frontend: Allow creating directories
All checks were successful
Build RPi Pico firmware image / Build-Firmware (push) Successful in 4m38s
Check code formatting / Check-C-Format (push) Successful in 7s
Check code formatting / Check-Python-Flake8 (push) Successful in 9s
Check code formatting / Check-Bash-Shellcheck (push) Successful in 4s
Run unit tests on host / Run-Unit-Tests (push) Successful in 8s
Run pytests / Check-Pytest (push) Successful in 10s
Signed-off-by: Matthias Blankertz <matthias@blankertz.org>
2025-12-21 14:23:34 +01:00
eec3703b7e feat: Extend audiofiles API
Extend the audiofiles GET API to return both directories and audio
files. Also change the JSON format to include the name and type of all
entries.

Signed-off-by: Matthias Blankertz <matthias@blankertz.org>
2025-12-21 14:22:38 +01:00
25ac3f0687 feat: Add API and frontend to upload files
All checks were successful
Build RPi Pico firmware image / Build-Firmware (push) Successful in 4m40s
Check code formatting / Check-C-Format (push) Successful in 7s
Check code formatting / Check-Python-Flake8 (push) Successful in 9s
Check code formatting / Check-Bash-Shellcheck (push) Successful in 4s
Run unit tests on host / Run-Unit-Tests (push) Successful in 8s
Run pytests / Check-Pytest (push) Successful in 11s
Signed-off-by: Matthias Blankertz <matthias@blankertz.org>
2025-12-21 13:29:47 +01:00
3367bba0c5 Merge branch 'misc-fixes' into mbl-next
All checks were successful
Build RPi Pico firmware image / Build-Firmware (push) Successful in 4m38s
Check code formatting / Check-C-Format (push) Successful in 7s
Check code formatting / Check-Python-Flake8 (push) Successful in 10s
Check code formatting / Check-Bash-Shellcheck (push) Successful in 4s
Run unit tests on host / Run-Unit-Tests (push) Successful in 8s
Run pytests / Check-Pytest (push) Successful in 11s
2025-12-21 12:23:23 +01:00
c555ad94f0 fix: Increase watchdog timeout
All checks were successful
Build RPi Pico firmware image / Build-Firmware (push) Successful in 4m42s
Check code formatting / Check-C-Format (push) Successful in 7s
Check code formatting / Check-Python-Flake8 (push) Successful in 10s
Check code formatting / Check-Bash-Shellcheck (push) Successful in 11s
Run unit tests on host / Run-Unit-Tests (push) Successful in 19s
Run pytests / Check-Pytest (push) Successful in 31s
Workaround for #60 until it is fixed.

Signed-off-by: Matthias Blankertz <matthias@blankertz.org>
2025-12-20 22:31:51 +01:00
10ec080e5f fix: app: Go to idle mode when playlist end is reached
Signed-off-by: Matthias Blankertz <matthias@blankertz.org>
2025-12-20 22:31:51 +01:00
fb01a8aebb micropython: Fix filename unicode issues, enable compile-commands.json
Signed-off-by: Matthias Blankertz <matthias@blankertz.org>
2025-12-20 22:31:51 +01:00
2aa2249238 Merge branch 'more-frontend' into mbl-next 2025-12-20 20:28:56 +01:00
3e275a0aee Merge branch 'mbl/backend-for-frontend' into mbl-next 2025-12-20 20:27:58 +01:00
71949bdd1a Merge branch '30-frontend'
All checks were successful
Build RPi Pico firmware image / Build-Firmware (push) Successful in 4m39s
Check code formatting / Check-C-Format (push) Successful in 7s
Check code formatting / Check-Python-Flake8 (push) Successful in 9s
Check code formatting / Check-Bash-Shellcheck (push) Successful in 5s
Run unit tests on host / Run-Unit-Tests (push) Successful in 8s
Run pytests / Check-Pytest (push) Successful in 11s
2025-12-20 20:26:33 +01:00
8070c0e113 feat: frontend: list of playlists screen, playlist screen, add tracks screen
Some checks failed
Build RPi Pico firmware image / Build-Firmware (push) Successful in 4m38s
Check code formatting / Check-C-Format (push) Successful in 7s
Check code formatting / Check-Python-Flake8 (push) Failing after 9s
Check code formatting / Check-Bash-Shellcheck (push) Successful in 4s
Run unit tests on host / Run-Unit-Tests (push) Successful in 8s
Run pytests / Check-Pytest (push) Successful in 11s
Signed-off-by: Matthias Blankertz <matthias@blankertz.org>
2025-12-20 20:17:06 +01:00
e31aabbefc Merge branch '30-frontend'
Some checks failed
Build RPi Pico firmware image / Build-Firmware (push) Successful in 4m40s
Check code formatting / Check-C-Format (push) Successful in 7s
Check code formatting / Check-Python-Flake8 (push) Failing after 9s
Check code formatting / Check-Bash-Shellcheck (push) Successful in 4s
Run unit tests on host / Run-Unit-Tests (push) Successful in 8s
Run pytests / Check-Pytest (push) Successful in 10s
2025-12-20 20:15:42 +01:00
94aa84879f Merge pull request 'webapi-last-tag-uid' (#58) from webapi-last-tag-uid into main
Some checks failed
Check code formatting / Check-C-Format (push) Has been cancelled
Check code formatting / Check-Python-Flake8 (push) Has been cancelled
Check code formatting / Check-Bash-Shellcheck (push) Has been cancelled
Build RPi Pico firmware image / Build-Firmware (push) Has been cancelled
Run unit tests on host / Run-Unit-Tests (push) Has been cancelled
Run pytests / Check-Pytest (push) Has been cancelled
Reviewed-on: #58
Reviewed-by: Matthias Blankertz <matthias@blankertz.org>
2025-12-20 19:14:20 +00:00
059b705a38 fix: webserver: Use streaming response for filesystem listing
All checks were successful
Build RPi Pico firmware image / Build-Firmware (push) Successful in 4m37s
Check code formatting / Check-C-Format (push) Successful in 7s
Check code formatting / Check-Python-Flake8 (push) Successful in 10s
Check code formatting / Check-Bash-Shellcheck (push) Successful in 4s
Run unit tests on host / Run-Unit-Tests (push) Successful in 8s
Run pytests / Check-Pytest (push) Successful in 10s
Signed-off-by: Matthias Blankertz <matthias@blankertz.org>
2025-12-20 19:26:18 +01:00
3213ec8f66 feat: webserver: set and delete playlists
Signed-off-by: Matthias Blankertz <matthias@blankertz.org>
2025-12-20 19:26:16 +01:00
e2ca9e5139 feat: api endpoint to get playlist properties
Some checks failed
Build RPi Pico firmware image / Build-Firmware (push) Successful in 4m36s
Check code formatting / Check-C-Format (push) Successful in 7s
Check code formatting / Check-Python-Flake8 (push) Failing after 9s
Check code formatting / Check-Bash-Shellcheck (push) Successful in 4s
Run unit tests on host / Run-Unit-Tests (push) Successful in 7s
Run pytests / Check-Pytest (push) Successful in 10s
2025-12-20 16:52:37 +01:00
070cf887ab feat(wip): api endpoint to get playlist properties
Some checks failed
Build RPi Pico firmware image / Build-Firmware (push) Successful in 4m47s
Check code formatting / Check-C-Format (push) Successful in 8s
Check code formatting / Check-Python-Flake8 (push) Failing after 9s
Check code formatting / Check-Bash-Shellcheck (push) Successful in 4s
Run unit tests on host / Run-Unit-Tests (push) Successful in 7s
Run pytests / Check-Pytest (push) Successful in 10s
2025-12-20 15:40:52 +01:00
28846c9274 wip: api endpoint to list all available audio files
Some checks failed
Build RPi Pico firmware image / Build-Firmware (push) Successful in 4m37s
Check code formatting / Check-C-Format (push) Successful in 8s
Check code formatting / Check-Python-Flake8 (push) Failing after 9s
Check code formatting / Check-Bash-Shellcheck (push) Successful in 5s
Run unit tests on host / Run-Unit-Tests (push) Successful in 8s
Run pytests / Check-Pytest (push) Successful in 10s
Iterative approach. Currently only lists mp3 files.
2025-12-20 14:40:07 +01:00
51cb2c3a68 feat: api endpoint for reading all available playlists
Some checks failed
Build RPi Pico firmware image / Build-Firmware (push) Successful in 4m36s
Check code formatting / Check-C-Format (push) Successful in 7s
Check code formatting / Check-Python-Flake8 (push) Failing after 10s
Check code formatting / Check-Bash-Shellcheck (push) Successful in 5s
Run unit tests on host / Run-Unit-Tests (push) Successful in 8s
Run pytests / Check-Pytest (push) Successful in 10s
2025-12-19 18:33:42 +01:00
32e996e446 build: add requirements.txt for host python deps
All checks were successful
Build RPi Pico firmware image / Build-Firmware (push) Successful in 4m40s
Check code formatting / Check-C-Format (push) Successful in 7s
Check code formatting / Check-Python-Flake8 (push) Successful in 10s
Check code formatting / Check-Bash-Shellcheck (push) Successful in 5s
Run unit tests on host / Run-Unit-Tests (push) Successful in 8s
Run pytests / Check-Pytest (push) Successful in 11s
Signed-off-by: Matthias Blankertz <matthias@blankertz.org>
2025-12-17 18:37:34 +01:00
32bf6a8d68 always return content type application/json
All checks were successful
Build RPi Pico firmware image / Build-Firmware (push) Successful in 4m36s
Check code formatting / Check-C-Format (push) Successful in 7s
Check code formatting / Check-Python-Flake8 (push) Successful in 10s
Check code formatting / Check-Bash-Shellcheck (push) Successful in 5s
Run unit tests on host / Run-Unit-Tests (push) Successful in 8s
Run pytests / Check-Pytest (push) Successful in 10s
2025-12-16 23:13:04 +01:00
49197c8ca4 Merge pull request 'feat: Move tagmode setting to config.json, remove playlistdb settings' (#57) from unify-config into main
All checks were successful
Build RPi Pico firmware image / Build-Firmware (push) Successful in 4m38s
Check code formatting / Check-C-Format (push) Successful in 6s
Check code formatting / Check-Python-Flake8 (push) Successful in 9s
Check code formatting / Check-Bash-Shellcheck (push) Successful in 5s
Run unit tests on host / Run-Unit-Tests (push) Successful in 8s
Run pytests / Check-Pytest (push) Successful in 11s
Reviewed-on: #57
Reviewed-by: Stefan Kratochwil <kratochwil-la@gmx.de>
2025-12-16 21:55:28 +00:00
e2f9287ebd feat: last connected tag uid available at /api/v1/last_tag_uid
All checks were successful
Build RPi Pico firmware image / Build-Firmware (push) Successful in 4m39s
Check code formatting / Check-C-Format (push) Successful in 8s
Check code formatting / Check-Python-Flake8 (push) Successful in 10s
Check code formatting / Check-Bash-Shellcheck (push) Successful in 5s
Run unit tests on host / Run-Unit-Tests (push) Successful in 9s
Run pytests / Check-Pytest (push) Successful in 11s
2025-12-16 22:41:55 +01:00
19dff763bd feat: webserver: keep alive; move playback write prot to handler
- Reset the app idle timer when interacting with the webapp, so that the
  device does not turn off while the web ui is used.

- Handle denying put/post while playback is active centrally in the
  before_request handler, so that it does not need to be copy/pasted
  into every request handler.

Signed-off-by: Matthias Blankertz <matthias@blankertz.org>
2025-12-16 22:34:06 +01:00
b20a31ccf4 feat: webserver: redirect / to /index.html
All checks were successful
Build RPi Pico firmware image / Build-Firmware (push) Successful in 4m41s
Check code formatting / Check-C-Format (push) Successful in 7s
Check code formatting / Check-Python-Flake8 (push) Successful in 9s
Check code formatting / Check-Bash-Shellcheck (push) Successful in 4s
Run unit tests on host / Run-Unit-Tests (push) Successful in 7s
Run pytests / Check-Pytest (push) Successful in 10s
Signed-off-by: Matthias Blankertz <matthias@blankertz.org>
2025-12-16 21:01:18 +01:00
8a2d621c7d feat: webserver: keep alive; move playback write prot to handler
- Reset the app idle timer when interacting with the webapp, so that the
  device does not turn off while the web ui is used.

- Handle denying put/post while playback is active centrally in the
  before_request handler, so that it does not need to be copy/pasted
  into every request handler.

Signed-off-by: Matthias Blankertz <matthias@blankertz.org>
2025-12-16 20:48:01 +01:00
e447902001 feat: Move tagmode setting to config.json, remove playlistdb settings
All checks were successful
Build RPi Pico firmware image / Build-Firmware (push) Successful in 4m35s
Check code formatting / Check-C-Format (push) Successful in 7s
Check code formatting / Check-Python-Flake8 (push) Successful in 9s
Check code formatting / Check-Bash-Shellcheck (push) Successful in 6s
Run unit tests on host / Run-Unit-Tests (push) Successful in 8s
Run pytests / Check-Pytest (push) Successful in 10s
Signed-off-by: Matthias Blankertz <matthias@blankertz.org>
2025-12-16 20:41:40 +01:00
93ea5036dc feat: config frontend
Implement nicer configuration UI:

- Show human-readable names for config settings
- Show error message received from server if storing settings fails
- Show appropriate input elements for enum choice and numerical inputs

Signed-off-by: Matthias Blankertz <matthias@blankertz.org>
2025-12-16 20:41:40 +01:00
aee5a48967 fix: config: Merge defaults into config at load time
Merge defaults into config at load time to ensure that all config
options show up in the configuration dialog, even if they were added
after the local configuration was last changed.

Also use the merge method to merge the local config with the new config
in set_config, ensuring the config contains all keys even if the
submitted config leaves some out.

Signed-off-by: Matthias Blankertz <matthias@blankertz.org>
2025-12-16 20:41:40 +01:00
936020df58 feat: Add and deploy frontend
Signed-off-by: Matthias Blankertz <matthias@blankertz.org>
2025-12-16 20:41:40 +01:00
768b630722 Merge pull request 'misc-stability-improvements' (#56) from misc-stability-improvements into main
All checks were successful
Build RPi Pico firmware image / Build-Firmware (push) Successful in 4m36s
Check code formatting / Check-C-Format (push) Successful in 7s
Check code formatting / Check-Python-Flake8 (push) Successful in 10s
Check code formatting / Check-Bash-Shellcheck (push) Successful in 5s
Run unit tests on host / Run-Unit-Tests (push) Successful in 7s
Run pytests / Check-Pytest (push) Successful in 10s
Reviewed-on: #56
Reviewed-by: Stefan Kratochwil <kratochwil-la@gmx.de>
2025-12-16 19:25:44 +00:00
e0ff9c54bc refactor: timer: split out two helpers from _timer_worker
All checks were successful
Build RPi Pico firmware image / Build-Firmware (push) Successful in 4m35s
Check code formatting / Check-C-Format (push) Successful in 8s
Check code formatting / Check-Python-Flake8 (push) Successful in 10s
Check code formatting / Check-Bash-Shellcheck (push) Successful in 5s
Run unit tests on host / Run-Unit-Tests (push) Successful in 8s
Run pytests / Check-Pytest (push) Successful in 11s
Signed-off-by: Matthias Blankertz <matthias@blankertz.org>
2025-12-16 20:19:30 +01:00
c0b9ef2961 fix: timer: handle modifications to timers when _timer_worker is already scheduled correctly
All checks were successful
Build RPi Pico firmware image / Build-Firmware (push) Successful in 4m45s
Check code formatting / Check-C-Format (push) Successful in 8s
Check code formatting / Check-Python-Flake8 (push) Successful in 11s
Check code formatting / Check-Bash-Shellcheck (push) Successful in 6s
Run unit tests on host / Run-Unit-Tests (push) Successful in 10s
Run pytests / Check-Pytest (push) Successful in 12s
Signed-off-by: Matthias Blankertz <matthias@blankertz.org>
2025-12-07 13:25:35 +01:00
e23f8bd34c fix: exceptions in callback should not terminate caller
All checks were successful
Build RPi Pico firmware image / Build-Firmware (push) Successful in 4m46s
Check code formatting / Check-C-Format (push) Successful in 9s
Check code formatting / Check-Python-Flake8 (push) Successful in 11s
Check code formatting / Check-Bash-Shellcheck (push) Successful in 6s
Run unit tests on host / Run-Unit-Tests (push) Successful in 10s
Run pytests / Check-Pytest (push) Successful in 13s
Signed-off-by: Matthias Blankertz <matthias@blankertz.org>
2025-12-07 13:09:34 +01:00
97e9742c75 feat: Add basic watchdog timer
Signed-off-by: Matthias Blankertz <matthias@blankertz.org>
2025-12-07 13:09:34 +01:00
c687e3a977 Merge pull request 'feat: copy build unix executable to other build artifacts' (#54) from feat/publish_unix_build into main
All checks were successful
Build RPi Pico firmware image / Build-Firmware (push) Successful in 4m40s
Check code formatting / Check-C-Format (push) Successful in 8s
Check code formatting / Check-Python-Flake8 (push) Successful in 10s
Check code formatting / Check-Bash-Shellcheck (push) Successful in 6s
Run unit tests on host / Run-Unit-Tests (push) Successful in 10s
Run pytests / Check-Pytest (push) Successful in 12s
Reviewed-on: #54
2025-12-03 20:11:49 +00:00
aa1a02ce54 feat: copy build unix executable to other build artifacts
All checks were successful
Build RPi Pico firmware image / Build-Firmware (push) Successful in 4m45s
Check code formatting / Check-C-Format (push) Successful in 8s
Check code formatting / Check-Python-Flake8 (push) Successful in 11s
Check code formatting / Check-Bash-Shellcheck (push) Successful in 8s
Run unit tests on host / Run-Unit-Tests (push) Successful in 10s
Run pytests / Check-Pytest (push) Successful in 12s
2025-12-03 21:00:26 +01:00
3e888790e4 Merge pull request 'fix: Block certain requests during playback' (#55) from block-requests-during-playback into main
All checks were successful
Build RPi Pico firmware image / Build-Firmware (push) Successful in 4m43s
Check code formatting / Check-C-Format (push) Successful in 8s
Check code formatting / Check-Python-Flake8 (push) Successful in 11s
Check code formatting / Check-Bash-Shellcheck (push) Successful in 6s
Run unit tests on host / Run-Unit-Tests (push) Successful in 9s
Run pytests / Check-Pytest (push) Successful in 12s
Reviewed-on: #55
Reviewed-by: Stefan Kratochwil <kratochwil-la@gmx.de>
2025-12-03 19:59:46 +00:00
96759c999c fix: Block certain requests during playback
All checks were successful
Build RPi Pico firmware image / Build-Firmware (push) Successful in 4m42s
Check code formatting / Check-C-Format (push) Successful in 9s
Check code formatting / Check-Python-Flake8 (push) Successful in 12s
Check code formatting / Check-Bash-Shellcheck (push) Successful in 7s
Run unit tests on host / Run-Unit-Tests (push) Successful in 10s
Run pytests / Check-Pytest (push) Successful in 13s
Probably due to memory/stack overrun, some more involved web API
requests cause the system to hang if performed during playback. For now,
block these requests during playback.

Signed-off-by: Matthias Blankertz <matthias@blankertz.org>
2025-12-03 20:36:41 +01:00
82ed3a3c2e Merge pull request '27-configuration-storage' (#53) from 27-configuration-storage into main
All checks were successful
Build RPi Pico firmware image / Build-Firmware (push) Successful in 4m42s
Check code formatting / Check-C-Format (push) Successful in 8s
Check code formatting / Check-Python-Flake8 (push) Successful in 10s
Check code formatting / Check-Bash-Shellcheck (push) Successful in 6s
Run unit tests on host / Run-Unit-Tests (push) Successful in 9s
Run pytests / Check-Pytest (push) Successful in 12s
Reviewed-on: #53
Reviewed-by: Stefan Kratochwil <kratochwil-la@gmx.de>
2025-12-03 19:20:26 +00:00
a7e58853bb feat: Add api/v1/config to get, put config
All checks were successful
Build RPi Pico firmware image / Build-Firmware (push) Successful in 4m40s
Check code formatting / Check-C-Format (push) Successful in 9s
Check code formatting / Check-Python-Flake8 (push) Successful in 12s
Check code formatting / Check-Bash-Shellcheck (push) Successful in 6s
Run unit tests on host / Run-Unit-Tests (push) Successful in 10s
Run pytests / Check-Pytest (push) Successful in 12s
Signed-off-by: Matthias Blankertz <matthias@blankertz.org>
2025-12-03 20:12:29 +01:00
2e1bc7782b fix: Ensure each timer can only be scheduled once
All checks were successful
Build RPi Pico firmware image / Build-Firmware (push) Successful in 4m58s
Check code formatting / Check-C-Format (push) Successful in 8s
Check code formatting / Check-Python-Flake8 (push) Successful in 11s
Check code formatting / Check-Bash-Shellcheck (push) Successful in 6s
Run unit tests on host / Run-Unit-Tests (push) Successful in 10s
Run pytests / Check-Pytest (push) Successful in 13s
If a timer is scheduled with the timer manager while it is already
scheduled, the new exipry time should override the prev. expiry time
instead of adding a second instance of the same timer function.

Signed-off-by: Matthias Blankertz <matthias@blankertz.org>
2025-12-01 20:25:30 +01:00
fa0e23ee87 feat: Add play/pause and prev track function
Signed-off-by: Matthias Blankertz <matthias@blankertz.org>
2025-12-01 20:25:30 +01:00
856bf34161 feat: Make button mapping configurable
- Remove Pin to button mapping from hwconfig, replace with a simple list
  of Pins to which buttons are attached
- Add BUTTON_MAP to config.json which maps indices in the HW button list
  to button names
- Update utils.Buttons to use the button map to connect the correct pins
  to the matching key codes

Signed-off-by: Matthias Blankertz <matthias@blankertz.org>
2025-12-01 20:25:30 +01:00
83deb1b4c2 feat: Replace hardcoded timeouts in app with configurable
Signed-off-by: Matthias Blankertz <matthias@blankertz.org>
2025-12-01 20:25:30 +01:00
2225906664 feat: Move LED_COUNT from hwconfig to config.json
Signed-off-by: Matthias Blankertz <matthias@blankertz.org>
2025-12-01 20:25:30 +01:00
176fc66c17 feat: config store
Load a config.json file from the root of the littlefs flash filesystem.

Signed-off-by: Matthias Blankertz <matthias@blankertz.org>
2025-12-01 20:25:30 +01:00
79a970e70a Merge pull request 'freeze-python-code' (#52) from freeze-python-code into main
All checks were successful
Build RPi Pico firmware image / Build-Firmware (push) Successful in 4m38s
Check code formatting / Check-C-Format (push) Successful in 39m39s
Check code formatting / Check-Python-Flake8 (push) Successful in 12s
Check code formatting / Check-Bash-Shellcheck (push) Successful in 6s
Run unit tests on host / Run-Unit-Tests (push) Successful in 10s
Run pytests / Check-Pytest (push) Successful in 13s
Reviewed-on: #52
Reviewed-by: Stefan Kratochwil <kratochwil-la@gmx.de>
2025-12-01 19:25:06 +00:00
19afb2f936 ci: Fix build.yaml for changed output dir
All checks were successful
Build RPi Pico firmware image / Build-Firmware (push) Successful in 4m39s
Check code formatting / Check-C-Format (push) Successful in 8s
Check code formatting / Check-Python-Flake8 (push) Successful in 11s
Check code formatting / Check-Bash-Shellcheck (push) Successful in 6s
Run unit tests on host / Run-Unit-Tests (push) Successful in 8s
Run pytests / Check-Pytest (push) Successful in 11s
Signed-off-by: Matthias Blankertz <matthias@blankertz.org>
2025-11-28 18:19:50 +01:00
3c23fc1446 feat: freeze all python code, restructure build
Add all python code run on the device to the manifest.py to freeze
it. This has two benefits:
- It precompiles the bytecode, so it is smaller and faster
- All code (C and python) is now in the firmware image, leaving the
  littlefs filesystem on the flash free for user settings.

This requires changing the way the hardware variants are handled. There
is now only one _filesystem_ image, but instead there are different
_firmware_ images for each variant.

Signed-off-by: Matthias Blankertz <matthias@blankertz.org>
2025-11-28 18:19:50 +01:00
31 changed files with 2056 additions and 211 deletions

View File

@@ -11,8 +11,10 @@ jobs:
uses: actions/checkout@v4
- name: Initialize submodules
run: cd software && ./update-submodules.sh
- name: Prepare venv
run: python -m venv build-venv && source build-venv/bin/activate && pip install freezefs
- name: Build
run: cd software && ./build.sh
run: source build-venv/bin/activate && cd software && ./build.sh
- name: Upload firmware
uses: actions/upload-artifact@v3
with:

View File

@@ -0,0 +1,29 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2025 Matthias Blankertz <matthias@blankertz.org>
#include "py/obj.h"
#include "py/runtime.h"
#include "py/objstr.h"
#ifndef TONBERRY_GIT_REVISION
#define TONBERRY_GIT_REVISION "unknown"
#endif
#ifndef TONBERRY_VERSION
#define TONBERRY_VERSION "unknown"
#endif
static const MP_DEFINE_STR_OBJ(tonberry_git_revision_obj, TONBERRY_GIT_REVISION);
static const MP_DEFINE_STR_OBJ(tonberry_version_obj, TONBERRY_VERSION);
static const mp_rom_map_elem_t board_module_globals_table[] = {
{MP_ROM_QSTR(MP_QSTR___name__), MP_ROM_QSTR(MP_QSTR_board)},
{MP_ROM_QSTR(MP_QSTR_revision), MP_ROM_PTR(&tonberry_git_revision_obj)},
{MP_ROM_QSTR(MP_QSTR_version), MP_ROM_PTR(&tonberry_version_obj)},
};
static MP_DEFINE_CONST_DICT(board_module_globals, board_module_globals_table);
const mp_obj_module_t board_cmodule = {
.base = {&mp_type_module},
.globals = (mp_obj_dict_t *)&board_module_globals,
};
MP_REGISTER_MODULE(MP_QSTR_board, board_cmodule);

View File

@@ -19,5 +19,8 @@ module("rp2_neopixel.py", "../../modules")
module("main.py", "../../src")
module("app.py", "../../src")
module("mp3player.py", "../../src")
module("webserver.py", "../../src")
package("utils", base_path="../../src")
package("nfc", base_path="../../src")
module("frozen_frontend.py", "../../build")

View File

@@ -20,3 +20,22 @@ set(GEN_PINS_CSV_ARG --board-csv "${GEN_PINS_BOARD_CSV}")
add_link_options("-Wl,--print-memory-usage")
set(PICO_USE_FASTEST_SUPPORTED_CLOCK 1)
find_program(GIT git)
execute_process(COMMAND ${GIT} rev-parse HEAD
WORKING_DIRECTORY "${CMAKE_CURRENT_LIST_DIR}"
OUTPUT_VARIABLE TONBERRY_GIT_REVISION
OUTPUT_STRIP_TRAILING_WHITESPACE
)
execute_process(COMMAND ${GIT} describe --match 'v*' --always --dirty
WORKING_DIRECTORY "${CMAKE_CURRENT_LIST_DIR}"
OUTPUT_VARIABLE TONBERRY_VERSION
OUTPUT_STRIP_TRAILING_WHITESPACE
)
set(MICROPY_SOURCE_BOARD "${CMAKE_CURRENT_LIST_DIR}/board.c")
set(MICROPY_DEF_BOARD
TONBERRY_GIT_REVISION="${TONBERRY_GIT_REVISION}"
TONBERRY_VERSION="${TONBERRY_VERSION}")

View File

@@ -17,6 +17,7 @@ set -eu
)
BUILDDIR=lib/micropython/ports/rp2/build-TONBERRY_RPI_PICO_W/
BUILDDIR_UNIX=lib/micropython/ports/unix/build-tonberry_unix/
OUTDIR=$(pwd)/build
mkdir -p "$OUTDIR"
FS_STAGE_DIR=$(mktemp -d)
@@ -24,6 +25,11 @@ mkdir "$FS_STAGE_DIR"/fs
trap 'rm -rf $FS_STAGE_DIR' EXIT
tools/mklittlefs/mklittlefs -p 256 -s 868352 -c "$FS_STAGE_DIR"/fs "$FS_STAGE_DIR"/filesystem.bin
FRONTEND_STAGE_DIR=$(mktemp -d)
trap 'rm -rf $FRONTEND_STAGE_DIR' EXIT
gzip -c frontend/index.html > "$FRONTEND_STAGE_DIR"/index.html.gz
python -m freezefs "$FRONTEND_STAGE_DIR" build/frozen_frontend.py --target=/frontend
for hwconfig in boards/RPI_PICO_W/manifest-*.py; do
hwconfig_base=$(basename "$hwconfig")
hwname=${hwconfig_base##manifest-}
@@ -51,5 +57,9 @@ for hwconfig in boards/RPI_PICO_W/manifest-*.py; do
$PICOTOOL uf2 convert $BUILDDIR/firmware-filesystem.bin "$OUTDIR"/firmware-filesystem-"$hwname".uf2
done
cp "$BUILDDIR_UNIX"/micropython "$OUTDIR"/micropython-tonberry_unix
chmod u+x "$OUTDIR"/micropython-tonberry_unix
echo "Output in" "${OUTDIR}"/firmware-*.uf2
echo "Images with filesystem in" "${OUTDIR}"/firmware-filesystem-*.uf2
echo "Unix build in" "${OUTDIR}"/micropython-tonberry_unix

View File

@@ -2,6 +2,8 @@
set -eu
TOPDIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )
check_command()
{
name=$1
@@ -16,7 +18,7 @@ check_command lsusb
check_command picotool
DEVICEPATH=/dev/disk/by-label/RPI-RP2
IMAGEPATH=lib/micropython/ports/rp2/build-TONBERRY_RPI_PICO_W/
IMAGEPATH=${TOPDIR}/build
REVISION=Rev1
flash_via_mountpoint()
@@ -83,7 +85,7 @@ if [ $# -gt 0 ]; then
usage
fi
IMAGEFILE="$IMAGEPATH"/firmware-filesystem-$REVISION.uf2
IMAGEFILE="$IMAGEPATH"/firmware-$REVISION.uf2
if [ "$FLASH_VIA_MOUNTPOINT" -eq 0 ]; then
flash_via_picotool

1069
software/frontend/index.html Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,11 +1,14 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2025 Matthias Blankertz <matthias@blankertz.org>
#include <stdarg.h>
#include "py/obj.h"
#include "sd.h"
// Include MicroPython API.
#include "py/mperrno.h"
#include "py/mpprint.h"
#include "py/runtime.h"
// This module is RP2 specific
@@ -13,6 +16,14 @@
#include <string.h>
void sd_printf(const char *fmt, ...)
{
va_list ap;
va_start(ap, fmt);
mp_vprintf(&mp_sys_stdout_print, fmt, ap);
va_end(ap);
}
const mp_obj_type_t sdcard_type;
struct sdcard_obj {
mp_obj_base_t base;
@@ -89,11 +100,14 @@ static mp_obj_t sdcard_writeblocks(mp_obj_t self_obj, mp_obj_t block_obj, mp_obj
if (bufinfo.len % SD_SECTOR_SIZE != 0)
mp_raise_ValueError(MP_ERROR_TEXT("Buffer length is invalid"));
const int nblocks = bufinfo.len / SD_SECTOR_SIZE;
for (int block = 0; block < nblocks; block++) {
// TODO: Implement CMD25 write multiple blocks
if (!sd_writeblock(&self->sd_context, start_block + block, bufinfo.buf + block * SD_SECTOR_SIZE))
mp_raise_OSError(MP_EIO);
bool ret;
if (nblocks > 1) {
ret = sd_writeblocks(&self->sd_context, start_block, nblocks, bufinfo.buf);
} else {
ret = sd_writeblock(&self->sd_context, start_block, bufinfo.buf);
}
if (!ret)
mp_raise_OSError(MP_EIO);
return mp_const_none;
}
static MP_DEFINE_CONST_FUN_OBJ_3(sdcard_writeblocks_obj, sdcard_writeblocks);

View File

@@ -6,7 +6,7 @@
#include <stdio.h>
#include <string.h>
// #define SD_DEBUG
extern void sd_printf(const char *fmt, ...);
#define SD_R1_ILLEGAL_COMMAND (1 << 2)
@@ -26,14 +26,14 @@ static bool sd_early_init(void)
for (int i = 0; i < 500; ++i) {
if (sd_cmd(0, 0, 1, &buf)) {
#ifdef SD_DEBUG
printf("CMD0 resp %02hhx\n", buf);
sd_printf("CMD0 resp %02x\n", buf);
#endif
if (buf == 0x01) {
return true;
}
}
#ifdef SD_DEBUG
printf("CMD0 timeout, try again...\n");
sd_printf("CMD0 timeout, try again...\n");
#endif
}
return false;
@@ -44,14 +44,14 @@ static bool sd_check_interface_condition(void)
uint8_t buf[5];
if (sd_cmd(8, 0x000001AA, 5, buf)) {
if ((buf[3] & 0xf) != 0x1 || buf[4] != 0xAA) {
printf("sd_init: check interface condition failed\n");
sd_printf("sd_init: check interface condition failed\n");
return false;
}
} else {
if (buf[0] & SD_R1_ILLEGAL_COMMAND) {
printf("sd_init: check interface condition returned illegal command - old card?\n");
sd_printf("sd_init: check interface condition returned illegal command - old card?\n");
} else {
printf("sd_init: check interface condition failed\n");
sd_printf("sd_init: check interface condition failed\n");
return false;
}
}
@@ -72,12 +72,12 @@ static bool sd_send_op_cond(void)
if (!result) {
if (use_acmd && buf & SD_R1_ILLEGAL_COMMAND) {
#ifdef SD_DEBUG
printf("sd_init: card does not understand ACMD41, try CMD1...\n");
sd_printf("sd_init: card does not understand ACMD41, try CMD1...\n");
#endif
use_acmd = false;
continue;
} else if (buf != 0x01) {
printf("sd_init: send_op_cond failed\n");
sd_printf("sd_init: send_op_cond failed\n");
return false;
} else {
continue;
@@ -87,7 +87,7 @@ static bool sd_send_op_cond(void)
return true;
}
}
printf("sd_init: send_op_cond: timeout waiting for !idle\n");
sd_printf("sd_init: send_op_cond: timeout waiting for !idle\n");
return false;
}
@@ -107,7 +107,7 @@ static void sd_dump_cid [[maybe_unused]] (void)
const uint8_t crc = sd_crc7(15, buf);
const uint8_t card_crc = buf[15] >> 1;
if (card_crc != crc) {
printf("CRC mismatch: Got %02hhx, expected %02hhx\n", card_crc, crc);
sd_printf("CRC mismatch: Got %02x, expected %02x\n", card_crc, crc);
// Some cheap SD cards always report CRC=0, don't fail in that case
if (card_crc != 0) {
return;
@@ -122,8 +122,8 @@ static void sd_dump_cid [[maybe_unused]] (void)
uint32_t psn = buf[9] << 24 | buf[10] << 16 | buf[11] << 8 | buf[12];
int mdt_year = 2000 + ((buf[13] & 0xf) << 4 | (buf[14] & 0xf0) >> 4);
int mdt_month = buf[14] & 0x0f;
printf("CID: mid: %02hhx, oid: %.2s, pnm: %.5s, prv: %02hhx, psn: %08" PRIx32 ", mdt_year: %d, mdt_month: %d\n",
mid, oid, pnm, prv, psn, mdt_year, mdt_month);
sd_printf("CID: mid: %02x, oid: %.2s, pnm: %.5s, prv: %02x, psn: %08" PRIx32 ", mdt_year: %d, mdt_month: %d\n",
mid, oid, pnm, prv, psn, mdt_year, mdt_month);
}
}
@@ -131,13 +131,13 @@ static bool sd_read_csd(struct sd_context *sd_context)
{
uint8_t buf[16];
if (!sd_cmd_read(9, 0, 16, buf)) {
printf("Failed to read CSD\n");
sd_printf("Failed to read CSD\n");
return false;
}
const uint8_t crc = sd_crc7(15, buf);
const uint8_t card_crc = buf[15] >> 1;
if (card_crc != crc) {
printf("CRC mismatch: Got %02hhx, expected %02hhx\n", card_crc, crc);
sd_printf("CRC mismatch: Got %02x, expected %02x\n", card_crc, crc);
// Some cheap SD cards always report CRC=0, don't fail in that case
if (card_crc != 0) {
return false;
@@ -151,12 +151,12 @@ static bool sd_read_csd(struct sd_context *sd_context)
switch (csd_ver) {
case 0: {
if (sd_context->sdhc_sdxc) {
printf("sd_init: Got CSD v1.0 but card is SDHC/SDXC?\n");
sd_printf("sd_init: Got CSD v1.0 but card is SDHC/SDXC?\n");
return false;
}
const unsigned read_bl_len = buf[5] & 0xf;
if (read_bl_len < 9 || read_bl_len > 11) {
printf("Invalid read_bl_len in CSD 1.0\n");
sd_printf("Invalid read_bl_len in CSD 1.0\n");
return false;
}
blocksize = 1 << (buf[5] & 0xf);
@@ -176,15 +176,15 @@ static bool sd_read_csd(struct sd_context *sd_context)
break;
}
case 2: {
printf("sd_init: Got CSD v3.0, but SDUC does not support SPI.\n");
sd_printf("sd_init: Got CSD v3.0, but SDUC does not support SPI.\n");
return false;
}
}
sd_context->blocks = blocks;
sd_context->blocksize = blocksize;
#ifdef SD_DEBUG
printf("CSD version %u.0, blocksize %u, blocks %u, capacity %llu MiB, max speed %u\n", version, blocksize, blocks,
((uint64_t)blocksize * blocks) / (1024 * 1024), max_speed);
sd_printf("CSD version %u.0, blocksize %u, blocks %u, capacity %u MiB, max speed %u\n", version, blocksize, blocks,
(uint32_t)(((uint64_t)blocksize * blocks) / (1024 * 1024)), max_speed);
#endif
return true;
}
@@ -205,11 +205,11 @@ bool sd_init(struct sd_context *sd_context, int mosi, int miso, int sck, int ss,
uint32_t ocr;
if (!sd_read_ocr(&ocr)) {
printf("sd_init: read OCR failed\n");
sd_printf("sd_init: read OCR failed\n");
goto out_spi;
}
if ((ocr & 0x00380000) != 0x00380000) {
printf("sd_init: unsupported card voltage range\n");
sd_printf("sd_init: unsupported card voltage range\n");
goto out_spi;
}
@@ -219,11 +219,11 @@ bool sd_init(struct sd_context *sd_context, int mosi, int miso, int sck, int ss,
sd_spi_set_bitrate(rate);
if (!sd_read_ocr(&ocr)) {
printf("sd_init: read OCR failed\n");
sd_printf("sd_init: read OCR failed\n");
goto out_spi;
}
if (!(ocr & (1 << 31))) {
printf("sd_init: card not powered up but !idle?\n");
sd_printf("sd_init: card not powered up but !idle?\n");
goto out_spi;
}
sd_context->sdhc_sdxc = (ocr & (1 << 30));
@@ -234,20 +234,20 @@ bool sd_init(struct sd_context *sd_context, int mosi, int miso, int sck, int ss,
if (sd_context->blocksize != SD_SECTOR_SIZE) {
if (sd_context->blocksize != 1024 && sd_context->blocksize != 2048) {
printf("sd_init: Unsupported block size %u\n", sd_context->blocksize);
sd_printf("sd_init: Unsupported block size %u\n", sd_context->blocksize);
goto out_spi;
}
// Attempt SET_BLOCKLEN command
uint8_t resp[1];
if (!sd_cmd(16, SD_SECTOR_SIZE, 1, resp)) {
printf("sd_init: SET_BLOCKLEN failed\n");
sd_printf("sd_init: SET_BLOCKLEN failed\n");
goto out_spi;
}
// Successfully set blocksize to SD_SECTOR_SIZE, adjust context
sd_context->blocks *= sd_context->blocksize / SD_SECTOR_SIZE;
#ifdef SD_DEBUG
printf("Adjusted blocksize from %u to 512, card now has %u blocks\n", sd_context->blocksize,
sd_context->blocks);
sd_printf("Adjusted blocksize from %u to 512, card now has %u blocks\n", sd_context->blocksize,
sd_context->blocks);
#endif
sd_context->blocksize = SD_SECTOR_SIZE;
}
@@ -307,7 +307,7 @@ bool sd_readblock_complete(struct sd_context *sd_context)
bool sd_readblock_is_complete(struct sd_context *sd_context) { return sd_cmd_read_is_complete(); }
bool sd_writeblock(struct sd_context *sd_context, size_t sector_num, uint8_t buffer[const static SD_SECTOR_SIZE])
bool sd_writeblock(struct sd_context *sd_context, const size_t sector_num, uint8_t buffer[const static SD_SECTOR_SIZE])
{
if (!sd_context->initialized || sector_num >= sd_context->blocks)
return false;
@@ -319,3 +319,20 @@ bool sd_writeblock(struct sd_context *sd_context, size_t sector_num, uint8_t buf
}
return sd_cmd_write(24, addr, SD_SECTOR_SIZE, buffer);
}
bool sd_writeblocks(struct sd_context *sd_context, const size_t sector_num, const size_t sectors, uint8_t *const buffer)
{
if (!sd_context->initialized || sector_num + sectors >= sd_context->blocks)
return false;
if (!sd_context->sdhc_sdxc) {
// Don't use multi-block writes for SDSC for now
// Need to configure WRITE_BL_LEN correctly
for (size_t sector = 0; sector < sectors; ++sector) {
if (!sd_writeblock(sd_context, sector_num + sector, buffer + sector * SD_SECTOR_SIZE))
return false;
}
return true;
}
return sd_cmd_write_multiple(25, sector_num, sectors, SD_SECTOR_SIZE, buffer);
}

View File

@@ -24,3 +24,4 @@ bool sd_readblock_complete(struct sd_context *context);
bool sd_readblock_is_complete(struct sd_context *context);
bool sd_writeblock(struct sd_context *context, size_t sector_num, uint8_t buffer[const static SD_SECTOR_SIZE]);
bool sd_writeblocks(struct sd_context *sd_context, const size_t sector_num, const size_t sectors, uint8_t *buffer);

View File

@@ -12,6 +12,8 @@
#include <pico/time.h>
#include <string.h>
extern void sd_printf(const char *fmt, ...);
typedef enum { DMA_READ_TOKEN, DMA_READ, DMA_IDLE } sd_dma_state;
struct sd_dma_context {
@@ -222,7 +224,7 @@ bool sd_cmd_read_complete(void)
sd_spi_read_blocking(0xff, &buf, 1);
if (sd_spi_context.sd_dma_context.read_token_buf != 0xfe) {
#ifdef SD_DEBUG
printf("read failed: invalid read token %02hhx\n", sd_spi_context.sd_dma_context.read_token_buf);
sd_printf("read failed: invalid read token %02x\n", sd_spi_context.sd_dma_context.read_token_buf);
#endif
return false;
}
@@ -231,7 +233,7 @@ bool sd_cmd_read_complete(void)
const uint16_t act_crc = sd_spi_context.sd_dma_context.crc_buf[0] << 8 | sd_spi_context.sd_dma_context.crc_buf[1];
if (act_crc != expect_crc) {
#ifdef SD_DEBUG
printf("read CRC fail: got %04hx, expected %04hx\n", act_crc, expect_crc);
sd_printf("read CRC fail: got %04x, expected %04x\n", act_crc, expect_crc);
#endif
return false;
}
@@ -239,10 +241,9 @@ bool sd_cmd_read_complete(void)
return true;
}
bool sd_cmd_write(uint8_t cmd, uint32_t arg, unsigned datalen, uint8_t data[const static datalen])
static bool sd_cmd_write_begin(uint8_t cmd, uint32_t arg)
{
uint8_t buf[2];
const uint16_t crc = sd_crc16(datalen, data);
uint8_t buf[1];
sd_spi_cmd_send(cmd, arg);
// Read up to 8 garbage bytes (0xff), followed by R1 (MSB is zero)
bool got_r1 = false;
@@ -253,9 +254,20 @@ bool sd_cmd_write(uint8_t cmd, uint32_t arg, unsigned datalen, uint8_t data[cons
break;
}
}
if (!got_r1 || buf[0] != 0x00)
goto abort;
buf[0] = 0xfe;
if (!got_r1 || buf[0] != 0x00) {
#ifdef SD_DEBUG
sd_printf("write cmd fail: %02x\n", buf[0]);
#endif
return false;
}
return true;
}
static bool sd_cmd_write_block(uint8_t token, unsigned datalen, uint8_t data[const static datalen])
{
uint8_t buf[2];
const uint16_t crc = sd_crc16(datalen, data);
buf[0] = token;
sd_spi_write_blocking(buf, 1);
sd_spi_write_blocking(data, datalen);
buf[0] = crc >> 8;
@@ -264,11 +276,16 @@ bool sd_cmd_write(uint8_t cmd, uint32_t arg, unsigned datalen, uint8_t data[cons
sd_spi_read_blocking(0xff, buf, 1);
if ((buf[0] & 0x1f) != 0x5) {
#ifdef SD_DEBUG
printf("Write fail: %2hhx\n", buf[0]);
sd_printf("Write fail: %2x\n", buf[0]);
#endif
goto abort;
return false;
}
return true;
}
static bool sd_cmd_write_wait_nbusy(void)
{
uint8_t buf[1];
int timeout = 0;
bool got_done = false;
for (timeout = 0; timeout < 131072; ++timeout) {
@@ -279,18 +296,73 @@ bool sd_cmd_write(uint8_t cmd, uint32_t arg, unsigned datalen, uint8_t data[cons
}
}
#ifdef SD_DEBUG
printf("dbg write end: %d, %2hhx\n", timeout, buf[0]);
sd_printf("dbg write end: %d, %2x\n", timeout, buf[0]);
#endif
if (!got_done)
return got_done;
}
bool sd_cmd_write(uint8_t cmd, uint32_t arg, unsigned datalen, uint8_t data[const static datalen])
{
#ifdef SD_DEBUG
sd_printf("write 1 block at %u\n", arg);
#endif
uint8_t buf[2];
if (!sd_cmd_write_begin(cmd, arg))
goto abort;
if (!sd_cmd_write_block(0xfe, datalen, data))
goto abort;
if (!sd_cmd_write_wait_nbusy())
goto abort;
gpio_put(sd_spi_context.ss, true);
sd_spi_read_blocking(0xff, buf, 1);
#ifdef SD_DEBUG
sd_printf("write ok\n");
#endif
return true;
abort:
gpio_put(sd_spi_context.ss, true);
sd_spi_read_blocking(0xff, buf, 1);
#ifdef SD_DEBUG
sd_printf("write fail\n");
#endif
return false;
}
bool sd_cmd_write_multiple(uint8_t cmd, uint32_t arg, unsigned blocks, unsigned datalen, uint8_t *const data)
{
#ifdef SD_DEBUG
sd_printf("write %u blocks at %u\n", blocks, arg);
#endif
uint8_t buf[2];
if (!sd_cmd_write_begin(cmd, arg))
goto abort;
for (unsigned i = 0; i < blocks; ++i) {
if (!sd_cmd_write_block(0b11111100, datalen, data + datalen * i))
goto abort;
if (!sd_cmd_write_wait_nbusy())
goto abort;
}
buf[0] = 0b11111101;
buf[1] = 0xff;
sd_spi_write_blocking(buf, 2);
if (!sd_cmd_write_wait_nbusy())
goto abort;
gpio_put(sd_spi_context.ss, true);
sd_spi_read_blocking(0xff, buf, 1);
#ifdef SD_DEBUG
sd_printf("write ok\n");
#endif
return true;
abort:
gpio_put(sd_spi_context.ss, true);
sd_spi_read_blocking(0xff, buf, 1);
#ifdef SD_DEBUG
sd_printf("write fail\n");
#endif
return false;
}

View File

@@ -27,3 +27,4 @@ bool sd_cmd_read_complete(void);
bool sd_cmd_read_is_complete(void);
bool sd_cmd_write(uint8_t cmd, uint32_t arg, unsigned datalen, uint8_t data[const static datalen]);
bool sd_cmd_write_multiple(uint8_t cmd, uint32_t arg, unsigned blocks, unsigned datalen, uint8_t *const data);

View File

@@ -0,0 +1 @@
freezefs

View File

@@ -6,19 +6,21 @@ import time
from utils import TimerManager
Dependencies = namedtuple('Dependencies', ('mp3player', 'nfcreader', 'buttons', 'playlistdb', 'hwconfig', 'leds'))
Dependencies = namedtuple('Dependencies', ('mp3player', 'nfcreader', 'buttons', 'playlistdb', 'hwconfig', 'leds',
'config'))
# Should be ~ 6dB steps
VOLUME_CURVE = [1, 2, 4, 8, 16, 32, 63, 126, 251]
# Should be ~ 3dB steps
VOLUME_CURVE = [1, 2, 3, 4, 6, 8, 11, 16, 23, 32, 45, 64, 91, 128, 181, 255]
class PlayerApp:
class TagStateMachine:
def __init__(self, parent, timer_manager):
def __init__(self, parent, timer_manager, timeout=5000):
self.parent = parent
self.timer_manager = timer_manager
self.current_tag = None
self.current_tag_time = time.ticks_ms()
self.timeout = timeout
def onTagChange(self, new_tag):
if new_tag is not None:
@@ -31,7 +33,7 @@ class PlayerApp:
self.current_tag = new_tag
self.parent.onNewTag(new_tag)
else:
self.timer_manager.schedule(time.ticks_ms() + 5000, self.onTagRemoveDelay)
self.timer_manager.schedule(time.ticks_ms() + self.timeout, self.onTagRemoveDelay)
def onTagRemoveDelay(self):
if self.current_tag is not None:
@@ -40,18 +42,31 @@ class PlayerApp:
def __init__(self, deps: Dependencies):
self.timer_manager = TimerManager()
self.tag_state_machine = self.TagStateMachine(self, self.timer_manager)
self.config = deps.config(self)
self.tag_timeout_ms = self.config.get_tag_timeout() * 1000
self.idle_timeout_ms = self.config.get_idle_timeout() * 1000
self.tag_state_machine = self.TagStateMachine(self, self.timer_manager, self.tag_timeout_ms)
self.player = deps.mp3player(self)
self.nfc = deps.nfcreader(self.tag_state_machine)
self.playlist_db = deps.playlistdb(self)
self.hwconfig = deps.hwconfig(self)
self.leds = deps.leds(self)
self.tag_mode = self.playlist_db.getSetting('tagmode')
self.tag_mode = self.config.get_tagmode()
self.volume_max = self.config.get_volume_max()
self.volume_pos = 3 # fallback if config.get_volume_boot is nonsense
try:
for idx, val in enumerate(VOLUME_CURVE):
if val >= self.config.get_volume_boot():
self.volume_pos = idx
break
except (TypeError, ValueError):
pass
self.playing_tag = None
self.playlist = None
self.buttons = deps.buttons(self) if deps.buttons is not None else None
self.mp3file = None
self.volume_pos = 3
self.paused = False
self.playing = False
self.player.set_volume(VOLUME_CURVE[self.volume_pos])
self._onIdle()
@@ -67,7 +82,7 @@ class PlayerApp:
uid_str = b''.join('{:02x}'.format(x).encode() for x in new_tag)
if self.tag_mode == 'tagremains' or (self.tag_mode == 'tagstartstop' and new_tag != self.playing_tag):
self._set_playlist(uid_str)
self.playing_tag = new_tag
self.playing_tag = new_tag if self.playlist is not None else None
elif self.tag_mode == 'tagstartstop':
print('Tag presented again, stopping playback')
self._unset_playlist()
@@ -84,18 +99,21 @@ class PlayerApp:
def onButtonPressed(self, what):
assert self.buttons is not None
if what == self.buttons.VOLUP:
self.volume_pos = min(self.volume_pos + 1, len(VOLUME_CURVE) - 1)
self.player.set_volume(VOLUME_CURVE[self.volume_pos])
new_volume = min(self.volume_pos + 1, len(VOLUME_CURVE) - 1)
if VOLUME_CURVE[new_volume] <= self.volume_max:
self.volume_pos = new_volume
self.player.set_volume(VOLUME_CURVE[self.volume_pos])
elif what == self.buttons.VOLDOWN:
self.volume_pos = max(self.volume_pos - 1, 0)
self.player.set_volume(VOLUME_CURVE[self.volume_pos])
elif what == self.buttons.NEXT:
self._play_next()
elif what == self.buttons.PREV:
self._play_prev()
elif what == self.buttons.PLAY_PAUSE:
self._pause_toggle()
def onPlaybackDone(self):
assert self.mp3file is not None
self.mp3file.close()
self.mp3file = None
self._play_next()
def onIdleTimeout(self):
@@ -103,7 +121,14 @@ class PlayerApp:
self.hwconfig.power_off()
else:
# Check again in a minute
self.timer_manager.schedule(time.ticks_ms() + 60*1000, self.onIdleTimeout)
self.timer_manager.schedule(time.ticks_ms() + self.idle_timeout_ms, self.onIdleTimeout)
def reset_idle_timeout(self):
if not self.playing:
self.timer_manager.schedule(time.ticks_ms() + self.idle_timeout_ms, self.onIdleTimeout)
def is_playing(self) -> bool:
return self.playing
def _set_playlist(self, tag: bytes):
if self.playlist is not None:
@@ -123,9 +148,16 @@ class PlayerApp:
self.playlist = None
def _play_next(self):
filename = self.playlist.getNextPath() if self.playlist is not None else None
self._play(filename)
if filename is None:
self.playlist = None
self.playing_tag = None
def _play_prev(self):
if self.playlist is None:
return
filename = self.playlist.getNextPath()
filename = self.playlist.getPrevPath()
self._play(filename)
if filename is None:
self.playlist = None
@@ -139,14 +171,40 @@ class PlayerApp:
self._onIdle()
if filename is not None:
print(f'Playing {filename!r}')
self.mp3file = open(filename, 'rb')
try:
self.mp3file = open(filename, 'rb')
except OSError as ex:
print(f"Could not play file {filename}: {ex}")
return
self.player.play(self.mp3file, offset)
self.paused = False
self._onActive()
def _pause_toggle(self):
if self.playlist is None:
return
if self.paused:
self._play(self.playlist.getCurrentPath(), self.pause_offset)
else:
self.pause_offset = self.player.stop()
self.paused = True
self._onIdle()
def _onIdle(self):
self.timer_manager.schedule(time.ticks_ms() + 60*1000, self.onIdleTimeout)
self.timer_manager.schedule(time.ticks_ms() + self.idle_timeout_ms, self.onIdleTimeout)
self.leds.set_state(self.leds.IDLE)
self.playing = False
def _onActive(self):
self.timer_manager.cancel(self.onIdleTimeout)
self.leds.set_state(self.leds.PLAYING)
self.playing = True
def get_nfc(self):
return self.nfc
def get_playlist_db(self):
return self.playlist_db
def get_leds(self):
return self.leds

View File

@@ -28,13 +28,14 @@ RC522_SS = Pin.board.GP13
# WS2812
LED_DIN = Pin.board.GP16
LED_COUNT = 1
# Buttons
BUTTON_VOLUP = Pin.board.GP17
BUTTON_VOLDOWN = Pin.board.GP19
BUTTON_NEXT = Pin.board.GP18
BUTTON_POWER = Pin.board.GP21
BUTTONS = [Pin.board.GP17,
Pin.board.GP18,
Pin.board.GP19,
Pin.board.GP20,
Pin.board.GP21,
]
# Power
POWER_EN = Pin.board.GP22

View File

@@ -27,13 +27,12 @@ RC522_SS = Pin.board.GP13
# WS2812
LED_DIN = Pin.board.GP16
LED_COUNT = 1
# Buttons
BUTTON_VOLUP = Pin.board.GP17
BUTTON_VOLDOWN = Pin.board.GP19
BUTTON_NEXT = Pin.board.GP18
BUTTON_POWER = None
BUTTONS = [Pin.board.GP17,
Pin.board.GP18,
Pin.board.GP19,
]
# Power
POWER_EN = None

View File

@@ -3,22 +3,22 @@
import aiorepl # type: ignore
import asyncio
from errno import ENOENT
import machine
import micropython
import network
import os
import time
import ubinascii
import sys
# Own modules
import app
from audiocore import AudioContext
import frozen_frontend # noqa: F401
from mfrc522 import MFRC522
from mp3player import MP3Player
from nfc import Nfc
from rp2_neopixel import NeoPixel
from utils import BTreeFileManager, Buttons, SDContext, TimerManager, LedManager
from utils import BTreeFileManager, Buttons, SDContext, TimerManager, LedManager, Configuration
from webserver import start_webserver
try:
@@ -36,14 +36,22 @@ hwconfig.board_init()
machine.mem32[0x40030000 + 0x00] = 0x10
def setup_wifi():
def setup_wifi(ssid='', passphrase='', security=network.WLAN.SEC_WPA_WPA2):
network.hostname("TonberryPico")
wlan = network.WLAN(network.WLAN.IF_AP)
wlan.config(ssid=f"TonberryPicoAP_{machine.unique_id().hex()}", security=wlan.SEC_OPEN)
wlan.active(True)
if ssid is None or ssid == '':
apname = f"TonberryPicoAP_{machine.unique_id().hex()}"
print(f"Create AP {apname}")
wlan = network.WLAN(network.WLAN.IF_AP)
wlan.config(ssid=apname, password=passphrase if passphrase is not None else '', security=security)
wlan.active(True)
else:
print(f"Connect to SSID {ssid} with passphrase {passphrase}...")
wlan = network.WLAN()
wlan.active(True)
wlan.connect(ssid, passphrase if passphrase is not None else '', security=security)
# disable power management
wlan.config(pm=network.WLAN.PM_NONE)
# configure power management
wlan.config(pm=network.WLAN.PM_PERFORMANCE)
mac = ubinascii.hexlify(network.WLAN().config('mac'), ':').decode()
print(f" mac: {mac}")
@@ -53,30 +61,48 @@ def setup_wifi():
print(f"ifconfig: {wlan.ifconfig()}")
async def wdt_task(wdt):
# TODO: more checking of app health
# Right now this only protects against the asyncio executor crashing completely
while True:
await asyncio.sleep_ms(100)
wdt.feed()
DB_PATH = '/sd/tonberry.db'
config = Configuration()
# Setup LEDs
np = NeoPixel(hwconfig.LED_DIN, config.get_led_count(), sm=1)
led_max = config.get_led_max()
np.fill((led_max, led_max, 0))
np.write()
def run():
asyncio.new_event_loop()
# Setup LEDs
np = NeoPixel(hwconfig.LED_DIN, hwconfig.LED_COUNT, sm=1)
# Wifi with default config
setup_wifi()
start_webserver()
if machine.Pin(hwconfig.BUTTONS[1], machine.Pin.IN, machine.Pin.PULL_UP).value() == 0:
np.fill((0, 0, led_max))
np.write()
# Force default access point
setup_wifi('', '', network.WLAN.SEC_OPEN)
else:
secstring = config.get_wifi_security()
security = network.WLAN.SEC_WPA_WPA2
if secstring == 'open':
security = network.WLAN.SEC_OPEN
elif secstring == 'wpa_wpa2':
security = network.WLAN.SEC_WPA_WPA2
elif secstring == 'wpa3':
security = network.WLAN.SEC_WPA3
elif secstring == 'wpa2_wpa3':
security = network.WLAN.SEC_WPA2_WPA3
setup_wifi(config.get_wifi_ssid(), config.get_wifi_passphrase(), security)
# Setup MP3 player
with SDContext(mosi=hwconfig.SD_DI, miso=hwconfig.SD_DO, sck=hwconfig.SD_SCK, ss=hwconfig.SD_CS,
baudrate=hwconfig.SD_CLOCKRATE):
# Temporary hack: build database from folders if no database exists
# Can be removed once playlists can be created via API
try:
_ = os.stat(DB_PATH)
except OSError as ex:
if ex.errno == ENOENT:
print("No playlist DB found, trying to build DB from tag dirs")
builddb()
with BTreeFileManager(DB_PATH) as playlistdb, \
AudioContext(hwconfig.I2S_DIN, hwconfig.I2S_DCLK, hwconfig.I2S_LRCLK) as audioctx:
@@ -87,39 +113,42 @@ def run():
# Setup app
deps = app.Dependencies(mp3player=lambda the_app: MP3Player(audioctx, the_app),
nfcreader=lambda the_app: Nfc(reader, the_app),
buttons=lambda the_app: Buttons(the_app, pin_volup=hwconfig.BUTTON_VOLUP,
pin_voldown=hwconfig.BUTTON_VOLDOWN,
pin_next=hwconfig.BUTTON_NEXT),
buttons=lambda the_app: Buttons(the_app, config, hwconfig),
playlistdb=lambda _: playlistdb,
hwconfig=lambda _: hwconfig,
leds=lambda _: LedManager(np))
leds=lambda _: LedManager(np, config),
config=lambda _: config)
the_app = app.PlayerApp(deps)
start_webserver(config, the_app)
# Start
wdt = machine.WDT(timeout=2000)
asyncio.create_task(aiorepl.task({'timer_manager': TimerManager(),
'app': the_app}))
asyncio.create_task(wdt_task(wdt))
asyncio.get_event_loop().run_forever()
def builddb():
"""
For testing, build a playlist db based on the previous tag directory format.
Can be removed once uploading files / playlist via the web api is possible.
"""
try:
os.unlink(DB_PATH)
except OSError:
pass
with BTreeFileManager(DB_PATH) as db:
for name, type_, _, _ in os.ilistdir(b'/sd'):
if type_ != 0x4000:
continue
fl = [b'/sd/' + name + b'/' + x for x in os.listdir(b'/sd/' + name) if x.endswith(b'.mp3')]
db.createPlaylistForTag(name, fl)
os.sync()
def error_blink():
while True:
if machine.Pin(hwconfig.BUTTONS[0], machine.Pin.IN, machine.Pin.PULL_UP).value() == 0:
machine.reset()
np.fill((led_max, 0, 0))
np.write()
time.sleep_ms(500)
np.fill((0, 0, 0))
np.write()
time.sleep_ms(500)
if __name__ == '__main__':
time.sleep(1)
if machine.Pin(hwconfig.BUTTON_VOLUP, machine.Pin.IN, machine.Pin.PULL_UP).value() != 0:
run()
if machine.Pin(hwconfig.BUTTONS[0], machine.Pin.IN, machine.Pin.PULL_UP).value() != 0:
try:
run()
except Exception as ex:
sys.print_exception(ex)
error_blink()
else:
np.fill((led_max, 0, 0))
np.write()

View File

@@ -6,6 +6,7 @@ Copyright (c) 2025 Matthias Blankertz <matthias@blankertz.org>
import asyncio
import time
from utils import safe_callback
from mfrc522 import MFRC522
try:
@@ -74,7 +75,7 @@ class Nfc:
self.last_uid = uid
self.last_uid_timestamp = time.ticks_us()
if self.cb is not None and last_callback_uid != uid:
self.cb.onTagChange(uid)
safe_callback(lambda: self.cb.onTagChange(uid), "nfc tag change")
last_callback_uid = uid
await asyncio.sleep_ms(poll_interval_ms)

View File

@@ -1,13 +1,15 @@
# SPDX-License-Identifier: MIT
# Copyright (c) 2025 Matthias Blankertz <matthias@blankertz.org>
from utils.helpers import safe_callback
from utils.timer import TimerManager
from utils.buttons import Buttons
from utils.config import Configuration
from utils.leds import LedManager
from utils.mbrpartition import MBRPartition
from utils.pinindex import get_pin_index
from utils.playlistdb import BTreeDB, BTreeFileManager
from utils.sdcontext import SDContext
from utils.timer import TimerManager
__all__ = ["BTreeDB", "BTreeFileManager", "Buttons", "get_pin_index", "LedManager", "MBRPartition", "SDContext",
"TimerManager"]
__all__ = ["BTreeDB", "BTreeFileManager", "Buttons", "Configuration", "get_pin_index", "LedManager", "MBRPartition",
"safe_callback", "SDContext", "TimerManager"]

View File

@@ -5,6 +5,7 @@ import asyncio
import machine
import micropython
import time
from utils import safe_callback, TimerManager
try:
from typing import TYPE_CHECKING # type: ignore
except ImportError:
@@ -17,21 +18,47 @@ if TYPE_CHECKING:
class Buttons:
def __init__(self, cb: "ButtonCallback", pin_volup=17, pin_voldown=19, pin_next=18):
self.VOLUP = micropython.const(1)
self.VOLDOWN = micropython.const(2)
self.NEXT = micropython.const(3)
VOLUP = micropython.const(1)
VOLDOWN = micropython.const(2)
NEXT = micropython.const(3)
PREV = micropython.const(4)
PLAY_PAUSE = micropython.const(5)
KEYMAP = {VOLUP: 'VOLUP',
VOLDOWN: 'VOLDOWN',
NEXT: 'NEXT',
PREV: 'PREV',
PLAY_PAUSE: 'PLAY_PAUSE'}
def __init__(self, cb: "ButtonCallback", config, hwconfig):
self.button_map = config.get_button_map()
self.hw_buttons = hwconfig.BUTTONS
self.hwconfig = hwconfig
self.cb = cb
self.buttons = {machine.Pin(pin_volup, machine.Pin.IN, machine.Pin.PULL_UP): self.VOLUP,
machine.Pin(pin_voldown, machine.Pin.IN, machine.Pin.PULL_UP): self.VOLDOWN,
machine.Pin(pin_next, machine.Pin.IN, machine.Pin.PULL_UP): self.NEXT}
self.buttons = dict()
for key_id, key_name in self.KEYMAP.items():
pin = self._get_pin(key_name)
if pin is None:
continue
self.buttons[pin] = key_id
self.int_flag = asyncio.ThreadSafeFlag()
self.pressed: list[int] = []
self.last: dict[int, int] = {}
self.timer_manager = TimerManager()
for button in self.buttons.keys():
button.irq(handler=self._interrupt, trigger=machine.Pin.IRQ_FALLING | machine.Pin.IRQ_RISING)
asyncio.create_task(self.task())
def _get_pin(self, key):
key_id = self.button_map.get(key, None)
if key_id is None:
return None
if key_id < 0 or key_id >= len(self.hw_buttons):
return None
pin = self.hw_buttons[key_id]
if pin is not None:
pin.init(machine.Pin.IN, machine.Pin.PULL_UP)
return pin
def _interrupt(self, button):
keycode = self.buttons[button]
last = self.last.get(keycode, 0)
@@ -44,10 +71,20 @@ class Buttons:
# print(f'B{keycode} {now}')
self.pressed.append(keycode)
self.int_flag.set()
if keycode == self.VOLDOWN:
self.timer_manager.schedule(time.ticks_ms() + 5000, self.long_press_shutdown)
if button.value() == 1 and keycode == self.VOLDOWN:
self.timer_manager.cancel(self.long_press_shutdown)
async def task(self):
while True:
await self.int_flag.wait()
while len(self.pressed) > 0:
what = self.pressed.pop()
self.cb.onButtonPressed(what)
safe_callback(lambda: self.cb.onButtonPressed(what), "button callback")
def long_press_shutdown(self):
if self.hwconfig.get_on_battery():
self.hwconfig.power_off()
else:
machine.reset()

View File

@@ -0,0 +1,138 @@
# SPDX-License-Identifier: MIT
# Copyright (c) 2025 Matthias Blankertz <matthias@blankertz.org>
from errno import ENOENT
import json
import os
try:
from typing import TYPE_CHECKING, Mapping, Any
except ImportError:
TYPE_CHECKING = False
class Configuration:
DEFAULT_CONFIG = {
'LED_COUNT': 1,
'IDLE_TIMEOUT_SECS': 60,
'TAG_TIMEOUT_SECS': 5,
'BUTTON_MAP': {
'PLAY_PAUSE': 4,
'VOLUP': 0,
'VOLDOWN': 2,
'PREV': None,
'NEXT': 1,
},
'TAGMODE': 'tagremains',
'WIFI': {
'SSID': '',
'PASSPHRASE': '',
'SECURITY': 'wpa_wpa2',
},
'VOLUME_MAX': 255,
'VOLUME_BOOT': 16,
'LED_MAX': 255,
}
def __init__(self, config_path='/config.json'):
self.config_path = config_path
try:
with open(self.config_path, 'r') as conf_file:
self.config = json.load(conf_file)
self._merge_configs(self.DEFAULT_CONFIG, self.config)
except OSError as ex:
if ex.errno == ENOENT:
self.config = Configuration.DEFAULT_CONFIG
self._save()
else:
raise
except ValueError as ex:
print(f"Warning: Could not load configuration {self.config_path}:\n{ex}")
self._move_config_to_backup()
self.config = Configuration.DEFAULT_CONFIG
def _move_config_to_backup(self):
# Remove old backup
try:
os.remove(self.config_path + '.bup')
os.rename(self.config_path, self.config_path + '.bup')
except OSError as ex:
if ex.errno != ENOENT:
raise
os.sync()
def _merge_configs(self, default, config):
for k in default.keys():
if k not in config:
if isinstance(default[k], dict):
config[k] = default[k].copy()
else:
config[k] = default[k]
elif isinstance(default[k], dict):
self._merge_configs(default[k], config[k])
def _save(self):
with open(self.config_path + '.new', 'w') as conf_file:
json.dump(self.config, conf_file)
self._move_config_to_backup()
os.rename(self.config_path + '.new', self.config_path)
os.sync()
def _get(self, key):
return self.config[key]
def get_led_count(self) -> int:
return self._get('LED_COUNT')
def get_idle_timeout(self) -> int:
return self._get('IDLE_TIMEOUT_SECS')
def get_tag_timeout(self) -> int:
return self._get('TAG_TIMEOUT_SECS')
def get_button_map(self) -> Mapping[str, int | None]:
return self._get('BUTTON_MAP')
def get_tagmode(self) -> str:
return self._get('TAGMODE')
def get_wifi_ssid(self) -> str:
return self._get('WIFI')['SSID']
def get_wifi_passphrase(self) -> str:
return self._get('WIFI')['PASSPHRASE']
def get_wifi_security(self) -> str:
return self._get('WIFI')['SECURITY']
def get_volume_max(self) -> int:
return self._get('VOLUME_MAX')
def get_led_max(self) -> int:
return self._get('LED_MAX')
def get_volume_boot(self) -> int:
return self._get('VOLUME_BOOT')
# For the web API
def get_config(self) -> Mapping[str, Any]:
return self.config
def _validate(self, default, config, path=''):
for k in config.keys():
if k not in default:
raise ValueError(f'Invalid config key {path}/{k}')
if isinstance(default[k], dict):
if not isinstance(config[k], dict):
raise ValueError(f'Invalid config: Value of {path}/{k} must be mapping')
self._validate(default[k], config[k], f'{path}/{k}')
def set_config(self, config):
self._validate(self.DEFAULT_CONFIG, config)
if 'TAGMODE' in config and config['TAGMODE'] not in ['tagremains', 'tagstartstop']:
raise ValueError("Invalid TAGMODE: Must be 'tagremains' or 'tagstartstop'")
if 'WLAN' in config and 'SECURITY' in config['WLAN'] and \
config['WLAN']['SECURITY'] not in ['open', 'wpa_wpa2', 'wpa3', 'wpa2_wpa3']:
raise ValueError("Invalid WLAN SECURITY: Must be 'open', 'wpa_wpa2', 'wpa3' or 'wpa2_wpa3'")
self._merge_configs(self.config, config)
self.config = config
self._save()

View File

@@ -0,0 +1,12 @@
# SPDX-License-Identifier: MIT
# Copyright (c) 2025 Matthias Blankertz <matthias@blankertz.org>
import sys
def safe_callback(func, name="callback"):
try:
func()
except Exception as ex:
print(f"Uncaught exception in {name}")
sys.print_exception(ex)

View File

@@ -10,16 +10,17 @@ import time
class LedManager:
IDLE = const(0)
PLAYING = const(1)
REBOOTING = const(2)
def __init__(self, np):
def __init__(self, np, config):
self.led_state = LedManager.IDLE
self.brightness = config.get_led_max() / 255
self.np = np
self.brightness = 0.1
self.leds = len(self.np)
asyncio.create_task(self.run())
def set_state(self, state):
assert state in [LedManager.IDLE, LedManager.PLAYING]
assert state in [LedManager.IDLE, LedManager.PLAYING, LedManager.REBOOTING]
self.led_state = state
def _gamma(self, value, X=2.2):
@@ -50,6 +51,8 @@ class LedManager:
self._pulse(time_, (0, 1, 0), 3)
elif self.led_state == LedManager.PLAYING:
self._rainbow(time_)
elif self.led_state == LedManager.REBOOTING:
self._pulse(time_, (1, 0, 1), 0.2)
time_ += 0.02
before = time.ticks_ms()
await self.np.async_write()

View File

@@ -31,17 +31,15 @@ class BTreeDB(IPlaylistDB):
PERSIST_NO = b'no'
PERSIST_TRACK = b'track'
PERSIST_OFFSET = b'offset'
DEFAULT_SETTINGS = {
b'tagmode': b'tagremains'
}
class Playlist(IPlaylist):
def __init__(self, parent: "BTreeDB", tag: bytes, pos: int, persist, shuffle):
def __init__(self, parent: "BTreeDB", tag: bytes, pos: int, persist, shuffle, name):
self.parent = parent
self.tag = tag
self.pos = pos
self.persist = persist
self.shuffle = shuffle
self.name = name
self.length = self.parent._getPlaylistLength(self.tag)
self._shuffle()
@@ -107,6 +105,17 @@ class BTreeDB(IPlaylistDB):
self.setPlaybackOffset(0)
return self.getCurrentPath()
def getPrevPath(self):
"""
Select prev track and return path.
"""
if self.pos > 0:
self.pos -= 1
if self.persist != BTreeDB.PERSIST_NO:
self.parent._setPlaylistPos(self.tag, self.pos)
self.setPlaybackOffset(0)
return self.getCurrentPath()
def setPlaybackOffset(self, offset):
"""
Store the current position in the track for PERSIST_OFFSET mode
@@ -160,6 +169,10 @@ class BTreeDB(IPlaylistDB):
return (b''.join([tag, b'/playlist/']),
b''.join([tag, b'/playlist0']))
@staticmethod
def _keyPlaylistName(tag):
return b''.join([tag, b'/playlistname'])
def _flush(self):
"""
Flush the database and call the flush_func if it was provided.
@@ -214,12 +227,13 @@ class BTreeDB(IPlaylistDB):
raise RuntimeError("Malformed playlist key")
return int(elements[2])+1
def _savePlaylist(self, tag, entries, persist, shuffle, flush=True):
def _savePlaylist(self, tag, entries, persist, shuffle, name, flush=True):
self._deletePlaylist(tag, False)
for idx, entry in enumerate(entries):
self.db[self._keyPlaylistEntry(tag, idx)] = entry
self.db[self._keyPlaylistPersist(tag)] = persist
self.db[self._keyPlaylistShuffle(tag)] = shuffle
self.db[self._keyPlaylistName(tag)] = name.encode()
if flush:
self._flush()
@@ -232,7 +246,7 @@ class BTreeDB(IPlaylistDB):
pass
for k in (self._keyPlaylistPos(tag), self._keyPlaylistPosOffset(tag),
self._keyPlaylistPersist(tag), self._keyPlaylistShuffle(tag),
self._keyPlaylistShuffleSeed(tag)):
self._keyPlaylistShuffleSeed(tag), self._keyPlaylistName(tag)):
try:
del self.db[k]
except KeyError:
@@ -240,6 +254,19 @@ class BTreeDB(IPlaylistDB):
if flush:
self._flush()
def getPlaylists(self):
"""
Get a list of all defined playlists with their tag and names.
"""
playlist_tags = set()
for item in self.db:
playlist_tags.add(item.split(b'/')[0])
playlists = []
for tag in playlist_tags:
name = self.db.get(self._keyPlaylistName(tag), b'').decode()
playlists.append({'tag': tag, 'name': name})
return playlists
def getPlaylistForTag(self, tag: bytes):
"""
Lookup the playlist for 'tag' and return the Playlist object. Return None if no playlist exists for the given
@@ -257,24 +284,23 @@ class BTreeDB(IPlaylistDB):
return None
if self._keyPlaylistEntry(tag, pos) not in self.db:
pos = 0
name = self.db.get(self._keyPlaylistName(tag), b'').decode()
shuffle = self.db.get(self._keyPlaylistShuffle(tag), self.SHUFFLE_NO)
return self.Playlist(self, tag, pos, persist, shuffle)
return self.Playlist(self, tag, pos, persist, shuffle, name)
def createPlaylistForTag(self, tag: bytes, entries: typing.Iterable[bytes], persist=PERSIST_TRACK,
shuffle=SHUFFLE_NO):
shuffle=SHUFFLE_NO, name: str = ''):
"""
Create and save a playlist for 'tag' and return the Playlist object. If a playlist already existed for 'tag' it
is overwritten.
"""
assert persist in (self.PERSIST_NO, self.PERSIST_TRACK, self.PERSIST_OFFSET)
assert shuffle in (self.SHUFFLE_NO, self.SHUFFLE_YES)
self._savePlaylist(tag, entries, persist, shuffle)
self._savePlaylist(tag, entries, persist, shuffle, name)
return self.getPlaylistForTag(tag)
def getSetting(self, key: bytes | str) -> str:
if type(key) is str:
key = key.encode()
return self.db.get(b'settings/' + key, self.DEFAULT_SETTINGS[key]).decode()
def deletePlaylistForTag(self, tag: bytes):
self._deletePlaylist(tag)
def validate(self, dump=False):
"""
@@ -295,8 +321,7 @@ class BTreeDB(IPlaylistDB):
fail(f'Malformed key {k!r}')
continue
if fields[0] == b'settings':
val = self.db[k].decode()
print(f'Setting {fields[1].decode()} = {val}')
# Legacy, not used any more
continue
if last_tag != fields[0]:
last_tag = fields[0]
@@ -355,6 +380,14 @@ class BTreeDB(IPlaylistDB):
_ = int(val)
except ValueError:
fail(f' Bad playlistposoffset value for {last_tag}: {val!r}')
elif fields[1] == b'playlistname':
val = self.db[k]
try:
name = val.decode()
if dump:
print(f'\tName: {name}')
except UnicodeError:
fail(f' Bad playlistname for {last_tag}: Not valid unicode')
else:
fail(f'Unknown key {k!r}')
return result

View File

@@ -4,6 +4,7 @@
import asyncio
import heapq
import time
from utils import safe_callback
TIMER_DEBUG = True
@@ -22,6 +23,7 @@ class TimerManager(object):
def schedule(self, when, what):
cur_nearest = self.timers[0][0] if len(self.timers) > 0 else None
self._remove_timer(what) # Ensure timer is not already scheduled
heapq.heappush(self.timers, (when, what))
if cur_nearest is None or cur_nearest > self.timers[0][0]:
# New timer is closer than previous closest timer
@@ -31,41 +33,53 @@ class TimerManager(object):
self.worker_event.set()
def cancel(self, what):
try:
(when, _), i = next(filter(lambda item: item[0][1] == what, zip(self.timers, range(len(self.timers)))))
except StopIteration:
return False
del self.timers[i]
heapq.heapify(self.timers)
if i == 0:
remove_idx = self._remove_timer(what)
if remove_idx == 0:
# Cancel timer was closest timer
if self.timer_debug:
print("cancel: wake")
self.worker_event.set()
return True
def _remove_timer(self, what):
try:
(when, _), i = next(filter(lambda item: item[0][1] == what, zip(self.timers, range(len(self.timers)))))
except StopIteration:
return False
del self.timers[i]
heapq.heapify(self.timers)
return i
def _next_timeout(self):
if len(self.timers) == 0:
if self.timer_debug:
print("timer: worker: queue empty")
return None
cur_nearest = self.timers[0][0]
next_timeout = cur_nearest - time.ticks_ms()
if self.timer_debug:
if next_timeout > 0:
print(f"timer: worker: next is {self.timers[0]}, sleep {next_timeout} ms")
else:
print(f"timer: worker: {self.timers[0]} elapsed @{cur_nearest}, delay {-next_timeout} ms")
return next_timeout
async def _wait(self, timeout):
try:
await asyncio.wait_for_ms(self.worker_event.wait(), timeout)
if self.timer_debug:
print("timer: worker: event")
# got woken up due to event
self.worker_event.clear()
return True
except asyncio.TimeoutError:
return False
async def _timer_worker(self):
while True:
if len(self.timers) == 0:
# Nothing to do
await self.worker_event.wait()
if self.timer_debug:
print("_timer_worker: event 0")
self.worker_event.clear()
continue
cur_nearest = self.timers[0][0]
wait_time = cur_nearest - time.ticks_ms()
if wait_time > 0:
if self.timer_debug:
print(f"_timer_worker: next is {self.timers[0]}, sleep {wait_time} ms")
try:
await asyncio.wait_for_ms(self.worker_event.wait(), wait_time)
if self.timer_debug:
print("_timer_worker: event 1")
# got woken up due to event
self.worker_event.clear()
continue
except asyncio.TimeoutError:
pass
_, callback = heapq.heappop(self.timers)
callback()
next_timeout = self._next_timeout()
if next_timeout is None or next_timeout > 0:
await self._wait(next_timeout)
else:
_, callback = heapq.heappop(self.timers)
safe_callback(callback, "timer callback")

View File

@@ -4,19 +4,51 @@ Copyright (c) 2024-2025 Stefan Kratochwil <Kratochwil-LA@gmx.de>
'''
import asyncio
import board
import errno
import hwconfig
import json
import machine
import network
import os
import time
import ubinascii
from microdot import Microdot
from array import array
from microdot import Microdot, redirect, send_file, Request
from utils import TimerManager, LedManager
webapp = Microdot()
server = None
config = None
app = None
nfc = None
playlist_db = None
leds = None
timer_manager = None
Request.max_content_length = 128 * 1024 * 1024 # 128MB requests allowed
def start_webserver():
global server
server = asyncio.create_task(webapp.start_server(port=80))
def start_webserver(config_, app_):
global server, config, app, nfc, playlist_db, leds, timer_manager
server = asyncio.create_task(webapp.start_server(host='::0', port=80))
config = config_
app = app_
nfc = app.get_nfc()
playlist_db = app.get_playlist_db()
leds = app.get_leds()
timer_manager = TimerManager()
@webapp.route('/')
@webapp.before_request
async def before_request_handler(request):
if request.method in ['PUT', 'POST', 'DELETE'] and app.is_playing():
return "Cannot write to device while playback is active", 503
app.reset_idle_timeout()
@webapp.route('/api/v1/hello')
async def index(request):
print("wohoo, a guest :)")
print(f" app: {request.app}")
@@ -39,3 +71,226 @@ async def filesystem_post(request):
async def playlist_post(request):
print(request)
return {'success': False}
@webapp.route('/api/v1/config', methods=['GET'])
async def config_get(request):
return config.get_config()
@webapp.route('/api/v1/config', methods=['PUT'])
async def config_put(request):
try:
config.set_config(request.json)
except ValueError as ex:
return str(ex), 400
return '', 204
@webapp.route('/api/v1/last_tag_uid', methods=['GET'])
async def last_tag_uid_get(request):
tag, _ = nfc.get_last_uid()
return {'tag': tag}
@webapp.route('/', methods=['GET'])
async def root_get(request):
return redirect('/index.html')
@webapp.route('/index.html', methods=['GET'])
async def index_get(request):
return send_file('/frontend/index.html.gz', content_type='text/html', compressed='gzip')
@webapp.route('/static/<path:path>', methods=['GET'])
async def static(request, path):
if '..' in path:
# directory traversal is not allowed
return 'Not found', 404
return send_file('/frontend/static/' + path, max_age=86400)
@webapp.route('/api/v1/playlists', methods=['GET'])
async def playlists_get(request):
return playlist_db.getPlaylists()
def is_hex(s):
hex_chars = '0123456789abcdef'
return all(c in hex_chars for c in s)
fsroot = b'/sd'
@webapp.route('/api/v1/playlist/<tag>', methods=['GET'])
async def playlist_get(request, tag):
if not is_hex(tag):
return 'invalid tag', 400
playlist = playlist_db.getPlaylistForTag(tag.encode())
if playlist is None:
return None, 404
return {
'shuffle': playlist.shuffle,
'persist': playlist.persist,
'paths': [(p[len(fsroot):] if p.startswith(fsroot) else p).decode()
for p in playlist.getPaths()],
'name': playlist.name
}
@webapp.route('/api/v1/playlist/<tag>', methods=['PUT'])
async def playlist_put(request, tag):
if not is_hex(tag):
return 'invalid tag', 400
playlist = request.json
if 'persist' in playlist and \
playlist['persist'] not in ['no', 'track', 'offset']:
return "Invalid 'persist' setting", 400
if 'shuffle' in playlist and \
playlist['shuffle'] not in ['no', 'yes']:
return "Invalid 'shuffle' setting", 400
playlist_db.createPlaylistForTag(tag.encode(),
(fsroot + path.encode() for path in playlist.get('paths', [])),
playlist.get('persist', 'track').encode(),
playlist.get('shuffle', 'no').encode(),
playlist.get('name', ''))
return '', 204
@webapp.route('/api/v1/playlist/<tag>', methods=['DELETE'])
async def playlist_delete(request, tag):
if not is_hex(tag):
return 'invalid tag', 400
playlist_db.deletePlaylistForTag(tag.encode())
return '', 204
@webapp.route('/api/v1/audiofiles', methods=['GET'])
async def audiofiles_get(request):
def directory_iterator():
yield '['
first = True
def make_json_str(obj):
nonlocal first
jsonpath = json.dumps(obj)
if not first:
jsonpath = ',' + jsonpath
first = False
return jsonpath
dirstack = [fsroot]
while dirstack:
current_dir = dirstack.pop()
for entry in os.ilistdir(current_dir):
name = entry[0]
type_ = entry[1]
current_path = current_dir + b'/' + name
if type_ == 0x4000:
yield make_json_str({'name': current_path[len(fsroot):], 'type': 'directory'})
dirstack.append(current_path)
elif type_ == 0x8000:
if name.lower().endswith('.mp3'):
yield make_json_str({'name': current_path[len(fsroot):], 'type': 'file'})
yield ']'
return directory_iterator(), {'Content-Type': 'application/json; charset=UTF-8'}
async def stream_to_file(stream, file_, length):
data = array('b', range(4096))
bytes_copied = 0
while True:
bytes_read = await stream.readinto(data)
if bytes_read == 0:
# End of body
break
bytes_written = file_.write(data[:bytes_read])
if bytes_written != bytes_read:
# short writes shouldn't happen
raise OSError(errno.EIO, 'unexpected short write')
bytes_copied += bytes_written
if bytes_copied == length:
break
app.reset_idle_timeout()
return bytes_copied
@webapp.route('/api/v1/audiofiles', methods=['POST'])
async def audiofile_upload(request):
if 'type' not in request.args or request.args['type'] not in ['file', 'directory']:
return 'invalid or missing type', 400
if 'location' not in request.args:
return 'missing location', 400
path = fsroot + '/' + request.args['location']
type_ = request.args['type']
length = request.content_length
print(f'Got upload request of type {type_} to {path} with length {length}')
if type_ == 'directory':
if length != 0:
return 'directory request may not have content', 400
os.mkdir(path)
return '', 204
with open(path, 'wb') as newfile:
try:
if length > Request.max_body_length:
bytes_copied = await stream_to_file(request.stream, newfile, length)
else:
bytes_copied = newfile.write(request.body)
except OSError as ex:
return f'error writing data to file: {ex}', 500
if bytes_copied == length:
return '', 204
else:
return 'size mismatch', 500
def recursive_delete(path):
stat = os.stat(path)
if stat[0] == 0x8000:
os.remove(path)
elif stat[0] == 0x4000:
for entry in os.ilistdir(path):
entry_path = path + '/' + entry[0]
recursive_delete(entry_path)
os.rmdir(path)
@webapp.route('/api/v1/audiofiles', methods=['DELETE'])
async def audiofile_delete(request):
if 'location' not in request.args:
return 'missing location', 400
location = request.args['location']
if '..' in location or len(location) == 0:
return 'bad location', 400
path = fsroot + '/' + request.args['location']
recursive_delete(path)
return '', 204
@webapp.route('/api/v1/reboot/<method>', methods=['POST'])
async def reboot(request, method):
if method == 'bootloader':
if hwconfig.get_on_battery():
return 'not possible: connect USB first', 403
leds.set_state(LedManager.REBOOTING)
timer_manager.schedule(time.ticks_ms() + 1500, machine.bootloader)
elif method == 'application':
leds.set_state(LedManager.REBOOTING)
timer_manager.schedule(time.ticks_ms() + 1500, machine.reset)
else:
return 'method not supported', 400
return '', 204
@webapp.route('/api/v1/info', methods=['GET'])
async def get_info(request):
mac = ubinascii.hexlify(network.WLAN().config('mac'), ':').decode()
return {'version': board.version,
'mac': mac}

View File

@@ -124,6 +124,28 @@ class FakeLeds:
self.state = state
class FakeConfig:
def __init__(self): pass
def get_led_count(self):
return 1
def get_idle_timeout(self):
return 60
def get_tag_timeout(self):
return 5
def get_tagmode(self):
return 'tagremains'
def get_volume_max(self):
return 255
def get_volume_boot(self):
return 16
def fake_open(filename, mode):
return FakeFile(filename, mode)
@@ -136,13 +158,14 @@ def faketimermanager(monkeypatch):
def _makedeps(mp3player=FakeMp3Player, nfcreader=FakeNfcReader, buttons=FakeButtons,
playlistdb=FakePlaylistDb, hwconfig=FakeHwconfig, leds=FakeLeds):
playlistdb=FakePlaylistDb, hwconfig=FakeHwconfig, leds=FakeLeds, config=FakeConfig):
return app.Dependencies(mp3player=lambda _: mp3player() if callable(mp3player) else mp3player,
nfcreader=lambda x: nfcreader(x) if callable(nfcreader) else nfcreader,
buttons=lambda _: buttons() if callable(buttons) else buttons,
playlistdb=lambda _: playlistdb() if callable(playlistdb) else playlistdb,
hwconfig=lambda _: hwconfig() if callable(hwconfig) else hwconfig,
leds=lambda _: leds() if callable(leds) else leds)
leds=lambda _: leds() if callable(leds) else leds,
config=lambda _: config() if callable(config) else config)
def test_construct_app(micropythonify, faketimermanager):
@@ -150,7 +173,7 @@ def test_construct_app(micropythonify, faketimermanager):
deps = _makedeps(mp3player=fake_mp3)
dut = app.PlayerApp(deps)
fake_mp3 = dut.player
assert fake_mp3.volume is not None
assert fake_mp3.volume is not None and fake_mp3.volume >= 16
def test_load_playlist_on_tag(micropythonify, faketimermanager, monkeypatch):
@@ -212,18 +235,16 @@ def test_playlist_unknown_tag(micropythonify, faketimermanager, monkeypatch):
def test_tagmode_startstop(micropythonify, faketimermanager, monkeypatch):
class MyFakePlaylistDb(FakePlaylistDb):
def __init__(self, tracklist=[b'test/path.mp3']):
super().__init__(tracklist)
class FakeStartStopConfig(FakeConfig):
def __init__(self):
super().__init__()
def getSetting(self, key: bytes | str):
if key == 'tagmode':
return 'tagstartstop'
return None
def get_tagmode(self):
return 'tagstartstop'
fake_db = MyFakePlaylistDb()
fake_db = FakePlaylistDb([b'test/path.mp3'])
fake_mp3 = FakeMp3Player()
deps = _makedeps(mp3player=fake_mp3, playlistdb=fake_db)
deps = _makedeps(mp3player=fake_mp3, playlistdb=fake_db, config=FakeStartStopConfig)
app.PlayerApp(deps)
with monkeypatch.context() as m:
m.setattr(builtins, 'open', fake_open)
@@ -250,16 +271,7 @@ def test_tagmode_startstop(micropythonify, faketimermanager, monkeypatch):
def test_tagmode_remains(micropythonify, faketimermanager, monkeypatch):
class MyFakePlaylistDb(FakePlaylistDb):
def __init__(self, tracklist=[b'test/path.mp3']):
super().__init__(tracklist)
def getSetting(self, key: bytes | str):
if key == 'tagmode':
return 'tagremains'
return None
fake_db = MyFakePlaylistDb()
fake_db = FakePlaylistDb([b'test/path.mp3'])
fake_mp3 = FakeMp3Player()
deps = _makedeps(mp3player=fake_mp3, playlistdb=fake_db)
app.PlayerApp(deps)

View File

@@ -15,6 +15,8 @@ option(ENABLE_PLAY_TEST "Enable mp3 playback test" OFF)
option(ENABLE_SD_READ_CRC "Enable crc check when reading from sd card" OFF)
option(ENABLE_SD_DEBUG "Enable debug output for sd card driver" OFF)
set(PICO_USE_FASTEST_SUPPORTED_CLOCK 1)
# initialize the Raspberry Pi Pico SDK
pico_sdk_init()
@@ -62,7 +64,7 @@ add_subdirectory(../../lib/helix_mp3 helix_mp3)
target_link_libraries(standalone_mp3 PRIVATE pico_stdlib hardware_dma hardware_spi hardware_sync hardware_pio helix_mp3)
target_include_directories(standalone_mp3 PRIVATE ${SD_LIB_DIR})
target_compile_options(standalone_mp3 PRIVATE -Og -DSD_DEBUG)
target_compile_options(standalone_mp3 PRIVATE -Og)
pico_add_extra_outputs(standalone_mp3)

View File

@@ -2,6 +2,7 @@
#include "sd.h"
#include <math.h>
#include <stdarg.h>
#include <stdio.h>
#include <string.h>
@@ -20,6 +21,14 @@ extern void sd_spi_dbg_clk(const int div, const int frac);
extern void sd_spi_dbg_loop(void);
void sd_printf(const char *fmt, ...)
{
va_list ap;
va_start(ap, fmt);
vprintf(fmt, ap);
va_end(ap);
}
#define MAX_VOLUME 0x8000u
void __time_critical_func(volume_adjust)(int16_t *restrict buf, size_t samples, uint16_t scalef)
@@ -157,7 +166,7 @@ static void write_test(struct sd_context *sd_context)
data_buffer[i] ^= 0xff;
}
if (!sd_writeblock(sd_context, 0, data_buffer)) {
if (!sd_writeblocks(sd_context, 0, sizeof(data_buffer) / SD_SECTOR_SIZE, data_buffer)) {
printf("sd_writeblock failed\n");
return;
}