Merge PR #316: WAV/MP3 playback for Lua apps

oumike. Closes #315 (pisti87's request).

Two conflicts, both resolved by keeping BOTH sides rather than choosing:

  - sdRuntimeLifecycleBusy() gained an audio-playback source here and a web
    reader source in #317. They are independent consumers of the same card and
    both have to gate the mount lifecycle. The reader's self-exclusion is kept:
    it calls this from its own task while holding the card and would otherwise
    deadlock against itself.
  - The Lua harness caps table needed sd_list from #312 as well as the audio
    flags, and the test order needed the wardrive suite from #324 as well as
    audio_api.

Built on all eight S3 envs and both ESP32-P4 targets.
This commit is contained in:
Kaj Schittecat
2026-08-27 10:49:59 +02:00
25 changed files with 3984 additions and 60 deletions
+156
View File
@@ -0,0 +1,156 @@
# Testing Lua audio playback
This guide tests issue #315 with a real WAV or MP3. The examples use the
T-Deck environment and the provided `scripts/audio_test.lua` app.
## Build and flash
Build and upload the branch as usual:
```sh
pio run -e LilyGo_TDeck_companion_radio_touch
pio run -e LilyGo_TDeck_companion_radio_touch -t upload
```
Keep the serial monitor available for `[LUAAPP]` or storage errors:
```sh
pio device monitor -b 115200
```
## Quick MP3 test from SD
1. Use a FAT32 SD card and create these paths on it:
```text
/Music/test.mp3
/meshcomod/apps/audio_test.lua
```
2. Copy `scripts/audio_test.lua` from this repository to the second path. Rename
your MP3 to `test.mp3`, or edit `SD_PATH` near the top of the Lua file.
3. Insert the card before boot, then reboot the T-Deck.
4. Enable **Settings > Sound** and set volume above zero.
5. Open **Apps > audio_test**. The screen should report
`audio=true`, `wav=true`, and `mp3=true`.
6. Press **Play**. Expected status:
```text
State: playing
Source: sd Format: mp3
```
7. Exercise **Pause**, **Resume**, and **Stop**. Pressing **Play** while already
playing must restart/replace the current track without overlapping it.
8. Start playback and leave the app. Audio must stop immediately when the app
closes.
The direct SD API used by the test is:
```lua
local ok, err = wada.audio.play("sd:/Music/test.mp3")
```
`sd:` paths are absolute from the physical card root. Empty segments,
backslashes, trailing slashes, `.` and `..` are rejected.
## Test app-local/internal storage
A plain filename is resolved inside the current app's private directory. For
`audio_test.lua`, the logical files are:
```text
/apps/audio_test.lua
/apps/audio_test.d/test.mp3
```
On T-Deck and Pager, internal app storage is SPIFFS. The least destructive way
to populate it is the repository's serial uploader:
1. Power off, remove the SD card, and reboot. This forces T-Deck app storage to
the internal SPIFFS backend.
2. Make a short internal-storage fixture. T-Deck has a 3.375 MB SPIFFS partition
shared with settings and history, so use the full song for SD testing and a
small clip here:
```sh
ffmpeg -y -i "/path/to/your/file.mp3" \
-t 10 -ac 1 -ar 22050 -b:a 48k \
/tmp/wadamesh-audio-test.mp3
```
3. Find the serial port with `pio device list`.
4. Install `pyserial` once if needed:
```sh
python3 -m pip install pyserial
```
5. Upload the app and short MP3. Replace the port as needed:
```sh
python3 scripts/sideload_app.py \
--port /dev/cu.usbmodem101 \
--remote /apps/audio_test.lua \
scripts/audio_test.lua
python3 scripts/sideload_app.py \
--port /dev/cu.usbmodem101 \
--remote /apps/audio_test.d/test.mp3 \
--reboot \
/tmp/wadamesh-audio-test.mp3
```
6. Open **Apps > audio_test** after reboot. With no card inserted, the app
selects `test.mp3`; press **Play**.
7. Expected status is `Source: app Format: mp3`.
The app-local API is simply:
```lua
local ok, err = wada.audio.play("test.mp3")
```
Tanmatsu uses internal FFat and T-Display P4 uses internal LittleFS. The same
serial destination `/apps/audio_test.d/test.mp3` follows the active internal
backend, so no Lua code changes are needed. Direct `sd:` playback is shown only
when `wada.sys.caps().audio_sd` is true.
Do not use `uploadfs` on a device with data you need to keep: a filesystem-image
upload replaces the internal storage partition, including settings and history.
## Error and lifecycle checks
- Disable Sound, then press **Play**: expect `Command: muted`.
- Rename the file or remove the card: expect `not found` or `no sd`.
- Try a non-WAV/MP3 extension: expect `unsupported format`.
- Remove the card during SD playback only after the baseline test. Playback must
enter `error` or stop without rebooting; reinserting the card should allow a
later play after the normal SD recovery delay.
- Receive a mesh notification during playback: it must not start a second audio
stream over the media track.
- Repeatedly open/play/close the app: each close must release the worker, codec,
amplifier, file handle, and storage lease.
## Host decoder regression test
The standalone test validates the pinned decoder and the same rolling 16 KB
input algorithm used by firmware:
```sh
c++ -std=c++17 -O2 test/test_minimp3_decoder.cpp -o /tmp/test_minimp3_decoder
/tmp/test_minimp3_decoder /path/to/your/file.mp3
```
A successful run prints decoded frames, samples per channel, sample rate,
channels, and bitrate changes. The implementation has also been checked with
CBR, VBR, leading ID3v2, trailing ID3v1, and trailing APEv2 metadata.
## Supported formats
- WAV: PCM, 16-bit, mono or stereo, 8-48 kHz.
- MP3: MPEG Layer III, mono or stereo, 8-48 kHz, CBR or VBR, with common ID3 and
APEv2 metadata.
Stereo is downmixed to the device's mono speaker path. `status()` reports
`stopped`, `playing`, `paused`, `ended`, or `error`.
+7
View File
@@ -54,6 +54,13 @@ ed25519 (bundled in lib/ed25519)
Orson Peters / orlp — public domain (Creative Commons Zero / zlib)
https://github.com/orlp/ed25519
minimp3 (bundled in lib/minimp3)
lieff/minimp3 contributors — CC0 1.0 Universal
https://github.com/lieff/minimp3
Pinned at ea99364f61c14656440e8d77e9c233ccf3124633. The bundled header has
a small opt-in scratch-storage hook so the decoder workspace can live in
PSRAM; default upstream behavior is unchanged when that hook is not used.
AsyncElegantOTA (vendored in arch/esp32/AsyncElegantOTA)
Copyright (c) Ayush Sharma — MIT License
+4
View File
@@ -42,6 +42,10 @@ the transports, …) are dropped from the lib via `-DMC_VENDORED_TOUCH_APP` so t
aren't compiled twice. The build is byte-identical to the original in-tree
meshcomod firmware.
Lua apps use the on-device SDK described in [LUA_APPS.md](LUA_APPS.md). For
WAV/MP3 playback, including a ready-to-sideload transport test app and exact SD
and internal-storage paths, see [AUDIO_PLAYBACK_TESTING.md](AUDIO_PLAYBACK_TESTING.md).
## Build
[PlatformIO](https://platformio.org/) pulls the core fork and all libraries
+4 -2
View File
@@ -16,8 +16,10 @@ One Lua file plus a small manifest. Apps talk to the firmware through the `wada.
API — `wada.ui` (widgets, colours, a text prompt), `wada.sys`, `wada.store`
(persistence), `wada.timer`, and on the larger boards `wada.fs`, `wada.net`,
`wada.crypto` and the writable half of `wada.mesh`. Supported boards also expose
read-only SD directory metadata through `wada.sd`; file contents and writes stay
inaccessible. There is no general-purpose filesystem or network access; the API
asynchronous WAV/MP3 playback through `wada.audio`; plain filenames come from the
app sandbox on internal flash, SD, or SD_MMC. Read-only physical-SD directory
metadata is available through `wada.sd`, and direct card playback uses an explicit
`sd:/...` path. There is no general-purpose filesystem or network access; the API
is the whole surface, which is what makes reviewing tractable. It is documented at
[wadamesh.com/sdk.html](https://wadamesh.com/sdk.html).
+2 -2
View File
@@ -21,8 +21,8 @@
{
"id": "sdktest",
"name": "SDK Test",
"ver": "1.6",
"desc": "Developer tool. Checks the extended Lua SDK on this board: capabilities, clock, battery, GPS, private file access, read-only SD listing, crypto against published RFC vectors, channel discovery, and buttons that TRANSMIT test messages."
"ver": "1.7",
"desc": "Developer tool. Checks the extended Lua SDK on this board: capabilities, clock, battery, GPS, private file access, audio API, read-only SD listing, crypto against published RFC vectors, channel discovery, and buttons that TRANSMIT test messages."
},
{
"id": "2048",
+1
View File
@@ -0,0 +1 @@
{"id":"sdktest","name":"SDK Test","ver":"1.7","desc":"Developer tool. Checks the extended Lua SDK on this board: capabilities, clock, battery, GPS, private files, audio API, read-only SD listing, crypto vectors, packet identity, channels, and opt-in transmit calls."}
+293
View File
@@ -0,0 +1,293 @@
-- SDK self-test. Exercises the extended SDK so the results can be read off the
-- screen instead of inferred from a build log. Published to the store as a
-- developer/bench tool.
--
-- 1.2 adds wada.crypto (checked against published RFC vectors, so a PASS here is
-- real evidence and not just "it returned something"), wada.mesh.channels, and
-- wada.mesh.send_dm.
-- 1.3 adds the discovery surface (wada.mesh.discover / discovered), packet
-- identity in rx_log, exact micro-degree coordinates, wada.ui.input and the
-- windowed wada.fs.read.
-- 1.6 adds wada.sd.list: capability reporting, traversal rejection, root
-- metadata, and a nested directory listing when the card contains one.
-- 1.7 adds storage-neutral wada.audio capability and API-shape checks. Playback
-- stays manual so opening the bench test never emits sound unexpectedly.
-- 1.5 uses wada.ui.text_lines() to measure instead of estimating, where the
-- firmware has it. That call was added because of the 1.3 bug below: an app
-- could ask how tall a line is but not how wide, so laying out rows meant
-- guessing whether text would wrap. Falls back to the 1.4 estimate on older
-- firmware, so it still lays out correctly there.
-- 1.4 fixes rows drawing on top of one another. label:width() turns wrapping
-- on, so any line wider than the screen became two lines while the caller still
-- advanced by one, and the next row landed on top. Reported with a photo of the
-- channels line sitting across the contacts line. row() now owns the cursor and
-- advances by the height the text actually needs.
local ui, sys, store, timer = wada.ui, wada.sys, wada.store, wada.timer
local C = ui.colors
local app = {}
local rows, keyline, sendline, dmline, msgline = {}, nil, nil, nil, nil
local discline, inputline = nil, nil
local W = 300
-- row() owns the vertical cursor. Every caller used to pass a y and then add a
-- hand-picked constant, which is only correct while the text fits on one line.
-- An app cannot measure rendered text, so the wrapped line count is estimated
-- from a deliberately pessimistic character width: over-estimating costs a
-- little whitespace, under-estimating overlaps the next row.
local LH, GAP = 14, 3
local cy = 4
local function row(text, color, extra_gap)
local l = ui.label(text, 6, cy, 12, color or C.text)
l:width(W - 12)
rows[#rows + 1] = l
local n
if ui.text_lines then
n = ui.text_lines(tostring(text), W - 12, 12) -- measured: exact
else
local cpl = math.max(16, (W - 12) // 7) -- older firmware: estimate
n = 0
for seg in (tostring(text) .. "\n"):gmatch("(.-)\n") do
n = n + math.max(1, math.ceil(#seg / cpl))
end
end
cy = cy + math.max(1, n or 1) * LH + GAP + (extra_gap or 0)
return l
end
local function yn(v) return v and "yes" or "NO" end
function app.on_open(w, h)
W = w or 300
ui.scroll(true)
cy = 4
local y
local c = sys.caps()
row("caps: sdk_ext=" .. yn(c.sdk_ext) .. " kbd=" .. yn(c.keyboard) ..
" touch=" .. yn(c.touch) .. " sd=" .. yn(c.sd), C.accent)
row("caps: sd_list=" .. yn(c.sd_list) .. " discover=" .. yn(c.discover) ..
" input=" .. yn(c.input) ..
" rx_identity=" .. yn(c.rx_identity), C.accent)
row("caps: audio=" .. yn(c.audio) .. " wav=" .. yn(c.audio_wav) ..
" mp3=" .. yn(c.audio_mp3) .. " audio_sd=" .. yn(c.audio_sd), C.accent)
if c.audio then
local api_ok = wada.audio and type(wada.audio.play) == "function" and
type(wada.audio.pause) == "function" and
type(wada.audio.resume) == "function" and
type(wada.audio.stop) == "function" and
type(wada.audio.status) == "function"
local status = api_ok and wada.audio.status() or nil
api_ok = api_ok and type(status) == "table" and type(status.state) == "string"
row("wada.audio API: " .. (api_ok and ("PASS (" .. status.state .. ")") or "FAIL"),
api_ok and C.good or C.bad)
else
row("wada.audio: unavailable on this board", C.sub)
end
row("layout: " .. (ui.text_lines and "measured (ui.text_lines)" or "estimated (older firmware)"),
ui.text_lines and C.good or C.sub)
-- crypto: published test vectors, so this is checkable rather than merely alive
if wada.crypto then
local hex = wada.crypto.hex
local k20 = string.rep(string.char(0x0b), 20)
local checks = {
{ "sha256('abc')", hex(wada.crypto.sha256("abc")),
"ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad" },
{ "sha1('abc')", hex(wada.crypto.sha1("abc")),
"a9993e364706816aba3e25717850c26c9cd0d89d" },
{ "hmac_sha1 RFC2202#1", hex(wada.crypto.hmac_sha1(k20, "Hi There")),
"b617318655057264e28bc0b6fb378c8ef146be00" },
{ "hmac_sha256 RFC4231#1", hex(wada.crypto.hmac_sha256(k20, "Hi There")),
"b0344c61d8db38535ca8afceaf0bf12b881dc200c9833da726e9376c2e32cff7" },
}
local allok = true
for _, t in ipairs(checks) do
local ok = (t[2] == t[3])
if not ok then allok = false end
row("crypto " .. t[1] .. ": " .. (ok and "PASS" or ("FAIL got " .. tostring(t[2]):sub(1, 16))),
ok and C.good or C.bad)
end
-- The point of having it in C: this loop would blow the instruction budget in Lua.
local t0 = sys.millis()
for _ = 1, 200 do wada.crypto.hmac_sha1(k20, "Hi There") end
row(string.format("crypto: 200 x hmac_sha1 in %d ms%s", sys.millis() - t0,
allok and "" or " (VECTORS FAILED)"), allok and C.good or C.bad)
else
row("wada.crypto: MISSING", C.bad)
end
-- channel discovery
local chans = wada.mesh.channels and wada.mesh.channels() or nil
if chans then
local names = table.concat(chans, ", ")
row("channels(): " .. #chans .. " " .. names:sub(1, 60), C.text)
else
row("wada.mesh.channels: MISSING", C.bad)
end
if not c.sdk_ext then
row("extended SDK is OFF on this board - stopping here.", C.sub)
return
end
-- contacts, so send_dm has a target to name. 1.3 also checks the pubkey field.
local cts = wada.mesh.contacts()
local first = cts[1] and cts[1].name or nil
row("contacts: " .. #cts .. (first and (" first=" .. first) or ""), C.text)
if cts[1] then
local pk = cts[1].pubkey
row("contacts[1].pubkey: " .. tostring(pk) ..
(type(pk) == "string" and #pk == 8 and " PASS" or " FAIL"),
(type(pk) == "string" and #pk == 8) and C.good or C.bad)
end
local me = wada.mesh.self()
row("self: " .. tostring(me.name) .. " pubkey=" .. tostring(me.pubkey), C.text)
-- Exact coordinates. Lua's floats here are 32-bit, so an app that logs a track
-- must use the _e6 integers; this proves they are present and consistent.
local fix = sys.gps()
if fix then
local drift = math.abs(fix.lat_e6 / 1e6 - fix.lat)
row(string.format("gps: %d,%d e6 alt %dm %d sats drift %.6f %s",
fix.lat_e6, fix.lon_e6, fix.alt_m or 0, fix.sats, drift,
drift < 0.001 and "PASS" or "FAIL"), drift < 0.001 and C.good or C.bad)
else
row("gps: no fix (normal indoors)", C.sub)
end
-- rx_log identity: adverts carry a real public key, addressed frames carry
-- one-byte hashes, everything else carries nothing. All three are correct.
local log = wada.mesh.rx_log()
local withpk, withsrc = 0, 0
for _, r in ipairs(log) do
if r.pubkey then withpk = withpk + 1 end
if r.src then withsrc = withsrc + 1 end
end
row(string.format("rx_log: %d frames, %d with a pubkey (adverts), %d with src/dst",
#log, withpk, withsrc), #log > 0 and C.text or C.sub)
-- fs: windowed read. Writes once (the 1/sec limit means one write per open).
if wada.fs then
local probe = "0123456789abcdef"
wada.fs.write("sdktest.bin", probe)
local part, total = wada.fs.read("sdktest.bin", 4, 4)
local ok = (part == "4567" and total == #probe)
row("fs.read(name,4,4): " .. tostring(part) .. " total=" .. tostring(total) ..
(ok and " PASS" or " FAIL"), ok and C.good or C.bad)
end
-- Physical SD listing is read-only and independently feature-detected. A
-- supported board with no inserted card is a normal bench state, not a test
-- failure; API shape and path rejection can still be checked separately.
if c.sd_list then
if not wada.sd or not wada.sd.list then
row("wada.sd.list: MISSING despite caps", C.bad)
else
local guard_ok, guard_failure = true, nil
local bad_paths = { "/..", "/.", "/a/../b", "/a//b", "/a/", "relative", "/a\\b" }
for _, path in ipairs(bad_paths) do
local rejected, baderr = wada.sd.list(path)
if rejected ~= nil or baderr ~= "bad path" then
guard_ok = false
guard_failure = path .. " -> " .. tostring(baderr)
break
end
end
row("sd traversal guard: " .. (guard_ok and "PASS" or ("FAIL " .. guard_failure)),
guard_ok and C.good or C.bad)
local entries, err = wada.sd.list("/")
if not entries then
local note = err == "busy" and " (retry later)" or " (no readable card)"
row("sd.list('/'): " .. tostring(err) .. note, C.sub)
else
local metadata_ok, first_dir = true, nil
for _, entry in ipairs(entries) do
if type(entry.name) ~= "string" or
(entry.type ~= "file" and entry.type ~= "dir") or
type(entry.size) ~= "number" or type(entry.mtime) ~= "number" then
metadata_ok = false
end
if not first_dir and entry.type == "dir" then first_dir = entry.name end
end
row("sd.list('/'): " .. #entries .. " entries, metadata " ..
(metadata_ok and "PASS" or "FAIL") ..
(entries.truncated and " (truncated)" or ""),
metadata_ok and C.good or C.bad)
if first_dir then
local nested, nested_err = wada.sd.list("/" .. first_dir)
row("sd.list('/" .. first_dir .. "'): " ..
(nested and (#nested .. " entries PASS") or ("FAIL " .. tostring(nested_err))),
nested and C.good or C.bad)
else
row("sd nested listing: no directory on card to test", C.sub)
end
end
end
else
row("wada.sd.list: unavailable on this board", C.sub)
end
sendline = row("mesh.send: not tried yet", C.sub)
dmline = row("mesh.send_dm: not tried yet", C.sub)
msgline = row("on_message: waiting (needs the read permissions)", C.sub)
y = cy + 4
ui.button("Send to Public", 6, y, 120, 30, function()
local ok, err = wada.mesh.send("Public", "wadamesh SDK self-test")
sendline:set("mesh.send: " .. tostring(ok) .. " " .. tostring(err))
sendline:color(ok and C.good or C.bad)
end)
ui.button("DM first contact", 132, y, 130, 30, function()
if not first then dmline:set("mesh.send_dm: no contacts to target"); dmline:color(C.bad); return end
local ok, err = wada.mesh.send_dm(first, "wadamesh SDK self-test (DM)")
dmline:set("mesh.send_dm -> " .. first .. ": " .. tostring(ok) .. " " .. tostring(err))
dmline:color(ok and C.good or C.bad)
end)
cy = y + 38
discline = row("mesh.discover: not tried yet", C.sub)
inputline = row("ui.input: not tried yet", C.sub)
-- Probing TRANSMITS and makes every neighbour reply, so it is a button, not
-- something this app does on open.
y = cy + 4
ui.button("Probe", 6, y, 90, 30, function()
local tag, err = wada.mesh.discover()
if not tag then
discline:set("mesh.discover: " .. tostring(err)); discline:color(C.bad); return
end
discline:set("mesh.discover: sent, waiting for replies..."); discline:color(C.text)
end)
ui.button("Results", 102, y, 90, 30, function()
local hits = wada.mesh.discovered()
if #hits == 0 then
discline:set("discovered(): nothing yet - probe, then wait a few seconds")
discline:color(C.sub); return
end
local h = hits[1]
discline:set(string.format("discovered(): %d first %s snr %.1f/%.1f %s",
#hits, h.name or h.pubkey, h.snr, h.their_snr, h.direct and "direct" or (h.hops .. "h")))
discline:color(C.good)
end)
y = y + 34
ui.button("Beep", 6, y, 70, 30, function() sys.beep() end)
ui.button("Input", 82, y, 90, 30, function()
ui.input("Type anything", "hello", function(text)
inputline:set("ui.input -> " .. (text and ("'" .. text .. "'") or "cancelled"))
inputline:color(text and C.good or C.sub)
end)
end)
end
function app.on_input(ev)
if ev.type == "key" and keyline then
keyline:set("keys: got '" .. tostring(ev.key) .. "'")
keyline:color(C.good)
end
end
-- Proves the kind field and that DMs/rooms reach an app at all, not just channels.
function app.on_message(m)
if not msgline then return end
msgline:set("on_message: kind=" .. tostring(m.kind) .. " from=" .. tostring(m.sender) ..
" ch=" .. tostring(m.channel) .. " text=" .. tostring(m.text):sub(1, 20))
msgline:color(C.good)
end
return app
+21 -2
View File
@@ -141,6 +141,7 @@
<a href="#net">wada.net</a>
<a href="#store">wada.store</a>
<a href="#fs">wada.fs</a>
<a href="#audio">wada.audio</a>
<a href="#sd">wada.sd</a>
<a href="#sys">wada.sys</a>
<a href="#timer">wada.timer</a>
@@ -441,6 +442,24 @@ end</code></pre>
</div></div>
</section>
<section class="doc" id="audio">
<h2>wada.audio <span class="chip">audio</span></h2>
<div class="row"><div class="prose">
<p>Asynchronous audio playback from the current app's private storage. A plain name such as <code>track.wav</code> resolves inside the same app folder as <a href="#fs">wada.fs</a>, whether that folder lives on internal flash, SD, or SD_MMC. Check <code>wada.sys.caps().audio</code> and <code>audio_wav</code> before using it.</p>
<table class="api">
<tr><th>Call</th><th>Returns / does</th></tr>
<tr><td>wada.audio.play(name)</td><td>Starts or replaces playback and returns <code>true</code>, or <code>nil, error</code>. Plain names use app-private storage. On boards with <code>caps().audio_sd</code>, <code>sd:/Music/track.wav</code> reads directly from the physical card.</td></tr>
<tr><td>wada.audio.pause()</td><td>Pauses an active track. Returns whether the command was accepted.</td></tr>
<tr><td>wada.audio.resume()</td><td>Resumes a paused track. Returns whether the command was accepted.</td></tr>
<tr><td>wada.audio.stop()</td><td>Stops the active track. Returns whether the command was accepted.</td></tr>
<tr><td>wada.audio.status()</td><td><code>{state, path, source, format, error}</code>. State is <code>stopped</code>, <code>playing</code>, <code>paused</code>, <code>ended</code>, or <code>error</code>; <code>error</code> is present only after a playback failure.</td></tr>
</table>
<p>Supported formats are PCM WAV (16-bit mono or stereo at 8&ndash;48&nbsp;kHz) and MPEG Layer III MP3, including CBR, VBR, and ID3-tagged files. Stereo is mixed to mono on the device. Check <code>audio_wav</code> or <code>audio_mp3</code> rather than assuming a format exists on older firmware.</p>
<p>Playback follows the user's Sound switch and volume, never blocks the Lua callback, and stops automatically when the app closes. A second <code>play()</code> replaces the current track. Playlist ordering belongs to the app, so there are no ambiguous host-side <code>next()</code> or <code>previous()</code> calls.</p>
<p>Plain names follow the same sandbox rules as <code>wada.fs</code>. Explicit <code>sd:</code> paths follow <a href="#sd">wada.sd</a> path validation. Expected errors include <code>bad path</code>, <code>no storage</code>, <code>no sd</code>, <code>not found</code>, <code>busy</code>, <code>muted</code>, and <code>unsupported format</code>.</p>
</div></div>
</section>
<section class="doc" id="sd">
<h2>wada.sd <span class="chip">sd_list</span></h2>
<div class="row"><div class="prose">
@@ -466,7 +485,7 @@ end</code></pre>
<tr><td>wada.sys.datetime()</td><td><code>{year, month, day, hour, min, sec, wday}</code> in local time. <code>wday</code> is 0 for Sunday.</td></tr>
<tr><td>wada.sys.tr(s)</td><td>Translate <code>s</code> through the device's active language, using the same table the firmware's own interface uses. Returns <code>s</code> unchanged when there is no translation, so it is always safe to wrap a string. Add your keys to <code>deploy/apps/lang/&lt;code&gt;.lang</code> alongside the firmware's. Older firmware has no <code>sys.tr</code>: write <code>local tr = sys.tr or function(x) return x end</code> and call <code>tr()</code>.</td></tr>
<tr><td>wada.sys.beep()</td><td>Short beep on boards with a buzzer; silent elsewhere, and silent when the user has sound off.</td></tr>
<tr><td>wada.sys.caps()</td><td><code>{sdk_ext, keyboard, touch, sd, sd_list, compass, accel, discover, input, rx_identity, list, packets, sensors, map, measure}</code>. Check <code>sdk_ext</code> before using anything marked <span class="chip">ext</span> below, and <code>sd_list</code> before using <code>wada.sd</code>. The rest are feature flags for calls added after the extended SDK first shipped, so an app can degrade on older firmware instead of erroring.</td></tr>
<tr><td>wada.sys.caps()</td><td><code>{sdk_ext, keyboard, touch, sd, sd_list, audio, audio_wav, audio_mp3, audio_sd, compass, accel, discover, input, rx_identity, list, packets, sensors, map, measure}</code>. Check <code>sdk_ext</code> before using anything marked <span class="chip">ext</span>, <code>sd_list</code> before using <code>wada.sd</code>, and <code>audio</code> plus the format flag before using <code>wada.audio</code>. <code>audio_sd</code> means direct <code>sd:</code> paths are available; plain audio names use app storage and do not require it.</td></tr>
<tr><td>wada.sys.battery() <span class="chip">ext</span></td><td><code>{mv, pct, charging}</code>.</td></tr>
<tr><td>wada.sys.env() <span class="chip">sensors</span></td><td><code>{temp_c, humidity, pressure_hpa, alt_m}</code>, or <code>nil</code>. A field is present only when the hardware actually reported it, so you can tell "no humidity sensor" from "0% humidity". Gated on <code>caps().sensors</code> &mdash; does this board <i>have</i> the sensor rail &mdash; and <b>not</b> on <code>sdk_ext</code>, which is a memory gate. Those are different questions: the plain Heltec V4 has the Expansion Kit but not the extended SDK, and most boards with the extended SDK have no sensors at all.</td></tr>
<tr><td>wada.sys.gps() <span class="chip">ext</span></td><td><code>{lat, lon, lat_e6, lon_e6, sats, alt_m, time, speed_kmh, course}</code>, or <code>nil</code> with no fix &mdash; which is the normal indoor case, so handle it. <code>lat_e6</code>/<code>lon_e6</code> are exact micro-degrees; <code>lat</code>/<code>lon</code> are single-precision floats, so <a href="#discover">log the integers</a>. <code>alt_m</code> is metres. <code>time</code> is satellite time, absent until the receiver has decoded the date. <code>speed_kmh</code> and <code>course</code> (degrees clockwise from north) appear only on boards whose GPS provider reports them, and <code>course</code> only while actually moving &mdash; a stationary receiver has no course, so it is absent rather than 0. Treat those last three as optional.</td></tr>
@@ -587,7 +606,7 @@ end</code></pre>
<div class="row"><div class="prose">
<p>Apps run in a restricted environment. These are removed: <code>io</code>, <code>os</code>, <code>require</code>, <code>dofile</code>, and loading new chunks at runtime. Available: <code>math</code>, <code>string</code>, <code>table</code>, and the usual <code>pairs</code>, <code>ipairs</code>, <code>select</code>, <code>pcall</code>, <code>tostring</code>, <code>tonumber</code>.</p>
<p>Memory comes from a capped pool in PSRAM, so an app that allocates without bound fails its own allocation rather than starving the radio or the UI. There is an <a href="#budget">instruction budget</a> on every callback for the same reason.</p>
<p><b>The extended calls are not on every board.</b> Anything marked <span class="chip">ext</span> above &mdash; <code>wada.fs</code>, <code>wada.mesh.send</code>, <code>wada.mesh.send_dm</code>, <code>wada.mesh.discover</code>, <code>sys.battery</code>, <code>sys.gps</code> &mdash; needs a board with the memory to carry it, so the Heltec V4 keeps its RAM for the mesh instead. Call <code>wada.sys.caps().sdk_ext</code> and degrade gracefully rather than assuming. <code>wada.sd</code> additionally needs a physical card interface, reported by <code>caps().sd_list</code>.</p>
<p><b>The extended calls are not on every board.</b> Anything marked <span class="chip">ext</span> above &mdash; <code>wada.fs</code>, <code>wada.mesh.send</code>, <code>wada.mesh.send_dm</code>, <code>wada.mesh.discover</code>, <code>sys.battery</code>, <code>sys.gps</code> &mdash; needs a board with the memory to carry it, so the Heltec V4 keeps its RAM for the mesh instead. Call <code>wada.sys.caps().sdk_ext</code> and degrade gracefully rather than assuming. <code>wada.sd</code> additionally needs a physical card interface, reported by <code>caps().sd_list</code>. <code>wada.audio</code> instead follows <code>caps().audio</code>: storage may be internal, but the device still needs a stream-capable speaker path.</p>
<p>Rate limits are part of the contract, not a rainy-day guard: <code>wada.fs</code> writes are about one per second with a 32&nbsp;KB file cap, and <code>wada.mesh.send</code> has a 5&nbsp;second floor and a 180-character limit. They return <code>false, "too fast"</code> rather than throwing, and hitting them is expected &mdash; handle it.</p>
<p>None of this makes a hostile app safe, which is why the catalog is curated. It makes an <i>honest</i> app that has a bug survivable: it gets closed, and the device keeps carrying traffic.</p>
</div></div>
+117
View File
@@ -0,0 +1,117 @@
CC0 1.0 Universal
Statement of Purpose
The laws of most jurisdictions throughout the world automatically confer
exclusive Copyright and Related Rights (defined below) upon the creator and
subsequent owner(s) (each and all, an "owner") of an original work of
authorship and/or a database (each, a "Work").
Certain owners wish to permanently relinquish those rights to a Work for the
purpose of contributing to a commons of creative, cultural and scientific
works ("Commons") that the public can reliably and without fear of later
claims of infringement build upon, modify, incorporate in other works, reuse
and redistribute as freely as possible in any form whatsoever and for any
purposes, including without limitation commercial purposes. These owners may
contribute to the Commons to promote the ideal of a free culture and the
further production of creative, cultural and scientific works, or to gain
reputation or greater distribution for their Work in part through the use and
efforts of others.
For these and/or other purposes and motivations, and without any expectation
of additional consideration or compensation, the person associating CC0 with a
Work (the "Affirmer"), to the extent that he or she is an owner of Copyright
and Related Rights in the Work, voluntarily elects to apply CC0 to the Work
and publicly distribute the Work under its terms, with knowledge of his or her
Copyright and Related Rights in the Work and the meaning and intended legal
effect of CC0 on those rights.
1. Copyright and Related Rights. A Work made available under CC0 may be
protected by copyright and related or neighboring rights ("Copyright and
Related Rights"). Copyright and Related Rights include, but are not limited
to, the following:
i. the right to reproduce, adapt, distribute, perform, display, communicate,
and translate a Work;
ii. moral rights retained by the original author(s) and/or performer(s);
iii. publicity and privacy rights pertaining to a person's image or likeness
depicted in a Work;
iv. rights protecting against unfair competition in regards to a Work,
subject to the limitations in paragraph 4(a), below;
v. rights protecting the extraction, dissemination, use and reuse of data in
a Work;
vi. database rights (such as those arising under Directive 96/9/EC of the
European Parliament and of the Council of 11 March 1996 on the legal
protection of databases, and under any national implementation thereof,
including any amended or successor version of such directive); and
vii. other similar, equivalent or corresponding rights throughout the world
based on applicable law or treaty, and any national implementations thereof.
2. Waiver. To the greatest extent permitted by, but not in contravention of,
applicable law, Affirmer hereby overtly, fully, permanently, irrevocably and
unconditionally waives, abandons, and surrenders all of Affirmer's Copyright
and Related Rights and associated claims and causes of action, whether now
known or unknown (including existing as well as future claims and causes of
action), in the Work (i) in all territories worldwide, (ii) for the maximum
duration provided by applicable law or treaty (including future time
extensions), (iii) in any current or future medium and for any number of
copies, and (iv) for any purpose whatsoever, including without limitation
commercial, advertising or promotional purposes (the "Waiver"). Affirmer makes
the Waiver for the benefit of each member of the public at large and to the
detriment of Affirmer's heirs and successors, fully intending that such Waiver
shall not be subject to revocation, rescission, cancellation, termination, or
any other legal or equitable action to disrupt the quiet enjoyment of the Work
by the public as contemplated by Affirmer's express Statement of Purpose.
3. Public License Fallback. Should any part of the Waiver for any reason be
judged legally invalid or ineffective under applicable law, then the Waiver
shall be preserved to the maximum extent permitted taking into account
Affirmer's express Statement of Purpose. In addition, to the extent the Waiver
is so judged Affirmer hereby grants to each affected person a royalty-free,
non transferable, non sublicensable, non exclusive, irrevocable and
unconditional license to exercise Affirmer's Copyright and Related Rights in
the Work (i) in all territories worldwide, (ii) for the maximum duration
provided by applicable law or treaty (including future time extensions), (iii)
in any current or future medium and for any number of copies, and (iv) for any
purpose whatsoever, including without limitation commercial, advertising or
promotional purposes (the "License"). The License shall be deemed effective as
of the date CC0 was applied by Affirmer to the Work. Should any part of the
License for any reason be judged legally invalid or ineffective under
applicable law, such partial invalidity or ineffectiveness shall not
invalidate the remainder of the License, and in such case Affirmer hereby
affirms that he or she will not (i) exercise any of his or her remaining
Copyright and Related Rights in the Work or (ii) assert any associated claims
and causes of action with respect to the Work, in either case contrary to
Affirmer's express Statement of Purpose.
4. Limitations and Disclaimers.
a. No trademark or patent rights held by Affirmer are waived, abandoned,
surrendered, licensed or otherwise affected by this document.
b. Affirmer offers the Work as-is and makes no representations or warranties
of any kind concerning the Work, express, implied, statutory or otherwise,
including without limitation warranties of title, merchantability, fitness
for a particular purpose, non infringement, or the absence of latent or
other defects, accuracy, or the present or absence of errors, whether or not
discoverable, all to the greatest extent permissible under applicable law.
c. Affirmer disclaims responsibility for clearing rights of other persons
that may apply to the Work or any use thereof, including without limitation
any person's Copyright and Related Rights in the Work. Further, Affirmer
disclaims responsibility for obtaining any necessary consents, permissions
or other rights required for any use of the Work.
d. Affirmer understands and acknowledges that Creative Commons is not a
party to this document and has no duty or obligation with respect to this
CC0 or use of the Work.
For more information, please see
<http://creativecommons.org/publicdomain/zero/1.0/>
+16
View File
@@ -0,0 +1,16 @@
# minimp3
This directory vendors `minimp3.h` from
[lieff/minimp3](https://github.com/lieff/minimp3) at commit
`ea99364f61c14656440e8d77e9c233ccf3124633` under CC0-1.0.
Pinned upstream and local file hashes:
- upstream `minimp3.h`: `57e437c5c1f0e8b243885d3929c8973b5e6c778451e0100ab4251d19915cb3ad`
- local `minimp3.h` with the hook below: `3f59a7f29de636ca0db0aab8a5b86739c43ac8bf9ac902010e7220f783c0ae0a`
- `LICENSE`: `6a1ee543e5282cd9061881edf462e6fdab181f328da71fc2c9a6950a80e94d01`
Wadamesh adds one opt-in `MINIMP3_SCRATCH` hook around the decoder's local
scratch object. The Lua audio worker uses it to place the roughly 16 KB decoder
workspace in PSRAM instead of its FreeRTOS task stack. Builds that do not define
the hook retain upstream behavior.
File diff suppressed because it is too large Load Diff
+104
View File
@@ -0,0 +1,104 @@
-- Manual WAV/MP3 transport test for wada.audio.
-- SD test file: /Music/test.mp3
-- App-local file: /apps/audio_test.d/test.mp3 on the active app storage.
local ui, sys, timer = wada.ui, wada.sys, wada.timer
local audio = wada.audio
local C = ui.colors
local SD_PATH = "sd:/Music/test.mp3"
local APP_PATH = "test.mp3"
local app = {}
local source = APP_PATH
local source_line, state_line, detail_line, command_line
local function show_command(ok, err)
command_line:set(ok and "Command: accepted" or ("Command: " .. tostring(err or "rejected")))
command_line:color(ok and C.good or C.bad)
end
local function refresh()
if not audio then return end
local status = audio.status()
state_line:set("State: " .. tostring(status.state))
state_line:color(status.state == "error" and C.bad or
status.state == "playing" and C.good or C.text)
local detail = "Source: " .. tostring(status.source or "-") ..
" Format: " .. tostring(status.format or "-")
if status.error then detail = detail .. " Error: " .. tostring(status.error) end
detail_line:set(detail)
detail_line:color(status.error and C.bad or C.sub)
end
function app.on_open(w, h)
local caps = sys.caps()
source = APP_PATH
if caps.audio_sd and wada.sd and wada.sd.list then
local entries = wada.sd.list("/")
if entries then source = SD_PATH end
end
ui.label("Audio playback test", 6, 4, 14, C.accent)
ui.label("audio=" .. tostring(caps.audio) ..
" wav=" .. tostring(caps.audio_wav) ..
" mp3=" .. tostring(caps.audio_mp3), 6, 24, 12, C.sub)
source_line = ui.label("File: " .. source, 6, 44, 12, C.text)
source_line:width(w - 12)
state_line = ui.label("State: stopped", 6, 64, 12, C.text)
detail_line = ui.label("Source: - Format: -", 6, 82, 12, C.sub)
detail_line:width(w - 12)
command_line = ui.label("Command: ready", 6, 100, 12, C.sub)
command_line:width(w - 12)
if not caps.audio or not audio then
command_line:set("Command: wada.audio unavailable")
command_line:color(C.bad)
return
end
local gap = 5
local button_w = math.max(54, (w - 12 - gap * 3) // 4)
local x = 6
ui.button("Play", x, 116, button_w, 30, function()
local ok, err = audio.play(source)
show_command(ok, err)
refresh()
end)
x = x + button_w + gap
ui.button("Pause", x, 116, button_w, 30, function()
show_command(audio.pause())
refresh()
end)
x = x + button_w + gap
ui.button("Resume", x, 116, button_w, 30, function()
show_command(audio.resume())
refresh()
end)
x = x + button_w + gap
ui.button("Stop", x, 116, button_w, 30, function()
show_command(audio.stop())
refresh()
end)
if caps.audio_sd then
ui.button("Switch SD / app storage", 6, 150, math.min(w - 12, 190), 26, function()
source = source == SD_PATH and APP_PATH or SD_PATH
source_line:set("File: " .. source)
command_line:set("Command: source changed")
command_line:color(C.sub)
end)
end
timer.every(200)
refresh()
end
function app.on_tick()
refresh()
end
function app.on_close()
if audio then audio.stop() end
end
return app
+121 -2
View File
@@ -47,6 +47,13 @@ local cfg = {} -- per-scenario device config
local widgets = { canvases = 0, labels = 0, buttons = 0, scroll = false, timer_ms = nil }
local drawlog = { text = {}, circles = {}, ops = 0 }
local storekv = {}
local audio_state = { state = "stopped", path = "", source = "", format = "", error = nil }
local audio_log = {}
local function audio_host_close()
audio_state.state = "stopped"
audio_log[#audio_log + 1] = "release"
end
local function mkcanvas(w, h)
checkint(w, "canvas w"); checkint(h, "canvas h")
@@ -123,7 +130,12 @@ local function build_wada()
wada.sys.beep = function() return false end
wada.sys.caps = function() return { sdk_ext = cfg.caps.sdk_ext, keyboard = cfg.caps.keyboard,
touch = cfg.caps.touch, sd = true, compass = cfg.caps.compass,
accel = cfg.caps.accel, discover = cfg.caps.discover } end
accel = cfg.caps.accel, discover = cfg.caps.discover,
sd_list = cfg.caps.sd_list or false,
audio = cfg.caps.audio or false,
audio_wav = cfg.caps.audio or false,
audio_mp3 = cfg.caps.audio or false,
audio_sd = cfg.caps.audio_sd or false } end
if cfg.caps.sdk_ext then
wada.sys.battery = function() return { mv = 3900, pct = 70, charging = false } end
wada.sys.gps = function() return cfg.gps and cfg.gps() or nil end
@@ -153,6 +165,56 @@ local function build_wada()
wada.timer.every = function(ms) checkint(ms, "timer.every"); if ms < 33 then ms = 33 end; widgets.timer_ms = ms end
wada.timer.stop = function() widgets.timer_ms = nil end
if cfg.caps.audio then
wada.audio = {}
wada.audio.play = function(path)
assert(type(path) == "string", "audio.play path must be a string")
local source = "app"
if path:sub(1, 3) == "sd:" then
if not cfg.caps.audio_sd then return nil, "no sd" end
local card_path = path:sub(4)
if card_path:sub(1, 1) ~= "/" or card_path:find("//", 1, true) or
card_path:find("/../", 1, true) or card_path:sub(-3) == "/.." or
card_path:sub(-2) == "/." or card_path:sub(-1) == "/" then
return nil, "bad path"
end
source = "sd"
elseif #path == 0 or #path > 32 or path:sub(1, 1) == "." or
not path:match("^[A-Za-z0-9._-]+$") then
return nil, "bad path"
end
local format = path:lower():sub(-4)
if format ~= ".wav" and format ~= ".mp3" then return nil, "unsupported format" end
audio_state = { state = "playing", path = path, source = source,
format = format:sub(2), error = nil }
audio_log[#audio_log + 1] = "play:" .. path
return true
end
wada.audio.pause = function()
if audio_state.state ~= "playing" then return false end
audio_state.state = "paused"; audio_log[#audio_log + 1] = "pause"; return true
end
wada.audio.resume = function()
if audio_state.state ~= "paused" then return false end
audio_state.state = "playing"; audio_log[#audio_log + 1] = "resume"; return true
end
wada.audio.stop = function()
if audio_state.state ~= "playing" and audio_state.state ~= "paused" then return false end
audio_state.state = "stopped"; audio_log[#audio_log + 1] = "stop"; return true
end
wada.audio.status = function()
local out = {}
for key, value in pairs(audio_state) do out[key] = value end
return out
end
end
if cfg.caps.sd_list then
wada.sd = { list = function(path)
assert(path == "/", "mock SD only exposes the root")
return {}
end }
end
return wada
end
@@ -186,6 +248,8 @@ local function reset_world()
widgets = { canvases = 0, labels = 0, buttons = 0, scroll = false, timer_ms = nil }
labels, buttons, toasts, drawlog = {}, {}, {}, { text = {}, circles = {}, ops = 0 }
clock_ms = 1000
audio_state = { state = "stopped", path = "", source = "", format = "", error = nil }
audio_log = {}
end
-- simulated magnetometer: Earth field 0.45 G at true heading `deg` (device frame ==
@@ -740,6 +804,61 @@ scenarios.tanmatsu = function()
if app.on_close then guarded(BUDGET, app.on_close) end
end
scenarios.audio_api = function()
cfg = { w = 320, h = 196,
caps = { sdk_ext = true, keyboard = true, touch = false,
audio = true, audio_sd = true, sd_list = true } }
wada = build_wada()
local caps = wada.sys.caps()
assert(caps.audio and caps.audio_wav and caps.audio_mp3 and caps.audio_sd,
"audio capability flags do not match the WAV/MP3 contract")
assert(wada.audio and wada.audio.play and wada.audio.pause and wada.audio.resume and
wada.audio.stop and wada.audio.status, "wada.audio API is incomplete")
assert(wada.audio.play("track.wav"))
local status = wada.audio.status()
assert(status.state == "playing" and status.path == "track.wav" and
status.source == "app" and status.format == "wav", "app-storage play status is wrong")
assert(wada.audio.pause() and wada.audio.status().state == "paused", "pause failed")
assert(wada.audio.resume() and wada.audio.status().state == "playing", "resume failed")
assert(wada.audio.play("next.wav") and wada.audio.status().path == "next.wav",
"a second play must replace the active track")
assert(wada.audio.play("sd:/Music/card.wav"))
assert(wada.audio.status().source == "sd", "sd: source was not reported")
local value, err = wada.audio.play("../escape.wav")
assert(value == nil and err == "bad path", "app sandbox traversal was accepted")
value, err = wada.audio.play("sd:/Music/../escape.wav")
assert(value == nil and err == "bad path", "SD traversal was accepted")
assert(wada.audio.play("track.mp3") and wada.audio.status().format == "mp3",
"MP3 playback was not accepted or reported")
value, err = wada.audio.play("track.flac")
assert(value == nil and err == "unsupported format", "unknown audio format was accepted")
assert(wada.audio.stop() and wada.audio.status().state == "stopped", "stop failed")
assert(wada.audio.play("close.wav"))
audio_host_close()
assert(wada.audio.status().state == "stopped" and audio_log[#audio_log] == "release",
"host close did not release playback")
print(" transport + storage sandbox: PASS (" .. #audio_log .. " host calls)")
if APP_PATH:match("audio_test%.lua$") then
local app = load_app()
assert(guarded(BUDGET, app.on_open, cfg.w, cfg.h))
assert(#buttons == 5, "audio test app did not create all transport/source buttons")
buttons[1].fn(); assert(wada.audio.status().state == "playing", "Play button failed")
buttons[2].fn(); assert(wada.audio.status().state == "paused", "Pause button failed")
buttons[3].fn(); assert(wada.audio.status().state == "playing", "Resume button failed")
buttons[4].fn(); assert(wada.audio.status().state == "stopped", "Stop button failed")
buttons[5].fn(); buttons[1].fn()
assert(wada.audio.status().source == "app", "source switch did not select app storage")
guarded(BUDGET, app.on_tick)
guarded(BUDGET, app.on_close)
assert(wada.audio.status().state == "stopped", "test app close did not stop playback")
print(" scripts/audio_test.lua UI: PASS")
end
end
-- instruction cost of one heavy tick (redraw forced every tick by spinning the heading)
scenarios.cost = function()
cfg = { w = 320, h = 196, caps = { sdk_ext = true, keyboard = true, touch = false, compass = true, accel = true },
@@ -770,7 +889,7 @@ end
local order = APP_PATH:find("/wardrive/", 1, true)
and { "wardrive_utf8" }
or { "declination", "align_nofix", "bearings_absolute", "m9", "r8", "v4", "pager", "pager_portrait_jumbo", "tanmatsu", "cost" }
or { "declination", "align_nofix", "bearings_absolute", "m9", "r8", "v4", "pager", "pager_portrait_jumbo", "tanmatsu", "audio_api", "cost" }
for _, name in ipairs(order) do
if SCENARIO == "all" or SCENARIO == name then
print("== " .. name)
+13 -2
View File
@@ -22,6 +22,7 @@ Usage:
scripts/sideload_app.py --port /dev/cu.wchusbserial10 deploy/apps/gpscompass/1.0
scripts/sideload_app.py --port ... --reboot deploy/apps/gpscompass/1.0/gpscompass.lua
scripts/sideload_app.py --port ... --dest /lang deploy/apps/lang/11/de.lang
scripts/sideload_app.py --port ... --remote /apps/audio_test.d/test.mp3 ~/Music/test.mp3
Pass an app VERSION directory to send its <id>.lua + <id>.json, or individual
files. --reboot restarts the device afterwards so the drawer/Store rescan
@@ -152,6 +153,7 @@ def main():
ap.add_argument("--port", required=True)
ap.add_argument("--baud", type=int, default=115200)
ap.add_argument("--dest", default="/apps", help="/apps (default) or /lang")
ap.add_argument("--remote", help="exact remote path for one file, including /apps/<id>.d/<name>")
ap.add_argument("--reboot", action="store_true", help="reboot the device afterwards")
args = ap.parse_args()
@@ -170,10 +172,19 @@ def main():
else:
sys.exit("not found: " + p)
if args.remote:
if len(files) != 1:
sys.exit("--remote requires exactly one input file")
if not args.remote.startswith(("/apps/", "/lang/")):
sys.exit("--remote must begin with /apps/ or /lang/")
s = open_port(args.port, args.baud)
try:
for f in files:
push(s, f, "%s/%s" % (args.dest.rstrip("/"), os.path.basename(f)))
if args.remote:
push(s, files[0], args.remote)
else:
for f in files:
push(s, f, "%s/%s" % (args.dest.rstrip("/"), os.path.basename(f)))
if args.reboot:
s.write(b"reboot\n")
s.flush()
+7
View File
@@ -230,6 +230,13 @@ bool DataStore::mkdirRooted(FILESYSTEM* fs, const char* dir) {
return fs->exists(p) || fs->mkdir(p);
}
File DataStore::openWriteRootedFlatSafe(FILESYSTEM* fs, const char* filename) {
// Do not pass create=true here. Arduino's VFS implementation interprets it
// as "mkdir every parent first", but SPIFFS is flat and returns ENOTSUP for
// mkdir while still accepting slash-containing file keys.
return fs->open(_rp(filename), "w");
}
bool DataStore::removeRooted(FILESYSTEM* fs, const char* filename) {
return fs->remove(_rp(filename));
}
+1
View File
@@ -109,6 +109,7 @@ public:
File openAppend(FILESYSTEM* fs, const char* filename); // create if missing
bool fileExists(FILESYSTEM* fs, const char* filename);
bool mkdirRooted(FILESYSTEM* fs, const char* dir); // true if it exists afterwards
File openWriteRootedFlatSafe(FILESYSTEM* fs, const char* filename); // SPIFFS accepts slash keys but no mkdir
bool removeRooted(FILESYSTEM* fs, const char* filename);
bool renameFile(FILESYSTEM* fs, const char* from, const char* to);
#endif
+46 -13
View File
@@ -5445,7 +5445,7 @@ void MyMesh::checkCLIRescueCmd() {
// opens the file, "fadd <off> <len> <sum> <base64>" lines append to it
// (self-checking, see cliPutChunk), "fend" closes it.
// Same physical-access trust level as "rm" and "erase" above, with a
// NARROWER scope: only the two sideload directories the Store writes.
// NARROWER scope: Store files plus one app's private <id>.d directory.
cliPutBegin(&cli_command[5]);
} else if (memcmp(cli_command, "fadd ", 5) == 0) {
cliPutChunk(&cli_command[5]);
@@ -5470,24 +5470,51 @@ void MyMesh::checkCLIRescueCmd() {
// "Error: ...".
void MyMesh::cliPutBegin(const char* path) {
if (_cli_put) { _cli_put.close(); _cli_put_len = 0; }
_cli_put_ended = false;
const char* dir = nullptr;
if (memcmp(path, "/apps/", 6) == 0) dir = "/apps";
else if (memcmp(path, "/lang/", 6) == 0) dir = "/lang";
if (!dir) { Serial.println("Error: path must be /apps/<name> or /lang/<name>"); return; }
const char* name = path + 6;
const size_t nlen = strlen(name);
if (nlen == 0 || nlen > 40) { Serial.println("Error: bad file name length"); return; }
for (size_t i = 0; i < nlen; i++) {
const char c = name[i];
const bool ok = (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') ||
c == '.' || c == '_' || c == '-';
const char* slash = strchr(name, '/');
const char* leaf = slash ? slash + 1 : name;
const size_t dir_len = slash ? (size_t)(slash - name) : 0;
const size_t leaf_len = strlen(leaf);
if (!leaf_len || leaf_len > 40 || (slash && leaf_len > 32) ||
leaf[0] == '.' || strchr(leaf, '/')) {
Serial.println("Error: bad file name");
return;
}
for (size_t i = 0; i < leaf_len; i++) {
const char c = leaf[i];
const bool ok = (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') ||
(c >= '0' && c <= '9') || c == '.' || c == '_' || c == '-';
if (!ok) { Serial.println("Error: file name may only use A-Z a-z 0-9 . _ -"); return; }
}
if (name[0] == '.') { Serial.println("Error: file name may not start with '.'"); return; }
char app_data_dir[48] = "";
if (slash) {
// The only nested destination is /apps/<id>.d/<file>, matching wada.fs.
if (strcmp(dir, "/apps") != 0 || dir_len < 3 || dir_len > 25 ||
name[dir_len - 2] != '.' || name[dir_len - 1] != 'd' || name[0] == '.') {
Serial.println("Error: nested path must be /apps/<id>.d/<name>");
return;
}
for (size_t i = 0; i + 2 < dir_len; i++) {
const char c = name[i];
const bool ok = (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') ||
(c >= '0' && c <= '9') || c == '_' || c == '-';
if (!ok) { Serial.println("Error: bad app id"); return; }
}
snprintf(app_data_dir, sizeof app_data_dir, "/apps/%.*s", (int)dir_len, name);
}
FILESYSTEM* fs = _store->getHotDataFS();
if (!fs) { Serial.println("Error: storage not ready"); return; }
if (!_store->mkdirRooted(fs, dir)) { Serial.printf("Error: cannot create %s\n", dir); return; }
_cli_put = _store->openWrite(fs, path);
// Hierarchical filesystems create these directories. SPIFFS returns ENOTSUP
// but accepts the complete slash-containing path as a flat file key.
_store->mkdirRooted(fs, dir);
if (app_data_dir[0]) _store->mkdirRooted(fs, app_data_dir);
_cli_put = _store->openWriteRootedFlatSafe(fs, path);
if (!_cli_put) { Serial.printf("Error: cannot open %s for writing\n", path); return; }
_cli_put_len = 0;
Serial.printf("ok fput %s\n", path);
@@ -5518,7 +5545,7 @@ void MyMesh::cliPutChunk(const char* args) {
if ((s & 0xFFFF) != (sum & 0xFFFF)) { Serial.printf("Error: checksum at %u\n", (unsigned)_cli_put_len); return; }
if (olen && _cli_put.write(buf, olen) != olen) {
Serial.println("Error: write failed (card full?)");
_cli_put.close(); _cli_put_len = 0;
_cli_put.close(); _cli_put_len = 0; _cli_put_ended = false;
return;
}
_cli_put_len += olen;
@@ -5526,9 +5553,15 @@ void MyMesh::cliPutChunk(const char* args) {
}
void MyMesh::cliPutEnd() {
if (!_cli_put) { Serial.println("Error: no file open (fput first)"); return; }
if (!_cli_put) {
if (_cli_put_ended) Serial.printf("ok fend %u bytes\n", (unsigned)_cli_put_last_len);
else Serial.println("Error: no file open (fput first)");
return;
}
_cli_put.close();
Serial.printf("ok fend %u bytes\n", (unsigned)_cli_put_len);
_cli_put_last_len = _cli_put_len;
_cli_put_ended = true;
Serial.printf("ok fend %u bytes\n", (unsigned)_cli_put_last_len);
_cli_put_len = 0;
}
#endif // ESP32
+2
View File
@@ -1309,6 +1309,8 @@ private:
// Serial sideload state ("fput" / "fadd" / "fend"): the file being written.
File _cli_put;
uint32_t _cli_put_len = 0;
uint32_t _cli_put_last_len = 0;
bool _cli_put_ended = false;
void cliPutBegin(const char* path);
void cliPutChunk(const char* args);
void cliPutEnd();
+122 -1
View File
@@ -36,6 +36,18 @@ extern bool luaHostScreenOn(); // false = display asleep; app tick
extern void luaHostKeepAwake(bool on); // hold the screen + ticks for a measuring app // notification chime; false = no sounder / muted
extern fs::FS* luaHostAppFs(); // /apps storage root FS (may be null)
extern void luaHostAppPath(char* out, size_t cap, const char* rel); // prefixes the store root
#if CAP_LUA_AUDIO
extern bool luaHostAudioPlay(fs::FS* fs, const char* path, const char* shown,
const char* source, uint32_t owner,
char* error, size_t error_cap);
extern bool luaHostAudioPause(uint32_t owner, bool pause);
extern bool luaHostAudioStop(uint32_t owner, bool release);
extern void luaHostAudioStatus(uint32_t owner, char* state, size_t state_cap,
char* path, size_t path_cap,
char* source, size_t source_cap,
char* format, size_t format_cap,
char* error, size_t error_cap);
#endif
#if CAP_LUA_SD_LIST
extern fs::FS* luaHostSdFs(bool* busy); // mounted physical SD, or null
extern bool luaHostSdReadFailed(); // failed open was a dead card
@@ -185,10 +197,12 @@ struct Host {
AppTimer timers[kMaxTimers];
bool in_lua = false; // re-entrancy guard (dismiss from inside a callback)
bool want_close = false;
uint32_t generation = 0; // resource ownership across close/reopen
char id[24] = "";
char title[32] = "";
};
Host* s_h = nullptr;
uint32_t s_host_generation = 0;
char s_bar_title[40]; // appPageBegin keeps the pointer — must outlive the page
@@ -799,12 +813,17 @@ int sysBeep(lua_State* L) { lua_pushboolean(L, luaHostBeep()); return 1; }
// rather than assume: the extended SDK is absent on low-resource boards (see
// CAP_LUA_SDK_EXT in device_caps.h), and a store app runs on all of them.
int sysCaps(lua_State* L) {
lua_createtable(L, 0, 14);
lua_createtable(L, 0, 20);
lua_pushboolean(L, CAP_LUA_SDK_EXT); lua_setfield(L, -2, "sdk_ext");
lua_pushboolean(L, CAP_KEYBOARD); lua_setfield(L, -2, "keyboard");
lua_pushboolean(L, CAP_TOUCH); lua_setfield(L, -2, "touch");
lua_pushboolean(L, CAP_SD); lua_setfield(L, -2, "sd");
lua_pushboolean(L, CAP_LUA_SD_LIST); lua_setfield(L, -2, "sd_list");
lua_pushboolean(L, CAP_LUA_AUDIO); lua_setfield(L, -2, "audio");
lua_pushboolean(L, CAP_LUA_AUDIO); lua_setfield(L, -2, "audio_wav");
lua_pushboolean(L, CAP_LUA_AUDIO); lua_setfield(L, -2, "audio_mp3");
lua_pushboolean(L, CAP_LUA_AUDIO && CAP_LUA_SD_LIST);
lua_setfield(L, -2, "audio_sd");
// Feature flags for the calls added after the first extended SDK shipped, so
// an app can degrade instead of erroring on firmware that predates them.
lua_pushboolean(L, CAP_LUA_SDK_EXT); lua_setfield(L, -2, "discover"); // wada.mesh.discover
@@ -1598,6 +1617,93 @@ int sdList(lua_State* L) {
#endif
#endif // CAP_LUA_SDK_EXT
#if CAP_LUA_AUDIO
static bool audioSafeName(const char* name, size_t len) {
if (!name || len == 0 || len > 32 || name[0] == '.') return false;
for (size_t i = 0; i < len; ++i) {
const char c = name[i];
const bool ok = (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') ||
(c >= '0' && c <= '9') || c == '.' || c == '_' || c == '-';
if (!ok) return false;
}
return true;
}
static int audioError(lua_State* L, const char* error) {
lua_pushnil(L);
lua_pushstring(L, error);
return 2;
}
// wada.audio.play(name) resolves inside /apps/<id>.d on the board's active
// storage backend. An explicit sd:/... path is available only with sd_list.
int audioPlay(lua_State* L) {
if (!s_h) return audioError(L, "closed");
size_t requested_len = 0;
const char* requested = luaL_checklstring(L, 1, &requested_len);
fs::FS* fs = nullptr;
char path[224] = "";
const char* source = "app";
if (requested_len >= 3 && !memcmp(requested, "sd:", 3)) {
#if CAP_LUA_SD_LIST
const char* card_path = requested + 3;
const size_t card_len = requested_len - 3;
if (!sdSafePath(card_path, card_len, path, sizeof path)) return audioError(L, "bad path");
bool busy = false;
fs = luaHostSdFs(&busy);
if (!fs) return audioError(L, busy ? "busy" : "no sd");
source = "sd";
#else
return audioError(L, "no sd");
#endif
} else {
if (!audioSafeName(requested, requested_len)) return audioError(L, "bad path");
fs = luaHostAppFs();
if (!fs) return audioError(L, "no storage");
char rel[96];
snprintf(rel, sizeof rel, "/apps/%s.d/%s", s_h->id, requested);
luaHostAppPath(path, sizeof path, rel);
}
char error[40] = "";
if (!luaHostAudioPlay(fs, path, requested, source, s_h->generation, error, sizeof error))
return audioError(L, error[0] ? error : "playback failed");
lua_pushboolean(L, 1);
return 1;
}
int audioPause(lua_State* L) {
lua_pushboolean(L, s_h && luaHostAudioPause(s_h->generation, true));
return 1;
}
int audioResume(lua_State* L) {
lua_pushboolean(L, s_h && luaHostAudioPause(s_h->generation, false));
return 1;
}
int audioStop(lua_State* L) {
lua_pushboolean(L, s_h && luaHostAudioStop(s_h->generation, false));
return 1;
}
int audioStatus(lua_State* L) {
char state[12], path[192], source[8], format[8], error[40];
luaHostAudioStatus(s_h ? s_h->generation : 0,
state, sizeof state, path, sizeof path,
source, sizeof source, format, sizeof format,
error, sizeof error);
lua_createtable(L, 0, 5);
lua_pushstring(L, state); lua_setfield(L, -2, "state");
lua_pushstring(L, path); lua_setfield(L, -2, "path");
lua_pushstring(L, source); lua_setfield(L, -2, "source");
lua_pushstring(L, format); lua_setfield(L, -2, "format");
if (error[0]) { lua_pushstring(L, error); lua_setfield(L, -2, "error"); }
return 1;
}
#endif
void storePath(char* out, size_t cap) {
char rel[40];
snprintf(rel, sizeof rel, "/apps/%s.sav", s_h->id);
@@ -2210,6 +2316,16 @@ void openWada(lua_State* L) {
lua_setfield(L, -2, "sd");
#endif
#if CAP_LUA_AUDIO
lua_newtable(L); // wada.audio (app storage + optional SD)
lua_pushcfunction(L, audioPlay); lua_setfield(L, -2, "play");
lua_pushcfunction(L, audioPause); lua_setfield(L, -2, "pause");
lua_pushcfunction(L, audioResume); lua_setfield(L, -2, "resume");
lua_pushcfunction(L, audioStop); lua_setfield(L, -2, "stop");
lua_pushcfunction(L, audioStatus); lua_setfield(L, -2, "status");
lua_setfield(L, -2, "audio");
#endif
lua_newtable(L); // wada.mesh (read-only)
lua_pushcfunction(L, meshContacts); lua_setfield(L, -2, "contacts");
lua_pushcfunction(L, meshRxLog); lua_setfield(L, -2, "rx_log");
@@ -2435,6 +2551,9 @@ void hostTeardown() {
Host* h = s_h;
if (!h) return;
s_h = nullptr; // bindings see "closed" from here on
#if CAP_LUA_AUDIO
luaHostAudioStop(h->generation, true);
#endif
// A wada.ui.input dialog lives on lv_layer_top, so it would outlive the app
// that opened it. Drop it before the state goes, not after.
luaHostTextPromptDismiss();
@@ -2503,6 +2622,8 @@ bool luaAppLaunch(const char* id, const char* title, const char* src, size_t len
Host* h = new Host();
h->heap.cap = kHeapCap;
h->generation = ++s_host_generation;
if (!h->generation) h->generation = ++s_host_generation;
snprintf(h->id, sizeof h->id, "%s", id);
snprintf(h->title, sizeof h->title, "%s", title ? title : id);
+851 -34
View File
@@ -19,6 +19,20 @@
#include <cstdlib>
#include <cmath>
#include <cerrno> // chat-store write diagnostics surface errno (ENFILE vs ENOSPC vs EIO)
#if CAP_LUA_AUDIO
static void* wadaMp3Scratch();
#define MINIMP3_ONLY_MP3
#define MINIMP3_NO_SIMD
#define MINIMP3_SCRATCH ((mp3dec_scratch_t*)wadaMp3Scratch())
#define MINIMP3_IMPLEMENTATION
#include "../../lib/minimp3/minimp3.h"
#undef MINIMP3_IMPLEMENTATION
#undef MINIMP3_SCRATCH
#undef MINIMP3_NO_SIMD
#undef MINIMP3_ONLY_MP3
static void* s_wada_mp3_scratch = nullptr;
static void* wadaMp3Scratch() { return s_wada_mp3_scratch; }
#endif
#if defined(ESP32)
#include <time.h>
#include <SPIFFS.h>
@@ -52,6 +66,7 @@
#include <esp_core_dump.h> // detect/read/erase the panic coredump for the crash-report export
#include <freertos/FreeRTOS.h>
#include <freertos/task.h> // xTaskGetCurrentTaskHandleForCPU / pcTaskGetName — Task-WDT crash self-record
#include <freertos/queue.h>
#else
// Tanmatsu's 16M.csv has no coredump partition yet — stub so the crash-export compiles + links.
#include <esp_err.h>
@@ -843,6 +858,56 @@ static inline void tileFetchPendingDec() {
// below are shared; T-Deck and the pager each get their own I2S install/
// tone/WAV functions, since the pager additionally drives an ES8311 codec
// over I2C that the T-Deck's plain MAX98357A DAC doesn't have.
#if CAP_AUDIO_STREAM
static uint32_t wavRd32(File& f){ uint8_t b[4]; if(f.read(b,4)!=4) return 0; return (uint32_t)b[0]|((uint32_t)b[1]<<8)|((uint32_t)b[2]<<16)|((uint32_t)b[3]<<24); }
static uint16_t wavRd16(File& f){ uint8_t b[2]; if(f.read(b,2)!=2) return 0; return (uint16_t)(b[0]|(b[1]<<8)); }
static bool wavParse(File& f, uint16_t* pch, uint32_t* prate, uint32_t* pdata) {
char tag[4];
if (f.read((uint8_t*)tag,4)!=4 || memcmp(tag,"RIFF",4)) return false;
const uint32_t riff_size = wavRd32(f);
const uint64_t riff_end = 8u + (uint64_t)riff_size;
if (riff_size < 4 || riff_end > f.size() || riff_end > 0xFFFFFFFFu) return false;
if (f.read((uint8_t*)tag,4)!=4 || memcmp(tag,"WAVE",4)) return false;
uint16_t fmt=0, ch=0, bits=0; uint32_t rate=0, dlen=0; bool hf=false, hd=false;
while ((uint64_t)f.position() + 8u <= riff_end) {
if (f.read((uint8_t*)tag,4)!=4) break;
uint32_t csz = wavRd32(f);
if (!memcmp(tag,"fmt ",4)) {
if (csz < 16) return false;
fmt = wavRd16(f); ch = wavRd16(f); rate = wavRd32(f); wavRd32(f); wavRd16(f); bits = wavRd16(f);
const uint64_t skip = (uint64_t)(csz - 16) + (csz & 1u);
const uint64_t next = (uint64_t)f.position() + skip;
if (next > riff_end || (skip && !f.seek((uint32_t)next))) return false;
hf = true;
} else if (!memcmp(tag,"data",4)) {
if ((uint64_t)f.position() + csz > riff_end) return false;
dlen = csz; hd = true; break;
} else {
const uint64_t skip = (uint64_t)csz + (csz & 1u);
const uint64_t next = (uint64_t)f.position() + skip;
if (next > riff_end || (skip && !f.seek((uint32_t)next))) return false;
}
}
if (!hf || !hd || fmt != 1 || bits != 16 || (ch != 1 && ch != 2) || rate < 8000 || rate > 48000)
return false;
if (pch) *pch = ch; if (prate) *prate = rate; if (pdata) *pdata = dlen;
return true;
}
#endif
#if CAP_LUA_AUDIO
static volatile bool s_lua_audio_active = false;
static volatile uint32_t s_lua_audio_storage_pending = 0;
static volatile bool s_lua_audio_storage_active = false;
static inline bool luaAudioActive() {
return __atomic_load_n(&s_lua_audio_active, __ATOMIC_ACQUIRE);
}
static inline bool luaAudioStorageBusy() {
return __atomic_load_n(&s_lua_audio_storage_pending, __ATOMIC_ACQUIRE) != 0 ||
__atomic_load_n(&s_lua_audio_storage_active, __ATOMIC_ACQUIRE);
}
#endif
#if defined(HELTEC_LORA_V4_R8) || defined(HAS_THINKNODE_M9)
static bool fmSdTryMount(); // V4-R8/M9 microSD — fwd decl (defined in the mount-helper block below; sdRestoreRun needs it)
#endif
@@ -999,29 +1064,6 @@ static void pagerPlayToneRaw(int freq, int ms) {
// amp. Supports PCM 16-bit, mono or stereo (downmixed to the mono amp), sample
// rate read from the header; capped to ~6 s so a stray big file can't hold the
// notify task. Any problem -> false, and the caller falls back to the chime.
static uint32_t wavRd32(File& f){ uint8_t b[4]; if(f.read(b,4)!=4) return 0; return (uint32_t)b[0]|((uint32_t)b[1]<<8)|((uint32_t)b[2]<<16)|((uint32_t)b[3]<<24); }
static uint16_t wavRd16(File& f){ uint8_t b[2]; if(f.read(b,2)!=2) return 0; return (uint16_t)(b[0]|(b[1]<<8)); }
static bool wavParse(File& f, uint16_t* pch, uint32_t* prate, uint32_t* pdata) {
char tag[4];
if (f.read((uint8_t*)tag,4)!=4 || memcmp(tag,"RIFF",4)) return false;
wavRd32(f);
if (f.read((uint8_t*)tag,4)!=4 || memcmp(tag,"WAVE",4)) return false;
uint16_t fmt=0, ch=0, bits=0; uint32_t rate=0, dlen=0; bool hf=false, hd=false;
while (f.available() >= 8) {
if (f.read((uint8_t*)tag,4)!=4) break;
uint32_t csz = wavRd32(f);
if (!memcmp(tag,"fmt ",4)) {
fmt = wavRd16(f); ch = wavRd16(f); rate = wavRd32(f); wavRd32(f); wavRd16(f); bits = wavRd16(f);
if (csz > 16) f.seek(f.position() + (csz - 16));
hf = true;
} else if (!memcmp(tag,"data",4)) { dlen = csz; hd = true; break; }
else f.seek(f.position() + csz + (csz & 1));
}
if (!hf || !hd || fmt != 1 || bits != 16 || (ch != 1 && ch != 2) || rate < 8000 || rate > 48000)
return false;
if (pch) *pch = ch; if (prate) *prate = rate; if (pdata) *pdata = dlen;
return true;
}
static bool wavOpen(const char* prefpath, File& f) {
if (!prefpath || !prefpath[0]) return false;
fs::FS* fsp = &SPIFFS; const char* fp = prefpath;
@@ -1313,6 +1355,9 @@ static void tanBeep(); // I2S notification tick; defined far below (with the C
#endif
// Play the platform's notification chime. Caller checks the buzzer/sound pref.
static inline void uiPlaySlot(int slot) {
#if CAP_LUA_AUDIO
if (luaAudioActive()) return;
#endif
#if defined(HAS_TDECK_GT911)
tdeckPlayNotifySlot(slot);
#elif defined(HAS_TDISPLAY_P4)
@@ -1332,6 +1377,9 @@ static inline void uiPlayMention() { uiPlaySlot(TOUCH_SND_MEN); }
// Preview an arbitrary WAV file (not yet saved to a slot) -- used by the
// sound picker's "play" button before the user commits to a choice.
static inline void uiPreviewWavFile(const char* path) {
#if CAP_LUA_AUDIO
if (luaAudioActive()) return;
#endif
#if defined(HAS_TDECK_GT911)
tdeckPreviewWavFile(path);
#elif defined(TLORA_PAGER)
@@ -20152,8 +20200,10 @@ static void fmOpenStorage(fs::FS* fs, const char* store, const char* path) {
}
static void fmInternalClickCb(lv_event_t* e) {
if (lv_event_get_code(e) != LV_EVENT_CLICKED) return;
#if defined(HAS_TANMATSU) || defined(HAS_TDISPLAY_P4)
fmOpenStorage(&FFat, "Internal", "/"); // the internal FAT data partition (locfd / 'storage')
#if defined(HAS_TANMATSU)
fmOpenStorage(&FFat, "Internal", "/"); // internal FAT data partition (locfd)
#elif defined(HAS_TDISPLAY_P4)
fmOpenStorage(&LittleFS, "Internal", "/"); // internal LittleFS 'storage' partition
#else
fmOpenStorage(&SPIFFS, "Internal", "/");
#endif
@@ -21671,6 +21721,9 @@ static uint64_t s_tan_sd_size = 0;
static uint32_t s_tan_sd_retry_after = 0; // backoff so an absent/cold card isn't re-probed every render
static bool tanSdTryMount() {
if (s_tan_sd_mounted) return true;
#if CAP_LUA_AUDIO
if (luaAudioStorageBusy()) return false;
#endif
if (millis() < s_tan_sd_retry_after) return false;
#if defined(HAS_TDISPLAY_P4)
// Hot-insert path (no-op when main.cpp already mounted at boot): 20 MHz like the boot ladder,
@@ -39793,6 +39846,11 @@ static void tanKbBacklightTick(bool off = false) {
if (v != s_last) { s_last = v; bsp_input_set_backlight_brightness(v); }
}
static uint8_t s_volume_pct = 70;
static SemaphoreHandle_t s_tan_audio_mutex = nullptr;
static SemaphoreHandle_t tanAudioMutex() {
if (!s_tan_audio_mutex) s_tan_audio_mutex = xSemaphoreCreateMutex();
return s_tan_audio_mutex;
}
// The audio subsystem (ES8156 codec + I2S) is brought up by bsp_device_initialize()
// at boot — so here we only set the codec volume and toggle the speaker amplifier.
static void applyVolume(uint8_t pct) {
@@ -39809,11 +39867,13 @@ static void applyVolume(uint8_t pct) {
// which leaves every descriptor zeroed once played out, and the tone stops cleanly.
static void tanBeep() {
if (s_volume_pct == 0) return;
SemaphoreHandle_t mutex = tanAudioMutex();
if (!mutex || xSemaphoreTake(mutex, 0) != pdTRUE) return;
static uint32_t s_last_beep = 0; // throttle: holding the slider ramps fast, but
if (millis() - s_last_beep < 200) return; // don't machine-gun a tone on every repeat step
if (millis() - s_last_beep < 200) { xSemaphoreGive(mutex); return; } // don't machine-gun a tone on every repeat step
s_last_beep = millis();
i2s_chan_handle_t h = nullptr;
if (bsp_audio_get_i2s_handle(&h) != ESP_OK || !h) return;
if (bsp_audio_get_i2s_handle(&h) != ESP_OK || !h) { xSemaphoreGive(mutex); return; }
const int rate = 44100, freq = 880;
const int tone = rate * 30 / 1000; // ~30 ms tone …
const int total = tone + rate * 50 / 1000; // … then ~50 ms silence (> the DMA ring) flushes the loop
@@ -39832,6 +39892,7 @@ static void tanBeep() {
}
size_t wr = 0;
i2s_channel_write(h, buf, (size_t)n * 2 * sizeof(int16_t), &wr, 200 / portTICK_PERIOD_MS);
xSemaphoreGive(mutex);
}
#elif defined(HAS_TDISPLAY_P4)
// T-Display P4: the RM69A10 AMOLED has no backlight — brightness is the panel's own DCS
@@ -47303,18 +47364,22 @@ static bool uiDataFsReady() {
}
#endif
#if defined(HAS_TANMATSU) || defined(HAS_TDISPLAY_P4)
// Tanmatsu + T-Display P4: prefer the microSD card. On the Tanmatsu the internal FFat 'locfd'
// loses frequently-rewritten data (broken FAT metadata; see the tile-cache notes); on the
// T-Display P4 SD-first keeps history on the same medium the DataStore adopts. Mirror the
// T-Deck's /meshcomod root; fall back to FFat only when no card is present. Both are mounted at
// boot in main.cpp (g_sd_ok / g_fs_ok). (The P4 used to fall into the #else SPIFFS branch below —
// no SPIFFS partition exists there, so history was never persisted and vanished on every reboot.)
// Tanmatsu + T-Display P4: use each board's mounted internal data partition
// (Tanmatsu FFat 'locfd', P4 LittleFS 'storage'), with SD_MMC as fallback.
// #167: hot UI data (chat history) lives on the INTERNAL LittleFS -- SD write bursts
// electrically disturb this board's AMOLED, and the P4's internal FAT layer is broken
// (see the storage note in tdisplay_p4/main/main.cpp). SD = degraded fallback only;
// tiles keep using the SD via their own selector.
extern bool g_fs_ok;
if (g_fs_ok) { s_ui_data_fs = &LittleFS; s_ui_data_root[0] = '\0'; return true; }
if (g_fs_ok) {
#if defined(HAS_TANMATSU)
s_ui_data_fs = &FFat;
#else
s_ui_data_fs = &LittleFS;
#endif
s_ui_data_root[0] = '\0';
return true;
}
extern bool g_sd_ok;
if (g_sd_ok) {
SD_MMC.mkdir("/meshcomod");
@@ -47441,6 +47506,752 @@ fs::FS* luaHostAppFs() { return uiDataFsReady() ? s_ui_data_fs : nullptr; }
void luaHostAppPath(char* out, size_t cap, const char* rel) {
snprintf(out, cap, "%s%s", s_ui_data_root, rel); // SD-rooted stores prefix /meshcomod
}
#if CAP_LUA_AUDIO
namespace {
enum class LuaAudioCommandKind : uint8_t { Play, Pause, Resume, Stop, Release };
enum class LuaAudioState : uint8_t { Stopped, Playing, Paused, Ended, Error };
enum class LuaAudioRunResult : uint8_t { Ended, Stopped, Replaced, Released, Error };
enum class LuaAudioTaskState : uint8_t { Stopped, Starting, Running, Stopping };
enum class LuaAudioFormat : uint8_t { Wav, Mp3 };
struct LuaAudioCommand {
LuaAudioCommandKind kind = LuaAudioCommandKind::Stop;
fs::FS* fs = nullptr;
uint32_t owner = 0;
LuaAudioFormat format = LuaAudioFormat::Wav;
char path[224] = "";
char shown[192] = "";
char source[8] = "";
};
struct LuaAudioStatus {
uint32_t owner = 0;
LuaAudioState state = LuaAudioState::Stopped;
char path[192] = "";
char source[8] = "";
char format[8] = "";
char error[40] = "";
};
struct LuaAudioSinkSession {
uint32_t output_rate = 0;
float gain = 1.0f;
bool open = false;
#if defined(HAS_TANMATSU)
i2s_chan_handle_t handle = nullptr;
SemaphoreHandle_t mutex = nullptr;
#endif
};
class LuaAudioStorageLease {
public:
LuaAudioStorageLease() {
__atomic_store_n(&s_lua_audio_storage_active, true, __ATOMIC_RELEASE);
if (__atomic_load_n(&s_lua_audio_storage_pending, __ATOMIC_ACQUIRE) != 0)
__atomic_fetch_sub(&s_lua_audio_storage_pending, 1u, __ATOMIC_ACQ_REL);
}
~LuaAudioStorageLease() {
__atomic_store_n(&s_lua_audio_storage_active, false, __ATOMIC_RELEASE);
}
};
static QueueHandle_t s_lua_audio_queue = nullptr;
static TaskHandle_t s_lua_audio_task = nullptr;
static LuaAudioTaskState s_lua_audio_task_state = LuaAudioTaskState::Stopped;
static portMUX_TYPE s_lua_audio_mux = portMUX_INITIALIZER_UNLOCKED;
static LuaAudioStatus s_lua_audio_status;
static void luaAudioCopy(char* out, size_t cap, const char* value) {
if (!cap) return;
snprintf(out, cap, "%s", value ? value : "");
}
static void luaAudioSetTrackStatus(const LuaAudioCommand& command, LuaAudioState state,
const char* error = nullptr) {
portENTER_CRITICAL(&s_lua_audio_mux);
s_lua_audio_status.owner = command.owner;
s_lua_audio_status.state = state;
luaAudioCopy(s_lua_audio_status.path, sizeof s_lua_audio_status.path, command.shown);
luaAudioCopy(s_lua_audio_status.source, sizeof s_lua_audio_status.source, command.source);
luaAudioCopy(s_lua_audio_status.format, sizeof s_lua_audio_status.format,
command.format == LuaAudioFormat::Mp3 ? "mp3" : "wav");
luaAudioCopy(s_lua_audio_status.error, sizeof s_lua_audio_status.error, error);
portEXIT_CRITICAL(&s_lua_audio_mux);
}
static void luaAudioSetState(uint32_t owner, LuaAudioState state, const char* error = nullptr) {
portENTER_CRITICAL(&s_lua_audio_mux);
if (s_lua_audio_status.owner == owner) {
s_lua_audio_status.state = state;
luaAudioCopy(s_lua_audio_status.error, sizeof s_lua_audio_status.error, error);
}
portEXIT_CRITICAL(&s_lua_audio_mux);
}
static bool luaAudioSinkBegin(uint32_t source_rate, LuaAudioSinkSession* sink,
const char** error) {
if (!sink) return false;
const int volume = (int)touchPrefsGetSoundVolume();
if (volume <= 0) { if (error) *error = "muted"; return false; }
#if defined(HAS_TDECK_GT911)
if (s_notify_playing) { if (error) *error = "busy"; return false; }
if (!tdeckAudioInstallRate((int)source_rate)) {
if (error) *error = "audio unavailable";
return false;
}
sink->output_rate = source_rate;
sink->gain = (float)volume / 100.0f;
#elif defined(TLORA_PAGER)
if (s_notify_playing) { if (error) *error = "busy"; return false; }
board.setAmpEnabled(true);
if (!pagerAudioInstallRate((int)source_rate)) {
board.setAmpEnabled(false);
if (error) *error = "audio unavailable";
return false;
}
s_pager_codec.setVolumePercent((uint8_t)volume);
s_pager_codec.setMute(false);
sink->output_rate = source_rate;
#elif defined(HAS_TDISPLAY_P4)
if (!p4AudioStreamBegin()) {
if (error) *error = "audio unavailable";
return false;
}
sink->output_rate = p4AudioStreamRate();
sink->gain = (float)volume / 100.0f;
#elif defined(HAS_TANMATSU)
sink->mutex = tanAudioMutex();
if (!sink->mutex || xSemaphoreTake(sink->mutex, pdMS_TO_TICKS(300)) != pdTRUE) {
if (error) *error = "busy";
return false;
}
if (bsp_audio_get_i2s_handle(&sink->handle) != ESP_OK || !sink->handle) {
xSemaphoreGive(sink->mutex);
sink->mutex = nullptr;
if (error) *error = "audio unavailable";
return false;
}
applyVolume((uint8_t)volume);
sink->output_rate = 44100;
#endif
sink->open = sink->output_rate != 0;
return sink->open;
}
static bool luaAudioSinkWrite(LuaAudioSinkSession* sink, const int16_t* samples, size_t frames) {
if (!sink || !sink->open || !samples || !frames) return false;
#if defined(HAS_TDECK_GT911) || defined(TLORA_PAGER)
size_t written = 0;
const size_t bytes = frames * sizeof(int16_t);
return i2s_write(kI2sPort, samples, bytes, &written, pdMS_TO_TICKS(300)) == ESP_OK &&
written == bytes;
#elif defined(HAS_TDISPLAY_P4)
return p4AudioStreamWrite(samples, frames);
#elif defined(HAS_TANMATSU)
static int16_t stereo[256 * 2];
if (frames > 256) return false;
for (size_t i = 0; i < frames; ++i)
stereo[2 * i] = stereo[2 * i + 1] = samples[i];
size_t written = 0;
const size_t bytes = frames * 2 * sizeof(int16_t);
return i2s_channel_write(sink->handle, stereo, bytes, &written, pdMS_TO_TICKS(300)) == ESP_OK &&
written == bytes;
#endif
}
static void luaAudioSinkEnd(LuaAudioSinkSession* sink) {
if (!sink || !sink->open) return;
#if defined(HAS_TDECK_GT911)
i2s_zero_dma_buffer(kI2sPort);
i2s_driver_uninstall(kI2sPort);
#elif defined(TLORA_PAGER)
pagerAudioUninstall();
board.setAmpEnabled(false);
#elif defined(HAS_TDISPLAY_P4)
p4AudioStreamEnd();
#elif defined(HAS_TANMATSU)
static const int16_t silence[256 * 2] = {};
for (int i = 0; i < 16; ++i) {
size_t written = 0;
i2s_channel_write(sink->handle, silence, sizeof silence, &written, pdMS_TO_TICKS(300));
}
if (sink->mutex) xSemaphoreGive(sink->mutex);
sink->handle = nullptr;
sink->mutex = nullptr;
#endif
sink->open = false;
}
static bool luaAudioControlMatches(const LuaAudioCommand& control, uint32_t owner) {
return control.kind == LuaAudioCommandKind::Play || control.owner == owner;
}
static bool luaAudioPollControl(const LuaAudioCommand& command,
LuaAudioCommand* replacement,
LuaAudioRunResult* result) {
LuaAudioCommand control;
if (xQueueReceive(s_lua_audio_queue, &control, 0) != pdTRUE ||
!luaAudioControlMatches(control, command.owner)) return false;
if (control.kind == LuaAudioCommandKind::Play) {
if (replacement) *replacement = control;
if (result) *result = LuaAudioRunResult::Replaced;
return true;
}
if (control.kind == LuaAudioCommandKind::Stop) {
if (result) *result = LuaAudioRunResult::Stopped;
return true;
}
if (control.kind == LuaAudioCommandKind::Release) {
if (result) *result = LuaAudioRunResult::Released;
return true;
}
if (control.kind != LuaAudioCommandKind::Pause) return false;
luaAudioSetState(command.owner, LuaAudioState::Paused);
for (;;) {
if (xQueueReceive(s_lua_audio_queue, &control, portMAX_DELAY) != pdTRUE) continue;
if (!luaAudioControlMatches(control, command.owner)) continue;
if (control.kind == LuaAudioCommandKind::Resume) {
luaAudioSetState(command.owner, LuaAudioState::Playing);
return false;
}
if (control.kind == LuaAudioCommandKind::Play) {
if (replacement) *replacement = control;
if (result) *result = LuaAudioRunResult::Replaced;
return true;
}
if (control.kind == LuaAudioCommandKind::Release) {
if (result) *result = LuaAudioRunResult::Released;
return true;
}
if (control.kind == LuaAudioCommandKind::Stop) {
if (result) *result = LuaAudioRunResult::Stopped;
return true;
}
}
}
static LuaAudioRunResult luaAudioRunTrack(const LuaAudioCommand& command,
LuaAudioCommand* replacement) {
LuaAudioStorageLease storage_lease;
File file = command.fs ? command.fs->open(command.path, "r") : File();
if (!file || file.isDirectory()) {
if (file) file.close();
luaAudioSetTrackStatus(command, LuaAudioState::Error, "not found");
return LuaAudioRunResult::Error;
}
uint16_t channels = 0;
uint32_t source_rate = 0, data_len = 0;
if (!wavParse(file, &channels, &source_rate, &data_len) ||
data_len < (uint32_t)channels * sizeof(int16_t)) {
file.close();
luaAudioSetTrackStatus(command, LuaAudioState::Error, "unsupported format");
return LuaAudioRunResult::Error;
}
LuaAudioSinkSession sink;
const char* sink_error = nullptr;
__atomic_store_n(&s_lua_audio_active, true, __ATOMIC_RELEASE);
if (!luaAudioSinkBegin(source_rate, &sink, &sink_error)) {
__atomic_store_n(&s_lua_audio_active, false, __ATOMIC_RELEASE);
file.close();
luaAudioSetTrackStatus(command, LuaAudioState::Error,
sink_error ? sink_error : "audio unavailable");
return LuaAudioRunResult::Error;
}
luaAudioSetTrackStatus(command, LuaAudioState::Playing);
LuaAudioRunResult result = LuaAudioRunResult::Ended;
const char* run_error = nullptr;
bool interrupted = false;
uint32_t remaining = data_len;
uint64_t resample_phase = 0;
int16_t input[256];
int16_t output[256];
size_t output_count = 0;
const uint32_t frame_bytes = (uint32_t)channels * sizeof(int16_t);
while (remaining >= frame_bytes && !interrupted) {
if (luaAudioPollControl(command, replacement, &result)) break;
size_t want = sizeof input;
if (want > remaining) want = remaining;
want -= want % frame_bytes;
const int got = file.read((uint8_t*)input, want);
if (got <= 0) {
result = LuaAudioRunResult::Error;
run_error = "read failed";
break;
}
const size_t frames = (size_t)got / frame_bytes;
for (size_t i = 0; i < frames; ++i) {
int32_t sample = channels == 2
? ((int32_t)input[2 * i] + input[2 * i + 1]) / 2
: input[i];
sample = (int32_t)((float)sample * sink.gain);
if (sample > 32767) sample = 32767;
else if (sample < -32768) sample = -32768;
resample_phase += sink.output_rate;
while (resample_phase >= source_rate) {
output[output_count++] = (int16_t)sample;
resample_phase -= source_rate;
if (output_count == sizeof output / sizeof output[0]) {
if (!luaAudioSinkWrite(&sink, output, output_count)) {
result = LuaAudioRunResult::Error;
run_error = "output failed";
interrupted = true;
break;
}
output_count = 0;
}
}
if (interrupted) break;
}
remaining -= (uint32_t)got;
}
if (!interrupted && result == LuaAudioRunResult::Ended && output_count &&
!luaAudioSinkWrite(&sink, output, output_count)) {
result = LuaAudioRunResult::Error;
run_error = "output failed";
}
luaAudioSinkEnd(&sink);
file.close();
__atomic_store_n(&s_lua_audio_active, false, __ATOMIC_RELEASE);
if (result == LuaAudioRunResult::Ended)
luaAudioSetState(command.owner, LuaAudioState::Ended);
else if (result == LuaAudioRunResult::Stopped || result == LuaAudioRunResult::Released)
luaAudioSetState(command.owner, LuaAudioState::Stopped);
else if (result == LuaAudioRunResult::Error)
luaAudioSetState(command.owner, LuaAudioState::Error, run_error ? run_error : "playback failed");
return result;
}
struct LuaMp3Work {
mp3dec_t decoder;
mp3dec_scratch_t scratch;
uint8_t input[16 * 1024];
mp3d_sample_t pcm[MINIMP3_MAX_SAMPLES_PER_FRAME];
};
static uint32_t luaAudioReadLe32(const uint8_t* value) {
return (uint32_t)value[0] | ((uint32_t)value[1] << 8) |
((uint32_t)value[2] << 16) | ((uint32_t)value[3] << 24);
}
static uint32_t luaAudioMp3DataEnd(File& file) {
uint32_t end = (uint32_t)file.size();
uint8_t footer[128];
if (end >= 128 && file.seek(end - 128) && file.read(footer, 3) == 3 &&
!memcmp(footer, "TAG", 3)) {
end -= 128;
}
if (end >= 32 && file.seek(end - 32) && file.read(footer, 32) == 32 &&
!memcmp(footer, "APETAGEX", 8)) {
const uint32_t version = luaAudioReadLe32(footer + 8);
const uint32_t tag_size = luaAudioReadLe32(footer + 12);
const uint32_t flags = luaAudioReadLe32(footer + 20);
const uint64_t total_size = (uint64_t)tag_size + ((flags & 0x80000000u) ? 32u : 0u);
if ((version == 1000 || version == 2000) && tag_size >= 32 && total_size <= end)
end -= (uint32_t)total_size;
}
file.seek(0);
return end;
}
static LuaAudioRunResult luaAudioRunMp3Track(const LuaAudioCommand& command,
LuaAudioCommand* replacement) {
LuaAudioStorageLease storage_lease;
File file = command.fs ? command.fs->open(command.path, "r") : File();
if (!file || file.isDirectory()) {
if (file) file.close();
luaAudioSetTrackStatus(command, LuaAudioState::Error, "not found");
return LuaAudioRunResult::Error;
}
LuaMp3Work* work = (LuaMp3Work*)heap_caps_malloc(
sizeof(LuaMp3Work), MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT);
if (!work) {
file.close();
luaAudioSetTrackStatus(command, LuaAudioState::Error, "out of memory");
return LuaAudioRunResult::Error;
}
s_wada_mp3_scratch = &work->scratch;
mp3dec_init(&work->decoder);
const uint32_t data_end = luaAudioMp3DataEnd(file);
if (!data_end) {
s_wada_mp3_scratch = nullptr;
heap_caps_free(work);
file.close();
luaAudioSetTrackStatus(command, LuaAudioState::Error, "unsupported format");
return LuaAudioRunResult::Error;
}
__atomic_store_n(&s_lua_audio_active, true, __ATOMIC_RELEASE);
luaAudioSetTrackStatus(command, LuaAudioState::Playing);
LuaAudioRunResult result = LuaAudioRunResult::Ended;
const char* run_error = nullptr;
LuaAudioSinkSession sink;
uint32_t source_rate = 0;
uint64_t resample_phase = 0;
int16_t output[256];
size_t output_count = 0;
size_t input_start = 0, input_count = 0;
bool eof = false, decoded_any = false, interrupted = false;
while (!interrupted) {
if (luaAudioPollControl(command, replacement, &result)) break;
if (!eof && input_count < 4096) {
if (input_start && input_start + input_count + 4096 > sizeof work->input) {
memmove(work->input, work->input + input_start, input_count);
input_start = 0;
}
size_t room = sizeof work->input - (input_start + input_count);
const uint32_t position = (uint32_t)file.position();
const uint32_t remaining = position < data_end ? data_end - position : 0;
if (room > remaining) room = remaining;
const int got = room ? file.read(work->input + input_start + input_count, room) : 0;
if (got > 0) {
input_count += (size_t)got;
eof = file.position() >= data_end;
} else if (file.position() < data_end) {
result = LuaAudioRunResult::Error;
run_error = "read failed";
break;
} else {
eof = true;
}
}
if (!input_count) {
if (!decoded_any) {
result = LuaAudioRunResult::Error;
run_error = "unsupported format";
}
break;
}
mp3dec_frame_info_t info = {};
const int samples = mp3dec_decode_frame(&work->decoder,
work->input + input_start, (int)input_count, work->pcm, &info);
if (info.frame_bytes < 0 || (size_t)info.frame_bytes > input_count) {
result = LuaAudioRunResult::Error;
run_error = "decode failed";
break;
}
if (info.frame_bytes > 0) {
input_start += (size_t)info.frame_bytes;
input_count -= (size_t)info.frame_bytes;
if (!input_count) input_start = 0;
} else if (eof) {
if (!decoded_any) {
result = LuaAudioRunResult::Error;
run_error = "unsupported format";
}
break;
} else if (input_start + input_count == sizeof work->input) {
result = LuaAudioRunResult::Error;
run_error = "decode failed";
break;
} else {
continue;
}
if (samples <= 0) continue;
if (info.layer != 3 || (info.channels != 1 && info.channels != 2) ||
info.hz < 8000 || info.hz > 48000) {
result = LuaAudioRunResult::Error;
run_error = "unsupported format";
break;
}
if (!sink.open) {
const char* sink_error = nullptr;
if (!luaAudioSinkBegin((uint32_t)info.hz, &sink, &sink_error)) {
result = LuaAudioRunResult::Error;
run_error = sink_error ? sink_error : "audio unavailable";
break;
}
source_rate = (uint32_t)info.hz;
} else if (source_rate != (uint32_t)info.hz) {
result = LuaAudioRunResult::Error;
run_error = "sample rate changed";
break;
}
decoded_any = true;
for (int i = 0; i < samples; ++i) {
int32_t sample = info.channels == 2
? ((int32_t)work->pcm[2 * i] + work->pcm[2 * i + 1]) / 2
: work->pcm[i];
sample = (int32_t)((float)sample * sink.gain);
if (sample > 32767) sample = 32767;
else if (sample < -32768) sample = -32768;
resample_phase += sink.output_rate;
while (resample_phase >= source_rate) {
output[output_count++] = (int16_t)sample;
resample_phase -= source_rate;
if (output_count == sizeof output / sizeof output[0]) {
if (!luaAudioSinkWrite(&sink, output, output_count)) {
result = LuaAudioRunResult::Error;
run_error = "output failed";
interrupted = true;
break;
}
output_count = 0;
}
}
if (interrupted) break;
}
}
if (!interrupted && result == LuaAudioRunResult::Ended && output_count &&
!luaAudioSinkWrite(&sink, output, output_count)) {
result = LuaAudioRunResult::Error;
run_error = "output failed";
}
luaAudioSinkEnd(&sink);
s_wada_mp3_scratch = nullptr;
heap_caps_free(work);
file.close();
__atomic_store_n(&s_lua_audio_active, false, __ATOMIC_RELEASE);
if (result == LuaAudioRunResult::Ended)
luaAudioSetState(command.owner, LuaAudioState::Ended);
else if (result == LuaAudioRunResult::Stopped || result == LuaAudioRunResult::Released)
luaAudioSetState(command.owner, LuaAudioState::Stopped);
else if (result == LuaAudioRunResult::Error)
luaAudioSetState(command.owner, LuaAudioState::Error,
run_error ? run_error : "playback failed");
return result;
}
static void luaAudioWorker(void*) {
ulTaskNotifyTake(pdTRUE, portMAX_DELAY);
bool release = false;
while (!release) {
LuaAudioCommand command;
if (xQueueReceive(s_lua_audio_queue, &command, portMAX_DELAY) != pdTRUE) continue;
if (command.kind == LuaAudioCommandKind::Release) {
luaAudioSetState(command.owner, LuaAudioState::Stopped);
break;
}
if (command.kind == LuaAudioCommandKind::Stop) {
luaAudioSetState(command.owner, LuaAudioState::Stopped);
continue;
}
if (command.kind != LuaAudioCommandKind::Play) continue;
for (;;) {
LuaAudioCommand replacement;
const LuaAudioRunResult result = command.format == LuaAudioFormat::Mp3
? luaAudioRunMp3Track(command, &replacement)
: luaAudioRunTrack(command, &replacement);
if (result == LuaAudioRunResult::Replaced) {
command = replacement;
continue;
}
release = result == LuaAudioRunResult::Released;
break;
}
}
__atomic_store_n(&s_lua_audio_active, false, __ATOMIC_RELEASE);
__atomic_store_n(&s_lua_audio_storage_pending, 0u, __ATOMIC_RELEASE);
__atomic_store_n(&s_lua_audio_storage_active, false, __ATOMIC_RELEASE);
xQueueReset(s_lua_audio_queue);
portENTER_CRITICAL(&s_lua_audio_mux);
s_lua_audio_task = nullptr;
s_lua_audio_task_state = LuaAudioTaskState::Stopped;
portEXIT_CRITICAL(&s_lua_audio_mux);
vTaskDelete(nullptr);
}
static bool luaAudioReturnError(char* error, size_t cap, const char* message) {
luaAudioCopy(error, cap, message);
return false;
}
static bool luaAudioPathEndsWith(const char* path, const char* suffix) {
if (!path || !suffix) return false;
const size_t path_len = strlen(path), suffix_len = strlen(suffix);
if (path_len < suffix_len) return false;
path += path_len - suffix_len;
for (size_t i = 0; i < suffix_len; ++i) {
char a = path[i], b = suffix[i];
if (a >= 'A' && a <= 'Z') a = (char)(a - 'A' + 'a');
if (b >= 'A' && b <= 'Z') b = (char)(b - 'A' + 'a');
if (a != b) return false;
}
return true;
}
} // namespace
bool luaHostAudioPlay(fs::FS* fs, const char* path, const char* shown, const char* source,
uint32_t owner, char* error, size_t error_cap) {
if (!fs) return luaAudioReturnError(error, error_cap, "no storage");
if (!path || !path[0] || strlen(path) >= 224)
return luaAudioReturnError(error, error_cap, "bad path");
if (!g_lv.task || g_lv.task->isBuzzerQuiet() || touchPrefsGetSoundVolume() == 0)
return luaAudioReturnError(error, error_cap, "muted");
File probe = fs->open(path, "r");
if (!probe || probe.isDirectory()) {
if (probe) probe.close();
return luaAudioReturnError(error, error_cap, "not found");
}
uint16_t channels = 0;
uint32_t rate = 0, data_len = 0;
LuaAudioFormat format;
bool supported = false;
if (luaAudioPathEndsWith(shown, ".wav")) {
format = LuaAudioFormat::Wav;
supported = wavParse(probe, &channels, &rate, &data_len) && data_len > 0;
} else if (luaAudioPathEndsWith(shown, ".mp3")) {
format = LuaAudioFormat::Mp3;
supported = probe.size() > 0;
}
probe.close();
if (!supported) return luaAudioReturnError(error, error_cap, "unsupported format");
if (!s_lua_audio_queue) s_lua_audio_queue = xQueueCreate(4, sizeof(LuaAudioCommand));
if (!s_lua_audio_queue) return luaAudioReturnError(error, error_cap, "out of memory");
bool start_task = false;
portENTER_CRITICAL(&s_lua_audio_mux);
if (s_lua_audio_task_state == LuaAudioTaskState::Stopped) {
s_lua_audio_task_state = LuaAudioTaskState::Starting;
start_task = true;
} else if (s_lua_audio_task_state != LuaAudioTaskState::Running) {
portEXIT_CRITICAL(&s_lua_audio_mux);
return luaAudioReturnError(error, error_cap, "busy");
}
portEXIT_CRITICAL(&s_lua_audio_mux);
TaskHandle_t task = nullptr;
if (start_task) {
if (xTaskCreate(luaAudioWorker, "lua-audio", 8192, nullptr, 3, &task) != pdPASS) {
portENTER_CRITICAL(&s_lua_audio_mux);
s_lua_audio_task_state = LuaAudioTaskState::Stopped;
portEXIT_CRITICAL(&s_lua_audio_mux);
return luaAudioReturnError(error, error_cap, "out of memory");
}
portENTER_CRITICAL(&s_lua_audio_mux);
s_lua_audio_task = task;
s_lua_audio_task_state = LuaAudioTaskState::Running;
portEXIT_CRITICAL(&s_lua_audio_mux);
}
LuaAudioCommand command;
command.kind = LuaAudioCommandKind::Play;
command.fs = fs;
command.owner = owner;
command.format = format;
luaAudioCopy(command.path, sizeof command.path, path);
luaAudioCopy(command.shown, sizeof command.shown, shown);
luaAudioCopy(command.source, sizeof command.source, source);
__atomic_fetch_add(&s_lua_audio_storage_pending, 1u, __ATOMIC_ACQ_REL);
if (xQueueSend(s_lua_audio_queue, &command, 0) != pdPASS) {
__atomic_fetch_sub(&s_lua_audio_storage_pending, 1u, __ATOMIC_ACQ_REL);
if (start_task) xTaskNotifyGive(task);
return luaAudioReturnError(error, error_cap, "busy");
}
luaAudioSetTrackStatus(command, LuaAudioState::Playing);
if (start_task) xTaskNotifyGive(task);
return true;
}
bool luaHostAudioPause(uint32_t owner, bool pause) {
LuaAudioState state;
TaskHandle_t task;
LuaAudioTaskState task_state;
portENTER_CRITICAL(&s_lua_audio_mux);
state = s_lua_audio_status.owner == owner ? s_lua_audio_status.state : LuaAudioState::Stopped;
task = s_lua_audio_task;
task_state = s_lua_audio_task_state;
portEXIT_CRITICAL(&s_lua_audio_mux);
if (!task || task_state != LuaAudioTaskState::Running ||
(pause ? state != LuaAudioState::Playing : state != LuaAudioState::Paused)) return false;
LuaAudioCommand command;
command.kind = pause ? LuaAudioCommandKind::Pause : LuaAudioCommandKind::Resume;
command.owner = owner;
return xQueueSend(s_lua_audio_queue, &command, 0) == pdPASS;
}
bool luaHostAudioStop(uint32_t owner, bool release) {
TaskHandle_t task;
LuaAudioTaskState task_state;
portENTER_CRITICAL(&s_lua_audio_mux);
task = s_lua_audio_task;
task_state = s_lua_audio_task_state;
if (release && task && task_state == LuaAudioTaskState::Running)
s_lua_audio_task_state = LuaAudioTaskState::Stopping;
portEXIT_CRITICAL(&s_lua_audio_mux);
if (!task || task_state == LuaAudioTaskState::Stopped) {
luaAudioSetState(owner, LuaAudioState::Stopped);
return true;
}
if (task_state != LuaAudioTaskState::Running) return false;
LuaAudioCommand command;
command.kind = release ? LuaAudioCommandKind::Release : LuaAudioCommandKind::Stop;
command.owner = owner;
const BaseType_t queued = release
? xQueueSendToFront(s_lua_audio_queue, &command, pdMS_TO_TICKS(100))
: xQueueSend(s_lua_audio_queue, &command, 0);
if (queued != pdPASS) {
if (release) {
portENTER_CRITICAL(&s_lua_audio_mux);
if (s_lua_audio_task_state == LuaAudioTaskState::Stopping)
s_lua_audio_task_state = LuaAudioTaskState::Running;
portEXIT_CRITICAL(&s_lua_audio_mux);
}
return false;
}
if (!release) return true;
for (int i = 0; i < 160; ++i) {
vTaskDelay(pdMS_TO_TICKS(5));
portENTER_CRITICAL(&s_lua_audio_mux);
task = s_lua_audio_task;
portEXIT_CRITICAL(&s_lua_audio_mux);
if (!task) return true;
}
return false;
}
void luaHostAudioStatus(uint32_t owner, char* state, size_t state_cap,
char* path, size_t path_cap, char* source, size_t source_cap,
char* format, size_t format_cap, char* error, size_t error_cap) {
LuaAudioStatus status;
portENTER_CRITICAL(&s_lua_audio_mux);
status = s_lua_audio_status;
portEXIT_CRITICAL(&s_lua_audio_mux);
if (status.owner != owner) status = LuaAudioStatus();
const char* state_name = "stopped";
if (status.state == LuaAudioState::Playing) state_name = "playing";
else if (status.state == LuaAudioState::Paused) state_name = "paused";
else if (status.state == LuaAudioState::Ended) state_name = "ended";
else if (status.state == LuaAudioState::Error) state_name = "error";
luaAudioCopy(state, state_cap, state_name);
luaAudioCopy(path, path_cap, status.path);
luaAudioCopy(source, source_cap, status.source);
luaAudioCopy(format, format_cap, status.format);
luaAudioCopy(error, error_cap, status.error);
}
#endif
#if CAP_LUA_SD_LIST
// Return the physical removable card, never the app-data filesystem. Mounting
// stays in UITask so Lua cannot bypass shared-bus coordination or create a
@@ -52997,10 +53808,16 @@ static bool sdRuntimeLifecycleBusy() {
s_sdinfo_busy ||
touchPrefsIoBusy();
#if defined(MULTI_TRANSPORT_COMPANION)
// The web reader and the Lua audio player are independent SD consumers, so
// both gate the mount lifecycle. The reader excludes ITSELF: it calls this
// from its own task while holding the card, and would otherwise deadlock.
const bool reader_is_caller = s_reader_sd_busy &&
s_reader_sd_owner == xTaskGetCurrentTaskHandle();
busy = busy || (s_reader_sd_busy && !reader_is_caller);
#endif
#if CAP_LUA_AUDIO
busy = busy || luaAudioStorageBusy();
#endif
#if defined(HAS_TDECK_GT911) || defined(TLORA_PAGER)
busy = busy || s_notify_playing;
#endif
+17
View File
@@ -273,6 +273,15 @@
#define CAP_SOUND_FILES 0
#endif
// Sustained PCM output for media playback. This is deliberately independent
// of storage: Lua app files may live on internal flash, SD, or SD_MMC.
#if defined(HAS_TDECK_GT911) || defined(TLORA_PAGER) || \
defined(HAS_TDISPLAY_P4) || defined(HAS_TANMATSU)
#define CAP_AUDIO_STREAM 1
#else
#define CAP_AUDIO_STREAM 0
#endif
// ---- On-device web browser (the "Web" reader app) ---------------------------
// The reader fetches pages over on-device HTTPS, and a TLS handshake needs ~30 KB of
// free INTERNAL heap. Only the 8 MB-PSRAM boards (T-Deck, Tanmatsu, ThinkNode M9, RAK
@@ -294,6 +303,14 @@
#define CAP_LUA_APPS 1
#endif
#ifndef CAP_LUA_AUDIO
#if CAP_LUA_APPS && CAP_AUDIO_STREAM
#define CAP_LUA_AUDIO 1
#else
#define CAP_LUA_AUDIO 0
#endif
#endif
// ---- Extended Lua SDK ------------------------------------------------------
// The BASE SDK (drawing, timers, key/value store, read-only mesh + radio stats,
// http_get) is board-agnostic and ships everywhere CAP_LUA_APPS is on.
+18 -1
View File
@@ -340,6 +340,8 @@ static const char kLuaSrc_sdktest[] = R"WADALUA(-- SDK self-test. Exercises the
-- windowed wada.fs.read.
-- 1.6 adds wada.sd.list: capability reporting, traversal rejection, root
-- metadata, and a nested directory listing when the card contains one.
-- 1.7 adds storage-neutral wada.audio capability and API-shape checks. Playback
-- stays manual so opening the bench test never emits sound unexpectedly.
-- 1.5 uses wada.ui.text_lines() to measure instead of estimating, where the
-- firmware has it. That call was added because of the 1.3 bug below: an app
-- could ask how tall a line is but not how wide, so laying out rows meant
@@ -396,6 +398,21 @@ function app.on_open(w, h)
row("caps: sd_list=" .. yn(c.sd_list) .. " discover=" .. yn(c.discover) ..
" input=" .. yn(c.input) ..
" rx_identity=" .. yn(c.rx_identity), C.accent)
row("caps: audio=" .. yn(c.audio) .. " wav=" .. yn(c.audio_wav) ..
" mp3=" .. yn(c.audio_mp3) .. " audio_sd=" .. yn(c.audio_sd), C.accent)
if c.audio then
local api_ok = wada.audio and type(wada.audio.play) == "function" and
type(wada.audio.pause) == "function" and
type(wada.audio.resume) == "function" and
type(wada.audio.stop) == "function" and
type(wada.audio.status) == "function"
local status = api_ok and wada.audio.status() or nil
api_ok = api_ok and type(status) == "table" and type(status.state) == "string"
row("wada.audio API: " .. (api_ok and ("PASS (" .. status.state .. ")") or "FAIL"),
api_ok and C.good or C.bad)
else
row("wada.audio: unavailable on this board", C.sub)
end
row("layout: " .. (ui.text_lines and "measured (ui.text_lines)" or "estimated (older firmware)"),
ui.text_lines and C.good or C.sub)
-- crypto: published test vectors, so this is checkable rather than merely alive
@@ -1803,7 +1820,7 @@ static const LuaBuiltinApp kLuaBuiltin[] = {
{ "monitor", "RF Monitor", "1.3", kLuaSrc_monitor },
{ "airtime", "Airtime", "1.4", kLuaSrc_airtime },
{ "snake", "Snake", "1.0", kLuaSrc_snake },
{ "sdktest", "SDK Test", "1.6", kLuaSrc_sdktest },
{ "sdktest", "SDK Test", "1.7", kLuaSrc_sdktest },
{ "2048", "2048", "1.2", kLuaSrc_2048 },
{ "wardrive", "Wardrive", "1.1", kLuaSrc_wardrive },
{ "nearby", "Nearby", "1.0", kLuaSrc_nearby },
+144
View File
@@ -0,0 +1,144 @@
#include <assert.h>
#include <stdint.h>
#include <stdio.h>
#include <string.h>
static void* testMp3Scratch();
#define MINIMP3_ONLY_MP3
#define MINIMP3_NO_SIMD
#define MINIMP3_SCRATCH ((mp3dec_scratch_t*)testMp3Scratch())
#define MINIMP3_IMPLEMENTATION
#include "../lib/minimp3/minimp3.h"
static mp3dec_scratch_t g_scratch;
static void* testMp3Scratch() { return &g_scratch; }
struct DecodeBuffer {
uint8_t input[16 * 1024];
mp3d_sample_t pcm[MINIMP3_MAX_SAMPLES_PER_FRAME];
};
static uint32_t readLe32(const uint8_t* value) {
return (uint32_t)value[0] | ((uint32_t)value[1] << 8) |
((uint32_t)value[2] << 16) | ((uint32_t)value[3] << 24);
}
static long mp3DataEnd(FILE* file) {
assert(fseek(file, 0, SEEK_END) == 0);
long end = ftell(file);
assert(end >= 0);
uint8_t footer[128];
if (end >= 128 && fseek(file, end - 128, SEEK_SET) == 0 &&
fread(footer, 1, 3, file) == 3 && !memcmp(footer, "TAG", 3)) {
end -= 128;
}
if (end >= 32 && fseek(file, end - 32, SEEK_SET) == 0 &&
fread(footer, 1, 32, file) == 32 && !memcmp(footer, "APETAGEX", 8)) {
const uint32_t version = readLe32(footer + 8);
const uint32_t tag_size = readLe32(footer + 12);
const uint32_t flags = readLe32(footer + 20);
const uint64_t total_size = (uint64_t)tag_size + ((flags & 0x80000000u) ? 32u : 0u);
if ((version == 1000 || version == 2000) && tag_size >= 32 && total_size <= (uint64_t)end)
end -= (long)total_size;
}
rewind(file);
return end;
}
static bool decodeFile(const char* path) {
FILE* file = fopen(path, "rb");
if (!file) {
fprintf(stderr, "%s: could not open\n", path);
return false;
}
const long data_end = mp3DataEnd(file);
if (data_end <= 0) { fclose(file); return false; }
DecodeBuffer buffer;
mp3dec_t decoder;
mp3dec_init(&decoder);
size_t input_start = 0, input_count = 0;
uint64_t samples_total = 0;
int frames = 0, sample_rate = 0, channels = 0, bitrate_changes = 0;
int last_bitrate = 0;
bool eof = false, ok = true;
while (ok) {
if (!eof && input_count < 4096) {
if (input_start && input_start + input_count + 4096 > sizeof buffer.input) {
memmove(buffer.input, buffer.input + input_start, input_count);
input_start = 0;
}
size_t room = sizeof buffer.input - (input_start + input_count);
const long position = ftell(file);
const size_t remaining = position < data_end ? (size_t)(data_end - position) : 0;
if (room > remaining) room = remaining;
const size_t got = fread(buffer.input + input_start + input_count, 1, room, file);
if (got > 0) {
input_count += got;
eof = ftell(file) >= data_end;
}
else if (ferror(file)) ok = false;
else eof = true;
}
if (!ok || !input_count) break;
mp3dec_frame_info_t info = {};
const int samples = mp3dec_decode_frame(&decoder, buffer.input + input_start,
(int)input_count, buffer.pcm, &info);
if (info.frame_bytes < 0 || (size_t)info.frame_bytes > input_count) {
ok = false;
break;
}
if (info.frame_bytes > 0) {
input_start += (size_t)info.frame_bytes;
input_count -= (size_t)info.frame_bytes;
if (!input_count) input_start = 0;
} else if (eof) {
break;
} else if (input_start + input_count == sizeof buffer.input) {
ok = false;
break;
} else {
continue;
}
if (samples <= 0) continue;
if (info.layer != 3 || (info.channels != 1 && info.channels != 2) ||
info.hz < 8000 || info.hz > 48000) {
ok = false;
break;
}
if (!sample_rate) {
sample_rate = info.hz;
channels = info.channels;
} else if (sample_rate != info.hz) {
ok = false;
break;
}
if (last_bitrate && last_bitrate != info.bitrate_kbps) ++bitrate_changes;
last_bitrate = info.bitrate_kbps;
samples_total += (uint64_t)samples;
++frames;
}
fclose(file);
if (!ok || frames == 0 || samples_total == 0) {
fprintf(stderr, "%s: decode failed after %d frames\n", path, frames);
return false;
}
printf("%s: %d frames, %llu samples/channel, %d Hz, %d channel(s), %d bitrate changes\n",
path, frames, (unsigned long long)samples_total, sample_rate, channels,
bitrate_changes);
return true;
}
int main(int argc, char** argv) {
if (argc < 2) {
fprintf(stderr, "usage: %s file.mp3 [file.mp3 ...]\n", argv[0]);
return 2;
}
for (int i = 1; i < argc; ++i) assert(decodeFile(argv[i]));
return 0;
}
+37 -1
View File
@@ -89,6 +89,7 @@ bool p4AudioReady() {
if (s_ready) return true;
if (s_failed) return false;
if (!s_mtx) s_mtx = xSemaphoreCreateMutex();
if (!s_mtx) return false;
xSemaphoreTake(s_mtx, portMAX_DELAY);
if (s_ready || s_failed) { xSemaphoreGive(s_mtx); return s_ready; }
@@ -125,6 +126,9 @@ bool p4AudioReady() {
if (i2s_channel_init_std_mode(s_tx, &std_cfg) != ESP_OK ||
i2s_channel_enable(s_tx) != ESP_OK) {
printf("[P4AUDIO] I2S init failed\n");
i2s_channel_disable(s_tx);
i2s_del_channel(s_tx);
s_tx = nullptr;
s_failed = true;
xSemaphoreGive(s_mtx);
return false;
@@ -139,7 +143,7 @@ void p4AudioTone(int freq_hz, int duration_ms, int amplitude) {
if (amplitude <= 0 || freq_hz <= 0 || duration_ms <= 0) return;
if (!p4AudioReady()) return;
if (amplitude > 30000) amplitude = 30000;
xSemaphoreTake(s_mtx, portMAX_DELAY);
if (xSemaphoreTake(s_mtx, 0) != pdTRUE) return;
const int total = P4A_RATE * duration_ms / 1000;
const int fade = P4A_RATE * 4 / 1000; // 4 ms in/out fade kills the click
static int16_t buf[256 * 2]; // stereo frames, chunked
@@ -163,4 +167,36 @@ void p4AudioTone(int freq_hz, int duration_ms, int amplitude) {
xSemaphoreGive(s_mtx);
}
uint32_t p4AudioStreamRate() { return P4A_RATE; }
bool p4AudioStreamBegin() {
if (!p4AudioReady()) return false;
return xSemaphoreTake(s_mtx, portMAX_DELAY) == pdTRUE;
}
bool p4AudioStreamWrite(const int16_t* samples, size_t frames) {
if (!samples || !frames || !s_tx) return false;
static int16_t stereo[256 * 2];
while (frames) {
const size_t count = frames > 256 ? 256 : frames;
for (size_t i = 0; i < count; ++i)
stereo[2 * i] = stereo[2 * i + 1] = samples[i];
size_t written = 0;
const size_t bytes = count * 2 * sizeof(int16_t);
if (i2s_channel_write(s_tx, stereo, bytes, &written, pdMS_TO_TICKS(300)) != ESP_OK ||
written != bytes) return false;
samples += count;
frames -= count;
}
return true;
}
void p4AudioStreamEnd() {
if (!s_mtx) return;
static const int16_t silence[256 * 2] = {};
size_t written = 0;
i2s_channel_write(s_tx, silence, sizeof silence, &written, pdMS_TO_TICKS(300));
xSemaphoreGive(s_mtx);
}
#endif // HAS_TDISPLAY_P4
+8
View File
@@ -5,6 +5,7 @@
// DOUT=10. Fixed 16 kHz / 16-bit stereo — plenty for notification chimes, tiny buffers.
// All calls are safe from any task; init is lazy on first use (~ms, I²C + I2S bring-up).
#include <stdint.h>
#include <stddef.h>
// Bring the codec + I2S up (idempotent). Returns false if the codec never answered.
bool p4AudioReady();
@@ -12,3 +13,10 @@ bool p4AudioReady();
// Blocking sine tone: `amplitude` is the 16-bit peak (0..~30000; the UI uses pct*130).
// No-op (fast) when amplitude <= 0 or the codec is absent.
void p4AudioTone(int freq_hz, int duration_ms, int amplitude);
// Exclusive fixed-rate PCM stream used by media playback. Samples are mono;
// the backend duplicates them to the codec's stereo I2S slots.
uint32_t p4AudioStreamRate();
bool p4AudioStreamBegin();
bool p4AudioStreamWrite(const int16_t* samples, size_t frames);
void p4AudioStreamEnd();