Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
de45b1b158 | ||
|
|
ea673f86a5 | ||
|
|
ca45bd4098 | ||
|
|
b014865ada | ||
|
|
fee132425c | ||
|
|
17643435ea |
No files matched your search
+67
-20
@@ -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 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> / <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 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 × 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 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 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 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 → 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 — <code>wada.fs</code>, <code>wada.mesh.send</code>, <code>sys.battery</code>, <code>sys.gps</code> — 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 KB file cap, and <code>wada.mesh.send</code> has a 5 second floor and a 180-character limit. They return <code>false, "too fast"</code> rather than throwing, and hitting them is expected — 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>
|
||||
|
||||
@@ -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.
|
||||
Executable
+62
@@ -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
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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(); }
|
||||
|
||||
Reference in new issue
Block a user