8 Commits

Author SHA1 Message Date
869a92d998 feat: Implement shutdown on idle when on battery
All checks were successful
Build RPi Pico firmware image / Build-Firmware (push) Successful in 3m24s
Check code formatting / Check-C-Format (push) Successful in 7s
Check code formatting / Check-Python-Flake8 (push) Successful in 10s
Check code formatting / Check-Bash-Shellcheck (push) Successful in 5s
Run unit tests on host / Run-Unit-Tests (push) Successful in 8s
Run pytests / Check-Pytest (push) Successful in 11s
Allow the PlayerApp to turn off the device if it is idle for longer then
the timeout. The timeout is currently hardcoded to 1 minute, this will be
made configurable in the future.

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

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

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

Signed-off-by: Matthias Blankertz <matthias@blankertz.org>
2025-11-10 21:45:57 +01:00
0f4f72253c hw: Update metadata, move silkscreen board name + rev
All checks were successful
Build RPi Pico firmware image / Build-Firmware (push) Successful in 3m27s
Check code formatting / Check-C-Format (push) Successful in 7s
Check code formatting / Check-Python-Flake8 (push) Successful in 10s
Check code formatting / Check-Bash-Shellcheck (push) Successful in 5s
Run unit tests on host / Run-Unit-Tests (push) Successful in 8s
Run pytests / Check-Pytest (push) Successful in 11s
No netlist or routing changes.

Signed-off-by: Matthias Blankertz <matthias@blankertz.org>
2025-11-10 19:19:28 +01:00
08fdb75297 hw: Add GND point for MAX98357 gain config
All checks were successful
Build RPi Pico firmware image / Build-Firmware (push) Successful in 3m20s
Check code formatting / Check-C-Format (push) Successful in 7s
Check code formatting / Check-Python-Flake8 (push) Successful in 10s
Check code formatting / Check-Bash-Shellcheck (push) Successful in 5s
Run unit tests on host / Run-Unit-Tests (push) Successful in 9s
Run pytests / Check-Pytest (push) Successful in 11s
Add a through-hole pad connected to GND next to the MAX98357 gain config
resistor to allow the user to select from all possible gains.

Signed-off-by: Matthias Blankertz <matthias@blankertz.org>
2025-11-08 13:47:10 +01:00
d28f0b1c0c hw: Smaller 'production' version of board w/o keys and direct RC522 mount
All checks were successful
Build RPi Pico firmware image / Build-Firmware (push) Successful in 3m22s
Check code formatting / Check-C-Format (push) Successful in 7s
Check code formatting / Check-Python-Flake8 (push) Successful in 10s
Check code formatting / Check-Bash-Shellcheck (push) Successful in 6s
Run unit tests on host / Run-Unit-Tests (push) Successful in 8s
Run pytests / Check-Pytest (push) Successful in 11s
Signed-off-by: Matthias Blankertz <matthias@blankertz.org>
2025-11-07 22:52:34 +01:00
9147bab5bb hw: Add fifth button, make traces wider
Signed-off-by: Matthias Blankertz <matthias@blankertz.org>
2025-11-07 21:02:55 +01:00
13 changed files with 7093 additions and 6991 deletions

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -37,9 +37,9 @@
"other_text_thickness": 0.15,
"other_text_upright": false,
"pads": {
"drill": 3.2,
"height": 3.2,
"width": 3.2
"drill": 0.8,
"height": 1.6,
"width": 1.6
},
"silk_line_width": 0.1,
"silk_text_italic": false,
@@ -60,35 +60,11 @@
],
"drc_exclusions": [
[
"items_not_allowed|117348000|131000000|4c513f4f-0c90-4a64-b2d1-bf0189c761db|00000000-0000-0000-0000-000000000000",
"lib_footprint_mismatch|148587019|59126370|4f17c230-1bc1-45e3-8816-9cf9d13e3a5c|00000000-0000-0000-0000-000000000000",
""
],
[
"items_not_allowed|117348000|131000000|fea7aba8-73c4-4553-8cc8-83472c47a83b|00000000-0000-0000-0000-000000000000",
""
],
[
"items_not_allowed|122420000|136340000|8ffc6a0d-806e-4b99-922f-2baead2bd98b|00000000-0000-0000-0000-000000000000",
""
],
[
"items_not_allowed|148270000|136340000|804b9d74-91fa-4f75-bc54-8f55b801562f|00000000-0000-0000-0000-000000000000",
""
],
[
"items_not_allowed|148720706|135916802|f2c1e20e-a272-42d8-a551-3e9074b96921|00000000-0000-0000-0000-000000000000",
""
],
[
"nonmirrored_text_on_back_layer|210811000|131073000|e87ca627-5327-4f7e-ac1a-c1eadd662c0a|00000000-0000-0000-0000-000000000000",
""
],
[
"silk_edge_clearance|115549999|139536698|483993e4-aad9-46a1-bcd0-7f427e76aa64|cd433b4c-40ac-48cc-b03e-5acf3357d88b",
""
],
[
"silk_overlap|115550000|131000000|cd433b4c-40ac-48cc-b03e-5acf3357d88b|fea7aba8-73c4-4553-8cc8-83472c47a83b",
"nonmirrored_text_on_back_layer|213233000|81790000|e87ca627-5327-4f7e-ac1a-c1eadd662c0a|00000000-0000-0000-0000-000000000000",
""
]
],
@@ -157,22 +133,22 @@
},
"rules": {
"max_error": 0.005,
"min_clearance": 0.2032,
"min_clearance": 0.15,
"min_connection": 0.0,
"min_copper_edge_clearance": 0.2,
"min_groove_width": 0.0,
"min_hole_clearance": 0.35,
"min_hole_to_hole": 0.45,
"min_hole_to_hole": 0.2,
"min_microvia_diameter": 0.2,
"min_microvia_drill": 0.1,
"min_resolved_spokes": 2,
"min_silk_clearance": 0.0,
"min_text_height": 1.0,
"min_text_thickness": 0.15,
"min_through_hole_diameter": 0.3,
"min_track_width": 0.2032,
"min_through_hole_diameter": 0.2,
"min_track_width": 0.15,
"min_via_annular_width": 0.15,
"min_via_diameter": 0.5,
"min_via_diameter": 0.35,
"solder_mask_to_copper_clearance": 0.005,
"use_height_for_length_calcs": true
},

View File

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

View File

@@ -6,7 +6,7 @@ import time
from utils import TimerManager
Dependencies = namedtuple('Dependencies', ('mp3player', 'nfcreader', 'buttons', 'playlistdb'))
Dependencies = namedtuple('Dependencies', ('mp3player', 'nfcreader', 'buttons', 'playlistdb', 'hwconfig', 'leds'))
# Should be ~ 6dB steps
VOLUME_CURVE = [1, 2, 4, 8, 16, 32, 63, 126, 251]
@@ -44,6 +44,8 @@ class PlayerApp:
self.player = deps.mp3player(self)
self.nfc = deps.nfcreader(self.tag_state_machine)
self.playlist_db = deps.playlistdb(self)
self.hwconfig = deps.hwconfig(self)
self.leds = deps.leds(self)
self.tag_mode = self.playlist_db.getSetting('tagmode')
self.playing_tag = None
self.playlist = None
@@ -51,6 +53,7 @@ class PlayerApp:
self.mp3file = None
self.volume_pos = 3
self.player.set_volume(VOLUME_CURVE[self.volume_pos])
self._onIdle()
def __del__(self):
if self.mp3file is not None:
@@ -95,8 +98,18 @@ class PlayerApp:
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() + 60*1000, self.onIdleTimeout)
def _set_playlist(self, tag: bytes):
self._unset_playlist()
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)
@@ -104,6 +117,7 @@ class PlayerApp:
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
@@ -122,7 +136,17 @@ class PlayerApp:
self.player.stop()
self.mp3file.close()
self.mp3file = None
self._onIdle()
if filename is not None:
print(f'Playing {filename!r}')
self.mp3file = open(filename, 'rb')
self.player.play(self.mp3file, offset)
self._onActive()
def _onIdle(self):
self.timer_manager.schedule(time.ticks_ms() + 60*1000, self.onIdleTimeout)
self.leds.set_state(self.leds.IDLE)
def _onActive(self):
self.timer_manager.cancel(self.onIdleTimeout)
self.leds.set_state(self.leds.PLAYING)

View File

@@ -39,6 +39,7 @@ BUTTON_POWER = Pin.board.GP21
# Power
POWER_EN = Pin.board.GP22
VBAT_ADC = Pin.board.GP26
VBUS_DET = Pin.board.WL_GPIO2
def board_init():
@@ -61,3 +62,13 @@ def get_battery_voltage():
adc = machine.ADC(VBAT_ADC) # create ADC object on ADC pin
battv = adc.read_u16()/65535.0*3.3*2
return battv
def power_off():
POWER_EN.init(mode=Pin.OUT)
POWER_EN.value(0)
def get_on_battery():
vbus = VBUS_DET.value()
return not vbus

View File

@@ -47,3 +47,12 @@ def board_init():
def get_battery_voltage():
# Not supported on breadboard
return None
def power_off():
# Not supported on breadboard
pass
def get_on_battery():
return False

View File

@@ -9,7 +9,6 @@ import micropython
import network
import os
import time
from math import pi, sin, pow
# Own modules
import app
@@ -18,7 +17,7 @@ from mfrc522 import MFRC522
from mp3player import MP3Player
from nfc import Nfc
from rp2_neopixel import NeoPixel
from utils import BTreeFileManager, Buttons, SDContext, TimerManager
from utils import BTreeFileManager, Buttons, SDContext, TimerManager, LedManager
try:
import hwconfig
@@ -31,28 +30,6 @@ micropython.alloc_emergency_exception_buf(100)
# Machine setup
hwconfig.board_init()
async def rainbow(np, period=10):
def gamma(value, X=2.2):
return min(max(int(brightness * pow(value / 255.0, X) * 255.0 + 0.5), 0), 255)
brightness = 0.05
count = 0.0
leds = len(np)
while True:
for i in range(leds):
ofs = (count + i) % leds
np[i] = (gamma((sin(ofs / leds * 2 * pi) + 1) * 127),
gamma((sin(ofs / leds * 2 * pi + 2/3*pi) + 1) * 127),
gamma((sin(ofs / leds * 2 * pi + 4/3*pi) + 1) * 127))
count += 0.02 * leds
before = time.ticks_ms()
await np.async_write()
now = time.ticks_ms()
if before + 20 > now:
await asyncio.sleep_ms(20 - (now - before))
# high prio for proc 1
machine.mem32[0x40030000 + 0x00] = 0x10
@@ -71,7 +48,6 @@ def run():
asyncio.new_event_loop()
# Setup LEDs
np = NeoPixel(hwconfig.LED_DIN, hwconfig.LED_COUNT, sm=1)
asyncio.create_task(rainbow(np))
# Wifi with default config
setup_wifi()
@@ -101,7 +77,9 @@ def run():
buttons=lambda the_app: Buttons(the_app, pin_volup=hwconfig.BUTTON_VOLUP,
pin_voldown=hwconfig.BUTTON_VOLDOWN,
pin_next=hwconfig.BUTTON_NEXT),
playlistdb=lambda _: playlistdb)
playlistdb=lambda _: playlistdb,
hwconfig=lambda _: hwconfig,
leds=lambda _: LedManager(np))
the_app = app.PlayerApp(deps)
# Start

View File

@@ -2,10 +2,12 @@
# Copyright (c) 2025 Matthias Blankertz <matthias@blankertz.org>
from utils.buttons import Buttons
from utils.leds import LedManager
from utils.mbrpartition import MBRPartition
from utils.pinindex import get_pin_index
from utils.playlistdb import BTreeDB, BTreeFileManager
from utils.sdcontext import SDContext
from utils.timer import TimerManager
__all__ = ["BTreeDB", "BTreeFileManager", "Buttons", "get_pin_index", "MBRPartition", "SDContext", "TimerManager"]
__all__ = ["BTreeDB", "BTreeFileManager", "Buttons", "get_pin_index", "LedManager", "MBRPartition", "SDContext",
"TimerManager"]

View File

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

View File

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

View File

@@ -43,11 +43,21 @@ class FakeMp3Player:
class FakeTimerManager:
def __init__(self): pass
def cancel(self, timer): pass
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):
what()
self.queued.append((when, what, False))
def testing_run_queued(self):
queued = self.queued
self.queued = []
for when, what, canceled in queued:
if not canceled:
what()
class FakeNfcReader:
@@ -91,32 +101,62 @@ class FakePlaylistDb:
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
def fake_open(filename, mode):
return FakeFile(filename, mode)
@pytest.fixture
def faketimermanager(monkeypatch):
monkeypatch.setattr(utils.timer.TimerManager, '_instance', FakeTimerManager())
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):
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)
def test_construct_app(micropythonify, faketimermanager):
fake_mp3 = FakeMp3Player()
deps = app.Dependencies(mp3player=lambda _: fake_mp3,
nfcreader=lambda _: FakeNfcReader(),
buttons=lambda _: FakeButtons(),
playlistdb=lambda _: FakePlaylistDb())
_ = app.PlayerApp(deps)
deps = _makedeps(mp3player=fake_mp3)
dut = app.PlayerApp(deps)
fake_mp3 = dut.player
assert fake_mp3.volume is not None
def test_load_playlist_on_tag(micropythonify, faketimermanager, monkeypatch):
fake_db = FakePlaylistDb()
fake_mp3 = FakeMp3Player()
deps = app.Dependencies(mp3player=lambda _: fake_mp3,
nfcreader=lambda x: FakeNfcReader(x),
buttons=lambda _: FakeButtons(),
playlistdb=lambda _: fake_db)
deps = _makedeps(mp3player=fake_mp3, playlistdb=fake_db)
app.PlayerApp(deps)
with monkeypatch.context() as m:
m.setattr(builtins, 'open', fake_open)
@@ -130,10 +170,7 @@ 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 = app.Dependencies(mp3player=lambda _: fake_mp3,
nfcreader=lambda x: FakeNfcReader(x),
buttons=lambda _: FakeButtons(),
playlistdb=lambda _: fake_db)
deps = _makedeps(mp3player=fake_mp3, playlistdb=fake_db)
dut = app.PlayerApp(deps)
with monkeypatch.context() as m:
m.setattr(builtins, 'open', fake_open)
@@ -166,10 +203,7 @@ def test_playlist_unknown_tag(micropythonify, faketimermanager, monkeypatch):
fake_db = FakeNoPlaylistDb()
fake_mp3 = FakeMp3Player()
deps = app.Dependencies(mp3player=lambda _: fake_mp3,
nfcreader=lambda x: FakeNfcReader(x),
buttons=lambda _: FakeButtons(),
playlistdb=lambda _: fake_db)
deps = _makedeps(mp3player=fake_mp3, playlistdb=fake_db)
app.PlayerApp(deps)
with monkeypatch.context() as m:
m.setattr(builtins, 'open', fake_open)
@@ -189,10 +223,7 @@ def test_tagmode_startstop(micropythonify, faketimermanager, monkeypatch):
fake_db = MyFakePlaylistDb()
fake_mp3 = FakeMp3Player()
deps = app.Dependencies(mp3player=lambda _: fake_mp3,
nfcreader=lambda x: FakeNfcReader(x),
buttons=lambda _: FakeButtons(),
playlistdb=lambda _: fake_db)
deps = _makedeps(mp3player=fake_mp3, playlistdb=fake_db)
app.PlayerApp(deps)
with monkeypatch.context() as m:
m.setattr(builtins, 'open', fake_open)
@@ -202,6 +233,7 @@ def test_tagmode_startstop(micropythonify, faketimermanager, monkeypatch):
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
@@ -209,6 +241,7 @@ def test_tagmode_startstop(micropythonify, faketimermanager, monkeypatch):
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])
@@ -228,10 +261,7 @@ def test_tagmode_remains(micropythonify, faketimermanager, monkeypatch):
fake_db = MyFakePlaylistDb()
fake_mp3 = FakeMp3Player()
deps = app.Dependencies(mp3player=lambda _: fake_mp3,
nfcreader=lambda x: FakeNfcReader(x),
buttons=lambda _: FakeButtons(),
playlistdb=lambda _: fake_db)
deps = _makedeps(mp3player=fake_mp3, playlistdb=fake_db)
app.PlayerApp(deps)
with monkeypatch.context() as m:
m.setattr(builtins, 'open', fake_open)
@@ -241,8 +271,52 @@ def test_tagmode_remains(micropythonify, faketimermanager, monkeypatch):
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