Compare commits
7 Commits
main
...
d8eb61e967
| Author | SHA1 | Date | |
|---|---|---|---|
| d8eb61e967 | |||
| a1d71964f7 | |||
| bdde8cb4c2 | |||
| 94a8c3d720 | |||
| 2971df7b68 | |||
| 18a58992f3 | |||
| 4e0af8e3fc |
20
DEVELOP.md
20
DEVELOP.md
@@ -52,23 +52,3 @@ 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.
|
||||
|
||||
@@ -147,7 +147,6 @@
|
||||
<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 -->
|
||||
@@ -243,10 +242,10 @@
|
||||
</div>
|
||||
|
||||
<!-- PLAYLIST EDITOR SCREEN 3: file browser -->
|
||||
<div id="screen-filebrowser" class="screen">
|
||||
<h2 id="playlist-filebrowser-title">Playlist Editor</h2>
|
||||
<div id="screen-playlist_filebrowser" class="screen">
|
||||
<h2>Playlist Editor</h2>
|
||||
<div id="playlist-filebrowser-container">
|
||||
<div class="scroll-container" style="margin-bottom: 10px">
|
||||
<div class="scroll-container">
|
||||
<div class="tree" id="playlist-filebrowser-tree">
|
||||
<ul><li>Loading...</li></ul>
|
||||
</div>
|
||||
@@ -358,12 +357,7 @@
|
||||
'root.TAG_TIMEOUT_SECS': 'Tag removal timeout (seconds)',
|
||||
'root.TAGMODE': 'Tag mode',
|
||||
'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'
|
||||
'root.VOLUME_MAX': 'Maximum volume (0-255)'
|
||||
};
|
||||
const config_input_override = {
|
||||
'root.TAGMODE': {
|
||||
@@ -397,29 +391,14 @@
|
||||
'root.LED_COUNT': {
|
||||
'input-type': 'number'
|
||||
},
|
||||
'root.WIFI.SSID': {
|
||||
'root.WLAN.SSID': {
|
||||
'input-type': 'text'
|
||||
},
|
||||
'root.WIFI.PASSPHRASE': {
|
||||
'root.WLAN.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'
|
||||
}
|
||||
};
|
||||
|
||||
@@ -655,7 +634,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("filebrowser", "playlist");
|
||||
showScreen("playlist_filebrowser");
|
||||
});
|
||||
document.getElementById('playlist-edit-save').addEventListener("click", (e) => save());
|
||||
}
|
||||
@@ -787,9 +766,7 @@
|
||||
/* ----------------------------------------
|
||||
PLAYLIST EDITOR LOGIC - ADD FILES SCREEN
|
||||
------------------------------------------- */
|
||||
Screens.filebrowser = (() => {
|
||||
let isFilesystemMode = false;
|
||||
|
||||
Screens.playlist_filebrowser = (() => {
|
||||
function init() {
|
||||
document.getElementById('playlist-filebrowser-cancel').addEventListener("click", (e) => {
|
||||
showScreen("playlist_edit", {});
|
||||
@@ -808,32 +785,9 @@
|
||||
});
|
||||
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')
|
||||
@@ -868,6 +822,7 @@
|
||||
if (type === 'directory') {
|
||||
const nested = document.createElement('ul');
|
||||
node.appendChild(nested);
|
||||
node.classList.add('expanded');
|
||||
parent.appendChild(node);
|
||||
return nested;
|
||||
}
|
||||
@@ -954,13 +909,13 @@
|
||||
}
|
||||
if (donecount + 1 === totalcount) {
|
||||
// Reload file list from device
|
||||
onShow('refresh');
|
||||
onShow();
|
||||
} else {
|
||||
uploadFileHelper(totalcount, donecount + 1, destdir, files.slice(1));
|
||||
}
|
||||
}
|
||||
};
|
||||
xhr.open("POST", `/api/v1/audiofiles?type=file&location=${encodeURIComponent(location)}`);
|
||||
xhr.open("POST", `/api/v1/audiofiles?type=file&location=${location}`);
|
||||
xhr.overrideMimeType("audio/mpeg");
|
||||
xhr.send(files[0]);
|
||||
}
|
||||
@@ -978,10 +933,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=${encodeURIComponent(location)}`,
|
||||
const saveRes = await fetch(`/api/v1/audiofiles?type=directory&location=${location}`,
|
||||
{method: 'POST'});
|
||||
// Reload file list from device
|
||||
onShow('refresh');
|
||||
onShow();
|
||||
}
|
||||
|
||||
async function deleteItems() {
|
||||
@@ -995,14 +950,14 @@
|
||||
items.sort();
|
||||
items.reverse();
|
||||
for (const item of items) {
|
||||
const saveRes = await fetch(`/api/v1/audiofiles?location=${encodeURIComponent(item)}`,
|
||||
const saveRes = await fetch(`/api/v1/audiofiles?location=${item}`,
|
||||
{method: 'DELETE'});
|
||||
if (!saveRes.ok) {
|
||||
alert(`Failed to delete item ${item}: ${await saveRes.text()}`);
|
||||
}
|
||||
}
|
||||
// Reload file list from device
|
||||
onShow('refresh');
|
||||
onShow();
|
||||
}
|
||||
|
||||
let tree = (() => {
|
||||
|
||||
@@ -53,18 +53,11 @@ class PlayerApp:
|
||||
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])
|
||||
@@ -82,7 +75,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()
|
||||
|
||||
@@ -3,9 +3,11 @@
|
||||
|
||||
import aiorepl # type: ignore
|
||||
import asyncio
|
||||
from errno import ENOENT
|
||||
import machine
|
||||
import micropython
|
||||
import network
|
||||
import os
|
||||
import time
|
||||
import ubinascii
|
||||
import sys
|
||||
@@ -36,19 +38,19 @@ hwconfig.board_init()
|
||||
machine.mem32[0x40030000 + 0x00] = 0x10
|
||||
|
||||
|
||||
def setup_wifi(ssid='', passphrase='', security=network.WLAN.SEC_WPA_WPA2):
|
||||
def setup_wifi(ssid='', passphrase='', sec=network.WLAN.SEC_WPA2_WPA3):
|
||||
network.hostname("TonberryPico")
|
||||
if ssid is None or ssid == '':
|
||||
if 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.config(ssid=apname, security=wlan.SEC_OPEN)
|
||||
wlan.active(True)
|
||||
else:
|
||||
print(f"Connect to SSID {ssid} with passphrase {passphrase}...")
|
||||
wlan = network.WLAN()
|
||||
wlan.active(True)
|
||||
wlan.connect(ssid, passphrase if passphrase is not None else '', security=security)
|
||||
wlan.connect(ssid, passphrase, security=sec)
|
||||
|
||||
# configure power management
|
||||
wlan.config(pm=network.WLAN.PM_PERFORMANCE)
|
||||
@@ -74,8 +76,7 @@ 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.fill((32, 32, 0))
|
||||
np.write()
|
||||
|
||||
|
||||
@@ -83,26 +84,25 @@ def run():
|
||||
asyncio.new_event_loop()
|
||||
|
||||
if machine.Pin(hwconfig.BUTTONS[1], machine.Pin.IN, machine.Pin.PULL_UP).value() == 0:
|
||||
np.fill((0, 0, led_max))
|
||||
np.fill((0, 0, 32))
|
||||
np.write()
|
||||
# Force default access point
|
||||
setup_wifi('', '', network.WLAN.SEC_OPEN)
|
||||
setup_wifi('', '')
|
||||
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_wifi(config.get_wifi_ssid(), config.get_wifi_passphrase())
|
||||
|
||||
# Setup MP3 player
|
||||
with SDContext(mosi=hwconfig.SD_DI, miso=hwconfig.SD_DO, sck=hwconfig.SD_SCK, ss=hwconfig.SD_CS,
|
||||
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:
|
||||
|
||||
@@ -116,7 +116,7 @@ def run():
|
||||
buttons=lambda the_app: Buttons(the_app, config, hwconfig),
|
||||
playlistdb=lambda _: playlistdb,
|
||||
hwconfig=lambda _: hwconfig,
|
||||
leds=lambda _: LedManager(np, config),
|
||||
leds=lambda _: LedManager(np),
|
||||
config=lambda _: config)
|
||||
the_app = app.PlayerApp(deps)
|
||||
|
||||
@@ -129,11 +129,27 @@ 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.fill((32, 0, 0))
|
||||
np.write()
|
||||
time.sleep_ms(500)
|
||||
np.fill((0, 0, 0))
|
||||
@@ -150,5 +166,5 @@ if __name__ == '__main__':
|
||||
sys.print_exception(ex)
|
||||
error_blink()
|
||||
else:
|
||||
np.fill((led_max, 0, 0))
|
||||
np.fill((32, 0, 0))
|
||||
np.write()
|
||||
|
||||
@@ -26,11 +26,8 @@ class Configuration:
|
||||
'WIFI': {
|
||||
'SSID': '',
|
||||
'PASSPHRASE': '',
|
||||
'SECURITY': 'wpa_wpa2',
|
||||
},
|
||||
'VOLUME_MAX': 255,
|
||||
'VOLUME_BOOT': 16,
|
||||
'LED_MAX': 255,
|
||||
'VOLUME_MAX': 255
|
||||
}
|
||||
|
||||
def __init__(self, config_path='/config.json'):
|
||||
@@ -101,18 +98,9 @@ class Configuration:
|
||||
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
|
||||
@@ -130,9 +118,6 @@ 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, 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())
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ Copyright (c) 2024-2025 Stefan Kratochwil <Kratochwil-LA@gmx.de>
|
||||
|
||||
import asyncio
|
||||
import board
|
||||
import errno
|
||||
import hwconfig
|
||||
import json
|
||||
import machine
|
||||
@@ -203,25 +202,6 @@ 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']:
|
||||
@@ -235,19 +215,29 @@ async def audiofile_upload(request):
|
||||
if type_ == 'directory':
|
||||
if length != 0:
|
||||
return 'directory request may not have content', 400
|
||||
try:
|
||||
os.mkdir(path)
|
||||
except OSError as ex:
|
||||
return f'error creating directory: {ex}', 500
|
||||
os.mkdir(path)
|
||||
return '', 204
|
||||
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
|
||||
with open(path, 'wb') as newfile:
|
||||
data = array('b', range(4096))
|
||||
bytes_copied = 0
|
||||
while True:
|
||||
try:
|
||||
bytes_read = await request.stream.readinto(data)
|
||||
except OSError as ex:
|
||||
return f'read error: {ex}', 500
|
||||
if bytes_read == 0:
|
||||
# End of body
|
||||
break
|
||||
try:
|
||||
bytes_written = newfile.write(data[:bytes_read])
|
||||
except OSError as ex:
|
||||
return f'write error: {ex}', 500
|
||||
if bytes_written != bytes_read:
|
||||
# short writes shouldn't happen
|
||||
return 'write failure', 500
|
||||
bytes_copied += bytes_written
|
||||
if bytes_copied == length:
|
||||
break
|
||||
if bytes_copied == length:
|
||||
return '', 204
|
||||
else:
|
||||
|
||||
@@ -142,9 +142,6 @@ class FakeConfig:
|
||||
def get_volume_max(self):
|
||||
return 255
|
||||
|
||||
def get_volume_boot(self):
|
||||
return 16
|
||||
|
||||
|
||||
def fake_open(filename, mode):
|
||||
return FakeFile(filename, mode)
|
||||
@@ -173,7 +170,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):
|
||||
|
||||
Reference in New Issue
Block a user