Compare commits
2 Commits
sd-multibl
...
44434683e5
| Author | SHA1 | Date | |
|---|---|---|---|
| 44434683e5 | |||
| 24fa935d6a |
@@ -11,10 +11,8 @@ 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:
|
||||
|
||||
@@ -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);
|
||||
@@ -19,8 +19,5 @@ 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")
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -17,7 +17,6 @@ set -eu
|
||||
)
|
||||
|
||||
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)
|
||||
@@ -25,11 +24,6 @@ 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-}
|
||||
@@ -57,9 +51,5 @@ for hwconfig in boards/RPI_PICO_W/manifest-*.py; do
|
||||
$PICOTOOL uf2 convert $BUILDDIR/firmware-filesystem.bin "$OUTDIR"/firmware-filesystem-"$hwname".uf2
|
||||
done
|
||||
|
||||
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
|
||||
|
||||
@@ -2,8 +2,6 @@
|
||||
|
||||
set -eu
|
||||
|
||||
TOPDIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )
|
||||
|
||||
check_command()
|
||||
{
|
||||
name=$1
|
||||
@@ -18,7 +16,7 @@ check_command lsusb
|
||||
check_command picotool
|
||||
|
||||
DEVICEPATH=/dev/disk/by-label/RPI-RP2
|
||||
IMAGEPATH=${TOPDIR}/build
|
||||
IMAGEPATH=lib/micropython/ports/rp2/build-TONBERRY_RPI_PICO_W/
|
||||
REVISION=Rev1
|
||||
|
||||
flash_via_mountpoint()
|
||||
@@ -85,7 +83,7 @@ if [ $# -gt 0 ]; then
|
||||
usage
|
||||
fi
|
||||
|
||||
IMAGEFILE="$IMAGEPATH"/firmware-$REVISION.uf2
|
||||
IMAGEFILE="$IMAGEPATH"/firmware-filesystem-$REVISION.uf2
|
||||
|
||||
if [ "$FLASH_VIA_MOUNTPOINT" -eq 0 ]; then
|
||||
flash_via_picotool
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Submodule software/lib/microdot updated: 10e740da2b...d864b81b65
Submodule software/lib/micropython updated: 6fdbf1d339...4ecb4099cf
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -26,14 +26,14 @@ static bool sd_early_init(void)
|
||||
for (int i = 0; i < 500; ++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;
|
||||
}
|
||||
}
|
||||
@@ -72,12 +72,12 @@ 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 +87,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 +107,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,8 +122,8 @@ 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -131,13 +131,13 @@ 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");
|
||||
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);
|
||||
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;
|
||||
@@ -151,12 +151,12 @@ static bool sd_read_csd(struct sd_context *sd_context)
|
||||
switch (csd_ver) {
|
||||
case 0: {
|
||||
if (sd_context->sdhc_sdxc) {
|
||||
sd_printf("sd_init: Got CSD v1.0 but card is SDHC/SDXC?\n");
|
||||
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");
|
||||
printf("Invalid read_bl_len in CSD 1.0\n");
|
||||
return false;
|
||||
}
|
||||
blocksize = 1 << (buf[5] & 0xf);
|
||||
@@ -176,15 +176,15 @@ static bool sd_read_csd(struct sd_context *sd_context)
|
||||
break;
|
||||
}
|
||||
case 2: {
|
||||
sd_printf("sd_init: Got CSD v3.0, but SDUC does not support SPI.\n");
|
||||
printf("sd_init: Got CSD v3.0, but SDUC does not support SPI.\n");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
sd_context->blocks = blocks;
|
||||
sd_context->blocksize = blocksize;
|
||||
#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, max speed %u\n", version, blocksize, blocks,
|
||||
((uint64_t)blocksize * blocks) / (1024 * 1024), max_speed);
|
||||
#endif
|
||||
return true;
|
||||
}
|
||||
@@ -205,11 +205,11 @@ bool sd_init(struct sd_context *sd_context, int mosi, int miso, int sck, int ss,
|
||||
|
||||
uint32_t ocr;
|
||||
if (!sd_read_ocr(&ocr)) {
|
||||
sd_printf("sd_init: read OCR failed\n");
|
||||
printf("sd_init: read OCR failed\n");
|
||||
goto out_spi;
|
||||
}
|
||||
if ((ocr & 0x00380000) != 0x00380000) {
|
||||
sd_printf("sd_init: unsupported card voltage range\n");
|
||||
printf("sd_init: unsupported card voltage range\n");
|
||||
goto out_spi;
|
||||
}
|
||||
|
||||
@@ -219,11 +219,11 @@ bool sd_init(struct sd_context *sd_context, int mosi, int miso, int sck, int ss,
|
||||
sd_spi_set_bitrate(rate);
|
||||
|
||||
if (!sd_read_ocr(&ocr)) {
|
||||
sd_printf("sd_init: read OCR failed\n");
|
||||
printf("sd_init: read OCR failed\n");
|
||||
goto out_spi;
|
||||
}
|
||||
if (!(ocr & (1 << 31))) {
|
||||
sd_printf("sd_init: card not powered up but !idle?\n");
|
||||
printf("sd_init: card not powered up but !idle?\n");
|
||||
goto out_spi;
|
||||
}
|
||||
sd_context->sdhc_sdxc = (ocr & (1 << 30));
|
||||
@@ -234,20 +234,20 @@ bool sd_init(struct sd_context *sd_context, int mosi, int miso, int sck, int ss,
|
||||
|
||||
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);
|
||||
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");
|
||||
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);
|
||||
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;
|
||||
}
|
||||
@@ -307,7 +307,7 @@ 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;
|
||||
@@ -319,20 +319,3 @@ bool sd_writeblock(struct sd_context *sd_context, const size_t sector_num, uint8
|
||||
}
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -24,4 +24,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);
|
||||
|
||||
@@ -12,8 +12,6 @@
|
||||
#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 {
|
||||
@@ -224,7 +222,7 @@ bool sd_cmd_read_complete(void)
|
||||
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);
|
||||
printf("read failed: invalid read token %02hhx\n", sd_spi_context.sd_dma_context.read_token_buf);
|
||||
#endif
|
||||
return false;
|
||||
}
|
||||
@@ -233,7 +231,7 @@ bool sd_cmd_read_complete(void)
|
||||
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;
|
||||
}
|
||||
@@ -241,9 +239,10 @@ bool sd_cmd_read_complete(void)
|
||||
return true;
|
||||
}
|
||||
|
||||
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 +253,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 +264,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 +279,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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
freezefs
|
||||
@@ -6,21 +6,19 @@ import time
|
||||
from utils import TimerManager
|
||||
|
||||
|
||||
Dependencies = namedtuple('Dependencies', ('mp3player', 'nfcreader', 'buttons', 'playlistdb', 'hwconfig', 'leds',
|
||||
'config'))
|
||||
Dependencies = namedtuple('Dependencies', ('mp3player', 'nfcreader', 'buttons', 'playlistdb', 'hwconfig', 'leds'))
|
||||
|
||||
# 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):
|
||||
def __init__(self, parent, timer_manager):
|
||||
self.parent = parent
|
||||
self.timer_manager = timer_manager
|
||||
self.current_tag = None
|
||||
self.current_tag_time = time.ticks_ms()
|
||||
self.timeout = timeout
|
||||
|
||||
def onTagChange(self, new_tag):
|
||||
if new_tag is not None:
|
||||
@@ -33,7 +31,7 @@ class PlayerApp:
|
||||
self.current_tag = new_tag
|
||||
self.parent.onNewTag(new_tag)
|
||||
else:
|
||||
self.timer_manager.schedule(time.ticks_ms() + self.timeout, self.onTagRemoveDelay)
|
||||
self.timer_manager.schedule(time.ticks_ms() + 5000, self.onTagRemoveDelay)
|
||||
|
||||
def onTagRemoveDelay(self):
|
||||
if self.current_tag is not None:
|
||||
@@ -42,31 +40,18 @@ class PlayerApp:
|
||||
|
||||
def __init__(self, deps: Dependencies):
|
||||
self.timer_manager = TimerManager()
|
||||
self.config = deps.config(self)
|
||||
self.tag_timeout_ms = self.config.get_tag_timeout() * 1000
|
||||
self.idle_timeout_ms = self.config.get_idle_timeout() * 1000
|
||||
self.tag_state_machine = self.TagStateMachine(self, self.timer_manager, self.tag_timeout_ms)
|
||||
self.tag_state_machine = self.TagStateMachine(self, self.timer_manager)
|
||||
self.player = deps.mp3player(self)
|
||||
self.nfc = deps.nfcreader(self.tag_state_machine)
|
||||
self.playlist_db = deps.playlistdb(self)
|
||||
self.hwconfig = deps.hwconfig(self)
|
||||
self.leds = deps.leds(self)
|
||||
self.tag_mode = self.config.get_tagmode()
|
||||
self.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.tag_mode = self.playlist_db.getSetting('tagmode')
|
||||
self.playing_tag = None
|
||||
self.playlist = None
|
||||
self.buttons = deps.buttons(self) if deps.buttons is not None else None
|
||||
self.mp3file = None
|
||||
self.paused = False
|
||||
self.playing = False
|
||||
self.volume_pos = 3
|
||||
self.player.set_volume(VOLUME_CURVE[self.volume_pos])
|
||||
self._onIdle()
|
||||
|
||||
@@ -82,7 +67,7 @@ class PlayerApp:
|
||||
uid_str = b''.join('{:02x}'.format(x).encode() for x in new_tag)
|
||||
if self.tag_mode == 'tagremains' or (self.tag_mode == 'tagstartstop' and new_tag != self.playing_tag):
|
||||
self._set_playlist(uid_str)
|
||||
self.playing_tag = new_tag if self.playlist is not None else None
|
||||
self.playing_tag = new_tag
|
||||
elif self.tag_mode == 'tagstartstop':
|
||||
print('Tag presented again, stopping playback')
|
||||
self._unset_playlist()
|
||||
@@ -99,21 +84,18 @@ class PlayerApp:
|
||||
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):
|
||||
@@ -121,14 +103,7 @@ class PlayerApp:
|
||||
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
|
||||
self.timer_manager.schedule(time.ticks_ms() + 60*1000, self.onIdleTimeout)
|
||||
|
||||
def _set_playlist(self, tag: bytes):
|
||||
if self.playlist is not None:
|
||||
@@ -148,16 +123,9 @@ class PlayerApp:
|
||||
self.playlist = 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
|
||||
@@ -171,40 +139,14 @@ class PlayerApp:
|
||||
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.mp3file = open(filename, 'rb')
|
||||
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.timer_manager.schedule(time.ticks_ms() + 60*1000, 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
|
||||
|
||||
@@ -28,14 +28,13 @@ RC522_SS = Pin.board.GP13
|
||||
|
||||
# WS2812
|
||||
LED_DIN = Pin.board.GP16
|
||||
LED_COUNT = 1
|
||||
|
||||
# Buttons
|
||||
BUTTONS = [Pin.board.GP17,
|
||||
Pin.board.GP18,
|
||||
Pin.board.GP19,
|
||||
Pin.board.GP20,
|
||||
Pin.board.GP21,
|
||||
]
|
||||
BUTTON_VOLUP = Pin.board.GP17
|
||||
BUTTON_VOLDOWN = Pin.board.GP19
|
||||
BUTTON_NEXT = Pin.board.GP18
|
||||
BUTTON_POWER = Pin.board.GP21
|
||||
|
||||
# Power
|
||||
POWER_EN = Pin.board.GP22
|
||||
|
||||
@@ -27,12 +27,13 @@ RC522_SS = Pin.board.GP13
|
||||
|
||||
# WS2812
|
||||
LED_DIN = Pin.board.GP16
|
||||
LED_COUNT = 1
|
||||
|
||||
# Buttons
|
||||
BUTTONS = [Pin.board.GP17,
|
||||
Pin.board.GP18,
|
||||
Pin.board.GP19,
|
||||
]
|
||||
BUTTON_VOLUP = Pin.board.GP17
|
||||
BUTTON_VOLDOWN = Pin.board.GP19
|
||||
BUTTON_NEXT = Pin.board.GP18
|
||||
BUTTON_POWER = None
|
||||
|
||||
# Power
|
||||
POWER_EN = None
|
||||
|
||||
@@ -3,22 +3,22 @@
|
||||
|
||||
import aiorepl # type: ignore
|
||||
import asyncio
|
||||
from errno import ENOENT
|
||||
import machine
|
||||
import micropython
|
||||
import network
|
||||
import os
|
||||
import time
|
||||
import ubinascii
|
||||
import sys
|
||||
|
||||
# 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 utils import BTreeFileManager, Buttons, SDContext, TimerManager, LedManager
|
||||
from webserver import start_webserver
|
||||
|
||||
try:
|
||||
@@ -36,22 +36,14 @@ hwconfig.board_init()
|
||||
machine.mem32[0x40030000 + 0x00] = 0x10
|
||||
|
||||
|
||||
def setup_wifi(ssid='', passphrase='', security=network.WLAN.SEC_WPA_WPA2):
|
||||
def setup_wifi():
|
||||
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)
|
||||
wlan = network.WLAN(network.WLAN.IF_AP)
|
||||
wlan.config(ssid=f"TonberryPicoAP_{machine.unique_id().hex()}", security=wlan.SEC_OPEN)
|
||||
wlan.active(True)
|
||||
|
||||
# configure power management
|
||||
wlan.config(pm=network.WLAN.PM_PERFORMANCE)
|
||||
# disable power management
|
||||
wlan.config(pm=network.WLAN.PM_NONE)
|
||||
|
||||
mac = ubinascii.hexlify(network.WLAN().config('mac'), ':').decode()
|
||||
print(f" mac: {mac}")
|
||||
@@ -61,48 +53,30 @@ def setup_wifi(ssid='', passphrase='', security=network.WLAN.SEC_WPA_WPA2):
|
||||
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()
|
||||
# Setup LEDs
|
||||
np = NeoPixel(hwconfig.LED_DIN, hwconfig.LED_COUNT, sm=1)
|
||||
|
||||
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)
|
||||
# Wifi with default config
|
||||
setup_wifi()
|
||||
start_webserver()
|
||||
|
||||
# Setup MP3 player
|
||||
with SDContext(mosi=hwconfig.SD_DI, miso=hwconfig.SD_DO, sck=hwconfig.SD_SCK, ss=hwconfig.SD_CS,
|
||||
baudrate=hwconfig.SD_CLOCKRATE):
|
||||
# Temporary hack: build database from folders if no database exists
|
||||
# Can be removed once playlists can be created via API
|
||||
try:
|
||||
_ = os.stat(DB_PATH)
|
||||
except OSError as ex:
|
||||
if ex.errno == ENOENT:
|
||||
print("No playlist DB found, trying to build DB from tag dirs")
|
||||
builddb()
|
||||
|
||||
with BTreeFileManager(DB_PATH) as playlistdb, \
|
||||
AudioContext(hwconfig.I2S_DIN, hwconfig.I2S_DCLK, hwconfig.I2S_LRCLK) as audioctx:
|
||||
|
||||
@@ -113,42 +87,39 @@ def run():
|
||||
# Setup app
|
||||
deps = app.Dependencies(mp3player=lambda the_app: MP3Player(audioctx, the_app),
|
||||
nfcreader=lambda the_app: Nfc(reader, the_app),
|
||||
buttons=lambda the_app: Buttons(the_app, config, hwconfig),
|
||||
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,
|
||||
hwconfig=lambda _: hwconfig,
|
||||
leds=lambda _: LedManager(np, config),
|
||||
config=lambda _: config)
|
||||
leds=lambda _: LedManager(np))
|
||||
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()
|
||||
|
||||
|
||||
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.
|
||||
"""
|
||||
try:
|
||||
os.unlink(DB_PATH)
|
||||
except OSError:
|
||||
pass
|
||||
with BTreeFileManager(DB_PATH) as db:
|
||||
for name, type_, _, _ in os.ilistdir(b'/sd'):
|
||||
if type_ != 0x4000:
|
||||
continue
|
||||
fl = [b'/sd/' + name + b'/' + x for x in os.listdir(b'/sd/' + name) if x.endswith(b'.mp3')]
|
||||
db.createPlaylistForTag(name, fl)
|
||||
os.sync()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
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(hwconfig.BUTTON_VOLUP, machine.Pin.IN, machine.Pin.PULL_UP).value() != 0:
|
||||
run()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1,15 +1,13 @@
|
||||
# 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", "get_pin_index", "LedManager", "MBRPartition", "SDContext",
|
||||
"TimerManager"]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -10,17 +10,16 @@ import time
|
||||
class LedManager:
|
||||
IDLE = const(0)
|
||||
PLAYING = const(1)
|
||||
REBOOTING = const(2)
|
||||
|
||||
def __init__(self, np, config):
|
||||
def __init__(self, np):
|
||||
self.led_state = LedManager.IDLE
|
||||
self.brightness = config.get_led_max() / 255
|
||||
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, LedManager.REBOOTING]
|
||||
assert state in [LedManager.IDLE, LedManager.PLAYING]
|
||||
self.led_state = state
|
||||
|
||||
def _gamma(self, value, X=2.2):
|
||||
@@ -51,8 +50,6 @@ class LedManager:
|
||||
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()
|
||||
|
||||
@@ -31,15 +31,17 @@ class BTreeDB(IPlaylistDB):
|
||||
PERSIST_NO = b'no'
|
||||
PERSIST_TRACK = b'track'
|
||||
PERSIST_OFFSET = b'offset'
|
||||
DEFAULT_SETTINGS = {
|
||||
b'tagmode': b'tagremains'
|
||||
}
|
||||
|
||||
class Playlist(IPlaylist):
|
||||
def __init__(self, parent: "BTreeDB", tag: bytes, pos: int, persist, shuffle, name):
|
||||
def __init__(self, parent: "BTreeDB", tag: bytes, pos: int, persist, shuffle):
|
||||
self.parent = parent
|
||||
self.tag = tag
|
||||
self.pos = pos
|
||||
self.persist = persist
|
||||
self.shuffle = shuffle
|
||||
self.name = name
|
||||
self.length = self.parent._getPlaylistLength(self.tag)
|
||||
self._shuffle()
|
||||
|
||||
@@ -105,17 +107,6 @@ class BTreeDB(IPlaylistDB):
|
||||
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
|
||||
@@ -169,10 +160,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.
|
||||
@@ -227,13 +214,12 @@ class BTreeDB(IPlaylistDB):
|
||||
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, persist, shuffle, flush=True):
|
||||
self._deletePlaylist(tag, False)
|
||||
for idx, entry in enumerate(entries):
|
||||
self.db[self._keyPlaylistEntry(tag, idx)] = entry
|
||||
self.db[self._keyPlaylistPersist(tag)] = persist
|
||||
self.db[self._keyPlaylistShuffle(tag)] = shuffle
|
||||
self.db[self._keyPlaylistName(tag)] = name.encode()
|
||||
if flush:
|
||||
self._flush()
|
||||
|
||||
@@ -246,7 +232,7 @@ class BTreeDB(IPlaylistDB):
|
||||
pass
|
||||
for k in (self._keyPlaylistPos(tag), self._keyPlaylistPosOffset(tag),
|
||||
self._keyPlaylistPersist(tag), self._keyPlaylistShuffle(tag),
|
||||
self._keyPlaylistShuffleSeed(tag), self._keyPlaylistName(tag)):
|
||||
self._keyPlaylistShuffleSeed(tag)):
|
||||
try:
|
||||
del self.db[k]
|
||||
except KeyError:
|
||||
@@ -254,19 +240,6 @@ class BTreeDB(IPlaylistDB):
|
||||
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 getPlaylistForTag(self, tag: bytes):
|
||||
"""
|
||||
Lookup the playlist for 'tag' and return the Playlist object. Return None if no playlist exists for the given
|
||||
@@ -284,23 +257,24 @@ class BTreeDB(IPlaylistDB):
|
||||
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)
|
||||
return self.Playlist(self, tag, pos, persist, shuffle)
|
||||
|
||||
def createPlaylistForTag(self, tag: bytes, entries: typing.Iterable[bytes], persist=PERSIST_TRACK,
|
||||
shuffle=SHUFFLE_NO, name: str = ''):
|
||||
shuffle=SHUFFLE_NO):
|
||||
"""
|
||||
Create and save a playlist for 'tag' and return the Playlist object. If a playlist already existed for 'tag' it
|
||||
is overwritten.
|
||||
"""
|
||||
assert persist in (self.PERSIST_NO, self.PERSIST_TRACK, self.PERSIST_OFFSET)
|
||||
assert shuffle in (self.SHUFFLE_NO, self.SHUFFLE_YES)
|
||||
self._savePlaylist(tag, entries, persist, shuffle, name)
|
||||
self._savePlaylist(tag, entries, persist, shuffle)
|
||||
return self.getPlaylistForTag(tag)
|
||||
|
||||
def deletePlaylistForTag(self, tag: bytes):
|
||||
self._deletePlaylist(tag)
|
||||
def getSetting(self, key: bytes | str) -> str:
|
||||
if type(key) is str:
|
||||
key = key.encode()
|
||||
return self.db.get(b'settings/' + key, self.DEFAULT_SETTINGS[key]).decode()
|
||||
|
||||
def validate(self, dump=False):
|
||||
"""
|
||||
@@ -321,7 +295,8 @@ class BTreeDB(IPlaylistDB):
|
||||
fail(f'Malformed key {k!r}')
|
||||
continue
|
||||
if fields[0] == b'settings':
|
||||
# Legacy, not used any more
|
||||
val = self.db[k].decode()
|
||||
print(f'Setting {fields[1].decode()} = {val}')
|
||||
continue
|
||||
if last_tag != fields[0]:
|
||||
last_tag = fields[0]
|
||||
@@ -380,14 +355,6 @@ class BTreeDB(IPlaylistDB):
|
||||
_ = 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}')
|
||||
return result
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -4,51 +4,19 @@ 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
|
||||
from microdot import Microdot
|
||||
|
||||
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()
|
||||
def start_webserver():
|
||||
global server
|
||||
server = asyncio.create_task(webapp.start_server(port=80))
|
||||
|
||||
|
||||
@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')
|
||||
@webapp.route('/')
|
||||
async def index(request):
|
||||
print("wohoo, a guest :)")
|
||||
print(f" app: {request.app}")
|
||||
@@ -71,226 +39,3 @@ async def filesystem_post(request):
|
||||
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}
|
||||
|
||||
@@ -124,28 +124,6 @@ class FakeLeds:
|
||||
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)
|
||||
|
||||
@@ -158,14 +136,13 @@ def faketimermanager(monkeypatch):
|
||||
|
||||
|
||||
def _makedeps(mp3player=FakeMp3Player, nfcreader=FakeNfcReader, buttons=FakeButtons,
|
||||
playlistdb=FakePlaylistDb, hwconfig=FakeHwconfig, leds=FakeLeds, config=FakeConfig):
|
||||
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,
|
||||
config=lambda _: config() if callable(config) else config)
|
||||
leds=lambda _: leds() if callable(leds) else leds)
|
||||
|
||||
|
||||
def test_construct_app(micropythonify, faketimermanager):
|
||||
@@ -173,7 +150,7 @@ def test_construct_app(micropythonify, faketimermanager):
|
||||
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
|
||||
assert fake_mp3.volume is not None
|
||||
|
||||
|
||||
def test_load_playlist_on_tag(micropythonify, faketimermanager, monkeypatch):
|
||||
@@ -235,16 +212,18 @@ def test_playlist_unknown_tag(micropythonify, faketimermanager, monkeypatch):
|
||||
|
||||
|
||||
def test_tagmode_startstop(micropythonify, faketimermanager, monkeypatch):
|
||||
class FakeStartStopConfig(FakeConfig):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
class MyFakePlaylistDb(FakePlaylistDb):
|
||||
def __init__(self, tracklist=[b'test/path.mp3']):
|
||||
super().__init__(tracklist)
|
||||
|
||||
def get_tagmode(self):
|
||||
return 'tagstartstop'
|
||||
def getSetting(self, key: bytes | str):
|
||||
if key == 'tagmode':
|
||||
return 'tagstartstop'
|
||||
return None
|
||||
|
||||
fake_db = FakePlaylistDb([b'test/path.mp3'])
|
||||
fake_db = MyFakePlaylistDb()
|
||||
fake_mp3 = FakeMp3Player()
|
||||
deps = _makedeps(mp3player=fake_mp3, playlistdb=fake_db, config=FakeStartStopConfig)
|
||||
deps = _makedeps(mp3player=fake_mp3, playlistdb=fake_db)
|
||||
app.PlayerApp(deps)
|
||||
with monkeypatch.context() as m:
|
||||
m.setattr(builtins, 'open', fake_open)
|
||||
@@ -271,7 +250,16 @@ def test_tagmode_startstop(micropythonify, faketimermanager, monkeypatch):
|
||||
|
||||
|
||||
def test_tagmode_remains(micropythonify, faketimermanager, monkeypatch):
|
||||
fake_db = FakePlaylistDb([b'test/path.mp3'])
|
||||
class MyFakePlaylistDb(FakePlaylistDb):
|
||||
def __init__(self, tracklist=[b'test/path.mp3']):
|
||||
super().__init__(tracklist)
|
||||
|
||||
def getSetting(self, key: bytes | str):
|
||||
if key == 'tagmode':
|
||||
return 'tagremains'
|
||||
return None
|
||||
|
||||
fake_db = MyFakePlaylistDb()
|
||||
fake_mp3 = FakeMp3Player()
|
||||
deps = _makedeps(mp3player=fake_mp3, playlistdb=fake_db)
|
||||
app.PlayerApp(deps)
|
||||
|
||||
@@ -15,8 +15,6 @@ 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()
|
||||
|
||||
@@ -64,7 +62,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)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user