Compare commits

...
6 Commits
Author SHA1 Message Date
Kaj SchittecatandClaude Opus 5 de45b1b158 release: beta_65 notes
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 19:22:47 +02:00
Kaj SchittecatandClaude Opus 5 ea673f86a5 docs: last on_stop reference -> app.on_close
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 19:19:33 +02:00
Kaj SchittecatandClaude Opus 5 ca45bd4098 docs: correct the Lua SDK page, and document the beta_64 additions
The published SDK page was not merely out of date, it was wrong about the first
thing an app author does. It said to "define the callbacks you need as globals"
and named them on_start / on_stop. The runtime has never called those: an app is
a table you build and `return`, and the fields are on_open(w,h), on_tick,
on_input, on_message and on_close — which is what every app in the Store actually
does. Following the published example produced an app where nothing was ever
called and no error appeared, i.e. a blank page. That cost me an hour today
building SDK Test 1.0, and I changed my app instead of noticing the docs were
wrong; anyone else writing an app hit the same wall with less to go on.

Also corrected against the shipped code: ev.name does not exist (it is ev.key,
with ev.code alongside), and label/button take explicit coordinates rather than
the flow-layout signatures the page listed.

The intro's claim that "API v1 is read-only where the mesh is concerned… it
cannot transmit" stopped being true when wada.mesh.send landed, so it now
describes the actual guarantee: transmitting exists, and it stops and asks the
user by name first.

New material for beta_64: sys.epoch/datetime/beep/caps/battery/gps, the whole
wada.fs section, mesh.send, app.on_message, a Permissions section covering the
two separate grants and Settings > App permissions, and sandbox notes for the
things that actually bite — the ext calls are absent on small boards (check
sys.caps().sdk_ext), and the rate limits return false,"too fast" as a normal
answer rather than throwing.

Every signature here is taken from the registration block in LuaAppHost.cpp or
from SDK Test, which was verified green on hardware — not from the old page.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 19:18:51 +02:00
Kaj SchittecatandClaude Opus 5 b014865ada feat: share your position with chosen contacts, on request (#266)
Requested by janmashat: sharing position currently means putting it in the
advert, which broadcasts it in the clear to every node in range, forever — poor
privacy for something as sensitive as where you are.

Almost all of the alternative was already built and unreachable. MeshCore answers
telemetry on REQUEST, encrypted to the contact that asked, with three independent
permission categories (battery, location, environment) each of which can be denied,
allowed for everyone, or allowed only for contacts carrying a permission bit in
contact.flags. The GPS emitter exists (EnvironmentSensorManager::querySensors adds
LPP GPS when the requester has TELEM_PERM_LOCATION), and the receiving end has
worked since #27 — an LPP_GPS field in a response is persisted with uiSetContactGps
and the contact appears on the map.

The one missing piece was any way to grant it: nothing in the touch UI ever wrote
telemetry_mode_loc, so location telemetry was permanently denied, and the
per-contact ALLOW_FLAGS mode was unreachable because nothing set those bits. The
existing switch is all-or-nothing over battery+environment only, with a comment
noting location "keeps its own separate setting" — a setting that was never built.

So: Settings > Mesh gains "Share my location when asked" (Never / Chosen contacts
only / Anyone who asks, ordered to match TELEM_MODE_*), and a contact's own menu
gains "Share my loc", which sets that contact's location bit and flips to the
per-contact mode if sharing was off entirely — otherwise the grant would silently
do nothing. Revocable from the same place.

Two behaviours made explicit rather than left to surprise people: nothing is
broadcast (it is sent only when that contact asks, encrypted to them), and
MeshCore only answers requests at all when base telemetry is allowed, so enabling
location implies answering — the note under the dropdown says so, and the setter
turns the master switch on rather than leaving a control that does nothing.

No protocol change, so this interoperates with the MeshCore app and other MeshCore
nodes rather than forking behaviour. All 8 S3 boards build.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 19:08:06 +02:00
Kaj SchittecatandClaude Opus 5 fee132425c fix: recover from "LoRa radio not detected" on the first boot after an update
Reported on a T-Deck straight after an OTA: the boot dead-ended on the fatal
"LoRa radio not detected" screen, and a manual reboot cleared it.

On boards with a peripheral power gate the LoRa module has no power until that
pin is driven high in Board::begin() — GPIO10 on the T-Deck — a few milliseconds
before the radio is probed. A cold start survives that because the rail was
already settled. A SOFTWARE reset does not, and an OTA ends in exactly one:
ESP.restart() releases the pin, the rail collapses, the app re-drives it and
probes the SX1262 while it is still powering up. The existing 150 ms retry does
not help because it never touches the rail — the module is latched in a bad
state, not merely slow, which is why only a real power cycle cleared it.

So do what the error screen would otherwise ask the user to do: bounce the rail
(LOW, drain, HIGH, wait out the power-on reset and crystal startup) and probe
once more. Guarded on PIN_PERF_POWERON, so boards without a gate are untouched,
and only reached when the alternative is halting on the fatal screen — a board
that probes normally never executes any of it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 18:46:28 +02:00
Kaj SchittecatandClaude Opus 5 17643435ea fix: leaving an app page by the "<" no longer also opens the status bar
Reported by Istvan on a T-Deck: back out of an app and it goes back correctly,
but the top dropdown opens at the same time.

beta_49 widened statusBarReaderBackCb from the Reader page to EVERY app page, so
the bar now closes a page on touch-DOWN (the cap-touch swipe detector can abort
the CLICKED, which used to trap people on touch-only boards). The comment claimed
the CLICKED that follows was then "a no-op" because close() clears
s_apppage_close. It is not: statusBarTapCb merely skips its app-page branch and
falls through every remaining branch to the control-center toggle at the end. So
the same tap went back AND popped the dropdown. Harmless while this was
Reader-only; wrong for every app page since beta_49.

Swallow the CLICKED that belongs to a press already used to go back, the same way
s_sb_shot_done suppresses the click after a screenshot hold. Timestamped rather
than a plain flag: the entire reason for closing on touch-DOWN is that the
matching CLICKED sometimes never arrives, and a sticky bool would then eat the
next genuine bar tap — a stale timestamp just expires.

Also adds scripts/deploy-apps.sh, which should have existed all along. The device
reads the app and language catalogs from firmware.wadamesh.com/apps/, a tree that
neither release.sh (out/firmware/) nor deploy-site.sh (deploy/site/) ships, so it
was only ever updated when someone remembered to rsync it by hand -- and twice
nobody did. Istvan is on hu.lang v8 while the repo has v11, and the SDK Test app
published earlier today never appeared in the store at all. The script validates
both catalogs first (parse, and every version a catalog points at must exist)
because a malformed one leaves a device with an empty store and no explanation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 18:40:56 +02:00
7 changed files with 334 additions and 22 deletions

No files matched your search

+67 -20
View File
@@ -140,8 +140,10 @@
<a href="#mesh">wada.mesh</a>
<a href="#net">wada.net</a>
<a href="#store">wada.store</a>
<a href="#fs">wada.fs</a>
<a href="#sys">wada.sys</a>
<a href="#timer">wada.timer</a>
<a href="#perms">Permissions</a>
<div class="grp">Shipping</div>
<a href="#format">App format</a>
<a href="#install">Install yours</a>
@@ -162,7 +164,7 @@
<p>An app is one Lua chunk. The firmware gives it a screen, an input stream, read access to the mesh, a little key/value storage and an HTTP fetch. That is deliberately the whole surface — roughly thirty functions, all under the <code>wada.</code> table.</p>
<p>LVGL is <b>not</b> exposed. Binding a UI toolkit wholesale would freeze us out of ever changing it, and would hand every app enough rope to wedge the device. Instead there is a small set of widgets we own and can keep stable across firmware versions.</p>
<p>Two of the apps that ship in the Store — <b>RF Monitor</b> and <b>Airtime</b> — are written against exactly this API and nothing else. They are the reference implementations; read them if a doc paragraph is ambiguous.</p>
<div class="note"><p><b>API v1 is read-only where the mesh is concerned.</b> An app can see contacts, the packet log and radio statistics. It cannot transmit. That line is deliberate: a buggy or hostile app must not be able to put packets on the air in your name.</p></div>
<div class="note"><p><b>An app cannot put packets on the air behind your back.</b> Reading the mesh — contacts, the packet log, radio statistics — is free. Transmitting is not: <code>wada.mesh.send</code> exists, but the first call from a given app stops and asks you, by name, and a refusal is remembered. Same for reading your incoming messages. See <a href="#perms">Permissions</a>.</p></div>
</div></div>
</section>
@@ -170,25 +172,29 @@
<h2>Your first app</h2>
<div class="row"><div class="prose">
<p>Save this as <code>hello.lua</code>:</p>
<pre><code>local n = 0
<pre><code>local app = {}
local status
local n = 0
function on_start()
wada.ui.label("Hello from Lua", 16)
status = wada.ui.label("tick 0", 12)
status:color(wada.ui.colors.sub)
function app.on_open(w, h)
wada.ui.label("Hello from Lua", 6, 4, 16, wada.ui.colors.text)
status = wada.ui.label("tick 0", 6, 26, 12, wada.ui.colors.sub)
wada.timer.every(1000)
end
function on_tick()
function app.on_tick()
n = n + 1
status:set("tick " .. n)
end
function on_input(ev)
function app.on_input(ev)
if ev.type == "key" then
wada.sys.toast("you pressed " .. tostring(ev.name))
wada.sys.toast("you pressed " .. tostring(ev.key))
end
end</code></pre>
end
return app</code></pre>
<div class="note"><p><b>The last line matters.</b> An app is a table you build and <code>return</code>; the firmware calls the <code>on_*</code> fields on that table. Callbacks defined as plain globals are never found, so the app opens to a blank page with no error. If nothing happens, check for <code>return app</code> first.</p></div>
<p>Copy it to <code>/apps/hello.lua</code> on the device's SD card (or internal storage on boards without a card), open the app drawer, and it is there. No manifest is required for a file you side-load yourself — see <a href="#install">Install yours</a>.</p>
</div></div>
</section>
@@ -196,16 +202,17 @@ end</code></pre>
<section class="doc" id="lifecycle">
<h2>App lifecycle</h2>
<div class="row"><div class="prose">
<p>Define the callbacks you need as globals. All four are optional.</p>
<p>Build a table, put the callbacks you need on it, and <code>return</code> it from the file. All of them are optional.</p>
<table class="api">
<tr><th>Callback</th><th>When it runs</th></tr>
<tr><td>on_start()</td><td>Once, when the app opens. Build your UI here.</td></tr>
<tr><td>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>on_input(ev)</td><td>For every touch, key or trackball event. See <a href="#input">wada.input</a>.</td></tr>
<tr><td>on_stop()</td><td>Once, when the app closes. Persist anything you care about here.</td></tr>
<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_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>Widgets you create in <code>on_start</code> are destroyed for you when the app closes. You do not free anything.</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>
@@ -215,8 +222,8 @@ end</code></pre>
<p>Widgets are created in order, top to bottom, on the app's page. Every constructor returns a handle with methods you can call later.</p>
<table class="api">
<tr><th>Call</th><th>Returns / does</th></tr>
<tr><td>wada.ui.label(text, size)</td><td>A text line. <code>size</code> is a class, not a pixel height: 12, 14 or 16.</td></tr>
<tr><td>wada.ui.button(text, fn)</td><td>A tappable button; <code>fn</code> is called with no arguments.</td></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.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>
@@ -258,7 +265,7 @@ end</code></pre>
<table class="api">
<tr><th>ev.type</th><th>Fields</th></tr>
<tr><td>"touch"</td><td><code>ev.x</code>, <code>ev.y</code> in page coordinates.</td></tr>
<tr><td>"key"</td><td><code>ev.name</code> — the key label on boards with a keyboard.</td></tr>
<tr><td>"key"</td><td><code>ev.key</code> — a one-character string for printable keys (<code>"w"</code>), or a name for the rest: <code>up</code>, <code>down</code>, <code>left</code>, <code>right</code>, <code>enter</code>, <code>esc</code>, <code>backspace</code>. <code>ev.code</code> carries the raw value. Boards with a keyboard only; the key that closes the app is never delivered, so an app cannot trap you inside it.</td></tr>
<tr><td>"dir"</td><td><code>ev.dir</code> — one of <code>up</code>, <code>down</code>, <code>left</code>, <code>right</code>, <code>select</code>. Trackball, D-pad and swipes all arrive here.</td></tr>
</table>
<p>Handle <code>"dir"</code> if you want your app to work on every board. A touch-only board sends swipes as directions, and a keyboard board sends its navigation keys the same way, so one branch covers both.</p>
@@ -274,6 +281,7 @@ end</code></pre>
<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.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>
</table>
<p>These are snapshots taken when you call them, not live views — call again on each tick to refresh.</p>
</div></div>
@@ -298,7 +306,23 @@ end</code></pre>
<tr><td>wada.store.set(key, value)</td><td>Write a string.</td></tr>
</table>
<p>Keys are namespaced per app, so two apps cannot collide. Keep it small — the budget is about 2&nbsp;KB per app, and the underlying store silently drops oversized values. High scores and settings, not logs.</p>
<p>Writes hit flash. Do them in <code>on_stop()</code> or on a real user action, never on every tick.</p>
<p>Writes hit flash. Do them in <code>app.on_close()</code> or on a real user action, never on every tick.</p>
</div></div>
</section>
<section class="doc" id="fs">
<h2>wada.fs <span class="chip">ext</span></h2>
<div class="row"><div class="prose">
<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.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>
<tr><td>wada.fs.remove(name)</td><td>Delete a file.</td></tr>
</table>
<p>Writes are rate-limited to roughly one per second and files are capped at 32&nbsp;KB. A write inside the window returns <code>false, "too fast"</code> — that is the normal answer, not a failure, so check it. The limit is not arbitrary: on a board with no card this lands on the same internal flash the mesh uses, and an app looping on writes would stall the whole device.</p>
</div></div>
</section>
@@ -310,6 +334,12 @@ end</code></pre>
<tr><td>wada.sys.board()</td><td><code>{w, h, touch, keyboard, trackball, gps}</code> — size and capabilities.</td></tr>
<tr><td>wada.sys.toast(msg)</td><td>Brief on-screen message.</td></tr>
<tr><td>wada.sys.random(n)</td><td>Integer in 1..n.</td></tr>
<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}</code>. Check <code>sdk_ext</code> before using anything marked <span class="chip">ext</span> below.</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}</code>, or <code>nil</code> with no fix — which is the normal indoor case, so handle it.</td></tr>
</table>
</div></div>
</section>
@@ -325,6 +355,21 @@ end</code></pre>
</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>
</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><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>
</section>
<section class="doc" id="format">
<h2>App format</h2>
<div class="row"><div class="prose">
@@ -377,6 +422,8 @@ end</code></pre>
<div class="row"><div class="prose">
<p>Apps run in a restricted environment. These are removed: <code>io</code>, <code>os</code>, <code>require</code>, <code>dofile</code>, and loading new chunks at runtime. Available: <code>math</code>, <code>string</code>, <code>table</code>, and the usual <code>pairs</code>, <code>ipairs</code>, <code>select</code>, <code>pcall</code>, <code>tostring</code>, <code>tonumber</code>.</p>
<p>Memory comes from a capped pool in PSRAM, so an app that allocates without bound fails its own allocation rather than starving the radio or the UI. There is an 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>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>
</section>
+14
View File
@@ -0,0 +1,14 @@
# beta_65 — test build. Stable stays beta_63.
# One user-facing note per non-blank, non-# line; # lines are section comments.
# --- Fixes for what beta_64 testers hit -------------------------------------------------
Updating no longer leaves the device stuck on "LoRa radio not detected". On boards where the radio sits behind a peripheral power switch, an update ends in a software reset that drops that power and then checks for the radio before it has finished coming back up - so the first boot after an update could dead-end on the error screen until you power-cycled by hand. The firmware now does that power-cycle itself and tries again, so the update just works.
Leaving an app by the back arrow no longer also pulls the status bar dropdown open. One tap did two things: it went back, and then the same tap fell through to the dropdown toggle.
# --- Position sharing, without broadcasting it ----------------------------------------------
You can now share your position with specific contacts instead of putting it in your advert. An advert is broadcast in the clear to every node in range; this is sent only when a contact you chose asks for it, and it is encrypted to them. Settings > Mesh > "Share my location when asked" - Never, Chosen contacts only, or Anyone who asks - and you pick the contacts from each contact's own menu, where you can also take it back later. Requested by janmashat.
# --- For people writing apps -------------------------------------------------------------
The Lua SDK documentation at wadamesh.com/sdk.html was wrong about the most basic thing and is now correct. It said to define your callbacks as globals named on_start and on_stop; the firmware has never called those. An app is a table you build and return, with on_open, on_tick, on_input, on_message and on_close. Following the old page gave you an app where nothing ran and nothing was reported - a blank screen. If you tried to write an app and gave up, that is why, and it is worth another go.
The page also now documents everything added in beta_64: the clock, the buzzer, battery, GPS, per-app file storage, sending mesh messages, receiving them, and the permission model behind the last two.
The app and language Store is served from a place nothing was publishing to, so translations sat at an older version than the ones in the project and a published app never appeared at all. Fixed, with the Hungarian, German and Dutch updates now live along with the SDK Test app.
+62
View File
@@ -0,0 +1,62 @@
#!/usr/bin/env bash
# wadamesh app+language store deploy: publish deploy/apps/ to the firmware VPS.
#
# The device reads BOTH catalogs from the firmware host, NOT the site host:
# http://firmware.wadamesh.com/apps/apps.json <- Store > Apps
# http://firmware.wadamesh.com/apps/langs.json <- Store > Languages
# http://firmware.wadamesh.com/apps/lang/<ver>/<code>.lang
# so this tree lives under the FIRMWARE web root, alongside releases/ and latest*/.
#
# WHY THIS SCRIPT EXISTS: there wasn't one. release.sh rsyncs out/firmware/ (which
# does not contain apps/) and deploy-site.sh rsyncs deploy/site/ — so the store was
# only ever updated by someone remembering to rsync it by hand, and twice they did
# not. Translations sat at v8 on the server while the repo had v9/v10/v11, and a
# published app never appeared at all. Editing deploy/apps/ is not publishing it;
# running this is.
#
# Usage:
# WADAMESH_VPS=user@your-vps scripts/deploy-apps.sh # deploy
# WADAMESH_VPS=user@your-vps scripts/deploy-apps.sh --dry-run # preview only
#
# Optional:
# WADAMESH_FW_PATH=/srv/wadamesh/firmware # override the firmware web root
#
# The VPS target comes from the environment — never commit it.
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
SRC="$ROOT/deploy/apps/"
DEST="${WADAMESH_VPS:?set WADAMESH_VPS=user@host}"
DEST_PATH="${WADAMESH_FW_PATH:-/srv/wadamesh/firmware}/apps"
DRY=""
[ "${1:-}" = "--dry-run" ] && DRY="--dry-run"
# Sanity-check the catalogs before shipping them: a device that gets malformed JSON
# shows an empty store with no clue why, and the versioned .lang dir each catalog
# entry points at has to actually exist or the download 404s on the device.
python3 - "$ROOT" <<'PY'
import json, os, sys
root = sys.argv[1]
apps_dir = os.path.join(root, "deploy", "apps")
langs = json.load(open(os.path.join(apps_dir, "langs.json")))["langs"]
missing = [f"{l['code']} v{l['ver']}" for l in langs
if not os.path.isfile(os.path.join(apps_dir, "lang", str(l["ver"]), l["code"] + ".lang"))]
if missing:
sys.exit("ABORT: langs.json points at files that do not exist: " + ", ".join(missing))
json.load(open(os.path.join(apps_dir, "apps.json"))) # parse check
print(f"catalogs ok: {len(langs)} languages, every referenced .lang present")
PY
# --delete mirrors the repo: the store is generated content, the repo is the source
# of truth, and stale versioned dirs left on the server are how a device ends up
# offered a language file nothing points at any more.
rsync -avz $DRY --delete "$SRC" "$DEST:$DEST_PATH/"
if [ -z "$DRY" ]; then
echo
echo "deployed deploy/apps/ -> $DEST:$DEST_PATH"
echo "verify:"
echo " curl -s http://firmware.wadamesh.com/apps/langs.json"
echo " curl -s http://firmware.wadamesh.com/apps/apps.json"
fi
+25
View File
@@ -971,6 +971,31 @@ public:
* evictable when the table filled (#178): the star lived only in
* TouchPrefs, invisible to the core. Returns false if the contact is gone;
* a no-op flag state is success without a save. */
// Per-contact telemetry permissions (#266: share position with chosen contacts
// instead of broadcasting it in every advert).
//
// MeshCore packs these into contact.flags ABOVE the favourite bit — the request
// handler does `contact.flags >> 1` before masking with TELEM_PERM_* — so the bit
// for a given TELEM_PERM_x lives at (x << 1) here. They only take effect for a
// category whose telemetry_mode_* is TELEM_MODE_ALLOW_FLAGS; ALLOW_ALL ignores
// them and answers everyone, DENY answers nobody.
static uint8_t contactTelemBit(uint8_t telem_perm) { return (uint8_t)(telem_perm << 1); }
bool uiGetContactTelemetryPerm(const uint8_t pub_key[32], uint8_t telem_perm) {
ContactInfo* slot = lookupContactByPubKey(pub_key, PUB_KEY_SIZE);
return slot && (slot->flags & contactTelemBit(telem_perm)) != 0;
}
bool uiSetContactTelemetryPerm(const uint8_t pub_key[32], uint8_t telem_perm, bool on) {
ContactInfo* slot = lookupContactByPubKey(pub_key, PUB_KEY_SIZE);
if (!slot) return false;
const uint8_t bit = contactTelemBit(telem_perm);
const uint8_t nf = on ? (uint8_t)(slot->flags | bit) : (uint8_t)(slot->flags & ~bit);
if (nf == slot->flags) return true;
slot->flags = nf;
saveContacts();
return true;
}
bool uiSetContactFavorite(const uint8_t pub_key[32], bool fav) {
ContactInfo* slot = lookupContactByPubKey(pub_key, PUB_KEY_SIZE);
if (!slot) return false;
+26
View File
@@ -740,6 +740,32 @@ void setup() {
bool radio_ok = radio_init();
if (!radio_ok) { delay(150); radio_ok = radio_init(); } // one retry: transient SPI/reset flakes
#if defined(PIN_PERF_POWERON)
// Last resort: do what the error screen would otherwise ask the USER to do —
// power-cycle the module — by bouncing the rail that feeds it.
//
// On boards with a peripheral power gate (T-Deck, GPIO10) the LoRa module is only
// powered once that pin is driven high in Board::begin(), a few milliseconds before
// it gets probed. A cold start survives that because the rail was already settled;
// a SOFTWARE reset does not. An OTA update ends in ESP.restart(), which releases the
// pin, collapses the rail, and re-drives it immediately — so the radio is probed
// while it is still coming up, and the boot dead-ends on "LoRa radio not detected"
// until the user power-cycles by hand (reported on a T-Deck straight after an
// update; a reboot cleared it). A brief, deliberate LOW gives the module a clean
// power-on reset regardless of how this boot was reached.
//
// Only reached when the alternative is halting with the fatal screen, so it cannot
// regress a healthy boot: a board that probes fine never runs any of this.
if (!radio_ok) {
Serial.println("[BOOT] radio not detected — power-cycling the peripheral rail and retrying");
digitalWrite(PIN_PERF_POWERON, LOW);
delay(120); // long enough for the rail to actually drain
digitalWrite(PIN_PERF_POWERON, HIGH);
delay(250); // POR + crystal startup before the module answers SPI
radio_ok = radio_init();
if (radio_ok) Serial.println("[BOOT] radio came up after the power-cycle");
}
#endif
if (!radio_ok) {
// A dead or absent LoRa module used to halt behind the frozen boot logo,
// with the only clue on serial (#244). Say it on the panel instead.
+139 -2
View File
@@ -9996,6 +9996,19 @@ static void buildProfileSettings() {
}
}
// Location telemetry (#266). Separate from the plain telemetry switch on purpose:
// battery/environment is not position, and a single "allow telemetry" must never be
// able to hand out where you are. Dropdown order matches TELEM_MODE_*: 0 deny,
// 1 allow-flags (chosen contacts), 2 allow-all.
static void locTelemetryModeChangedCb(lv_event_t* e) {
if (lv_event_get_code(e) != LV_EVENT_VALUE_CHANGED || !g_lv.task) return;
const uint8_t sel = (uint8_t)lv_dropdown_get_selected(lv_event_get_target(e));
g_lv.task->setLocationTelemetryMode(sel);
const char* msg = (sel == TELEM_MODE_ALLOW_ALL) ? TR("Location shared with anyone who asks")
: (sel == TELEM_MODE_ALLOW_FLAGS) ? TR("Location shared with chosen contacts only")
: TR("Location sharing off");
g_lv.task->showAlert(msg, 1600);
}
static void pathHashModeChangedCb(lv_event_t* e) {
if (lv_event_get_code(e) != LV_EVENT_VALUE_CHANGED || !g_lv.task) return;
uint8_t sel = (uint8_t)lv_dropdown_get_selected(lv_event_get_target(e));
@@ -10319,6 +10332,51 @@ static void buildRadioSettings() {
y += LV_MAX(SC(40), h + SC(12));
}
// Position sharing (#266). Until now the only way to share position was to put it
// in the advert, which broadcasts it in the clear to every node in range, forever.
// MeshCore can answer position on REQUEST instead — encrypted to the contact that
// asked, and only to contacts you picked — but nothing ever set telemetry_mode_loc,
// so that whole path was unreachable. "Chosen contacts" reads the per-contact
// permission set from each contact's own menu.
mk_label(TR("Share my location when asked"));
{
lv_obj_t* dd = lv_dropdown_create(body);
lv_obj_set_size(dd, lv_pct(100), SC(34));
lv_obj_set_pos(dd, 2, y);
// Order MUST match TELEM_MODE_*: 0 = deny, 1 = allow-flags, 2 = allow-all.
lv_dropdown_set_options(dd, TR("Never\nChosen contacts only\nAnyone who asks"));
lv_obj_set_style_text_font(dd, &g_font_12, LV_PART_MAIN);
lv_obj_set_style_bg_color(dd, lv_color_hex(COLOR_PANEL), LV_PART_MAIN);
lv_obj_set_style_text_color(dd, lv_color_hex(COLOR_TEXT), LV_PART_MAIN);
lv_obj_set_style_border_color(dd, lv_color_hex(0x18191A), LV_PART_MAIN);
lv_obj_t* loclist = lv_dropdown_get_list(dd);
lv_obj_set_style_bg_color(loclist, lv_color_hex(COLOR_PANEL), LV_PART_MAIN);
lv_obj_set_style_text_color(loclist, lv_color_hex(COLOR_TEXT), LV_PART_MAIN);
lv_obj_set_style_text_font(loclist, &g_font_12, LV_PART_MAIN);
uint8_t lm = prefs ? prefs->telemetry_mode_loc : TELEM_MODE_DENY;
if (lm > TELEM_MODE_ALLOW_ALL) lm = TELEM_MODE_DENY;
lv_dropdown_set_selected(dd, lm);
lv_obj_add_event_cb(dd, locTelemetryModeChangedCb, LV_EVENT_VALUE_CHANGED, nullptr);
lv_obj_add_event_cb(dd, clampDropdownListCb, LV_EVENT_CLICKED, nullptr);
y += SC(40);
}
{
// Be explicit about the two things that surprise people: this is answered on
// request (nothing is broadcast), and MeshCore only replies at all when plain
// telemetry is allowed, so the switch above is a prerequisite rather than an
// unrelated setting.
lv_obj_t* note = lv_label_create(body);
lv_label_set_text(note, TR("Sent only when that contact asks, encrypted to them — not broadcast. "
"Needs \"Answer telemetry requests\" on. Pick contacts in a contact's menu."));
lv_obj_set_width(note, s_settings_content_w - SC(4));
lv_label_set_long_mode(note, LV_LABEL_LONG_WRAP);
lv_obj_set_style_text_font(note, &g_font_12, LV_PART_MAIN);
lv_obj_set_style_text_color(note, lv_color_hex(COLOR_SUB), LV_PART_MAIN);
lv_obj_set_pos(note, 2, y);
lv_obj_update_layout(note);
y += lv_obj_get_height(note) + SC(10);
}
// Multi-byte routing: how many bytes of each repeater's hash this node stamps
// into the path when it adverts / sends. 1 byte (legacy) collides in large
// regions; 2-3 bytes disambiguate. Applied immediately + persisted (no radio
@@ -15803,6 +15861,40 @@ static void actionSheetFavoriteCb(lv_event_t* e) {
#endif
}
// Share position with THIS contact (#266). Sharing position used to mean putting it
// in the advert, i.e. broadcasting it in the clear to everyone in range. MeshCore can
// instead answer position only when a contact asks, encrypted to that contact — the
// permission is a bit in contact.flags, read whenever telemetry_mode_loc is
// ALLOW_FLAGS. Granting is per contact and revocable from the same menu.
static void actionSheetShareLocCb(lv_event_t* e) {
if (lv_event_get_code(e) != LV_EVENT_CLICKED || !g_lv.task) return;
#if defined(ESP32)
ContactInfo c;
const bool ok = the_mesh.getContactByIdx(s_action_sheet_mesh_idx, c);
closeActionSheet();
if (!ok) { g_lv.task->showAlert(TR("Contact gone"), 1200); return; }
const bool was = the_mesh.uiGetContactTelemetryPerm(c.id.pub_key, TELEM_PERM_LOCATION);
if (!the_mesh.uiSetContactTelemetryPerm(c.id.pub_key, TELEM_PERM_LOCATION, !was)) {
g_lv.task->showAlert(TR("Contact gone"), 1200);
return;
}
if (!was) {
// Granting one contact is an unambiguous "share with someone". If position sharing
// is switched off entirely the grant would silently do nothing, so move to the
// per-contact mode — which still shares with nobody except the contacts picked here.
NodePrefs* np = the_mesh.getNodePrefs();
if (np && np->telemetry_mode_loc == TELEM_MODE_DENY)
g_lv.task->setLocationTelemetryMode(TELEM_MODE_ALLOW_FLAGS);
g_lv.task->showAlert(TR("Location shared with this contact"), 1600);
} else {
g_lv.task->showAlert(TR("Location no longer shared"), 1600);
}
#else
closeActionSheet();
g_lv.task->showAlert(TR("Not supported on this board"), 1100);
#endif
}
// Block / unblock toggle: stores the sender's 6-byte pub-key prefix in the
// ignore list (newMsgImpl drops messages from ignored senders). Same prefix
// scheme as favorites; managed here or from the chat "Blocked users" sheet.
@@ -16667,6 +16759,19 @@ static void openContactActionSheet(uint32_t mesh_idx, bool is_repeater, const ch
actionSheetFavoriteCb, 0);
#else
mk_btn(TOUCH_SYM_STAR " Favorite", actionSheetFavoriteCb, 0);
#endif
// Share position with this contact (#266) — label flips on the current grant.
// Hidden on the map-marker sheet, which stays compact.
#if defined(ESP32)
if (!from_map) {
bool _loc_shared = false;
ContactInfo _lc;
if (the_mesh.getContactByIdx(s_action_sheet_mesh_idx, _lc))
_loc_shared = the_mesh.uiGetContactTelemetryPerm(_lc.id.pub_key, TELEM_PERM_LOCATION);
mk_btn(_loc_shared ? TR(LV_SYMBOL_GPS " Stop sharing loc")
: TR(LV_SYMBOL_GPS " Share my loc"),
actionSheetShareLocCb, 0);
}
#endif
mk_btn(TR(LV_SYMBOL_LOOP " Reset path"), actionSheetResetPathCb, 0);
// Block / unblock — label flips on the current ignore state. Skipped when the
@@ -40194,6 +40299,13 @@ static void openAppDrawer() {
// Status-bar long-hold (3 s) -> full-screen screenshot to /screenshots on SD.
static uint32_t s_sb_press_ms = 0;
static bool s_sb_shot_done = false;
// millis() of a press the bar's PRESSED handler already used to leave an app page, so
// the CLICKED from that same tap gets swallowed instead of toggling the control center.
// TIMESTAMPED rather than a plain flag on purpose: the whole reason that handler closes
// on touch-DOWN is that the matching CLICKED sometimes never arrives (the cap-touch swipe
// detector aborts it), and a sticky bool would then eat the NEXT genuine bar tap. A stale
// one simply expires.
static uint32_t s_sb_back_ms = 0;
static void takeScreenshotToSd(); // fwd
static void statusBarHoldCb(lv_event_t* e) {
switch (lv_event_get_code(e)) {
@@ -40218,6 +40330,11 @@ static void statusBarTapCb(lv_event_t* e) {
// statusBarReaderBackCb, which they never got and which is the only reliable exit on a
// cap-touch board that drops the lone CLICKED.
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).
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
// chevron + title, so a tap goes back (closes the page) just like settings.
if (s_apppage_close) { s_apppage_close(); return; }
@@ -40252,8 +40369,13 @@ static void statusBarReaderBackCb(lv_event_t* e) {
// exited only by the bar's CLICKED -> s_apppage_close. On cap-touch the swipe detector's
// click-abort (lvglTouchRead) can drop that lone CLICKED, trapping the user on a touch-only board
// (the P4). Close on PRESSED too — the proven Reader fallback, now generalised to every app page.
// close() clears s_apppage_close, so the later CLICKED in statusBarTapCb is then a no-op.
if (s_apppage_close) s_apppage_close();
//
// The CLICKED that follows this same tap must be SWALLOWED. Clearing s_apppage_close does not
// make it harmless (the original claim here): statusBarTapCb only skips its app-page branch and
// then falls through every other branch to the control-center toggle at its end — so exiting any
// app page by the "‹" also popped the dropdown open. Harmless while this was Reader-only, wrong
// for every app page since beta_49.
if (s_apppage_close) { s_apppage_close(); s_sb_back_ms = millis() | 1u; } // |1: 0 means "none"
}
// Capture the composited screen to a 16-bit BMP on the SD card. BMP (no
@@ -49009,6 +49131,21 @@ void UITask::setTelemetryAllow(bool on) {
_node_prefs->telemetry_mode_env = on ? TELEM_MODE_ALLOW_ALL : TELEM_MODE_DENY;
the_mesh.savePrefs();
}
// Position sharing, deliberately its own control rather than part of the switch above
// (#266). DENY / ALLOW_FLAGS (only contacts you granted it) / ALLOW_ALL.
void UITask::setLocationTelemetryMode(uint8_t mode) {
if (!_node_prefs) return;
if (mode > TELEM_MODE_ALLOW_ALL) mode = TELEM_MODE_DENY;
_node_prefs->telemetry_mode_loc = mode;
// MeshCore answers a telemetry request at all only when the BASE permission is set,
// so location would silently never be sent with the master switch off. Turning
// sharing ON implies answering requests; say so via the note under the dropdown
// rather than leaving the user with a setting that does nothing.
if (mode != TELEM_MODE_DENY && _node_prefs->telemetry_mode_base == TELEM_MODE_DENY) {
_node_prefs->telemetry_mode_base = TELEM_MODE_ALLOW_ALL;
}
the_mesh.savePrefs();
}
void UITask::setExperimentalFlags(uint8_t multi_acks, uint8_t client_repeat, uint8_t rx_boosted) {
if (!_node_prefs) return;
+1
View File
@@ -490,6 +490,7 @@ public:
void setPathHashMode(uint8_t mode);
void setExperimentalFlags(uint8_t multi_acks, uint8_t client_repeat, uint8_t rx_boosted);
void setTelemetryAllow(bool on); // answer mesh telemetry requests (battery+env; location stays separate)
void setLocationTelemetryMode(uint8_t mode); // TELEM_MODE_* — position on request (#266)
/** Meshcomod CLI on device: `wifi on` / `wifi off`. */
bool setWifiRadio(bool on);
bool isTcpEnabled() const { return _serial && _serial->isTcpEnabled(); }