Merge upstream beta_68 into the M9 compass / GPS work

beta_68 expanded the Lua SDK (map, lists, packet delivery, discovery, private
messages/rooms, native crypto) across the same files as this branch, so four
files conflicted. Nothing was dropped from either side:

- wada.sys.gps(): both widenings merged into one binding. Upstream's
  fix_time / lat_e6 / lon_e6 and our speed_kmh / course now share a signature,
  and our stricter gate wins -- the call returns nil when the user has GPS
  switched off, not just when there is no fix.
- Altitude is upstream's `alt_m` alone. The resolution first carried `alt`
  beside it to protect a shipped app, but gpscompass has never been published
  to the store (it exists only in this branch and on a bench device), so
  carrying a duplicate key into the API forever was the wrong trade: the app
  reads alt_m instead.
- sysCaps() carries all twelve feature flags (upstream's seven, our compass,
  the four originals) with a matching table hint.
- wada.geo (upstream) and wada.sys.compass (ours) both survive; upstream's
  "no board has a magnetometer" note is corrected in the code and on the SDK
  page, since the M9 now does.
- hostTeardown frees upstream's new POST payload buffer as well as the fetch
  buffer, under the same in-flight guard.
- The catalog keeps all three new apps: upstream's wardrive and nearby, ours
  gpscompass (8 total, every referenced file present).

M9, V4, V4-R8, T-Deck and Pager all compile; the Lua host harness passes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Christopher Van Hoose
2026-08-22 02:43:55 -04:00
co-authored by Claude Fable 5
103 changed files with 57796 additions and 266 deletions
+6
View File
@@ -36,3 +36,9 @@ CLAUDE.md
# Local reconstruction notes (scratch, never publish)
/.notes/
# Tanmatsu sideload tooling: fetched/dumped per developer, never committed.
# appfs-src is badge.team's repo (fetch-appfs.sh); dev_appfs.bin is a dump of YOUR
# device's AppFS partition and is both large and device-specific.
tanmatsu/tools/appfs-src/
tanmatsu/tools/dev_appfs.bin
+4 -1
View File
@@ -13,6 +13,10 @@ USB) and the [GitHub releases](https://github.com/ALLFATHER-BV/wadamesh/releases
| Tanmatsu | ESP32-P4 + ESP32-C6, SX1262 | 4" 800x480, 69-key keyboard (no touch) | Tanmatsu app store on the device (runs under the badge.team launcher, not web-flashable) | Store tracks the test channel | Fully supported; LoRa + Wi-Fi + Bluetooth simultaneously, standalone and companion in one |
| Elecrow ThinkNode M9 | ESP32-S3, LR1110 | 2.4" 240x320 (no touch), I2C QWERTY + d-pad | Web flasher | Beta (new in beta_38) | Hardware-complete community port by ded (#138): GPS, microSD, buzzer, lock screen, d-pad navigation |
| RAK WisMesh Tap V2 (RAK3312) | ESP32-S3, SX1262 | Touch display (LovyanGFX, 30+ fps) | Web flasher | Beta (new in beta_38) | Early community port by Ethac.chen (#136); core mesh, chat and map working |
| LilyGo T-Lora Pager | ESP32-S3, LR1121 or SX1262 | 2.33" AMOLED (no touch), QWERTY + rotary encoder | Web flasher (pick the build matching your radio) | Stable | Fully supported; keyboard-first navigation, microSD, map tile packs |
| Heltec V4-R8 + Expansion Kit V2 | ESP32-S3 (8 MB octal PSRAM), SX1262 | 2.4" touch (CHSC6x) | Web flasher | Stable | As the V4 plus microSD and the Expansion Kit sensors; buzzer supported |
| LilyGo T-Display P4 | ESP32-P4 + ESP32-C6, SX1262 | AMOLED or TFT-LCD, touch | Web flasher (AMOLED) or the .bin for the LCD SKU | Stable | Two screen SKUs; fuel-gauge battery reporting, full TX power |
| Attaky Mesh Series | ESP32-S3, SX1262 | Touch display | Web flasher | Beta (new in beta_47) | Community port by attakygit (#158/#169); detachable keyboard supported |
## Feature notes per board
@@ -41,7 +45,6 @@ Open hardware requests, roughly in demand order. Ports are welcome, see
PRs.
- LilyGo T-Deck Pro Max: [#62](https://github.com/ALLFATHER-BV/wadamesh/issues/62)
- LilyGo T-pager: [#60](https://github.com/ALLFATHER-BV/wadamesh/issues/60)
- SenseCAP Indicator D1L: [#14](https://github.com/ALLFATHER-BV/wadamesh/issues/14)
## Channels
+7 -3
View File
@@ -8,7 +8,7 @@
<p align="center"><b>A real touchscreen UI for your mesh radio.</b> &middot; open source &middot; GPL-3.0</p>
Touch-UI [MeshCore](https://github.com/meshcore-dev/MeshCore) companion-radio
firmware for the **LilyGo T-Deck / T-Deck Plus** and **Heltec V4 + TFT**
firmware for the **LilyGo T-Deck / T-Deck Plus**, **Heltec V4 + TFT** and eight other boards
(ESP32-S3).
An LVGL touch UI — map, chat, contacts, channels, settings — split out of
@@ -22,14 +22,18 @@ per-board status.
- LilyGo T-Deck / T-Deck Plus — env `LilyGo_TDeck_companion_radio_touch` (stable)
- Heltec V4 + TFT + CHSC6x touch — env `heltec_v4_tft_companion_radio_usb_tcp_touch` (stable)
- Tanmatsu (ESP32-P4) — built from `tanmatsu/` (ESP-IDF), ships via the Tanmatsu app store
- Tanmatsu (ESP32-P4) — built from `tanmatsu/` (ESP-IDF), ships via the Tanmatsu app store — [sideload guide](TANMATSU_SIDELOAD.md) for running your own build
- Elecrow ThinkNode M9 — env `ThinkNode_M9_companion_radio_touch` (beta) — [keyboard & d-pad guide](THINKNODE_M9_SHORTCUTS.md)
- RAK WisMesh Tap V2 (RAK3312) — env `rak_tap_v2_companion_radio_touch` (beta)
- LilyGo T-Lora Pager — envs `tlora_pager_lr1121_companion_radio_touch` / `tlora_pager_sx1262_companion_radio_touch` (stable) — [keyboard shortcuts](TLORA_PAGER_SHORTCUTS.md)
- Heltec V4-R8 + Expansion Kit V2 — env `heltec_v4_r8_tft_companion_radio_usb_tcp_touch` (stable)
- LilyGo T-Display P4 — built from `tdisplay_p4/` (ESP-IDF); AMOLED by default, `WADA_P4_LCD=1` for the TFT-LCD SKU
- Attaky Mesh Series — env `attaky_mesh_series_companion_radio_touch` (beta)
## Architecture
This repo holds only the **app**: the `companion_radio` glue, the `ui-touch`
LVGL UI, the two boards' glue/variants, and `platformio.ini`. The **MeshCore
LVGL UI, each board's glue/variants, and `platformio.ini`. The **MeshCore
core is not vendored here** — it's pulled as a library via `lib_deps` from the
[`ALLFATHER-BV/meshcomod`](https://github.com/ALLFATHER-BV/meshcomod) monorepo
(the same repo as the non-touch firmware), pinned by a lean source-only `core-*`
+151
View File
@@ -0,0 +1,151 @@
# Sideloading wadamesh on the Tanmatsu
For developers building wadamesh from source and putting it on a Tanmatsu.
If you just want to run it, install **WadaMesh** from the launcher's app store
instead; this document is about running your own build.
## Understand the model first, or you will brick the launcher
wadamesh is **not** standalone firmware on this board. The Tanmatsu boots
badge.team's launcher, and wadamesh runs as an **AppFS app** underneath it,
exactly like the launcher's own apps.
So you never flash the whole device. You write **one app image plus one metadata
sector** into the AppFS partition and leave the bootloader, the partition table
and the OTA slots alone.
> **Never run `idf.py flash` on a Tanmatsu.** It writes the bootloader, partition
> table and OTA data, which replaces the launcher OS itself. Recovering means
> reflashing the launcher from badge.team. Every command in this guide touches
> only the AppFS partition.
The board has three processors and only one of them is yours: the **ESP32-P4**
runs the launcher and your app, an **ESP32-C6** handles Wi-Fi, Bluetooth and LoRa
over esp-hosted, and a **CH32** runs the keyboard and power. You flash the P4.
## One-time setup
You need ESP-IDF 5.5.1. The build script uses a project-local copy in
`tanmatsu/esp-idf` (gitignored, around 3 GB); `make prepare` from `tanmatsu/`
fetches it.
Then two one-off steps:
```bash
tanmatsu/tools/fetch-appfs.sh
```
This clones badge.team's `esp32-component-appfs`, whose `appfs.py` reads and
rewrites the AppFS image. It is not vendored into this repo on purpose: it is
their code, and the copy in circulation has no licence header.
```bash
tanmatsu/tools/dump-pristine.sh /dev/cu.usbmodemXXXX
```
This dumps **your** device's AppFS partition as the baseline that later deploys
diff against. Do it while the launcher's own apps are installed and wadamesh is
not, so the baseline is the device as it ships.
Use your own dump, not someone else's. The deploy writes only the sectors that
differ from this file, so if it describes a different device you can overwrite
apps you actually have.
## Build
```bash
cd tanmatsu && ./build.sh build
```
Use `build.sh`, not `idf.py` directly: it applies two build-time patches to
`managed_components` (esp-hosted Wi-Fi-init tolerance, and an Arduino BLE stub)
that the build needs.
**The build ends with an error and that is expected:**
```
Generated .../application.bin
Error: All app partitions are too small
```
The ~2.9 MB app is being size-checked against the 2 MB `ota_0` slot, but it does
not live there: it lives in the 8 MB `appfs` partition. Judge success by
`Generated ... application.bin` appearing with no `error:` or `undefined
reference` above it.
## Install
```bash
tanmatsu/tools/tan_flash.sh
```
With no port argument it probes each USB serial device and picks the one that
answers as an ESP32-P4. The Tanmatsu exposes **two** ports, the P4 and the C6,
and which name each gets changes between replugs, so detecting by chip beats
guessing. Pass a port explicitly if you prefer.
What it does, and why in this order:
1. Builds the AppFS image from your pristine baseline plus the new
`application.bin`, and works out the minimum set of 64 KB sectors that changed.
2. Writes the **app data first**, with no reset afterwards.
3. Writes the **metadata sector last**, then resets. That sector is what makes the
app visible to the launcher, so writing it last means an interrupted flash
leaves the previous app intact instead of a half-written one marked complete.
4. Reads the metadata back and compares it. The commit write is the one that can
silently drop, and without this check the launcher would quietly keep showing
the old build.
Expect `metadata verified — installed`. Anything else means re-run it.
The version number increments automatically on each deploy. It has to: the
launcher keys updates on name plus version, so republishing the same number
leaves you on the old binary with no error anywhere.
## Verify
Launch **WadaMesh** from the launcher menu. An empty serial log immediately after
flashing is normal, because the launcher is a silent GUI OS on USB-CDC; the boot
log appears once the app itself starts.
```bash
python3 tanmatsu/tools/sermon.py /dev/cu.usbmodemXXXX /tmp/wada.log
```
`sermon.py` is a non-resetting monitor that survives the USB re-enumeration when
an app launches. Prefer it to a monitor that asserts DTR/RTS: **resetting the P4
also desynchronises the C6**, which has no reset line of its own, and the launcher
then hangs on "Initializing radio" until you fully power-cycle the board.
## When it goes wrong
**No serial ports at all.** The C6 has wedged. Fully power-cycle the board, off
then on. A reset is not enough.
**"No ESP32-P4 found"** with ports present. Something else is holding the port,
often a still-running monitor. Close it and retry.
**"metadata DIFFERS".** The commit write did not land. Re-run; nothing is broken,
the device still has the previous app.
**A crash to decode.**
```bash
riscv32-esp-elf-addr2line -e tanmatsu/build/tanmatsu/application.elf -fpC <MEPC> <RA> <stack RAs>
```
## Before you ship anything
The Tanmatsu shares one `src/` with the ESP32-S3 boards, so a change here can
break them without you noticing. Build at least:
```bash
pio run -e LilyGo_TDeck_companion_radio_touch
pio run -e heltec_v4_tft_companion_radio_usb_tcp_touch
```
## Related
- `TANMATSU_PORT.md` — the port's status and outstanding work
- [wadamesh.com/sdk.html](https://wadamesh.com/sdk.html) — writing Lua apps, which
needs no firmware build at all
+16 -3
View File
@@ -13,9 +13,17 @@ so somebody has to read it first.
## What an app is
One Lua file plus a small manifest. Apps talk to the firmware through the `wada.*`
API — `wada.ui` (widgets, colours), `wada.sys`, `wada.store` (persistence),
`wada.timer`. There is no arbitrary filesystem or network access; the API is the
whole surface, which is what makes reviewing tractable.
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`. There is no arbitrary
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).
Two numbers worth knowing before you start: every callback runs under a
**100,000 Lua instruction** budget, and Lua here is built with **32-bit numbers**,
so its floats are single precision. If you are logging coordinates, use the
`lat_e6` / `lon_e6` integers rather than `lat` / `lon`.
Apps run on **every** board that has the Apps drawer, from the 2 MB-PSRAM Heltec
V4 to the 800×480 Tanmatsu. Do not hard-code pixel sizes — read the screen from
@@ -90,6 +98,11 @@ people's devices. We read every submission for:
has 2 MB and is the floor.
- **Radio behaviour** — an app may read counters and statistics. Transmitting, or
changing radio parameters, needs a clear reason and an explicit user action.
- **Probing** — `wada.mesh.discover()` costs a transmission from the user *and a
reply from every node that hears it*, so it spends other people's airtime as
well as theirs. The firmware enforces 15 seconds between probes, but a survey
app is still expected to probe on a cadence a person chose and to stop when it
is not on screen. Probing in a loop for no stated reason gets sent back.
- **Where data goes** — anything leaving the device has to be something the user
asked for and can see.
+14 -2
View File
@@ -21,8 +21,8 @@
{
"id": "sdktest",
"name": "SDK Test",
"ver": "1.1",
"desc": "Developer tool. Checks the extended Lua SDK on this board: capabilities, clock, battery, GPS, file access and its limits, keyboard input. Has a button that TRANSMITS a test message to Public (asks permission first)."
"ver": "1.3",
"desc": "Developer tool. Checks the extended Lua SDK on this board: capabilities, clock, battery, GPS, file access, crypto against published RFC vectors, channel discovery, and buttons that TRANSMIT a test message to Public and a DM to your first contact (each asks permission first)."
},
{
"id": "2048",
@@ -30,6 +30,18 @@
"ver": "1.2",
"desc": "The classic sliding-tile puzzle. Swipe to move, tap to restart. Keeps your best score. Contributed by pisti87."
},
{
"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."
},
{
"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."
},
{
"id": "gpscompass",
"name": "GPS Compass",
+1 -1
View File
@@ -365,7 +365,7 @@ local function refresh(now)
set_text("fix", string.format("%d sats", sats_n), C.text)
set_text("lat", string.format("%.5f", g.lat), C.text)
set_text("lon", string.format("%.5f", g.lon), C.text)
set_text("alt", fmt_alt(g.alt or 0), C.text)
set_text("alt", fmt_alt(g.alt_m or 0), C.text)
if g.speed_kmh then
local s = fmt_speed(g.speed_kmh)
if g.course then s = s .. string.format(" %03d\194\176", math.floor(g.course + 0.5) % 360) end
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+15 -1
View File
@@ -1,7 +1,7 @@
# wadamesh language file
# canonical source - translators: edit this file and PR it
# code: bg
# ver: 12
# ver: 16
# name: Български
# base: bg
(device behind) (устройството изостава)
@@ -1014,3 +1014,17 @@ Share my loc Share my loc
Share my location when asked Share my location when asked
Stop sharing loc Stop sharing loc
not asked yet not asked yet
Battery life: gathering data… Battery life: gathering data…
Small\nMedium\nLarge\nJumbo Small\nMedium\nLarge\nJumbo
"%s" wants to send PRIVATE messages as you.\n\nIt could write to any of your contacts, or post to a room, and the message will look exactly like one you typed.\n\nOnly allow this for an app you trust. "%s" wants to send PRIVATE messages as you.\n\nIt could write to any of your contacts, or post to a room, and the message will look exactly like one you typed.\n\nOnly allow this for an app you trust.
Map pan off Map pan off
Map pan: arrows pan, Back exits Map pan: arrows pan, Back exits
Perf: Perf:
Post to channels as me Post to channels as me
Powering off… press BOOT to wake Powering off… press BOOT to wake
Read channel messages Read channel messages
Read private messages Read private messages
Send private messages as me Send private messages as me
What each installed app is allowed to do. Anything an app sends goes out under your node name and cannot be told apart from a message you typed. Private covers direct messages and room posts, and is kept separate from channels on purpose. Turn one off to take it back. What each installed app is allowed to do. Anything an app sends goes out under your node name and cannot be told apart from a message you typed. Private covers direct messages and room posts, and is kept separate from channels on purpose. Turn one off to take it back.
Wi-Fi off, new map areas can't download Wi-Fi off, new map areas can't download
App permissions App permissions
+15 -1
View File
@@ -1,7 +1,7 @@
# wadamesh language file
# canonical source - translators: edit this file and PR it
# code: de
# ver: 12
# ver: 16
# name: Deutsch
# base: de
(device behind) (Gerät geht nach)
@@ -1014,3 +1014,17 @@ Share my loc Share my loc
Share my location when asked Share my location when asked
Stop sharing loc Stop sharing loc
not asked yet not asked yet
Battery life: gathering data… Battery life: gathering data…
Small\nMedium\nLarge\nJumbo Small\nMedium\nLarge\nJumbo
"%s" wants to send PRIVATE messages as you.\n\nIt could write to any of your contacts, or post to a room, and the message will look exactly like one you typed.\n\nOnly allow this for an app you trust. "%s" wants to send PRIVATE messages as you.\n\nIt could write to any of your contacts, or post to a room, and the message will look exactly like one you typed.\n\nOnly allow this for an app you trust.
Map pan off Map pan off
Map pan: arrows pan, Back exits Map pan: arrows pan, Back exits
Perf: Perf:
Post to channels as me Post to channels as me
Powering off… press BOOT to wake Powering off… press BOOT to wake
Read channel messages Read channel messages
Read private messages Read private messages
Send private messages as me Send private messages as me
What each installed app is allowed to do. Anything an app sends goes out under your node name and cannot be told apart from a message you typed. Private covers direct messages and room posts, and is kept separate from channels on purpose. Turn one off to take it back. What each installed app is allowed to do. Anything an app sends goes out under your node name and cannot be told apart from a message you typed. Private covers direct messages and room posts, and is kept separate from channels on purpose. Turn one off to take it back.
Wi-Fi off, new map areas can't download Wi-Fi off, new map areas can't download
App permissions App permissions
+15 -1
View File
@@ -1,7 +1,7 @@
# wadamesh language file
# canonical source - translators: edit this file and PR it
# code: el
# ver: 12
# ver: 16
# name: Ελληνικά
# base: el
(device behind) (συσκευή πίσω)
@@ -1014,3 +1014,17 @@ Share my loc Share my loc
Share my location when asked Share my location when asked
Stop sharing loc Stop sharing loc
not asked yet not asked yet
Battery life: gathering data… Battery life: gathering data…
Small\nMedium\nLarge\nJumbo Small\nMedium\nLarge\nJumbo
"%s" wants to send PRIVATE messages as you.\n\nIt could write to any of your contacts, or post to a room, and the message will look exactly like one you typed.\n\nOnly allow this for an app you trust. "%s" wants to send PRIVATE messages as you.\n\nIt could write to any of your contacts, or post to a room, and the message will look exactly like one you typed.\n\nOnly allow this for an app you trust.
Map pan off Map pan off
Map pan: arrows pan, Back exits Map pan: arrows pan, Back exits
Perf: Perf:
Post to channels as me Post to channels as me
Powering off… press BOOT to wake Powering off… press BOOT to wake
Read channel messages Read channel messages
Read private messages Read private messages
Send private messages as me Send private messages as me
What each installed app is allowed to do. Anything an app sends goes out under your node name and cannot be told apart from a message you typed. Private covers direct messages and room posts, and is kept separate from channels on purpose. Turn one off to take it back. What each installed app is allowed to do. Anything an app sends goes out under your node name and cannot be told apart from a message you typed. Private covers direct messages and room posts, and is kept separate from channels on purpose. Turn one off to take it back.
Wi-Fi off, new map areas can't download Wi-Fi off, new map areas can't download
App permissions App permissions
+15 -1
View File
@@ -1,7 +1,7 @@
# wadamesh language file
# canonical source - translators: edit this file and PR it
# code: es
# ver: 12
# ver: 16
# name: Español
# base: es
(device behind) (dispositivo atrasado)
@@ -1014,3 +1014,17 @@ Share my loc Share my loc
Share my location when asked Share my location when asked
Stop sharing loc Stop sharing loc
not asked yet not asked yet
Battery life: gathering data… Battery life: gathering data…
Small\nMedium\nLarge\nJumbo Small\nMedium\nLarge\nJumbo
"%s" wants to send PRIVATE messages as you.\n\nIt could write to any of your contacts, or post to a room, and the message will look exactly like one you typed.\n\nOnly allow this for an app you trust. "%s" wants to send PRIVATE messages as you.\n\nIt could write to any of your contacts, or post to a room, and the message will look exactly like one you typed.\n\nOnly allow this for an app you trust.
Map pan off Map pan off
Map pan: arrows pan, Back exits Map pan: arrows pan, Back exits
Perf: Perf:
Post to channels as me Post to channels as me
Powering off… press BOOT to wake Powering off… press BOOT to wake
Read channel messages Read channel messages
Read private messages Read private messages
Send private messages as me Send private messages as me
What each installed app is allowed to do. Anything an app sends goes out under your node name and cannot be told apart from a message you typed. Private covers direct messages and room posts, and is kept separate from channels on purpose. Turn one off to take it back. What each installed app is allowed to do. Anything an app sends goes out under your node name and cannot be told apart from a message you typed. Private covers direct messages and room posts, and is kept separate from channels on purpose. Turn one off to take it back.
Wi-Fi off, new map areas can't download Wi-Fi off, new map areas can't download
App permissions App permissions
+15 -1
View File
@@ -1,7 +1,7 @@
# wadamesh language file
# canonical source - translators: edit this file and PR it
# code: fr
# ver: 12
# ver: 16
# name: Français
# base: fr
(device behind) (appareil en retard)
@@ -1014,3 +1014,17 @@ Share my loc Share my loc
Share my location when asked Share my location when asked
Stop sharing loc Stop sharing loc
not asked yet not asked yet
Battery life: gathering data… Battery life: gathering data…
Small\nMedium\nLarge\nJumbo Small\nMedium\nLarge\nJumbo
"%s" wants to send PRIVATE messages as you.\n\nIt could write to any of your contacts, or post to a room, and the message will look exactly like one you typed.\n\nOnly allow this for an app you trust. "%s" wants to send PRIVATE messages as you.\n\nIt could write to any of your contacts, or post to a room, and the message will look exactly like one you typed.\n\nOnly allow this for an app you trust.
Map pan off Map pan off
Map pan: arrows pan, Back exits Map pan: arrows pan, Back exits
Perf: Perf:
Post to channels as me Post to channels as me
Powering off… press BOOT to wake Powering off… press BOOT to wake
Read channel messages Read channel messages
Read private messages Read private messages
Send private messages as me Send private messages as me
What each installed app is allowed to do. Anything an app sends goes out under your node name and cannot be told apart from a message you typed. Private covers direct messages and room posts, and is kept separate from channels on purpose. Turn one off to take it back. What each installed app is allowed to do. Anything an app sends goes out under your node name and cannot be told apart from a message you typed. Private covers direct messages and room posts, and is kept separate from channels on purpose. Turn one off to take it back.
Wi-Fi off, new map areas can't download Wi-Fi off, new map areas can't download
App permissions App permissions
+77 -63
View File
@@ -1,7 +1,7 @@
# wadamesh language file
# canonical source - translators: edit this file and PR it
# code: hu
# ver: 12
# ver: 16
# name: Magyar
# base: hu
(device behind) (készülék hátulján)
@@ -40,13 +40,13 @@ Add contact Névjegy hozzáadás
Added Hozzáadva
Added to contacts Hozzáadva a névjegyekhez
Added to favorites Hozzáadva a kedvencekhez
Admin Adminisztráció
Admin Admin
Advert Advert
Advert failed Advert sikertelen
Advert location Advert helye
Advert policy saved Advert szabályzat mentve
Advert sent Advert elküldve
All marked read Összes megjelölve olvasottnak
All marked read Össz. olvasottnak
Already exists Már létezik
Already exists or table full. Már létezik, vagy a tábla megtelt.
Already in contacts Már a névjegyek közt
@@ -72,10 +72,10 @@ Auto-Join Auto. csatlakozás
Auto-add Auto. hozzáad.
Auto-add contacts Névjegyek automatikus hozzáadása
Auto-add saved Auto. hozzáadás mentve
Auto-add settings Auto. hozzáadás beállításai
Auto-delete above hops Auto. törlés a fenti hops-ok felett
Auto-delete oldest Legrégebbi automatikus törlése
Auto-delete oldest makes room when full. Hops: 0 = keep all; nodes heard via more hops are removed. A legrégebbi automatikus törlése helyet szabadít fel, ha megtelik. Hops-ok: 0 = összes megtartása; a további hops-on keresztül hallott nodeok eltávolításra kerülnek.
Auto-add settings Auto. hozzáadás
Auto-delete above hops Auto. törlés hop felett
Auto-delete oldest Régebbi auto. törlése
Auto-delete oldest makes room when full. Hops: 0 = keep all; nodes heard via more hops are removed. Ha megtelik, a legrégebbi törlődik. Hops: 0 = összes; távolabbi node-ok törlődnek.
Auto-discover Auto. felismerés
Auto-discover off Auto. felismerés kikapcsolva
Auto-discover on Auto. felismerés bekapcsolva
@@ -89,7 +89,7 @@ BT BT
Back Vissza
Backups Mentés
Battery Akkumlátor
Battery & power history Akkumulátor és teljesítmény előzményei
Battery & power history Akku és teljesítmény előzményei
Battery (V) Akkumlátor (V)
Battery calibration reset to default Az akkumulátor kalibrálása visszaállt az alapértelmezett értékre
Battery read too low — charge fully first Az akkumulátor túl alacsony állapotban van először töltse fel teljesen
@@ -97,7 +97,7 @@ Battery saver (experimental) Akkumulátorkímélő (kísérleti)
Battery — last 24h Akkumulátor elmúlt 24 óra
Battery: Akkumlátor
Block Tiltás
Block list full A feketelista megtelt
Block list full A tiltólista megtelt
Blocked list Tiltólista
Blocked users Letiltott felhasználók
Blocked — their messages are hidden Blokkolt az üzeneteik rejtve vannak
@@ -153,7 +153,7 @@ Chat save fallback (failed tries) Csevegésmentés tartalék (hibás próbák)
Chats, contacts, settings and a mesh terminal - all in the browser. No reboot; the device keeps its normal screen. Csevegések, névjegyek, beállítások és mesh-terminál - mind a böngészőben. Nincs újraindítás; az eszköz képernyője változatlan marad.
Choose .wav from files .wav választása a fájlokból
Choose your name Válaszd ki a neved
Choose your region Válassza ki a régióját
Choose your region Válaszd ki a régiód
Clear Tisztít
Clear battery history? Törli az akkumulátorelőzményeket?
Clear history Előzmények törlése
@@ -218,7 +218,7 @@ Couldn't start the analyzer.\nTry again. Nem sikerült elindítani az analizáto
Crash export failed A Crash exportálása sikertelen volt
Create Létrehoz
Create QR QR készítése
Create a private channel Privát csatorna létrehozása
Create a private channel Privát létrehozása
Create failed Létrehozás sikertelen
Create private channel Privát csatorna létrehozása
Current tile transfer is busy - retry Az aktuális csempeátvitel folyamatban van próbáld újra később.
@@ -394,9 +394,9 @@ Invalid lat/lon Hosszúság/szélesség érvénytelen
Invalid max hops Érvénytelen maximális ugrásszám
Invalid radio values Érvénytelen rádióértékek
Join Csatlakozás
Join a hashtag channel Csatlakozás hashtag csatornához
Join a private channel Csatlakozás privát csatornához
Join hashtag channel Csatlakozz a hashtag csatornához
Join a hashtag channel Belépés # csatornához
Join a private channel Belépés priváthoz
Join hashtag channel Csatlakozz # csatornához
Join network Csatlakozás a hálózathoz
Join private channel Csatlakozz privát csatornához
Join the public channel Csatlakozás a nyilvános csatornához
@@ -520,7 +520,7 @@ No Bluetooth on this device Nincs Bluetooth ezen az eszközön
No DM key Nincs DM kulcs
No GPS module on this device. Nincs GPS modul ezen a készüléken.
No JPEGs found in /lock/.\nAdd images via the file\nmanager or an SD card. Nem található JPEG fájl a /lock/ mappában.\nKépek hozzáadása a fájlkezelőn vagy SD-kártyán keresztül.
No SD card Geen SD-kaart
No SD card Nincs SD-kártya
No SD card (or unreadable format) Nincs SD-kártya (vagy olvashatatlan formátumú)
No backups yet. Tap Export new backup above to create one. Még nincsenek biztonsági mentések. Koppintson a fenti Új biztonsági mentés exportálása gombra egy létrehozásához.
No battery history yet.\n\nLogged every 5 minutes. Még nincsenek akkumulátor-előzmények.\n\n5 percenként naplózva.
@@ -622,7 +622,7 @@ Public key (64 hex chars) Nyilvános kulcs (64 hex karakter)
Public key: Nyilvános kulcs
Publish channel messages Csatornaüzenetek közzététele
Publish direct messages Közvetlen üzenetek közzététele
Purge Purge
Purge Törlés
Purge all discovered nodes? Törölje az összes felfedezett csomópontot?
Quick replies Gyors válaszok
Quick reply Gyors válasz
@@ -663,7 +663,7 @@ Remote terminal off Távoli terminál kikapcsolva
Remove Eltávolítás
Remove %s? Eltávolítod: %s?
Remove channel Csatorna eltávolítása
Remove channel "%s"?\nLeaves it on this device only. Eltávolítja a(z) %s csatornát?\nCsak ezen az eszközön hagyja.
Remove channel "%s"?\nLeaves it on this device only. Eltávolítja a(z) "%s" csatornát?\nCsak ezen az eszközön hagyja.
Removed Eltávolítva
Removed from favorites Eltávolítva a kedvencek közül
Rename Átnevezés
@@ -870,7 +870,7 @@ Today Ma
Toggle GPS GPS be-/kikapcs.
Too large to edit (>8 KB) Túl nagy a szerkesztéshez (>8 KB)
Topographic map Topográfiai térkép
Trace Trace
Trace Nyomkövetés
Trace SNR SNR nyomkövetés
Trace failed Nyomkövetés sikertelen
Trace route Útvonal nyomkövetése
@@ -975,7 +975,7 @@ now talking to %s most %s-szel beszélgetek
off ki
off = Enter adds a new line; tap Send to send ki = Az Enter új sort hoz létre; a küldéshez koppintson a Küldés gombra
on be
peak -- peak
peak -- peak --
pick accented letters as you type; off = plain typing ékezetes betűk kiválasztása gépelés közben; kikapcsolva = sima gépelés
press Volume Down to unlock Nyomja meg a hangerő csökkentését a feloldáshoz
push past a screen edge to scroll content Görgetéshez húzza végig az ujját a képernyő szélén
@@ -990,47 +990,61 @@ 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
another region
my region
Save FAIL x%u
Save migrating
Saved %s
Sort discovered
Sort by
Peers
Favorites
Has location
Direct (0-hop)
Heard %s
Traffic (since boot)\n Sent %u flood, %u direct\n Recv %u flood, %u direct
Overwrite oldest non-favorite
"%s" wants to send messages on the mesh.\n\nThey will be sent as %s and cannot be told apart from messages you typed.\n\nOnly allow this for an app you trust. "%s" wants to send messages on the mesh.\n\nThey will be sent as %s and cannot be told apart from messages you typed.\n\nOnly allow this for an app you trust.
%d of %d on map %d of %d on map
1-character messages will be ignored 1-character messages will be ignored
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
Location shared with anyone who asks Location shared with anyone who asks
Location shared with chosen contacts only Location shared with chosen contacts only
Location shared with this contact Location shared with this contact
Location sharing off Location sharing off
Max dots Max dots
Most mesh spam is a single character, because it is the cheapest message to send. Dropped silently: no chat entry, no notification, no sound. Most mesh spam is a single character, because it is the cheapest message to send. Dropped silently: no chat entry, no notification, no sound.
Never\nChosen contacts only\nAnyone who asks Never\nChosen contacts only\nAnyone who asks
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. Csak akkor küldi el, ha az adott névjegy kéri, és titkosítva, kizárólag neki küldi. Semmi nem kerül közzétételre. Az Telemetriai kérések megválaszolása engedélynek be kell lennie kapcsolva. A névjegyeket a névjegy menüjében választhatod ki.
Server has forgotten you.\nJoin with the room password. Server has forgotten you.\nJoin with the room password.
Share my loc Share my loc
Share my location when asked Share my location when asked
Stop sharing loc Stop sharing loc
not asked yet not asked yet
Scope Hatókör
another region másik régió
my region saját régió
Save FAIL x%u Mentési hiba x%u
Save migrating Mentés áthelyezése
Saved %s Mentve: %s
Sort discovered Felfedezettek rendezése
Sort by Rendezés szerint
Peers Partnerek
Favorites Kedvencek
Has location Van helyadata
Direct (0-hop) Közvetlen (0 ugrás)
Heard %s Észlelve: %s
Traffic (since boot)\n Sent %u flood, %u direct\n Recv %u flood, %u direct Forgalom (indítás óta)\n Küldve %u flood, %u közvetlen\n Fogadva %u flood, %u közvetlen
Overwrite oldest non-favorite Legrégebbi nem kedvenc felülírása
"%s" wants to send messages on the mesh.\n\nThey will be sent as %s and cannot be told apart from messages you typed.\n\nOnly allow this for an app you trust. "%s" üzeneteket szeretne küldeni a mesh hálózaton.\n\nAz üzeneteket %s néven küldi, és nem különböztethetők meg az általad beírt üzenetektől.\n\nCsak megbízható alkalmazásnak engedélyezd.
%d of %d on map %d / %d a térképen
1-character messages will be ignored Az 1 karakteres üzenetek figyelmen kívül lesznek hagyva
1-character messages will be shown Az 1 karakteres üzenetek megjelennek
All Összes
Allow Engedélyezés
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. Azok az alkalmazások, amelyek üzeneteket küldhetnek a mesh hálózaton. Az általuk küldött üzenetek a csomópontod neve alatt jelennek meg, és nem különböztethetők meg az általad beírt üzenetektől. Kapcsold ki az engedélyt annak visszavonásához.
Ignore 1-character messages 1 karakteres üzenetek figyelmen kívül hagyása
Join w/ password Csatlakozás jelszóval
Location no longer shared A helyzetmegosztás megszűnt
Location shared with anyone who asks A helyzet megosztása bárkivel, aki kéri
Location shared with chosen contacts only A helyzet megosztása csak a kiválasztott névjegyekkel
Location shared with this contact A helyzet megosztása ezzel a névjeggyel
Location sharing off Helyzetmegosztás kikapcsolva
Max dots Max. pontok
Most mesh spam is a single character, because it is the cheapest message to send. Dropped silently: no chat entry, no notification, no sound. A legtöbb mesh spam jelenleg egyetlen karakterből áll, mert ez a legolcsóbban elküldhető üzenet. Csendben eldobva: nem jelenik meg a csevegésben, nincs értesítés és nincs hangjelzés.
Never\nChosen contacts only\nAnyone who asks Soha\nCsak a kiválasztott névjegyek\nBárki, aki kéri
No apps installed. Nincsenek telepített alkalmazások.
No reply. Path hash is %u bytes;\nolder repeaters drop those.\nTry 1 byte (Settings > Radio & Mesh) Nincs válasz. Az útvonal hash értéke %u bájt;\na régebbi átjátszók eldobhatják ezeket.\nPróbáld 1 bájttal (Beállítások > Rádió és Mesh)
Not supported on this board Ezen az eszközön nem támogatott.
Read incoming messages Beérkező üzenetek olvasása
Reconnected to %.40s Újracsatlakozva ehhez: %.40s
Send messages as me Üzenetek küldése a nevemben
Sent only when that contact asks, encrypted to them. Nothing is broadcast. Needs "Answer telemetry requests" on. Pick contacts in a contact's menu. Csak akkor küldi el, ha az adott névjegy kéri, és titkosítva, kizárólag neki küldi. Semmi nem kerül közzétételre. Az "Telemetriai kérések megválaszolása" engedélynek be kell lennie kapcsolva. A névjegyeket a névjegy menüjében választhatod ki.
Server has forgotten you.\nJoin with the room password. A szerver elfelejtett téged.\nCsatlakozz a szoba jelszavával.
Share my loc Helyzetem megosztása
Share my location when asked Helyzetem megosztása kérésre
Stop sharing loc Helyzetmegosztás leállítása
not asked yet Még nem kérték
Battery life: gathering data… Üzemidő: adatok gyűjtése…
Small\nMedium\nLarge\nJumbo Small\nMedium\nLarge\nJumbo
"%s" wants to send PRIVATE messages as you.\n\nIt could write to any of your contacts, or post to a room, and the message will look exactly like one you typed.\n\nOnly allow this for an app you trust. "%s" wants to send PRIVATE messages as you.\n\nIt could write to any of your contacts, or post to a room, and the message will look exactly like one you typed.\n\nOnly allow this for an app you trust.
Map pan off Map pan off
Map pan: arrows pan, Back exits Map pan: arrows pan, Back exits
Perf: Perf:
Post to channels as me Post to channels as me
Powering off… press BOOT to wake Powering off… press BOOT to wake
Read channel messages Read channel messages
Read private messages Read private messages
Send private messages as me Send private messages as me
What each installed app is allowed to do. Anything an app sends goes out under your node name and cannot be told apart from a message you typed. Private covers direct messages and room posts, and is kept separate from channels on purpose. Turn one off to take it back. What each installed app is allowed to do. Anything an app sends goes out under your node name and cannot be told apart from a message you typed. Private covers direct messages and room posts, and is kept separate from channels on purpose. Turn one off to take it back.
Wi-Fi off, new map areas can't download Wi-Fi off, new map areas can't download
App permissions App permissions
+15 -1
View File
@@ -1,7 +1,7 @@
# wadamesh language file
# canonical source - translators: edit this file and PR it
# code: it
# ver: 12
# ver: 16
# name: Italiano
# base: it
(device behind) (dispositivo indietro)
@@ -1014,3 +1014,17 @@ Share my loc Share my loc
Share my location when asked Share my location when asked
Stop sharing loc Stop sharing loc
not asked yet not asked yet
Battery life: gathering data… Battery life: gathering data…
Small\nMedium\nLarge\nJumbo Small\nMedium\nLarge\nJumbo
"%s" wants to send PRIVATE messages as you.\n\nIt could write to any of your contacts, or post to a room, and the message will look exactly like one you typed.\n\nOnly allow this for an app you trust. "%s" wants to send PRIVATE messages as you.\n\nIt could write to any of your contacts, or post to a room, and the message will look exactly like one you typed.\n\nOnly allow this for an app you trust.
Map pan off Map pan off
Map pan: arrows pan, Back exits Map pan: arrows pan, Back exits
Perf: Perf:
Post to channels as me Post to channels as me
Powering off… press BOOT to wake Powering off… press BOOT to wake
Read channel messages Read channel messages
Read private messages Read private messages
Send private messages as me Send private messages as me
What each installed app is allowed to do. Anything an app sends goes out under your node name and cannot be told apart from a message you typed. Private covers direct messages and room posts, and is kept separate from channels on purpose. Turn one off to take it back. What each installed app is allowed to do. Anything an app sends goes out under your node name and cannot be told apart from a message you typed. Private covers direct messages and room posts, and is kept separate from channels on purpose. Turn one off to take it back.
Wi-Fi off, new map areas can't download Wi-Fi off, new map areas can't download
App permissions App permissions
+16 -2
View File
@@ -1,7 +1,7 @@
# wadamesh language file
# canonical source - translators: edit this file and PR it
# code: nl
# ver: 12
# ver: 16
# name: Nederlands
# base: nl
(device behind) (apparaat loopt achter)
@@ -514,7 +514,7 @@ No Bluetooth on this device Geen Bluetooth op dit apparaat
No DM key Geen DM-sleutel
No GPS module on this device. Geen GPS-module op dit apparaat.
No JPEGs found in /lock/.\nAdd images via the file\nmanager or an SD card. Geen JPEG's gevonden in /lock/.\nVoeg afbeeldingen toe via de\nbestandsbeheerder of een SD-kaart.
No SD card Nincs SD-kártya
No SD card Geen SD-kaart
No SD card (or unreadable format) Geen SD-kaart (of onleesbaar formaat)
No backups yet. Tap Export new backup above to create one. Nog geen back-ups. Tik hierboven op Nieuwe back-up om er een te maken.
No battery history yet.\n\nLogged every 5 minutes. Nog geen batterijgeschiedenis.\n\nElke 5 minuten gelogd.
@@ -1014,3 +1014,17 @@ Share my loc Share my loc
Share my location when asked Share my location when asked
Stop sharing loc Stop sharing loc
not asked yet not asked yet
Battery life: gathering data… Battery life: gathering data…
Small\nMedium\nLarge\nJumbo Small\nMedium\nLarge\nJumbo
"%s" wants to send PRIVATE messages as you.\n\nIt could write to any of your contacts, or post to a room, and the message will look exactly like one you typed.\n\nOnly allow this for an app you trust. "%s" wants to send PRIVATE messages as you.\n\nIt could write to any of your contacts, or post to a room, and the message will look exactly like one you typed.\n\nOnly allow this for an app you trust.
Map pan off Map pan off
Map pan: arrows pan, Back exits Map pan: arrows pan, Back exits
Perf: Perf:
Post to channels as me Post to channels as me
Powering off… press BOOT to wake Powering off… press BOOT to wake
Read channel messages Read channel messages
Read private messages Read private messages
Send private messages as me Send private messages as me
What each installed app is allowed to do. Anything an app sends goes out under your node name and cannot be told apart from a message you typed. Private covers direct messages and room posts, and is kept separate from channels on purpose. Turn one off to take it back. What each installed app is allowed to do. Anything an app sends goes out under your node name and cannot be told apart from a message you typed. Private covers direct messages and room posts, and is kept separate from channels on purpose. Turn one off to take it back.
Wi-Fi off, new map areas can't download Wi-Fi off, new map areas can't download
App permissions App permissions
+15 -1
View File
@@ -1,7 +1,7 @@
# wadamesh language file
# canonical source - translators: edit this file and PR it
# code: pt-br
# ver: 12
# ver: 16
# name: Português (BR)
# base: pt-br
(device behind) (dispositivo atrasado)
@@ -1014,3 +1014,17 @@ Share my loc Share my loc
Share my location when asked Share my location when asked
Stop sharing loc Stop sharing loc
not asked yet not asked yet
Battery life: gathering data… Battery life: gathering data…
Small\nMedium\nLarge\nJumbo Small\nMedium\nLarge\nJumbo
"%s" wants to send PRIVATE messages as you.\n\nIt could write to any of your contacts, or post to a room, and the message will look exactly like one you typed.\n\nOnly allow this for an app you trust. "%s" wants to send PRIVATE messages as you.\n\nIt could write to any of your contacts, or post to a room, and the message will look exactly like one you typed.\n\nOnly allow this for an app you trust.
Map pan off Map pan off
Map pan: arrows pan, Back exits Map pan: arrows pan, Back exits
Perf: Perf:
Post to channels as me Post to channels as me
Powering off… press BOOT to wake Powering off… press BOOT to wake
Read channel messages Read channel messages
Read private messages Read private messages
Send private messages as me Send private messages as me
What each installed app is allowed to do. Anything an app sends goes out under your node name and cannot be told apart from a message you typed. Private covers direct messages and room posts, and is kept separate from channels on purpose. Turn one off to take it back. What each installed app is allowed to do. Anything an app sends goes out under your node name and cannot be told apart from a message you typed. Private covers direct messages and room posts, and is kept separate from channels on purpose. Turn one off to take it back.
Wi-Fi off, new map areas can't download Wi-Fi off, new map areas can't download
App permissions App permissions
+15 -1
View File
@@ -1,7 +1,7 @@
# wadamesh language file
# canonical source - translators: edit this file and PR it
# code: ro
# ver: 12
# ver: 16
# name: Română
# base: ro
(device behind) (dispozitiv în urmă)
@@ -1014,3 +1014,17 @@ Share my loc Share my loc
Share my location when asked Share my location when asked
Stop sharing loc Stop sharing loc
not asked yet not asked yet
Battery life: gathering data… Battery life: gathering data…
Small\nMedium\nLarge\nJumbo Small\nMedium\nLarge\nJumbo
"%s" wants to send PRIVATE messages as you.\n\nIt could write to any of your contacts, or post to a room, and the message will look exactly like one you typed.\n\nOnly allow this for an app you trust. "%s" wants to send PRIVATE messages as you.\n\nIt could write to any of your contacts, or post to a room, and the message will look exactly like one you typed.\n\nOnly allow this for an app you trust.
Map pan off Map pan off
Map pan: arrows pan, Back exits Map pan: arrows pan, Back exits
Perf: Perf:
Post to channels as me Post to channels as me
Powering off… press BOOT to wake Powering off… press BOOT to wake
Read channel messages Read channel messages
Read private messages Read private messages
Send private messages as me Send private messages as me
What each installed app is allowed to do. Anything an app sends goes out under your node name and cannot be told apart from a message you typed. Private covers direct messages and room posts, and is kept separate from channels on purpose. Turn one off to take it back. What each installed app is allowed to do. Anything an app sends goes out under your node name and cannot be told apart from a message you typed. Private covers direct messages and room posts, and is kept separate from channels on purpose. Turn one off to take it back.
Wi-Fi off, new map areas can't download Wi-Fi off, new map areas can't download
App permissions App permissions
+15 -1
View File
@@ -1,7 +1,7 @@
# wadamesh language file
# canonical source - translators: edit this file and PR it
# code: ru
# ver: 12
# ver: 16
# name: Русский
# base: ru
(device behind) (устройство отстаёт)
@@ -1014,3 +1014,17 @@ Share my loc Share my loc
Share my location when asked Share my location when asked
Stop sharing loc Stop sharing loc
not asked yet not asked yet
Battery life: gathering data… Battery life: gathering data…
Small\nMedium\nLarge\nJumbo Small\nMedium\nLarge\nJumbo
"%s" wants to send PRIVATE messages as you.\n\nIt could write to any of your contacts, or post to a room, and the message will look exactly like one you typed.\n\nOnly allow this for an app you trust. "%s" wants to send PRIVATE messages as you.\n\nIt could write to any of your contacts, or post to a room, and the message will look exactly like one you typed.\n\nOnly allow this for an app you trust.
Map pan off Map pan off
Map pan: arrows pan, Back exits Map pan: arrows pan, Back exits
Perf: Perf:
Post to channels as me Post to channels as me
Powering off… press BOOT to wake Powering off… press BOOT to wake
Read channel messages Read channel messages
Read private messages Read private messages
Send private messages as me Send private messages as me
What each installed app is allowed to do. Anything an app sends goes out under your node name and cannot be told apart from a message you typed. Private covers direct messages and room posts, and is kept separate from channels on purpose. Turn one off to take it back. What each installed app is allowed to do. Anything an app sends goes out under your node name and cannot be told apart from a message you typed. Private covers direct messages and room posts, and is kept separate from channels on purpose. Turn one off to take it back.
Wi-Fi off, new map areas can't download Wi-Fi off, new map areas can't download
App permissions App permissions
+15 -1
View File
@@ -1,7 +1,7 @@
# wadamesh language file
# canonical source - translators: edit this file and PR it
# code: sr
# ver: 12
# ver: 16
# name: Српски
# base: sr
(device behind) (уређај касни)
@@ -1014,3 +1014,17 @@ Share my loc Share my loc
Share my location when asked Share my location when asked
Stop sharing loc Stop sharing loc
not asked yet not asked yet
Battery life: gathering data… Battery life: gathering data…
Small\nMedium\nLarge\nJumbo Small\nMedium\nLarge\nJumbo
"%s" wants to send PRIVATE messages as you.\n\nIt could write to any of your contacts, or post to a room, and the message will look exactly like one you typed.\n\nOnly allow this for an app you trust. "%s" wants to send PRIVATE messages as you.\n\nIt could write to any of your contacts, or post to a room, and the message will look exactly like one you typed.\n\nOnly allow this for an app you trust.
Map pan off Map pan off
Map pan: arrows pan, Back exits Map pan: arrows pan, Back exits
Perf: Perf:
Post to channels as me Post to channels as me
Powering off… press BOOT to wake Powering off… press BOOT to wake
Read channel messages Read channel messages
Read private messages Read private messages
Send private messages as me Send private messages as me
What each installed app is allowed to do. Anything an app sends goes out under your node name and cannot be told apart from a message you typed. Private covers direct messages and room posts, and is kept separate from channels on purpose. Turn one off to take it back. What each installed app is allowed to do. Anything an app sends goes out under your node name and cannot be told apart from a message you typed. Private covers direct messages and room posts, and is kept separate from channels on purpose. Turn one off to take it back.
Wi-Fi off, new map areas can't download Wi-Fi off, new map areas can't download
App permissions App permissions
+15 -1
View File
@@ -1,7 +1,7 @@
# wadamesh language file
# canonical source - translators: edit this file and PR it
# code: uk
# ver: 12
# ver: 16
# name: Українська
# base: uk
(device behind) (пристрій відстає)
@@ -1014,3 +1014,17 @@ Share my loc Share my loc
Share my location when asked Share my location when asked
Stop sharing loc Stop sharing loc
not asked yet not asked yet
Battery life: gathering data… Battery life: gathering data…
Small\nMedium\nLarge\nJumbo Small\nMedium\nLarge\nJumbo
"%s" wants to send PRIVATE messages as you.\n\nIt could write to any of your contacts, or post to a room, and the message will look exactly like one you typed.\n\nOnly allow this for an app you trust. "%s" wants to send PRIVATE messages as you.\n\nIt could write to any of your contacts, or post to a room, and the message will look exactly like one you typed.\n\nOnly allow this for an app you trust.
Map pan off Map pan off
Map pan: arrows pan, Back exits Map pan: arrows pan, Back exits
Perf: Perf:
Post to channels as me Post to channels as me
Powering off… press BOOT to wake Powering off… press BOOT to wake
Read channel messages Read channel messages
Read private messages Read private messages
Send private messages as me Send private messages as me
What each installed app is allowed to do. Anything an app sends goes out under your node name and cannot be told apart from a message you typed. Private covers direct messages and room posts, and is kept separate from channels on purpose. Turn one off to take it back. What each installed app is allowed to do. Anything an app sends goes out under your node name and cannot be told apart from a message you typed. Private covers direct messages and room posts, and is kept separate from channels on purpose. Turn one off to take it back.
Wi-Fi off, new map areas can't download Wi-Fi off, new map areas can't download
App permissions App permissions
+13 -13
View File
@@ -3,67 +3,67 @@
{
"code": "bg",
"name": "Български",
"ver": "12"
"ver": "16"
},
{
"code": "de",
"name": "Deutsch",
"ver": "12"
"ver": "16"
},
{
"code": "el",
"name": "Ελληνικά",
"ver": "12"
"ver": "16"
},
{
"code": "es",
"name": "Español",
"ver": "12"
"ver": "16"
},
{
"code": "fr",
"name": "Français",
"ver": "12"
"ver": "16"
},
{
"code": "hu",
"name": "Magyar",
"ver": "12"
"ver": "16"
},
{
"code": "it",
"name": "Italiano",
"ver": "12"
"ver": "16"
},
{
"code": "nl",
"name": "Nederlands",
"ver": "12"
"ver": "16"
},
{
"code": "pt-br",
"name": "Português (BR)",
"ver": "12"
"ver": "16"
},
{
"code": "ro",
"name": "Română",
"ver": "12"
"ver": "16"
},
{
"code": "ru",
"name": "Русский",
"ver": "12"
"ver": "16"
},
{
"code": "sr",
"name": "Српски",
"ver": "12"
"ver": "16"
},
{
"code": "uk",
"name": "Українська",
"ver": "12"
"ver": "16"
}
]
}
+1
View File
@@ -0,0 +1 @@
{"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."}
+134
View File
@@ -0,0 +1,134 @@
-- Nearby (Lua) — contacts on a real map, with distance and bearing, and a live
-- packet counter.
--
-- Reference app for the four things the SDK gained alongside it: wada.map (the
-- firmware's own tiles inside an app page), wada.ui.list (scrollable selectable
-- rows), app.on_packet (each frame delivered once instead of polled), and
-- wada.geo (great-circle maths in C rather than in your instruction budget).
local ui, sys, mesh, geo, timer = wada.ui, wada.sys, wada.mesh, wada.geo, wada.timer
local C = ui.colors
local app = {}
local map, list, hdr, detail = nil, nil, nil, nil
local pts, sel = {}, 0
local pkt_count, pkt_adverts = 0, 0
local W, H
local function here()
local fix = sys.gps()
if fix then return fix.lat, fix.lon, true end
local me = mesh.self()
-- self() is the last known position, which may be old. Say which one is in
-- use rather than presenting a stale fix as a live one.
if me.lat ~= 0 or me.lon ~= 0 then return me.lat, me.lon, false end
return nil
end
local function fmt_dist(m)
if m < 1000 then return string.format("%.0f m", m) end
return string.format("%.1f km", m / 1000)
end
local function rebuild()
local lat, lon, live = here()
pts, sel = {}, 0
for _, c in ipairs(mesh.contacts()) do
if c.lat ~= 0 or c.lon ~= 0 then
local e = { name = c.name, lat = c.lat, lon = c.lon, type = c.type, pubkey = c.pubkey }
if lat then
e.dist = geo.distance(lat, lon, c.lat, c.lon)
e.brg = geo.bearing(lat, lon, c.lat, c.lon)
end
pts[#pts + 1] = e
end
end
if lat then table.sort(pts, function(a, b) return (a.dist or 1e9) < (b.dist or 1e9) end) end
list:clear()
for i, e in ipairs(pts) do
local label = e.dist
and string.format("%-12s %8s %-2s", e.name:sub(1, 12), fmt_dist(e.dist), geo.cardinal(e.brg))
or e.name
list:add(label, function() app.pick(i) end)
end
if #pts == 0 then list:add("no contacts have a position yet") end
hdr:set(string.format("%d placed | %s | %d frames (%d adverts)",
#pts, lat and (live and "GPS fix" or "last known position") or "no position", pkt_count, pkt_adverts))
hdr:color(lat and C.accent or C.bad)
if lat then app.draw(lat, lon) end
end
function app.draw(lat, lon)
if not map then return end
map:center(lat, lon)
map:clear()
if map:tiles() == 0 then
-- An empty rectangle looks like open water. Say what it actually is.
detail:set("no map tiles cached here at zoom " .. map:zoom())
detail:color(C.sub)
end
for i, e in ipairs(pts) do
map:line(lat, lon, e.lat, e.lon, i == sel and C.good or 0x2A3340, i == sel and 2 or 1)
end
for i, e in ipairs(pts) do
map:marker(e.lat, e.lon, i == sel and C.good or C.accent, i == sel and 12 or 8)
end
map:marker(lat, lon, 0xE8A33D, 10) -- us, last so it sits on top
end
function app.pick(i)
sel = i
list:select(i)
local e = pts[i]
if not e then return end
local lat, lon = here()
if e.dist then
detail:set(string.format("%s %s %.0f deg (%s) %s",
e.name, fmt_dist(e.dist), e.brg, geo.cardinal(e.brg), e.pubkey))
else
detail:set(e.name .. " " .. e.pubkey)
end
detail:color(C.text)
if lat then app.draw(lat, lon) end
end
function app.on_open(w, h)
W, H = w, h
local LH = ui.text_h(12)
hdr = ui.label("", 4, 4, 12, C.accent); hdr:width(w - 10)
local map_h = math.floor(h * 0.45)
local y = 4 + LH + 4
if sys.caps().map then
map = wada.map.view(4, y, w - 8, map_h)
else
ui.label("this board has no map support", 6, y + 10, 12, C.sub)
end
y = y + map_h + 4
detail = ui.label("pick a contact", 4, y, 12, C.sub); detail:width(w - 10)
y = y + LH + 4
list = ui.list(4, y, w - 8, h - y - 4)
rebuild()
-- Two cadences, which is what wada.timer.after/every made possible: a slow
-- rebuild (contacts and GPS move slowly) and no fast tick at all, because
-- packets now arrive by callback instead of being polled for.
timer.every(5000, rebuild)
end
-- One call per frame the radio received, in arrival order. Counting was simply
-- not possible against a polled 16-entry ring.
function app.on_packet(p)
pkt_count = pkt_count + 1
if p.pubkey then pkt_adverts = pkt_adverts + 1 end
end
function app.on_close()
if map then map:close() end
end
return app
+1
View File
@@ -0,0 +1 @@
{"id":"sdktest","name":"SDK Test","ver":"1.2","desc":"Developer tool. Checks the extended Lua SDK on this board: capabilities, clock, battery, GPS, file access, crypto against published RFC vectors, channel discovery, and buttons that TRANSMIT a test message to Public and a DM to your first contact (each asks permission first)."}
+115
View File
@@ -0,0 +1,115 @@
-- 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.
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 W = 300
local function row(y, text, color)
local l = ui.label(text, 6, y, 12, color or C.text)
l:width(W - 12)
rows[#rows + 1] = l
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)
local y = 4
local c = sys.caps()
row(y, "caps: sdk_ext=" .. yn(c.sdk_ext) .. " kbd=" .. yn(c.keyboard) ..
" touch=" .. yn(c.touch) .. " sd=" .. yn(c.sd), C.accent); y = y + 16
-- 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(y, "crypto " .. t[1] .. ": " .. (ok and "PASS" or ("FAIL got " .. tostring(t[2]):sub(1, 16))),
ok and C.good or C.bad); y = y + 16
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(y, 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); y = y + 20
else
row(y, "wada.crypto: MISSING", C.bad); y = y + 20
end
-- channel discovery
local chans = wada.mesh.channels and wada.mesh.channels() or nil
if chans then
local names = table.concat(chans, ", ")
row(y, "channels(): " .. #chans .. " " .. names:sub(1, 60), C.text); y = y + 20
else
row(y, "wada.mesh.channels: MISSING", C.bad); y = y + 20
end
if not c.sdk_ext then
row(y, "extended SDK is OFF on this board - stopping here.", C.sub)
return
end
-- contacts, so send_dm has a target to name
local cts = wada.mesh.contacts()
local first = cts[1] and cts[1].name or nil
row(y, "contacts: " .. #cts .. (first and (" first=" .. first) or ""), C.text); y = y + 20
sendline = row(y, "mesh.send: not tried yet", C.sub); y = y + 18
dmline = row(y, "mesh.send_dm: not tried yet", C.sub); y = y + 18
msgline = row(y, "on_message: waiting (needs the read permissions)", C.sub); y = y + 22
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)
y = y + 34
ui.button("Beep", 6, y, 70, 30, function() sys.beep() 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
+1
View File
@@ -0,0 +1 @@
{"id": "sdktest", "name": "SDK Test", "ver": "1.3", "desc": "Developer tool. Checks the extended Lua SDK on this board: capabilities, clock, battery, GPS with exact coordinates, windowed file access, crypto against published RFC vectors, packet identity, channels, and buttons that TRANSMIT - a test message, a DM, and a discovery probe (each asks permission first)."}
+196
View File
@@ -0,0 +1,196 @@
-- 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.
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
local function row(y, text, color)
local l = ui.label(text, 6, y, 12, color or C.text)
l:width(W - 12)
rows[#rows + 1] = l
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)
local y = 4
local c = sys.caps()
row(y, "caps: sdk_ext=" .. yn(c.sdk_ext) .. " kbd=" .. yn(c.keyboard) ..
" touch=" .. yn(c.touch) .. " sd=" .. yn(c.sd), C.accent); y = y + 16
row(y, "caps: discover=" .. yn(c.discover) .. " input=" .. yn(c.input) ..
" rx_identity=" .. yn(c.rx_identity), C.accent); y = y + 20
-- 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(y, "crypto " .. t[1] .. ": " .. (ok and "PASS" or ("FAIL got " .. tostring(t[2]):sub(1, 16))),
ok and C.good or C.bad); y = y + 16
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(y, 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); y = y + 20
else
row(y, "wada.crypto: MISSING", C.bad); y = y + 20
end
-- channel discovery
local chans = wada.mesh.channels and wada.mesh.channels() or nil
if chans then
local names = table.concat(chans, ", ")
row(y, "channels(): " .. #chans .. " " .. names:sub(1, 60), C.text); y = y + 20
else
row(y, "wada.mesh.channels: MISSING", C.bad); y = y + 20
end
if not c.sdk_ext then
row(y, "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(y, "contacts: " .. #cts .. (first and (" first=" .. first) or ""), C.text); y = y + 16
if cts[1] then
local pk = cts[1].pubkey
row(y, "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); y = y + 16
end
local me = wada.mesh.self()
row(y, "self: " .. tostring(me.name) .. " pubkey=" .. tostring(me.pubkey), C.text); y = y + 16
-- 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(y, 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(y, "gps: no fix (normal indoors)", C.sub)
end
y = y + 20
-- 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(y, 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); y = y + 20
-- 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(y, "fs.read(name,4,4): " .. tostring(part) .. " total=" .. tostring(total) ..
(ok and " PASS" or " FAIL"), ok and C.good or C.bad); y = y + 20
end
sendline = row(y, "mesh.send: not tried yet", C.sub); y = y + 18
dmline = row(y, "mesh.send_dm: not tried yet", C.sub); y = y + 18
msgline = row(y, "on_message: waiting (needs the read permissions)", C.sub); y = y + 22
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)
y = y + 34
y = y + 4
discline = row(y, "mesh.discover: not tried yet", C.sub); y = y + 18
inputline = row(y, "ui.input: not tried yet", C.sub); y = y + 22
-- Probing TRANSMITS and makes every neighbour reply, so it is a button, not
-- something this app does on open.
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
+1
View File
@@ -0,0 +1 @@
{"id":"wardrive","name":"Wardrive","ver":"1.0","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."}
+188
View File
@@ -0,0 +1,188 @@
-- 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
-- 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",
e.n.name:sub(1, 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
+86 -3
View File
@@ -58,6 +58,11 @@
.prose li{margin:4px 0}
.prose b{color:var(--ink)}
.prose code{font-family:'JetBrains Mono',monospace; font-size:.85em; background:var(--bg2); border:1px solid var(--line); border-radius:6px; padding:1px 6px}
/* Code blocks and callouts, matching sdk.html so the two reference pages look alike. */
.prose pre{background:var(--bg2);border:1px solid var(--line);border-radius:10px;padding:14px 16px;overflow-x:auto;margin:14px 0;font:13px/1.6 'JetBrains Mono',ui-monospace,Menlo,Consolas,monospace}
.prose pre code{background:none;border:none;padding:0;font-size:inherit}
.note{background:var(--bg2);border-left:3px solid var(--teal);border-radius:0 8px 8px 0;padding:12px 16px;margin:16px 0}
.note p{margin:0}
.shot{position:sticky; top:74px}
.cap{text-align:center; font-family:'JetBrains Mono',monospace; font-size:.68rem; color:var(--mut); margin-top:10px; letter-spacing:.04em}
@@ -129,6 +134,8 @@
<a href="#contacts">Contacts</a>
<a href="#map">Map</a>
<a href="#settings">Settings</a>
<div class="grp">Finding nodes</div>
<a href="#discover">Discover &amp; wardriving</a>
<div class="grp">With your phone</div>
<a href="#companion">Companion mode</a>
<a href="#remote">Remote &amp; web app</a>
@@ -137,6 +144,7 @@
<a href="#updates">Updates &amp; test builds</a>
<a href="#settings-pages">Settings pages</a>
<a href="#apps">Apps</a>
<a href="#translations">Translating WADAMESH</a>
</nav>
<main>
@@ -171,6 +179,9 @@
<li><b>Settings</b>, <b>Terminal</b> (MeshCore CLI), <b>Files</b> (microSD).</li>
</ul>
<p>The bar along the bottom is always there — it switches the five main tabs (Chat, Contacts, Home, Map, Settings) from anywhere. The top bar shows unread count, time, signal and battery.</p>
<h3>Building it yourself <span class="tag">Dev</span></h3>
<p>Firmware for the ESP32-S3 boards builds with PlatformIO from the repo. The <b>Tanmatsu</b> is different: it runs under badge.team's launcher as an app rather than as standalone firmware, and flashing it the usual way replaces the launcher OS. There is a separate guide for that: <a href="tanmatsu.html">Sideloading on the Tanmatsu</a>.</p>
<p>If you only want to write software for the device, you do not need a firmware build at all: see the <a href="sdk.html">Lua app SDK</a>.</p>
<h3>Is my history actually saving? <span class="tag">Cmdr</span></h3>
<p><b>Settings &rarr; About &rarr; Chat store</b> is the answer: it names the storage backend in use, when history last saved successfully, and the exact stage and error a failing save is hitting. Worth a look if messages seem to vanish across a reboot.</p>
</div>
@@ -199,7 +210,7 @@
<div class="row">
<div class="prose">
<p>Everyone your node knows — name, type and how recently you heard them. Tap a contact to message it or open its details: signal history, a route trace, telemetry, and share/favourite options.</p>
<p>The <b>overflow menu</b> (top right) holds Search, the <b>Discovered</b> list (nodes heard but not yet added), Add contact, Auto-add settings, and the Blocked list. Auto-add can pull new nodes in from their adverts, or you can add them by hand.</p>
<p>The <b>overflow menu</b> (top right) holds Search, the <b>Discovered</b> list (nodes heard but not yet added), Add contact, Auto-add settings, and the Blocked list. That list is passive, built from what the device overhears; to go looking for nodes actively, see <a href="#discover">Discover</a>. Auto-add can pull new nodes in from their adverts, or you can add them by hand.</p>
</div>
<div class="shot"><div class="device"><img class="screen" src="docs-img/contacts.png" alt="Contacts list"></div><div class="cap">Contacts</div></div>
</div>
@@ -209,7 +220,7 @@
<h2><span class="ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 18l-6 3V6l6-3 6 3 6-3v15l-6 3-6-3z"/><path d="M9 3v15"/><path d="M15 6v15"/></svg></span> Map</h2>
<div class="row">
<div class="prose">
<p>An OpenStreetMap / OpenTopoMap view with your position and your contacts plotted as markers, plus link lines from you to each node. Tiles are fetched over Wi-Fi through the wadamesh proxy and cached to flash (or microSD), so areas you've viewed work offline.</p>
<p>An OpenStreetMap / OpenTopoMap view with your position and your contacts plotted as markers, plus link lines from you to each node. Tiles are fetched over Wi-Fi through the wadamesh proxy and cached to flash (or microSD), so areas you've viewed work offline. Coloured dots along a route you have walked or driven come from <a href="#discover">wardriving</a>.</p>
<p>It also has a line-of-sight analyzer: pick a contact and it uses SRTM elevation to show whether terrain blocks the path.</p>
</div>
<div class="shot"><div class="device"><img class="screen" src="docs-img/map.png?v=2" alt="Map"></div><div class="cap">Map</div></div>
@@ -235,6 +246,40 @@
</div>
</section>
<section class="doc" id="discover">
<h2><span class="ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M12 2v3M12 19v3M2 12h3M19 12h3"/><path d="M6.3 6.3l2.1 2.1M15.6 15.6l2.1 2.1M17.7 6.3l-2.1 2.1M8.4 15.6l-2.1 2.1"/></svg></span> Discover &amp; wardriving</h2>
<div class="prose" style="max-width:64ch">
<p><b>Discover</b> is an app in the home launcher. It does something the rest of the firmware never does: instead of waiting to overhear other nodes, it <b>asks</b>. It broadcasts a request that neighbouring repeaters answer directly, so anything that replies is provably reachable from exactly where you are standing.</p>
<div class="note"><p><b>Two similar names, two different things.</b> The <b>Discovered</b> list in the Contacts overflow menu is passive: nodes you happened to overhear, waiting to be added. The <b>Discover app</b> is active: it transmits a request and lists who answers. This section is about the app.</p></div>
<h3>What you see</h3>
<p>A live list of everything answering right now, strongest signal first. Each row shows the node's name (or <code>Node &middot;A1B2</code> from its key if it is not a contact yet), its signal, how many hops away it is, and how long ago it replied. <b>Tap a row to add that node as a contact.</b></p>
<p>The list is live rather than historical: a node that stops answering for more than about eight seconds drops off it. That is the point, because the question being answered is "what can I reach from here, now".</p>
<h3>Wardriving: mapping your coverage</h3>
<p>If the device has a <b>GPS fix</b>, Discover starts recording while it scans. There is nothing to switch on. Every time you move roughly 15 metres, or every 20 seconds if you stay put, it takes a sample: your position, plus every node answering at that moment.</p>
<p>Because a reply proves reachability from that spot, the trail of samples is a map of <b>your own radio coverage</b>. Open the <b>Map</b> tab and the samples are drawn as coloured dots along the route you walked or drove: <span style="color:#53C06B"><b>green</b></span> where the best signal was strong, <span style="color:#D7574E"><b>red</b></span> where it was weak.</p>
<p>The footer of the Discover app tells you what is happening: <code>Wardrive: 42 coverage pts &middot; 137 logged to SD</code>, or <code>waiting for GPS fix</code> if there is no position yet.</p>
<h3>How to do a wardrive</h3>
<ol>
<li>Put a <b>microSD card</b> in, if your board takes one. Without it the map still works, but nothing survives a reboot.</li>
<li>Wait for a <b>GPS fix</b>. The footer says so plainly when it is still waiting.</li>
<li>Open <b>Discover</b> from the home launcher and leave it open.</li>
<li>Go for a walk or a drive. Samples are taken automatically as you move.</li>
<li>Open the <b>Map</b> tab to see the coverage dots along your route.</li>
</ol>
<p>Discover keeps transmitting while it is open, so it uses more power and more airtime than sitting idle. It is a tool to use deliberately for a while, not something to leave running all day.</p>
<h3>The log file</h3>
<p>Every sighting is appended to <code>/meshcomod/discover/wardrive.csv</code> on the card, one row per node per sample:</p>
<pre><code>epoch,lat,lon,type,pubkey,rssi,snr,hops</code></pre>
<p>That is a plain CSV, so it opens in any spreadsheet or mapping tool. It is the only part of a wardrive that survives a reboot: the coloured dots on the map are held in memory and the newest 160 samples are kept, so a long drive keeps the most recent stretch rather than the whole thing.</p>
<p>There is <b>no on-device viewer</b> for the log. You can see the file in the Files app, but reading the sightings back means taking the card to a computer.</p>
</div>
</section>
<section class="doc" id="settings">
<h2><span class="ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-2.4.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 8 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-2.4 1.65 1.65 0 0 0-1.51-1H1a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 3 8z"/></svg></span> Settings</h2>
<div class="row">
@@ -578,6 +623,44 @@
<div class="mini"><img src="docs-img/app_spectrum.png" alt="Spectrum analyzer"><div class="cap">Spectrum analyzer</div></div>
</div>
</section>
<section class="doc" id="translations">
<h2><span class="ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m5 8 6 6"/><path d="m4 14 6-6 2-3"/><path d="M2 5h12"/><path d="M7 2h1"/><path d="m22 22-5-10-5 10"/><path d="M14 18h6"/></svg></span> Translating WADAMESH</h2>
<div class="prose" style="max-width:64ch">
<p>The interface ships in thirteen languages besides English, and every one of them was written by somebody who uses the device. Corrections and new languages are welcome. This section is the whole procedure, because getting it wrong is easy and the failure is quiet.</p>
<h3>Edit one file, and only that file</h3>
<p>The canonical source for a language is:</p>
<pre><code>deploy/apps/lang/&lt;code&gt;.lang</code></pre>
<p>for example <code>hu.lang</code>, <code>de.lang</code>, <code>nl.lang</code>. It is a plain UTF-8 text file, one string per line, with the English original and your translation separated by a <b>single TAB</b>:</p>
<pre><code>Contacts Névjegyek
No SD card Nincs SD-kártya</code></pre>
<p>A short header at the top carries the language code, its name as it appears in the picker, and a version number.</p>
<div class="note"><p><b>Do not edit <code>src/ui-touch/i18n_builtin.h</code>.</b> It is a <i>generated</i> file and says so on its first line. It is rebuilt from the <code>.lang</code> files by a script, so anything typed into it directly is thrown away the next time anyone regenerates it — and it never reaches the language Store either, so devices that download their language would keep the old text while devices using the built-in copy showed the new. Two sources of truth, silently disagreeing. Put the work in the <code>.lang</code> file and it lands in both.</p></div>
<h3>Rules that keep a language file safe</h3>
<ul>
<li><b>The English side is a key, not a label.</b> It has to match the firmware's string exactly — every character, including the ellipsis <code></code> where the original uses one rather than three dots. A key that does not match is simply never used, and nothing warns you.</li>
<li><b>Placeholders must survive.</b> If the English contains <code>%s</code>, <code>%d</code> or <code>%u</code>, your line needs the same ones, the same number of times, in an order that still makes sense. The firmware checks this and <i>ignores</i> a line whose placeholders do not match, because a mismatch there can crash the device.</li>
<li><b>A newline is written <code>\n</code></b>, two characters, exactly as in the English. A real line break would end the entry.</li>
<li><b>No tab characters inside a translation</b> — the tab is the separator.</li>
<li><b>Leave the technical strings alone.</b> Units and protocol terms (<code>SF</code>, <code>CR</code>, <code>MHz</code>, <code>dBm</code>, <code>GPS</code>, <code>Wi-Fi</code>, <code>Bluetooth</code>) and the product name are meant to stay as they are.</li>
<li><b>Watch the length.</b> Buttons and list rows are sized for the English. A translation twice as long will be cut off on a small screen — prefer the shorter phrasing where there is a choice.</li>
</ul>
<h3>How to send it</h3>
<p>A pull request that changes only <code>deploy/apps/lang/&lt;code&gt;.lang</code> is the easiest thing to review and merge. If you would rather not use git, open an issue and attach the file, or paste the lines you have changed — that is just as welcome, and we will do the rest.</p>
<p>What happens on our side: the version number is bumped, the file is republished to the language Store so existing devices are offered the update, and the compiled-in table is regenerated so a fresh install has it too. You do not need to do any of that.</p>
<h3>Starting a new language</h3>
<p>Copy an existing file, change the header's code and name, and translate as much as you have patience for. <b>Partial is fine.</b> Any line you leave out falls back to English, so a half-finished language is genuinely useful on day one and can be filled in over time.</p>
<h3>Finding what still needs doing</h3>
<p>Strings are added and reworded as the firmware grows, so a language drifts. Open an issue asking for the current gaps for your language and we will generate the list: exactly which strings are missing, and which are still sitting in English. It is usually a couple of dozen lines rather than a thousand.</p>
<p>One thing worth knowing when a translation you wrote seems to have disappeared: if the English wording itself was changed — say a setting was renamed to be more specific — the old key no longer matches anything, and the translation attached to it goes unused. It has not been deleted or rejected. It just needs re-pointing at the new English, which is a good moment to check the meaning still fits.</p>
</div>
</section>
</main>
</div>
@@ -605,7 +688,7 @@
if (a) a.classList.add('on');
}});
}, { rootMargin: '-25% 0px -65% 0px' });
['overview','home','chat','contacts','map','settings','companion','settings-pages','apps'].forEach(function(id){ var el = document.getElementById(id); if (el) obs.observe(el); });
['overview','home','chat','contacts','map','settings','companion','settings-pages','apps','translations'].forEach(function(id){ var el = document.getElementById(id); if (el) obs.observe(el); });
</script>
</body>
</html>
+7
View File
@@ -561,6 +561,13 @@
<span class="go">Build an app</span>
</a>
<a class="rescard" href="tanmatsu.html">
<span class="ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="4" y="4" width="16" height="16" rx="2"/><rect x="9" y="9" width="6" height="6"/><path d="M9 2v2M15 2v2M9 20v2M15 20v2M2 9h2M2 15h2M20 9h2M20 15h2"/></svg></span>
<h4>Tanmatsu sideload</h4>
<p>Building wadamesh yourself and running it on a Tanmatsu, where it installs as an app under the launcher rather than as standalone firmware.</p>
<span class="go">Read the guide</span>
</a>
<a class="rescard" href="https://t413.com/p/projects/MapTileDl/" target="_blank" rel="noopener">
<span class="ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M9 18l-6 3V6l6-3 6 3 6-3v15l-6 3-6-3z"/><path d="M9 3v15"/><path d="M15 6v15"/></svg></span>
<h4>Offline map tiles</h4>
+163 -17
View File
@@ -143,6 +143,13 @@
<a href="#fs">wada.fs</a>
<a href="#sys">wada.sys</a>
<a href="#timer">wada.timer</a>
<a href="#discover">Discovery</a>
<a href="#map">wada.map</a>
<a href="#geo">wada.geo</a>
<a href="#packets">on_packet</a>
<a href="#timers">Timers</a>
<a href="#budget">Instruction budget</a>
<a href="#crypto">wada.crypto</a>
<a href="#perms">Permissions</a>
<div class="grp">Shipping</div>
<a href="#format">App format</a>
@@ -208,10 +215,10 @@ return app</code></pre>
<tr><td>app.on_open(w, h)</td><td>Once, when the app opens. Build your UI here. <code>w</code>&nbsp;/&nbsp;<code>h</code> are the usable body size in pixels — use them instead of assuming a screen size, since the boards differ.</td></tr>
<tr><td>app.on_tick()</td><td>On the cadence set by <code>wada.timer.every(ms)</code>. Never faster than 33&nbsp;ms.</td></tr>
<tr><td>app.on_input(ev)</td><td>For every touch, key or trackball event. See <a href="#input">wada.input</a>.</td></tr>
<tr><td>app.on_message(m)</td><td>An incoming mesh message, if the user granted the app the read permission. See <a href="#perms">Permissions</a>.</td></tr>
<tr><td>app.on_message(m)</td><td>An incoming message. <code>m.kind</code> is <code>"channel"</code>, <code>"dm"</code> or <code>"room"</code>, alongside <code>m.channel</code>, <code>m.sender</code> and <code>m.text</code>. Channel traffic needs the channel-read permission; DMs and room posts need the private-read one. See <a href="#perms">Permissions</a>.</td></tr>
<tr><td>app.on_close()</td><td>Once, when the app closes. Persist anything you care about here.</td></tr>
</table>
<p>Every callback runs inside a guarded call on the UI thread. If your code raises an error the app is closed with a toast rather than taking the firmware down with it — but it does mean a slow callback stalls the whole UI, so keep them short. There is an instruction budget: an app that spins forever is stopped.</p>
<p>Every callback runs inside a guarded call on the UI thread. If your code raises an error the app is closed with a toast rather than taking the firmware down with it — but it does mean a slow callback stalls the whole UI, so keep them short. There is an <a href="#budget">instruction budget</a> on every callback, so an app that spins forever is stopped rather than taking the device with it.</p>
<p>Widgets you create in <code>on_open</code> are destroyed for you when the app closes. You do not free anything.</p>
</div></div>
</section>
@@ -227,6 +234,8 @@ return app</code></pre>
<tr><th>Call</th><th>Returns / does</th></tr>
<tr><td>wada.ui.label(text, x, y, size, color)</td><td>A text line placed at <code>x, y</code>. <code>size</code> is a class, not a pixel height: 12, 14 or 16. <code>color</code> takes a <code>wada.ui.colors</code> value.</td></tr>
<tr><td>wada.ui.button(text, x, y, w, h, fn)</td><td>A tappable button at <code>x, y</code> sized <code>w &times; h</code>; <code>fn</code> is called with no arguments.</td></tr>
<tr><td>wada.ui.list(x, y, w, h)</td><td>A scrollable list of selectable rows &mdash; the "pick one of N" widget. Methods: <code>add(text [, fn])</code> returning the row index, <code>set(i, text)</code>, <code>color(i, c)</code>, <code>select(i)</code> (highlights <i>and</i> scrolls it into view), <code>selected()</code>, <code>count()</code>, <code>clear()</code>, <code>pos(x, y)</code>. Rows are real buttons, so keyboard and trackball navigation walks them on touchless boards without you doing anything.</td></tr>
<tr><td>wada.ui.input(title, initial, cb)</td><td>A modal text field. <code>cb(text)</code> on OK, <code>cb(nil)</code> on cancel or on the dialog being closed any other way &mdash; 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.</td></tr>
<tr><td>wada.ui.canvas(w, h)</td><td>A drawing surface. See the canvas methods below.</td></tr>
<tr><td>wada.ui.chart(points)</td><td>A line chart sized to the page. The Airtime and RF Monitor primitive.</td></tr>
<tr><td>wada.ui.scroll(on)</td><td>Allow the page to scroll when content is taller than the screen.</td></tr>
@@ -278,15 +287,112 @@ return app</code></pre>
<section class="doc" id="mesh">
<h2>wada.mesh</h2>
<div class="row"><div class="prose">
<p>Read-only in API v1.</p>
<table class="api">
<tr><td>wada.mesh.contacts()</td><td>Array of <code>{name, type, ago_s, lat, lon}</code>.</td></tr>
<tr><td>wada.mesh.rx_log()</td><td>Recent packets: <code>{ago_ms, type, rssi, snr, hops}</code>. The RF Monitor feed.</td></tr>
<tr><td>wada.mesh.contacts()</td><td>Array of <code>{name, pubkey, type, ago_s, lat, lon, lat_e6, lon_e6}</code>. <code>pubkey</code> is the first 4 bytes as 8 hex characters &mdash; the same short form the rest of the interface uses, and what lines a contact up with a discovery hit.</td></tr>
<tr><td>wada.mesh.rx_log()</td><td>Recent packets, newest first: <code>{ago_ms, type, rssi, snr, hops, route, len}</code>, plus whatever identity the frame actually carried &mdash; see below. The RF Monitor feed.</td></tr>
<tr><td>wada.mesh.stats()</td><td><code>{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}</code>.</td></tr>
<tr><td>wada.mesh.self()</td><td>This node: <code>{name, lat, lon}</code>.</td></tr>
<tr><td>wada.mesh.send(channel, text) <span class="chip">ext</span></td><td>Send to a channel by name. Returns <code>ok, err</code>. Needs the send permission — the first call prompts the user. <code>err</code> is <code>"denied"</code> if they refused, <code>"too fast"</code> inside the 5&nbsp;second floor, <code>"too long"</code> past 180 characters.</td></tr>
<tr><td>wada.mesh.self()</td><td>This node: <code>{name, pubkey, lat, lon, lat_e6, lon_e6}</code>.</td></tr>
<tr><td>wada.mesh.discovered()</td><td>Everything that has answered a probe this session: <code>{pubkey, name, type, rssi, snr, their_snr, hops, direct, first_ms_ago, last_ms_ago, heard}</code>. <code>name</code> is present only if the responder is already one of your contacts. See <a href="#discover">discovery</a>.</td></tr>
<tr><td>wada.mesh.discover_clear()</td><td>Forget every hit so far. A survey calls this between locations, or the next sample inherits nodes you heard a street back.</td></tr>
<tr><td>wada.mesh.discover([types]) <span class="chip">ext</span></td><td>Broadcast a discovery probe. Returns the scan tag, or <code>false, err</code>. Needs the <b>discovery-probe</b> permission and has a 15&nbsp;second floor. See <a href="#discover">discovery</a>.</td></tr>
<tr><td>wada.mesh.send(channel, text) <span class="chip">ext</span></td><td>Post to a channel by name, public or private. Returns <code>ok, err</code>. Needs the channel-send permission; the first call prompts the user. <code>err</code> is <code>"permission denied"</code> if they refused, <code>"too fast"</code> inside the 5&nbsp;second floor, <code>"bad length"</code> past 180 characters.</td></tr>
<tr><td>wada.mesh.send_dm(to, text) <span class="chip">ext</span></td><td>Send a direct message, or post to a room server. <code>to</code> is a contact <b>name</b> as it appears in <code>wada.mesh.contacts()</code>. A room is a contact too, so one call covers both and the second return is <code>"sent"</code> or <code>"room"</code>. Needs the <b>private</b> send permission, which is separate from channels.</td></tr>
<tr><td>wada.mesh.channels()</td><td>Array of the channel names configured on this device, so you can find the private ones rather than being told their names. Names only: the channel secret is never exposed to Lua.</td></tr>
</table>
<p>These are snapshots taken when you call them, not live views — call again on each tick to refresh.</p>
<h3>Who sent a packet</h3>
<p>MeshCore only puts a real identity on the wire in one place: an <b>advert</b>, whose payload opens with the sender's public key. Those entries in <code>rx_log()</code> carry <code>pubkey</code>, 8 hex characters, and they are the ones worth logging &mdash; an advert both names a node and proves it was audible from where you are.</p>
<p>Addressed frames (text, requests, responses, path replies) carry one-byte destination and source hashes instead, exposed as <code>dst</code> and <code>src</code>, two hex characters each. Two characters collide easily, so treat them as a hint and never as an identity. Everything else is anonymous on the wire and gets neither field. Nothing here is inferred: if a field is absent, the frame did not contain it.</p>
<h3 id="node-types">Node types</h3>
<p>The <code>type</code> field everywhere, and the filter <code>discover()</code> takes, use these. They are on the <code>wada.mesh</code> table so you never have to hardcode the wire numbers.</p>
<table class="api">
<tr><td>wada.mesh.NODE_CHAT</td><td><code>1</code> &mdash; a companion / chat node.</td></tr>
<tr><td>wada.mesh.NODE_REPEATER</td><td><code>2</code></td></tr>
<tr><td>wada.mesh.NODE_ROOM</td><td><code>3</code> &mdash; a room server.</td></tr>
<tr><td>wada.mesh.NODE_SENSOR</td><td><code>4</code></td></tr>
</table>
</div></div>
</section>
<section class="doc" id="discover">
<h2>Discovery, and writing a coverage survey</h2>
<div class="row"><div class="prose">
<p><code>wada.mesh.discover()</code> broadcasts a zero-hop request that every node in earshot answers. That is a different thing from listening, and the difference is the whole reason it exists: a reply <b>proves</b> the link works from exactly where you are standing. Listening only tells you what happened to transmit while you were there, so a node that stays quiet is indistinguishable from a node you cannot reach.</p>
<p>The call returns immediately. Replies arrive over the next few seconds and accumulate in <code>wada.mesh.discovered()</code>, so the shape of a survey loop is: probe, wait a few seconds, read, <code>discover_clear()</code>, move.</p>
<h3>Both directions of the link</h3>
<p>Each hit carries <code>snr</code> (how well <b>you</b> heard <b>them</b>) and <code>their_snr</code> (how well <b>they</b> heard <b>you</b>, reported back inside the reply). These are rarely equal, because their antenna, height and transmit power are not yours. The asymmetry is the most useful thing a survey can record: <i>"I can hear the repeater but it cannot hear me"</i> is a different fact from <i>"no coverage"</i>, and no amount of passive listening will tell you which one you are looking at.</p>
<p><code>hops</code> is <code>0</code> when the reply came straight back &mdash; <code>direct</code> is that same test as a boolean. A hit with <code>hops &gt; 0</code> reached you through a repeater, so it says something about the mesh but nothing about your radio horizon.</p>
<h3>Cost, and the rate limit</h3>
<p>One probe costs one transmission from you and <b>a reply from every node that hears it</b>. That is airtime spent across the whole neighbourhood, not just yours, which is why it sits behind its own permission rather than reusing the message-send one: making other people's radios transmit and speaking as the user are different impositions, and a survey app has no business acquiring the second. The firmware enforces a <b>15&nbsp;second</b> minimum between probes per app; a faster call returns <code>false, "too fast"</code>. In practice sweep every 20&nbsp;seconds, which is quicker than anyone drives out of a cell.</p>
<h3>Logging it</h3>
<p>Lua here is built with 32-bit numbers, so <code>lat</code> and <code>lon</code> are <b>single-precision floats</b> &mdash; about a metre, fine to display and lossy to store. <code>sys.gps()</code>, <code>mesh.self()</code> and <code>mesh.contacts()</code> therefore also hand you <code>lat_e6</code> / <code>lon_e6</code>, the same reading as exact integers in micro-degrees. Write those. <code>sys.gps()</code> also gives <code>alt_m</code>, which matters more than people expect: height dominates LoRa range, and two samples a metre apart on the map can be a hilltop and a hollow.</p>
<p><code>wada.fs</code> allows one write a second, so buffer a sweep's rows and append them as one block rather than a line at a time. <code>wada.fs.read(name, offset, len)</code> reads a window, so an append-only log may grow past the 32&nbsp;KB single-read limit and still be readable in chunks.</p>
<p>There is a working example of all of this: the <b>Wardrive</b> app in the Store. It probes on a cadence, discards samples taken without a GPS fix rather than logging them at 0,0, and writes a CSV with both link directions per node.</p>
</div></div>
</section>
<section class="doc" id="timers">
<h2>wada.timer</h2>
<div class="row"><div class="prose">
<table class="api">
<tr><td>wada.timer.every(ms)</td><td>Sets the period of <code>on_tick</code>. This is the app heartbeat and there is one of it.</td></tr>
<tr><td>wada.timer.every(ms, fn)</td><td>A <b>named</b> repeating timer calling <code>fn</code>. Returns a handle.</td></tr>
<tr><td>wada.timer.after(ms, fn)</td><td>Fires <code>fn</code> once. Returns a handle you can cancel before it fires.</td></tr>
<tr><td>wada.timer.stop()</td><td>Stops the <code>on_tick</code> heartbeat.</td></tr>
<tr><td>wada.timer.stop(handle) / handle:stop()</td><td>Stops that one timer.</td></tr>
</table>
<p>An app used to get exactly one clock. Anything needing a second cadence &mdash; a slow poll beside a fast animation, a delayed retry, a timeout on a fetch &mdash; had to be a counter inside <code>on_tick</code>, which is both tedious and wrong when the tick period changes. Up to <b>eight</b> named timers per app; a ninth raises an error rather than quietly filling LVGL's timer list. Everything is stopped for you when the app closes.</p>
</div></div>
</section>
<section class="doc" id="packets">
<h2>app.on_packet</h2>
<div class="row"><div class="prose">
<p>Declare <code>on_packet</code> and the firmware calls it once per frame the radio received, in arrival order, with the same table <a href="#mesh"><code>rx_log()</code></a> rows use.</p>
<pre><code>function app.on_packet(p)
if p.pubkey then adverts = adverts + 1 end -- an advert: a real identity
total = total + 1
end</code></pre>
<p>Polling <code>rx_log()</code> on a tick samples a 16-deep ring, so in any real traffic an app sees a subset and cannot <i>count</i> anything. This delivers each frame exactly once. An app that does not declare <code>on_packet</code> costs nothing: the drain is only started for apps that asked for it.</p>
<p>Ungated, exactly like <code>rx_log()</code>: this is radio metadata about frames the device already received, not message content. Anything carrying content still arrives through <code>on_message</code> and its permissions.</p>
</div></div>
</section>
<section class="doc" id="map">
<h2>wada.map <span class="chip">ext</span></h2>
<div class="row"><div class="prose">
<p>The firmware's own slippy map, inside your app page: the same OpenStreetMap tiles, the same Web Mercator projection and the same on-disk cache the Map tab uses. Before this an app could draw geography only onto a bare canvas, with no basemap.</p>
<table class="api">
<tr><td>wada.map.view(x, y, w, h)</td><td>Creates the view. <b>One per app</b> &mdash; it owns a pool of up to four decoded tiles, which is 512&nbsp;KB of PSRAM, so opening them in a loop would be a straightforward way to exhaust the board. A second call errors.</td></tr>
<tr><td>map:center(lat, lon [, zoom])</td><td>Moves the view and redraws.</td></tr>
<tr><td>map:zoom() / map:zoom(z)</td><td>Reads, or sets and redraws. 1&ndash;19.</td></tr>
<tr><td>map:marker(lat, lon [, color [, size]])</td><td>A dot. Returns <code>false</code> if the point is outside the view, in which case nothing was drawn.</td></tr>
<tr><td>map:line(lat1, lon1, lat2, lon2 [, color [, width]])</td><td>A segment between two coordinates.</td></tr>
<tr><td>map:clear()</td><td>Removes every marker and line. The basemap stays.</td></tr>
<tr><td>map:to_screen(lat, lon)</td><td><code>x, y</code> within the view. Deliberately not clamped, so you can tell that a point is off-view instead of finding everything piled on the edge.</td></tr>
<tr><td>map:to_latlon(x, y)</td><td>The inverse &mdash; turn a tap into a coordinate.</td></tr>
<tr><td>map:tiles()</td><td>How many tiles the last redraw actually placed. <b>Check this.</b> Zero means nothing is cached for this area at this zoom, and an empty rectangle looks exactly like open water unless you say otherwise.</td></tr>
<tr><td>map:redraw() / map:close()</td><td>Force a redraw; dispose of the view early rather than waiting for the collector.</td></tr>
</table>
<p>Tiles come from whatever the device already has &mdash; the SD pack or the online cache &mdash; and a missing one is queued for download if Wi-Fi is up, exactly as on the Map tab. The view follows the user's night-mode setting rather than introducing a second one. The <b>Nearby</b> app in the Store is a worked example.</p>
</div></div>
</section>
<section class="doc" id="geo">
<h2>wada.geo</h2>
<div class="row"><div class="prose">
<table class="api">
<tr><td>wada.geo.distance(lat1, lon1, lat2, lon2)</td><td>Great-circle distance in <b>metres</b>.</td></tr>
<tr><td>wada.geo.bearing(lat1, lon1, lat2, lon2)</td><td>Initial true bearing, 0&ndash;360 degrees.</td></tr>
<tr><td>wada.geo.cardinal(degrees)</td><td><code>"N"</code>, <code>"NE"</code>, … Eight points, not sixteen: more would be false precision on a bearing derived from consumer GPS.</td></tr>
</table>
<p>In C because it is not a convenience wrapper: haversine and the bearing formula are a dozen trig calls each, and an app doing them per contact per tick spends a real slice of its <a href="#budget">instruction budget</a> on arithmetic the chip does in microseconds.</p>
<div class="note"><p><b>This is not a compass.</b> <code>bearing()</code> is a true bearing from one coordinate to another &mdash; the direction to <i>steer</i>, not the direction the user is <i>facing</i>. Only the ThinkNode M9 carries a magnetometer, and even there the heading is the app&apos;s to derive &mdash; see <a href="#sys">wada.sys.compass()</a>. On every other board a magnetic heading is not available at any level of the stack: to point somebody at something you need their course over ground from successive GPS fixes (<code>sys.gps().course</code>), or a physical compass.</p></div>
</div></div>
</section>
@@ -294,8 +400,10 @@ return app</code></pre>
<h2>wada.net</h2>
<div class="row"><div class="prose">
<table class="api">
<tr><td>wada.net.http_get(url, cb)</td><td>Fetch a URL. <code>cb(body)</code> runs when it lands, or with <code>nil</code> on failure.</td></tr>
<tr><td>wada.net.http_get(url, cb [, max_bytes])</td><td>Fetch a URL. <code>cb(status, body)</code> runs when it lands, or with a negative status and <code>nil</code> on failure.</td></tr>
<tr><td>wada.net.http_post(url, body [, content_type], cb [, max_reply])</td><td>Upload. Any 2xx counts as success, so an endpoint answering 201 or 204 works. <code>content_type</code> defaults to <code>application/octet-stream</code>. This is how an app gets data <b>off</b> the device &mdash; a survey log, a sensor series, a webhook.</td></tr>
</table>
<p><code>http://</code> 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 <code>https</code> to offer; put a proxy in front if you need it. One request in flight at a time per app.</p>
<p>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.</p>
<div class="note"><p><b>Plain HTTP only, and that is not an oversight.</b> After Wi-Fi associates there is not enough free internal memory on the smaller boards for a TLS handshake — mbedTLS wants around 30&nbsp;KB and roughly 5&nbsp;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&nbsp;KB by default).</p></div>
</div></div>
@@ -319,7 +427,7 @@ return app</code></pre>
<p>A private folder per app, for things too big or too structured for <a href="#store">wada.store</a> — a log, a track, a cache. Paths are plain names inside your own folder: an app cannot name a file outside it, and cannot see another app's files. <code>..</code> and absolute paths are rejected rather than sanitised.</p>
<table class="api">
<tr><th>Call</th><th>Returns / does</th></tr>
<tr><td>wada.fs.read(name)</td><td>File contents as a string, or <code>nil</code>.</td></tr>
<tr><td>wada.fs.read(name [, offset [, len]])</td><td>Returns <code>data, total_size</code>, or <code>nil</code> if there is no such file. One read hands back at most 32&nbsp;KB, but <code>offset</code> makes that a window rather than a ceiling: an append-only log may grow past it and be walked in chunks using the returned total.</td></tr>
<tr><td>wada.fs.write(name, data)</td><td>Replace a file. Returns <code>ok, err</code>.</td></tr>
<tr><td>wada.fs.append(name, data)</td><td>Append. Returns <code>ok, err</code>.</td></tr>
<tr><td>wada.fs.list()</td><td>Array of <code>{name, size}</code>.</td></tr>
@@ -340,10 +448,11 @@ return app</code></pre>
<tr><td>wada.sys.epoch()</td><td>Unix time in seconds, or <code>nil</code> when the clock has not been set yet (no GPS fix and no NTP). Always handle the <code>nil</code>.</td></tr>
<tr><td>wada.sys.datetime()</td><td><code>{year, month, day, hour, min, sec, wday}</code> in local time. <code>wday</code> is 0 for Sunday.</td></tr>
<tr><td>wada.sys.beep()</td><td>Short beep on boards with a buzzer; silent elsewhere, and silent when the user has sound off.</td></tr>
<tr><td>wada.sys.caps()</td><td><code>{sdk_ext, keyboard, touch, sd, compass}</code>. Check <code>sdk_ext</code> before using anything marked <span class="chip">ext</span> below, and <code>compass</code> before <code>wada.sys.compass()</code>.</td></tr>
<tr><td>wada.sys.caps()</td><td><code>{sdk_ext, keyboard, touch, sd, compass, discover, input, rx_identity, list, packets, sensors, map}</code>. Check <code>sdk_ext</code> before using anything marked <span class="chip">ext</span> below &mdash; that one is a <i>memory</i> gate, absent on the small boards. <code>keyboard</code>, <code>touch</code>, <code>sd</code>, <code>compass</code> and <code>sensors</code> are hardware questions: does this board physically carry the thing. The rest &mdash; <code>discover</code>, <code>input</code>, <code>rx_identity</code>, <code>list</code>, <code>packets</code>, <code>map</code> &mdash; are feature flags for calls added after the extended SDK first shipped, so an app can degrade on older firmware instead of erroring. The three groups are different questions; branch on the one you actually mean.</td></tr>
<tr><td>wada.sys.battery() <span class="chip">ext</span></td><td><code>{mv, pct, charging}</code>.</td></tr>
<tr><td>wada.sys.gps() <span class="chip">ext</span></td><td><code>{lat, lon, sats, alt, speed_kmh, course}</code>, or <code>nil</code> with no fix — which is the normal indoor case, so handle it. <code>alt</code> is metres. <code>speed_kmh</code> and <code>course</code> (degrees clockwise from north) are only present on boards whose GPS provider reports them, and <code>course</code> only while actually moving — a stationary receiver has no course, so it is absent rather than 0. Treat both as optional.</td></tr>
<tr><td>wada.sys.compass()</td><td>Only where <code>caps().compass</code> is true (ThinkNode M9 today). <code>{x, y, z, ovfl}</code> magnetic field in Gauss in the sensor's own frame, <b>uncalibrated</b>, or <code>nil</code> when nothing fresh; <code>ovfl</code> is true when the chip flagged the sample as saturated (a magnet nearby), in which case show that rather than a heading. There is deliberately no <code>heading</code>: the board carries a hard-iron bias the user has to calibrate away (track per-axis min/max while they turn the device, subtract the midpoints), and the sensor-to-screen axis mapping is the app's to set. Then <code>math.atan(-fy, fx)</code> with <code>fx</code> along the screen's top edge and <code>fy</code> along its right edge gives the heading. The GPS Compass app in the Store is the worked example.</td></tr>
<tr><td>wada.sys.env() <span class="chip">sensors</span></td><td><code>{temp_c, humidity, pressure_hpa, alt_m}</code>, or <code>nil</code>. A field is present only when the hardware actually reported it, so you can tell "no humidity sensor" from "0% humidity". Gated on <code>caps().sensors</code> &mdash; does this board <i>have</i> the sensor rail &mdash; and <b>not</b> on <code>sdk_ext</code>, which is a memory gate. Those are different questions: the plain Heltec V4 has the Expansion Kit but not the extended SDK, and most boards with the extended SDK have no sensors at all.</td></tr>
<tr><td>wada.sys.gps() <span class="chip">ext</span></td><td><code>{lat, lon, lat_e6, lon_e6, sats, alt_m, time, speed_kmh, course}</code>, or <code>nil</code> with no fix &mdash; which is the normal indoor case, so handle it. <code>lat_e6</code>/<code>lon_e6</code> are exact micro-degrees; <code>lat</code>/<code>lon</code> are single-precision floats, so <a href="#discover">log the integers</a>. <code>alt_m</code> is metres. <code>time</code> is satellite time, absent until the receiver has decoded the date. <code>speed_kmh</code> and <code>course</code> (degrees clockwise from north) appear only on boards whose GPS provider reports them, and <code>course</code> only while actually moving &mdash; a stationary receiver has no course, so it is absent rather than 0. Treat those last three as optional.</td></tr>
<tr><td>wada.sys.compass()</td><td>Only where <code>caps().compass</code> is true (ThinkNode M9 today), and note that this one is <b>not</b> <span class="chip">ext</span>-gated: it rides the hardware flag, not the memory flag. <code>{x, y, z, ovfl}</code> magnetic field in Gauss in the sensor's own frame, <b>uncalibrated</b>, or <code>nil</code> when nothing fresh; <code>ovfl</code> is true when the chip flagged the sample as saturated (a magnet nearby), in which case show that rather than a heading. There is deliberately no <code>heading</code>: the board carries a hard-iron bias the user has to calibrate away (track per-axis min/max while they turn the device, subtract the midpoints), and the sensor-to-screen axis mapping is the app's to set. Then <code>math.atan(-fy, fx)</code> with <code>fx</code> along the screen's top edge and <code>fy</code> along its right edge gives the heading. The GPS Compass app in the Store is the worked example.</td></tr>
</table>
</div></div>
</section>
@@ -359,16 +468,53 @@ return app</code></pre>
</div></div>
</section>
<section class="doc" id="budget">
<h2>The instruction budget</h2>
<div class="row"><div class="prose">
<p>Every callback runs under a cap of <b>100,000 Lua VM instructions</b>. Reaching it raises <code>instruction budget exceeded (app tick too long)</code>, which closes the app with a toast instead of letting it hold the UI thread forever.</p>
<table class="api">
<tr><th>Question</th><th>Answer</th></tr>
<tr><td>What is counted?</td><td><b>VM opcodes</b>, by Lua's own counter (<code>lua_sethook</code> with <code>LUA_MASKCOUNT</code>). Not native calls, not wall-clock time.</td></tr>
<tr><td>Per what?</td><td><b>Per callback.</b> The counter is armed immediately before each call and cleared after, so <code>on_open</code>, every <code>on_tick</code>, every <code>on_input</code> and every <code>on_message</code> each get a fresh 100,000. It is not a session allowance you can exhaust.</td></tr>
<tr><td>Does <code>on_open</code> get more?</td><td>Yes, <b>5&times;</b> (500,000). Setting a UI up legitimately does more work than a tick.</td></tr>
<tr><td>Do <code>wada.*</code> calls count?</td><td><b>No.</b> Time inside a native call costs wall-clock but not budget, because the VM is not executing opcodes. That cuts both ways: the budget will not save you from a slow host call, which is why <code>wada.fs</code> and the mesh sends carry their own rate limits instead.</td></tr>
</table>
<p>For scale, 100,000 instructions is well under 2&nbsp;ms of straight-line Lua on an ESP32-S3, and a runaway loop is contained in about 30&nbsp;ms. Ordinary per-tick app code is nowhere near it.</p>
<div class="note"><p><b>If you are hitting it, you are probably doing cryptography.</b> A HMAC-SHA1 in pure Lua is 80 bit operations per 64-byte block with interpreter overhead on every one, and that genuinely exceeds 100,000 instructions. That is not a runaway and the answer is not a bigger budget: use <a href="#crypto">wada.crypto</a>, where the same work costs microseconds and a handful of opcodes. This section exists because a one-time-password app hit exactly this.</p></div>
<p>The other way to stay inside it is to do less per callback. State kept in your app table persists between ticks, so a long job can be split across several: do a slice of the work, return, and continue on the next <code>on_tick</code>.</p>
</div></div>
</section>
<section class="doc" id="crypto">
<h2>wada.crypto</h2>
<div class="row"><div class="prose">
<p>Hashing in C, because hashing in Lua does not fit the instruction budget. HMAC-SHA1 is 80 bit operations per 64-byte block and the interpreter overhead multiplies every one, so a one-time-password app doing it by hand runs out of budget mid-callback. These call the mbedTLS the firmware already links: microseconds, and a handful of VM instructions.</p>
<table class="api">
<tr><th>Call</th><th>Returns</th></tr>
<tr><td>wada.crypto.sha256(data)</td><td>32-byte digest, as a binary string.</td></tr>
<tr><td>wada.crypto.sha1(data)</td><td>20-byte digest.</td></tr>
<tr><td>wada.crypto.hmac_sha256(key, msg)</td><td>32-byte HMAC.</td></tr>
<tr><td>wada.crypto.hmac_sha1(key, msg)</td><td>20-byte HMAC, the RFC&nbsp;4226 / 6238 one.</td></tr>
<tr><td>wada.crypto.hex(binary)</td><td>Lower-case hex of a binary string.</td></tr>
</table>
<p>Digests come back <b>raw</b>, not hex, so RFC&nbsp;4226 dynamic truncation works on them directly. Lua strings are 8-bit clean, so binary keys and messages are fine as they are.</p>
<p>Available on <b>every</b> board, unlike the <span class="chip">ext</span> calls. This is pure computation over data your app already holds: it reads nothing of the user's and transmits nothing, so there is no reason for the small boards to go without it.</p>
</div></div>
</section>
<section class="doc" id="perms">
<h2>Permissions</h2>
<div class="row"><div class="prose">
<p>Two things an app can ask for reach past its own window, so they are not granted by installing it:</p>
<table class="api">
<tr><th>Permission</th><th>Unlocks</th></tr>
<tr><td>Send</td><td><code>wada.mesh.send</code> — transmitting in your name.</td></tr>
<tr><td>Read messages</td><td><code>app.on_message</code> — seeing messages as they arrive.</td></tr>
<tr><td>Post to channels as me</td><td><code>wada.mesh.send</code>, posting to a channel in your name.</td></tr>
<tr><td>Read channel messages</td><td><code>app.on_message</code> for <code>kind == "channel"</code>.</td></tr>
<tr><td>Send private messages as me</td><td><code>wada.mesh.send_dm</code>, writing to one contact or posting to a room as you.</td></tr>
<tr><td>Read private messages</td><td><code>app.on_message</code> for <code>kind == "dm"</code> and <code>"room"</code>.</td></tr>
<tr><td>Send discovery probes</td><td><code>wada.mesh.discover</code>. Separate from the send permissions because it transmits nothing under your name but does make every node in range transmit a reply.</td></tr>
</table>
<p>They are separate: granting one does not grant the other. The prompt names the app and appears on the first attempt, not at install time, so you are asked at the moment it is obvious what the app wants it for. A refusal is remembered — the app does not get to ask again on a loop — and the call simply returns <code>false, "denied"</code>, which a well-written app should handle rather than break on.</p>
<p>Four rather than one because the risks genuinely differ: posting to a channel you are already in is not the same act as writing to one person as you, and channel traffic is not somebody's private conversations. They are separate: granting one does not grant another. The prompt names the app and appears on the first attempt, not at install time, so you are asked at the moment it is obvious what the app wants it for. A refusal is remembered — the app does not get to ask again on a loop — and the call simply returns <code>false, "denied"</code>, which a well-written app should handle rather than break on.</p>
<p><b>Settings &rarr; App permissions</b> lists every installed app and what it holds, and revokes with one switch. Granting happens where an app asks; that page is for review and for taking it back.</p>
<div class="note"><p><b>Write for the denial.</b> The user can say no, or revoke later. Treat a permitted send as the lucky path, not the assumption.</p></div>
</div></div>
@@ -420,8 +566,8 @@ return app</code></pre>
<h2>Sandbox limits</h2>
<div class="row"><div class="prose">
<p>Apps run in a restricted environment. These are removed: <code>io</code>, <code>os</code>, <code>require</code>, <code>dofile</code>, and loading new chunks at runtime. Available: <code>math</code>, <code>string</code>, <code>table</code>, and the usual <code>pairs</code>, <code>ipairs</code>, <code>select</code>, <code>pcall</code>, <code>tostring</code>, <code>tonumber</code>.</p>
<p>Memory comes from a capped pool in PSRAM, so an app that allocates without bound fails its own allocation rather than starving the radio or the UI. There is an instruction budget on every callback for the same reason.</p>
<p><b>The extended calls are not on every board.</b> Anything marked <span class="chip">ext</span> above &mdash; <code>wada.fs</code>, <code>wada.mesh.send</code>, <code>sys.battery</code>, <code>sys.gps</code> &mdash; needs a board with the memory to carry it, so the Heltec V4 keeps its RAM for the mesh instead. Call <code>wada.sys.caps().sdk_ext</code> and degrade gracefully rather than assuming; an app that hard-depends on them without checking simply errors out on the small boards.</p>
<p>Memory comes from a capped pool in PSRAM, so an app that allocates without bound fails its own allocation rather than starving the radio or the UI. There is an <a href="#budget">instruction budget</a> on every callback for the same reason.</p>
<p><b>The extended calls are not on every board.</b> Anything marked <span class="chip">ext</span> above &mdash; <code>wada.fs</code>, <code>wada.mesh.send</code>, <code>wada.mesh.send_dm</code>, <code>wada.mesh.discover</code>, <code>sys.battery</code>, <code>sys.gps</code> &mdash; needs a board with the memory to carry it, so the Heltec V4 keeps its RAM for the mesh instead. Call <code>wada.sys.caps().sdk_ext</code> and degrade gracefully rather than assuming; an app that hard-depends on them without checking simply errors out on the small boards.</p>
<p>Rate limits are part of the contract, not a rainy-day guard: <code>wada.fs</code> writes are about one per second with a 32&nbsp;KB file cap, and <code>wada.mesh.send</code> has a 5&nbsp;second floor and a 180-character limit. They return <code>false, "too fast"</code> rather than throwing, and hitting them is expected &mdash; handle it.</p>
<p>None of this makes a hostile app safe, which is why the catalog is curated. It makes an <i>honest</i> app that has a bug survivable: it gets closed, and the device keeps carrying traffic.</p>
</div></div>
+248
View File
@@ -0,0 +1,248 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>WADAMESH — Sideloading on the Tanmatsu</title>
<meta name="description" content="Write your own apps for WADAMESH in Lua. The wada.* API reference: ui, input, mesh, net, store, sys and timer, plus the app format and how to publish to the Store.">
<link rel="icon" href="wadamesh-badge.svg">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@600;700&display=swap" rel="stylesheet">
<style>
:root{
--bg:#ffffff; --bg2:#f4f6f2; --ink:#15181e; --mut:#5d6770;
--line:#e5e8e2; --teal:#15b6a6; --teal-ink:#0e8d80;
color-scheme:light;
}
*{box-sizing:border-box;margin:0;padding:0}
html{scroll-behavior:smooth}
body{font:16px/1.65 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Inter,sans-serif; background:var(--bg); color:var(--ink); -webkit-font-smoothing:antialiased}
a{color:var(--teal-ink); text-decoration:none}
.top{position:sticky; top:0; z-index:30; display:flex; align-items:center; gap:14px; padding:12px 22px;
background:rgba(255,255,255,.88); backdrop-filter:saturate(1.2) blur(8px); border-bottom:1px solid var(--line)}
.top .word{font-weight:700; letter-spacing:2px; font-size:1.05rem; color:var(--ink)}
.top .word .t{color:var(--teal)}
.top .tag{font-family:'JetBrains Mono',monospace; font-size:.66rem; letter-spacing:.18em; text-transform:uppercase; color:var(--mut); border:1px solid var(--line); border-radius:100px; padding:4px 9px}
.top .sp{flex:1}
.top .home{display:inline-flex; align-items:center; gap:7px; color:var(--mut); font-size:.86rem; border:1px solid var(--line); border-radius:100px; padding:6px 13px; background:#fff; transition:background .15s,color .15s,border-color .15s}
.top .home:hover{color:var(--teal-ink); background:var(--bg2); border-color:#bfe6e0}
.top .home svg{width:16px;height:16px}
.shell{max-width:1200px; margin:0 auto; display:grid; grid-template-columns:220px 1fr; gap:34px; padding:0 22px}
nav.toc{position:sticky; top:64px; align-self:start; height:calc(100vh - 64px); overflow:auto; padding:26px 0 40px}
nav.toc .grp{font-family:'JetBrains Mono',monospace; font-size:.68rem; letter-spacing:.13em; text-transform:uppercase; color:var(--mut); margin:20px 0 7px}
nav.toc .grp:first-child{margin-top:0}
nav.toc a{display:block; padding:6px 12px; border-radius:9px; color:#46525c; font-size:.92rem; border-left:2px solid transparent}
nav.toc a:hover{background:var(--bg2); color:var(--ink)}
nav.toc a.on{color:var(--teal-ink); background:rgba(21,182,166,.09); border-left-color:var(--teal); font-weight:600}
main{padding:30px 0 90px; min-width:0}
.hero h1{font-size:clamp(1.9rem,4.4vw,2.6rem); letter-spacing:-.5px; font-weight:750}
.hero p{color:var(--mut); max-width:64ch; margin-top:10px; font-size:1.05rem}
.hero .meta{display:flex; gap:8px; flex-wrap:wrap; margin-top:16px}
.chip{display:inline-flex; align-items:center; gap:6px; font-size:.8rem; color:#3a444d; border:1px solid var(--line); border-radius:100px; padding:5px 11px; background:#fff}
.chip svg{width:14px;height:14px;color:var(--teal)}
section.doc{padding:36px 0; border-top:1px solid var(--line); scroll-margin-top:74px}
.doc h2{font-size:1.5rem; font-weight:730; letter-spacing:-.3px; display:flex; align-items:center; gap:10px}
.doc h2 .ico{width:30px;height:30px;border-radius:9px;display:grid;place-items:center;flex:none; background:rgba(21,182,166,.12);color:var(--teal)}
.doc h2 .ico svg{width:18px;height:18px}
.row{display:grid; grid-template-columns:minmax(0,1fr) 372px; gap:34px; align-items:start; margin-top:16px}
@media(max-width:980px){.row{grid-template-columns:1fr}}
.prose{color:#3c454d; min-width:0}
.prose p{margin:0 0 12px}
.prose h3{font-size:1.02rem; margin:18px 0 6px; color:var(--ink)}
.prose ul{margin:4px 0 12px; padding-left:18px}
.prose li{margin:4px 0}
.prose b{color:var(--ink)}
.prose code{font-family:'JetBrains Mono',monospace; font-size:.85em; background:var(--bg2); border:1px solid var(--line); border-radius:6px; padding:1px 6px}
.shot{position:sticky; top:74px}
.cap{text-align:center; font-family:'JetBrains Mono',monospace; font-size:.68rem; color:var(--mut); margin-top:10px; letter-spacing:.04em}
/* T-Deck frame: the real screenshot + a generated QWERTY keyboard (added in JS) */
.device{width:344px; margin:0 auto; background:#16191d; border-radius:16px; padding:12px 12px 9px; border:1px solid #e9ece7; box-shadow:0 24px 60px -30px rgba(21,24,30,.5)}
.device img.screen{display:block; width:320px; height:240px; border-radius:8px}
.kbd{margin:9px auto 2px; display:flex; flex-direction:column; gap:4px; width:312px}
.kr{display:flex; gap:4px; justify-content:center}
.key{flex:1; max-width:28px; height:15px; background:#23282e; border-radius:4px; display:grid; place-items:center; font:8px/1 system-ui; color:#7d8893}
.key.wide{max-width:140px; flex:4}
.gallery{display:grid; grid-template-columns:repeat(auto-fill,minmax(232px,1fr)); gap:20px; margin-top:18px}
.mini{background:#16191d; border-radius:12px; padding:7px; border:1px solid #e9ece7}
.mini img{display:block; width:100%; height:auto; border-radius:6px}
.mini .cap{margin-top:6px}
.webgrid{display:grid; grid-template-columns:repeat(auto-fill,minmax(178px,1fr)); gap:18px; margin-top:22px}
figure.phone{margin:0; background:#16191d; border-radius:20px; padding:8px; border:1px solid #e9ece7; box-shadow:0 18px 44px -26px rgba(21,24,30,.5)}
figure.phone img{display:block; width:100%; height:auto; border-radius:13px}
figure.phone figcaption{text-align:center; font-family:'JetBrains Mono',monospace; font-size:.66rem; color:var(--mut); margin-top:8px; letter-spacing:.04em}
.subh{font-size:1.06rem; font-weight:700; color:var(--ink); margin:30px 0 2px}
/* Settings-page detail cards: real screenshot + what the page does + its controls */
.sgrp{font-family:'JetBrains Mono',monospace; font-size:.7rem; letter-spacing:.14em; text-transform:uppercase; color:var(--mut); margin:30px 0 0; padding-top:4px}
.scards{display:grid; grid-template-columns:repeat(auto-fill,minmax(290px,1fr)); gap:20px; margin-top:13px}
.scard{border:1px solid var(--line); border-radius:14px; background:#fff; padding:13px; display:flex; flex-direction:column}
.scard .thumb{background:#16191d; border-radius:9px; padding:6px}
.scard .thumb img{display:block; width:100%; height:auto; border-radius:5px}
.scard .thumb.ph{aspect-ratio:4/3; display:grid; place-items:center; border:1px dashed #39424b; padding:0}
.scard .thumb.ph span{font-family:'JetBrains Mono',monospace; font-size:.6rem; letter-spacing:.12em; color:#7d8893; text-transform:uppercase}
.scard h3{font-size:1.04rem; margin:12px 0 2px; color:var(--ink); display:flex; align-items:center; gap:8px; flex-wrap:wrap}
.scard h3 .tag{font-family:'JetBrains Mono',monospace; font-size:.58rem; letter-spacing:.05em; text-transform:uppercase; color:var(--teal-ink); background:rgba(21,182,166,.1); border-radius:5px; padding:2px 6px; font-weight:600}
.scard .lede{color:var(--mut); font-size:.9rem; margin:2px 0 9px}
.scard ul{margin:0; padding-left:17px}
.scard li{margin:4px 0; font-size:.89rem; color:#3c454d; line-height:1.5}
.scard li b{color:var(--ink); font-weight:600}
/* Caution callout (amber) */
.callout{display:flex; gap:11px; margin-top:18px; padding:13px 15px; border-radius:11px; background:#fff7ed; border:1px solid #f6dcb8}
.callout .cico{flex:none; width:20px; height:20px; color:#c2740b; margin-top:1px}
.callout .cico svg{width:20px; height:20px}
.callout b{display:block; color:#7c4a03; margin-bottom:3px; font-size:.94rem}
.callout p{margin:0; color:#7a5320; font-size:.9rem; line-height:1.55}
footer{border-top:1px solid var(--line); padding:26px 22px; text-align:center; color:var(--mut); font-size:.85rem}
@media(max-width:980px){
.shell{grid-template-columns:1fr; gap:0}
nav.toc{position:static; height:auto; border-bottom:1px solid var(--line); padding:14px 0; display:flex; gap:6px; overflow:auto; white-space:nowrap}
nav.toc .grp{display:none} nav.toc a{border-left:0; flex:none}
.shot{position:static; margin:0 auto 6px}
}
.prose pre{background:var(--bg2);border:1px solid var(--line);border-radius:10px;padding:14px 16px;overflow-x:auto;margin:14px 0;font:13px/1.6 "JetBrains Mono",ui-monospace,Menlo,Consolas,monospace}
.prose pre code{background:none;padding:0;font-size:inherit}
.api{width:100%;border-collapse:collapse;margin:12px 0 18px;font-size:.92rem;display:block;overflow-x:auto}
.api th,.api td{text-align:left;padding:8px 10px;border-bottom:1px solid var(--line);vertical-align:top}
.api th{font-weight:700;white-space:nowrap}
.api td:first-child{white-space:nowrap;font-family:"JetBrains Mono",ui-monospace,Menlo,Consolas,monospace;font-size:.86rem;color:var(--teal-ink)}
.note{background:var(--bg2);border-left:3px solid var(--teal);border-radius:0 8px 8px 0;padding:12px 16px;margin:16px 0}
.note p{margin:0}
</style>
</head>
<body>
<div class="top">
<a class="word" href="index.html">WADA<span class="t">MESH</span></a>
<span class="tag">Lua SDK</span>
<span class="sp"></span>
<a class="home" href="index.html"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m12 19-7-7 7-7"/><path d="M19 12H5"/></svg> Back to site</a>
</div>
<div class="shell">
<nav class="toc" id="toc">
<div class="grp">Tanmatsu</div>
<a href="#model">How it works</a>
<a href="#setup">One-time setup</a>
<a href="#build">Build</a>
<a href="#install">Install</a>
<a href="#verify">Verify</a>
<a href="#trouble">When it goes wrong</a>
<div class="grp">Elsewhere</div>
<a href="docs.html">User guide</a>
<a href="sdk.html">Lua app SDK</a>
</nav>
<main>
<div class="hero">
<h1>Sideloading on the Tanmatsu</h1>
<p>For developers building wadamesh from source and running it on a Tanmatsu. If you just want to use it, install <b>WadaMesh</b> from the launcher's own app store instead.</p>
<div class="meta"><span class="chip">ESP32-P4</span> <span class="chip">ESP-IDF 5.5.1</span> <span class="chip">AppFS</span></div>
</div>
<section class="doc" id="model">
<h2>How it works, and the one way to break it</h2>
<div class="row"><div class="prose">
<p>wadamesh is <b>not</b> standalone firmware on this board. The Tanmatsu boots badge.team's launcher, and wadamesh runs as an <b>AppFS app</b> underneath it, exactly like the launcher's own apps.</p>
<p>So you never flash the whole device. You write <b>one app image and one metadata sector</b> into the AppFS partition, and leave the bootloader, the partition table and the OTA slots alone.</p>
<div class="note"><p><b>Never run <code>idf.py flash</code> on a Tanmatsu.</b> It writes the bootloader, partition table and OTA data, which replaces the launcher OS itself. Recovering means reflashing the launcher from badge.team. Everything on this page touches only the AppFS partition.</p></div>
<p>The board has three processors and only one of them is yours: the <b>ESP32-P4</b> runs the launcher and your app, an <b>ESP32-C6</b> handles Wi-Fi, Bluetooth and LoRa over esp-hosted, and a <b>CH32</b> runs the keyboard and power. You flash the P4.</p>
</div></div>
</section>
<section class="doc" id="setup">
<h2>One-time setup</h2>
<div class="row"><div class="prose">
<div class="note"><p><b>The tools live on <code>main</code>.</b> <code>tanmatsu/tools/</code> was added on 21 August 2026, shortly <i>after</i> <code>beta_67</code> was tagged, so a release tarball or a <code>stable</code> checkout does not contain it and neither does a clone made before that date. Get it with <code>git fetch origin &amp;&amp; git checkout main &amp;&amp; git pull</code>, or browse the four scripts directly at <a href="https://github.com/ALLFATHER-BV/wadamesh/tree/main/tanmatsu/tools">github.com/ALLFATHER-BV/wadamesh/tree/main/tanmatsu/tools</a>. They will be in the next beta as a matter of course.</p></div>
<p>You need ESP-IDF 5.5.1. The build uses a project-local copy in <code>tanmatsu/esp-idf</code> (gitignored, around 3&nbsp;GB); <code>make prepare</code> from <code>tanmatsu/</code> fetches it. Then two one-off steps:</p>
<pre><code>tanmatsu/tools/fetch-appfs.sh</code></pre>
<p>Clones badge.team's <code>esp32-component-appfs</code>, whose <code>appfs.py</code> reads and rewrites the AppFS image. It is deliberately not vendored into the wadamesh repo: it is their code, and the copy in circulation carries no licence header.</p>
<pre><code>tanmatsu/tools/dump-pristine.sh /dev/cu.usbmodemXXXX</code></pre>
<p>Dumps <b>your</b> device's AppFS partition as the baseline that later deploys diff against. Do it while the launcher's own apps are installed and wadamesh is not, so the baseline is the device as it ships.</p>
<div class="note"><p>Use your own dump, never someone else's. The deploy writes only the sectors that <i>differ</i> from this file, so a baseline describing a different device can overwrite apps you actually have.</p></div>
</div></div>
</section>
<section class="doc" id="build">
<h2>Build</h2>
<div class="row"><div class="prose">
<pre><code>cd tanmatsu &amp;&amp; ./build.sh build</code></pre>
<p>Use <code>build.sh</code> rather than <code>idf.py</code> directly: it applies two build-time patches to <code>managed_components</code> (esp-hosted Wi-Fi-init tolerance, and an Arduino BLE stub) that the build needs.</p>
<div class="note"><p><b>The build ends with an error, and that is expected.</b></p>
<pre><code>Generated .../application.bin
Error: All app partitions are too small</code></pre>
<p>The ~2.9&nbsp;MB app is being size-checked against the 2&nbsp;MB <code>ota_0</code> slot, but it does not live there: it lives in the 8&nbsp;MB <code>appfs</code> partition. Judge success by <code>Generated ... application.bin</code> appearing with no <code>error:</code> or <code>undefined reference</code> above it.</p></div>
</div></div>
</section>
<section class="doc" id="install">
<h2>Install</h2>
<div class="row"><div class="prose">
<pre><code>tanmatsu/tools/tan_flash.sh</code></pre>
<p>With no port argument it probes each USB serial device and picks the one answering as an ESP32-P4. The Tanmatsu exposes <b>two</b> ports, the P4 and the C6, and which name each gets changes between replugs, so detecting by chip beats guessing. Pass a port explicitly if you prefer.</p>
<table class="api">
<tr><th>Step</th><th>Why in this order</th></tr>
<tr><td>Build the image, diff against the baseline</td><td>Only the 64&nbsp;KB sectors that actually changed get written.</td></tr>
<tr><td>Write the <b>app data</b>, no reset</td><td>The bytes land before anything points at them.</td></tr>
<tr><td>Write the <b>metadata sector</b>, then reset</td><td>That sector is what makes the app visible to the launcher. Writing it last means an interrupted flash leaves the previous app intact rather than a half-written one marked complete.</td></tr>
<tr><td>Read the metadata back and compare</td><td>The commit write is the one that can silently drop. Without this check the launcher would quietly keep showing the old build.</td></tr>
</table>
<p>Expect <code>metadata verified — installed</code>. Anything else means run it again.</p>
<p>The version number increments on every deploy, and it has to: the launcher keys updates on name plus version, so republishing the same number leaves you on the old binary with no error anywhere.</p>
</div></div>
</section>
<section class="doc" id="verify">
<h2>Verify</h2>
<div class="row"><div class="prose">
<p>Launch <b>WadaMesh</b> from the launcher menu. An empty serial log straight after flashing is normal: the launcher is a silent GUI OS on USB-CDC, and the boot log only appears once the app itself starts.</p>
<pre><code>python3 tanmatsu/tools/sermon.py /dev/cu.usbmodemXXXX /tmp/wada.log</code></pre>
<div class="note"><p><b>Do not attach a monitor that asserts DTR/RTS.</b> Resetting the P4 also desynchronises the C6, which has no reset line of its own, and the launcher then hangs on "Initializing radio" until the board is fully power-cycled. <code>sermon.py</code> does not reset, and survives the USB re-enumeration when an app launches.</p></div>
</div></div>
</section>
<section class="doc" id="trouble">
<h2>When it goes wrong</h2>
<div class="row"><div class="prose">
<table class="api">
<tr><th>Symptom</th><th>Cause and fix</th></tr>
<tr><td>No serial ports at all</td><td>The C6 has wedged. <b>Fully power-cycle</b> the board, off then on. A reset is not enough.</td></tr>
<tr><td>"No ESP32-P4 found", ports present</td><td>Something else is holding the port, usually a monitor still running. Close it and retry.</td></tr>
<tr><td>"metadata DIFFERS"</td><td>The commit write did not land. Re-run. Nothing is broken; the device still has the previous app.</td></tr>
<tr><td>Launcher stuck on "Initializing radio"</td><td>esp-hosted desync, almost always from a resetting monitor. Full power cycle.</td></tr>
<tr><td>A crash to decode</td><td><code>riscv32-esp-elf-addr2line -e tanmatsu/build/tanmatsu/application.elf -fpC &lt;MEPC&gt; &lt;RA&gt; &lt;stack RAs&gt;</code></td></tr>
</table>
<p><b>Before you ship anything:</b> the Tanmatsu shares one <code>src/</code> with the ESP32-S3 boards, so a change here can break them without you noticing. Build at least the T-Deck and Heltec V4 environments as well.</p>
<p>Full text of this guide, kept with the code: <code>TANMATSU_SIDELOAD.md</code> in the repo. Port status and outstanding work: <code>TANMATSU_PORT.md</code>.</p>
</div></div>
</section>
</main>
</div>
<script>
// Highlight the section currently in view, same behaviour as the docs page.
(function () {
var links = [].slice.call(document.querySelectorAll('#toc a'));
var secs = links.map(function (a) { return document.querySelector(a.getAttribute('href')); });
function sync() {
var y = window.scrollY + 120, cur = 0;
for (var i = 0; i < secs.length; i++) if (secs[i] && secs[i].offsetTop <= y) cur = i;
links.forEach(function (a, i) { a.classList.toggle('on', i === cur); });
}
window.addEventListener('scroll', sync, { passive: true });
sync();
})();
</script>
</body>
</html>
+1 -1
View File
@@ -27,4 +27,4 @@ More of the interface is translatable: the Discovered and Contacts sort menus, a
# --- Community ------------------------------------------------------------------------------
Pager keyboard shortcuts, portrait layout fixes, a theme-matching Wi-Fi icon and Heltec UI tweaks (oumike, PR #263).
Thanks to Yoss101 and pisti87, whose reports drove the contact-store work - particularly Yoss101 for coming back to say a fix had only half worked, which is the report that found the rest of it. Thanks to pisti87 also for the character-rendering reports, and to istvan for finding the last text field with boxes in it.
Thanks to Yoss101 and pisti87, whose reports drove the contact-store work - particularly Yoss101 for coming back to say a fix had only half worked, which is the report that found the rest of it. Thanks to pisti87 also for the character-rendering reports, including finding the last text field with boxes in it.
+1 -1
View File
@@ -37,4 +37,4 @@ More of the interface is translatable: the Discovered and Contacts sort menus, a
# --- Community ---------------------------------------------------------------------------------
Pager keyboard shortcuts, portrait layout fixes, a theme-matching Wi-Fi icon and Heltec UI tweaks (oumike, PR #263).
Thanks to Yoss101 and pisti87, whose reports drove the contact-store work, and particularly to Yoss101 for coming back to say a first fix had only half worked, which is the report that found the rest of it. Thanks to pisti87 for the character-rendering reports and the 2048 app, to istvan for finding the last text field with boxes in it, and to janmashat for the position-sharing request.
Thanks to Yoss101 and pisti87, whose reports drove the contact-store work, and particularly to Yoss101 for coming back to say a first fix had only half worked, which is the report that found the rest of it. Thanks to pisti87 for the character-rendering reports, including finding the last text field with boxes in it, and for the 2048 app, and to janmashat for the position-sharing request.
+27
View File
@@ -0,0 +1,27 @@
# beta_67 — test build. Stable stays beta_65.
# One user-facing note per non-blank, non-# line; # lines are section comments.
# --- Lua apps: PLEASE TEST THIS ONE ---------------------------------------------------------
Apps can now hold a whole conversation, not just post to a channel. New: send a direct message to a contact, post to a room server, list the channels configured on the device, and receive incoming messages tagged as channel, dm or room. This is the largest change to the app SDK so far and it is the part of this build most in need of real testing, especially by anyone writing an app.
Permissions went from two to four, split the way the risk actually splits: post to channels as me, read channel messages, send private messages as me, read private messages. Posting to a channel you are already in is not the same act as writing to one person as you, and channel traffic is not your private conversations, so each is granted separately. Settings > App permissions lists all four per app and takes any of them back.
Fixed along the way: granting one permission used to silently revoke another, because the grant wrote the whole permission set rather than the single switch. With one permission that never showed; with four it would have been constant.
Also fixed: apps could only ever be handed channel messages. The delivery hook ran before the sender and message body were worked out, so direct messages and room posts never reached an app at all. It now runs after the block and spam filters too, so an app sees exactly the messages you see.
New wada.crypto: SHA-1, SHA-256 and both HMACs, plus hex. Doing a HMAC in pure Lua does not fit inside the per-callback instruction budget, which is what an app author hit while writing a one-time-password app. The chip already carries the crypto library, so the same work is now microseconds instead of impossible. Available on every board, including the ones that do not get the rest of the extended toolkit, because it only computes over data the app already has.
The SDK documentation at wadamesh.com/sdk.html covers all of the above, including why the permissions are split four ways and why crypto is not board-gated.
# --- T-Display P4 --------------------------------------------------------------------------
The battery percentage no longer flickers between 0 and the real value. The fuel gauge shares its wiring with several other chips, so an occasional lost reading is normal; the code was treating a failed read as a genuine 0%. It now keeps the last good figure, exactly as the voltage reading always did.
# --- ThinkNode M9 and Heltec V4-R8 -----------------------------------------------------------
A large audit pass on both boards, contributed by cvhviz (PR #287) and validated on hardware. M9: navigation soft-locks fixed, including a hardware Back key that had stopped working and a permission dialog that could not be answered on a board with no touchscreen; "Store data on SD" now actually honoured, with chat history and the map tile cache following it; deep sleep really drops the power rails; and the keyboard, terminal and update paths gained the features that had been quietly limited to the T-Deck.
Spectrum on the M9 now runs to the radio chip's specification: roughly twice as fast per sweep, correct frequency limits, and opening it mid-transmission waits rather than cutting the packet off the air.
V4-R8: chat history and the battery log follow the SD card properly, a reboot trap in the Orientation setting is defused, and a crash when Wi-Fi associated with Bluetooth already on is fixed.
# --- Fixes ------------------------------------------------------------------------------------
Map tile packs placed in MAPS/ on the card now load. The device creates that folder and the card's own readme names it, but the tile loader only ever looked for the lower-case spelling, so following our instructions exactly could leave the map blank. Reported via pisti87 on behalf of w7aaf.
"Save update bin to SD" now appears on every board with a card, rather than a hand-maintained list of three that left the T-Lora Pager out. Reported by oumike.
Every screen is fully translated again: forty strings added across recent releases had never reached the language files.
The supported-device list finally matches reality. The T-Lora Pager, Heltec V4-R8, T-Display P4 and Attaky Mesh Series all ship and none of them were listed; the Pager was still filed under boards people had requested.
# --- Community ----------------------------------------------------------------------------------
The M9 and V4-R8 audit passes are cvhviz's work (PR #287). Hungarian translations by pisti87. Thanks to wb6zsu, who measured the P4 transmit-power fix on a test set and confirmed it, and to Tiamat and Jade, whose one-time-password app is the reason wada.crypto exists.
+33
View File
@@ -0,0 +1,33 @@
# beta_68 - test build. Stable stays beta_65.
# One user-facing note per non-blank, non-# line; # lines are section comments.
# --- Apps can now do the things the firmware does ------------------------------------------
The Lua SDK gained the four capabilities that were holding third-party apps back, so an app can now be as good as a built-in one rather than a sketch of one.
Apps can put the real map on their page. Not a drawing of a map: the same OpenStreetMap tiles, the same projection and the same cached tiles the Map tab uses, with markers and lines placed by latitude and longitude, taps turned back into coordinates, and a tile count so an app can say "nothing cached here" instead of showing an empty rectangle that looks like open water.
Apps got a scrollable list. There was no "pick one of N" widget, so every app that needed one drew rows by hand and tracked scrolling itself, which is why app screens looked worse than the firmware's own. Rows are real buttons, so keyboard and trackball navigation walks them on boards without a touchscreen.
Apps can be told about each packet as it arrives instead of polling for it. Polling sampled a 16-deep ring on a timer, so in real traffic an app saw a subset and could not count anything. Every frame is now delivered once, in order.
Apps can ask the mesh who is out there. A discovery probe is a zero-hop request that every node in earshot answers, and a reply proves the link works from where you are standing, which listening can never establish. It has its own permission, separate from sending messages, because it costs airtime on every neighbour's radio and not only yours.
Each discovery reply carries both directions of the link: how well you heard them, and how well they heard you. Those are rarely equal, and "I can hear the repeater but it cannot hear me" is a different fact from "no coverage".
Packets an app is shown now say who sent them, as far as the frame honestly can. An advert carries a real public key and is reported as one; addressed frames carry only one-byte hashes and are reported as those; anything else stays anonymous, because it is. Nothing is guessed.
Text entry, at last. An app can ask for a line of text using the firmware's own dialog and keyboard, so it behaves the same on a touchscreen, on a physical keyboard and on a trackball.
Distance and bearing between two points, computed in C. A dozen trigonometry calls per contact per tick is a real slice of an app's instruction budget, and now costs almost none of it.
Environment sensors are available to apps on the boards that have them. That is a hardware question, so it is now asked as one, rather than being bundled with the memory limit that gates the rest of the extended SDK.
Apps can set more than one timer, including one-shot ones. Previously an app had one clock and anything with a second cadence had to be a counter inside it.
Apps can upload as well as download. A survey log, a sensor series or a webhook can now leave the device.
Coordinates handed to apps come with exact integers alongside the decimals. The scripting language uses 32-bit floats, which is fine to display and lossy to store, so anything writing a track was quietly losing about a metre. GPS altitude and satellite time are available too.
Two new apps in the Store show all of it working. Wardrive is a coverage survey that probes on a cadence and logs each reply with position, altitude and both link directions to a CSV. Nearby puts your contacts on a real map sorted by distance, with bearing, and counts frames live.
The SDK page at wadamesh.com/sdk.html documents every one of these, including the instruction budget and what it costs.
# --- Fixes -------------------------------------------------------------------------------------
The map zoom level was invisible if you used the +/- buttons instead of the slider. The number only ever lived above the slider and was hidden along with it, so buttons mode had no zoom indicator at all. Tapping + or - now shows the level briefly beside the buttons.
Hungarian and Dutch had each other's translation of "No SD card": Hungarian users saw Dutch and Dutch users saw Hungarian. Both are back in the right file, and all thirteen languages were checked for the same problem.
The app and language Store is published from a script for the first time, so translations and new apps actually reach devices instead of sitting in the project.
Two boards could not be built at all. A change merged for the Heltec and ThinkNode audit put a variable somewhere the ESP32-P4 builds never compile, so both the Tanmatsu and the T-Display P4 failed to link while every other board looked fine.
# --- For testers -------------------------------------------------------------------------------
This drop is almost entirely new SDK surface and has had far less time on real hardware than usual. If you write apps, please exercise the new calls and report anything that misbehaves.
The map inside an app holds up to half a megabyte of tiles while it is open, so it is limited to boards with the memory for it and to one map view per app. Watch for anything that looks like memory pressure and say so.
# --- Community -----------------------------------------------------------------------------------
Thanks to pisti87 for the Hungarian translation work covering the strings added over the last few releases, and for the report that found the missing map zoom indicator.
Thanks to cvhviz for the ThinkNode M9 and Heltec V4-R8 audit passes that landed in beta_67.
+45 -1
View File
@@ -44,7 +44,29 @@ def source_keys():
k = k.lstrip(' ')
if k.strip():
keys.add(k)
keys |= {"Apps", "Built-in", "Languages"} # TR(kStoreTabs[t])
# INDIRECT keys: TR(kSomeTable[i]) / TR(tbl[i].field). The regex above only sees
# TR("literal"), so every string reached through a table looked unused — including
# "About", "Backups" and every other settings-category name. Anyone pruning the
# language files on that output would have deleted live translations, which is
# exactly what a translator asked about before doing it (#262).
#
# So: find the table names that appear inside a TR(...) subscript, then pull every
# string literal out of each table's initialiser. Deliberately greedy — over-
# 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()
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
while i < len(src) and depth:
if src[i] == '{': depth += 1
elif src[i] == '}': depth -= 1
i += 1
for lit in re.findall(r'"((?:[^"\\]|\\.)*)"', src[m.end():i]):
k = unescape_c(lit)
if k.strip():
keys.add(k)
return keys
# Known untranslatable / identical-everywhere keys.
@@ -63,6 +85,28 @@ def lang_keys(path):
def main():
keys = source_keys() - SKIP
# --obsolete: the REVERSE check. Rows in the .lang files that no TR() key matches
# any more, i.e. translation work that is being carried for strings the firmware
# no longer shows. Reported, never deleted automatically.
#
# Read the caveat before acting on it: this is static analysis. A string reached in
# a way the extractor cannot see reads as unreferenced, and pruning it silently
# deletes a working translation. That is not hypothetical -- before the indirect-
# table support above, "About" and every other settings-category name appeared in
# this list. Treat the output as candidates to CHECK, not a delete list.
if "--obsolete" in sys.argv:
for path in sorted(glob.glob(os.path.join(ROOT, 'deploy/apps/lang/*.lang'))):
have = lang_keys(path)
dead = sorted(k for k in have if k not in keys)
tag = os.path.basename(path)
print(f"{tag}: {len(have)} rows, {len(dead)} unreferenced")
for k in dead:
print(f" {k!r}")
print("\nCandidates only. Grep the source for one before removing it: a string "
"reached indirectly can look unreferenced while being on screen.")
return 0
bad = 0
for path in sorted(glob.glob(os.path.join(ROOT, 'deploy/apps/lang/*.lang'))):
missing = sorted(k for k in keys if k not in lang_keys(path))
+27 -2
View File
@@ -2186,11 +2186,36 @@ void MyMesh::logRxRaw(float snr, float rssi, const uint8_t raw[], int len) {
// with 4 transport-code bytes following iff route is a TRANSPORT_* one; the
// next byte's low 6 bits are the path length. hops == 0 = heard DIRECTLY.
uint8_t rt = 0, hops = 0;
// What the frame says about WHO sent it. Only an advert carries a real
// identity (its payload opens with the sender's 32-byte public key); the
// addressed types open with a one-byte destination hash then a one-byte
// source hash. Anything else is anonymous on the wire and stays that way
// here -- see UiRxRec::org_kind.
uint8_t org_kind = 0, org[4] = {0, 0, 0, 0};
if (len > 0) {
rt = raw[0] & 0x03;
const bool xp = (rt == ROUTE_TYPE_TRANSPORT_FLOOD || rt == ROUTE_TYPE_TRANSPORT_DIRECT);
const int ps = 1 + (xp ? 4 : 0);
hops = (ps < len) ? (uint8_t)(raw[ps] & 0x3F) : 0;
// The path-length byte packs BOTH counts: low 6 bits = number of hops, top
// 2 bits = bytes per hop hash minus one (Packet::getPathHashSize). So the
// path occupies count * size bytes on the wire, not count -- assuming one
// byte each reads the payload at the wrong offset the moment a mesh uses
// wider hashes, and a mis-offset "public key" is worse than none.
const uint8_t plb = (ps < len) ? raw[ps] : 0;
hops = (uint8_t)(plb & 0x3F);
const int payload = ps + 1 + (int)hops * (int)(((plb >> 6) & 0x03) + 1);
switch ((raw[0] >> 2) & 0x0F) {
case PAYLOAD_TYPE_ADVERT:
if (payload + 32 <= len) { org_kind = 1; memcpy(org, &raw[payload], 4); }
break;
case PAYLOAD_TYPE_REQ:
case PAYLOAD_TYPE_RESPONSE:
case PAYLOAD_TYPE_TXT_MSG:
case PAYLOAD_TYPE_PATH:
if (payload + 2 <= len) { org_kind = 2; org[0] = raw[payload]; org[1] = raw[payload + 1]; }
break;
default: break;
}
}
// Live signal for the top-bar icon: ONLY from packets heard DIRECTLY (0-hop), so
// the reading reflects a real direct-neighbour RF link, not the SNR of a repeater
@@ -2206,7 +2231,7 @@ void MyMesh::logRxRaw(float snr, float rssi, const uint8_t raw[], int len) {
if (len > 0) {
uiRxLogPush(now_ms, (int8_t)rssi, snr_q4,
(uint8_t)((raw[0] >> 2) & 0x0F), rt, hops,
(uint8_t)(len > 255 ? 255 : len));
(uint8_t)(len > 255 ? 255 : len), org_kind, org);
}
#if defined(DISPLAY_CLASS)
// Diagnostic: log EVERY received frame so we can prove what reaches the
+14 -1
View File
@@ -571,6 +571,16 @@ public:
uint8_t route; // route type raw[0]&0x03
uint8_t hops; // path length carried (0 = heard direct from origin)
uint8_t len; // frame length (clamped to 255)
// Who sent it, as far as the frame actually says. MeshCore only carries a
// full identity on an ADVERT (the payload opens with the 32-byte public
// key); the addressed types carry one-byte destination/source hashes, and
// the rest carry nothing at all. Recorded honestly rather than guessed, so
// a caller can tell "node X was here" from "something was here".
// org_kind 0 = nothing identifying in this frame
// 1 = org[0..3] is the first 4 bytes of the origin's public key
// 2 = org[0] is the destination hash, org[1] the source hash
uint8_t org_kind;
uint8_t org[4];
};
static const int UI_RXLOG_MAX = 16;
UiRxRec _ui_rxlog[UI_RXLOG_MAX];
@@ -585,10 +595,13 @@ public:
}
// Record a reception into the ring (called from logRxRaw).
void uiRxLogPush(uint32_t ms, int8_t rssi, int8_t snr_q4,
uint8_t ptype, uint8_t route, uint8_t hops, uint8_t len) {
uint8_t ptype, uint8_t route, uint8_t hops, uint8_t len,
uint8_t org_kind = 0, const uint8_t* org = nullptr) {
UiRxRec& r = _ui_rxlog[_ui_rxlog_head];
r.ms = ms; r.rssi = rssi; r.snr_q4 = snr_q4;
r.ptype = ptype; r.route = route; r.hops = hops; r.len = len;
r.org_kind = org ? org_kind : 0;
if (r.org_kind) memcpy(r.org, org, 4); else memset(r.org, 0, 4);
_ui_rxlog_head = (uint8_t)((_ui_rxlog_head + 1) % UI_RXLOG_MAX);
if (_ui_rxlog_cnt < UI_RXLOG_MAX) _ui_rxlog_cnt++;
}
+6 -1
View File
@@ -253,7 +253,12 @@ void halt() {
// failure and AP-side rejection alike — the reason number tells them apart
// (15 = 4-way handshake timeout ≈ wrong password; 2/202 = auth expired/failed
// ≈ WPA3-only or auth mismatch; 201 = AP not found).
volatile uint8_t g_wifi_last_disc_reason = 0;
//
// DEFINED in ui-touch/UITask.cpp, not here. The ESP32-P4 targets (Tanmatsu,
// T-Display P4) are IDF builds with their own main.cpp and never compile this
// file, so a definition here links on the S3 boards and leaves both P4 targets
// with an undefined reference from the UI that reads it.
extern volatile uint8_t g_wifi_last_disc_reason;
#endif
#include "esp_task_wdt.h" // task-watchdog reconfigure — see setup() (GH #56)
File diff suppressed because it is too large Load Diff
+12 -4
View File
@@ -24,10 +24,18 @@ void luaAppPress(); // synthetic centre tap (down+up) -> on_in
bool luaAppKey(int key); // hardware key -> on_input; true = app consumed it
bool luaAppHasOnInput(); // app declares on_input? (touchless boards: app keys vs native d-pad)
bool luaAppScroll(bool up); // page-scroll the app body (display-only apps on touchless boards)
void luaAppMessage(const char* channel, const char* sender, const char* text); // -> on_message
// kind: "channel" | "dm" | "room" — what arrived, so an app can tell them apart.
void luaAppMessage(const char* kind, const char* channel, const char* sender, const char* text); // -> on_message
// Per-app permission bits, stored as a decimal mask in /apps/perms.kv.
#define LUA_PERM_SEND 1
#define LUA_PERM_READ 2
// Per-app grants, stored as a bitmask in /apps/perms.kv. Channels and private
// conversations are deliberately separate grants: posting to #public is not the
// same act as reading somebody's direct messages, and an app that legitimately
// needs one usually has no business with the other.
#define LUA_PERM_SEND 1 // post to a channel (public or private) the user has
#define LUA_PERM_READ 2 // see incoming channel traffic
#define LUA_PERM_DM_SEND 4 // send a direct message, or post to a room server, AS the user
#define LUA_PERM_DM_READ 8 // see incoming direct messages and room posts
#define LUA_PERM_PROBE 16 // transmit discovery probes (wada.mesh.discover)
#else
inline bool luaAppIsOpen() { return false; }
inline void luaAppDismiss() {}
@@ -36,5 +44,5 @@ inline void luaAppPress() {}
inline bool luaAppKey(int) { return false; }
inline bool luaAppHasOnInput() { return false; }
inline bool luaAppScroll(bool) { return false; }
inline void luaAppMessage(const char*, const char*, const char*) {}
inline void luaAppMessage(const char*, const char*, const char*, const char*) {}
#endif
+702 -51
View File
@@ -5373,7 +5373,7 @@ static void otaButtonRefreshState() {
}
}
#if (defined(HAS_TDECK_GT911) || defined(HAS_THINKNODE_M9) || defined(HELTEC_LORA_V4_R8)) && defined(ESP32) && defined(MULTI_TRANSPORT_COMPANION) && CAP_OTA
#if CAP_SD && defined(ESP32) && defined(MULTI_TRANSPORT_COMPANION) && CAP_OTA
// ---- Save-update-to-SD (Launcher installs) ---------------------------------
// Launcher-managed T-Decks have no spare A/B slot (touchHasOtaUpdateSlot() is
// false), so Wi-Fi OTA can't work there — but the Launcher itself can flash an
@@ -12637,7 +12637,7 @@ static void buildDeviceSettings(int sec) {
y += settingsRowLabel(body, y, 0, TR("UI size (restart to apply)"), COLOR_SUB, &g_font_12, 0) + 2;
lv_obj_t* dd = lv_dropdown_create(body);
#if defined(TLORA_PAGER)
lv_dropdown_set_options(dd, "Small\nMedium\nLarge\nJumbo");
lv_dropdown_set_options(dd, TR("Small\nMedium\nLarge\nJumbo"));
#else
lv_dropdown_set_options(dd, TR("Normal (100%)\nLarge (150%)\nHuge (200%)"));
#endif
@@ -18244,7 +18244,7 @@ static void batteryEstimateText(const uint32_t* eps, const uint16_t* mvs, int n,
const double x = (double)(eps[i] - t0), y = (double)mvs[i];
sx += x; sy += y; sxx += x * x; sxy += x * y; ++m; last_v = mvs[i]; last_t = eps[i];
}
if (m < 3 || (last_t - t0) < 900) { snprintf(out, cap, "Battery life: gathering data\xe2\x80\xa6"); return; }
if (m < 3 || (last_t - t0) < 900) { snprintf(out, cap, "%s", TR("Battery life: gathering data\xe2\x80\xa6")); return; }
const double denom = (double)m * sxx - sx * sx;
if (denom <= 0) { snprintf(out, cap, "Battery life: \xe2\x80\x94"); return; }
const double slope = ((double)m * sxy - sx * sy) / denom; // mV/s (negative = discharging)
@@ -20192,12 +20192,20 @@ static bool fmRmRecursive(fs::FS* fs, const char* path) {
}
// ----- text-input modal (rename / new folder) -----
// A Lua app driving this prompt (wada.ui.input) must hear about EVERY way it
// closes, or it sits waiting on a callback that will never arrive. Set while an
// OK is being delivered so the close below does not also report a cancel.
void luaHostTextPromptCancel();
static bool s_fm_prompt_ok = false;
static void fmPromptClose() {
if (s_fm_prompt) {
hideKb();
popupClose(&s_fm_prompt);
s_fm_prompt_ta = nullptr;
}
#if CAP_LUA_APPS
if (!s_fm_prompt_ok) luaHostTextPromptCancel();
#endif
}
static void fmPromptOkCb(lv_event_t* e) {
if (lv_event_get_code(e) != LV_EVENT_CLICKED) return;
@@ -20207,7 +20215,9 @@ static void fmPromptOkCb(lv_event_t* e) {
const char* t = lv_textarea_get_text(s_fm_prompt_ta);
snprintf(buf, sizeof buf, "%s", t ? t : "");
void (*cb)(const char*) = s_fm_prompt_cb;
s_fm_prompt_ok = (cb && buf[0]); // empty text is a cancel, not a delivery
fmPromptClose();
s_fm_prompt_ok = false;
if (cb && buf[0]) cb(buf);
}
static void fmPromptCancelCb(lv_event_t* e) {
@@ -25830,15 +25840,26 @@ static bool luaJsonField(const char* buf, const char* key, char* out, size_t cap
return o > 0;
}
// GET url into a PSRAM buffer (cap'd). Returns bytes read, -1 on failure.
int luaStoreHttpGet(WiFiClient& client, HTTPClient& http, const char* url,
char* buf, size_t cap) { // shared with LuaAppHost wada.net
// Fetch url into a PSRAM buffer (cap'd). Returns bytes of response read, -1 on
// failure. `body` non-null makes it a POST with that payload; null is a GET.
int luaStoreHttpReq(WiFiClient& client, HTTPClient& http, const char* url,
char* buf, size_t cap,
const uint8_t* body, size_t body_len, const char* ctype) {
http.setReuse(false);
http.setConnectTimeout(8000);
http.setTimeout(12000);
http.setUserAgent("wadamesh-touch");
if (!http.begin(client, url)) return -1;
if (http.GET() != 200) { http.end(); return -1; }
int code;
if (body) {
http.addHeader("Content-Type", (ctype && *ctype) ? ctype : "application/octet-stream");
code = http.POST((uint8_t*)body, body_len);
} else {
code = http.GET();
}
// Any 2xx counts: a POST that a server answers 201 or 204 succeeded, and
// demanding exactly 200 would fail every correctly-built upload endpoint.
if (code < 200 || code > 299) { http.end(); return -1; }
// Read by Content-Length. The old loop trusted connected() to mean "stream
// done" and could quit after the first TCP window (~16 KB) with the rest
// still in flight — a 49 KB language file came back one-third read. Now the
@@ -25869,6 +25890,11 @@ int luaStoreHttpGet(WiFiClient& client, HTTPClient& http, const char* url,
return (int)total;
}
int luaStoreHttpGet(WiFiClient& client, HTTPClient& http, const char* url,
char* buf, size_t cap) {
return luaStoreHttpReq(client, http, url, buf, cap, nullptr, 0, nullptr);
}
// Opaque bridge for LuaAppHost.cpp — see the long note at the top of that file. It
// must not name the client type (WiFiClient is a real class here, an alias of
// NetworkClient on the Tanmatsu, and a #define to C6Client on the T-Display P4), so
@@ -25877,6 +25903,11 @@ int luaStoreHttpGetOpaque(void* client, void* http, const char* url, char* buf,
return luaStoreHttpGet(*static_cast<WiFiClient*>(client), *static_cast<HTTPClient*>(http),
url, buf, cap);
}
int luaStoreHttpPostOpaque(void* client, void* http, const char* url, char* buf, size_t cap,
const void* body, size_t body_len, const char* ctype) {
return luaStoreHttpReq(*static_cast<WiFiClient*>(client), *static_cast<HTTPClient*>(http),
url, buf, cap, (const uint8_t*)body, body_len, ctype);
}
static void luaStoreFetchCatalogWorker(WiFiClient& client, HTTPClient& http) {
if (!s_luacat_buf)
@@ -27140,11 +27171,25 @@ static bool loadTileJpeg(uint8_t z, int32_t x, int32_t y,
// Prefer the Meshtastic/MeshCore standard layout /maps/osm/{z}/{x}/{y}.png
// (decoded via lodepng); fall back to the legacy /tiles/{z}/{x}/{y}.jpg.
char ppath[56];
snprintf(ppath, sizeof(ppath), "/maps/osm/%u/%ld/%ld.png", (unsigned)z, (long)x, (long)y);
File fsd = SD.open(ppath, FILE_READ);
if (!fsd) { // some tile packs name the extension upper-case (.PNG)
snprintf(ppath, sizeof(ppath), "/maps/osm/%u/%ld/%ld.PNG", (unsigned)z, (long)x, (long)y);
fsd = SD.open(ppath, FILE_READ);
// Directory case matters here even though FAT is nominally case-insensitive: what
// resolves depends on the card's long-filename entries and the FATFS build, and we
// were only ever asking for the lower-case spelling. Meanwhile our OWN card
// bootstrap creates "/MAPS" and the README we write onto the card says "MAPS/", so
// a user who followed our instructions to the letter could end up with a directory
// the loader never looked in. Reported by w7aaf via pisti87 (#286), who could not
// reproduce it on his own card — which is exactly the shape of a case-resolution
// difference rather than a wrong path.
//
// Ask for both spellings, upper first since that is the one we create.
File fsd;
static const char* const kMapRoots[2] = { "/MAPS", "/maps" };
static const char* const kPngExt[2] = { "png", "PNG" };
for (int r = 0; r < 2 && !fsd; ++r) {
for (int e = 0; e < 2 && !fsd; ++e) {
snprintf(ppath, sizeof(ppath), "%s/osm/%u/%ld/%ld.%s",
kMapRoots[r], (unsigned)z, (long)x, (long)y, kPngExt[e]);
fsd = SD.open(ppath, FILE_READ);
}
}
// Plain /tiles/<z>/<x>/<y>.{png,PNG} — a PNG pack dropped straight into /tiles/
// (OSM's native format), not the /maps/osm layout. Decoded via lodepng like the
@@ -27352,10 +27397,15 @@ static bool tileExistsAt(uint8_t z, long x, long y) {
if (s_sd_fail_note_ms) return false; // card suspected dead — skip the five per-tile probes (sdHealthTick arbitrates)
if (!s_sd_mounted) return false; // never ladder from the zoom guard (see loadTileJpeg)
char p[56];
snprintf(p, sizeof p, "/maps/osm/%u/%ld/%ld.png", (unsigned)z, x, y);
if (SD.exists(p)) return true;
snprintf(p, sizeof p, "/maps/osm/%u/%ld/%ld.PNG", (unsigned)z, x, y); // upper-case packs
if (SD.exists(p)) return true;
// Both directory spellings, upper first: our own card bootstrap creates "/MAPS"
// while this only ever probed "/maps" (#286). Must stay in step with the loader in
// tileFromSd(), or zoom levels would advertise as reachable and then not draw.
for (const char* root : { "/MAPS", "/maps" }) {
for (const char* ext : { "png", "PNG" }) {
snprintf(p, sizeof p, "%s/osm/%u/%ld/%ld.%s", root, (unsigned)z, x, y, ext);
if (SD.exists(p)) return true;
}
}
snprintf(p, sizeof p, "/tiles/%u/%ld/%ld.png", (unsigned)z, x, y); // PNG pack in /tiles/
if (SD.exists(p)) return true;
snprintf(p, sizeof p, "/tiles/%u/%ld/%ld.PNG", (unsigned)z, x, y);
@@ -27800,6 +27850,286 @@ struct MapMarker {
// (reported as "new contacts never appear on the map, but the number is right").
// Markers are only built for contacts INSIDE the viewport, so this is how many are
// visible at once, not the contact count. Costs 8 bytes of static RAM per slot.
#if CAP_LUA_SDK_EXT
// ---------------------------------------------------------------------------
// App map views (wada.map) -- a real slippy map an app can put on its page.
//
// Apps could previously draw geography only onto their own canvas, from
// scratch, with no basemap. This gives them the firmware's actual tiles,
// projection and cache.
//
// It does NOT share the map tab's tile pool. That pool is sized and culled for
// a full-screen viewport and is rebuilt on pan/zoom; borrowing slots from it
// would mean an app and the map tab evicting each other's tiles at whatever
// rate they happened to redraw. A view owns a small pool of its own instead,
// capped at kAppMapTilesMax so the cost is bounded and predictable: 128 KB per
// tile, exactly like the map tab, and freed the moment the view is collected.
static const int kAppMapTilesMax = 4;
static const size_t kAppMapTileBytes = 256u * 256u * sizeof(lv_color_t);
struct AppMapTile {
uint8_t z; int32_t x, y;
uint8_t* rgb = nullptr;
int w = 0, h = 0;
lv_img_dsc_t dsc;
lv_obj_t* img = nullptr;
bool in_use = false;
};
struct AppMapView {
lv_obj_t* cont = nullptr; // clips to the app's requested rectangle
lv_obj_t* tiles = nullptr; // tile images
lv_obj_t* ovl = nullptr; // markers + lines, always above the tiles
int w = 0, h = 0;
double lat = 0, lon = 0;
uint8_t zoom = 13;
int placed = 0; // tiles actually drawn at the last render
AppMapTile pool[kAppMapTilesMax];
};
static void appMapFreeTile(AppMapTile& t) {
if (t.img) { lv_obj_del(t.img); t.img = nullptr; }
if (t.rgb) { lvglPsramFree(t.rgb); t.rgb = nullptr; }
t.in_use = false;
}
void* luaHostMapCreate(int x, int y, int w, int h) {
if (!g_lv.task) return nullptr;
AppMapView* v = new (std::nothrow) AppMapView();
if (!v) return nullptr;
v->w = w; v->h = h;
extern lv_obj_t* luaHostAppBody();
lv_obj_t* body = luaHostAppBody();
if (!body) { delete v; return nullptr; }
v->cont = lv_obj_create(body);
lv_obj_remove_style_all(v->cont);
lv_obj_set_pos(v->cont, (lv_coord_t)x, (lv_coord_t)y);
lv_obj_set_size(v->cont, (lv_coord_t)w, (lv_coord_t)h);
lv_obj_set_style_bg_color(v->cont, lv_color_hex(0x11161B), LV_PART_MAIN);
lv_obj_set_style_bg_opa(v->cont, LV_OPA_COVER, LV_PART_MAIN);
lv_obj_clear_flag(v->cont, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_add_flag(v->cont, LV_OBJ_FLAG_CLICKABLE);
// Two layers so overlays can never be painted over by a tile that happens to
// be created later -- the exact bug the map tab fixes with move_background.
v->tiles = lv_obj_create(v->cont);
lv_obj_remove_style_all(v->tiles);
lv_obj_set_pos(v->tiles, 0, 0);
lv_obj_set_size(v->tiles, (lv_coord_t)w, (lv_coord_t)h);
lv_obj_clear_flag(v->tiles, LV_OBJ_FLAG_SCROLLABLE);
v->ovl = lv_obj_create(v->cont);
lv_obj_remove_style_all(v->ovl);
lv_obj_set_pos(v->ovl, 0, 0);
lv_obj_set_size(v->ovl, (lv_coord_t)w, (lv_coord_t)h);
lv_obj_clear_flag(v->ovl, LV_OBJ_FLAG_SCROLLABLE);
return v;
}
void luaHostMapDestroy(void* vp) {
AppMapView* v = (AppMapView*)vp;
if (!v) return;
for (int i = 0; i < kAppMapTilesMax; i++) appMapFreeTile(v->pool[i]);
if (v->cont) lv_obj_del(v->cont); // takes the two layers and every overlay with it
delete v;
}
void luaHostMapSet(void* vp, double lat, double lon, int zoom) {
AppMapView* v = (AppMapView*)vp;
if (!v) return;
v->lat = lat; v->lon = lon;
if (zoom >= 1 && zoom <= 19) v->zoom = (uint8_t)zoom;
}
// Zoom alone. Separate from the call above because 0,0 is a real coordinate,
// so there is no lat/lon value that can mean "leave the centre alone".
void luaHostMapSetZoom(void* vp, int zoom) {
AppMapView* v = (AppMapView*)vp;
if (v && zoom >= 1 && zoom <= 19) v->zoom = (uint8_t)zoom;
}
int luaHostMapZoom(void* vp) { AppMapView* v = (AppMapView*)vp; return v ? v->zoom : 0; }
int luaHostMapPlaced(void* vp) { AppMapView* v = (AppMapView*)vp; return v ? v->placed : 0; }
// lat/lon -> pixel within the view. Deliberately NOT clamped: an app placing
// its own labels needs to know a point is off-view, and a clamped coordinate
// would silently pile everything onto the edge.
void luaHostMapToScreen(void* vp, double lat, double lon, int* px, int* py) {
AppMapView* v = (AppMapView*)vp;
if (!v) { *px = *py = 0; return; }
double cwx, cwy, wx, wy;
latLonToWorldPx(v->lat, v->lon, v->zoom, &cwx, &cwy);
latLonToWorldPx(lat, lon, v->zoom, &wx, &wy);
*px = (int)lround(wx - cwx + v->w / 2.0);
*py = (int)lround(wy - cwy + v->h / 2.0);
}
void luaHostMapToLatLon(void* vp, int px, int py, double* lat, double* lon) {
AppMapView* v = (AppMapView*)vp;
if (!v) { *lat = *lon = 0; return; }
double cwx, cwy;
latLonToWorldPx(v->lat, v->lon, v->zoom, &cwx, &cwy);
worldPxToLatLon(cwx + (px - v->w / 2.0), cwy + (py - v->h / 2.0), v->zoom, lat, lon);
}
void luaHostMapClearOverlay(void* vp) {
AppMapView* v = (AppMapView*)vp;
if (v && v->ovl) lv_obj_clean(v->ovl);
}
int luaHostMapMarker(void* vp, double lat, double lon, uint32_t color, int size) {
AppMapView* v = (AppMapView*)vp;
if (!v || !v->ovl) return 0;
int px, py;
luaHostMapToScreen(vp, lat, lon, &px, &py);
if (size < 3) size = 3;
if (size > 24) size = 24;
// Off-view markers are dropped rather than created off-screen: they would be
// invisible LVGL objects accumulating on every redraw of a panning map.
if (px < -size || py < -size || px > v->w + size || py > v->h + size) return 0;
lv_obj_t* d = lv_obj_create(v->ovl);
lv_obj_remove_style_all(d);
lv_obj_set_size(d, size, size);
lv_obj_set_pos(d, px - size / 2, py - size / 2);
lv_obj_set_style_radius(d, LV_RADIUS_CIRCLE, LV_PART_MAIN);
lv_obj_set_style_bg_color(d, lv_color_hex(color), LV_PART_MAIN);
lv_obj_set_style_bg_opa(d, LV_OPA_COVER, LV_PART_MAIN);
lv_obj_set_style_border_width(d, 1, LV_PART_MAIN);
lv_obj_set_style_border_color(d, lv_color_hex(0x0E1216), LV_PART_MAIN);
lv_obj_clear_flag(d, LV_OBJ_FLAG_SCROLLABLE);
return 1;
}
int luaHostMapLine(void* vp, double la1, double lo1, double la2, double lo2,
uint32_t color, int width) {
AppMapView* v = (AppMapView*)vp;
if (!v || !v->ovl) return 0;
int x1, y1, x2, y2;
luaHostMapToScreen(vp, la1, lo1, &x1, &y1);
luaHostMapToScreen(vp, la2, lo2, &x2, &y2);
// Both ends off the same side means the segment cannot cross the view.
if ((x1 < 0 && x2 < 0) || (y1 < 0 && y2 < 0) ||
(x1 > v->w && x2 > v->w) || (y1 > v->h && y2 > v->h)) return 0;
// LVGL does not copy a line's point array, so it has to outlive the object.
// Attaching it as the object's user_data and freeing it on DELETE keeps the
// lifetime tied to the widget instead of to a fixed slot table.
lv_point_t* pts = (lv_point_t*)lv_mem_alloc(sizeof(lv_point_t) * 2);
if (!pts) return 0;
pts[0].x = (lv_coord_t)x1; pts[0].y = (lv_coord_t)y1;
pts[1].x = (lv_coord_t)x2; pts[1].y = (lv_coord_t)y2;
lv_obj_t* ln = lv_line_create(v->ovl);
lv_line_set_points(ln, pts, 2);
lv_obj_set_user_data(ln, pts);
lv_obj_add_event_cb(ln, [](lv_event_t* e) {
void* p = lv_obj_get_user_data(lv_event_get_target(e));
if (p) lv_mem_free(p);
}, LV_EVENT_DELETE, nullptr);
lv_obj_set_style_line_color(ln, lv_color_hex(color), LV_PART_MAIN);
lv_obj_set_style_line_width(ln, width < 1 ? 1 : (width > 8 ? 8 : width), LV_PART_MAIN);
lv_obj_set_style_line_opa(ln, LV_OPA_COVER, LV_PART_MAIN);
return 1;
}
// Render the tiles covering the view. Same slot-reuse idea as the map tab: a
// small pan shares most tiles, so only newcomers are read and decoded.
void luaHostMapRender(void* vp) {
AppMapView* v = (AppMapView*)vp;
if (!v || !v->tiles) return;
double cwx, cwy;
latLonToWorldPx(v->lat, v->lon, v->zoom, &cwx, &cwy);
const int32_t ctx = (int32_t)floor(cwx / 256.0);
const int32_t cty = (int32_t)floor(cwy / 256.0);
const int rx = (v->w / 2 + 255) / 256; // tiles needed each side to cover the view
const int ry = (v->h / 2 + 255) / 256;
struct Want { int32_t tx, ty; double d2; bool placed; };
Want cand[25];
int nc = 0;
for (int dy = -ry; dy <= ry; dy++) {
for (int dx = -rx; dx <= rx; dx++) {
if (nc >= (int)(sizeof cand / sizeof cand[0])) break;
const int32_t tx = ctx + dx, ty = cty + dy;
// Cull to what actually touches the view, so the pool is spent on tiles
// the user can see rather than on the corners of an oversized grid.
const double sx = (double)tx * 256.0 - cwx + v->w / 2.0;
const double sy = (double)ty * 256.0 - cwy + v->h / 2.0;
if (sx > v->w || sy > v->h || sx + 256 < 0 || sy + 256 < 0) continue;
// Distance from the tile's centre to the view's, so that when a view is
// bigger than the pool can cover we keep the MOST VISIBLE tiles rather
// than whichever the scan happened to reach first -- that ordering put
// the gap in the middle of the view instead of at its edge.
const double mx = sx + 128.0 - v->w / 2.0, my = sy + 128.0 - v->h / 2.0;
cand[nc++] = { tx, ty, mx * mx + my * my, false };
}
}
for (int a = 1; a < nc; a++) { // insertion sort; nc is at most 25
Want key = cand[a];
int b = a - 1;
while (b >= 0 && cand[b].d2 > key.d2) { cand[b + 1] = cand[b]; b--; }
cand[b + 1] = key;
}
const int nw = nc < kAppMapTilesMax ? nc : kAppMapTilesMax;
Want* wanted = cand;
for (int i = 0; i < kAppMapTilesMax; i++) { // keep matches, free the rest
AppMapTile& t = v->pool[i];
if (!t.in_use) continue;
bool keep = false;
for (int k = 0; k < nw; k++) {
if (t.z == v->zoom && t.x == wanted[k].tx && t.y == wanted[k].ty) {
keep = true; wanted[k].placed = true;
lv_obj_set_pos(t.img, (lv_coord_t)((double)t.x * 256.0 - cwx + v->w / 2.0),
(lv_coord_t)((double)t.y * 256.0 - cwy + v->h / 2.0));
break;
}
}
if (!keep) appMapFreeTile(t);
}
v->placed = 0;
for (int k = 0; k < nw; k++) {
if (wanted[k].placed) { v->placed++; continue; }
AppMapTile* dst = nullptr;
for (int i = 0; i < kAppMapTilesMax; i++) if (!v->pool[i].in_use) { dst = &v->pool[i]; break; }
if (!dst) break;
uint8_t* enc = nullptr; size_t elen = 0; bool repairable = false;
if (!loadTileJpeg(v->zoom, wanted[k].tx, wanted[k].ty, &enc, &elen, &repairable) || !enc) {
#if defined(ESP32) && defined(MULTI_TRANSPORT_COMPANION)
queueTileForFetch(v->zoom, wanted[k].tx, wanted[k].ty); // fills in if Wi-Fi is up
#endif
continue;
}
if (!dst->rgb) dst->rgb = (uint8_t*)lvglPsramAlloc(kAppMapTileBytes);
if (!dst->rgb) { lvglPsramFree(enc); break; }
int dw = 0, dh = 0;
uint8_t* rgb = (elen >= 4 && enc[0] == 0x89 && enc[1] == 'P' && enc[2] == 'N' && enc[3] == 'G')
? decodePngToRgb565(enc, elen, &dw, &dh, dst->rgb, kAppMapTileBytes)
: decodeJpegScaledToRgb565(enc, elen, &dw, &dh, 256, dst->rgb, kAppMapTileBytes);
lvglPsramFree(enc);
if (!rgb) continue;
if (s_map_night) { // follow the user's map setting rather than inventing a second one
uint16_t* px = (uint16_t*)rgb;
const int cnt = dw * dh;
for (int p = 0; p < cnt; ++p) px[p] = (uint16_t)~px[p];
}
dst->z = v->zoom; dst->x = wanted[k].tx; dst->y = wanted[k].ty;
dst->w = dw; dst->h = dh;
memset(&dst->dsc, 0, sizeof(dst->dsc));
dst->dsc.header.cf = LV_IMG_CF_TRUE_COLOR;
dst->dsc.header.w = (uint32_t)dw;
dst->dsc.header.h = (uint32_t)dh;
dst->dsc.data = rgb;
dst->dsc.data_size = (uint32_t)((size_t)dw * dh * sizeof(lv_color_t));
lv_img_cache_invalidate_src(&dst->dsc); // the buffer is reused at a stable address
dst->img = lv_img_create(v->tiles);
lv_img_set_src(dst->img, &dst->dsc);
lv_obj_set_pos(dst->img, (lv_coord_t)((double)dst->x * 256.0 - cwx + v->w / 2.0),
(lv_coord_t)((double)dst->y * 256.0 - cwy + v->h / 2.0));
dst->in_use = true;
v->placed++;
// Same watchdog courtesy as the map tab: read + decode on the loop task can
// pin the core long enough to starve IDLE if several tiles land at once.
vTaskDelay(1);
}
if (v->ovl) lv_obj_move_foreground(v->ovl);
}
#endif // CAP_LUA_SDK_EXT
constexpr int k_map_markers_max = 256;
// The self->contact link LINES keep their own, much smaller ceiling: each one holds a
// persistent 2-point array LVGL does not copy, so they cost 12 bytes a slot rather than
@@ -29497,6 +29827,36 @@ static bool mapZoomReachable(uint8_t z) {
#endif
// One zoom step, shared by the +/- buttons and the keyboard-nav scroll keys.
// Skips levels the current tile source can't show (same walk the buttons did).
// In slider mode the zoom number lives above the slider, so it is on screen the
// whole time the slider is. Buttons mode hides the slider, and the readout was
// hidden with it -- which left the +/- pair with no indication of the level at
// all. Reported by a user who had switched to buttons and could no longer see
// the zoom anywhere. So in buttons mode the readout is flashed beside the
// buttons on each step instead.
static lv_timer_t* s_map_zoomval_hide = nullptr;
static void mapZoomValHideCb(lv_timer_t* t) {
if (s_map_zoom_val && lv_obj_is_valid(s_map_zoom_val))
lv_obj_add_flag(s_map_zoom_val, LV_OBJ_FLAG_HIDDEN);
if (t) lv_timer_del(t);
s_map_zoomval_hide = nullptr;
}
static void mapZoomValFlash() {
if (!s_map_zoom_val || !lv_obj_is_valid(s_map_zoom_val)) return;
lv_label_set_text_fmt(s_map_zoom_val, TR("zoom %d"), (int)s_map_zoom);
// Anchored to the slider at build time, which is hidden here. Re-anchor to the
// "+" button so it tracks the column: the buttons are placed with plain
// set_pos and no status-bar offset, so a hand-computed y would sit low.
if (s_map_btn_zoomin && lv_obj_is_valid(s_map_btn_zoomin))
lv_obj_align_to(s_map_zoom_val, s_map_btn_zoomin, LV_ALIGN_OUT_LEFT_MID, -6, 0);
else
lv_obj_align(s_map_zoom_val, LV_ALIGN_TOP_RIGHT, -(32 + 10), 4 + 32);
lv_obj_clear_flag(s_map_zoom_val, LV_OBJ_FLAG_HIDDEN);
lv_obj_move_foreground(s_map_zoom_val);
if (s_map_zoomval_hide) { lv_timer_del(s_map_zoomval_hide); s_map_zoomval_hide = nullptr; }
s_map_zoomval_hide = lv_timer_create(mapZoomValHideCb, 1400, nullptr);
if (s_map_zoomval_hide) lv_timer_set_repeat_count(s_map_zoomval_hide, 1);
}
static void mapZoomStep(bool zoom_in) {
if (zoom_in && s_map_zoom >= k_map_zoom_max) return;
if (!zoom_in && s_map_zoom <= k_map_zoom_min) return;
@@ -29526,6 +29886,7 @@ static void mapZoomStep(bool zoom_in) {
renderMapTiles();
renderMapMarkers();
refreshMapInfoLabel();
if (s_map_zoom_buttons) mapZoomValFlash(); // slider mode already shows it permanently
}
static void mapZoomInCb(lv_event_t* e) {
if (lv_event_get_code(e) != LV_EVENT_CLICKED) return;
@@ -29600,6 +29961,8 @@ static void mapZoomControlsApply() {
hide(s_map_btn_zoomtoggle);
if (s_map_zoom_slider) lv_obj_add_flag(s_map_zoom_slider, LV_OBJ_FLAG_HIDDEN); // no slider in buttons mode
if (s_map_zoom_val) lv_obj_add_flag(s_map_zoom_val, LV_OBJ_FLAG_HIDDEN);
// Starts hidden; mapZoomStep flashes it on each +/- press.
if (s_map_zoomval_hide) { lv_timer_del(s_map_zoomval_hide); s_map_zoomval_hide = nullptr; }
} else {
show(s_map_btn_zoomtoggle, y); y += H;
hide(s_map_btn_zoomin);
@@ -30934,7 +31297,12 @@ static void settingsCatBuild(int cat) {
lv_obj_set_style_text_color(beta_note, lv_color_hex(COLOR_SUB), LV_PART_MAIN);
}
#if (defined(HAS_TDECK_GT911) || defined(HAS_THINKNODE_M9) || defined(HELTEC_LORA_V4_R8)) && CAP_OTA
// Gated on the CAPABILITY, not a list of board names: this needs a writable Arduino-SD
// card and OTA, which is exactly CAP_SD && CAP_OTA. The board list had grown to three
// entries and still left the T-Lora Pager out despite it having both (#289) — every new
// board with a card had to remember to add itself here, which is the failure mode
// device_caps.h exists to prevent.
#if CAP_SD && defined(ESP32) && defined(MULTI_TRANSPORT_COMPANION) && CAP_OTA
// "Save update bin to SD" — the Launcher-install update path: downloads
// the app-only bin for the active channel into /BINS/ on the SD card,
// named wadamesh-beta_<N>-<stable|beta>.bin, ready for the Launcher to
@@ -37419,7 +37787,7 @@ static bool m9HandleArrowKey(int key, lv_obj_t* ta) {
// with only the small corner label saying why. Say it once per session.
if (!s_m9_pan_gap_hinted && s_map_last_missing > 0 && WiFi.status() != WL_CONNECTED) {
s_m9_pan_gap_hinted = true;
if (g_lv.task) g_lv.task->showAlert(TR("Wi-Fi off new map areas can't download"), 1600);
if (g_lv.task) g_lv.task->showAlert(TR("Wi-Fi off, new map areas can't download"), 1600);
}
#endif
if (g_lv.task) g_lv.task->noteUserInput();
@@ -38130,7 +38498,7 @@ static const char* wifiStaStatusBrief(int s) {
// Append the esp_wifi disconnect reason — "auth failed" alone covers
// wrong password (r15, 4-way handshake timeout), WPA3-only/H2E SAE
// failures (r2/r202), and AP-side rejection alike.
extern volatile uint8_t g_wifi_last_disc_reason; // main.cpp
extern volatile uint8_t g_wifi_last_disc_reason; // defined at the foot of this file
static char s_auth_fail[24];
if (g_wifi_last_disc_reason) {
snprintf(s_auth_fail, sizeof s_auth_fail, "auth failed (r%u)", (unsigned)g_wifi_last_disc_reason);
@@ -41246,7 +41614,7 @@ static void statusBarTapCb(lv_event_t* e) {
if (s_sb_shot_done) { s_sb_shot_done = false; return; } // this press was a screenshot hold
// …and this one already closed an app page on touch-DOWN (statusBarReaderBackCb).
// Without this the tap goes back AND falls through to the control-center toggle at
// the bottom of this function "back also opens the status bar" (Istvan, beta_64).
// the bottom of this function: "back also opens the status bar" (pisti87, beta_64).
if (s_sb_back_ms && (uint32_t)(millis() - s_sb_back_ms) < 1500) { s_sb_back_ms = 0; return; }
s_sb_back_ms = 0;
// A full-screen tool page (RF Monitor / Spectrum) is up: the bar carries its Back
@@ -46186,14 +46554,31 @@ 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
}
// ---- wada.mesh read-only bridges (no mesh types cross into the host TU) ----
// Hex-encode the first n bytes of a public key. 4 bytes (8 hex chars) is what the
// rest of the UI uses to name a node, and it is what an app needs to line up a
// contact with a discovery hit or an overheard advert.
static void luaPubkeyHex(const uint8_t* pk, int n, char* out, size_t cap) {
static const char* H = "0123456789abcdef";
size_t o = 0;
for (int i = 0; i < n && o + 2 < cap; i++) {
out[o++] = H[(pk[i] >> 4) & 0x0F];
out[o++] = H[pk[i] & 0x0F];
}
if (cap) out[o < cap ? o : cap - 1] = '\0';
}
int luaHostContactAt(int idx, char* name, size_t name_cap, int* type, uint32_t* secs_ago,
double* lat, double* lon) {
double* lat, double* lon, char* pk_hex, size_t pk_cap,
int32_t* lat_e6, int32_t* lon_e6) {
ContactInfo ci;
if (idx < 0 || !the_mesh.getContactByIdx((uint32_t)idx, ci)) return 0;
snprintf(name, name_cap, "%s", ci.name);
*type = ci.type;
if (pk_hex && pk_cap) luaPubkeyHex(ci.id.pub_key, 4, pk_hex, pk_cap);
*lat = ci.gps_lat / 1.0e6;
*lon = ci.gps_lon / 1.0e6;
if (lat_e6) *lat_e6 = ci.gps_lat; // stored as micro-degrees already
if (lon_e6) *lon_e6 = ci.gps_lon;
// One clock read per contacts() walk, not one per contact: on boards whose
// clock is an I2C RTC (the M9's PCF8563 on Wire) getCurrentTime() is a bus
// transaction, and an app listing 100 contacts would otherwise stall the UI
@@ -46205,14 +46590,24 @@ int luaHostContactAt(int idx, char* name, size_t name_cap, int* type, uint32_t*
? now - ci.last_advert_timestamp : 0;
return 1;
}
int luaHostRxLogAt(int idx, uint32_t* ms_ago, int* ptype, int* rssi, float* snr, int* hops) {
int luaHostRxLogAt(int idx, uint32_t* ms_ago, int* ptype, int* rssi, float* snr, int* hops,
int* route, int* len, int* org_kind, char* org_hex, size_t org_cap,
uint32_t* at_ms) {
MyMesh::UiRxRec r;
if (idx < 0 || !the_mesh.uiRxLogGet((uint8_t)idx, r)) return 0;
if (at_ms) *at_ms = r.ms; // the record's own timestamp: a stable delivery key
*ms_ago = millis() - r.ms;
*ptype = r.ptype;
*rssi = r.rssi;
*snr = (float)r.snr_q4 / 4.0f;
*hops = r.hops;
*route = r.route;
*len = r.len;
*org_kind = r.org_kind;
// kind 1 = a real public-key prefix (advert); kind 2 = the one-byte
// destination and source hashes an addressed frame carries. Encoded to the
// width the frame actually justifies, so 8 hex chars always means a key.
if (org_hex && org_cap) luaPubkeyHex(r.org, r.org_kind == 1 ? 4 : 2, org_hex, org_cap);
return 1;
}
void luaHostRadioStats(float* rssi, float* noise, uint32_t* rx_air_s, uint32_t* tx_air_s,
@@ -46300,6 +46695,33 @@ bool luaHostReadPerm(const char* app_id) {
return (luaHostAppPerms(app_id, &asked) & LUA_PERM_READ) != 0;
}
// Private conversations are their own grants. Posting to a channel the user is
// already in is a different act from writing to one person as them, and reading
// channel traffic is different from reading their DMs — so an app that needs one
// does not silently acquire the other.
int luaHostDmSendPerm(const char* app_id) {
bool asked = false;
const int mask = luaHostAppPerms(app_id, &asked);
if (!asked) return 0;
return (mask & LUA_PERM_DM_SEND) ? 1 : -1;
}
bool luaHostDmReadPerm(const char* app_id) {
bool asked = false;
return (luaHostAppPerms(app_id, &asked) & LUA_PERM_DM_READ) != 0;
}
// Set or clear ONE permission bit, leaving the app's other grants alone. The
// prompt path used to write a whole mask, so asking for send silently revoked a
// read grant the user had given in Settings — harmless with one bit, wrong the
// moment there is more than one.
static void luaPermWrite(const char* app_id, int mask);
static void luaPermSetBit(const char* app_id, int bit, bool on) {
bool asked = false;
int mask = luaHostAppPerms(app_id, &asked);
if (on) mask |= bit; else mask &= ~bit;
luaPermWrite(app_id, mask);
}
static void luaPermWrite(const char* app_id, int mask) {
if (!app_id || !*app_id || !s_ui_data_fs) return;
// Rewrite whole-file: the table is a handful of short lines, and an in-place
@@ -46340,7 +46762,7 @@ void luaHostRequestSendPerm(const char* app_id, const char* app_name) {
// Record the refusal FIRST, so the answer is "no" unless the user says
// otherwise -- a power cut mid-dialog must not leave a grant. It also stops
// this app re-prompting: its next send sees -1 and fails without a dialog.
luaPermWrite(app_id, 0); // refused unless the user says otherwise
luaPermSetBit(app_id, LUA_PERM_SEND, false); // refused unless the user says otherwise
const char* label = (app_name && *app_name) ? app_name : app_id;
char msg[220];
snprintf(msg, sizeof msg,
@@ -46350,12 +46772,78 @@ void luaHostRequestSendPerm(const char* app_id, const char* app_name) {
label, the_mesh.getNodePrefs()->node_name);
// Cancel needs no handler: the refusal is already on disk. The user can revisit
// a decision by deleting /apps/perms.kv.
showConfirm(msg, TR("Allow"), +[]() { luaPermWrite(s_lua_perm_target, LUA_PERM_SEND); });
showConfirm(msg, TR("Allow"), +[]() { luaPermSetBit(s_lua_perm_target, LUA_PERM_SEND, true); });
}
// Send on a channel matched BY NAME. Matching by name rather than a cached slot
// index is deliberate: a stale slot transmits on the WRONG key, which is exactly
// the bug behind the channel-send fix in the touch composer.
// Send a direct message to a contact BY NAME, or post to a room server (a room is
// just a contact of type ADV_TYPE_ROOM, and the send is identical — which is why one
// call covers both). Mirrors into the on-device chat thread and registers the expected
// ACK exactly as a composer send does, so an app-sent message is a first-class message
// rather than something that vanishes off the radio with no local trace.
bool luaHostMeshSendDM(const char* to_name, const char* text, bool* was_room) {
if (was_room) *was_room = false;
if (!to_name || !*to_name || !text || !*text) return false;
ContactInfo* by = nullptr;
const uint32_t n = the_mesh.getNumContacts();
for (uint32_t i = 0; i < n; ++i) {
ContactInfo c;
if (!the_mesh.getContactByIdx(i, c)) continue;
if (strcmp(c.name, to_name) != 0) continue;
by = the_mesh.lookupContactByPubKey(c.id.pub_key, PUB_KEY_SIZE);
break;
}
if (!by) return false; // no contact with that name
if (was_room) *was_room = (by->type == ADV_TYPE_ROOM);
ContactInfo rcpt = *by;
rcpt.out_path_len = OUT_PATH_UNKNOWN; // flood, same as the Chats composer does
const uint32_t ts = the_mesh.getRTCClock()->getCurrentTimeUnique();
uint32_t expected_ack = 0, est_timeout = 0;
const int rr = the_mesh.sendMessage(rcpt, ts, 0, (char*)text, expected_ack, est_timeout);
if (rr == MSG_SEND_FAILED) return false;
the_mesh.uiRegisterExpectedAck(expected_ack, by->id.pub_key);
if (g_lv.task)
g_lv.task->appSentMsgToContact(by->id.pub_key, by->name, text, expected_ack, the_mesh.uiLastSentFp());
return true;
}
// Names of the channels configured on this device, so an app can discover the
// private ones rather than having to be told their names. Names only: the channel
// SECRET is never exposed to Lua, so an app can post to a channel the user already
// has but can never derive one or hand it to anybody.
int luaHostMeshChannelNames(char out[][32], int max_n) {
int n = 0;
for (int i = 0; i < MAX_GROUP_CHANNELS && n < max_n; i++) {
ChannelDetails cd;
if (!the_mesh.getChannel(i, cd) || !cd.name[0]) continue;
snprintf(out[n], 32, "%s", cd.name);
n++;
}
return n;
}
// Raised from wada.mesh.send_dm's first refusal. Worded harder than the channel
// prompt on purpose: a channel post is visible to everyone on that channel and is
// obviously "from" the node, while a direct message arrives in one person's private
// thread looking exactly like something the user typed to them.
void luaHostRequestDmSendPerm(const char* app_id, const char* app_name) {
if (!app_id || !*app_id) return;
snprintf(s_lua_perm_target, sizeof s_lua_perm_target, "%s", app_id);
luaPermSetBit(app_id, LUA_PERM_DM_SEND, false); // refused unless the user says otherwise
const char* label = (app_name && *app_name) ? app_name : app_id;
char msg[240];
snprintf(msg, sizeof msg,
TR("\"%s\" wants to send PRIVATE messages as you.\n\n"
"It could write to any of your contacts, or post to a room, and the "
"message will look exactly like one you typed.\n\n"
"Only allow this for an app you trust."),
label);
showConfirm(msg, TR("Allow"), +[]() { luaPermSetBit(s_lua_perm_target, LUA_PERM_DM_SEND, true); });
}
bool luaHostMeshSendChannel(const char* chan_name, const char* text) {
if (!chan_name || !*chan_name || !text || !*text) return false;
for (int i = 0; i < MAX_GROUP_CHANNELS; i++) {
@@ -46386,22 +46874,39 @@ void luaHostBattery(uint16_t* mv, int* pct, bool* charging) {
// plotting a track needs to tell a live fix from a stale one. Also false while
// the user has GPS switched off (same gate updateGpsLocation() applies), so a
// last sentence parsed before the toggle cannot masquerade as a live fix.
// Altitude comes from getGpsAltitude() (metres, same accessor the Device page
// uses). Speed/course exist only where the board's target.cpp provides
// wadaGpsMotion() on top of WadaNmeaLocationProvider (HAS_GPS_MOTION); the
// core provider keeps its RMC fields private, so other boards report NAN and
// the binding omits the fields.
// Altitude (getGpsAltitude(), metres, the accessor the Device page uses) is
// included because height dominates LoRa range: two samples a metre apart on
// the map can be a hilltop and a hollow, and a coverage survey that records
// only lat/lon cannot tell them apart afterwards. `time` is satellite time
// (0 until the receiver has decoded the date), which is the only clock a
// logger can trust before the device has been near a network.
// Speed/course exist only where the board's target.cpp provides wadaGpsMotion()
// on top of WadaNmeaLocationProvider (HAS_GPS_MOTION); the core provider keeps
// its RMC fields private, so other boards report NAN and the binding omits the
// fields.
#if defined(HAS_GPS_MOTION)
extern bool wadaGpsMotion(float* speed_kmh, float* course_deg);
#endif
bool luaHostGps(double* lat, double* lon, int* sats, int* alt_m, float* speed_kmh, float* course_deg) {
bool luaHostGps(double* lat, double* lon, int* sats, int* alt_m, uint32_t* fix_time,
int32_t* lat_e6, int32_t* lon_e6, float* speed_kmh, float* course_deg) {
// Pre-set the motion fields BEFORE the fix gate: a caller that bails on a
// false return still reads defined values, and boards without HAS_GPS_MOTION
// report NAN so the binding can omit the fields rather than publish a 0.
if (speed_kmh) *speed_kmh = NAN;
if (course_deg) *course_deg = NAN;
if (!g_lv.task || !g_lv.task->getGPSState() || !g_lv.task->getGpsFix()) return false;
if (lat) *lat = g_lv.task->getNodeLat();
if (lon) *lon = g_lv.task->getNodeLon();
if (sats) *sats = g_lv.task->getGpsSats();
if (alt_m) *alt_m = g_lv.task->getGpsAltitude();
const double la = g_lv.task->getNodeLat(), lo = g_lv.task->getNodeLon();
if (lat) *lat = la;
if (lon) *lon = lo;
if (sats) *sats = g_lv.task->getGpsSats();
if (alt_m) *alt_m = g_lv.task->getGpsAltitude();
if (fix_time) *fix_time = g_lv.task->getGpsTime();
// Lua is built LUA_32BITS, so its floats are SINGLE precision: a latitude
// keeps roughly a metre. Fine to display, not fine to log -- so the exact
// micro-degrees go across as integers too, which int32 holds without loss
// (180e6 is well inside its range). A track logger should write these.
if (lat_e6) *lat_e6 = (int32_t)llround(la * 1.0e6);
if (lon_e6) *lon_e6 = (int32_t)llround(lo * 1.0e6);
#if defined(HAS_GPS_MOTION)
{
float spd = NAN, crs = NAN;
@@ -46457,9 +46962,10 @@ static void appPermsToggleCb(lv_event_t* e) {
static void buildAppPermsSettings(lv_obj_t* page, lv_coord_t lblw) {
int y = 6;
lv_obj_t* hint = lv_label_create(page);
lv_label_set_text(hint, TR("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."));
lv_label_set_text(hint, TR("What each installed app is allowed to do. Anything an app sends goes out "
"under your node name and cannot be told apart from a message you "
"typed. Private covers direct messages and room posts, and is kept "
"separate from channels on purpose. Turn one off to take it back."));
lv_label_set_long_mode(hint, LV_LABEL_LONG_WRAP);
lv_obj_set_width(hint, lblw);
lv_obj_set_style_text_font(hint, &g_font_12, LV_PART_MAIN);
@@ -46513,15 +47019,20 @@ static void buildAppPermsSettings(lv_obj_t* page, lv_coord_t lblw) {
bool asked = false;
const int mask = luaHostAppPerms(ids[i], &asked);
const int rowh = (h > 0 ? h : 18);
// Two switches: sending as you, and reading what arrives. They are separate
// risks -- one speaks in your name, the other sees your conversations -- and
// an app that wants both has to be given both.
struct { const char* label; int bit; } perms[2] = {
{ TR("Send messages as me"), LUA_PERM_SEND },
{ TR("Read incoming messages"), LUA_PERM_READ },
// Four switches: speaking in your name vs reading what arrives, each split
// again between channels and private conversations. They are different risks --
// posting to a channel you are already in is not the same act as writing to one
// person as you, and channel traffic is not your DMs -- so an app that wants
// more than one has to be given each.
struct { const char* label; int bit; } perms[] = {
{ TR("Post to channels as me"), LUA_PERM_SEND },
{ TR("Read channel messages"), LUA_PERM_READ },
{ TR("Send private messages as me"), LUA_PERM_DM_SEND },
{ TR("Read private messages"), LUA_PERM_DM_READ },
{ TR("Send discovery probes"), LUA_PERM_PROBE },
};
int sy = y + rowh + 2;
for (int k = 0; k < 2; ++k) {
for (int k = 0; k < (int)(sizeof perms / sizeof perms[0]); ++k) {
lv_obj_t* pl = lv_label_create(page);
lv_label_set_text(pl, perms[k].label);
lv_obj_set_style_text_font(pl, &g_font_12, LV_PART_MAIN);
@@ -46548,12 +47059,136 @@ static void buildAppPermsSettings(lv_obj_t* page, lv_coord_t lblw) {
(void)perm;
}
}
// ---- wada.mesh.discover: the active half of discovery -----------------------
// A probe is a zero-hop broadcast that asks every node in earshot to answer, so
// one call costs airtime across the whole neighbourhood, not just ours. That is
// also exactly what makes it worth having: a reply PROVES the link works from
// where you are standing, which no amount of listening can establish.
//
// Its own permission rather than reusing "send": posting a message and making
// every neighbour transmit are different impositions on other people's radios,
// and a survey app has no business acquiring the ability to speak as the user.
int luaHostProbePerm(const char* app_id) {
bool asked = false;
const int mask = luaHostAppPerms(app_id, &asked);
if (!asked) return 0;
return (mask & LUA_PERM_PROBE) ? 1 : -1;
}
void luaHostRequestProbePerm(const char* app_id, const char* app_name) {
if (!app_id || !*app_id) return;
snprintf(s_lua_perm_target, sizeof s_lua_perm_target, "%s", app_id);
luaPermSetBit(app_id, LUA_PERM_PROBE, false); // refused unless the user says otherwise
const char* label = (app_name && *app_name) ? app_name : app_id;
char msg[240];
snprintf(msg, sizeof msg,
TR("\"%s\" wants to send discovery probes.\n\n"
"Each probe asks every node in range to reply, so it uses airtime "
"on the whole local mesh, not just yours.\n\n"
"Nothing is sent under your name and no message is transmitted."),
label);
showConfirm(msg, TR("Allow"), +[]() { luaPermSetBit(s_lua_perm_target, LUA_PERM_PROBE, true); });
}
// type_filter is a bitmask over ADV_TYPE_*; 0 means every node type.
// Returns the scan tag (non-zero) if the probe went out.
uint32_t luaHostMeshDiscover(int type_filter) {
return the_mesh.uiStartDiscoverScan((uint8_t)(type_filter & 0xFF));
}
#endif // CAP_LUA_SDK_EXT
void luaHostSelfInfo(char* name, size_t name_cap, double* lat, double* lon) {
void luaHostSelfInfo(char* name, size_t name_cap, double* lat, double* lon,
char* pk_hex, size_t pk_cap, int32_t* lat_e6, int32_t* lon_e6) {
snprintf(name, name_cap, "%s", the_mesh.getNodePrefs()->node_name);
*lat = g_lv.task ? g_lv.task->getNodeLat() : 0.0;
*lon = g_lv.task ? g_lv.task->getNodeLon() : 0.0;
if (lat_e6) *lat_e6 = (int32_t)llround(*lat * 1.0e6);
if (lon_e6) *lon_e6 = (int32_t)llround(*lon * 1.0e6);
if (pk_hex && pk_cap) luaPubkeyHex(the_mesh.self_id.pub_key, 4, pk_hex, pk_cap);
}
// ---- Discovery results (read side) -----------------------------------------
// The table the Discover app fills. Reading it is passive -- these are replies
// the radio already received -- so it is ungated, like contacts() and rx_log().
// FIRING a probe is the gated half; see luaHostMeshDiscover below.
int luaHostDiscoverCount() { return the_mesh.discoverCount(); }
void luaHostDiscoverClear() { the_mesh.discoverClear(); }
int luaHostDiscoverAt(int idx, char* pk_hex, size_t pk_cap, char* name, size_t name_cap,
int* type, int* rssi, float* snr, float* their_snr, int* hops,
uint32_t* first_ms_ago, uint32_t* last_ms_ago, int* heard) {
MyMesh::DiscoverHit h;
if (idx < 0 || !the_mesh.discoverGet((uint8_t)idx, h)) return 0;
luaPubkeyHex(h.pubkey, 4, pk_hex, pk_cap);
// A responder is only named if the user already has it as a contact. A
// discovery reply carries no name of its own, and inventing one would let an
// app present an unknown node as a known one.
ContactInfo* c = the_mesh.lookupContactByPubKey(h.pubkey, PUB_KEY_SIZE);
snprintf(name, name_cap, "%s", c ? c->name : "");
*type = h.node_type;
*rssi = h.our_rssi;
*snr = (float)h.our_snr_q4 / 4.0f;
// The reverse link: how well THEY heard US. This is the half a wardriving
// survey cannot get any other way, and it is why a probe beats listening.
*their_snr = (float)h.their_snr_q4 / 4.0f;
*hops = h.path_len;
const uint32_t now = millis();
*first_ms_ago = now - h.first_ms;
*last_ms_ago = now - h.last_ms;
*heard = h.heard;
return 1;
}
// ---- wada.ui.input: one modal text field, reusing the file-manager prompt ----
// Exactly one callback per call: the text on OK, nullptr on any other close, so
// an app can always re-enable itself.
static void (*s_lua_prompt_cb)(const char*) = nullptr;
static void luaPromptDeliver(const char* text) {
void (*cb)(const char*) = s_lua_prompt_cb;
s_lua_prompt_cb = nullptr;
if (cb) cb(text);
}
void luaHostTextPromptCancel() { // closed some other way: report a cancel
void (*cb)(const char*) = s_lua_prompt_cb;
s_lua_prompt_cb = nullptr;
if (cb) cb(nullptr);
}
#if CAP_SENSORS
// wada.sys.env(). Reports per-field presence rather than a value with a magic
// "missing" number, so an app can distinguish an absent sensor from a real
// reading of zero. The snapshot is the same one the Sensors tab renders.
void luaHostEnv(bool* ok, bool* have_t, float* temp_c, bool* have_h, float* hum_pct,
bool* have_p, float* press_hpa, bool* have_alt, int* alt_m) {
*ok = false;
if (!g_lv.task) return;
UITask::LocalEnvSnapshot snap;
if (!g_lv.task->getLocalEnvSnapshot(snap) || !snap.query_ok) return;
// Two possible sources for temperature and humidity: the BME280 on the
// Expansion Kit and the GXHTC3 on the board. Prefer the BME, fall back.
*have_t = snap.have_bme_temp || snap.have_gxhtv3_temp;
*temp_c = snap.have_bme_temp ? snap.bme_temp_c : snap.gxhtv3_temp_c;
*have_h = snap.have_bme_hum || snap.have_gxhtv3_hum;
*hum_pct = snap.have_bme_hum ? snap.bme_hum_pct : snap.gxhtv3_hum_pct;
*have_p = snap.have_bme_pressure;
*press_hpa = snap.bme_pressure_hpa;
*have_alt = snap.have_bme_alt;
*alt_m = snap.bme_alt_m;
*ok = (*have_t || *have_h || *have_p);
}
#endif
// App teardown. Drops the callback FIRST so the close below cannot re-enter a
// Lua state that is being torn down, then closes the dialog -- it lives on
// lv_layer_top and would otherwise outlive the app that opened it.
void luaHostTextPromptDismiss() {
s_lua_prompt_cb = nullptr;
fmPromptClose();
}
void luaHostTextPrompt(const char* title, const char* initial, void (*cb)(const char*)) {
luaHostTextPromptCancel(); // a second prompt cancels the first
// Register the callback AFTER the dialog exists. fmTextPrompt opens with its
// own fmPromptClose(), which reports a cancel to whatever is pending -- so
// setting cb first made every prompt fire cb(nil) before the user saw it.
fmTextPrompt(title && *title ? title : TR("Enter text"), initial, luaPromptDeliver);
s_lua_prompt_cb = cb;
}
#endif
// True when the chat history lives on a removable SD card (T-Deck SPI SD or the
@@ -50653,12 +51288,6 @@ void UITask::newMsgImpl(uint8_t path_len, const char* from_name, const char* tex
_msgcount = msgcount;
const bool have_sender_override = (sender_override && sender_override[0]);
bool channel = (g_last_event == UIEventType::channelMessage);
#if CAP_LUA_SDK_EXT
// Hand a running Lua app the incoming message (app.on_message). Permission is
// rechecked inside, per message, so revoking read access in Settings stops
// delivery at once rather than at the app's next launch.
if (channel && luaAppIsOpen()) luaAppMessage(from_name, sender_override, text);
#endif
const char* thread = channel
? (from_name && from_name[0] ? from_name : "#unknown")
: (from_name && from_name[0] ? from_name : "Unknown");
@@ -50722,6 +51351,19 @@ void UITask::newMsgImpl(uint8_t path_len, const char* from_name, const char* tex
}
#endif
#if CAP_LUA_SDK_EXT
// Hand a running Lua app the incoming message (app.on_message), AFTER the block
// and spam filters so an app sees exactly the messages the user does. Permission
// is rechecked per message inside deliverMessage, so revoking in Settings stops
// delivery at once rather than at the app's next launch. Channel traffic rides
// LUA_PERM_READ; direct messages and room posts need the separate, more sensitive
// LUA_PERM_DM_READ. "kind" lets an app tell them apart rather than inferring it.
if (luaAppIsOpen()) {
const char* kind = channel ? "channel" : (have_sender_override ? "room" : "dm");
luaAppMessage(kind, channel ? thread : "", sender, body);
}
#endif
#if defined(HAS_UI_SOUND) || defined(HAS_TANMATSU)
// Notification chime (T-Deck I2S speaker / Heltec V4 piezo / Tanmatsu I2S codec).
// @-mentions get a distinct sound + their own enable, so an operator can mute message
@@ -52520,3 +53162,12 @@ static bool popupRegistryBlocksSwipe() {
if ((e.flags & PF_SWIPE) && e.is_open()) return true;
return false;
}
// Last Wi-Fi STA disconnect reason, surfaced by wifiStaStatusBrief as
// "auth failed (rNN)". Defined HERE rather than in src/main.cpp because every
// target compiles this file, while the two ESP32-P4 targets are IDF builds with
// their own main.cpp -- the S3-only definition left both of them failing to link.
// The S3 Wi-Fi event handler in src/main.cpp writes it through an extern.
#if defined(ESP32)
volatile uint8_t g_wifi_last_disc_reason = 0;
#endif
File diff suppressed because it is too large Load Diff
+17
View File
@@ -0,0 +1,17 @@
#!/usr/bin/env bash
# One-time: dump YOUR Tanmatsu's AppFS partition as the "pristine" baseline that
# tan_deploy.py diffs against.
#
# Do this while the launcher's own apps are installed and wadamesh is NOT, so the
# baseline represents the device as it ships. Using someone else's dump risks writing
# over apps you actually have: the deploy writes only the sectors that differ from
# this file, so the file has to describe YOUR device.
#
# Usage: dump-pristine.sh /dev/cu.usbmodemXXXX
set -euo pipefail
HERE="$(cd "$(dirname "$0")" && pwd)"
PORT="${1:?usage: dump-pristine.sh /dev/cu.usbmodemXXXX}"
ESPTOOL="${ESPTOOL:-$HOME/.platformio/packages/tool-esptoolpy/esptool.py}"
python3 "$ESPTOOL" --chip esp32p4 -p "$PORT" read_flash 0x420000 0x800000 "$HERE/dev_appfs.bin"
ls -la "$HERE/dev_appfs.bin"
echo "baseline saved. Keep it: every later sideload diffs against it."
+16
View File
@@ -0,0 +1,16 @@
#!/usr/bin/env bash
# One-time: fetch badge.team's appfs.py, which tan_deploy.py needs to read and rewrite
# the AppFS partition image.
#
# It is NOT vendored into this repo on purpose: it is badge.team's code and the copy in
# circulation carries no licence header, so cloning it keeps the authorship where it
# belongs rather than silently absorbing it into a GPL tree.
set -euo pipefail
HERE="$(cd "$(dirname "$0")" && pwd)"
DEST="$HERE/appfs-src"
if [ -d "$DEST" ]; then
echo "already present: $DEST"
else
git clone --depth 1 https://github.com/badgeteam/esp32-component-appfs.git "$DEST"
fi
python3 -c "import sys; sys.path.insert(0,'$DEST/tools'); import appfs; print('appfs.py OK')"
+42
View File
@@ -0,0 +1,42 @@
#!/usr/bin/env python3
# Resilient non-resetting serial monitor for the Tanmatsu P4 USB-serial-JTAG console.
# Auto-reconnects across USB re-enumeration (device reset when an app is launched).
import serial, sys, time
port = sys.argv[1] if len(sys.argv) > 1 else "/dev/cu.usbmodem1301"
logpath = sys.argv[2] if len(sys.argv) > 2 else "/tmp/wada_serial.log"
def open_port():
s = serial.Serial()
s.port = port
s.baudrate = 115200
s.timeout = 1
s.dsrdtr = False
s.rtscts = False
s.open()
try:
s.dtr = False
s.rts = False
except Exception:
pass
return s
with open(logpath, "ab") as out:
while True:
try:
s = open_port()
out.write(b"\n==== monitor attached ====\n"); out.flush()
while True:
d = s.read(4096)
if d:
out.write(d); out.flush()
except Exception as e:
try:
out.write(("\n==== port dropped: %s — reconnecting ====\n" % e).encode()); out.flush()
except Exception:
pass
try:
s.close()
except Exception:
pass
time.sleep(0.5)

Some files were not shown because too many files have changed in this diff Show More