Compare commits
30 Commits
mbl-next
...
1a9ef41962
| Author | SHA1 | Date | |
|---|---|---|---|
| 1a9ef41962 | |||
| 69fbb15bca | |||
| f9a82c121e | |||
| 245b76e04e | |||
| fa4d8debd0 | |||
| 4e9a902a1c | |||
| bd15a45090 | |||
| 0a20b70478 | |||
| 3537a2f1bb | |||
| 39a9c68aae | |||
| 6dee7fff7e | |||
| 6976aa6963 | |||
| 763305c659 | |||
| 6d18437863 | |||
| 2cf88b26ee | |||
| 73da134a12 | |||
| 5c8a61eb27 | |||
| 4c85683fcb | |||
| 355a8bd345 | |||
| f46c045589 | |||
| fe1c1eadf7 | |||
| 743188e1a4 | |||
| cd5939f4ee | |||
| 02954cd87c | |||
| 58f8526d7e | |||
| d3aef1be32 | |||
| b9baa1c7d5 | |||
| 67d7650923 | |||
| 43fd68779c | |||
| 704951074b |
20
DEVELOP.md
20
DEVELOP.md
@@ -52,3 +52,23 @@ would be stored in the following key/value pairs in the btree db:
|
||||
* 00aa11bb22/playlist/00000: a.mp3
|
||||
* 00aa11bb22/playlist/00001: b.mp3
|
||||
* 00aa11bb22/playlistpos: 00000
|
||||
|
||||
## Notes for UI development with chromium
|
||||
|
||||
Features for the web interface are best prototyped in a browser directly. By using the built-in developmer tools and
|
||||
and their "override" feature, the web contents are replaced by a locally stored copy, which can be used to directly
|
||||
test the modifications without going all the way through the build and flash process.
|
||||
|
||||
However, modern browsers may restrict or even completely forbid the execution of dynamic content like JavaScript, if
|
||||
the content is stored on the local machine and/or the content is accessed using http. In such a case, chromium issues
|
||||
an error message similar to the following one:
|
||||
|
||||
> Access to fetch at 'http://192.168.4.1/api/v1/audiofiles' from origin 'http://192.168.4.1' has been blocked by CORS
|
||||
> policy: The request client is not a secure context and the resource is in more-private address space `local`.
|
||||
|
||||
To mitigate this, chromium offers two flags that need modification:
|
||||
- 'chrome://flags/#local-network-access-check' must be `Disabled`
|
||||
- 'chrome://flags/#unsafely-treat-insecure-origin-as-secure' must be `Enabled`
|
||||
|
||||
Note that these settings leave the browser susceptible to security issues and should be returned to
|
||||
their default values as soon as possible.
|
||||
|
||||
29
software/boards/RPI_PICO_W/board.c
Normal file
29
software/boards/RPI_PICO_W/board.c
Normal file
@@ -0,0 +1,29 @@
|
||||
// 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);
|
||||
@@ -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}")
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Device Admin</title>
|
||||
<title>TonBERRY pico</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
@@ -131,16 +131,23 @@
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.footer {
|
||||
font-size: x-small;
|
||||
color: gray;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<h1>Device Admin</h1>
|
||||
<h1>TonBERRY pico</h1>
|
||||
|
||||
<nav>
|
||||
<button onclick="showScreen('menu')">🏠 Main Menu</button>
|
||||
<button onclick="showScreen('config')">⚙️ Config Editor</button>
|
||||
<button onclick="showScreen('playlist')">🖹 Playlist Editor</button>
|
||||
<button onclick="showScreen('filebrowser', 'filesystem')">📂 Filesystem</button>
|
||||
</nav>
|
||||
|
||||
<!-- MAIN MENU -->
|
||||
@@ -161,7 +168,7 @@
|
||||
<div id="screen-config" class="screen">
|
||||
<h2>Configuration Editor</h2>
|
||||
<div id="config-container">Loading…</div>
|
||||
<button id="config-save-btn" disabled>Save</button>
|
||||
<button id="config-save-btn" disabled>Save & Reboot</button>
|
||||
</div>
|
||||
|
||||
<!-- PLAYLIST EDITOR SCREEN 1: list of playlists -->
|
||||
@@ -207,6 +214,10 @@
|
||||
<option value="audioplay">Audioplay (no shuffle, start at previous track)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label>Playlist name</label>
|
||||
<input type="text" placeholder="Playlist name" id="playlist-edit-name" />
|
||||
</div>
|
||||
<div class="flex-horizontal">
|
||||
<div style="flex-grow: 1">
|
||||
<label>Tracks</label>
|
||||
@@ -232,10 +243,10 @@
|
||||
</div>
|
||||
|
||||
<!-- PLAYLIST EDITOR SCREEN 3: file browser -->
|
||||
<div id="screen-playlist_filebrowser" class="screen">
|
||||
<h2>Playlist Editor</h2>
|
||||
<div id="screen-filebrowser" class="screen">
|
||||
<h2 id="playlist-filebrowser-title">Playlist Editor</h2>
|
||||
<div id="playlist-filebrowser-container">
|
||||
<div class="scroll-container">
|
||||
<div class="scroll-container" style="margin-bottom: 10px">
|
||||
<div class="tree" id="playlist-filebrowser-tree">
|
||||
<ul><li>Loading...</li></ul>
|
||||
</div>
|
||||
@@ -257,7 +268,17 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
<hr>
|
||||
<div class="flex-horizontal">
|
||||
<div>
|
||||
<a href="https://git.ka.blankertz.org/TonBERRY/tonberry-pico">TonBERRY pico</a>
|
||||
</div>
|
||||
<div>
|
||||
Version: <span id="footer-version">unknown</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
const Screens = {};
|
||||
let activeScreen = null;
|
||||
@@ -296,7 +317,9 @@
|
||||
return;
|
||||
}
|
||||
|
||||
alert("Configuration saved successfully!");
|
||||
alert("Configuration saved successfully, device will now reboot/shutdown! " +
|
||||
"On battery, press Power button after shutdown to restart.");
|
||||
await fetch('/api/v1/reboot/application', {'method': 'POST'});
|
||||
} catch (err) {
|
||||
alert("Error saving configuration: " + err);
|
||||
}
|
||||
@@ -334,7 +357,13 @@
|
||||
'root.BUTTON_MAP.PLAY_PAUSE': 'Play/Pause',
|
||||
'root.TAG_TIMEOUT_SECS': 'Tag removal timeout (seconds)',
|
||||
'root.TAGMODE': 'Tag mode',
|
||||
'root.LED_COUNT': 'Length of WS2182 (Neopixel) LED chain'
|
||||
'root.LED_COUNT': 'Length of WS2182 (Neopixel) LED chain',
|
||||
'root.VOLUME_MAX': 'Maximum volume (0-255)',
|
||||
'root.VOLUME_BOOT': 'Volume at startup (0-255)',
|
||||
'root.LED_MAX': 'Maximum LED brightness (0-255)',
|
||||
'root.WIFI.SSID': 'Network name (SSID) (leave empty for AP mode)',
|
||||
'root.WIFI.PASSPHRASE': 'Password',
|
||||
'root.WIFI.SECURITY': 'Security mode'
|
||||
};
|
||||
const config_input_override = {
|
||||
'root.TAGMODE': {
|
||||
@@ -344,6 +373,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'
|
||||
},
|
||||
@@ -353,6 +397,30 @@
|
||||
'root.LED_COUNT': {
|
||||
'input-type': 'number'
|
||||
},
|
||||
'root.WIFI.SSID': {
|
||||
'input-type': 'text'
|
||||
},
|
||||
'root.WIFI.PASSPHRASE': {
|
||||
'input-type': 'text'
|
||||
},
|
||||
'root.WIFI.SECURITY': {
|
||||
'element': 'select',
|
||||
'values': {
|
||||
'open': 'Open',
|
||||
'wpa_wpa2': 'WPA/WPA2 (PSK Mixed)',
|
||||
'wpa3': 'WPA3 (SAE AES)',
|
||||
'wpa2_wpa3': 'WPA2/WPA3 (PSK AES)'
|
||||
}
|
||||
},
|
||||
'root.VOLUME_MAX': {
|
||||
'input-type': 'number'
|
||||
},
|
||||
'root.VOLUME_BOOT': {
|
||||
'input-type': 'number'
|
||||
},
|
||||
'root.LED_MAX': {
|
||||
'input-type': 'number'
|
||||
}
|
||||
};
|
||||
|
||||
function renderObject(obj, path) {
|
||||
@@ -423,7 +491,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;
|
||||
});
|
||||
@@ -456,7 +524,7 @@
|
||||
document.getElementById('playlist-exist-button-delete')
|
||||
.addEventListener('click', (e) => {
|
||||
if (lastSelected === null) return;
|
||||
const tagid = lastSelected.innerText;
|
||||
const tagid = lastSelected.getAttribute('data-tag');
|
||||
if(confirm(`Really delete playlist ${tagid}?`)) {
|
||||
fetch(`/api/v1/playlist/${tagid}`, {
|
||||
method: 'DELETE'})
|
||||
@@ -467,7 +535,7 @@
|
||||
.addEventListener('click', selectLastTag);
|
||||
document.getElementById('playlist-exist-button-edit').addEventListener("click", (e) => {
|
||||
if (lastSelected !== null)
|
||||
showScreen("playlist_edit", {load: lastSelected.innerText});
|
||||
showScreen("playlist_edit", {load: lastSelected.getAttribute('data-tag')});
|
||||
});
|
||||
document.getElementById('playlist-exist-list').addEventListener("click", (e) => {
|
||||
const node = e.target.closest("li");
|
||||
@@ -522,8 +590,9 @@
|
||||
container.innerHTML = "";
|
||||
for (const playlist of playlists) {
|
||||
const li = document.createElement("li");
|
||||
li.innerHTML = playlist;
|
||||
li.innerHTML = `${playlist.name} (${playlist.tag})`;
|
||||
li.className = "node"
|
||||
li.setAttribute('data-tag', playlist.tag);
|
||||
container.appendChild(li)
|
||||
}
|
||||
}
|
||||
@@ -548,7 +617,7 @@
|
||||
document.getElementById('playlist-exist-list')
|
||||
.querySelectorAll("li")
|
||||
.forEach(n => {
|
||||
if (n.innerText == tagtext) {
|
||||
if (n.getAttribute('data-tag') == tagtext) {
|
||||
n.classList.add("selected");
|
||||
lastSelected = n;
|
||||
} else {
|
||||
@@ -586,7 +655,7 @@
|
||||
document.getElementById('playlist-edit-removetrack').addEventListener("click", (e) => deleteSelectedTracks());
|
||||
document.getElementById('playlist-edit-back').addEventListener("click", (e) => showScreen('playlist'));
|
||||
document.getElementById('playlist-edit-addtrack').addEventListener("click", (e) => {
|
||||
showScreen("playlist_filebrowser");
|
||||
showScreen("filebrowser", "playlist");
|
||||
});
|
||||
document.getElementById('playlist-edit-save').addEventListener("click", (e) => save());
|
||||
}
|
||||
@@ -650,6 +719,8 @@
|
||||
} else if (playlist.persist === "track" && playlist.shuffle === "no") {
|
||||
playlisttype.value = "audioplay";
|
||||
}
|
||||
const playlistname = document.getElementById('playlist-edit-name');
|
||||
playlistname.value = playlist.name;
|
||||
}
|
||||
|
||||
async function save() {
|
||||
@@ -672,6 +743,8 @@
|
||||
playlistData.shuffle = "no";
|
||||
break;
|
||||
}
|
||||
const playlistname = document.getElementById('playlist-edit-name');
|
||||
playlistData.name = playlistname.value;
|
||||
const container = document.getElementById('playlist-edit-list');
|
||||
playlistData.paths = [...container.querySelectorAll("li")].map((node) => node.innerText);
|
||||
const saveRes = await fetch(`/api/v1/playlist/${playlistId}`,
|
||||
@@ -714,7 +787,9 @@
|
||||
/* ----------------------------------------
|
||||
PLAYLIST EDITOR LOGIC - ADD FILES SCREEN
|
||||
------------------------------------------- */
|
||||
Screens.playlist_filebrowser = (() => {
|
||||
Screens.filebrowser = (() => {
|
||||
let isFilesystemMode = false;
|
||||
|
||||
function init() {
|
||||
document.getElementById('playlist-filebrowser-cancel').addEventListener("click", (e) => {
|
||||
showScreen("playlist_edit", {});
|
||||
@@ -733,9 +808,32 @@
|
||||
});
|
||||
tree.init();
|
||||
}
|
||||
|
||||
|
||||
async function onShow(intent) {
|
||||
console.log(intent)
|
||||
document.getElementById('playlist-filebrowser-addtrack').disabled = true;
|
||||
|
||||
const title = document.getElementById('playlist-filebrowser-title');
|
||||
const cancelButton = document.getElementById('playlist-filebrowser-cancel');
|
||||
const addTracksButton = document.getElementById('playlist-filebrowser-addtrack');
|
||||
|
||||
if (intent !== 'refresh') {
|
||||
isFilesystemMode = (intent === 'filesystem');
|
||||
|
||||
document.getElementById('playlist-filebrowser-upload-progress').value = 0;
|
||||
document.getElementById("playlist-filebrowser-upload-files").value = "";
|
||||
}
|
||||
|
||||
if (isFilesystemMode) {
|
||||
title.innerText = "Filesystem";
|
||||
cancelButton.style.display = 'none';
|
||||
addTracksButton.style.display = 'none';
|
||||
} else {
|
||||
title.innerText = "Playlist Editor";
|
||||
cancelButton.style.display = ''
|
||||
addTracksButton.style.display = ''
|
||||
}
|
||||
|
||||
tree = document.getElementById("playlist-filebrowser-tree");
|
||||
tree.innerHTML = "Loading...";
|
||||
fetch('/api/v1/audiofiles')
|
||||
@@ -770,7 +868,6 @@
|
||||
if (type === 'directory') {
|
||||
const nested = document.createElement('ul');
|
||||
node.appendChild(nested);
|
||||
node.classList.add('expanded');
|
||||
parent.appendChild(node);
|
||||
return nested;
|
||||
}
|
||||
@@ -857,13 +954,13 @@
|
||||
}
|
||||
if (donecount + 1 === totalcount) {
|
||||
// Reload file list from device
|
||||
onShow();
|
||||
onShow('refresh');
|
||||
} else {
|
||||
uploadFileHelper(totalcount, donecount + 1, destdir, files.slice(1));
|
||||
}
|
||||
}
|
||||
};
|
||||
xhr.open("POST", `/api/v1/audiofiles?type=file&location=${location}`);
|
||||
xhr.open("POST", `/api/v1/audiofiles?type=file&location=${encodeURIComponent(location)}`);
|
||||
xhr.overrideMimeType("audio/mpeg");
|
||||
xhr.send(files[0]);
|
||||
}
|
||||
@@ -881,10 +978,10 @@
|
||||
const location = selectedNodes.length === 1
|
||||
? selectedNodes[0].getAttribute('data-path') + '/' + name.value
|
||||
: '/' + name.value;
|
||||
const saveRes = await fetch(`/api/v1/audiofiles?type=directory&location=${location}`,
|
||||
const saveRes = await fetch(`/api/v1/audiofiles?type=directory&location=${encodeURIComponent(location)}`,
|
||||
{method: 'POST'});
|
||||
// Reload file list from device
|
||||
onShow();
|
||||
onShow('refresh');
|
||||
}
|
||||
|
||||
async function deleteItems() {
|
||||
@@ -898,14 +995,14 @@
|
||||
items.sort();
|
||||
items.reverse();
|
||||
for (const item of items) {
|
||||
const saveRes = await fetch(`/api/v1/audiofiles?location=${item}`,
|
||||
const saveRes = await fetch(`/api/v1/audiofiles?location=${encodeURIComponent(item)}`,
|
||||
{method: 'DELETE'});
|
||||
if (!saveRes.ok) {
|
||||
alert(`Failed to delete item ${item}: ${await saveRes.text()}`);
|
||||
}
|
||||
}
|
||||
// Reload file list from device
|
||||
onShow();
|
||||
onShow('refresh');
|
||||
}
|
||||
|
||||
let tree = (() => {
|
||||
@@ -981,6 +1078,13 @@
|
||||
});
|
||||
|
||||
showScreen("menu");
|
||||
|
||||
fetch('/api/v1/info')
|
||||
.then((resp) => resp.json())
|
||||
.then((info) => {
|
||||
const version = document.getElementById('footer-version');
|
||||
version.innerText = info.version;
|
||||
});
|
||||
</script>
|
||||
|
||||
</body>
|
||||
|
||||
Submodule software/lib/microdot updated: d864b81b65...10e740da2b
@@ -9,8 +9,8 @@ from utils import TimerManager
|
||||
Dependencies = namedtuple('Dependencies', ('mp3player', 'nfcreader', 'buttons', 'playlistdb', 'hwconfig', 'leds',
|
||||
'config'))
|
||||
|
||||
# Should be ~ 6dB steps
|
||||
VOLUME_CURVE = [1, 2, 4, 8, 16, 32, 63, 126, 251]
|
||||
# Should be ~ 3dB steps
|
||||
VOLUME_CURVE = [1, 2, 3, 4, 6, 8, 11, 16, 23, 32, 45, 64, 91, 128, 181, 255]
|
||||
|
||||
|
||||
class PlayerApp:
|
||||
@@ -52,11 +52,19 @@ class PlayerApp:
|
||||
self.hwconfig = deps.hwconfig(self)
|
||||
self.leds = deps.leds(self)
|
||||
self.tag_mode = self.config.get_tagmode()
|
||||
self.volume_max = self.config.get_volume_max()
|
||||
self.volume_pos = 3 # fallback if config.get_volume_boot is nonsense
|
||||
try:
|
||||
for idx, val in enumerate(VOLUME_CURVE):
|
||||
if val >= self.config.get_volume_boot():
|
||||
self.volume_pos = idx
|
||||
break
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
self.playing_tag = None
|
||||
self.playlist = None
|
||||
self.buttons = deps.buttons(self) if deps.buttons is not None else None
|
||||
self.mp3file = None
|
||||
self.volume_pos = 3
|
||||
self.paused = False
|
||||
self.playing = False
|
||||
self.player.set_volume(VOLUME_CURVE[self.volume_pos])
|
||||
@@ -74,7 +82,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
|
||||
self.playing_tag = new_tag if self.playlist is not None else None
|
||||
elif self.tag_mode == 'tagstartstop':
|
||||
print('Tag presented again, stopping playback')
|
||||
self._unset_playlist()
|
||||
@@ -91,8 +99,10 @@ class PlayerApp:
|
||||
def onButtonPressed(self, what):
|
||||
assert self.buttons is not None
|
||||
if what == self.buttons.VOLUP:
|
||||
self.volume_pos = min(self.volume_pos + 1, len(VOLUME_CURVE) - 1)
|
||||
self.player.set_volume(VOLUME_CURVE[self.volume_pos])
|
||||
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])
|
||||
elif what == self.buttons.VOLDOWN:
|
||||
self.volume_pos = max(self.volume_pos - 1, 0)
|
||||
self.player.set_volume(VOLUME_CURVE[self.volume_pos])
|
||||
|
||||
@@ -3,13 +3,12 @@
|
||||
|
||||
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
|
||||
@@ -37,14 +36,22 @@ hwconfig.board_init()
|
||||
machine.mem32[0x40030000 + 0x00] = 0x10
|
||||
|
||||
|
||||
def setup_wifi():
|
||||
def setup_wifi(ssid='', passphrase='', security=network.WLAN.SEC_WPA_WPA2):
|
||||
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, 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)
|
||||
|
||||
# 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}")
|
||||
@@ -65,27 +72,37 @@ 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, 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:
|
||||
np.fill((0, 0, led_max))
|
||||
np.write()
|
||||
# Force default access point
|
||||
setup_wifi('', '', network.WLAN.SEC_OPEN)
|
||||
else:
|
||||
secstring = config.get_wifi_security()
|
||||
security = network.WLAN.SEC_WPA_WPA2
|
||||
if secstring == 'open':
|
||||
security = network.WLAN.SEC_OPEN
|
||||
elif secstring == 'wpa_wpa2':
|
||||
security = network.WLAN.SEC_WPA_WPA2
|
||||
elif secstring == 'wpa3':
|
||||
security = network.WLAN.SEC_WPA3
|
||||
elif secstring == 'wpa2_wpa3':
|
||||
security = network.WLAN.SEC_WPA2_WPA3
|
||||
setup_wifi(config.get_wifi_ssid(), config.get_wifi_passphrase(), security)
|
||||
|
||||
# Setup 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:
|
||||
|
||||
@@ -99,7 +116,7 @@ def run():
|
||||
buttons=lambda the_app: Buttons(the_app, config, hwconfig),
|
||||
playlistdb=lambda _: playlistdb,
|
||||
hwconfig=lambda _: hwconfig,
|
||||
leds=lambda _: LedManager(np),
|
||||
leds=lambda _: LedManager(np, config),
|
||||
config=lambda _: config)
|
||||
the_app = app.PlayerApp(deps)
|
||||
|
||||
@@ -112,25 +129,26 @@ def run():
|
||||
asyncio.get_event_loop().run_forever()
|
||||
|
||||
|
||||
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()
|
||||
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)
|
||||
|
||||
|
||||
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((led_max, 0, 0))
|
||||
np.write()
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
# 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
|
||||
@@ -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"]
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -22,7 +22,15 @@ class Configuration:
|
||||
'PREV': None,
|
||||
'NEXT': 1,
|
||||
},
|
||||
'TAGMODE': 'tagremains'
|
||||
'TAGMODE': 'tagremains',
|
||||
'WIFI': {
|
||||
'SSID': '',
|
||||
'PASSPHRASE': '',
|
||||
'SECURITY': 'wpa_wpa2',
|
||||
},
|
||||
'VOLUME_MAX': 255,
|
||||
'VOLUME_BOOT': 16,
|
||||
'LED_MAX': 255,
|
||||
}
|
||||
|
||||
def __init__(self, config_path='/config.json'):
|
||||
@@ -87,6 +95,24 @@ 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']
|
||||
|
||||
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
|
||||
@@ -104,6 +130,9 @@ class Configuration:
|
||||
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()
|
||||
|
||||
@@ -12,10 +12,10 @@ class LedManager:
|
||||
PLAYING = const(1)
|
||||
REBOOTING = const(2)
|
||||
|
||||
def __init__(self, np):
|
||||
def __init__(self, np, config):
|
||||
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())
|
||||
|
||||
|
||||
@@ -33,12 +33,13 @@ class BTreeDB(IPlaylistDB):
|
||||
PERSIST_OFFSET = b'offset'
|
||||
|
||||
class Playlist(IPlaylist):
|
||||
def __init__(self, parent: "BTreeDB", tag: bytes, pos: int, persist, shuffle):
|
||||
def __init__(self, parent: "BTreeDB", tag: bytes, pos: int, persist, shuffle, name):
|
||||
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()
|
||||
|
||||
@@ -168,6 +169,10 @@ 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.
|
||||
@@ -222,12 +227,13 @@ class BTreeDB(IPlaylistDB):
|
||||
raise RuntimeError("Malformed playlist key")
|
||||
return int(elements[2])+1
|
||||
|
||||
def _savePlaylist(self, tag, entries, persist, shuffle, flush=True):
|
||||
def _savePlaylist(self, tag, entries, persist, shuffle, name, 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()
|
||||
|
||||
@@ -240,7 +246,7 @@ class BTreeDB(IPlaylistDB):
|
||||
pass
|
||||
for k in (self._keyPlaylistPos(tag), self._keyPlaylistPosOffset(tag),
|
||||
self._keyPlaylistPersist(tag), self._keyPlaylistShuffle(tag),
|
||||
self._keyPlaylistShuffleSeed(tag)):
|
||||
self._keyPlaylistShuffleSeed(tag), self._keyPlaylistName(tag)):
|
||||
try:
|
||||
del self.db[k]
|
||||
except KeyError:
|
||||
@@ -248,15 +254,18 @@ class BTreeDB(IPlaylistDB):
|
||||
if flush:
|
||||
self._flush()
|
||||
|
||||
def getPlaylistTags(self):
|
||||
def getPlaylists(self):
|
||||
"""
|
||||
Get a keys-only dict of all defined playlists. Playlists currently do not have names, but are identified by
|
||||
their tag.
|
||||
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])
|
||||
return playlist_tags
|
||||
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):
|
||||
"""
|
||||
@@ -275,18 +284,19 @@ 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)
|
||||
return self.Playlist(self, tag, pos, persist, shuffle, name)
|
||||
|
||||
def createPlaylistForTag(self, tag: bytes, entries: typing.Iterable[bytes], persist=PERSIST_TRACK,
|
||||
shuffle=SHUFFLE_NO):
|
||||
shuffle=SHUFFLE_NO, name: str = ''):
|
||||
"""
|
||||
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)
|
||||
self._savePlaylist(tag, entries, persist, shuffle, name)
|
||||
return self.getPlaylistForTag(tag)
|
||||
|
||||
def deletePlaylistForTag(self, tag: bytes):
|
||||
@@ -370,6 +380,14 @@ 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,11 +4,15 @@ 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
|
||||
@@ -28,7 +32,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()
|
||||
@@ -109,7 +113,7 @@ async def static(request, path):
|
||||
|
||||
@webapp.route('/api/v1/playlists', methods=['GET'])
|
||||
async def playlists_get(request):
|
||||
return sorted(playlist_db.getPlaylistTags())
|
||||
return playlist_db.getPlaylists()
|
||||
|
||||
|
||||
def is_hex(s):
|
||||
@@ -130,10 +134,11 @@ async def playlist_get(request, tag):
|
||||
return None, 404
|
||||
|
||||
return {
|
||||
'shuffle': playlist.__dict__.get('shuffle'),
|
||||
'persist': playlist.__dict__.get('persist'),
|
||||
'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
|
||||
}
|
||||
|
||||
|
||||
@@ -153,7 +158,8 @@ async def playlist_put(request, tag):
|
||||
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('shuffle', 'no').encode(),
|
||||
playlist.get('name', ''))
|
||||
return '', 204
|
||||
|
||||
|
||||
@@ -197,6 +203,25 @@ async def audiofiles_get(request):
|
||||
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']:
|
||||
@@ -210,23 +235,19 @@ async def audiofile_upload(request):
|
||||
if type_ == 'directory':
|
||||
if length != 0:
|
||||
return 'directory request may not have content', 400
|
||||
os.mkdir(path)
|
||||
try:
|
||||
os.mkdir(path)
|
||||
except OSError as ex:
|
||||
return f'error creating directory: {ex}', 500
|
||||
return '', 204
|
||||
with open(path, 'wb') as newfile:
|
||||
data = array('b', range(4096))
|
||||
bytes_copied = 0
|
||||
while True:
|
||||
bytes_read = await request.stream.readinto(data)
|
||||
if bytes_read == 0:
|
||||
# End of body
|
||||
break
|
||||
bytes_written = newfile.write(data[:bytes_read])
|
||||
if bytes_written != bytes_read:
|
||||
# short writes shouldn't happen
|
||||
return 'write failure', 500
|
||||
bytes_copied += bytes_written
|
||||
if bytes_copied == length:
|
||||
break
|
||||
try:
|
||||
with open(path, 'wb') as newfile:
|
||||
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:
|
||||
@@ -258,10 +279,9 @@ async def audiofile_delete(request):
|
||||
|
||||
@webapp.route('/api/v1/reboot/<method>', methods=['POST'])
|
||||
async def reboot(request, method):
|
||||
if hwconfig.get_on_battery():
|
||||
return 'not allowed: usb not connected', 403
|
||||
|
||||
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':
|
||||
@@ -270,3 +290,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}
|
||||
|
||||
@@ -139,6 +139,12 @@ class FakeConfig:
|
||||
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)
|
||||
@@ -167,7 +173,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
|
||||
assert fake_mp3.volume is not None and fake_mp3.volume >= 16
|
||||
|
||||
|
||||
def test_load_playlist_on_tag(micropythonify, faketimermanager, monkeypatch):
|
||||
|
||||
Reference in New Issue
Block a user