diff --git a/AUDIO_PLAYBACK_TESTING.md b/AUDIO_PLAYBACK_TESTING.md
new file mode 100644
index 0000000..cbbc67b
--- /dev/null
+++ b/AUDIO_PLAYBACK_TESTING.md
@@ -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`.
diff --git a/DEVICES.md b/DEVICES.md
index a863ef6..4820503 100644
--- a/DEVICES.md
+++ b/DEVICES.md
@@ -23,6 +23,10 @@ USB) and the [GitHub releases](https://github.com/ALLFATHER-BV/wadamesh/releases
- **T-Deck**: the everything device: touch, physical keyboard, trackball cursor
or d-pad navigation, microSD (deep 5000-message chat history, map tile packs,
data storage), GPS on the Plus, notification sounds through the I2S speaker.
+ Tap Sym or Alt for one symbol, or double-tap either to lock the symbol layer;
+ this needs [LilyGO keyboard-controller firmware with raw matrix mode](https://github.com/Xinyuan-LilyGO/T-Deck/tree/master/examples/Keyboard_ESP32C3)
+ (June 2025 or newer). Older controller firmware keeps normal typing and
+ reports the unavailable latch mode on Serial.
- **Heltec V4 + TFT**: touch UI with the on-screen keyboard; the optional
Expansion Kit adds environment sensors (home-screen chart) and a piezo
buzzer. V4.3 boards get the switchable high-gain receive LNA toggle.
@@ -33,6 +37,8 @@ USB) and the [GitHub releases](https://github.com/ALLFATHER-BV/wadamesh/releases
- **T-Lora Pager**: no touchscreen at all — the QWERTY keyboard and the rotary
encoder drive everything (Alt+turn free-scrolls a page; see
[TLORA_PAGER_SHORTCUTS.md](TLORA_PAGER_SHORTCUTS.md) for the full key map).
+ Tap Fn/Alt for one symbol or double-tap it to lock the symbol layer; physical
+ hold combinations keep their existing behavior.
GPS, microSD, keyboard backlight, lock screen and notification sound through
the onboard codec and amp all work. Two builds, one per radio: LR1121 and
SX1262 — flashing the wrong one leaves you with no radio, so check the label
diff --git a/NOTICE b/NOTICE
index e5b4be9..711abdc 100644
--- a/NOTICE
+++ b/NOTICE
@@ -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
diff --git a/README.md b/README.md
index 60ea0cd..2946c56 100644
--- a/README.md
+++ b/README.md
@@ -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
diff --git a/TLORA_PAGER_PORT.md b/TLORA_PAGER_PORT.md
index 08c1d8e..89916f2 100644
--- a/TLORA_PAGER_PORT.md
+++ b/TLORA_PAGER_PORT.md
@@ -225,8 +225,8 @@ WASD-panning).
`updatePagerAltShiftChord()` toggles Caps Lock ONLY while a text field is
actually being edited (same "ta" derivation `handleHwKey()`'s TLORA_PAGER
branch already uses to tell a bound-but-unfocused field apart from one
- actually being edited), and jumps straight to Home (`navGoToMainTab`)
- everywhere else.
+ actually being edited), and is a no-op everywhere else. Alt+Backspace is
+ the context-independent Home shortcut.
3. **@-mention contact picker made encoder-navigable** (commit `57802eb`) —
the same unreachable-without-touch problem the accent box had, fixed the
same way (`mentionNavRestyle()`/`mentionNavConfirm()` mirror
@@ -622,9 +622,9 @@ existing boards. Notes:
`pagerKeyboardConsumeAltShiftChord()`; `UITask.cpp`'s
`updatePagerAltShiftChord()` toggles persistent Caps Lock ONLY while a text
field is actually being edited (same "ta" derivation as `handleHwKey()`'s
- TLORA_PAGER branch), and jumps straight Home (`navGoToMainTab`) everywhere
- else — toggling Caps Lock with no field to see it change in was reported as
- surprising/purposeless outside of typing.
+ TLORA_PAGER branch), and is a no-op everywhere else — toggling Caps Lock
+ with no field to see it change in was reported as surprising/purposeless
+ outside of typing. Alt+Backspace owns the Home shortcut.
See `PagerKeyboard.cpp`'s matrix-legend comment for the one documented
edge case (Alt+Shift+O or Alt+Shift+L all three held phantom-ghosts a
stray 'q'/'a' — a diode-less-matrix limitation, not a bug, and not the
@@ -751,7 +751,7 @@ existing boards. Notes:
| Accent-variant popup keyboard nav | Fn+Space arms it; encoder walks variants; encoder click/Enter confirms; Backspace cancels (`s_accentnav_active`/`accentNavRestyle()`/`accentNavConfirm()`) | medium | done |
| @-mention picker keyboard nav | Auto-arms the instant the list appears (no Fn+Space needed); same encoder walk/confirm/cancel as the accent popup (`s_mentionnav_active`/`mentionNavRestyle()`/`mentionNavConfirm()`) | medium | done |
| Encoder short click on a focused bubble | Mirrors keyboard Enter — opens the same action menu instead of a plain ENTER a bubble doesn't react to | small | done |
-| Fn+Shift chord effect | Toggles Caps Lock while editing a text field; jumps to Home (`navGoToMainTab`) everywhere else — `PagerKeyboard.cpp` only reports the chord, `UITask.cpp` decides the effect | small | done |
+| Fn+Shift chord effect | Toggles Caps Lock while editing a text field; no-op everywhere else. Fn+Backspace jumps Home — `PagerKeyboard.cpp` only reports the chords, `UITask.cpp` decides the effects | small | done |
| Map pan (WASD) + slider nudge (Q/E) | WASD pans the Map tab (`mapNudge()`, shared with Tanmatsu's Ctrl+Arrow); Q/E adjust a focused slider (moved off D/F to avoid colliding with WASD) | small | done |
| App-drawer tile focus highlight | `NAV_ACCENTFOCUS_FLAG` makes `navFocusCb` use the tile's own accent tint instead of the generic white reverse-fill, on every board | small | done |
| SX1262-variant env | `tlora_pager_sx1262_companion_radio_touch` — same board/pins, `CustomSX1262`/`CustomSX1262Wrapper` instead of the LR1121 classes | medium | done (worklist ⑩) |
diff --git a/TLORA_PAGER_SHORTCUTS.md b/TLORA_PAGER_SHORTCUTS.md
index 0b4abe3..3519edf 100644
--- a/TLORA_PAGER_SHORTCUTS.md
+++ b/TLORA_PAGER_SHORTCUTS.md
@@ -40,7 +40,7 @@ while editing a text field, where the keys type normally.
| Gesture | Action | Keyboard equivalent |
|---|---|---|
-| Turn | Move focus to the next/previous item on screen | **Fn (Alt) tapped alone** moves forward one step (NEXT only — no keyboard way to go backward) |
+| Turn | Move focus to the next/previous item on screen | none — encoder only |
| Short click | Select / confirm the focused item | **Enter** |
| Hold ~1 s, then release | **Back**: closes a popup → closes an open chat → goes Home → Esc (whichever applies first) | **Backspace held ~1 s** |
| **Fn (Alt) + turn**, on a main tab | Move between the 5 main tabs (Mail / Contacts / Home / Map / Settings) | **M / C / H / A / S** jumps directly |
@@ -93,29 +93,19 @@ text box → Send**, in that order, in both directions — nothing is skipped.
Turning past the △ chip (toward the messages) always jumps straight to the
newest message, regardless of channel or DM.
-### Backspace and Fn (Alt) — leaving vs. catching up
+### Backspace — catching up
-These two keys are your shortcuts in and out of "reading mode" on this
-screen, and they do different things depending on whether you've got unread
-messages:
+**Backspace (tap)** jumps to whichever message needs your attention:
-- **Fn (Alt) tapped alone** — jumps straight to the **newest** message and
- moves focus into the text box, ready to type a reply. This is the
- deliberate "I'm done reading, let me respond" gesture — it works regardless
- of where your focus currently is in the chat.
-- **Backspace (tap)** — jumps to whichever message needs your attention:
- - If there are unread messages, it jumps to the first one — right below the
- **"NEW ----"** divider — and selects it, so you can then turn forward
- through your unread messages in order, oldest-of-the-unread first.
- - If nothing is unread, it jumps to the newest message instead (same
- destination as Fn+Alt, but leaves focus on the message itself rather than
- the text box).
+- If there are unread messages, it jumps to the first one — right below the
+ **"NEW ----"** divider — and selects it, so you can then turn forward
+ through your unread messages in order, oldest-of-the-unread first.
+- If nothing is unread, it jumps to the newest message instead (same
+ destination, with focus left on the message).
Backspace's override above only applies while you're inside an open chat and
*not* actively editing the text box — the usual rule from the top of this
guide; with a field focused, Backspace deletes a character as normal.
-Fn (Alt) tap's override fires the same way whether or not the text box
-already has focus, since landing there is the whole point of the gesture.
## Keyboard layout
@@ -128,7 +118,9 @@ a s d f g h j k l [Enter]
[Space]
```
-Hold **Fn (Alt)** for numbers/symbols instead:
+Hold **Fn (Alt)** for numbers/symbols, or tap it once to apply this layer to
+the next key only. Double-tap Fn to lock the symbol layer; tap it again to
+return to letters.
```
1 2 3 4 5 6 7 8 9 0
@@ -162,7 +154,7 @@ Hold **Fn (Alt)** for numbers/symbols instead:
| **Space** (tap) | Types a space | — |
| **Space** (double-tap, within 250 ms) | Switches between English and your configured secondary keyboard layout | — |
| **Space** (hold ~1 s) | — | Locks the screen (shows a "Locking…" progress bar; tapping any key cancels) |
-| **Fn (Alt)** tapped alone (press+release, nothing else) | — | Moves focus to the next field/item — a keyboard-only substitute for a turn of the encoder. In an open chat: jumps to the newest message and focuses the text box instead — see [Chat screen](#chat-screen) |
+| **Fn (Alt)** (tap / double-tap) | Next key uses symbols / lock symbols until tapped again | Same |
The **BOOT** button (top of the device) instantly wakes the screen from
idle-dim. It does *not* unlock a screen you've manually locked with the
@@ -249,13 +241,14 @@ that changes keyboard language.
| Input | Action |
|---|---|
| M / C / H / A / S (top-level main screens only) | Open Mail / Contacts / Home / Map / Settings |
-| Turn encoder (or Fn tap = NEXT only) | Move focus |
+| Turn encoder | Move focus |
| Turn, at the loaded edge of a chat | Load more history, keep moving — only exits the list at the true oldest/newest message |
| Click encoder (or Enter) | Select / confirm |
| Hold encoder ~1s (or hold Backspace ~1s) | Back |
| Fn + turn (main tab) | Switch tabs |
| Fn + turn (page/chat) | Scroll |
-| Fn tap alone | Next field (in a chat: jump to newest message + focus the text box) |
+| Fn tap alone | Use the symbol layer for the next key |
+| Fn double-tap | Lock the symbol layer; tap Fn again to unlock |
| Hold Shift + letter | Momentary uppercase |
| Fn + Shift (editing a field) | Toggle Caps Lock |
| Fn + Shift (not editing a field) | Nothing |
diff --git a/deploy/apps/README.md b/deploy/apps/README.md
index 6e9b046..751dcfb 100644
--- a/deploy/apps/README.md
+++ b/deploy/apps/README.md
@@ -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).
diff --git a/deploy/apps/apps.json b/deploy/apps/apps.json
index ee2da03..9e6b97d 100644
--- a/deploy/apps/apps.json
+++ b/deploy/apps/apps.json
@@ -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",
@@ -33,14 +33,16 @@
{
"id": "wardrive",
"name": "Wardrive",
- "ver": "1.0",
- "desc": "LoRa coverage survey. Probes every 20s and logs each reply with GPS position, altitude and BOTH link directions (how well you heard them, how well they heard you) to a CSV. Reference app for wada.mesh.discover."
+ "ver": "1.1",
+ "desc": "LoRa coverage survey. Probes every 20s and logs each reply with GPS position, altitude and BOTH link directions (how well you heard them, how well they heard you) to a CSV. Reference app for wada.mesh.discover.",
+ "requires": "sdk_ext"
},
{
"id": "nearby",
"name": "Nearby",
"ver": "1.0",
- "desc": "Your contacts on a real map, sorted by distance, with bearing and a live frame counter. Reference app for wada.map, wada.ui.list, wada.geo and app.on_packet."
+ "desc": "Your contacts on a real map, sorted by distance, with bearing and a live frame counter. Reference app for wada.map, wada.ui.list, wada.geo and app.on_packet.",
+ "requires": "sdk_ext"
},
{
"id": "gpscompass",
diff --git a/deploy/apps/lang/18/bg.lang b/deploy/apps/lang/19/bg.lang
similarity index 97%
rename from deploy/apps/lang/18/bg.lang
rename to deploy/apps/lang/19/bg.lang
index 92bcace..21b7c3f 100644
--- a/deploy/apps/lang/18/bg.lang
+++ b/deploy/apps/lang/19/bg.lang
@@ -1,7 +1,7 @@
# wadamesh language file
# canonical source - translators: edit this file and PR it
# code: bg
-# ver: 18
+# ver: 19
# name: Български
# base: bg
(device behind) (устройството изостава)
@@ -826,7 +826,7 @@ TCP on TCP вкл
TOUCH BETA TOUCH BETA
TX TX
Tab hotkeys — tap a row, then press a key Клавиши за раздела — докосни ред, после натисни клавиш
-Tap Use to switch - the device reboots to apply. Натиснете „Избери“, за да превключите — устройството ще се рестартира.
+Tap Use to switch - the device reboots to apply. Натиснете "Избери", за да превключите — устройството ще се рестартира.
Tap Use to switch - the device reboots to apply. Language files live in /lang on the storage; edit them or add your own. Докосни «Избери» за смяна - устройството се рестартира. Езиковите файлове са в /lang в паметта; редактирай ги или добави свои.
Tap a language; the device reboots to apply. Изберете език; устройството ще се рестартира.
Taste the rainbow! Вкуси дъгата!
@@ -977,8 +977,6 @@ tap to exit full screen докоснете за изход от цял екра
zoom мащаб
zoom %d мащаб %d
~17 dB amp for quiet/remote areas; turn off in noisy spots. усилвател ~17 dB за тихи/отдалечени места; изключи в шумни.
-© OpenStreetMap © OpenStreetMap
-© OpenTopoMap © OpenTopoMap
Scope
another region
my region
@@ -1000,7 +998,6 @@ Overwrite oldest non-favorite
1-character messages will be shown 1-character messages will be shown
All All
Allow Allow
-Apps that may send messages on the mesh. Anything they send goes out under your node name and cannot be told apart from a message you typed. Turn one off to take the permission back. Apps that may send messages on the mesh. Anything they send goes out under your node name and cannot be told apart from a message you typed. Turn one off to take the permission back.
Ignore 1-character messages Ignore 1-character messages
Join w/ password Join w/ password
Location no longer shared Location no longer shared
@@ -1014,9 +1011,7 @@ Never\nChosen contacts only\nAnyone who asks Never\nChosen contacts only\nAnyone
No apps installed. No apps installed.
No reply. Path hash is %u bytes;\nolder repeaters drop those.\nTry 1 byte (Settings > Radio & Mesh) No reply. Path hash is %u bytes;\nolder repeaters drop those.\nTry 1 byte (Settings > Radio & Mesh)
Not supported on this board Not supported on this board
-Read incoming messages Read incoming messages
Reconnected to %.40s Reconnected to %.40s
-Send messages as me Send messages as me
Sent only when that contact asks, encrypted to them. Nothing is broadcast. Needs "Answer telemetry requests" on. Pick contacts in a contact's menu. Sent only when that contact asks, encrypted to them. Nothing is broadcast. Needs "Answer telemetry requests" on. Pick contacts in a contact's menu.
Server has forgotten you.\nJoin with the room password. Server has forgotten you.\nJoin with the room password.
Share my loc Share my loc
@@ -1042,7 +1037,6 @@ App permissions App permissions
About / credits About / credits
Auth failed Auth failed
Auto-add on for %s — new %s land in Contacts automatically. Auto-add on for %s — new %s land in Contacts automatically.
-Batteryactivityon Batteryactivityon
Console mode Console mode
Console mode (experimental) Console mode (experimental)
Direct (0-hop) only Direct (0-hop) only
@@ -1051,8 +1045,6 @@ EXPERIMENTAL. Boots into a text console with no graphical interface: type comman
Enter text Enter text
Entering console mode - rebooting... Entering console mode - rebooting...
Favorite Favorite
-Geblokkeerde gebruikers Geblokkeerde gebruikers
-Hide system filesShow system files Hide system filesShow system files
How tiles work:\nThe map is built from 256×256 "slippy" tiles. Only the tiles for the area you're viewing are fetched — there is no bulk pre-download.\n\nBecause this device can't do HTTPS (not enough heap after Wi-Fi starts) and decodes JPEG far more cheaply than PNG, tiles come from the wadamesh proxy: it fetches the upstream PNG over HTTPS with an identifying User-Agent, re-encodes it as JPEG, and caches it. Your device then caches each tile to its own flash, so a tile is only downloaded once.\n\nUse Options → Reload tiles to re-download the tiles currently in view if one looks corrupted. How tiles work:\nThe map is built from 256×256 "slippy" tiles. Only the tiles for the area you're viewing are fetched — there is no bulk pre-download.\n\nBecause this device can't do HTTPS (not enough heap after Wi-Fi starts) and decodes JPEG far more cheaply than PNG, tiles come from the wadamesh proxy: it fetches the upstream PNG over HTTPS with an identifying User-Agent, re-encodes it as JPEG, and caches it. Your device then caches each tile to its own flash, so a tile is only downloaded once.\n\nUse Options → Reload tiles to re-download the tiles currently in view if one looks corrupted.
Init… Init…
Leaving console mode - rebooting... Leaving console mode - rebooting...
@@ -1062,8 +1054,6 @@ Map data © OpenStreetMap contributors.\nopenstreetmap.org/copyright\nLicensed u
Map style © OpenTopoMap (CC-BY-SA) — opentopomap.org\nMap data © OpenStreetMap contributors (ODbL) + SRTM.\n\n Map style © OpenTopoMap (CC-BY-SA) — opentopomap.org\nMap data © OpenStreetMap contributors (ODbL) + SRTM.\n\n
Name (A-Z) Name (A-Z)
Nearest first Nearest first
-Other networksNetworks Other networksNetworks
-Paste (move)Paste (copy) Paste (move)Paste (copy)
Recent message Recent message
Reload tiles in view Reload tiles in view
Repeaters Repeaters
@@ -1076,13 +1066,8 @@ Show contacts Show contacts
Show coordinates Show coordinates
Show tile z/x/y Show tile z/x/y
Starting… Starting…
-Stop sharing loc Share my loc Stop sharing loc Share my loc
-Supprimer Supprimer
-Switch to the external antenna?\n\nMake sure an antenna is actually connected to the external antenna socket first. Transmitting with nothing attached can damage the radio.\n\nResets to the on-board antenna on every reboot.Turn on legacy per-transmit switching?\n\nDiagnostic mode, for comparison only. It transmits on the on-board antenna and listens on the external one, so your outbound signal will be much weaker than your inbound.\n\nResets to the on-board antenna on every reboot. Switch to the external antenna?\n\nMake sure an antenna is actually connected to the external antenna socket first. Transmitting with nothing attached can damage the radio.\n\nResets to the on-board antenna on every reboot.Turn on legacy per-transmit switching?\n\nDiagnostic mode, for comparison only. It transmits on the on-board antenna and listens on the external one, so your outbound signal will be much weaker than your inbound.\n\nResets to the on-board antenna on every reboot.
Text console (experimental) Text console (experimental)
Tile debug overlay Tile debug overlay
-Unblock Block Unblock Block
-Unfav Favorite Unfav Favorite
chats chats
repeaters repeaters
rooms rooms
@@ -1098,3 +1083,23 @@ channel busy %.1f%% channel busy %.1f%%
sampling... sampling...
teal = now blue = average teal = now blue = average
window restarted window restarted
+Filter Filter
+Hide system files Hide system files
+Networks Networks
+Other networks Other networks
+Show system files Show system files
+Unfav Unfav
+Firmware beta_%d\nChecking for updates over Wi-Fi… Firmware beta_%d\nChecking for updates over Wi-Fi…
+Install beta_%d?\nThis downgrades the firmware and reboots. Install beta_%d?\nThis downgrades the firmware and reboots.
+Other (hidden) network… Other (hidden) network…
+Paused Paused
+Scan again Scan again
+At a glance At a glance
+At a glance disabled At a glance disabled
+At a glance enabled At a glance enabled
+At a glance while locked At a glance while locked
+Glance only shows when unlocked Glance only shows when unlocked
+Glance shows message previews\nwhile the screen is locked Glance shows message previews\nwhile the screen is locked
+Snake — swipe, roll, or press a direction Snake — swipe, roll, or press a direction
+N/A N/A
+This board cannot run this app. This board cannot run this app.
diff --git a/deploy/apps/lang/18/de.lang b/deploy/apps/lang/19/de.lang
similarity index 97%
rename from deploy/apps/lang/18/de.lang
rename to deploy/apps/lang/19/de.lang
index 60d5b56..1722419 100644
--- a/deploy/apps/lang/18/de.lang
+++ b/deploy/apps/lang/19/de.lang
@@ -1,7 +1,7 @@
# wadamesh language file
# canonical source - translators: edit this file and PR it
# code: de
-# ver: 18
+# ver: 19
# name: Deutsch
# base: de
(device behind) (Gerät geht nach)
@@ -452,7 +452,6 @@ Login OK (perms %u) Anmeldung OK (Rechte %u)
Login failed Anmeldung fehlgeschlagen
Logs Protokolle
Longitude Längengrad
-Lua Store Lua Store
MHz MHz
MQTT bridge MQTT-Brücke
Map Karte
@@ -694,7 +693,7 @@ SD card remounted SD-Karte neu eingebunden
SD card removed SD-Karte entfernt
SD data is unavailable - contacts and channels cannot be saved until the card is reinserted. SD-Daten nicht verfügbar – Kontakte und Kanäle können erst nach Einsetzen der Karte gespeichert werden.
SD data is unavailable - identity, settings, contacts and channels cannot be saved until the card is reinserted. SD-Daten nicht verfügbar – Identität, Einstellungen, Kontakte und Kanäle können erst nach Einsetzen der Karte gespeichert werden.
-SD data migration is incomplete. Identity and settings remain internal; use Copy internal data to SD to retry. SD-Datenübertragung unvollständig. Identität und Einstellungen bleiben intern; „Interne Daten auf SD kopieren“ zum erneuten Versuch verwenden.
+SD data migration is incomplete. Identity and settings remain internal; use Copy internal data to SD to retry. SD-Datenübertragung unvollständig. Identität und Einstellungen bleiben intern; "Interne Daten auf SD kopieren" zum erneuten Versuch verwenden.
SD download failed: %s SD-Download fehlgeschlagen: %s
SD formatted - %s (MESHCOMOD) SD formatiert - %s (MESHCOMOD)
SD is busy - close tools and retry SD ist belegt – Tools schließen und erneut versuchen
@@ -797,7 +796,6 @@ Sleep ready Bereit zum Schlafen
Slot %d cleared Slot %d gelöscht
Slot empty Slot leer
Small\nMedium\nLarge Klein\nMittel\nGroß
-Snake Snake
Snake — swipe or roll the trackball Snake — wischen oder Trackball rollen
Sort Sortieren
Sound Ton
@@ -805,7 +803,6 @@ Sound off Ton aus
Sound on Ton ein
Sound: %s Ton: %s
Start time Startzeit
-Status Status
Step 1 of 3 Schritt 1 von 3
Step 2 of 3 Schritt 2 von 3
Step 3 of 3 Schritt 3 von 3
@@ -818,7 +815,6 @@ Streams the device screen as a live picture you tap to control. Heavier; the pan
Switch Wechseln
Switch to the external antenna?\n\nMake sure an antenna is actually connected to the external antenna socket first. Transmitting with nothing attached can damage the radio.\n\nResets to the on-board antenna on every reboot. Auf die externe Antenne wechseln?\n\nErst sicherstellen, dass wirklich eine Antenne an der Buchse steckt. Senden ohne Antenne kann das Funkmodul beschädigen.\n\nFällt bei jedem Neustart auf die interne Antenne zurück.
Sync clock from system Uhr synchronisieren
-System System
System info Systeminfo
TCP %s BLE %s\nWS clients %d GPS %s Buzzer %s TCP %s BLE %s\nWS-Clients %d GPS %s Summer %s
TCP off TCP aus
@@ -977,8 +973,6 @@ tap to exit full screen tippen zum Verlassen des Vollbilds
zoom Zoom
zoom %d Zoom %d
~17 dB amp for quiet/remote areas; turn off in noisy spots. ~17 dB Verstärker für ruhige/abgelegene Gebiete; in lauten Zonen aus.
-© OpenStreetMap © OpenStreetMap
-© OpenTopoMap © OpenTopoMap
Scope
another region
my region
@@ -1000,7 +994,6 @@ Overwrite oldest non-favorite
1-character messages will be shown 1-character messages will be shown
All All
Allow Allow
-Apps that may send messages on the mesh. Anything they send goes out under your node name and cannot be told apart from a message you typed. Turn one off to take the permission back. Apps that may send messages on the mesh. Anything they send goes out under your node name and cannot be told apart from a message you typed. Turn one off to take the permission back.
Ignore 1-character messages Ignore 1-character messages
Join w/ password Join w/ password
Location no longer shared Location no longer shared
@@ -1014,9 +1007,7 @@ Never\nChosen contacts only\nAnyone who asks Never\nChosen contacts only\nAnyone
No apps installed. No apps installed.
No reply. Path hash is %u bytes;\nolder repeaters drop those.\nTry 1 byte (Settings > Radio & Mesh) No reply. Path hash is %u bytes;\nolder repeaters drop those.\nTry 1 byte (Settings > Radio & Mesh)
Not supported on this board Not supported on this board
-Read incoming messages Read incoming messages
Reconnected to %.40s Reconnected to %.40s
-Send messages as me Send messages as me
Sent only when that contact asks, encrypted to them. Nothing is broadcast. Needs "Answer telemetry requests" on. Pick contacts in a contact's menu. Sent only when that contact asks, encrypted to them. Nothing is broadcast. Needs "Answer telemetry requests" on. Pick contacts in a contact's menu.
Server has forgotten you.\nJoin with the room password. Server has forgotten you.\nJoin with the room password.
Share my loc Share my loc
@@ -1042,7 +1033,6 @@ App permissions App permissions
About / credits About / credits
Auth failed Auth failed
Auto-add on for %s — new %s land in Contacts automatically. Auto-add on for %s — new %s land in Contacts automatically.
-Batteryactivityon Batteryactivityon
Console mode Console mode
Console mode (experimental) Console mode (experimental)
Direct (0-hop) only Direct (0-hop) only
@@ -1051,8 +1041,6 @@ EXPERIMENTAL. Boots into a text console with no graphical interface: type comman
Enter text Enter text
Entering console mode - rebooting... Entering console mode - rebooting...
Favorite Favorite
-Geblokkeerde gebruikers Geblokkeerde gebruikers
-Hide system filesShow system files Hide system filesShow system files
How tiles work:\nThe map is built from 256×256 "slippy" tiles. Only the tiles for the area you're viewing are fetched — there is no bulk pre-download.\n\nBecause this device can't do HTTPS (not enough heap after Wi-Fi starts) and decodes JPEG far more cheaply than PNG, tiles come from the wadamesh proxy: it fetches the upstream PNG over HTTPS with an identifying User-Agent, re-encodes it as JPEG, and caches it. Your device then caches each tile to its own flash, so a tile is only downloaded once.\n\nUse Options → Reload tiles to re-download the tiles currently in view if one looks corrupted. How tiles work:\nThe map is built from 256×256 "slippy" tiles. Only the tiles for the area you're viewing are fetched — there is no bulk pre-download.\n\nBecause this device can't do HTTPS (not enough heap after Wi-Fi starts) and decodes JPEG far more cheaply than PNG, tiles come from the wadamesh proxy: it fetches the upstream PNG over HTTPS with an identifying User-Agent, re-encodes it as JPEG, and caches it. Your device then caches each tile to its own flash, so a tile is only downloaded once.\n\nUse Options → Reload tiles to re-download the tiles currently in view if one looks corrupted.
Init… Init…
Leaving console mode - rebooting... Leaving console mode - rebooting...
@@ -1062,8 +1050,6 @@ Map data © OpenStreetMap contributors.\nopenstreetmap.org/copyright\nLicensed u
Map style © OpenTopoMap (CC-BY-SA) — opentopomap.org\nMap data © OpenStreetMap contributors (ODbL) + SRTM.\n\n Map style © OpenTopoMap (CC-BY-SA) — opentopomap.org\nMap data © OpenStreetMap contributors (ODbL) + SRTM.\n\n
Name (A-Z) Name (A-Z)
Nearest first Nearest first
-Other networksNetworks Other networksNetworks
-Paste (move)Paste (copy) Paste (move)Paste (copy)
Recent message Recent message
Reload tiles in view Reload tiles in view
Repeaters Repeaters
@@ -1076,13 +1062,8 @@ Show contacts Show contacts
Show coordinates Show coordinates
Show tile z/x/y Show tile z/x/y
Starting… Starting…
-Stop sharing loc Share my loc Stop sharing loc Share my loc
-Supprimer Supprimer
-Switch to the external antenna?\n\nMake sure an antenna is actually connected to the external antenna socket first. Transmitting with nothing attached can damage the radio.\n\nResets to the on-board antenna on every reboot.Turn on legacy per-transmit switching?\n\nDiagnostic mode, for comparison only. It transmits on the on-board antenna and listens on the external one, so your outbound signal will be much weaker than your inbound.\n\nResets to the on-board antenna on every reboot. Switch to the external antenna?\n\nMake sure an antenna is actually connected to the external antenna socket first. Transmitting with nothing attached can damage the radio.\n\nResets to the on-board antenna on every reboot.Turn on legacy per-transmit switching?\n\nDiagnostic mode, for comparison only. It transmits on the on-board antenna and listens on the external one, so your outbound signal will be much weaker than your inbound.\n\nResets to the on-board antenna on every reboot.
Text console (experimental) Text console (experimental)
Tile debug overlay Tile debug overlay
-Unblock Block Unblock Block
-Unfav Favorite Unfav Favorite
chats chats
repeaters repeaters
rooms rooms
@@ -1098,3 +1079,23 @@ channel busy %.1f%% channel busy %.1f%%
sampling... sampling...
teal = now blue = average teal = now blue = average
window restarted window restarted
+Filter Filter
+Hide system files Hide system files
+Networks Networks
+Other networks Other networks
+Show system files Show system files
+Unfav Unfav
+Firmware beta_%d\nChecking for updates over Wi-Fi… Firmware beta_%d\nChecking for updates over Wi-Fi…
+Install beta_%d?\nThis downgrades the firmware and reboots. Install beta_%d?\nThis downgrades the firmware and reboots.
+Other (hidden) network… Other (hidden) network…
+Paused Paused
+Scan again Scan again
+At a glance At a glance
+At a glance disabled At a glance disabled
+At a glance enabled At a glance enabled
+At a glance while locked At a glance while locked
+Glance only shows when unlocked Glance only shows when unlocked
+Glance shows message previews\nwhile the screen is locked Glance shows message previews\nwhile the screen is locked
+Snake — swipe, roll, or press a direction Snake — swipe, roll, or press a direction
+N/A N/A
+This board cannot run this app. This board cannot run this app.
diff --git a/deploy/apps/lang/18/el.lang b/deploy/apps/lang/19/el.lang
similarity index 97%
rename from deploy/apps/lang/18/el.lang
rename to deploy/apps/lang/19/el.lang
index 203ba0b..6e966fc 100644
--- a/deploy/apps/lang/18/el.lang
+++ b/deploy/apps/lang/19/el.lang
@@ -1,7 +1,7 @@
# wadamesh language file
# canonical source - translators: edit this file and PR it
# code: el
-# ver: 18
+# ver: 19
# name: Ελληνικά
# base: el
(device behind) (συσκευή πίσω)
@@ -977,8 +977,6 @@ tap to exit full screen πατήστε για έξοδο από πλήρη οθ
zoom ζουμ
zoom %d ζουμ %d
~17 dB amp for quiet/remote areas; turn off in noisy spots. ενισχυτής ~17 dB για ήσυχες/απομακρυσμένες περιοχές· κλείστε σε θορυβώδεις.
-© OpenStreetMap © OpenStreetMap
-© OpenTopoMap © OpenTopoMap
Scope
another region
my region
@@ -1000,7 +998,6 @@ Overwrite oldest non-favorite
1-character messages will be shown 1-character messages will be shown
All All
Allow Allow
-Apps that may send messages on the mesh. Anything they send goes out under your node name and cannot be told apart from a message you typed. Turn one off to take the permission back. Apps that may send messages on the mesh. Anything they send goes out under your node name and cannot be told apart from a message you typed. Turn one off to take the permission back.
Ignore 1-character messages Ignore 1-character messages
Join w/ password Join w/ password
Location no longer shared Location no longer shared
@@ -1014,9 +1011,7 @@ Never\nChosen contacts only\nAnyone who asks Never\nChosen contacts only\nAnyone
No apps installed. No apps installed.
No reply. Path hash is %u bytes;\nolder repeaters drop those.\nTry 1 byte (Settings > Radio & Mesh) No reply. Path hash is %u bytes;\nolder repeaters drop those.\nTry 1 byte (Settings > Radio & Mesh)
Not supported on this board Not supported on this board
-Read incoming messages Read incoming messages
Reconnected to %.40s Reconnected to %.40s
-Send messages as me Send messages as me
Sent only when that contact asks, encrypted to them. Nothing is broadcast. Needs "Answer telemetry requests" on. Pick contacts in a contact's menu. Sent only when that contact asks, encrypted to them. Nothing is broadcast. Needs "Answer telemetry requests" on. Pick contacts in a contact's menu.
Server has forgotten you.\nJoin with the room password. Server has forgotten you.\nJoin with the room password.
Share my loc Share my loc
@@ -1042,7 +1037,6 @@ App permissions App permissions
About / credits About / credits
Auth failed Auth failed
Auto-add on for %s — new %s land in Contacts automatically. Auto-add on for %s — new %s land in Contacts automatically.
-Batteryactivityon Batteryactivityon
Console mode Console mode
Console mode (experimental) Console mode (experimental)
Direct (0-hop) only Direct (0-hop) only
@@ -1051,8 +1045,6 @@ EXPERIMENTAL. Boots into a text console with no graphical interface: type comman
Enter text Enter text
Entering console mode - rebooting... Entering console mode - rebooting...
Favorite Favorite
-Geblokkeerde gebruikers Geblokkeerde gebruikers
-Hide system filesShow system files Hide system filesShow system files
How tiles work:\nThe map is built from 256×256 "slippy" tiles. Only the tiles for the area you're viewing are fetched — there is no bulk pre-download.\n\nBecause this device can't do HTTPS (not enough heap after Wi-Fi starts) and decodes JPEG far more cheaply than PNG, tiles come from the wadamesh proxy: it fetches the upstream PNG over HTTPS with an identifying User-Agent, re-encodes it as JPEG, and caches it. Your device then caches each tile to its own flash, so a tile is only downloaded once.\n\nUse Options → Reload tiles to re-download the tiles currently in view if one looks corrupted. How tiles work:\nThe map is built from 256×256 "slippy" tiles. Only the tiles for the area you're viewing are fetched — there is no bulk pre-download.\n\nBecause this device can't do HTTPS (not enough heap after Wi-Fi starts) and decodes JPEG far more cheaply than PNG, tiles come from the wadamesh proxy: it fetches the upstream PNG over HTTPS with an identifying User-Agent, re-encodes it as JPEG, and caches it. Your device then caches each tile to its own flash, so a tile is only downloaded once.\n\nUse Options → Reload tiles to re-download the tiles currently in view if one looks corrupted.
Init… Init…
Leaving console mode - rebooting... Leaving console mode - rebooting...
@@ -1062,8 +1054,6 @@ Map data © OpenStreetMap contributors.\nopenstreetmap.org/copyright\nLicensed u
Map style © OpenTopoMap (CC-BY-SA) — opentopomap.org\nMap data © OpenStreetMap contributors (ODbL) + SRTM.\n\n Map style © OpenTopoMap (CC-BY-SA) — opentopomap.org\nMap data © OpenStreetMap contributors (ODbL) + SRTM.\n\n
Name (A-Z) Name (A-Z)
Nearest first Nearest first
-Other networksNetworks Other networksNetworks
-Paste (move)Paste (copy) Paste (move)Paste (copy)
Recent message Recent message
Reload tiles in view Reload tiles in view
Repeaters Repeaters
@@ -1076,13 +1066,8 @@ Show contacts Show contacts
Show coordinates Show coordinates
Show tile z/x/y Show tile z/x/y
Starting… Starting…
-Stop sharing loc Share my loc Stop sharing loc Share my loc
-Supprimer Supprimer
-Switch to the external antenna?\n\nMake sure an antenna is actually connected to the external antenna socket first. Transmitting with nothing attached can damage the radio.\n\nResets to the on-board antenna on every reboot.Turn on legacy per-transmit switching?\n\nDiagnostic mode, for comparison only. It transmits on the on-board antenna and listens on the external one, so your outbound signal will be much weaker than your inbound.\n\nResets to the on-board antenna on every reboot. Switch to the external antenna?\n\nMake sure an antenna is actually connected to the external antenna socket first. Transmitting with nothing attached can damage the radio.\n\nResets to the on-board antenna on every reboot.Turn on legacy per-transmit switching?\n\nDiagnostic mode, for comparison only. It transmits on the on-board antenna and listens on the external one, so your outbound signal will be much weaker than your inbound.\n\nResets to the on-board antenna on every reboot.
Text console (experimental) Text console (experimental)
Tile debug overlay Tile debug overlay
-Unblock Block Unblock Block
-Unfav Favorite Unfav Favorite
chats chats
repeaters repeaters
rooms rooms
@@ -1098,3 +1083,23 @@ channel busy %.1f%% channel busy %.1f%%
sampling... sampling...
teal = now blue = average teal = now blue = average
window restarted window restarted
+Filter Filter
+Hide system files Hide system files
+Networks Networks
+Other networks Other networks
+Show system files Show system files
+Unfav Unfav
+Firmware beta_%d\nChecking for updates over Wi-Fi… Firmware beta_%d\nChecking for updates over Wi-Fi…
+Install beta_%d?\nThis downgrades the firmware and reboots. Install beta_%d?\nThis downgrades the firmware and reboots.
+Other (hidden) network… Other (hidden) network…
+Paused Paused
+Scan again Scan again
+At a glance At a glance
+At a glance disabled At a glance disabled
+At a glance enabled At a glance enabled
+At a glance while locked At a glance while locked
+Glance only shows when unlocked Glance only shows when unlocked
+Glance shows message previews\nwhile the screen is locked Glance shows message previews\nwhile the screen is locked
+Snake — swipe, roll, or press a direction Snake — swipe, roll, or press a direction
+N/A N/A
+This board cannot run this app. This board cannot run this app.
diff --git a/deploy/apps/lang/18/es.lang b/deploy/apps/lang/19/es.lang
similarity index 97%
rename from deploy/apps/lang/18/es.lang
rename to deploy/apps/lang/19/es.lang
index fe56063..0b2cdbc 100644
--- a/deploy/apps/lang/18/es.lang
+++ b/deploy/apps/lang/19/es.lang
@@ -1,7 +1,7 @@
# wadamesh language file
# canonical source - translators: edit this file and PR it
# code: es
-# ver: 18
+# ver: 19
# name: Español
# base: es
(device behind) (dispositivo atrasado)
@@ -977,8 +977,6 @@ tap to exit full screen toca para salir de pantalla completa
zoom zoom
zoom %d zoom %d
~17 dB amp for quiet/remote areas; turn off in noisy spots. amplificador ~17 dB para zonas tranquilas/remotas; apaga en zonas ruidosas.
-© OpenStreetMap © OpenStreetMap
-© OpenTopoMap © OpenTopoMap
Scope
another region
my region
@@ -1000,7 +998,6 @@ Overwrite oldest non-favorite
1-character messages will be shown 1-character messages will be shown
All All
Allow Allow
-Apps that may send messages on the mesh. Anything they send goes out under your node name and cannot be told apart from a message you typed. Turn one off to take the permission back. Apps that may send messages on the mesh. Anything they send goes out under your node name and cannot be told apart from a message you typed. Turn one off to take the permission back.
Ignore 1-character messages Ignore 1-character messages
Join w/ password Join w/ password
Location no longer shared Location no longer shared
@@ -1014,9 +1011,7 @@ Never\nChosen contacts only\nAnyone who asks Never\nChosen contacts only\nAnyone
No apps installed. No apps installed.
No reply. Path hash is %u bytes;\nolder repeaters drop those.\nTry 1 byte (Settings > Radio & Mesh) No reply. Path hash is %u bytes;\nolder repeaters drop those.\nTry 1 byte (Settings > Radio & Mesh)
Not supported on this board Not supported on this board
-Read incoming messages Read incoming messages
Reconnected to %.40s Reconnected to %.40s
-Send messages as me Send messages as me
Sent only when that contact asks, encrypted to them. Nothing is broadcast. Needs "Answer telemetry requests" on. Pick contacts in a contact's menu. Sent only when that contact asks, encrypted to them. Nothing is broadcast. Needs "Answer telemetry requests" on. Pick contacts in a contact's menu.
Server has forgotten you.\nJoin with the room password. Server has forgotten you.\nJoin with the room password.
Share my loc Share my loc
@@ -1042,7 +1037,6 @@ App permissions App permissions
About / credits About / credits
Auth failed Auth failed
Auto-add on for %s — new %s land in Contacts automatically. Auto-add on for %s — new %s land in Contacts automatically.
-Batteryactivityon Batteryactivityon
Console mode Console mode
Console mode (experimental) Console mode (experimental)
Direct (0-hop) only Direct (0-hop) only
@@ -1051,8 +1045,6 @@ EXPERIMENTAL. Boots into a text console with no graphical interface: type comman
Enter text Enter text
Entering console mode - rebooting... Entering console mode - rebooting...
Favorite Favorite
-Geblokkeerde gebruikers Geblokkeerde gebruikers
-Hide system filesShow system files Hide system filesShow system files
How tiles work:\nThe map is built from 256×256 "slippy" tiles. Only the tiles for the area you're viewing are fetched — there is no bulk pre-download.\n\nBecause this device can't do HTTPS (not enough heap after Wi-Fi starts) and decodes JPEG far more cheaply than PNG, tiles come from the wadamesh proxy: it fetches the upstream PNG over HTTPS with an identifying User-Agent, re-encodes it as JPEG, and caches it. Your device then caches each tile to its own flash, so a tile is only downloaded once.\n\nUse Options → Reload tiles to re-download the tiles currently in view if one looks corrupted. How tiles work:\nThe map is built from 256×256 "slippy" tiles. Only the tiles for the area you're viewing are fetched — there is no bulk pre-download.\n\nBecause this device can't do HTTPS (not enough heap after Wi-Fi starts) and decodes JPEG far more cheaply than PNG, tiles come from the wadamesh proxy: it fetches the upstream PNG over HTTPS with an identifying User-Agent, re-encodes it as JPEG, and caches it. Your device then caches each tile to its own flash, so a tile is only downloaded once.\n\nUse Options → Reload tiles to re-download the tiles currently in view if one looks corrupted.
Init… Init…
Leaving console mode - rebooting... Leaving console mode - rebooting...
@@ -1062,8 +1054,6 @@ Map data © OpenStreetMap contributors.\nopenstreetmap.org/copyright\nLicensed u
Map style © OpenTopoMap (CC-BY-SA) — opentopomap.org\nMap data © OpenStreetMap contributors (ODbL) + SRTM.\n\n Map style © OpenTopoMap (CC-BY-SA) — opentopomap.org\nMap data © OpenStreetMap contributors (ODbL) + SRTM.\n\n
Name (A-Z) Name (A-Z)
Nearest first Nearest first
-Other networksNetworks Other networksNetworks
-Paste (move)Paste (copy) Paste (move)Paste (copy)
Recent message Recent message
Reload tiles in view Reload tiles in view
Repeaters Repeaters
@@ -1076,13 +1066,8 @@ Show contacts Show contacts
Show coordinates Show coordinates
Show tile z/x/y Show tile z/x/y
Starting… Starting…
-Stop sharing loc Share my loc Stop sharing loc Share my loc
-Supprimer Supprimer
-Switch to the external antenna?\n\nMake sure an antenna is actually connected to the external antenna socket first. Transmitting with nothing attached can damage the radio.\n\nResets to the on-board antenna on every reboot.Turn on legacy per-transmit switching?\n\nDiagnostic mode, for comparison only. It transmits on the on-board antenna and listens on the external one, so your outbound signal will be much weaker than your inbound.\n\nResets to the on-board antenna on every reboot. Switch to the external antenna?\n\nMake sure an antenna is actually connected to the external antenna socket first. Transmitting with nothing attached can damage the radio.\n\nResets to the on-board antenna on every reboot.Turn on legacy per-transmit switching?\n\nDiagnostic mode, for comparison only. It transmits on the on-board antenna and listens on the external one, so your outbound signal will be much weaker than your inbound.\n\nResets to the on-board antenna on every reboot.
Text console (experimental) Text console (experimental)
Tile debug overlay Tile debug overlay
-Unblock Block Unblock Block
-Unfav Favorite Unfav Favorite
chats chats
repeaters repeaters
rooms rooms
@@ -1098,3 +1083,23 @@ channel busy %.1f%% channel busy %.1f%%
sampling... sampling...
teal = now blue = average teal = now blue = average
window restarted window restarted
+Filter Filter
+Hide system files Hide system files
+Networks Networks
+Other networks Other networks
+Show system files Show system files
+Unfav Unfav
+Firmware beta_%d\nChecking for updates over Wi-Fi… Firmware beta_%d\nChecking for updates over Wi-Fi…
+Install beta_%d?\nThis downgrades the firmware and reboots. Install beta_%d?\nThis downgrades the firmware and reboots.
+Other (hidden) network… Other (hidden) network…
+Paused Paused
+Scan again Scan again
+At a glance At a glance
+At a glance disabled At a glance disabled
+At a glance enabled At a glance enabled
+At a glance while locked At a glance while locked
+Glance only shows when unlocked Glance only shows when unlocked
+Glance shows message previews\nwhile the screen is locked Glance shows message previews\nwhile the screen is locked
+Snake — swipe, roll, or press a direction Snake — swipe, roll, or press a direction
+N/A N/A
+This board cannot run this app. This board cannot run this app.
diff --git a/deploy/apps/lang/18/fr.lang b/deploy/apps/lang/19/fr.lang
similarity index 97%
rename from deploy/apps/lang/18/fr.lang
rename to deploy/apps/lang/19/fr.lang
index 7d5a664..8ccf214 100644
--- a/deploy/apps/lang/18/fr.lang
+++ b/deploy/apps/lang/19/fr.lang
@@ -1,7 +1,7 @@
# wadamesh language file
# canonical source - translators: edit this file and PR it
# code: fr
-# ver: 18
+# ver: 19
# name: Français
# base: fr
(device behind) (appareil en retard)
@@ -452,7 +452,6 @@ Login OK (perms %u) Connexion OK (perms %u)
Login failed Échec de connexion
Logs Journaux
Longitude Longitude
-Lua Store Lua Store
MHz MHz
MQTT bridge Pont MQTT
Map Carte
@@ -977,8 +976,6 @@ tap to exit full screen toucher pour quitter le plein écran
zoom zoom
zoom %d zoom %d
~17 dB amp for quiet/remote areas; turn off in noisy spots. ampli ~17 dB pour zones calmes/isolées ; désactiver en zone bruyante.
-© OpenStreetMap © OpenStreetMap
-© OpenTopoMap © OpenTopoMap
Scope
another region
my region
@@ -1000,7 +997,6 @@ Overwrite oldest non-favorite
1-character messages will be shown 1-character messages will be shown
All All
Allow Allow
-Apps that may send messages on the mesh. Anything they send goes out under your node name and cannot be told apart from a message you typed. Turn one off to take the permission back. Apps that may send messages on the mesh. Anything they send goes out under your node name and cannot be told apart from a message you typed. Turn one off to take the permission back.
Ignore 1-character messages Ignore 1-character messages
Join w/ password Join w/ password
Location no longer shared Location no longer shared
@@ -1014,9 +1010,7 @@ Never\nChosen contacts only\nAnyone who asks Never\nChosen contacts only\nAnyone
No apps installed. No apps installed.
No reply. Path hash is %u bytes;\nolder repeaters drop those.\nTry 1 byte (Settings > Radio & Mesh) No reply. Path hash is %u bytes;\nolder repeaters drop those.\nTry 1 byte (Settings > Radio & Mesh)
Not supported on this board Not supported on this board
-Read incoming messages Read incoming messages
Reconnected to %.40s Reconnected to %.40s
-Send messages as me Send messages as me
Sent only when that contact asks, encrypted to them. Nothing is broadcast. Needs "Answer telemetry requests" on. Pick contacts in a contact's menu. Sent only when that contact asks, encrypted to them. Nothing is broadcast. Needs "Answer telemetry requests" on. Pick contacts in a contact's menu.
Server has forgotten you.\nJoin with the room password. Server has forgotten you.\nJoin with the room password.
Share my loc Share my loc
@@ -1042,7 +1036,6 @@ App permissions App permissions
About / credits About / credits
Auth failed Auth failed
Auto-add on for %s — new %s land in Contacts automatically. Auto-add on for %s — new %s land in Contacts automatically.
-Batteryactivityon Batteryactivityon
Console mode Console mode
Console mode (experimental) Console mode (experimental)
Direct (0-hop) only Direct (0-hop) only
@@ -1051,8 +1044,6 @@ EXPERIMENTAL. Boots into a text console with no graphical interface: type comman
Enter text Enter text
Entering console mode - rebooting... Entering console mode - rebooting...
Favorite Favorite
-Geblokkeerde gebruikers Geblokkeerde gebruikers
-Hide system filesShow system files Hide system filesShow system files
How tiles work:\nThe map is built from 256×256 "slippy" tiles. Only the tiles for the area you're viewing are fetched — there is no bulk pre-download.\n\nBecause this device can't do HTTPS (not enough heap after Wi-Fi starts) and decodes JPEG far more cheaply than PNG, tiles come from the wadamesh proxy: it fetches the upstream PNG over HTTPS with an identifying User-Agent, re-encodes it as JPEG, and caches it. Your device then caches each tile to its own flash, so a tile is only downloaded once.\n\nUse Options → Reload tiles to re-download the tiles currently in view if one looks corrupted. How tiles work:\nThe map is built from 256×256 "slippy" tiles. Only the tiles for the area you're viewing are fetched — there is no bulk pre-download.\n\nBecause this device can't do HTTPS (not enough heap after Wi-Fi starts) and decodes JPEG far more cheaply than PNG, tiles come from the wadamesh proxy: it fetches the upstream PNG over HTTPS with an identifying User-Agent, re-encodes it as JPEG, and caches it. Your device then caches each tile to its own flash, so a tile is only downloaded once.\n\nUse Options → Reload tiles to re-download the tiles currently in view if one looks corrupted.
Init… Init…
Leaving console mode - rebooting... Leaving console mode - rebooting...
@@ -1062,8 +1053,6 @@ Map data © OpenStreetMap contributors.\nopenstreetmap.org/copyright\nLicensed u
Map style © OpenTopoMap (CC-BY-SA) — opentopomap.org\nMap data © OpenStreetMap contributors (ODbL) + SRTM.\n\n Map style © OpenTopoMap (CC-BY-SA) — opentopomap.org\nMap data © OpenStreetMap contributors (ODbL) + SRTM.\n\n
Name (A-Z) Name (A-Z)
Nearest first Nearest first
-Other networksNetworks Other networksNetworks
-Paste (move)Paste (copy) Paste (move)Paste (copy)
Recent message Recent message
Reload tiles in view Reload tiles in view
Repeaters Repeaters
@@ -1076,13 +1065,8 @@ Show contacts Show contacts
Show coordinates Show coordinates
Show tile z/x/y Show tile z/x/y
Starting… Starting…
-Stop sharing loc Share my loc Stop sharing loc Share my loc
-Supprimer Supprimer
-Switch to the external antenna?\n\nMake sure an antenna is actually connected to the external antenna socket first. Transmitting with nothing attached can damage the radio.\n\nResets to the on-board antenna on every reboot.Turn on legacy per-transmit switching?\n\nDiagnostic mode, for comparison only. It transmits on the on-board antenna and listens on the external one, so your outbound signal will be much weaker than your inbound.\n\nResets to the on-board antenna on every reboot. Switch to the external antenna?\n\nMake sure an antenna is actually connected to the external antenna socket first. Transmitting with nothing attached can damage the radio.\n\nResets to the on-board antenna on every reboot.Turn on legacy per-transmit switching?\n\nDiagnostic mode, for comparison only. It transmits on the on-board antenna and listens on the external one, so your outbound signal will be much weaker than your inbound.\n\nResets to the on-board antenna on every reboot.
Text console (experimental) Text console (experimental)
Tile debug overlay Tile debug overlay
-Unblock Block Unblock Block
-Unfav Favorite Unfav Favorite
chats chats
repeaters repeaters
rooms rooms
@@ -1098,3 +1082,23 @@ channel busy %.1f%% channel busy %.1f%%
sampling... sampling...
teal = now blue = average teal = now blue = average
window restarted window restarted
+Filter Filter
+Hide system files Hide system files
+Networks Networks
+Other networks Other networks
+Show system files Show system files
+Unfav Unfav
+Firmware beta_%d\nChecking for updates over Wi-Fi… Firmware beta_%d\nChecking for updates over Wi-Fi…
+Install beta_%d?\nThis downgrades the firmware and reboots. Install beta_%d?\nThis downgrades the firmware and reboots.
+Other (hidden) network… Other (hidden) network…
+Paused Paused
+Scan again Scan again
+At a glance At a glance
+At a glance disabled At a glance disabled
+At a glance enabled At a glance enabled
+At a glance while locked At a glance while locked
+Glance only shows when unlocked Glance only shows when unlocked
+Glance shows message previews\nwhile the screen is locked Glance shows message previews\nwhile the screen is locked
+Snake — swipe, roll, or press a direction Snake — swipe, roll, or press a direction
+N/A N/A
+This board cannot run this app. This board cannot run this app.
diff --git a/deploy/apps/lang/18/hu.lang b/deploy/apps/lang/19/hu.lang
similarity index 96%
rename from deploy/apps/lang/18/hu.lang
rename to deploy/apps/lang/19/hu.lang
index 84dfac7..fa5188b 100644
--- a/deploy/apps/lang/18/hu.lang
+++ b/deploy/apps/lang/19/hu.lang
@@ -1,7 +1,7 @@
# wadamesh language file
# canonical source - translators: edit this file and PR it
# code: hu
-# ver: 18
+# ver: 19
# name: Magyar
# base: hu
(device behind) (készülék hátulján)
@@ -703,7 +703,7 @@ SD card remounted SD kártya újracsatolva
SD card removed SD-kártya eltávolítva
SD data is unavailable - contacts and channels cannot be saved until the card is reinserted. Az SD-kártya adatai nem érhetők el – a névjegyek és a csatornák nem menthetők a kártya újbóli behelyezéséig.
SD data is unavailable - identity, settings, contacts and channels cannot be saved until the card is reinserted. Az SD-kártya adatai nem érhetők el – az azonosító, a beállítások, a névjegyek és a csatornák nem menthetők a kártya újbóli behelyezéséig.
-SD data migration is incomplete. Identity and settings remain internal; use Copy internal data to SD to retry. Az SD-kártya adatainak áthelyezése nem teljes. Az azonosító és a beállítások továbbra is a belső tárhelyen vannak; a folytatáshoz használd a „Belső adatok másolása SD-kártyára” lehetőséget.
+SD data migration is incomplete. Identity and settings remain internal; use Copy internal data to SD to retry. Az SD-kártya adatainak áthelyezése nem teljes. Az azonosító és a beállítások továbbra is a belső tárhelyen vannak; a folytatáshoz használd a "Belső adatok másolása SD-kártyára" lehetőséget.
SD download failed: %s SD-letöltés sikertelen: %s
SD formatted - %s (MESHCOMOD) SD formázás - %s (MESHCOMOD)
SD is busy - close tools and retry Az SD-kártya foglalt – zárd be az eszközöket, majd próbáld újra.
@@ -812,7 +812,6 @@ Sleep ready Készen áll az alvásra
Slot %d cleared %d hely kiürítve
Slot empty Üres hely
Small\nMedium\nLarge Kicsi\nKözepes\nNagy
-Snake Snake
Snake — swipe or roll the trackball Kígyó – pöccintsd vagy görgesd a görgetőgolyót
Sort Sort
Sound Hang
@@ -997,8 +996,6 @@ tap to exit full screen koppintson a teljes képernyős kilépéshez
zoom zoom
zoom %d zoom %d
~17 dB amp for quiet/remote areas; turn off in noisy spots. ~17 dB erősítővel csendes/távoli helyeken; zajos helyeken kapcsold ki.
-© OpenStreetMap © OpenStreetMap
-© OpenTopoMap © OpenTopoMap
Scope Hatókör
another region másik régió
my region saját régió
@@ -1065,11 +1062,10 @@ sensors érzékelők
Auto-add on for %s — new %s land in Contacts automatically. Automatikus hozzáadás bekapcsolva: %s — az új %s automatikusan a Névjegyek közé kerülnek.
Map data © OpenStreetMap contributors.\nopenstreetmap.org/copyright\nLicensed under the Open Database License (ODbL).\n\n Térkép adatok © OpenStreetMap közreműködői.\nopenstreetmap.org/copyright\nAz Open Database License (ODbL) licenc alatt.\n\n
Map style © OpenTopoMap (CC-BY-SA) — opentopomap.org\nMap data © OpenStreetMap contributors (ODbL) + SRTM.\n\n Térkép stílus © OpenTopoMap (CC-BY-SA) — opentopomap.org\nTérkép adatok © OpenStreetMap közreműködői (ODbL) + SRTM.\n\n
-How tiles work:\nThe map is built from 256×256 "slippy" tiles. Only the tiles for the area you're viewing are fetched — there is no bulk pre-download.\n\nBecause this device can't do HTTPS (not enough heap after Wi-Fi starts) and decodes JPEG far more cheaply than PNG, tiles come from the wadamesh proxy: it fetches the upstream PNG over HTTPS with an identifying User-Agent, re-encodes it as JPEG, and caches it. Your device then caches each tile to its own flash, so a tile is only downloaded once.\n\nUse Options → Reload tiles to re-download the tiles currently in view if one looks corrupted. A csempék működése:\nA térkép 256×256 képpontos „slippy” csempékből épül fel. Csak a megtekintett területhez szükséges csempék töltődnek le — nincs tömeges előzetes letöltés.\n\nMivel az eszköz nem támogatja a HTTPS-t (a Wi-Fi elindítása után nincs elegendő heap memória), és a JPEG képek dekódolása sokkal kevesebb erőforrást igényel, mint a PNG-é, a csempék a wadamesh proxyn keresztül érkeznek: a proxy HTTPS-en keresztül lekéri a forrásból a PNG-t egy azonosító User-Agent használatával, JPEG formátumba kódolja át, majd eltárolja a gyorsítótárban. Az eszköz ezután minden csempét a saját flash memóriájában is eltárol, így egy csempét csak egyszer kell letölteni.\n\nAz Options → Reload tiles lehetőséggel újra letöltheted a jelenleg megtekintett terület csempéit, ha valamelyik hibásan jelenik meg.
+How tiles work:\nThe map is built from 256×256 "slippy" tiles. Only the tiles for the area you're viewing are fetched — there is no bulk pre-download.\n\nBecause this device can't do HTTPS (not enough heap after Wi-Fi starts) and decodes JPEG far more cheaply than PNG, tiles come from the wadamesh proxy: it fetches the upstream PNG over HTTPS with an identifying User-Agent, re-encodes it as JPEG, and caches it. Your device then caches each tile to its own flash, so a tile is only downloaded once.\n\nUse Options → Reload tiles to re-download the tiles currently in view if one looks corrupted. A csempék működése:\nA térkép 256×256 képpontos "slippy" csempékből épül fel. Csak a megtekintett területhez szükséges csempék töltődnek le — nincs tömeges előzetes letöltés.\n\nMivel az eszköz nem támogatja a HTTPS-t (a Wi-Fi elindítása után nincs elegendő heap memória), és a JPEG képek dekódolása sokkal kevesebb erőforrást igényel, mint a PNG-é, a csempék a wadamesh proxyn keresztül érkeznek: a proxy HTTPS-en keresztül lekéri a forrásból a PNG-t egy azonosító User-Agent használatával, JPEG formátumba kódolja át, majd eltárolja a gyorsítótárban. Az eszköz ezután minden csempét a saját flash memóriájában is eltárol, így egy csempét csak egyszer kell letölteni.\n\nAz Options → Reload tiles lehetőséggel újra letöltheted a jelenleg megtekintett terület csempéit, ha valamelyik hibásan jelenik meg.
"%s" wants to send discovery probes.\n\nEach probe asks every node in range to reply, so it uses airtime on the whole local mesh, not just yours.\n\nNothing is sent under your name and no message is transmitted. "%s" felfedezési probe-ok küldését kéri.\n\nMinden probe válaszra kéri a hatótávolságon belüli node-okat, ezért a teljes helyi mesh hálózat airtime-ját használja, nem csak a tiédet.\n\nSemmi sem kerül elküldésre a nevedben, és üzenet sem kerül továbbításra.
? ?
Auth failed Auth failed
-Batteryactivityon Batteryactivityon
Console mode Console mode
Console mode (experimental) Console mode (experimental)
Direct (0-hop) only Csak közvetlen (0-hop)
@@ -1078,16 +1074,12 @@ EXPERIMENTAL. Boots into a text console with no graphical interface: type comman
Enter text Szöveg megadása
Entering console mode - rebooting... Entering console mode - rebooting...
Favorite Favorite
-Geblokkeerde gebruikers Geblokkeerde gebruikers
-Hide system filesShow system files Hide system filesShow system files
Init… Init…
Leaving console mode - rebooting... Leaving console mode - rebooting...
Link lost Link lost
MESH MESH
Name (A-Z) Név (A–Z)
Nearest first Legközelebbi elöl
-Other networksNetworks Other networksNetworks
-Paste (move)Paste (copy) Paste (move)Paste (copy)
Recent message Legutóbbi üzenet
Reload tiles in view Csempék újratöltése
Repeaters Repeaters
@@ -1098,13 +1090,8 @@ Show contacts Névjegyek megjelenítése
Show coordinates Koordináták
Show tile z/x/y Csempe z/x/y
Starting… Starting…
-Stop sharing loc Share my loc Stop sharing loc Share my loc
-Supprimer Supprimer
-Switch to the external antenna?\n\nMake sure an antenna is actually connected to the external antenna socket first. Transmitting with nothing attached can damage the radio.\n\nResets to the on-board antenna on every reboot.Turn on legacy per-transmit switching?\n\nDiagnostic mode, for comparison only. It transmits on the on-board antenna and listens on the external one, so your outbound signal will be much weaker than your inbound.\n\nResets to the on-board antenna on every reboot. Switch to the external antenna?\n\nMake sure an antenna is actually connected to the external antenna socket first. Transmitting with nothing attached can damage the radio.\n\nResets to the on-board antenna on every reboot.Turn on legacy per-transmit switching?\n\nDiagnostic mode, for comparison only. It transmits on the on-board antenna and listens on the external one, so your outbound signal will be much weaker than your inbound.\n\nResets to the on-board antenna on every reboot.
Text console (experimental) Text console (experimental)
Tile debug overlay Csempe hiba keret
-Unblock Block Unblock Block
-Unfav Favorite Unfav Favorite
tap to cycle off / on / auto tap to cycle off / on / auto
#7A7F87 Scanning… nothing has answered yet# #7A7F87 Scanning… nothing has answered yet#
Export crash report (%uK) Hibajelentés exportálása (%uK)
@@ -1116,3 +1103,21 @@ channel busy %.1f%% channel busy %.1f%%
sampling... sampling...
teal = now blue = average teal = now blue = average
window restarted window restarted
+Filter Filter
+Hide system files Hide system files
+Networks Networks
+Show system files Show system files
+Unfav Unfav
+Firmware beta_%d\nChecking for updates over Wi-Fi… Firmware beta_%d\nChecking for updates over Wi-Fi…
+Install beta_%d?\nThis downgrades the firmware and reboots. A beta_%d telepítése?\nEz leminősíti a firmware-t és újraindul.
+Other (hidden) network… Other (hidden) network…
+Paused Paused
+At a glance At a glance
+At a glance disabled At a glance disabled
+At a glance enabled At a glance enabled
+At a glance while locked At a glance while locked
+Glance only shows when unlocked Glance only shows when unlocked
+Glance shows message previews\nwhile the screen is locked Glance shows message previews\nwhile the screen is locked
+Snake — swipe, roll, or press a direction Snake — swipe, roll, or press a direction
+N/A N/A
+This board cannot run this app. This board cannot run this app.
diff --git a/deploy/apps/lang/18/it.lang b/deploy/apps/lang/19/it.lang
similarity index 97%
rename from deploy/apps/lang/18/it.lang
rename to deploy/apps/lang/19/it.lang
index e0350d1..49b3183 100644
--- a/deploy/apps/lang/18/it.lang
+++ b/deploy/apps/lang/19/it.lang
@@ -1,7 +1,7 @@
# wadamesh language file
# canonical source - translators: edit this file and PR it
# code: it
-# ver: 18
+# ver: 19
# name: Italiano
# base: it
(device behind) (dispositivo indietro)
@@ -452,7 +452,6 @@ Login OK (perms %u) Accesso OK (permessi %u)
Login failed Accesso non riuscito
Logs Log
Longitude Longitudine
-Lua Store Lua Store
MHz MHz
MQTT bridge Ponte MQTT
Map Mappa
@@ -977,8 +976,6 @@ tap to exit full screen tocca per uscire da schermo intero
zoom zoom
zoom %d zoom %d
~17 dB amp for quiet/remote areas; turn off in noisy spots. ampli ~17 dB per zone tranquille/remote; spegni in zone rumorose.
-© OpenStreetMap © OpenStreetMap
-© OpenTopoMap © OpenTopoMap
Scope
another region
my region
@@ -1000,7 +997,6 @@ Overwrite oldest non-favorite
1-character messages will be shown 1-character messages will be shown
All All
Allow Allow
-Apps that may send messages on the mesh. Anything they send goes out under your node name and cannot be told apart from a message you typed. Turn one off to take the permission back. Apps that may send messages on the mesh. Anything they send goes out under your node name and cannot be told apart from a message you typed. Turn one off to take the permission back.
Ignore 1-character messages Ignore 1-character messages
Join w/ password Join w/ password
Location no longer shared Location no longer shared
@@ -1014,9 +1010,7 @@ Never\nChosen contacts only\nAnyone who asks Never\nChosen contacts only\nAnyone
No apps installed. No apps installed.
No reply. Path hash is %u bytes;\nolder repeaters drop those.\nTry 1 byte (Settings > Radio & Mesh) No reply. Path hash is %u bytes;\nolder repeaters drop those.\nTry 1 byte (Settings > Radio & Mesh)
Not supported on this board Not supported on this board
-Read incoming messages Read incoming messages
Reconnected to %.40s Reconnected to %.40s
-Send messages as me Send messages as me
Sent only when that contact asks, encrypted to them. Nothing is broadcast. Needs "Answer telemetry requests" on. Pick contacts in a contact's menu. Sent only when that contact asks, encrypted to them. Nothing is broadcast. Needs "Answer telemetry requests" on. Pick contacts in a contact's menu.
Server has forgotten you.\nJoin with the room password. Server has forgotten you.\nJoin with the room password.
Share my loc Share my loc
@@ -1042,7 +1036,6 @@ App permissions App permissions
About / credits About / credits
Auth failed Auth failed
Auto-add on for %s — new %s land in Contacts automatically. Auto-add on for %s — new %s land in Contacts automatically.
-Batteryactivityon Batteryactivityon
Console mode Console mode
Console mode (experimental) Console mode (experimental)
Direct (0-hop) only Direct (0-hop) only
@@ -1051,8 +1044,6 @@ EXPERIMENTAL. Boots into a text console with no graphical interface: type comman
Enter text Enter text
Entering console mode - rebooting... Entering console mode - rebooting...
Favorite Favorite
-Geblokkeerde gebruikers Geblokkeerde gebruikers
-Hide system filesShow system files Hide system filesShow system files
How tiles work:\nThe map is built from 256×256 "slippy" tiles. Only the tiles for the area you're viewing are fetched — there is no bulk pre-download.\n\nBecause this device can't do HTTPS (not enough heap after Wi-Fi starts) and decodes JPEG far more cheaply than PNG, tiles come from the wadamesh proxy: it fetches the upstream PNG over HTTPS with an identifying User-Agent, re-encodes it as JPEG, and caches it. Your device then caches each tile to its own flash, so a tile is only downloaded once.\n\nUse Options → Reload tiles to re-download the tiles currently in view if one looks corrupted. How tiles work:\nThe map is built from 256×256 "slippy" tiles. Only the tiles for the area you're viewing are fetched — there is no bulk pre-download.\n\nBecause this device can't do HTTPS (not enough heap after Wi-Fi starts) and decodes JPEG far more cheaply than PNG, tiles come from the wadamesh proxy: it fetches the upstream PNG over HTTPS with an identifying User-Agent, re-encodes it as JPEG, and caches it. Your device then caches each tile to its own flash, so a tile is only downloaded once.\n\nUse Options → Reload tiles to re-download the tiles currently in view if one looks corrupted.
Init… Init…
Leaving console mode - rebooting... Leaving console mode - rebooting...
@@ -1062,8 +1053,6 @@ Map data © OpenStreetMap contributors.\nopenstreetmap.org/copyright\nLicensed u
Map style © OpenTopoMap (CC-BY-SA) — opentopomap.org\nMap data © OpenStreetMap contributors (ODbL) + SRTM.\n\n Map style © OpenTopoMap (CC-BY-SA) — opentopomap.org\nMap data © OpenStreetMap contributors (ODbL) + SRTM.\n\n
Name (A-Z) Name (A-Z)
Nearest first Nearest first
-Other networksNetworks Other networksNetworks
-Paste (move)Paste (copy) Paste (move)Paste (copy)
Recent message Recent message
Reload tiles in view Reload tiles in view
Repeaters Repeaters
@@ -1076,13 +1065,8 @@ Show contacts Show contacts
Show coordinates Show coordinates
Show tile z/x/y Show tile z/x/y
Starting… Starting…
-Stop sharing loc Share my loc Stop sharing loc Share my loc
-Supprimer Supprimer
-Switch to the external antenna?\n\nMake sure an antenna is actually connected to the external antenna socket first. Transmitting with nothing attached can damage the radio.\n\nResets to the on-board antenna on every reboot.Turn on legacy per-transmit switching?\n\nDiagnostic mode, for comparison only. It transmits on the on-board antenna and listens on the external one, so your outbound signal will be much weaker than your inbound.\n\nResets to the on-board antenna on every reboot. Switch to the external antenna?\n\nMake sure an antenna is actually connected to the external antenna socket first. Transmitting with nothing attached can damage the radio.\n\nResets to the on-board antenna on every reboot.Turn on legacy per-transmit switching?\n\nDiagnostic mode, for comparison only. It transmits on the on-board antenna and listens on the external one, so your outbound signal will be much weaker than your inbound.\n\nResets to the on-board antenna on every reboot.
Text console (experimental) Text console (experimental)
Tile debug overlay Tile debug overlay
-Unblock Block Unblock Block
-Unfav Favorite Unfav Favorite
chats chats
repeaters repeaters
rooms rooms
@@ -1098,3 +1082,23 @@ channel busy %.1f%% channel busy %.1f%%
sampling... sampling...
teal = now blue = average teal = now blue = average
window restarted window restarted
+Filter Filter
+Hide system files Hide system files
+Networks Networks
+Other networks Other networks
+Show system files Show system files
+Unfav Unfav
+Firmware beta_%d\nChecking for updates over Wi-Fi… Firmware beta_%d\nChecking for updates over Wi-Fi…
+Install beta_%d?\nThis downgrades the firmware and reboots. Install beta_%d?\nThis downgrades the firmware and reboots.
+Other (hidden) network… Other (hidden) network…
+Paused Paused
+Scan again Scan again
+At a glance At a glance
+At a glance disabled At a glance disabled
+At a glance enabled At a glance enabled
+At a glance while locked At a glance while locked
+Glance only shows when unlocked Glance only shows when unlocked
+Glance shows message previews\nwhile the screen is locked Glance shows message previews\nwhile the screen is locked
+Snake — swipe, roll, or press a direction Snake — swipe, roll, or press a direction
+N/A N/A
+This board cannot run this app. This board cannot run this app.
diff --git a/deploy/apps/lang/18/nl.lang b/deploy/apps/lang/19/nl.lang
similarity index 97%
rename from deploy/apps/lang/18/nl.lang
rename to deploy/apps/lang/19/nl.lang
index a7f4f93..cc0e72d 100644
--- a/deploy/apps/lang/18/nl.lang
+++ b/deploy/apps/lang/19/nl.lang
@@ -1,7 +1,7 @@
# wadamesh language file
# canonical source - translators: edit this file and PR it
# code: nl
-# ver: 18
+# ver: 19
# name: Nederlands
# base: nl
(device behind) (apparaat loopt achter)
@@ -452,7 +452,6 @@ Login OK (perms %u) Inloggen OK (rechten %u)
Login failed Inloggen mislukt
Logs Logboeken
Longitude Lengtegraad
-Lua Store Lua Store
MHz MHz
MQTT bridge MQTT-brug
Map Kaart
@@ -797,7 +796,6 @@ Sleep ready Klaar voor slaap
Slot %d cleared Slot %d gewist
Slot empty Slot leeg
Small\nMedium\nLarge Klein\nMiddel\nGroot
-Snake Snake
Snake — swipe or roll the trackball Snake — veeg of rol de trackball
Sort Sorteer
Sound Geluid
@@ -805,7 +803,6 @@ Sound off Geluid uit
Sound on Geluid aan
Sound: %s Geluid: %s
Start time Starttijd
-Status Status
Step 1 of 3 Stap 1 van 3
Step 2 of 3 Stap 2 van 3
Step 3 of 3 Stap 3 van 3
@@ -977,8 +974,6 @@ tap to exit full screen tik om volledig scherm te verlaten
zoom zoom
zoom %d zoom %d
~17 dB amp for quiet/remote areas; turn off in noisy spots. ~17 dB versterker voor stille/afgelegen gebieden; uit in drukke plekken.
-© OpenStreetMap © OpenStreetMap
-© OpenTopoMap © OpenTopoMap
Scope
another region
my region
@@ -1000,7 +995,6 @@ Overwrite oldest non-favorite
1-character messages will be shown 1-character messages will be shown
All All
Allow Allow
-Apps that may send messages on the mesh. Anything they send goes out under your node name and cannot be told apart from a message you typed. Turn one off to take the permission back. Apps that may send messages on the mesh. Anything they send goes out under your node name and cannot be told apart from a message you typed. Turn one off to take the permission back.
Ignore 1-character messages Ignore 1-character messages
Join w/ password Join w/ password
Location no longer shared Location no longer shared
@@ -1014,9 +1008,7 @@ Never\nChosen contacts only\nAnyone who asks Never\nChosen contacts only\nAnyone
No apps installed. No apps installed.
No reply. Path hash is %u bytes;\nolder repeaters drop those.\nTry 1 byte (Settings > Radio & Mesh) No reply. Path hash is %u bytes;\nolder repeaters drop those.\nTry 1 byte (Settings > Radio & Mesh)
Not supported on this board Not supported on this board
-Read incoming messages Read incoming messages
Reconnected to %.40s Reconnected to %.40s
-Send messages as me Send messages as me
Sent only when that contact asks, encrypted to them. Nothing is broadcast. Needs "Answer telemetry requests" on. Pick contacts in a contact's menu. Sent only when that contact asks, encrypted to them. Nothing is broadcast. Needs "Answer telemetry requests" on. Pick contacts in a contact's menu.
Server has forgotten you.\nJoin with the room password. Server has forgotten you.\nJoin with the room password.
Share my loc Share my loc
@@ -1042,7 +1034,6 @@ App permissions App permissions
About / credits About / credits
Auth failed Auth failed
Auto-add on for %s — new %s land in Contacts automatically. Auto-add on for %s — new %s land in Contacts automatically.
-Batteryactivityon Batteryactivityon
Console mode Console mode
Console mode (experimental) Console mode (experimental)
Direct (0-hop) only Direct (0-hop) only
@@ -1051,8 +1042,6 @@ EXPERIMENTAL. Boots into a text console with no graphical interface: type comman
Enter text Enter text
Entering console mode - rebooting... Entering console mode - rebooting...
Favorite Favorite
-Geblokkeerde gebruikers Geblokkeerde gebruikers
-Hide system filesShow system files Hide system filesShow system files
How tiles work:\nThe map is built from 256×256 "slippy" tiles. Only the tiles for the area you're viewing are fetched — there is no bulk pre-download.\n\nBecause this device can't do HTTPS (not enough heap after Wi-Fi starts) and decodes JPEG far more cheaply than PNG, tiles come from the wadamesh proxy: it fetches the upstream PNG over HTTPS with an identifying User-Agent, re-encodes it as JPEG, and caches it. Your device then caches each tile to its own flash, so a tile is only downloaded once.\n\nUse Options → Reload tiles to re-download the tiles currently in view if one looks corrupted. How tiles work:\nThe map is built from 256×256 "slippy" tiles. Only the tiles for the area you're viewing are fetched — there is no bulk pre-download.\n\nBecause this device can't do HTTPS (not enough heap after Wi-Fi starts) and decodes JPEG far more cheaply than PNG, tiles come from the wadamesh proxy: it fetches the upstream PNG over HTTPS with an identifying User-Agent, re-encodes it as JPEG, and caches it. Your device then caches each tile to its own flash, so a tile is only downloaded once.\n\nUse Options → Reload tiles to re-download the tiles currently in view if one looks corrupted.
Init… Init…
Leaving console mode - rebooting... Leaving console mode - rebooting...
@@ -1062,8 +1051,6 @@ Map data © OpenStreetMap contributors.\nopenstreetmap.org/copyright\nLicensed u
Map style © OpenTopoMap (CC-BY-SA) — opentopomap.org\nMap data © OpenStreetMap contributors (ODbL) + SRTM.\n\n Map style © OpenTopoMap (CC-BY-SA) — opentopomap.org\nMap data © OpenStreetMap contributors (ODbL) + SRTM.\n\n
Name (A-Z) Name (A-Z)
Nearest first Nearest first
-Other networksNetworks Other networksNetworks
-Paste (move)Paste (copy) Paste (move)Paste (copy)
Recent message Recent message
Reload tiles in view Reload tiles in view
Repeaters Repeaters
@@ -1076,13 +1063,8 @@ Show contacts Show contacts
Show coordinates Show coordinates
Show tile z/x/y Show tile z/x/y
Starting… Starting…
-Stop sharing loc Share my loc Stop sharing loc Share my loc
-Supprimer Supprimer
-Switch to the external antenna?\n\nMake sure an antenna is actually connected to the external antenna socket first. Transmitting with nothing attached can damage the radio.\n\nResets to the on-board antenna on every reboot.Turn on legacy per-transmit switching?\n\nDiagnostic mode, for comparison only. It transmits on the on-board antenna and listens on the external one, so your outbound signal will be much weaker than your inbound.\n\nResets to the on-board antenna on every reboot. Switch to the external antenna?\n\nMake sure an antenna is actually connected to the external antenna socket first. Transmitting with nothing attached can damage the radio.\n\nResets to the on-board antenna on every reboot.Turn on legacy per-transmit switching?\n\nDiagnostic mode, for comparison only. It transmits on the on-board antenna and listens on the external one, so your outbound signal will be much weaker than your inbound.\n\nResets to the on-board antenna on every reboot.
Text console (experimental) Text console (experimental)
Tile debug overlay Tile debug overlay
-Unblock Block Unblock Block
-Unfav Favorite Unfav Favorite
chats chats
repeaters repeaters
rooms rooms
@@ -1098,3 +1080,23 @@ channel busy %.1f%% channel busy %.1f%%
sampling... sampling...
teal = now blue = average teal = now blue = average
window restarted window restarted
+Filter Filter
+Hide system files Hide system files
+Networks Networks
+Other networks Other networks
+Show system files Show system files
+Unfav Unfav
+Firmware beta_%d\nChecking for updates over Wi-Fi… Firmware beta_%d\nChecking for updates over Wi-Fi…
+Install beta_%d?\nThis downgrades the firmware and reboots. Install beta_%d?\nThis downgrades the firmware and reboots.
+Other (hidden) network… Other (hidden) network…
+Paused Paused
+Scan again Scan again
+At a glance At a glance
+At a glance disabled At a glance disabled
+At a glance enabled At a glance enabled
+At a glance while locked At a glance while locked
+Glance only shows when unlocked Glance only shows when unlocked
+Glance shows message previews\nwhile the screen is locked Glance shows message previews\nwhile the screen is locked
+Snake — swipe, roll, or press a direction Snake — swipe, roll, or press a direction
+N/A N/A
+This board cannot run this app. This board cannot run this app.
diff --git a/deploy/apps/lang/18/pt-br.lang b/deploy/apps/lang/19/pt-br.lang
similarity index 97%
rename from deploy/apps/lang/18/pt-br.lang
rename to deploy/apps/lang/19/pt-br.lang
index e39cb21..2bcda5e 100644
--- a/deploy/apps/lang/18/pt-br.lang
+++ b/deploy/apps/lang/19/pt-br.lang
@@ -1,7 +1,7 @@
# wadamesh language file
# canonical source - translators: edit this file and PR it
# code: pt-br
-# ver: 18
+# ver: 19
# name: Português (BR)
# base: pt-br
(device behind) (dispositivo atrasado)
@@ -805,7 +805,6 @@ Sound off Som desligado
Sound on Som ligado
Sound: %s Som: %s
Start time Hora de início
-Status Status
Step 1 of 3 Passo 1 de 3
Step 2 of 3 Passo 2 de 3
Step 3 of 3 Passo 3 de 3
@@ -977,8 +976,6 @@ tap to exit full screen toque para sair da tela cheia
zoom zoom
zoom %d zoom %d
~17 dB amp for quiet/remote areas; turn off in noisy spots. amplificador ~17 dB para áreas silenciosas/remotas; desligue em locais ruidosos.
-© OpenStreetMap © OpenStreetMap
-© OpenTopoMap © OpenTopoMap
Scope
another region
my region
@@ -1000,7 +997,6 @@ Overwrite oldest non-favorite
1-character messages will be shown 1-character messages will be shown
All All
Allow Allow
-Apps that may send messages on the mesh. Anything they send goes out under your node name and cannot be told apart from a message you typed. Turn one off to take the permission back. Apps that may send messages on the mesh. Anything they send goes out under your node name and cannot be told apart from a message you typed. Turn one off to take the permission back.
Ignore 1-character messages Ignore 1-character messages
Join w/ password Join w/ password
Location no longer shared Location no longer shared
@@ -1014,9 +1010,7 @@ Never\nChosen contacts only\nAnyone who asks Never\nChosen contacts only\nAnyone
No apps installed. No apps installed.
No reply. Path hash is %u bytes;\nolder repeaters drop those.\nTry 1 byte (Settings > Radio & Mesh) No reply. Path hash is %u bytes;\nolder repeaters drop those.\nTry 1 byte (Settings > Radio & Mesh)
Not supported on this board Not supported on this board
-Read incoming messages Read incoming messages
Reconnected to %.40s Reconnected to %.40s
-Send messages as me Send messages as me
Sent only when that contact asks, encrypted to them. Nothing is broadcast. Needs "Answer telemetry requests" on. Pick contacts in a contact's menu. Sent only when that contact asks, encrypted to them. Nothing is broadcast. Needs "Answer telemetry requests" on. Pick contacts in a contact's menu.
Server has forgotten you.\nJoin with the room password. Server has forgotten you.\nJoin with the room password.
Share my loc Share my loc
@@ -1042,7 +1036,6 @@ App permissions App permissions
About / credits About / credits
Auth failed Auth failed
Auto-add on for %s — new %s land in Contacts automatically. Auto-add on for %s — new %s land in Contacts automatically.
-Batteryactivityon Batteryactivityon
Console mode Console mode
Console mode (experimental) Console mode (experimental)
Direct (0-hop) only Direct (0-hop) only
@@ -1051,8 +1044,6 @@ EXPERIMENTAL. Boots into a text console with no graphical interface: type comman
Enter text Enter text
Entering console mode - rebooting... Entering console mode - rebooting...
Favorite Favorite
-Geblokkeerde gebruikers Geblokkeerde gebruikers
-Hide system filesShow system files Hide system filesShow system files
How tiles work:\nThe map is built from 256×256 "slippy" tiles. Only the tiles for the area you're viewing are fetched — there is no bulk pre-download.\n\nBecause this device can't do HTTPS (not enough heap after Wi-Fi starts) and decodes JPEG far more cheaply than PNG, tiles come from the wadamesh proxy: it fetches the upstream PNG over HTTPS with an identifying User-Agent, re-encodes it as JPEG, and caches it. Your device then caches each tile to its own flash, so a tile is only downloaded once.\n\nUse Options → Reload tiles to re-download the tiles currently in view if one looks corrupted. How tiles work:\nThe map is built from 256×256 "slippy" tiles. Only the tiles for the area you're viewing are fetched — there is no bulk pre-download.\n\nBecause this device can't do HTTPS (not enough heap after Wi-Fi starts) and decodes JPEG far more cheaply than PNG, tiles come from the wadamesh proxy: it fetches the upstream PNG over HTTPS with an identifying User-Agent, re-encodes it as JPEG, and caches it. Your device then caches each tile to its own flash, so a tile is only downloaded once.\n\nUse Options → Reload tiles to re-download the tiles currently in view if one looks corrupted.
Init… Init…
Leaving console mode - rebooting... Leaving console mode - rebooting...
@@ -1062,8 +1053,6 @@ Map data © OpenStreetMap contributors.\nopenstreetmap.org/copyright\nLicensed u
Map style © OpenTopoMap (CC-BY-SA) — opentopomap.org\nMap data © OpenStreetMap contributors (ODbL) + SRTM.\n\n Map style © OpenTopoMap (CC-BY-SA) — opentopomap.org\nMap data © OpenStreetMap contributors (ODbL) + SRTM.\n\n
Name (A-Z) Name (A-Z)
Nearest first Nearest first
-Other networksNetworks Other networksNetworks
-Paste (move)Paste (copy) Paste (move)Paste (copy)
Recent message Recent message
Reload tiles in view Reload tiles in view
Repeaters Repeaters
@@ -1076,13 +1065,8 @@ Show contacts Show contacts
Show coordinates Show coordinates
Show tile z/x/y Show tile z/x/y
Starting… Starting…
-Stop sharing loc Share my loc Stop sharing loc Share my loc
-Supprimer Supprimer
-Switch to the external antenna?\n\nMake sure an antenna is actually connected to the external antenna socket first. Transmitting with nothing attached can damage the radio.\n\nResets to the on-board antenna on every reboot.Turn on legacy per-transmit switching?\n\nDiagnostic mode, for comparison only. It transmits on the on-board antenna and listens on the external one, so your outbound signal will be much weaker than your inbound.\n\nResets to the on-board antenna on every reboot. Switch to the external antenna?\n\nMake sure an antenna is actually connected to the external antenna socket first. Transmitting with nothing attached can damage the radio.\n\nResets to the on-board antenna on every reboot.Turn on legacy per-transmit switching?\n\nDiagnostic mode, for comparison only. It transmits on the on-board antenna and listens on the external one, so your outbound signal will be much weaker than your inbound.\n\nResets to the on-board antenna on every reboot.
Text console (experimental) Text console (experimental)
Tile debug overlay Tile debug overlay
-Unblock Block Unblock Block
-Unfav Favorite Unfav Favorite
chats chats
repeaters repeaters
rooms rooms
@@ -1098,3 +1082,23 @@ channel busy %.1f%% channel busy %.1f%%
sampling... sampling...
teal = now blue = average teal = now blue = average
window restarted window restarted
+Filter Filter
+Hide system files Hide system files
+Networks Networks
+Other networks Other networks
+Show system files Show system files
+Unfav Unfav
+Firmware beta_%d\nChecking for updates over Wi-Fi… Firmware beta_%d\nChecking for updates over Wi-Fi…
+Install beta_%d?\nThis downgrades the firmware and reboots. Install beta_%d?\nThis downgrades the firmware and reboots.
+Other (hidden) network… Other (hidden) network…
+Paused Paused
+Scan again Scan again
+At a glance At a glance
+At a glance disabled At a glance disabled
+At a glance enabled At a glance enabled
+At a glance while locked At a glance while locked
+Glance only shows when unlocked Glance only shows when unlocked
+Glance shows message previews\nwhile the screen is locked Glance shows message previews\nwhile the screen is locked
+Snake — swipe, roll, or press a direction Snake — swipe, roll, or press a direction
+N/A N/A
+This board cannot run this app. This board cannot run this app.
diff --git a/deploy/apps/lang/18/ro.lang b/deploy/apps/lang/19/ro.lang
similarity index 97%
rename from deploy/apps/lang/18/ro.lang
rename to deploy/apps/lang/19/ro.lang
index 6967b9d..4679e46 100644
--- a/deploy/apps/lang/18/ro.lang
+++ b/deploy/apps/lang/19/ro.lang
@@ -1,7 +1,7 @@
# wadamesh language file
# canonical source - translators: edit this file and PR it
# code: ro
-# ver: 18
+# ver: 19
# name: Română
# base: ro
(device behind) (dispozitiv în urmă)
@@ -978,7 +978,6 @@ zoom zoom
zoom %d zoom %d
~17 dB amp for quiet/remote areas; turn off in noisy spots. amplificator ~17 dB pentru zone liniştite/izolate; opreşte-l în locuri zgomotoase.
© OpenStreetMap
-© OpenTopoMap © OpenTopoMap
Scope
another region
my region
@@ -1000,7 +999,6 @@ Overwrite oldest non-favorite
1-character messages will be shown 1-character messages will be shown
All All
Allow Allow
-Apps that may send messages on the mesh. Anything they send goes out under your node name and cannot be told apart from a message you typed. Turn one off to take the permission back. Apps that may send messages on the mesh. Anything they send goes out under your node name and cannot be told apart from a message you typed. Turn one off to take the permission back.
Ignore 1-character messages Ignore 1-character messages
Join w/ password Join w/ password
Location no longer shared Location no longer shared
@@ -1014,9 +1012,7 @@ Never\nChosen contacts only\nAnyone who asks Never\nChosen contacts only\nAnyone
No apps installed. No apps installed.
No reply. Path hash is %u bytes;\nolder repeaters drop those.\nTry 1 byte (Settings > Radio & Mesh) No reply. Path hash is %u bytes;\nolder repeaters drop those.\nTry 1 byte (Settings > Radio & Mesh)
Not supported on this board Not supported on this board
-Read incoming messages Read incoming messages
Reconnected to %.40s Reconnected to %.40s
-Send messages as me Send messages as me
Sent only when that contact asks, encrypted to them. Nothing is broadcast. Needs "Answer telemetry requests" on. Pick contacts in a contact's menu. Sent only when that contact asks, encrypted to them. Nothing is broadcast. Needs "Answer telemetry requests" on. Pick contacts in a contact's menu.
Server has forgotten you.\nJoin with the room password. Server has forgotten you.\nJoin with the room password.
Share my loc Share my loc
@@ -1042,7 +1038,6 @@ App permissions App permissions
About / credits About / credits
Auth failed Auth failed
Auto-add on for %s — new %s land in Contacts automatically. Auto-add on for %s — new %s land in Contacts automatically.
-Batteryactivityon Batteryactivityon
Console mode Console mode
Console mode (experimental) Console mode (experimental)
Direct (0-hop) only Direct (0-hop) only
@@ -1051,8 +1046,6 @@ EXPERIMENTAL. Boots into a text console with no graphical interface: type comman
Enter text Enter text
Entering console mode - rebooting... Entering console mode - rebooting...
Favorite Favorite
-Geblokkeerde gebruikers Geblokkeerde gebruikers
-Hide system filesShow system files Hide system filesShow system files
How tiles work:\nThe map is built from 256×256 "slippy" tiles. Only the tiles for the area you're viewing are fetched — there is no bulk pre-download.\n\nBecause this device can't do HTTPS (not enough heap after Wi-Fi starts) and decodes JPEG far more cheaply than PNG, tiles come from the wadamesh proxy: it fetches the upstream PNG over HTTPS with an identifying User-Agent, re-encodes it as JPEG, and caches it. Your device then caches each tile to its own flash, so a tile is only downloaded once.\n\nUse Options → Reload tiles to re-download the tiles currently in view if one looks corrupted. How tiles work:\nThe map is built from 256×256 "slippy" tiles. Only the tiles for the area you're viewing are fetched — there is no bulk pre-download.\n\nBecause this device can't do HTTPS (not enough heap after Wi-Fi starts) and decodes JPEG far more cheaply than PNG, tiles come from the wadamesh proxy: it fetches the upstream PNG over HTTPS with an identifying User-Agent, re-encodes it as JPEG, and caches it. Your device then caches each tile to its own flash, so a tile is only downloaded once.\n\nUse Options → Reload tiles to re-download the tiles currently in view if one looks corrupted.
Init… Init…
Leaving console mode - rebooting... Leaving console mode - rebooting...
@@ -1062,8 +1055,6 @@ Map data © OpenStreetMap contributors.\nopenstreetmap.org/copyright\nLicensed u
Map style © OpenTopoMap (CC-BY-SA) — opentopomap.org\nMap data © OpenStreetMap contributors (ODbL) + SRTM.\n\n Map style © OpenTopoMap (CC-BY-SA) — opentopomap.org\nMap data © OpenStreetMap contributors (ODbL) + SRTM.\n\n
Name (A-Z) Name (A-Z)
Nearest first Nearest first
-Other networksNetworks Other networksNetworks
-Paste (move)Paste (copy) Paste (move)Paste (copy)
Recent message Recent message
Reload tiles in view Reload tiles in view
Repeaters Repeaters
@@ -1076,13 +1067,8 @@ Show contacts Show contacts
Show coordinates Show coordinates
Show tile z/x/y Show tile z/x/y
Starting… Starting…
-Stop sharing loc Share my loc Stop sharing loc Share my loc
-Supprimer Supprimer
-Switch to the external antenna?\n\nMake sure an antenna is actually connected to the external antenna socket first. Transmitting with nothing attached can damage the radio.\n\nResets to the on-board antenna on every reboot.Turn on legacy per-transmit switching?\n\nDiagnostic mode, for comparison only. It transmits on the on-board antenna and listens on the external one, so your outbound signal will be much weaker than your inbound.\n\nResets to the on-board antenna on every reboot. Switch to the external antenna?\n\nMake sure an antenna is actually connected to the external antenna socket first. Transmitting with nothing attached can damage the radio.\n\nResets to the on-board antenna on every reboot.Turn on legacy per-transmit switching?\n\nDiagnostic mode, for comparison only. It transmits on the on-board antenna and listens on the external one, so your outbound signal will be much weaker than your inbound.\n\nResets to the on-board antenna on every reboot.
Text console (experimental) Text console (experimental)
Tile debug overlay Tile debug overlay
-Unblock Block Unblock Block
-Unfav Favorite Unfav Favorite
chats chats
repeaters repeaters
rooms rooms
@@ -1098,3 +1084,23 @@ channel busy %.1f%% channel busy %.1f%%
sampling... sampling...
teal = now blue = average teal = now blue = average
window restarted window restarted
+Filter Filter
+Hide system files Hide system files
+Networks Networks
+Other networks Other networks
+Show system files Show system files
+Unfav Unfav
+Firmware beta_%d\nChecking for updates over Wi-Fi… Firmware beta_%d\nChecking for updates over Wi-Fi…
+Install beta_%d?\nThis downgrades the firmware and reboots. Install beta_%d?\nThis downgrades the firmware and reboots.
+Other (hidden) network… Other (hidden) network…
+Paused Paused
+Scan again Scan again
+At a glance At a glance
+At a glance disabled At a glance disabled
+At a glance enabled At a glance enabled
+At a glance while locked At a glance while locked
+Glance only shows when unlocked Glance only shows when unlocked
+Glance shows message previews\nwhile the screen is locked Glance shows message previews\nwhile the screen is locked
+Snake — swipe, roll, or press a direction Snake — swipe, roll, or press a direction
+N/A N/A
+This board cannot run this app. This board cannot run this app.
diff --git a/deploy/apps/lang/18/ru.lang b/deploy/apps/lang/19/ru.lang
similarity index 97%
rename from deploy/apps/lang/18/ru.lang
rename to deploy/apps/lang/19/ru.lang
index ceb1ec3..5c8d5cc 100644
--- a/deploy/apps/lang/18/ru.lang
+++ b/deploy/apps/lang/19/ru.lang
@@ -1,7 +1,7 @@
# wadamesh language file
# canonical source - translators: edit this file and PR it
# code: ru
-# ver: 18
+# ver: 19
# name: Русский
# base: ru
(device behind) (устройство отстаёт)
@@ -977,8 +977,6 @@ tap to exit full screen нажмите для выхода из полноэкр
zoom масштаб
zoom %d масштаб %d
~17 dB amp for quiet/remote areas; turn off in noisy spots. усилитель ~17 дБ для тихих/удалённых мест; выключайте в шумных.
-© OpenStreetMap © OpenStreetMap
-© OpenTopoMap © OpenTopoMap
Scope
another region
my region
@@ -1000,7 +998,6 @@ Overwrite oldest non-favorite
1-character messages will be shown 1-character messages will be shown
All All
Allow Allow
-Apps that may send messages on the mesh. Anything they send goes out under your node name and cannot be told apart from a message you typed. Turn one off to take the permission back. Apps that may send messages on the mesh. Anything they send goes out under your node name and cannot be told apart from a message you typed. Turn one off to take the permission back.
Ignore 1-character messages Ignore 1-character messages
Join w/ password Join w/ password
Location no longer shared Location no longer shared
@@ -1014,9 +1011,7 @@ Never\nChosen contacts only\nAnyone who asks Never\nChosen contacts only\nAnyone
No apps installed. No apps installed.
No reply. Path hash is %u bytes;\nolder repeaters drop those.\nTry 1 byte (Settings > Radio & Mesh) No reply. Path hash is %u bytes;\nolder repeaters drop those.\nTry 1 byte (Settings > Radio & Mesh)
Not supported on this board Not supported on this board
-Read incoming messages Read incoming messages
Reconnected to %.40s Reconnected to %.40s
-Send messages as me Send messages as me
Sent only when that contact asks, encrypted to them. Nothing is broadcast. Needs "Answer telemetry requests" on. Pick contacts in a contact's menu. Sent only when that contact asks, encrypted to them. Nothing is broadcast. Needs "Answer telemetry requests" on. Pick contacts in a contact's menu.
Server has forgotten you.\nJoin with the room password. Server has forgotten you.\nJoin with the room password.
Share my loc Share my loc
@@ -1042,7 +1037,6 @@ App permissions App permissions
About / credits About / credits
Auth failed Auth failed
Auto-add on for %s — new %s land in Contacts automatically. Auto-add on for %s — new %s land in Contacts automatically.
-Batteryactivityon Batteryactivityon
Console mode Console mode
Console mode (experimental) Console mode (experimental)
Direct (0-hop) only Direct (0-hop) only
@@ -1051,8 +1045,6 @@ EXPERIMENTAL. Boots into a text console with no graphical interface: type comman
Enter text Enter text
Entering console mode - rebooting... Entering console mode - rebooting...
Favorite Favorite
-Geblokkeerde gebruikers Geblokkeerde gebruikers
-Hide system filesShow system files Hide system filesShow system files
How tiles work:\nThe map is built from 256×256 "slippy" tiles. Only the tiles for the area you're viewing are fetched — there is no bulk pre-download.\n\nBecause this device can't do HTTPS (not enough heap after Wi-Fi starts) and decodes JPEG far more cheaply than PNG, tiles come from the wadamesh proxy: it fetches the upstream PNG over HTTPS with an identifying User-Agent, re-encodes it as JPEG, and caches it. Your device then caches each tile to its own flash, so a tile is only downloaded once.\n\nUse Options → Reload tiles to re-download the tiles currently in view if one looks corrupted. How tiles work:\nThe map is built from 256×256 "slippy" tiles. Only the tiles for the area you're viewing are fetched — there is no bulk pre-download.\n\nBecause this device can't do HTTPS (not enough heap after Wi-Fi starts) and decodes JPEG far more cheaply than PNG, tiles come from the wadamesh proxy: it fetches the upstream PNG over HTTPS with an identifying User-Agent, re-encodes it as JPEG, and caches it. Your device then caches each tile to its own flash, so a tile is only downloaded once.\n\nUse Options → Reload tiles to re-download the tiles currently in view if one looks corrupted.
Init… Init…
Leaving console mode - rebooting... Leaving console mode - rebooting...
@@ -1062,8 +1054,6 @@ Map data © OpenStreetMap contributors.\nopenstreetmap.org/copyright\nLicensed u
Map style © OpenTopoMap (CC-BY-SA) — opentopomap.org\nMap data © OpenStreetMap contributors (ODbL) + SRTM.\n\n Map style © OpenTopoMap (CC-BY-SA) — opentopomap.org\nMap data © OpenStreetMap contributors (ODbL) + SRTM.\n\n
Name (A-Z) Name (A-Z)
Nearest first Nearest first
-Other networksNetworks Other networksNetworks
-Paste (move)Paste (copy) Paste (move)Paste (copy)
Recent message Recent message
Reload tiles in view Reload tiles in view
Repeaters Repeaters
@@ -1076,13 +1066,8 @@ Show contacts Show contacts
Show coordinates Show coordinates
Show tile z/x/y Show tile z/x/y
Starting… Starting…
-Stop sharing loc Share my loc Stop sharing loc Share my loc
-Supprimer Supprimer
-Switch to the external antenna?\n\nMake sure an antenna is actually connected to the external antenna socket first. Transmitting with nothing attached can damage the radio.\n\nResets to the on-board antenna on every reboot.Turn on legacy per-transmit switching?\n\nDiagnostic mode, for comparison only. It transmits on the on-board antenna and listens on the external one, so your outbound signal will be much weaker than your inbound.\n\nResets to the on-board antenna on every reboot. Switch to the external antenna?\n\nMake sure an antenna is actually connected to the external antenna socket first. Transmitting with nothing attached can damage the radio.\n\nResets to the on-board antenna on every reboot.Turn on legacy per-transmit switching?\n\nDiagnostic mode, for comparison only. It transmits on the on-board antenna and listens on the external one, so your outbound signal will be much weaker than your inbound.\n\nResets to the on-board antenna on every reboot.
Text console (experimental) Text console (experimental)
Tile debug overlay Tile debug overlay
-Unblock Block Unblock Block
-Unfav Favorite Unfav Favorite
chats chats
repeaters repeaters
rooms rooms
@@ -1098,3 +1083,23 @@ channel busy %.1f%% channel busy %.1f%%
sampling... sampling...
teal = now blue = average teal = now blue = average
window restarted window restarted
+Filter Filter
+Hide system files Hide system files
+Networks Networks
+Other networks Other networks
+Show system files Show system files
+Unfav Unfav
+Firmware beta_%d\nChecking for updates over Wi-Fi… Firmware beta_%d\nChecking for updates over Wi-Fi…
+Install beta_%d?\nThis downgrades the firmware and reboots. Install beta_%d?\nThis downgrades the firmware and reboots.
+Other (hidden) network… Other (hidden) network…
+Paused Paused
+Scan again Scan again
+At a glance At a glance
+At a glance disabled At a glance disabled
+At a glance enabled At a glance enabled
+At a glance while locked At a glance while locked
+Glance only shows when unlocked Glance only shows when unlocked
+Glance shows message previews\nwhile the screen is locked Glance shows message previews\nwhile the screen is locked
+Snake — swipe, roll, or press a direction Snake — swipe, roll, or press a direction
+N/A N/A
+This board cannot run this app. This board cannot run this app.
diff --git a/deploy/apps/lang/18/sr.lang b/deploy/apps/lang/19/sr.lang
similarity index 97%
rename from deploy/apps/lang/18/sr.lang
rename to deploy/apps/lang/19/sr.lang
index 0aab3e1..bd479b9 100644
--- a/deploy/apps/lang/18/sr.lang
+++ b/deploy/apps/lang/19/sr.lang
@@ -1,7 +1,7 @@
# wadamesh language file
# canonical source - translators: edit this file and PR it
# code: sr
-# ver: 18
+# ver: 19
# name: Српски
# base: sr
(device behind) (уређај касни)
@@ -826,7 +826,7 @@ TCP on TCP укљ
TOUCH BETA TOUCH BETA
TX TX
Tab hotkeys — tap a row, then press a key Пречице картице — додирни ред, па притисни тастер
-Tap Use to switch - the device reboots to apply. Додирните „Користи“ да промените — уређај ће се поново покренути.
+Tap Use to switch - the device reboots to apply. Додирните "Користи" да промените — уређај ће се поново покренути.
Tap Use to switch - the device reboots to apply. Language files live in /lang on the storage; edit them or add your own. Додирни «Користи» за промену - уређај се поново покреће. Датотеке језика су у /lang на меморији; уреди их или додај своје.
Tap a language; the device reboots to apply. Изаберите језик; уређај ће се рестартовати.
Taste the rainbow! Окуси дугу!
@@ -977,8 +977,6 @@ tap to exit full screen додирните за излаз из целог ек
zoom зум
zoom %d зум %d
~17 dB amp for quiet/remote areas; turn off in noisy spots. појачало ~17 dB за тиха/удаљена места; искључи у бучним.
-© OpenStreetMap © OpenStreetMap
-© OpenTopoMap © OpenTopoMap
Scope
another region
my region
@@ -1000,7 +998,6 @@ Overwrite oldest non-favorite
1-character messages will be shown 1-character messages will be shown
All All
Allow Allow
-Apps that may send messages on the mesh. Anything they send goes out under your node name and cannot be told apart from a message you typed. Turn one off to take the permission back. Apps that may send messages on the mesh. Anything they send goes out under your node name and cannot be told apart from a message you typed. Turn one off to take the permission back.
Ignore 1-character messages Ignore 1-character messages
Join w/ password Join w/ password
Location no longer shared Location no longer shared
@@ -1014,9 +1011,7 @@ Never\nChosen contacts only\nAnyone who asks Never\nChosen contacts only\nAnyone
No apps installed. No apps installed.
No reply. Path hash is %u bytes;\nolder repeaters drop those.\nTry 1 byte (Settings > Radio & Mesh) No reply. Path hash is %u bytes;\nolder repeaters drop those.\nTry 1 byte (Settings > Radio & Mesh)
Not supported on this board Not supported on this board
-Read incoming messages Read incoming messages
Reconnected to %.40s Reconnected to %.40s
-Send messages as me Send messages as me
Sent only when that contact asks, encrypted to them. Nothing is broadcast. Needs "Answer telemetry requests" on. Pick contacts in a contact's menu. Sent only when that contact asks, encrypted to them. Nothing is broadcast. Needs "Answer telemetry requests" on. Pick contacts in a contact's menu.
Server has forgotten you.\nJoin with the room password. Server has forgotten you.\nJoin with the room password.
Share my loc Share my loc
@@ -1042,7 +1037,6 @@ App permissions App permissions
About / credits About / credits
Auth failed Auth failed
Auto-add on for %s — new %s land in Contacts automatically. Auto-add on for %s — new %s land in Contacts automatically.
-Batteryactivityon Batteryactivityon
Console mode Console mode
Console mode (experimental) Console mode (experimental)
Direct (0-hop) only Direct (0-hop) only
@@ -1051,8 +1045,6 @@ EXPERIMENTAL. Boots into a text console with no graphical interface: type comman
Enter text Enter text
Entering console mode - rebooting... Entering console mode - rebooting...
Favorite Favorite
-Geblokkeerde gebruikers Geblokkeerde gebruikers
-Hide system filesShow system files Hide system filesShow system files
How tiles work:\nThe map is built from 256×256 "slippy" tiles. Only the tiles for the area you're viewing are fetched — there is no bulk pre-download.\n\nBecause this device can't do HTTPS (not enough heap after Wi-Fi starts) and decodes JPEG far more cheaply than PNG, tiles come from the wadamesh proxy: it fetches the upstream PNG over HTTPS with an identifying User-Agent, re-encodes it as JPEG, and caches it. Your device then caches each tile to its own flash, so a tile is only downloaded once.\n\nUse Options → Reload tiles to re-download the tiles currently in view if one looks corrupted. How tiles work:\nThe map is built from 256×256 "slippy" tiles. Only the tiles for the area you're viewing are fetched — there is no bulk pre-download.\n\nBecause this device can't do HTTPS (not enough heap after Wi-Fi starts) and decodes JPEG far more cheaply than PNG, tiles come from the wadamesh proxy: it fetches the upstream PNG over HTTPS with an identifying User-Agent, re-encodes it as JPEG, and caches it. Your device then caches each tile to its own flash, so a tile is only downloaded once.\n\nUse Options → Reload tiles to re-download the tiles currently in view if one looks corrupted.
Init… Init…
Leaving console mode - rebooting... Leaving console mode - rebooting...
@@ -1062,8 +1054,6 @@ Map data © OpenStreetMap contributors.\nopenstreetmap.org/copyright\nLicensed u
Map style © OpenTopoMap (CC-BY-SA) — opentopomap.org\nMap data © OpenStreetMap contributors (ODbL) + SRTM.\n\n Map style © OpenTopoMap (CC-BY-SA) — opentopomap.org\nMap data © OpenStreetMap contributors (ODbL) + SRTM.\n\n
Name (A-Z) Name (A-Z)
Nearest first Nearest first
-Other networksNetworks Other networksNetworks
-Paste (move)Paste (copy) Paste (move)Paste (copy)
Recent message Recent message
Reload tiles in view Reload tiles in view
Repeaters Repeaters
@@ -1076,13 +1066,8 @@ Show contacts Show contacts
Show coordinates Show coordinates
Show tile z/x/y Show tile z/x/y
Starting… Starting…
-Stop sharing loc Share my loc Stop sharing loc Share my loc
-Supprimer Supprimer
-Switch to the external antenna?\n\nMake sure an antenna is actually connected to the external antenna socket first. Transmitting with nothing attached can damage the radio.\n\nResets to the on-board antenna on every reboot.Turn on legacy per-transmit switching?\n\nDiagnostic mode, for comparison only. It transmits on the on-board antenna and listens on the external one, so your outbound signal will be much weaker than your inbound.\n\nResets to the on-board antenna on every reboot. Switch to the external antenna?\n\nMake sure an antenna is actually connected to the external antenna socket first. Transmitting with nothing attached can damage the radio.\n\nResets to the on-board antenna on every reboot.Turn on legacy per-transmit switching?\n\nDiagnostic mode, for comparison only. It transmits on the on-board antenna and listens on the external one, so your outbound signal will be much weaker than your inbound.\n\nResets to the on-board antenna on every reboot.
Text console (experimental) Text console (experimental)
Tile debug overlay Tile debug overlay
-Unblock Block Unblock Block
-Unfav Favorite Unfav Favorite
chats chats
repeaters repeaters
rooms rooms
@@ -1098,3 +1083,23 @@ channel busy %.1f%% channel busy %.1f%%
sampling... sampling...
teal = now blue = average teal = now blue = average
window restarted window restarted
+Filter Filter
+Hide system files Hide system files
+Networks Networks
+Other networks Other networks
+Show system files Show system files
+Unfav Unfav
+Firmware beta_%d\nChecking for updates over Wi-Fi… Firmware beta_%d\nChecking for updates over Wi-Fi…
+Install beta_%d?\nThis downgrades the firmware and reboots. Install beta_%d?\nThis downgrades the firmware and reboots.
+Other (hidden) network… Other (hidden) network…
+Paused Paused
+Scan again Scan again
+At a glance At a glance
+At a glance disabled At a glance disabled
+At a glance enabled At a glance enabled
+At a glance while locked At a glance while locked
+Glance only shows when unlocked Glance only shows when unlocked
+Glance shows message previews\nwhile the screen is locked Glance shows message previews\nwhile the screen is locked
+Snake — swipe, roll, or press a direction Snake — swipe, roll, or press a direction
+N/A N/A
+This board cannot run this app. This board cannot run this app.
diff --git a/deploy/apps/lang/18/uk.lang b/deploy/apps/lang/19/uk.lang
similarity index 97%
rename from deploy/apps/lang/18/uk.lang
rename to deploy/apps/lang/19/uk.lang
index 4ebd00f..52d7572 100644
--- a/deploy/apps/lang/18/uk.lang
+++ b/deploy/apps/lang/19/uk.lang
@@ -1,7 +1,7 @@
# wadamesh language file
# canonical source - translators: edit this file and PR it
# code: uk
-# ver: 18
+# ver: 19
# name: Українська
# base: uk
(device behind) (пристрій відстає)
@@ -977,8 +977,6 @@ tap to exit full screen торкніться, щоб вийти з повное
zoom масштаб
zoom %d масштаб %d
~17 dB amp for quiet/remote areas; turn off in noisy spots. підсилювач ~17 дБ для тихих/віддалених місць; вимикайте в галасливих.
-© OpenStreetMap © OpenStreetMap
-© OpenTopoMap © OpenTopoMap
Scope
another region
my region
@@ -1000,7 +998,6 @@ Overwrite oldest non-favorite
1-character messages will be shown 1-character messages will be shown
All All
Allow Allow
-Apps that may send messages on the mesh. Anything they send goes out under your node name and cannot be told apart from a message you typed. Turn one off to take the permission back. Apps that may send messages on the mesh. Anything they send goes out under your node name and cannot be told apart from a message you typed. Turn one off to take the permission back.
Ignore 1-character messages Ignore 1-character messages
Join w/ password Join w/ password
Location no longer shared Location no longer shared
@@ -1014,9 +1011,7 @@ Never\nChosen contacts only\nAnyone who asks Never\nChosen contacts only\nAnyone
No apps installed. No apps installed.
No reply. Path hash is %u bytes;\nolder repeaters drop those.\nTry 1 byte (Settings > Radio & Mesh) No reply. Path hash is %u bytes;\nolder repeaters drop those.\nTry 1 byte (Settings > Radio & Mesh)
Not supported on this board Not supported on this board
-Read incoming messages Read incoming messages
Reconnected to %.40s Reconnected to %.40s
-Send messages as me Send messages as me
Sent only when that contact asks, encrypted to them. Nothing is broadcast. Needs "Answer telemetry requests" on. Pick contacts in a contact's menu. Sent only when that contact asks, encrypted to them. Nothing is broadcast. Needs "Answer telemetry requests" on. Pick contacts in a contact's menu.
Server has forgotten you.\nJoin with the room password. Server has forgotten you.\nJoin with the room password.
Share my loc Share my loc
@@ -1042,7 +1037,6 @@ App permissions App permissions
About / credits About / credits
Auth failed Auth failed
Auto-add on for %s — new %s land in Contacts automatically. Auto-add on for %s — new %s land in Contacts automatically.
-Batteryactivityon Batteryactivityon
Console mode Console mode
Console mode (experimental) Console mode (experimental)
Direct (0-hop) only Direct (0-hop) only
@@ -1051,8 +1045,6 @@ EXPERIMENTAL. Boots into a text console with no graphical interface: type comman
Enter text Enter text
Entering console mode - rebooting... Entering console mode - rebooting...
Favorite Favorite
-Geblokkeerde gebruikers Geblokkeerde gebruikers
-Hide system filesShow system files Hide system filesShow system files
How tiles work:\nThe map is built from 256×256 "slippy" tiles. Only the tiles for the area you're viewing are fetched — there is no bulk pre-download.\n\nBecause this device can't do HTTPS (not enough heap after Wi-Fi starts) and decodes JPEG far more cheaply than PNG, tiles come from the wadamesh proxy: it fetches the upstream PNG over HTTPS with an identifying User-Agent, re-encodes it as JPEG, and caches it. Your device then caches each tile to its own flash, so a tile is only downloaded once.\n\nUse Options → Reload tiles to re-download the tiles currently in view if one looks corrupted. How tiles work:\nThe map is built from 256×256 "slippy" tiles. Only the tiles for the area you're viewing are fetched — there is no bulk pre-download.\n\nBecause this device can't do HTTPS (not enough heap after Wi-Fi starts) and decodes JPEG far more cheaply than PNG, tiles come from the wadamesh proxy: it fetches the upstream PNG over HTTPS with an identifying User-Agent, re-encodes it as JPEG, and caches it. Your device then caches each tile to its own flash, so a tile is only downloaded once.\n\nUse Options → Reload tiles to re-download the tiles currently in view if one looks corrupted.
Init… Init…
Leaving console mode - rebooting... Leaving console mode - rebooting...
@@ -1062,8 +1054,6 @@ Map data © OpenStreetMap contributors.\nopenstreetmap.org/copyright\nLicensed u
Map style © OpenTopoMap (CC-BY-SA) — opentopomap.org\nMap data © OpenStreetMap contributors (ODbL) + SRTM.\n\n Map style © OpenTopoMap (CC-BY-SA) — opentopomap.org\nMap data © OpenStreetMap contributors (ODbL) + SRTM.\n\n
Name (A-Z) Name (A-Z)
Nearest first Nearest first
-Other networksNetworks Other networksNetworks
-Paste (move)Paste (copy) Paste (move)Paste (copy)
Recent message Recent message
Reload tiles in view Reload tiles in view
Repeaters Repeaters
@@ -1076,13 +1066,8 @@ Show contacts Show contacts
Show coordinates Show coordinates
Show tile z/x/y Show tile z/x/y
Starting… Starting…
-Stop sharing loc Share my loc Stop sharing loc Share my loc
-Supprimer Supprimer
-Switch to the external antenna?\n\nMake sure an antenna is actually connected to the external antenna socket first. Transmitting with nothing attached can damage the radio.\n\nResets to the on-board antenna on every reboot.Turn on legacy per-transmit switching?\n\nDiagnostic mode, for comparison only. It transmits on the on-board antenna and listens on the external one, so your outbound signal will be much weaker than your inbound.\n\nResets to the on-board antenna on every reboot. Switch to the external antenna?\n\nMake sure an antenna is actually connected to the external antenna socket first. Transmitting with nothing attached can damage the radio.\n\nResets to the on-board antenna on every reboot.Turn on legacy per-transmit switching?\n\nDiagnostic mode, for comparison only. It transmits on the on-board antenna and listens on the external one, so your outbound signal will be much weaker than your inbound.\n\nResets to the on-board antenna on every reboot.
Text console (experimental) Text console (experimental)
Tile debug overlay Tile debug overlay
-Unblock Block Unblock Block
-Unfav Favorite Unfav Favorite
chats chats
repeaters repeaters
rooms rooms
@@ -1098,3 +1083,23 @@ channel busy %.1f%% channel busy %.1f%%
sampling... sampling...
teal = now blue = average teal = now blue = average
window restarted window restarted
+Filter Filter
+Hide system files Hide system files
+Networks Networks
+Other networks Other networks
+Show system files Show system files
+Unfav Unfav
+Firmware beta_%d\nChecking for updates over Wi-Fi… Firmware beta_%d\nChecking for updates over Wi-Fi…
+Install beta_%d?\nThis downgrades the firmware and reboots. Install beta_%d?\nThis downgrades the firmware and reboots.
+Other (hidden) network… Other (hidden) network…
+Paused Paused
+Scan again Scan again
+At a glance At a glance
+At a glance disabled At a glance disabled
+At a glance enabled At a glance enabled
+At a glance while locked At a glance while locked
+Glance only shows when unlocked Glance only shows when unlocked
+Glance shows message previews\nwhile the screen is locked Glance shows message previews\nwhile the screen is locked
+Snake — swipe, roll, or press a direction Snake — swipe, roll, or press a direction
+N/A N/A
+This board cannot run this app. This board cannot run this app.
diff --git a/deploy/apps/lang/bg.lang b/deploy/apps/lang/bg.lang
index 92bcace..46da469 100644
--- a/deploy/apps/lang/bg.lang
+++ b/deploy/apps/lang/bg.lang
@@ -1,7 +1,7 @@
# wadamesh language file
# canonical source - translators: edit this file and PR it
# code: bg
-# ver: 18
+# ver: 19
# name: Български
# base: bg
(device behind) (устройството изостава)
@@ -826,7 +826,7 @@ TCP on TCP вкл
TOUCH BETA TOUCH BETA
TX TX
Tab hotkeys — tap a row, then press a key Клавиши за раздела — докосни ред, после натисни клавиш
-Tap Use to switch - the device reboots to apply. Натиснете „Избери“, за да превключите — устройството ще се рестартира.
+Tap Use to switch - the device reboots to apply. Натиснете "Избери", за да превключите — устройството ще се рестартира.
Tap Use to switch - the device reboots to apply. Language files live in /lang on the storage; edit them or add your own. Докосни «Избери» за смяна - устройството се рестартира. Езиковите файлове са в /lang в паметта; редактирай ги или добави свои.
Tap a language; the device reboots to apply. Изберете език; устройството ще се рестартира.
Taste the rainbow! Вкуси дъгата!
@@ -977,8 +977,6 @@ tap to exit full screen докоснете за изход от цял екра
zoom мащаб
zoom %d мащаб %d
~17 dB amp for quiet/remote areas; turn off in noisy spots. усилвател ~17 dB за тихи/отдалечени места; изключи в шумни.
-© OpenStreetMap © OpenStreetMap
-© OpenTopoMap © OpenTopoMap
Scope
another region
my region
@@ -1000,7 +998,6 @@ Overwrite oldest non-favorite
1-character messages will be shown 1-character messages will be shown
All All
Allow Allow
-Apps that may send messages on the mesh. Anything they send goes out under your node name and cannot be told apart from a message you typed. Turn one off to take the permission back. Apps that may send messages on the mesh. Anything they send goes out under your node name and cannot be told apart from a message you typed. Turn one off to take the permission back.
Ignore 1-character messages Ignore 1-character messages
Join w/ password Join w/ password
Location no longer shared Location no longer shared
@@ -1014,9 +1011,7 @@ Never\nChosen contacts only\nAnyone who asks Never\nChosen contacts only\nAnyone
No apps installed. No apps installed.
No reply. Path hash is %u bytes;\nolder repeaters drop those.\nTry 1 byte (Settings > Radio & Mesh) No reply. Path hash is %u bytes;\nolder repeaters drop those.\nTry 1 byte (Settings > Radio & Mesh)
Not supported on this board Not supported on this board
-Read incoming messages Read incoming messages
Reconnected to %.40s Reconnected to %.40s
-Send messages as me Send messages as me
Sent only when that contact asks, encrypted to them. Nothing is broadcast. Needs "Answer telemetry requests" on. Pick contacts in a contact's menu. Sent only when that contact asks, encrypted to them. Nothing is broadcast. Needs "Answer telemetry requests" on. Pick contacts in a contact's menu.
Server has forgotten you.\nJoin with the room password. Server has forgotten you.\nJoin with the room password.
Share my loc Share my loc
@@ -1042,7 +1037,6 @@ App permissions App permissions
About / credits About / credits
Auth failed Auth failed
Auto-add on for %s — new %s land in Contacts automatically. Auto-add on for %s — new %s land in Contacts automatically.
-Batteryactivityon Batteryactivityon
Console mode Console mode
Console mode (experimental) Console mode (experimental)
Direct (0-hop) only Direct (0-hop) only
@@ -1051,8 +1045,6 @@ EXPERIMENTAL. Boots into a text console with no graphical interface: type comman
Enter text Enter text
Entering console mode - rebooting... Entering console mode - rebooting...
Favorite Favorite
-Geblokkeerde gebruikers Geblokkeerde gebruikers
-Hide system filesShow system files Hide system filesShow system files
How tiles work:\nThe map is built from 256×256 "slippy" tiles. Only the tiles for the area you're viewing are fetched — there is no bulk pre-download.\n\nBecause this device can't do HTTPS (not enough heap after Wi-Fi starts) and decodes JPEG far more cheaply than PNG, tiles come from the wadamesh proxy: it fetches the upstream PNG over HTTPS with an identifying User-Agent, re-encodes it as JPEG, and caches it. Your device then caches each tile to its own flash, so a tile is only downloaded once.\n\nUse Options → Reload tiles to re-download the tiles currently in view if one looks corrupted. How tiles work:\nThe map is built from 256×256 "slippy" tiles. Only the tiles for the area you're viewing are fetched — there is no bulk pre-download.\n\nBecause this device can't do HTTPS (not enough heap after Wi-Fi starts) and decodes JPEG far more cheaply than PNG, tiles come from the wadamesh proxy: it fetches the upstream PNG over HTTPS with an identifying User-Agent, re-encodes it as JPEG, and caches it. Your device then caches each tile to its own flash, so a tile is only downloaded once.\n\nUse Options → Reload tiles to re-download the tiles currently in view if one looks corrupted.
Init… Init…
Leaving console mode - rebooting... Leaving console mode - rebooting...
@@ -1062,8 +1054,6 @@ Map data © OpenStreetMap contributors.\nopenstreetmap.org/copyright\nLicensed u
Map style © OpenTopoMap (CC-BY-SA) — opentopomap.org\nMap data © OpenStreetMap contributors (ODbL) + SRTM.\n\n Map style © OpenTopoMap (CC-BY-SA) — opentopomap.org\nMap data © OpenStreetMap contributors (ODbL) + SRTM.\n\n
Name (A-Z) Name (A-Z)
Nearest first Nearest first
-Other networksNetworks Other networksNetworks
-Paste (move)Paste (copy) Paste (move)Paste (copy)
Recent message Recent message
Reload tiles in view Reload tiles in view
Repeaters Repeaters
@@ -1076,13 +1066,8 @@ Show contacts Show contacts
Show coordinates Show coordinates
Show tile z/x/y Show tile z/x/y
Starting… Starting…
-Stop sharing loc Share my loc Stop sharing loc Share my loc
-Supprimer Supprimer
-Switch to the external antenna?\n\nMake sure an antenna is actually connected to the external antenna socket first. Transmitting with nothing attached can damage the radio.\n\nResets to the on-board antenna on every reboot.Turn on legacy per-transmit switching?\n\nDiagnostic mode, for comparison only. It transmits on the on-board antenna and listens on the external one, so your outbound signal will be much weaker than your inbound.\n\nResets to the on-board antenna on every reboot. Switch to the external antenna?\n\nMake sure an antenna is actually connected to the external antenna socket first. Transmitting with nothing attached can damage the radio.\n\nResets to the on-board antenna on every reboot.Turn on legacy per-transmit switching?\n\nDiagnostic mode, for comparison only. It transmits on the on-board antenna and listens on the external one, so your outbound signal will be much weaker than your inbound.\n\nResets to the on-board antenna on every reboot.
Text console (experimental) Text console (experimental)
Tile debug overlay Tile debug overlay
-Unblock Block Unblock Block
-Unfav Favorite Unfav Favorite
chats chats
repeaters repeaters
rooms rooms
@@ -1098,3 +1083,26 @@ channel busy %.1f%% channel busy %.1f%%
sampling... sampling...
teal = now blue = average teal = now blue = average
window restarted window restarted
+Filter Filter
+Hide system files Hide system files
+Networks Networks
+Other networks Other networks
+Show system files Show system files
+Unfav Unfav
+Firmware beta_%d\nChecking for updates over Wi-Fi… Firmware beta_%d\nChecking for updates over Wi-Fi…
+Install beta_%d?\nThis downgrades the firmware and reboots. Install beta_%d?\nThis downgrades the firmware and reboots.
+Other (hidden) network… Other (hidden) network…
+Paused Paused
+Scan again Scan again
+At a glance At a glance
+At a glance disabled At a glance disabled
+At a glance enabled At a glance enabled
+At a glance while locked At a glance while locked
+Glance only shows when unlocked Glance only shows when unlocked
+Glance shows message previews\nwhile the screen is locked Glance shows message previews\nwhile the screen is locked
+Snake — swipe, roll, or press a direction Snake — swipe, roll, or press a direction
+N/A N/A
+This board cannot run this app. This board cannot run this app.
+Signal Signal
+Signal (stale) Signal (stale)
+nothing heard yet nothing heard yet
diff --git a/deploy/apps/lang/de.lang b/deploy/apps/lang/de.lang
index 60d5b56..e26299e 100644
--- a/deploy/apps/lang/de.lang
+++ b/deploy/apps/lang/de.lang
@@ -1,7 +1,7 @@
# wadamesh language file
# canonical source - translators: edit this file and PR it
# code: de
-# ver: 18
+# ver: 19
# name: Deutsch
# base: de
(device behind) (Gerät geht nach)
@@ -452,7 +452,6 @@ Login OK (perms %u) Anmeldung OK (Rechte %u)
Login failed Anmeldung fehlgeschlagen
Logs Protokolle
Longitude Längengrad
-Lua Store Lua Store
MHz MHz
MQTT bridge MQTT-Brücke
Map Karte
@@ -694,7 +693,7 @@ SD card remounted SD-Karte neu eingebunden
SD card removed SD-Karte entfernt
SD data is unavailable - contacts and channels cannot be saved until the card is reinserted. SD-Daten nicht verfügbar – Kontakte und Kanäle können erst nach Einsetzen der Karte gespeichert werden.
SD data is unavailable - identity, settings, contacts and channels cannot be saved until the card is reinserted. SD-Daten nicht verfügbar – Identität, Einstellungen, Kontakte und Kanäle können erst nach Einsetzen der Karte gespeichert werden.
-SD data migration is incomplete. Identity and settings remain internal; use Copy internal data to SD to retry. SD-Datenübertragung unvollständig. Identität und Einstellungen bleiben intern; „Interne Daten auf SD kopieren“ zum erneuten Versuch verwenden.
+SD data migration is incomplete. Identity and settings remain internal; use Copy internal data to SD to retry. SD-Datenübertragung unvollständig. Identität und Einstellungen bleiben intern; "Interne Daten auf SD kopieren" zum erneuten Versuch verwenden.
SD download failed: %s SD-Download fehlgeschlagen: %s
SD formatted - %s (MESHCOMOD) SD formatiert - %s (MESHCOMOD)
SD is busy - close tools and retry SD ist belegt – Tools schließen und erneut versuchen
@@ -797,7 +796,6 @@ Sleep ready Bereit zum Schlafen
Slot %d cleared Slot %d gelöscht
Slot empty Slot leer
Small\nMedium\nLarge Klein\nMittel\nGroß
-Snake Snake
Snake — swipe or roll the trackball Snake — wischen oder Trackball rollen
Sort Sortieren
Sound Ton
@@ -805,7 +803,6 @@ Sound off Ton aus
Sound on Ton ein
Sound: %s Ton: %s
Start time Startzeit
-Status Status
Step 1 of 3 Schritt 1 von 3
Step 2 of 3 Schritt 2 von 3
Step 3 of 3 Schritt 3 von 3
@@ -818,7 +815,6 @@ Streams the device screen as a live picture you tap to control. Heavier; the pan
Switch Wechseln
Switch to the external antenna?\n\nMake sure an antenna is actually connected to the external antenna socket first. Transmitting with nothing attached can damage the radio.\n\nResets to the on-board antenna on every reboot. Auf die externe Antenne wechseln?\n\nErst sicherstellen, dass wirklich eine Antenne an der Buchse steckt. Senden ohne Antenne kann das Funkmodul beschädigen.\n\nFällt bei jedem Neustart auf die interne Antenne zurück.
Sync clock from system Uhr synchronisieren
-System System
System info Systeminfo
TCP %s BLE %s\nWS clients %d GPS %s Buzzer %s TCP %s BLE %s\nWS-Clients %d GPS %s Summer %s
TCP off TCP aus
@@ -977,8 +973,6 @@ tap to exit full screen tippen zum Verlassen des Vollbilds
zoom Zoom
zoom %d Zoom %d
~17 dB amp for quiet/remote areas; turn off in noisy spots. ~17 dB Verstärker für ruhige/abgelegene Gebiete; in lauten Zonen aus.
-© OpenStreetMap © OpenStreetMap
-© OpenTopoMap © OpenTopoMap
Scope
another region
my region
@@ -1000,7 +994,6 @@ Overwrite oldest non-favorite
1-character messages will be shown 1-character messages will be shown
All All
Allow Allow
-Apps that may send messages on the mesh. Anything they send goes out under your node name and cannot be told apart from a message you typed. Turn one off to take the permission back. Apps that may send messages on the mesh. Anything they send goes out under your node name and cannot be told apart from a message you typed. Turn one off to take the permission back.
Ignore 1-character messages Ignore 1-character messages
Join w/ password Join w/ password
Location no longer shared Location no longer shared
@@ -1014,9 +1007,7 @@ Never\nChosen contacts only\nAnyone who asks Never\nChosen contacts only\nAnyone
No apps installed. No apps installed.
No reply. Path hash is %u bytes;\nolder repeaters drop those.\nTry 1 byte (Settings > Radio & Mesh) No reply. Path hash is %u bytes;\nolder repeaters drop those.\nTry 1 byte (Settings > Radio & Mesh)
Not supported on this board Not supported on this board
-Read incoming messages Read incoming messages
Reconnected to %.40s Reconnected to %.40s
-Send messages as me Send messages as me
Sent only when that contact asks, encrypted to them. Nothing is broadcast. Needs "Answer telemetry requests" on. Pick contacts in a contact's menu. Sent only when that contact asks, encrypted to them. Nothing is broadcast. Needs "Answer telemetry requests" on. Pick contacts in a contact's menu.
Server has forgotten you.\nJoin with the room password. Server has forgotten you.\nJoin with the room password.
Share my loc Share my loc
@@ -1042,7 +1033,6 @@ App permissions App permissions
About / credits About / credits
Auth failed Auth failed
Auto-add on for %s — new %s land in Contacts automatically. Auto-add on for %s — new %s land in Contacts automatically.
-Batteryactivityon Batteryactivityon
Console mode Console mode
Console mode (experimental) Console mode (experimental)
Direct (0-hop) only Direct (0-hop) only
@@ -1051,8 +1041,6 @@ EXPERIMENTAL. Boots into a text console with no graphical interface: type comman
Enter text Enter text
Entering console mode - rebooting... Entering console mode - rebooting...
Favorite Favorite
-Geblokkeerde gebruikers Geblokkeerde gebruikers
-Hide system filesShow system files Hide system filesShow system files
How tiles work:\nThe map is built from 256×256 "slippy" tiles. Only the tiles for the area you're viewing are fetched — there is no bulk pre-download.\n\nBecause this device can't do HTTPS (not enough heap after Wi-Fi starts) and decodes JPEG far more cheaply than PNG, tiles come from the wadamesh proxy: it fetches the upstream PNG over HTTPS with an identifying User-Agent, re-encodes it as JPEG, and caches it. Your device then caches each tile to its own flash, so a tile is only downloaded once.\n\nUse Options → Reload tiles to re-download the tiles currently in view if one looks corrupted. How tiles work:\nThe map is built from 256×256 "slippy" tiles. Only the tiles for the area you're viewing are fetched — there is no bulk pre-download.\n\nBecause this device can't do HTTPS (not enough heap after Wi-Fi starts) and decodes JPEG far more cheaply than PNG, tiles come from the wadamesh proxy: it fetches the upstream PNG over HTTPS with an identifying User-Agent, re-encodes it as JPEG, and caches it. Your device then caches each tile to its own flash, so a tile is only downloaded once.\n\nUse Options → Reload tiles to re-download the tiles currently in view if one looks corrupted.
Init… Init…
Leaving console mode - rebooting... Leaving console mode - rebooting...
@@ -1062,8 +1050,6 @@ Map data © OpenStreetMap contributors.\nopenstreetmap.org/copyright\nLicensed u
Map style © OpenTopoMap (CC-BY-SA) — opentopomap.org\nMap data © OpenStreetMap contributors (ODbL) + SRTM.\n\n Map style © OpenTopoMap (CC-BY-SA) — opentopomap.org\nMap data © OpenStreetMap contributors (ODbL) + SRTM.\n\n
Name (A-Z) Name (A-Z)
Nearest first Nearest first
-Other networksNetworks Other networksNetworks
-Paste (move)Paste (copy) Paste (move)Paste (copy)
Recent message Recent message
Reload tiles in view Reload tiles in view
Repeaters Repeaters
@@ -1076,13 +1062,8 @@ Show contacts Show contacts
Show coordinates Show coordinates
Show tile z/x/y Show tile z/x/y
Starting… Starting…
-Stop sharing loc Share my loc Stop sharing loc Share my loc
-Supprimer Supprimer
-Switch to the external antenna?\n\nMake sure an antenna is actually connected to the external antenna socket first. Transmitting with nothing attached can damage the radio.\n\nResets to the on-board antenna on every reboot.Turn on legacy per-transmit switching?\n\nDiagnostic mode, for comparison only. It transmits on the on-board antenna and listens on the external one, so your outbound signal will be much weaker than your inbound.\n\nResets to the on-board antenna on every reboot. Switch to the external antenna?\n\nMake sure an antenna is actually connected to the external antenna socket first. Transmitting with nothing attached can damage the radio.\n\nResets to the on-board antenna on every reboot.Turn on legacy per-transmit switching?\n\nDiagnostic mode, for comparison only. It transmits on the on-board antenna and listens on the external one, so your outbound signal will be much weaker than your inbound.\n\nResets to the on-board antenna on every reboot.
Text console (experimental) Text console (experimental)
Tile debug overlay Tile debug overlay
-Unblock Block Unblock Block
-Unfav Favorite Unfav Favorite
chats chats
repeaters repeaters
rooms rooms
@@ -1098,3 +1079,26 @@ channel busy %.1f%% channel busy %.1f%%
sampling... sampling...
teal = now blue = average teal = now blue = average
window restarted window restarted
+Filter Filter
+Hide system files Hide system files
+Networks Networks
+Other networks Other networks
+Show system files Show system files
+Unfav Unfav
+Firmware beta_%d\nChecking for updates over Wi-Fi… Firmware beta_%d\nChecking for updates over Wi-Fi…
+Install beta_%d?\nThis downgrades the firmware and reboots. Install beta_%d?\nThis downgrades the firmware and reboots.
+Other (hidden) network… Other (hidden) network…
+Paused Paused
+Scan again Scan again
+At a glance At a glance
+At a glance disabled At a glance disabled
+At a glance enabled At a glance enabled
+At a glance while locked At a glance while locked
+Glance only shows when unlocked Glance only shows when unlocked
+Glance shows message previews\nwhile the screen is locked Glance shows message previews\nwhile the screen is locked
+Snake — swipe, roll, or press a direction Snake — swipe, roll, or press a direction
+N/A N/A
+This board cannot run this app. This board cannot run this app.
+Signal Signal
+Signal (stale) Signal (stale)
+nothing heard yet nothing heard yet
diff --git a/deploy/apps/lang/el.lang b/deploy/apps/lang/el.lang
index 203ba0b..ee4ade2 100644
--- a/deploy/apps/lang/el.lang
+++ b/deploy/apps/lang/el.lang
@@ -1,7 +1,7 @@
# wadamesh language file
# canonical source - translators: edit this file and PR it
# code: el
-# ver: 18
+# ver: 19
# name: Ελληνικά
# base: el
(device behind) (συσκευή πίσω)
@@ -977,8 +977,6 @@ tap to exit full screen πατήστε για έξοδο από πλήρη οθ
zoom ζουμ
zoom %d ζουμ %d
~17 dB amp for quiet/remote areas; turn off in noisy spots. ενισχυτής ~17 dB για ήσυχες/απομακρυσμένες περιοχές· κλείστε σε θορυβώδεις.
-© OpenStreetMap © OpenStreetMap
-© OpenTopoMap © OpenTopoMap
Scope
another region
my region
@@ -1000,7 +998,6 @@ Overwrite oldest non-favorite
1-character messages will be shown 1-character messages will be shown
All All
Allow Allow
-Apps that may send messages on the mesh. Anything they send goes out under your node name and cannot be told apart from a message you typed. Turn one off to take the permission back. Apps that may send messages on the mesh. Anything they send goes out under your node name and cannot be told apart from a message you typed. Turn one off to take the permission back.
Ignore 1-character messages Ignore 1-character messages
Join w/ password Join w/ password
Location no longer shared Location no longer shared
@@ -1014,9 +1011,7 @@ Never\nChosen contacts only\nAnyone who asks Never\nChosen contacts only\nAnyone
No apps installed. No apps installed.
No reply. Path hash is %u bytes;\nolder repeaters drop those.\nTry 1 byte (Settings > Radio & Mesh) No reply. Path hash is %u bytes;\nolder repeaters drop those.\nTry 1 byte (Settings > Radio & Mesh)
Not supported on this board Not supported on this board
-Read incoming messages Read incoming messages
Reconnected to %.40s Reconnected to %.40s
-Send messages as me Send messages as me
Sent only when that contact asks, encrypted to them. Nothing is broadcast. Needs "Answer telemetry requests" on. Pick contacts in a contact's menu. Sent only when that contact asks, encrypted to them. Nothing is broadcast. Needs "Answer telemetry requests" on. Pick contacts in a contact's menu.
Server has forgotten you.\nJoin with the room password. Server has forgotten you.\nJoin with the room password.
Share my loc Share my loc
@@ -1042,7 +1037,6 @@ App permissions App permissions
About / credits About / credits
Auth failed Auth failed
Auto-add on for %s — new %s land in Contacts automatically. Auto-add on for %s — new %s land in Contacts automatically.
-Batteryactivityon Batteryactivityon
Console mode Console mode
Console mode (experimental) Console mode (experimental)
Direct (0-hop) only Direct (0-hop) only
@@ -1051,8 +1045,6 @@ EXPERIMENTAL. Boots into a text console with no graphical interface: type comman
Enter text Enter text
Entering console mode - rebooting... Entering console mode - rebooting...
Favorite Favorite
-Geblokkeerde gebruikers Geblokkeerde gebruikers
-Hide system filesShow system files Hide system filesShow system files
How tiles work:\nThe map is built from 256×256 "slippy" tiles. Only the tiles for the area you're viewing are fetched — there is no bulk pre-download.\n\nBecause this device can't do HTTPS (not enough heap after Wi-Fi starts) and decodes JPEG far more cheaply than PNG, tiles come from the wadamesh proxy: it fetches the upstream PNG over HTTPS with an identifying User-Agent, re-encodes it as JPEG, and caches it. Your device then caches each tile to its own flash, so a tile is only downloaded once.\n\nUse Options → Reload tiles to re-download the tiles currently in view if one looks corrupted. How tiles work:\nThe map is built from 256×256 "slippy" tiles. Only the tiles for the area you're viewing are fetched — there is no bulk pre-download.\n\nBecause this device can't do HTTPS (not enough heap after Wi-Fi starts) and decodes JPEG far more cheaply than PNG, tiles come from the wadamesh proxy: it fetches the upstream PNG over HTTPS with an identifying User-Agent, re-encodes it as JPEG, and caches it. Your device then caches each tile to its own flash, so a tile is only downloaded once.\n\nUse Options → Reload tiles to re-download the tiles currently in view if one looks corrupted.
Init… Init…
Leaving console mode - rebooting... Leaving console mode - rebooting...
@@ -1062,8 +1054,6 @@ Map data © OpenStreetMap contributors.\nopenstreetmap.org/copyright\nLicensed u
Map style © OpenTopoMap (CC-BY-SA) — opentopomap.org\nMap data © OpenStreetMap contributors (ODbL) + SRTM.\n\n Map style © OpenTopoMap (CC-BY-SA) — opentopomap.org\nMap data © OpenStreetMap contributors (ODbL) + SRTM.\n\n
Name (A-Z) Name (A-Z)
Nearest first Nearest first
-Other networksNetworks Other networksNetworks
-Paste (move)Paste (copy) Paste (move)Paste (copy)
Recent message Recent message
Reload tiles in view Reload tiles in view
Repeaters Repeaters
@@ -1076,13 +1066,8 @@ Show contacts Show contacts
Show coordinates Show coordinates
Show tile z/x/y Show tile z/x/y
Starting… Starting…
-Stop sharing loc Share my loc Stop sharing loc Share my loc
-Supprimer Supprimer
-Switch to the external antenna?\n\nMake sure an antenna is actually connected to the external antenna socket first. Transmitting with nothing attached can damage the radio.\n\nResets to the on-board antenna on every reboot.Turn on legacy per-transmit switching?\n\nDiagnostic mode, for comparison only. It transmits on the on-board antenna and listens on the external one, so your outbound signal will be much weaker than your inbound.\n\nResets to the on-board antenna on every reboot. Switch to the external antenna?\n\nMake sure an antenna is actually connected to the external antenna socket first. Transmitting with nothing attached can damage the radio.\n\nResets to the on-board antenna on every reboot.Turn on legacy per-transmit switching?\n\nDiagnostic mode, for comparison only. It transmits on the on-board antenna and listens on the external one, so your outbound signal will be much weaker than your inbound.\n\nResets to the on-board antenna on every reboot.
Text console (experimental) Text console (experimental)
Tile debug overlay Tile debug overlay
-Unblock Block Unblock Block
-Unfav Favorite Unfav Favorite
chats chats
repeaters repeaters
rooms rooms
@@ -1098,3 +1083,26 @@ channel busy %.1f%% channel busy %.1f%%
sampling... sampling...
teal = now blue = average teal = now blue = average
window restarted window restarted
+Filter Filter
+Hide system files Hide system files
+Networks Networks
+Other networks Other networks
+Show system files Show system files
+Unfav Unfav
+Firmware beta_%d\nChecking for updates over Wi-Fi… Firmware beta_%d\nChecking for updates over Wi-Fi…
+Install beta_%d?\nThis downgrades the firmware and reboots. Install beta_%d?\nThis downgrades the firmware and reboots.
+Other (hidden) network… Other (hidden) network…
+Paused Paused
+Scan again Scan again
+At a glance At a glance
+At a glance disabled At a glance disabled
+At a glance enabled At a glance enabled
+At a glance while locked At a glance while locked
+Glance only shows when unlocked Glance only shows when unlocked
+Glance shows message previews\nwhile the screen is locked Glance shows message previews\nwhile the screen is locked
+Snake — swipe, roll, or press a direction Snake — swipe, roll, or press a direction
+N/A N/A
+This board cannot run this app. This board cannot run this app.
+Signal Signal
+Signal (stale) Signal (stale)
+nothing heard yet nothing heard yet
diff --git a/deploy/apps/lang/es.lang b/deploy/apps/lang/es.lang
index fe56063..ccc5c3e 100644
--- a/deploy/apps/lang/es.lang
+++ b/deploy/apps/lang/es.lang
@@ -1,7 +1,7 @@
# wadamesh language file
# canonical source - translators: edit this file and PR it
# code: es
-# ver: 18
+# ver: 19
# name: Español
# base: es
(device behind) (dispositivo atrasado)
@@ -977,8 +977,6 @@ tap to exit full screen toca para salir de pantalla completa
zoom zoom
zoom %d zoom %d
~17 dB amp for quiet/remote areas; turn off in noisy spots. amplificador ~17 dB para zonas tranquilas/remotas; apaga en zonas ruidosas.
-© OpenStreetMap © OpenStreetMap
-© OpenTopoMap © OpenTopoMap
Scope
another region
my region
@@ -1000,7 +998,6 @@ Overwrite oldest non-favorite
1-character messages will be shown 1-character messages will be shown
All All
Allow Allow
-Apps that may send messages on the mesh. Anything they send goes out under your node name and cannot be told apart from a message you typed. Turn one off to take the permission back. Apps that may send messages on the mesh. Anything they send goes out under your node name and cannot be told apart from a message you typed. Turn one off to take the permission back.
Ignore 1-character messages Ignore 1-character messages
Join w/ password Join w/ password
Location no longer shared Location no longer shared
@@ -1014,9 +1011,7 @@ Never\nChosen contacts only\nAnyone who asks Never\nChosen contacts only\nAnyone
No apps installed. No apps installed.
No reply. Path hash is %u bytes;\nolder repeaters drop those.\nTry 1 byte (Settings > Radio & Mesh) No reply. Path hash is %u bytes;\nolder repeaters drop those.\nTry 1 byte (Settings > Radio & Mesh)
Not supported on this board Not supported on this board
-Read incoming messages Read incoming messages
Reconnected to %.40s Reconnected to %.40s
-Send messages as me Send messages as me
Sent only when that contact asks, encrypted to them. Nothing is broadcast. Needs "Answer telemetry requests" on. Pick contacts in a contact's menu. Sent only when that contact asks, encrypted to them. Nothing is broadcast. Needs "Answer telemetry requests" on. Pick contacts in a contact's menu.
Server has forgotten you.\nJoin with the room password. Server has forgotten you.\nJoin with the room password.
Share my loc Share my loc
@@ -1042,7 +1037,6 @@ App permissions App permissions
About / credits About / credits
Auth failed Auth failed
Auto-add on for %s — new %s land in Contacts automatically. Auto-add on for %s — new %s land in Contacts automatically.
-Batteryactivityon Batteryactivityon
Console mode Console mode
Console mode (experimental) Console mode (experimental)
Direct (0-hop) only Direct (0-hop) only
@@ -1051,8 +1045,6 @@ EXPERIMENTAL. Boots into a text console with no graphical interface: type comman
Enter text Enter text
Entering console mode - rebooting... Entering console mode - rebooting...
Favorite Favorite
-Geblokkeerde gebruikers Geblokkeerde gebruikers
-Hide system filesShow system files Hide system filesShow system files
How tiles work:\nThe map is built from 256×256 "slippy" tiles. Only the tiles for the area you're viewing are fetched — there is no bulk pre-download.\n\nBecause this device can't do HTTPS (not enough heap after Wi-Fi starts) and decodes JPEG far more cheaply than PNG, tiles come from the wadamesh proxy: it fetches the upstream PNG over HTTPS with an identifying User-Agent, re-encodes it as JPEG, and caches it. Your device then caches each tile to its own flash, so a tile is only downloaded once.\n\nUse Options → Reload tiles to re-download the tiles currently in view if one looks corrupted. How tiles work:\nThe map is built from 256×256 "slippy" tiles. Only the tiles for the area you're viewing are fetched — there is no bulk pre-download.\n\nBecause this device can't do HTTPS (not enough heap after Wi-Fi starts) and decodes JPEG far more cheaply than PNG, tiles come from the wadamesh proxy: it fetches the upstream PNG over HTTPS with an identifying User-Agent, re-encodes it as JPEG, and caches it. Your device then caches each tile to its own flash, so a tile is only downloaded once.\n\nUse Options → Reload tiles to re-download the tiles currently in view if one looks corrupted.
Init… Init…
Leaving console mode - rebooting... Leaving console mode - rebooting...
@@ -1062,8 +1054,6 @@ Map data © OpenStreetMap contributors.\nopenstreetmap.org/copyright\nLicensed u
Map style © OpenTopoMap (CC-BY-SA) — opentopomap.org\nMap data © OpenStreetMap contributors (ODbL) + SRTM.\n\n Map style © OpenTopoMap (CC-BY-SA) — opentopomap.org\nMap data © OpenStreetMap contributors (ODbL) + SRTM.\n\n
Name (A-Z) Name (A-Z)
Nearest first Nearest first
-Other networksNetworks Other networksNetworks
-Paste (move)Paste (copy) Paste (move)Paste (copy)
Recent message Recent message
Reload tiles in view Reload tiles in view
Repeaters Repeaters
@@ -1076,13 +1066,8 @@ Show contacts Show contacts
Show coordinates Show coordinates
Show tile z/x/y Show tile z/x/y
Starting… Starting…
-Stop sharing loc Share my loc Stop sharing loc Share my loc
-Supprimer Supprimer
-Switch to the external antenna?\n\nMake sure an antenna is actually connected to the external antenna socket first. Transmitting with nothing attached can damage the radio.\n\nResets to the on-board antenna on every reboot.Turn on legacy per-transmit switching?\n\nDiagnostic mode, for comparison only. It transmits on the on-board antenna and listens on the external one, so your outbound signal will be much weaker than your inbound.\n\nResets to the on-board antenna on every reboot. Switch to the external antenna?\n\nMake sure an antenna is actually connected to the external antenna socket first. Transmitting with nothing attached can damage the radio.\n\nResets to the on-board antenna on every reboot.Turn on legacy per-transmit switching?\n\nDiagnostic mode, for comparison only. It transmits on the on-board antenna and listens on the external one, so your outbound signal will be much weaker than your inbound.\n\nResets to the on-board antenna on every reboot.
Text console (experimental) Text console (experimental)
Tile debug overlay Tile debug overlay
-Unblock Block Unblock Block
-Unfav Favorite Unfav Favorite
chats chats
repeaters repeaters
rooms rooms
@@ -1098,3 +1083,26 @@ channel busy %.1f%% channel busy %.1f%%
sampling... sampling...
teal = now blue = average teal = now blue = average
window restarted window restarted
+Filter Filter
+Hide system files Hide system files
+Networks Networks
+Other networks Other networks
+Show system files Show system files
+Unfav Unfav
+Firmware beta_%d\nChecking for updates over Wi-Fi… Firmware beta_%d\nChecking for updates over Wi-Fi…
+Install beta_%d?\nThis downgrades the firmware and reboots. Install beta_%d?\nThis downgrades the firmware and reboots.
+Other (hidden) network… Other (hidden) network…
+Paused Paused
+Scan again Scan again
+At a glance At a glance
+At a glance disabled At a glance disabled
+At a glance enabled At a glance enabled
+At a glance while locked At a glance while locked
+Glance only shows when unlocked Glance only shows when unlocked
+Glance shows message previews\nwhile the screen is locked Glance shows message previews\nwhile the screen is locked
+Snake — swipe, roll, or press a direction Snake — swipe, roll, or press a direction
+N/A N/A
+This board cannot run this app. This board cannot run this app.
+Signal Signal
+Signal (stale) Signal (stale)
+nothing heard yet nothing heard yet
diff --git a/deploy/apps/lang/fr.lang b/deploy/apps/lang/fr.lang
index 7d5a664..038ab8a 100644
--- a/deploy/apps/lang/fr.lang
+++ b/deploy/apps/lang/fr.lang
@@ -1,7 +1,7 @@
# wadamesh language file
# canonical source - translators: edit this file and PR it
# code: fr
-# ver: 18
+# ver: 19
# name: Français
# base: fr
(device behind) (appareil en retard)
@@ -452,7 +452,6 @@ Login OK (perms %u) Connexion OK (perms %u)
Login failed Échec de connexion
Logs Journaux
Longitude Longitude
-Lua Store Lua Store
MHz MHz
MQTT bridge Pont MQTT
Map Carte
@@ -977,8 +976,6 @@ tap to exit full screen toucher pour quitter le plein écran
zoom zoom
zoom %d zoom %d
~17 dB amp for quiet/remote areas; turn off in noisy spots. ampli ~17 dB pour zones calmes/isolées ; désactiver en zone bruyante.
-© OpenStreetMap © OpenStreetMap
-© OpenTopoMap © OpenTopoMap
Scope
another region
my region
@@ -1000,7 +997,6 @@ Overwrite oldest non-favorite
1-character messages will be shown 1-character messages will be shown
All All
Allow Allow
-Apps that may send messages on the mesh. Anything they send goes out under your node name and cannot be told apart from a message you typed. Turn one off to take the permission back. Apps that may send messages on the mesh. Anything they send goes out under your node name and cannot be told apart from a message you typed. Turn one off to take the permission back.
Ignore 1-character messages Ignore 1-character messages
Join w/ password Join w/ password
Location no longer shared Location no longer shared
@@ -1014,9 +1010,7 @@ Never\nChosen contacts only\nAnyone who asks Never\nChosen contacts only\nAnyone
No apps installed. No apps installed.
No reply. Path hash is %u bytes;\nolder repeaters drop those.\nTry 1 byte (Settings > Radio & Mesh) No reply. Path hash is %u bytes;\nolder repeaters drop those.\nTry 1 byte (Settings > Radio & Mesh)
Not supported on this board Not supported on this board
-Read incoming messages Read incoming messages
Reconnected to %.40s Reconnected to %.40s
-Send messages as me Send messages as me
Sent only when that contact asks, encrypted to them. Nothing is broadcast. Needs "Answer telemetry requests" on. Pick contacts in a contact's menu. Sent only when that contact asks, encrypted to them. Nothing is broadcast. Needs "Answer telemetry requests" on. Pick contacts in a contact's menu.
Server has forgotten you.\nJoin with the room password. Server has forgotten you.\nJoin with the room password.
Share my loc Share my loc
@@ -1042,7 +1036,6 @@ App permissions App permissions
About / credits About / credits
Auth failed Auth failed
Auto-add on for %s — new %s land in Contacts automatically. Auto-add on for %s — new %s land in Contacts automatically.
-Batteryactivityon Batteryactivityon
Console mode Console mode
Console mode (experimental) Console mode (experimental)
Direct (0-hop) only Direct (0-hop) only
@@ -1051,8 +1044,6 @@ EXPERIMENTAL. Boots into a text console with no graphical interface: type comman
Enter text Enter text
Entering console mode - rebooting... Entering console mode - rebooting...
Favorite Favorite
-Geblokkeerde gebruikers Geblokkeerde gebruikers
-Hide system filesShow system files Hide system filesShow system files
How tiles work:\nThe map is built from 256×256 "slippy" tiles. Only the tiles for the area you're viewing are fetched — there is no bulk pre-download.\n\nBecause this device can't do HTTPS (not enough heap after Wi-Fi starts) and decodes JPEG far more cheaply than PNG, tiles come from the wadamesh proxy: it fetches the upstream PNG over HTTPS with an identifying User-Agent, re-encodes it as JPEG, and caches it. Your device then caches each tile to its own flash, so a tile is only downloaded once.\n\nUse Options → Reload tiles to re-download the tiles currently in view if one looks corrupted. How tiles work:\nThe map is built from 256×256 "slippy" tiles. Only the tiles for the area you're viewing are fetched — there is no bulk pre-download.\n\nBecause this device can't do HTTPS (not enough heap after Wi-Fi starts) and decodes JPEG far more cheaply than PNG, tiles come from the wadamesh proxy: it fetches the upstream PNG over HTTPS with an identifying User-Agent, re-encodes it as JPEG, and caches it. Your device then caches each tile to its own flash, so a tile is only downloaded once.\n\nUse Options → Reload tiles to re-download the tiles currently in view if one looks corrupted.
Init… Init…
Leaving console mode - rebooting... Leaving console mode - rebooting...
@@ -1062,8 +1053,6 @@ Map data © OpenStreetMap contributors.\nopenstreetmap.org/copyright\nLicensed u
Map style © OpenTopoMap (CC-BY-SA) — opentopomap.org\nMap data © OpenStreetMap contributors (ODbL) + SRTM.\n\n Map style © OpenTopoMap (CC-BY-SA) — opentopomap.org\nMap data © OpenStreetMap contributors (ODbL) + SRTM.\n\n
Name (A-Z) Name (A-Z)
Nearest first Nearest first
-Other networksNetworks Other networksNetworks
-Paste (move)Paste (copy) Paste (move)Paste (copy)
Recent message Recent message
Reload tiles in view Reload tiles in view
Repeaters Repeaters
@@ -1076,13 +1065,8 @@ Show contacts Show contacts
Show coordinates Show coordinates
Show tile z/x/y Show tile z/x/y
Starting… Starting…
-Stop sharing loc Share my loc Stop sharing loc Share my loc
-Supprimer Supprimer
-Switch to the external antenna?\n\nMake sure an antenna is actually connected to the external antenna socket first. Transmitting with nothing attached can damage the radio.\n\nResets to the on-board antenna on every reboot.Turn on legacy per-transmit switching?\n\nDiagnostic mode, for comparison only. It transmits on the on-board antenna and listens on the external one, so your outbound signal will be much weaker than your inbound.\n\nResets to the on-board antenna on every reboot. Switch to the external antenna?\n\nMake sure an antenna is actually connected to the external antenna socket first. Transmitting with nothing attached can damage the radio.\n\nResets to the on-board antenna on every reboot.Turn on legacy per-transmit switching?\n\nDiagnostic mode, for comparison only. It transmits on the on-board antenna and listens on the external one, so your outbound signal will be much weaker than your inbound.\n\nResets to the on-board antenna on every reboot.
Text console (experimental) Text console (experimental)
Tile debug overlay Tile debug overlay
-Unblock Block Unblock Block
-Unfav Favorite Unfav Favorite
chats chats
repeaters repeaters
rooms rooms
@@ -1098,3 +1082,26 @@ channel busy %.1f%% channel busy %.1f%%
sampling... sampling...
teal = now blue = average teal = now blue = average
window restarted window restarted
+Filter Filter
+Hide system files Hide system files
+Networks Networks
+Other networks Other networks
+Show system files Show system files
+Unfav Unfav
+Firmware beta_%d\nChecking for updates over Wi-Fi… Firmware beta_%d\nChecking for updates over Wi-Fi…
+Install beta_%d?\nThis downgrades the firmware and reboots. Install beta_%d?\nThis downgrades the firmware and reboots.
+Other (hidden) network… Other (hidden) network…
+Paused Paused
+Scan again Scan again
+At a glance At a glance
+At a glance disabled At a glance disabled
+At a glance enabled At a glance enabled
+At a glance while locked At a glance while locked
+Glance only shows when unlocked Glance only shows when unlocked
+Glance shows message previews\nwhile the screen is locked Glance shows message previews\nwhile the screen is locked
+Snake — swipe, roll, or press a direction Snake — swipe, roll, or press a direction
+N/A N/A
+This board cannot run this app. This board cannot run this app.
+Signal Signal
+Signal (stale) Signal (stale)
+nothing heard yet nothing heard yet
diff --git a/deploy/apps/lang/hu.lang b/deploy/apps/lang/hu.lang
index 84dfac7..e1167b2 100644
--- a/deploy/apps/lang/hu.lang
+++ b/deploy/apps/lang/hu.lang
@@ -1,7 +1,7 @@
# wadamesh language file
# canonical source - translators: edit this file and PR it
# code: hu
-# ver: 18
+# ver: 19
# name: Magyar
# base: hu
(device behind) (készülék hátulján)
@@ -703,7 +703,7 @@ SD card remounted SD kártya újracsatolva
SD card removed SD-kártya eltávolítva
SD data is unavailable - contacts and channels cannot be saved until the card is reinserted. Az SD-kártya adatai nem érhetők el – a névjegyek és a csatornák nem menthetők a kártya újbóli behelyezéséig.
SD data is unavailable - identity, settings, contacts and channels cannot be saved until the card is reinserted. Az SD-kártya adatai nem érhetők el – az azonosító, a beállítások, a névjegyek és a csatornák nem menthetők a kártya újbóli behelyezéséig.
-SD data migration is incomplete. Identity and settings remain internal; use Copy internal data to SD to retry. Az SD-kártya adatainak áthelyezése nem teljes. Az azonosító és a beállítások továbbra is a belső tárhelyen vannak; a folytatáshoz használd a „Belső adatok másolása SD-kártyára” lehetőséget.
+SD data migration is incomplete. Identity and settings remain internal; use Copy internal data to SD to retry. Az SD-kártya adatainak áthelyezése nem teljes. Az azonosító és a beállítások továbbra is a belső tárhelyen vannak; a folytatáshoz használd a "Belső adatok másolása SD-kártyára" lehetőséget.
SD download failed: %s SD-letöltés sikertelen: %s
SD formatted - %s (MESHCOMOD) SD formázás - %s (MESHCOMOD)
SD is busy - close tools and retry Az SD-kártya foglalt – zárd be az eszközöket, majd próbáld újra.
@@ -812,7 +812,6 @@ Sleep ready Készen áll az alvásra
Slot %d cleared %d hely kiürítve
Slot empty Üres hely
Small\nMedium\nLarge Kicsi\nKözepes\nNagy
-Snake Snake
Snake — swipe or roll the trackball Kígyó – pöccintsd vagy görgesd a görgetőgolyót
Sort Sort
Sound Hang
@@ -997,8 +996,6 @@ tap to exit full screen koppintson a teljes képernyős kilépéshez
zoom zoom
zoom %d zoom %d
~17 dB amp for quiet/remote areas; turn off in noisy spots. ~17 dB erősítővel csendes/távoli helyeken; zajos helyeken kapcsold ki.
-© OpenStreetMap © OpenStreetMap
-© OpenTopoMap © OpenTopoMap
Scope Hatókör
another region másik régió
my region saját régió
@@ -1065,11 +1062,10 @@ sensors érzékelők
Auto-add on for %s — new %s land in Contacts automatically. Automatikus hozzáadás bekapcsolva: %s — az új %s automatikusan a Névjegyek közé kerülnek.
Map data © OpenStreetMap contributors.\nopenstreetmap.org/copyright\nLicensed under the Open Database License (ODbL).\n\n Térkép adatok © OpenStreetMap közreműködői.\nopenstreetmap.org/copyright\nAz Open Database License (ODbL) licenc alatt.\n\n
Map style © OpenTopoMap (CC-BY-SA) — opentopomap.org\nMap data © OpenStreetMap contributors (ODbL) + SRTM.\n\n Térkép stílus © OpenTopoMap (CC-BY-SA) — opentopomap.org\nTérkép adatok © OpenStreetMap közreműködői (ODbL) + SRTM.\n\n
-How tiles work:\nThe map is built from 256×256 "slippy" tiles. Only the tiles for the area you're viewing are fetched — there is no bulk pre-download.\n\nBecause this device can't do HTTPS (not enough heap after Wi-Fi starts) and decodes JPEG far more cheaply than PNG, tiles come from the wadamesh proxy: it fetches the upstream PNG over HTTPS with an identifying User-Agent, re-encodes it as JPEG, and caches it. Your device then caches each tile to its own flash, so a tile is only downloaded once.\n\nUse Options → Reload tiles to re-download the tiles currently in view if one looks corrupted. A csempék működése:\nA térkép 256×256 képpontos „slippy” csempékből épül fel. Csak a megtekintett területhez szükséges csempék töltődnek le — nincs tömeges előzetes letöltés.\n\nMivel az eszköz nem támogatja a HTTPS-t (a Wi-Fi elindítása után nincs elegendő heap memória), és a JPEG képek dekódolása sokkal kevesebb erőforrást igényel, mint a PNG-é, a csempék a wadamesh proxyn keresztül érkeznek: a proxy HTTPS-en keresztül lekéri a forrásból a PNG-t egy azonosító User-Agent használatával, JPEG formátumba kódolja át, majd eltárolja a gyorsítótárban. Az eszköz ezután minden csempét a saját flash memóriájában is eltárol, így egy csempét csak egyszer kell letölteni.\n\nAz Options → Reload tiles lehetőséggel újra letöltheted a jelenleg megtekintett terület csempéit, ha valamelyik hibásan jelenik meg.
+How tiles work:\nThe map is built from 256×256 "slippy" tiles. Only the tiles for the area you're viewing are fetched — there is no bulk pre-download.\n\nBecause this device can't do HTTPS (not enough heap after Wi-Fi starts) and decodes JPEG far more cheaply than PNG, tiles come from the wadamesh proxy: it fetches the upstream PNG over HTTPS with an identifying User-Agent, re-encodes it as JPEG, and caches it. Your device then caches each tile to its own flash, so a tile is only downloaded once.\n\nUse Options → Reload tiles to re-download the tiles currently in view if one looks corrupted. A csempék működése:\nA térkép 256×256 képpontos "slippy" csempékből épül fel. Csak a megtekintett területhez szükséges csempék töltődnek le — nincs tömeges előzetes letöltés.\n\nMivel az eszköz nem támogatja a HTTPS-t (a Wi-Fi elindítása után nincs elegendő heap memória), és a JPEG képek dekódolása sokkal kevesebb erőforrást igényel, mint a PNG-é, a csempék a wadamesh proxyn keresztül érkeznek: a proxy HTTPS-en keresztül lekéri a forrásból a PNG-t egy azonosító User-Agent használatával, JPEG formátumba kódolja át, majd eltárolja a gyorsítótárban. Az eszköz ezután minden csempét a saját flash memóriájában is eltárol, így egy csempét csak egyszer kell letölteni.\n\nAz Options → Reload tiles lehetőséggel újra letöltheted a jelenleg megtekintett terület csempéit, ha valamelyik hibásan jelenik meg.
"%s" wants to send discovery probes.\n\nEach probe asks every node in range to reply, so it uses airtime on the whole local mesh, not just yours.\n\nNothing is sent under your name and no message is transmitted. "%s" felfedezési probe-ok küldését kéri.\n\nMinden probe válaszra kéri a hatótávolságon belüli node-okat, ezért a teljes helyi mesh hálózat airtime-ját használja, nem csak a tiédet.\n\nSemmi sem kerül elküldésre a nevedben, és üzenet sem kerül továbbításra.
? ?
Auth failed Auth failed
-Batteryactivityon Batteryactivityon
Console mode Console mode
Console mode (experimental) Console mode (experimental)
Direct (0-hop) only Csak közvetlen (0-hop)
@@ -1078,16 +1074,12 @@ EXPERIMENTAL. Boots into a text console with no graphical interface: type comman
Enter text Szöveg megadása
Entering console mode - rebooting... Entering console mode - rebooting...
Favorite Favorite
-Geblokkeerde gebruikers Geblokkeerde gebruikers
-Hide system filesShow system files Hide system filesShow system files
Init… Init…
Leaving console mode - rebooting... Leaving console mode - rebooting...
Link lost Link lost
MESH MESH
Name (A-Z) Név (A–Z)
Nearest first Legközelebbi elöl
-Other networksNetworks Other networksNetworks
-Paste (move)Paste (copy) Paste (move)Paste (copy)
Recent message Legutóbbi üzenet
Reload tiles in view Csempék újratöltése
Repeaters Repeaters
@@ -1098,13 +1090,8 @@ Show contacts Névjegyek megjelenítése
Show coordinates Koordináták
Show tile z/x/y Csempe z/x/y
Starting… Starting…
-Stop sharing loc Share my loc Stop sharing loc Share my loc
-Supprimer Supprimer
-Switch to the external antenna?\n\nMake sure an antenna is actually connected to the external antenna socket first. Transmitting with nothing attached can damage the radio.\n\nResets to the on-board antenna on every reboot.Turn on legacy per-transmit switching?\n\nDiagnostic mode, for comparison only. It transmits on the on-board antenna and listens on the external one, so your outbound signal will be much weaker than your inbound.\n\nResets to the on-board antenna on every reboot. Switch to the external antenna?\n\nMake sure an antenna is actually connected to the external antenna socket first. Transmitting with nothing attached can damage the radio.\n\nResets to the on-board antenna on every reboot.Turn on legacy per-transmit switching?\n\nDiagnostic mode, for comparison only. It transmits on the on-board antenna and listens on the external one, so your outbound signal will be much weaker than your inbound.\n\nResets to the on-board antenna on every reboot.
Text console (experimental) Text console (experimental)
Tile debug overlay Csempe hiba keret
-Unblock Block Unblock Block
-Unfav Favorite Unfav Favorite
tap to cycle off / on / auto tap to cycle off / on / auto
#7A7F87 Scanning… nothing has answered yet# #7A7F87 Scanning… nothing has answered yet#
Export crash report (%uK) Hibajelentés exportálása (%uK)
@@ -1116,3 +1103,23 @@ channel busy %.1f%% channel busy %.1f%%
sampling... sampling...
teal = now blue = average teal = now blue = average
window restarted window restarted
+Filter Filter
+Hide system files Hide system files
+Networks Networks
+Show system files Show system files
+Unfav Unfav
+Firmware beta_%d\nChecking for updates over Wi-Fi… Firmware beta_%d\nChecking for updates over Wi-Fi…
+Install beta_%d?\nThis downgrades the firmware and reboots. A beta_%d telepítése?\nEz leminősíti a firmware-t és újraindul.
+Other (hidden) network… Other (hidden) network…
+Paused Paused
+At a glance At a glance
+At a glance disabled At a glance disabled
+At a glance enabled At a glance enabled
+At a glance while locked At a glance while locked
+Glance only shows when unlocked Glance only shows when unlocked
+Glance shows message previews\nwhile the screen is locked Glance shows message previews\nwhile the screen is locked
+Snake — swipe, roll, or press a direction Snake — swipe, roll, or press a direction
+N/A N/A
+This board cannot run this app. This board cannot run this app.
+Signal (stale) Jel (elavult)
+nothing heard yet Még semmi hír.
diff --git a/deploy/apps/lang/it.lang b/deploy/apps/lang/it.lang
index e0350d1..af103b8 100644
--- a/deploy/apps/lang/it.lang
+++ b/deploy/apps/lang/it.lang
@@ -1,7 +1,7 @@
# wadamesh language file
# canonical source - translators: edit this file and PR it
# code: it
-# ver: 18
+# ver: 19
# name: Italiano
# base: it
(device behind) (dispositivo indietro)
@@ -452,7 +452,6 @@ Login OK (perms %u) Accesso OK (permessi %u)
Login failed Accesso non riuscito
Logs Log
Longitude Longitudine
-Lua Store Lua Store
MHz MHz
MQTT bridge Ponte MQTT
Map Mappa
@@ -977,8 +976,6 @@ tap to exit full screen tocca per uscire da schermo intero
zoom zoom
zoom %d zoom %d
~17 dB amp for quiet/remote areas; turn off in noisy spots. ampli ~17 dB per zone tranquille/remote; spegni in zone rumorose.
-© OpenStreetMap © OpenStreetMap
-© OpenTopoMap © OpenTopoMap
Scope
another region
my region
@@ -1000,7 +997,6 @@ Overwrite oldest non-favorite
1-character messages will be shown 1-character messages will be shown
All All
Allow Allow
-Apps that may send messages on the mesh. Anything they send goes out under your node name and cannot be told apart from a message you typed. Turn one off to take the permission back. Apps that may send messages on the mesh. Anything they send goes out under your node name and cannot be told apart from a message you typed. Turn one off to take the permission back.
Ignore 1-character messages Ignore 1-character messages
Join w/ password Join w/ password
Location no longer shared Location no longer shared
@@ -1014,9 +1010,7 @@ Never\nChosen contacts only\nAnyone who asks Never\nChosen contacts only\nAnyone
No apps installed. No apps installed.
No reply. Path hash is %u bytes;\nolder repeaters drop those.\nTry 1 byte (Settings > Radio & Mesh) No reply. Path hash is %u bytes;\nolder repeaters drop those.\nTry 1 byte (Settings > Radio & Mesh)
Not supported on this board Not supported on this board
-Read incoming messages Read incoming messages
Reconnected to %.40s Reconnected to %.40s
-Send messages as me Send messages as me
Sent only when that contact asks, encrypted to them. Nothing is broadcast. Needs "Answer telemetry requests" on. Pick contacts in a contact's menu. Sent only when that contact asks, encrypted to them. Nothing is broadcast. Needs "Answer telemetry requests" on. Pick contacts in a contact's menu.
Server has forgotten you.\nJoin with the room password. Server has forgotten you.\nJoin with the room password.
Share my loc Share my loc
@@ -1042,7 +1036,6 @@ App permissions App permissions
About / credits About / credits
Auth failed Auth failed
Auto-add on for %s — new %s land in Contacts automatically. Auto-add on for %s — new %s land in Contacts automatically.
-Batteryactivityon Batteryactivityon
Console mode Console mode
Console mode (experimental) Console mode (experimental)
Direct (0-hop) only Direct (0-hop) only
@@ -1051,8 +1044,6 @@ EXPERIMENTAL. Boots into a text console with no graphical interface: type comman
Enter text Enter text
Entering console mode - rebooting... Entering console mode - rebooting...
Favorite Favorite
-Geblokkeerde gebruikers Geblokkeerde gebruikers
-Hide system filesShow system files Hide system filesShow system files
How tiles work:\nThe map is built from 256×256 "slippy" tiles. Only the tiles for the area you're viewing are fetched — there is no bulk pre-download.\n\nBecause this device can't do HTTPS (not enough heap after Wi-Fi starts) and decodes JPEG far more cheaply than PNG, tiles come from the wadamesh proxy: it fetches the upstream PNG over HTTPS with an identifying User-Agent, re-encodes it as JPEG, and caches it. Your device then caches each tile to its own flash, so a tile is only downloaded once.\n\nUse Options → Reload tiles to re-download the tiles currently in view if one looks corrupted. How tiles work:\nThe map is built from 256×256 "slippy" tiles. Only the tiles for the area you're viewing are fetched — there is no bulk pre-download.\n\nBecause this device can't do HTTPS (not enough heap after Wi-Fi starts) and decodes JPEG far more cheaply than PNG, tiles come from the wadamesh proxy: it fetches the upstream PNG over HTTPS with an identifying User-Agent, re-encodes it as JPEG, and caches it. Your device then caches each tile to its own flash, so a tile is only downloaded once.\n\nUse Options → Reload tiles to re-download the tiles currently in view if one looks corrupted.
Init… Init…
Leaving console mode - rebooting... Leaving console mode - rebooting...
@@ -1062,8 +1053,6 @@ Map data © OpenStreetMap contributors.\nopenstreetmap.org/copyright\nLicensed u
Map style © OpenTopoMap (CC-BY-SA) — opentopomap.org\nMap data © OpenStreetMap contributors (ODbL) + SRTM.\n\n Map style © OpenTopoMap (CC-BY-SA) — opentopomap.org\nMap data © OpenStreetMap contributors (ODbL) + SRTM.\n\n
Name (A-Z) Name (A-Z)
Nearest first Nearest first
-Other networksNetworks Other networksNetworks
-Paste (move)Paste (copy) Paste (move)Paste (copy)
Recent message Recent message
Reload tiles in view Reload tiles in view
Repeaters Repeaters
@@ -1076,13 +1065,8 @@ Show contacts Show contacts
Show coordinates Show coordinates
Show tile z/x/y Show tile z/x/y
Starting… Starting…
-Stop sharing loc Share my loc Stop sharing loc Share my loc
-Supprimer Supprimer
-Switch to the external antenna?\n\nMake sure an antenna is actually connected to the external antenna socket first. Transmitting with nothing attached can damage the radio.\n\nResets to the on-board antenna on every reboot.Turn on legacy per-transmit switching?\n\nDiagnostic mode, for comparison only. It transmits on the on-board antenna and listens on the external one, so your outbound signal will be much weaker than your inbound.\n\nResets to the on-board antenna on every reboot. Switch to the external antenna?\n\nMake sure an antenna is actually connected to the external antenna socket first. Transmitting with nothing attached can damage the radio.\n\nResets to the on-board antenna on every reboot.Turn on legacy per-transmit switching?\n\nDiagnostic mode, for comparison only. It transmits on the on-board antenna and listens on the external one, so your outbound signal will be much weaker than your inbound.\n\nResets to the on-board antenna on every reboot.
Text console (experimental) Text console (experimental)
Tile debug overlay Tile debug overlay
-Unblock Block Unblock Block
-Unfav Favorite Unfav Favorite
chats chats
repeaters repeaters
rooms rooms
@@ -1098,3 +1082,26 @@ channel busy %.1f%% channel busy %.1f%%
sampling... sampling...
teal = now blue = average teal = now blue = average
window restarted window restarted
+Filter Filter
+Hide system files Hide system files
+Networks Networks
+Other networks Other networks
+Show system files Show system files
+Unfav Unfav
+Firmware beta_%d\nChecking for updates over Wi-Fi… Firmware beta_%d\nChecking for updates over Wi-Fi…
+Install beta_%d?\nThis downgrades the firmware and reboots. Install beta_%d?\nThis downgrades the firmware and reboots.
+Other (hidden) network… Other (hidden) network…
+Paused Paused
+Scan again Scan again
+At a glance At a glance
+At a glance disabled At a glance disabled
+At a glance enabled At a glance enabled
+At a glance while locked At a glance while locked
+Glance only shows when unlocked Glance only shows when unlocked
+Glance shows message previews\nwhile the screen is locked Glance shows message previews\nwhile the screen is locked
+Snake — swipe, roll, or press a direction Snake — swipe, roll, or press a direction
+N/A N/A
+This board cannot run this app. This board cannot run this app.
+Signal Signal
+Signal (stale) Signal (stale)
+nothing heard yet nothing heard yet
diff --git a/deploy/apps/lang/nl.lang b/deploy/apps/lang/nl.lang
index a7f4f93..b8a59f9 100644
--- a/deploy/apps/lang/nl.lang
+++ b/deploy/apps/lang/nl.lang
@@ -1,7 +1,7 @@
# wadamesh language file
# canonical source - translators: edit this file and PR it
# code: nl
-# ver: 18
+# ver: 19
# name: Nederlands
# base: nl
(device behind) (apparaat loopt achter)
@@ -452,7 +452,6 @@ Login OK (perms %u) Inloggen OK (rechten %u)
Login failed Inloggen mislukt
Logs Logboeken
Longitude Lengtegraad
-Lua Store Lua Store
MHz MHz
MQTT bridge MQTT-brug
Map Kaart
@@ -797,7 +796,6 @@ Sleep ready Klaar voor slaap
Slot %d cleared Slot %d gewist
Slot empty Slot leeg
Small\nMedium\nLarge Klein\nMiddel\nGroot
-Snake Snake
Snake — swipe or roll the trackball Snake — veeg of rol de trackball
Sort Sorteer
Sound Geluid
@@ -805,7 +803,6 @@ Sound off Geluid uit
Sound on Geluid aan
Sound: %s Geluid: %s
Start time Starttijd
-Status Status
Step 1 of 3 Stap 1 van 3
Step 2 of 3 Stap 2 van 3
Step 3 of 3 Stap 3 van 3
@@ -977,8 +974,6 @@ tap to exit full screen tik om volledig scherm te verlaten
zoom zoom
zoom %d zoom %d
~17 dB amp for quiet/remote areas; turn off in noisy spots. ~17 dB versterker voor stille/afgelegen gebieden; uit in drukke plekken.
-© OpenStreetMap © OpenStreetMap
-© OpenTopoMap © OpenTopoMap
Scope
another region
my region
@@ -1000,7 +995,6 @@ Overwrite oldest non-favorite
1-character messages will be shown 1-character messages will be shown
All All
Allow Allow
-Apps that may send messages on the mesh. Anything they send goes out under your node name and cannot be told apart from a message you typed. Turn one off to take the permission back. Apps that may send messages on the mesh. Anything they send goes out under your node name and cannot be told apart from a message you typed. Turn one off to take the permission back.
Ignore 1-character messages Ignore 1-character messages
Join w/ password Join w/ password
Location no longer shared Location no longer shared
@@ -1014,9 +1008,7 @@ Never\nChosen contacts only\nAnyone who asks Never\nChosen contacts only\nAnyone
No apps installed. No apps installed.
No reply. Path hash is %u bytes;\nolder repeaters drop those.\nTry 1 byte (Settings > Radio & Mesh) No reply. Path hash is %u bytes;\nolder repeaters drop those.\nTry 1 byte (Settings > Radio & Mesh)
Not supported on this board Not supported on this board
-Read incoming messages Read incoming messages
Reconnected to %.40s Reconnected to %.40s
-Send messages as me Send messages as me
Sent only when that contact asks, encrypted to them. Nothing is broadcast. Needs "Answer telemetry requests" on. Pick contacts in a contact's menu. Sent only when that contact asks, encrypted to them. Nothing is broadcast. Needs "Answer telemetry requests" on. Pick contacts in a contact's menu.
Server has forgotten you.\nJoin with the room password. Server has forgotten you.\nJoin with the room password.
Share my loc Share my loc
@@ -1042,7 +1034,6 @@ App permissions App permissions
About / credits About / credits
Auth failed Auth failed
Auto-add on for %s — new %s land in Contacts automatically. Auto-add on for %s — new %s land in Contacts automatically.
-Batteryactivityon Batteryactivityon
Console mode Console mode
Console mode (experimental) Console mode (experimental)
Direct (0-hop) only Direct (0-hop) only
@@ -1051,8 +1042,6 @@ EXPERIMENTAL. Boots into a text console with no graphical interface: type comman
Enter text Enter text
Entering console mode - rebooting... Entering console mode - rebooting...
Favorite Favorite
-Geblokkeerde gebruikers Geblokkeerde gebruikers
-Hide system filesShow system files Hide system filesShow system files
How tiles work:\nThe map is built from 256×256 "slippy" tiles. Only the tiles for the area you're viewing are fetched — there is no bulk pre-download.\n\nBecause this device can't do HTTPS (not enough heap after Wi-Fi starts) and decodes JPEG far more cheaply than PNG, tiles come from the wadamesh proxy: it fetches the upstream PNG over HTTPS with an identifying User-Agent, re-encodes it as JPEG, and caches it. Your device then caches each tile to its own flash, so a tile is only downloaded once.\n\nUse Options → Reload tiles to re-download the tiles currently in view if one looks corrupted. How tiles work:\nThe map is built from 256×256 "slippy" tiles. Only the tiles for the area you're viewing are fetched — there is no bulk pre-download.\n\nBecause this device can't do HTTPS (not enough heap after Wi-Fi starts) and decodes JPEG far more cheaply than PNG, tiles come from the wadamesh proxy: it fetches the upstream PNG over HTTPS with an identifying User-Agent, re-encodes it as JPEG, and caches it. Your device then caches each tile to its own flash, so a tile is only downloaded once.\n\nUse Options → Reload tiles to re-download the tiles currently in view if one looks corrupted.
Init… Init…
Leaving console mode - rebooting... Leaving console mode - rebooting...
@@ -1062,8 +1051,6 @@ Map data © OpenStreetMap contributors.\nopenstreetmap.org/copyright\nLicensed u
Map style © OpenTopoMap (CC-BY-SA) — opentopomap.org\nMap data © OpenStreetMap contributors (ODbL) + SRTM.\n\n Map style © OpenTopoMap (CC-BY-SA) — opentopomap.org\nMap data © OpenStreetMap contributors (ODbL) + SRTM.\n\n
Name (A-Z) Name (A-Z)
Nearest first Nearest first
-Other networksNetworks Other networksNetworks
-Paste (move)Paste (copy) Paste (move)Paste (copy)
Recent message Recent message
Reload tiles in view Reload tiles in view
Repeaters Repeaters
@@ -1076,13 +1063,8 @@ Show contacts Show contacts
Show coordinates Show coordinates
Show tile z/x/y Show tile z/x/y
Starting… Starting…
-Stop sharing loc Share my loc Stop sharing loc Share my loc
-Supprimer Supprimer
-Switch to the external antenna?\n\nMake sure an antenna is actually connected to the external antenna socket first. Transmitting with nothing attached can damage the radio.\n\nResets to the on-board antenna on every reboot.Turn on legacy per-transmit switching?\n\nDiagnostic mode, for comparison only. It transmits on the on-board antenna and listens on the external one, so your outbound signal will be much weaker than your inbound.\n\nResets to the on-board antenna on every reboot. Switch to the external antenna?\n\nMake sure an antenna is actually connected to the external antenna socket first. Transmitting with nothing attached can damage the radio.\n\nResets to the on-board antenna on every reboot.Turn on legacy per-transmit switching?\n\nDiagnostic mode, for comparison only. It transmits on the on-board antenna and listens on the external one, so your outbound signal will be much weaker than your inbound.\n\nResets to the on-board antenna on every reboot.
Text console (experimental) Text console (experimental)
Tile debug overlay Tile debug overlay
-Unblock Block Unblock Block
-Unfav Favorite Unfav Favorite
chats chats
repeaters repeaters
rooms rooms
@@ -1098,3 +1080,26 @@ channel busy %.1f%% channel busy %.1f%%
sampling... sampling...
teal = now blue = average teal = now blue = average
window restarted window restarted
+Filter Filter
+Hide system files Hide system files
+Networks Networks
+Other networks Other networks
+Show system files Show system files
+Unfav Unfav
+Firmware beta_%d\nChecking for updates over Wi-Fi… Firmware beta_%d\nChecking for updates over Wi-Fi…
+Install beta_%d?\nThis downgrades the firmware and reboots. Install beta_%d?\nThis downgrades the firmware and reboots.
+Other (hidden) network… Other (hidden) network…
+Paused Paused
+Scan again Scan again
+At a glance At a glance
+At a glance disabled At a glance disabled
+At a glance enabled At a glance enabled
+At a glance while locked At a glance while locked
+Glance only shows when unlocked Glance only shows when unlocked
+Glance shows message previews\nwhile the screen is locked Glance shows message previews\nwhile the screen is locked
+Snake — swipe, roll, or press a direction Snake — swipe, roll, or press a direction
+N/A N/A
+This board cannot run this app. This board cannot run this app.
+Signal Signal
+Signal (stale) Signal (stale)
+nothing heard yet nothing heard yet
diff --git a/deploy/apps/lang/pt-br.lang b/deploy/apps/lang/pt-br.lang
index e39cb21..96cda17 100644
--- a/deploy/apps/lang/pt-br.lang
+++ b/deploy/apps/lang/pt-br.lang
@@ -1,7 +1,7 @@
# wadamesh language file
# canonical source - translators: edit this file and PR it
# code: pt-br
-# ver: 18
+# ver: 19
# name: Português (BR)
# base: pt-br
(device behind) (dispositivo atrasado)
@@ -805,7 +805,6 @@ Sound off Som desligado
Sound on Som ligado
Sound: %s Som: %s
Start time Hora de início
-Status Status
Step 1 of 3 Passo 1 de 3
Step 2 of 3 Passo 2 de 3
Step 3 of 3 Passo 3 de 3
@@ -977,8 +976,6 @@ tap to exit full screen toque para sair da tela cheia
zoom zoom
zoom %d zoom %d
~17 dB amp for quiet/remote areas; turn off in noisy spots. amplificador ~17 dB para áreas silenciosas/remotas; desligue em locais ruidosos.
-© OpenStreetMap © OpenStreetMap
-© OpenTopoMap © OpenTopoMap
Scope
another region
my region
@@ -1000,7 +997,6 @@ Overwrite oldest non-favorite
1-character messages will be shown 1-character messages will be shown
All All
Allow Allow
-Apps that may send messages on the mesh. Anything they send goes out under your node name and cannot be told apart from a message you typed. Turn one off to take the permission back. Apps that may send messages on the mesh. Anything they send goes out under your node name and cannot be told apart from a message you typed. Turn one off to take the permission back.
Ignore 1-character messages Ignore 1-character messages
Join w/ password Join w/ password
Location no longer shared Location no longer shared
@@ -1014,9 +1010,7 @@ Never\nChosen contacts only\nAnyone who asks Never\nChosen contacts only\nAnyone
No apps installed. No apps installed.
No reply. Path hash is %u bytes;\nolder repeaters drop those.\nTry 1 byte (Settings > Radio & Mesh) No reply. Path hash is %u bytes;\nolder repeaters drop those.\nTry 1 byte (Settings > Radio & Mesh)
Not supported on this board Not supported on this board
-Read incoming messages Read incoming messages
Reconnected to %.40s Reconnected to %.40s
-Send messages as me Send messages as me
Sent only when that contact asks, encrypted to them. Nothing is broadcast. Needs "Answer telemetry requests" on. Pick contacts in a contact's menu. Sent only when that contact asks, encrypted to them. Nothing is broadcast. Needs "Answer telemetry requests" on. Pick contacts in a contact's menu.
Server has forgotten you.\nJoin with the room password. Server has forgotten you.\nJoin with the room password.
Share my loc Share my loc
@@ -1042,7 +1036,6 @@ App permissions App permissions
About / credits About / credits
Auth failed Auth failed
Auto-add on for %s — new %s land in Contacts automatically. Auto-add on for %s — new %s land in Contacts automatically.
-Batteryactivityon Batteryactivityon
Console mode Console mode
Console mode (experimental) Console mode (experimental)
Direct (0-hop) only Direct (0-hop) only
@@ -1051,8 +1044,6 @@ EXPERIMENTAL. Boots into a text console with no graphical interface: type comman
Enter text Enter text
Entering console mode - rebooting... Entering console mode - rebooting...
Favorite Favorite
-Geblokkeerde gebruikers Geblokkeerde gebruikers
-Hide system filesShow system files Hide system filesShow system files
How tiles work:\nThe map is built from 256×256 "slippy" tiles. Only the tiles for the area you're viewing are fetched — there is no bulk pre-download.\n\nBecause this device can't do HTTPS (not enough heap after Wi-Fi starts) and decodes JPEG far more cheaply than PNG, tiles come from the wadamesh proxy: it fetches the upstream PNG over HTTPS with an identifying User-Agent, re-encodes it as JPEG, and caches it. Your device then caches each tile to its own flash, so a tile is only downloaded once.\n\nUse Options → Reload tiles to re-download the tiles currently in view if one looks corrupted. How tiles work:\nThe map is built from 256×256 "slippy" tiles. Only the tiles for the area you're viewing are fetched — there is no bulk pre-download.\n\nBecause this device can't do HTTPS (not enough heap after Wi-Fi starts) and decodes JPEG far more cheaply than PNG, tiles come from the wadamesh proxy: it fetches the upstream PNG over HTTPS with an identifying User-Agent, re-encodes it as JPEG, and caches it. Your device then caches each tile to its own flash, so a tile is only downloaded once.\n\nUse Options → Reload tiles to re-download the tiles currently in view if one looks corrupted.
Init… Init…
Leaving console mode - rebooting... Leaving console mode - rebooting...
@@ -1062,8 +1053,6 @@ Map data © OpenStreetMap contributors.\nopenstreetmap.org/copyright\nLicensed u
Map style © OpenTopoMap (CC-BY-SA) — opentopomap.org\nMap data © OpenStreetMap contributors (ODbL) + SRTM.\n\n Map style © OpenTopoMap (CC-BY-SA) — opentopomap.org\nMap data © OpenStreetMap contributors (ODbL) + SRTM.\n\n
Name (A-Z) Name (A-Z)
Nearest first Nearest first
-Other networksNetworks Other networksNetworks
-Paste (move)Paste (copy) Paste (move)Paste (copy)
Recent message Recent message
Reload tiles in view Reload tiles in view
Repeaters Repeaters
@@ -1076,13 +1065,8 @@ Show contacts Show contacts
Show coordinates Show coordinates
Show tile z/x/y Show tile z/x/y
Starting… Starting…
-Stop sharing loc Share my loc Stop sharing loc Share my loc
-Supprimer Supprimer
-Switch to the external antenna?\n\nMake sure an antenna is actually connected to the external antenna socket first. Transmitting with nothing attached can damage the radio.\n\nResets to the on-board antenna on every reboot.Turn on legacy per-transmit switching?\n\nDiagnostic mode, for comparison only. It transmits on the on-board antenna and listens on the external one, so your outbound signal will be much weaker than your inbound.\n\nResets to the on-board antenna on every reboot. Switch to the external antenna?\n\nMake sure an antenna is actually connected to the external antenna socket first. Transmitting with nothing attached can damage the radio.\n\nResets to the on-board antenna on every reboot.Turn on legacy per-transmit switching?\n\nDiagnostic mode, for comparison only. It transmits on the on-board antenna and listens on the external one, so your outbound signal will be much weaker than your inbound.\n\nResets to the on-board antenna on every reboot.
Text console (experimental) Text console (experimental)
Tile debug overlay Tile debug overlay
-Unblock Block Unblock Block
-Unfav Favorite Unfav Favorite
chats chats
repeaters repeaters
rooms rooms
@@ -1098,3 +1082,26 @@ channel busy %.1f%% channel busy %.1f%%
sampling... sampling...
teal = now blue = average teal = now blue = average
window restarted window restarted
+Filter Filter
+Hide system files Hide system files
+Networks Networks
+Other networks Other networks
+Show system files Show system files
+Unfav Unfav
+Firmware beta_%d\nChecking for updates over Wi-Fi… Firmware beta_%d\nChecking for updates over Wi-Fi…
+Install beta_%d?\nThis downgrades the firmware and reboots. Install beta_%d?\nThis downgrades the firmware and reboots.
+Other (hidden) network… Other (hidden) network…
+Paused Paused
+Scan again Scan again
+At a glance At a glance
+At a glance disabled At a glance disabled
+At a glance enabled At a glance enabled
+At a glance while locked At a glance while locked
+Glance only shows when unlocked Glance only shows when unlocked
+Glance shows message previews\nwhile the screen is locked Glance shows message previews\nwhile the screen is locked
+Snake — swipe, roll, or press a direction Snake — swipe, roll, or press a direction
+N/A N/A
+This board cannot run this app. This board cannot run this app.
+Signal Signal
+Signal (stale) Signal (stale)
+nothing heard yet nothing heard yet
diff --git a/deploy/apps/lang/ro.lang b/deploy/apps/lang/ro.lang
index 6967b9d..0331e6c 100644
--- a/deploy/apps/lang/ro.lang
+++ b/deploy/apps/lang/ro.lang
@@ -1,7 +1,7 @@
# wadamesh language file
# canonical source - translators: edit this file and PR it
# code: ro
-# ver: 18
+# ver: 19
# name: Română
# base: ro
(device behind) (dispozitiv în urmă)
@@ -978,7 +978,6 @@ zoom zoom
zoom %d zoom %d
~17 dB amp for quiet/remote areas; turn off in noisy spots. amplificator ~17 dB pentru zone liniştite/izolate; opreşte-l în locuri zgomotoase.
© OpenStreetMap
-© OpenTopoMap © OpenTopoMap
Scope
another region
my region
@@ -1000,7 +999,6 @@ Overwrite oldest non-favorite
1-character messages will be shown 1-character messages will be shown
All All
Allow Allow
-Apps that may send messages on the mesh. Anything they send goes out under your node name and cannot be told apart from a message you typed. Turn one off to take the permission back. Apps that may send messages on the mesh. Anything they send goes out under your node name and cannot be told apart from a message you typed. Turn one off to take the permission back.
Ignore 1-character messages Ignore 1-character messages
Join w/ password Join w/ password
Location no longer shared Location no longer shared
@@ -1014,9 +1012,7 @@ Never\nChosen contacts only\nAnyone who asks Never\nChosen contacts only\nAnyone
No apps installed. No apps installed.
No reply. Path hash is %u bytes;\nolder repeaters drop those.\nTry 1 byte (Settings > Radio & Mesh) No reply. Path hash is %u bytes;\nolder repeaters drop those.\nTry 1 byte (Settings > Radio & Mesh)
Not supported on this board Not supported on this board
-Read incoming messages Read incoming messages
Reconnected to %.40s Reconnected to %.40s
-Send messages as me Send messages as me
Sent only when that contact asks, encrypted to them. Nothing is broadcast. Needs "Answer telemetry requests" on. Pick contacts in a contact's menu. Sent only when that contact asks, encrypted to them. Nothing is broadcast. Needs "Answer telemetry requests" on. Pick contacts in a contact's menu.
Server has forgotten you.\nJoin with the room password. Server has forgotten you.\nJoin with the room password.
Share my loc Share my loc
@@ -1042,7 +1038,6 @@ App permissions App permissions
About / credits About / credits
Auth failed Auth failed
Auto-add on for %s — new %s land in Contacts automatically. Auto-add on for %s — new %s land in Contacts automatically.
-Batteryactivityon Batteryactivityon
Console mode Console mode
Console mode (experimental) Console mode (experimental)
Direct (0-hop) only Direct (0-hop) only
@@ -1051,8 +1046,6 @@ EXPERIMENTAL. Boots into a text console with no graphical interface: type comman
Enter text Enter text
Entering console mode - rebooting... Entering console mode - rebooting...
Favorite Favorite
-Geblokkeerde gebruikers Geblokkeerde gebruikers
-Hide system filesShow system files Hide system filesShow system files
How tiles work:\nThe map is built from 256×256 "slippy" tiles. Only the tiles for the area you're viewing are fetched — there is no bulk pre-download.\n\nBecause this device can't do HTTPS (not enough heap after Wi-Fi starts) and decodes JPEG far more cheaply than PNG, tiles come from the wadamesh proxy: it fetches the upstream PNG over HTTPS with an identifying User-Agent, re-encodes it as JPEG, and caches it. Your device then caches each tile to its own flash, so a tile is only downloaded once.\n\nUse Options → Reload tiles to re-download the tiles currently in view if one looks corrupted. How tiles work:\nThe map is built from 256×256 "slippy" tiles. Only the tiles for the area you're viewing are fetched — there is no bulk pre-download.\n\nBecause this device can't do HTTPS (not enough heap after Wi-Fi starts) and decodes JPEG far more cheaply than PNG, tiles come from the wadamesh proxy: it fetches the upstream PNG over HTTPS with an identifying User-Agent, re-encodes it as JPEG, and caches it. Your device then caches each tile to its own flash, so a tile is only downloaded once.\n\nUse Options → Reload tiles to re-download the tiles currently in view if one looks corrupted.
Init… Init…
Leaving console mode - rebooting... Leaving console mode - rebooting...
@@ -1062,8 +1055,6 @@ Map data © OpenStreetMap contributors.\nopenstreetmap.org/copyright\nLicensed u
Map style © OpenTopoMap (CC-BY-SA) — opentopomap.org\nMap data © OpenStreetMap contributors (ODbL) + SRTM.\n\n Map style © OpenTopoMap (CC-BY-SA) — opentopomap.org\nMap data © OpenStreetMap contributors (ODbL) + SRTM.\n\n
Name (A-Z) Name (A-Z)
Nearest first Nearest first
-Other networksNetworks Other networksNetworks
-Paste (move)Paste (copy) Paste (move)Paste (copy)
Recent message Recent message
Reload tiles in view Reload tiles in view
Repeaters Repeaters
@@ -1076,13 +1067,8 @@ Show contacts Show contacts
Show coordinates Show coordinates
Show tile z/x/y Show tile z/x/y
Starting… Starting…
-Stop sharing loc Share my loc Stop sharing loc Share my loc
-Supprimer Supprimer
-Switch to the external antenna?\n\nMake sure an antenna is actually connected to the external antenna socket first. Transmitting with nothing attached can damage the radio.\n\nResets to the on-board antenna on every reboot.Turn on legacy per-transmit switching?\n\nDiagnostic mode, for comparison only. It transmits on the on-board antenna and listens on the external one, so your outbound signal will be much weaker than your inbound.\n\nResets to the on-board antenna on every reboot. Switch to the external antenna?\n\nMake sure an antenna is actually connected to the external antenna socket first. Transmitting with nothing attached can damage the radio.\n\nResets to the on-board antenna on every reboot.Turn on legacy per-transmit switching?\n\nDiagnostic mode, for comparison only. It transmits on the on-board antenna and listens on the external one, so your outbound signal will be much weaker than your inbound.\n\nResets to the on-board antenna on every reboot.
Text console (experimental) Text console (experimental)
Tile debug overlay Tile debug overlay
-Unblock Block Unblock Block
-Unfav Favorite Unfav Favorite
chats chats
repeaters repeaters
rooms rooms
@@ -1098,3 +1084,26 @@ channel busy %.1f%% channel busy %.1f%%
sampling... sampling...
teal = now blue = average teal = now blue = average
window restarted window restarted
+Filter Filter
+Hide system files Hide system files
+Networks Networks
+Other networks Other networks
+Show system files Show system files
+Unfav Unfav
+Firmware beta_%d\nChecking for updates over Wi-Fi… Firmware beta_%d\nChecking for updates over Wi-Fi…
+Install beta_%d?\nThis downgrades the firmware and reboots. Install beta_%d?\nThis downgrades the firmware and reboots.
+Other (hidden) network… Other (hidden) network…
+Paused Paused
+Scan again Scan again
+At a glance At a glance
+At a glance disabled At a glance disabled
+At a glance enabled At a glance enabled
+At a glance while locked At a glance while locked
+Glance only shows when unlocked Glance only shows when unlocked
+Glance shows message previews\nwhile the screen is locked Glance shows message previews\nwhile the screen is locked
+Snake — swipe, roll, or press a direction Snake — swipe, roll, or press a direction
+N/A N/A
+This board cannot run this app. This board cannot run this app.
+Signal Signal
+Signal (stale) Signal (stale)
+nothing heard yet nothing heard yet
diff --git a/deploy/apps/lang/ru.lang b/deploy/apps/lang/ru.lang
index ceb1ec3..d8a02db 100644
--- a/deploy/apps/lang/ru.lang
+++ b/deploy/apps/lang/ru.lang
@@ -1,7 +1,7 @@
# wadamesh language file
# canonical source - translators: edit this file and PR it
# code: ru
-# ver: 18
+# ver: 19
# name: Русский
# base: ru
(device behind) (устройство отстаёт)
@@ -977,8 +977,6 @@ tap to exit full screen нажмите для выхода из полноэкр
zoom масштаб
zoom %d масштаб %d
~17 dB amp for quiet/remote areas; turn off in noisy spots. усилитель ~17 дБ для тихих/удалённых мест; выключайте в шумных.
-© OpenStreetMap © OpenStreetMap
-© OpenTopoMap © OpenTopoMap
Scope
another region
my region
@@ -1000,7 +998,6 @@ Overwrite oldest non-favorite
1-character messages will be shown 1-character messages will be shown
All All
Allow Allow
-Apps that may send messages on the mesh. Anything they send goes out under your node name and cannot be told apart from a message you typed. Turn one off to take the permission back. Apps that may send messages on the mesh. Anything they send goes out under your node name and cannot be told apart from a message you typed. Turn one off to take the permission back.
Ignore 1-character messages Ignore 1-character messages
Join w/ password Join w/ password
Location no longer shared Location no longer shared
@@ -1014,9 +1011,7 @@ Never\nChosen contacts only\nAnyone who asks Never\nChosen contacts only\nAnyone
No apps installed. No apps installed.
No reply. Path hash is %u bytes;\nolder repeaters drop those.\nTry 1 byte (Settings > Radio & Mesh) No reply. Path hash is %u bytes;\nolder repeaters drop those.\nTry 1 byte (Settings > Radio & Mesh)
Not supported on this board Not supported on this board
-Read incoming messages Read incoming messages
Reconnected to %.40s Reconnected to %.40s
-Send messages as me Send messages as me
Sent only when that contact asks, encrypted to them. Nothing is broadcast. Needs "Answer telemetry requests" on. Pick contacts in a contact's menu. Sent only when that contact asks, encrypted to them. Nothing is broadcast. Needs "Answer telemetry requests" on. Pick contacts in a contact's menu.
Server has forgotten you.\nJoin with the room password. Server has forgotten you.\nJoin with the room password.
Share my loc Share my loc
@@ -1042,7 +1037,6 @@ App permissions App permissions
About / credits About / credits
Auth failed Auth failed
Auto-add on for %s — new %s land in Contacts automatically. Auto-add on for %s — new %s land in Contacts automatically.
-Batteryactivityon Batteryactivityon
Console mode Console mode
Console mode (experimental) Console mode (experimental)
Direct (0-hop) only Direct (0-hop) only
@@ -1051,8 +1045,6 @@ EXPERIMENTAL. Boots into a text console with no graphical interface: type comman
Enter text Enter text
Entering console mode - rebooting... Entering console mode - rebooting...
Favorite Favorite
-Geblokkeerde gebruikers Geblokkeerde gebruikers
-Hide system filesShow system files Hide system filesShow system files
How tiles work:\nThe map is built from 256×256 "slippy" tiles. Only the tiles for the area you're viewing are fetched — there is no bulk pre-download.\n\nBecause this device can't do HTTPS (not enough heap after Wi-Fi starts) and decodes JPEG far more cheaply than PNG, tiles come from the wadamesh proxy: it fetches the upstream PNG over HTTPS with an identifying User-Agent, re-encodes it as JPEG, and caches it. Your device then caches each tile to its own flash, so a tile is only downloaded once.\n\nUse Options → Reload tiles to re-download the tiles currently in view if one looks corrupted. How tiles work:\nThe map is built from 256×256 "slippy" tiles. Only the tiles for the area you're viewing are fetched — there is no bulk pre-download.\n\nBecause this device can't do HTTPS (not enough heap after Wi-Fi starts) and decodes JPEG far more cheaply than PNG, tiles come from the wadamesh proxy: it fetches the upstream PNG over HTTPS with an identifying User-Agent, re-encodes it as JPEG, and caches it. Your device then caches each tile to its own flash, so a tile is only downloaded once.\n\nUse Options → Reload tiles to re-download the tiles currently in view if one looks corrupted.
Init… Init…
Leaving console mode - rebooting... Leaving console mode - rebooting...
@@ -1062,8 +1054,6 @@ Map data © OpenStreetMap contributors.\nopenstreetmap.org/copyright\nLicensed u
Map style © OpenTopoMap (CC-BY-SA) — opentopomap.org\nMap data © OpenStreetMap contributors (ODbL) + SRTM.\n\n Map style © OpenTopoMap (CC-BY-SA) — opentopomap.org\nMap data © OpenStreetMap contributors (ODbL) + SRTM.\n\n
Name (A-Z) Name (A-Z)
Nearest first Nearest first
-Other networksNetworks Other networksNetworks
-Paste (move)Paste (copy) Paste (move)Paste (copy)
Recent message Recent message
Reload tiles in view Reload tiles in view
Repeaters Repeaters
@@ -1076,13 +1066,8 @@ Show contacts Show contacts
Show coordinates Show coordinates
Show tile z/x/y Show tile z/x/y
Starting… Starting…
-Stop sharing loc Share my loc Stop sharing loc Share my loc
-Supprimer Supprimer
-Switch to the external antenna?\n\nMake sure an antenna is actually connected to the external antenna socket first. Transmitting with nothing attached can damage the radio.\n\nResets to the on-board antenna on every reboot.Turn on legacy per-transmit switching?\n\nDiagnostic mode, for comparison only. It transmits on the on-board antenna and listens on the external one, so your outbound signal will be much weaker than your inbound.\n\nResets to the on-board antenna on every reboot. Switch to the external antenna?\n\nMake sure an antenna is actually connected to the external antenna socket first. Transmitting with nothing attached can damage the radio.\n\nResets to the on-board antenna on every reboot.Turn on legacy per-transmit switching?\n\nDiagnostic mode, for comparison only. It transmits on the on-board antenna and listens on the external one, so your outbound signal will be much weaker than your inbound.\n\nResets to the on-board antenna on every reboot.
Text console (experimental) Text console (experimental)
Tile debug overlay Tile debug overlay
-Unblock Block Unblock Block
-Unfav Favorite Unfav Favorite
chats chats
repeaters repeaters
rooms rooms
@@ -1098,3 +1083,26 @@ channel busy %.1f%% channel busy %.1f%%
sampling... sampling...
teal = now blue = average teal = now blue = average
window restarted window restarted
+Filter Filter
+Hide system files Hide system files
+Networks Networks
+Other networks Other networks
+Show system files Show system files
+Unfav Unfav
+Firmware beta_%d\nChecking for updates over Wi-Fi… Firmware beta_%d\nChecking for updates over Wi-Fi…
+Install beta_%d?\nThis downgrades the firmware and reboots. Install beta_%d?\nThis downgrades the firmware and reboots.
+Other (hidden) network… Other (hidden) network…
+Paused Paused
+Scan again Scan again
+At a glance At a glance
+At a glance disabled At a glance disabled
+At a glance enabled At a glance enabled
+At a glance while locked At a glance while locked
+Glance only shows when unlocked Glance only shows when unlocked
+Glance shows message previews\nwhile the screen is locked Glance shows message previews\nwhile the screen is locked
+Snake — swipe, roll, or press a direction Snake — swipe, roll, or press a direction
+N/A N/A
+This board cannot run this app. This board cannot run this app.
+Signal Signal
+Signal (stale) Signal (stale)
+nothing heard yet nothing heard yet
diff --git a/deploy/apps/lang/sr.lang b/deploy/apps/lang/sr.lang
index 0aab3e1..b1d9f31 100644
--- a/deploy/apps/lang/sr.lang
+++ b/deploy/apps/lang/sr.lang
@@ -1,7 +1,7 @@
# wadamesh language file
# canonical source - translators: edit this file and PR it
# code: sr
-# ver: 18
+# ver: 19
# name: Српски
# base: sr
(device behind) (уређај касни)
@@ -826,7 +826,7 @@ TCP on TCP укљ
TOUCH BETA TOUCH BETA
TX TX
Tab hotkeys — tap a row, then press a key Пречице картице — додирни ред, па притисни тастер
-Tap Use to switch - the device reboots to apply. Додирните „Користи“ да промените — уређај ће се поново покренути.
+Tap Use to switch - the device reboots to apply. Додирните "Користи" да промените — уређај ће се поново покренути.
Tap Use to switch - the device reboots to apply. Language files live in /lang on the storage; edit them or add your own. Додирни «Користи» за промену - уређај се поново покреће. Датотеке језика су у /lang на меморији; уреди их или додај своје.
Tap a language; the device reboots to apply. Изаберите језик; уређај ће се рестартовати.
Taste the rainbow! Окуси дугу!
@@ -977,8 +977,6 @@ tap to exit full screen додирните за излаз из целог ек
zoom зум
zoom %d зум %d
~17 dB amp for quiet/remote areas; turn off in noisy spots. појачало ~17 dB за тиха/удаљена места; искључи у бучним.
-© OpenStreetMap © OpenStreetMap
-© OpenTopoMap © OpenTopoMap
Scope
another region
my region
@@ -1000,7 +998,6 @@ Overwrite oldest non-favorite
1-character messages will be shown 1-character messages will be shown
All All
Allow Allow
-Apps that may send messages on the mesh. Anything they send goes out under your node name and cannot be told apart from a message you typed. Turn one off to take the permission back. Apps that may send messages on the mesh. Anything they send goes out under your node name and cannot be told apart from a message you typed. Turn one off to take the permission back.
Ignore 1-character messages Ignore 1-character messages
Join w/ password Join w/ password
Location no longer shared Location no longer shared
@@ -1014,9 +1011,7 @@ Never\nChosen contacts only\nAnyone who asks Never\nChosen contacts only\nAnyone
No apps installed. No apps installed.
No reply. Path hash is %u bytes;\nolder repeaters drop those.\nTry 1 byte (Settings > Radio & Mesh) No reply. Path hash is %u bytes;\nolder repeaters drop those.\nTry 1 byte (Settings > Radio & Mesh)
Not supported on this board Not supported on this board
-Read incoming messages Read incoming messages
Reconnected to %.40s Reconnected to %.40s
-Send messages as me Send messages as me
Sent only when that contact asks, encrypted to them. Nothing is broadcast. Needs "Answer telemetry requests" on. Pick contacts in a contact's menu. Sent only when that contact asks, encrypted to them. Nothing is broadcast. Needs "Answer telemetry requests" on. Pick contacts in a contact's menu.
Server has forgotten you.\nJoin with the room password. Server has forgotten you.\nJoin with the room password.
Share my loc Share my loc
@@ -1042,7 +1037,6 @@ App permissions App permissions
About / credits About / credits
Auth failed Auth failed
Auto-add on for %s — new %s land in Contacts automatically. Auto-add on for %s — new %s land in Contacts automatically.
-Batteryactivityon Batteryactivityon
Console mode Console mode
Console mode (experimental) Console mode (experimental)
Direct (0-hop) only Direct (0-hop) only
@@ -1051,8 +1045,6 @@ EXPERIMENTAL. Boots into a text console with no graphical interface: type comman
Enter text Enter text
Entering console mode - rebooting... Entering console mode - rebooting...
Favorite Favorite
-Geblokkeerde gebruikers Geblokkeerde gebruikers
-Hide system filesShow system files Hide system filesShow system files
How tiles work:\nThe map is built from 256×256 "slippy" tiles. Only the tiles for the area you're viewing are fetched — there is no bulk pre-download.\n\nBecause this device can't do HTTPS (not enough heap after Wi-Fi starts) and decodes JPEG far more cheaply than PNG, tiles come from the wadamesh proxy: it fetches the upstream PNG over HTTPS with an identifying User-Agent, re-encodes it as JPEG, and caches it. Your device then caches each tile to its own flash, so a tile is only downloaded once.\n\nUse Options → Reload tiles to re-download the tiles currently in view if one looks corrupted. How tiles work:\nThe map is built from 256×256 "slippy" tiles. Only the tiles for the area you're viewing are fetched — there is no bulk pre-download.\n\nBecause this device can't do HTTPS (not enough heap after Wi-Fi starts) and decodes JPEG far more cheaply than PNG, tiles come from the wadamesh proxy: it fetches the upstream PNG over HTTPS with an identifying User-Agent, re-encodes it as JPEG, and caches it. Your device then caches each tile to its own flash, so a tile is only downloaded once.\n\nUse Options → Reload tiles to re-download the tiles currently in view if one looks corrupted.
Init… Init…
Leaving console mode - rebooting... Leaving console mode - rebooting...
@@ -1062,8 +1054,6 @@ Map data © OpenStreetMap contributors.\nopenstreetmap.org/copyright\nLicensed u
Map style © OpenTopoMap (CC-BY-SA) — opentopomap.org\nMap data © OpenStreetMap contributors (ODbL) + SRTM.\n\n Map style © OpenTopoMap (CC-BY-SA) — opentopomap.org\nMap data © OpenStreetMap contributors (ODbL) + SRTM.\n\n
Name (A-Z) Name (A-Z)
Nearest first Nearest first
-Other networksNetworks Other networksNetworks
-Paste (move)Paste (copy) Paste (move)Paste (copy)
Recent message Recent message
Reload tiles in view Reload tiles in view
Repeaters Repeaters
@@ -1076,13 +1066,8 @@ Show contacts Show contacts
Show coordinates Show coordinates
Show tile z/x/y Show tile z/x/y
Starting… Starting…
-Stop sharing loc Share my loc Stop sharing loc Share my loc
-Supprimer Supprimer
-Switch to the external antenna?\n\nMake sure an antenna is actually connected to the external antenna socket first. Transmitting with nothing attached can damage the radio.\n\nResets to the on-board antenna on every reboot.Turn on legacy per-transmit switching?\n\nDiagnostic mode, for comparison only. It transmits on the on-board antenna and listens on the external one, so your outbound signal will be much weaker than your inbound.\n\nResets to the on-board antenna on every reboot. Switch to the external antenna?\n\nMake sure an antenna is actually connected to the external antenna socket first. Transmitting with nothing attached can damage the radio.\n\nResets to the on-board antenna on every reboot.Turn on legacy per-transmit switching?\n\nDiagnostic mode, for comparison only. It transmits on the on-board antenna and listens on the external one, so your outbound signal will be much weaker than your inbound.\n\nResets to the on-board antenna on every reboot.
Text console (experimental) Text console (experimental)
Tile debug overlay Tile debug overlay
-Unblock Block Unblock Block
-Unfav Favorite Unfav Favorite
chats chats
repeaters repeaters
rooms rooms
@@ -1098,3 +1083,26 @@ channel busy %.1f%% channel busy %.1f%%
sampling... sampling...
teal = now blue = average teal = now blue = average
window restarted window restarted
+Filter Filter
+Hide system files Hide system files
+Networks Networks
+Other networks Other networks
+Show system files Show system files
+Unfav Unfav
+Firmware beta_%d\nChecking for updates over Wi-Fi… Firmware beta_%d\nChecking for updates over Wi-Fi…
+Install beta_%d?\nThis downgrades the firmware and reboots. Install beta_%d?\nThis downgrades the firmware and reboots.
+Other (hidden) network… Other (hidden) network…
+Paused Paused
+Scan again Scan again
+At a glance At a glance
+At a glance disabled At a glance disabled
+At a glance enabled At a glance enabled
+At a glance while locked At a glance while locked
+Glance only shows when unlocked Glance only shows when unlocked
+Glance shows message previews\nwhile the screen is locked Glance shows message previews\nwhile the screen is locked
+Snake — swipe, roll, or press a direction Snake — swipe, roll, or press a direction
+N/A N/A
+This board cannot run this app. This board cannot run this app.
+Signal Signal
+Signal (stale) Signal (stale)
+nothing heard yet nothing heard yet
diff --git a/deploy/apps/lang/uk.lang b/deploy/apps/lang/uk.lang
index 4ebd00f..4cba790 100644
--- a/deploy/apps/lang/uk.lang
+++ b/deploy/apps/lang/uk.lang
@@ -1,7 +1,7 @@
# wadamesh language file
# canonical source - translators: edit this file and PR it
# code: uk
-# ver: 18
+# ver: 19
# name: Українська
# base: uk
(device behind) (пристрій відстає)
@@ -977,8 +977,6 @@ tap to exit full screen торкніться, щоб вийти з повное
zoom масштаб
zoom %d масштаб %d
~17 dB amp for quiet/remote areas; turn off in noisy spots. підсилювач ~17 дБ для тихих/віддалених місць; вимикайте в галасливих.
-© OpenStreetMap © OpenStreetMap
-© OpenTopoMap © OpenTopoMap
Scope
another region
my region
@@ -1000,7 +998,6 @@ Overwrite oldest non-favorite
1-character messages will be shown 1-character messages will be shown
All All
Allow Allow
-Apps that may send messages on the mesh. Anything they send goes out under your node name and cannot be told apart from a message you typed. Turn one off to take the permission back. Apps that may send messages on the mesh. Anything they send goes out under your node name and cannot be told apart from a message you typed. Turn one off to take the permission back.
Ignore 1-character messages Ignore 1-character messages
Join w/ password Join w/ password
Location no longer shared Location no longer shared
@@ -1014,9 +1011,7 @@ Never\nChosen contacts only\nAnyone who asks Never\nChosen contacts only\nAnyone
No apps installed. No apps installed.
No reply. Path hash is %u bytes;\nolder repeaters drop those.\nTry 1 byte (Settings > Radio & Mesh) No reply. Path hash is %u bytes;\nolder repeaters drop those.\nTry 1 byte (Settings > Radio & Mesh)
Not supported on this board Not supported on this board
-Read incoming messages Read incoming messages
Reconnected to %.40s Reconnected to %.40s
-Send messages as me Send messages as me
Sent only when that contact asks, encrypted to them. Nothing is broadcast. Needs "Answer telemetry requests" on. Pick contacts in a contact's menu. Sent only when that contact asks, encrypted to them. Nothing is broadcast. Needs "Answer telemetry requests" on. Pick contacts in a contact's menu.
Server has forgotten you.\nJoin with the room password. Server has forgotten you.\nJoin with the room password.
Share my loc Share my loc
@@ -1042,7 +1037,6 @@ App permissions App permissions
About / credits About / credits
Auth failed Auth failed
Auto-add on for %s — new %s land in Contacts automatically. Auto-add on for %s — new %s land in Contacts automatically.
-Batteryactivityon Batteryactivityon
Console mode Console mode
Console mode (experimental) Console mode (experimental)
Direct (0-hop) only Direct (0-hop) only
@@ -1051,8 +1045,6 @@ EXPERIMENTAL. Boots into a text console with no graphical interface: type comman
Enter text Enter text
Entering console mode - rebooting... Entering console mode - rebooting...
Favorite Favorite
-Geblokkeerde gebruikers Geblokkeerde gebruikers
-Hide system filesShow system files Hide system filesShow system files
How tiles work:\nThe map is built from 256×256 "slippy" tiles. Only the tiles for the area you're viewing are fetched — there is no bulk pre-download.\n\nBecause this device can't do HTTPS (not enough heap after Wi-Fi starts) and decodes JPEG far more cheaply than PNG, tiles come from the wadamesh proxy: it fetches the upstream PNG over HTTPS with an identifying User-Agent, re-encodes it as JPEG, and caches it. Your device then caches each tile to its own flash, so a tile is only downloaded once.\n\nUse Options → Reload tiles to re-download the tiles currently in view if one looks corrupted. How tiles work:\nThe map is built from 256×256 "slippy" tiles. Only the tiles for the area you're viewing are fetched — there is no bulk pre-download.\n\nBecause this device can't do HTTPS (not enough heap after Wi-Fi starts) and decodes JPEG far more cheaply than PNG, tiles come from the wadamesh proxy: it fetches the upstream PNG over HTTPS with an identifying User-Agent, re-encodes it as JPEG, and caches it. Your device then caches each tile to its own flash, so a tile is only downloaded once.\n\nUse Options → Reload tiles to re-download the tiles currently in view if one looks corrupted.
Init… Init…
Leaving console mode - rebooting... Leaving console mode - rebooting...
@@ -1062,8 +1054,6 @@ Map data © OpenStreetMap contributors.\nopenstreetmap.org/copyright\nLicensed u
Map style © OpenTopoMap (CC-BY-SA) — opentopomap.org\nMap data © OpenStreetMap contributors (ODbL) + SRTM.\n\n Map style © OpenTopoMap (CC-BY-SA) — opentopomap.org\nMap data © OpenStreetMap contributors (ODbL) + SRTM.\n\n
Name (A-Z) Name (A-Z)
Nearest first Nearest first
-Other networksNetworks Other networksNetworks
-Paste (move)Paste (copy) Paste (move)Paste (copy)
Recent message Recent message
Reload tiles in view Reload tiles in view
Repeaters Repeaters
@@ -1076,13 +1066,8 @@ Show contacts Show contacts
Show coordinates Show coordinates
Show tile z/x/y Show tile z/x/y
Starting… Starting…
-Stop sharing loc Share my loc Stop sharing loc Share my loc
-Supprimer Supprimer
-Switch to the external antenna?\n\nMake sure an antenna is actually connected to the external antenna socket first. Transmitting with nothing attached can damage the radio.\n\nResets to the on-board antenna on every reboot.Turn on legacy per-transmit switching?\n\nDiagnostic mode, for comparison only. It transmits on the on-board antenna and listens on the external one, so your outbound signal will be much weaker than your inbound.\n\nResets to the on-board antenna on every reboot. Switch to the external antenna?\n\nMake sure an antenna is actually connected to the external antenna socket first. Transmitting with nothing attached can damage the radio.\n\nResets to the on-board antenna on every reboot.Turn on legacy per-transmit switching?\n\nDiagnostic mode, for comparison only. It transmits on the on-board antenna and listens on the external one, so your outbound signal will be much weaker than your inbound.\n\nResets to the on-board antenna on every reboot.
Text console (experimental) Text console (experimental)
Tile debug overlay Tile debug overlay
-Unblock Block Unblock Block
-Unfav Favorite Unfav Favorite
chats chats
repeaters repeaters
rooms rooms
@@ -1098,3 +1083,26 @@ channel busy %.1f%% channel busy %.1f%%
sampling... sampling...
teal = now blue = average teal = now blue = average
window restarted window restarted
+Filter Filter
+Hide system files Hide system files
+Networks Networks
+Other networks Other networks
+Show system files Show system files
+Unfav Unfav
+Firmware beta_%d\nChecking for updates over Wi-Fi… Firmware beta_%d\nChecking for updates over Wi-Fi…
+Install beta_%d?\nThis downgrades the firmware and reboots. Install beta_%d?\nThis downgrades the firmware and reboots.
+Other (hidden) network… Other (hidden) network…
+Paused Paused
+Scan again Scan again
+At a glance At a glance
+At a glance disabled At a glance disabled
+At a glance enabled At a glance enabled
+At a glance while locked At a glance while locked
+Glance only shows when unlocked Glance only shows when unlocked
+Glance shows message previews\nwhile the screen is locked Glance shows message previews\nwhile the screen is locked
+Snake — swipe, roll, or press a direction Snake — swipe, roll, or press a direction
+N/A N/A
+This board cannot run this app. This board cannot run this app.
+Signal Signal
+Signal (stale) Signal (stale)
+nothing heard yet nothing heard yet
diff --git a/deploy/apps/langs.json b/deploy/apps/langs.json
index 76bba5e..7467d82 100644
--- a/deploy/apps/langs.json
+++ b/deploy/apps/langs.json
@@ -3,67 +3,67 @@
{
"code": "bg",
"name": "Български",
- "ver": "18"
+ "ver": "19"
},
{
"code": "de",
"name": "Deutsch",
- "ver": "18"
+ "ver": "19"
},
{
"code": "el",
"name": "Ελληνικά",
- "ver": "18"
+ "ver": "19"
},
{
"code": "es",
"name": "Español",
- "ver": "18"
+ "ver": "19"
},
{
"code": "fr",
"name": "Français",
- "ver": "18"
+ "ver": "19"
},
{
"code": "hu",
"name": "Magyar",
- "ver": "18"
+ "ver": "19"
},
{
"code": "it",
"name": "Italiano",
- "ver": "18"
+ "ver": "19"
},
{
"code": "nl",
"name": "Nederlands",
- "ver": "18"
+ "ver": "19"
},
{
"code": "pt-br",
"name": "Português (BR)",
- "ver": "18"
+ "ver": "19"
},
{
"code": "ro",
"name": "Română",
- "ver": "18"
+ "ver": "19"
},
{
"code": "ru",
"name": "Русский",
- "ver": "18"
+ "ver": "19"
},
{
"code": "sr",
"name": "Српски",
- "ver": "18"
+ "ver": "19"
},
{
"code": "uk",
"name": "Українська",
- "ver": "18"
+ "ver": "19"
}
]
}
diff --git a/deploy/apps/sdktest/1.7/sdktest.json b/deploy/apps/sdktest/1.7/sdktest.json
new file mode 100644
index 0000000..aa4e24e
--- /dev/null
+++ b/deploy/apps/sdktest/1.7/sdktest.json
@@ -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."}
diff --git a/deploy/apps/sdktest/1.7/sdktest.lua b/deploy/apps/sdktest/1.7/sdktest.lua
new file mode 100644
index 0000000..94b64da
--- /dev/null
+++ b/deploy/apps/sdktest/1.7/sdktest.lua
@@ -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
diff --git a/deploy/apps/wardrive/1.1/wardrive.json b/deploy/apps/wardrive/1.1/wardrive.json
new file mode 100644
index 0000000..d467e18
--- /dev/null
+++ b/deploy/apps/wardrive/1.1/wardrive.json
@@ -0,0 +1 @@
+{"id":"wardrive","name":"Wardrive","ver":"1.1","desc":"LoRa coverage survey. Probes every 20s, logs each reply with GPS position, altitude and BOTH link directions to a CSV you can pull off the device. Reference app for wada.mesh.discover."}
\ No newline at end of file
diff --git a/deploy/apps/wardrive/1.1/wardrive.lua b/deploy/apps/wardrive/1.1/wardrive.lua
new file mode 100644
index 0000000..a2f8377
--- /dev/null
+++ b/deploy/apps/wardrive/1.1/wardrive.lua
@@ -0,0 +1,216 @@
+-- Wardrive (Lua) — a LoRa coverage survey that logs to a CSV you can pull off
+-- the device afterwards.
+--
+-- Reference app for the discovery half of the SDK. It works the way a survey
+-- has to work: it PROBES rather than listens. A probe is a zero-hop request
+-- that every node in earshot answers, so a reply proves the link works from
+-- exactly where you are standing. Listening only ever tells you what happened
+-- to transmit while you were there, which is a different and much weaker claim.
+--
+-- Each reply carries both directions of the link: how well we heard them, and
+-- how well they heard us. They are rarely equal, and the asymmetry is the point
+-- -- "I can hear the repeater but it cannot hear me" is not the same fact as
+-- "no coverage", and only a probe reveals it.
+local ui, sys, mesh, fs, timer = wada.ui, wada.sys, wada.mesh, wada.fs, wada.timer
+local C = ui.colors
+local app = {}
+
+local SWEEP_MS = 20000 -- above the 15 s floor the firmware enforces on probes
+local HARVEST_MS = 4000 -- replies land over the few seconds after a probe
+local TYPE = { [1]="chat", [2]="repeater", [3]="room", [4]="sensor" }
+
+local run, running, samples, sweeps, last_err = "run", false, 0, 0, nil
+local node_count = 0
+local phase, phase_at = "idle", 0
+local hdr, gps_lbl, stat_lbl, rows = nil, nil, nil, {}
+local nodes = {} -- pubkey -> { name, type, best, worst, seen }
+local pending = {} -- lines waiting on the 1 write/sec limit
+
+local function logname() return run .. ".csv" end
+
+-- Return at most max_bytes without splitting a UTF-8 sequence. The row's
+-- fixed-width columns are byte-budgeted, not character-budgeted; a raw
+-- string.sub(1, 14) cut Ouderkerk + sun + VS16 inside the final codepoint and
+-- left LVGL unable to advance through the label (#323, same class as #223).
+local function utf8_prefix_bytes(text, max_bytes)
+ local offset, last, length = 1, 0, #text
+ while offset <= length and offset <= max_bytes do
+ local first = text:byte(offset)
+ local width = first <= 0x7F and 1
+ or (first >= 0xC2 and first <= 0xDF and 2)
+ or (first >= 0xE0 and first <= 0xEF and 3)
+ or (first >= 0xF0 and first <= 0xF4 and 4) or 0
+ if width == 0 or offset + width - 1 > length or offset + width - 1 > max_bytes then break end
+ local valid = true
+ for i = 2, width do
+ local byte = text:byte(offset + i - 1)
+ if byte < 0x80 or byte > 0xBF then valid = false; break end
+ end
+ local second = width > 1 and text:byte(offset + 1) or 0
+ if (first == 0xE0 and second < 0xA0) or (first == 0xED and second > 0x9F) or
+ (first == 0xF0 and second < 0x90) or (first == 0xF4 and second > 0x8F) then valid = false end
+ if not valid then break end
+ last = offset + width - 1
+ offset = last + 1
+ end
+ return text:sub(1, last)
+end
+
+-- Lua here is built with 32-bit floats, so fix.lat is good to about a metre and
+-- no better. fix.lat_e6 is the same reading as an exact integer in
+-- micro-degrees, which is what belongs in a log: a survey you plot months later
+-- should not carry rounding the device never had.
+local HEADER = "epoch,lat_e6,lon_e6,alt_m,pubkey,name,type,rssi,snr,their_snr,hops"
+local wrote_header = false
+
+-- The filesystem allows one write a second. Sweeps produce a burst of rows, so
+-- they queue here and drain a chunk per tick instead of being dropped.
+local function flush()
+ if #pending == 0 then return end
+ local chunk = table.concat(pending, "\n") .. "\n"
+ if not wrote_header then chunk = HEADER .. "\n" .. chunk end
+ local ok = fs.append(logname(), chunk)
+ if ok then pending, wrote_header = {}, true end
+end
+
+local function record(fix, hit)
+ local key = hit.pubkey
+ local n = nodes[key]
+ if not n then
+ n = { name = hit.name or key, type = hit.type, best = hit.snr, worst = hit.snr, seen = 0 }
+ nodes[key] = n
+ node_count = node_count + 1
+ end
+ if hit.snr > n.best then n.best = hit.snr end
+ if hit.snr < n.worst then n.worst = hit.snr end
+ n.seen = n.seen + 1
+ if hit.name then n.name = hit.name end
+
+ samples = samples + 1
+ pending[#pending + 1] = string.format("%d,%d,%d,%d,%s,%s,%d,%d,%.2f,%.2f,%d",
+ fix.time or sys.epoch(), fix.lat_e6, fix.lon_e6, fix.alt_m or 0,
+ key, (hit.name or ""):gsub(",", " "), hit.type,
+ hit.rssi, hit.snr, hit.their_snr, hit.hops)
+end
+
+local function sweep()
+ local tag, err = mesh.discover() -- every node type
+ if not tag then last_err = err; return false end
+ last_err = nil
+ sweeps = sweeps + 1
+ return true
+end
+
+local function harvest()
+ local fix = sys.gps()
+ if not fix then
+ -- No fix means the sample cannot be placed, so it is discarded rather than
+ -- logged at 0,0. A survey file with phantom points at Null Island is worse
+ -- than a shorter one.
+ mesh.discover_clear()
+ return
+ end
+ for _, hit in ipairs(mesh.discovered()) do record(fix, hit) end
+ mesh.discover_clear() -- next sample must not inherit this one
+end
+
+local function redraw()
+ local fix = sys.gps()
+ if fix then
+ gps_lbl:set(string.format("%.5f, %.5f %dm %d sats", fix.lat, fix.lon, fix.alt_m or 0, fix.sats))
+ gps_lbl:color(C.good)
+ else
+ gps_lbl:set("waiting for a GPS fix - samples are discarded until then")
+ gps_lbl:color(C.bad)
+ end
+
+ local state = running and (phase == "probe" and "listening..." or "sweeping") or "stopped"
+ stat_lbl:set(string.format("%s | %s | %d sweeps, %d samples, %d nodes%s",
+ run, state, sweeps, samples, node_count,
+ last_err and (" [" .. last_err .. "]") or ""))
+ stat_lbl:color(last_err and C.bad or C.accent)
+
+ local list = {}
+ for key, n in pairs(nodes) do list[#list + 1] = { key = key, n = n } end
+ table.sort(list, function(a, b) return a.n.best > b.n.best end)
+ for i = 1, #rows do
+ local e = list[i]
+ if e then
+ rows[i]:set(string.format("%-14s %-8s best %5.1f worst %5.1f x%d",
+ utf8_prefix_bytes(e.n.name, 14), TYPE[e.n.type] or "?", e.n.best, e.n.worst, e.n.seen))
+ rows[i]:color(e.n.best > 0 and C.good or C.text)
+ else
+ rows[i]:set(i == 1 and "nothing has answered a probe yet" or "")
+ rows[i]:color(C.sub)
+ end
+ end
+end
+
+function app.on_open(w, h)
+ if not sys.caps().discover then
+ ui.label("This board does not carry the extended SDK,", 6, 8, 12, C.bad)
+ ui.label("so it cannot send discovery probes.", 6, 26, 12, C.bad)
+ return
+ end
+ ui.scroll(true)
+ local LH = ui.text_h(12)
+ local y = 4
+
+ hdr = ui.label("LoRa coverage survey", 4, y, 12, C.accent); hdr:width(w - 10); y = y + LH + 3
+ gps_lbl = ui.label("", 4, y, 12, C.sub); gps_lbl:width(w - 10); y = y + LH + 3
+ stat_lbl = ui.label("", 4, y, 12, C.text); stat_lbl:width(w - 10); y = y + LH * 2 + 5
+
+ local bw = math.min(96, (w - 20) // 3)
+ ui.button("Start", 4, y, bw, 32, function()
+ running = not running
+ if running then phase, phase_at = "idle", 0 end
+ sys.toast(running and "Survey running" or "Survey stopped", 1200)
+ end)
+ ui.button("Name", 8 + bw, y, bw, 32, function()
+ ui.input("Name this run", run, function(text)
+ if text then
+ run = text:gsub("[^%w%-_]", "_")
+ wrote_header = false -- a new file needs its own header row
+ sys.toast("Logging to " .. logname(), 1500)
+ end
+ end)
+ end)
+ ui.button("Reset", 12 + bw * 2, y, bw, 32, function()
+ nodes, samples, sweeps, pending, node_count = {}, 0, 0, {}, 0
+ mesh.discover_clear()
+ fs.remove(logname())
+ wrote_header = false
+ sys.toast("Cleared " .. logname(), 1200)
+ end)
+ y = y + 38
+
+ ui.label("strongest first", 4, y, 12, C.sub); y = y + LH + 2
+ for i = 1, 12 do
+ rows[i] = ui.label("", 4, y, 12, C.text); rows[i]:width(w - 10); y = y + LH + 2
+ end
+
+ redraw()
+ timer.every(1000)
+end
+
+function app.on_tick()
+ if running then
+ local now = sys.millis()
+ if phase == "idle" or (phase == "wait" and now - phase_at >= SWEEP_MS) then
+ -- A refused or rate-limited probe backs off a full sweep interval. Retrying
+ -- every tick would re-enter the permission path once a second for nothing.
+ phase, phase_at = sweep() and "probe" or "wait", now
+ elseif phase == "probe" and now - phase_at >= HARVEST_MS then
+ harvest()
+ phase = "wait" -- phase_at stays at the probe time, so sweeps stay on cadence
+ end
+ end
+ flush()
+ redraw()
+end
+
+function app.on_close()
+ flush() -- one last drain; anything queued would otherwise be lost
+end
+
+return app
diff --git a/deploy/site/sdk.html b/deploy/site/sdk.html
index 6e66ce6..0defa3b 100644
--- a/deploy/site/sdk.html
+++ b/deploy/site/sdk.html
@@ -141,6 +141,7 @@
wada.net
wada.store
wada.fs
+ wada.audio
wada.sd
wada.sys
wada.timer
@@ -237,6 +238,7 @@ return app
| wada.ui.button(text, x, y, w, h, fn) | A tappable button at x, y sized w × h; fn is called with no arguments. |
| wada.ui.text_w(text [, size]) | Rendered width of text in pixels, for the given size class. |
| wada.ui.text_lines(text, width [, size]) | How many lines text wraps to inside width. Counts explicit newlines too. Use this when you lay out your own rows. label:width() turns wrapping on, so a line longer than the screen becomes two while a fixed y step still advances by one, and the next row is drawn on top of it. This is the single most common way an app's layout goes wrong; it happened to the SDK Test app's channel list. |
+ | wada.ui.clear() | Remove every widget from the app's page, leaving the page itself. This is how you build an app with more than one screen: clear, then create the next screen's widgets in the same place you built the first. Any handle you still hold from before the clear is dead — calling a method on it does nothing rather than crashing, but it will not come back, so re-create and re-assign. Older firmware has no ui.clear: write local clear = ui.clear or function() end if you need to run on both. |
| wada.ui.list(x, y, w, h) | A scrollable list of selectable rows — the "pick one of N" widget. Methods: add(text [, fn]) returning the row index, set(i, text), color(i, c), select(i) (highlights and scrolls it into view), selected(), count(), clear(), pos(x, y). Rows are real buttons, so keyboard and trackball navigation walks them on touchless boards without you doing anything. |
| wada.ui.input(title, initial, cb) | A modal text field. cb(text) on OK, cb(nil) on cancel or on the dialog being closed any other way — exactly one call, always, so an app that disabled itself while waiting always gets to re-enable. Uses the firmware's own dialog and keyboard, so it behaves identically on a touchscreen, on the Tanmatsu's physical keyboard and on a trackball board. One prompt at a time. |
| wada.ui.canvas(w, h) | A drawing surface. See the canvas methods below. |
@@ -291,7 +293,8 @@ return app
wada.mesh
- | wada.mesh.contacts() | Array of {name, pubkey, type, ago_s, lat, lon, lat_e6, lon_e6}. pubkey is the first 4 bytes as 8 hex characters — the same short form the rest of the interface uses, and what lines a contact up with a discovery hit. |
+ | wada.mesh.contacts([offset], [limit]) | Array of {name, pubkey, type, ago_s, lat, lon, lat_e6, lon_e6}. pubkey is the first 4 bytes as 8 hex characters — the same short form the rest of the interface uses, and what lines a contact up with a discovery hit. Returns at most limit entries (default 100, maximum 250) starting at offset, because every entry is an eight-field table and building thousands in one call would exhaust the app heap. On a device with more contacts than that, page through them: ask for 100, then 100 from offset 100, and so on. |
+ | wada.mesh.contact_count() | How many contacts the device holds, so you can page through contacts() without calling it repeatedly to find where the list ends. |
| wada.mesh.rx_log() | Recent packets, newest first: {ago_ms, type, rssi, snr, hops, route, len}, plus whatever identity the frame actually carried — see below. The RF Monitor feed. |
| wada.mesh.stats() | {rssi, noise, rx_air_s, tx_air_s, rx_pkts, rx_err, tx_budget_ms, rx_events, rx_dropped, tx_pkts, freq, bw, sf, duty_pct}. |
| wada.mesh.self() | This node: {name, pubkey, lat, lon, lat_e6, lon_e6}. |
@@ -409,7 +412,7 @@ end
http:// only, both of them. On-device TLS is not workable at the heap these boards have left once Wi-Fi has associated, so there is no https to offer; put a proxy in front if you need it. One request in flight at a time per app.
The fetch is asynchronous and runs on the firmware's existing network worker, so it does not block the UI. Your callback runs on the UI thread once the body is in memory.
-
Plain HTTP only, and that is not an oversight. After Wi-Fi associates there is not enough free internal memory on the smaller boards for a TLS handshake — mbedTLS wants around 30 KB and roughly 5 KB is free. If you need an HTTPS source, put a small proxy in front of it, the way the map tiles do. Responses are capped (64 KB by default).
+
Plain HTTP only, and that is not an oversight. After Wi-Fi associates there is not enough free internal memory on the smaller boards for a TLS handshake — mbedTLS wants around 30 KB and roughly 5 KB is free. If you need an HTTPS source, put a small proxy in front of it, the way the map tiles do. Responses are capped (192 KB by default).
@@ -441,6 +444,24 @@ end
+
+ wada.audio audio
+
+
Asynchronous audio playback from the current app's private storage. A plain name such as track.wav resolves inside the same app folder as wada.fs, whether that folder lives on internal flash, SD, or SD_MMC. Check wada.sys.caps().audio and audio_wav before using it.
+
+ | Call | Returns / does |
+ | wada.audio.play(name) | Starts or replaces playback and returns true, or nil, error. Plain names use app-private storage. On boards with caps().audio_sd, sd:/Music/track.wav reads directly from the physical card. |
+ | wada.audio.pause() | Pauses an active track. Returns whether the command was accepted. |
+ | wada.audio.resume() | Resumes a paused track. Returns whether the command was accepted. |
+ | wada.audio.stop() | Stops the active track. Returns whether the command was accepted. |
+ | wada.audio.status() | {state, path, source, format, error}. State is stopped, playing, paused, ended, or error; error is present only after a playback failure. |
+
+
Supported formats are PCM WAV (16-bit mono or stereo at 8–48 kHz) and MPEG Layer III MP3, including CBR, VBR, and ID3-tagged files. Stereo is mixed to mono on the device. Check audio_wav or audio_mp3 rather than assuming a format exists on older firmware.
+
Playback follows the user's Sound switch and volume, never blocks the Lua callback, and stops automatically when the app closes. A second play() replaces the current track. Playlist ordering belongs to the app, so there are no ambiguous host-side next() or previous() calls.
+
Plain names follow the same sandbox rules as wada.fs. Explicit sd: paths follow wada.sd path validation. Expected errors include bad path, no storage, no sd, not found, busy, muted, and unsupported format.
+
+
+
wada.sd sd_list
@@ -466,7 +487,7 @@ end
| wada.sys.datetime() | {year, month, day, hour, min, sec, wday} in local time. wday is 0 for Sunday. |
| wada.sys.tr(s) | Translate s through the device's active language, using the same table the firmware's own interface uses. Returns s unchanged when there is no translation, so it is always safe to wrap a string. Add your keys to deploy/apps/lang/<code>.lang alongside the firmware's. Older firmware has no sys.tr: write local tr = sys.tr or function(x) return x end and call tr(). |
| wada.sys.beep() | Short beep on boards with a buzzer; silent elsewhere, and silent when the user has sound off. |
-
| wada.sys.caps() | {sdk_ext, keyboard, touch, sd, sd_list, compass, accel, discover, input, rx_identity, list, packets, sensors, map, measure}. Check sdk_ext before using anything marked ext below, and sd_list before using wada.sd. 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. |
+
| wada.sys.caps() | {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}. Check sdk_ext before using anything marked ext, sd_list before using wada.sd, and audio plus the format flag before using wada.audio. audio_sd means direct sd: paths are available; plain audio names use app storage and do not require it. |
| wada.sys.battery() ext | {mv, pct, charging}. |
| wada.sys.env() sensors | {temp_c, humidity, pressure_hpa, alt_m}, or nil. A field is present only when the hardware actually reported it, so you can tell "no humidity sensor" from "0% humidity". Gated on caps().sensors — does this board have the sensor rail — and not on sdk_ext, 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. |
| wada.sys.gps() ext | {lat, lon, lat_e6, lon_e6, sats, alt_m, time, speed_kmh, course}, or nil with no fix — which is the normal indoor case, so handle it. lat_e6/lon_e6 are exact micro-degrees; lat/lon are single-precision floats, so log the integers. alt_m is metres. time is satellite time, absent until the receiver has decoded the date. speed_kmh and course (degrees clockwise from north) appear only on boards whose GPS provider reports them, and course only while actually moving — a stationary receiver has no course, so it is absent rather than 0. Treat those last three as optional. |
@@ -587,7 +608,7 @@ end
Apps run in a restricted environment. These are removed: io, os, require, dofile, and loading new chunks at runtime. Available: math, string, table, and the usual pairs, ipairs, select, pcall, tostring, tonumber.
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 instruction budget on every callback for the same reason.
-
The extended calls are not on every board. Anything marked ext above — wada.fs, wada.mesh.send, wada.mesh.send_dm, wada.mesh.discover, sys.battery, sys.gps — needs a board with the memory to carry it, so the Heltec V4 keeps its RAM for the mesh instead. Call wada.sys.caps().sdk_ext and degrade gracefully rather than assuming. wada.sd additionally needs a physical card interface, reported by caps().sd_list.
+
The extended calls are not on every board. Anything marked ext above — wada.fs, wada.mesh.send, wada.mesh.send_dm, wada.mesh.discover, sys.battery, sys.gps — needs a board with the memory to carry it, so the Heltec V4 keeps its RAM for the mesh instead. Call wada.sys.caps().sdk_ext and degrade gracefully rather than assuming. wada.sd additionally needs a physical card interface, reported by caps().sd_list. wada.audio instead follows caps().audio: storage may be internal, but the device still needs a stream-capable speaker path.
Rate limits are part of the contract, not a rainy-day guard: wada.fs writes are about one per second with a 32 KB file cap, and wada.mesh.send has a 5 second floor and a 180-character limit. They return false, "too fast" rather than throwing, and hitting them is expected — handle it.
None of this makes a hostile app safe, which is why the catalog is curated. It makes an honest app that has a bug survivable: it gets closed, and the device keeps carrying traffic.
diff --git a/include/lv_conf.h b/include/lv_conf.h
index 4c330f7..8808621 100644
--- a/include/lv_conf.h
+++ b/include/lv_conf.h
@@ -61,6 +61,13 @@
#define LV_FONT_MONTSERRAT_20 1
#define LV_FONT_MONTSERRAT_24 1
#endif
+/* T-Deck-only, unrelated to the Large/Huge UI-scale block above: an experiment
+ * to shrink the "at a glance" notification's message body from 28px to 20px
+ * on this board only (see atGlanceEnsureFont() in UITask.cpp). Easy to revert
+ * by dropping this block + the T-Deck branch in atGlanceEnsureFont(). */
+#if defined(HAS_TDECK_GT911)
+#define LV_FONT_MONTSERRAT_20 1
+#endif
/* 28 px Montserrat for the boot splash title — keeps the rest of the UI on
* the smaller fonts so the .data cost stays modest. */
#define LV_FONT_MONTSERRAT_28 1
diff --git a/include/lv_conf_m9.h b/include/lv_conf_m9.h
new file mode 100644
index 0000000..db9237a
--- /dev/null
+++ b/include/lv_conf_m9.h
@@ -0,0 +1,25 @@
+#pragma once
+
+// M9-specific LVGL config wrapper.
+//
+// Why this exists:
+// - LVGL 8.4's lv_hal_indev.h unconditionally defines LV_INDEV_DEF_SCROLL_*.
+// - Our shared lv_conf defines LV_INDEV_DEF_SCROLL_LIMIT for touch tuning, and
+// M9's build flags define LV_INDEV_DEF_SCROLL_THROW. That combination emits
+// macro-redefinition warnings, even though M9 has no touch input path.
+//
+// For M9, include the shared project LV config, then drop touch-scroll tuning
+// macros so LVGL's keypad-only defaults can apply without warnings.
+#ifndef LV_CONF_H
+#define LV_CONF_H
+#endif
+
+#include "lv_conf.h"
+
+#ifdef LV_INDEV_DEF_SCROLL_LIMIT
+#undef LV_INDEV_DEF_SCROLL_LIMIT
+#endif
+
+#ifdef LV_INDEV_DEF_SCROLL_THROW
+#undef LV_INDEV_DEF_SCROLL_THROW
+#endif
diff --git a/lib/minimp3/LICENSE b/lib/minimp3/LICENSE
new file mode 100644
index 0000000..2c4afab
--- /dev/null
+++ b/lib/minimp3/LICENSE
@@ -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
+
+
diff --git a/lib/minimp3/README.md b/lib/minimp3/README.md
new file mode 100644
index 0000000..7fbdc38
--- /dev/null
+++ b/lib/minimp3/README.md
@@ -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.
diff --git a/lib/minimp3/minimp3.h b/lib/minimp3/minimp3.h
new file mode 100644
index 0000000..ffab423
--- /dev/null
+++ b/lib/minimp3/minimp3.h
@@ -0,0 +1,1872 @@
+#ifndef MINIMP3_H
+#define MINIMP3_H
+/*
+ https://github.com/lieff/minimp3
+ To the extent possible under law, the author(s) have dedicated all copyright and related and neighboring rights to this software to the public domain worldwide.
+ This software is distributed without any warranty.
+ See
.
+*/
+#include
+
+#define MINIMP3_MAX_SAMPLES_PER_FRAME (1152*2)
+
+typedef struct
+{
+ int frame_bytes, frame_offset, channels, hz, layer, bitrate_kbps;
+} mp3dec_frame_info_t;
+
+typedef struct
+{
+ float mdct_overlap[2][9*32], qmf_state[15*2*32];
+ int reserv, free_format_bytes;
+ unsigned char header[4], reserv_buf[511];
+} mp3dec_t;
+
+#ifdef __cplusplus
+extern "C" {
+#endif /* __cplusplus */
+
+void mp3dec_init(mp3dec_t *dec);
+#ifndef MINIMP3_FLOAT_OUTPUT
+typedef int16_t mp3d_sample_t;
+#else /* MINIMP3_FLOAT_OUTPUT */
+typedef float mp3d_sample_t;
+void mp3dec_f32_to_s16(const float *in, int16_t *out, int num_samples);
+#endif /* MINIMP3_FLOAT_OUTPUT */
+int mp3dec_decode_frame(mp3dec_t *dec, const uint8_t *mp3, int mp3_bytes, mp3d_sample_t *pcm, mp3dec_frame_info_t *info);
+
+#ifdef __cplusplus
+}
+#endif /* __cplusplus */
+
+#endif /* MINIMP3_H */
+#if defined(MINIMP3_IMPLEMENTATION) && !defined(_MINIMP3_IMPLEMENTATION_GUARD)
+#define _MINIMP3_IMPLEMENTATION_GUARD
+
+#include
+#include
+
+#define MAX_FREE_FORMAT_FRAME_SIZE 2304 /* more than ISO spec's */
+#ifndef MAX_FRAME_SYNC_MATCHES
+#define MAX_FRAME_SYNC_MATCHES 10
+#endif /* MAX_FRAME_SYNC_MATCHES */
+
+#define MAX_L3_FRAME_PAYLOAD_BYTES MAX_FREE_FORMAT_FRAME_SIZE /* MUST be >= 320000/8/32000*1152 = 1440 */
+
+#define MAX_BITRESERVOIR_BYTES 511
+#define SHORT_BLOCK_TYPE 2
+#define STOP_BLOCK_TYPE 3
+#define MODE_MONO 3
+#define MODE_JOINT_STEREO 1
+#define HDR_SIZE 4
+#define HDR_IS_MONO(h) (((h[3]) & 0xC0) == 0xC0)
+#define HDR_IS_MS_STEREO(h) (((h[3]) & 0xE0) == 0x60)
+#define HDR_IS_FREE_FORMAT(h) (((h[2]) & 0xF0) == 0)
+#define HDR_IS_CRC(h) (!((h[1]) & 1))
+#define HDR_TEST_PADDING(h) ((h[2]) & 0x2)
+#define HDR_TEST_MPEG1(h) ((h[1]) & 0x8)
+#define HDR_TEST_NOT_MPEG25(h) ((h[1]) & 0x10)
+#define HDR_TEST_I_STEREO(h) ((h[3]) & 0x10)
+#define HDR_TEST_MS_STEREO(h) ((h[3]) & 0x20)
+#define HDR_GET_STEREO_MODE(h) (((h[3]) >> 6) & 3)
+#define HDR_GET_STEREO_MODE_EXT(h) (((h[3]) >> 4) & 3)
+#define HDR_GET_LAYER(h) (((h[1]) >> 1) & 3)
+#define HDR_GET_BITRATE(h) ((h[2]) >> 4)
+#define HDR_GET_SAMPLE_RATE(h) (((h[2]) >> 2) & 3)
+#define HDR_GET_MY_SAMPLE_RATE(h) (HDR_GET_SAMPLE_RATE(h) + (((h[1] >> 3) & 1) + ((h[1] >> 4) & 1))*3)
+#define HDR_IS_FRAME_576(h) ((h[1] & 14) == 2)
+#define HDR_IS_LAYER_1(h) ((h[1] & 6) == 6)
+
+#define BITS_DEQUANTIZER_OUT -1
+#define MAX_SCF (255 + BITS_DEQUANTIZER_OUT*4 - 210)
+#define MAX_SCFI ((MAX_SCF + 3) & ~3)
+
+#define MINIMP3_MIN(a, b) ((a) > (b) ? (b) : (a))
+#define MINIMP3_MAX(a, b) ((a) < (b) ? (b) : (a))
+
+#if !defined(MINIMP3_NO_SIMD)
+
+#if !defined(MINIMP3_ONLY_SIMD) && (defined(_M_X64) || defined(__x86_64__) || defined(__aarch64__) || defined(_M_ARM64))
+/* x64 always have SSE2, arm64 always have neon, no need for generic code */
+#define MINIMP3_ONLY_SIMD
+#endif /* SIMD checks... */
+
+#if (defined(_MSC_VER) && (defined(_M_IX86) || defined(_M_X64))) || ((defined(__i386__) || defined(__x86_64__)) && defined(__SSE2__))
+#if defined(_MSC_VER)
+#include
+#endif /* defined(_MSC_VER) */
+#include
+#define HAVE_SSE 1
+#define HAVE_SIMD 1
+#define VSTORE _mm_storeu_ps
+#define VLD _mm_loadu_ps
+#define VSET _mm_set1_ps
+#define VADD _mm_add_ps
+#define VSUB _mm_sub_ps
+#define VMUL _mm_mul_ps
+#define VMAC(a, x, y) _mm_add_ps(a, _mm_mul_ps(x, y))
+#define VMSB(a, x, y) _mm_sub_ps(a, _mm_mul_ps(x, y))
+#define VMUL_S(x, s) _mm_mul_ps(x, _mm_set1_ps(s))
+#define VREV(x) _mm_shuffle_ps(x, x, _MM_SHUFFLE(0, 1, 2, 3))
+typedef __m128 f4;
+#if defined(_MSC_VER) || defined(MINIMP3_ONLY_SIMD)
+#define minimp3_cpuid __cpuid
+#else /* defined(_MSC_VER) || defined(MINIMP3_ONLY_SIMD) */
+static __inline__ __attribute__((always_inline)) void minimp3_cpuid(int CPUInfo[], const int InfoType)
+{
+#if defined(__PIC__)
+ __asm__ __volatile__(
+#if defined(__x86_64__)
+ "push %%rbx\n"
+ "cpuid\n"
+ "xchgl %%ebx, %1\n"
+ "pop %%rbx\n"
+#else /* defined(__x86_64__) */
+ "xchgl %%ebx, %1\n"
+ "cpuid\n"
+ "xchgl %%ebx, %1\n"
+#endif /* defined(__x86_64__) */
+ : "=a" (CPUInfo[0]), "=r" (CPUInfo[1]), "=c" (CPUInfo[2]), "=d" (CPUInfo[3])
+ : "a" (InfoType));
+#else /* defined(__PIC__) */
+ __asm__ __volatile__(
+ "cpuid"
+ : "=a" (CPUInfo[0]), "=b" (CPUInfo[1]), "=c" (CPUInfo[2]), "=d" (CPUInfo[3])
+ : "a" (InfoType));
+#endif /* defined(__PIC__)*/
+}
+#endif /* defined(_MSC_VER) || defined(MINIMP3_ONLY_SIMD) */
+static int have_simd(void)
+{
+#ifdef MINIMP3_ONLY_SIMD
+ return 1;
+#else /* MINIMP3_ONLY_SIMD */
+ static int g_have_simd;
+ int CPUInfo[4];
+#ifdef MINIMP3_TEST
+ static int g_counter;
+ if (g_counter++ > 100)
+ return 0;
+#endif /* MINIMP3_TEST */
+ if (g_have_simd)
+ goto end;
+ minimp3_cpuid(CPUInfo, 0);
+ g_have_simd = 1;
+ if (CPUInfo[0] > 0)
+ {
+ minimp3_cpuid(CPUInfo, 1);
+ g_have_simd = (CPUInfo[3] & (1 << 26)) + 1; /* SSE2 */
+ }
+end:
+ return g_have_simd - 1;
+#endif /* MINIMP3_ONLY_SIMD */
+}
+#elif defined(__ARM_NEON) || defined(__aarch64__) || defined(_M_ARM64)
+#include
+#define HAVE_SSE 0
+#define HAVE_SIMD 1
+#define VSTORE vst1q_f32
+#define VLD vld1q_f32
+#define VSET vmovq_n_f32
+#define VADD vaddq_f32
+#define VSUB vsubq_f32
+#define VMUL vmulq_f32
+#define VMAC(a, x, y) vmlaq_f32(a, x, y)
+#define VMSB(a, x, y) vmlsq_f32(a, x, y)
+#define VMUL_S(x, s) vmulq_f32(x, vmovq_n_f32(s))
+#define VREV(x) vcombine_f32(vget_high_f32(vrev64q_f32(x)), vget_low_f32(vrev64q_f32(x)))
+typedef float32x4_t f4;
+static int have_simd()
+{ /* TODO: detect neon for !MINIMP3_ONLY_SIMD */
+ return 1;
+}
+#else /* SIMD checks... */
+#define HAVE_SSE 0
+#define HAVE_SIMD 0
+#ifdef MINIMP3_ONLY_SIMD
+#error MINIMP3_ONLY_SIMD used, but SSE/NEON not enabled
+#endif /* MINIMP3_ONLY_SIMD */
+#endif /* SIMD checks... */
+#else /* !defined(MINIMP3_NO_SIMD) */
+#define HAVE_SIMD 0
+#endif /* !defined(MINIMP3_NO_SIMD) */
+
+#if defined(__ARM_ARCH) && (__ARM_ARCH >= 6) && !defined(__aarch64__) && !defined(_M_ARM64) && !defined(__ARM_ARCH_6M__)
+#define HAVE_ARMV6 1
+static __inline__ __attribute__((always_inline)) int32_t minimp3_clip_int16_arm(int32_t a)
+{
+ int32_t x = 0;
+ __asm__ ("ssat %0, #16, %1" : "=r"(x) : "r"(a));
+ return x;
+}
+#else
+#define HAVE_ARMV6 0
+#endif
+
+typedef struct
+{
+ const uint8_t *buf;
+ int pos, limit;
+} bs_t;
+
+typedef struct
+{
+ float scf[3*64];
+ uint8_t total_bands, stereo_bands, bitalloc[64], scfcod[64];
+} L12_scale_info;
+
+typedef struct
+{
+ uint8_t tab_offset, code_tab_width, band_count;
+} L12_subband_alloc_t;
+
+typedef struct
+{
+ const uint8_t *sfbtab;
+ uint16_t part_23_length, big_values, scalefac_compress;
+ uint8_t global_gain, block_type, mixed_block_flag, n_long_sfb, n_short_sfb;
+ uint8_t table_select[3], region_count[3], subblock_gain[3];
+ uint8_t preflag, scalefac_scale, count1_table, scfsi;
+} L3_gr_info_t;
+
+typedef struct
+{
+ bs_t bs;
+ uint8_t maindata[MAX_BITRESERVOIR_BYTES + MAX_L3_FRAME_PAYLOAD_BYTES];
+ L3_gr_info_t gr_info[4];
+ float grbuf[2][576], scf[40], syn[18 + 15][2*32];
+ uint8_t ist_pos[2][39];
+} mp3dec_scratch_t;
+
+static void bs_init(bs_t *bs, const uint8_t *data, int bytes)
+{
+ bs->buf = data;
+ bs->pos = 0;
+ bs->limit = bytes*8;
+}
+
+static uint32_t get_bits(bs_t *bs, int n)
+{
+ uint32_t next, cache = 0, s = bs->pos & 7;
+ int shl = n + s;
+ const uint8_t *p = bs->buf + (bs->pos >> 3);
+ if ((bs->pos += n) > bs->limit)
+ return 0;
+ next = *p++ & (255 >> s);
+ while ((shl -= 8) > 0)
+ {
+ cache |= next << shl;
+ next = *p++;
+ }
+ return cache | (next >> -shl);
+}
+
+static int hdr_valid(const uint8_t *h)
+{
+ return h[0] == 0xff &&
+ ((h[1] & 0xF0) == 0xf0 || (h[1] & 0xFE) == 0xe2) &&
+ (HDR_GET_LAYER(h) != 0) &&
+ (HDR_GET_BITRATE(h) != 15) &&
+ (HDR_GET_SAMPLE_RATE(h) != 3);
+}
+
+static int hdr_compare(const uint8_t *h1, const uint8_t *h2)
+{
+ return hdr_valid(h2) &&
+ ((h1[1] ^ h2[1]) & 0xFE) == 0 &&
+ ((h1[2] ^ h2[2]) & 0x0C) == 0 &&
+ !(HDR_IS_FREE_FORMAT(h1) ^ HDR_IS_FREE_FORMAT(h2));
+}
+
+static unsigned hdr_bitrate_kbps(const uint8_t *h)
+{
+ static const uint8_t halfrate[2][3][15] = {
+ { { 0,4,8,12,16,20,24,28,32,40,48,56,64,72,80 }, { 0,4,8,12,16,20,24,28,32,40,48,56,64,72,80 }, { 0,16,24,28,32,40,48,56,64,72,80,88,96,112,128 } },
+ { { 0,16,20,24,28,32,40,48,56,64,80,96,112,128,160 }, { 0,16,24,28,32,40,48,56,64,80,96,112,128,160,192 }, { 0,16,32,48,64,80,96,112,128,144,160,176,192,208,224 } },
+ };
+ return 2*halfrate[!!HDR_TEST_MPEG1(h)][HDR_GET_LAYER(h) - 1][HDR_GET_BITRATE(h)];
+}
+
+static unsigned hdr_sample_rate_hz(const uint8_t *h)
+{
+ static const unsigned g_hz[3] = { 44100, 48000, 32000 };
+ return g_hz[HDR_GET_SAMPLE_RATE(h)] >> (int)!HDR_TEST_MPEG1(h) >> (int)!HDR_TEST_NOT_MPEG25(h);
+}
+
+static unsigned hdr_frame_samples(const uint8_t *h)
+{
+ return HDR_IS_LAYER_1(h) ? 384 : (1152 >> (int)HDR_IS_FRAME_576(h));
+}
+
+static int hdr_frame_bytes(const uint8_t *h, int free_format_size)
+{
+ int frame_bytes = hdr_frame_samples(h)*hdr_bitrate_kbps(h)*125/hdr_sample_rate_hz(h);
+ if (HDR_IS_LAYER_1(h))
+ {
+ frame_bytes &= ~3; /* slot align */
+ }
+ return frame_bytes ? frame_bytes : free_format_size;
+}
+
+static int hdr_padding(const uint8_t *h)
+{
+ return HDR_TEST_PADDING(h) ? (HDR_IS_LAYER_1(h) ? 4 : 1) : 0;
+}
+
+#ifndef MINIMP3_ONLY_MP3
+static const L12_subband_alloc_t *L12_subband_alloc_table(const uint8_t *hdr, L12_scale_info *sci)
+{
+ const L12_subband_alloc_t *alloc;
+ int mode = HDR_GET_STEREO_MODE(hdr);
+ int nbands, stereo_bands = (mode == MODE_MONO) ? 0 : (mode == MODE_JOINT_STEREO) ? (HDR_GET_STEREO_MODE_EXT(hdr) << 2) + 4 : 32;
+
+ if (HDR_IS_LAYER_1(hdr))
+ {
+ static const L12_subband_alloc_t g_alloc_L1[] = { { 76, 4, 32 } };
+ alloc = g_alloc_L1;
+ nbands = 32;
+ } else if (!HDR_TEST_MPEG1(hdr))
+ {
+ static const L12_subband_alloc_t g_alloc_L2M2[] = { { 60, 4, 4 }, { 44, 3, 7 }, { 44, 2, 19 } };
+ alloc = g_alloc_L2M2;
+ nbands = 30;
+ } else
+ {
+ static const L12_subband_alloc_t g_alloc_L2M1[] = { { 0, 4, 3 }, { 16, 4, 8 }, { 32, 3, 12 }, { 40, 2, 7 } };
+ int sample_rate_idx = HDR_GET_SAMPLE_RATE(hdr);
+ unsigned kbps = hdr_bitrate_kbps(hdr) >> (int)(mode != MODE_MONO);
+ if (!kbps) /* free-format */
+ {
+ kbps = 192;
+ }
+
+ alloc = g_alloc_L2M1;
+ nbands = 27;
+ if (kbps < 56)
+ {
+ static const L12_subband_alloc_t g_alloc_L2M1_lowrate[] = { { 44, 4, 2 }, { 44, 3, 10 } };
+ alloc = g_alloc_L2M1_lowrate;
+ nbands = sample_rate_idx == 2 ? 12 : 8;
+ } else if (kbps >= 96 && sample_rate_idx != 1)
+ {
+ nbands = 30;
+ }
+ }
+
+ sci->total_bands = (uint8_t)nbands;
+ sci->stereo_bands = (uint8_t)MINIMP3_MIN(stereo_bands, nbands);
+
+ return alloc;
+}
+
+static void L12_read_scalefactors(bs_t *bs, uint8_t *pba, uint8_t *scfcod, int bands, float *scf)
+{
+ static const float g_deq_L12[18*3] = {
+#define DQ(x) 9.53674316e-07f/x, 7.56931807e-07f/x, 6.00777173e-07f/x
+ DQ(3),DQ(7),DQ(15),DQ(31),DQ(63),DQ(127),DQ(255),DQ(511),DQ(1023),DQ(2047),DQ(4095),DQ(8191),DQ(16383),DQ(32767),DQ(65535),DQ(3),DQ(5),DQ(9)
+ };
+ int i, m;
+ for (i = 0; i < bands; i++)
+ {
+ float s = 0;
+ int ba = *pba++;
+ int mask = ba ? 4 + ((19 >> scfcod[i]) & 3) : 0;
+ for (m = 4; m; m >>= 1)
+ {
+ if (mask & m)
+ {
+ int b = get_bits(bs, 6);
+ s = g_deq_L12[ba*3 - 6 + b % 3]*(1 << 21 >> b/3);
+ }
+ *scf++ = s;
+ }
+ }
+}
+
+static void L12_read_scale_info(const uint8_t *hdr, bs_t *bs, L12_scale_info *sci)
+{
+ static const uint8_t g_bitalloc_code_tab[] = {
+ 0,17, 3, 4, 5,6,7, 8,9,10,11,12,13,14,15,16,
+ 0,17,18, 3,19,4,5, 6,7, 8, 9,10,11,12,13,16,
+ 0,17,18, 3,19,4,5,16,
+ 0,17,18,16,
+ 0,17,18,19, 4,5,6, 7,8, 9,10,11,12,13,14,15,
+ 0,17,18, 3,19,4,5, 6,7, 8, 9,10,11,12,13,14,
+ 0, 2, 3, 4, 5,6,7, 8,9,10,11,12,13,14,15,16
+ };
+ const L12_subband_alloc_t *subband_alloc = L12_subband_alloc_table(hdr, sci);
+
+ int i, k = 0, ba_bits = 0;
+ const uint8_t *ba_code_tab = g_bitalloc_code_tab;
+
+ for (i = 0; i < sci->total_bands; i++)
+ {
+ uint8_t ba;
+ if (i == k)
+ {
+ k += subband_alloc->band_count;
+ ba_bits = subband_alloc->code_tab_width;
+ ba_code_tab = g_bitalloc_code_tab + subband_alloc->tab_offset;
+ subband_alloc++;
+ }
+ ba = ba_code_tab[get_bits(bs, ba_bits)];
+ sci->bitalloc[2*i] = ba;
+ if (i < sci->stereo_bands)
+ {
+ ba = ba_code_tab[get_bits(bs, ba_bits)];
+ }
+ sci->bitalloc[2*i + 1] = sci->stereo_bands ? ba : 0;
+ }
+
+ for (i = 0; i < 2*sci->total_bands; i++)
+ {
+ sci->scfcod[i] = sci->bitalloc[i] ? HDR_IS_LAYER_1(hdr) ? 2 : get_bits(bs, 2) : 6;
+ }
+
+ L12_read_scalefactors(bs, sci->bitalloc, sci->scfcod, sci->total_bands*2, sci->scf);
+
+ for (i = sci->stereo_bands; i < sci->total_bands; i++)
+ {
+ sci->bitalloc[2*i + 1] = 0;
+ }
+}
+
+static int L12_dequantize_granule(float *grbuf, bs_t *bs, L12_scale_info *sci, int group_size)
+{
+ int i, j, k, choff = 576;
+ for (j = 0; j < 4; j++)
+ {
+ float *dst = grbuf + group_size*j;
+ for (i = 0; i < 2*sci->total_bands; i++)
+ {
+ int ba = sci->bitalloc[i];
+ if (ba != 0)
+ {
+ if (ba < 17)
+ {
+ int half = (1 << (ba - 1)) - 1;
+ for (k = 0; k < group_size; k++)
+ {
+ dst[k] = (float)((int)get_bits(bs, ba) - half);
+ }
+ } else
+ {
+ unsigned mod = (2 << (ba - 17)) + 1; /* 3, 5, 9 */
+ unsigned code = get_bits(bs, mod + 2 - (mod >> 3)); /* 5, 7, 10 */
+ for (k = 0; k < group_size; k++, code /= mod)
+ {
+ dst[k] = (float)((int)(code % mod - mod/2));
+ }
+ }
+ }
+ dst += choff;
+ choff = 18 - choff;
+ }
+ }
+ return group_size*4;
+}
+
+static void L12_apply_scf_384(L12_scale_info *sci, const float *scf, float *dst)
+{
+ int i, k;
+ memcpy(dst + 576 + sci->stereo_bands*18, dst + sci->stereo_bands*18, (sci->total_bands - sci->stereo_bands)*18*sizeof(float));
+ for (i = 0; i < sci->total_bands; i++, dst += 18, scf += 6)
+ {
+ for (k = 0; k < 12; k++)
+ {
+ dst[k + 0] *= scf[0];
+ dst[k + 576] *= scf[3];
+ }
+ }
+}
+#endif /* MINIMP3_ONLY_MP3 */
+
+static int L3_read_side_info(bs_t *bs, L3_gr_info_t *gr, const uint8_t *hdr)
+{
+ static const uint8_t g_scf_long[8][23] = {
+ { 6,6,6,6,6,6,8,10,12,14,16,20,24,28,32,38,46,52,60,68,58,54,0 },
+ { 12,12,12,12,12,12,16,20,24,28,32,40,48,56,64,76,90,2,2,2,2,2,0 },
+ { 6,6,6,6,6,6,8,10,12,14,16,20,24,28,32,38,46,52,60,68,58,54,0 },
+ { 6,6,6,6,6,6,8,10,12,14,16,18,22,26,32,38,46,54,62,70,76,36,0 },
+ { 6,6,6,6,6,6,8,10,12,14,16,20,24,28,32,38,46,52,60,68,58,54,0 },
+ { 4,4,4,4,4,4,6,6,8,8,10,12,16,20,24,28,34,42,50,54,76,158,0 },
+ { 4,4,4,4,4,4,6,6,6,8,10,12,16,18,22,28,34,40,46,54,54,192,0 },
+ { 4,4,4,4,4,4,6,6,8,10,12,16,20,24,30,38,46,56,68,84,102,26,0 }
+ };
+ static const uint8_t g_scf_short[8][40] = {
+ { 4,4,4,4,4,4,4,4,4,6,6,6,8,8,8,10,10,10,12,12,12,14,14,14,18,18,18,24,24,24,30,30,30,40,40,40,18,18,18,0 },
+ { 8,8,8,8,8,8,8,8,8,12,12,12,16,16,16,20,20,20,24,24,24,28,28,28,36,36,36,2,2,2,2,2,2,2,2,2,26,26,26,0 },
+ { 4,4,4,4,4,4,4,4,4,6,6,6,6,6,6,8,8,8,10,10,10,14,14,14,18,18,18,26,26,26,32,32,32,42,42,42,18,18,18,0 },
+ { 4,4,4,4,4,4,4,4,4,6,6,6,8,8,8,10,10,10,12,12,12,14,14,14,18,18,18,24,24,24,32,32,32,44,44,44,12,12,12,0 },
+ { 4,4,4,4,4,4,4,4,4,6,6,6,8,8,8,10,10,10,12,12,12,14,14,14,18,18,18,24,24,24,30,30,30,40,40,40,18,18,18,0 },
+ { 4,4,4,4,4,4,4,4,4,4,4,4,6,6,6,8,8,8,10,10,10,12,12,12,14,14,14,18,18,18,22,22,22,30,30,30,56,56,56,0 },
+ { 4,4,4,4,4,4,4,4,4,4,4,4,6,6,6,6,6,6,10,10,10,12,12,12,14,14,14,16,16,16,20,20,20,26,26,26,66,66,66,0 },
+ { 4,4,4,4,4,4,4,4,4,4,4,4,6,6,6,8,8,8,12,12,12,16,16,16,20,20,20,26,26,26,34,34,34,42,42,42,12,12,12,0 }
+ };
+ static const uint8_t g_scf_mixed[8][40] = {
+ { 6,6,6,6,6,6,6,6,6,8,8,8,10,10,10,12,12,12,14,14,14,18,18,18,24,24,24,30,30,30,40,40,40,18,18,18,0 },
+ { 12,12,12,4,4,4,8,8,8,12,12,12,16,16,16,20,20,20,24,24,24,28,28,28,36,36,36,2,2,2,2,2,2,2,2,2,26,26,26,0 },
+ { 6,6,6,6,6,6,6,6,6,6,6,6,8,8,8,10,10,10,14,14,14,18,18,18,26,26,26,32,32,32,42,42,42,18,18,18,0 },
+ { 6,6,6,6,6,6,6,6,6,8,8,8,10,10,10,12,12,12,14,14,14,18,18,18,24,24,24,32,32,32,44,44,44,12,12,12,0 },
+ { 6,6,6,6,6,6,6,6,6,8,8,8,10,10,10,12,12,12,14,14,14,18,18,18,24,24,24,30,30,30,40,40,40,18,18,18,0 },
+ { 4,4,4,4,4,4,6,6,4,4,4,6,6,6,8,8,8,10,10,10,12,12,12,14,14,14,18,18,18,22,22,22,30,30,30,56,56,56,0 },
+ { 4,4,4,4,4,4,6,6,4,4,4,6,6,6,6,6,6,10,10,10,12,12,12,14,14,14,16,16,16,20,20,20,26,26,26,66,66,66,0 },
+ { 4,4,4,4,4,4,6,6,4,4,4,6,6,6,8,8,8,12,12,12,16,16,16,20,20,20,26,26,26,34,34,34,42,42,42,12,12,12,0 }
+ };
+
+ unsigned tables, scfsi = 0;
+ int main_data_begin, part_23_sum = 0;
+ int sr_idx = HDR_GET_MY_SAMPLE_RATE(hdr); sr_idx -= (sr_idx != 0);
+ int gr_count = HDR_IS_MONO(hdr) ? 1 : 2;
+
+ if (HDR_TEST_MPEG1(hdr))
+ {
+ gr_count *= 2;
+ main_data_begin = get_bits(bs, 9);
+ scfsi = get_bits(bs, 7 + gr_count);
+ } else
+ {
+ main_data_begin = get_bits(bs, 8 + gr_count) >> gr_count;
+ }
+
+ do
+ {
+ if (HDR_IS_MONO(hdr))
+ {
+ scfsi <<= 4;
+ }
+ gr->part_23_length = (uint16_t)get_bits(bs, 12);
+ part_23_sum += gr->part_23_length;
+ gr->big_values = (uint16_t)get_bits(bs, 9);
+ if (gr->big_values > 288)
+ {
+ return -1;
+ }
+ gr->global_gain = (uint8_t)get_bits(bs, 8);
+ gr->scalefac_compress = (uint16_t)get_bits(bs, HDR_TEST_MPEG1(hdr) ? 4 : 9);
+ gr->sfbtab = g_scf_long[sr_idx];
+ gr->n_long_sfb = 22;
+ gr->n_short_sfb = 0;
+ if (get_bits(bs, 1))
+ {
+ gr->block_type = (uint8_t)get_bits(bs, 2);
+ if (!gr->block_type)
+ {
+ return -1;
+ }
+ gr->mixed_block_flag = (uint8_t)get_bits(bs, 1);
+ gr->region_count[0] = 7;
+ gr->region_count[1] = 255;
+ if (gr->block_type == SHORT_BLOCK_TYPE)
+ {
+ scfsi &= 0x0F0F;
+ if (!gr->mixed_block_flag)
+ {
+ gr->region_count[0] = 8;
+ gr->sfbtab = g_scf_short[sr_idx];
+ gr->n_long_sfb = 0;
+ gr->n_short_sfb = 39;
+ } else
+ {
+ gr->sfbtab = g_scf_mixed[sr_idx];
+ gr->n_long_sfb = HDR_TEST_MPEG1(hdr) ? 8 : 6;
+ gr->n_short_sfb = 30;
+ }
+ }
+ tables = get_bits(bs, 10);
+ tables <<= 5;
+ gr->subblock_gain[0] = (uint8_t)get_bits(bs, 3);
+ gr->subblock_gain[1] = (uint8_t)get_bits(bs, 3);
+ gr->subblock_gain[2] = (uint8_t)get_bits(bs, 3);
+ } else
+ {
+ gr->block_type = 0;
+ gr->mixed_block_flag = 0;
+ tables = get_bits(bs, 15);
+ gr->region_count[0] = (uint8_t)get_bits(bs, 4);
+ gr->region_count[1] = (uint8_t)get_bits(bs, 3);
+ gr->region_count[2] = 255;
+ }
+ gr->table_select[0] = (uint8_t)(tables >> 10);
+ gr->table_select[1] = (uint8_t)((tables >> 5) & 31);
+ gr->table_select[2] = (uint8_t)((tables) & 31);
+ gr->preflag = HDR_TEST_MPEG1(hdr) ? get_bits(bs, 1) : (gr->scalefac_compress >= 500);
+ gr->scalefac_scale = (uint8_t)get_bits(bs, 1);
+ gr->count1_table = (uint8_t)get_bits(bs, 1);
+ gr->scfsi = (uint8_t)((scfsi >> 12) & 15);
+ scfsi <<= 4;
+ gr++;
+ } while(--gr_count);
+
+ if (part_23_sum + bs->pos > bs->limit + main_data_begin*8)
+ {
+ return -1;
+ }
+
+ return main_data_begin;
+}
+
+static void L3_read_scalefactors(uint8_t *scf, uint8_t *ist_pos, const uint8_t *scf_size, const uint8_t *scf_count, bs_t *bitbuf, int scfsi)
+{
+ int i, k;
+ for (i = 0; i < 4 && scf_count[i]; i++, scfsi *= 2)
+ {
+ int cnt = scf_count[i];
+ if (scfsi & 8)
+ {
+ memcpy(scf, ist_pos, cnt);
+ } else
+ {
+ int bits = scf_size[i];
+ if (!bits)
+ {
+ memset(scf, 0, cnt);
+ memset(ist_pos, 0, cnt);
+ } else
+ {
+ int max_scf = (scfsi < 0) ? (1 << bits) - 1 : -1;
+ for (k = 0; k < cnt; k++)
+ {
+ int s = get_bits(bitbuf, bits);
+ ist_pos[k] = (s == max_scf ? -1 : s);
+ scf[k] = s;
+ }
+ }
+ }
+ ist_pos += cnt;
+ scf += cnt;
+ }
+ scf[0] = scf[1] = scf[2] = 0;
+}
+
+static float L3_ldexp_q2(float y, int exp_q2)
+{
+ static const float g_expfrac[4] = { 9.31322575e-10f,7.83145814e-10f,6.58544508e-10f,5.53767716e-10f };
+ int e;
+ do
+ {
+ e = MINIMP3_MIN(30*4, exp_q2);
+ y *= g_expfrac[e & 3]*(1 << 30 >> (e >> 2));
+ } while ((exp_q2 -= e) > 0);
+ return y;
+}
+
+static void L3_decode_scalefactors(const uint8_t *hdr, uint8_t *ist_pos, bs_t *bs, const L3_gr_info_t *gr, float *scf, int ch)
+{
+ static const uint8_t g_scf_partitions[3][28] = {
+ { 6,5,5, 5,6,5,5,5,6,5, 7,3,11,10,0,0, 7, 7, 7,0, 6, 6,6,3, 8, 8,5,0 },
+ { 8,9,6,12,6,9,9,9,6,9,12,6,15,18,0,0, 6,15,12,0, 6,12,9,6, 6,18,9,0 },
+ { 9,9,6,12,9,9,9,9,9,9,12,6,18,18,0,0,12,12,12,0,12, 9,9,6,15,12,9,0 }
+ };
+ const uint8_t *scf_partition = g_scf_partitions[!!gr->n_short_sfb + !gr->n_long_sfb];
+ uint8_t scf_size[4], iscf[40];
+ int i, scf_shift = gr->scalefac_scale + 1, gain_exp, scfsi = gr->scfsi;
+ float gain;
+
+ if (HDR_TEST_MPEG1(hdr))
+ {
+ static const uint8_t g_scfc_decode[16] = { 0,1,2,3, 12,5,6,7, 9,10,11,13, 14,15,18,19 };
+ int part = g_scfc_decode[gr->scalefac_compress];
+ scf_size[1] = scf_size[0] = (uint8_t)(part >> 2);
+ scf_size[3] = scf_size[2] = (uint8_t)(part & 3);
+ } else
+ {
+ static const uint8_t g_mod[6*4] = { 5,5,4,4,5,5,4,1,4,3,1,1,5,6,6,1,4,4,4,1,4,3,1,1 };
+ int k, modprod, sfc, ist = HDR_TEST_I_STEREO(hdr) && ch;
+ sfc = gr->scalefac_compress >> ist;
+ for (k = ist*3*4; sfc >= 0; sfc -= modprod, k += 4)
+ {
+ for (modprod = 1, i = 3; i >= 0; i--)
+ {
+ scf_size[i] = (uint8_t)(sfc / modprod % g_mod[k + i]);
+ modprod *= g_mod[k + i];
+ }
+ }
+ scf_partition += k;
+ scfsi = -16;
+ }
+ L3_read_scalefactors(iscf, ist_pos, scf_size, scf_partition, bs, scfsi);
+
+ if (gr->n_short_sfb)
+ {
+ int sh = 3 - scf_shift;
+ for (i = 0; i < gr->n_short_sfb; i += 3)
+ {
+ iscf[gr->n_long_sfb + i + 0] += gr->subblock_gain[0] << sh;
+ iscf[gr->n_long_sfb + i + 1] += gr->subblock_gain[1] << sh;
+ iscf[gr->n_long_sfb + i + 2] += gr->subblock_gain[2] << sh;
+ }
+ } else if (gr->preflag)
+ {
+ static const uint8_t g_preamp[10] = { 1,1,1,1,2,2,3,3,3,2 };
+ for (i = 0; i < 10; i++)
+ {
+ iscf[11 + i] += g_preamp[i];
+ }
+ }
+
+ gain_exp = gr->global_gain + BITS_DEQUANTIZER_OUT*4 - 210 - (HDR_IS_MS_STEREO(hdr) ? 2 : 0);
+ gain = L3_ldexp_q2(1 << (MAX_SCFI/4), MAX_SCFI - gain_exp);
+ for (i = 0; i < (int)(gr->n_long_sfb + gr->n_short_sfb); i++)
+ {
+ scf[i] = L3_ldexp_q2(gain, iscf[i] << scf_shift);
+ }
+}
+
+static const float g_pow43[129 + 16] = {
+ 0,-1,-2.519842f,-4.326749f,-6.349604f,-8.549880f,-10.902724f,-13.390518f,-16.000000f,-18.720754f,-21.544347f,-24.463781f,-27.473142f,-30.567351f,-33.741992f,-36.993181f,
+ 0,1,2.519842f,4.326749f,6.349604f,8.549880f,10.902724f,13.390518f,16.000000f,18.720754f,21.544347f,24.463781f,27.473142f,30.567351f,33.741992f,36.993181f,40.317474f,43.711787f,47.173345f,50.699631f,54.288352f,57.937408f,61.644865f,65.408941f,69.227979f,73.100443f,77.024898f,81.000000f,85.024491f,89.097188f,93.216975f,97.382800f,101.593667f,105.848633f,110.146801f,114.487321f,118.869381f,123.292209f,127.755065f,132.257246f,136.798076f,141.376907f,145.993119f,150.646117f,155.335327f,160.060199f,164.820202f,169.614826f,174.443577f,179.305980f,184.201575f,189.129918f,194.090580f,199.083145f,204.107210f,209.162385f,214.248292f,219.364564f,224.510845f,229.686789f,234.892058f,240.126328f,245.389280f,250.680604f,256.000000f,261.347174f,266.721841f,272.123723f,277.552547f,283.008049f,288.489971f,293.998060f,299.532071f,305.091761f,310.676898f,316.287249f,321.922592f,327.582707f,333.267377f,338.976394f,344.709550f,350.466646f,356.247482f,362.051866f,367.879608f,373.730522f,379.604427f,385.501143f,391.420496f,397.362314f,403.326427f,409.312672f,415.320884f,421.350905f,427.402579f,433.475750f,439.570269f,445.685987f,451.822757f,457.980436f,464.158883f,470.357960f,476.577530f,482.817459f,489.077615f,495.357868f,501.658090f,507.978156f,514.317941f,520.677324f,527.056184f,533.454404f,539.871867f,546.308458f,552.764065f,559.238575f,565.731879f,572.243870f,578.774440f,585.323483f,591.890898f,598.476581f,605.080431f,611.702349f,618.342238f,625.000000f,631.675540f,638.368763f,645.079578f
+};
+
+static float L3_pow_43(int x)
+{
+ float frac;
+ int sign, mult = 256;
+
+ if (x < 129)
+ {
+ return g_pow43[16 + x];
+ }
+
+ if (x < 1024)
+ {
+ mult = 16;
+ x <<= 3;
+ }
+
+ sign = 2*x & 64;
+ frac = (float)((x & 63) - sign) / ((x & ~63) + sign);
+ return g_pow43[16 + ((x + sign) >> 6)]*(1.f + frac*((4.f/3) + frac*(2.f/9)))*mult;
+}
+
+static void L3_huffman(float *dst, bs_t *bs, const L3_gr_info_t *gr_info, const float *scf, int layer3gr_limit)
+{
+ static const int16_t tabs[] = { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
+ 785,785,785,785,784,784,784,784,513,513,513,513,513,513,513,513,256,256,256,256,256,256,256,256,256,256,256,256,256,256,256,256,
+ -255,1313,1298,1282,785,785,785,785,784,784,784,784,769,769,769,769,256,256,256,256,256,256,256,256,256,256,256,256,256,256,256,256,290,288,
+ -255,1313,1298,1282,769,769,769,769,529,529,529,529,529,529,529,529,528,528,528,528,528,528,528,528,512,512,512,512,512,512,512,512,290,288,
+ -253,-318,-351,-367,785,785,785,785,784,784,784,784,769,769,769,769,256,256,256,256,256,256,256,256,256,256,256,256,256,256,256,256,819,818,547,547,275,275,275,275,561,560,515,546,289,274,288,258,
+ -254,-287,1329,1299,1314,1312,1057,1057,1042,1042,1026,1026,784,784,784,784,529,529,529,529,529,529,529,529,769,769,769,769,768,768,768,768,563,560,306,306,291,259,
+ -252,-413,-477,-542,1298,-575,1041,1041,784,784,784,784,769,769,769,769,256,256,256,256,256,256,256,256,256,256,256,256,256,256,256,256,-383,-399,1107,1092,1106,1061,849,849,789,789,1104,1091,773,773,1076,1075,341,340,325,309,834,804,577,577,532,532,516,516,832,818,803,816,561,561,531,531,515,546,289,289,288,258,
+ -252,-429,-493,-559,1057,1057,1042,1042,529,529,529,529,529,529,529,529,784,784,784,784,769,769,769,769,512,512,512,512,512,512,512,512,-382,1077,-415,1106,1061,1104,849,849,789,789,1091,1076,1029,1075,834,834,597,581,340,340,339,324,804,833,532,532,832,772,818,803,817,787,816,771,290,290,290,290,288,258,
+ -253,-349,-414,-447,-463,1329,1299,-479,1314,1312,1057,1057,1042,1042,1026,1026,785,785,785,785,784,784,784,784,769,769,769,769,768,768,768,768,-319,851,821,-335,836,850,805,849,341,340,325,336,533,533,579,579,564,564,773,832,578,548,563,516,321,276,306,291,304,259,
+ -251,-572,-733,-830,-863,-879,1041,1041,784,784,784,784,769,769,769,769,256,256,256,256,256,256,256,256,256,256,256,256,256,256,256,256,-511,-527,-543,1396,1351,1381,1366,1395,1335,1380,-559,1334,1138,1138,1063,1063,1350,1392,1031,1031,1062,1062,1364,1363,1120,1120,1333,1348,881,881,881,881,375,374,359,373,343,358,341,325,791,791,1123,1122,-703,1105,1045,-719,865,865,790,790,774,774,1104,1029,338,293,323,308,-799,-815,833,788,772,818,803,816,322,292,307,320,561,531,515,546,289,274,288,258,
+ -251,-525,-605,-685,-765,-831,-846,1298,1057,1057,1312,1282,785,785,785,785,784,784,784,784,769,769,769,769,512,512,512,512,512,512,512,512,1399,1398,1383,1367,1382,1396,1351,-511,1381,1366,1139,1139,1079,1079,1124,1124,1364,1349,1363,1333,882,882,882,882,807,807,807,807,1094,1094,1136,1136,373,341,535,535,881,775,867,822,774,-591,324,338,-671,849,550,550,866,864,609,609,293,336,534,534,789,835,773,-751,834,804,308,307,833,788,832,772,562,562,547,547,305,275,560,515,290,290,
+ -252,-397,-477,-557,-622,-653,-719,-735,-750,1329,1299,1314,1057,1057,1042,1042,1312,1282,1024,1024,785,785,785,785,784,784,784,784,769,769,769,769,-383,1127,1141,1111,1126,1140,1095,1110,869,869,883,883,1079,1109,882,882,375,374,807,868,838,881,791,-463,867,822,368,263,852,837,836,-543,610,610,550,550,352,336,534,534,865,774,851,821,850,805,593,533,579,564,773,832,578,578,548,548,577,577,307,276,306,291,516,560,259,259,
+ -250,-2107,-2507,-2764,-2909,-2974,-3007,-3023,1041,1041,1040,1040,769,769,769,769,256,256,256,256,256,256,256,256,256,256,256,256,256,256,256,256,-767,-1052,-1213,-1277,-1358,-1405,-1469,-1535,-1550,-1582,-1614,-1647,-1662,-1694,-1726,-1759,-1774,-1807,-1822,-1854,-1886,1565,-1919,-1935,-1951,-1967,1731,1730,1580,1717,-1983,1729,1564,-1999,1548,-2015,-2031,1715,1595,-2047,1714,-2063,1610,-2079,1609,-2095,1323,1323,1457,1457,1307,1307,1712,1547,1641,1700,1699,1594,1685,1625,1442,1442,1322,1322,-780,-973,-910,1279,1278,1277,1262,1276,1261,1275,1215,1260,1229,-959,974,974,989,989,-943,735,478,478,495,463,506,414,-1039,1003,958,1017,927,942,987,957,431,476,1272,1167,1228,-1183,1256,-1199,895,895,941,941,1242,1227,1212,1135,1014,1014,490,489,503,487,910,1013,985,925,863,894,970,955,1012,847,-1343,831,755,755,984,909,428,366,754,559,-1391,752,486,457,924,997,698,698,983,893,740,740,908,877,739,739,667,667,953,938,497,287,271,271,683,606,590,712,726,574,302,302,738,736,481,286,526,725,605,711,636,724,696,651,589,681,666,710,364,467,573,695,466,466,301,465,379,379,709,604,665,679,316,316,634,633,436,436,464,269,424,394,452,332,438,363,347,408,393,448,331,422,362,407,392,421,346,406,391,376,375,359,1441,1306,-2367,1290,-2383,1337,-2399,-2415,1426,1321,-2431,1411,1336,-2447,-2463,-2479,1169,1169,1049,1049,1424,1289,1412,1352,1319,-2495,1154,1154,1064,1064,1153,1153,416,390,360,404,403,389,344,374,373,343,358,372,327,357,342,311,356,326,1395,1394,1137,1137,1047,1047,1365,1392,1287,1379,1334,1364,1349,1378,1318,1363,792,792,792,792,1152,1152,1032,1032,1121,1121,1046,1046,1120,1120,1030,1030,-2895,1106,1061,1104,849,849,789,789,1091,1076,1029,1090,1060,1075,833,833,309,324,532,532,832,772,818,803,561,561,531,560,515,546,289,274,288,258,
+ -250,-1179,-1579,-1836,-1996,-2124,-2253,-2333,-2413,-2477,-2542,-2574,-2607,-2622,-2655,1314,1313,1298,1312,1282,785,785,785,785,1040,1040,1025,1025,768,768,768,768,-766,-798,-830,-862,-895,-911,-927,-943,-959,-975,-991,-1007,-1023,-1039,-1055,-1070,1724,1647,-1103,-1119,1631,1767,1662,1738,1708,1723,-1135,1780,1615,1779,1599,1677,1646,1778,1583,-1151,1777,1567,1737,1692,1765,1722,1707,1630,1751,1661,1764,1614,1736,1676,1763,1750,1645,1598,1721,1691,1762,1706,1582,1761,1566,-1167,1749,1629,767,766,751,765,494,494,735,764,719,749,734,763,447,447,748,718,477,506,431,491,446,476,461,505,415,430,475,445,504,399,460,489,414,503,383,474,429,459,502,502,746,752,488,398,501,473,413,472,486,271,480,270,-1439,-1455,1357,-1471,-1487,-1503,1341,1325,-1519,1489,1463,1403,1309,-1535,1372,1448,1418,1476,1356,1462,1387,-1551,1475,1340,1447,1402,1386,-1567,1068,1068,1474,1461,455,380,468,440,395,425,410,454,364,467,466,464,453,269,409,448,268,432,1371,1473,1432,1417,1308,1460,1355,1446,1459,1431,1083,1083,1401,1416,1458,1445,1067,1067,1370,1457,1051,1051,1291,1430,1385,1444,1354,1415,1400,1443,1082,1082,1173,1113,1186,1066,1185,1050,-1967,1158,1128,1172,1097,1171,1081,-1983,1157,1112,416,266,375,400,1170,1142,1127,1065,793,793,1169,1033,1156,1096,1141,1111,1155,1080,1126,1140,898,898,808,808,897,897,792,792,1095,1152,1032,1125,1110,1139,1079,1124,882,807,838,881,853,791,-2319,867,368,263,822,852,837,866,806,865,-2399,851,352,262,534,534,821,836,594,594,549,549,593,593,533,533,848,773,579,579,564,578,548,563,276,276,577,576,306,291,516,560,305,305,275,259,
+ -251,-892,-2058,-2620,-2828,-2957,-3023,-3039,1041,1041,1040,1040,769,769,769,769,256,256,256,256,256,256,256,256,256,256,256,256,256,256,256,256,-511,-527,-543,-559,1530,-575,-591,1528,1527,1407,1526,1391,1023,1023,1023,1023,1525,1375,1268,1268,1103,1103,1087,1087,1039,1039,1523,-604,815,815,815,815,510,495,509,479,508,463,507,447,431,505,415,399,-734,-782,1262,-815,1259,1244,-831,1258,1228,-847,-863,1196,-879,1253,987,987,748,-767,493,493,462,477,414,414,686,669,478,446,461,445,474,429,487,458,412,471,1266,1264,1009,1009,799,799,-1019,-1276,-1452,-1581,-1677,-1757,-1821,-1886,-1933,-1997,1257,1257,1483,1468,1512,1422,1497,1406,1467,1496,1421,1510,1134,1134,1225,1225,1466,1451,1374,1405,1252,1252,1358,1480,1164,1164,1251,1251,1238,1238,1389,1465,-1407,1054,1101,-1423,1207,-1439,830,830,1248,1038,1237,1117,1223,1148,1236,1208,411,426,395,410,379,269,1193,1222,1132,1235,1221,1116,976,976,1192,1162,1177,1220,1131,1191,963,963,-1647,961,780,-1663,558,558,994,993,437,408,393,407,829,978,813,797,947,-1743,721,721,377,392,844,950,828,890,706,706,812,859,796,960,948,843,934,874,571,571,-1919,690,555,689,421,346,539,539,944,779,918,873,932,842,903,888,570,570,931,917,674,674,-2575,1562,-2591,1609,-2607,1654,1322,1322,1441,1441,1696,1546,1683,1593,1669,1624,1426,1426,1321,1321,1639,1680,1425,1425,1305,1305,1545,1668,1608,1623,1667,1592,1638,1666,1320,1320,1652,1607,1409,1409,1304,1304,1288,1288,1664,1637,1395,1395,1335,1335,1622,1636,1394,1394,1319,1319,1606,1621,1392,1392,1137,1137,1137,1137,345,390,360,375,404,373,1047,-2751,-2767,-2783,1062,1121,1046,-2799,1077,-2815,1106,1061,789,789,1105,1104,263,355,310,340,325,354,352,262,339,324,1091,1076,1029,1090,1060,1075,833,833,788,788,1088,1028,818,818,803,803,561,561,531,531,816,771,546,546,289,274,288,258,
+ -253,-317,-381,-446,-478,-509,1279,1279,-811,-1179,-1451,-1756,-1900,-2028,-2189,-2253,-2333,-2414,-2445,-2511,-2526,1313,1298,-2559,1041,1041,1040,1040,1025,1025,1024,1024,1022,1007,1021,991,1020,975,1019,959,687,687,1018,1017,671,671,655,655,1016,1015,639,639,758,758,623,623,757,607,756,591,755,575,754,559,543,543,1009,783,-575,-621,-685,-749,496,-590,750,749,734,748,974,989,1003,958,988,973,1002,942,987,957,972,1001,926,986,941,971,956,1000,910,985,925,999,894,970,-1071,-1087,-1102,1390,-1135,1436,1509,1451,1374,-1151,1405,1358,1480,1420,-1167,1507,1494,1389,1342,1465,1435,1450,1326,1505,1310,1493,1373,1479,1404,1492,1464,1419,428,443,472,397,736,526,464,464,486,457,442,471,484,482,1357,1449,1434,1478,1388,1491,1341,1490,1325,1489,1463,1403,1309,1477,1372,1448,1418,1433,1476,1356,1462,1387,-1439,1475,1340,1447,1402,1474,1324,1461,1371,1473,269,448,1432,1417,1308,1460,-1711,1459,-1727,1441,1099,1099,1446,1386,1431,1401,-1743,1289,1083,1083,1160,1160,1458,1445,1067,1067,1370,1457,1307,1430,1129,1129,1098,1098,268,432,267,416,266,400,-1887,1144,1187,1082,1173,1113,1186,1066,1050,1158,1128,1143,1172,1097,1171,1081,420,391,1157,1112,1170,1142,1127,1065,1169,1049,1156,1096,1141,1111,1155,1080,1126,1154,1064,1153,1140,1095,1048,-2159,1125,1110,1137,-2175,823,823,1139,1138,807,807,384,264,368,263,868,838,853,791,867,822,852,837,866,806,865,790,-2319,851,821,836,352,262,850,805,849,-2399,533,533,835,820,336,261,578,548,563,577,532,532,832,772,562,562,547,547,305,275,560,515,290,290,288,258 };
+ static const uint8_t tab32[] = { 130,162,193,209,44,28,76,140,9,9,9,9,9,9,9,9,190,254,222,238,126,94,157,157,109,61,173,205 };
+ static const uint8_t tab33[] = { 252,236,220,204,188,172,156,140,124,108,92,76,60,44,28,12 };
+ static const int16_t tabindex[2*16] = { 0,32,64,98,0,132,180,218,292,364,426,538,648,746,0,1126,1460,1460,1460,1460,1460,1460,1460,1460,1842,1842,1842,1842,1842,1842,1842,1842 };
+ static const uint8_t g_linbits[] = { 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,2,3,4,6,8,10,13,4,5,6,7,8,9,11,13 };
+
+#define PEEK_BITS(n) (bs_cache >> (32 - n))
+#define FLUSH_BITS(n) { bs_cache <<= (n); bs_sh += (n); }
+#define CHECK_BITS while (bs_sh >= 0) { bs_cache |= (uint32_t)*bs_next_ptr++ << bs_sh; bs_sh -= 8; }
+#define BSPOS ((bs_next_ptr - bs->buf)*8 - 24 + bs_sh)
+
+ float one = 0.0f;
+ int ireg = 0, big_val_cnt = gr_info->big_values;
+ const uint8_t *sfb = gr_info->sfbtab;
+ const uint8_t *bs_next_ptr = bs->buf + bs->pos/8;
+ uint32_t bs_cache = (((bs_next_ptr[0]*256u + bs_next_ptr[1])*256u + bs_next_ptr[2])*256u + bs_next_ptr[3]) << (bs->pos & 7);
+ int pairs_to_decode, np, bs_sh = (bs->pos & 7) - 8;
+ bs_next_ptr += 4;
+
+ while (big_val_cnt > 0)
+ {
+ int tab_num = gr_info->table_select[ireg];
+ int sfb_cnt = gr_info->region_count[ireg++];
+ const int16_t *codebook = tabs + tabindex[tab_num];
+ int linbits = g_linbits[tab_num];
+ if (linbits)
+ {
+ do
+ {
+ np = *sfb++ / 2;
+ pairs_to_decode = MINIMP3_MIN(big_val_cnt, np);
+ one = *scf++;
+ do
+ {
+ int j, w = 5;
+ int leaf = codebook[PEEK_BITS(w)];
+ while (leaf < 0)
+ {
+ FLUSH_BITS(w);
+ w = leaf & 7;
+ leaf = codebook[PEEK_BITS(w) - (leaf >> 3)];
+ }
+ FLUSH_BITS(leaf >> 8);
+
+ for (j = 0; j < 2; j++, dst++, leaf >>= 4)
+ {
+ int lsb = leaf & 0x0F;
+ if (lsb == 15)
+ {
+ lsb += PEEK_BITS(linbits);
+ FLUSH_BITS(linbits);
+ CHECK_BITS;
+ *dst = one*L3_pow_43(lsb)*((int32_t)bs_cache < 0 ? -1: 1);
+ } else
+ {
+ *dst = g_pow43[16 + lsb - 16*(bs_cache >> 31)]*one;
+ }
+ FLUSH_BITS(lsb ? 1 : 0);
+ }
+ CHECK_BITS;
+ } while (--pairs_to_decode);
+ } while ((big_val_cnt -= np) > 0 && --sfb_cnt >= 0);
+ } else
+ {
+ do
+ {
+ np = *sfb++ / 2;
+ pairs_to_decode = MINIMP3_MIN(big_val_cnt, np);
+ one = *scf++;
+ do
+ {
+ int j, w = 5;
+ int leaf = codebook[PEEK_BITS(w)];
+ while (leaf < 0)
+ {
+ FLUSH_BITS(w);
+ w = leaf & 7;
+ leaf = codebook[PEEK_BITS(w) - (leaf >> 3)];
+ }
+ FLUSH_BITS(leaf >> 8);
+
+ for (j = 0; j < 2; j++, dst++, leaf >>= 4)
+ {
+ int lsb = leaf & 0x0F;
+ *dst = g_pow43[16 + lsb - 16*(bs_cache >> 31)]*one;
+ FLUSH_BITS(lsb ? 1 : 0);
+ }
+ CHECK_BITS;
+ } while (--pairs_to_decode);
+ } while ((big_val_cnt -= np) > 0 && --sfb_cnt >= 0);
+ }
+ }
+
+ for (np = 1 - big_val_cnt;; dst += 4)
+ {
+ const uint8_t *codebook_count1 = (gr_info->count1_table) ? tab33 : tab32;
+ int leaf = codebook_count1[PEEK_BITS(4)];
+ if (!(leaf & 8))
+ {
+ leaf = codebook_count1[(leaf >> 3) + (bs_cache << 4 >> (32 - (leaf & 3)))];
+ }
+ FLUSH_BITS(leaf & 7);
+ if (BSPOS > layer3gr_limit)
+ {
+ break;
+ }
+#define RELOAD_SCALEFACTOR if (!--np) { np = *sfb++/2; if (!np) break; one = *scf++; }
+#define DEQ_COUNT1(s) if (leaf & (128 >> s)) { dst[s] = ((int32_t)bs_cache < 0) ? -one : one; FLUSH_BITS(1) }
+ RELOAD_SCALEFACTOR;
+ DEQ_COUNT1(0);
+ DEQ_COUNT1(1);
+ RELOAD_SCALEFACTOR;
+ DEQ_COUNT1(2);
+ DEQ_COUNT1(3);
+ CHECK_BITS;
+ }
+
+ bs->pos = layer3gr_limit;
+}
+
+static void L3_midside_stereo(float *left, int n)
+{
+ int i = 0;
+ float *right = left + 576;
+#if HAVE_SIMD
+ if (have_simd())
+ {
+ for (; i < n - 3; i += 4)
+ {
+ f4 vl = VLD(left + i);
+ f4 vr = VLD(right + i);
+ VSTORE(left + i, VADD(vl, vr));
+ VSTORE(right + i, VSUB(vl, vr));
+ }
+#ifdef __GNUC__
+ /* Workaround for spurious -Waggressive-loop-optimizations warning from gcc.
+ * For more info see: https://github.com/lieff/minimp3/issues/88
+ */
+ if (__builtin_constant_p(n % 4 == 0) && n % 4 == 0)
+ return;
+#endif
+ }
+#endif /* HAVE_SIMD */
+ for (; i < n; i++)
+ {
+ float a = left[i];
+ float b = right[i];
+ left[i] = a + b;
+ right[i] = a - b;
+ }
+}
+
+static void L3_intensity_stereo_band(float *left, int n, float kl, float kr)
+{
+ int i;
+ for (i = 0; i < n; i++)
+ {
+ left[i + 576] = left[i]*kr;
+ left[i] = left[i]*kl;
+ }
+}
+
+static void L3_stereo_top_band(const float *right, const uint8_t *sfb, int nbands, int max_band[3])
+{
+ int i, k;
+
+ max_band[0] = max_band[1] = max_band[2] = -1;
+
+ for (i = 0; i < nbands; i++)
+ {
+ for (k = 0; k < sfb[i]; k += 2)
+ {
+ if (right[k] != 0 || right[k + 1] != 0)
+ {
+ max_band[i % 3] = i;
+ break;
+ }
+ }
+ right += sfb[i];
+ }
+}
+
+static void L3_stereo_process(float *left, const uint8_t *ist_pos, const uint8_t *sfb, const uint8_t *hdr, int max_band[3], int mpeg2_sh)
+{
+ static const float g_pan[7*2] = { 0,1,0.21132487f,0.78867513f,0.36602540f,0.63397460f,0.5f,0.5f,0.63397460f,0.36602540f,0.78867513f,0.21132487f,1,0 };
+ unsigned i, max_pos = HDR_TEST_MPEG1(hdr) ? 7 : 64;
+
+ for (i = 0; sfb[i]; i++)
+ {
+ unsigned ipos = ist_pos[i];
+ if ((int)i > max_band[i % 3] && ipos < max_pos)
+ {
+ float kl, kr, s = HDR_TEST_MS_STEREO(hdr) ? 1.41421356f : 1;
+ if (HDR_TEST_MPEG1(hdr))
+ {
+ kl = g_pan[2*ipos];
+ kr = g_pan[2*ipos + 1];
+ } else
+ {
+ kl = 1;
+ kr = L3_ldexp_q2(1, (ipos + 1) >> 1 << mpeg2_sh);
+ if (ipos & 1)
+ {
+ kl = kr;
+ kr = 1;
+ }
+ }
+ L3_intensity_stereo_band(left, sfb[i], kl*s, kr*s);
+ } else if (HDR_TEST_MS_STEREO(hdr))
+ {
+ L3_midside_stereo(left, sfb[i]);
+ }
+ left += sfb[i];
+ }
+}
+
+static void L3_intensity_stereo(float *left, uint8_t *ist_pos, const L3_gr_info_t *gr, const uint8_t *hdr)
+{
+ int max_band[3], n_sfb = gr->n_long_sfb + gr->n_short_sfb;
+ int i, max_blocks = gr->n_short_sfb ? 3 : 1;
+
+ L3_stereo_top_band(left + 576, gr->sfbtab, n_sfb, max_band);
+ if (gr->n_long_sfb)
+ {
+ max_band[0] = max_band[1] = max_band[2] = MINIMP3_MAX(MINIMP3_MAX(max_band[0], max_band[1]), max_band[2]);
+ }
+ for (i = 0; i < max_blocks; i++)
+ {
+ int default_pos = HDR_TEST_MPEG1(hdr) ? 3 : 0;
+ int itop = n_sfb - max_blocks + i;
+ int prev = itop - max_blocks;
+ ist_pos[itop] = max_band[i] >= prev ? default_pos : ist_pos[prev];
+ }
+ L3_stereo_process(left, ist_pos, gr->sfbtab, hdr, max_band, gr[1].scalefac_compress & 1);
+}
+
+static void L3_reorder(float *grbuf, float *scratch, const uint8_t *sfb)
+{
+ int i, len;
+ float *src = grbuf, *dst = scratch;
+
+ for (;0 != (len = *sfb); sfb += 3, src += 2*len)
+ {
+ for (i = 0; i < len; i++, src++)
+ {
+ *dst++ = src[0*len];
+ *dst++ = src[1*len];
+ *dst++ = src[2*len];
+ }
+ }
+ memcpy(grbuf, scratch, (dst - scratch)*sizeof(float));
+}
+
+static void L3_antialias(float *grbuf, int nbands)
+{
+ static const float g_aa[2][8] = {
+ {0.85749293f,0.88174200f,0.94962865f,0.98331459f,0.99551782f,0.99916056f,0.99989920f,0.99999316f},
+ {0.51449576f,0.47173197f,0.31337745f,0.18191320f,0.09457419f,0.04096558f,0.01419856f,0.00369997f}
+ };
+
+ for (; nbands > 0; nbands--, grbuf += 18)
+ {
+ int i = 0;
+#if HAVE_SIMD
+ if (have_simd()) for (; i < 8; i += 4)
+ {
+ f4 vu = VLD(grbuf + 18 + i);
+ f4 vd = VLD(grbuf + 14 - i);
+ f4 vc0 = VLD(g_aa[0] + i);
+ f4 vc1 = VLD(g_aa[1] + i);
+ vd = VREV(vd);
+ VSTORE(grbuf + 18 + i, VSUB(VMUL(vu, vc0), VMUL(vd, vc1)));
+ vd = VADD(VMUL(vu, vc1), VMUL(vd, vc0));
+ VSTORE(grbuf + 14 - i, VREV(vd));
+ }
+#endif /* HAVE_SIMD */
+#ifndef MINIMP3_ONLY_SIMD
+ for(; i < 8; i++)
+ {
+ float u = grbuf[18 + i];
+ float d = grbuf[17 - i];
+ grbuf[18 + i] = u*g_aa[0][i] - d*g_aa[1][i];
+ grbuf[17 - i] = u*g_aa[1][i] + d*g_aa[0][i];
+ }
+#endif /* MINIMP3_ONLY_SIMD */
+ }
+}
+
+static void L3_dct3_9(float *y)
+{
+ float s0, s1, s2, s3, s4, s5, s6, s7, s8, t0, t2, t4;
+
+ s0 = y[0]; s2 = y[2]; s4 = y[4]; s6 = y[6]; s8 = y[8];
+ t0 = s0 + s6*0.5f;
+ s0 -= s6;
+ t4 = (s4 + s2)*0.93969262f;
+ t2 = (s8 + s2)*0.76604444f;
+ s6 = (s4 - s8)*0.17364818f;
+ s4 += s8 - s2;
+
+ s2 = s0 - s4*0.5f;
+ y[4] = s4 + s0;
+ s8 = t0 - t2 + s6;
+ s0 = t0 - t4 + t2;
+ s4 = t0 + t4 - s6;
+
+ s1 = y[1]; s3 = y[3]; s5 = y[5]; s7 = y[7];
+
+ s3 *= 0.86602540f;
+ t0 = (s5 + s1)*0.98480775f;
+ t4 = (s5 - s7)*0.34202014f;
+ t2 = (s1 + s7)*0.64278761f;
+ s1 = (s1 - s5 - s7)*0.86602540f;
+
+ s5 = t0 - s3 - t2;
+ s7 = t4 - s3 - t0;
+ s3 = t4 + s3 - t2;
+
+ y[0] = s4 - s7;
+ y[1] = s2 + s1;
+ y[2] = s0 - s3;
+ y[3] = s8 + s5;
+ y[5] = s8 - s5;
+ y[6] = s0 + s3;
+ y[7] = s2 - s1;
+ y[8] = s4 + s7;
+}
+
+static void L3_imdct36(float *grbuf, float *overlap, const float *window, int nbands)
+{
+ int i, j;
+ static const float g_twid9[18] = {
+ 0.73727734f,0.79335334f,0.84339145f,0.88701083f,0.92387953f,0.95371695f,0.97629601f,0.99144486f,0.99904822f,0.67559021f,0.60876143f,0.53729961f,0.46174861f,0.38268343f,0.30070580f,0.21643961f,0.13052619f,0.04361938f
+ };
+
+ for (j = 0; j < nbands; j++, grbuf += 18, overlap += 9)
+ {
+ float co[9], si[9];
+ co[0] = -grbuf[0];
+ si[0] = grbuf[17];
+ for (i = 0; i < 4; i++)
+ {
+ si[8 - 2*i] = grbuf[4*i + 1] - grbuf[4*i + 2];
+ co[1 + 2*i] = grbuf[4*i + 1] + grbuf[4*i + 2];
+ si[7 - 2*i] = grbuf[4*i + 4] - grbuf[4*i + 3];
+ co[2 + 2*i] = -(grbuf[4*i + 3] + grbuf[4*i + 4]);
+ }
+ L3_dct3_9(co);
+ L3_dct3_9(si);
+
+ si[1] = -si[1];
+ si[3] = -si[3];
+ si[5] = -si[5];
+ si[7] = -si[7];
+
+ i = 0;
+
+#if HAVE_SIMD
+ if (have_simd()) for (; i < 8; i += 4)
+ {
+ f4 vovl = VLD(overlap + i);
+ f4 vc = VLD(co + i);
+ f4 vs = VLD(si + i);
+ f4 vr0 = VLD(g_twid9 + i);
+ f4 vr1 = VLD(g_twid9 + 9 + i);
+ f4 vw0 = VLD(window + i);
+ f4 vw1 = VLD(window + 9 + i);
+ f4 vsum = VADD(VMUL(vc, vr1), VMUL(vs, vr0));
+ VSTORE(overlap + i, VSUB(VMUL(vc, vr0), VMUL(vs, vr1)));
+ VSTORE(grbuf + i, VSUB(VMUL(vovl, vw0), VMUL(vsum, vw1)));
+ vsum = VADD(VMUL(vovl, vw1), VMUL(vsum, vw0));
+ VSTORE(grbuf + 14 - i, VREV(vsum));
+ }
+#endif /* HAVE_SIMD */
+ for (; i < 9; i++)
+ {
+ float ovl = overlap[i];
+ float sum = co[i]*g_twid9[9 + i] + si[i]*g_twid9[0 + i];
+ overlap[i] = co[i]*g_twid9[0 + i] - si[i]*g_twid9[9 + i];
+ grbuf[i] = ovl*window[0 + i] - sum*window[9 + i];
+ grbuf[17 - i] = ovl*window[9 + i] + sum*window[0 + i];
+ }
+ }
+}
+
+static void L3_idct3(float x0, float x1, float x2, float *dst)
+{
+ float m1 = x1*0.86602540f;
+ float a1 = x0 - x2*0.5f;
+ dst[1] = x0 + x2;
+ dst[0] = a1 + m1;
+ dst[2] = a1 - m1;
+}
+
+static void L3_imdct12(float *x, float *dst, float *overlap)
+{
+ static const float g_twid3[6] = { 0.79335334f,0.92387953f,0.99144486f, 0.60876143f,0.38268343f,0.13052619f };
+ float co[3], si[3];
+ int i;
+
+ L3_idct3(-x[0], x[6] + x[3], x[12] + x[9], co);
+ L3_idct3(x[15], x[12] - x[9], x[6] - x[3], si);
+ si[1] = -si[1];
+
+ for (i = 0; i < 3; i++)
+ {
+ float ovl = overlap[i];
+ float sum = co[i]*g_twid3[3 + i] + si[i]*g_twid3[0 + i];
+ overlap[i] = co[i]*g_twid3[0 + i] - si[i]*g_twid3[3 + i];
+ dst[i] = ovl*g_twid3[2 - i] - sum*g_twid3[5 - i];
+ dst[5 - i] = ovl*g_twid3[5 - i] + sum*g_twid3[2 - i];
+ }
+}
+
+static void L3_imdct_short(float *grbuf, float *overlap, int nbands)
+{
+ for (;nbands > 0; nbands--, overlap += 9, grbuf += 18)
+ {
+ float tmp[18];
+ memcpy(tmp, grbuf, sizeof(tmp));
+ memcpy(grbuf, overlap, 6*sizeof(float));
+ L3_imdct12(tmp, grbuf + 6, overlap + 6);
+ L3_imdct12(tmp + 1, grbuf + 12, overlap + 6);
+ L3_imdct12(tmp + 2, overlap, overlap + 6);
+ }
+}
+
+static void L3_change_sign(float *grbuf)
+{
+ int b, i;
+ for (b = 0, grbuf += 18; b < 32; b += 2, grbuf += 36)
+ for (i = 1; i < 18; i += 2)
+ grbuf[i] = -grbuf[i];
+}
+
+static void L3_imdct_gr(float *grbuf, float *overlap, unsigned block_type, unsigned n_long_bands)
+{
+ static const float g_mdct_window[2][18] = {
+ { 0.99904822f,0.99144486f,0.97629601f,0.95371695f,0.92387953f,0.88701083f,0.84339145f,0.79335334f,0.73727734f,0.04361938f,0.13052619f,0.21643961f,0.30070580f,0.38268343f,0.46174861f,0.53729961f,0.60876143f,0.67559021f },
+ { 1,1,1,1,1,1,0.99144486f,0.92387953f,0.79335334f,0,0,0,0,0,0,0.13052619f,0.38268343f,0.60876143f }
+ };
+ if (n_long_bands)
+ {
+ L3_imdct36(grbuf, overlap, g_mdct_window[0], n_long_bands);
+ grbuf += 18*n_long_bands;
+ overlap += 9*n_long_bands;
+ }
+ if (block_type == SHORT_BLOCK_TYPE)
+ L3_imdct_short(grbuf, overlap, 32 - n_long_bands);
+ else
+ L3_imdct36(grbuf, overlap, g_mdct_window[block_type == STOP_BLOCK_TYPE], 32 - n_long_bands);
+}
+
+static void L3_save_reservoir(mp3dec_t *h, mp3dec_scratch_t *s)
+{
+ int pos = (s->bs.pos + 7)/8u;
+ int remains = s->bs.limit/8u - pos;
+ if (remains > MAX_BITRESERVOIR_BYTES)
+ {
+ pos += remains - MAX_BITRESERVOIR_BYTES;
+ remains = MAX_BITRESERVOIR_BYTES;
+ }
+ if (remains > 0)
+ {
+ memmove(h->reserv_buf, s->maindata + pos, remains);
+ }
+ h->reserv = remains;
+}
+
+static int L3_restore_reservoir(mp3dec_t *h, bs_t *bs, mp3dec_scratch_t *s, int main_data_begin)
+{
+ int frame_bytes = (bs->limit - bs->pos)/8;
+ int bytes_have = MINIMP3_MIN(h->reserv, main_data_begin);
+ memcpy(s->maindata, h->reserv_buf + MINIMP3_MAX(0, h->reserv - main_data_begin), MINIMP3_MIN(h->reserv, main_data_begin));
+ memcpy(s->maindata + bytes_have, bs->buf + bs->pos/8, frame_bytes);
+ bs_init(&s->bs, s->maindata, bytes_have + frame_bytes);
+ return h->reserv >= main_data_begin;
+}
+
+static void L3_decode(mp3dec_t *h, mp3dec_scratch_t *s, L3_gr_info_t *gr_info, int nch)
+{
+ int ch;
+
+ for (ch = 0; ch < nch; ch++)
+ {
+ int layer3gr_limit = s->bs.pos + gr_info[ch].part_23_length;
+ L3_decode_scalefactors(h->header, s->ist_pos[ch], &s->bs, gr_info + ch, s->scf, ch);
+ L3_huffman(s->grbuf[ch], &s->bs, gr_info + ch, s->scf, layer3gr_limit);
+ }
+
+ if (HDR_TEST_I_STEREO(h->header))
+ {
+ L3_intensity_stereo(s->grbuf[0], s->ist_pos[1], gr_info, h->header);
+ } else if (HDR_IS_MS_STEREO(h->header))
+ {
+ L3_midside_stereo(s->grbuf[0], 576);
+ }
+
+ for (ch = 0; ch < nch; ch++, gr_info++)
+ {
+ int aa_bands = 31;
+ int n_long_bands = (gr_info->mixed_block_flag ? 2 : 0) << (int)(HDR_GET_MY_SAMPLE_RATE(h->header) == 2);
+
+ if (gr_info->n_short_sfb)
+ {
+ aa_bands = n_long_bands - 1;
+ L3_reorder(s->grbuf[ch] + n_long_bands*18, s->syn[0], gr_info->sfbtab + gr_info->n_long_sfb);
+ }
+
+ L3_antialias(s->grbuf[ch], aa_bands);
+ L3_imdct_gr(s->grbuf[ch], h->mdct_overlap[ch], gr_info->block_type, n_long_bands);
+ L3_change_sign(s->grbuf[ch]);
+ }
+}
+
+static void mp3d_DCT_II(float *grbuf, int n)
+{
+ static const float g_sec[24] = {
+ 10.19000816f,0.50060302f,0.50241929f,3.40760851f,0.50547093f,0.52249861f,2.05778098f,0.51544732f,0.56694406f,1.48416460f,0.53104258f,0.64682180f,1.16943991f,0.55310392f,0.78815460f,0.97256821f,0.58293498f,1.06067765f,0.83934963f,0.62250412f,1.72244716f,0.74453628f,0.67480832f,5.10114861f
+ };
+ int i, k = 0;
+#if HAVE_SIMD
+ if (have_simd()) for (; k < n; k += 4)
+ {
+ f4 t[4][8], *x;
+ float *y = grbuf + k;
+
+ for (x = t[0], i = 0; i < 8; i++, x++)
+ {
+ f4 x0 = VLD(&y[i*18]);
+ f4 x1 = VLD(&y[(15 - i)*18]);
+ f4 x2 = VLD(&y[(16 + i)*18]);
+ f4 x3 = VLD(&y[(31 - i)*18]);
+ f4 t0 = VADD(x0, x3);
+ f4 t1 = VADD(x1, x2);
+ f4 t2 = VMUL_S(VSUB(x1, x2), g_sec[3*i + 0]);
+ f4 t3 = VMUL_S(VSUB(x0, x3), g_sec[3*i + 1]);
+ x[0] = VADD(t0, t1);
+ x[8] = VMUL_S(VSUB(t0, t1), g_sec[3*i + 2]);
+ x[16] = VADD(t3, t2);
+ x[24] = VMUL_S(VSUB(t3, t2), g_sec[3*i + 2]);
+ }
+ for (x = t[0], i = 0; i < 4; i++, x += 8)
+ {
+ f4 x0 = x[0], x1 = x[1], x2 = x[2], x3 = x[3], x4 = x[4], x5 = x[5], x6 = x[6], x7 = x[7], xt;
+ xt = VSUB(x0, x7); x0 = VADD(x0, x7);
+ x7 = VSUB(x1, x6); x1 = VADD(x1, x6);
+ x6 = VSUB(x2, x5); x2 = VADD(x2, x5);
+ x5 = VSUB(x3, x4); x3 = VADD(x3, x4);
+ x4 = VSUB(x0, x3); x0 = VADD(x0, x3);
+ x3 = VSUB(x1, x2); x1 = VADD(x1, x2);
+ x[0] = VADD(x0, x1);
+ x[4] = VMUL_S(VSUB(x0, x1), 0.70710677f);
+ x5 = VADD(x5, x6);
+ x6 = VMUL_S(VADD(x6, x7), 0.70710677f);
+ x7 = VADD(x7, xt);
+ x3 = VMUL_S(VADD(x3, x4), 0.70710677f);
+ x5 = VSUB(x5, VMUL_S(x7, 0.198912367f)); /* rotate by PI/8 */
+ x7 = VADD(x7, VMUL_S(x5, 0.382683432f));
+ x5 = VSUB(x5, VMUL_S(x7, 0.198912367f));
+ x0 = VSUB(xt, x6); xt = VADD(xt, x6);
+ x[1] = VMUL_S(VADD(xt, x7), 0.50979561f);
+ x[2] = VMUL_S(VADD(x4, x3), 0.54119611f);
+ x[3] = VMUL_S(VSUB(x0, x5), 0.60134488f);
+ x[5] = VMUL_S(VADD(x0, x5), 0.89997619f);
+ x[6] = VMUL_S(VSUB(x4, x3), 1.30656302f);
+ x[7] = VMUL_S(VSUB(xt, x7), 2.56291556f);
+ }
+
+ if (k > n - 3)
+ {
+#if HAVE_SSE
+#define VSAVE2(i, v) _mm_storel_pi((__m64 *)(void*)&y[i*18], v)
+#else /* HAVE_SSE */
+#define VSAVE2(i, v) vst1_f32((float32_t *)&y[i*18], vget_low_f32(v))
+#endif /* HAVE_SSE */
+ for (i = 0; i < 7; i++, y += 4*18)
+ {
+ f4 s = VADD(t[3][i], t[3][i + 1]);
+ VSAVE2(0, t[0][i]);
+ VSAVE2(1, VADD(t[2][i], s));
+ VSAVE2(2, VADD(t[1][i], t[1][i + 1]));
+ VSAVE2(3, VADD(t[2][1 + i], s));
+ }
+ VSAVE2(0, t[0][7]);
+ VSAVE2(1, VADD(t[2][7], t[3][7]));
+ VSAVE2(2, t[1][7]);
+ VSAVE2(3, t[3][7]);
+ } else
+ {
+#define VSAVE4(i, v) VSTORE(&y[i*18], v)
+ for (i = 0; i < 7; i++, y += 4*18)
+ {
+ f4 s = VADD(t[3][i], t[3][i + 1]);
+ VSAVE4(0, t[0][i]);
+ VSAVE4(1, VADD(t[2][i], s));
+ VSAVE4(2, VADD(t[1][i], t[1][i + 1]));
+ VSAVE4(3, VADD(t[2][1 + i], s));
+ }
+ VSAVE4(0, t[0][7]);
+ VSAVE4(1, VADD(t[2][7], t[3][7]));
+ VSAVE4(2, t[1][7]);
+ VSAVE4(3, t[3][7]);
+ }
+ } else
+#endif /* HAVE_SIMD */
+#ifdef MINIMP3_ONLY_SIMD
+ {} /* for HAVE_SIMD=1, MINIMP3_ONLY_SIMD=1 case we do not need non-intrinsic "else" branch */
+#else /* MINIMP3_ONLY_SIMD */
+ for (; k < n; k++)
+ {
+ float t[4][8], *x, *y = grbuf + k;
+
+ for (x = t[0], i = 0; i < 8; i++, x++)
+ {
+ float x0 = y[i*18];
+ float x1 = y[(15 - i)*18];
+ float x2 = y[(16 + i)*18];
+ float x3 = y[(31 - i)*18];
+ float t0 = x0 + x3;
+ float t1 = x1 + x2;
+ float t2 = (x1 - x2)*g_sec[3*i + 0];
+ float t3 = (x0 - x3)*g_sec[3*i + 1];
+ x[0] = t0 + t1;
+ x[8] = (t0 - t1)*g_sec[3*i + 2];
+ x[16] = t3 + t2;
+ x[24] = (t3 - t2)*g_sec[3*i + 2];
+ }
+ for (x = t[0], i = 0; i < 4; i++, x += 8)
+ {
+ float x0 = x[0], x1 = x[1], x2 = x[2], x3 = x[3], x4 = x[4], x5 = x[5], x6 = x[6], x7 = x[7], xt;
+ xt = x0 - x7; x0 += x7;
+ x7 = x1 - x6; x1 += x6;
+ x6 = x2 - x5; x2 += x5;
+ x5 = x3 - x4; x3 += x4;
+ x4 = x0 - x3; x0 += x3;
+ x3 = x1 - x2; x1 += x2;
+ x[0] = x0 + x1;
+ x[4] = (x0 - x1)*0.70710677f;
+ x5 = x5 + x6;
+ x6 = (x6 + x7)*0.70710677f;
+ x7 = x7 + xt;
+ x3 = (x3 + x4)*0.70710677f;
+ x5 -= x7*0.198912367f; /* rotate by PI/8 */
+ x7 += x5*0.382683432f;
+ x5 -= x7*0.198912367f;
+ x0 = xt - x6; xt += x6;
+ x[1] = (xt + x7)*0.50979561f;
+ x[2] = (x4 + x3)*0.54119611f;
+ x[3] = (x0 - x5)*0.60134488f;
+ x[5] = (x0 + x5)*0.89997619f;
+ x[6] = (x4 - x3)*1.30656302f;
+ x[7] = (xt - x7)*2.56291556f;
+
+ }
+ for (i = 0; i < 7; i++, y += 4*18)
+ {
+ y[0*18] = t[0][i];
+ y[1*18] = t[2][i] + t[3][i] + t[3][i + 1];
+ y[2*18] = t[1][i] + t[1][i + 1];
+ y[3*18] = t[2][i + 1] + t[3][i] + t[3][i + 1];
+ }
+ y[0*18] = t[0][7];
+ y[1*18] = t[2][7] + t[3][7];
+ y[2*18] = t[1][7];
+ y[3*18] = t[3][7];
+ }
+#endif /* MINIMP3_ONLY_SIMD */
+}
+
+#ifndef MINIMP3_FLOAT_OUTPUT
+static int16_t mp3d_scale_pcm(float sample)
+{
+#if HAVE_ARMV6
+ int32_t s32 = (int32_t)(sample + .5f);
+ s32 -= (s32 < 0);
+ int16_t s = (int16_t)minimp3_clip_int16_arm(s32);
+#else
+ if (sample >= 32766.5) return (int16_t) 32767;
+ if (sample <= -32767.5) return (int16_t)-32768;
+ int16_t s = (int16_t)(sample + .5f);
+ s -= (s < 0); /* away from zero, to be compliant */
+#endif
+ return s;
+}
+#else /* MINIMP3_FLOAT_OUTPUT */
+static float mp3d_scale_pcm(float sample)
+{
+ return sample*(1.f/32768.f);
+}
+#endif /* MINIMP3_FLOAT_OUTPUT */
+
+static void mp3d_synth_pair(mp3d_sample_t *pcm, int nch, const float *z)
+{
+ float a;
+ a = (z[14*64] - z[ 0]) * 29;
+ a += (z[ 1*64] + z[13*64]) * 213;
+ a += (z[12*64] - z[ 2*64]) * 459;
+ a += (z[ 3*64] + z[11*64]) * 2037;
+ a += (z[10*64] - z[ 4*64]) * 5153;
+ a += (z[ 5*64] + z[ 9*64]) * 6574;
+ a += (z[ 8*64] - z[ 6*64]) * 37489;
+ a += z[ 7*64] * 75038;
+ pcm[0] = mp3d_scale_pcm(a);
+
+ z += 2;
+ a = z[14*64] * 104;
+ a += z[12*64] * 1567;
+ a += z[10*64] * 9727;
+ a += z[ 8*64] * 64019;
+ a += z[ 6*64] * -9975;
+ a += z[ 4*64] * -45;
+ a += z[ 2*64] * 146;
+ a += z[ 0*64] * -5;
+ pcm[16*nch] = mp3d_scale_pcm(a);
+}
+
+static void mp3d_synth(float *xl, mp3d_sample_t *dstl, int nch, float *lins)
+{
+ int i;
+ float *xr = xl + 576*(nch - 1);
+ mp3d_sample_t *dstr = dstl + (nch - 1);
+
+ static const float g_win[] = {
+ -1,26,-31,208,218,401,-519,2063,2000,4788,-5517,7134,5959,35640,-39336,74992,
+ -1,24,-35,202,222,347,-581,2080,1952,4425,-5879,7640,5288,33791,-41176,74856,
+ -1,21,-38,196,225,294,-645,2087,1893,4063,-6237,8092,4561,31947,-43006,74630,
+ -1,19,-41,190,227,244,-711,2085,1822,3705,-6589,8492,3776,30112,-44821,74313,
+ -1,17,-45,183,228,197,-779,2075,1739,3351,-6935,8840,2935,28289,-46617,73908,
+ -1,16,-49,176,228,153,-848,2057,1644,3004,-7271,9139,2037,26482,-48390,73415,
+ -2,14,-53,169,227,111,-919,2032,1535,2663,-7597,9389,1082,24694,-50137,72835,
+ -2,13,-58,161,224,72,-991,2001,1414,2330,-7910,9592,70,22929,-51853,72169,
+ -2,11,-63,154,221,36,-1064,1962,1280,2006,-8209,9750,-998,21189,-53534,71420,
+ -2,10,-68,147,215,2,-1137,1919,1131,1692,-8491,9863,-2122,19478,-55178,70590,
+ -3,9,-73,139,208,-29,-1210,1870,970,1388,-8755,9935,-3300,17799,-56778,69679,
+ -3,8,-79,132,200,-57,-1283,1817,794,1095,-8998,9966,-4533,16155,-58333,68692,
+ -4,7,-85,125,189,-83,-1356,1759,605,814,-9219,9959,-5818,14548,-59838,67629,
+ -4,7,-91,117,177,-106,-1428,1698,402,545,-9416,9916,-7154,12980,-61289,66494,
+ -5,6,-97,111,163,-127,-1498,1634,185,288,-9585,9838,-8540,11455,-62684,65290
+ };
+ float *zlin = lins + 15*64;
+ const float *w = g_win;
+
+ zlin[4*15] = xl[18*16];
+ zlin[4*15 + 1] = xr[18*16];
+ zlin[4*15 + 2] = xl[0];
+ zlin[4*15 + 3] = xr[0];
+
+ zlin[4*31] = xl[1 + 18*16];
+ zlin[4*31 + 1] = xr[1 + 18*16];
+ zlin[4*31 + 2] = xl[1];
+ zlin[4*31 + 3] = xr[1];
+
+ mp3d_synth_pair(dstr, nch, lins + 4*15 + 1);
+ mp3d_synth_pair(dstr + 32*nch, nch, lins + 4*15 + 64 + 1);
+ mp3d_synth_pair(dstl, nch, lins + 4*15);
+ mp3d_synth_pair(dstl + 32*nch, nch, lins + 4*15 + 64);
+
+#if HAVE_SIMD
+ if (have_simd()) for (i = 14; i >= 0; i--)
+ {
+#define VLOAD(k) f4 w0 = VSET(*w++); f4 w1 = VSET(*w++); f4 vz = VLD(&zlin[4*i - 64*k]); f4 vy = VLD(&zlin[4*i - 64*(15 - k)]);
+#define V0(k) { VLOAD(k) b = VADD(VMUL(vz, w1), VMUL(vy, w0)) ; a = VSUB(VMUL(vz, w0), VMUL(vy, w1)); }
+#define V1(k) { VLOAD(k) b = VADD(b, VADD(VMUL(vz, w1), VMUL(vy, w0))); a = VADD(a, VSUB(VMUL(vz, w0), VMUL(vy, w1))); }
+#define V2(k) { VLOAD(k) b = VADD(b, VADD(VMUL(vz, w1), VMUL(vy, w0))); a = VADD(a, VSUB(VMUL(vy, w1), VMUL(vz, w0))); }
+ f4 a, b;
+ zlin[4*i] = xl[18*(31 - i)];
+ zlin[4*i + 1] = xr[18*(31 - i)];
+ zlin[4*i + 2] = xl[1 + 18*(31 - i)];
+ zlin[4*i + 3] = xr[1 + 18*(31 - i)];
+ zlin[4*i + 64] = xl[1 + 18*(1 + i)];
+ zlin[4*i + 64 + 1] = xr[1 + 18*(1 + i)];
+ zlin[4*i - 64 + 2] = xl[18*(1 + i)];
+ zlin[4*i - 64 + 3] = xr[18*(1 + i)];
+
+ V0(0) V2(1) V1(2) V2(3) V1(4) V2(5) V1(6) V2(7)
+
+ {
+#ifndef MINIMP3_FLOAT_OUTPUT
+#if HAVE_SSE
+ static const f4 g_max = { 32767.0f, 32767.0f, 32767.0f, 32767.0f };
+ static const f4 g_min = { -32768.0f, -32768.0f, -32768.0f, -32768.0f };
+ __m128i pcm8 = _mm_packs_epi32(_mm_cvtps_epi32(_mm_max_ps(_mm_min_ps(a, g_max), g_min)),
+ _mm_cvtps_epi32(_mm_max_ps(_mm_min_ps(b, g_max), g_min)));
+ dstr[(15 - i)*nch] = _mm_extract_epi16(pcm8, 1);
+ dstr[(17 + i)*nch] = _mm_extract_epi16(pcm8, 5);
+ dstl[(15 - i)*nch] = _mm_extract_epi16(pcm8, 0);
+ dstl[(17 + i)*nch] = _mm_extract_epi16(pcm8, 4);
+ dstr[(47 - i)*nch] = _mm_extract_epi16(pcm8, 3);
+ dstr[(49 + i)*nch] = _mm_extract_epi16(pcm8, 7);
+ dstl[(47 - i)*nch] = _mm_extract_epi16(pcm8, 2);
+ dstl[(49 + i)*nch] = _mm_extract_epi16(pcm8, 6);
+#else /* HAVE_SSE */
+ int16x4_t pcma, pcmb;
+ a = VADD(a, VSET(0.5f));
+ b = VADD(b, VSET(0.5f));
+ pcma = vqmovn_s32(vqaddq_s32(vcvtq_s32_f32(a), vreinterpretq_s32_u32(vcltq_f32(a, VSET(0)))));
+ pcmb = vqmovn_s32(vqaddq_s32(vcvtq_s32_f32(b), vreinterpretq_s32_u32(vcltq_f32(b, VSET(0)))));
+ vst1_lane_s16(dstr + (15 - i)*nch, pcma, 1);
+ vst1_lane_s16(dstr + (17 + i)*nch, pcmb, 1);
+ vst1_lane_s16(dstl + (15 - i)*nch, pcma, 0);
+ vst1_lane_s16(dstl + (17 + i)*nch, pcmb, 0);
+ vst1_lane_s16(dstr + (47 - i)*nch, pcma, 3);
+ vst1_lane_s16(dstr + (49 + i)*nch, pcmb, 3);
+ vst1_lane_s16(dstl + (47 - i)*nch, pcma, 2);
+ vst1_lane_s16(dstl + (49 + i)*nch, pcmb, 2);
+#endif /* HAVE_SSE */
+
+#else /* MINIMP3_FLOAT_OUTPUT */
+
+ static const f4 g_scale = { 1.0f/32768.0f, 1.0f/32768.0f, 1.0f/32768.0f, 1.0f/32768.0f };
+ a = VMUL(a, g_scale);
+ b = VMUL(b, g_scale);
+#if HAVE_SSE
+ _mm_store_ss(dstr + (15 - i)*nch, _mm_shuffle_ps(a, a, _MM_SHUFFLE(1, 1, 1, 1)));
+ _mm_store_ss(dstr + (17 + i)*nch, _mm_shuffle_ps(b, b, _MM_SHUFFLE(1, 1, 1, 1)));
+ _mm_store_ss(dstl + (15 - i)*nch, _mm_shuffle_ps(a, a, _MM_SHUFFLE(0, 0, 0, 0)));
+ _mm_store_ss(dstl + (17 + i)*nch, _mm_shuffle_ps(b, b, _MM_SHUFFLE(0, 0, 0, 0)));
+ _mm_store_ss(dstr + (47 - i)*nch, _mm_shuffle_ps(a, a, _MM_SHUFFLE(3, 3, 3, 3)));
+ _mm_store_ss(dstr + (49 + i)*nch, _mm_shuffle_ps(b, b, _MM_SHUFFLE(3, 3, 3, 3)));
+ _mm_store_ss(dstl + (47 - i)*nch, _mm_shuffle_ps(a, a, _MM_SHUFFLE(2, 2, 2, 2)));
+ _mm_store_ss(dstl + (49 + i)*nch, _mm_shuffle_ps(b, b, _MM_SHUFFLE(2, 2, 2, 2)));
+#else /* HAVE_SSE */
+ vst1q_lane_f32(dstr + (15 - i)*nch, a, 1);
+ vst1q_lane_f32(dstr + (17 + i)*nch, b, 1);
+ vst1q_lane_f32(dstl + (15 - i)*nch, a, 0);
+ vst1q_lane_f32(dstl + (17 + i)*nch, b, 0);
+ vst1q_lane_f32(dstr + (47 - i)*nch, a, 3);
+ vst1q_lane_f32(dstr + (49 + i)*nch, b, 3);
+ vst1q_lane_f32(dstl + (47 - i)*nch, a, 2);
+ vst1q_lane_f32(dstl + (49 + i)*nch, b, 2);
+#endif /* HAVE_SSE */
+#endif /* MINIMP3_FLOAT_OUTPUT */
+ }
+ } else
+#endif /* HAVE_SIMD */
+#ifdef MINIMP3_ONLY_SIMD
+ {} /* for HAVE_SIMD=1, MINIMP3_ONLY_SIMD=1 case we do not need non-intrinsic "else" branch */
+#else /* MINIMP3_ONLY_SIMD */
+ for (i = 14; i >= 0; i--)
+ {
+#define LOAD(k) float w0 = *w++; float w1 = *w++; float *vz = &zlin[4*i - k*64]; float *vy = &zlin[4*i - (15 - k)*64];
+#define S0(k) { int j; LOAD(k); for (j = 0; j < 4; j++) b[j] = vz[j]*w1 + vy[j]*w0, a[j] = vz[j]*w0 - vy[j]*w1; }
+#define S1(k) { int j; LOAD(k); for (j = 0; j < 4; j++) b[j] += vz[j]*w1 + vy[j]*w0, a[j] += vz[j]*w0 - vy[j]*w1; }
+#define S2(k) { int j; LOAD(k); for (j = 0; j < 4; j++) b[j] += vz[j]*w1 + vy[j]*w0, a[j] += vy[j]*w1 - vz[j]*w0; }
+ float a[4], b[4];
+
+ zlin[4*i] = xl[18*(31 - i)];
+ zlin[4*i + 1] = xr[18*(31 - i)];
+ zlin[4*i + 2] = xl[1 + 18*(31 - i)];
+ zlin[4*i + 3] = xr[1 + 18*(31 - i)];
+ zlin[4*(i + 16)] = xl[1 + 18*(1 + i)];
+ zlin[4*(i + 16) + 1] = xr[1 + 18*(1 + i)];
+ zlin[4*(i - 16) + 2] = xl[18*(1 + i)];
+ zlin[4*(i - 16) + 3] = xr[18*(1 + i)];
+
+ S0(0) S2(1) S1(2) S2(3) S1(4) S2(5) S1(6) S2(7)
+
+ dstr[(15 - i)*nch] = mp3d_scale_pcm(a[1]);
+ dstr[(17 + i)*nch] = mp3d_scale_pcm(b[1]);
+ dstl[(15 - i)*nch] = mp3d_scale_pcm(a[0]);
+ dstl[(17 + i)*nch] = mp3d_scale_pcm(b[0]);
+ dstr[(47 - i)*nch] = mp3d_scale_pcm(a[3]);
+ dstr[(49 + i)*nch] = mp3d_scale_pcm(b[3]);
+ dstl[(47 - i)*nch] = mp3d_scale_pcm(a[2]);
+ dstl[(49 + i)*nch] = mp3d_scale_pcm(b[2]);
+ }
+#endif /* MINIMP3_ONLY_SIMD */
+}
+
+static void mp3d_synth_granule(float *qmf_state, float *grbuf, int nbands, int nch, mp3d_sample_t *pcm, float *lins)
+{
+ int i;
+ for (i = 0; i < nch; i++)
+ {
+ mp3d_DCT_II(grbuf + 576*i, nbands);
+ }
+
+ memcpy(lins, qmf_state, sizeof(float)*15*64);
+
+ for (i = 0; i < nbands; i += 2)
+ {
+ mp3d_synth(grbuf + i, pcm + 32*nch*i, nch, lins + i*64);
+ }
+#ifndef MINIMP3_NONSTANDARD_BUT_LOGICAL
+ if (nch == 1)
+ {
+ for (i = 0; i < 15*64; i += 2)
+ {
+ qmf_state[i] = lins[nbands*64 + i];
+ }
+ } else
+#endif /* MINIMP3_NONSTANDARD_BUT_LOGICAL */
+ {
+ memcpy(qmf_state, lins + nbands*64, sizeof(float)*15*64);
+ }
+}
+
+static int mp3d_match_frame(const uint8_t *hdr, int mp3_bytes, int frame_bytes)
+{
+ int i, nmatch;
+ for (i = 0, nmatch = 0; nmatch < MAX_FRAME_SYNC_MATCHES; nmatch++)
+ {
+ i += hdr_frame_bytes(hdr + i, frame_bytes) + hdr_padding(hdr + i);
+ if (i + HDR_SIZE > mp3_bytes)
+ return nmatch > 0;
+ if (!hdr_compare(hdr, hdr + i))
+ return 0;
+ }
+ return 1;
+}
+
+static int mp3d_find_frame(const uint8_t *mp3, int mp3_bytes, int *free_format_bytes, int *ptr_frame_bytes)
+{
+ int i, k;
+ for (i = 0; i < mp3_bytes - HDR_SIZE; i++, mp3++)
+ {
+ if (hdr_valid(mp3))
+ {
+ int frame_bytes = hdr_frame_bytes(mp3, *free_format_bytes);
+ int frame_and_padding = frame_bytes + hdr_padding(mp3);
+
+ for (k = HDR_SIZE; !frame_bytes && k < MAX_FREE_FORMAT_FRAME_SIZE && i + 2*k < mp3_bytes - HDR_SIZE; k++)
+ {
+ if (hdr_compare(mp3, mp3 + k))
+ {
+ int fb = k - hdr_padding(mp3);
+ int nextfb = fb + hdr_padding(mp3 + k);
+ if (i + k + nextfb + HDR_SIZE > mp3_bytes || !hdr_compare(mp3, mp3 + k + nextfb))
+ continue;
+ frame_and_padding = k;
+ frame_bytes = fb;
+ *free_format_bytes = fb;
+ }
+ }
+ if ((frame_bytes && i + frame_and_padding <= mp3_bytes &&
+ mp3d_match_frame(mp3, mp3_bytes - i, frame_bytes)) ||
+ (!i && frame_and_padding == mp3_bytes))
+ {
+ *ptr_frame_bytes = frame_and_padding;
+ return i;
+ }
+ *free_format_bytes = 0;
+ }
+ }
+ *ptr_frame_bytes = 0;
+ return mp3_bytes;
+}
+
+void mp3dec_init(mp3dec_t *dec)
+{
+ dec->header[0] = 0;
+}
+
+int mp3dec_decode_frame(mp3dec_t *dec, const uint8_t *mp3, int mp3_bytes, mp3d_sample_t *pcm, mp3dec_frame_info_t *info)
+{
+ int i = 0, igr, frame_size = 0, success = 1;
+ const uint8_t *hdr;
+ bs_t bs_frame[1];
+#ifdef MINIMP3_SCRATCH
+#define scratch (*MINIMP3_SCRATCH)
+#else
+ mp3dec_scratch_t scratch;
+#endif
+
+ if (mp3_bytes > 4 && dec->header[0] == 0xff && hdr_compare(dec->header, mp3))
+ {
+ frame_size = hdr_frame_bytes(mp3, dec->free_format_bytes) + hdr_padding(mp3);
+ if (frame_size != mp3_bytes && (frame_size + HDR_SIZE > mp3_bytes || !hdr_compare(mp3, mp3 + frame_size)))
+ {
+ frame_size = 0;
+ }
+ }
+ if (!frame_size)
+ {
+ memset(dec, 0, sizeof(mp3dec_t));
+ i = mp3d_find_frame(mp3, mp3_bytes, &dec->free_format_bytes, &frame_size);
+ if (!frame_size || i + frame_size > mp3_bytes)
+ {
+ info->frame_bytes = i;
+ return 0;
+ }
+ }
+
+ hdr = mp3 + i;
+ memcpy(dec->header, hdr, HDR_SIZE);
+ info->frame_bytes = i + frame_size;
+ info->frame_offset = i;
+ info->channels = HDR_IS_MONO(hdr) ? 1 : 2;
+ info->hz = hdr_sample_rate_hz(hdr);
+ info->layer = 4 - HDR_GET_LAYER(hdr);
+ info->bitrate_kbps = hdr_bitrate_kbps(hdr);
+
+ if (!pcm)
+ {
+ return hdr_frame_samples(hdr);
+ }
+
+ bs_init(bs_frame, hdr + HDR_SIZE, frame_size - HDR_SIZE);
+ if (HDR_IS_CRC(hdr))
+ {
+ get_bits(bs_frame, 16);
+ }
+
+ if (info->layer == 3)
+ {
+ int main_data_begin = L3_read_side_info(bs_frame, scratch.gr_info, hdr);
+ if (main_data_begin < 0 || bs_frame->pos > bs_frame->limit)
+ {
+ mp3dec_init(dec);
+ return 0;
+ }
+ success = L3_restore_reservoir(dec, bs_frame, &scratch, main_data_begin);
+ if (success)
+ {
+ for (igr = 0; igr < (HDR_TEST_MPEG1(hdr) ? 2 : 1); igr++, pcm += 576*info->channels)
+ {
+ memset(scratch.grbuf[0], 0, 576*2*sizeof(float));
+ L3_decode(dec, &scratch, scratch.gr_info + igr*info->channels, info->channels);
+ mp3d_synth_granule(dec->qmf_state, scratch.grbuf[0], 18, info->channels, pcm, scratch.syn[0]);
+ }
+ }
+ L3_save_reservoir(dec, &scratch);
+ } else
+ {
+#ifdef MINIMP3_ONLY_MP3
+ return 0;
+#else /* MINIMP3_ONLY_MP3 */
+ L12_scale_info sci[1];
+ L12_read_scale_info(hdr, bs_frame, sci);
+
+ memset(scratch.grbuf[0], 0, 576*2*sizeof(float));
+ for (i = 0, igr = 0; igr < 3; igr++)
+ {
+ if (12 == (i += L12_dequantize_granule(scratch.grbuf[0] + i, bs_frame, sci, info->layer | 1)))
+ {
+ i = 0;
+ L12_apply_scf_384(sci, sci->scf + igr, scratch.grbuf[0]);
+ mp3d_synth_granule(dec->qmf_state, scratch.grbuf[0], 12, info->channels, pcm, scratch.syn[0]);
+ memset(scratch.grbuf[0], 0, 576*2*sizeof(float));
+ pcm += 384*info->channels;
+ }
+ if (bs_frame->pos > bs_frame->limit)
+ {
+ mp3dec_init(dec);
+ return 0;
+ }
+ }
+#endif /* MINIMP3_ONLY_MP3 */
+ }
+#ifdef MINIMP3_SCRATCH
+#undef scratch
+#endif
+ return success*hdr_frame_samples(dec->header);
+}
+
+#ifdef MINIMP3_FLOAT_OUTPUT
+void mp3dec_f32_to_s16(const float *in, int16_t *out, int num_samples)
+{
+ int i = 0;
+#if HAVE_SIMD
+ int aligned_count = num_samples & ~7;
+ for(; i < aligned_count; i += 8)
+ {
+ static const f4 g_scale = { 32768.0f, 32768.0f, 32768.0f, 32768.0f };
+ f4 a = VMUL(VLD(&in[i ]), g_scale);
+ f4 b = VMUL(VLD(&in[i+4]), g_scale);
+#if HAVE_SSE
+ static const f4 g_max = { 32767.0f, 32767.0f, 32767.0f, 32767.0f };
+ static const f4 g_min = { -32768.0f, -32768.0f, -32768.0f, -32768.0f };
+ __m128i pcm8 = _mm_packs_epi32(_mm_cvtps_epi32(_mm_max_ps(_mm_min_ps(a, g_max), g_min)),
+ _mm_cvtps_epi32(_mm_max_ps(_mm_min_ps(b, g_max), g_min)));
+ out[i ] = _mm_extract_epi16(pcm8, 0);
+ out[i+1] = _mm_extract_epi16(pcm8, 1);
+ out[i+2] = _mm_extract_epi16(pcm8, 2);
+ out[i+3] = _mm_extract_epi16(pcm8, 3);
+ out[i+4] = _mm_extract_epi16(pcm8, 4);
+ out[i+5] = _mm_extract_epi16(pcm8, 5);
+ out[i+6] = _mm_extract_epi16(pcm8, 6);
+ out[i+7] = _mm_extract_epi16(pcm8, 7);
+#else /* HAVE_SSE */
+ int16x4_t pcma, pcmb;
+ a = VADD(a, VSET(0.5f));
+ b = VADD(b, VSET(0.5f));
+ pcma = vqmovn_s32(vqaddq_s32(vcvtq_s32_f32(a), vreinterpretq_s32_u32(vcltq_f32(a, VSET(0)))));
+ pcmb = vqmovn_s32(vqaddq_s32(vcvtq_s32_f32(b), vreinterpretq_s32_u32(vcltq_f32(b, VSET(0)))));
+ vst1_lane_s16(out+i , pcma, 0);
+ vst1_lane_s16(out+i+1, pcma, 1);
+ vst1_lane_s16(out+i+2, pcma, 2);
+ vst1_lane_s16(out+i+3, pcma, 3);
+ vst1_lane_s16(out+i+4, pcmb, 0);
+ vst1_lane_s16(out+i+5, pcmb, 1);
+ vst1_lane_s16(out+i+6, pcmb, 2);
+ vst1_lane_s16(out+i+7, pcmb, 3);
+#endif /* HAVE_SSE */
+ }
+#endif /* HAVE_SIMD */
+ for(; i < num_samples; i++)
+ {
+ float sample = in[i] * 32768.0f;
+ if (sample >= 32766.5)
+ out[i] = (int16_t) 32767;
+ else if (sample <= -32767.5)
+ out[i] = (int16_t)-32768;
+ else
+ {
+ int16_t s = (int16_t)(sample + .5f);
+ s -= (s < 0); /* away from zero, to be compliant */
+ out[i] = s;
+ }
+ }
+}
+#endif /* MINIMP3_FLOAT_OUTPUT */
+#endif /* MINIMP3_IMPLEMENTATION && !_MINIMP3_IMPLEMENTATION_GUARD */
diff --git a/platformio.ini b/platformio.ini
index 0d67afa..0c51f74 100644
--- a/platformio.ini
+++ b/platformio.ini
@@ -613,6 +613,15 @@ build_flags =
-D HAS_TDECK_GT911=1
-D HAS_TDECK_TRACKBALL=1
-D HAS_TDECK_KEYBOARD=1
+ ; Forced here (not just in lv_conf.h) because .pio/libdeps//MeshCore/include/lv_conf.h
+ ; -- a stale snapshot vendored by the pinned core tag -- sits earlier in the -I search
+ ; order than this repo's own include/lv_conf.h, so LV_CONF_INCLUDE_SIMPLE's quoted
+ ; #include "lv_conf.h" silently resolves to that stale copy for src/ compiles (though
+ ; library .c files, e.g. lvgl's own, still see the right one -- inconsistent per TU). A
+ ; -D here is immune to that: it's set before any header is processed and isn't
+ ; overridden by a plain #define. Only 20px is forced, for the "at a glance" experiment
+ ; below (atGlanceEnsureFont() in UITask.cpp) -- not 18/24, which this board doesn't use.
+ -D LV_FONT_MONTSERRAT_20=1
-D ENABLE_ADVERT_ON_BOOT=0
-D PIN_TB_UP=15
-D PIN_TB_DOWN=3
@@ -1143,13 +1152,17 @@ board_build.partitions = variants/thinknode_m9/partitions_m9_touch.csv
; DriveDiosInSleepMode (0x012A, FW 0x0308+) with CMD_PERR -> begin() = -706.
; The pre-script makes RadioLib's config() skip it on old FW (idempotent,
; per-env libdeps copy). See scripts/build/patch_radiolib_lr11x0.py.
-extra_scripts = pre:scripts/build/pre_gen_baked.py, pre:scripts/inject_wifi_env.py, pre:scripts/build/patch_radiolib_lr11x0.py, merge-bin.py
+extra_scripts = pre:scripts/build/pre_gen_baked.py, pre:scripts/inject_wifi_env.py, pre:scripts/build/patch_radiolib_lr11x0.py, pre:scripts/build/patch_meshcore_rv3028_define.py, merge-bin.py
+
+build_unflags =
+ -std=gnu++11
+ -std=gnu++14
build_flags =
; 1.17 core: ESP32Board.cpp includes -> MicroNMEALocationProvider.h (header-only
; provider), so MicroNMEA never chains into the core lib's LDF scope -- hand it the path.
-I"${platformio.libdeps_dir}/${this.__env__}/MicroNMEA/src"
- -Wno-deprecated-declarations -Wno-unused-parameter -DNDEBUG -DRADIOLIB_STATIC_ONLY=1 -DRADIOLIB_GODMODE=1
+ -Wno-deprecated-declarations -Wno-unused-parameter -Wno-cpp -Wno-overflow -Wno-return-type -std=gnu++17 -DNDEBUG -DRADIOLIB_STATIC_ONLY=1 -DRADIOLIB_GODMODE=1
-D MC_VENDORED_TOUCH_APP
-D LORA_FREQ=869.618
-D LORA_BW=62.5
@@ -1331,9 +1344,8 @@ build_flags =
-D HAS_TOUCH_UI=1
-D FIRMWARE_OTA_ENV='"ThinkNode_M9_companion_radio_touch"'
-D ENABLE_ADVERT_ON_BOOT=0
- -D LV_CONF_PATH=lv_conf.h
+ -D LV_CONF_PATH=lv_conf_m9.h
-D LV_CONF_INCLUDE_SIMPLE=1
- -D LV_INDEV_DEF_SCROLL_THROW=7
-D MAX_CONTACTS=2000
-D MAX_GROUP_CHANNELS=40
-D DISPLAY_CLASS=ST7789LCDDisplay
diff --git a/release-notes/beta_70.txt b/release-notes/beta_70.txt
new file mode 100644
index 0000000..c713bbc
--- /dev/null
+++ b/release-notes/beta_70.txt
@@ -0,0 +1,39 @@
+# beta_70 - test build. Stable stays beta_65.
+# One user-facing note per non-blank, non-# line; # lines are section comments.
+
+# --- The crash on the regions screen ------------------------------------------------------
+Adding or removing known regions and then leaving that screen crashed the device. If you have been unable to get out of Radio & mesh without resetting, this was why.
+The cause was general, not specific to regions: when a text field is destroyed, the interface library sends it one last "you lost focus" event, and seven fields in the firmware save their value on losing focus. So a field was reading and rewriting itself from inside its own destructor. All seven now ignore a blur that is really a deletion.
+Found by decoding the core dump attached to the report, against a rebuilt beta_69 image. Thank you for sending it.
+
+# --- Translations: undoing damage we caused ------------------------------------------------
+Language file v18 contained keys that do not exist anywhere in the firmware. Entries like "Paste (move)Paste (copy)" and "Unblock Block" were two separate buttons glued into one, and one entry was a Dutch phrase that appears only inside a source-code comment.
+That was a bug in the tool that decides which text belongs in a language file, introduced in the last release. It has been fixed twice over: it no longer joins the two halves of a choice into one entry, and it no longer reads text out of comments.
+202 invented entries are gone from all thirteen files. Nothing a translator had actually translated was touched.
+The curly quotes in the Hungarian map credits showed as empty boxes because no bundled font contains them. Replaced with straight quotes.
+Five more strings are translatable: the Wi-Fi rescan and hidden-network rows, the update check, and the downgrade prompt.
+Language files are at v19. v18 is withdrawn.
+
+# --- The Store stops offering apps your board cannot run -----------------------------------
+Wardrive and Nearby need the extended SDK, which the Heltec V4 does not have. The Store offered both anyway, so the most common board in the mesh could download, install and open two apps that then refused to work, with no warning. Apps can now say what they need, and the Store says so on the row instead.
+
+# --- Community work, by Michael A. Cojocari (@oumike) ---------------------------------------
+Lua apps can play WAV and MP3 audio, from the SD card or from memory.
+@-mention autocomplete in the device chat and the web interface, suggesting people whose adverts this device actually heard rather than everyone in your contacts.
+Symbol and alt keys latch: tap once for the next key, tap twice to lock the layer until you tap again. No more holding a modifier down.
+The web reader opens a home page from the SD card if you put one there, so a device with no internet still has somewhere to start.
+Wardrive no longer stops when a node name contains an emoji. Names are cut on character boundaries now, and text coming from an app is checked before it reaches the screen.
+Screenshots from the Control Center on the ThinkNode M9 and both T-Lora Pagers, with a timer for immediate, 3-second or 10-second capture.
+The M9's Home key works while you are typing in a text field, and the built-in Snake starts from the first direction you press.
+Tapping a chat composer that is already focused reopens the on-screen keyboard instead of doing nothing.
+
+# --- Community work, by Tesso M Costa (@codemonkeybr) --------------------------------------
+A new "At a glance" notification style: when a message arrives while the screen is dimmed, it shows a short preview without fully waking the device or lighting the keyboard. Optional, and it can be limited to when the device is unlocked.
+
+# --- Also fixed -----------------------------------------------------------------------------
+Granting an app permission could silently take a permission away from a different app. The switches packed the app's position and the permission into too few bits, so "Send discovery probes" wrote the wrong app's record, marking an app you never touched as refused. That is why apps reported "permission denied" with every permission granted.
+
+# --- For testers ------------------------------------------------------------------------------
+The regions crash is the fix most worth confirming: add and remove a few known regions, leave the screen, and check the device stays up.
+If you translate, your language file is clean again but has more untranslated entries than before, because strings that were previously invisible to the checker are now listed.
+Console mode is still an early preview and has not changed this release.
diff --git a/release-notes/beta_71.txt b/release-notes/beta_71.txt
new file mode 100644
index 0000000..1ddb87d
--- /dev/null
+++ b/release-notes/beta_71.txt
@@ -0,0 +1,14 @@
+# beta_71 - test build. Stable stays beta_65.
+# One user-facing note per non-blank, non-# line; # lines are section comments.
+
+# --- For people writing Lua apps ------------------------------------------------------------
+Apps can clear their page. There was no way to remove the widgets an app had already created, so anything with a second screen drew it on top of the first. wada.ui.clear() takes the page back to empty, and any widget you were still holding from before the clear becomes inert rather than dangerous.
+Apps can reach all your contacts. The list stopped at 100, and it only ever looked at the first 200 in the first place, so on a device with a lot of contacts most of them were unreachable and nothing said so. wada.mesh.contacts() now takes an offset and a limit, and wada.mesh.contact_count() tells an app how many there are to page through.
+Apps can be three times larger. The limit was 64 KB, which an ordinary program can reach. It is 192 KB now.
+
+# --- T-Deck keyboard ---------------------------------------------------------------------------
+Modifier latching could fail to turn on even with the right keyboard firmware installed. The firmware asked the keyboard what it was capable of exactly once, on the first read after boot, and the first read after boot is the one most likely to come back garbled because the keyboard controller is still starting. One bad answer meant no latching for the rest of the session, and rebooting simply asked again at the same unlucky moment. It now keeps asking for a second and a half before giving up, and says what it actually saw when it does.
+
+# --- For testers ---------------------------------------------------------------------------------
+This is a small drop on top of beta_70, which carried the fix for the crash when leaving the known-regions screen. If you have not tried that yet, it is still the thing most worth confirming: add and remove a few regions, leave the screen, check the device stays up.
+If your screen is not going to sleep, we are still investigating (issue 333) and it is not fixed here.
diff --git a/scripts/audio_test.lua b/scripts/audio_test.lua
new file mode 100644
index 0000000..c539d3a
--- /dev/null
+++ b/scripts/audio_test.lua
@@ -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
diff --git a/scripts/build/i18n-audit.py b/scripts/build/i18n-audit.py
index 1466401..1f56efb 100644
--- a/scripts/build/i18n-audit.py
+++ b/scripts/build/i18n-audit.py
@@ -31,7 +31,7 @@ def source_keys():
keys = set()
for f in glob.glob(os.path.join(ROOT, 'src/ui-touch/*.cpp')) + \
glob.glob(os.path.join(ROOT, 'src/ui-touch/*.h')):
- src = open(f, encoding='utf-8').read()
+ src = strip_comments(open(f, encoding='utf-8').read())
# An optional LV_SYMBOL_* macro may precede the literal: TR(LV_SYMBOL_OK " Text").
# Requiring the group to START with a quote silently skipped every such
# call - the audit's worst blind spot (the sheet rows never got checked).
@@ -55,7 +55,7 @@ def source_keys():
# collecting keeps a translation alive, under-collecting deletes one.
for f in glob.glob(os.path.join(ROOT, 'src/ui-touch/*.cpp')) + \
glob.glob(os.path.join(ROOT, 'src/ui-touch/*.h')):
- src = open(f, encoding='utf-8').read()
+ src = strip_comments(open(f, encoding='utf-8').read())
for name in set(re.findall(r'TR\(\s*([A-Za-z_][A-Za-z0-9_]*)\s*\[', src)):
for m in re.finditer(r'\b' + re.escape(name) + r'\s*\[[^\]]*\]\s*=\s*\{', src):
i, depth = m.end(), 1
@@ -80,24 +80,22 @@ def source_keys():
# parameters and note which parameter, then pull the literal out of that
# argument position at every call site.
for f in _sources():
- src = open(f, encoding='utf-8').read()
+ src = strip_comments(open(f, encoding='utf-8').read())
for name, idx in _tr_wrapping_helpers(src):
for arg in _call_args_at(src, name, idx):
- parts = re.findall(r'"((?:[^"\\]|\\.)*)"', arg)
- if not parts:
- continue
- k = unescape_c(''.join(parts))
- while k and 0xE000 <= ord(k[0]) <= 0xF8FF:
- k = k[1:]
- k = k.lstrip(' ')
- if k.strip():
- keys.add(k)
+ for lit in literal_groups(arg):
+ k = unescape_c(lit)
+ while k and 0xE000 <= ord(k[0]) <= 0xF8FF:
+ k = k[1:]
+ k = k.lstrip(' ')
+ if k.strip():
+ keys.add(k)
# RANGE-FOR over a local table: `struct {...} rows[] = {{"Show contacts", ...}};`
# then `for (auto& r : rows) TR(r.label)`. Same blind spot as the helper case --
# correct at runtime, invisible to a TR("literal") scan, so untranslatable.
for f in _sources():
- src = open(f, encoding='utf-8').read()
+ src = strip_comments(open(f, encoding='utf-8').read())
for m in re.finditer(r'for\s*\(\s*(?:const\s+)?auto\s*&?\s*(\w+)\s*:\s*(\w+)\s*\)', src):
var, tbl = m.group(1), m.group(2)
brace = src.find('{', m.end())
@@ -119,7 +117,7 @@ def source_keys():
# TR(someFunc(...)): a helper that RETURNS one of several literals, translated
# by the caller. Every literal it can return is a key.
for f in _sources():
- src = open(f, encoding='utf-8').read()
+ src = strip_comments(open(f, encoding='utf-8').read())
for name in set(re.findall(r'TR\(\s*([A-Za-z_]\w*)\s*\(', src)):
for d in re.finditer(r'\b' + re.escape(name) + r'\s*\([^;{}]*\)\s*\{', src):
seg = src[d.end() - 1:_balanced(src, d.end() - 1)]
@@ -130,7 +128,7 @@ def source_keys():
# LUA APPS: wada.sys.tr("...") (aliased to a local `tr` by convention). The
# apps ship from the same catalog and their strings belong in the same files.
for f in sorted(glob.glob(os.path.join(ROOT, 'deploy/apps/*/*/*.lua'))):
- src = open(f, encoding='utf-8').read()
+ src = strip_comments(open(f, encoding='utf-8').read())
for lit in re.findall(r'\b(?:sys\.)?tr\(\s*"((?:[^"\\]|\\.)*)"', src):
k = unescape_c(lit)
if k.strip():
@@ -138,6 +136,66 @@ def source_keys():
return keys
+def strip_comments(src):
+ """Blank out //... and /*...*/ while preserving string/char literals and line
+ count. The extractor scans raw text, so a quoted phrase inside a comment was
+ being collected as a translatable key -- that is how the Dutch string
+ "Geblokkeerde gebruikers", which only appears in a comment explaining how a
+ long translation degrades, ended up as a KEY in all thirteen files."""
+ out, i, n = [], 0, len(src)
+ while i < n:
+ c = src[i]
+ if c == '"' or c == "'":
+ q = c; out.append(c); i += 1
+ while i < n:
+ out.append(src[i])
+ if src[i] == '\\' and i + 1 < n:
+ out.append(src[i + 1]); i += 2; continue
+ if src[i] == q: i += 1; break
+ i += 1
+ continue
+ if c == '/' and i + 1 < n and src[i + 1] == '/':
+ while i < n and src[i] != '\n': i += 1
+ continue
+ if c == '/' and i + 1 < n and src[i + 1] == '*':
+ i += 2
+ while i + 1 < n and not (src[i] == '*' and src[i + 1] == '/'):
+ if src[i] == '\n': out.append('\n')
+ i += 1
+ i += 2
+ continue
+ out.append(c); i += 1
+ return ''.join(out)
+
+
+def literal_groups(arg):
+ """Keys from one call argument.
+
+ A C compiler concatenates ADJACENT string literals, so `"a" "b"` is one
+ string. `cond ? "a" : "b"` is two, and joining them (which this used to do)
+ invented keys that exist nowhere: "Paste (move)Paste (copy)",
+ "Unblock Block", "Unfav Favorite". Those shipped to every translator in
+ v18. Only whitespace and an LV_SYMBOL_* macro may sit between literals of
+ one group; anything else starts a new one."""
+ groups, cur, i, n = [], [], 0, len(arg)
+ while i < n:
+ c = arg[i]
+ if c == '"':
+ j, buf = i + 1, []
+ while j < n:
+ if arg[j] == '\\' and j + 1 < n: buf.append(arg[j:j + 2]); j += 2; continue
+ if arg[j] == '"': break
+ buf.append(arg[j]); j += 1
+ cur.append(''.join(buf)); i = j + 1; continue
+ if c.isspace(): i += 1; continue
+ m = re.match(r'LV_SYMBOL_[A-Z0-9_]+', arg[i:])
+ if m: i += m.end(); continue
+ if cur: groups.append(cur); cur = []
+ i += 1
+ if cur: groups.append(cur)
+ return [''.join(g) for g in groups]
+
+
def _sources():
return sorted(glob.glob(os.path.join(ROOT, 'src/ui-touch/*.cpp')) +
glob.glob(os.path.join(ROOT, 'src/ui-touch/*.h')))
diff --git a/scripts/build/patch_meshcore_rv3028_define.py b/scripts/build/patch_meshcore_rv3028_define.py
new file mode 100644
index 0000000..ab88690
--- /dev/null
+++ b/scripts/build/patch_meshcore_rv3028_define.py
@@ -0,0 +1,34 @@
+Import("env")
+import os
+
+MARKER = "wadamesh-rv3028-guard-patch"
+OLD = "#define RV3028_ADDRESS 0x52"
+NEW = """#ifndef RV3028_ADDRESS
+#define RV3028_ADDRESS 0x52
+#endif // wadamesh-rv3028-guard-patch"""
+
+path = os.path.join(
+ env.subst("$PROJECT_LIBDEPS_DIR"),
+ env.subst("$PIOENV"),
+ "MeshCore",
+ "src",
+ "helpers",
+ "AutoDiscoverRTCClock.cpp",
+)
+
+if not os.path.isfile(path):
+ print("[patch_meshcore_rv3028_define] WARNING: %s not found (libdeps not fetched yet?)" % path)
+ print("[patch_meshcore_rv3028_define] WARNING: MeshCore NOT patched - re-run build once libdeps exist")
+else:
+ with open(path) as f:
+ src = f.read()
+
+ if MARKER in src:
+ print("[patch_meshcore_rv3028_define] already patched")
+ elif OLD in src:
+ with open(path, "w") as f:
+ f.write(src.replace(OLD, NEW, 1))
+ print("[patch_meshcore_rv3028_define] patched RV3028_ADDRESS guard in AutoDiscoverRTCClock.cpp")
+ else:
+ print("[patch_meshcore_rv3028_define] WARNING: pattern not found - MeshCore version drift?")
+ print("[patch_meshcore_rv3028_define] WARNING: check AutoDiscoverRTCClock.cpp by hand, NOT patched")
\ No newline at end of file
diff --git a/scripts/lua-harness/README.md b/scripts/lua-harness/README.md
index 08cfa7b..771b411 100644
--- a/scripts/lua-harness/README.md
+++ b/scripts/lua-harness/README.md
@@ -5,7 +5,7 @@ it ever touches a device. Not part of the firmware build.
```sh
scripts/lua-harness/run.sh # gpscompass, all scenarios
-scripts/lua-harness/run.sh deploy/apps/wardrive/1.0/wardrive.lua
+scripts/lua-harness/run.sh deploy/apps/wardrive/1.1/wardrive.lua
SCENARIO=declination scripts/lua-harness/run.sh # one scenario
```
diff --git a/scripts/lua-harness/harness.lua b/scripts/lua-harness/harness.lua
index dc7adc2..3f8a205 100644
--- a/scripts/lua-harness/harness.lua
+++ b/scripts/lua-harness/harness.lua
@@ -10,6 +10,27 @@ local function checkint(v, what) assert(math.tointeger(v) ~= nil, what .. ": not
local function checkcol(v, what) if v ~= nil then checkint(v, what .. " color") end end
local function checkstr(v, what) assert(type(v) == "string" or type(v) == "number", what .. ": not a string") end
+local function valid_utf8(text)
+ local i, n = 1, #text
+ while i <= n do
+ local first = text:byte(i)
+ local width = first <= 0x7F and 1
+ or (first >= 0xC2 and first <= 0xDF and 2)
+ or (first >= 0xE0 and first <= 0xEF and 3)
+ or (first >= 0xF0 and first <= 0xF4 and 4) or 0
+ if width == 0 or i + width - 1 > n then return false end
+ for j = 2, width do
+ local byte = text:byte(i + j - 1)
+ if byte < 0x80 or byte > 0xBF then return false end
+ end
+ local second = width > 1 and text:byte(i + 1) or 0
+ if (first == 0xE0 and second < 0xA0) or (first == 0xED and second > 0x9F) or
+ (first == 0xF0 and second < 0x90) or (first == 0xF4 and second > 0x8F) then return false end
+ i = i + width
+ end
+ return true
+end
+
-- instruction budget like guardedCall(): error() out of the hook, pcall catches
local function guarded(budget, fn, ...)
local count = 0
@@ -26,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")
@@ -77,7 +105,7 @@ local function mkbutton(text, x, y, w, h, fn)
end
local function build_wada()
- local wada = { ui = {}, sys = {}, mesh = {}, store = {}, timer = {}, net = {} }
+ local wada = { ui = {}, sys = {}, mesh = {}, store = {}, timer = {}, net = {}, fs = {} }
wada.ui.colors = { accent = 0x15B6A6, text = 0xE6E9ED, sub = 0x7A7F87, bg = 0x000000, panel = 0x15181B, bad = 0xD7574E, good = 0x53C06B }
wada.ui.canvas = mkcanvas
wada.ui.label = mklabel
@@ -102,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 } 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
@@ -118,6 +151,12 @@ local function build_wada()
wada.mesh.self = function() return { name = "me", lat = cfg.self_lat or 0, lon = cfg.self_lon or 0 } end
wada.mesh.stats = function() return {} end
wada.mesh.rx_log = function() return {} end
+ wada.mesh.discover = function() return "probe-1" end
+ wada.mesh.discovered = function() return cfg.discovered or {} end
+ wada.mesh.discover_clear = function() cfg.discovered = {} end
+
+ wada.fs.append = function(name, data) checkstr(name, "fs.append name"); checkstr(data, "fs.append data"); return true end
+ wada.fs.remove = function(name) checkstr(name, "fs.remove name"); return true end
wada.store.get = function(k, d) assert(type(k) == "string"); local v = storekv[k]; if v == nil then return d end return v end
wada.store.set = function(k, v) assert(type(k) == "string", "store key must be a string")
@@ -126,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
@@ -159,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 ==
@@ -204,6 +295,36 @@ local contacts_fixture = {
local scenarios = {}
+scenarios.wardrive_utf8 = function()
+ cfg = {
+ w = 320, h = 196,
+ caps = { sdk_ext = true, keyboard = true, touch = false, compass = false, accel = false, discover = true },
+ discovered = {
+ { pubkey = "01020304", name = "Ouderkerk☀️", type = 2,
+ rssi = -72, snr = 7.25, their_snr = 6.5, hops = 0 }
+ }
+ }
+ cfg.gps = function()
+ return { lat = 52.295, lon = 4.907, lat_e6 = 52295000, lon_e6 = 4907000,
+ sats = 9, alt_m = 3, time = 1787620000 }
+ end
+ storekv = {}
+ wada = build_wada()
+ local app = load_app()
+ assert(guarded(BUDGET, app.on_open, cfg.w, cfg.h))
+ assert(buttons[1] and buttons[1].fn, "Wardrive Start button missing")
+ assert(guarded(BUDGET, buttons[1].fn))
+ tick(app, 1, 100)
+ tick(app, 1, 4000)
+ local found = false
+ for _, label in ipairs(labels) do
+ assert(valid_utf8(label.text), "Wardrive rendered invalid UTF-8")
+ if label.text:find("Ouderkerk☀", 1, true) then found = true end
+ end
+ assert(found, "emoji-bearing repeater name was not rendered")
+ if app.on_close then guarded(BUDGET, app.on_close) end
+end
+
-- The declination model, as it actually ships. Rather than testing a copy in
-- out/wmm, this pulls the do-block straight out of the app file that gets
-- sideloaded, so an inlining mistake fails here instead of on the device.
@@ -683,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 },
@@ -711,7 +887,9 @@ scenarios.cost = function()
assert(worst < BUDGET / 4, "tick too expensive")
end
-local order = { "declination", "align_nofix", "bearings_absolute", "m9", "r8", "v4", "pager", "pager_portrait_jumbo", "tanmatsu", "cost" }
+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", "audio_api", "cost" }
for _, name in ipairs(order) do
if SCENARIO == "all" or SCENARIO == name then
print("== " .. name)
diff --git a/scripts/sideload_app.py b/scripts/sideload_app.py
index c74525e..3b29497 100644
--- a/scripts/sideload_app.py
+++ b/scripts/sideload_app.py
@@ -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 .lua + .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/.d/")
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()
diff --git a/src/DataStore.cpp b/src/DataStore.cpp
index 4919f10..cd698b8 100644
--- a/src/DataStore.cpp
+++ b/src/DataStore.cpp
@@ -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));
}
diff --git a/src/DataStore.h b/src/DataStore.h
index 0a547c3..539f8e9 100644
--- a/src/DataStore.h
+++ b/src/DataStore.h
@@ -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
diff --git a/src/MyMesh.cpp b/src/MyMesh.cpp
index 8f482cc..b769e62 100644
--- a/src/MyMesh.cpp
+++ b/src/MyMesh.cpp
@@ -5445,7 +5445,7 @@ void MyMesh::checkCLIRescueCmd() {
// opens the file, "fadd " 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 .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/ or /lang/"); 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/.d/, 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/.d/");
+ 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
diff --git a/src/MyMesh.h b/src/MyMesh.h
index 4fb8a51..8a8344a 100644
--- a/src/MyMesh.h
+++ b/src/MyMesh.h
@@ -415,6 +415,17 @@ public:
return sendTelemetryRequestForUI(recipient);
}
+ /** Interactive logins must rediscover the route. MeshCore 1.16 repeaters
+ * answer a direct login by flooding the response without refreshing their
+ * return path; a flooded login returns PATH + LOGIN_OK and refreshes both
+ * sides before the deferred request or first admin command is sent. */
+ void uiResetPathForLogin(ContactInfo& recipient) {
+ if (recipient.out_path_len == OUT_PATH_UNKNOWN) return;
+ uiResetContactPath(recipient.id.pub_key);
+ recipient.out_path_len = OUT_PATH_UNKNOWN;
+ memset(recipient.out_path, 0, sizeof(recipient.out_path));
+ }
+
/** Touch-UI manual STATUS/TELEMETRY request that DEFERS the REQ until the
* guest LOGIN is acknowledged. The chained helpers above fire LOGIN and REQ
* back-to-back, but a repeater drops a PAYLOAD_TYPE_REQ from a sender it
@@ -435,6 +446,7 @@ public:
return (kind == UiReqKind::Telemetry) ? sendTelemetryRequestForUI(recipient)
: sendStatusPingForUI(recipient);
}
+ uiResetPathForLogin(recipient);
uint32_t login_est = 0;
int r = sendLogin(recipient, "", login_est);
if (r == MSG_SEND_SENT_FLOOD || r == MSG_SEND_SENT_DIRECT) {
@@ -453,6 +465,7 @@ public:
* AbstractUITask::onAdminLoginResult so the UI can flip from "logging
* in…" to "logged in" (or "failed"). */
int uiSendAdminLogin(ContactInfo& recipient, const char* password) {
+ uiResetPathForLogin(recipient);
uint32_t est = 0;
int r = sendLogin(recipient, password ? password : "", est);
if (r == MSG_SEND_SENT_FLOOD || r == MSG_SEND_SENT_DIRECT) {
@@ -1296,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();
diff --git a/src/helpers/esp32/TouchPrefsStore.cpp b/src/helpers/esp32/TouchPrefsStore.cpp
index 65a875a..07aef96 100644
--- a/src/helpers/esp32/TouchPrefsStore.cpp
+++ b/src/helpers/esp32/TouchPrefsStore.cpp
@@ -1849,6 +1849,10 @@ bool touchPrefsGetEdgeScroll() { if (!s_begun) touchPrefsBegin(); return s_
void touchPrefsSetEdgeScroll(bool on) { if (!s_begun) touchPrefsBegin(); prefsPutUChar("tb_edgesc", on ? 1 : 0); }
bool touchPrefsGetLockOnScreenOff() { if (!s_begun) touchPrefsBegin(); return s_prefs.getUChar("lock_off", 0) != 0; }
void touchPrefsSetLockOnScreenOff(bool on) { if (!s_begun) touchPrefsBegin(); prefsPutUChar("lock_off", on ? 1 : 0); }
+bool touchPrefsGetGlanceWhenLocked() { if (!s_begun) touchPrefsBegin(); return s_prefs.getUChar("glance_lck", 0) != 0; }
+void touchPrefsSetGlanceWhenLocked(bool on) { if (!s_begun) touchPrefsBegin(); prefsPutUChar("glance_lck", on ? 1 : 0); }
+bool touchPrefsGetGlanceEnabled() { if (!s_begun) touchPrefsBegin(); return s_prefs.getUChar("glance_en", 1) != 0; }
+void touchPrefsSetGlanceEnabled(bool on) { if (!s_begun) touchPrefsBegin(); prefsPutUChar("glance_en", on ? 1 : 0); }
#if defined(HAS_TANMATSU) // only the Tanmatsu has the message LED — keep S3 (T-Deck/V4) bins unchanged
bool touchPrefsGetMsgLed() { if (!s_begun) touchPrefsBegin(); return s_prefs.getUChar("msg_led", 1) != 0; } // default ON
diff --git a/src/helpers/esp32/TouchPrefsStore.h b/src/helpers/esp32/TouchPrefsStore.h
index b528898..f3b0151 100644
--- a/src/helpers/esp32/TouchPrefsStore.h
+++ b/src/helpers/esp32/TouchPrefsStore.h
@@ -451,6 +451,10 @@ bool touchPrefsGetEdgeScroll(); // push cursor past edge to scrol
void touchPrefsSetEdgeScroll(bool on);
bool touchPrefsGetLockOnScreenOff(); // idle screen-off auto-locks; only a deliberate hold wakes (default false)
void touchPrefsSetLockOnScreenOff(bool on);
+bool touchPrefsGetGlanceWhenLocked(); // "at a glance" also fires while manually/idle locked, not just unlocked+dimmed (default false)
+void touchPrefsSetGlanceWhenLocked(bool on);
+bool touchPrefsGetGlanceEnabled(); // master "at a glance" feature toggle (default true)
+void touchPrefsSetGlanceEnabled(bool on);
/** Per-channel mute, keyed by channel name. Bit 0 = mute messages, bit 1 =
* mute @-mentions. Suppresses the notification SOUND for that channel (the
diff --git a/src/helpers/input/LatchedModifier.h b/src/helpers/input/LatchedModifier.h
new file mode 100644
index 0000000..4a30c8a
--- /dev/null
+++ b/src/helpers/input/LatchedModifier.h
@@ -0,0 +1,91 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+#pragma once
+
+#include
+
+class LatchedModifier {
+ public:
+ static constexpr uint32_t DOUBLE_TAP_MS = 400;
+
+ void press() {
+ held_ = true;
+ used_while_held_ = false;
+ }
+
+ void release(uint32_t now_ms) {
+ held_ = false;
+ if (discard_release_) {
+ discard_release_ = false;
+ used_while_held_ = false;
+ return;
+ }
+ if (used_while_held_) return;
+ tap(now_ms);
+ }
+
+ void tap(uint32_t now_ms) {
+ if (locked_) {
+ clearLatched();
+ return;
+ }
+ if (one_shot_ && (uint32_t)(now_ms - last_tap_ms_) <= DOUBLE_TAP_MS) {
+ one_shot_ = false;
+ locked_ = true;
+ last_tap_ms_ = 0;
+ return;
+ }
+ one_shot_ = true;
+ last_tap_ms_ = now_ms;
+ }
+
+ bool consumeForKey() {
+ const bool active_now = held_ || one_shot_ || locked_;
+ if (held_) used_while_held_ = true;
+ if (one_shot_) {
+ one_shot_ = false;
+ last_tap_ms_ = 0;
+ }
+ return active_now;
+ }
+
+ void markHeldUsed() {
+ if (!held_) return;
+ used_while_held_ = true;
+ one_shot_ = false;
+ last_tap_ms_ = 0;
+ }
+
+ void clearLatched() {
+ one_shot_ = false;
+ locked_ = false;
+ last_tap_ms_ = 0;
+ }
+
+ void discard() {
+ clearLatched();
+ if (held_) {
+ used_while_held_ = true;
+ discard_release_ = true;
+ }
+ }
+
+ void baseline(bool physically_held) {
+ clearLatched();
+ held_ = physically_held;
+ used_while_held_ = physically_held;
+ discard_release_ = physically_held;
+ }
+
+ bool held() const { return held_; }
+ bool latched() const { return one_shot_ || locked_; }
+ bool active() const { return held_ || one_shot_ || locked_; }
+ bool locked() const { return locked_; }
+
+ private:
+ bool held_ = false;
+ bool used_while_held_ = false;
+ bool one_shot_ = false;
+ bool locked_ = false;
+ bool discard_release_ = false;
+ uint32_t last_tap_ms_ = 0;
+};
\ No newline at end of file
diff --git a/src/helpers/input/PagerKeyboard.cpp b/src/helpers/input/PagerKeyboard.cpp
index e357368..4e39a1d 100644
--- a/src/helpers/input/PagerKeyboard.cpp
+++ b/src/helpers/input/PagerKeyboard.cpp
@@ -6,7 +6,7 @@
#include
#include
#include
-#include
+#include "PagerKeyboardState.h"
#ifndef KB_INT
#define KB_INT 6
@@ -14,30 +14,10 @@
#ifndef KB_BACKLIGHT
#define KB_BACKLIGHT 46
#endif
-#define KB_ROWS 4
-#define KB_COLS 10
-
-// Matrix legend, s_keymap[row][col] — same physical keyboard PCB as
-// trail-mate's working LR1121 pager build, cross-checked there. '\0' = no
-// character at that position (dead cell, or intercepted as a modifier below).
-static constexpr char s_keymap[KB_ROWS][KB_COLS] = {
- {'q', 'w', 'e', 'r', 't', 'y', 'u', 'i', 'o', 'p'},
- {'a', 's', 'd', 'f', 'g', 'h', 'j', 'k', 'l', '\r'},
- {'\0', 'z', 'x', 'c', 'v', 'b', 'n', 'm', '\0', '\0'},
- {' ', '\0', '\0', '\0', '\0', '\0', '\0', '\0', '\0', '\0'},
-};
-// Alt layer (symbols/numbers) — this hardware has no separate physical Symbol
-// key, so Alt alone drives it (matches trail-mate's has_symbol_key=false path).
-static constexpr char s_symbolMap[KB_ROWS][KB_COLS] = {
- {'1', '2', '3', '4', '5', '6', '7', '8', '9', '0'},
- {'*', '/', '+', '-', '=', ':', '\'', '"', '@', '\0'},
- {'\0', '_', '$', ';', '?', '!', ',', '.', '\0', '\0'},
- {' ', '\0', '\0', '\0', '\0', '\0', '\0', '\0', '\0', '\0'},
-};
-
// Modifier/special-key positions: 0-based (row*KB_COLS + col), matching the
-// TCA8418 raw event's (code & 0x7F) - 1. Alt is a hold (symbol layer while
-// held); Shift is a hold too (momentary uppercase on the base layer, real
+// TCA8418 raw event's (code & 0x7F) - 1. Alt selects symbols while held, a
+// solo tap selects them for one key, and a double tap locks the layer. Shift
+// is a hold too (momentary uppercase on the base layer, real
// Shift-key semantics) — held Alt THEN a Shift press instead chords into
// Alt+Shift, reported via s_alt_shift_chord_pending. What that chord DOES is
// a UI-level decision (UITask.cpp): Caps Lock toggle while editing a text
@@ -55,29 +35,21 @@ static constexpr char s_symbolMap[KB_ROWS][KB_COLS] = {
// harmless in practice since the intended gesture is
// hold-Alt-tap-Shift-release-both, not holding all three simultaneously.
// Backspace (row2,col9) sits one column over from Shift, so Alt+Backspace
-// doesn't share this exact ghosting risk with any base-layer letter.
-static constexpr uint8_t kAltPos = 2 * KB_COLS + 0; // row2,col0 ('\0' in both layers)
-static constexpr uint8_t kShiftPos = 2 * KB_COLS + 8; // row2,col8 ('\0' in both layers)
-static constexpr uint8_t kBackspacePos = 2 * KB_COLS + 9; // row2,col9 ('\0' in both layers)
-static constexpr uint8_t kSpacePos = 3 * KB_COLS + 0; // row3,col0 (' ' in both layers)
+// doesn't share this exact ghosting risk with any base-layer letter. The maps
+// and positions live in PagerKeyboardState so host tests exercise the exact
+// production translation logic.
static Adafruit_TCA8418 s_kb;
static bool s_inited = false;
-static bool s_alt = false;
-static bool s_alt_used = false; // Alt consumed as a modifier since it was last pressed
-static bool s_alt_tap_pending = false; // Alt pressed+released with nothing else happening meanwhile
-static bool s_caps = false;
-static bool s_shift_held = false; // momentary Shift, mirrors s_backspace_held/s_space_held
-static bool s_alt_shift_chord_pending = false; // one-shot, see pagerKeyboardConsumeAltShiftChord()
-static bool s_alt_backspace_chord_pending = false; // one-shot, see pagerKeyboardConsumeAltBackspaceChord()
-static bool s_backspace_held = false;
-static bool s_space_held = false;
+static PagerKeyboardState s_state;
-// Single-producer (poll) / single-consumer (UI thread) ring — same pattern as
-// TDeckKeyboard.cpp; byte indices are atomic enough for SPSC without a lock.
-static volatile uint8_t s_ring[16];
-static volatile uint8_t s_head = 0;
-static volatile uint8_t s_tail = 0;
+// Single-producer (poll) / single-consumer ring. Polling currently happens on
+// the UI task, but the short critical section preserves the header's contract
+// if a future board moves I2C polling to another core.
+static portMUX_TYPE s_ring_mux = portMUX_INITIALIZER_UNLOCKED;
+static uint8_t s_ring[16];
+static uint8_t s_head = 0;
+static uint8_t s_tail = 0;
static bool s_bl_ready = false;
// This framework's Arduino-ESP32 core only has the channel-based LEDC API
@@ -88,16 +60,19 @@ static bool s_bl_ready = false;
static constexpr uint8_t kKbBacklightPwmChannel = 0;
static void ringPush(uint8_t c) {
+ portENTER_CRITICAL(&s_ring_mux);
const uint8_t nh = (uint8_t)((s_head + 1) & 15);
if (nh != s_tail) { // drop if the ring is full
s_ring[s_head] = c;
s_head = nh;
}
+ portEXIT_CRITICAL(&s_ring_mux);
}
void pagerKeyboardBegin() {
if (s_inited) return;
- s_inited = s_kb.begin(TCA8418_DEFAULT_ADDR, &Wire) && s_kb.matrix(KB_ROWS, KB_COLS);
+ s_inited = s_kb.begin(TCA8418_DEFAULT_ADDR, &Wire) &&
+ s_kb.matrix(PagerKeyboardState::ROWS, PagerKeyboardState::COLS);
if (!s_inited) return;
s_kb.flush();
pinMode(KB_INT, INPUT_PULLUP); // TCA8418 INT is open-drain active-low; not ISR-driven here (see .h)
@@ -115,58 +90,20 @@ void pagerKeyboardPoll() {
const bool pressed = (raw & 0x80) != 0;
const uint8_t code = (uint8_t)((raw & 0x7F) - 1);
- if (code == kAltPos) {
- // Solo tap (press+release, nothing else in between) vs. a modifier hold
- // (symbol-layer typing, or the rotary encoder's Alt+turn via
- // pagerKeyboardMarkAltUsed()) — only the former queues a pending tap.
- if (pressed) { s_alt = true; s_alt_used = false; }
- else { if (!s_alt_used) s_alt_tap_pending = true; s_alt = false; }
- continue;
- }
- // Any other key event while Alt is held means Alt is being used as a
- // modifier, not tapped solo — cancels the pending-tap interpretation.
- if (s_alt && pressed) s_alt_used = true;
- if (code == kShiftPos) {
- if (pressed) {
- if (s_alt) s_alt_shift_chord_pending = true; // Alt (Fn) held + Shift press = chord (UI decides the effect)
- else s_shift_held = true;
- } else {
- s_shift_held = false;
- }
- continue;
- }
- if (code == kBackspacePos) {
- if (pressed) {
- // Alt+Backspace is a distinct chord (jump Home, everywhere -- see
- // pagerKeyboardConsumeAltBackspaceChord()), not a delete: suppress
- // both the '\b' ring-push AND s_backspace_held, so the plain-
- // Backspace hold gestures (back / unlock) never also see this press.
- if (s_alt) s_alt_backspace_chord_pending = true;
- else { s_backspace_held = true; ringPush('\b'); }
- } else {
- s_backspace_held = false;
- }
- continue;
- }
- if (code == kSpacePos) { s_space_held = pressed; if (pressed) ringPush(' '); continue; }
- if (!pressed) continue; // base/symbol keys only emit on press
-
- const uint8_t row = code / KB_COLS;
- const uint8_t col = code % KB_COLS;
- if (row >= KB_ROWS) continue; // a GPIO event outside the matrix, not a key
-
- char c = s_alt ? s_symbolMap[row][col] : s_keymap[row][col];
- if (c == '\0') continue;
- // Caps Lock and a held Shift both mean "uppercase," base layer only.
- if ((s_caps || s_shift_held) && !s_alt) c = (char)toupper((unsigned char)c);
- ringPush((uint8_t)c);
+ const uint8_t key = s_state.event(code, pressed, millis());
+ if (key) ringPush(key);
}
}
int pagerKeyboardReadKey() {
- if (s_tail == s_head) return 0;
+ portENTER_CRITICAL(&s_ring_mux);
+ if (s_tail == s_head) {
+ portEXIT_CRITICAL(&s_ring_mux);
+ return 0;
+ }
const uint8_t c = s_ring[s_tail];
s_tail = (uint8_t)((s_tail + 1) & 15);
+ portEXIT_CRITICAL(&s_ring_mux);
return c;
}
@@ -179,32 +116,26 @@ void pagerKeyboardSetBacklight(uint8_t level) {
ledcWrite(kKbBacklightPwmChannel, level);
}
-bool pagerKeyboardAltHeld() { return s_alt; }
+bool pagerKeyboardAltHeld() { return s_state.altHeld(); }
-void pagerKeyboardMarkAltUsed() { s_alt_used = true; }
+void pagerKeyboardMarkAltUsed() { s_state.markAltUsed(); }
-bool pagerKeyboardConsumeAltTap() {
- if (!s_alt_tap_pending) return false;
- s_alt_tap_pending = false;
- return true;
+void pagerKeyboardDiscardAlt() {
+ s_state.discardAlt();
}
-bool pagerKeyboardBackspaceHeld() { return s_backspace_held; }
+bool pagerKeyboardBackspaceHeld() { return s_state.backspaceHeld(); }
-bool pagerKeyboardSpaceHeld() { return s_space_held; }
+bool pagerKeyboardSpaceHeld() { return s_state.spaceHeld(); }
bool pagerKeyboardConsumeAltShiftChord() {
- if (!s_alt_shift_chord_pending) return false;
- s_alt_shift_chord_pending = false;
- return true;
+ return s_state.consumeAltShiftChord();
}
-void pagerKeyboardToggleCaps() { s_caps = !s_caps; }
+void pagerKeyboardToggleCaps() { s_state.toggleCaps(); }
bool pagerKeyboardConsumeAltBackspaceChord() {
- if (!s_alt_backspace_chord_pending) return false;
- s_alt_backspace_chord_pending = false;
- return true;
+ return s_state.consumeAltBackspaceChord();
}
#endif
diff --git a/src/helpers/input/PagerKeyboard.h b/src/helpers/input/PagerKeyboard.h
index 774e552..7d3eca4 100644
--- a/src/helpers/input/PagerKeyboard.h
+++ b/src/helpers/input/PagerKeyboard.h
@@ -3,8 +3,8 @@
// T-LoRa Pager physical QWERTY keyboard: a TCA8418 I2C matrix controller (4
// rows x 10 cols, addr 0x34) on the shared I2C bus (SDA 3 / SCL 2). Unlike
-// the T-Deck's keyboard (a second MCU that resolves ASCII itself before we
-// ever see a byte), the TCA8418 only reports raw row/col matrix events — the
+// the T-Deck's keyboard (a second MCU, with raw mode only on newer firmware),
+// the TCA8418 always reports raw row/col matrix events — the
// keymap + shift/sym/alt state machine lives HERE, so pagerKeyboardReadKey()
// produces the same final ASCII/control-code stream handleHwKey() already
// expects from the T-Deck; no UI-side changes needed to consume it.
@@ -13,7 +13,7 @@
// must be called from a single, consistent context each tick (whichever task
// ends up owning it — wired in a later milestone; this board has no
// pre-existing shared-bus task the way the T-Deck's touch poll does).
-// pagerKeyboardReadKey() only pops from a lock-free ring and is safe to call
+// pagerKeyboardReadKey() only pops from a critical-section-protected ring and is safe to call
// from the UI thread regardless of which context polls.
#if defined(HAS_PAGER_KEYBOARD) && defined(ESP32)
@@ -46,15 +46,12 @@ bool pagerKeyboardAltHeld();
/** Mark the currently-held Alt as "used as a modifier" — call this when some
* other gesture (e.g. the rotary encoder's Alt+turn) consumes the hold, so
- * releasing Alt afterward isn't also read as a solo tap by
- * pagerKeyboardConsumeAltTap(). */
+ * releasing Alt afterward does not arm the one-shot symbol layer. */
void pagerKeyboardMarkAltUsed();
-/** One-shot: true exactly once if Alt was pressed and released without being
- * used as a modifier for anything else in between (no key typed, no
- * pagerKeyboardMarkAltUsed() call) — a "solo tap", distinct from a
- * symbol-layer or Alt+turn hold. Consumes the pending flag on read. */
-bool pagerKeyboardConsumeAltTap();
+/** Cancel one-shot/locked Alt, suppress a later release from a held Alt, and
+ * discard pending Alt chords. Used when input is intentionally discarded. */
+void pagerKeyboardDiscardAlt();
/** True while Backspace is physically held WITHOUT Alt (raw state, mirrors
* pagerKeyboardAltHeld()). A plain press still immediately ring-pushes '\b'
diff --git a/src/helpers/input/PagerKeyboardState.h b/src/helpers/input/PagerKeyboardState.h
new file mode 100644
index 0000000..f2c77fc
--- /dev/null
+++ b/src/helpers/input/PagerKeyboardState.h
@@ -0,0 +1,113 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+#pragma once
+
+#include
+
+#include "LatchedModifier.h"
+
+class PagerKeyboardState {
+ public:
+ static constexpr uint8_t ROWS = 4;
+ static constexpr uint8_t COLS = 10;
+ static constexpr uint8_t ALT_POS = 2 * COLS;
+ static constexpr uint8_t SHIFT_POS = 2 * COLS + 8;
+ static constexpr uint8_t BACKSPACE_POS = 2 * COLS + 9;
+ static constexpr uint8_t SPACE_POS = 3 * COLS;
+
+ uint8_t event(uint8_t code, bool pressed, uint32_t now_ms) {
+ static const char base[ROWS][COLS] = {
+ {'q', 'w', 'e', 'r', 't', 'y', 'u', 'i', 'o', 'p'},
+ {'a', 's', 'd', 'f', 'g', 'h', 'j', 'k', 'l', '\r'},
+ {'\0', 'z', 'x', 'c', 'v', 'b', 'n', 'm', '\0', '\0'},
+ {' ', '\0', '\0', '\0', '\0', '\0', '\0', '\0', '\0', '\0'},
+ };
+ static const char symbols[ROWS][COLS] = {
+ {'1', '2', '3', '4', '5', '6', '7', '8', '9', '0'},
+ {'*', '/', '+', '-', '=', ':', '\'', '"', '@', '\0'},
+ {'\0', '_', '$', ';', '?', '!', ',', '.', '\0', '\0'},
+ {' ', '\0', '\0', '\0', '\0', '\0', '\0', '\0', '\0', '\0'},
+ };
+
+ if (code == ALT_POS) {
+ if (pressed) alt_.press();
+ else alt_.release(now_ms);
+ return 0;
+ }
+ if (code == SHIFT_POS) {
+ if (pressed) {
+ if (alt_.held()) {
+ alt_.markHeldUsed();
+ alt_shift_chord_pending_ = true;
+ } else {
+ shift_held_ = true;
+ }
+ } else {
+ shift_held_ = false;
+ }
+ return 0;
+ }
+ if (code == BACKSPACE_POS) {
+ if (pressed) {
+ if (alt_.held()) {
+ alt_.markHeldUsed();
+ alt_backspace_chord_pending_ = true;
+ } else {
+ alt_.consumeForKey();
+ backspace_held_ = true;
+ return '\b';
+ }
+ } else {
+ backspace_held_ = false;
+ }
+ return 0;
+ }
+ if (code == SPACE_POS) {
+ space_held_ = pressed;
+ if (pressed) {
+ alt_.consumeForKey();
+ return ' ';
+ }
+ return 0;
+ }
+ if (!pressed) return 0;
+
+ const uint8_t row = code / COLS;
+ const uint8_t col = code % COLS;
+ if (row >= ROWS) return 0;
+ const bool symbol_layer = alt_.consumeForKey();
+ char key = symbol_layer ? symbols[row][col] : base[row][col];
+ if (key == '\0') return 0;
+ if ((caps_ || shift_held_) && !symbol_layer && key >= 'a' && key <= 'z') key -= 32;
+ return (uint8_t)key;
+ }
+
+ bool altHeld() const { return alt_.held(); }
+ void markAltUsed() { alt_.markHeldUsed(); }
+ void discardAlt() {
+ alt_.discard();
+ alt_shift_chord_pending_ = false;
+ alt_backspace_chord_pending_ = false;
+ }
+ bool backspaceHeld() const { return backspace_held_; }
+ bool spaceHeld() const { return space_held_; }
+ bool consumeAltShiftChord() {
+ const bool pending = alt_shift_chord_pending_;
+ alt_shift_chord_pending_ = false;
+ return pending;
+ }
+ void toggleCaps() { caps_ = !caps_; }
+ bool consumeAltBackspaceChord() {
+ const bool pending = alt_backspace_chord_pending_;
+ alt_backspace_chord_pending_ = false;
+ return pending;
+ }
+
+ private:
+ LatchedModifier alt_;
+ bool caps_ = false;
+ bool shift_held_ = false;
+ bool alt_shift_chord_pending_ = false;
+ bool alt_backspace_chord_pending_ = false;
+ bool backspace_held_ = false;
+ bool space_held_ = false;
+};
\ No newline at end of file
diff --git a/src/helpers/input/TDeckKeyboard.cpp b/src/helpers/input/TDeckKeyboard.cpp
index 091b6b3..cdc8b47 100644
--- a/src/helpers/input/TDeckKeyboard.cpp
+++ b/src/helpers/input/TDeckKeyboard.cpp
@@ -2,6 +2,7 @@
#if defined(HAS_TDECK_KEYBOARD) && defined(ESP32)
#include "TDeckKeyboard.h"
+#include "TDeckKeyboardState.h"
#include
#include
@@ -9,19 +10,96 @@
#define PIN_KB_ADDR 0x55
#endif
-// Single-producer (core-0 poll) / single-consumer (UI thread) ring. Byte indices
-// on a 32-bit MCU are atomic enough for SPSC without a lock.
-static volatile uint8_t s_ring[16];
-static volatile uint8_t s_head = 0; // written by producer
-static volatile uint8_t s_tail = 0; // written by consumer
+// Core-0 produces and the UI task consumes. One short cross-core critical
+// section publishes each byte together with its index and also protects the
+// desired modifier-input mode below.
+static portMUX_TYPE s_keyboard_mux = portMUX_INITIALIZER_UNLOCKED;
+static uint8_t s_ring[16];
+static uint8_t s_head = 0;
+static uint8_t s_tail = 0;
static bool s_inited = false;
+enum class KeyboardMode : uint8_t { Probe, Raw, Legacy };
+// How long to keep asking the C3 for raw frames before settling for legacy mode.
+// Generous on purpose: getting this wrong costs the user modifier latching for
+// the entire session, and the only cost of waiting is that the first second of
+// keystrokes goes through the legacy path, which still works.
+static const uint32_t kProbeWindowMs = 1500;
+static KeyboardMode s_mode = KeyboardMode::Probe;
+static TDeckKeyboardState s_raw_state;
+static bool s_modifier_input_allowed = true; // guarded by s_keyboard_mux
+static uint32_t s_modifier_mode_generation = 0; // guarded by s_keyboard_mux
+static uint32_t s_modifier_generation_applied = 0; // core-0 owner only
// Backlight: the UI thread requests a level; the actual I2C write happens in the
// poll (core 0). The keyboard's C3 firmware sets the backlight on an I2C write.
static volatile uint8_t s_bl_desired = 0;
static volatile bool s_bl_dirty = false;
+static void ringPushLocked(uint8_t key) {
+ if (!key) return;
+ const uint8_t next = (uint8_t)((s_head + 1) & 15);
+ if (next != s_tail) {
+ s_ring[s_head] = key;
+ s_head = next;
+ }
+}
+
+static void keyboardCommand(uint8_t command) {
+ Wire.beginTransmission(PIN_KB_ADDR);
+ Wire.write(command);
+ Wire.endTransmission();
+}
+
+static int keyboardRead(uint8_t* out, size_t count) {
+ Wire.requestFrom((int)PIN_KB_ADDR, (int)count);
+ int read = 0;
+ while (Wire.available() && read < (int)count) out[read++] = (uint8_t)Wire.read();
+ while (Wire.available()) Wire.read();
+ return read;
+}
+
+static void processRawFrame(const uint8_t frame[TDeckKeyboardState::COLS], uint32_t now_ms) {
+ uint8_t keys[16];
+ portENTER_CRITICAL(&s_keyboard_mux);
+ const uint32_t generation = s_modifier_mode_generation;
+ if (generation != s_modifier_generation_applied) {
+ // Transition frame: establish current physical state, publish nothing.
+ // The mode setter already cleared older queued bytes under this same lock.
+ s_raw_state.baseline(frame);
+ s_modifier_generation_applied = generation;
+ } else {
+ const size_t key_count = s_raw_state.update(frame, now_ms, keys, sizeof keys,
+ s_modifier_input_allowed);
+ for (size_t i = 0; i < key_count; ++i) ringPushLocked(keys[i]);
+ }
+ portEXIT_CRITICAL(&s_keyboard_mux);
+}
+
+static void processLegacyKey(uint8_t key) {
+ portENTER_CRITICAL(&s_keyboard_mux);
+ const uint32_t generation = s_modifier_mode_generation;
+ if (generation != s_modifier_generation_applied) {
+ // No raw modifier state exists, and the C3 exposes one latest-byte mailbox
+ // (comdata/comdata_flag), not one response per host mode transition. Any
+ // number of transitions before this read therefore coalesce deliberately:
+ // drop the sole pending byte once so it cannot cross into the final mode.
+ s_modifier_generation_applied = generation;
+ } else {
+ ringPushLocked(key);
+ }
+ portEXIT_CRITICAL(&s_keyboard_mux);
+}
+
void tdeckKeyboardBegin() {
+ s_mode = KeyboardMode::Probe;
+ s_raw_state = TDeckKeyboardState{};
+ portENTER_CRITICAL(&s_keyboard_mux);
+ s_head = s_tail = 0;
+ // Keep any desired mode the UI published immediately after starting this
+ // task. Setting applied one generation behind forces the first response to
+ // baseline (or drain the legacy controller's one-byte mailbox).
+ s_modifier_generation_applied = s_modifier_mode_generation - 1;
+ portEXIT_CRITICAL(&s_keyboard_mux);
s_inited = true; // Wire was configured (18/8, 400k, 20ms timeout) by the touch driver
}
@@ -49,21 +127,92 @@ void tdeckKeyboardFlushBacklight() {
void tdeckKeyboardPoll() {
if (!s_inited) return;
tdeckKeyboardFlushBacklight();
- if (Wire.requestFrom((int)PIN_KB_ADDR, 1) != 1) return;
- uint8_t key = Wire.read();
- if (key == 0) return; // no key this scan
- const uint8_t nh = (uint8_t)((s_head + 1) & 15);
- if (nh != s_tail) { // drop if the ring is full
- s_ring[s_head] = key;
- s_head = nh;
+ if (s_mode == KeyboardMode::Probe) {
+ // LilyGO keyboard firmware from June 2025 onward accepts 0x03 and returns
+ // five column-state bytes. Older controllers ignore it and keep returning
+ // one resolved ASCII byte; restore 0x04 key mode when that is what we have.
+ //
+ // KEEP RETRYING before concluding "old firmware". This used to decide on a
+ // SINGLE read, and the first I2C read after boot is the least trustworthy
+ // one there is: the C3 is still coming up, so a short or garbled frame is
+ // ordinary. One such frame committed the device to legacy mode for the whole
+ // session, with no recovery short of a reboot -- reported as latching simply
+ // not working on a T-Deck carrying the June 2025 firmware (#332).
+ static uint32_t s_probe_start_ms = 0;
+ const uint32_t now = millis();
+ if (!s_probe_start_ms) s_probe_start_ms = now;
+
+ keyboardCommand(0x03);
+ uint8_t frame[TDeckKeyboardState::COLS] = {};
+ const int count = keyboardRead(frame, sizeof frame);
+ bool valid_raw = count == (int)sizeof frame;
+ size_t bad_col = 0;
+ for (size_t col = 0; col < sizeof frame && valid_raw; ++col) {
+ if (frame[col] & 0x80) { valid_raw = false; bad_col = col; }
+ }
+ if (valid_raw) {
+ s_mode = KeyboardMode::Raw;
+ Serial.println("[keyboard] T-Deck raw mode: modifier latching enabled");
+ processRawFrame(frame, millis());
+ return;
+ }
+ // A legacy controller answers with one resolved byte. Deliver it either way
+ // so nothing typed during the probe window is dropped.
+ if (count == 1) { keyboardCommand(0x04); processLegacyKey(frame[0]); }
+ if ((uint32_t)(now - s_probe_start_ms) < kProbeWindowMs) return; // try again next poll
+
+ keyboardCommand(0x04);
+ s_mode = KeyboardMode::Legacy;
+ // Say WHY, so the next report of "latching does not work" is one line to
+ // diagnose instead of a guess about which firmware someone has.
+ Serial.printf("[keyboard] T-Deck legacy mode: update keyboard C3 firmware for modifier "
+ "latching (probe read %d bytes, wanted %d%s)\n",
+ count, (int)sizeof frame,
+ (count == (int)sizeof frame) ? ", high bit set in a column" : "");
+ (void)bad_col;
+ return;
}
+ if (s_mode == KeyboardMode::Raw) {
+ uint8_t frame[TDeckKeyboardState::COLS] = {};
+ if (keyboardRead(frame, sizeof frame) != (int)sizeof frame) return;
+ for (size_t col = 0; col < sizeof frame; ++col) if (frame[col] & 0x80) return;
+ processRawFrame(frame, millis());
+ return;
+ }
+ uint8_t key = 0;
+ if (keyboardRead(&key, 1) == 1) processLegacyKey(key);
}
int tdeckKeyboardReadKey() {
- if (s_tail == s_head) return 0; // empty
+ portENTER_CRITICAL(&s_keyboard_mux);
+ if (s_tail == s_head) {
+ portEXIT_CRITICAL(&s_keyboard_mux);
+ return 0;
+ }
const uint8_t key = s_ring[s_tail];
s_tail = (uint8_t)((s_tail + 1) & 15);
+ portEXIT_CRITICAL(&s_keyboard_mux);
return key;
}
+void tdeckKeyboardDiscardModifiers() {
+ portENTER_CRITICAL(&s_keyboard_mux);
+ if (s_modifier_input_allowed) {
+ s_modifier_input_allowed = false;
+ ++s_modifier_mode_generation;
+ s_tail = s_head;
+ }
+ portEXIT_CRITICAL(&s_keyboard_mux);
+}
+
+void tdeckKeyboardAllowModifiers() {
+ portENTER_CRITICAL(&s_keyboard_mux);
+ if (!s_modifier_input_allowed) {
+ s_modifier_input_allowed = true;
+ ++s_modifier_mode_generation;
+ s_tail = s_head;
+ }
+ portEXIT_CRITICAL(&s_keyboard_mux);
+}
+
#endif
diff --git a/src/helpers/input/TDeckKeyboard.h b/src/helpers/input/TDeckKeyboard.h
index cf64910..4030baf 100644
--- a/src/helpers/input/TDeckKeyboard.h
+++ b/src/helpers/input/TDeckKeyboard.h
@@ -1,14 +1,16 @@
#pragma once
// LilyGo T-Deck physical keyboard. It's a separate ESP32-C3 running the
-// T-Keyboard firmware on the shared I2C bus (SDA 18 / SCL 8, addr 0x55): each
-// 1-byte read returns the ASCII code of the last key press (0 = none). The C3
-// resolves shift / sym layers itself, so we just receive final characters.
+// T-Keyboard firmware on the shared I2C bus (SDA 18 / SCL 8, addr 0x55).
+// Current controller firmware supports a five-byte raw matrix mode, which lets
+// this driver preserve held modifiers and add tap/double-tap latching. Older
+// firmware returns one resolved ASCII byte; startup detects that and restores
+// the original controller-side key mode automatically.
//
// CRITICAL: the keyboard shares the I2C bus with the GT911 touch controller,
// which is polled from a core-0 task. To avoid two cores hitting Wire at once,
// tdeckKeyboardPoll() must be called from THAT task; the UI thread only drains
-// the lock-free ring via tdeckKeyboardReadKey().
+// the critical-section-protected ring via tdeckKeyboardReadKey().
#if defined(HAS_TDECK_KEYBOARD) && defined(ESP32)
#include
@@ -23,6 +25,14 @@ void tdeckKeyboardPoll();
/** Pop the next buffered key (ASCII), or 0 if none. Safe from the UI thread. */
int tdeckKeyboardReadKey();
+/** Cancel one-shot/locked modifiers and suppress a later release from a
+ * currently held modifier. Call when queued input is intentionally discarded. */
+void tdeckKeyboardDiscardModifiers();
+
+/** Stop continuously discarding raw modifier state after the UI returns to a
+ * mode where keyboard input is meaningful. */
+void tdeckKeyboardAllowModifiers();
+
/** Request a keyboard-backlight level (0 = off, 0xFF = on). Safe from the UI
* thread — the I2C write happens inside tdeckKeyboardPoll() (core 0), which
* owns the bus. Only re-sent when the value changes. */
diff --git a/src/helpers/input/TDeckKeyboardState.h b/src/helpers/input/TDeckKeyboardState.h
new file mode 100644
index 0000000..3848046
--- /dev/null
+++ b/src/helpers/input/TDeckKeyboardState.h
@@ -0,0 +1,111 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+#pragma once
+
+#include
+#include
+
+#include "LatchedModifier.h"
+
+class TDeckKeyboardState {
+ public:
+ static constexpr size_t COLS = 5;
+
+ size_t update(const uint8_t current[COLS], uint32_t now_ms,
+ uint8_t* out, size_t out_capacity, bool modifiers_enabled = true) {
+ static const char base[COLS][7] = {
+ {'q', 'w', '\0', 'a', '\0', ' ', '\0'},
+ {'e', 's', 'd', 'p', 'x', 'z', '\0'},
+ {'r', 'g', 't', '\0', 'v', 'c', 'f'},
+ {'u', 'h', 'y', '\0', 'b', 'n', 'j'},
+ {'o', 'l', 'i', '\0', '$', 'm', 'k'},
+ };
+ static const char symbols[COLS][7] = {
+ {'#', '1', '\0', '*', '\0', '\0', '0'},
+ {'2', '4', '5', '@', '8', '7', '\0'},
+ {'3', '/', '(', '\0', '?', '9', '6'},
+ {'_', ':', ')', '\0', '!', ',', ';'},
+ {'+', '"', '-', '\0', '\0', '.', '\''},
+ };
+ auto down = [&](size_t col, uint8_t row) {
+ return (current[col] & (uint8_t)(1U << row)) != 0;
+ };
+ auto pressed = [&](size_t col, uint8_t row) {
+ return down(col, row) && (previous_[col] & (uint8_t)(1U << row)) == 0;
+ };
+ auto released = [&](size_t col, uint8_t row) {
+ return !down(col, row) && (previous_[col] & (uint8_t)(1U << row)) != 0;
+ };
+
+ if (!modifiers_enabled) {
+ symbol_.baseline(down(0, 2));
+ alt_.baseline(down(0, 4));
+ }
+ if (modifiers_enabled && pressed(0, 2)) symbol_.press();
+ if (modifiers_enabled && pressed(0, 4)) alt_.press();
+ if (modifiers_enabled && released(0, 2)) symbol_.release(now_ms);
+ if (modifiers_enabled && released(0, 4)) alt_.release(now_ms);
+
+ const bool shift = modifiers_enabled && (down(1, 6) || down(2, 3));
+ size_t written = 0;
+ auto emit = [&](uint8_t key) {
+ if (written < out_capacity) out[written++] = key;
+ };
+ for (uint8_t row = 0; row < 7; ++row) {
+ for (size_t col = 0; col < COLS; ++col) {
+ if (!pressed(col, row)) continue;
+ if ((col == 0 && (row == 2 || row == 4)) ||
+ (col == 1 && row == 6) || (col == 2 && row == 3)) continue;
+ const bool physical_alt = modifiers_enabled && alt_.held();
+ if (physical_alt) alt_.markHeldUsed();
+ // Preserve LilyGO controller shortcuts while raw mode moves ordinary
+ // matrix translation into the host. The C3 still handles Alt+B's
+ // backlight toggle; suppress its character exactly as key mode does.
+ if (physical_alt && col == 3 && row == 4) {
+ symbol_.consumeForKey();
+ continue;
+ }
+ if (physical_alt && col == 2 && row == 5) {
+ symbol_.consumeForKey();
+ emit(0x0C);
+ continue;
+ }
+ if (col == 3 && row == 3) {
+ symbol_.consumeForKey(); alt_.consumeForKey(); emit('\r'); continue;
+ }
+ if (col == 4 && row == 3) {
+ symbol_.consumeForKey(); alt_.consumeForKey(); emit('\b'); continue;
+ }
+
+ const bool use_symbols = modifiers_enabled && (symbol_.active() || alt_.latched());
+ const char key = use_symbols ? symbols[col][row] : base[col][row];
+ if (modifiers_enabled) {
+ symbol_.consumeForKey();
+ if (!physical_alt) alt_.consumeForKey();
+ }
+ if (key == '\0') continue;
+ char resolved = key;
+ if (!use_symbols && shift && resolved >= 'a' && resolved <= 'z') resolved -= 32;
+ emit((uint8_t)resolved);
+ }
+ }
+
+ for (size_t col = 0; col < COLS; ++col) previous_[col] = current[col] & 0x7F;
+ return written;
+ }
+
+ void baseline(const uint8_t current[COLS]) {
+ symbol_.baseline((current[0] & (uint8_t)(1U << 2)) != 0);
+ alt_.baseline((current[0] & (uint8_t)(1U << 4)) != 0);
+ for (size_t col = 0; col < COLS; ++col) previous_[col] = current[col] & 0x7F;
+ }
+
+ void discardModifiers() {
+ symbol_.discard();
+ alt_.discard();
+ }
+
+ private:
+ uint8_t previous_[COLS] = {};
+ LatchedModifier symbol_;
+ LatchedModifier alt_;
+};
\ No newline at end of file
diff --git a/src/ui-touch/LuaAppHost.cpp b/src/ui-touch/LuaAppHost.cpp
index dd51781..efad828 100644
--- a/src/ui-touch/LuaAppHost.cpp
+++ b/src/ui-touch/LuaAppHost.cpp
@@ -18,6 +18,7 @@
#include "AppPage.h"
#include "device_caps.h"
#include "i18n.h" // wada.sys.tr: apps can use the same translation table the UI does
+#include "Utf8Text.h"
#include "helpers/esp32/WdtHeavyGuard.h" // wada.fs writes can trigger SPIFFS GC
extern "C" {
@@ -35,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
@@ -157,10 +170,25 @@ void budgetHook(lua_State* L, lua_Debug*) {
// host state
// ---------------------------------------------------------------------------
constexpr size_t kHeapCap = 256 * 1024; // per-app PSRAM cap
-constexpr size_t kMaxSrc = 64 * 1024; // app source size limit
+// App source size limit. Was 64 KB, which a real app can reach: reported from a
+// 2000-contact device whose author hit it writing an ordinary Lua program, not
+// anything pathological. The source lives in PSRAM only until luaL_loadbuffer
+// has compiled it, so this is a transient allocation rather than a per-app cost,
+// and the compiled chunk still has to fit the 256 KB per-app heap below.
+constexpr size_t kMaxSrc = 192 * 1024; // app source size limit
constexpr size_t kStoreMax = 2048; // per-app persisted KV budget (bytes, serialized)
constexpr int kMinTickMs = 33; // fastest on_tick cadence (~30 fps)
+// Bumped by wada.ui.clear(). Every widget handle records the generation it was
+// created in; a handle from an older one has already been destroyed, so the
+// accessors below null its pointer and the `if (u->obj)` guard that every
+// method already has does the rest.
+//
+// Without this, clear() would be a use-after-free generator: WidgetUd holds a
+// raw lv_obj_t*, so a Lua variable still referring to a label from the previous
+// screen would sail past the null check straight into freed memory.
+static uint32_t s_ui_gen = 1;
+
struct Host {
lua_State* L = nullptr;
LuaHeap heap;
@@ -184,10 +212,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
@@ -246,8 +276,21 @@ uint32_t argColor(lua_State* L, int idx, uint32_t def = 0xFFFFFF) {
return (uint32_t)luaL_optinteger(L, idx, (lua_Integer)def) & 0xFFFFFF;
}
+// LVGL assumes structurally valid UTF-8 and can stop advancing when an app
+// hands it a partial codepoint. Keep one reusable repair buffer: valid strings
+// stay zero-copy, while malformed runs become one ASCII '?'. Every call below
+// is synchronous and LVGL copies label text before this buffer can be reused.
+const char* safeUiText(const char* text, size_t length, size_t* safe_length = nullptr,
+ unsigned scratch_slot = 0) {
+ static std::string scratch_slots[2];
+ std::string& scratch = scratch_slots[scratch_slot & 1U];
+ const char* safe = Utf8Text::sanitize(text, length, scratch);
+ if (safe_length) *safe_length = safe == text ? length : scratch.size();
+ return safe;
+}
+
// ---- canvas userdata ----
-struct CanvasUd { lv_obj_t* obj; lv_color_t* buf; int w, h; };
+struct CanvasUd { lv_obj_t* obj; lv_color_t* buf; int w, h; uint32_t gen; };
CanvasUd* checkCanvas(lua_State* L) {
return (CanvasUd*)luaL_checkudata(L, 1, "wada.canvas");
@@ -298,11 +341,14 @@ int cvCircle(lua_State* L) {
int cvText(lua_State* L) {
CanvasUd* c = checkCanvas(L);
if (!c->obj) return 0;
+ const lv_coord_t x = (lv_coord_t)luaL_checkinteger(L, 2);
+ const lv_coord_t y = (lv_coord_t)luaL_checkinteger(L, 3);
+ size_t text_length = 0;
+ const char* text = luaL_checklstring(L, 4, &text_length);
lv_draw_label_dsc_t d; lv_draw_label_dsc_init(&d);
d.color = lv_color_hex(argColor(L, 5));
d.font = luaHostFontForSize((int)luaL_optinteger(L, 6, 14));
- lv_canvas_draw_text(c->obj, (lv_coord_t)luaL_checkinteger(L, 2), (lv_coord_t)luaL_checkinteger(L, 3),
- c->w, &d, luaL_checkstring(L, 4));
+ lv_canvas_draw_text(c->obj, x, y, c->w, &d, safeUiText(text, text_length));
return 0;
}
int cvPos(lua_State* L) {
@@ -337,6 +383,7 @@ int uiCanvas(lua_State* L) {
lv_canvas_set_buffer(cv, buf, w, h, LV_IMG_CF_TRUE_COLOR);
lv_canvas_fill_bg(cv, lv_color_hex(0x000000), LV_OPA_COVER);
CanvasUd* ud = (CanvasUd*)lua_newuserdatauv(L, sizeof(CanvasUd), 0);
+ ud->gen = s_ui_gen;
ud->obj = cv; ud->buf = buf; ud->w = w; ud->h = h;
luaL_setmetatable(L, "wada.canvas");
// Pin the handle in the registry for the app's lifetime. LVGL draws straight
@@ -352,12 +399,18 @@ int uiCanvas(lua_State* L) {
}
// ---- label userdata ----
-struct WidgetUd { lv_obj_t* obj; };
-WidgetUd* checkLabel(lua_State* L) { return (WidgetUd*)luaL_checkudata(L, 1, "wada.label"); }
+struct WidgetUd { lv_obj_t* obj; uint32_t gen; };
+WidgetUd* checkLabel(lua_State* L) {
+ WidgetUd* u = (WidgetUd*)luaL_checkudata(L, 1, "wada.label");
+ if (u->gen != s_ui_gen) u->obj = nullptr; // cleared out from under this handle
+ return u;
+}
int lbSet(lua_State* L) {
WidgetUd* u = checkLabel(L);
- if (u->obj) lv_label_set_text(u->obj, luaL_checkstring(L, 2));
+ size_t length = 0;
+ const char* text = luaL_checklstring(L, 2, &length);
+ if (u->obj) lv_label_set_text(u->obj, safeUiText(text, length));
return 0;
}
int lbPos(lua_State* L) {
@@ -390,20 +443,31 @@ int lbWidth(lua_State* L) { // label:width(px [, "left"|"center"|"right"]) —
int uiLabel(lua_State* L) {
if (!s_h || !s_h->body) return luaL_error(L, "no app body");
+ size_t length = 0;
+ const char* text = luaL_checklstring(L, 1, &length);
+ const lv_coord_t x = (lv_coord_t)luaL_optinteger(L, 2, 0);
+ const lv_coord_t y = (lv_coord_t)luaL_optinteger(L, 3, 0);
+ const lv_font_t* font = luaHostFontForSize((int)luaL_optinteger(L, 4, 14));
+ const uint32_t color = argColor(L, 5, 0xE6E9ED);
lv_obj_t* l = lv_label_create(s_h->body);
- lv_label_set_text(l, luaL_checkstring(L, 1));
- lv_obj_set_pos(l, (lv_coord_t)luaL_optinteger(L, 2, 0), (lv_coord_t)luaL_optinteger(L, 3, 0));
- lv_obj_set_style_text_font(l, luaHostFontForSize((int)luaL_optinteger(L, 4, 14)), LV_PART_MAIN);
- lv_obj_set_style_text_color(l, lv_color_hex(argColor(L, 5, 0xE6E9ED)), LV_PART_MAIN);
+ lv_label_set_text(l, safeUiText(text, length));
+ lv_obj_set_pos(l, x, y);
+ lv_obj_set_style_text_font(l, font, LV_PART_MAIN);
+ lv_obj_set_style_text_color(l, lv_color_hex(color), LV_PART_MAIN);
WidgetUd* ud = (WidgetUd*)lua_newuserdatauv(L, sizeof(WidgetUd), 0);
+ ud->gen = s_ui_gen;
ud->obj = l;
luaL_setmetatable(L, "wada.label");
return 1;
}
// ---- chart userdata (the primitive the native Monitor/Airtime pages use) ----
-struct ChartUd { lv_obj_t* obj; lv_chart_series_t* ser[2]; int n_ser; };
-ChartUd* checkChart(lua_State* L) { return (ChartUd*)luaL_checkudata(L, 1, "wada.chart"); }
+struct ChartUd { lv_obj_t* obj; lv_chart_series_t* ser[2]; int n_ser; uint32_t gen; };
+ChartUd* checkChart(lua_State* L) {
+ ChartUd* u = (ChartUd*)luaL_checkudata(L, 1, "wada.chart");
+ if (u->gen != s_ui_gen) { u->obj = nullptr; u->n_ser = 0; }
+ return u;
+}
int chPush(lua_State* L) { // chart:push(series_idx, value)
ChartUd* c = checkChart(L);
@@ -470,6 +534,7 @@ int uiChart(lua_State* L) { // wada.ui.chart(w, h, points, color1 [, color2] [
lv_obj_set_style_line_color(ch, lv_color_hex(0x1A1D1F), LV_PART_MAIN); // grid
lv_obj_set_style_line_width(ch, 2, LV_PART_ITEMS);
ChartUd* ud = (ChartUd*)lua_newuserdatauv(L, sizeof(ChartUd), 0);
+ ud->gen = s_ui_gen;
ud->obj = ch;
ud->n_ser = 0;
ud->ser[0] = lv_chart_add_series(ch, lv_color_hex(argColor(L, 4, 0x15B6A6)), LV_CHART_AXIS_PRIMARY_Y);
@@ -497,12 +562,17 @@ void btnEventCb(lv_event_t* e) {
int uiButton(lua_State* L) {
if (!s_h || !s_h->body) return luaL_error(L, "no app body");
- const char* txt = luaL_checkstring(L, 1);
+ size_t text_length = 0;
+ const char* text = luaL_checklstring(L, 1, &text_length);
+ const lv_coord_t x = (lv_coord_t)luaL_checkinteger(L, 2);
+ const lv_coord_t y = (lv_coord_t)luaL_checkinteger(L, 3);
+ const lv_coord_t w = (lv_coord_t)luaL_optinteger(L, 4, 90);
+ const lv_coord_t h = (lv_coord_t)luaL_optinteger(L, 5, 34);
lv_obj_t* b = lv_btn_create(s_h->body);
- lv_obj_set_pos(b, (lv_coord_t)luaL_checkinteger(L, 2), (lv_coord_t)luaL_checkinteger(L, 3));
- lv_obj_set_size(b, (lv_coord_t)luaL_optinteger(L, 4, 90), (lv_coord_t)luaL_optinteger(L, 5, 34));
+ lv_obj_set_pos(b, x, y);
+ lv_obj_set_size(b, w, h);
lv_obj_t* bl = lv_label_create(b);
- lv_label_set_text(bl, txt);
+ lv_label_set_text(bl, safeUiText(text, text_length));
lv_obj_center(bl);
if (lua_isfunction(L, 6)) {
lua_rawgeti(L, LUA_REGISTRYINDEX, s_h->ref_btncb);
@@ -513,6 +583,7 @@ int uiButton(lua_State* L) {
lv_obj_add_event_cb(b, btnEventCb, LV_EVENT_CLICKED, nullptr);
}
WidgetUd* ud = (WidgetUd*)lua_newuserdatauv(L, sizeof(WidgetUd), 0);
+ ud->gen = s_ui_gen;
ud->obj = b;
luaL_setmetatable(L, "wada.label"); // shares set/pos/color methods
return 1;
@@ -529,8 +600,12 @@ int uiButton(lua_State* L) {
// keyboard/trackball focus navigation walks them on touchless boards for free,
// and a tap works on the ones with a screen. Per-row callbacks ride the same
// button-callback table uiButton uses.
-struct ListUd { lv_obj_t* obj; int sel; };
-ListUd* checkList(lua_State* L) { return (ListUd*)luaL_checkudata(L, 1, "wada.list"); }
+struct ListUd { lv_obj_t* obj; int sel; uint32_t gen; };
+ListUd* checkList(lua_State* L) {
+ ListUd* u = (ListUd*)luaL_checkudata(L, 1, "wada.list");
+ if (u->gen != s_ui_gen) u->obj = nullptr;
+ return u;
+}
lv_obj_t* listRowAt(ListUd* u, int i) { // i is 1-based, as everywhere in Lua
if (!u->obj || i < 1) return nullptr;
@@ -554,6 +629,7 @@ int uiTextW(lua_State* L) {
size_t len = 0;
const char* txt = luaL_checklstring(L, 1, &len);
const lv_font_t* f = luaHostFontForSize((int)luaL_optinteger(L, 2, 14));
+ txt = safeUiText(txt, len, &len);
lua_pushinteger(L, (lua_Integer)lv_txt_get_width(txt, (uint32_t)len, f, 0, LV_TEXT_FLAG_NONE));
return 1;
}
@@ -566,6 +642,7 @@ int uiTextLines(lua_State* L) {
const char* txt = luaL_checklstring(L, 1, &len);
const int w = (int)luaL_checkinteger(L, 2);
const lv_font_t* f = luaHostFontForSize((int)luaL_optinteger(L, 3, 14));
+ txt = safeUiText(txt, len, &len);
if (w <= 0) { lua_pushinteger(L, 1); return 1; }
int lines = 0;
const char* p = txt;
@@ -580,6 +657,24 @@ int uiTextLines(lua_State* L) {
return 1;
}
+// wada.ui.clear() -- remove every widget from the app's page.
+//
+// The SDK documented building widgets in on_open() and gave no way to take them
+// down again, so an app with more than one screen drew the second on top of the
+// first. Reported by pisti87, who had been working around it with buttons.
+//
+// Bumping the generation is what makes this safe. A Lua variable still holding a
+// label from the screen just cleared keeps a raw pointer to freed memory; the
+// accessors compare generations and null it, so the existing `if (u->obj)` guard
+// in every method turns a stale call into a no-op instead of a crash.
+static int uiClear(lua_State* L) {
+ (void)L;
+ if (!s_h || !s_h->body) return 0;
+ lv_obj_clean(s_h->body); // deletes the children, keeps the page itself
+ ++s_ui_gen;
+ return 0;
+}
+
int uiList(lua_State* L) {
if (!s_h || !s_h->body) return luaL_error(L, "no app body");
const int x = (int)luaL_checkinteger(L, 1), y = (int)luaL_checkinteger(L, 2);
@@ -597,6 +692,7 @@ int uiList(lua_State* L) {
lv_obj_set_scroll_dir(c, LV_DIR_VER);
lv_obj_add_flag(c, LV_OBJ_FLAG_SCROLLABLE);
ListUd* ud = (ListUd*)lua_newuserdatauv(L, sizeof(ListUd), 0);
+ ud->gen = s_ui_gen;
ud->obj = c; ud->sel = 0;
luaL_setmetatable(L, "wada.list");
return 1;
@@ -605,7 +701,8 @@ int uiList(lua_State* L) {
// list:add(text [, fn]) -> index of the new row
int lsAdd(lua_State* L) {
ListUd* u = checkList(L);
- const char* txt = luaL_checkstring(L, 2);
+ size_t text_length = 0;
+ const char* text = luaL_checklstring(L, 2, &text_length);
if (!u->obj || !s_h) return 0;
lv_obj_t* row = lv_btn_create(u->obj);
lv_obj_set_width(row, LV_PCT(100));
@@ -614,7 +711,7 @@ int lsAdd(lua_State* L) {
lv_obj_set_style_pad_all(row, 6, LV_PART_MAIN);
listPaintRow(row, false);
lv_obj_t* lb = lv_label_create(row);
- lv_label_set_text(lb, txt);
+ lv_label_set_text(lb, safeUiText(text, text_length));
lv_label_set_long_mode(lb, LV_LABEL_LONG_DOT); // a long name truncates instead of reflowing the row
lv_obj_set_width(lb, LV_PCT(100));
lv_obj_set_style_text_font(lb, luaHostFontForSize(12), LV_PART_MAIN);
@@ -633,9 +730,11 @@ int lsAdd(lua_State* L) {
int lsSet(lua_State* L) {
ListUd* u = checkList(L);
lv_obj_t* row = listRowAt(u, (int)luaL_checkinteger(L, 2));
+ size_t text_length = 0;
+ const char* text = luaL_checklstring(L, 3, &text_length);
if (!row) return 0;
lv_obj_t* lb = lv_obj_get_child(row, 0);
- if (lb) lv_label_set_text(lb, luaL_checkstring(L, 3));
+ if (lb) lv_label_set_text(lb, safeUiText(text, text_length));
return 0;
}
int lsColor(lua_State* L) {
@@ -764,12 +863,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
@@ -1563,6 +1667,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/.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);
@@ -1694,10 +1885,35 @@ void pressCb(lv_event_t* e) {
}
// ---- wada.mesh (read-only) ----
+// wada.mesh.contacts([offset], [limit])
+//
+// Used to scan the first 200 contacts and return at most 100, with no way to see
+// past that and nothing to say it had truncated. On a device with 2000 contacts
+// an app simply could not reach most of them (reported by Jade).
+//
+// It takes an offset and a limit now. The default limit stays 100 because every
+// entry is an 8-field Lua table and building thousands of them in one call would
+// blow the per-app heap -- but the window can be moved, so all contacts are
+// reachable by asking twice. wada.mesh.contact_count() says how many there are.
+// How many contacts the device holds, so an app can page through them without
+// calling contacts() repeatedly just to discover where the list ends.
+static int meshContactCount(lua_State* L) {
+ char name[36], pk[12]; int type; uint32_t ago; double lat, lon; int32_t lat6, lon6;
+ int n = 0;
+ while (n < 4096 && luaHostContactAt(n, name, sizeof name, &type, &ago, &lat, &lon,
+ pk, sizeof pk, &lat6, &lon6)) ++n;
+ lua_pushinteger(L, n);
+ return 1;
+}
+
int meshContacts(lua_State* L) {
+ const int offset = (int)luaL_optinteger(L, 1, 0);
+ int limit = (int)luaL_optinteger(L, 2, 100);
+ if (limit < 1) limit = 1;
+ if (limit > 250) limit = 250; // one call still has to fit the app's heap
lua_newtable(L);
char name[36], pk[12]; int type; uint32_t ago; double lat, lon; int32_t lat6, lon6;
- for (int i = 0, out = 0; i < 200 && out < 100; i++) {
+ for (int i = (offset > 0 ? offset : 0), out = 0; out < limit; i++) {
if (!luaHostContactAt(i, name, sizeof name, &type, &ago, &lat, &lon, pk, sizeof pk,
&lat6, &lon6)) break;
lua_createtable(L, 0, 8);
@@ -1939,11 +2155,14 @@ void promptDeliver(const char* text) {
}
int uiInput(lua_State* L) {
- const char* title = luaL_optstring(L, 1, "");
- const char* initial = luaL_optstring(L, 2, "");
+ size_t title_length = 0, initial_length = 0;
+ const char* title_raw = luaL_optlstring(L, 1, "", &title_length);
+ const char* initial_raw = luaL_optlstring(L, 2, "", &initial_length);
luaL_checktype(L, 3, LUA_TFUNCTION);
if (!s_h) return luaL_error(L, "no app");
if (s_h->prompt_cb != LUA_NOREF) return luaL_error(L, "an input prompt is already open");
+ const char* title = safeUiText(title_raw, title_length, nullptr, 0);
+ const char* initial = safeUiText(initial_raw, initial_length, nullptr, 1);
lua_pushvalue(L, 3);
s_h->prompt_cb = luaL_ref(L, LUA_REGISTRYINDEX);
luaHostTextPrompt(title, initial, promptDeliver);
@@ -2105,6 +2324,7 @@ void openWada(lua_State* L) {
lua_setfield(L, -2, "colors");
lua_pushcfunction(L, uiInput); lua_setfield(L, -2, "input"); // modal text entry
lua_pushcfunction(L, uiList); lua_setfield(L, -2, "list"); // scrollable selectable rows
+ lua_pushcfunction(L, uiClear); lua_setfield(L, -2, "clear"); // wipe the page (#318 / Discord)
lua_setfield(L, -2, "ui");
lua_newtable(L); // wada.sys
@@ -2172,8 +2392,19 @@ 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, meshContactCount); lua_setfield(L, -2, "contact_count");
lua_pushcfunction(L, meshRxLog); lua_setfield(L, -2, "rx_log");
lua_pushcfunction(L, meshStats); lua_setfield(L, -2, "stats");
lua_pushcfunction(L, meshSelf); lua_setfield(L, -2, "self");
@@ -2397,6 +2628,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();
@@ -2465,6 +2699,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);
diff --git a/src/ui-touch/ReaderContent.cpp b/src/ui-touch/ReaderContent.cpp
new file mode 100644
index 0000000..2f1a385
--- /dev/null
+++ b/src/ui-touch/ReaderContent.cpp
@@ -0,0 +1,287 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+#include "ReaderContent.h"
+
+#include
+#include
+
+namespace ReaderContent {
+namespace {
+
+bool ciPrefix(const char* text, size_t text_len, const char* prefix) {
+ const size_t prefix_len = strlen(prefix);
+ if (text_len < prefix_len) return false;
+ for (size_t i = 0; i < prefix_len; ++i) {
+ char actual = text[i];
+ char expected = prefix[i];
+ if (actual >= 'A' && actual <= 'Z') actual += 32;
+ if (expected >= 'A' && expected <= 'Z') expected += 32;
+ if (actual != expected) return false;
+ }
+ return true;
+}
+
+size_t decodeEntity(const char* text, size_t text_len, char* out, int* wrote) {
+ static const struct { const char* name; const char* utf8; } named[] = {
+ {"amp;","&"},{"lt;","<"},{"gt;",">"},{"quot;","\""},{"apos;","'"},{"nbsp;"," "},
+ {"mdash;","\xe2\x80\x94"},{"ndash;","\xe2\x80\x93"},{"hellip;","\xe2\x80\xa6"},
+ {"lsquo;","\xe2\x80\x98"},{"rsquo;","\xe2\x80\x99"},{"ldquo;","\xe2\x80\x9c"},
+ {"rdquo;","\xe2\x80\x9d"},{"copy;","\xc2\xa9"},{"reg;","\xc2\xae"},{"euro;","\xe2\x82\xac"},
+ {"deg;","\xc2\xb0"},{"middot;","\xc2\xb7"},{"bull;","\xe2\x80\xa2"},{"trade;","\xe2\x84\xa2"},
+ };
+ for (const auto& entity : named) {
+ const size_t name_len = strlen(entity.name);
+ if (text_len > name_len && strncmp(text + 1, entity.name, name_len) == 0) {
+ const int width = strlen(entity.utf8);
+ memcpy(out, entity.utf8, width);
+ *wrote = width;
+ return name_len + 1;
+ }
+ }
+ if (text_len > 3 && text[1] == '#') {
+ long codepoint = 0;
+ const bool hex = text[2] == 'x' || text[2] == 'X';
+ size_t i = hex ? 3 : 2;
+ const size_t start = i;
+ for (; i < text_len && text[i] != ';'; ++i) {
+ const char c = text[i];
+ int digit;
+ if (c >= '0' && c <= '9') digit = c - '0';
+ else if (hex && c >= 'a' && c <= 'f') digit = c - 'a' + 10;
+ else if (hex && c >= 'A' && c <= 'F') digit = c - 'A' + 10;
+ else { i = start; break; }
+ codepoint = codepoint * (hex ? 16 : 10) + digit;
+ }
+ if (i > start && i < text_len && text[i] == ';' && codepoint > 0 && codepoint <= 0x10FFFF) {
+ int width = 0;
+ if (codepoint < 0x80) out[width++] = static_cast(codepoint);
+ else if (codepoint < 0x800) {
+ out[width++] = 0xC0 | (codepoint >> 6);
+ out[width++] = 0x80 | (codepoint & 0x3F);
+ } else if (codepoint < 0x10000) {
+ out[width++] = 0xE0 | (codepoint >> 12);
+ out[width++] = 0x80 | ((codepoint >> 6) & 0x3F);
+ out[width++] = 0x80 | (codepoint & 0x3F);
+ } else {
+ out[width++] = 0xF0 | (codepoint >> 18);
+ out[width++] = 0x80 | ((codepoint >> 12) & 0x3F);
+ out[width++] = 0x80 | ((codepoint >> 6) & 0x3F);
+ out[width++] = 0x80 | (codepoint & 0x3F);
+ }
+ *wrote = width;
+ return i + 1;
+ }
+ }
+ return 0;
+}
+
+bool tagAttribute(const char* tag, size_t tag_len, const char* attribute,
+ char* out, size_t cap) {
+ const size_t attribute_len = strlen(attribute);
+ for (size_t i = 0; i + attribute_len + 1 < tag_len; ++i) {
+ if (!ciPrefix(tag + i, tag_len - i, attribute)) continue;
+ if (i > 0 && tag[i - 1] != ' ' && tag[i - 1] != '\t' &&
+ tag[i - 1] != '\r' && tag[i - 1] != '\n') continue;
+ size_t cursor = i + attribute_len;
+ while (cursor < tag_len && (tag[cursor] == ' ' || tag[cursor] == '\t')) ++cursor;
+ if (cursor >= tag_len || tag[cursor] != '=') continue;
+ ++cursor;
+ while (cursor < tag_len && (tag[cursor] == ' ' || tag[cursor] == '\t')) ++cursor;
+ char quote = 0;
+ if (cursor < tag_len && (tag[cursor] == '"' || tag[cursor] == '\'')) {
+ quote = tag[cursor++];
+ }
+ size_t written = 0;
+ while (cursor < tag_len && written + 1 < cap) {
+ const char c = tag[cursor];
+ if (quote ? c == quote : (c == ' ' || c == '>' || c == '\t')) break;
+ out[written++] = c;
+ ++cursor;
+ }
+ out[written] = 0;
+ return written > 0;
+ }
+ return false;
+}
+
+} // namespace
+
+bool resolveUrl(const char* base, const char* href, char* out, size_t cap) {
+ if (!base || !href || !out || cap == 0) return false;
+ while (*href == ' ') ++href;
+ const size_t href_len = strlen(href);
+ if (!href_len || href[0] == '#') return false;
+ if (ciPrefix(href, href_len, "javascript:") || ciPrefix(href, href_len, "mailto:") ||
+ ciPrefix(href, href_len, "tel:") || ciPrefix(href, href_len, "data:")) return false;
+
+ if (ciPrefix(href, href_len, "http://") || ciPrefix(href, href_len, "https://") ||
+ ciPrefix(href, href_len, "sd:/")) {
+ snprintf(out, cap, "%s", href);
+ } else if (ciPrefix(base, strlen(base), "sd:/")) {
+ if (href[0] == '/' && href[1] == '/') {
+ snprintf(out, cap, "https:%s", href);
+ } else if (href[0] == '/') {
+ snprintf(out, cap, "sd:%s", href);
+ } else {
+ const char* last_slash = strrchr(base + 3, '/');
+ if (last_slash) snprintf(out, cap, "%.*s%s", static_cast(last_slash - base + 1), base, href);
+ else snprintf(out, cap, "sd:/%s", href);
+ }
+ } else {
+ const char* scheme_end = strstr(base, "://");
+ if (!scheme_end) return false;
+ const int scheme_len = static_cast(scheme_end - base);
+ const char* host = scheme_end + 3;
+ const char* host_end = strchr(host, '/');
+ const int host_len = host_end ? static_cast(host_end - host) : static_cast(strlen(host));
+ if (href[0] == '/' && href[1] == '/') {
+ snprintf(out, cap, "%.*s:%s", scheme_len, base, href);
+ } else if (href[0] == '/') {
+ snprintf(out, cap, "%.*s://%.*s%s", scheme_len, base, host_len, host, href);
+ } else {
+ const char* last_slash = strrchr(base, '/');
+ if (last_slash && last_slash > scheme_end + 2)
+ snprintf(out, cap, "%.*s%s", static_cast(last_slash - base + 1), base, href);
+ else
+ snprintf(out, cap, "%.*s://%.*s/%s", scheme_len, base, host_len, host, href);
+ }
+ }
+ char* fragment = strchr(out, '#');
+ if (fragment) *fragment = 0;
+ return out[0] != 0;
+}
+
+size_t htmlToText(const char* html, size_t html_len,
+ char* out, size_t out_cap, const char* base,
+ Link* links, size_t link_capacity, size_t* link_count) {
+ if (link_count) *link_count = 0;
+ if (!html || !out || out_cap == 0) return 0;
+
+ size_t out_len = 0;
+ size_t links_len = 0;
+ int pending_newlines = 0;
+ bool pending_space = false;
+ bool started = false;
+ bool in_anchor = false;
+ uint32_t anchor_start = 0;
+ char anchor_href[HREF_CAPACITY] = "";
+ auto emit = [&](char c) { if (out_len + 1 < out_cap) out[out_len++] = c; };
+ auto flush = [&]() {
+ if (!started) {
+ pending_newlines = 0;
+ pending_space = false;
+ return;
+ }
+ while (pending_newlines > 0) { emit('\n'); --pending_newlines; }
+ if (pending_space) { emit(' '); pending_space = false; }
+ };
+ auto finishAnchor = [&]() {
+ if (in_anchor && out_len > anchor_start && links && links_len < link_capacity) {
+ links[links_len].start = anchor_start;
+ links[links_len].end = static_cast(out_len);
+ snprintf(links[links_len].href, sizeof links[links_len].href, "%s", anchor_href);
+ ++links_len;
+ }
+ in_anchor = false;
+ };
+ static const char* blocks[] = {
+ "p","div","br","li","ul","ol","tr","h1","h2","h3","h4","h5","h6",
+ "section","article","header","footer","table","blockquote","pre","hr","nav","title","body", nullptr
+ };
+
+ size_t i = 0;
+ while (i < html_len && out_len + 5 < out_cap) {
+ const char c = html[i];
+ if (c == '<') {
+ if (html_len - i >= 4 && html[i + 1] == '!' && html[i + 2] == '-' && html[i + 3] == '-') {
+ size_t end = i + 4;
+ while (end + 2 < html_len && !(html[end] == '-' && html[end + 1] == '-' && html[end + 2] == '>')) ++end;
+ i = end + 2 < html_len ? end + 3 : html_len;
+ continue;
+ }
+ const bool script = ciPrefix(html + i, html_len - i, "') j++; i = (j < n) ? j + 1 : n;
- if (pend_nl < 2) pend_nl++; continue;
- }
- size_t j = i + 1; bool closing = (j < n && h[j] == '/'); if (closing) j++; // tag name
- char name[12]; int ni = 0;
- while (j < n && ni < 11) { char t = h[j]; if ((t>='a'&&t<='z')||(t>='A'&&t<='Z')||(t>='0'&&t<='9')) { name[ni++] = (t>='A'&&t<='Z')?t+32:t; j++; } else break; }
- name[ni] = 0;
- size_t k = i + 1; while (k < n && h[k] != '>') k++; // k = '>' position
- // Links: record the anchor-text byte range + resolved href.
- if (name[0] == 'a' && name[1] == 0) {
- if (in_a) { if (o > a_start && s_reader_links && s_reader_nlinks < kReaderMaxLinks) {
- s_reader_links[s_reader_nlinks].start = a_start; s_reader_links[s_reader_nlinks].end = (uint32_t)o;
- snprintf(s_reader_links[s_reader_nlinks].href, sizeof s_reader_links[0].href, "%s", a_href); s_reader_nlinks++; }
- in_a = false; }
- if (!closing) { char raw[300];
- if (base && s_reader_links && readerTagAttr(h + i, (k < n ? k : n) - i, "href", raw, sizeof raw) &&
- readerResolveUrl(base, raw, a_href, sizeof a_href)) { in_a = true; a_start = (uint32_t)o; } }
- }
- i = (k < n) ? k + 1 : n;
- for (int b = 0; blocks[b]; b++) if (strcmp(name, blocks[b]) == 0) { pend_sp = false; if (pend_nl < 2) pend_nl++; break; }
- continue;
- }
- if (c == '&') {
- char buf[8]; int w = 0; size_t used = readerEntity(h + i, n - i, buf, &w);
- if (used) { for (int q = 0; q < w; q++) { char e = buf[q]; if (e == ' ') { if (started) pend_sp = true; } else { flush(); emit(e); started = true; } } i += used; continue; }
- flush(); emit('&'); started = true; i++; continue;
- }
- if (c == ' ' || c == '\t' || c == '\r' || c == '\n') { if (started) pend_sp = true; i++; continue; }
- flush(); emit(c); started = true; i++;
- }
- out[o < cap ? o : cap - 1] = 0; return o;
-}
-// Worker (core 0): fetch s_reader_url, strip to s_reader_text, publish via s_reader_dirty.
+
+// Worker (core 0): load s_reader_url, strip to s_reader_text, publish via s_reader_dirty.
static void readerFetchTaskFn(void*) {
+ const uint32_t generation = s_reader_task_generation;
char url[300]; strncpy(url, s_reader_url, sizeof url - 1); url[sizeof url - 1] = 0;
- s_reader_ok = false;
+ bool result_ok = false;
+ bool result_home_missing = false;
+ char result_msg[sizeof s_reader_msg] = "";
+ const bool local = readerCiPrefix(url, strlen(url), "sd:/");
const bool https = readerCiPrefix(url, strlen(url), "https://");
// Big contiguous PSRAM is scarce on the 2 MB V4 (a chat's message ring fragments it),
// so fall back to smaller buffers — a typical text page still fits in 48 KB.
@@ -22344,62 +22498,102 @@ static void readerFetchTaskFn(void*) {
if (!raw) { rawcap = 96 * 1024; raw = (uint8_t*)heap_caps_malloc(rawcap, MALLOC_CAP_SPIRAM); }
if (!raw) { rawcap = 48 * 1024; raw = (uint8_t*)heap_caps_malloc(rawcap, MALLOC_CAP_SPIRAM); }
if (!raw) { rawcap = 24 * 1024; raw = (uint8_t*)heap_caps_malloc(rawcap, MALLOC_CAP_SPIRAM); }
- int code = -1; size_t total = 0;
+ int code = -1; size_t total = 0; bool source_ok = false;
+ ReaderLocalResult local_result = ReaderLocalResult::NoStorage;
if (raw) {
- HTTPClient httpc;
- httpc.setReuse(false); httpc.setConnectTimeout(9000); httpc.setTimeout(15000);
- httpc.setFollowRedirects(HTTPC_STRICT_FOLLOW_REDIRECTS); httpc.setUserAgent("wadamesh-reader");
- WiFiClientSecure scli; WiFiClient pcli; bool began;
- if (https) { scli.setInsecure(); began = httpc.begin(scli, url); }
- else { began = httpc.begin(pcli, url); }
- if (began) { httpc.addHeader("Accept-Encoding", "identity"); code = httpc.GET(); }
- if (code == HTTP_CODE_OK) {
- auto* st = httpc.getStreamPtr(); // NetworkClient* on arduino 3.x (P4: C6Client behind it)
- unsigned long t0 = millis();
- while (st && total < rawcap - 1 && (millis() - t0) < 20000) {
- int a = st->available();
- if (a <= 0) { if (!httpc.connected()) break; vTaskDelay(pdMS_TO_TICKS(8)); continue; }
- int r = st->read(raw + total, (rawcap - 1) - total);
- if (r <= 0) { if (!httpc.connected()) break; vTaskDelay(pdMS_TO_TICKS(5)); continue; }
- total += (size_t)r;
- if ((total & 0x3FFF) == 0) { snprintf(s_reader_msg, sizeof s_reader_msg, "Loading\xe2\x80\xa6 %uk", (unsigned)(total / 1024)); s_reader_dirty = true; }
- vTaskDelay(1);
+ if (local) {
+ local_result = readerReadLocal(url, raw, rawcap - 1, &total, generation);
+ source_ok = local_result == ReaderLocalResult::Ok;
+ } else {
+ HTTPClient httpc;
+ httpc.setReuse(false); httpc.setConnectTimeout(9000); httpc.setTimeout(15000);
+ httpc.setFollowRedirects(HTTPC_STRICT_FOLLOW_REDIRECTS); httpc.setUserAgent("wadamesh-reader");
+ WiFiClientSecure scli; WiFiClient pcli; bool began;
+ if (https) { scli.setInsecure(); began = httpc.begin(scli, url); }
+ else { began = httpc.begin(pcli, url); }
+ if (began) { httpc.addHeader("Accept-Encoding", "identity"); code = httpc.GET(); }
+ if (code == HTTP_CODE_OK) {
+ ReaderBufferStream sink(raw, rawcap - 1, generation);
+ const int transfer_result = httpc.writeToStream(&sink);
+ total = sink.size();
+ source_ok = transfer_result >= 0 || sink.full() || total > 0;
}
+ httpc.end();
}
- httpc.end();
}
- if (!raw) snprintf(s_reader_msg, sizeof s_reader_msg, "Out of memory");
- else if (code == HTTP_CODE_OK && total >= 2 && raw[0] == 0x1f && (uint8_t)raw[1] == 0x8b)
- snprintf(s_reader_msg, sizeof s_reader_msg, "Page is gzip-compressed \xe2\x80\x94 not supported yet");
- else if (code == HTTP_CODE_OK && total > 0) {
+ if (!raw) snprintf(result_msg, sizeof result_msg, "Out of memory");
+ else if (source_ok && total >= 2 && raw[0] == 0x1f && (uint8_t)raw[1] == 0x8b)
+ snprintf(result_msg, sizeof result_msg, "Page is gzip-compressed \xe2\x80\x94 not supported yet");
+ else if (source_ok) {
if (!s_reader_text) s_reader_text = (char*)heap_caps_malloc(kReaderTextCap, MALLOC_CAP_SPIRAM);
if (!s_reader_links) s_reader_links = (ReaderLink*)heap_caps_malloc(sizeof(ReaderLink) * kReaderMaxLinks, MALLOC_CAP_SPIRAM);
- if (s_reader_text) { size_t tl = readerHtmlToText((const char*)raw, total, s_reader_text, kReaderTextCap, s_reader_url);
+ if (s_reader_text) { size_t parsed_links = 0;
+ size_t tl = ReaderContent::htmlToText((const char*)raw, total, s_reader_text, kReaderTextCap,
+ url, s_reader_links, kReaderMaxLinks, &parsed_links);
+ s_reader_nlinks = (int)parsed_links;
if (tl == 0) { strcpy(s_reader_text, "(no readable text on this page)"); s_reader_nlinks = 0; }
- snprintf(s_reader_msg, sizeof s_reader_msg, "%s", s_reader_url); s_reader_ok = true; }
- else snprintf(s_reader_msg, sizeof s_reader_msg, "Out of memory");
+ snprintf(result_msg, sizeof result_msg, "%s", url); result_ok = true; }
+ else snprintf(result_msg, sizeof result_msg, "Out of memory");
}
- else if (code > 0) { snprintf(s_reader_msg, sizeof s_reader_msg, "HTTP %d", code); }
- else { snprintf(s_reader_msg, sizeof s_reader_msg, "Couldn't load (offline, bad URL, or TLS failed)"); }
+ else if (local) {
+ const bool home = strcmp(url, kReaderHomeUrl) == 0;
+ result_home_missing = home;
+ if (home) snprintf(result_msg, sizeof result_msg, "No SD /home.htm found");
+ else if (local_result == ReaderLocalResult::NoStorage) snprintf(result_msg, sizeof result_msg, "SD card not available");
+ else if (local_result == ReaderLocalResult::ReadFailed) snprintf(result_msg, sizeof result_msg, "Couldn't read SD file");
+ else snprintf(result_msg, sizeof result_msg, "SD file not found");
+ }
+ else if (code > 0) { snprintf(result_msg, sizeof result_msg, "HTTP %d", code); }
+ else { snprintf(result_msg, sizeof result_msg, "Couldn't load (offline, bad URL, or TLS failed)"); }
if (raw) heap_caps_free(raw);
- s_reader_busy = false; s_reader_dirty = true; s_reader_task = nullptr;
+ if (generation == s_reader_generation) {
+ s_reader_ok = result_ok;
+ s_reader_home_missing = result_home_missing;
+ snprintf(s_reader_msg, sizeof s_reader_msg, "%s", result_msg);
+ }
+ s_reader_task = nullptr; s_reader_busy = false; s_reader_dirty = true;
vTaskDelete(nullptr);
}
// Navigate to a URL. push=true records it in history (a fresh navigation from Go / a
// link / Enter); push=false is a back / forward / refresh replay that must NOT grow
// history. readerStart() is the public "new navigation" entry point.
-static void readerNavigate(const char* in, bool push) {
- if (s_reader_busy || !in) return;
+static bool readerNavigate(const char* in, bool push) {
+ if (s_reader_busy || !in) return false;
while (*in == ' ') in++;
- if (readerCiPrefix(in, strlen(in), "http://") || readerCiPrefix(in, strlen(in), "https://"))
- snprintf(s_reader_url, sizeof s_reader_url, "%s", in);
- else snprintf(s_reader_url, sizeof s_reader_url, "https://%s", in); // default to https
+ const bool local = readerCiPrefix(in, strlen(in), "sd:/");
+ char next_url[sizeof s_reader_url];
+ if (local || readerCiPrefix(in, strlen(in), "http://") || readerCiPrefix(in, strlen(in), "https://"))
+ snprintf(next_url, sizeof next_url, "%s", in);
+ else snprintf(next_url, sizeof next_url, "https://%s", in); // default to https
+ if (!next_url[0] || (!local && !strchr(next_url, '.'))) { snprintf(s_reader_msg, sizeof s_reader_msg, "Enter a valid URL"); s_reader_dirty = true; if (s_reader_root) readerSetAddrExpanded(true); return false; }
+ if (!local && WiFi.status() != WL_CONNECTED) { snprintf(s_reader_msg, sizeof s_reader_msg, "Wi-Fi not connected (Settings \xe2\x86\x92 Wi-Fi)"); s_reader_dirty = true; if (s_reader_root) readerSetAddrExpanded(true); return false; }
+ char previous_url[sizeof s_reader_url];
+ snprintf(previous_url, sizeof previous_url, "%s", s_reader_url);
+ snprintf(s_reader_url, sizeof s_reader_url, "%s", next_url);
// Keep the address field in sync with what we're actually loading, so a failed load
// shows the URL we tried (not the stale default that made it look like it "opened
// wadamesh.com").
if (s_reader_url_ta) { lv_textarea_set_text(s_reader_url_ta, s_reader_url); s_reader_pristine = false; }
- if (!s_reader_url[0] || !strchr(s_reader_url, '.')) { snprintf(s_reader_msg, sizeof s_reader_msg, "Enter a valid URL"); s_reader_dirty = true; if (s_reader_root) readerSetAddrExpanded(true); return; }
- if (WiFi.status() != WL_CONNECTED) { snprintf(s_reader_msg, sizeof s_reader_msg, "Wi-Fi not connected (Settings \xe2\x86\x92 Wi-Fi)"); s_reader_dirty = true; if (s_reader_root) readerSetAddrExpanded(true); return; }
+ s_reader_ok = false;
+ s_reader_home_missing = false;
+ s_reader_task_generation = ++s_reader_generation;
+ s_reader_busy = true; snprintf(s_reader_msg, sizeof s_reader_msg, "%s", local ? "Opening SD page\xe2\x80\xa6" : "Connecting\xe2\x80\xa6"); s_reader_dirty = true;
+ // Immediate feedback (we're on the UI thread): collapse the URL into the topbar and
+ // show a Loading placeholder at once, so a tapped link visibly does something now.
+ if (s_reader_root) { readerSetAddrExpanded(false); readerShowLoading(); }
+ if (xTaskCreatePinnedToCore(readerFetchTaskFn, "reader", 16384, nullptr, 4, &s_reader_task, 0) != pdPASS) {
+ // Couldn't spawn the fetch worker (low internal RAM) — don't leave it stuck on
+ // "Loading…" forever; surface it and drop the busy flag so a retry works.
+ s_reader_task = nullptr; s_reader_busy = false;
+ snprintf(s_reader_msg, sizeof s_reader_msg, "Low memory \xe2\x80\x94 try again");
+ s_reader_ok = false; s_reader_dirty = true;
+ snprintf(s_reader_url, sizeof s_reader_url, "%s", previous_url);
+ if (s_reader_url_ta) {
+ lv_textarea_set_text(s_reader_url_ta, previous_url[0] ? previous_url : kReaderDefaultUrl);
+ s_reader_pristine = !previous_url[0];
+ }
+ return false;
+ }
if (push && s_reader_hist) {
// Drop any forward tail, then push (unless it repeats the current entry).
if (s_reader_hist_pos < 0 || strcmp(s_reader_hist[s_reader_hist_pos], s_reader_url) != 0) {
@@ -22411,31 +22605,23 @@ static void readerNavigate(const char* in, bool push) {
}
s_reader_hist_n = s_reader_hist_pos + 1;
}
- s_reader_busy = true; snprintf(s_reader_msg, sizeof s_reader_msg, "Connecting\xe2\x80\xa6"); s_reader_dirty = true;
readerUpdateNavButtons();
- // Immediate feedback (we're on the UI thread): collapse the URL into the topbar and
- // show a Loading placeholder at once, so a tapped link visibly does something now.
- if (s_reader_root) { readerSetAddrExpanded(false); readerShowLoading(); }
- if (xTaskCreatePinnedToCore(readerFetchTaskFn, "reader", 16384, nullptr, 4, &s_reader_task, 0) != pdPASS) {
- // Couldn't spawn the fetch worker (low internal RAM) — don't leave it stuck on
- // "Loading…" forever; surface it and drop the busy flag so a retry works.
- s_reader_task = nullptr; s_reader_busy = false;
- snprintf(s_reader_msg, sizeof s_reader_msg, "Low memory \xe2\x80\x94 try again");
- s_reader_ok = false; s_reader_dirty = true;
- }
+ return true;
}
static void readerStart(const char* in) { readerNavigate(in, true); }
static void readerBackCb(lv_event_t* e) {
if (lv_event_get_code(e) != LV_EVENT_CLICKED || s_reader_busy) return;
- if (s_reader_hist_pos > 0) readerNavigate(s_reader_hist[--s_reader_hist_pos], false);
- else closeReaderPage(); // at the first page, the ◀ button exits the browser (reliable exit)
+ if (s_reader_hist_pos > 0) {
+ const int target = s_reader_hist_pos - 1;
+ if (readerNavigate(s_reader_hist[target], false)) { s_reader_hist_pos = target; readerUpdateNavButtons(); }
+ }
}
-static void readerFwdCb(lv_event_t* e) { if (lv_event_get_code(e) == LV_EVENT_CLICKED && !s_reader_busy && s_reader_hist && s_reader_hist_pos + 1 < s_reader_hist_n) readerNavigate(s_reader_hist[++s_reader_hist_pos], false); }
+static void readerFwdCb(lv_event_t* e) { if (lv_event_get_code(e) == LV_EVENT_CLICKED && !s_reader_busy && s_reader_hist && s_reader_hist_pos + 1 < s_reader_hist_n) { const int target = s_reader_hist_pos + 1; if (readerNavigate(s_reader_hist[target], false)) { s_reader_hist_pos = target; readerUpdateNavButtons(); } } }
static void readerRefreshCb(lv_event_t* e) { if (lv_event_get_code(e) == LV_EVENT_CLICKED && !s_reader_busy && s_reader_url[0]) readerNavigate(s_reader_url, false); }
// Dim the back / forward arrows when there's nowhere to go that way.
static void readerUpdateNavButtons() {
auto dim = [](lv_obj_t* b, bool on) { if (!b) return; lv_obj_t* l = lv_obj_get_child(b, 0); if (l) lv_obj_set_style_text_opa(l, on ? LV_OPA_COVER : LV_OPA_30, LV_PART_MAIN); };
- dim(s_reader_back, true); // always active: goes back in history, or exits at the first page
+ dim(s_reader_back, s_reader_hist && s_reader_hist_pos > 0);
dim(s_reader_fwd, s_reader_hist && s_reader_hist_pos + 1 < s_reader_hist_n);
}
static void readerGoCb(lv_event_t*) { if (s_reader_url_ta) readerStart(lv_textarea_get_text(s_reader_url_ta)); }
@@ -22512,21 +22698,36 @@ static void readerRenderBody() {
return;
}
// Walk the text, emitting text[cursor..link.start] as paragraphs and each link
- // range as a tappable label. NUL-swap in place so we never copy big runs.
+ // range as a tappable label. Boundary whitespace is redundant once a range becomes
+ // its own flex row; trimming it prevents paragraph newlines from becoming blank rows.
+ // NUL-swap in place so we never copy big runs.
const uint32_t tlen = (uint32_t)strlen(s_reader_text);
+ auto mkRange = [&](uint32_t start, uint32_t end, bool link, int idx) {
+ auto boundarySpace = [](char c) { return c == ' ' || c == '\t' || c == '\r' || c == '\n'; };
+ while (start < end && boundarySpace(s_reader_text[start])) start++;
+ while (end > start && boundarySpace(s_reader_text[end - 1])) end--;
+ if (start == end) return false;
+ char saved = s_reader_text[end];
+ s_reader_text[end] = 0;
+ mkLabel(s_reader_text + start, link, idx);
+ s_reader_text[end] = saved;
+ return true;
+ };
uint32_t cursor = 0; int blocks = 0;
for (int i = 0; i < s_reader_nlinks && blocks < 500; i++) {
ReaderLink& lk = s_reader_links[i];
if (lk.start < cursor || lk.end > tlen || lk.end <= lk.start) continue;
- if (lk.start > cursor) { char sv = s_reader_text[lk.start]; s_reader_text[lk.start] = 0; mkLabel(s_reader_text + cursor, false, -1); s_reader_text[lk.start] = sv; blocks++; }
- { char sv = s_reader_text[lk.end]; s_reader_text[lk.end] = 0; mkLabel(s_reader_text[lk.start] ? s_reader_text + lk.start : "(link)", true, i); s_reader_text[lk.end] = sv; blocks++; }
+ if (lk.start > cursor && mkRange(cursor, lk.start, false, -1)) blocks++;
+ if (mkRange(lk.start, lk.end, true, i)) blocks++;
cursor = lk.end;
}
- if (cursor < tlen) mkLabel(s_reader_text + cursor, false, -1);
+ if (cursor < tlen) mkRange(cursor, tlen, false, -1);
lv_obj_scroll_to_y(s_reader_scroll, 0, LV_ANIM_OFF);
}
static void closeReaderPage() {
if (!s_reader_root) return;
+ ++s_reader_generation;
+ s_reader_pending_url[0] = 0;
s_apppage_title = nullptr; s_apppage_close = nullptr;
s_reader_bar_url = false; // un-hide the status-bar clock
s_reader_page_open = false; // restore the tall-bar glass fade for other pages
@@ -22542,7 +22743,7 @@ static void readerEditBtnCb(lv_event_t* e) {
readerSetAddrExpanded(true);
if (s_reader_url_ta) { lv_textarea_set_text(s_reader_url_ta, s_reader_url); s_reader_pristine = false; lv_group_focus_obj(s_reader_url_ta); }
}
-static void openReaderPage() {
+static void openReaderPage(const char* initial_url = nullptr) {
closeReaderPage();
const lv_coord_t sw = lv_disp_get_hor_res(nullptr);
const lv_coord_t sh = lv_disp_get_ver_res(nullptr);
@@ -22628,7 +22829,8 @@ static void openReaderPage() {
s_reader_fwd = navBtn(LV_SYMBOL_RIGHT, readerFwdCb);
navBtn(LV_SYMBOL_REFRESH, readerRefreshCb);
s_reader_editbtn = navBtn(LV_SYMBOL_EDIT, readerEditBtnCb);
- readerRenderBody();
+ if (s_reader_busy) readerShowLoading();
+ else readerRenderBody();
// Reopen after a successful load -> collapsed (fullscreen reading); first open or
// after an error -> expanded so you can type a URL.
readerSetAddrExpanded(!(s_reader_ok && s_reader_text && s_reader_text[0]));
@@ -22637,6 +22839,15 @@ static void openReaderPage() {
// this page opens over a chat, leaving the tall bar's lower row (the back tap) buried
// under the reader root.
if (g_statusbar.root) lv_obj_move_foreground(g_statusbar.root);
+ const char* target = initial_url && initial_url[0] ? initial_url : kReaderHomeUrl;
+ if (s_reader_busy) {
+ snprintf(s_reader_pending_url, sizeof s_reader_pending_url, "%s", target);
+ readerSetAddrExpanded(false);
+ readerShowLoading();
+ } else {
+ s_reader_pending_url[0] = 0;
+ readerStart(target);
+ }
}
#endif // Reader
@@ -22850,7 +23061,7 @@ static void discoverBuildFeed() {
else if (buf[q - 1] == '\n') buf[q - 1] = '\0';
lv_label_set_text(s_discover_feed, buf);
if (s_discover_status)
- lv_label_set_text_fmt(s_discover_status, TR("%s \xC2\xB7 %d nearby (%d rpt, %d comp)"), s_discover_scanning ? "Scanning\xE2\x80\xA6" : "Paused", (int)m, rpt, comp);
+ lv_label_set_text_fmt(s_discover_status, TR("%s \xC2\xB7 %d nearby (%d rpt, %d comp)"), s_discover_scanning ? TR("Scanning\xE2\x80\xA6") : TR("Paused"), (int)m, rpt, comp);
}
// Sweep interval, backing off. A probe is not one packet: it is our zero-hop
@@ -26188,7 +26399,12 @@ static int verchkFetchLatest(WiFiClient& client, HTTPClient& http) {
fs::FS* luaHostAppFs(); // bridges (defined later in this TU)
void luaHostAppPath(char* out, size_t cap, const char* rel);
-struct LuaCatApp { char id[20]; char name[28]; char ver[12]; char desc[72]; };
+// `requires` is an optional capability name the board must have for the app to
+// work at all ("sdk_ext" today). beta_69 published Wardrive and Nearby, both of
+// which need the extended SDK, and the Heltec V4 does not have it -- so the most
+// common board in the mesh offered a prominent Get button for two apps that then
+// refused to do anything, with no warning anywhere (reported on Discord).
+struct LuaCatApp { char id[20]; char name[28]; char ver[12]; char desc[72]; char requires_cap[16]; };
// `icon` is the manifest's optional icon NAME (not a glyph): the device's JSON
// scanner only reads quoted strings, and a name is reviewable in a store
// submission where a raw private-use codepoint would not be. Mapped to a glyph
@@ -26310,7 +26526,7 @@ static bool luaStoreDownloadWorker(WiFiClient& client, HTTPClient& http,
luaHostAppPath(appsdir, sizeof appsdir, "/apps");
fs->mkdir(appsdir);
static const char* const kExt[2] = { "lua", "json" };
- const size_t kCap[2] = { 64 * 1024, 1024 };
+ const size_t kCap[2] = { 192 * 1024, 1024 }; // must match kMaxSrc in LuaAppHost.cpp
for (int e = 0; e < 2; e++) {
char* buf = (char*)heap_caps_malloc(kCap[e], MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT);
if (!buf) return false;
@@ -31256,6 +31472,11 @@ static void makeChatDetail(LvChatPanel& p) {
}
// Tap on composer → show keyboard
lv_obj_add_event_cb(p.composer_ta, composerFocusCb, LV_EVENT_FOCUSED, &p);
+#if CAP_TOUCH && !CAP_KEYBOARD
+ // A composer may remain group-focused after the keyboard is dismissed, so
+ // a later tap emits CLICKED without another FOCUSED event.
+ lv_obj_add_event_cb(p.composer_ta, composerFocusCb, LV_EVENT_CLICKED, &p);
+#endif
lv_obj_add_event_cb(p.composer_ta, kbActivityPressCb, LV_EVENT_PRESSED, nullptr);
// Grow / shrink the composer row as the message wraps (every text change).
lv_obj_add_event_cb(p.composer_ta, composerAutoGrowCb, LV_EVENT_VALUE_CHANGED, &p);
@@ -34099,7 +34320,7 @@ static void urlMenuOpenWebCb(lv_event_t* e) {
// new fetch) and the previous page so openReaderPage doesn't re-render the last URL.
if (!s_reader_task) s_reader_busy = false;
s_reader_ok = false;
- openReaderPage(); readerStart(u);
+ openReaderPage(u);
}
#endif
static void openUrlMenu(const char* url) {
@@ -36172,30 +36393,6 @@ static void updatePagerBackspaceHold(unsigned long now) {
s_was_held = held;
}
-// Orange/Alt key, tapped alone (not held as a symbol-layer modifier or for the
-// encoder's Alt+turn). In an open chat it takes over the old Backspace role:
-// jump to the latest message (when scrolled up in history) and drop focus in
-// the composer, ready to type — the natural "done reading, reply now" motion,
-// and the deliberate way OUT of the message list now that plain encoder turns
-// hold focus inside it while history loads (pagerEncoderChatEdgeScroll).
-// Everywhere else it stays the same "next field" as one rotary NEXT detent.
-// Call once per loop tick while the screen is on; screen-off handling discards
-// any pending tap instead (see loop()'s HAS_PAGER_KEYBOARD branch) so a stray
-// tap picked up while idle-dimmed can't fire the moment the screen wakes.
-static void updatePagerAltTapNext() {
- if (!pagerKeyboardConsumeAltTap()) return;
- LvChatPanel* cp = navOpenChatPanel();
- if (cp && cp->msgs && cp->composer_ta && lv_obj_is_valid(cp->composer_ta)) {
- if (chatVirtAwayFromBottom(cp)) chatVirtJumpToLatest(cp);
- lv_group_focus_obj(cp->composer_ta);
- s_nav_show = true;
- if (g_lv.task) g_lv.task->noteUserInput();
- return;
- }
- navPushTap(LV_KEY_NEXT);
- if (g_lv.task) g_lv.task->noteUserInput();
-}
-
// Alt(Fn)+Shift chord (PagerKeyboard.cpp only reports it, since the driver has
// no UI visibility): toggles Caps Lock while actually editing a text field
// (the field is where "Caps Lock" means anything) and is a deliberate no-op
@@ -36410,9 +36607,7 @@ static bool pagerEncoderScrollOversizedFocused(bool up) {
// the list instead (same navScrollFocused the Alt+turn branch uses): the
// scroll fires the virtualization render, the neighbor bubble joins the nav
// group via the tree-signature rebuild, and the next detent walks onto it.
-// Focus only leaves the list at the TRUE oldest/newest message. The orange-key
-// solo tap (updatePagerAltTapNext) pushes LV_KEY_NEXT directly and never comes
-// through here, so it stays the deliberate "leave the messages now" exit.
+// Focus only leaves the list at the TRUE oldest/newest message.
// Returns true when the detent was consumed as a scroll.
static bool pagerEncoderChatEdgeScroll(bool up) {
LvChatPanel* cp = navOpenChatPanel();
@@ -36583,8 +36778,8 @@ static void updatePagerEncoder(unsigned long now) {
if ((delta != 0 || held) && g_lv.task) g_lv.task->noteUserInput();
if (delta != 0 || held) noteKbActivity(); // same activity counts for the keyboard-backlight auto mode
- // Alt+turn is a modifier combo, not a solo Alt tap -- mark it used so a
- // release right after this doesn't ALSO fire updatePagerAltTapNext()'s NEXT.
+ // Alt+turn is a modifier combo, not a solo tap -- mark it used so releasing
+ // Alt afterward does not also arm the one-shot symbol layer.
if (pagerKeyboardAltHeld() && delta != 0) pagerKeyboardMarkAltUsed();
if (s_mentionnav_active) {
@@ -38103,6 +38298,7 @@ static bool m9HandleNavKey(int key) {
s_nav_show = true; if (g_lv.task) g_lv.task->noteUserInput(); return true;
case M9_KEY_HOME:
if (s_setup_root) return true;
+ s_nav_ta_editing = false; // Home is global, including while a textarea is being edited
if (s_apppage_close) {
// A full-screen app page (Lua app, Snake, Web…) isn't a popup-registry
// row — close it and stop: one action per press, so HOME on the Home
@@ -38408,6 +38604,10 @@ if (g_lv.task && g_lv.task->isManualLock()) {
lv_obj_t* ta = ta_focused;
#endif
#if defined(HAS_M9_KEYBOARD)
+ // Unlike directional keys and Back, the dedicated Home key is global. Route
+ // it before the textarea split so edit mode cannot swallow it as an unknown
+ // non-printable byte.
+ if (key == M9_KEY_HOME && m9HandleNavKey(key)) return;
if (m9HandleArrowKey(key, ta)) return; // ← runs BEFORE the if(!ta) split
#endif
if (!ta) {
@@ -38430,9 +38630,9 @@ if (g_lv.task && g_lv.task->isManualLock()) {
// below the "NEW ----" unread divider) and focus it, so the user reads the
// new messages chronologically with plain encoder turns from there; with
// no divider (nothing unread) it jumps to the NEWEST message instead.
- // (The old Backspace role -- jump to latest + focus the composer -- moved
- // to the solo orange/Alt tap, see updatePagerAltTapNext().) The divider
- // jump reuses the exact scroll recipe refreshChatDetail's open-to-divider
+ // The former solo-Alt shortcut now latches the symbol layer; at the true
+ // newest message, the next encoder detent leaves the list for the composer.
+ // The divider jump reuses the exact scroll recipe refreshChatDetail's open-to-divider
// path uses; both cases use the pending-focus re-aim so the recreated row
// for the target message ends up focused after the virtualization render.
// Outside a chat, Backspace keeps its normal no-op / hold-to-back
@@ -39209,6 +39409,49 @@ static void ccThemeCb(lv_event_t* e) {
openAccentPicker();
}
+#if defined(HAS_THINKNODE_M9) || defined(TLORA_PAGER)
+static uint8_t s_cc_screenshot_delay_s = 3;
+static lv_timer_t* s_cc_screenshot_timer = nullptr;
+
+static const char* ccScreenshotDelayText() {
+ return s_cc_screenshot_delay_s == 0 ? "now" :
+ s_cc_screenshot_delay_s == 3 ? "3s" : "10s";
+}
+
+static void ccScreenshotTimerCb(lv_timer_t*) {
+ s_cc_screenshot_timer = nullptr;
+ takeScreenshotToSd();
+}
+
+static void ccScreenshotDelayCb(lv_event_t* e) {
+ if (lv_event_get_code(e) != LV_EVENT_CLICKED) return;
+ s_cc_screenshot_delay_s = s_cc_screenshot_delay_s == 0 ? 3 :
+ s_cc_screenshot_delay_s == 3 ? 10 : 0;
+ openControlCenter();
+}
+
+static void ccScreenshotCb(lv_event_t* e) {
+ if (lv_event_get_code(e) != LV_EVENT_CLICKED) return;
+ if (s_cc_screenshot_timer) {
+ lv_timer_del(s_cc_screenshot_timer);
+ s_cc_screenshot_timer = nullptr;
+ }
+ closeControlCenter();
+ const uint32_t wait_ms = (uint32_t)s_cc_screenshot_delay_s * 1000u + 150u;
+ s_cc_screenshot_timer = lv_timer_create(ccScreenshotTimerCb, wait_ms, nullptr);
+ if (!s_cc_screenshot_timer) {
+ if (g_lv.task) g_lv.task->showAlert(TR("Screenshot: low memory"), 1800);
+ return;
+ }
+ lv_timer_set_repeat_count(s_cc_screenshot_timer, 1);
+ if (s_cc_screenshot_delay_s && g_lv.task) {
+ char msg[28];
+ snprintf(msg, sizeof msg, "Screenshot in %us", (unsigned)s_cc_screenshot_delay_s);
+ g_lv.task->showAlert(msg, 1200);
+ }
+}
+#endif
+
#if CAP_KEYBOARD
static void ccKbBacklightCb(lv_event_t* e) {
if (lv_event_get_code(e) != LV_EVENT_CLICKED) return;
@@ -39611,6 +39854,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) {
@@ -39627,11 +39875,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
@@ -39650,6 +39900,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
@@ -39750,7 +40001,13 @@ static void openControlCenter() {
lv_obj_remove_style_all(card);
#if defined(HAS_TANMATSU)
const int card_h = 384; // bigger: header + 3 roomier sliders + toggle grid + sysinfo
-#elif defined(HAS_TDECK_GT911) || defined(HAS_THINKNODE_M9)
+#elif defined(HAS_THINKNODE_M9)
+ // The panel may report a 240px portrait viewport even though the physical
+ // display is mounted above the keyboard. Eight chips need three rows there;
+ // use the available height instead of clipping the last row in the old
+ // T-Deck-sized 200px card. Landscape still needs only two rows.
+ const int card_h = portrait ? (sh - STATUSBAR_H - 10) : 210;
+#elif defined(HAS_TDECK_GT911)
const int card_h = 200; // sysinfo + thin brightness slider + 2-row toggle grid (fits 240−22 screen)
#elif defined(TLORA_PAGER)
// 222-px-tall screen: the shared V4/T-Deck-landscape 212px card below is
@@ -39855,7 +40112,9 @@ static void openControlCenter() {
// Brightness slider takes the first row (just under the date / battery) and the
// GPS line follows it, so the slider sits ABOVE the GPS line. Boards without a
// backlight PWM have no slider, so GPS reclaims that first row.
-#if defined(HAS_TANMATSU)
+#if defined(HAS_THINKNODE_M9) || defined(TLORA_PAGER)
+ const int bl_y = row_y; // keyboard-only boards use everything below this slider for controls
+#elif defined(HAS_TANMATSU)
const int bl_y = row_y; // three stacked sliders: Screen / Keys / Sound
const int gps_y = row_y + SC(100); // GPS line sits below all three (scaled so it clears taller sliders)
#elif defined(HAS_TDISPLAY_P4)
@@ -39868,6 +40127,7 @@ static void openControlCenter() {
const int gps_y = row_y;
#endif
+#if !defined(HAS_THINKNODE_M9) && !defined(TLORA_PAGER)
// ---- GPS fix status (live; refreshed while the panel is open) ----
s_cc_gps_label = lv_label_create(card);
lv_label_set_long_mode(s_cc_gps_label, LV_LABEL_LONG_DOT);
@@ -39890,6 +40150,7 @@ static void openControlCenter() {
lv_obj_align(s_cc_env_label, LV_ALIGN_TOP_LEFT, 0, gps_y + 12);
}
#endif
+#endif // !HAS_THINKNODE_M9 && !TLORA_PAGER
#if defined(HAS_TANMATSU)
// ---- Three stacked sliders: Screen brightness, Keyboard backlight, Volume.
@@ -39968,6 +40229,7 @@ static void openControlCenter() {
lv_obj_add_event_cb(bl, ccBrightnessReleaseCb, LV_EVENT_RELEASED, nullptr);
#endif
+#if !defined(HAS_THINKNODE_M9) && !defined(TLORA_PAGER)
// ---- System info line (CPU + RAM% + PSRAM% + IP) — one thin line, pinned to
// the very bottom of the card. Memory is shown as % used so it stays
// compact and all four facts fit on one row.
@@ -39987,6 +40249,7 @@ static void openControlCenter() {
lv_obj_set_style_text_color(sysl, lv_color_hex(COLOR_SUB), LV_PART_MAIN);
lv_obj_align(sysl, LV_ALIGN_BOTTOM_MID, 0, 0);
}
+#endif
// ---- Quick toggles (bottom row) ----
bool wifi_on = false, ble_on = false;
@@ -40001,7 +40264,16 @@ static void openControlCenter() {
lv_obj_set_flex_flow(row, LV_FLEX_FLOW_ROW_WRAP);
lv_obj_set_style_pad_row(row, 10, LV_PART_MAIN);
lv_obj_set_style_pad_column(row, 10, LV_PART_MAIN);
-#elif defined(HAS_TDECK_GT911) || defined(HAS_THINKNODE_M9)
+#elif defined(HAS_THINKNODE_M9) || defined(TLORA_PAGER)
+ // Reclaim everything below brightness: no GPS/status line and no bottom
+ // system-info line. Five compact cells fit across the Pager landscape view;
+ // M9 portrait wraps to three rows. Spread the rows through the reclaimed band.
+ const int controls_y = bl_y + 16;
+ lv_obj_set_size(row, card_w - 20, card_h - controls_y - 20);
+ lv_obj_align(row, LV_ALIGN_TOP_MID, 0, controls_y);
+ lv_obj_set_flex_flow(row, LV_FLEX_FLOW_ROW_WRAP);
+ lv_obj_set_style_pad_row(row, 4, LV_PART_MAIN);
+#elif defined(HAS_TDECK_GT911)
// 2-row grid: 4 chips per row, so chip 5 (Lock) wraps onto a 2nd row. Sits
// ABOVE the bottom system-info line (-16 offset leaves room for it).
lv_obj_set_size(row, card_w - 20, 80);
@@ -40016,9 +40288,15 @@ static void openControlCenter() {
lv_obj_set_flex_flow(row, LV_FLEX_FLOW_ROW_WRAP);
lv_obj_set_style_pad_column(row, 5, LV_PART_MAIN);
#endif
+#if !defined(HAS_THINKNODE_M9) && !defined(TLORA_PAGER)
// Bottom-anchored but lifted clear of the 1-line sysinfo (~16 px) plus a gap.
lv_obj_align(row, LV_ALIGN_BOTTOM_MID, 0, -20);
+#endif
+#if defined(HAS_THINKNODE_M9) || defined(TLORA_PAGER)
+ lv_obj_set_flex_align(row, LV_FLEX_ALIGN_SPACE_EVENLY, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_SPACE_EVENLY);
+#else
lv_obj_set_flex_align(row, LV_FLEX_ALIGN_SPACE_EVENLY, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
+#endif
lv_obj_clear_flag(row, LV_OBJ_FLAG_SCROLLABLE);
const bool gps_on = g_lv.task && g_lv.task->getGPSState();
// T-Deck: chips in a 2-row grid. V4/pager: chips sized to fit the card width
@@ -40028,7 +40306,9 @@ static void openControlCenter() {
tw = (card_w - 20 - 20) / 3; // 3 chips per row (Wi-Fi/BT/GPS/Theme/Keys/Sound → 2×3 grid)
if (tw > 175) tw = 175;
th = 80;
-#elif defined(HAS_TDECK_GT911) || defined(HAS_THINKNODE_M9)
+#elif defined(HAS_THINKNODE_M9) || defined(TLORA_PAGER)
+ tw = 56; th = 34;
+#elif defined(HAS_TDECK_GT911)
tw = 58; th = 36;
#else
// Count only the chips this board/session will actually add below (which chips
@@ -40098,6 +40378,11 @@ static void openControlCenter() {
const bool dnd_on = touchPrefsGetDndEnabled();
ccToggle(row, dnd_on ? TOUCH_SYM_BELL_SLASH : TOUCH_SYM_BELL, TR("DND"), dnd_on, ccDndCb, tw, th, CAT_SOUND);
}
+#if defined(HAS_THINKNODE_M9) || defined(TLORA_PAGER)
+ ccToggle(row, LV_SYMBOL_REFRESH, "Timer", s_cc_screenshot_delay_s != 0,
+ ccScreenshotDelayCb, tw, th, -1, ccScreenshotDelayText());
+ ccToggle(row, LV_SYMBOL_IMAGE, "Screenshot", false, ccScreenshotCb, tw, th);
+#endif
// (Power is the round icon in the card's top-right corner, not a grid chip.)
}
@@ -40364,6 +40649,7 @@ static void luaStoreParseCatalog() {
luaJsonField(obj, "name", c.name, sizeof c.name) &&
luaJsonField(obj, "ver", c.ver, sizeof c.ver)) {
if (!luaJsonField(obj, "desc", c.desc, sizeof c.desc)) c.desc[0] = 0;
+ if (!luaJsonField(obj, "requires", c.requires_cap, sizeof c.requires_cap)) c.requires_cap[0] = 0;
s_lua_cat_n++;
}
p = cb + 1;
@@ -40378,6 +40664,16 @@ static void luaStoreParseCatalog() {
// The caption is HIDDEN rather than deleted: this runs inside the button's own
// click event, and deleting objects mid-dispatch is exactly how the popup
// use-after-frees happened. Adding the spinner as a new child is safe.
+// Empty requirement = runs anywhere. Unknown requirement = allow: a newer
+// catalog must not disable apps on older firmware that has never heard of the
+// name, which would be the wrong way round.
+static bool luaStoreBoardMeets(const char* req) {
+ if (!req || !req[0]) return true;
+ if (!strcmp(req, "sdk_ext")) return CAP_LUA_SDK_EXT != 0;
+ if (!strcmp(req, "sd")) return CAP_SD != 0;
+ return true;
+}
+
static void luaStoreMarkBtnBusy(lv_obj_t* b) {
if (!b) return;
const uint32_t n = lv_obj_get_child_cnt(b);
@@ -40984,10 +41280,18 @@ static void luaStoreRebuildList() {
const bool cur = inst && strcmp(inst->ver, s_lua_cat[i].ver) == 0;
if (!inst) lv_obj_set_style_bg_color(b, lv_color_hex(COLOR_ACCENT), LV_PART_MAIN);
else if (!cur) lv_obj_set_style_bg_color(b, lv_color_hex(0x4F9DF7), LV_PART_MAIN);
- lv_label_set_text(bl, !inst ? TR("Get") : cur ? TR("Remove") : TR("Update"));
+ const bool runnable = luaStoreBoardMeets(s_lua_cat[i].requires_cap);
+ lv_label_set_text(bl, !runnable ? TR("N/A") : !inst ? TR("Get") : cur ? TR("Remove") : TR("Update"));
lv_obj_set_style_text_font(bl, &g_font_12, LV_PART_MAIN);
lv_obj_center(bl);
- if (inst && cur) {
+ if (!runnable && !inst) {
+ // Greyed and inert, with the reason on the row rather than a toast after
+ // a pointless download.
+ lv_obj_set_style_bg_color(b, lv_color_hex(0x39404C), LV_PART_MAIN);
+ lv_obj_clear_flag(b, LV_OBJ_FLAG_CLICKABLE);
+ lv_obj_set_style_text_color(ds, lv_color_hex(0xD7574E), LV_PART_MAIN);
+ lv_label_set_text(ds, TR("This board cannot run this app."));
+ } else if (inst && cur) {
// Remove needs the INSTALLED index
int ii = (int)(inst - s_lua_inst);
lv_obj_set_style_bg_color(b, lv_color_hex(0x8A4444), LV_PART_MAIN);
@@ -42033,7 +42337,6 @@ static bool s_sb_shot_done = false;
// detector aborts it), and a sticky bool would then eat the NEXT genuine bar tap. A stale
// one simply expires.
static uint32_t s_sb_back_ms = 0;
-static void takeScreenshotToSd(); // fwd
static void statusBarHoldCb(lv_event_t* e) {
switch (lv_event_get_code(e)) {
case LV_EVENT_PRESSED: s_sb_press_ms = millis(); s_sb_shot_done = false; break;
@@ -42247,7 +42550,7 @@ static void docCaptureTour() {
// On-device web browser (8 MB boards only) + the chat-link menu + the QR popup.
// The reader renders from the UI-update poll (which doesn't run while this tour
// blocks the loop), so drive the fetch-wait + render inline here.
- openReaderPage(); readerStart("wadamesh.com");
+ openReaderPage("wadamesh.com");
{ int _w = 0; while (s_reader_busy && _w < 700) { lv_timer_handler(); delay(22); ++_w; } esp_task_wdt_reset(); }
if (s_reader_ok) { readerRenderBody(); readerSetAddrExpanded(false); }
else { readerShowMessage(s_reader_msg[0] ? s_reader_msg : "Couldn't load page", 0xE0A0A0); readerSetAddrExpanded(true); }
@@ -43635,14 +43938,31 @@ static void refreshStatusLabels() {
// The page is a full-screen overlay (any tab), so gate on the page being open.
if (s_reader_dirty && s_reader_root) {
s_reader_dirty = false;
- if (s_reader_status) lv_label_set_text(s_reader_status, s_reader_msg);
- if (!s_reader_busy) { // the fetch finished
- if (s_reader_ok) { // …with a page
- readerRenderBody();
- readerSetAddrExpanded(false); // collapse the address bar -> fullscreen reading
- } else { // …with an error — SHOW it (don't sit on "Loading…")
- readerShowMessage(s_reader_msg[0] ? s_reader_msg : "Couldn't load page", 0xE0A0A0);
- readerSetAddrExpanded(true); // expand so the address bar + retry are reachable
+ if (!s_reader_busy && s_reader_pending_url[0]) {
+ char pending_url[sizeof s_reader_pending_url];
+ snprintf(pending_url, sizeof pending_url, "%s", s_reader_pending_url);
+ s_reader_pending_url[0] = 0;
+ readerStart(pending_url);
+ } else {
+ if (s_reader_status) lv_label_set_text(s_reader_status, s_reader_msg);
+ if (!s_reader_busy) { // the fetch finished
+ if (s_reader_ok) { // …with a page
+ readerRenderBody();
+ readerSetAddrExpanded(false); // collapse the address bar -> fullscreen reading
+ } else if (s_reader_home_missing) { // no optional SD home page: retain the normal URL-entry UX
+ s_reader_home_missing = false;
+ s_reader_hist_n = 0; s_reader_hist_pos = -1;
+ s_reader_url[0] = 0;
+ if (s_reader_text) s_reader_text[0] = 0;
+ s_reader_nlinks = 0;
+ if (s_reader_url_ta) lv_textarea_set_text(s_reader_url_ta, kReaderDefaultUrl);
+ s_reader_pristine = true;
+ readerRenderBody();
+ readerSetAddrExpanded(true);
+ } else { // …with an error — SHOW it (don't sit on "Loading…")
+ readerShowMessage(s_reader_msg[0] ? s_reader_msg : "Couldn't load page", 0xE0A0A0);
+ readerSetAddrExpanded(true); // expand so the address bar + retry are reachable
+ }
}
}
}
@@ -47071,18 +47391,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");
@@ -47209,6 +47533,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
@@ -52167,6 +53237,169 @@ void consoleHostRebootToUi() {
void UITask::msgRead(int msgcount) { _msgcount = msgcount; }
+// ---- "At a glance" notification --------------------------------------------
+// New, board-agnostic feature (distinct from the T-Deck's existing opt-in
+// "msgFlash", which fully wakes to the live app + pulses the keyboard
+// backlight): while the device is UNLOCKED but its screen has idle-dimmed to
+// off, an incoming message (DND permitting) briefly lights a plain black
+// overlay showing just the channel/sender name and the message text, then
+// dims back out on its own after a fixed window -- a glance, not a wake.
+// Never opens the chat, so the message stays unread, and whatever tab/chat
+// was open underneath is left exactly as it was. atGlanceShow() below only
+// builds/updates the overlay; UITask::newMsgImpl() decides whether to fire it
+// (and wakes the screen + arms the auto-hide window); UITask::loop() clears it.
+static lv_obj_t* s_glance_root = nullptr;
+static lv_obj_t* s_glance_title = nullptr;
+static lv_obj_t* s_glance_body = nullptr;
+static uint32_t s_glance_lit_ms = 0; // millis() when (re)shown, 0 = not showing -- loop() re-dims 5s later
+static bool s_glance_fading_out = false; // one-shot guard: the 200ms fade-out anim has already been started
+
+static void atGlanceOpaCb(void* var, int32_t v) {
+ lv_obj_set_style_opa(static_cast(var), static_cast(v), LV_PART_MAIN);
+}
+
+// Message-body font, built once: stock Montserrat 28 (ASCII) -> extras_lat_28
+// (accented Latin, em-dash, ellipsis -- now compiled on every board, not just the
+// Tanmatsu; see extras_lat_28.c) -> extras_16 tail (Cyrillic/Greek/Arabic, baked at
+// 16 px so those scripts render a bit small relative to Latin text, but never a
+// missing-glyph box). 28, not 24: only 12/14/16/28 are actually enabled in this
+// project's vendored LVGL config (LV_FONT_MONTSERRAT_24 isn't). g_font_16 itself
+// is deliberately left alone -- it's the UI-scale-driven font hundreds of OTHER
+// widgets use, not something to repurpose for one feature.
+//
+// T-Deck experiment: 20px instead of 28px (LV_FONT_MONTSERRAT_20 + extras_lat_20
+// widened for this board specifically, see lv_conf.h/extras_lat_20.c) -- trying
+// a smaller glance body on this panel. Revert by dropping this branch + those
+// two gate widenings.
+static lv_font_t s_glance_body_font;
+static bool s_glance_font_ready = false;
+static void atGlanceEnsureFont() {
+ if (s_glance_font_ready) return;
+ s_glance_font_ready = true;
+#if defined(HAS_TDECK_GT911)
+ static lv_font_t s_lat20;
+ s_lat20 = extras_lat_20;
+ s_lat20.fallback = &extras_16;
+ s_glance_body_font = lv_font_montserrat_20;
+ s_glance_body_font.fallback = &s_lat20;
+#else
+ static lv_font_t s_lat28;
+ s_lat28 = extras_lat_28;
+ s_lat28.fallback = &extras_16;
+ s_glance_body_font = lv_font_montserrat_28;
+ s_glance_body_font.fallback = &s_lat28;
+#endif
+}
+
+static void atGlanceHide() {
+ if (s_glance_root) {
+ // Only the TEXT fades (see atGlanceShow()) -- the root's own opa is never
+ // touched, so nothing to reset on it. Just cancel any in-flight label fades.
+ if (s_glance_title) { lv_anim_del(s_glance_title, atGlanceOpaCb); lv_obj_set_style_opa(s_glance_title, LV_OPA_COVER, LV_PART_MAIN); }
+ if (s_glance_body) { lv_anim_del(s_glance_body, atGlanceOpaCb); lv_obj_set_style_opa(s_glance_body, LV_OPA_COVER, LV_PART_MAIN); }
+ lv_obj_add_flag(s_glance_root, LV_OBJ_FLAG_HIDDEN);
+ }
+ s_glance_lit_ms = 0;
+ s_glance_fading_out = false;
+}
+
+// `fade_in`: true on the first reveal of a burst (screen was off), false when
+// a later message in the same burst just updates the text on an already-lit
+// overlay -- no need to re-fade something already fully visible.
+static void atGlanceShow(const char* title, const char* body, bool fade_in) {
+ if (!g_lv.ready) return;
+ if (!s_glance_root) {
+ s_glance_root = lv_obj_create(lv_layer_top());
+ lv_obj_remove_style_all(s_glance_root);
+ lv_obj_set_style_bg_color(s_glance_root, lv_color_black(), LV_PART_MAIN);
+ lv_obj_set_style_bg_opa(s_glance_root, LV_OPA_COVER, LV_PART_MAIN);
+ lv_obj_clear_flag(s_glance_root, LV_OBJ_FLAG_SCROLLABLE);
+ lv_obj_add_flag(s_glance_root, LV_OBJ_FLAG_CLICKABLE); // absorb taps -- no UI leak underneath
+ // Never a keyboard/encoder nav target on any board: same reasoning as the
+ // lock screen's NAV_SKIP_FLAG (see lockscreenShow()) -- a CLICKABLE
+ // top-layer overlay with nothing to navigate to would otherwise be
+ // collected as the sole focus target, and navFocusCb's focus-highlight
+ // would invert its text to dark the instant the nav group (re)builds.
+ lv_obj_add_flag(s_glance_root, NAV_SKIP_FLAG);
+
+ // Small "eyebrow" label (channel/sender) above the message, in the app's
+ // accent colour -- the message itself is the thing that needs to be
+ // readable from a few feet away (desk-mounted device), so it gets the
+ // dominant size/position below and this stays a secondary context cue.
+ s_glance_title = lv_label_create(s_glance_root);
+ lv_obj_set_style_text_font(s_glance_title, &g_font_16, LV_PART_MAIN);
+ lv_obj_set_style_text_color(s_glance_title, lv_color_hex(COLOR_ACCENT), LV_PART_MAIN);
+ lv_obj_set_width(s_glance_title, lv_pct(85));
+ lv_obj_set_style_text_align(s_glance_title, LV_TEXT_ALIGN_LEFT, LV_PART_MAIN);
+ lv_label_set_long_mode(s_glance_title, LV_LABEL_LONG_DOT); // single line, ellipsize -- guaranteed to fit every board's width
+ lv_obj_align(s_glance_title, LV_ALIGN_TOP_LEFT, 14, 30); // below the ~22 px status bar band (the real status bar renders above this overlay)
+
+ // Message body: 28 px (bumped up from 16 -- 16 wasn't legible from a few
+ // feet away on a desk-mounted device) via atGlanceEnsureFont()'s own
+ // fallback chain, so arbitrary chat text (accents, em-dash, ellipsis)
+ // still never shows a missing-glyph box at this size.
+ atGlanceEnsureFont();
+ s_glance_body = lv_label_create(s_glance_root);
+ lv_obj_set_style_text_font(s_glance_body, &s_glance_body_font, LV_PART_MAIN);
+ lv_obj_set_style_text_color(s_glance_body, lv_color_hex(0xFFFFFFu), LV_PART_MAIN);
+ lv_obj_set_width(s_glance_body, lv_pct(85));
+ lv_obj_set_style_text_align(s_glance_body, LV_TEXT_ALIGN_LEFT, LV_PART_MAIN);
+ // LV_LABEL_LONG_DOT only wraps across multiple lines (dot-ellipsizing the LAST
+ // one on overflow) when the label has a fixed HEIGHT, not just a fixed width --
+ // left at auto/content height (as this was), it sizes to exactly one line, so
+ // every board was effectively single-line regardless of panel size (reported:
+ // text cropped at the end of the first line instead of continuing on a second).
+ // Give it the actual remaining space below this label's own y-offset down to a
+ // small bottom margin, computed from this board's resolution + active body font,
+ // so it uses exactly what's available -- more lines on tall panels, fewer on the
+ // 222 px-tall Pager -- instead of a guessed fixed line count.
+ {
+ const lv_coord_t avail_h = lv_disp_get_ver_res(nullptr) - 60 - 10;
+ const lv_coord_t line_h = lv_font_get_line_height(&s_glance_body_font);
+ lv_obj_set_height(s_glance_body, avail_h > line_h ? avail_h : line_h);
+ }
+ lv_label_set_long_mode(s_glance_body, LV_LABEL_LONG_DOT);
+ lv_obj_align(s_glance_body, LV_ALIGN_TOP_LEFT, 14, 60); // a bit more clearance now the title above is 16 px, not 12
+ }
+ const lv_coord_t sw = lv_disp_get_hor_res(nullptr);
+ const lv_coord_t sh = lv_disp_get_ver_res(nullptr);
+ lv_obj_set_size(s_glance_root, sw, sh);
+ lv_obj_set_pos(s_glance_root, 0, 0);
+ lv_label_set_text(s_glance_title, title ? title : "");
+ lv_label_set_text(s_glance_body, body ? body : "");
+ // Cancel any fade-out already begun (by an earlier message in this burst) on
+ // either label.
+ lv_anim_del(s_glance_title, atGlanceOpaCb);
+ lv_anim_del(s_glance_body, atGlanceOpaCb);
+ lv_obj_clear_flag(s_glance_root, LV_OBJ_FLAG_HIDDEN);
+ lv_obj_move_foreground(s_glance_root);
+ if (fade_in) {
+ // Only the TEXT fades in, never the root: the root's bg_opa is COVER (solid
+ // black) from the moment it's created and stays that way, so the very first
+ // frame -- forced-painted BEFORE the backlight comes on (see newMsgImpl()) --
+ // is already a clean black screen, not a transparent one letting the live
+ // app screen show through underneath for a frame (reported: "I see the app
+ // screen then I see the at-glance"). Fading the whole root's opa used to fade
+ // the background along with the text, which is what caused that.
+ lv_obj_set_style_opa(s_glance_title, LV_OPA_TRANSP, LV_PART_MAIN);
+ lv_obj_set_style_opa(s_glance_body, LV_OPA_TRANSP, LV_PART_MAIN);
+ lv_obj_t* targets[2] = { s_glance_title, s_glance_body };
+ for (lv_obj_t* t : targets) {
+ lv_anim_t a;
+ lv_anim_init(&a);
+ lv_anim_set_var(&a, t);
+ lv_anim_set_time(&a, 180);
+ lv_anim_set_values(&a, LV_OPA_TRANSP, LV_OPA_COVER);
+ lv_anim_set_exec_cb(&a, atGlanceOpaCb);
+ lv_anim_start(&a);
+ }
+ } else {
+ // Already visible -- just swap the text, no re-fade.
+ lv_obj_set_style_opa(s_glance_title, LV_OPA_COVER, LV_PART_MAIN);
+ lv_obj_set_style_opa(s_glance_body, LV_OPA_COVER, LV_PART_MAIN);
+ }
+}
+
// Shared core for newMsg / newMsgFromPubWithMeta. Bundles the channel-vs-DM
// sender parsing + thread routing in one place so the meta-aware path doesn't
// drift from the plain path. `meta_flags` is 0 (no RX metadata) when called
@@ -52269,6 +53502,61 @@ void UITask::newMsgImpl(uint8_t path_len, const char* from_name, const char* tex
else if (touchPrefsGetSoundMessages() && !(cmute & TOUCH_CHMUTE_MSG)) uiPlaySlot(TOUCH_SND_MSG);
}
}
+#endif
+#if defined(HAS_TOUCH_UI)
+ // "At a glance": device unlocked but idle-dimmed to off -- light the plain
+ // black glance overlay instead of the full app (see atGlanceShow() above).
+ // Master-disabled via Options > Display > "At a glance"
+ // (touchPrefsGetGlanceEnabled()) skips this entirely. Skipped if DND is on.
+ // Manually locked is ALSO skipped unless the user opted in via Options >
+ // Display > "At a glance while locked" (touchPrefsGetGlanceWhenLocked())
+ // -- off by default since it's a real trade-off (message text becomes
+ // readable off a locked device). `_screen_off` covers the first message of
+ // a burst (wake + show); `s_glance_lit_ms` (already showing, from an
+ // earlier message in the same window) covers later ones -- update the text
+ // and restart the 5 s window instead of dropping them, since our own wake
+ // already cleared _screen_off by then.
+#if defined(ESP32)
+ const bool glance_enabled_ok = touchPrefsGetGlanceEnabled();
+ const bool glance_locked_ok = !_manual_lock || touchPrefsGetGlanceWhenLocked();
+#else
+ const bool glance_enabled_ok = true;
+ const bool glance_locked_ok = !_manual_lock;
+#endif
+ if (glance_enabled_ok && !dndActive() && glance_locked_ok && (_screen_off || s_glance_lit_ms)) {
+ const bool was_off = _screen_off;
+ atGlanceShow(thread, body, was_off); // fade in only on the initial reveal of a burst
+ if (was_off) {
+ lv_refr_now(nullptr); // paint before the backlight comes on -- no stale-frame flash
+ if (_manual_lock) {
+ // Locked: light the panel WITHOUT clearing the lock -- wakeScreen()
+ // unconditionally clears _manual_lock, which would silently unlock the
+ // device (touch/input live again) just to show a glance. Mirrors
+ // lockscreenReveal()'s peek path instead: same fields, lock untouched.
+ // The auto-hide re-dim below (UITask::loop()) is symmetric -- it never
+ // touches _manual_lock either, so the device is exactly as locked after
+ // the glance as before it.
+ setCpuForScreen(true);
+ touchScreenBacklight(true);
+ _screen_off = false;
+ // MUST bump this even though it's not real input: loop()'s generic idle
+ // screen-timeout check fires on !_screen_off && (now - _last_input_ms) >=
+ // _screen_timeout_ms regardless of _manual_lock -- leaving it stale (from
+ // before the lock) made that check true on the very next loop() tick,
+ // instantly flipping the backlight back off before the glance was ever
+ // seen (reported: sound played but nothing showed). wakeScreen() bumps
+ // this for the same reason; omitting it here was the bug.
+ _last_input_ms = millis();
+#if CAP_LOCK_SCREEN
+ _lock_lit_ms = millis(); // keep the lock screen's own burn-in guard armed too
+#endif
+ } else {
+ wakeScreen();
+ }
+ }
+ s_glance_lit_ms = millis();
+ s_glance_fading_out = false; // a new message cancels any fade-out already begun
+ }
#endif
// Inbound route (repeater hashes) for the Info popup — flood RX only; read
// synchronously from MyMesh, which stashed it just before this call.
@@ -52546,6 +53834,17 @@ static bool sdRuntimeLifecycleBusy() {
bool busy = s_hist_flush_busy || s_hist_flush_req || s_sdinfo_request ||
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
@@ -52834,6 +54133,8 @@ void UITask::loop() {
// started above.
bool con_activity = false;
#if defined(HAS_TDECK_KEYBOARD)
+ if (_screen_off || _manual_lock) tdeckKeyboardDiscardModifiers();
+ else tdeckKeyboardAllowModifiers();
for (int kbi = 0; kbi < 12; ++kbi) {
int key = tdeckKeyboardReadKey();
if (key <= 0) break;
@@ -52842,7 +54143,7 @@ void UITask::loop() {
// A key pressed on a dark screen WAKES rather than types, the same as a
// touch does in the UI. Otherwise the character you used to see the screen
// ends up in the command you are typing.
- if (_screen_off) { wakeScreen(); continue; }
+ if (_screen_off) { tdeckKeyboardDiscardModifiers(); wakeScreen(); continue; }
consoleKey(key);
}
#endif
@@ -53035,6 +54336,7 @@ void UITask::loop() {
* doesn't fire later. */
if (s_ui_ping_deadline_ms != 0 && now >= s_ui_ping_deadline_ms) {
s_ui_ping_deadline_ms = 0;
+ the_mesh.cancelUIDeferredLogin();
the_mesh.cancelUIPingPending();
char msg[64];
snprintf(msg, sizeof(msg), TR("No reply from %s"), s_ui_ping_target_name);
@@ -53260,6 +54562,40 @@ void UITask::loop() {
}
#endif // HAS_TDECK_KEYBOARD || HAS_M9_KEYBOARD (notify-wake re-dim)
+ // "At a glance" auto-hide: same re-dim shape as the msgflash window above,
+ // but this feature's own fixed 5 s window (the last 200 ms of it spent
+ // fading out, not a hard cut) and unconditional on every board (see
+ // atGlanceShow()/newMsgImpl() above). Real input during the window hands
+ // off to the user actually looking at the screen now -- don't snatch it
+ // back off from underneath them; just stop tracking it and let the normal
+ // idle timeout (already bumped by that real input) take over.
+ if (s_glance_lit_ms) {
+ if (_screen_off) atGlanceHide(); // already dark again some other way
+ else if (_last_input_ms > s_glance_lit_ms) atGlanceHide(); // user took over
+ else {
+ const int32_t elapsed = (int32_t)(now - s_glance_lit_ms);
+ if (!s_glance_fading_out && elapsed >= 4800) {
+ s_glance_fading_out = true;
+ lv_obj_t* targets[2] = { s_glance_title, s_glance_body }; // text only -- see atGlanceShow()
+ for (lv_obj_t* t : targets) {
+ lv_anim_t a;
+ lv_anim_init(&a);
+ lv_anim_set_var(&a, t);
+ lv_anim_set_time(&a, 200);
+ lv_anim_set_values(&a, LV_OPA_COVER, LV_OPA_TRANSP);
+ lv_anim_set_exec_cb(&a, atGlanceOpaCb);
+ lv_anim_start(&a);
+ }
+ }
+ if (elapsed >= 5000) {
+ atGlanceHide();
+ touchScreenBacklight(false);
+ setCpuForScreen(false);
+ _screen_off = true;
+ }
+ }
+ }
+
#if defined(HAS_TOUCH_UI)
if (!g_lv.ready) return;
@@ -53445,12 +54781,15 @@ void UITask::loop() {
updatePagerEncoder(now);
#endif
#if defined(HAS_TDECK_KEYBOARD)
+ if (_screen_off || _manual_lock || s_remote_mode) tdeckKeyboardDiscardModifiers();
+ else tdeckKeyboardAllowModifiers();
// Drain physical-keyboard presses buffered by the touch task into the field.
for (int kbi = 0; kbi < 12; ++kbi) {
int key = tdeckKeyboardReadKey();
if (key <= 0) break;
if (!_screen_off) s_kb_last_key_ms = now; // a keypress while locked must not light the kb
- if (s_remote_mode) { remotePhysicalKey(key); continue; } // remote mode: physical keys are the exit
+ if (s_remote_mode) { tdeckKeyboardDiscardModifiers(); remotePhysicalKey(key); continue; } // remote mode: physical keys are the exit
+ if (_screen_off) tdeckKeyboardDiscardModifiers();
handleHwKey(key);
}
// Focusing a text field (cursor starts blinking — tapped or auto-focused)
@@ -53492,6 +54831,7 @@ void UITask::loop() {
// working while the screen is dark. updatePagerKbBacklight() follows the
// same rule. Remote Mode pauses both because it owns the lit placeholder.
pagerKeyboardPoll();
+ if (g_lv.task && g_lv.task->isManualLock()) pagerKeyboardDiscardAlt();
// Remote Mode keeps the placeholder lit and reserves all keys for its local
// escape path, so normal lock/backlight state machines stay paused there.
if (!s_remote_mode) {
@@ -53507,7 +54847,7 @@ void UITask::loop() {
if (key <= 0) break;
remotePhysicalKey(key);
}
- pagerKeyboardConsumeAltTap();
+ pagerKeyboardDiscardAlt();
pagerKeyboardConsumeAltShiftChord();
pagerKeyboardConsumeAltBackspaceChord();
} else if (g_lv.task && g_lv.task->isScreenOff()) {
@@ -53526,10 +54866,10 @@ void UITask::loop() {
any = true;
if (k == 0x08) saw_backspace = true;
}
- // Discard any Alt tap / Alt+Shift / Alt+Backspace chord picked up while
- // idle-dimmed -- none of them may fire (NEXT / Caps toggle / jump Home)
+ // Discard any Alt latch / Alt+Shift / Alt+Backspace chord picked up while
+ // idle-dimmed -- none of them may fire (symbol layer / Caps / jump Home)
// the instant the screen wakes.
- pagerKeyboardConsumeAltTap();
+ pagerKeyboardDiscardAlt();
pagerKeyboardConsumeAltShiftChord();
pagerKeyboardConsumeAltBackspaceChord();
// Hard-locked: an ordinary keypress must NOT wake/unlock -- only holding
@@ -53554,7 +54894,6 @@ void UITask::loop() {
if (key <= 0) break;
handleHwKey(key);
}
- updatePagerAltTapNext();
updatePagerAltShiftChord();
updatePagerAltBackspaceChord();
updatePagerBackspaceHold(now);
diff --git a/src/ui-touch/Utf8Text.h b/src/ui-touch/Utf8Text.h
new file mode 100644
index 0000000..90571fe
--- /dev/null
+++ b/src/ui-touch/Utf8Text.h
@@ -0,0 +1,78 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+#pragma once
+
+#include
+#include
+#include
+
+namespace Utf8Text {
+
+inline bool continuation(uint8_t byte) {
+ return (byte & 0xC0U) == 0x80U;
+}
+
+inline size_t sequenceLength(const uint8_t* text, size_t remaining) {
+ if (!remaining) return 0;
+ const uint8_t first = text[0];
+ if (first <= 0x7F) return 1;
+ if (first >= 0xC2 && first <= 0xDF) {
+ return remaining >= 2 && continuation(text[1]) ? 2 : 0;
+ }
+ if (first == 0xE0) {
+ return remaining >= 3 && text[1] >= 0xA0 && text[1] <= 0xBF && continuation(text[2]) ? 3 : 0;
+ }
+ if ((first >= 0xE1 && first <= 0xEC) || (first >= 0xEE && first <= 0xEF)) {
+ return remaining >= 3 && continuation(text[1]) && continuation(text[2]) ? 3 : 0;
+ }
+ if (first == 0xED) {
+ return remaining >= 3 && text[1] >= 0x80 && text[1] <= 0x9F && continuation(text[2]) ? 3 : 0;
+ }
+ if (first == 0xF0) {
+ return remaining >= 4 && text[1] >= 0x90 && text[1] <= 0xBF &&
+ continuation(text[2]) && continuation(text[3]) ? 4 : 0;
+ }
+ if (first >= 0xF1 && first <= 0xF3) {
+ return remaining >= 4 && continuation(text[1]) && continuation(text[2]) && continuation(text[3]) ? 4 : 0;
+ }
+ if (first == 0xF4) {
+ return remaining >= 4 && text[1] >= 0x80 && text[1] <= 0x8F &&
+ continuation(text[2]) && continuation(text[3]) ? 4 : 0;
+ }
+ return 0;
+}
+
+inline bool valid(const char* text, size_t length) {
+ const uint8_t* bytes = reinterpret_cast(text);
+ size_t offset = 0;
+ while (offset < length) {
+ const size_t sequence = sequenceLength(bytes + offset, length - offset);
+ if (!sequence) return false;
+ offset += sequence;
+ }
+ return true;
+}
+
+// Returns the original pointer when it is already valid. Malformed runs are
+// collapsed to one ASCII '?' in scratch so LVGL never receives a partial codepoint.
+inline const char* sanitize(const char* text, size_t length, std::string& scratch) {
+ scratch.clear();
+ if (valid(text, length)) return text;
+
+ scratch.reserve(length);
+ const uint8_t* bytes = reinterpret_cast(text);
+ size_t offset = 0;
+ while (offset < length) {
+ const size_t sequence = sequenceLength(bytes + offset, length - offset);
+ if (sequence) {
+ scratch.append(text + offset, sequence);
+ offset += sequence;
+ continue;
+ }
+ scratch.push_back('?');
+ ++offset;
+ while (offset < length && continuation(bytes[offset])) ++offset;
+ }
+ return scratch.c_str();
+}
+
+} // namespace Utf8Text
\ No newline at end of file
diff --git a/src/ui-touch/device_caps.h b/src/ui-touch/device_caps.h
index 879d43f..b005ffc 100644
--- a/src/ui-touch/device_caps.h
+++ b/src/ui-touch/device_caps.h
@@ -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.
diff --git a/src/ui-touch/extras_lat_20.c b/src/ui-touch/extras_lat_20.c
index 2a4de98..db9406a 100644
--- a/src/ui-touch/extras_lat_20.c
+++ b/src/ui-touch/extras_lat_20.c
@@ -1,5 +1,9 @@
#include "device_caps.h"
-#if defined(HAS_TANMATSU)
+// Was HAS_TANMATSU-only (Large/Huge UI-scale accented-Latin fallback); the T-Deck
+// now also builds this for the "at a glance" notification's 20 px message body
+// (see atGlanceEnsureFont() in UITask.cpp) -- an experiment to see whether a
+// smaller-than-28px glance body is still legible on that panel.
+#if defined(HAS_TANMATSU) || defined(HAS_TDECK_GT911)
/*******************************************************************************
* Size: 20 px
@@ -2563,4 +2567,4 @@ lv_font_t extras_lat_20 = {
#endif /*#if EXTRAS_LAT_20*/
-#endif /* HAS_TANMATSU */
+#endif /* HAS_TANMATSU || HAS_TDECK_GT911 */
diff --git a/src/ui-touch/extras_lat_28.c b/src/ui-touch/extras_lat_28.c
index a9a3965..fa4aebc 100644
--- a/src/ui-touch/extras_lat_28.c
+++ b/src/ui-touch/extras_lat_28.c
@@ -1,5 +1,8 @@
#include "device_caps.h"
-#if defined(HAS_TANMATSU)
+// Was HAS_TANMATSU-only (only consumer used to be the Tanmatsu's Large/Huge UI-scale
+// accented-Latin fallback); now compiled on every board too for the "at a glance"
+// notification's 28 px message body (see atGlanceEnsureFont() in UITask.cpp), which
+// needs accented Latin / em-dash / ellipsis glyph coverage at that size on any board.
/*******************************************************************************
* Size: 28 px
@@ -3637,6 +3640,3 @@ lv_font_t extras_lat_28 = {
#endif /*#if EXTRAS_LAT_28*/
-
-
-#endif /* HAS_TANMATSU */
diff --git a/src/ui-touch/i18n_builtin.h b/src/ui-touch/i18n_builtin.h
index 29c9de1..bf5050f 100644
--- a/src/ui-touch/i18n_builtin.h
+++ b/src/ui-touch/i18n_builtin.h
@@ -363,7 +363,7 @@ static const I18nPair kBuiltin_hu[] = {
{ "History limit off", "Előzménykorlát kikapcsolva" },
{ "Home", "Kezdőlap" },
{ "Hotkey saved", "Gyorsbillentyű mentése" },
- { "How tiles work:\nThe map is built from 256×256 \"slippy\" tiles. Only the tiles for the area you're viewing are fetched — there is no bulk pre-download.\n\nBecause this device can't do HTTPS (not enough heap after Wi-Fi starts) and decodes JPEG far more cheaply than PNG, tiles come from the wadamesh proxy: it fetches the upstream PNG over HTTPS with an identifying User-Agent, re-encodes it as JPEG, and caches it. Your device then caches each tile to its own flash, so a tile is only downloaded once.\n\nUse Options → Reload tiles to re-download the tiles currently in view if one looks corrupted.", "A csempék működése:\nA térkép 256×256 képpontos „slippy” csempékből épül fel. Csak a megtekintett területhez szükséges csempék töltődnek le — nincs tömeges előzetes letöltés.\n\nMivel az eszköz nem támogatja a HTTPS-t (a Wi-Fi elindítása után nincs elegendő heap memória), és a JPEG képek dekódolása sokkal kevesebb erőforrást igényel, mint a PNG-é, a csempék a wadamesh proxyn keresztül érkeznek: a proxy HTTPS-en keresztül lekéri a forrásból a PNG-t egy azonosító User-Agent használatával, JPEG formátumba kódolja át, majd eltárolja a gyorsítótárban. Az eszköz ezután minden csempét a saját flash memóriájában is eltárol, így egy csempét csak egyszer kell letölteni.\n\nAz Options → Reload tiles lehetőséggel újra letöltheted a jelenleg megtekintett terület csempéit, ha valamelyik hibásan jelenik meg." },
+ { "How tiles work:\nThe map is built from 256×256 \"slippy\" tiles. Only the tiles for the area you're viewing are fetched — there is no bulk pre-download.\n\nBecause this device can't do HTTPS (not enough heap after Wi-Fi starts) and decodes JPEG far more cheaply than PNG, tiles come from the wadamesh proxy: it fetches the upstream PNG over HTTPS with an identifying User-Agent, re-encodes it as JPEG, and caches it. Your device then caches each tile to its own flash, so a tile is only downloaded once.\n\nUse Options → Reload tiles to re-download the tiles currently in view if one looks corrupted.", "A csempék működése:\nA térkép 256×256 képpontos \"slippy\" csempékből épül fel. Csak a megtekintett területhez szükséges csempék töltődnek le — nincs tömeges előzetes letöltés.\n\nMivel az eszköz nem támogatja a HTTPS-t (a Wi-Fi elindítása után nincs elegendő heap memória), és a JPEG képek dekódolása sokkal kevesebb erőforrást igényel, mint a PNG-é, a csempék a wadamesh proxyn keresztül érkeznek: a proxy HTTPS-en keresztül lekéri a forrásból a PNG-t egy azonosító User-Agent használatával, JPEG formátumba kódolja át, majd eltárolja a gyorsítótárban. Az eszköz ezután minden csempét a saját flash memóriájában is eltárol, így egy csempét csak egyszer kell letölteni.\n\nAz Options → Reload tiles lehetőséggel újra letöltheted a jelenleg megtekintett terület csempéit, ha valamelyik hibásan jelenik meg." },
{ "How you'll appear to other nodes. You can change this later in Settings.", "Hogyan fogsz megjelenni más csomópontok számára. Ezt később módosíthatod a Beállításokban." },
{ "Humidity", "Páratartalom" },
{ "I accept the privacy risk", "Elfogadom az adatvédelmi kockázatot" },
@@ -386,6 +386,7 @@ static const I18nPair kBuiltin_hu[] = {
{ "Install", "Telepít" },
{ "Install a previous version", "Telepítse az előző verziót" },
{ "Install an earlier version", "Telepítse egy korábbi verziót" },
+ { "Install beta_%d?\nThis downgrades the firmware and reboots.", "A beta_%d telepítése?\nEz leminősíti a firmware-t és újraindul." },
{ "Install failed (network?)", "A telepítés nem sikerült (hálózat?)" },
{ "Install update", "Frissítés telepítése" },
{ "Installed", "Telepítve" },
@@ -723,7 +724,7 @@ static const I18nPair kBuiltin_hu[] = {
{ "SD card removed", "SD-kártya eltávolítva" },
{ "SD data is unavailable - contacts and channels cannot be saved until the card is reinserted.", "Az SD-kártya adatai nem érhetők el – a névjegyek és a csatornák nem menthetők a kártya újbóli behelyezéséig." },
{ "SD data is unavailable - identity, settings, contacts and channels cannot be saved until the card is reinserted.", "Az SD-kártya adatai nem érhetők el – az azonosító, a beállítások, a névjegyek és a csatornák nem menthetők a kártya újbóli behelyezéséig." },
- { "SD data migration is incomplete. Identity and settings remain internal; use Copy internal data to SD to retry.", "Az SD-kártya adatainak áthelyezése nem teljes. Az azonosító és a beállítások továbbra is a belső tárhelyen vannak; a folytatáshoz használd a „Belső adatok másolása SD-kártyára” lehetőséget." },
+ { "SD data migration is incomplete. Identity and settings remain internal; use Copy internal data to SD to retry.", "Az SD-kártya adatainak áthelyezése nem teljes. Az azonosító és a beállítások továbbra is a belső tárhelyen vannak; a folytatáshoz használd a \"Belső adatok másolása SD-kártyára\" lehetőséget." },
{ "SD download failed: %s", "SD-letöltés sikertelen: %s" },
{ "SD formatted - %s (MESHCOMOD)", "SD formázás - %s (MESHCOMOD)" },
{ "SD is busy - close tools and retry", "Az SD-kártya foglalt – zárd be az eszközöket, majd próbáld újra." },
@@ -835,6 +836,7 @@ static const I18nPair kBuiltin_hu[] = {
{ "Sightline", "Rálátás" },
{ "Signal", "Jelerősség" },
{ "Signal & traffic", "Jelzés és forgalom" },
+ { "Signal (stale)", "Jel (elavult)" },
{ "Signal probe", "Jelmérés" },
{ "Size", "Méret" },
{ "Skip", "Kihagyás" },
@@ -1010,6 +1012,7 @@ static const I18nPair kBuiltin_hu[] = {
{ "my region", "saját régió" },
{ "n/a in this build", "n/a ebben a buildben" },
{ "not asked yet", "Még nem kérték" },
+ { "nothing heard yet", "Még semmi hír." },
{ "now on channel %s", "most a %s csatornán" },
{ "now talking to %s", "most %s-szel beszélgetek" },
{ "off", "ki" },
@@ -2606,7 +2609,7 @@ static const I18nPair kBuiltin_de[] = {
{ "SD card removed", "SD-Karte entfernt" },
{ "SD data is unavailable - contacts and channels cannot be saved until the card is reinserted.", "SD-Daten nicht verfügbar – Kontakte und Kanäle können erst nach Einsetzen der Karte gespeichert werden." },
{ "SD data is unavailable - identity, settings, contacts and channels cannot be saved until the card is reinserted.", "SD-Daten nicht verfügbar – Identität, Einstellungen, Kontakte und Kanäle können erst nach Einsetzen der Karte gespeichert werden." },
- { "SD data migration is incomplete. Identity and settings remain internal; use Copy internal data to SD to retry.", "SD-Datenübertragung unvollständig. Identität und Einstellungen bleiben intern; „Interne Daten auf SD kopieren“ zum erneuten Versuch verwenden." },
+ { "SD data migration is incomplete. Identity and settings remain internal; use Copy internal data to SD to retry.", "SD-Datenübertragung unvollständig. Identität und Einstellungen bleiben intern; \"Interne Daten auf SD kopieren\" zum erneuten Versuch verwenden." },
{ "SD download failed: %s", "SD-Download fehlgeschlagen: %s" },
{ "SD formatted - %s (MESHCOMOD)", "SD formatiert - %s (MESHCOMOD)" },
{ "SD is busy - close tools and retry", "SD ist belegt – Tools schließen und erneut versuchen" },
@@ -8070,7 +8073,7 @@ static const I18nPair kBuiltin_bg[] = {
{ "TCP off", "TCP изкл" },
{ "TCP on", "TCP вкл" },
{ "Tab hotkeys — tap a row, then press a key", "Клавиши за раздела — докосни ред, после натисни клавиш" },
- { "Tap Use to switch - the device reboots to apply.", "Натиснете „Избери“, за да превключите — устройството ще се рестартира." },
+ { "Tap Use to switch - the device reboots to apply.", "Натиснете \"Избери\", за да превключите — устройството ще се рестартира." },
{ "Tap Use to switch - the device reboots to apply. Language files live in /lang on the storage; edit them or add your own.", "Докосни «Избери» за смяна - устройството се рестартира. Езиковите файлове са в /lang в паметта; редактирай ги или добави свои." },
{ "Tap a language; the device reboots to apply.", "Изберете език; устройството ще се рестартира." },
{ "Taste the rainbow!", "Вкуси дъгата!" },
@@ -8965,7 +8968,7 @@ static const I18nPair kBuiltin_sr[] = {
{ "TCP off", "TCP искљ" },
{ "TCP on", "TCP укљ" },
{ "Tab hotkeys — tap a row, then press a key", "Пречице картице — додирни ред, па притисни тастер" },
- { "Tap Use to switch - the device reboots to apply.", "Додирните „Користи“ да промените — уређај ће се поново покренути." },
+ { "Tap Use to switch - the device reboots to apply.", "Додирните \"Користи\" да промените — уређај ће се поново покренути." },
{ "Tap Use to switch - the device reboots to apply. Language files live in /lang on the storage; edit them or add your own.", "Додирни «Користи» за промену - уређај се поново покреће. Датотеке језика су у /lang на меморији; уреди их или додај своје." },
{ "Tap a language; the device reboots to apply.", "Изаберите језик; уређај ће се рестартовати." },
{ "Taste the rainbow!", "Окуси дугу!" },
diff --git a/src/ui-touch/lua_builtin.h b/src/ui-touch/lua_builtin.h
index ecbcc82..e653820 100644
--- a/src/ui-touch/lua_builtin.h
+++ b/src/ui-touch/lua_builtin.h
@@ -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
@@ -1473,6 +1490,34 @@ local pending = {} -- lines waiting on the 1 write/sec limit
local function logname() return run .. ".csv" end
+-- Return at most max_bytes without splitting a UTF-8 sequence. The row's
+-- fixed-width columns are byte-budgeted, not character-budgeted; a raw
+-- string.sub(1, 14) cut Ouderkerk + sun + VS16 inside the final codepoint and
+-- left LVGL unable to advance through the label (#323, same class as #223).
+local function utf8_prefix_bytes(text, max_bytes)
+ local offset, last, length = 1, 0, #text
+ while offset <= length and offset <= max_bytes do
+ local first = text:byte(offset)
+ local width = first <= 0x7F and 1
+ or (first >= 0xC2 and first <= 0xDF and 2)
+ or (first >= 0xE0 and first <= 0xEF and 3)
+ or (first >= 0xF0 and first <= 0xF4 and 4) or 0
+ if width == 0 or offset + width - 1 > length or offset + width - 1 > max_bytes then break end
+ local valid = true
+ for i = 2, width do
+ local byte = text:byte(offset + i - 1)
+ if byte < 0x80 or byte > 0xBF then valid = false; break end
+ end
+ local second = width > 1 and text:byte(offset + 1) or 0
+ if (first == 0xE0 and second < 0xA0) or (first == 0xED and second > 0x9F) or
+ (first == 0xF0 and second < 0x90) or (first == 0xF4 and second > 0x8F) then valid = false end
+ if not valid then break end
+ last = offset + width - 1
+ offset = last + 1
+ end
+ return text:sub(1, last)
+end
+
-- Lua here is built with 32-bit floats, so fix.lat is good to about a metre and
-- no better. fix.lat_e6 is the same reading as an exact integer in
-- micro-degrees, which is what belongs in a log: a survey you plot months later
@@ -1554,7 +1599,7 @@ local function redraw()
local e = list[i]
if e then
rows[i]:set(string.format("%-14s %-8s best %5.1f worst %5.1f x%d",
- e.n.name:sub(1, 14), TYPE[e.n.type] or "?", e.n.best, e.n.worst, e.n.seen))
+ utf8_prefix_bytes(e.n.name, 14), TYPE[e.n.type] or "?", e.n.best, e.n.worst, e.n.seen))
rows[i]:color(e.n.best > 0 and C.good or C.text)
else
rows[i]:set(i == 1 and "nothing has answered a probe yet" or "")
@@ -1775,9 +1820,9 @@ 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.0", kLuaSrc_wardrive },
+ { "wardrive", "Wardrive", "1.1", kLuaSrc_wardrive },
{ "nearby", "Nearby", "1.0", kLuaSrc_nearby },
};
static const int kLuaBuiltinCount = (int)(sizeof(kLuaBuiltin)/sizeof(kLuaBuiltin[0]));
diff --git a/test/test_latched_modifier.cpp b/test/test_latched_modifier.cpp
new file mode 100644
index 0000000..c7ade51
--- /dev/null
+++ b/test/test_latched_modifier.cpp
@@ -0,0 +1,62 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+#include
+
+#include "helpers/input/LatchedModifier.h"
+
+int main() {
+ LatchedModifier modifier;
+
+ modifier.press();
+ assert(modifier.consumeForKey());
+ modifier.release(100);
+ assert(!modifier.consumeForKey());
+
+ modifier.press();
+ modifier.release(200);
+ assert(modifier.consumeForKey());
+ assert(!modifier.consumeForKey());
+
+ modifier.press();
+ modifier.release(400);
+ modifier.press();
+ modifier.release(700);
+ assert(modifier.locked());
+ assert(modifier.consumeForKey());
+ assert(modifier.consumeForKey());
+
+ modifier.press();
+ modifier.release(800);
+ assert(!modifier.active());
+
+ modifier.tap(1000);
+ modifier.tap(1500);
+ assert(!modifier.locked());
+ assert(modifier.consumeForKey());
+
+ modifier.tap(2000);
+ modifier.clearLatched();
+ assert(!modifier.active());
+
+ modifier.tap(2100);
+ modifier.discard();
+ assert(!modifier.active());
+
+ modifier.press();
+ modifier.discard();
+ modifier.release(2200);
+ assert(!modifier.active());
+
+ LatchedModifier wrapped;
+ wrapped.tap(UINT32_MAX - 100);
+ wrapped.tap(100);
+ assert(wrapped.locked());
+
+ wrapped.baseline(true);
+ assert(wrapped.held());
+ wrapped.release(200);
+ assert(!wrapped.active());
+ wrapped.baseline(false);
+ assert(!wrapped.active());
+ return 0;
+}
\ No newline at end of file
diff --git a/test/test_minimp3_decoder.cpp b/test/test_minimp3_decoder.cpp
new file mode 100644
index 0000000..93f4faf
--- /dev/null
+++ b/test/test_minimp3_decoder.cpp
@@ -0,0 +1,144 @@
+#include
+#include
+#include
+#include
+
+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;
+}
diff --git a/test/test_pager_keyboard_state.cpp b/test/test_pager_keyboard_state.cpp
new file mode 100644
index 0000000..8d428ae
--- /dev/null
+++ b/test/test_pager_keyboard_state.cpp
@@ -0,0 +1,87 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+#include
+
+#include "helpers/input/PagerKeyboardState.h"
+
+int main() {
+ PagerKeyboardState keyboard;
+
+ assert(keyboard.event(0, true, 10) == 'q');
+ keyboard.event(0, false, 20);
+
+ keyboard.event(PagerKeyboardState::ALT_POS, true, 100);
+ assert(keyboard.altHeld());
+ assert(keyboard.event(0, true, 110) == '1');
+ keyboard.event(0, false, 120);
+ keyboard.event(PagerKeyboardState::ALT_POS, false, 130);
+ assert(keyboard.event(0, true, 140) == 'q');
+ keyboard.event(0, false, 150);
+
+ keyboard.event(PagerKeyboardState::ALT_POS, true, 200);
+ keyboard.event(PagerKeyboardState::ALT_POS, false, 220);
+ assert(keyboard.event(1, true, 230) == '2');
+ keyboard.event(1, false, 240);
+ assert(keyboard.event(1, true, 250) == 'w');
+ keyboard.event(1, false, 260);
+
+ keyboard.event(PagerKeyboardState::ALT_POS, true, 300);
+ keyboard.event(PagerKeyboardState::ALT_POS, false, 320);
+ keyboard.event(PagerKeyboardState::ALT_POS, true, 400);
+ keyboard.event(PagerKeyboardState::ALT_POS, false, 420);
+ assert(keyboard.event(0, true, 430) == '1');
+ keyboard.event(0, false, 440);
+ assert(keyboard.event(1, true, 450) == '2');
+ keyboard.event(1, false, 460);
+ keyboard.event(PagerKeyboardState::ALT_POS, true, 470);
+ keyboard.event(PagerKeyboardState::ALT_POS, false, 480);
+ assert(keyboard.event(0, true, 490) == 'q');
+ keyboard.event(0, false, 500);
+
+ keyboard.event(PagerKeyboardState::SHIFT_POS, true, 510);
+ assert(keyboard.event(0, true, 520) == 'Q');
+ keyboard.event(0, false, 530);
+ keyboard.event(PagerKeyboardState::SHIFT_POS, false, 540);
+
+ keyboard.event(PagerKeyboardState::ALT_POS, true, 600);
+ keyboard.event(PagerKeyboardState::SHIFT_POS, true, 610);
+ assert(keyboard.consumeAltShiftChord());
+ assert(!keyboard.consumeAltShiftChord());
+ keyboard.toggleCaps();
+ keyboard.event(PagerKeyboardState::SHIFT_POS, false, 620);
+ keyboard.event(PagerKeyboardState::ALT_POS, false, 630);
+ assert(keyboard.event(0, true, 640) == 'Q');
+ keyboard.event(0, false, 650);
+ keyboard.toggleCaps();
+
+ keyboard.event(PagerKeyboardState::ALT_POS, true, 700);
+ assert(keyboard.event(PagerKeyboardState::BACKSPACE_POS, true, 710) == 0);
+ assert(keyboard.consumeAltBackspaceChord());
+ assert(!keyboard.backspaceHeld());
+ keyboard.event(PagerKeyboardState::BACKSPACE_POS, false, 720);
+ keyboard.event(PagerKeyboardState::ALT_POS, false, 730);
+ assert(keyboard.event(0, true, 740) == 'q');
+ keyboard.event(0, false, 750);
+
+ keyboard.event(PagerKeyboardState::ALT_POS, true, 800);
+ keyboard.discardAlt();
+ keyboard.event(PagerKeyboardState::ALT_POS, false, 810);
+ assert(keyboard.event(0, true, 820) == 'q');
+ keyboard.event(0, false, 830);
+
+ keyboard.event(PagerKeyboardState::ALT_POS, true, 900);
+ keyboard.event(PagerKeyboardState::ALT_POS, false, 920);
+ keyboard.markAltUsed(); // no effect without a physical hold
+ assert(keyboard.event(PagerKeyboardState::SPACE_POS, true, 930) == ' ');
+ assert(keyboard.spaceHeld());
+ keyboard.event(PagerKeyboardState::SPACE_POS, false, 940);
+ assert(!keyboard.spaceHeld());
+ assert(keyboard.event(0, true, 950) == 'q');
+ keyboard.event(0, false, 960);
+
+ keyboard.event(PagerKeyboardState::ALT_POS, true, 1000);
+ keyboard.markAltUsed(); // physical Alt+encoder equivalent
+ keyboard.event(PagerKeyboardState::ALT_POS, false, 1010);
+ assert(keyboard.event(0, true, 1020) == 'q');
+ return 0;
+}
\ No newline at end of file
diff --git a/test/test_reader_content.cpp b/test/test_reader_content.cpp
new file mode 100644
index 0000000..588422f
--- /dev/null
+++ b/test/test_reader_content.cpp
@@ -0,0 +1,50 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+#include
+#include
+
+#include "ui-touch/ReaderContent.h"
+
+int main() {
+ static const char html[] =
+ "Bookmarks
"
+ "CNN
"
+ "Local news & weather
"
+ "";
+ char text[256];
+ ReaderContent::Link links[4] = {};
+ size_t link_count = 0;
+ const size_t text_len = ReaderContent::htmlToText(
+ html, strlen(html), text, sizeof text, "sd:/home.htm",
+ links, 4, &link_count);
+
+ assert(text_len == strlen(text));
+ assert(strstr(text, "Bookmarks") != nullptr);
+ assert(strstr(text, "CNN") != nullptr);
+ assert(strstr(text, "Local news & weather") != nullptr);
+ assert(strstr(text, "12345") == nullptr);
+ assert(link_count == 2);
+ assert(strcmp(links[0].href, "https://lite.cnn.com") == 0);
+ assert(strcmp(links[1].href, "sd:/news.htm") == 0);
+ assert(strncmp(text + links[0].start, "CNN", links[0].end - links[0].start) == 0);
+ assert(strncmp(text + links[1].start, "Local news", links[1].end - links[1].start) == 0);
+
+ char resolved[240];
+ assert(ReaderContent::resolveUrl("sd:/bookmarks/home.htm", "../index.htm",
+ resolved, sizeof resolved));
+ assert(strcmp(resolved, "sd:/bookmarks/../index.htm") == 0);
+ assert(ReaderContent::resolveUrl("sd:/home.htm", "//text.npr.org",
+ resolved, sizeof resolved));
+ assert(strcmp(resolved, "https://text.npr.org") == 0);
+ assert(!ReaderContent::resolveUrl("sd:/home.htm", "javascript:alert(1)",
+ resolved, sizeof resolved));
+
+ static const char attribute_html[] =
+ "Right";
+ link_count = 0;
+ ReaderContent::htmlToText(attribute_html, strlen(attribute_html), text, sizeof text,
+ "sd:/home.htm", links, 4, &link_count);
+ assert(link_count == 1);
+ assert(strcmp(links[0].href, "sd:/right.htm") == 0);
+ return 0;
+}
\ No newline at end of file
diff --git a/test/test_tdeck_keyboard_state.cpp b/test/test_tdeck_keyboard_state.cpp
new file mode 100644
index 0000000..7357435
--- /dev/null
+++ b/test/test_tdeck_keyboard_state.cpp
@@ -0,0 +1,164 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+#include
+#include
+
+#include "helpers/input/TDeckKeyboardState.h"
+
+static size_t update(TDeckKeyboardState& keyboard, uint8_t state[5], uint32_t now,
+ uint8_t* out) {
+ return keyboard.update(state, now, out, 8);
+}
+
+static void setKey(uint8_t state[5], int col, int row, bool down) {
+ if (down) state[col] |= (uint8_t)(1U << row);
+ else state[col] &= (uint8_t)~(1U << row);
+}
+
+int main() {
+ TDeckKeyboardState keyboard;
+ uint8_t state[5] = {};
+ uint8_t out[8] = {};
+
+ setKey(state, 0, 2, true); // hold Sym
+ assert(update(keyboard, state, 10, out) == 0);
+ setKey(state, 0, 0, true); // q -> #
+ assert(update(keyboard, state, 20, out) == 1 && out[0] == '#');
+ setKey(state, 0, 0, false);
+ update(keyboard, state, 30, out);
+ setKey(state, 0, 2, false);
+ update(keyboard, state, 40, out);
+ setKey(state, 0, 0, true); // hold use must not latch
+ assert(update(keyboard, state, 50, out) == 1 && out[0] == 'q');
+ setKey(state, 0, 0, false);
+ update(keyboard, state, 60, out);
+
+ setKey(state, 0, 2, true); // tap Sym
+ update(keyboard, state, 100, out);
+ setKey(state, 0, 2, false);
+ update(keyboard, state, 120, out);
+ setKey(state, 0, 1, true); // w -> 1 once
+ assert(update(keyboard, state, 130, out) == 1 && out[0] == '1');
+ setKey(state, 0, 1, false);
+ update(keyboard, state, 140, out);
+ setKey(state, 0, 1, true);
+ assert(update(keyboard, state, 150, out) == 1 && out[0] == 'w');
+ setKey(state, 0, 1, false);
+ update(keyboard, state, 160, out);
+
+ setKey(state, 0, 2, true); // an unmapped symbol still uses the hold
+ update(keyboard, state, 170, out);
+ setKey(state, 0, 5, true); // Space has no T-Deck symbol mapping
+ assert(update(keyboard, state, 175, out) == 0);
+ setKey(state, 0, 5, false);
+ update(keyboard, state, 180, out);
+ setKey(state, 0, 2, false);
+ update(keyboard, state, 185, out);
+ setKey(state, 0, 0, true);
+ assert(update(keyboard, state, 190, out) == 1 && out[0] == 'q');
+ setKey(state, 0, 0, false);
+ update(keyboard, state, 195, out);
+
+ setKey(state, 0, 4, true); // held Alt preserves base layer
+ update(keyboard, state, 200, out);
+ setKey(state, 0, 0, true);
+ assert(update(keyboard, state, 210, out) == 1 && out[0] == 'q');
+ setKey(state, 0, 0, false);
+ update(keyboard, state, 215, out);
+ setKey(state, 3, 4, true); // Alt+B stays controller-only
+ assert(update(keyboard, state, 216, out) == 0);
+ setKey(state, 3, 4, false);
+ update(keyboard, state, 217, out);
+ setKey(state, 2, 5, true); // Alt+C keeps its legacy control code
+ assert(update(keyboard, state, 218, out) == 1 && out[0] == 0x0C);
+ setKey(state, 2, 5, false);
+ update(keyboard, state, 219, out);
+ setKey(state, 0, 4, false);
+ update(keyboard, state, 220, out);
+
+ setKey(state, 0, 4, true); // solo Alt is a one-shot symbol latch
+ update(keyboard, state, 225, out);
+ setKey(state, 0, 4, false);
+ update(keyboard, state, 230, out);
+ setKey(state, 0, 0, true);
+ assert(update(keyboard, state, 235, out) == 1 && out[0] == '#');
+ setKey(state, 0, 0, false);
+ update(keyboard, state, 240, out);
+
+ setKey(state, 0, 4, true); // double-tap Alt locks symbols
+ update(keyboard, state, 250, out);
+ setKey(state, 0, 4, false);
+ update(keyboard, state, 270, out);
+ setKey(state, 0, 4, true);
+ update(keyboard, state, 300, out);
+ setKey(state, 0, 4, false);
+ update(keyboard, state, 320, out);
+ setKey(state, 0, 0, true);
+ assert(update(keyboard, state, 330, out) == 1 && out[0] == '#');
+ setKey(state, 0, 0, false);
+ update(keyboard, state, 340, out);
+ setKey(state, 0, 1, true);
+ assert(update(keyboard, state, 350, out) == 1 && out[0] == '1');
+ setKey(state, 0, 1, false);
+ update(keyboard, state, 360, out);
+ setKey(state, 0, 4, true); // tap Alt unlocks
+ update(keyboard, state, 400, out);
+ setKey(state, 0, 4, false);
+ update(keyboard, state, 420, out);
+
+ setKey(state, 0, 2, true); // double-tap Sym locks symbols
+ update(keyboard, state, 430, out);
+ setKey(state, 0, 2, false);
+ update(keyboard, state, 440, out);
+ setKey(state, 0, 2, true);
+ update(keyboard, state, 450, out);
+ setKey(state, 0, 2, false);
+ update(keyboard, state, 460, out);
+ setKey(state, 0, 1, true);
+ assert(update(keyboard, state, 470, out) == 1 && out[0] == '1');
+ setKey(state, 0, 1, false);
+ update(keyboard, state, 475, out);
+ setKey(state, 0, 2, true);
+ update(keyboard, state, 480, out);
+ setKey(state, 0, 2, false);
+ update(keyboard, state, 490, out);
+
+ setKey(state, 1, 6, true); // held Shift still uppercases base
+ update(keyboard, state, 500, out);
+ setKey(state, 0, 0, true);
+ assert(update(keyboard, state, 510, out) == 1 && out[0] == 'Q');
+
+ setKey(state, 0, 0, false);
+ setKey(state, 1, 6, false);
+ update(keyboard, state, 520, out);
+ setKey(state, 0, 2, true); // discarded physical hold
+ update(keyboard, state, 600, out);
+ keyboard.discardModifiers();
+ setKey(state, 0, 2, false);
+ update(keyboard, state, 620, out);
+ setKey(state, 0, 0, true);
+ assert(update(keyboard, state, 630, out) == 1 && out[0] == 'q');
+ setKey(state, 0, 0, false);
+ update(keyboard, state, 640, out);
+
+ setKey(state, 0, 4, true); // discarded Alt hold cannot latch on release
+ update(keyboard, state, 650, out);
+ keyboard.discardModifiers();
+ setKey(state, 0, 4, false);
+ update(keyboard, state, 660, out);
+ setKey(state, 0, 0, true);
+ assert(update(keyboard, state, 670, out) == 1 && out[0] == 'q');
+
+ setKey(state, 0, 0, false);
+ update(keyboard, state, 680, out);
+ setKey(state, 0, 2, true); // modifiers disabled: base key still routes
+ update(keyboard, state, 690, out);
+ setKey(state, 0, 0, true);
+ assert(keyboard.update(state, 700, out, 8, false) == 1 && out[0] == 'q');
+
+ uint8_t baseline[5] = {};
+ setKey(baseline, 0, 1, true); // transition frame produces no edge later
+ keyboard.baseline(baseline);
+ assert(keyboard.update(baseline, 710, out, 8) == 0);
+ return 0;
+}
\ No newline at end of file
diff --git a/test/test_utf8_text.cpp b/test/test_utf8_text.cpp
new file mode 100644
index 0000000..3ccecf0
--- /dev/null
+++ b/test/test_utf8_text.cpp
@@ -0,0 +1,32 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+#include
+#include
+#include
+
+#include "ui-touch/Utf8Text.h"
+
+int main() {
+ const char full[] = "Ouderkerk\xE2\x98\x80\xEF\xB8\x8F";
+ assert(strlen(full) == 15);
+ assert(Utf8Text::valid(full, strlen(full)));
+
+ std::string broken(full, 14); // Wardrive's former name:sub(1, 14)
+ assert(!Utf8Text::valid(broken.data(), broken.size()));
+
+ std::string scratch;
+ const char* clean = Utf8Text::sanitize(broken.data(), broken.size(), scratch);
+ assert(scratch == "Ouderkerk\xE2\x98\x80?");
+ assert(Utf8Text::valid(clean, scratch.size()));
+
+ scratch = "allocated";
+ const char* unchanged = Utf8Text::sanitize(full, strlen(full), scratch);
+ assert(unchanged == full);
+ assert(scratch.empty());
+
+ const char overlong[] = { (char)0xC0, (char)0xAF };
+ assert(!Utf8Text::valid(overlong, sizeof overlong));
+ Utf8Text::sanitize(overlong, sizeof overlong, scratch);
+ assert(scratch == "?");
+ return 0;
+}
\ No newline at end of file
diff --git a/variants/tdisplay_p4/P4Audio.cpp b/variants/tdisplay_p4/P4Audio.cpp
index c0a5a9e..a2658b8 100644
--- a/variants/tdisplay_p4/P4Audio.cpp
+++ b/variants/tdisplay_p4/P4Audio.cpp
@@ -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
diff --git a/variants/tdisplay_p4/P4Audio.h b/variants/tdisplay_p4/P4Audio.h
index 46d9681..e1d1418 100644
--- a/variants/tdisplay_p4/P4Audio.h
+++ b/variants/tdisplay_p4/P4Audio.h
@@ -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
+#include
// 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();