23 Commits

Author SHA1 Message Date
60e96b5a09 Defined the two endpoints we need for webapi version 1
Some checks failed
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) 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 11s
2025-11-11 20:38:45 +01:00
c84d52bd4c Enabled --update flag for differential flashing 2025-11-11 20:38:45 +01:00
7e11c73af3 Factored out webserver sample into dedicated module 2025-11-11 20:38:45 +01:00
2d2a4309a5 basic webserver works 2025-11-11 20:38:45 +01:00
081ec79c14 print network interface info 2025-11-11 20:38:45 +01:00
76ae02815e disabled wifi power management for now 2025-11-11 20:38:45 +01:00
801389605b Minimal example for api endpoint 2025-11-11 20:38:45 +01:00
3201d57c88 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 20:38:45 +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
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
17 changed files with 7506 additions and 7092 deletions

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))
(condition "A.isPlated() && A.Type != 'Via' && B.Type == 'track'"))
(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

@@ -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,
@@ -60,35 +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|136340000|8ffc6a0d-806e-4b99-922f-2baead2bd98b|00000000-0000-0000-0000-000000000000",
""
],
[
"items_not_allowed|148270000|136340000|804b9d74-91fa-4f75-bc54-8f55b801562f|00000000-0000-0000-0000-000000000000",
""
],
[
"items_not_allowed|148720706|135916802|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_edge_clearance|115549999|139536698|483993e4-aad9-46a1-bcd0-7f427e76aa64|cd433b4c-40ac-48cc-b03e-5acf3357d88b",
""
],
[
"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",
""
]
],
@@ -157,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
},

View File

@@ -4,7 +4,127 @@
(generator_version "9.0")
(uuid "7226ca0f-138a-46b4-89f3-dc32dfb1f9f7")
(paper "A4")
(title_block
(title "Tonberry Pico")
(date "2025-11-08")
(rev "1.2")
(comment 1 "Copyright (c) 2025 Matthias Blankertz <matthias@blankertz.org>")
(comment 2 "SPDX-License-Identifier: MIT")
)
(lib_symbols
(symbol "Connector_Generic:Conn_01x01"
(pin_names
(offset 1.016)
(hide yes)
)
(exclude_from_sim no)
(in_bom yes)
(on_board yes)
(property "Reference" "J"
(at 0 2.54 0)
(effects
(font
(size 1.27 1.27)
)
)
)
(property "Value" "Conn_01x01"
(at 0 -2.54 0)
(effects
(font
(size 1.27 1.27)
)
)
)
(property "Footprint" ""
(at 0 0 0)
(effects
(font
(size 1.27 1.27)
)
(hide yes)
)
)
(property "Datasheet" "~"
(at 0 0 0)
(effects
(font
(size 1.27 1.27)
)
(hide yes)
)
)
(property "Description" "Generic connector, single row, 01x01, script generated (kicad-library-utils/schlib/autogen/connector/)"
(at 0 0 0)
(effects
(font
(size 1.27 1.27)
)
(hide yes)
)
)
(property "ki_keywords" "connector"
(at 0 0 0)
(effects
(font
(size 1.27 1.27)
)
(hide yes)
)
)
(property "ki_fp_filters" "Connector*:*_1x??_*"
(at 0 0 0)
(effects
(font
(size 1.27 1.27)
)
(hide yes)
)
)
(symbol "Conn_01x01_1_1"
(rectangle
(start -1.27 1.27)
(end 1.27 -1.27)
(stroke
(width 0.254)
(type default)
)
(fill
(type background)
)
)
(rectangle
(start -1.27 0.127)
(end 0 -0.127)
(stroke
(width 0.1524)
(type default)
)
(fill
(type none)
)
)
(pin passive line
(at -5.08 0 0)
(length 3.81)
(name "Pin_1"
(effects
(font
(size 1.27 1.27)
)
)
)
(number "1"
(effects
(font
(size 1.27 1.27)
)
)
)
)
)
(embedded_fonts no)
)
(symbol "Connector_Generic:Conn_01x03"
(pin_names
(offset 1.016)
@@ -176,7 +296,7 @@
)
(embedded_fonts no)
)
(symbol "Connector_Generic:Conn_01x05"
(symbol "Connector_Generic:Conn_01x06"
(pin_names
(offset 1.016)
(hide yes)
@@ -192,8 +312,8 @@
)
)
)
(property "Value" "Conn_01x05"
(at 0 -7.62 0)
(property "Value" "Conn_01x06"
(at 0 -10.16 0)
(effects
(font
(size 1.27 1.27)
@@ -218,7 +338,7 @@
(hide yes)
)
)
(property "Description" "Generic connector, single row, 01x05, script generated (kicad-library-utils/schlib/autogen/connector/)"
(property "Description" "Generic connector, single row, 01x06, script generated (kicad-library-utils/schlib/autogen/connector/)"
(at 0 0 0)
(effects
(font
@@ -245,10 +365,10 @@
(hide yes)
)
)
(symbol "Conn_01x05_1_1"
(symbol "Conn_01x06_1_1"
(rectangle
(start -1.27 6.35)
(end 1.27 -6.35)
(end 1.27 -8.89)
(stroke
(width 0.254)
(type default)
@@ -312,6 +432,17 @@
(type none)
)
)
(rectangle
(start -1.27 -7.493)
(end 0 -7.747)
(stroke
(width 0.1524)
(type default)
)
(fill
(type none)
)
)
(pin passive line
(at -5.08 5.08 0)
(length 3.81)
@@ -402,6 +533,24 @@
)
)
)
(pin passive line
(at -5.08 -7.62 0)
(length 3.81)
(name "Pin_6"
(effects
(font
(size 1.27 1.27)
)
)
)
(number "6"
(effects
(font
(size 1.27 1.27)
)
)
)
)
)
(embedded_fonts no)
)
@@ -3809,7 +3958,7 @@
)
(rectangle
(start 242.57 60.96)
(end 283.21 154.94)
(end 283.21 163.83)
(stroke
(width 0)
(type default)
@@ -3859,16 +4008,6 @@
)
(uuid "466265fb-ac3b-4fe8-8af4-502fa85087f9")
)
(text "Pin changes for revision 1 (compared to breadboard):\n- RC522_RST to GPIO14 instead of GPIO9\n- RC522_IRQ to GPIO15 instead of GPIO14\n- I2S_SD to GPIO9 instead of NC\n- I2S_LRCLK to GPIO6 instead of GPIO7\n- I2S_DCLK to GPIO7 instead of GPIO6"
(exclude_from_sim no)
(at 256.032 174.244 0)
(effects
(font
(size 1.27 1.27)
)
)
(uuid "6843afc4-f352-41ed-b1f0-21b2625ea765")
)
(text "Sparkfun board has pullup\nfor mono mode by default"
(exclude_from_sim no)
(at 70.104 69.85 0)
@@ -3899,6 +4038,16 @@
)
(uuid "dcabf837-4155-4957-820b-e0a63b5947c8")
)
(text "Place GND through-hole pad in line with +5V side of R2\nto allow user to select different gain for MAX98357A"
(exclude_from_sim no)
(at 40.894 32.766 0)
(effects
(font
(size 1.27 1.27)
)
)
(uuid "dcca3a95-1162-4543-8f05-573beea44e38")
)
(text "Battery power"
(exclude_from_sim no)
(at 21.59 111.76 0)
@@ -3927,6 +4076,12 @@
(color 0 0 0 0)
(uuid "7b8323c0-6656-4c4e-b086-636ff2b9c6b7")
)
(junction
(at 276.86 148.59)
(diameter 0)
(color 0 0 0 0)
(uuid "9a1919a6-5883-47bb-82e5-3e3ecca52cb1")
)
(junction
(at 55.88 45.72)
(diameter 0)
@@ -4005,10 +4160,6 @@
(at 132.08 41.91)
(uuid "327eca18-19ed-4df2-b4a4-b6473b4637be")
)
(no_connect
(at 177.8 59.69)
(uuid "476000a3-9e7f-4cbf-9a8d-0e319caba0cd")
)
(no_connect
(at 177.8 69.85)
(uuid "50c74a0a-6abe-49d5-b374-0fde9fb9680b")
@@ -4289,6 +4440,16 @@
)
(uuid "31e9b0f8-7cda-4a52-89a1-0e733bdf35fb")
)
(wire
(pts
(xy 276.86 148.59) (xy 276.86 151.13)
)
(stroke
(width 0)
(type default)
)
(uuid "382ff189-be47-4469-ba80-056083381655")
)
(wire
(pts
(xy 254 138.43) (xy 265.43 138.43)
@@ -4389,6 +4550,16 @@
)
(uuid "48ccc495-741c-4b2b-9585-fde090268ab2")
)
(wire
(pts
(xy 275.59 148.59) (xy 276.86 148.59)
)
(stroke
(width 0)
(type default)
)
(uuid "4c57d923-2d70-4cfe-aa54-f3da164b16e8")
)
(wire
(pts
(xy 276.86 128.27) (xy 276.86 138.43)
@@ -4409,6 +4580,16 @@
)
(uuid "4f930ab5-0db4-4b67-a7e7-7fda8dc25972")
)
(wire
(pts
(xy 254 87.63) (xy 266.7 87.63)
)
(stroke
(width 0)
(type default)
)
(uuid "50622824-c775-4a38-abd2-79f7254111d1")
)
(wire
(pts
(xy 85.09 170.18) (xy 72.39 170.18)
@@ -4421,7 +4602,7 @@
)
(wire
(pts
(xy 276.86 138.43) (xy 276.86 139.7)
(xy 276.86 138.43) (xy 276.86 148.59)
)
(stroke
(width 0)
@@ -4599,6 +4780,16 @@
)
(uuid "79bb88bf-f554-4e83-bbcb-698a1df2a819")
)
(wire
(pts
(xy 36.83 46.99) (xy 36.83 49.53)
)
(stroke
(width 0)
(type default)
)
(uuid "7ac8879d-9fc7-40b2-b550-c83532147c07")
)
(wire
(pts
(xy 55.88 45.72) (xy 55.88 44.45)
@@ -4809,6 +5000,16 @@
)
(uuid "9c334529-8798-4c90-875a-da73ae41ede1")
)
(wire
(pts
(xy 177.8 59.69) (xy 182.88 59.69)
)
(stroke
(width 0)
(type default)
)
(uuid "9d9ed882-a31b-445f-833c-142b7ba4e54b")
)
(wire
(pts
(xy 49.53 162.56) (xy 49.53 190.5)
@@ -5169,6 +5370,16 @@
)
(uuid "ccf59af6-59fd-48d7-a33f-49c9aeb1ece9")
)
(wire
(pts
(xy 254 148.59) (xy 265.43 148.59)
)
(stroke
(width 0)
(type default)
)
(uuid "cdc6967b-fa8e-4d0e-8256-ef9790895e47")
)
(wire
(pts
(xy 52.07 55.88) (xy 52.07 69.85)
@@ -5713,6 +5924,28 @@
)
)
)
(global_label "BUTTON4"
(shape output)
(at 254 87.63 180)
(fields_autoplaced yes)
(effects
(font
(size 1.27 1.27)
)
(justify right)
)
(uuid "21ff2bc3-e911-4f23-a993-f08a650905a9")
(property "Intersheetrefs" "${INTERSHEET_REFS}"
(at 242.6086 87.63 0)
(effects
(font
(size 1.27 1.27)
)
(justify right)
(hide yes)
)
)
)
(global_label "BUTTON1"
(shape output)
(at 254 80.01 180)
@@ -5735,6 +5968,28 @@
)
)
)
(global_label "BUTTON4"
(shape input)
(at 182.88 59.69 0)
(fields_autoplaced yes)
(effects
(font
(size 1.27 1.27)
)
(justify left)
)
(uuid "2736ef81-384c-4324-a05c-697c55fa029e")
(property "Intersheetrefs" "${INTERSHEET_REFS}"
(at 194.2714 59.69 0)
(effects
(font
(size 1.27 1.27)
)
(justify left)
(hide yes)
)
)
)
(global_label "BUTTON2"
(shape output)
(at 254 128.27 180)
@@ -5779,6 +6034,28 @@
)
)
)
(global_label "BUTTON4"
(shape output)
(at 254 148.59 180)
(fields_autoplaced yes)
(effects
(font
(size 1.27 1.27)
)
(justify right)
)
(uuid "47d67395-d5c6-4e13-8fd7-2f3ddfb71aa2")
(property "Intersheetrefs" "${INTERSHEET_REFS}"
(at 242.6086 148.59 0)
(effects
(font
(size 1.27 1.27)
)
(justify right)
(hide yes)
)
)
)
(global_label "POWER_BUTTON"
(shape input)
(at 83.82 157.48 0)
@@ -6115,7 +6392,7 @@
(justify left)
)
)
(property "Footprint" "Connector_PinSocket_2.54mm:PinSocket_1x03_P2.54mm_Vertical"
(property "Footprint" "Connector_JST:JST_XH_B3B-XH-A_1x03_P2.50mm_Vertical"
(at 207.01 49.53 0)
(effects
(font
@@ -6160,6 +6437,72 @@
)
)
)
(symbol
(lib_id "power:GND")
(at 36.83 49.53 0)
(unit 1)
(exclude_from_sim no)
(in_bom yes)
(on_board yes)
(dnp no)
(uuid "0e3366a9-cc30-4574-9a85-1f934576d050")
(property "Reference" "#PWR018"
(at 36.83 55.88 0)
(effects
(font
(size 1.27 1.27)
)
(hide yes)
)
)
(property "Value" "GND"
(at 38.862 53.34 0)
(effects
(font
(size 1.27 1.27)
)
(justify right)
)
)
(property "Footprint" ""
(at 36.83 49.53 0)
(effects
(font
(size 1.27 1.27)
)
(hide yes)
)
)
(property "Datasheet" ""
(at 36.83 49.53 0)
(effects
(font
(size 1.27 1.27)
)
(hide yes)
)
)
(property "Description" "Power symbol creates a global label with name \"GND\" , ground"
(at 36.83 49.53 0)
(effects
(font
(size 1.27 1.27)
)
(hide yes)
)
)
(pin "1"
(uuid "4411b33e-7a34-433c-a726-528c62666fb1")
)
(instances
(project "tonberry-pico"
(path "/7226ca0f-138a-46b4-89f3-dc32dfb1f9f7"
(reference "#PWR018")
(unit 1)
)
)
)
)
(symbol
(lib_id "Jumper:Jumper_2_Bridged")
(at 93.98 69.85 0)
@@ -6814,7 +7157,7 @@
(justify left)
)
)
(property "Footprint" "Package_TO_SOT_THT:TO-92_Inline"
(property "Footprint" "Package_TO_SOT_THT:TO-92_Inline_Wide"
(at 58.42 172.085 0)
(effects
(font
@@ -6935,8 +7278,8 @@
(unit 1)
(exclude_from_sim no)
(in_bom yes)
(on_board yes)
(dnp no)
(on_board no)
(dnp yes)
(fields_autoplaced yes)
(uuid "6107dfa8-3a58-4296-84b3-7c3671cb0432")
(property "Reference" "SW3"
@@ -7003,8 +7346,8 @@
(unit 1)
(exclude_from_sim no)
(in_bom yes)
(on_board yes)
(dnp no)
(on_board no)
(dnp yes)
(uuid "623caba9-5088-4f71-b179-4289ab57e20f")
(property "Reference" "SW4"
(at 270.51 96.52 0)
@@ -7090,7 +7433,7 @@
)
)
)
(property "Footprint" "Connector_PinHeader_2.54mm:PinHeader_1x02_P2.54mm_Vertical"
(property "Footprint" "PCM_JLCPCB:SW_TS-1088-AR02016"
(at 266.7 20.32 0)
(effects
(font
@@ -7586,8 +7929,8 @@
(unit 1)
(exclude_from_sim no)
(in_bom yes)
(on_board yes)
(dnp no)
(on_board no)
(dnp yes)
(uuid "79f78486-f390-4ddb-b9f4-4a3a2c11d855")
(property "Reference" "SW1"
(at 270.51 110.49 0)
@@ -7653,8 +7996,8 @@
(unit 1)
(exclude_from_sim no)
(in_bom yes)
(on_board yes)
(dnp no)
(on_board no)
(dnp yes)
(fields_autoplaced yes)
(uuid "7cf33776-e3ad-49e6-8da4-da9b4c4012c2")
(property "Reference" "SW2"
@@ -7787,7 +8130,7 @@
)
(symbol
(lib_id "power:GND")
(at 276.86 139.7 0)
(at 276.86 151.13 0)
(unit 1)
(exclude_from_sim no)
(in_bom yes)
@@ -7796,7 +8139,7 @@
(fields_autoplaced yes)
(uuid "7f17a4cc-3063-4ad7-9820-0d414285324e")
(property "Reference" "#PWR013"
(at 276.86 146.05 0)
(at 276.86 157.48 0)
(effects
(font
(size 1.27 1.27)
@@ -7805,7 +8148,7 @@
)
)
(property "Value" "GND"
(at 276.86 144.78 0)
(at 276.86 156.21 0)
(effects
(font
(size 1.27 1.27)
@@ -7813,7 +8156,7 @@
)
)
(property "Footprint" ""
(at 276.86 139.7 0)
(at 276.86 151.13 0)
(effects
(font
(size 1.27 1.27)
@@ -7822,7 +8165,7 @@
)
)
(property "Datasheet" ""
(at 276.86 139.7 0)
(at 276.86 151.13 0)
(effects
(font
(size 1.27 1.27)
@@ -7831,7 +8174,7 @@
)
)
(property "Description" "Power symbol creates a global label with name \"GND\" , ground"
(at 276.86 139.7 0)
(at 276.86 151.13 0)
(effects
(font
(size 1.27 1.27)
@@ -8009,7 +8352,7 @@
)
)
)
(property "Footprint" "Modules:AZ_Delivery_RC522"
(property "Footprint" "Connector_PinSocket_2.54mm:PinSocket_1x08_P2.54mm_Vertical"
(at 74.93 90.17 0)
(effects
(font
@@ -8164,7 +8507,7 @@
(justify left)
)
)
(property "Footprint" "Package_TO_SOT_THT:TO-92_Inline"
(property "Footprint" "Package_TO_SOT_THT:TO-92_Inline_Wide"
(at 46.99 159.385 0)
(effects
(font
@@ -8720,6 +9063,73 @@
)
)
)
(symbol
(lib_id "Connector_Generic:Conn_01x01")
(at 36.83 41.91 90)
(unit 1)
(exclude_from_sim no)
(in_bom no)
(on_board yes)
(dnp no)
(fields_autoplaced yes)
(uuid "d3bcb0bc-df35-41fa-87ea-2781bf254e68")
(property "Reference" "J8"
(at 39.37 40.6399 90)
(effects
(font
(size 1.27 1.27)
)
(justify right)
)
)
(property "Value" "Conn_01x01"
(at 39.37 43.1799 90)
(effects
(font
(size 1.27 1.27)
)
(justify right)
)
)
(property "Footprint" "Connector_Wire:SolderWire-0.25sqmm_1x01_D0.65mm_OD1.7mm"
(at 36.83 41.91 0)
(effects
(font
(size 1.27 1.27)
)
(hide yes)
)
)
(property "Datasheet" "~"
(at 36.83 41.91 0)
(effects
(font
(size 1.27 1.27)
)
(hide yes)
)
)
(property "Description" "Generic connector, single row, 01x01, script generated (kicad-library-utils/schlib/autogen/connector/)"
(at 36.83 41.91 0)
(effects
(font
(size 1.27 1.27)
)
(hide yes)
)
)
(pin "1"
(uuid "516d40fb-50f8-4c22-aeaf-d075175736e6")
)
(instances
(project ""
(path "/7226ca0f-138a-46b4-89f3-dc32dfb1f9f7"
(reference "J8")
(unit 1)
)
)
)
)
(symbol
(lib_id "Connector_Generic:Conn_01x07")
(at 73.66 49.53 0)
@@ -9427,6 +9837,74 @@
)
)
)
(symbol
(lib_id "Switch:SW_Push")
(at 270.51 148.59 0)
(unit 1)
(exclude_from_sim no)
(in_bom yes)
(on_board no)
(dnp yes)
(fields_autoplaced yes)
(uuid "fb95655b-d8ba-47f5-91f3-286848a1e70f")
(property "Reference" "SW6"
(at 270.51 140.97 0)
(effects
(font
(size 1.27 1.27)
)
)
)
(property "Value" "SW_Push"
(at 270.51 143.51 0)
(effects
(font
(size 1.27 1.27)
)
)
)
(property "Footprint" "Button_Switch_THT:Push_E-Switch_KS01Q01"
(at 270.51 143.51 0)
(effects
(font
(size 1.27 1.27)
)
(hide yes)
)
)
(property "Datasheet" "~"
(at 270.51 143.51 0)
(effects
(font
(size 1.27 1.27)
)
(hide yes)
)
)
(property "Description" "Push button switch, generic, two pins"
(at 270.51 148.59 0)
(effects
(font
(size 1.27 1.27)
)
(hide yes)
)
)
(pin "1"
(uuid "6694cff6-47a5-4135-b781-b53e5eb0d46c")
)
(pin "2"
(uuid "aa2c7820-7193-44e1-aac3-be02505e4c11")
)
(instances
(project "tonberry-pico"
(path "/7226ca0f-138a-46b4-89f3-dc32dfb1f9f7"
(reference "SW6")
(unit 1)
)
)
)
)
(symbol
(lib_id "Connector_Generic:Conn_01x03")
(at 115.57 31.75 90)
@@ -9572,7 +10050,7 @@
)
)
(symbol
(lib_id "Connector_Generic:Conn_01x05")
(lib_id "Connector_Generic:Conn_01x06")
(at 271.78 80.01 0)
(unit 1)
(exclude_from_sim no)
@@ -9582,7 +10060,7 @@
(fields_autoplaced yes)
(uuid "fccc1f29-a663-4617-8514-4cf1edd994cd")
(property "Reference" "J7"
(at 274.32 78.7399 0)
(at 274.32 80.0099 0)
(effects
(font
(size 1.27 1.27)
@@ -9591,7 +10069,7 @@
)
)
(property "Value" "Buttons"
(at 274.32 81.2799 0)
(at 274.32 82.5499 0)
(effects
(font
(size 1.27 1.27)
@@ -9599,7 +10077,7 @@
(justify left)
)
)
(property "Footprint" "Connector_PinSocket_2.54mm:PinSocket_1x05_P2.54mm_Vertical"
(property "Footprint" "Connector_JST:JST_XH_B6B-XH-A_1x06_P2.50mm_Vertical"
(at 271.78 80.01 0)
(effects
(font
@@ -9617,7 +10095,7 @@
(hide yes)
)
)
(property "Description" "Generic connector, single row, 01x05, script generated (kicad-library-utils/schlib/autogen/connector/)"
(property "Description" "Generic connector, single row, 01x06, script generated (kicad-library-utils/schlib/autogen/connector/)"
(at 271.78 80.01 0)
(effects
(font
@@ -9641,6 +10119,9 @@
(pin "1"
(uuid "46257db4-53cd-407e-8cb7-103df4c3a13d")
)
(pin "6"
(uuid "cabc7efd-b7fb-49c1-8a3e-beca01096a1a")
)
(instances
(project "tonberry-pico"
(path "/7226ca0f-138a-46b4-89f3-dc32dfb1f9f7"

View File

@@ -41,7 +41,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

View File

@@ -13,13 +13,40 @@ VOLUME_CURVE = [1, 2, 4, 8, 16, 32, 63, 126, 251]
class PlayerApp:
class TagStateMachine:
def __init__(self, parent, timer_manager):
self.parent = parent
self.timer_manager = timer_manager
self.current_tag = None
self.current_tag_time = time.ticks_ms()
def onTagChange(self, new_tag):
if new_tag is not None:
self.timer_manager.cancel(self.onTagRemoveDelay)
if new_tag == self.current_tag:
return
# Change playlist on new tag
if new_tag is not None:
self.current_tag_time = time.ticks_ms()
self.current_tag = new_tag
self.parent.onNewTag(new_tag)
else:
self.timer_manager.schedule(time.ticks_ms() + 5000, self.onTagRemoveDelay)
def onTagRemoveDelay(self):
if self.current_tag is not None:
self.current_tag = None
self.parent.onTagRemoved()
def __init__(self, deps: Dependencies):
self.current_tag = None
self.current_tag_time = time.ticks_ms()
self.timer_manager = TimerManager()
self.tag_state_machine = self.TagStateMachine(self, self.timer_manager)
self.player = deps.mp3player(self)
self.nfc = deps.nfcreader(self)
self.nfc = deps.nfcreader(self.tag_state_machine)
self.playlist_db = deps.playlistdb(self)
self.tag_mode = self.playlist_db.getSetting('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
@@ -30,25 +57,26 @@ class PlayerApp:
self.mp3file.close()
self.mp3file = None
def onTagChange(self, new_tag):
if new_tag is not None:
self.timer_manager.cancel(self.onTagRemoveDelay)
if new_tag == self.current_tag:
return
# Change playlist on new tag
if new_tag is not None:
self.current_tag_time = time.ticks_ms()
self.current_tag = new_tag
uid_str = b''.join('{:02x}'.format(x).encode() for x in new_tag)
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)
else:
self.timer_manager.schedule(time.ticks_ms() + 5000, self.onTagRemoveDelay)
self.playing_tag = new_tag
elif self.tag_mode == 'tagstartstop':
print('Tag presented again, stopping playback')
self._unset_playlist()
self.playing_tag = None
def onTagRemoveDelay(self):
if self.current_tag is not 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.current_tag = None
self.player.stop()
self._unset_playlist()
def onButtonPressed(self, what):
assert self.buttons is not None
@@ -68,8 +96,17 @@ class PlayerApp:
self._play_next()
def _set_playlist(self, tag: bytes):
self._unset_playlist()
self.playlist = self.playlist_db.getPlaylistForTag(tag)
self._play(self.playlist.getCurrentPath() if self.playlist is not None else None)
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()
if pos is not None:
self.playlist.setPlaybackOffset(pos)
self.playlist = None
def _play_next(self):
if self.playlist is None:
@@ -78,8 +115,9 @@ class PlayerApp:
self._play(filename)
if filename is None:
self.playlist = None
self.playing_tag = None
def _play(self, filename: bytes | None):
def _play(self, filename: bytes | None, offset=0):
if self.mp3file is not None:
self.player.stop()
self.mp3file.close()
@@ -87,4 +125,4 @@ class PlayerApp:
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)

View File

@@ -10,6 +10,8 @@ import network
import os
import time
from math import pi, sin, pow
import ubinascii
from microdot import Microdot
# Own modules
import app
@@ -19,6 +21,7 @@ from mp3player import MP3Player
from nfc import Nfc
from rp2_neopixel import NeoPixel
from utils import BTreeFileManager, Buttons, SDContext, TimerManager
from webserver import start_webserver
try:
import hwconfig
@@ -63,6 +66,16 @@ def setup_wifi():
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()}")
DB_PATH = '/sd/tonberry.db'
@@ -75,7 +88,8 @@ def run():
# Wifi with default config
setup_wifi()
start_webserver()
# 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):

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

@@ -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

@@ -2,6 +2,8 @@
# 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
@@ -24,11 +26,56 @@ else:
class BTreeDB(IPlaylistDB):
SHUFFLE_NO = b'no'
SHUFFLE_YES = b'yes'
PERSIST_NO = b'no'
PERSIST_TRACK = b'track'
PERSIST_OFFSET = b'offset'
DEFAULT_SETTINGS = {
b'tagmode': b'tagremains'
}
class Playlist(IPlaylist):
def __init__(self, parent, tag, pos):
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):
"""
@@ -40,21 +87,42 @@ class BTreeDB(IPlaylistDB):
"""
Get path of file that should be played.
"""
return self.parent._getPlaylistEntry(self.tag, self.pos)
return self.parent._getPlaylistEntry(self.tag, self._getPlaylistPos())
def getNextPath(self):
"""
Select next track and return path.
"""
try:
self.pos = self.parent._getNextTrack(self.tag, self.pos)
except StopIteration:
self.pos = self.parent._getFirstTrack(self.tag)
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
finally:
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
@@ -63,6 +131,22 @@ class BTreeDB(IPlaylistDB):
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()])
@@ -84,23 +168,58 @@ class BTreeDB(IPlaylistDB):
if self.flush_func is not None:
self.flush_func()
def _getPlaylistValueIterator(self, tag):
def _getPlaylistValueIterator(self, tag: bytes):
start, end = self._keyPlaylistStartEnd(tag)
return self.db.values(start, end)
def _getPlaylistEntry(self, _, pos):
return self.db[pos]
def _getPlaylistEntry(self, tag: bytes, pos: int) -> bytes:
return self.db[self._keyPlaylistEntry(tag, pos)]
def _setPlaylistPos(self, tag, pos, flush=True):
assert pos.startswith(self._keyPlaylistStart(tag))
self.db[self._keyPlaylistPos(tag)] = pos[len(self._keyPlaylistStart(tag)):]
def _setPlaylistPos(self, tag: bytes, pos: int, flush=True):
self.db[self._keyPlaylistPos(tag)] = str(pos).encode()
if flush:
self._flush()
def _savePlaylist(self, tag, entries, flush=True):
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()
@@ -111,60 +230,74 @@ class BTreeDB(IPlaylistDB):
del self.db[k]
except KeyError:
pass
try:
del self.db[self._keyPlaylistPos(tag)]
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 _getFirstTrack(self, tag: bytes):
start_key, end_key = self._keyPlaylistStartEnd(tag)
return next(self.db.keys(start_key, end_key))
def _getNextTrack(self, tag, pos):
_, end_key = self._keyPlaylistStartEnd(tag)
it = self.db.keys(pos, end_key)
next(it)
return next(it)
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.
"""
pos = self.db.get(self._keyPlaylistPos(tag))
if pos is None:
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 = self._getFirstTrack(tag)
except StopIteration:
# playist does not exist
return None
else:
pos = self._keyPlaylistStart(tag) + pos
return self.Playlist(self, tag, pos)
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]):
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.
"""
self._savePlaylist(tag, entries)
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 getSetting(self, key: bytes | str) -> str:
if type(key) is str:
key = key.encode()
return self.db.get(b'settings/' + key, self.DEFAULT_SETTINGS[key]).decode()
def 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
index_width = None
for k in self.db.keys():
fields = k.split(b'/')
if len(fields) <= 1:
print(f'Malformed key {k!r}')
result = False
fail(f'Malformed key {k!r}')
continue
if fields[0] == b'settings':
val = self.db[k].decode()
print(f'Setting {fields[1].decode()} = {val}')
continue
if last_tag != fields[0]:
last_tag = fields[0]
last_pos = None
@@ -172,24 +305,19 @@ class BTreeDB(IPlaylistDB):
print(f'Tag {fields[0]}')
if fields[1] == b'playlist':
if len(fields) != 3:
print(f'Malformed playlist entry: {k!r}')
result = False
fail(f'Malformed playlist entry: {k!r}')
continue
try:
idx = int(fields[2])
except ValueError:
print(f'Malformed playlist entry: {k!r}')
result = False
fail(f'Malformed playlist entry: {k!r}')
continue
if index_width is not None and len(fields[2]) != index_width:
print(f'Inconsistent index width for {last_tag} at {idx}')
result = False
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):
print(f'Bad playlist entry sequence for {last_tag} at {idx}')
result = False
fail(f'Bad playlist entry sequence for {last_tag} at {idx}')
last_pos = idx
index_width = len(fields[2])
if dump:
print(f'\tTrack {idx}: {self.db[k]!r}')
elif fields[1] == b'playlistpos':
@@ -197,17 +325,38 @@ class BTreeDB(IPlaylistDB):
try:
idx = int(val)
except ValueError:
print(f'Malformed playlist position: {val!r}')
result = False
fail(f'Malformed playlist position: {val!r}')
continue
if 0 > idx or idx > last_pos:
print(f'Playlist position out of range for {last_tag}: {idx}')
result = False
if dump:
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:
print(f'Unknown key {k!r}')
result = False
fail(f'Unknown key {k!r}')
return result

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

@@ -0,0 +1,38 @@
'''
SPDX-License-Identifier: MIT
Copyright (c) 2024-2025 Stefan Kratochwil <Kratochwil-LA@gmx.de>
'''
import asyncio
from microdot import Microdot
webapp = Microdot()
server = None
def start_webserver():
server = asyncio.create_task(webapp.start_server(port=80))
@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}

View File

@@ -19,3 +19,7 @@ class BTree:
def open(dbfile) -> BTree:
pass
DESC = 1
INCL = 2

View File

@@ -35,17 +35,26 @@ class FakeMp3Player:
def set_volume(self, vol: int):
self.volume = vol
def play(self, track: FakeFile):
def play(self, track: FakeFile, offset: int):
self.track = track
def stop(self):
self.track = None
class FakeTimerManager:
def __init__(self): pass
def cancel(self, timer): pass
def schedule(self, when, what):
what()
class FakeNfcReader:
def __init__(self): pass
tag_callback = None
def __init__(self, tag_callback=None):
FakeNfcReader.tag_callback = tag_callback
class FakeButtons:
@@ -67,12 +76,20 @@ class FakePlaylistDb:
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
def fake_open(filename, mode):
return FakeFile(filename, mode)
@@ -97,13 +114,13 @@ def test_load_playlist_on_tag(micropythonify, faketimermanager, monkeypatch):
fake_db = FakePlaylistDb()
fake_mp3 = FakeMp3Player()
deps = app.Dependencies(mp3player=lambda _: fake_mp3,
nfcreader=lambda _: FakeNfcReader(),
nfcreader=lambda x: FakeNfcReader(x),
buttons=lambda _: FakeButtons(),
playlistdb=lambda _: fake_db)
dut = app.PlayerApp(deps)
app.PlayerApp(deps)
with monkeypatch.context() as m:
m.setattr(builtins, 'open', fake_open)
dut.onTagChange([23, 42, 1, 2, 3])
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
@@ -114,13 +131,13 @@ def test_playlist_seq(micropythonify, faketimermanager, monkeypatch):
fake_db = FakePlaylistDb([b'track1.mp3', b'track2.mp3', b'track3.mp3'])
fake_mp3 = FakeMp3Player()
deps = app.Dependencies(mp3player=lambda _: fake_mp3,
nfcreader=lambda _: FakeNfcReader(),
nfcreader=lambda x: FakeNfcReader(x),
buttons=lambda _: FakeButtons(),
playlistdb=lambda _: fake_db)
dut = app.PlayerApp(deps)
with monkeypatch.context() as m:
m.setattr(builtins, 'open', fake_open)
dut.onTagChange([23, 42, 1, 2, 3])
FakeNfcReader.tag_callback.onTagChange([23, 42, 1, 2, 3])
assert fake_mp3.track is not None
assert fake_mp3.track.filename == b'track1.mp3'
@@ -144,14 +161,88 @@ def test_playlist_unknown_tag(micropythonify, faketimermanager, monkeypatch):
def getPlaylistForTag(self, tag):
return None
def getSetting(self, key: bytes | str):
return None
fake_db = FakeNoPlaylistDb()
fake_mp3 = FakeMp3Player()
deps = app.Dependencies(mp3player=lambda _: fake_mp3,
nfcreader=lambda _: FakeNfcReader(),
nfcreader=lambda x: FakeNfcReader(x),
buttons=lambda _: FakeButtons(),
playlistdb=lambda _: fake_db)
dut = app.PlayerApp(deps)
app.PlayerApp(deps)
with monkeypatch.context() as m:
m.setattr(builtins, 'open', fake_open)
dut.onTagChange([23, 42, 1, 2, 3])
FakeNfcReader.tag_callback.onTagChange([23, 42, 1, 2, 3])
assert fake_mp3.track is None
def test_tagmode_startstop(micropythonify, faketimermanager, monkeypatch):
class MyFakePlaylistDb(FakePlaylistDb):
def __init__(self, tracklist=[b'test/path.mp3']):
super().__init__(tracklist)
def getSetting(self, key: bytes | str):
if key == 'tagmode':
return 'tagstartstop'
return None
fake_db = MyFakePlaylistDb()
fake_mp3 = FakeMp3Player()
deps = app.Dependencies(mp3player=lambda _: fake_mp3,
nfcreader=lambda x: FakeNfcReader(x),
buttons=lambda _: FakeButtons(),
playlistdb=lambda _: 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'
# Removing tag should not stop playback
FakeNfcReader.tag_callback.onTagChange(None)
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)
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):
class MyFakePlaylistDb(FakePlaylistDb):
def __init__(self, tracklist=[b'test/path.mp3']):
super().__init__(tracklist)
def getSetting(self, key: bytes | str):
if key == 'tagmode':
return 'tagremains'
return None
fake_db = MyFakePlaylistDb()
fake_mp3 = FakeMp3Player()
deps = app.Dependencies(mp3player=lambda _: fake_mp3,
nfcreader=lambda x: FakeNfcReader(x),
buttons=lambda _: FakeButtons(),
playlistdb=lambda _: 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)
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'

View File

@@ -1,9 +1,21 @@
# 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
@@ -17,18 +29,24 @@ class FakeDB:
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:
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:
if end_key is not None and end_key <= key:
break
yield key
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)
@@ -42,11 +60,14 @@ class FakeDB:
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/0': b'track1',
b'foo/playlist/1': b'track2',
b'foo/playlist/00000': b'track1',
b'foo/playlist/00001': b'track2',
b'foo/playlisttt': b'no'
}
uut = BTreeDB(FakeDB(contents))
@@ -57,8 +78,8 @@ def test_playlist_load():
def test_playlist_nextpath():
contents = FakeDB({b'foo/part': b'no',
b'foo/playlist/0': b'track1',
b'foo/playlist/1': b'track2',
b'foo/playlist/00000': b'track1',
b'foo/playlist/00001': b'track2',
b'foo/playlisttt': b'no'
})
uut = BTreeDB(contents)
@@ -68,8 +89,8 @@ def test_playlist_nextpath():
def test_playlist_nextpath_last():
contents = FakeDB({b'foo/playlist/0': b'track1',
b'foo/playlist/1': b'track2',
contents = FakeDB({b'foo/playlist/00000': b'track1',
b'foo/playlist/00001': b'track2',
b'foo/playlistpos': b'1'
})
uut = BTreeDB(contents)
@@ -79,8 +100,8 @@ def test_playlist_nextpath_last():
def test_playlist_create():
contents = FakeDB({b'foo/playlist/0': b'track1',
b'foo/playlist/1': b'track2',
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']
@@ -92,35 +113,85 @@ def test_playlist_create():
def test_playlist_load_notexist():
contents = FakeDB({b'foo/playlist/0': b'track1',
b'foo/playlist/1': b'track2',
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_remains_lexicographically_ordered_by_key():
contents = FakeDB({b'foo/playlist/3': b'track3',
b'foo/playlist/2': b'track2',
b'foo/playlist/1': b'track1',
b'foo/playlistpos': b'1'
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'
assert pl.getNextPath() == b'track3'
del pl
pl = uut.getPlaylistForTag(b'foo')
assert pl.getCurrentPath() == b'track1'
def test_playlist_remains_lexicographically_ordered_with_non_numeric_keys():
contents = FakeDB({b'foo/playlist/k': b'trackk',
b'foo/playlist/l': b'trackl',
b'foo/playlist/i': b'tracki',
b'foo/playlistpos': b'k'
@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')
assert pl.getCurrentPath() == b'trackk'
assert pl.getNextPath() == b'trackl'
assert pl.getNextPath() is None
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