3 Commits

Author SHA1 Message Date
e862019b20 app: Fix bug when a tag that has no playlist is encountered
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 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 10s
2025-08-28 17:38:00 +02:00
d75f7d11ce 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-08-28 17:38:00 +02:00
307b448e7f 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-08-28 17:38:00 +02:00
70 changed files with 1948 additions and 27590 deletions

View File

@@ -1,6 +1,6 @@
---
name: Build RPi Pico firmware image
"on":
on:
push:
jobs:
@@ -11,17 +11,15 @@ jobs:
uses: actions/checkout@v4
- name: Initialize submodules
run: cd software && ./update-submodules.sh
- name: Prepare venv
run: python -m venv build-venv && source build-venv/bin/activate && pip install freezefs
- name: Build
run: source build-venv/bin/activate && cd software && ./build.sh
run: cd software && ./build.sh
- name: Upload firmware
uses: actions/upload-artifact@v3
with:
name: firmware-RPi-Pico-W
path: software/build/firmware-*.uf2
path: software/lib/micropython/ports/rp2/build-TONBERRY_RPI_PICO_W/firmware.uf2
- name: Upload firmware w/ filesystem
uses: actions/upload-artifact@v3
with:
name: firmware-RPi-Pico-W-with-fs
path: software/build/firmware-filesystem-*.uf2
path: software/lib/micropython/ports/rp2/build-TONBERRY_RPI_PICO_W/firmware-filesystem.uf2

View File

@@ -9,46 +9,3 @@ 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

@@ -1,443 +0,0 @@
(footprint "AZ_Delivery_RC522"
(version 20241229)
(generator "pcbnew")
(generator_version "9.0")
(layer "F.Cu")
(descr "Through hole straight socket strip, 1x08, 2.54mm pitch, single row (from Kicad 4.0.7), script generated")
(tags "Through hole socket strip THT 1x08 2.54mm single row")
(property "Reference" "REF**"
(at 0 -2.77 0)
(layer "F.SilkS")
(uuid "a34ef152-6377-4c69-9f9a-fb0a9cbaaa0f")
(effects
(font
(size 1 1)
(thickness 0.15)
)
)
)
(property "Value" "AZ_Delivery_RC522"
(at -1.9 11.03 0)
(layer "F.Fab")
(uuid "eb0c6019-b136-4812-894a-851e8bd21756")
(effects
(font
(size 1 1)
(thickness 0.15)
)
)
)
(property "Datasheet" ""
(at 0 0 0)
(layer "F.Fab")
(hide yes)
(uuid "2d28acfc-38d9-483d-a1f6-5b4a7c5c5208")
(effects
(font
(size 1.27 1.27)
(thickness 0.15)
)
)
)
(property "Description" ""
(at 0 0 0)
(layer "F.Fab")
(hide yes)
(uuid "d9f04e4e-c6ea-48a4-82e1-9fd680507783")
(effects
(font
(size 1.27 1.27)
(thickness 0.15)
)
)
)
(attr through_hole)
(fp_line
(start -1.33 1.27)
(end -1.33 19.11)
(stroke
(width 0.12)
(type solid)
)
(layer "F.SilkS")
(uuid "5c816ce7-1e16-4fef-8a94-56ce4f336409")
)
(fp_line
(start -1.33 1.27)
(end 1.33 1.27)
(stroke
(width 0.12)
(type solid)
)
(layer "F.SilkS")
(uuid "31cfc545-00ec-41d5-a909-28f51668cbb9")
)
(fp_line
(start -1.33 19.11)
(end 1.33 19.11)
(stroke
(width 0.12)
(type solid)
)
(layer "F.SilkS")
(uuid "6fdb9624-459f-42e6-8474-8476e3e73a19")
)
(fp_line
(start 0 -1.33)
(end 1.33 -1.33)
(stroke
(width 0.12)
(type solid)
)
(layer "F.SilkS")
(uuid "673bcfad-3a7d-4a5b-ae2e-68bfc3cb0212")
)
(fp_line
(start 1.33 -1.33)
(end 1.33 0)
(stroke
(width 0.12)
(type solid)
)
(layer "F.SilkS")
(uuid "596a7cf5-b26a-4237-9ea9-a0b83ce26be7")
)
(fp_line
(start 1.33 1.27)
(end 1.33 19.11)
(stroke
(width 0.12)
(type solid)
)
(layer "F.SilkS")
(uuid "dd0a885d-9fe0-46b4-a266-a488d8626717")
)
(fp_rect
(start -1.9 -9.5)
(end 58.1 30.5)
(stroke
(width 0.1524)
(type solid)
)
(fill no)
(layer "F.SilkS")
(uuid "8f986849-0666-4030-885e-2108faf1da15")
)
(fp_line
(start -1.8 -1.8)
(end 1.75 -1.8)
(stroke
(width 0.05)
(type solid)
)
(layer "F.CrtYd")
(uuid "446aed1d-b80f-4b30-b956-5f8297a19941")
)
(fp_line
(start -1.8 19.55)
(end -1.8 -1.8)
(stroke
(width 0.05)
(type solid)
)
(layer "F.CrtYd")
(uuid "22ebe2a7-8b67-47c2-bdf8-cdb4cd9951cf")
)
(fp_line
(start 1.75 -1.8)
(end 1.75 19.55)
(stroke
(width 0.05)
(type solid)
)
(layer "F.CrtYd")
(uuid "fe065b5b-3b98-4eb6-bb1d-7dc77992767f")
)
(fp_line
(start 1.75 19.55)
(end -1.8 19.55)
(stroke
(width 0.05)
(type solid)
)
(layer "F.CrtYd")
(uuid "3e9ed458-acaf-44bb-9c22-6291112f2c8b")
)
(fp_circle
(center 13.75 -7.02)
(end 16.29 -7.02)
(stroke
(width 0.05)
(type solid)
)
(fill no)
(layer "F.CrtYd")
(uuid "b0fc3695-90f5-41e7-8964-ebecbadeaa08")
)
(fp_circle
(center 13.75 27.98)
(end 16.29 27.98)
(stroke
(width 0.05)
(type solid)
)
(fill no)
(layer "F.CrtYd")
(uuid "37ebb819-cba1-4d26-8219-ba4fc5e3d318")
)
(fp_circle
(center 51.25 -2.22)
(end 53.79 -2.22)
(stroke
(width 0.05)
(type solid)
)
(fill no)
(layer "F.CrtYd")
(uuid "c91279d4-19e9-4d6b-82e4-3abaf6c9450a")
)
(fp_circle
(center 51.25 23.63)
(end 53.79 23.63)
(stroke
(width 0.05)
(type solid)
)
(fill no)
(layer "F.CrtYd")
(uuid "49613ec7-c049-4577-95b9-7914f2d0eeb3")
)
(fp_line
(start -1.27 -1.27)
(end 0.635 -1.27)
(stroke
(width 0.1)
(type solid)
)
(layer "F.Fab")
(uuid "2a668cbf-d67d-4e90-af33-7fe4d2a9e9d2")
)
(fp_line
(start -1.27 19.05)
(end -1.27 -1.27)
(stroke
(width 0.1)
(type solid)
)
(layer "F.Fab")
(uuid "063425b4-bc99-4269-a589-b598140b14bf")
)
(fp_line
(start 0.635 -1.27)
(end 1.27 -0.635)
(stroke
(width 0.1)
(type solid)
)
(layer "F.Fab")
(uuid "a5b3e0e3-31ba-4ee4-a261-36ed072fb6d3")
)
(fp_line
(start 1.27 -0.635)
(end 1.27 19.05)
(stroke
(width 0.1)
(type solid)
)
(layer "F.Fab")
(uuid "626e075f-5f87-420e-9637-1a7362632a52")
)
(fp_line
(start 1.27 19.05)
(end -1.27 19.05)
(stroke
(width 0.1)
(type solid)
)
(layer "F.Fab")
(uuid "92009a7e-4398-40ed-8894-3370eb975c50")
)
(fp_text user "keep out"
(at 29 9 0)
(unlocked yes)
(layer "Dwgs.User")
(uuid "1b8c2096-cda5-41b5-8771-3eac90083181")
(effects
(font
(size 1 1)
(thickness 0.15)
)
(justify left bottom)
)
)
(fp_text user "Antenna"
(at 29 7.5 0)
(unlocked yes)
(layer "Dwgs.User")
(uuid "1b984db0-349b-4503-b267-9f2bd33cff0c")
(effects
(font
(size 1 1)
(thickness 0.15)
)
(justify left bottom)
)
)
(fp_text user "${REFERENCE}"
(at 0 8.89 90)
(layer "F.Fab")
(uuid "1781e910-89a3-48c8-8888-4f06a0dd2fdc")
(effects
(font
(size 1 1)
(thickness 0.15)
)
)
)
(pad "" np_thru_hole circle
(at 13.75 -7.02)
(size 3.2 3.2)
(drill 3.2)
(layers "F&B.Cu" "*.Mask")
(uuid "7c71dc53-c556-469e-a5bc-b4c067525bc5")
)
(pad "" np_thru_hole circle
(at 13.75 27.98)
(size 3.2 3.2)
(drill 3.2)
(layers "F&B.Cu" "*.Mask")
(uuid "23726452-e50f-490c-a39e-a24c479dbdd3")
)
(pad "" np_thru_hole circle
(at 51.25 -2.22)
(size 3.2 3.2)
(drill 3.2)
(layers "F&B.Cu" "*.Mask")
(uuid "e3c5ee74-770a-46e8-910d-a5bf1769975b")
)
(pad "" np_thru_hole circle
(at 51.25 23.63)
(size 3.2 3.2)
(drill 3.2)
(layers "F&B.Cu" "*.Mask")
(uuid "d92ad159-9fb8-4328-a573-b78abcdc471f")
)
(pad "1" thru_hole rect
(at 0 0)
(size 1.7 1.7)
(drill 1)
(layers "*.Cu" "*.Mask")
(remove_unused_layers no)
(uuid "cb8ff318-d964-4ff3-93e9-18ed02ecc716")
)
(pad "2" thru_hole circle
(at 0 2.54)
(size 1.7 1.7)
(drill 1)
(layers "*.Cu" "*.Mask")
(remove_unused_layers no)
(uuid "171fee8a-00a9-4f1b-bee2-5dc44fbef460")
)
(pad "3" thru_hole circle
(at 0 5.08)
(size 1.7 1.7)
(drill 1)
(layers "*.Cu" "*.Mask")
(remove_unused_layers no)
(uuid "ff79db69-5af0-439a-b402-c1c0dc0f6a5d")
)
(pad "4" thru_hole circle
(at 0 7.62)
(size 1.7 1.7)
(drill 1)
(layers "*.Cu" "*.Mask")
(remove_unused_layers no)
(uuid "59f1cdf4-9788-48e6-8ac6-11fe687d19a6")
)
(pad "5" thru_hole circle
(at 0 10.16)
(size 1.7 1.7)
(drill 1)
(layers "*.Cu" "*.Mask")
(remove_unused_layers no)
(uuid "02a680e0-7902-4b9b-97d5-04111a6ad630")
)
(pad "6" thru_hole circle
(at 0 12.7)
(size 1.7 1.7)
(drill 1)
(layers "*.Cu" "*.Mask")
(remove_unused_layers no)
(uuid "ea4d2b35-79b2-4d5d-8abe-b0d0675a2a70")
)
(pad "7" thru_hole circle
(at 0 15.24)
(size 1.7 1.7)
(drill 1)
(layers "*.Cu" "*.Mask")
(remove_unused_layers no)
(uuid "c0db5362-fdbd-4784-9c33-469f195ac9af")
)
(pad "8" thru_hole circle
(at 0 17.78)
(size 1.7 1.7)
(drill 1)
(layers "*.Cu" "*.Mask")
(remove_unused_layers no)
(uuid "adabdc0d-c196-4362-98fc-523b0680144d")
)
(zone
(net 0)
(net_name "")
(layers "F.Cu" "B.Cu")
(uuid "5e845900-288e-4f42-a8ac-c20c1cc0aa41")
(name "Antenna")
(hatch edge 0.5)
(connect_pads
(clearance 0)
)
(min_thickness 0.25)
(filled_areas_thickness no)
(keepout
(tracks not_allowed)
(vias not_allowed)
(pads not_allowed)
(copperpour not_allowed)
(footprints not_allowed)
)
(placement
(enabled no)
(sheetname "")
)
(fill
(thermal_gap 0.5)
(thermal_bridge_width 0.5)
)
(polygon
(pts
(xy 58.1 -9.5) (xy 15.5 -9.5) (xy 15.5 30.5) (xy 58.1 30.5)
)
)
)
(group ""
(uuid "72a71035-0d89-4c39-b4c6-1fefc66807fa")
(members "23726452-e50f-490c-a39e-a24c479dbdd3" "37ebb819-cba1-4d26-8219-ba4fc5e3d318"
"49613ec7-c049-4577-95b9-7914f2d0eeb3" "7c71dc53-c556-469e-a5bc-b4c067525bc5"
"8f986849-0666-4030-885e-2108faf1da15" "b0fc3695-90f5-41e7-8964-ebecbadeaa08"
"c91279d4-19e9-4d6b-82e4-3abaf6c9450a" "d92ad159-9fb8-4328-a573-b78abcdc471f"
"e3c5ee74-770a-46e8-910d-a5bf1769975b" "eb0c6019-b136-4812-894a-851e8bd21756"
)
)
(embedded_fonts no)
(model "${KICAD9_3DMODEL_DIR}/Connector_PinSocket_2.54mm.3dshapes/PinSocket_1x08_P2.54mm_Vertical.step"
(offset
(xyz 0 0 0)
)
(scale
(xyz 1 1 1)
)
(rotate
(xyz 0 0 0)
)
)
)

View File

@@ -1,355 +0,0 @@
(footprint "Adafruit_bq25185"
(version 20241229)
(generator "pcbnew")
(generator_version "9.0")
(layer "F.Cu")
(descr "Through hole straight socket strip, 1x07, 2.54mm pitch, single row (from Kicad 4.0.7), script generated")
(tags "Through hole socket strip THT 1x07 2.54mm single row")
(property "Reference" "REF**"
(at 0 -8.255 0)
(layer "F.SilkS")
(uuid "6e575542-8fac-4808-b8c9-ded669296d0d")
(effects
(font
(size 1.016 1.016)
(thickness 0.1524)
)
)
)
(property "Value" "Adafruit_bq25185"
(at -3.81 23.495 0)
(layer "F.Fab")
(uuid "86edab5c-7f9d-4119-b32b-b12277ace150")
(effects
(font
(size 1 1)
(thickness 0.15)
)
)
)
(property "Datasheet" ""
(at 0 0 0)
(layer "F.Fab")
(hide yes)
(uuid "178de254-c544-4662-881b-f069a6a8f6a5")
(effects
(font
(size 1.27 1.27)
(thickness 0.15)
)
)
)
(property "Description" ""
(at 0 0 0)
(layer "F.Fab")
(hide yes)
(uuid "66b522fa-0a20-4d7b-9c1c-01db271771b4")
(effects
(font
(size 1.27 1.27)
(thickness 0.15)
)
)
)
(attr through_hole)
(fp_line
(start -1.33 15.24)
(end -1.33 -2.54)
(stroke
(width 0.12)
(type solid)
)
(layer "F.SilkS")
(uuid "aca72a9b-9330-4c35-bf1b-82d487a9447f")
)
(fp_line
(start -1.33 17.78)
(end -1.33 16.51)
(stroke
(width 0.12)
(type solid)
)
(layer "F.SilkS")
(uuid "518da0df-6fd0-4f31-af5f-48e331dc849c")
)
(fp_line
(start 1.33 15.24)
(end -1.33 15.24)
(stroke
(width 0.12)
(type solid)
)
(layer "F.SilkS")
(uuid "e4083416-4300-4fb2-b50c-b38793ec4834")
)
(fp_line
(start 1.33 15.24)
(end 1.33 -2.54)
(stroke
(width 0.12)
(type solid)
)
(layer "F.SilkS")
(uuid "9b378274-151c-4b84-ae74-fca52b0e1635")
)
(fp_rect
(start -16.51 -6.985)
(end 2.54 22.225)
(stroke
(width 0.1524)
(type solid)
)
(fill no)
(layer "F.SilkS")
(uuid "07d7f04c-4960-4dfe-92c3-4d957a4244ce")
)
(fp_line
(start -1.8 -1.8)
(end -1.799999 -2.652907)
(stroke
(width 0.05)
(type default)
)
(layer "F.CrtYd")
(uuid "4452e51e-0893-4b70-92fd-70f0b54ced32")
)
(fp_line
(start -1.778371 17.931627)
(end -1.8 -1.8)
(stroke
(width 0.05)
(type solid)
)
(layer "F.CrtYd")
(uuid "9f38d32c-752e-406c-81c2-56a23e2124ab")
)
(fp_line
(start 1.75 -1.8)
(end 1.75 -2.604051)
(stroke
(width 0.05)
(type default)
)
(layer "F.CrtYd")
(uuid "355fe774-8482-4f16-ae08-c09c0defb5e5")
)
(fp_line
(start 1.75 -1.8)
(end 1.778373 17.931628)
(stroke
(width 0.05)
(type solid)
)
(layer "F.CrtYd")
(uuid "f10f2b1d-14a2-4182-8823-7cb2b08a980c")
)
(fp_arc
(start -1.8 -2.652908)
(mid 0.034954 -6.984758)
(end 1.75 -2.604051)
(stroke
(width 0.05)
(type default)
)
(layer "F.CrtYd")
(uuid "f25457eb-8a07-4acd-907f-7cd787994a5c")
)
(fp_arc
(start 1.778373 17.931627)
(mid 0 22.224999)
(end -1.778373 17.931627)
(stroke
(width 0.05)
(type default)
)
(layer "F.CrtYd")
(uuid "b284c369-20df-4fe6-a2a0-f28fcc3b58d6")
)
(fp_circle
(center -13.97 -4.42)
(end -16.485 -4.42)
(stroke
(width 0.05)
(type default)
)
(fill no)
(layer "F.CrtYd")
(uuid "bd44fb04-efc3-4813-ad81-2f8b403680df")
)
(fp_circle
(center -13.97 19.71)
(end -16.485 19.71)
(stroke
(width 0.05)
(type default)
)
(fill no)
(layer "F.CrtYd")
(uuid "cf8bdd37-9205-40af-8a51-17c2a30f9014")
)
(fp_line
(start -1.27 -2.501639)
(end 1.27 -2.501639)
(stroke
(width 0.1)
(type solid)
)
(layer "F.Fab")
(uuid "7afbc36b-dc93-48ce-b6e3-213a0cc77565")
)
(fp_line
(start -1.27 17.145)
(end -1.27 -2.501639)
(stroke
(width 0.1)
(type solid)
)
(layer "F.Fab")
(uuid "2c7bc528-2d0c-40c9-9e97-d56236d26fe6")
)
(fp_line
(start -0.635 17.78)
(end -1.27 17.145)
(stroke
(width 0.1)
(type solid)
)
(layer "F.Fab")
(uuid "a12a655f-03d7-4c2a-87ca-b0238aca19c3")
)
(fp_line
(start 1.27 -2.501639)
(end 1.27 17.78)
(stroke
(width 0.1)
(type solid)
)
(layer "F.Fab")
(uuid "7a7136ae-feda-4d2a-9391-81bb7d3312d2")
)
(fp_line
(start 1.27 17.78)
(end -0.635 17.78)
(stroke
(width 0.1)
(type solid)
)
(layer "F.Fab")
(uuid "c13c8715-e3c4-4d03-8849-e2a1e26d40a1")
)
(fp_text user "${REFERENCE}"
(at -2.54 12.7 270)
(layer "F.Fab")
(uuid "a8c74cf2-2d56-4366-b45f-d04e448ebc72")
(effects
(font
(size 1 1)
(thickness 0.15)
)
)
)
(pad "" np_thru_hole circle
(at -13.97 -4.445)
(size 2.54 2.54)
(drill 2.54)
(layers "F&B.Cu" "*.Mask")
(uuid "356e8d03-c3ad-4b24-bf9f-f35de4f1636d")
)
(pad "" np_thru_hole circle
(at -13.97 19.685)
(size 2.54 2.54)
(drill 2.54)
(layers "F&B.Cu" "*.Mask")
(uuid "c342310e-eb02-4174-9d9c-eb95934aebf5")
)
(pad "" np_thru_hole circle
(at 0 -4.445)
(size 2.54 2.54)
(drill 2.54)
(layers "F&B.Cu" "*.Mask")
(uuid "6306c281-706a-4b36-9e39-5aa956be2d80")
)
(pad "" np_thru_hole circle
(at 0 19.685)
(size 2.54 2.54)
(drill 2.54)
(layers "F&B.Cu" "*.Mask")
(uuid "d01f277d-a18c-4fb7-8bce-ad82f53d5330")
)
(pad "1" thru_hole rect
(at 0 16.51 180)
(size 1.7 1.7)
(drill 1)
(layers "*.Cu" "*.Mask")
(remove_unused_layers no)
(uuid "9c199252-bdac-484c-a687-6b4ef26cc3e6")
)
(pad "2" thru_hole circle
(at 0 13.97 180)
(size 1.7 1.7)
(drill 1)
(layers "*.Cu" "*.Mask")
(remove_unused_layers no)
(uuid "be025cc7-4905-4574-955b-178576ccc1cc")
)
(pad "3" thru_hole circle
(at 0 11.43 180)
(size 1.7 1.7)
(drill 1)
(layers "*.Cu" "*.Mask")
(remove_unused_layers no)
(uuid "e46efbed-3393-457c-95ba-f30245b6478b")
)
(pad "4" thru_hole circle
(at 0 8.89 180)
(size 1.7 1.7)
(drill 1)
(layers "*.Cu" "*.Mask")
(remove_unused_layers no)
(uuid "5bb680ed-7bca-4b4a-852d-e6849574c8a6")
)
(pad "5" thru_hole circle
(at 0 6.35 180)
(size 1.7 1.7)
(drill 1)
(layers "*.Cu" "*.Mask")
(remove_unused_layers no)
(uuid "59961279-9785-4a81-948d-7855e0e08516")
)
(pad "6" thru_hole circle
(at 0 3.81 180)
(size 1.7 1.7)
(drill 1)
(layers "*.Cu" "*.Mask")
(remove_unused_layers no)
(uuid "eda46f52-d12e-47b7-951c-bc4005bc694e")
)
(pad "7" thru_hole circle
(at 0 1.27 180)
(size 1.7 1.7)
(drill 1)
(layers "*.Cu" "*.Mask")
(remove_unused_layers no)
(uuid "2cb79b81-6688-44c2-92c7-e75f5df30e91")
)
(pad "8" thru_hole circle
(at 0 -1.27 180)
(size 1.7 1.7)
(drill 1)
(layers "*.Cu" "*.Mask")
(remove_unused_layers no)
(uuid "b2d6f12f-bf69-4a9b-a3e9-164231e4f483")
)
(embedded_fonts no)
(model "${KICAD9_3DMODEL_DIR}/Connector_PinSocket_2.54mm.3dshapes/PinSocket_1x08_P2.54mm_Vertical.step"
(offset
(xyz 0 1.25 0)
)
(scale
(xyz 1 1 1)
)
(rotate
(xyz -0 -0 -0)
)
)
)

View File

@@ -1,381 +0,0 @@
(footprint "Sparkfun_MAX98357A"
(version 20241229)
(generator "pcbnew")
(generator_version "9.0")
(layer "F.Cu")
(descr "Through hole straight socket strip, 1x07, 2.54mm pitch, single row (from Kicad 4.0.7), script generated")
(tags "Through hole socket strip THT 1x07 2.54mm single row")
(property "Reference" "REF**"
(at 0 -3.556 0)
(layer "F.SilkS")
(uuid "2656f117-ae27-4bd8-a9c3-f25e38d25a50")
(effects
(font
(size 1.016 1.016)
(thickness 0.1524)
)
)
)
(property "Value" "Sparkfun_MAX98357A"
(at 0 18.01 0)
(layer "F.Fab")
(uuid "e7891e54-c3b2-4045-b729-ec1b29005170")
(effects
(font
(size 1 1)
(thickness 0.15)
)
)
)
(property "Datasheet" ""
(at 0 0 0)
(layer "F.Fab")
(hide yes)
(uuid "aab68a41-9b58-43f5-b6ed-218d51a35c21")
(effects
(font
(size 1.27 1.27)
(thickness 0.15)
)
)
)
(property "Description" ""
(at 0 0 0)
(layer "F.Fab")
(hide yes)
(uuid "d5308824-df7c-46bf-b6ad-dfc407c4cafc")
(effects
(font
(size 1.27 1.27)
(thickness 0.15)
)
)
)
(attr through_hole)
(fp_line
(start -1.33 -2.54)
(end 19.05 -2.54)
(stroke
(width 0.1524)
(type solid)
)
(layer "F.SilkS")
(uuid "a225c96b-f88a-46ab-8898-d5a961a69e4d")
)
(fp_line
(start -1.33 1.27)
(end -1.33 -2.54)
(stroke
(width 0.1524)
(type solid)
)
(layer "F.SilkS")
(uuid "d9e35813-ad57-481a-bd0f-e96849b81f42")
)
(fp_line
(start -1.33 1.27)
(end -1.33 16.57)
(stroke
(width 0.12)
(type solid)
)
(layer "F.SilkS")
(uuid "acaab1d6-f4d6-4ae2-98ff-f3ba2e3cd533")
)
(fp_line
(start -1.33 1.27)
(end 1.33 1.27)
(stroke
(width 0.12)
(type solid)
)
(layer "F.SilkS")
(uuid "c2228c31-a76f-4f6c-8dac-e47a00dd4a83")
)
(fp_line
(start -1.33 16.57)
(end 1.33 16.57)
(stroke
(width 0.12)
(type solid)
)
(layer "F.SilkS")
(uuid "4067195c-1874-47b5-b0f1-8f78aa17f1fb")
)
(fp_line
(start -1.27 17.78)
(end -1.27 16.57)
(stroke
(width 0.1524)
(type solid)
)
(layer "F.SilkS")
(uuid "b5f022f0-b8d1-4bd3-b372-d667aa83852e")
)
(fp_line
(start 0 -1.33)
(end 1.33 -1.33)
(stroke
(width 0.12)
(type solid)
)
(layer "F.SilkS")
(uuid "3672f0c4-cfd3-4ed8-a916-26b23e299b5c")
)
(fp_line
(start 1.33 -1.33)
(end 1.33 0)
(stroke
(width 0.12)
(type solid)
)
(layer "F.SilkS")
(uuid "e8fb0117-396a-4d0e-92dd-8c1cd555e5c6")
)
(fp_line
(start 1.33 1.27)
(end 1.33 16.57)
(stroke
(width 0.12)
(type solid)
)
(layer "F.SilkS")
(uuid "1ac1a480-cb45-4327-a825-2b9d92cf5a54")
)
(fp_line
(start 19.05 -2.54)
(end 19.05 17.78)
(stroke
(width 0.1524)
(type solid)
)
(layer "F.SilkS")
(uuid "51996aa9-4796-4afe-9489-c6b28405d249")
)
(fp_line
(start 19.05 17.78)
(end -1.27 17.78)
(stroke
(width 0.1524)
(type solid)
)
(layer "F.SilkS")
(uuid "18391d8a-34f3-4119-86e5-c63f37e37070")
)
(fp_line
(start -1.8 -1.8)
(end 1.75 -1.8)
(stroke
(width 0.05)
(type solid)
)
(layer "F.CrtYd")
(uuid "858f249c-fdd9-466b-b8a1-4dd799afef6b")
)
(fp_line
(start -1.8 17)
(end -1.8 -1.8)
(stroke
(width 0.05)
(type solid)
)
(layer "F.CrtYd")
(uuid "1d1e798f-8b02-4758-b297-16af69eea697")
)
(fp_line
(start 1.75 -1.8)
(end 1.75 17)
(stroke
(width 0.05)
(type solid)
)
(layer "F.CrtYd")
(uuid "43d35c9f-6401-4fe4-80f8-4316968255a0")
)
(fp_line
(start 1.75 17)
(end -1.8 17)
(stroke
(width 0.05)
(type solid)
)
(layer "F.CrtYd")
(uuid "35349e7d-9f0b-4de6-b84b-46bf4473917f")
)
(fp_circle
(center 15.875 0.635)
(end 18.415 0.635)
(stroke
(width 0.05)
(type default)
)
(fill no)
(layer "F.CrtYd")
(uuid "dfaa1b74-8adb-43a3-83c0-f701d5acdf69")
)
(fp_circle
(center 15.875 14.605)
(end 18.415 14.605)
(stroke
(width 0.05)
(type default)
)
(fill no)
(layer "F.CrtYd")
(uuid "b6741d2d-655e-4405-bdec-635bf83d8ad5")
)
(fp_line
(start -1.27 -1.27)
(end 0.635 -1.27)
(stroke
(width 0.1)
(type solid)
)
(layer "F.Fab")
(uuid "37e9acde-12d2-4319-a282-cdf2fdbe341a")
)
(fp_line
(start -1.27 16.51)
(end -1.27 -1.27)
(stroke
(width 0.1)
(type solid)
)
(layer "F.Fab")
(uuid "56075374-a1e4-485e-b882-8e4cce7262d5")
)
(fp_line
(start 0.635 -1.27)
(end 1.27 -0.635)
(stroke
(width 0.1)
(type solid)
)
(layer "F.Fab")
(uuid "b0ddd3e4-2153-4cae-8cb5-d50cbfc8f47e")
)
(fp_line
(start 1.27 -0.635)
(end 1.27 16.51)
(stroke
(width 0.1)
(type solid)
)
(layer "F.Fab")
(uuid "2c20ee51-27de-4f59-83a5-2a2f58df65fb")
)
(fp_line
(start 1.27 16.51)
(end -1.27 16.51)
(stroke
(width 0.1)
(type solid)
)
(layer "F.Fab")
(uuid "d6de62ad-e175-43b9-85c8-340c8ab61fbc")
)
(fp_rect
(start -1.3716 -2.6416)
(end 19.1516 17.8816)
(stroke
(width 0.05)
(type solid)
)
(fill no)
(layer "F.Fab")
(uuid "6931ddba-5c8b-4684-9522-4250e5a9daf6")
)
(fp_text user "${REFERENCE}"
(at 0 7.62 90)
(layer "F.Fab")
(uuid "d0f4c06e-6fb2-4a19-9b48-8ad674e44ff7")
(effects
(font
(size 1 1)
(thickness 0.15)
)
)
)
(pad "" np_thru_hole circle
(at 15.875 0.635)
(size 3.2 3.2)
(drill 3.2)
(layers "F&B.Cu" "*.Mask")
(uuid "71993a8f-c48d-437a-8a3c-3e63eb969208")
)
(pad "" np_thru_hole circle
(at 15.875 14.605)
(size 3.2 3.2)
(drill 3.2)
(layers "F&B.Cu" "*.Mask")
(uuid "ed9c8b97-5d58-4937-959a-98150878726c")
)
(pad "1" thru_hole rect
(at 0 0)
(size 1.7 1.7)
(drill 1)
(layers "*.Cu" "*.Mask")
(remove_unused_layers no)
(uuid "1c83ddd7-1eb1-4a63-97b8-8cad20ed8adb")
)
(pad "2" thru_hole circle
(at 0 2.54)
(size 1.7 1.7)
(drill 1)
(layers "*.Cu" "*.Mask")
(remove_unused_layers no)
(uuid "4b5aedc5-52ef-4559-9513-4cfe7a9290f5")
)
(pad "3" thru_hole circle
(at 0 5.08)
(size 1.7 1.7)
(drill 1)
(layers "*.Cu" "*.Mask")
(remove_unused_layers no)
(uuid "d2e33fa3-db5c-4923-b9dd-22f0a7d95420")
)
(pad "4" thru_hole circle
(at 0 7.62)
(size 1.7 1.7)
(drill 1)
(layers "*.Cu" "*.Mask")
(remove_unused_layers no)
(uuid "769680bd-a4f8-488a-9497-b63b5c793581")
)
(pad "5" thru_hole circle
(at 0 10.16)
(size 1.7 1.7)
(drill 1)
(layers "*.Cu" "*.Mask")
(remove_unused_layers no)
(uuid "995ea8f5-e63f-41d7-93fe-dc90eacb16c7")
)
(pad "6" thru_hole circle
(at 0 12.7)
(size 1.7 1.7)
(drill 1)
(layers "*.Cu" "*.Mask")
(remove_unused_layers no)
(uuid "3060d1e5-b1f1-47ad-9a39-6410cc4f3629")
)
(pad "7" thru_hole circle
(at 0 15.24)
(size 1.7 1.7)
(drill 1)
(layers "*.Cu" "*.Mask")
(remove_unused_layers no)
(uuid "8651b2dd-3cf3-4c82-aa1a-ccc59e2c1675")
)
(embedded_fonts no)
(model "${KICAD9_3DMODEL_DIR}/Connector_PinSocket_2.54mm.3dshapes/PinSocket_1x07_P2.54mm_Vertical.step"
(offset
(xyz 0 0 0)
)
(scale
(xyz 1 1 1)
)
(rotate
(xyz 0 0 0)
)
)
)

View File

@@ -1,306 +0,0 @@
(footprint "Sparkfun_MicroSD_Breakout"
(version 20241229)
(generator "pcbnew")
(generator_version "9.0")
(layer "F.Cu")
(descr "Through hole straight socket strip, 1x07, 2.54mm pitch, single row (from Kicad 4.0.7), script generated")
(tags "Through hole socket strip THT 1x07 2.54mm single row")
(property "Reference" "REF**"
(at 0 -3.81 0)
(layer "F.SilkS")
(uuid "2649026f-8664-4aec-9bb7-08c001d0263d")
(effects
(font
(size 1.016 1.016)
(thickness 0.1524)
)
)
)
(property "Value" "Sparkfun_MicroSD_Breakout"
(at -8.255 19.05 0)
(layer "F.Fab")
(uuid "befe8006-9d37-47c5-9cb4-916482040426")
(effects
(font
(size 1 1)
(thickness 0.15)
)
)
)
(property "Datasheet" ""
(at 0 0 0)
(layer "F.Fab")
(hide yes)
(uuid "bda792b3-ed8e-4973-9481-8397712eba90")
(effects
(font
(size 1.27 1.27)
(thickness 0.15)
)
)
)
(property "Description" ""
(at 0 0 0)
(layer "F.Fab")
(hide yes)
(uuid "2b01e065-879a-4a28-815b-34368d12b6af")
(effects
(font
(size 1.27 1.27)
(thickness 0.15)
)
)
)
(attr through_hole)
(fp_line
(start -1.33 1.27)
(end -1.33 16.57)
(stroke
(width 0.12)
(type solid)
)
(layer "F.SilkS")
(uuid "c581ca6d-5026-479b-93ae-7bad6b62ddd9")
)
(fp_line
(start -1.33 1.27)
(end 1.33 1.27)
(stroke
(width 0.12)
(type solid)
)
(layer "F.SilkS")
(uuid "d49fc77d-4bea-4acf-adb5-43551cb508f1")
)
(fp_line
(start -1.33 16.57)
(end 1.33 16.57)
(stroke
(width 0.12)
(type solid)
)
(layer "F.SilkS")
(uuid "fa1edbcf-d278-4f63-83db-6cdbef0fe0fb")
)
(fp_line
(start 0 -1.33)
(end 1.33 -1.33)
(stroke
(width 0.12)
(type solid)
)
(layer "F.SilkS")
(uuid "466d8a50-67a4-4117-b2ef-fd8fa3c112f8")
)
(fp_line
(start 1.33 -1.33)
(end 1.33 0)
(stroke
(width 0.12)
(type solid)
)
(layer "F.SilkS")
(uuid "cc6f4d87-5f66-4c7a-b151-a392aa8f66ed")
)
(fp_line
(start 1.33 1.27)
(end 1.33 16.57)
(stroke
(width 0.12)
(type solid)
)
(layer "F.SilkS")
(uuid "dc1b7dd4-ca53-4049-ae81-eb11216e81d9")
)
(fp_rect
(start -20.574 -2.794)
(end 1.778 18.034)
(stroke
(width 0.1524)
(type solid)
)
(fill no)
(layer "F.SilkS")
(uuid "0af85dea-fde2-4a4a-83d8-7c1dc2a9aafa")
)
(fp_line
(start -1.8 -1.8)
(end 1.75 -1.8)
(stroke
(width 0.05)
(type solid)
)
(layer "F.CrtYd")
(uuid "abe8217b-b617-4a57-8b1b-aaad9a1a9408")
)
(fp_line
(start -1.8 17)
(end -1.8 -1.8)
(stroke
(width 0.05)
(type solid)
)
(layer "F.CrtYd")
(uuid "f6453894-baf7-4501-8b5d-b696bc45df5b")
)
(fp_line
(start 1.75 -1.8)
(end 1.75 17)
(stroke
(width 0.05)
(type solid)
)
(layer "F.CrtYd")
(uuid "9c21be3b-ef13-48a3-a28c-f5586af79a94")
)
(fp_line
(start 1.75 17)
(end -1.8 17)
(stroke
(width 0.05)
(type solid)
)
(layer "F.CrtYd")
(uuid "27ab22be-40bf-42f8-a30e-00bb317bcedd")
)
(fp_line
(start -1.27 -1.27)
(end 0.635 -1.27)
(stroke
(width 0.1)
(type solid)
)
(layer "F.Fab")
(uuid "bbaf3582-2515-4a48-ac3f-6c1eac7a9c7a")
)
(fp_line
(start -1.27 16.51)
(end -1.27 -1.27)
(stroke
(width 0.1)
(type solid)
)
(layer "F.Fab")
(uuid "7ba01148-ed5c-4058-bbbe-f7586e6b0fe3")
)
(fp_line
(start 0.635 -1.27)
(end 1.27 -0.635)
(stroke
(width 0.1)
(type solid)
)
(layer "F.Fab")
(uuid "449b7ca7-e50d-486c-95c9-ec3b9328d1e9")
)
(fp_line
(start 1.27 -0.635)
(end 1.27 16.51)
(stroke
(width 0.1)
(type solid)
)
(layer "F.Fab")
(uuid "a8fe924f-576e-43f4-bdbd-bede8f50e4a9")
)
(fp_line
(start 1.27 16.51)
(end -1.27 16.51)
(stroke
(width 0.1)
(type solid)
)
(layer "F.Fab")
(uuid "bc826cb1-1f13-4927-b007-dbc4e7bf9119")
)
(fp_rect
(start -20.5105 -2.8575)
(end 1.7145 18.0975)
(stroke
(width 0.1)
(type solid)
)
(fill no)
(layer "F.Fab")
(uuid "ff136a20-1e67-47d3-967e-862544e6261a")
)
(fp_text user "${REFERENCE}"
(at 0 7.62 90)
(layer "F.Fab")
(uuid "c75ce234-326d-4a91-a01c-aa463620f982")
(effects
(font
(size 1 1)
(thickness 0.15)
)
)
)
(pad "1" thru_hole rect
(at 0 0)
(size 1.7 1.7)
(drill 1)
(layers "*.Cu" "*.Mask")
(remove_unused_layers no)
(uuid "94205f6f-c0ef-4190-8ab9-3ca1f6dfb4f7")
)
(pad "2" thru_hole circle
(at 0 2.54)
(size 1.7 1.7)
(drill 1)
(layers "*.Cu" "*.Mask")
(remove_unused_layers no)
(uuid "b734434c-14a9-4700-99dc-f466e6509db7")
)
(pad "3" thru_hole circle
(at 0 5.08)
(size 1.7 1.7)
(drill 1)
(layers "*.Cu" "*.Mask")
(remove_unused_layers no)
(uuid "971175f6-b389-4741-aaaf-0fb15d3e7fff")
)
(pad "4" thru_hole circle
(at 0 7.62)
(size 1.7 1.7)
(drill 1)
(layers "*.Cu" "*.Mask")
(remove_unused_layers no)
(uuid "dca213e6-9378-4e06-9621-11be8656a233")
)
(pad "5" thru_hole circle
(at 0 10.16)
(size 1.7 1.7)
(drill 1)
(layers "*.Cu" "*.Mask")
(remove_unused_layers no)
(uuid "c723245c-962a-4ab8-b0e1-229741e9138e")
)
(pad "6" thru_hole circle
(at 0 12.7)
(size 1.7 1.7)
(drill 1)
(layers "*.Cu" "*.Mask")
(remove_unused_layers no)
(uuid "9b547c20-1f88-4220-82b8-d72f851fb6a1")
)
(pad "7" thru_hole circle
(at 0 15.24)
(size 1.7 1.7)
(drill 1)
(layers "*.Cu" "*.Mask")
(remove_unused_layers no)
(uuid "a588ed23-dbc2-406f-bc1c-3c6e69dd99ee")
)
(embedded_fonts no)
(model "${KICAD9_3DMODEL_DIR}/Connector_PinSocket_2.54mm.3dshapes/PinSocket_1x07_P2.54mm_Vertical.step"
(offset
(xyz 0 0 0)
)
(scale
(xyz 1 1 1)
)
(rotate
(xyz 0 0 0)
)
)
)

View File

@@ -1,4 +0,0 @@
(fp_lib_table
(version 7)
(lib (name "Modules")(type "KiCad")(uri "${KIPRJMOD}/Modules.pretty")(options "")(descr ""))
)

View File

@@ -1,28 +0,0 @@
(version 1)
(rule "Pad to Silkscreen"
(constraint silk_clearance (min 0.15mm))
(layer outer)
(condition "A.Type == 'pad' && (B.Type == 'text' || B.Type == 'graphic')"))
(rule "drill hole size (mechanical)"
(constraint hole_size (min 0.2mm) (max 6.3mm)))
(rule "Minimum Via Hole Size"
(constraint hole_size (min 0.2mm))
(condition "A.Type == 'via'"))
(rule "Minimum Via Diameter"
(constraint via_diameter (min 0.35mm))
(condition "A.Type == 'via'"))
(rule "PTH Hole Size"
(constraint hole_size (min 0.2mm) (max 6.35mm))
(condition "A.isPlated()"))
(rule "Minimum Non-plated Hole Size"
(constraint hole_size (min 0.5mm))
(condition "A.Type == 'pad' && !A.isPlated()"))
(rule "Pad to Track clearance"
(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

@@ -4,9 +4,7 @@
"active_layer_preset": "",
"auto_track_width": true,
"hidden_netclasses": [],
"hidden_nets": [
"GND"
],
"hidden_nets": [],
"high_contrast_mode": 0,
"net_color_mode": 1,
"opacity": {
@@ -18,17 +16,17 @@
"zones": 0.6
},
"selection_filter": {
"dimensions": false,
"footprints": false,
"dimensions": true,
"footprints": true,
"graphics": true,
"keepouts": false,
"keepouts": true,
"lockedItems": false,
"otherItems": false,
"pads": false,
"otherItems": true,
"pads": true,
"text": true,
"tracks": true,
"vias": true,
"zones": false
"zones": true
},
"visible_items": [
"vias",
@@ -51,7 +49,7 @@
"conflict_shadows",
"shapes"
],
"visible_layers": "00000000_00000000_0ffffff7_ffffffff",
"visible_layers": "00000000_00000000_0fffffff_ffffffff",
"zone_display_mode": 0
},
"git": {
@@ -65,42 +63,9 @@
"version": 5
},
"net_inspector_panel": {
"col_hidden": [
false,
false,
false,
false,
false,
false,
false,
false,
false,
false
],
"col_order": [
0,
1,
2,
3,
4,
5,
6,
7,
8,
9
],
"col_widths": [
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
],
"col_hidden": [],
"col_order": [],
"col_widths": [],
"custom_group_rules": [],
"expanded_rows": [],
"filter_by_net_name": true,
@@ -111,7 +76,7 @@
"show_unconnected_nets": false,
"show_zero_pad_nets": false,
"sort_ascending": true,
"sorting_column": 0
"sorting_column": -1
},
"open_jobsets": [],
"project": {

View File

@@ -2,247 +2,12 @@
"board": {
"3dviewports": [],
"design_settings": {
"defaults": {
"apply_defaults_to_fp_fields": false,
"apply_defaults_to_fp_shapes": false,
"apply_defaults_to_fp_text": false,
"board_outline_line_width": 0.05,
"copper_line_width": 0.2,
"copper_text_italic": false,
"copper_text_size_h": 1.5,
"copper_text_size_v": 1.5,
"copper_text_thickness": 0.3,
"copper_text_upright": false,
"courtyard_line_width": 0.05,
"dimension_precision": 4,
"dimension_units": 3,
"dimensions": {
"arrow_length": 1270000,
"extension_offset": 500000,
"keep_text_aligned": true,
"suppress_zeroes": true,
"text_position": 0,
"units_format": 0
},
"fab_line_width": 0.1,
"fab_text_italic": false,
"fab_text_size_h": 1.0,
"fab_text_size_v": 1.0,
"fab_text_thickness": 0.15,
"fab_text_upright": false,
"other_line_width": 0.1,
"other_text_italic": false,
"other_text_size_h": 1.0,
"other_text_size_v": 1.0,
"other_text_thickness": 0.15,
"other_text_upright": false,
"pads": {
"drill": 0.8,
"height": 1.6,
"width": 1.6
},
"silk_line_width": 0.1,
"silk_text_italic": false,
"silk_text_size_h": 1.0,
"silk_text_size_v": 1.0,
"silk_text_thickness": 0.1,
"silk_text_upright": false,
"zones": {
"min_clearance": 0.4064
}
},
"diff_pair_dimensions": [
{
"gap": 0.0,
"via_gap": 0.0,
"width": 0.0
}
],
"drc_exclusions": [
[
"lib_footprint_mismatch|148587019|59126370|4f17c230-1bc1-45e3-8816-9cf9d13e3a5c|00000000-0000-0000-0000-000000000000",
""
],
[
"nonmirrored_text_on_back_layer|213233000|81790000|e87ca627-5327-4f7e-ac1a-c1eadd662c0a|00000000-0000-0000-0000-000000000000",
""
]
],
"meta": {
"filename": "board_design_settings.json",
"version": 2
},
"rule_severities": {
"annular_width": "error",
"clearance": "error",
"connection_width": "warning",
"copper_edge_clearance": "error",
"copper_sliver": "warning",
"courtyards_overlap": "error",
"creepage": "error",
"diff_pair_gap_out_of_range": "error",
"diff_pair_uncoupled_length_too_long": "error",
"drill_out_of_range": "error",
"duplicate_footprints": "warning",
"extra_footprint": "warning",
"footprint": "error",
"footprint_filters_mismatch": "ignore",
"footprint_symbol_mismatch": "warning",
"footprint_type_mismatch": "ignore",
"hole_clearance": "error",
"hole_to_hole": "warning",
"holes_co_located": "warning",
"invalid_outline": "error",
"isolated_copper": "warning",
"item_on_disabled_layer": "error",
"items_not_allowed": "error",
"length_out_of_range": "error",
"lib_footprint_issues": "warning",
"lib_footprint_mismatch": "warning",
"malformed_courtyard": "error",
"microvia_drill_out_of_range": "error",
"mirrored_text_on_front_layer": "warning",
"missing_courtyard": "ignore",
"missing_footprint": "warning",
"net_conflict": "warning",
"nonmirrored_text_on_back_layer": "warning",
"npth_inside_courtyard": "ignore",
"padstack": "warning",
"pth_inside_courtyard": "ignore",
"shorting_items": "error",
"silk_edge_clearance": "warning",
"silk_over_copper": "warning",
"silk_overlap": "warning",
"skew_out_of_range": "error",
"solder_mask_bridge": "error",
"starved_thermal": "error",
"text_height": "warning",
"text_on_edge_cuts": "error",
"text_thickness": "warning",
"through_hole_pad_without_hole": "error",
"too_many_vias": "error",
"track_angle": "error",
"track_dangling": "warning",
"track_segment_length": "error",
"track_width": "error",
"tracks_crossing": "error",
"unconnected_items": "error",
"unresolved_variable": "error",
"via_dangling": "warning",
"zones_intersect": "error"
},
"rules": {
"max_error": 0.005,
"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.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.2,
"min_track_width": 0.15,
"min_via_annular_width": 0.15,
"min_via_diameter": 0.35,
"solder_mask_to_copper_clearance": 0.005,
"use_height_for_length_calcs": true
},
"teardrop_options": [
{
"td_onpthpad": true,
"td_onroundshapesonly": false,
"td_onsmdpad": true,
"td_ontrackend": false,
"td_onvia": true
}
],
"teardrop_parameters": [
{
"td_allow_use_two_tracks": true,
"td_curve_segcount": 0,
"td_height_ratio": 1.0,
"td_length_ratio": 0.5,
"td_maxheight": 2.0,
"td_maxlen": 1.0,
"td_on_pad_in_zone": false,
"td_target_name": "td_round_shape",
"td_width_to_size_filter_ratio": 0.9
},
{
"td_allow_use_two_tracks": true,
"td_curve_segcount": 0,
"td_height_ratio": 1.0,
"td_length_ratio": 0.5,
"td_maxheight": 2.0,
"td_maxlen": 1.0,
"td_on_pad_in_zone": false,
"td_target_name": "td_rect_shape",
"td_width_to_size_filter_ratio": 0.9
},
{
"td_allow_use_two_tracks": true,
"td_curve_segcount": 0,
"td_height_ratio": 1.0,
"td_length_ratio": 0.5,
"td_maxheight": 2.0,
"td_maxlen": 1.0,
"td_on_pad_in_zone": false,
"td_target_name": "td_track_end",
"td_width_to_size_filter_ratio": 0.9
}
],
"track_widths": [
0.0,
0.2032,
0.4064,
0.508
],
"tuning_pattern_settings": {
"diff_pair_defaults": {
"corner_radius_percentage": 80,
"corner_style": 1,
"max_amplitude": 1.0,
"min_amplitude": 0.2,
"single_sided": false,
"spacing": 1.0
},
"diff_pair_skew_defaults": {
"corner_radius_percentage": 80,
"corner_style": 1,
"max_amplitude": 1.0,
"min_amplitude": 0.2,
"single_sided": false,
"spacing": 0.6
},
"single_track_defaults": {
"corner_radius_percentage": 80,
"corner_style": 1,
"max_amplitude": 1.0,
"min_amplitude": 0.2,
"single_sided": false,
"spacing": 0.6
}
},
"via_dimensions": [
{
"diameter": 0.0,
"drill": 0.0
},
{
"diameter": 0.6,
"drill": 0.3
},
{
"diameter": 0.8,
"drill": 0.4
}
],
"zones_allow_external_fillets": false
"defaults": {},
"diff_pair_dimensions": [],
"drc_exclusions": [],
"rules": {},
"track_widths": [],
"via_dimensions": []
},
"ipc2581": {
"dist": "",
@@ -260,12 +25,7 @@
"equivalence_files": []
},
"erc": {
"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."
]
],
"erc_exclusions": [],
"meta": {
"version": 0
},
@@ -453,9 +213,9 @@
"extra_units": "error",
"footprint_filter": "ignore",
"footprint_link_issues": "warning",
"four_way_junction": "warning",
"four_way_junction": "ignore",
"global_label_dangling": "warning",
"hier_label_mismatch": "warning",
"hier_label_mismatch": "error",
"label_dangling": "error",
"label_multiple_wires": "warning",
"lib_symbol_issues": "warning",
@@ -470,17 +230,16 @@
"no_connect_dangling": "warning",
"pin_not_connected": "error",
"pin_not_driven": "error",
"pin_to_pin": "error",
"pin_to_pin": "warning",
"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": "warning",
"single_global_label": "ignore",
"unannotated": "error",
"unconnected_wire_endpoint": "warning",
"undefined_netclass": "error",
"unit_value_mismatch": "error",
"unresolved_variable": "error",
"wire_dangling": "error"
@@ -498,7 +257,7 @@
"classes": [
{
"bus_width": 12,
"clearance": 0.1778,
"clearance": 0.2,
"diff_pair_gap": 0.25,
"diff_pair_via_gap": 0.25,
"diff_pair_width": 0.2,
@@ -509,20 +268,10 @@
"pcb_color": "rgba(0, 0, 0, 0.000)",
"priority": 2147483647,
"schematic_color": "rgba(0, 0, 0, 0.000)",
"track_width": 0.2032,
"via_diameter": 0.8,
"via_drill": 0.4,
"track_width": 0.2,
"via_diameter": 0.6,
"via_drill": 0.3,
"wire_width": 6
},
{
"clearance": 0.1778,
"name": "Power",
"pcb_color": "rgba(0, 0, 0, 0.000)",
"priority": 0,
"schematic_color": "rgba(0, 0, 0, 0.000)",
"track_width": 0.4064,
"via_diameter": 0.8,
"via_drill": 0.4
}
],
"meta": {
@@ -530,27 +279,14 @@
},
"net_colors": null,
"netclass_assignments": null,
"netclass_patterns": [
{
"netclass": "Power",
"pattern": "+*"
},
{
"netclass": "Power",
"pattern": "GND"
},
{
"netclass": "Power",
"pattern": "/*BAT"
}
]
"netclass_patterns": []
},
"pcbnew": {
"last_paths": {
"gencad": "",
"idf": "",
"netlist": "",
"plot": "pcb_test/",
"plot": "",
"pos_files": "",
"specctra_dsn": "",
"step": "",
@@ -611,36 +347,12 @@
"label": "DNP",
"name": "${DNP}",
"show": true
},
{
"group_by": false,
"label": "#",
"name": "${ITEM_NUMBER}",
"show": false
},
{
"group_by": false,
"label": "Description",
"name": "Description",
"show": false
},
{
"group_by": false,
"label": "Sim.Pins",
"name": "Sim.Pins",
"show": false
},
{
"group_by": false,
"label": "Sim.Device",
"name": "Sim.Device",
"show": false
}
],
"filter_string": "",
"group_symbols": true,
"include_excluded_from_bom": false,
"name": "",
"name": "Grouped By Value",
"sort_asc": true,
"sort_field": "Reference"
},

File diff suppressed because it is too large Load Diff

View File

@@ -54,9 +54,9 @@ add_subdirectory(modules/audiocore)
add_custom_target(check-format
find . -iname '*.[ch]' -exec clang-format -Werror --dry-run {} +
WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}/modules
WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}/src
)
add_custom_target(clang-format
find . -iname '*.[ch]' -exec clang-format -i {} +
WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}/modules
WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}/src
)

View File

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

View File

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

View File

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

View File

@@ -15,12 +15,3 @@ 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")
module("frozen_frontend.py", "../../build")

View File

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

View File

@@ -25,12 +25,3 @@ 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

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

View File

@@ -1,31 +0,0 @@
/*
* 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

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

View File

@@ -1,126 +0,0 @@
/*
* 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,60 +6,33 @@ set -eu
( cd lib/micropython
make -C mpy-cross -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)"
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)"
)
( 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
FRONTEND_STAGE_DIR=$(mktemp -d)
trap 'rm -rf $FRONTEND_STAGE_DIR' EXIT
gzip -c frontend/index.html > "$FRONTEND_STAGE_DIR"/index.html.gz
python -m freezefs "$FRONTEND_STAGE_DIR" build/frozen_frontend.py --target=/frontend
for hwconfig in boards/RPI_PICO_W/manifest-*.py; do
hwconfig_base=$(basename "$hwconfig")
hwname=${hwconfig_base##manifest-}
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
PICOTOOL=picotool
if ! command -v $PICOTOOL >/dev/null 2>&1; then
echo "system picotool not found, checking SDK build dir"
PICOTOOL=lib/micropython/ports/rp2/build-TONBERRY_RPI_PICO_W/_deps/picotool-build/picotool
if ! command -v $PICOTOOL >/dev/null 2>&1; then
echo "system picotool not found, checking SDK build dir"
PICOTOOL=lib/micropython/ports/rp2/build-TONBERRY_RPI_PICO_W/_deps/picotool-build/picotool
if ! command -v $PICOTOOL >/dev/null 2>&1; then
echo "No picotool found, exiting"
exit 1
fi
echo "No picotool found, exiting"
exit 1
fi
truncate -s 2M $BUILDDIR/firmware-filesystem.bin
dd if=$BUILDDIR/firmware.bin of=$BUILDDIR/firmware-filesystem.bin bs=1k
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
fi
BUILDDIR=lib/micropython/ports/rp2/build-TONBERRY_RPI_PICO_W/
FS_STAGE_DIR=$(mktemp -d)
trap 'rm -rf $FS_STAGE_DIR' EXIT
find src/ -iname '*.py' | cpio -pdm "$FS_STAGE_DIR"
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.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
echo "Output in $BUILDDIR/firmware.uf2"
echo "Image with filesystem in $BUILDDIR/firmware-filesystem.uf2"

View File

@@ -2,8 +2,6 @@
set -eu
TOPDIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )
check_command()
{
name=$1
@@ -18,15 +16,14 @@ check_command lsusb
check_command picotool
DEVICEPATH=/dev/disk/by-label/RPI-RP2
IMAGEPATH=${TOPDIR}/build
REVISION=Rev1
IMAGEPATH=lib/micropython/ports/rp2/build-TONBERRY_RPI_PICO_W/firmware.uf2
flash_via_mountpoint()
{
while [ ! -e "$DEVICEPATH" ] ; do sleep 1; echo 'Waiting for RP2...'; done
udisksctl mount -b "$DEVICEPATH"
cp "$IMAGEFILE" "$(findmnt "$DEVICEPATH" -n -o TARGET)"
cp "$IMAGEPATH" "$(findmnt "$DEVICEPATH" -n -o TARGET)"
}
PID="2e8a"
@@ -43,7 +40,7 @@ flash_via_picotool()
local device="${bus_device[1]//[!0-9]/}"
echo "Found RP2 with serial $serial on Bus $bus Device $device"
picotool load --update --bus "$bus" --address "$device" "$IMAGEFILE"
picotool load --bus "$bus" --address "$device" "$IMAGEPATH"
}
FLASH_VIA_MOUNTPOINT=0
@@ -55,12 +52,11 @@ usage()
echo
echo " -m, --via-mountpoint Mount first found RP2 and flash image by"
echo " copying to mountpoint."
echo " -r, --revision <rev> Hardware revision to flash. Default is Rev1"
echo " -h, --help Print this text and exit."
exit 2
}
PARSED_ARGUMENTS=$(getopt -a -n "$0" -o mhr: --long via-mountpoint,revision:,help -- "$@")
PARSED_ARGUMENTS=$(getopt -a -n "$0" -o mh --long via-mountpoint,help -- "$@")
# shellcheck disable=SC2181
# Indirect getopt return value checking is okay here
if [ "$?" != "0" ]; then
@@ -72,7 +68,6 @@ while :
do
case "$1" in
-m | --via-mountpoint) FLASH_VIA_MOUNTPOINT=1 ; shift ;;
-r | --revision) REVISION=$2 ; shift 2 ;;
-h | --help) usage ;;
--) shift; break ;;
*) echo "Unexpected option: $1"
@@ -85,8 +80,6 @@ if [ $# -gt 0 ]; then
usage
fi
IMAGEFILE="$IMAGEPATH"/firmware-$REVISION.uf2
if [ "$FLASH_VIA_MOUNTPOINT" -eq 0 ]; then
flash_via_picotool
else

File diff suppressed because it is too large Load Diff

View File

@@ -40,7 +40,7 @@ void __time_critical_func(core1_main)(void)
{
uint32_t ret = 0;
bool running = true, playing = false;
if (!i2s_init(shared_context.out_pin, shared_context.sideset_base, shared_context.sideset_dclk_first)) {
if (!i2s_init(shared_context.out_pin, shared_context.sideset_base)) {
ret = MP_EIO;
goto out;
}

View File

@@ -31,7 +31,6 @@ struct audiocore_shared_context {
// Set by module.c before core1 is launched and then never changed, can be read without lock
int out_pin, sideset_base, samplerate;
bool sideset_dclk_first;
// Must hold lock. The indices 0..MP3_BUFFER_PREAREA-1 may only be read and written on core1 (no
// lock needed) The buffer is aligned to, and MP3_BUFFER_PREAREA is a multiple of, the machine

View File

@@ -1,17 +1,13 @@
# SPDX-License-Identifier: MIT
# Copyright (c) 2025 Matthias Blankertz <matthias@blankertz.org>
import _audiocore
from asyncio import ThreadSafeFlag
from utils import get_pin_index
class Audiocore:
def __init__(self, din, dclk, lrclk):
# PIO requires sideset pins to be adjacent
assert get_pin_index(lrclk) == get_pin_index(dclk)+1 or get_pin_index(lrclk) == get_pin_index(dclk)-1
def __init__(self, pin, sideset):
self.notify = ThreadSafeFlag()
self._audiocore = _audiocore.Audiocore(din, dclk, lrclk, self._interrupt)
self.pin = pin
self.sideset = sideset
self._audiocore = _audiocore.Audiocore(self.pin, self.sideset, self._interrupt)
def deinit(self):
self._audiocore.deinit()
@@ -44,13 +40,12 @@ class Audiocore:
class AudioContext:
def __init__(self, din, dclk, lrclk):
self.din = din
self.dclk = dclk
self.lrclk = lrclk
def __init__(self, pin, sideset):
self.pin = pin
self.sideset = sideset
def __enter__(self):
self._audiocore = Audiocore(self.din, self.dclk, self.lrclk)
self._audiocore = Audiocore(self.pin, self.sideset)
return self._audiocore
def __exit__(self, exc_type, exc_value, traceback):

View File

@@ -91,21 +91,19 @@ void i2s_stop(void)
{
if (!i2s_context.playback_active)
return;
while (true) {
bool have_data = false;
do {
const long flags = save_and_disable_interrupts();
const int next_buf = (i2s_context.cur_playing + 1) % AUDIO_BUFS;
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();
have_data = i2s_context.has_data[next_buf];
restore_interrupts(flags);
__nop(); // Ensure at least two instructions between enable interrupts and subsequent disable
__nop();
}
if (have_data)
__wfi();
} while (have_data);
const long flags = save_and_disable_interrupts();
i2s_context.playback_active = false;
shared_context.underruns = 0;
restore_interrupts(flags);
// Workaround rp2040 E13
dma_channel_set_irq1_enabled(i2s_context.dma_ch, false);
dma_channel_abort(i2s_context.dma_ch);
@@ -115,18 +113,17 @@ void i2s_stop(void)
pio_sm_clear_fifos(audiocore_pio, i2s_context.pio_sm);
}
bool i2s_init(int out_pin, int sideset_base, bool dclk_first)
bool i2s_init(int out_pin, int sideset_base)
{
memset(i2s_context.dma_buf, 0, sizeof(i2s_context.dma_buf[0][0]) * AUDIO_BUFS * I2S_DMA_BUF_SIZE);
const pio_program_t *program = dclk_first ? &i2s_max98357_program : &i2s_max98357_lrclk_program;
if (!pio_can_add_program(audiocore_pio, program))
if (!pio_can_add_program(audiocore_pio, (const pio_program_t *)&i2s_max98357_program))
return false;
i2s_context.pio_sm = pio_claim_unused_sm(audiocore_pio, false);
i2s_context.out_pin = out_pin;
i2s_context.sideset_base = sideset_base;
if (i2s_context.pio_sm == -1)
return false;
i2s_context.pio_program_offset = pio_add_program(audiocore_pio, program);
i2s_context.pio_program_offset = pio_add_program(audiocore_pio, (const pio_program_t *)&i2s_max98357_program);
i2s_context.dma_ch = dma_claim_unused_channel(false);
if (i2s_context.dma_ch == -1)

View File

@@ -8,7 +8,7 @@
#define I2S_DMA_BUF_SIZE (1152)
bool i2s_init(int out_pin, int sideset_base, bool dclk_first);
bool i2s_init(int out_pin, int sideset_base);
void i2s_deinit(void);
void i2s_play(int samplerate);

View File

@@ -10,7 +10,7 @@
.lang_opt python autopull = True
// data - DOUT
// sideset - 2-LRCLK, 1-BCLK
// sideset - 2-BCLK, 1-LRCLK
set x,15 side 0
nop side 1
@@ -33,38 +33,6 @@ right_loop:
set x, 14 side 1
.wrap
.program i2s_max98357_lrclk
.side_set 2
.lang_opt python sideset_init = pico.PIO.OUT_LOW
.lang_opt python out_init = pico.PIO.OUT_LOW
.lang_opt python out_shiftdir = pcio.PIO.SHIFT_LEFT
.lang_opt python autopull = True
// data - DOUT
// sideset - 2-BCLK, 1-LRCLK
set x,15 side 0
nop side 2
startup_loop:
nop side 1
jmp x-- startup_loop side 3
nop side 0
set x, 14 side 2
left_loop:
.wrap_target
out pins, 1 side 0
jmp x-- left_loop side 2
out pins, 1 side 1
set x, 14 side 3
right_loop:
out pins, 1 side 1
jmp x-- right_loop side 3
out pins, 1 side 0
set x, 14 side 2
.wrap
% c-sdk {
#include "hardware/clocks.h"

View File

@@ -170,11 +170,10 @@ static MP_DEFINE_CONST_FUN_OBJ_2(audiocore_set_volume_obj, audiocore_set_volume)
*/
static void audiocore_init(struct audiocore_obj *obj, size_t n_args, const mp_obj_t *pos_args, mp_map_t *kw_args)
{
enum { ARG_pin, ARG_dclk, ARG_lrclk, ARG_handler };
enum { ARG_pin, ARG_sideset, ARG_handler };
static const mp_arg_t allowed_args[] = {
{MP_QSTR_pin, MP_ARG_REQUIRED | MP_ARG_OBJ, {.u_obj = MP_OBJ_NULL}},
{MP_QSTR_dclk, MP_ARG_REQUIRED | MP_ARG_OBJ, {.u_obj = MP_OBJ_NULL}},
{MP_QSTR_lrclk, MP_ARG_REQUIRED | MP_ARG_OBJ, {.u_obj = MP_OBJ_NULL}},
{MP_QSTR_sideset, MP_ARG_REQUIRED | MP_ARG_OBJ, {.u_obj = MP_OBJ_NULL}},
{MP_QSTR_handler, MP_ARG_OBJ, {.u_obj = MP_OBJ_NULL}},
};
if (initialized)
@@ -189,8 +188,7 @@ static void audiocore_init(struct audiocore_obj *obj, size_t n_args, const mp_ob
mp_arg_val_t args[MP_ARRAY_SIZE(allowed_args)];
mp_arg_parse_all(n_args, pos_args, kw_args, MP_ARRAY_SIZE(allowed_args), allowed_args, args);
const mp_hal_pin_obj_t pin = mp_hal_get_pin_obj(args[ARG_pin].u_obj);
const mp_hal_pin_obj_t dclk_pin = mp_hal_get_pin_obj(args[ARG_dclk].u_obj);
const mp_hal_pin_obj_t lrclk_pin = mp_hal_get_pin_obj(args[ARG_lrclk].u_obj);
const mp_hal_pin_obj_t sideset_pin = mp_hal_get_pin_obj(args[ARG_sideset].u_obj);
if (args[ARG_handler].u_obj != MP_OBJ_NULL) {
obj->irq_obj = mp_irq_new(&audiocore_irq_methods, MP_OBJ_FROM_PTR(obj));
obj->irq_obj->handler = args[ARG_handler].u_obj;
@@ -205,14 +203,7 @@ static void audiocore_init(struct audiocore_obj *obj, size_t n_args, const mp_ob
memset(shared_context.mp3_buffer, 0, MP3_BUFFER_PREAREA + MP3_BUFFER_SIZE);
multicore_reset_core1();
shared_context.out_pin = pin;
// PIO requires sideset pins to be adjacent, but we support both dclk first and lrclk first
if (lrclk_pin == dclk_pin + 1) {
shared_context.sideset_base = dclk_pin;
shared_context.sideset_dclk_first = true;
} else {
shared_context.sideset_base = lrclk_pin;
shared_context.sideset_dclk_first = false;
}
shared_context.sideset_base = sideset_pin;
initialized = true;
multicore_launch_core1(&core1_main);
uint32_t result = get_fifo_read_value_blocking(obj);

View File

@@ -15,7 +15,7 @@ static unsigned multicore_fifo_push_last;
static unsigned (*multicore_fifo_pop_blocking_cb)(void);
bool i2s_init(int out_pin, int sideset_base, bool dclk_first)
bool i2s_init(int out_pin, int sideset_base)
{
TEST_ASSERT_FALSE(i2s_initialized);
if (i2s_init_return)

View File

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

View File

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

View File

@@ -8,7 +8,6 @@
struct sd_context {
size_t blocks;
size_t blocksize;
bool initialized;
bool old_card;
bool sdhc_sdxc;
@@ -24,4 +23,3 @@ bool sd_readblock_complete(struct sd_context *context);
bool sd_readblock_is_complete(struct sd_context *context);
bool sd_writeblock(struct sd_context *context, size_t sector_num, uint8_t buffer[const static SD_SECTOR_SIZE]);
bool sd_writeblocks(struct sd_context *sd_context, const size_t sector_num, const size_t sectors, uint8_t *buffer);

View File

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

View File

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

View File

@@ -52,12 +52,10 @@ 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;
sm_config_set_clkdiv(&c, div);
// for some reason, small clkdiv values (even integer ones) cause issues
sm_config_set_clkdiv(&c, div < 2.5f ? 2.5f : div);
pio_sm_init(pio, sm, offset, &c);
}
%}

View File

@@ -1 +0,0 @@
freezefs

View File

@@ -6,205 +6,85 @@ import time
from utils import TimerManager
Dependencies = namedtuple('Dependencies', ('mp3player', 'nfcreader', 'buttons', 'playlistdb', 'hwconfig', 'leds',
'config'))
Dependencies = namedtuple('Dependencies', ('mp3player', 'nfcreader', 'buttons', 'playlistdb'))
# Should be ~ 3dB steps
VOLUME_CURVE = [1, 2, 3, 4, 6, 8, 11, 16, 23, 32, 45, 64, 91, 128, 181, 255]
# Should be ~ 6dB steps
VOLUME_CURVE = [1, 2, 4, 8, 16, 32, 63, 126, 251]
class PlayerApp:
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.timeout = timeout
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() + self.timeout, 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.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.nfc = deps.nfcreader(self)
self.playlist_db = deps.playlistdb(self)
self.hwconfig = deps.hwconfig(self)
self.leds = deps.leds(self)
self.tag_mode = self.config.get_tagmode()
self.volume_max = self.config.get_volume_max()
self.volume_pos = 3 # fallback if config.get_volume_boot is nonsense
try:
for idx, val in enumerate(VOLUME_CURVE):
if val >= self.config.get_volume_boot():
self.volume_pos = idx
break
except (TypeError, ValueError):
pass
self.playing_tag = None
self.playlist = None
self.buttons = deps.buttons(self) if deps.buttons is not None else None
self.mp3file = None
self.paused = False
self.playing = False
self.volume_pos = 3
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):
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)
self._set_playlist(uid_str)
self.playing_tag = new_tag if self.playlist is not None else None
elif self.tag_mode == 'tagstartstop':
print('Tag presented again, stopping playback')
self._unset_playlist()
self.playing_tag = None
else:
self.timer_manager.schedule(time.ticks_ms() + 5000, self.onTagRemoveDelay)
def onTagRemoved(self):
"""
Callback (typically called by TagStateMachine) to signal that a tag has been removed.
"""
if self.tag_mode == 'tagremains':
def onTagRemoveDelay(self):
if self.current_tag is not None:
print('Tag gone, stopping playback')
self._unset_playlist()
self.current_tag = None
self.player.stop()
def onButtonPressed(self, what):
assert self.buttons is not None
if what == self.buttons.VOLUP:
new_volume = min(self.volume_pos + 1, len(VOLUME_CURVE) - 1)
if VOLUME_CURVE[new_volume] <= self.volume_max:
self.volume_pos = new_volume
self.player.set_volume(VOLUME_CURVE[self.volume_pos])
self.volume_pos = min(self.volume_pos + 1, len(VOLUME_CURVE) - 1)
self.player.set_volume(VOLUME_CURVE[self.volume_pos])
elif what == self.buttons.VOLDOWN:
self.volume_pos = max(self.volume_pos - 1, 0)
self.player.set_volume(VOLUME_CURVE[self.volume_pos])
elif what == self.buttons.NEXT:
self._play_next()
elif what == self.buttons.PREV:
self._play_prev()
elif what == self.buttons.PLAY_PAUSE:
self._pause_toggle()
def onPlaybackDone(self):
assert self.mp3file is not None
self.mp3file.close()
self.mp3file = None
self._play_next()
def onIdleTimeout(self):
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 reset_idle_timeout(self):
if not self.playing:
self.timer_manager.schedule(time.ticks_ms() + self.idle_timeout_ms, self.onIdleTimeout)
def is_playing(self) -> bool:
return self.playing
def _set_playlist(self, tag: bytes):
if self.playlist is not None:
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
self._play(self.playlist.getCurrentPath() if self.playlist is not None else None)
def _play_next(self):
filename = self.playlist.getNextPath() if self.playlist is not None else None
self._play(filename)
if filename is None:
self.playlist = None
self.playing_tag = None
def _play_prev(self):
if self.playlist is None:
return
filename = self.playlist.getPrevPath()
filename = self.playlist.getNextPath()
self._play(filename)
if filename is None:
self.playlist = None
self.playing_tag = None
def _play(self, filename: bytes | None, offset=0):
def _play(self, filename: bytes | None):
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}')
try:
self.mp3file = open(filename, 'rb')
except OSError as ex:
print(f"Could not play file {filename}: {ex}")
return
self.player.play(self.mp3file, offset)
self.paused = False
self._onActive()
def _pause_toggle(self):
if self.playlist is None:
return
if self.paused:
self._play(self.playlist.getCurrentPath(), self.pause_offset)
else:
self.pause_offset = self.player.stop()
self.paused = True
self._onIdle()
def _onIdle(self):
self.timer_manager.schedule(time.ticks_ms() + self.idle_timeout_ms, self.onIdleTimeout)
self.leds.set_state(self.leds.IDLE)
self.playing = False
def _onActive(self):
self.timer_manager.cancel(self.onIdleTimeout)
self.leds.set_state(self.leds.PLAYING)
self.playing = True
def get_nfc(self):
return self.nfc
def get_playlist_db(self):
return self.playlist_db
def get_leds(self):
return self.leds
self.mp3file = open(filename, 'rb')
self.player.play(self.mp3file)

View File

@@ -1,75 +0,0 @@
# SPDX-License-Identifier: MIT
# Copyright (c) 2025 Matthias Blankertz <matthias@blankertz.org>
import machine
from machine import Pin
# SD Card SPI
SD_DI = Pin.board.GP3
SD_DO = Pin.board.GP4
SD_SCK = Pin.board.GP2
SD_CS = Pin.board.GP5
SD_CLOCKRATE = 25000000
# MAX98357
I2S_LRCLK = Pin.board.GP6
I2S_DCLK = Pin.board.GP7
I2S_DIN = Pin.board.GP8
I2S_SD = Pin.board.GP9
# RC522
RC522_SPIID = 1
RC522_RST = Pin.board.GP14
RC522_IRQ = Pin.board.GP15
RC522_MOSI = Pin.board.GP11
RC522_MISO = Pin.board.GP12
RC522_SCK = Pin.board.GP10
RC522_SS = Pin.board.GP13
# WS2812
LED_DIN = Pin.board.GP16
# Buttons
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():
# POWER_EN turned on in MICROPY_BOARD_STARTUP
# TODO: Implement soft power off
# SD_DO / MISO input doesn't need any special configuration
# Set 8 mA drive strength for SCK and MOSI
machine.mem32[0x4001c004 + 2*4] = 0x60 # SCK
machine.mem32[0x4001c004 + 3*4] = 0x60 # MOSI
# SD_CS doesn't need any special configuration
# Permanently enable amplifier
# TODO: Implement amplifier power management
I2S_SD.init(mode=Pin.OPEN_DRAIN)
I2S_SD.value(1)
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

@@ -1,57 +0,0 @@
# SPDX-License-Identifier: MIT
# Copyright (c) 2025 Matthias Blankertz <matthias@blankertz.org>
from machine import Pin
# SD Card SPI
SD_DI = Pin.board.GP3
SD_DO = Pin.board.GP4
SD_SCK = Pin.board.GP2
SD_CS = Pin.board.GP5
SD_CLOCKRATE = 15000000
# MAX98357
I2S_LRCLK = Pin.board.GP7
I2S_DCLK = Pin.board.GP6
I2S_DIN = Pin.board.GP8
I2S_SD = None
# RC522
RC522_SPIID = 1
RC522_RST = Pin.board.GP9
RC522_IRQ = Pin.board.GP14
RC522_MOSI = Pin.board.GP11
RC522_MISO = Pin.board.GP12
RC522_SCK = Pin.board.GP10
RC522_SS = Pin.board.GP13
# WS2812
LED_DIN = Pin.board.GP16
# Buttons
BUTTONS = [Pin.board.GP17,
Pin.board.GP18,
Pin.board.GP19,
]
# Power
POWER_EN = None
VBAT_ADC = Pin.board.GP26
def board_init():
pass
def get_battery_voltage():
# Not supported on breadboard
return None
def power_off():
# Not supported on breadboard
pass
def get_on_battery():
return False

58
software/src/led_test.py Normal file
View File

@@ -0,0 +1,58 @@
# 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

@@ -5,150 +5,99 @@ import aiorepl # type: ignore
import asyncio
import machine
import micropython
import network
import time
import ubinascii
import sys
from machine import Pin
from math import pi, sin, pow
# Own modules
import app
from audiocore import AudioContext
import frozen_frontend # noqa: F401
from mfrc522 import MFRC522
from mp3player import MP3Player
from nfc import Nfc
from rp2_neopixel import NeoPixel
from utils import BTreeFileManager, Buttons, SDContext, TimerManager, LedManager, Configuration
from webserver import start_webserver
try:
import hwconfig
except ImportError:
print("Fatal: No hwconfig.py found")
raise
from utils import BTreeFileManager, Buttons, SDContext, TimerManager
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.5
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.2
before = time.ticks_ms()
await np.async_write()
now = time.ticks_ms()
if before + 20 > now:
await asyncio.sleep_ms(20 - (now - before))
# Machine setup
# 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
# high prio for proc 1
machine.mem32[0x40030000 + 0x00] = 0x10
def setup_wifi(ssid='', passphrase='', security=network.WLAN.SEC_WPA_WPA2):
network.hostname("TonberryPico")
if ssid is None or ssid == '':
apname = f"TonberryPicoAP_{machine.unique_id().hex()}"
print(f"Create AP {apname}")
wlan = network.WLAN(network.WLAN.IF_AP)
wlan.config(ssid=apname, password=passphrase if passphrase is not None else '', security=security)
wlan.active(True)
else:
print(f"Connect to SSID {ssid} with passphrase {passphrase}...")
wlan = network.WLAN()
wlan.active(True)
wlan.connect(ssid, passphrase if passphrase is not None else '', security=security)
# configure power management
wlan.config(pm=network.WLAN.PM_PERFORMANCE)
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(100)
wdt.feed()
DB_PATH = '/sd/tonberry.db'
config = Configuration()
# Setup LEDs
np = NeoPixel(hwconfig.LED_DIN, config.get_led_count(), sm=1)
led_max = config.get_led_max()
np.fill((led_max, led_max, 0))
np.write()
def run():
asyncio.new_event_loop()
if machine.Pin(hwconfig.BUTTONS[1], machine.Pin.IN, machine.Pin.PULL_UP).value() == 0:
np.fill((0, 0, led_max))
np.write()
# Force default access point
setup_wifi('', '', network.WLAN.SEC_OPEN)
else:
secstring = config.get_wifi_security()
security = network.WLAN.SEC_WPA_WPA2
if secstring == 'open':
security = network.WLAN.SEC_OPEN
elif secstring == 'wpa_wpa2':
security = network.WLAN.SEC_WPA_WPA2
elif secstring == 'wpa3':
security = network.WLAN.SEC_WPA3
elif secstring == 'wpa2_wpa3':
security = network.WLAN.SEC_WPA2_WPA3
setup_wifi(config.get_wifi_ssid(), config.get_wifi_passphrase(), security)
# Setup LEDs
pin = Pin.board.GP16
np = NeoPixel(pin, 10, sm=1)
asyncio.create_task(rainbow(np))
# 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):
with BTreeFileManager(DB_PATH) as playlistdb, \
AudioContext(hwconfig.I2S_DIN, hwconfig.I2S_DCLK, hwconfig.I2S_LRCLK) as audioctx:
with SDContext(mosi=Pin(3), miso=Pin(4), sck=Pin(2), ss=Pin(5), baudrate=15000000), \
BTreeFileManager('/sd/tonberry.db') as playlistdb, \
AudioContext(Pin(8), Pin(6)) as audioctx:
# Setup NFC
reader = MFRC522(spi_id=hwconfig.RC522_SPIID, sck=hwconfig.RC522_SCK, miso=hwconfig.RC522_MISO,
mosi=hwconfig.RC522_MOSI, cs=hwconfig.RC522_SS, rst=hwconfig.RC522_RST, tocard_retries=20)
# Setup NFC
reader = MFRC522(spi_id=1, sck=10, miso=12, mosi=11, cs=13, rst=9, tocard_retries=20)
# 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, config, hwconfig),
playlistdb=lambda _: playlistdb,
hwconfig=lambda _: hwconfig,
leds=lambda _: LedManager(np, config),
config=lambda _: config)
the_app = app.PlayerApp(deps)
# 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),
playlistdb=lambda _: playlistdb)
the_app = app.PlayerApp(deps)
start_webserver(config, the_app)
# Start
wdt = machine.WDT(timeout=2000)
asyncio.create_task(aiorepl.task({'timer_manager': TimerManager(),
'app': the_app}))
asyncio.create_task(wdt_task(wdt))
asyncio.get_event_loop().run_forever()
# Start
asyncio.create_task(aiorepl.task({'timer_manager': TimerManager(),
'app': the_app}))
asyncio.get_event_loop().run_forever()
def error_blink():
while True:
if machine.Pin(hwconfig.BUTTONS[0], machine.Pin.IN, machine.Pin.PULL_UP).value() == 0:
machine.reset()
np.fill((led_max, 0, 0))
np.write()
time.sleep_ms(500)
np.fill((0, 0, 0))
np.write()
time.sleep_ms(500)
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.
"""
import os
os.unlink('/sd/tonberry.db')
with BTreeFileManager('/sd/tonberry.db') 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__':
time.sleep(1)
if machine.Pin(hwconfig.BUTTONS[0], machine.Pin.IN, machine.Pin.PULL_UP).value() != 0:
try:
run()
except Exception as ex:
sys.print_exception(ex)
error_blink()
else:
np.fill((led_max, 0, 0))
np.write()
if machine.Pin(17, machine.Pin.IN, machine.Pin.PULL_UP).value() != 0:
time.sleep(5)
run()

View File

@@ -0,0 +1,73 @@
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

@@ -0,0 +1,38 @@
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,19 +21,14 @@ class MP3Player:
self.mp3task = None
self.volume = 128
self.cb = cb
self.pos = 0
def play(self, stream, offset=0):
def play(self, stream):
"""
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):
@@ -43,8 +38,6 @@ 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):
"""
@@ -67,7 +60,6 @@ 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,7 +6,6 @@ Copyright (c) 2025 Matthias Blankertz <matthias@blankertz.org>
import asyncio
import time
from utils import safe_callback
from mfrc522 import MFRC522
try:
@@ -75,7 +74,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:
safe_callback(lambda: self.cb.onTagChange(uid), "nfc tag change")
self.cb.onTagChange(uid)
last_callback_uid = uid
await asyncio.sleep_ms(poll_interval_ms)

113
software/src/test.py Normal file
View File

@@ -0,0 +1,113 @@
# 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

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

View File

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

View File

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

View File

@@ -1,12 +0,0 @@
# 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

@@ -1,61 +0,0 @@
# 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)
REBOOTING = const(2)
def __init__(self, np, config):
self.led_state = LedManager.IDLE
self.brightness = config.get_led_max() / 255
self.np = np
self.leds = len(self.np)
asyncio.create_task(self.run())
def set_state(self, state):
assert state in [LedManager.IDLE, LedManager.PLAYING, LedManager.REBOOTING]
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_)
elif self.led_state == LedManager.REBOOTING:
self._pulse(time_, (1, 0, 1), 0.2)
time_ += 0.02
before = time.ticks_ms()
await self.np.async_write()
now = time.ticks_ms()
if before + 20 > now:
await asyncio.sleep_ms(20 - (now - before))

View File

@@ -1,43 +0,0 @@
# SPDX-License-Identifier: MIT
# Copyright (c) 2025 Matthias Blankertz <matthias@blankertz.org>
from machine import Pin
pins = [Pin.board.GP0,
Pin.board.GP1,
Pin.board.GP2,
Pin.board.GP3,
Pin.board.GP4,
Pin.board.GP5,
Pin.board.GP6,
Pin.board.GP7,
Pin.board.GP8,
Pin.board.GP9,
Pin.board.GP10,
Pin.board.GP11,
Pin.board.GP12,
Pin.board.GP13,
Pin.board.GP14,
Pin.board.GP15,
Pin.board.GP16,
Pin.board.GP17,
Pin.board.GP18,
Pin.board.GP19,
Pin.board.GP20,
Pin.board.GP21,
Pin.board.GP22,
None, # 23
None, # 24
None, # 25
Pin.board.GP26,
Pin.board.GP27,
Pin.board.GP28,
]
def get_pin_index(pin: Pin) -> int:
"""
Get the pin index back from a pin object.
Unfortunately, micropython has no built-in function for this.
"""
return pins.index(pin)

View File

@@ -2,8 +2,6 @@
# 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
@@ -26,54 +24,11 @@ else:
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, name):
def __init__(self, parent, tag, pos):
self.parent = parent
self.tag = tag
self.pos = pos
self.persist = persist
self.shuffle = shuffle
self.name = name
self.length = self.parent._getPlaylistLength(self.tag)
self._shuffle()
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):
"""
@@ -85,53 +40,21 @@ class BTreeDB(IPlaylistDB):
"""
Get path of file that should be played.
"""
return self.parent._getPlaylistEntry(self.tag, self._getPlaylistPos())
return self.parent._getPlaylistEntry(self.tag, self.pos)
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)
try:
self.pos = self.parent._getNextTrack(self.tag, self.pos)
except StopIteration:
self.pos = self.parent._getFirstTrack(self.tag)
return None
self.pos += 1
if self.persist != BTreeDB.PERSIST_NO:
finally:
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
@@ -140,25 +63,9 @@ 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()])
return b''.join([tag, b'/playlist/', "{:05}".format(pos).encode()])
@staticmethod
def _keyPlaylistStart(tag):
@@ -169,10 +76,6 @@ class BTreeDB(IPlaylistDB):
return (b''.join([tag, b'/playlist/']),
b''.join([tag, b'/playlist0']))
@staticmethod
def _keyPlaylistName(tag):
return b''.join([tag, b'/playlistname'])
def _flush(self):
"""
Flush the database and call the flush_func if it was provided.
@@ -181,59 +84,22 @@ class BTreeDB(IPlaylistDB):
if self.flush_func is not None:
self.flush_func()
def _getPlaylistValueIterator(self, tag: bytes):
def _getPlaylistValueIterator(self, tag):
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 _getPlaylistEntry(self, _, pos):
return self.db[pos]
def _setPlaylistPos(self, tag: bytes, pos: int, flush=True):
self.db[self._keyPlaylistPos(tag)] = str(pos).encode()
def _setPlaylistPos(self, tag, pos, flush=True):
self.db[self._keyPlaylistPos(tag)] = pos.removeprefix(self._keyPlaylistStart(tag))
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, name, flush=True):
def _savePlaylist(self, tag, entries, flush=True):
self._deletePlaylist(tag, False)
for idx, entry in enumerate(entries):
self.db[self._keyPlaylistEntry(tag, idx)] = entry
self.db[self._keyPlaylistPersist(tag)] = persist
self.db[self._keyPlaylistShuffle(tag)] = shuffle
self.db[self._keyPlaylistName(tag)] = name.encode()
if flush:
self._flush()
@@ -244,85 +110,57 @@ class BTreeDB(IPlaylistDB):
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), self._keyPlaylistName(tag)):
try:
del self.db[k]
except KeyError:
pass
try:
del self.db[self._keyPlaylistPos(tag)]
except KeyError:
pass
if flush:
self._flush()
def getPlaylists(self):
"""
Get a list of all defined playlists with their tag and names.
"""
playlist_tags = set()
for item in self.db:
playlist_tags.add(item.split(b'/')[0])
playlists = []
for tag in playlist_tags:
name = self.db.get(self._keyPlaylistName(tag), b'').decode()
playlists.append({'tag': tag, 'name': name})
return playlists
def _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)
return next(self.db.keys(pos, end_key))
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:
pos = self.db.get(self._keyPlaylistPos(tag))
if pos is None:
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
name = self.db.get(self._keyPlaylistName(tag), b'').decode()
shuffle = self.db.get(self._keyPlaylistShuffle(tag), self.SHUFFLE_NO)
return self.Playlist(self, tag, pos, persist, shuffle, name)
pos = self._getFirstTrack(tag)
except StopIteration:
# playist does not exist
return None
else:
pos = self._keyPlaylistStart(tag) + pos
return self.Playlist(self, tag, pos)
def createPlaylistForTag(self, tag: bytes, entries: typing.Iterable[bytes], persist=PERSIST_TRACK,
shuffle=SHUFFLE_NO, name: str = ''):
def createPlaylistForTag(self, tag: bytes, entries: typing.Iterable[bytes]):
"""
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, name)
self._savePlaylist(tag, entries)
return self.getPlaylistForTag(tag)
def deletePlaylistForTag(self, tag: bytes):
self._deletePlaylist(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
print(f'Malformed key {k!r}')
result = False
if last_tag != fields[0]:
last_tag = fields[0]
last_pos = None
@@ -330,18 +168,19 @@ class BTreeDB(IPlaylistDB):
print(f'Tag {fields[0]}')
if fields[1] == b'playlist':
if len(fields) != 3:
fail(f'Malformed playlist entry: {k!r}')
print(f'Malformed playlist entry: {k!r}')
result = False
continue
try:
idx = int(fields[2])
except ValueError:
fail(f'Malformed playlist entry: {k!r}')
print(f'Malformed playlist entry: {k!r}')
result = False
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}')
print(f'Bad playlist entry sequence for {last_tag} at {idx}')
result = False
last_pos = idx
if dump:
print(f'\tTrack {idx}: {self.db[k]!r}')
@@ -350,46 +189,17 @@ class BTreeDB(IPlaylistDB):
try:
idx = int(val)
except ValueError:
fail(f'Malformed playlist position: {val!r}')
print(f'Malformed playlist position: {val!r}')
result = False
continue
if 0 > idx or idx > last_pos:
fail(f'Playlist position out of range for {last_tag}: {idx}')
elif dump:
print(f'Playlist position out of range for {last_tag}: {idx}')
result = False
if 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}')
elif fields[1] == b'playlistname':
val = self.db[k]
try:
name = val.decode()
if dump:
print(f'\tName: {name}')
except UnicodeError:
fail(f' Bad playlistname for {last_tag}: Not valid unicode')
else:
fail(f'Unknown key {k!r}')
print(f'Unknown key {k!r}')
result = False
return result

View File

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

View File

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

View File

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

View File

@@ -1,40 +1,2 @@
# SPDX-License-Identifier: MIT
# Copyright (c) 2025 Matthias Blankertz <matthias@blankertz.org>
class Pin:
def __init__(self, idx):
self.idx = idx
board = None
class Board:
GP0 = Pin(0)
GP1 = Pin(1)
GP2 = Pin(2)
GP3 = Pin(3)
GP4 = Pin(4)
GP5 = Pin(5)
GP6 = Pin(6)
GP7 = Pin(7)
GP8 = Pin(8)
GP9 = Pin(9)
GP10 = Pin(10)
GP11 = Pin(11)
GP12 = Pin(12)
GP13 = Pin(13)
GP14 = Pin(14)
GP15 = Pin(15)
GP16 = Pin(16)
GP17 = Pin(17)
GP18 = Pin(18)
GP19 = Pin(19)
GP20 = Pin(20)
GP21 = Pin(21)
GP22 = Pin(22)
GP26 = Pin(26)
GP27 = Pin(27)
GP28 = Pin(28)
Pin.board = Board

View File

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

View File

@@ -35,36 +35,17 @@ class FakeMp3Player:
def set_volume(self, vol: int):
self.volume = vol
def play(self, track: FakeFile, offset: int):
def play(self, track: FakeFile):
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()
def __init__(self): pass
def cancel(self, timer): pass
class FakeNfcReader:
tag_callback = None
def __init__(self, tag_callback=None):
FakeNfcReader.tag_callback = tag_callback
def __init__(self): pass
class FakeButtons:
@@ -86,65 +67,12 @@ 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
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 get_volume_max(self):
return 255
def get_volume_boot(self):
return 16
def fake_open(filename, mode):
return FakeFile(filename, mode)
@@ -152,38 +80,30 @@ def fake_open(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)
monkeypatch.setattr(utils.timer.TimerManager, '_instance', FakeTimerManager())
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 and fake_mp3.volume >= 16
deps = app.Dependencies(mp3player=lambda _: fake_mp3,
nfcreader=lambda _: FakeNfcReader(),
buttons=lambda _: FakeButtons(),
playlistdb=lambda _: FakePlaylistDb())
_ = app.PlayerApp(deps)
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)
deps = app.Dependencies(mp3player=lambda _: fake_mp3,
nfcreader=lambda _: FakeNfcReader(),
buttons=lambda _: FakeButtons(),
playlistdb=lambda _: 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])
dut.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
@@ -193,11 +113,14 @@ def test_load_playlist_on_tag(micropythonify, faketimermanager, monkeypatch):
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)
deps = app.Dependencies(mp3player=lambda _: fake_mp3,
nfcreader=lambda _: FakeNfcReader(),
buttons=lambda _: FakeButtons(),
playlistdb=lambda _: 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])
dut.onTagChange([23, 42, 1, 2, 3])
assert fake_mp3.track is not None
assert fake_mp3.track.filename == b'track1.mp3'
@@ -221,114 +144,14 @@ 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 = _makedeps(mp3player=fake_mp3, playlistdb=fake_db)
app.PlayerApp(deps)
deps = app.Dependencies(mp3player=lambda _: fake_mp3,
nfcreader=lambda _: FakeNfcReader(),
buttons=lambda _: FakeButtons(),
playlistdb=lambda _: 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])
dut.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

@@ -1,21 +1,9 @@
# 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
@@ -27,26 +15,20 @@ class FakeDB:
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:
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:
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
keys.append(key)
if flags is not None and flags & btree.DESC != 0:
keys.reverse()
return iter(keys)
yield key
def get(self, key, default=None):
return self.contents.get(key, default)
@@ -60,14 +42,11 @@ 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/00000': b'track1',
b'foo/playlist/00001': b'track2',
b'foo/playlist/0': b'track1',
b'foo/playlist/1': b'track2',
b'foo/playlisttt': b'no'
}
uut = BTreeDB(FakeDB(contents))
@@ -78,8 +57,8 @@ def test_playlist_load():
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/playlist/0': b'track1',
b'foo/playlist/1': b'track2',
b'foo/playlisttt': b'no'
})
uut = BTreeDB(contents)
@@ -89,8 +68,8 @@ def test_playlist_nextpath():
def test_playlist_nextpath_last():
contents = FakeDB({b'foo/playlist/00000': b'track1',
b'foo/playlist/00001': b'track2',
contents = FakeDB({b'foo/playlist/0': b'track1',
b'foo/playlist/1': b'track2',
b'foo/playlistpos': b'1'
})
uut = BTreeDB(contents)
@@ -100,8 +79,8 @@ def test_playlist_nextpath_last():
def test_playlist_create():
contents = FakeDB({b'foo/playlist/00000': b'track1',
b'foo/playlist/00001': b'track2',
contents = FakeDB({b'foo/playlist/0': b'track1',
b'foo/playlist/1': b'track2',
b'foo/playlistpos': b'1'
})
newplaylist = [b'never gonna give you up.mp3', b'durch den monsun.mp3']
@@ -113,85 +92,9 @@ def test_playlist_create():
def test_playlist_load_notexist():
contents = FakeDB({b'foo/playlist/00000': b'track1',
b'foo/playlist/00001': b'track2',
contents = FakeDB({b'foo/playlist/0': b'track1',
b'foo/playlist/1': 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,13 +10,10 @@ 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)
set(PICO_USE_FASTEST_SUPPORTED_CLOCK 1)
# initialize the Raspberry Pi Pico SDK
pico_sdk_init()
@@ -40,10 +37,6 @@ 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()
@@ -64,7 +57,7 @@ add_subdirectory(../../lib/helix_mp3 helix_mp3)
target_link_libraries(standalone_mp3 PRIVATE pico_stdlib hardware_dma hardware_spi hardware_sync hardware_pio helix_mp3)
target_include_directories(standalone_mp3 PRIVATE ${SD_LIB_DIR})
target_compile_options(standalone_mp3 PRIVATE -Og)
target_compile_options(standalone_mp3 PRIVATE -Og -DSD_DEBUG)
pico_add_extra_outputs(standalone_mp3)

View File

@@ -2,7 +2,6 @@
#include "sd.h"
#include <math.h>
#include <stdarg.h>
#include <stdio.h>
#include <string.h>
@@ -21,14 +20,6 @@ extern void sd_spi_dbg_clk(const int div, const int frac);
extern void sd_spi_dbg_loop(void);
void sd_printf(const char *fmt, ...)
{
va_list ap;
va_start(ap, fmt);
vprintf(fmt, ap);
va_end(ap);
}
#define MAX_VOLUME 0x8000u
void __time_critical_func(volume_adjust)(int16_t *restrict buf, size_t samples, uint16_t scalef)
@@ -166,7 +157,7 @@ static void write_test(struct sd_context *sd_context)
data_buffer[i] ^= 0xff;
}
if (!sd_writeblocks(sd_context, 0, sizeof(data_buffer) / SD_SECTOR_SIZE, data_buffer)) {
if(!sd_writeblock(sd_context, 0, data_buffer)) {
printf("sd_writeblock failed\n");
return;
}
@@ -174,20 +165,6 @@ 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();
@@ -195,23 +172,10 @@ int main()
struct sd_context sd_context;
#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");
if (!sd_init(&sd_context, 3, 4, 2, 5, 15000000)) {
return 1;
}
#ifdef READ_TEST
read_test(&sd_context);
#endif
#ifdef WRITE_TEST
write_test(&sd_context);
#endif