From 704951074ba91c4d9c2fef4be582506b5cfe4206 Mon Sep 17 00:00:00 2001 From: Matthias Blankertz Date: Sun, 21 Dec 2025 17:31:03 +0100 Subject: [PATCH 01/20] feat: Long press VOL_DOWN button to shutdown/reset device Signed-off-by: Matthias Blankertz --- software/src/utils/__init__.py | 2 +- software/src/utils/buttons.py | 14 +++++++++++++- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/software/src/utils/__init__.py b/software/src/utils/__init__.py index cdb1ec8..2649cfe 100644 --- a/software/src/utils/__init__.py +++ b/software/src/utils/__init__.py @@ -2,6 +2,7 @@ # Copyright (c) 2025 Matthias Blankertz 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 @@ -9,7 +10,6 @@ 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"] diff --git a/software/src/utils/buttons.py b/software/src/utils/buttons.py index b6b5a24..aa6c669 100644 --- a/software/src/utils/buttons.py +++ b/software/src/utils/buttons.py @@ -5,7 +5,7 @@ import asyncio import machine import micropython import time -from utils import safe_callback +from utils import safe_callback, TimerManager try: from typing import TYPE_CHECKING # type: ignore except ImportError: @@ -32,6 +32,7 @@ class Buttons: def __init__(self, cb: "ButtonCallback", config, hwconfig): self.button_map = config.get_button_map() self.hw_buttons = hwconfig.BUTTONS + self.hwconfig = hwconfig self.cb = cb self.buttons = dict() for key_id, key_name in self.KEYMAP.items(): @@ -42,6 +43,7 @@ class Buttons: 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()) @@ -69,6 +71,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: @@ -76,3 +82,9 @@ class Buttons: 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() From 43fd68779c6f6b6d6ee3945cafef4952bffb57a2 Mon Sep 17 00:00:00 2001 From: Matthias Blankertz Date: Sun, 21 Dec 2025 20:59:16 +0100 Subject: [PATCH 02/20] feat: store git version in fw and show in web ui Signed-off-by: Matthias Blankertz --- software/boards/RPI_PICO_W/board.c | 29 +++++++++++++++++++ .../boards/RPI_PICO_W/mpconfigboard.cmake | 19 ++++++++++++ software/frontend/index.html | 25 +++++++++++++++- software/src/webserver.py | 10 +++++++ 4 files changed, 82 insertions(+), 1 deletion(-) create mode 100644 software/boards/RPI_PICO_W/board.c diff --git a/software/boards/RPI_PICO_W/board.c b/software/boards/RPI_PICO_W/board.c new file mode 100644 index 0000000..bc4ec6a --- /dev/null +++ b/software/boards/RPI_PICO_W/board.c @@ -0,0 +1,29 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2025 Matthias Blankertz + +#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); diff --git a/software/boards/RPI_PICO_W/mpconfigboard.cmake b/software/boards/RPI_PICO_W/mpconfigboard.cmake index 937e648..875f951 100644 --- a/software/boards/RPI_PICO_W/mpconfigboard.cmake +++ b/software/boards/RPI_PICO_W/mpconfigboard.cmake @@ -20,3 +20,22 @@ set(GEN_PINS_CSV_ARG --board-csv "${GEN_PINS_BOARD_CSV}") add_link_options("-Wl,--print-memory-usage") set(PICO_USE_FASTEST_SUPPORTED_CLOCK 1) + +find_program(GIT git) + +execute_process(COMMAND ${GIT} rev-parse HEAD + WORKING_DIRECTORY "${CMAKE_CURRENT_LIST_DIR}" + OUTPUT_VARIABLE TONBERRY_GIT_REVISION + OUTPUT_STRIP_TRAILING_WHITESPACE +) + +execute_process(COMMAND ${GIT} describe --match 'v*' --always --dirty + WORKING_DIRECTORY "${CMAKE_CURRENT_LIST_DIR}" + OUTPUT_VARIABLE TONBERRY_VERSION + OUTPUT_STRIP_TRAILING_WHITESPACE +) + +set(MICROPY_SOURCE_BOARD "${CMAKE_CURRENT_LIST_DIR}/board.c") +set(MICROPY_DEF_BOARD + TONBERRY_GIT_REVISION="${TONBERRY_GIT_REVISION}" + TONBERRY_VERSION="${TONBERRY_VERSION}") diff --git a/software/frontend/index.html b/software/frontend/index.html index 0184d89..20f2a22 100644 --- a/software/frontend/index.html +++ b/software/frontend/index.html @@ -131,6 +131,12 @@ list-style: none; } + .footer { + font-size: x-small; + color: gray; + margin-top: 20px; + } + @@ -257,7 +263,17 @@ - + diff --git a/software/src/webserver.py b/software/src/webserver.py index ea7f3cb..78803f3 100644 --- a/software/src/webserver.py +++ b/software/src/webserver.py @@ -4,11 +4,14 @@ Copyright (c) 2024-2025 Stefan Kratochwil ''' import asyncio +import board 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 @@ -270,3 +273,10 @@ async def reboot(request, method): 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} From 67d7650923c0c71ef848d4db42116a6187fbabd6 Mon Sep 17 00:00:00 2001 From: Matthias Blankertz Date: Mon, 22 Dec 2025 12:24:07 +0100 Subject: [PATCH 03/20] fix: webserver: Catch and report IO errors on upload Signed-off-by: Matthias Blankertz --- software/src/webserver.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/software/src/webserver.py b/software/src/webserver.py index 78803f3..474ef18 100644 --- a/software/src/webserver.py +++ b/software/src/webserver.py @@ -219,11 +219,17 @@ async def audiofile_upload(request): data = array('b', range(4096)) bytes_copied = 0 while True: - bytes_read = await request.stream.readinto(data) + try: + bytes_read = await request.stream.readinto(data) + except OSError as ex: + return f'read error: {ex}', 500 if bytes_read == 0: # End of body break - bytes_written = newfile.write(data[:bytes_read]) + try: + bytes_written = newfile.write(data[:bytes_read]) + except OSError as ex: + return f'write error: {ex}', 500 if bytes_written != bytes_read: # short writes shouldn't happen return 'write failure', 500 From b9baa1c7d5e2d547f75623ae2e8d38e74e9a4ee7 Mon Sep 17 00:00:00 2001 From: Matthias Blankertz Date: Mon, 22 Dec 2025 12:24:16 +0100 Subject: [PATCH 04/20] microdot: Update to v2.5.1 The important fix is an urldecode bug causing umlauts to be decoded incorrectly from url arguments. This was fixed in v2.2.0, but let's just update to the latest version. Signed-off-by: Matthias Blankertz --- software/lib/microdot | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/software/lib/microdot b/software/lib/microdot index d864b81..10e740d 160000 --- a/software/lib/microdot +++ b/software/lib/microdot @@ -1 +1 @@ -Subproject commit d864b81b651a1b80b277ec46817dd5b709bcb72b +Subproject commit 10e740da2b4adb2a19c2cf251dae2e2d7de447ba From d3aef1be329dbbc6d49fca4dd288170ad2b589d4 Mon Sep 17 00:00:00 2001 From: Matthias Blankertz Date: Tue, 23 Dec 2025 11:44:31 +0100 Subject: [PATCH 05/20] fix: frontend: don't convert text that looks like an integer to integers Signed-off-by: Matthias Blankertz --- software/frontend/index.html | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/software/frontend/index.html b/software/frontend/index.html index 20f2a22..cd70a29 100644 --- a/software/frontend/index.html +++ b/software/frontend/index.html @@ -360,6 +360,21 @@ 'tagstartstop': 'Present tag once to start, present again to stop playback' } }, + 'root.BUTTON_MAP.NEXT': { + 'input-type': 'number' + }, + 'root.BUTTON_MAP.PREV': { + 'input-type': 'number' + }, + 'root.BUTTON_MAP.VOLUP': { + 'input-type': 'number' + }, + 'root.BUTTON_MAP.VOLDOWN': { + 'input-type': 'number' + }, + 'root.BUTTON_MAP.PLAY_PAUSE': { + 'input-type': 'number' + }, 'root.IDLE_TIMEOUT_SECS': { 'input-type': 'number' }, @@ -439,7 +454,7 @@ let val = input.value.trim(); if (val === "") val = null; - else if (!isNaN(val)) val = Number(val); + else if (!isNaN(val) && input.type != 'text') val = Number(val); current[path[path.length - 1]] = val; }); From 58f8526d7eb79bc9a3437fce85388eaae8a39b5b Mon Sep 17 00:00:00 2001 From: Matthias Blankertz Date: Tue, 23 Dec 2025 11:46:32 +0100 Subject: [PATCH 06/20] feat: Join an existing WiFi network Add ssid and passphrase to config to configure the network to join. If empty SSID is configured, it will create the "TonberryPicoAP..." network in AP mode as before. Holding the button 1 during startup will revert to AP mode regardless of the current configuration. Signed-off-by: Matthias Blankertz --- software/frontend/index.html | 6 ++++++ software/src/main.py | 27 +++++++++++++++++++-------- software/src/utils/config.py | 12 +++++++++++- 3 files changed, 36 insertions(+), 9 deletions(-) diff --git a/software/frontend/index.html b/software/frontend/index.html index cd70a29..7cce847 100644 --- a/software/frontend/index.html +++ b/software/frontend/index.html @@ -384,6 +384,12 @@ 'root.LED_COUNT': { 'input-type': 'number' }, + 'root.WLAN.SSID': { + 'input-type': 'text' + }, + 'root.WLAN.PASSPHRASE': { + 'input-type': 'text' + } }; function renderObject(obj, path) { diff --git a/software/src/main.py b/software/src/main.py index e1e7b15..d3a0ca3 100644 --- a/software/src/main.py +++ b/software/src/main.py @@ -37,14 +37,22 @@ hwconfig.board_init() machine.mem32[0x40030000 + 0x00] = 0x10 -def setup_wifi(): +def setup_wifi(ssid='', passphrase='', sec=network.WLAN.SEC_WPA2_WPA3): network.hostname("TonberryPico") - wlan = network.WLAN(network.WLAN.IF_AP) - wlan.config(ssid=f"TonberryPicoAP_{machine.unique_id().hex()}", security=wlan.SEC_OPEN) - wlan.active(True) + if ssid is None or ssid == '': + apname = f"TonberryPicoAP_{machine.unique_id().hex()}" + print(f"Create AP {apname}") + wlan = network.WLAN(network.WLAN.IF_AP) + wlan.config(ssid=apname, security=wlan.SEC_OPEN) + 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=sec) - # disable power management - wlan.config(pm=network.WLAN.PM_NONE) + # configure power management + wlan.config(pm=network.WLAN.PM_PERFORMANCE) mac = ubinascii.hexlify(network.WLAN().config('mac'), ':').decode() print(f" mac: {mac}") @@ -71,8 +79,11 @@ def run(): # Setup LEDs np = NeoPixel(hwconfig.LED_DIN, config.get_led_count(), sm=1) - # Wifi with default config - setup_wifi() + if machine.Pin(hwconfig.BUTTONS[1], machine.Pin.IN, machine.Pin.PULL_UP).value() == 0: + # Force default access point + setup_wifi('', '') + else: + setup_wifi(config.get_wifi_ssid(), config.get_wifi_passphrase()) # Setup MP3 player with SDContext(mosi=hwconfig.SD_DI, miso=hwconfig.SD_DO, sck=hwconfig.SD_SCK, ss=hwconfig.SD_CS, diff --git a/software/src/utils/config.py b/software/src/utils/config.py index 0ab4ad2..f5891b6 100644 --- a/software/src/utils/config.py +++ b/software/src/utils/config.py @@ -22,7 +22,11 @@ class Configuration: 'PREV': None, 'NEXT': 1, }, - 'TAGMODE': 'tagremains' + 'TAGMODE': 'tagremains', + 'WIFI': { + 'SSID': '', + 'PASSPHRASE': '', + } } def __init__(self, config_path='/config.json'): @@ -87,6 +91,12 @@ class Configuration: 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'] + # For the web API def get_config(self) -> Mapping[str, Any]: return self.config From 02954cd87c1689f0789e03d25c91d112d9d5e670 Mon Sep 17 00:00:00 2001 From: Matthias Blankertz Date: Tue, 23 Dec 2025 11:49:28 +0100 Subject: [PATCH 07/20] feat: Visual feedback on LEDs during startup Set the LEDs to a fixed color during bootup to show the different modes: - Orange when the device is booting - Red when button 0 is held and the device goes to a shell - Blue when button 1 is held and the device goes to AP mode instead of joining the configured WiFi network - Red blinking when the run() method throws an exception for any reason Signed-off-by: Matthias Blankertz --- software/src/main.py | 29 ++++++++++++++++++++++++++--- 1 file changed, 26 insertions(+), 3 deletions(-) diff --git a/software/src/main.py b/software/src/main.py index d3a0ca3..cf1deea 100644 --- a/software/src/main.py +++ b/software/src/main.py @@ -10,6 +10,7 @@ import network import os import time import ubinascii +import sys # Own modules import app @@ -73,13 +74,18 @@ DB_PATH = '/sd/tonberry.db' config = Configuration() +# Setup LEDs +np = NeoPixel(hwconfig.LED_DIN, config.get_led_count(), sm=1) +np.fill((32, 32, 0)) +np.write() + def run(): asyncio.new_event_loop() - # Setup LEDs - np = NeoPixel(hwconfig.LED_DIN, config.get_led_count(), sm=1) if machine.Pin(hwconfig.BUTTONS[1], machine.Pin.IN, machine.Pin.PULL_UP).value() == 0: + np.fill((0, 0, 32)) + np.write() # Force default access point setup_wifi('', '') else: @@ -141,7 +147,24 @@ def builddb(): os.sync() +def error_blink(): + while True: + np.fill((32, 0, 0)) + np.write() + time.sleep_ms(500) + np.fill((0, 0, 0)) + np.write() + time.sleep_ms(500) + + if __name__ == '__main__': time.sleep(1) if machine.Pin(hwconfig.BUTTONS[0], machine.Pin.IN, machine.Pin.PULL_UP).value() != 0: - run() + try: + run() + except Exception as ex: + sys.print_exception(ex) + error_blink() + else: + np.fill((32, 0, 0)) + np.write() From cd5939f4eeb7334888caddd612766129dd35ebca Mon Sep 17 00:00:00 2001 From: Matthias Blankertz Date: Tue, 23 Dec 2025 11:59:55 +0100 Subject: [PATCH 08/20] feat: Enable dualstack IPv4/IPv6 for microdot Signed-off-by: Matthias Blankertz --- software/src/webserver.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/software/src/webserver.py b/software/src/webserver.py index 474ef18..8808dde 100644 --- a/software/src/webserver.py +++ b/software/src/webserver.py @@ -31,7 +31,7 @@ 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(port=80)) + server = asyncio.create_task(webapp.start_server(host='::0', port=80)) config = config_ app = app_ nfc = app.get_nfc() From 743188e1a436c7a6777916a91afc957604fc25e0 Mon Sep 17 00:00:00 2001 From: Matthias Blankertz Date: Tue, 23 Dec 2025 12:08:36 +0100 Subject: [PATCH 09/20] fix: frontend: Replace generic 'Device admin' title Signed-off-by: Matthias Blankertz --- software/frontend/index.html | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/software/frontend/index.html b/software/frontend/index.html index 7cce847..01867da 100644 --- a/software/frontend/index.html +++ b/software/frontend/index.html @@ -2,7 +2,7 @@ -Device Admin +TonBERRY pico