74 Commits

Author SHA1 Message Date
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
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
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
111ae65ebc cleanup: Remove unused python files from src
Remove python files that are not referenced directly or indirectly by
main.py.

Signed-off-by: Matthias Blankertz <matthias@blankertz.org>
2025-11-28 17:59:13 +01:00
1356ea06ab Merge pull request 'feat: new micropython unix variant with support for microdot' (#51) from feat/micropython_on_unix into main
All checks were successful
Build RPi Pico firmware image / Build-Firmware (push) Successful in 3m39s
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
Reviewed-on: #51
Reviewed-by: Matthias Blankertz <matthias@blankertz.org>
2025-11-28 16:58:59 +00:00
e07ee46518 style: Fix flake8 complaints
All checks were successful
Build RPi Pico firmware image / Build-Firmware (push) Successful in 4m28s
Check code formatting / Check-C-Format (push) Successful in 7s
Check code formatting / Check-Python-Flake8 (push) Successful in 11s
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
Remove unused imports in main.py

Add missing "global" in webserver and fix spacing.

Signed-off-by: Matthias Blankertz <matthias@blankertz.org>
2025-11-28 17:48:39 +01:00
cd5515ddad feat: new micropython variant with support for microdot
Some checks failed
Build RPi Pico firmware image / Build-Firmware (push) Successful in 3m36s
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 9s
Run pytests / Check-Pytest (push) Successful in 12s
2025-11-11 22:32:00 +01:00
99ad8582f0 Defined the two endpoints we need for webapi version 1
Some checks failed
Build RPi Pico firmware image / Build-Firmware (push) Successful in 3m28s
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-11-11 22:30:23 +01:00
ae875950cd Enabled --update flag for differential flashing 2025-11-11 22:30:23 +01:00
340aea6be6 Factored out webserver sample into dedicated module 2025-11-11 22:30:18 +01:00
f64bbc27fd basic webserver works 2025-11-11 22:28:55 +01:00
abb880baca print network interface info 2025-11-11 22:28:49 +01:00
a59f00ad60 disabled wifi power management for now 2025-11-11 22:26:04 +01:00
135ad11de9 Minimal example for api endpoint 2025-11-11 22:26:04 +01:00
ff52e989a2 Forumlated potentially useful json schema.
Note that this is based on an earlier state of the project, and the
terms used in this schema may differ from the current state of the project.
2025-11-11 22:26:04 +01:00
fbb383abed Merge pull request 'LED patterns, idle shutdown' (#49) from misc-mvp-features into main
All checks were successful
Build RPi Pico firmware image / Build-Firmware (push) Successful in 3m27s
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 6s
Run unit tests on host / Run-Unit-Tests (push) Successful in 8s
Run pytests / Check-Pytest (push) Successful in 12s
Reviewed-on: #49
Reviewed-by: Stefan Kratochwil <kratochwil-la@gmx.de>
2025-11-11 19:39:54 +00:00
869a92d998 feat: Implement shutdown on idle when on battery
All checks were successful
Build RPi Pico firmware image / Build-Firmware (push) Successful in 3m24s
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
Allow the PlayerApp to turn off the device if it is idle for longer then
the timeout. The timeout is currently hardcoded to 1 minute, this will be
made configurable in the future.

Signed-off-by: Matthias Blankertz <matthias@blankertz.org>
2025-11-11 20:31:48 +01:00
696f7b956c feat: Change LED pattern based on playback state
Move the LED control to the utils.LedManager class. For the first
implementation, support two LED patterns 'idle' and 'playing'.

Extend the PlayerApp to set the LED pattern based on playback state.

Signed-off-by: Matthias Blankertz <matthias@blankertz.org>
2025-11-11 20:30:54 +01:00
0bb1b2758a Merge pull request 'hw-board-updates' (#50) from hw-board-updates into main
All checks were successful
Build RPi Pico firmware image / Build-Firmware (push) Successful in 3m23s
Check code formatting / Check-C-Format (push) Successful in 8s
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: #50
2025-11-10 20:52:07 +00:00
419d85209e hw: Final fixes for Rev 1.2
All checks were successful
Build RPi Pico firmware image / Build-Firmware (push) Successful in 3m23s
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 9s
Run pytests / Check-Pytest (push) Successful in 11s
- Move RC522 connector a bit further away from the MAX98375 module
  footprint, so a JST-XH connector can be used here too.
- Update custom rules and fix some DRC warnings (no netlist and layout
  changes).

Signed-off-by: Matthias Blankertz <matthias@blankertz.org>
2025-11-10 21:45:57 +01:00
0f4f72253c hw: Update metadata, move silkscreen board name + rev
All checks were successful
Build RPi Pico firmware image / Build-Firmware (push) Successful in 3m27s
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
No netlist or routing changes.

Signed-off-by: Matthias Blankertz <matthias@blankertz.org>
2025-11-10 19:19:28 +01:00
595f3bd37f fix(db): Fix type check in BTreeDB.getSetting
All checks were successful
Build RPi Pico firmware image / Build-Firmware (push) Successful in 3m30s
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
Fix missing type() call in type check.
Also remove leftover debug print().

Signed-off-by: Matthias Blankertz <matthias@blankertz.org>
2025-11-09 16:33:33 +01:00
08fdb75297 hw: Add GND point for MAX98357 gain config
All checks were successful
Build RPi Pico firmware image / Build-Firmware (push) Successful in 3m20s
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
Add a through-hole pad connected to GND next to the MAX98357 gain config
resistor to allow the user to select from all possible gains.

Signed-off-by: Matthias Blankertz <matthias@blankertz.org>
2025-11-08 13:47:10 +01:00
d28f0b1c0c hw: Smaller 'production' version of board w/o keys and direct RC522 mount
All checks were successful
Build RPi Pico firmware image / Build-Firmware (push) Successful in 3m22s
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 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-07 22:52:34 +01:00
9147bab5bb hw: Add fifth button, make traces wider
Signed-off-by: Matthias Blankertz <matthias@blankertz.org>
2025-11-07 21:02:55 +01:00
0820ec1fc8 Merge pull request '28-configurable-tag-handling' (#48) from 28-configurable-tag-handling into main
All checks were successful
Build RPi Pico firmware image / Build-Firmware (push) Successful in 3m21s
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
Reviewed-on: #48
Reviewed-by: Stefan Kratochwil <kratochwil-la@gmx.de>
2025-11-04 18:54:54 +00:00
7d3cdbabe4 feat(app): Implement tag handling modes according to #28.
All checks were successful
Build RPi Pico firmware image / Build-Firmware (push) Successful in 3m24s
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 6s
Run unit tests on host / Run-Unit-Tests (push) Successful in 9s
Run pytests / Check-Pytest (push) Successful in 11s
If the tagmode setting is 'tagremains' (the default): Play as long as the
tag is on the reader, with a 5 second timeout (i.e. playback stops when
tag is gone for more than 5 seconds).

If the tagmode setting is 'tagstartstop': Playback starts when a tag is
seen. Presenting a different tag causes the playback to stop and
playback for the new tag to start. Re-presenting the same tag used to
start with a no-tag-time of 5 seconds or more in between stops playback.

Signed-off-by: Matthias Blankertz <matthias@blankertz.org>
2025-11-04 19:25:10 +01:00
b5e3df1054 refactor(app): Refactor tag handling
Split the tag handling in PlayerApp into two parts: The low level
handling of changed/removed tags from the Nfc reader, including the
timeout processing is moved into the nested TagStateMachine class. The
main PlayerApp has two new (internal) callbacks "onNewTag" and
"onTagRemoved" which are called when a new tag is presented or a tag was
actually removed. This will hopefully make the implementation of #28
"Configurable Tag Handling" cleaner.

Signed-off-by: Matthias Blankertz <matthias@blankertz.org>
2025-11-04 19:25:10 +01:00
55718aa1ff Merge pull request '24-playlist-modes' (#46) from 24-playlist-modes into main
All checks were successful
Build RPi Pico firmware image / Build-Firmware (push) Successful in 3m23s
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 5s
Run unit tests on host / Run-Unit-Tests (push) Successful in 9s
Run pytests / Check-Pytest (push) Successful in 11s
Reviewed-on: #46
Reviewed-by: Stefan Kratochwil <kratochwil-la@gmx.de>
2025-11-04 18:23:57 +00:00
981c75020f Merge pull request 'fix(main): Make upgrade from tag directories more ergonomical' (#47) from fix-no-db into main
All checks were successful
Build RPi Pico firmware image / Build-Firmware (push) Successful in 3m20s
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
Reviewed-on: #47
Reviewed-by: Stefan Kratochwil <kratochwil-la@gmx.de>
2025-11-02 17:31:57 +00:00
b56e4f36b4 fix(main): Make upgrade from tag directories more ergonomical
All checks were successful
Build RPi Pico firmware image / Build-Firmware (push) Successful in 3m21s
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
As we don't have an API to upload files and create playlists yet, we
used the convention "tag uid as directory name" to allow testing
playback. With the introduction of the database in #39 "Add playlist db"
a builddb() function to initialize the database from the tag directories
was added, but it is not automatically called. To make the developer
experience more ergonomical, add a check for that automatically runs
builddb() when no database exists.

Signed-off-by: Matthias Blankertz <matthias@blankertz.org>
2025-11-02 18:06:18 +01:00
9ba6e698b9 Merge pull request 'networking' (#33) from networking into main
All checks were successful
Build RPi Pico firmware image / Build-Firmware (push) Successful in 3m19s
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 9s
Run pytests / Check-Pytest (push) Successful in 11s
Reviewed-on: #33
Reviewed-by: Matthias Blankertz <matthias@blankertz.org>
2025-11-02 16:42:29 +00:00
db136ed79a Wifi setup function with initial wlan config and per-board unique ssid
All checks were successful
Build RPi Pico firmware image / Build-Firmware (push) Successful in 3m21s
Check code formatting / Check-C-Format (push) Successful in 9s
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
2025-11-02 17:21:48 +01:00
6a9ff9eb0a playlistdb: Implement shuffle
All checks were successful
Build RPi Pico firmware image / Build-Firmware (push) Successful in 3m22s
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
Initial implementation of shuffle with a naive algorithm.

Signed-off-by: Matthias Blankertz <matthias@blankertz.org>
2025-10-31 14:21:35 +01:00
7327549eea refactor(playlistdb): Enforce constant entry index length
To simplify the playlist handling, enforce that the indices are always
formatted to the same length (5, which allows for 100000 entries, that
should be enough).

Then make the position stored in the Playlist object be a simple integer
instead of a database key. This simplifies the code, and will make
implementing shuffle much easier.

Signed-off-by: Matthias Blankertz <matthias@blankertz.org>
2025-10-31 14:21:35 +01:00
7e532ec641 app, mp3player: Hook up playlists 'offset' persist mode
Signed-off-by: Matthias Blankertz <matthias@blankertz.org>
2025-10-31 14:21:35 +01:00
5013e2359d playlistdb: Add shuffle and persist settings
Add documentation of playlist database schema in DEVELOP.md.

Add settings for persist and shuffle to BTreeDB. Implement the different
persist modes. Shuffle will be implemented in a followup commit.

Signed-off-by: Matthias Blankertz <matthias@blankertz.org>
2025-10-31 14:21:35 +01:00
4512b91763 Merge PR #42 from remote-tracking branch 'origin/fix-sd-spi-hwconfig' into main
All checks were successful
Build RPi Pico firmware image / Build-Firmware (push) Successful in 3m23s
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
Signed-off-by: Matthias Blankertz <matthias@blankertz.org>

Reviewed-on: #42
Reviewed-by: Stefan Kratochwil <kratochwil-la@gmx.de>
2025-10-31 14:19:27 +01:00
5891006bcd Merge pull request 'hw-new-pico-symbol' (#44) from hw-new-pico-symbol into main
All checks were successful
Build RPi Pico firmware image / Build-Firmware (push) Successful in 3m25s
Check code formatting / Check-C-Format (push) Successful in 7s
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
Reviewed-on: #44
Reviewed-by: Stefan Kratochwil <kratochwil-la@gmx.de>
2025-10-30 16:52:39 +00:00
a81952fb8a Merge pull request 'fix-interrupt-race' (#45) from fix-interrupt-race into main
Some checks failed
Check code formatting / Check-C-Format (push) Has been cancelled
Build RPi Pico firmware image / Build-Firmware (push) Has been cancelled
Check code formatting / Check-Python-Flake8 (push) Has been cancelled
Check code formatting / Check-Bash-Shellcheck (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: #45
Reviewed-by: Stefan Kratochwil <kratochwil-la@gmx.de>
2025-10-30 16:52:25 +00:00
92f9ce3d5a Merge pull request 'Add playlist database' (#39) from 23-add-playlist-db into main
All checks were successful
Build RPi Pico firmware image / Build-Firmware (push) Successful in 3m23s
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
Reviewed-on: #39
Reviewed-by: Stefan Kratochwil <kratochwil-la@gmx.de>
2025-10-27 20:37:49 +00:00
23d5b050dc audiocore: Fix race window in i2s_stop
All checks were successful
Build RPi Pico firmware image / Build-Firmware (push) Successful in 3m23s
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
Apply the same pattern as in 6f155ebb55 ("audiocore: Fix small race
window in get_fifo_read_value_blocking") and 2a4033d3ca ("rp2_sd: Fix
race window in sd_spi_wait_complete") to the wait for interrupt in
i2s_stop too.

Signed-off-by: Matthias Blankertz <matthias@blankertz.org>
2025-10-27 21:25:52 +01:00
2a4033d3ca rp2_sd: Fix race window in sd_spi_wait_complete
Similar to the fix in 6f155ebb55 ("audiocore: Fix small race window in
get_fifo_read_value_blocking"), a race and deadlock is possible when
calling __wfi with interrupts enabled. Fix it in sd_spi_wait_complete by
copying the fix from the above commit.

Signed-off-by: Matthias Blankertz <matthias@blankertz.org>
2025-10-27 21:13:48 +01:00
2809d3f6e7 tools: standalone_mp3: Add read test, increase speed to 25 MHz
All checks were successful
Build RPi Pico firmware image / Build-Firmware (push) Successful in 3m22s
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
Add a read test to the SD tests in standalone_mp3, and also apply the
drive strength setup and higher clockrate from board_Rev1.py.

Signed-off-by: Matthias Blankertz <matthias@blankertz.org>
2025-10-19 17:02:51 +02:00
8e4f2fde21 rp2_sd: Improve error handling
* In sd_cmd_read_complete, check read token and report appropriate error
  _before_ performing CRC check.
* In sd_read_csd, correctly handle the sd_cmd_read() failing.
* In sd_init, deinit the lower level sd_spi driver correctly on failure.

Signed-off-by: Matthias Blankertz <matthias@blankertz.org>
2025-10-19 17:02:51 +02:00
9357b4d243 rp2_sd: Disable input synchronizer for MISO pin
The PIO has an internal synchronizer on each GPIO input which adds two
cycles of delay. This prevents metastabilities in the PIO logic (see
RP2040 datasheet p. 374f). For high speed synchronous interfaces such as
SPI this needs to be disabled to reduce input delay.

Signed-off-by: Matthias Blankertz <matthias@blankertz.org>
2025-10-19 16:49:46 +02:00
231172f794 Merge pull request 'board, hwconfig: Set POWER_EN in early boot' (#43) from fix-set-power-en-in-early-boot into main
All checks were successful
Build RPi Pico firmware image / Build-Firmware (push) Successful in 3m22s
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
Reviewed-on: #43
Reviewed-by: Stefan Kratochwil <kratochwil-la@gmx.de>
2025-10-14 21:09:06 +00:00
5625f43f81 playlistdb: testing lexicographic sorting of db entries
All checks were successful
Build RPi Pico firmware image / Build-Firmware (push) Successful in 3m21s
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
2025-10-14 21:21:16 +02:00
902ce980af board, hwconfig: Set POWER_EN in early boot
All checks were successful
Build RPi Pico firmware image / Build-Firmware (push) Successful in 3m42s
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
In order to turn the Tonberry device on more reliably even if the power
button is only pressed for a short time, move the setting of POWER_EN
pin to high from the python board_init to the MICROPY_BOARD_STARTUP
macro so that is is started in the C startup code run before the
micropython interpreter is initialized.

Measured time from power on to POWER_EN time was 600-800 ms with the
python board_init, vs. 155 ms for the MICROPY_BOARD_STARTUP.

Signed-off-by: Matthias Blankertz <matthias@blankertz.org>
2025-10-13 23:20:56 +02:00
834e07966a hw: Cleanup schematic style, fix ERC violations
All checks were successful
Build RPi Pico firmware image / Build-Firmware (push) Successful in 3m23s
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 12s
No netlist changes.

Signed-off-by: Matthias Blankertz <matthias@blankertz.org>
2025-10-12 12:08:37 +02:00
e8beb4c8f7 hw: Use new and improved RPi Pico symbol in schematic
Signed-off-by: Matthias Blankertz <matthias@blankertz.org>
2025-10-12 12:08:37 +02:00
fef9e690cd playlistdb: micropython does not have bytes.removeprefix()
All checks were successful
Build RPi Pico firmware image / Build-Firmware (push) Successful in 3m26s
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 5s
Run unit tests on host / Run-Unit-Tests (push) Successful in 9s
Run pytests / Check-Pytest (push) Successful in 11s
2025-10-07 22:23:54 +02:00
4507275a02 app: Fix bug when a tag that has no playlist is encountered 2025-10-07 22:23:54 +02:00
16d5180d34 playlistdb: Allow up to 100k tracks; Add validate method; docstrings
- Increase the formatting of playlist entries to allow up to 100000
  tracks. Also enforce that playlist entries are indexed by integers
  using the validate method.

- Add a validate method to validate the data stored in the
  btreedb. Optionally dump the contents to stdout. For testing, add a
  validate+dump by default when opening the db. This can be removed once
  the playlistdb is validated.
2025-10-07 22:23:54 +02:00
69e119a8a0 Add playlist database
Add playlist database based on the micropython 'btree' module.
Supported features are:
* Create playlist
* Load playlist
* Store position in playlist

Different playlist modes will be added in a followup for #24.

Implements #23.

The playlist data is stored in the btree database in a hierarchical
schema. The hierarchy levels are separated by the '/' character.
Currently, the schema is as follows: The top level for a playlist is the
'tag' id encoded as a hexadecimal string. Beneath this, the 'playlist'
key contains the elements in the playlist. The exact keys used for the
playlist entries are not specified, they are enumerated in native sort
order to build the playlist. When writing a playlist using the
playlistdb module, the keys are 000, 001, etc. by default.  The
'playlistpos' key is also located under the 'tag' key and stores the key
of the current playlist entry.

For example, a playlist with two entries 'a.mp3' and 'b.mp3' for a tag
with the id '00aa11bb22' would be stored in the following key/value
pairs in the btree db:
- 00aa11bb22/playlist/000: a.mp3
- 00aa11bb22/playlist/001: b.mp3
- 00aa11bb22/playlistpos: 000

Signed-off-by: Matthias Blankertz <matthias@blankertz.org>
2025-10-07 22:23:51 +02:00
46 changed files with 11786 additions and 10012 deletions

View File

@@ -17,9 +17,9 @@ jobs:
uses: actions/upload-artifact@v3
with:
name: firmware-RPi-Pico-W
path: software/lib/micropython/ports/rp2/build-TONBERRY_RPI_PICO_W/firmware.uf2
path: software/build/firmware-*.uf2
- name: Upload firmware w/ filesystem
uses: actions/upload-artifact@v3
with:
name: firmware-RPi-Pico-W-with-fs
path: software/lib/micropython/ports/rp2/build-TONBERRY_RPI_PICO_W/firmware-filesystem-*.uf2
path: software/build/firmware-filesystem-*.uf2

View File

@@ -9,3 +9,46 @@ python -m venv test-venv
pip install -r tests/requirements.txt
pip install -U micropython-rp2-pico_w-stubs --target typings
```
## 'database' schema for btree db
### Playlist storage
The playlist data is stored in the btree database in a hierarchical schema. The hierarchy levels are
separated by the '/' character. Currently, the schema is as follows: The top level for a playlist
is the 'tag' id encoded as a hexadecimal string. Beneath this, the 'playlist' key contains the
elements in the playlist. The keys used for the playlist entries must be decimal integers,
left-padded with zeros so their length is 5 (e.g. format `{:05}`).
#### Playlist modes
The 'playlistshuffle' key located under the 'tag' key can be 'no' or 'yes' and specifies whether the
playlist is in shuffle mode. Should this key be absent the default value is 'no'.
The 'playlistpersist' key located under the 'tag' key can be 'no', 'track' or 'offset'. Should this
key be absent the default value is 'track'.
* When it is 'no', the playlist position is not saved when playback stops. If shuffle mode is
active, the shuffle random seed is also not saved.
* When it is 'track', the currently playing track is saved when playback stops. If shuffle mode is
active, the shuffle random seed is also saved. Should playback reach the last track (in shuffle
mode: the last track in the permutated order), the saved position is reset and playback is
stopped. The next time the playlist is started it will start from the first track and with a new
shuffle seed if applicable.
* When it is 'offset', the operation is basically the same as in 'track' mode. The difference is
that the offset in the currently playing track is also saved and playback will resume at that
position.
The 'playlistpos' key located under the 'tag' key stores the key of the current playlist
entry. The 'playlistshuffleseed' key stores the random seed used to shuffle the playlist.
The 'playlistposoffset' key stores the offset in the current playlist entry.
#### Example
For example, a playlist with two entries 'a.mp3' and 'b.mp3' for a tag with the id '00aa11bb22'
would be stored in the following key/value pairs in the btree db:
* 00aa11bb22/playlist/00000: a.mp3
* 00aa11bb22/playlist/00001: b.mp3
* 00aa11bb22/playlistpos: 00000

View File

@@ -5,18 +5,18 @@
(condition "A.Type == 'pad' && (B.Type == 'text' || B.Type == 'graphic')"))
(rule "drill hole size (mechanical)"
(constraint hole_size (min 0.3mm) (max 6.3mm)))
(constraint hole_size (min 0.2mm) (max 6.3mm)))
(rule "Minimum Via Hole Size"
(constraint hole_size (min 0.3mm))
(constraint hole_size (min 0.2mm))
(condition "A.Type == 'via'"))
(rule "Minimum Via Diameter"
(constraint via_diameter (min 20mil))
(constraint via_diameter (min 0.35mm))
(condition "A.Type == 'via'"))
(rule "PTH Hole Size"
(constraint hole_size (min 12mil) (max 6.35mm))
(constraint hole_size (min 0.2mm) (max 6.35mm))
(condition "A.isPlated()"))
(rule "Minimum Non-plated Hole Size"
@@ -24,5 +24,5 @@
(condition "A.Type == 'pad' && !A.isPlated()"))
(rule "Pad to Track clearance"
(constraint clearance (min 0.2mm))
(constraint clearance (min 0.1mm))
(condition "A.isPlated() && A.Type != 'Via' && B.Type == 'track'"))

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"board": {
"active_layer": 5,
"active_layer": 0,
"active_layer_preset": "",
"auto_track_width": true,
"hidden_netclasses": [],
@@ -18,17 +18,17 @@
"zones": 0.6
},
"selection_filter": {
"dimensions": true,
"footprints": true,
"dimensions": false,
"footprints": false,
"graphics": true,
"keepouts": true,
"keepouts": false,
"lockedItems": false,
"otherItems": true,
"pads": true,
"otherItems": false,
"pads": false,
"text": true,
"tracks": true,
"vias": true,
"zones": true
"zones": false
},
"visible_items": [
"vias",
@@ -51,7 +51,7 @@
"conflict_shadows",
"shapes"
],
"visible_layers": "00000000_00000000_0fffffff_ffffffff",
"visible_layers": "00000000_00000000_0ffffff7_ffffffff",
"zone_display_mode": 0
},
"git": {

View File

@@ -37,9 +37,9 @@
"other_text_thickness": 0.15,
"other_text_upright": false,
"pads": {
"drill": 3.2,
"height": 3.2,
"width": 3.2
"drill": 0.8,
"height": 1.6,
"width": 1.6
},
"silk_line_width": 0.1,
"silk_text_italic": false,
@@ -48,7 +48,7 @@
"silk_text_thickness": 0.1,
"silk_text_upright": false,
"zones": {
"min_clearance": 0.0
"min_clearance": 0.4064
}
},
"diff_pair_dimensions": [
@@ -60,31 +60,11 @@
],
"drc_exclusions": [
[
"items_not_allowed|117348000|131000000|4c513f4f-0c90-4a64-b2d1-bf0189c761db|00000000-0000-0000-0000-000000000000",
"lib_footprint_mismatch|148587019|59126370|4f17c230-1bc1-45e3-8816-9cf9d13e3a5c|00000000-0000-0000-0000-000000000000",
""
],
[
"items_not_allowed|117348000|131000000|fea7aba8-73c4-4553-8cc8-83472c47a83b|00000000-0000-0000-0000-000000000000",
""
],
[
"items_not_allowed|122420000|136975000|8ffc6a0d-806e-4b99-922f-2baead2bd98b|00000000-0000-0000-0000-000000000000",
""
],
[
"items_not_allowed|148270000|136975000|804b9d74-91fa-4f75-bc54-8f55b801562f|00000000-0000-0000-0000-000000000000",
""
],
[
"items_not_allowed|148720706|136551802|f2c1e20e-a272-42d8-a551-3e9074b96921|00000000-0000-0000-0000-000000000000",
""
],
[
"nonmirrored_text_on_back_layer|210811000|131073000|e87ca627-5327-4f7e-ac1a-c1eadd662c0a|00000000-0000-0000-0000-000000000000",
""
],
[
"silk_overlap|115550000|131000000|cd433b4c-40ac-48cc-b03e-5acf3357d88b|fea7aba8-73c4-4553-8cc8-83472c47a83b",
"nonmirrored_text_on_back_layer|213233000|81790000|e87ca627-5327-4f7e-ac1a-c1eadd662c0a|00000000-0000-0000-0000-000000000000",
""
]
],
@@ -153,22 +133,22 @@
},
"rules": {
"max_error": 0.005,
"min_clearance": 0.2032,
"min_clearance": 0.15,
"min_connection": 0.0,
"min_copper_edge_clearance": 0.2,
"min_groove_width": 0.0,
"min_hole_clearance": 0.35,
"min_hole_to_hole": 0.45,
"min_hole_to_hole": 0.2,
"min_microvia_diameter": 0.2,
"min_microvia_drill": 0.1,
"min_resolved_spokes": 2,
"min_silk_clearance": 0.0,
"min_text_height": 1.0,
"min_text_thickness": 0.15,
"min_through_hole_diameter": 0.3,
"min_track_width": 0.2032,
"min_through_hole_diameter": 0.2,
"min_track_width": 0.15,
"min_via_annular_width": 0.15,
"min_via_diameter": 0.5,
"min_via_diameter": 0.35,
"solder_mask_to_copper_clearance": 0.005,
"use_height_for_length_calcs": true
},
@@ -280,7 +260,12 @@
"equivalence_files": []
},
"erc": {
"erc_exclusions": [],
"erc_exclusions": [
[
"pin_to_pin|1549400|977900|f11a46f2-13fb-4d63-9b53-b032e49a375d|4b6603d6-e95b-4131-942b-96ddc8a9a606|/7226ca0f-138a-46b4-89f3-dc32dfb1f9f7|/7226ca0f-138a-46b4-89f3-dc32dfb1f9f7|/7226ca0f-138a-46b4-89f3-dc32dfb1f9f7",
"Raspberry Pi Pico W Datasheet p. 9: If the ADC is not used or ADC performance is not critical, this pin can be connected to digital ground."
]
],
"meta": {
"version": 0
},
@@ -468,9 +453,9 @@
"extra_units": "error",
"footprint_filter": "ignore",
"footprint_link_issues": "warning",
"four_way_junction": "ignore",
"four_way_junction": "warning",
"global_label_dangling": "warning",
"hier_label_mismatch": "error",
"hier_label_mismatch": "warning",
"label_dangling": "error",
"label_multiple_wires": "warning",
"lib_symbol_issues": "warning",
@@ -485,14 +470,14 @@
"no_connect_dangling": "warning",
"pin_not_connected": "error",
"pin_not_driven": "error",
"pin_to_pin": "warning",
"pin_to_pin": "error",
"power_pin_not_driven": "error",
"same_local_global_label": "warning",
"similar_label_and_power": "warning",
"similar_labels": "warning",
"similar_power": "warning",
"simulation_model_issue": "ignore",
"single_global_label": "ignore",
"single_global_label": "warning",
"unannotated": "error",
"unconnected_wire_endpoint": "warning",
"undefined_netclass": "error",

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,3 @@
include("manifest.py")
module("hwconfig.py", "../../src/hwconfig_Rev1")

View File

@@ -0,0 +1,3 @@
include("manifest.py")
module("hwconfig.py", "../../src/hwconfig_breadboard")

View File

@@ -15,3 +15,10 @@ module("microdot.py", "../../lib/microdot/src/microdot/")
# TonberryPico modules
module("audiocore.py", "../../modules/audiocore")
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")

View File

@@ -25,3 +25,12 @@ int mp_hal_is_pin_reserved(int n);
#define MICROPY_HW_PIN_RESERVED(i) mp_hal_is_pin_reserved(i)
#define MICROPY_PY_THREAD (0)
#define TONBERRY_POWER_EN 22
#define MICROPY_BOARD_STARTUP() \
{ \
gpio_init(TONBERRY_POWER_EN); \
gpio_set_dir(TONBERRY_POWER_EN, true); \
gpio_put(TONBERRY_POWER_EN, true); \
}

View File

@@ -0,0 +1,5 @@
include("$(PORT_DIR)/variants/manifest.py")
include("$(MPY_DIR)/extmod/asyncio")
module("microdot.py", "../../lib/microdot/src/microdot/")

View File

@@ -0,0 +1,31 @@
/*
* This file is part of the MicroPython project, http://micropython.org/
*
* The MIT License (MIT)
*
* Copyright (c) 2019 Damien P. George
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
*/
// Set base feature level.
#define MICROPY_CONFIG_ROM_LEVEL (MICROPY_CONFIG_ROM_LEVEL_EXTRA_FEATURES)
// Enable extra Unix features.
#include "mpconfigvariant_common.h"

View File

@@ -0,0 +1,3 @@
# This is the default variant when you `make` the Unix port.
FROZEN_MANIFEST ?= $(VARIANT_DIR)/manifest.py

View File

@@ -0,0 +1,126 @@
/*
* This file is part of the MicroPython project, http://micropython.org/
*
* The MIT License (MIT)
*
* Copyright (c) 2022 Jim Mussared
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
*/
// This file enables and configures features common to all variants
// other than "minimal".
// Send raise KeyboardInterrupt directly from the signal handler rather than
// scheduling it into the VM.
#define MICROPY_ASYNC_KBD_INTR (!MICROPY_PY_THREAD_GIL)
// Enable helpers for printing debugging information.
#ifndef MICROPY_DEBUG_PRINTERS
#define MICROPY_DEBUG_PRINTERS (1)
#endif
// Enable floating point by default.
#ifndef MICROPY_FLOAT_IMPL
#define MICROPY_FLOAT_IMPL (MICROPY_FLOAT_IMPL_DOUBLE)
#endif
// Don't use native _Float16 because it increases code size by a lot.
#ifndef MICROPY_FLOAT_USE_NATIVE_FLT16
#define MICROPY_FLOAT_USE_NATIVE_FLT16 (0)
#endif
// Enable arbitrary precision long-int by default.
#ifndef MICROPY_LONGINT_IMPL
#define MICROPY_LONGINT_IMPL (MICROPY_LONGINT_IMPL_MPZ)
#endif
// Enable use of C libraries that need read/write/lseek/fsync, e.g. axtls.
#define MICROPY_STREAMS_POSIX_API (1)
// REPL conveniences.
#define MICROPY_REPL_EMACS_WORDS_MOVE (1)
#define MICROPY_REPL_EMACS_EXTRA_WORDS_MOVE (1)
#define MICROPY_USE_READLINE_HISTORY (1)
#ifndef MICROPY_READLINE_HISTORY_SIZE
#define MICROPY_READLINE_HISTORY_SIZE (50)
#endif
// Seed random on import.
#define MICROPY_PY_RANDOM_SEED_INIT_FUNC (mp_random_seed_init())
// Allow exception details in low-memory conditions.
#define MICROPY_ENABLE_EMERGENCY_EXCEPTION_BUF (1)
#define MICROPY_EMERGENCY_EXCEPTION_BUF_SIZE (256)
// Allow loading of .mpy files.
#define MICROPY_PERSISTENT_CODE_LOAD (1)
// Extra memory debugging.
#define MICROPY_MALLOC_USES_ALLOCATED_SIZE (1)
#define MICROPY_MEM_STATS (1)
// Enable a small performance boost for the VM.
#define MICROPY_OPT_COMPUTED_GOTO (1)
// Return number of collected objects from gc.collect().
#define MICROPY_PY_GC_COLLECT_RETVAL (1)
// Enable detailed error messages and warnings.
#define MICROPY_ERROR_REPORTING (MICROPY_ERROR_REPORTING_DETAILED)
#define MICROPY_WARNINGS (1)
#define MICROPY_PY_STR_BYTES_CMP_WARN (1)
// Configure the "sys" module with features not usually enabled on bare-metal.
#define MICROPY_PY_SYS_ATEXIT (1)
#define MICROPY_PY_SYS_EXC_INFO (1)
// Configure the "os" module with extra unix features.
#define MICROPY_PY_OS_INCLUDEFILE "ports/unix/modos.c"
#define MICROPY_PY_OS_ERRNO (1)
#define MICROPY_PY_OS_GETENV_PUTENV_UNSETENV (1)
#define MICROPY_PY_OS_SYSTEM (1)
#define MICROPY_PY_OS_URANDOM (1)
// Enable the unix-specific "time" module.
#define MICROPY_PY_TIME (1)
#define MICROPY_PY_TIME_TIME_TIME_NS (1)
#define MICROPY_PY_TIME_CUSTOM_SLEEP (1)
#define MICROPY_PY_TIME_INCLUDEFILE "ports/unix/modtime.c"
#if MICROPY_PY_SSL
#define MICROPY_PY_HASHLIB_MD5 (1)
#define MICROPY_PY_HASHLIB_SHA1 (1)
#define MICROPY_PY_CRYPTOLIB (1)
#endif
// The "select" module is enabled by default, but disable select.select().
#define MICROPY_PY_SELECT_POSIX_OPTIMISATIONS (1)
#define MICROPY_PY_SELECT_SELECT (0)
// Enable the "websocket" module.
#define MICROPY_PY_WEBSOCKET (1)
// Enable the "machine" module, mostly for machine.mem*.
#define MICROPY_PY_MACHINE (1)
#define MICROPY_PY_MACHINE_PULSE (1)
#define MICROPY_PY_MACHINE_PIN_BASE (1)
#define MICROPY_VFS_ROM (1)
#define MICROPY_VFS_ROM_IOCTL (0)

View File

@@ -6,15 +6,36 @@ set -eu
( cd lib/micropython
make -C mpy-cross -j "$(nproc)"
make -C ports/rp2 BOARD=TONBERRY_RPI_PICO_W BOARD_DIR="$TOPDIR"/boards/RPI_PICO_W clean
make -C ports/rp2 BOARD=TONBERRY_RPI_PICO_W BOARD_DIR="$TOPDIR"/boards/RPI_PICO_W \
USER_C_MODULES="$TOPDIR"/modules/micropython.cmake -j "$(nproc)"
# build tonberry specific unix port of micropython
make -C ports/unix VARIANT_DIR="$TOPDIR"/boards/tonberry_unix clean
make -C ports/unix VARIANT_DIR="$TOPDIR"/boards/tonberry_unix -j "$(nproc)"
)
( cd tools/mklittlefs
make -j "$(nproc)"
)
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)
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
for hwconfig in boards/RPI_PICO_W/manifest-*.py; do
hwconfig_base=$(basename "$hwconfig")
hwname=${hwconfig_base##manifest-}
hwname=${hwname%%.py}
hwconfig_abs=$(realpath "$hwconfig")
( cd lib/micropython
make -C ports/rp2 BOARD=TONBERRY_RPI_PICO_W BOARD_DIR="$TOPDIR"/boards/RPI_PICO_W clean
make -C ports/rp2 BOARD=TONBERRY_RPI_PICO_W BOARD_DIR="$TOPDIR"/boards/RPI_PICO_W \
USER_C_MODULES="$TOPDIR"/modules/micropython.cmake \
FROZEN_MANIFEST="$hwconfig_abs" -j "$(nproc)"
)
PICOTOOL=picotool
if ! command -v $PICOTOOL >/dev/null 2>&1; then
echo "system picotool not found, checking SDK build dir"
@@ -24,22 +45,16 @@ if ! command -v $PICOTOOL >/dev/null 2>&1; then
exit 1
fi
fi
BUILDDIR=lib/micropython/ports/rp2/build-TONBERRY_RPI_PICO_W/
FS_STAGE_DIR=$(mktemp -d)
trap 'rm -rf $FS_STAGE_DIR' EXIT
for hwconfig in src/hwconfig_*.py; do
hwconfig_base=$(basename "$hwconfig")
hwname=${hwconfig_base##hwconfig_}
hwname=${hwname%%.py}
find src/ -iname '*.py' \! -iname 'hwconfig_*.py' | cpio -pdm "$FS_STAGE_DIR"
cp "$hwconfig" "$FS_STAGE_DIR"/src/hwconfig.py
tools/mklittlefs/mklittlefs -p 256 -s 868352 -c "$FS_STAGE_DIR"/src $BUILDDIR/filesystem.bin
truncate -s 2M $BUILDDIR/firmware-filesystem.bin
dd if=$BUILDDIR/firmware.bin of=$BUILDDIR/firmware-filesystem.bin bs=1k
dd if=$BUILDDIR/filesystem.bin of=$BUILDDIR/firmware-filesystem.bin bs=1k seek=1200
$PICOTOOL uf2 convert $BUILDDIR/firmware-filesystem.bin $BUILDDIR/firmware-filesystem-"$hwname".uf2
rm -r "${FS_STAGE_DIR:?}"/*
dd if="$FS_STAGE_DIR"/filesystem.bin of=$BUILDDIR/firmware-filesystem.bin bs=1k seek=1200
cp $BUILDDIR/firmware.uf2 "$OUTDIR"/firmware-"$hwname".uf2
$PICOTOOL uf2 convert $BUILDDIR/firmware-filesystem.bin "$OUTDIR"/firmware-filesystem-"$hwname".uf2
done
echo "Output in $BUILDDIR/firmware.uf2"
echo "Images with filesystem in" ${BUILDDIR}firmware-filesystem-*.uf2
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()
@@ -41,7 +43,7 @@ flash_via_picotool()
local device="${bus_device[1]//[!0-9]/}"
echo "Found RP2 with serial $serial on Bus $bus Device $device"
picotool load --bus "$bus" --address "$device" "$IMAGEFILE"
picotool load --update --bus "$bus" --address "$device" "$IMAGEFILE"
}
FLASH_VIA_MOUNTPOINT=0
@@ -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

View File

@@ -91,19 +91,21 @@ void i2s_stop(void)
{
if (!i2s_context.playback_active)
return;
bool have_data = false;
do {
while (true) {
const long flags = save_and_disable_interrupts();
const int next_buf = (i2s_context.cur_playing + 1) % AUDIO_BUFS;
have_data = i2s_context.has_data[next_buf];
restore_interrupts(flags);
if (have_data)
__wfi();
} while (have_data);
const long flags = save_and_disable_interrupts();
const bool have_data = i2s_context.has_data[next_buf];
if (!have_data) {
i2s_context.playback_active = false;
shared_context.underruns = 0;
restore_interrupts(flags);
break;
}
__wfi();
restore_interrupts(flags);
__nop(); // Ensure at least two instructions between enable interrupts and subsequent disable
__nop();
}
// Workaround rp2040 E13
dma_channel_set_irq1_enabled(i2s_context.dma_ch, false);
dma_channel_abort(i2s_context.dma_ch);

View File

@@ -130,7 +130,10 @@ static void sd_dump_cid [[maybe_unused]] (void)
static bool sd_read_csd(struct sd_context *sd_context)
{
uint8_t buf[16];
if (sd_cmd_read(9, 0, 16, buf)) {
if (!sd_cmd_read(9, 0, 16, buf)) {
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) {
@@ -144,6 +147,7 @@ static bool sd_read_csd(struct sd_context *sd_context)
unsigned blocksize [[maybe_unused]] = 0;
unsigned blocks = 0;
unsigned version [[maybe_unused]] = 0;
unsigned max_speed [[maybe_unused]] = 0;
switch (csd_ver) {
case 0: {
if (sd_context->sdhc_sdxc) {
@@ -160,6 +164,7 @@ static bool sd_read_csd(struct sd_context *sd_context)
const unsigned c_size = (buf[6] & 0x3) << 10 | (buf[7] << 2) | (buf[8] & 0xc0) >> 6;
blocks = (c_size + 1) * (1 << (c_size_mult + 2));
version = 1;
max_speed = buf[3];
break;
}
case 1: {
@@ -167,6 +172,7 @@ static bool sd_read_csd(struct sd_context *sd_context)
const unsigned c_size = (buf[7] & 0x3f) << 16 | buf[8] << 8 | buf[9];
blocks = (c_size + 1) * 1024;
version = 2;
max_speed = buf[3];
break;
}
case 2: {
@@ -177,10 +183,9 @@ static bool sd_read_csd(struct sd_context *sd_context)
sd_context->blocks = blocks;
sd_context->blocksize = blocksize;
#ifdef SD_DEBUG
printf("CSD version %u.0, blocksize %u, blocks %u, capacity %llu MiB\n", version, blocksize, blocks,
((uint64_t)blocksize * blocks) / (1024 * 1024));
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);
#endif
}
return true;
}
@@ -191,52 +196,52 @@ bool sd_init(struct sd_context *sd_context, int mosi, int miso, int sck, int ss,
}
if (!sd_early_init()) {
return false;
goto out_spi;
}
if (!sd_check_interface_condition()) {
return false;
goto out_spi;
}
uint32_t ocr;
if (!sd_read_ocr(&ocr)) {
printf("sd_init: read OCR failed\n");
return false;
goto out_spi;
}
if ((ocr & 0x00380000) != 0x00380000) {
printf("sd_init: unsupported card voltage range\n");
return false;
goto out_spi;
}
if (!sd_send_op_cond())
return false;
goto out_spi;
sd_spi_set_bitrate(rate);
if (!sd_read_ocr(&ocr)) {
printf("sd_init: read OCR failed\n");
return false;
goto out_spi;
}
if (!(ocr & (1 << 31))) {
printf("sd_init: card not powered up but !idle?\n");
return false;
goto out_spi;
}
sd_context->sdhc_sdxc = (ocr & (1 << 30));
if (!sd_read_csd(sd_context)) {
return false;
goto out_spi;
}
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);
return false;
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");
return false;
goto out_spi;
}
// Successfully set blocksize to SD_SECTOR_SIZE, adjust context
sd_context->blocks *= sd_context->blocksize / SD_SECTOR_SIZE;
@@ -253,6 +258,10 @@ bool sd_init(struct sd_context *sd_context, int mosi, int miso, int sck, int ss,
sd_context->initialized = true;
return true;
out_spi:
sd_spi_deinit();
return false;
}
bool sd_deinit(struct sd_context *sd_context)

View File

@@ -12,13 +12,15 @@
#include <pico/time.h>
#include <string.h>
typedef enum { DMA_READ_TOKEN, DMA_READ, DMA_IDLE } sd_dma_state;
struct sd_dma_context {
uint8_t *read_buf;
size_t len;
uint8_t crc_buf[2];
uint8_t read_token_buf;
uint8_t wrdata;
_Atomic enum { DMA_READ_TOKEN, DMA_READ, DMA_IDLE } state;
_Atomic sd_dma_state state;
};
struct sd_spi_context {
@@ -111,8 +113,18 @@ static void __time_critical_func(sd_spi_dma_isr)(void)
void sd_spi_wait_complete(void)
{
while (sd_spi_context.sd_dma_context.state != DMA_IDLE)
while (true) {
const long flags = save_and_disable_interrupts();
const sd_dma_state state = sd_spi_context.sd_dma_context.state;
if (state == DMA_IDLE) {
restore_interrupts(flags);
return;
}
__wfi();
restore_interrupts(flags);
__nop(); // Ensure at least two instructions between enable interrupts and subsequent disable
__nop();
}
}
bool sd_cmd_read_is_complete(void) { return sd_spi_context.sd_dma_context.state == DMA_IDLE; }
@@ -208,6 +220,12 @@ bool sd_cmd_read_complete(void)
sd_spi_wait_complete();
gpio_put(sd_spi_context.ss, true);
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);
#endif
return false;
}
#ifdef SD_READ_CRC_CHECK
const uint16_t expect_crc = sd_crc16(sd_spi_context.sd_dma_context.len, sd_spi_context.sd_dma_context.read_buf);
const uint16_t act_crc = sd_spi_context.sd_dma_context.crc_buf[0] << 8 | sd_spi_context.sd_dma_context.crc_buf[1];
@@ -218,7 +236,7 @@ bool sd_cmd_read_complete(void)
return false;
}
#endif
return (sd_spi_context.sd_dma_context.read_token_buf == 0xfe);
return true;
}
bool sd_cmd_write(uint8_t cmd, uint32_t arg, unsigned datalen, uint8_t data[const static datalen])

View File

@@ -52,10 +52,12 @@ static inline void sd_spi_pio_program_init(PIO pio, uint sm, uint offset, uint m
sm_config_set_out_shift(&c, false, true, 8);
sm_config_set_in_shift(&c, false, true, 8);
// high speed SPI needs to bypass the input synchronizers on the MISO pin
hw_set_bits(&pio->input_sync_bypass, 1u << miso);
const unsigned pio_freq = bitrate*4;
const float div = clock_get_hz(clk_sys) / (float)pio_freq;
// for some reason, small clkdiv values (even integer ones) cause issues
sm_config_set_clkdiv(&c, div < 2.5f ? 2.5f : div);
sm_config_set_clkdiv(&c, div);
pio_sm_init(pio, sm, offset, &c);
}
%}

View File

@@ -2,33 +2,25 @@
# Copyright (c) 2025 Matthias Blankertz <matthias@blankertz.org>
from collections import namedtuple
import os
import time
from utils import TimerManager
Dependencies = namedtuple('Dependencies', ('mp3player', 'nfcreader', 'buttons'))
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]
class PlayerApp:
def __init__(self, deps: Dependencies):
class TagStateMachine:
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.timer_manager = TimerManager()
self.player = deps.mp3player(self)
self.nfc = deps.nfcreader(self)
self.buttons = deps.buttons(self) if deps.buttons is not None else None
self.mp3file = None
self.volume_pos = 3
self.player.set_volume(VOLUME_CURVE[self.volume_pos])
def __del__(self):
if self.mp3file is not None:
self.mp3file.close()
self.mp3file = None
self.timeout = timeout
def onTagChange(self, new_tag):
if new_tag is not None:
@@ -39,27 +31,65 @@ class PlayerApp:
if new_tag is not None:
self.current_tag_time = time.ticks_ms()
self.current_tag = new_tag
uid_str = ''.join('{:02x}'.format(x) for x in new_tag)
try:
testfiles = [f'/sd/{uid_str}/'.encode() + name for name in os.listdir(f'/sd/{uid_str}'.encode())
if name.endswith(b'mp3')]
except OSError as ex:
print(f'Could not get playlist for tag {uid_str}: {ex}')
self.current_tag = None
self.player.stop()
return
testfiles.sort()
self._set_playlist(testfiles)
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:
print('Tag gone, stopping playback')
self.current_tag = None
self.player.stop()
self.parent.onTagRemoved()
def __init__(self, deps: Dependencies):
self.timer_manager = TimerManager()
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.config.get_tagmode()
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()
def __del__(self):
if self.mp3file is not None:
self.mp3file.close()
self.mp3file = None
def onNewTag(self, new_tag):
"""
Callback (typically called by TagStateMachine) to signal that a new tag has been presented.
"""
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
elif self.tag_mode == 'tagstartstop':
print('Tag presented again, stopping playback')
self._unset_playlist()
self.playing_tag = None
def onTagRemoved(self):
"""
Callback (typically called by TagStateMachine) to signal that a tag has been removed.
"""
if self.tag_mode == 'tagremains':
print('Tag gone, stopping playback')
self._unset_playlist()
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])
@@ -68,26 +98,91 @@ class PlayerApp:
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 _set_playlist(self, files: list[bytes]):
self.playlist_pos = 0
self.playlist = files
self._play(self.playlist[self.playlist_pos])
def onIdleTimeout(self):
if self.hwconfig.get_on_battery():
self.hwconfig.power_off()
else:
# Check again in a minute
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:
pos = self.player.stop()
if pos is not None:
self.playlist.setPlaybackOffset(pos)
self.playlist = self.playlist_db.getPlaylistForTag(tag)
self._play(self.playlist.getCurrentPath() if self.playlist is not None else None,
self.playlist.getPlaybackOffset() if self.playlist is not None else 0)
def _unset_playlist(self):
if self.playlist is not None:
pos = self.player.stop()
self._onIdle()
if pos is not None:
self.playlist.setPlaybackOffset(pos)
self.playlist = None
def _play_next(self):
if self.playlist_pos + 1 < len(self.playlist):
self.playlist_pos += 1
self._play(self.playlist[self.playlist_pos])
if self.playlist is None:
return
filename = self.playlist.getNextPath()
self._play(filename)
if filename is None:
self.playlist = None
self.playing_tag = None
def _play(self, filename: bytes):
def _play_prev(self):
if self.playlist is None:
return
filename = self.playlist.getPrevPath()
self._play(filename)
if filename is None:
self.playlist = None
self.playing_tag = None
def _play(self, filename: bytes | None, offset=0):
if self.mp3file is not None:
self.player.stop()
self.mp3file.close()
self.mp3file = None
self._onIdle()
if filename is not None:
print(f'Playing {filename!r}')
self.mp3file = open(filename, 'rb')
self.player.play(self.mp3file)
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() + 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

View File

@@ -28,24 +28,24 @@ 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
VBAT_ADC = Pin.board.GP26
VBUS_DET = Pin.board.WL_GPIO2
def board_init():
# Keep power turned on
# POWER_EN turned on in MICROPY_BOARD_STARTUP
# TODO: Implement soft power off
POWER_EN.init(mode=Pin.OUT)
POWER_EN.value(1)
# SD_DO / MISO input doesn't need any special configuration
# Set 8 mA drive strength for SCK and MOSI
@@ -63,3 +63,13 @@ def get_battery_voltage():
adc = machine.ADC(VBAT_ADC) # create ADC object on ADC pin
battv = adc.read_u16()/65535.0*3.3*2
return battv
def power_off():
POWER_EN.init(mode=Pin.OUT)
POWER_EN.value(0)
def get_on_battery():
vbus = VBUS_DET.value()
return not vbus

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
@@ -47,3 +46,12 @@ def board_init():
def get_battery_voltage():
# Not supported on breadboard
return None
def power_off():
# Not supported on breadboard
pass
def get_on_battery():
return False

View File

@@ -1,58 +0,0 @@
# SPDX-License-Identifier: MIT
# Copyright (c) 2024 Matthias Blankertz <matthias@blankertz.org>
# Run with mpremote.py run src/led_test.py
from machine import Pin
from math import pi, sin, pow
from micropython import const
from rp2_neopixel import NeoPixel
from time import sleep, ticks_ms
import asyncio
pin = Pin.board.GP16
leds = const(10)
brightness = 0.5
np = NeoPixel(pin, leds)
# test fill and write
print("LEDs should now turn red")
np.fill((255, 0, 0))
np.write()
sleep(1)
print("LEDs should now turn green")
np.fill((0, 255, 0))
np.write()
sleep(1)
print("LEDs should now turn blue")
np.fill((0, 0, 255))
np.write()
sleep(1)
# test async
def gamma(value, X=2.2):
return min(max(int(brightness * pow(value / 255.0, X) * 255.0 + 0.5), 0), 255)
async def rainbow(np, period=10):
count = 0.0
while True:
for i in range(leds):
ofs = (count + i) % leds
np[i] = (gamma((sin(ofs / leds * 2 * pi) + 1) * 127),
gamma((sin(ofs / leds * 2 * pi + 2/3*pi) + 1) * 127),
gamma((sin(ofs / leds * 2 * pi + 4/3*pi) + 1) * 127))
count += 0.2
before = ticks_ms()
await np.async_write()
now = ticks_ms()
if before + 20 > now:
await asyncio.sleep_ms(20 - (now - before))
print("LEDs should now start rainbowing")
asyncio.run(rainbow(np))

View File

@@ -3,10 +3,13 @@
import aiorepl # type: ignore
import asyncio
from errno import ENOENT
import machine
import micropython
import network
import os
import time
from math import pi, sin, pow
import ubinascii
# Own modules
import app
@@ -15,7 +18,8 @@ from mfrc522 import MFRC522
from mp3player import MP3Player
from nfc import Nfc
from rp2_neopixel import NeoPixel
from utils import Buttons, SDContext, TimerManager
from utils import BTreeFileManager, Buttons, SDContext, TimerManager, LedManager, Configuration
from webserver import start_webserver
try:
import hwconfig
@@ -28,41 +32,60 @@ micropython.alloc_emergency_exception_buf(100)
# Machine setup
hwconfig.board_init()
async def rainbow(np, period=10):
def gamma(value, X=2.2):
return min(max(int(brightness * pow(value / 255.0, X) * 255.0 + 0.5), 0), 255)
brightness = 0.05
count = 0.0
leds = len(np)
while True:
for i in range(leds):
ofs = (count + i) % leds
np[i] = (gamma((sin(ofs / leds * 2 * pi) + 1) * 127),
gamma((sin(ofs / leds * 2 * pi + 2/3*pi) + 1) * 127),
gamma((sin(ofs / leds * 2 * pi + 4/3*pi) + 1) * 127))
count += 0.02 * leds
before = time.ticks_ms()
await np.async_write()
now = time.ticks_ms()
if before + 20 > now:
await asyncio.sleep_ms(20 - (now - before))
# high prio for proc 1
machine.mem32[0x40030000 + 0x00] = 0x10
def setup_wifi():
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)
# disable power management
wlan.config(pm=network.WLAN.PM_NONE)
mac = ubinascii.hexlify(network.WLAN().config('mac'), ':').decode()
print(f" mac: {mac}")
print(f" channel: {wlan.config('channel')}")
print(f" essid: {wlan.config('essid')}")
print(f" txpower: {wlan.config('txpower')}")
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(500)
wdt.feed()
DB_PATH = '/sd/tonberry.db'
config = Configuration()
def run():
asyncio.new_event_loop()
# Setup LEDs
np = NeoPixel(hwconfig.LED_DIN, hwconfig.LED_COUNT, sm=1)
asyncio.create_task(rainbow(np))
np = NeoPixel(hwconfig.LED_DIN, config.get_led_count(), sm=1)
# Wifi with default config
setup_wifi()
# 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), \
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:
# Setup NFC
@@ -72,18 +95,41 @@ 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),
config=lambda _: config)
the_app = app.PlayerApp(deps)
start_webserver(config, the_app)
# Start
wdt = machine.WDT(timeout=1000)
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()
if __name__ == '__main__':
if machine.Pin(17, machine.Pin.IN, machine.Pin.PULL_UP).value() != 0:
time.sleep(1)
if machine.Pin(hwconfig.BUTTONS[0], machine.Pin.IN, machine.Pin.PULL_UP).value() != 0:
run()

View File

@@ -1,73 +0,0 @@
from mfrc522 import MFRC522
import asyncio
import time
delay_sum = 0
delay_count = 0
max_delay = 0
async def latency_test():
global delay_sum
global delay_count
global max_delay
global min_delay
min_delay = 0xffffffff
await asyncio.sleep_ms(1)
while True:
for _ in range(2000):
before = time.ticks_us()
await asyncio.sleep(0)
after = time.ticks_us()
delay = after - before
delay_sum += delay
delay_count += 1
if delay > max_delay:
max_delay = delay
if delay < min_delay:
min_delay = delay
await asyncio.sleep_ms(1)
print(f"delay (min / max / avg) [µs]: ({min_delay} / {max_delay} / {delay/delay_sum})")
def uid_to_string(uid: list):
return '0x' + ''.join(f'{i:02x}' for i in uid)
async def get_tag_uid(reader: MFRC522, poll_interval_ms: int = 50) -> list:
'''
The maximum measured delay with poll_interval_ms=50 and a reader with tocard_retries=5 is
15.9 ms:
delay (min / max / avg) [µs]: (360 / 15945 / 1.892923e-06)
The maximum measured delay dropped to 11.6 ms by setting tocard_retries=1:
delay (min / max / avg) [µs]: (368 / 11696 / 6.204211e-06)
'''
while True:
reader.init()
# For now we omit the tag type
(stat, _) = reader.request(reader.REQIDL)
if stat == reader.OK:
(stat, uid) = reader.SelectTagSN()
if stat == reader.OK:
print(f"uid={uid_to_string(uid)}")
await asyncio.sleep_ms(poll_interval_ms)
def main():
reader = MFRC522(spi_id=1, sck=10, miso=12, mosi=11, cs=13, rst=9, tocard_retries=1)
print("")
print("Please place card on reader")
print("")
asyncio.create_task(get_tag_uid(reader))
asyncio.create_task(latency_test())
asyncio.get_event_loop().run_forever()
if __name__ == "__main__":
main()

View File

@@ -1,38 +0,0 @@
import rp2
import network
import ubinascii
from microdot import Microdot
rp2.country('DE')
wlan = network.WLAN(network.AP_IF)
wlan.config(ssid='TonberryPico', security=network.WLAN.SEC_OPEN)
# Important: we cannot change the ip in station mode, otherwise dhcp won't work!
# wlan.ipconfig(addr4='10.0.0.1')
wlan.active(True) # loads the firmware
while wlan.active() is False:
pass
wlan.config(pm=network.WLAN.PM_NONE)
mac = ubinascii.hexlify(network.WLAN().config('mac'), ':').decode()
print(f" mac: {mac}")
print(f" channel: {wlan.config('channel')}")
print(f" essid: {wlan.config('essid')}")
print(f" txpower: {wlan.config('txpower')}")
print(f"ifconfig: {wlan.ifconfig()}")
app = Microdot()
@app.route('/')
async def index(request):
print("wohoo, a guest :)")
print(f" app: {request.app}")
print(f" client: {request.client_addr}")
print(f" method: {request.method}")
print(f" url: {request.url}")
print(f" headers: {request.headers}")
print(f" cookies: {request.cookies}")
return "TonberryPico says 'Hello World!'"
app.run(port=80)

View File

@@ -21,14 +21,19 @@ class MP3Player:
self.mp3task = None
self.volume = 128
self.cb = cb
self.pos = 0
def play(self, stream):
def play(self, stream, offset=0):
"""
Play from byte stream.
If offset > 0, discard the first offset bytes
"""
if self.mp3task is not None:
self.mp3task.cancel()
self.mp3task = None
if offset > 0:
stream.seek(offset, 1)
self.pos = offset
self.mp3task = asyncio.create_task(self._play_task(stream))
def stop(self):
@@ -38,6 +43,8 @@ class MP3Player:
if self.mp3task is not None:
self.mp3task.cancel()
self.mp3task = None
return self.pos
return None
def set_volume(self, volume: int):
"""
@@ -60,6 +67,7 @@ class MP3Player:
# End of file
break
_, _, underruns = await self.audiocore.async_put(data[:bytes_read])
self.pos += bytes_read
if underruns > known_underruns:
print(f"{underruns:x}")
known_underruns = underruns

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,113 +0,0 @@
# SPDX-License-Identifier: MIT
# Copyright (c) 2024-2025 Matthias Blankertz <matthias@blankertz.org>
import aiorepl
import asyncio
import machine
import micropython
import os
import time
from machine import Pin
from math import pi, sin, pow
from micropython import const
# Own modules
from audiocore import Audiocore
from mp3player import MP3Player
from rp2_neopixel import NeoPixel
from rp2_sd import SDCard
micropython.alloc_emergency_exception_buf(100)
leds = const(10)
brightness = 0.5
def gamma(value, X=2.2):
return min(max(int(brightness * pow(value / 255.0, X) * 255.0 + 0.5), 0), 255)
async def rainbow(np, period=10):
count = 0.0
while True:
for i in range(leds):
ofs = (count + i) % leds
np[i] = (gamma((sin(ofs / leds * 2 * pi) + 1) * 127),
gamma((sin(ofs / leds * 2 * pi + 2/3*pi) + 1) * 127),
gamma((sin(ofs / leds * 2 * pi + 4/3*pi) + 1) * 127))
count += 0.2
before = time.ticks_ms()
await np.async_write()
now = time.ticks_ms()
if before + 20 > now:
await asyncio.sleep_ms(20 - (now - before))
# Set 8 mA drive strength and fast slew rate
machine.mem32[0x4001c004 + 6*4] = 0x67
machine.mem32[0x4001c004 + 7*4] = 0x67
machine.mem32[0x4001c004 + 8*4] = 0x67
def list_sd():
try:
sd = SDCard(mosi=Pin(3), miso=Pin(4), sck=Pin(2), ss=Pin(5), baudrate=15000000)
except OSError:
for i in range(leds):
np[i] = (255, 0, 0)
np.write()
return
try:
os.mount(sd, '/sd')
print(os.listdir(b'/sd'))
except OSError as ex:
print(f"{ex}")
delay_sum = 0
delay_count = 0
max_delay = 0
async def latency_test():
global delay_sum
global delay_count
global max_delay
await asyncio.sleep_ms(1)
while True:
for _ in range(2000):
before = time.ticks_us()
await asyncio.sleep(0)
after = time.ticks_us()
delay = after - before
delay_sum += delay
delay_count += 1
if delay > max_delay:
max_delay = delay
await asyncio.sleep_ms(1)
print(f"Max delay {max_delay} us, average {delay/delay_sum} us")
pin = Pin.board.GP16
np = NeoPixel(pin, leds)
# Test SD card
list_sd()
# Test NeoPixel
asyncio.create_task(rainbow(np))
# Test audio
audioctx = Audiocore(Pin(8), Pin(6))
player = MP3Player(audioctx)
# high prio for proc 1
machine.mem32[0x40030000 + 0x00] = 0x10
testfiles = [b'/sd/' + name for name in os.listdir(b'/sd') if name.endswith(b'mp3')]
player.set_playlist(testfiles)
asyncio.create_task(player.task())
asyncio.create_task(aiorepl.task({'player': player}))
asyncio.get_event_loop().run_forever()

View File

@@ -0,0 +1,53 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"definitions": {
"PlaybackPosition": {
"type": "object",
"properties": {
"position_seconds": { "type": "number" },
"device_uptime": { "type": "number" }
},
"required": ["position_seconds"]
},
"AudioFile": {
"type": "object",
"properties": {
"id": { "type": "string", "format": "uuid" },
"filename": { "type": "string" },
"size_bytes": { "type": "integer" },
"duration_seconds": { "type": "number" },
"last_played_uptime": { "type": "number" },
"playback_position": { "$ref": "#/definitions/PlaybackPosition" }
},
"required": ["id", "filename"]
},
"Playlist": {
"type": "object",
"properties": {
"id": { "type": "string", "format": "uuid" },
"name": { "type": "string" },
"audio_files": {
"type": "array",
"items": { "$ref": "#/definitions/AudioFile" }
},
"current_track_index": { "type": "integer", "minimum": 0 },
"last_played_uptime": { "type": "number" },
"playback_position": { "$ref": "#/definitions/PlaybackPosition" }
},
"required": ["id", "name", "audio_files"]
},
"NfcTag": {
"type": "object",
"properties": {
"uid": { "type": "string" },
"name": { "type": "string" },
"linked_type": {
"type": "string",
"enum": ["audio_file", "playlist"]
},
"linked_id": { "type": "string", "format": "uuid" }
},
"required": ["uid", "linked_type", "linked_id"]
}
}
}

View File

@@ -1,10 +1,15 @@
# SPDX-License-Identifier: MIT
# Copyright (c) 2025 Matthias Blankertz <matthias@blankertz.org>
from utils.helpers import safe_callback
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__ = ["Buttons", "get_pin_index", "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
try:
from typing import TYPE_CHECKING # type: ignore
except ImportError:
@@ -17,14 +18,27 @@ 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.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] = {}
@@ -32,6 +46,17 @@ class Buttons:
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)
@@ -50,4 +75,4 @@ class Buttons:
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")

View File

@@ -0,0 +1,97 @@
# 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'
}
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)
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 _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.get(key, self.DEFAULT_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')
# 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'")
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

@@ -0,0 +1,58 @@
# SPDX-License-Identifier: MIT
# Copyright (c) 2025 Matthias Blankertz <matthias@blankertz.org>
import asyncio
from math import sin, pi
from micropython import const
import time
class LedManager:
IDLE = const(0)
PLAYING = const(1)
def __init__(self, np):
self.led_state = LedManager.IDLE
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]
self.led_state = state
def _gamma(self, value, X=2.2):
result = min(max(int(self.brightness * pow(value / 255.0, X) * 255.0 + 0.5), 0), 255)
if value > 0:
result = max(1, result)
return result
def _rainbow(self, time):
for i in range(self.leds):
ofs = (time * self.leds + i) % self.leds
self.np[i] = (self._gamma((sin(ofs / self.leds * 2 * pi) + 1) * 127),
self._gamma((sin(ofs / self.leds * 2 * pi + 2/3*pi) + 1) * 127),
self._gamma((sin(ofs / self.leds * 2 * pi + 4/3*pi) + 1) * 127))
def _pulse(self, time, color, speed):
scaled_sin = max(1, abs(sin(time / speed * 2 * pi)) * 255)
val = (self._gamma(color[0]*scaled_sin),
self._gamma(color[1]*scaled_sin),
self._gamma(color[2]*scaled_sin))
for i in range(self.leds):
self.np[i] = val
async def run(self):
time_ = 0.0
while True:
if self.led_state == LedManager.IDLE:
self._pulse(time_, (0, 1, 0), 3)
elif self.led_state == LedManager.PLAYING:
self._rainbow(time_)
time_ += 0.02
before = time.ticks_ms()
await self.np.async_write()
now = time.ticks_ms()
if before + 20 > now:
await asyncio.sleep_ms(20 - (now - before))

View File

@@ -0,0 +1,388 @@
# SPDX-License-Identifier: MIT
# Copyright (c) 2025 Matthias Blankertz <matthias@blankertz.org>
import btree
import random
import time
try:
import typing
from typing import TYPE_CHECKING, Iterable # type: ignore
except ImportError:
TYPE_CHECKING = False
if TYPE_CHECKING:
class IPlaylist(typing.Protocol):
def getPaths(self) -> Iterable[bytes]: ...
def getCurrentPath(self) -> bytes: ...
def getNextPath(self) -> bytes | None: ...
class IPlaylistDB(typing.Protocol):
def getPlaylistForTag(self, tag: bytes) -> IPlaylist: ...
else:
class IPlaylistDB(object):
...
class IPlaylist(object):
...
class BTreeDB(IPlaylistDB):
SHUFFLE_NO = b'no'
SHUFFLE_YES = b'yes'
PERSIST_NO = b'no'
PERSIST_TRACK = b'track'
PERSIST_OFFSET = b'offset'
class Playlist(IPlaylist):
def __init__(self, parent: "BTreeDB", tag: bytes, pos: int, persist, shuffle):
self.parent = parent
self.tag = tag
self.pos = pos
self.persist = persist
self.shuffle = shuffle
self.length = self.parent._getPlaylistLength(self.tag)
self._shuffle()
def _getPlaylistPos(self):
"""
Gets the position to pass to parent._getPlaylistEntry etc.
"""
if self.shuffle == BTreeDB.SHUFFLE_YES:
return self.shuffle_order[self.pos]
else:
return self.pos
def _shuffle(self, reshuffle=False):
if self.shuffle == BTreeDB.SHUFFLE_NO:
return
self.shuffle_seed = None
# Try to get seed from DB if persisted
if self.persist != BTreeDB.PERSIST_NO and not reshuffle:
self.shuffle_seed = self.parent._getPlaylistShuffleSeed(self.tag)
if self.shuffle_seed is None:
# Either not persisted or could not read from db
self.shuffle_seed = time.ticks_cpu()
if self.persist != BTreeDB.PERSIST_NO:
self.parent._setPlaylistShuffleSeed(self.tag, self.shuffle_seed)
# TODO: Find an algorithm for shuffling that does not use O(n) memory for playlist of length n
random.seed(self.shuffle_seed)
entries = list(range(0, self.length))
# We don't have random.shuffle in micropython, so emulate it with random.choice
self.shuffle_order = []
while len(entries) > 0:
chosen = random.choice(entries)
self.shuffle_order.append(chosen)
entries.remove(chosen)
def getPaths(self):
"""
Get entire playlist in storage order
"""
return self.parent._getPlaylistValueIterator(self.tag)
def getCurrentPath(self):
"""
Get path of file that should be played.
"""
return self.parent._getPlaylistEntry(self.tag, self._getPlaylistPos())
def getNextPath(self):
"""
Select next track and return path.
"""
if self.pos + 1 >= self.length:
self.pos = 0
if self.persist != BTreeDB.PERSIST_NO:
self.parent._setPlaylistPos(self.tag, self.pos)
self.setPlaybackOffset(0)
self._shuffle(True)
return None
self.pos += 1
if self.persist != BTreeDB.PERSIST_NO:
self.parent._setPlaylistPos(self.tag, self.pos)
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
"""
if self.persist != BTreeDB.PERSIST_OFFSET:
return
self.parent._setPlaylistPosOffset(self.tag, offset)
def getPlaybackOffset(self):
"""
Get the current position in the track for PERSIST_OFFSET mode
"""
if self.persist != BTreeDB.PERSIST_OFFSET:
return 0
return self.parent._getPlaylistPosOffset(self.tag)
def __init__(self, db: btree.BTree, flush_func: typing.Callable | None = None):
self.db = db
self.flush_func = flush_func
@staticmethod
def _keyPlaylistPos(tag):
return b''.join([tag, b'/playlistpos'])
@staticmethod
def _keyPlaylistPosOffset(tag):
return b''.join([tag, b'/playlistposoffset'])
@staticmethod
def _keyPlaylistShuffle(tag):
return b''.join([tag, b'/playlistshuffle'])
@staticmethod
def _keyPlaylistShuffleSeed(tag):
return b''.join([tag, b'/playlistshuffleseed'])
@staticmethod
def _keyPlaylistPersist(tag):
return b''.join([tag, b'/playlistpersist'])
@staticmethod
def _keyPlaylistEntry(tag, pos):
return b''.join([tag, b'/playlist/', '{:05}'.format(pos).encode()])
@staticmethod
def _keyPlaylistStart(tag):
return b''.join([tag, b'/playlist/'])
@staticmethod
def _keyPlaylistStartEnd(tag):
return (b''.join([tag, b'/playlist/']),
b''.join([tag, b'/playlist0']))
def _flush(self):
"""
Flush the database and call the flush_func if it was provided.
"""
self.db.flush()
if self.flush_func is not None:
self.flush_func()
def _getPlaylistValueIterator(self, tag: bytes):
start, end = self._keyPlaylistStartEnd(tag)
return self.db.values(start, end)
def _getPlaylistEntry(self, tag: bytes, pos: int) -> bytes:
return self.db[self._keyPlaylistEntry(tag, pos)]
def _setPlaylistPos(self, tag: bytes, pos: int, flush=True):
self.db[self._keyPlaylistPos(tag)] = str(pos).encode()
if flush:
self._flush()
def _setPlaylistPosOffset(self, tag: bytes, offset: int, flush=True):
self.db[self._keyPlaylistPosOffset(tag)] = str(offset).encode()
if flush:
self._flush()
def _getPlaylistPosOffset(self, tag: bytes) -> int:
return int(self.db.get(self._keyPlaylistPosOffset(tag), b'0'))
def _getPlaylistShuffleSeed(self, tag: bytes) -> int | None:
try:
return int(self.db[self._keyPlaylistShuffleSeed(tag)])
except (ValueError, KeyError):
return None
def _setPlaylistShuffleSeed(self, tag, seed: int, flush=True):
self.db[self._keyPlaylistShuffleSeed(tag)] = str(seed).encode()
if flush:
self._flush()
def _getPlaylistLength(self, tag: bytes) -> int:
start, end = self._keyPlaylistStartEnd(tag)
for k in self.db.keys(end, start, btree.DESC):
# There is a bug in btreedb that causes an additional key after 'end' to be returned when iterating in
# descending order
# Check for this and skip it if needed
elements = k.split(b'/')
if len(elements) >= 2 and elements[1] == b'playlist':
last = k
break
elements = last.split(b'/')
if len(elements) != 3:
raise RuntimeError("Malformed playlist key")
return int(elements[2])+1
def _savePlaylist(self, tag, entries, persist, shuffle, 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
if flush:
self._flush()
def _deletePlaylist(self, tag, flush=True):
start_key, end_key = self._keyPlaylistStartEnd(tag)
for k in self.db.keys(start_key, end_key):
try:
del self.db[k]
except KeyError:
pass
for k in (self._keyPlaylistPos(tag), self._keyPlaylistPosOffset(tag),
self._keyPlaylistPersist(tag), self._keyPlaylistShuffle(tag),
self._keyPlaylistShuffleSeed(tag)):
try:
del self.db[k]
except KeyError:
pass
if flush:
self._flush()
def getPlaylistForTag(self, tag: bytes):
"""
Lookup the playlist for 'tag' and return the Playlist object. Return None if no playlist exists for the given
tag.
"""
persist = self.db.get(self._keyPlaylistPersist(tag), self.PERSIST_TRACK)
pos = 0
if persist != self.PERSIST_NO and self._keyPlaylistPos(tag) in self.db:
try:
pos = int(self.db[self._keyPlaylistPos(tag)])
except ValueError:
pass
if self._keyPlaylistEntry(tag, 0) not in self.db:
# Empty playlist
return None
if self._keyPlaylistEntry(tag, pos) not in self.db:
pos = 0
shuffle = self.db.get(self._keyPlaylistShuffle(tag), self.SHUFFLE_NO)
return self.Playlist(self, tag, pos, persist, shuffle)
def createPlaylistForTag(self, tag: bytes, entries: typing.Iterable[bytes], persist=PERSIST_TRACK,
shuffle=SHUFFLE_NO):
"""
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)
return self.getPlaylistForTag(tag)
def validate(self, dump=False):
"""
Validate the structure of the playlist database.
"""
result = True
def fail(msg):
nonlocal result
print(msg)
result = False
last_tag = None
last_pos = None
for k in self.db.keys():
fields = k.split(b'/')
if len(fields) <= 1:
fail(f'Malformed key {k!r}')
continue
if fields[0] == b'settings':
# Legacy, not used any more
continue
if last_tag != fields[0]:
last_tag = fields[0]
last_pos = None
if dump:
print(f'Tag {fields[0]}')
if fields[1] == b'playlist':
if len(fields) != 3:
fail(f'Malformed playlist entry: {k!r}')
continue
try:
idx = int(fields[2])
except ValueError:
fail(f'Malformed playlist entry: {k!r}')
continue
if len(fields[2]) != 5:
fail(f'Bad index width for {last_tag} at {idx}')
if (last_pos is not None and last_pos + 1 != idx) or \
(last_pos is None and idx != 0):
fail(f'Bad playlist entry sequence for {last_tag} at {idx}')
last_pos = idx
if dump:
print(f'\tTrack {idx}: {self.db[k]!r}')
elif fields[1] == b'playlistpos':
val = self.db[k]
try:
idx = int(val)
except ValueError:
fail(f'Malformed playlist position: {val!r}')
continue
if 0 > idx or idx > last_pos:
fail(f'Playlist position out of range for {last_tag}: {idx}')
elif dump:
print(f'\tPosition {idx}')
elif fields[1] == b'playlistshuffle':
val = self.db[k]
if val not in (b'no', b'yes'):
fail(f'Bad playlistshuffle value for {last_tag}: {val!r}')
if dump and val == b'yes':
print('\tShuffle')
elif fields[1] == b'playlistpersist':
val = self.db[k]
if val not in (b'no', b'track', b'offset'):
fail(f'Bad playlistpersist value for {last_tag}: {val!r}')
elif dump:
print(f'\tPersist: {val.decode()}')
elif fields[1] == b'playlistshuffleseed':
val = self.db[k]
try:
_ = int(val)
except ValueError:
fail(f' Bad playlistshuffleseed value for {last_tag}: {val!r}')
elif fields[1] == b'playlistposoffset':
val = self.db[k]
try:
_ = int(val)
except ValueError:
fail(f' Bad playlistposoffset value for {last_tag}: {val!r}')
else:
fail(f'Unknown key {k!r}')
return result
class BTreeFileManager:
"""
Context manager for a BTreeDB playlist db backed by a file in the filesystem.
"""
def __init__(self, db_path: str | bytes):
self.db_path = db_path
def __enter__(self):
try:
self.db_file = open(self.db_path, 'r+b')
except OSError:
self.db_file = open(self.db_path, 'w+b')
try:
self.db = btree.open(self.db_file, pagesize=512, cachesize=1024)
btdb = BTreeDB(self.db, lambda: self.db_file.flush())
btdb.validate(True) # while testing, validate and dump DB on startup
return btdb
except Exception:
self.db_file.close()
raise
def __exit__(self, exc_type, exc_value, traceback):
self.db.close()
self.db_file.close()

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
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")
def _remove_timer(self, what):
try:
await asyncio.wait_for_ms(self.worker_event.wait(), wait_time)
(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: event 1")
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()
continue
return True
except asyncio.TimeoutError:
pass
return False
async def _timer_worker(self):
while True:
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)
callback()
safe_callback(callback, "timer callback")

61
software/src/webserver.py Normal file
View File

@@ -0,0 +1,61 @@
'''
SPDX-License-Identifier: MIT
Copyright (c) 2024-2025 Stefan Kratochwil <Kratochwil-LA@gmx.de>
'''
import asyncio
from microdot import Microdot
webapp = Microdot()
server = None
config = None
app = None
def start_webserver(config_, app_):
global server, config, app
server = asyncio.create_task(webapp.start_server(port=80))
config = config_
app = app_
@webapp.route('/')
async def index(request):
print("wohoo, a guest :)")
print(f" app: {request.app}")
print(f" client: {request.client_addr}")
print(f" method: {request.method}")
print(f" url: {request.url}")
print(f" headers: {request.headers}")
print(f" cookies: {request.cookies}")
return "TonberryPico says 'Hello World!'"
@webapp.route('/api/v1/filesystem', methods=['POST'])
async def filesystem_post(request):
# curl -X POST -d "burp" http://192.168.4.1/api/v1/filesystem
print(request)
return {'success': False}
@webapp.route('/api/v1/playlist', methods=['POST'])
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):
if app.is_playing():
return 503
try:
config.set_config(request.json)
except ValueError as ex:
return str(ex), 400
return '', 204

View File

@@ -0,0 +1,25 @@
# SPDX-License-Identifier: MIT
# Copyright (c) 2025 Matthias Blankertz <matthias@blankertz.org>
from typing import Iterable
class BTree:
def close(self): ...
def values(self, start_key: str | bytes, end_key: str | bytes | None = None, flags=None) -> Iterable[str | bytes]:
pass
def __setitem__(self, key: str | bytes, val: str | bytes): ...
def flush(self): ...
def get(self, key: str | bytes, default: str | bytes | None = None) -> str | bytes: ...
def open(dbfile) -> BTree:
pass
DESC = 1
INCL = 2

View File

@@ -1,2 +1,5 @@
# SPDX-License-Identifier: MIT
# Copyright (c) 2025 Matthias Blankertz <matthias@blankertz.org>
def const(x):
return x

View File

@@ -0,0 +1,328 @@
# SPDX-License-Identifier: MIT
# Copyright (c) 2025 Matthias Blankertz <matthias@blankertz.org>
import app
import builtins
import pytest # type: ignore
import time
import utils
@pytest.fixture
def micropythonify():
def time_ticks_ms():
return time.time_ns() // 1000000
time.ticks_ms = time_ticks_ms
yield
del time.ticks_ms
class FakeFile:
def __init__(self, filename, mode):
self.filename = filename
self.mode = mode
self.closed = False
def close(self):
self.closed = True
class FakeMp3Player:
def __init__(self):
self.volume: int | None = None
self.track: FakeFile | None = None
def set_volume(self, vol: int):
self.volume = vol
def play(self, track: FakeFile, offset: int):
self.track = track
def stop(self):
self.track = None
class FakeTimerManager:
def __init__(self):
self.queued = []
def cancel(self, timer):
self.queued = [(elem[0], elem[1], True) if elem[1] == timer else elem for elem in self.queued]
def schedule(self, when, what):
self.queued.append((when, what, False))
def testing_run_queued(self):
queued = self.queued
self.queued = []
for when, what, canceled in queued:
if not canceled:
what()
class FakeNfcReader:
tag_callback = None
def __init__(self, tag_callback=None):
FakeNfcReader.tag_callback = tag_callback
class FakeButtons:
def __init__(self): pass
class FakePlaylistDb:
class FakePlaylist:
def __init__(self, parent, pos=0):
self.parent = parent
self.pos = 0
def getCurrentPath(self):
return self.parent.tracklist[self.pos]
def getNextPath(self):
self.pos += 1
if self.pos >= len(self.parent.tracklist):
return None
return self.parent.tracklist[self.pos]
def getPlaybackOffset(self):
return 0
def __init__(self, tracklist=[b'test/path.mp3']):
self.tracklist = tracklist
def getPlaylistForTag(self, tag: bytes):
return self.FakePlaylist(self)
def getSetting(self, key: bytes | str):
if key == 'tagmode':
return 'tagremains'
return None
class FakeHwconfig:
def __init__(self):
self.powered = True
self.on_battery = False
def power_off(self):
self.powered = False
def get_on_battery(self):
return self.on_battery
class FakeLeds:
IDLE = 0
PLAYING = 1
def __init__(self):
self.state = None
def set_state(self, state):
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 fake_open(filename, mode):
return FakeFile(filename, mode)
@pytest.fixture
def faketimermanager(monkeypatch):
fake_timer_manager = FakeTimerManager()
monkeypatch.setattr(utils.timer.TimerManager, '_instance', fake_timer_manager)
yield fake_timer_manager
def _makedeps(mp3player=FakeMp3Player, nfcreader=FakeNfcReader, buttons=FakeButtons,
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,
config=lambda _: config() if callable(config) else config)
def test_construct_app(micropythonify, faketimermanager):
fake_mp3 = FakeMp3Player()
deps = _makedeps(mp3player=fake_mp3)
dut = app.PlayerApp(deps)
fake_mp3 = dut.player
assert fake_mp3.volume is not None
def test_load_playlist_on_tag(micropythonify, faketimermanager, monkeypatch):
fake_db = FakePlaylistDb()
fake_mp3 = FakeMp3Player()
deps = _makedeps(mp3player=fake_mp3, playlistdb=fake_db)
app.PlayerApp(deps)
with monkeypatch.context() as m:
m.setattr(builtins, 'open', fake_open)
FakeNfcReader.tag_callback.onTagChange([23, 42, 1, 2, 3])
assert fake_mp3.track is not None
assert fake_mp3.track.filename == b'test/path.mp3'
assert "r" in fake_mp3.track.mode
assert "b" in fake_mp3.track.mode
def test_playlist_seq(micropythonify, faketimermanager, monkeypatch):
fake_db = FakePlaylistDb([b'track1.mp3', b'track2.mp3', b'track3.mp3'])
fake_mp3 = FakeMp3Player()
deps = _makedeps(mp3player=fake_mp3, playlistdb=fake_db)
dut = app.PlayerApp(deps)
with monkeypatch.context() as m:
m.setattr(builtins, 'open', fake_open)
FakeNfcReader.tag_callback.onTagChange([23, 42, 1, 2, 3])
assert fake_mp3.track is not None
assert fake_mp3.track.filename == b'track1.mp3'
fake_mp3.track = None
dut.onPlaybackDone()
assert fake_mp3.track is not None
assert fake_mp3.track.filename == b'track2.mp3'
fake_mp3.track = None
dut.onPlaybackDone()
assert fake_mp3.track is not None
assert fake_mp3.track.filename == b'track3.mp3'
fake_mp3.track = None
dut.onPlaybackDone()
assert fake_mp3.track is None
def test_playlist_unknown_tag(micropythonify, faketimermanager, monkeypatch):
class FakeNoPlaylistDb:
def getPlaylistForTag(self, tag):
return None
def getSetting(self, key: bytes | str):
return None
fake_db = FakeNoPlaylistDb()
fake_mp3 = FakeMp3Player()
deps = _makedeps(mp3player=fake_mp3, playlistdb=fake_db)
app.PlayerApp(deps)
with monkeypatch.context() as m:
m.setattr(builtins, 'open', fake_open)
FakeNfcReader.tag_callback.onTagChange([23, 42, 1, 2, 3])
assert fake_mp3.track is None
def test_tagmode_startstop(micropythonify, faketimermanager, monkeypatch):
class FakeStartStopConfig(FakeConfig):
def __init__(self):
super().__init__()
def get_tagmode(self):
return 'tagstartstop'
fake_db = FakePlaylistDb([b'test/path.mp3'])
fake_mp3 = FakeMp3Player()
deps = _makedeps(mp3player=fake_mp3, playlistdb=fake_db, config=FakeStartStopConfig)
app.PlayerApp(deps)
with monkeypatch.context() as m:
m.setattr(builtins, 'open', fake_open)
# Present tag to start playback
FakeNfcReader.tag_callback.onTagChange([23, 42, 1, 2, 3])
assert fake_mp3.track is not None
assert fake_mp3.track.filename == b'test/path.mp3'
# Removing tag should not stop playback
FakeNfcReader.tag_callback.onTagChange(None)
faketimermanager.testing_run_queued()
assert fake_mp3.track is not None
assert fake_mp3.track.filename == b'test/path.mp3'
# Presenting tag should stop playback
FakeNfcReader.tag_callback.onTagChange([23, 42, 1, 2, 3])
assert fake_mp3.track is None
# Nothing should change here
FakeNfcReader.tag_callback.onTagChange(None)
faketimermanager.testing_run_queued()
assert fake_mp3.track is None
# Presenting tag again should start playback again
FakeNfcReader.tag_callback.onTagChange([23, 42, 1, 2, 3])
assert fake_mp3.track is not None
assert fake_mp3.track.filename == b'test/path.mp3'
def test_tagmode_remains(micropythonify, faketimermanager, monkeypatch):
fake_db = FakePlaylistDb([b'test/path.mp3'])
fake_mp3 = FakeMp3Player()
deps = _makedeps(mp3player=fake_mp3, playlistdb=fake_db)
app.PlayerApp(deps)
with monkeypatch.context() as m:
m.setattr(builtins, 'open', fake_open)
# Present tag to start playback
FakeNfcReader.tag_callback.onTagChange([23, 42, 1, 2, 3])
assert fake_mp3.track is not None
assert fake_mp3.track.filename == b'test/path.mp3'
# Remove tag to stop playback
FakeNfcReader.tag_callback.onTagChange(None)
faketimermanager.testing_run_queued()
assert fake_mp3.track is None
# Presenting tag again should start playback again
FakeNfcReader.tag_callback.onTagChange([23, 42, 1, 2, 3])
assert fake_mp3.track is not None
assert fake_mp3.track.filename == b'test/path.mp3'
def test_led_state(micropythonify, faketimermanager, monkeypatch):
fake_leds = FakeLeds()
deps = _makedeps(leds=fake_leds)
app.PlayerApp(deps)
assert fake_leds.state == FakeLeds.IDLE
with monkeypatch.context() as m:
m.setattr(builtins, 'open', fake_open)
FakeNfcReader.tag_callback.onTagChange([23, 42, 1, 2, 3])
assert fake_leds.state == FakeLeds.PLAYING
FakeNfcReader.tag_callback.onTagChange(None)
faketimermanager.testing_run_queued()
assert fake_leds.state == FakeLeds.IDLE
def test_idle_shutdown_after_start(micropythonify, faketimermanager, monkeypatch):
fake_hwconfig = FakeHwconfig()
fake_hwconfig.on_battery = True
deps = _makedeps(hwconfig=fake_hwconfig)
app.PlayerApp(deps)
assert fake_hwconfig.powered
faketimermanager.testing_run_queued()
assert not fake_hwconfig.powered
def test_idle_shutdown_after_playback(micropythonify, faketimermanager, monkeypatch):
fake_hwconfig = FakeHwconfig()
fake_hwconfig.on_battery = True
deps = _makedeps(hwconfig=fake_hwconfig)
app.PlayerApp(deps)
assert fake_hwconfig.powered
with monkeypatch.context() as m:
m.setattr(builtins, 'open', fake_open)
FakeNfcReader.tag_callback.onTagChange([23, 42, 1, 2, 3])
faketimermanager.testing_run_queued()
assert fake_hwconfig.powered
# Stop playback
FakeNfcReader.tag_callback.onTagChange(None)
faketimermanager.testing_run_queued()
# Elapse idle timer
faketimermanager.testing_run_queued()
assert not fake_hwconfig.powered

View File

@@ -0,0 +1,197 @@
# SPDX-License-Identifier: MIT
# Copyright (c) 2025 Matthias Blankertz <matthias@blankertz.org>
import btree
import pytest
import time
from utils import BTreeDB
@pytest.fixture(autouse=True)
def micropythonify():
def time_ticks_cpu():
return time.time_ns()
time.ticks_cpu = time_ticks_cpu
yield
del time.ticks_cpu
class FakeDB:
def __init__(self, contents):
self.contents = contents
self.saved_contents = dict(contents)
def flush(self):
self.saved_contents = dict(self.contents)
def values(self, start_key=None, end_key=None, flags=None):
res = []
for key in sorted(self.contents):
if start_key is not None and start_key > key:
continue
if end_key is not None and end_key <= key:
break
yield self.contents[key]
res.append(self.contents[key])
def keys(self, start_key=None, end_key=None, flags=None):
keys = []
if flags is not None and flags & btree.DESC != 0:
start_key, end_key = end_key, start_key
for key in sorted(self.contents):
if start_key is not None and start_key > key:
continue
if end_key is not None and end_key <= key:
break
keys.append(key)
if flags is not None and flags & btree.DESC != 0:
keys.reverse()
return iter(keys)
def get(self, key, default=None):
return self.contents.get(key, default)
def __getitem__(self, key):
return self.contents[key]
def __setitem__(self, key, val):
self.contents[key] = val
def __delitem__(self, key):
del self.contents[key]
def __contains__(self, key):
return key in self.contents
def test_playlist_load():
contents = {b'foo/part': b'no',
b'foo/playlist/00000': b'track1',
b'foo/playlist/00001': b'track2',
b'foo/playlisttt': b'no'
}
uut = BTreeDB(FakeDB(contents))
pl = uut.getPlaylistForTag(b'foo')
assert list(pl.getPaths()) == [b'track1', b'track2']
assert pl.getCurrentPath() == b'track1'
def test_playlist_nextpath():
contents = FakeDB({b'foo/part': b'no',
b'foo/playlist/00000': b'track1',
b'foo/playlist/00001': b'track2',
b'foo/playlisttt': b'no'
})
uut = BTreeDB(contents)
pl = uut.getPlaylistForTag(b'foo')
assert pl.getNextPath() == b'track2'
assert contents.saved_contents[b'foo/playlistpos'] == b'1'
def test_playlist_nextpath_last():
contents = FakeDB({b'foo/playlist/00000': b'track1',
b'foo/playlist/00001': b'track2',
b'foo/playlistpos': b'1'
})
uut = BTreeDB(contents)
pl = uut.getPlaylistForTag(b'foo')
assert pl.getNextPath() is None
assert contents.saved_contents[b'foo/playlistpos'] == b'0'
def test_playlist_create():
contents = FakeDB({b'foo/playlist/00000': b'track1',
b'foo/playlist/00001': b'track2',
b'foo/playlistpos': b'1'
})
newplaylist = [b'never gonna give you up.mp3', b'durch den monsun.mp3']
uut = BTreeDB(contents)
new_pl = uut.createPlaylistForTag(b'foo', newplaylist)
assert list(new_pl.getPaths()) == newplaylist
assert new_pl.getCurrentPath() == newplaylist[0]
assert uut.validate(True)
def test_playlist_load_notexist():
contents = FakeDB({b'foo/playlist/00000': b'track1',
b'foo/playlist/00001': b'track2',
b'foo/playlistpos': b'1'
})
uut = BTreeDB(contents)
assert uut.getPlaylistForTag(b'notfound') is None
def test_playlist_starts_at_beginning_in_persist_no_mode():
contents = FakeDB({b'foo/playlist/00000': b'track1',
b'foo/playlist/00001': b'track2',
b'foo/playlistpersist': b'no',
})
uut = BTreeDB(contents)
pl = uut.getPlaylistForTag(b'foo')
assert pl.getCurrentPath() == b'track1'
assert pl.getNextPath() == b'track2'
del pl
pl = uut.getPlaylistForTag(b'foo')
assert pl.getCurrentPath() == b'track1'
@pytest.mark.parametrize("mode", [b'no', b'track'])
def test_playlist_ignores_offset_in_other_modes(mode):
contents = FakeDB({b'foo/playlist/00000': b'track1',
b'foo/playlist/00001': b'track2',
b'foo/playlistpersist': mode,
})
uut = BTreeDB(contents)
pl = uut.getPlaylistForTag(b'foo')
pl.setPlaybackOffset(42)
del pl
pl = uut.getPlaylistForTag(b'foo')
assert pl.getPlaybackOffset() == 0
def test_playlist_stores_offset_in_offset_mode():
contents = FakeDB({b'foo/playlist/00000': b'track1',
b'foo/playlist/00001': b'track2',
b'foo/playlistpersist': b'offset',
})
uut = BTreeDB(contents)
pl = uut.getPlaylistForTag(b'foo')
pl.setPlaybackOffset(42)
del pl
pl = uut.getPlaylistForTag(b'foo')
assert pl.getPlaybackOffset() == 42
def test_playlist_resets_offset_on_next_track():
contents = FakeDB({b'foo/playlist/00000': b'track1',
b'foo/playlist/00001': b'track2',
b'foo/playlistpersist': b'offset',
})
uut = BTreeDB(contents)
pl = uut.getPlaylistForTag(b'foo')
pl.setPlaybackOffset(42)
assert pl.getNextPath() == b'track2'
del pl
pl = uut.getPlaylistForTag(b'foo')
assert pl.getCurrentPath() == b'track2'
assert pl.getPlaybackOffset() == 0
def test_playlist_shuffle():
contents_dict = {b'foo/playlistpersist': b'track',
b'foo/playlistshuffle': b'yes',
}
for i in range(256):
contents_dict['foo/playlist/{:05}'.format(i).encode()] = 'track{}'.format(i).encode()
contents = FakeDB(contents_dict)
uut = BTreeDB(contents)
pl = uut.getPlaylistForTag(b'foo')
shuffled = False
last_idx = int(pl.getCurrentPath().removeprefix(b'track'))
while (t := pl.getNextPath()) is not None:
idx = int(t.removeprefix(b'track'))
if idx != last_idx + 1:
shuffled = True
break
# A false negative ratr of 1 in 256! should be good enough for this test
assert shuffled

View File

@@ -10,6 +10,7 @@ include(../../lib/micropython/lib/pico-sdk/pico_sdk_init.cmake)
project(standalone_mp3)
option(ENABLE_WRITE_TEST "Enable write test" OFF)
option(ENABLE_READ_TEST "Enable read test" ON)
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)
@@ -37,6 +38,10 @@ if(ENABLE_WRITE_TEST)
target_compile_definitions(standalone_mp3 PRIVATE WRITE_TEST)
endif()
if(ENABLE_READ_TEST)
target_compile_definitions(standalone_mp3 PRIVATE READ_TEST)
endif()
if(ENABLE_PLAY_TEST)
target_compile_definitions(standalone_mp3 PRIVATE PLAY_TEST)
endif()

View File

@@ -165,6 +165,20 @@ static void write_test(struct sd_context *sd_context)
} while (data_buffer[SD_SECTOR_SIZE - 1] != 0xAA);
}
static void read_test(struct sd_context *sd_context)
{
uint8_t data_buffer[512];
const uint64_t before = time_us_64();
for (int block = 0; block < 245760; ++block) {
if (!sd_readblock(sd_context, block, data_buffer)) {
printf("sd_readblock(%d) failed\n", block);
return;
}
}
const uint64_t elapsed = time_us_64() - before;
printf("%llu ms elapsed, %f kB/s\n", elapsed / 1000LLU, 128 * 1024.f / (elapsed / 1000000.f));
}
int main()
{
stdio_init_all();
@@ -172,10 +186,23 @@ int main()
struct sd_context sd_context;
if (!sd_init(&sd_context, 3, 4, 2, 5, 15000000)) {
#define DRIVE_STRENGTH GPIO_DRIVE_STRENGTH_8MA
#define SLEW_RATE GPIO_SLEW_RATE_SLOW
gpio_set_drive_strength(2, DRIVE_STRENGTH);
gpio_set_slew_rate(2, SLEW_RATE);
gpio_set_drive_strength(3, DRIVE_STRENGTH);
gpio_set_slew_rate(3, SLEW_RATE);
if (!sd_init(&sd_context, 3, 4, 2, 5, 25000000)) {
printf("sd_init failed\n");
return 1;
}
#ifdef READ_TEST
read_test(&sd_context);
#endif
#ifdef WRITE_TEST
write_test(&sd_context);
#endif