Merge remote-tracking branch 'upstream/main' into 271

This commit is contained in:
Michael A. Cojocari committed 2026-08-22 14:52:20 -04:00
commit 98803f1f14
17 files changed
+2070 -62

No files matched your search

+311
View File
@@ -0,0 +1,311 @@
# Console mode
A boot mode where the device runs with **no LVGL**: a text console driven by
typed commands, with core mesh functionality and Lua apps. For boards that
struggle under the UI, and for people who would rather type than tap.
Regular mode is unaffected and you can go back at any time.
---
## Short answer
**Possible: yes, and considerably more of it already exists than you would
expect.** Four of the five pieces are built and shipping today.
**A lot of work: no, not for a usable console.** The work is not the console
itself, it is everything that currently assumes the UI is there. Realistic
shape: a useful console in a few days of work, a polished one that runs Lua
apps in a couple of weeks.
**Saves resources: yes, but not the resource you might think.** See
[What it actually saves](#what-it-actually-saves) below, because this changes
how the feature should be sold and to whom.
---
## What already exists
This is the reason the estimate is not larger.
| Piece | Where | State |
|---|---|---|
| **Command surface** | `CommonCLI::handleCommand` (MeshCore core) | Done. The full command set the node already answers over serial and over the mesh. |
| **Run a command locally, capture its output** | `MyMesh::runLocalCli()` + `MyMesh::setTerminalSink()` | Done. Feed it a line, get reply lines back through a callback. |
| **Text rendering with no LVGL** | `DisplayDriver` (core): `startFrame`, `setCursor`, `print`, `printWordWrap`, `getTextWidth`, `fillRect`, `drawTextEllipsized`, `endFrame`, UTF-8 to blocks | Done, and linked into the touch build already. |
| **A mode where LVGL does not drive the panel** | Remote mode (`s_remote_mode` in `UITask.cpp`) | Done and shipping. It paints the physical screen with `display.drawTextCentered(...)` directly while LVGL renders elsewhere. This is the exact precedent. |
| **An on-device terminal** | `homeTerminalCb` / `s_term_log_box` in `UITask.cpp` | Done, but built out of LVGL widgets. The *behaviour* is the model; the widgets are what console mode replaces. |
So the console does not need a new command language, a new renderer, or a new
way of talking to the mesh. It needs a different **front end** over machinery
that is already there and already tested.
---
## What it actually saves
Worth being precise, because the obvious assumption is wrong in one direction
and right in another.
### PSRAM: a real saving, and it is the object tree, not the draw buffer
Everything LVGL allocates is PSRAM: `include/lv_conf.h` sets `LV_MEM_CUSTOM 1`
with `LV_MEM_CUSTOM_ALLOC lvglPsramAlloc`, so every object, style and font cache
entry is a PSRAM allocation, and the draw buffer is `MALLOC_CAP_SPIRAM` too.
**The draw buffer is not the prize.** `LV_DRAW_BUF_LINES` is 24, so it is
37.5 KB on the 800 px panels and **11.2 KB on the V4**. Worth having back, not
worth building a mode for.
The saving is the **object tree**: every screen, tab, list row, label, style and
cached glyph, all of it live for the life of the session. That is the number
Phase 0 exists to measure, because it is the one nobody here can quote from
memory.
For scale on the V4, its PSRAM is also carrying up to four 128 KB map tiles, so
whatever LVGL is holding sits alongside that. Console mode frees the LVGL half
outright.
**This matters most exactly where you said it hurts.** The Heltec V4 has 2 MB of
PSRAM, and PSRAM pressure there is a recurring source of real bugs: map tiles
fragmenting the heap, the tile pool needing a cull and a cap, the extended Lua
SDK gated off the board entirely for want of memory. Console mode gives that
board its PSRAM back.
### Internal DRAM: a smaller saving than it looks
The scarce resource on these boards is internal DRAM, and LVGL is largely not
in it. What *is* in it is UITask's static data: the contact cache, message
rings, layout tables. Most of that is data the console still needs to do the
same job, so it does not simply disappear.
Two caveats worth keeping honest:
- There is a fallback at boot: if the PSRAM draw-buffer allocation fails,
`malloc()` puts it in internal DRAM instead. On a board already under
pressure that is exactly when it happens, so for the worst-off devices the
DRAM saving is real after all.
- A meaningful DRAM number needs measuring, not guessing. See Phase 0.
### CPU: a real saving, and probably the one users feel
`lv_timer_handler()` runs on every pass of the main loop, alongside input-device
polling and any animation or scroll in flight, and a redraw blits through SPI.
A console redraws only when a line is added.
This is also the mechanism behind several bugs we have already fixed: the loop
being stalled by the UI is what starved the GPS UART ring, and what made SPIFFS
garbage collection visible as a freeze. Less time in the UI is less exposure to
that whole class of problem.
**How to describe the feature honestly:** it buys back PSRAM and CPU. On the
V4 that is the difference between comfortable and not. It is not a fix for
internal-DRAM exhaustion on its own.
---
## The hard parts
Not the console. These three.
### 1. Everything that assumes the UI exists
`src/main.cpp` calls `ui_task.showAlert(...)` in roughly twenty places to report
Wi-Fi and Bluetooth outcomes, and `ui_task.loop()` unconditionally. UITask is
also the owner of things the console still wants: the storage root, the Lua app
host, the update check.
The wrong fix is `#if` sprinkled through main. The right one is that UITask
keeps its role as the front end and gains a **headless personality**: the same
entry points, LVGL never initialised, `showAlert` becoming a console line.
### 2. Lua apps: the `wada.ui` translator
Decided: `wada.ui` gets a **console backend** behind the same names, so an app
keeps working without knowing which mode it is in.
This turns out to be far more achievable than "no UI" suggests, because console
mode is **not a text-only terminal**. It still owns a real pixel panel. The
thing being removed is LVGL's object tree, not the ability to draw.
The enabler is a raw framebuffer blit that already exists outside LVGL:
```
ST7789LCDDisplay::writePixelsRGB565(x, y, w, h, const uint16_t* pixels)
```
Present today on `ST7789LCDDisplay` (V4 TFT, T-Deck), `TanmatsuDisplay`,
`RM69A10Display` and `HI8561Display`. Missing only on `LGFXDisplay` (V4-R8) and
`ST7796LCDDisplay` (the pagers), and LovyanGFX has `pushImage` underneath, so
those are a wrapper method rather than new work.
That changes the answer on the two APIs that looked impossible:
| API | Console backend | Why it works |
|---|---|---|
| `label` | Text at a cursor. | `print` / `printWordWrap` / `drawTextEllipsized`. |
| `button` | A highlighted line with a selection index; activated by key or tap. | Text plus `fillRect` for the highlight. |
| `list` | Numbered lines, one selected. | Same. |
| `input` | A prompt, using the console's own keypad. | Phase 1 already builds it. |
| `text_w` / `text_lines` | `getTextWidth`, honestly measured. | Already the right shape. |
| `chart` | Drawn directly. | It is bars and lines; `fillRect` does it. |
| **`canvas`** | **Works.** The app already gets an RGB565 buffer and draws into it; console mode blits that buffer instead of handing it to LVGL. | `writePixelsRGB565`. |
| **`map`** | **Works.** Tiles are already decoded to RGB565 buffers (`MapTile.rgb565`); the projection and cache are ours and have no LVGL in them. | Same blit, plus markers as `fillRect`. |
| Everything non-UI | Unchanged. | `mesh`, `fs`, `net`, `crypto`, `geo`, `sys`, `timer`, `store` never touched LVGL. |
**What is genuinely lost is interaction, not drawing.** LVGL supplies scrolling
containers, a focus group, flex layout and event bubbling. The console backend
has to provide its own much simpler version: a line cursor, a selection index,
a scroll offset. That is the real work in this phase, and it is bounded.
So the honest expectation is that **most apps work**, including the graphical
ones, and a few that lean on LVGL behaviour we do not reimplement will not.
Apps already feature-detect through `wada.sys.caps()`, so anything that cannot
be supported is reported rather than broken, and `caps().console` lets an app
adapt its layout deliberately.
### 3. Getting back out
A boot flag that lands you in a console with no way back is a device that feels
bricked, and it will happen to somebody. This needs to be designed, not bolted
on. See [Safety](#safety).
---
## Plan
Each phase is shippable and useful on its own. Stop after any of them and
nothing is half-built.
### Phase 0: measure first
Before building anything, get the numbers the feature is being justified by.
- Free internal DRAM and free PSRAM at the home screen, on a V4 and a T-Deck.
- The same with `lv_timer_handler()` stubbed out and nothing built.
- Loop iterations per second, both ways.
Cheap, and it either confirms the case or changes what to build. If the DRAM
saving turns out to be negligible on the V4 and the PSRAM saving large, that is
worth knowing before the docs promise anything.
**Output:** real numbers in this file.
### Phase 1: the console front end
A `ConsoleUI` that owns the panel through `DisplayDriver`, with no LVGL.
- Scrollback ring in PSRAM, N lines, prune oldest.
- Prompt line, cursor, word wrap via `printWordWrap`.
- Input from the hardware keyboard where there is one.
- **An on-screen keypad on touch boards.** Decided: the V4 is the board that
most needs its resources back, so console mode has to be usable there without
a keyboard. The existing on-screen keyboard is LVGL and cannot be reused, so
the console draws its own: `fillRect` + `drawRect` + `print` for the keys, and
touch read straight from the board driver (`heltecV4CapTouchCheck()` and its
equivalents), which is already independent of LVGL. `lvglTouchRead` is only an
adapter feeding LVGL, so nothing needs to be untangled first.
- Commands go straight to `the_mesh.runLocalCli()`; output arrives through
`setTerminalSink`. This part is close to free.
- One touch affordance, as you said: a back/exit target.
At the end of this phase the device is usable over the mesh from a console.
### Phase 2: boot into it, and back out
- A pref, `touchPrefsGetBootMode()`, read early in `setup()`.
- In console mode: never call `lv_init()`, never allocate the draw buffer, never
build the UI. This is where the saving comes from, so it has to be a genuine
skip and not a hidden LVGL instance.
- `ui_task.showAlert()` and friends route to the console.
- Commands to switch: `ui` reboots into the graphical mode, `console` reboots
into this one. Also a Settings toggle in normal mode.
### Phase 3: the things the CLI does not cover
`CommonCLI` is a node CLI, not a messenger. Console mode needs conversational
commands, some of which the existing on-device terminal already has (`to <name>`
sets a recipient):
- `msg`, `to`, `reply`, unread counts, reading a thread's scrollback
- `contacts`, `chans`, `discover` with the results table
- `apps`, `run <id>`
Most of this is a thin layer over calls that already exist for the UI.
### Phase 4: Lua apps in the console
- A `WADA_UI_CONSOLE` backend behind the same `wada.ui` names, per the table
above.
- `caps()` gains `console`, `canvas`, `map` so apps can adapt, and the docs say
which apps work.
- Store apps audited; those that cannot work say so on launch rather than
failing oddly.
### Phase 5: polish
- Command history and tab completion.
- Colour, where the panel has it.
- A `help` that is actually good, since there is no discoverable UI.
- Docs page, and a section in the user guide.
---
## Safety
The failure that matters is a device that boots to a console the owner cannot
leave. Three independent ways out, because one is not enough:
1. **A command.** `ui` then reboot.
2. **A key held at boot.** Any boot with the key down forces graphical mode
regardless of the pref, the same shape as the existing recovery paths.
3. **The web flasher and the serial CLI** can always clear the pref.
And the pref should be stored so that a corrupt or unreadable value means
*graphical*, never console. Fail toward the mode everyone can use.
---
## Risks
| Risk | Containment |
|---|---|
| Console mode rots because nobody builds it | It compiles into every build from Phase 1, and SDK Test runs in it. A broken console fails the build, not a user. |
| Two front ends drift apart | The console consumes the same `runLocalCli` and the same MyMesh accessors the UI does. Do not let it grow a private copy of anything. |
| Touch-only boards get a console they cannot type into | Decide in Phase 1. Requiring a keyboard is a legitimate answer. |
| It quietly degrades normal mode | Everything new is behind the boot pref, and the graphical path keeps its current code path unchanged. Phase 0's numbers are the regression check. |
| Lua apps half-work and look broken | `caps()` first, refuse to launch second, document third. Less likely now that canvas and map can actually be drawn. |
| Two display drivers lack a raw blit | `LGFXDisplay` and `ST7796LCDDisplay` need a `writePixelsRGB565`; LovyanGFX's `pushImage` does the work. Until then `caps().canvas` is false on those boards and apps adapt. |
---
## Effort
Honest, assuming no surprises:
| Phase | Effort |
|---|---|
| 0 measure | Half a day |
| 1 console front end (incl. the drawn keypad) | 3 to 4 days |
| 2 boot mode and back out | 1 to 2 days |
| 3 messaging commands | 2 to 3 days |
| 4 `wada.ui` console backend | 4 to 6 days |
| 5 polish and docs | 2 days |
**A console you can actually use on the mesh: Phases 0 to 3, about a week.**
Phase 4 is the one that can grow, because it is the one with a design question
in it rather than just code.
---
## Open decisions
Worth settling before Phase 1 rather than during it.
1. ~~Keyboard required?~~ **Decided: no.** Touch boards get a drawn on-screen
keypad, because the V4 is exactly the board this feature is for.
2. **Do Lua apps matter in v1?** Phases 0 to 3 are worth shipping without them.
The translator is now a known quantity rather than a question mark, so it can
follow safely.
3. **Does this replace the Terminal app** in graphical mode eventually, or do
both stay?
+1 -1
View File
@@ -21,7 +21,7 @@
{
"id": "sdktest",
"name": "SDK Test",
"ver": "1.3",
"ver": "1.5",
"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)."
},
{
+5 -2
View File
@@ -99,8 +99,11 @@ function app.on_open(w, h)
local LH = ui.text_h(12)
hdr = ui.label("", 4, 4, 12, C.accent); hdr:width(w - 10)
-- hdr and detail below are width-limited, which turns wrapping on, and both
-- carry variable-length text (node names, a status phrase). Reserve two lines
-- for each: a wrapped line would otherwise be drawn over by whatever follows.
local map_h = math.floor(h * 0.45)
local y = 4 + LH + 4
local y = 4 + LH * 2 + 4
if sys.caps().map then
map = wada.map.view(4, y, w - 8, map_h)
else
@@ -109,7 +112,7 @@ function app.on_open(w, h)
y = y + map_h + 4
detail = ui.label("pick a contact", 4, y, 12, C.sub); detail:width(w - 10)
y = y + LH + 4
y = y + LH * 2 + 4
list = ui.list(4, y, w - 8, h - y - 4)
+1
View File
@@ -0,0 +1 @@
{"id":"sdktest","name":"SDK Test","ver":"1.4","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)."}
+209
View File
@@ -0,0 +1,209 @@
-- SDK self-test. Exercises the extended SDK so the results can be read off the
-- screen instead of inferred from a build log. Published to the store as a
-- developer/bench tool.
--
-- 1.2 adds wada.crypto (checked against published RFC vectors, so a PASS here is
-- real evidence and not just "it returned something"), wada.mesh.channels, and
-- wada.mesh.send_dm.
-- 1.3 adds the discovery surface (wada.mesh.discover / discovered), packet
-- identity in rx_log, exact micro-degree coordinates, wada.ui.input and the
-- windowed wada.fs.read.
-- 1.4 fixes rows drawing on top of one another. label:width() turns wrapping
-- on, so any line wider than the screen became two lines while the caller still
-- advanced by one, and the next row landed on top. Reported with a photo of the
-- channels line sitting across the contacts line. row() now owns the cursor and
-- advances by the height the text actually needs.
local ui, sys, store, timer = wada.ui, wada.sys, wada.store, wada.timer
local C = ui.colors
local app = {}
local rows, keyline, sendline, dmline, msgline = {}, nil, nil, nil, nil
local discline, inputline = nil, nil
local W = 300
-- row() owns the vertical cursor. Every caller used to pass a y and then add a
-- hand-picked constant, which is only correct while the text fits on one line.
-- An app cannot measure rendered text, so the wrapped line count is estimated
-- from a deliberately pessimistic character width: over-estimating costs a
-- little whitespace, under-estimating overlaps the next row.
local LH, GAP = 14, 3
local cy = 4
local function row(text, color, extra_gap)
local l = ui.label(text, 6, cy, 12, color or C.text)
l:width(W - 12)
rows[#rows + 1] = l
local cpl = math.max(16, (W - 12) // 7)
local n = 0
for seg in (tostring(text) .. "\n"):gmatch("(.-)\n") do
n = n + math.max(1, math.ceil(#seg / cpl))
end
cy = cy + math.max(1, n) * LH + GAP + (extra_gap or 0)
return l
end
local function yn(v) return v and "yes" or "NO" end
function app.on_open(w, h)
W = w or 300
ui.scroll(true)
cy = 4
local y
local c = sys.caps()
row("caps: sdk_ext=" .. yn(c.sdk_ext) .. " kbd=" .. yn(c.keyboard) ..
" touch=" .. yn(c.touch) .. " sd=" .. yn(c.sd), C.accent)
row("caps: discover=" .. yn(c.discover) .. " input=" .. yn(c.input) ..
" rx_identity=" .. yn(c.rx_identity), C.accent)
-- crypto: published test vectors, so this is checkable rather than merely alive
if wada.crypto then
local hex = wada.crypto.hex
local k20 = string.rep(string.char(0x0b), 20)
local checks = {
{ "sha256('abc')", hex(wada.crypto.sha256("abc")),
"ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad" },
{ "sha1('abc')", hex(wada.crypto.sha1("abc")),
"a9993e364706816aba3e25717850c26c9cd0d89d" },
{ "hmac_sha1 RFC2202#1", hex(wada.crypto.hmac_sha1(k20, "Hi There")),
"b617318655057264e28bc0b6fb378c8ef146be00" },
{ "hmac_sha256 RFC4231#1", hex(wada.crypto.hmac_sha256(k20, "Hi There")),
"b0344c61d8db38535ca8afceaf0bf12b881dc200c9833da726e9376c2e32cff7" },
}
local allok = true
for _, t in ipairs(checks) do
local ok = (t[2] == t[3])
if not ok then allok = false end
row("crypto " .. t[1] .. ": " .. (ok and "PASS" or ("FAIL got " .. tostring(t[2]):sub(1, 16))),
ok and C.good or C.bad)
end
-- The point of having it in C: this loop would blow the instruction budget in Lua.
local t0 = sys.millis()
for _ = 1, 200 do wada.crypto.hmac_sha1(k20, "Hi There") end
row(string.format("crypto: 200 x hmac_sha1 in %d ms%s", sys.millis() - t0,
allok and "" or " (VECTORS FAILED)"), allok and C.good or C.bad)
else
row("wada.crypto: MISSING", C.bad)
end
-- channel discovery
local chans = wada.mesh.channels and wada.mesh.channels() or nil
if chans then
local names = table.concat(chans, ", ")
row("channels(): " .. #chans .. " " .. names:sub(1, 60), C.text)
else
row("wada.mesh.channels: MISSING", C.bad)
end
if not c.sdk_ext then
row("extended SDK is OFF on this board - stopping here.", C.sub)
return
end
-- contacts, so send_dm has a target to name. 1.3 also checks the pubkey field.
local cts = wada.mesh.contacts()
local first = cts[1] and cts[1].name or nil
row("contacts: " .. #cts .. (first and (" first=" .. first) or ""), C.text)
if cts[1] then
local pk = cts[1].pubkey
row("contacts[1].pubkey: " .. tostring(pk) ..
(type(pk) == "string" and #pk == 8 and " PASS" or " FAIL"),
(type(pk) == "string" and #pk == 8) and C.good or C.bad)
end
local me = wada.mesh.self()
row("self: " .. tostring(me.name) .. " pubkey=" .. tostring(me.pubkey), C.text)
-- Exact coordinates. Lua's floats here are 32-bit, so an app that logs a track
-- must use the _e6 integers; this proves they are present and consistent.
local fix = sys.gps()
if fix then
local drift = math.abs(fix.lat_e6 / 1e6 - fix.lat)
row(string.format("gps: %d,%d e6 alt %dm %d sats drift %.6f %s",
fix.lat_e6, fix.lon_e6, fix.alt_m or 0, fix.sats, drift,
drift < 0.001 and "PASS" or "FAIL"), drift < 0.001 and C.good or C.bad)
else
row("gps: no fix (normal indoors)", C.sub)
end
-- rx_log identity: adverts carry a real public key, addressed frames carry
-- one-byte hashes, everything else carries nothing. All three are correct.
local log = wada.mesh.rx_log()
local withpk, withsrc = 0, 0
for _, r in ipairs(log) do
if r.pubkey then withpk = withpk + 1 end
if r.src then withsrc = withsrc + 1 end
end
row(string.format("rx_log: %d frames, %d with a pubkey (adverts), %d with src/dst",
#log, withpk, withsrc), #log > 0 and C.text or C.sub)
-- fs: windowed read. Writes once (the 1/sec limit means one write per open).
if wada.fs then
local probe = "0123456789abcdef"
wada.fs.write("sdktest.bin", probe)
local part, total = wada.fs.read("sdktest.bin", 4, 4)
local ok = (part == "4567" and total == #probe)
row("fs.read(name,4,4): " .. tostring(part) .. " total=" .. tostring(total) ..
(ok and " PASS" or " FAIL"), ok and C.good or C.bad)
end
sendline = row("mesh.send: not tried yet", C.sub)
dmline = row("mesh.send_dm: not tried yet", C.sub)
msgline = row("on_message: waiting (needs the read permissions)", C.sub)
y = cy + 4
ui.button("Send to Public", 6, y, 120, 30, function()
local ok, err = wada.mesh.send("Public", "wadamesh SDK self-test")
sendline:set("mesh.send: " .. tostring(ok) .. " " .. tostring(err))
sendline:color(ok and C.good or C.bad)
end)
ui.button("DM first contact", 132, y, 130, 30, function()
if not first then dmline:set("mesh.send_dm: no contacts to target"); dmline:color(C.bad); return end
local ok, err = wada.mesh.send_dm(first, "wadamesh SDK self-test (DM)")
dmline:set("mesh.send_dm -> " .. first .. ": " .. tostring(ok) .. " " .. tostring(err))
dmline:color(ok and C.good or C.bad)
end)
cy = y + 38
discline = row("mesh.discover: not tried yet", C.sub)
inputline = row("ui.input: not tried yet", C.sub)
-- Probing TRANSMITS and makes every neighbour reply, so it is a button, not
-- something this app does on open.
y = cy + 4
ui.button("Probe", 6, y, 90, 30, function()
local tag, err = wada.mesh.discover()
if not tag then
discline:set("mesh.discover: " .. tostring(err)); discline:color(C.bad); return
end
discline:set("mesh.discover: sent, waiting for replies..."); discline:color(C.text)
end)
ui.button("Results", 102, y, 90, 30, function()
local hits = wada.mesh.discovered()
if #hits == 0 then
discline:set("discovered(): nothing yet - probe, then wait a few seconds")
discline:color(C.sub); return
end
local h = hits[1]
discline:set(string.format("discovered(): %d first %s snr %.1f/%.1f %s",
#hits, h.name or h.pubkey, h.snr, h.their_snr, h.direct and "direct" or (h.hops .. "h")))
discline:color(C.good)
end)
y = y + 34
ui.button("Beep", 6, y, 70, 30, function() sys.beep() end)
ui.button("Input", 82, y, 90, 30, function()
ui.input("Type anything", "hello", function(text)
inputline:set("ui.input -> " .. (text and ("'" .. text .. "'") or "cancelled"))
inputline:color(text and C.good or C.sub)
end)
end)
end
function app.on_input(ev)
if ev.type == "key" and keyline then
keyline:set("keys: got '" .. tostring(ev.key) .. "'")
keyline:color(C.good)
end
end
-- Proves the kind field and that DMs/rooms reach an app at all, not just channels.
function app.on_message(m)
if not msgline then return end
msgline:set("on_message: kind=" .. tostring(m.kind) .. " from=" .. tostring(m.sender) ..
" ch=" .. tostring(m.channel) .. " text=" .. tostring(m.text):sub(1, 20))
msgline:color(C.good)
end
return app
+1
View File
@@ -0,0 +1 @@
{"id":"sdktest","name":"SDK Test","ver":"1.5","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)."}
+221
View File
@@ -0,0 +1,221 @@
-- SDK self-test. Exercises the extended SDK so the results can be read off the
-- screen instead of inferred from a build log. Published to the store as a
-- developer/bench tool.
--
-- 1.2 adds wada.crypto (checked against published RFC vectors, so a PASS here is
-- real evidence and not just "it returned something"), wada.mesh.channels, and
-- wada.mesh.send_dm.
-- 1.3 adds the discovery surface (wada.mesh.discover / discovered), packet
-- identity in rx_log, exact micro-degree coordinates, wada.ui.input and the
-- windowed wada.fs.read.
-- 1.5 uses wada.ui.text_lines() to measure instead of estimating, where the
-- firmware has it. That call was added because of the 1.3 bug below: an app
-- could ask how tall a line is but not how wide, so laying out rows meant
-- guessing whether text would wrap. Falls back to the 1.4 estimate on older
-- firmware, so it still lays out correctly there.
-- 1.4 fixes rows drawing on top of one another. label:width() turns wrapping
-- on, so any line wider than the screen became two lines while the caller still
-- advanced by one, and the next row landed on top. Reported with a photo of the
-- channels line sitting across the contacts line. row() now owns the cursor and
-- advances by the height the text actually needs.
local ui, sys, store, timer = wada.ui, wada.sys, wada.store, wada.timer
local C = ui.colors
local app = {}
local rows, keyline, sendline, dmline, msgline = {}, nil, nil, nil, nil
local discline, inputline = nil, nil
local W = 300
-- row() owns the vertical cursor. Every caller used to pass a y and then add a
-- hand-picked constant, which is only correct while the text fits on one line.
-- An app cannot measure rendered text, so the wrapped line count is estimated
-- from a deliberately pessimistic character width: over-estimating costs a
-- little whitespace, under-estimating overlaps the next row.
local LH, GAP = 14, 3
local cy = 4
local function row(text, color, extra_gap)
local l = ui.label(text, 6, cy, 12, color or C.text)
l:width(W - 12)
rows[#rows + 1] = l
local n
if ui.text_lines then
n = ui.text_lines(tostring(text), W - 12, 12) -- measured: exact
else
local cpl = math.max(16, (W - 12) // 7) -- older firmware: estimate
n = 0
for seg in (tostring(text) .. "\n"):gmatch("(.-)\n") do
n = n + math.max(1, math.ceil(#seg / cpl))
end
end
cy = cy + math.max(1, n or 1) * LH + GAP + (extra_gap or 0)
return l
end
local function yn(v) return v and "yes" or "NO" end
function app.on_open(w, h)
W = w or 300
ui.scroll(true)
cy = 4
local y
local c = sys.caps()
row("caps: sdk_ext=" .. yn(c.sdk_ext) .. " kbd=" .. yn(c.keyboard) ..
" touch=" .. yn(c.touch) .. " sd=" .. yn(c.sd), C.accent)
row("caps: discover=" .. yn(c.discover) .. " input=" .. yn(c.input) ..
" rx_identity=" .. yn(c.rx_identity), C.accent)
row("layout: " .. (ui.text_lines and "measured (ui.text_lines)" or "estimated (older firmware)"),
ui.text_lines and C.good or C.sub)
-- crypto: published test vectors, so this is checkable rather than merely alive
if wada.crypto then
local hex = wada.crypto.hex
local k20 = string.rep(string.char(0x0b), 20)
local checks = {
{ "sha256('abc')", hex(wada.crypto.sha256("abc")),
"ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad" },
{ "sha1('abc')", hex(wada.crypto.sha1("abc")),
"a9993e364706816aba3e25717850c26c9cd0d89d" },
{ "hmac_sha1 RFC2202#1", hex(wada.crypto.hmac_sha1(k20, "Hi There")),
"b617318655057264e28bc0b6fb378c8ef146be00" },
{ "hmac_sha256 RFC4231#1", hex(wada.crypto.hmac_sha256(k20, "Hi There")),
"b0344c61d8db38535ca8afceaf0bf12b881dc200c9833da726e9376c2e32cff7" },
}
local allok = true
for _, t in ipairs(checks) do
local ok = (t[2] == t[3])
if not ok then allok = false end
row("crypto " .. t[1] .. ": " .. (ok and "PASS" or ("FAIL got " .. tostring(t[2]):sub(1, 16))),
ok and C.good or C.bad)
end
-- The point of having it in C: this loop would blow the instruction budget in Lua.
local t0 = sys.millis()
for _ = 1, 200 do wada.crypto.hmac_sha1(k20, "Hi There") end
row(string.format("crypto: 200 x hmac_sha1 in %d ms%s", sys.millis() - t0,
allok and "" or " (VECTORS FAILED)"), allok and C.good or C.bad)
else
row("wada.crypto: MISSING", C.bad)
end
-- channel discovery
local chans = wada.mesh.channels and wada.mesh.channels() or nil
if chans then
local names = table.concat(chans, ", ")
row("channels(): " .. #chans .. " " .. names:sub(1, 60), C.text)
else
row("wada.mesh.channels: MISSING", C.bad)
end
if not c.sdk_ext then
row("extended SDK is OFF on this board - stopping here.", C.sub)
return
end
-- contacts, so send_dm has a target to name. 1.3 also checks the pubkey field.
local cts = wada.mesh.contacts()
local first = cts[1] and cts[1].name or nil
row("contacts: " .. #cts .. (first and (" first=" .. first) or ""), C.text)
if cts[1] then
local pk = cts[1].pubkey
row("contacts[1].pubkey: " .. tostring(pk) ..
(type(pk) == "string" and #pk == 8 and " PASS" or " FAIL"),
(type(pk) == "string" and #pk == 8) and C.good or C.bad)
end
local me = wada.mesh.self()
row("self: " .. tostring(me.name) .. " pubkey=" .. tostring(me.pubkey), C.text)
-- Exact coordinates. Lua's floats here are 32-bit, so an app that logs a track
-- must use the _e6 integers; this proves they are present and consistent.
local fix = sys.gps()
if fix then
local drift = math.abs(fix.lat_e6 / 1e6 - fix.lat)
row(string.format("gps: %d,%d e6 alt %dm %d sats drift %.6f %s",
fix.lat_e6, fix.lon_e6, fix.alt_m or 0, fix.sats, drift,
drift < 0.001 and "PASS" or "FAIL"), drift < 0.001 and C.good or C.bad)
else
row("gps: no fix (normal indoors)", C.sub)
end
-- rx_log identity: adverts carry a real public key, addressed frames carry
-- one-byte hashes, everything else carries nothing. All three are correct.
local log = wada.mesh.rx_log()
local withpk, withsrc = 0, 0
for _, r in ipairs(log) do
if r.pubkey then withpk = withpk + 1 end
if r.src then withsrc = withsrc + 1 end
end
row(string.format("rx_log: %d frames, %d with a pubkey (adverts), %d with src/dst",
#log, withpk, withsrc), #log > 0 and C.text or C.sub)
-- fs: windowed read. Writes once (the 1/sec limit means one write per open).
if wada.fs then
local probe = "0123456789abcdef"
wada.fs.write("sdktest.bin", probe)
local part, total = wada.fs.read("sdktest.bin", 4, 4)
local ok = (part == "4567" and total == #probe)
row("fs.read(name,4,4): " .. tostring(part) .. " total=" .. tostring(total) ..
(ok and " PASS" or " FAIL"), ok and C.good or C.bad)
end
sendline = row("mesh.send: not tried yet", C.sub)
dmline = row("mesh.send_dm: not tried yet", C.sub)
msgline = row("on_message: waiting (needs the read permissions)", C.sub)
y = cy + 4
ui.button("Send to Public", 6, y, 120, 30, function()
local ok, err = wada.mesh.send("Public", "wadamesh SDK self-test")
sendline:set("mesh.send: " .. tostring(ok) .. " " .. tostring(err))
sendline:color(ok and C.good or C.bad)
end)
ui.button("DM first contact", 132, y, 130, 30, function()
if not first then dmline:set("mesh.send_dm: no contacts to target"); dmline:color(C.bad); return end
local ok, err = wada.mesh.send_dm(first, "wadamesh SDK self-test (DM)")
dmline:set("mesh.send_dm -> " .. first .. ": " .. tostring(ok) .. " " .. tostring(err))
dmline:color(ok and C.good or C.bad)
end)
cy = y + 38
discline = row("mesh.discover: not tried yet", C.sub)
inputline = row("ui.input: not tried yet", C.sub)
-- Probing TRANSMITS and makes every neighbour reply, so it is a button, not
-- something this app does on open.
y = cy + 4
ui.button("Probe", 6, y, 90, 30, function()
local tag, err = wada.mesh.discover()
if not tag then
discline:set("mesh.discover: " .. tostring(err)); discline:color(C.bad); return
end
discline:set("mesh.discover: sent, waiting for replies..."); discline:color(C.text)
end)
ui.button("Results", 102, y, 90, 30, function()
local hits = wada.mesh.discovered()
if #hits == 0 then
discline:set("discovered(): nothing yet - probe, then wait a few seconds")
discline:color(C.sub); return
end
local h = hits[1]
discline:set(string.format("discovered(): %d first %s snr %.1f/%.1f %s",
#hits, h.name or h.pubkey, h.snr, h.their_snr, h.direct and "direct" or (h.hops .. "h")))
discline:color(C.good)
end)
y = y + 34
ui.button("Beep", 6, y, 70, 30, function() sys.beep() end)
ui.button("Input", 82, y, 90, 30, function()
ui.input("Type anything", "hello", function(text)
inputline:set("ui.input -> " .. (text and ("'" .. text .. "'") or "cancelled"))
inputline:color(text and C.good or C.sub)
end)
end)
end
function app.on_input(ev)
if ev.type == "key" and keyline then
keyline:set("keys: got '" .. tostring(ev.key) .. "'")
keyline:color(C.good)
end
end
-- Proves the kind field and that DMs/rooms reach an app at all, not just channels.
function app.on_message(m)
if not msgline then return end
msgline:set("on_message: kind=" .. tostring(m.kind) .. " from=" .. tostring(m.sender) ..
" ch=" .. tostring(m.channel) .. " text=" .. tostring(m.text):sub(1, 20))
msgline:color(C.good)
end
return app
+3 -1
View File
@@ -231,6 +231,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.text_w(text [, size])</td><td>Rendered width of <code>text</code> in pixels, for the given size class.</td></tr>
<tr><td>wada.ui.text_lines(text, width [, size])</td><td>How many lines <code>text</code> wraps to inside <code>width</code>. Counts explicit newlines too. <b>Use this when you lay out your own rows.</b> <code>label:width()</code> turns wrapping on, so a line longer than the screen becomes two while a fixed <code>y</code> step still advances by one, and the next row is drawn on top of it. This is the single most common way an app's layout goes wrong; it happened to the SDK Test app's channel list.</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>
@@ -445,7 +447,7 @@ end</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, discover, input, rx_identity, list, packets, sensors, map}</code>. Check <code>sdk_ext</code> before using anything marked <span class="chip">ext</span> below. The last three are feature flags for calls added after the extended SDK first shipped, so an app can degrade on older firmware instead of erroring.</td></tr>
<tr><td>wada.sys.caps()</td><td><code>{sdk_ext, keyboard, touch, sd, discover, input, rx_identity, list, packets, sensors, map, measure}</code>. Check <code>sdk_ext</code> before using anything marked <span class="chip">ext</span> below. The last three are feature flags for calls added after the extended SDK first shipped, so an app can degrade on older firmware instead of erroring.</td></tr>
<tr><td>wada.sys.battery() <span class="chip">ext</span></td><td><code>{mv, pct, charging}</code>.</td></tr>
<tr><td>wada.sys.env() <span class="chip">sensors</span></td><td><code>{temp_c, humidity, pressure_hpa, alt_m}</code>, or <code>nil</code>. A field is present only when the hardware actually reported it, so you can tell "no humidity sensor" from "0% humidity". Gated on <code>caps().sensors</code> &mdash; does this board <i>have</i> the sensor rail &mdash; and <b>not</b> on <code>sdk_ext</code>, which is a memory gate. Those are different questions: the plain Heltec V4 has the Expansion Kit but not the extended SDK, and most boards with the extended SDK have no sensors at all.</td></tr>
<tr><td>wada.sys.gps() <span class="chip">ext</span></td><td><code>{lat, lon, lat_e6, lon_e6, alt_m, sats, time}</code>, or <code>nil</code> with no fix — 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>time</code> is satellite time, absent until the receiver has decoded the date.</td></tr>
+15 -2
View File
@@ -9,7 +9,7 @@
namespace TouchPrefsSchema {
static constexpr uint16_t MAGIC = 0x5743; // 'WC' (WadaCfg)
static constexpr uint8_t CURRENT_VERSION = 50; // v49: fem_lna default flip on the V4-R8; v50: map tile z/x/y line off by default (no new fields)
static constexpr uint8_t CURRENT_VERSION = 52; // v49: fem_lna default flip on the V4-R8; v50: map tile z/x/y line off by default (no new fields); v51: console_mode (boot into the text console; new trailing field, default OFF); v52: console_monitor (show incoming messages in the console, default ON)
static constexpr uint8_t BROKEN_MID_INSERT_VERSION = 44;
// Persisted byte layout. New fields must be appended at the end: older blobs
@@ -79,6 +79,10 @@ struct __attribute__((packed)) Config {
uint8_t retry_echo; // v45: actual trailing location; v44 inserted it before web_mirror
uint32_t app_hide; // v46: app-drawer hide bitmask (Store page "built-in" toggles)
char lang_file[12]; // v48: file-language code ("" = built-in ui_lang; file at <data>/lang/<code>.lang)
// NEW FIELDS GO HERE, AT THE TAIL. v44 inserted one mid-struct and shifted
// every field after it, which is what v45 had to undo. Append, never insert.
uint8_t console_mode; // v51: boot into the LVGL-free text console (CONSOLE_MODE.md)
uint8_t console_monitor; // v52: console mode shows incoming messages as they arrive
};
static constexpr size_t HEADER_SIZE = offsetof(Config, bright);
@@ -86,8 +90,17 @@ static constexpr size_t STABLE_V44_PREFIX_SIZE = offsetof(Config, web_mirror);
static_assert(offsetof(Config, web_mirror) == offsetof(Config, rx_queue) + sizeof(Config::rx_queue),
"the pre-v44 suffix moved");
static_assert(offsetof(Config, lang_file) + sizeof(Config::lang_file) == sizeof(Config),
static_assert(offsetof(Config, console_monitor) + sizeof(Config::console_monitor) == sizeof(Config),
"new preference fields must remain trailing");
// lang_file must stay immediately before the tail, or a v48-era blob overlays
// onto the wrong bytes. Checking both ends means the next person to append is
// told at compile time instead of shipping another v44.
static_assert(offsetof(Config, console_mode) ==
offsetof(Config, lang_file) + sizeof(Config::lang_file),
"console_mode must follow lang_file");
static_assert(offsetof(Config, console_monitor) ==
offsetof(Config, console_mode) + sizeof(Config::console_mode),
"console_monitor must follow console_mode");
// Overlay a persisted blob on caller-provided defaults. Beta 57 wrote v44 with
// retry_echo inserted before web_mirror, shifting every later value. That blob
+31
View File
@@ -133,6 +133,8 @@ static void cfgSetDefaults(TouchCfg& c) {
c.local_adv_min = 0; // OFF: no periodic zero-hop self-advert
c.beta_updates = 0; // OFF: stable update channel (opt-in to beta/test firmware)
c.boot_advert = 0; // OFF: no automatic advert on boot — opt-in (#76)
c.console_mode = 0; // OFF: boot into the graphical UI (CONSOLE_MODE.md)
c.console_monitor = 1; // ON: the console shows messages as they arrive
c.compact_chat = 0; // OFF: bubble chat layout (opt-in IRC-style dense rows)
c.clock_floor = 0; // no persisted send-timestamp floor yet
c.rx_queue = 1; // ON: buffered receive (test-channel default; opt-out toggle in Radio & Mesh)
@@ -235,6 +237,11 @@ static void cfgLoadOrMigrate() {
if (stored_version < 28) s_cfg.beta_updates = 0;
if (stored_version < 29 && s_cfg.ui_scale == 0) s_cfg.ui_scale = 1; // bump old 100% default -> Large (150%)
if (stored_version < 30) s_cfg.boot_advert = 0; // #76 new trailing field: advert-on-boot off by default
// v51 new trailing field. Anything older than 51 never stored it, so it
// must be forced OFF rather than inherited from whatever byte was there:
// a garbage 1 would boot a user into a console they did not ask for.
if (stored_version < 51) s_cfg.console_mode = 0;
if (stored_version < 52) s_cfg.console_monitor = 1; // new trailing field: on by default
if (stored_version < 31) s_cfg.compact_chat = 0; // new trailing field: compact chat rows off by default
if (stored_version < 32) s_cfg.clock_floor = 0; // new trailing field: no send-timestamp floor persisted yet (#89)
if (stored_version < 33) s_cfg.rx_queue = 1; // buffered LoRa receive ON for the test channel (opt-out toggle in Radio & Mesh)
@@ -1093,6 +1100,30 @@ bool touchPrefsSetMsgFlash(bool on) {
return cfgFlush();
}
// Console mode is a BOOT mode, so this is read before the UI is built. It fails
// safe by construction: the only value that means console is exactly 1, so a
// corrupt or unreadable pref boots the graphical UI, which is the mode everyone
// can use. Never invert this test.
bool touchPrefsGetConsoleMode() {
if (!s_begun) touchPrefsBegin();
return s_cfg.console_mode == 1;
}
bool touchPrefsSetConsoleMode(bool on) {
if (!s_begun) touchPrefsBegin();
s_cfg.console_mode = on ? 1 : 0;
return cfgFlush();
}
bool touchPrefsGetConsoleMonitor() {
if (!s_begun) touchPrefsBegin();
return s_cfg.console_monitor != 0;
}
bool touchPrefsSetConsoleMonitor(bool on) {
if (!s_begun) touchPrefsBegin();
s_cfg.console_monitor = on ? 1 : 0;
return cfgFlush();
}
bool touchPrefsGetBootAdvert() {
if (!s_begun) touchPrefsBegin();
return s_cfg.boot_advert != 0;
+4
View File
@@ -191,6 +191,10 @@ bool touchPrefsSetMsgFlash(bool on);
/* Advertise on boot (#76): fire one flood self-advert ~6s after boot so peers with auto-add on
* refresh our pubkey (useful after a reflash wiped storage). Opt-in, default off. All boards. */
bool touchPrefsGetConsoleMode(); // boot into the LVGL-free console (CONSOLE_MODE.md)
bool touchPrefsSetConsoleMode(bool on);
bool touchPrefsGetConsoleMonitor(); // console: show incoming messages live
bool touchPrefsSetConsoleMonitor(bool on);
bool touchPrefsGetBootAdvert();
bool touchPrefsSetBootAdvert(bool on);
+748
View File
@@ -0,0 +1,748 @@
// SPDX-License-Identifier: GPL-3.0-or-later
#include "ConsoleUI.h"
#if CAP_CONSOLE
#include <Arduino.h>
#include <stdarg.h>
#include <string.h>
#include <helpers/ui/DisplayDriver.h>
#if defined(ESP32)
#include <esp_heap_caps.h>
#endif
// The command backend. Both of these already exist and are what the LVGL
// Terminal app uses, so console mode inherits the full command set rather than
// growing a private one.
#include "../MyMesh.h" // declares the_mesh itself (a reference on PSRAM boards)
#if defined(ESP32)
#include "../helpers/esp32/TouchPrefsStore.h"
#include "../helpers/esp32/WifiRuntimeStore.h" // wifiConfigGetRadioEnabled
#endif
// Contacts, channels and the send path, all from UITask so the console reuses
// exactly what the UI and the Lua host use. In particular the channel send
// matches BY NAME at transmit time; a cached slot index is how messages once
// went out encrypted to the wrong channel.
extern int luaHostContactAt(int idx, char* name, size_t name_cap, int* type, uint32_t* secs_ago,
double* lat, double* lon, char* pk_hex, size_t pk_cap,
int32_t* lat_e6, int32_t* lon_e6);
extern int luaHostMeshChannelNames(char out[][32], int max_n);
extern bool luaHostMeshSendChannel(const char* chan_name, const char* text);
extern bool luaHostMeshSendDM(const char* to_name, const char* text, bool* was_room);
// Unread state. The monitor prints messages as they arrive but never marks them
// read; these report what is waiting and clear a thread on request.
extern int consoleHostUnreadTotal();
extern int consoleHostThreadAt(int idx, char* name, size_t cap, int* unread, bool* is_channel);
extern bool consoleHostMarkRead(const char* name);
extern int consoleHostHistoryAt(const char* thread, int back, char* sender, size_t sc,
char* text, size_t tc, uint32_t* ts, bool* outgoing);
extern void luaHostRadioStats(float* rssi, float* noise, uint32_t* rx_air_s, uint32_t* tx_air_s,
uint32_t* rx_pkts, uint32_t* rx_err, int* budget_ms);
extern void luaHostBattery(uint16_t* mv, int* pct, bool* charging);
extern uint32_t luaHostMeshDiscover(int type_filter);
extern int luaHostDiscoverCount();
extern 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);
#if CAP_TOUCH
// Board touch driver, read directly. lvglTouchRead() is only an adapter that
// feeds LVGL, so there is nothing to unpick here: the driver polls either way.
extern bool heltecV4CapTouchGetLive(uint16_t* x, uint16_t* y);
#endif
namespace {
// ---- scrollback -------------------------------------------------------------
// Flat ring in PSRAM: no per-line allocation, so a busy command cannot fragment
// the heap the way a strdup-per-line log would. Lines are fixed width and long
// output is wrapped into several of them by consoleWriteLine.
constexpr int kMaxLines = 160;
constexpr int kLineCap = 96; // chars per stored line, excluding the NUL
constexpr int kInputCap = 128;
char* s_ring = nullptr; // kMaxLines * (kLineCap + 1)
uint8_t* s_ring_col = nullptr; // one ConsoleColor per stored line
int s_head = 0; // next write slot
int s_count = 0;
int s_scroll = 0; // lines scrolled back from the newest
// Two levels of dirt, because startFrame() is a full fillScreen() and endFrame()
// is a no-op: drawing is direct to the panel with no buffer. A full redraw for
// every cursor blink flashed the whole screen twice a second, and one per
// keystroke did the same while typing. Only the scrollback changing needs the
// full clear; the input line and the cursor repaint their own few pixels.
bool s_dirty = true; // scrollback changed -> full redraw
bool s_dirty_in = false; // only the input line / cursor changed
bool s_active = false;
int s_cur_x = 0, s_cur_y = 0; // cursor cell, set by the last input-line draw
DisplayDriver* s_disp = nullptr;
int s_char_w = 6, s_line_h = 8, s_cols = 40, s_rows = 10;
char s_input[kInputCap] = {0};
int s_input_len = 0;
uint32_t s_blink_ms = 0;
bool s_blink_on = true;
inline char* lineAt(int i) { return s_ring + (size_t)i * (kLineCap + 1); }
uint8_t s_push_col = CC_TEXT; // colour applied by the next ringPush
void ringPush(const char* s, int len) {
if (!s_ring) return;
if (len > kLineCap) len = kLineCap;
char* dst = lineAt(s_head);
memcpy(dst, s, len);
dst[len] = '\0';
if (s_ring_col) s_ring_col[s_head] = s_push_col;
s_head = (s_head + 1) % kMaxLines;
if (s_count < kMaxLines) s_count++;
s_scroll = 0; // any new output jumps back to the live tail
s_dirty = true;
}
// Paint just the input row: clear it to the background, then prompt + text.
// Never touches the scrollback above it or the keypad below, so no flash.
void drawInputLine(bool cursor_on);
// i = 0 is the OLDEST retained line.
const char* ringGet(int i) {
if (i < 0 || i >= s_count) return nullptr;
const int start = (s_head - s_count + kMaxLines * 2) % kMaxLines;
return lineAt((start + i) % kMaxLines);
}
uint8_t ringGetCol(int i) {
if (!s_ring_col || i < 0 || i >= s_count) return CC_TEXT;
const int start = (s_head - s_count + kMaxLines * 2) % kMaxLines;
return s_ring_col[(start + i) % kMaxLines];
}
// One place that decides what a colour means on this panel. UIColor is the
// core's named set, so the console tracks the rest of the firmware rather than
// inventing its own values.
uint16_t colourFor(uint8_t c) {
switch (c) {
case CC_DIM: return UIColor::secondary_txt;
case CC_ECHO: return UIColor::title_txt;
case CC_OK: return UIColor::corp_blue;
case CC_WARN: return UIColor::warning_txt;
case CC_ERR: return UIColor::warning_txt;
case CC_CHAN: return UIColor::corp_blue;
case CC_DM: return UIColor::title_txt;
case CC_HEAD: return UIColor::title_txt;
default: return UIColor::primary_txt;
}
}
#if CAP_TOUCH && !CAP_KEYBOARD
// ---- on-screen keypad -------------------------------------------------------
// Touch boards have no hardware keyboard and the firmware's own on-screen one is
// LVGL, so console mode draws its own. Deliberate: the V4 is the board this
// feature exists for, so it has to be usable there without a keyboard.
//
// Three layers rather than a shift key that changes every glyph: lower, upper,
// and symbols. Fewer states to get wrong, and the label on a key is always what
// that key types.
constexpr int kRows = 4;
const char* const kLayer[3][kRows] = {
{ "qwertyuiop", "asdfghjkl", "\x01zxcvbnm\x08", "\x02 \n" }, // lower
{ "QWERTYUIOP", "ASDFGHJKL", "\x01ZXCVBNM\x08", "\x02 \n" }, // upper
{ "1234567890", "-/:;()$&@\"", "\x01.,?!'#\x08", "\x02 \n" }, // symbols
};
// \x01 = layer cycle, \x02 = scroll-back toggle, \x08 = backspace, \n = enter.
int s_layer = 0;
int s_kb_top = 0; // y where the keypad starts; scrollback ends here
int s_key_h = 0;
void keypadLayout() {
if (!s_disp) return;
s_key_h = s_disp->height() / 12; // proportional, so it fits 240x320 and bigger
if (s_key_h < 14) s_key_h = 14;
s_kb_top = s_disp->height() - kRows * s_key_h;
}
void keypadDraw() {
if (!s_disp) return;
for (int r = 0; r < kRows; r++) {
const char* row = kLayer[s_layer][r];
const int n = (int)strlen(row);
if (n <= 0) continue;
const int kw = s_disp->width() / n;
const int y = s_kb_top + r * s_key_h;
for (int c = 0; c < n; c++) {
const int x = c * kw;
s_disp->setColor(UIColor::secondary_txt);
s_disp->drawRect(x, y, kw - 1, s_key_h - 1);
char lbl[8];
switch (row[c]) {
case '\x01': snprintf(lbl, sizeof lbl, "%s", s_layer == 2 ? "ab" : (s_layer == 1 ? "12" : "AB")); break;
case '\x02': snprintf(lbl, sizeof lbl, "%s", "^v"); break;
case '\x08': snprintf(lbl, sizeof lbl, "%s", "<-"); break;
case '\n': snprintf(lbl, sizeof lbl, "%s", "ret"); break;
case ' ': snprintf(lbl, sizeof lbl, "%s", "spc"); break;
default: lbl[0] = row[c]; lbl[1] = '\0'; break;
}
s_disp->setColor(UIColor::primary_txt);
s_disp->drawTextCentered(x + kw / 2, y + (s_key_h - s_line_h) / 2, lbl);
}
}
}
// Which key is under (x, y)? Returns 0 when the tap missed the keypad.
char keypadHit(int x, int y) {
if (!s_disp || y < s_kb_top) return 0;
int r = (y - s_kb_top) / (s_key_h ? s_key_h : 1);
if (r < 0) r = 0;
if (r >= kRows) r = kRows - 1;
const char* row = kLayer[s_layer][r];
const int n = (int)strlen(row);
if (n <= 0) return 0;
const int kw = s_disp->width() / n;
int c = kw ? (x / kw) : 0;
if (c < 0) c = 0;
if (c >= n) c = n - 1;
return row[c];
}
uint32_t s_touch_start = 0;
uint16_t s_touch_x = 0, s_touch_y = 0;
bool s_scroll_mode = false; // ^v pressed: taps above the keypad scroll
void touchTick() {
uint16_t tx, ty;
const bool pressed = heltecV4CapTouchGetLive(&tx, &ty);
const uint32_t now = millis();
if (pressed && !s_touch_start) {
s_touch_start = now;
s_touch_x = tx; s_touch_y = ty;
return;
}
if (pressed || !s_touch_start) return;
const uint32_t held = now - s_touch_start;
s_touch_start = 0;
if (!s_disp) return;
const char k = keypadHit(s_touch_x, s_touch_y);
if (k) {
switch (k) {
case '\x01': s_layer = (s_layer + 1) % 3; s_dirty = true; break;
case '\x02': s_scroll_mode = !s_scroll_mode; s_dirty = true; break;
case '\x08': consoleKey('\b'); break;
case '\n': consoleKey('\n'); break;
default: consoleKey(k); break;
}
return;
}
// Above the keypad. In scroll mode the halves page the scrollback; otherwise a
// long press there is the way out, mirroring how remote mode is left.
if (s_scroll_mode) {
if (s_touch_y < s_kb_top / 2) { if (s_scroll < s_count - s_rows) { s_scroll++; s_dirty = true; } }
else { if (s_scroll > 0) { s_scroll--; s_dirty = true; } }
} else if (held >= 1200) {
consoleWriteLine("(hold registered - use the 'ui' command to leave console mode)");
}
}
#endif // CAP_TOUCH && !CAP_KEYBOARD
// ---- metrics ----------------------------------------------------------------
// DisplayDriver has getTextWidth but no text height, and the concrete drivers
// scale a fixed 6x8 cell, so derive the height from the measured width instead
// of hardcoding a number that would be wrong at another scale.
void measure() {
if (!s_disp) return;
s_disp->setTextSize(1);
const int w = s_disp->getTextWidth("M");
s_char_w = w > 0 ? w : 6;
s_line_h = (s_char_w * 8) / 6;
if (s_line_h < 8) s_line_h = 8;
s_cols = s_disp->width() / s_char_w;
if (s_cols < 8) s_cols = 8;
if (s_cols > kLineCap) s_cols = kLineCap;
// Rows available for scrollback: everything except the input line.
s_rows = (s_disp->height() / s_line_h) - 1;
#if CAP_TOUCH && !CAP_KEYBOARD
keypadLayout();
// The keypad owns the bottom of the panel, so the scrollback and the input
// line have to live above it rather than under it.
s_rows = (s_kb_top / s_line_h) - 1;
#endif
if (s_rows < 2) s_rows = 2;
}
// ---- render -----------------------------------------------------------------
void render() {
if (!s_disp || !s_active) return;
s_disp->startFrame(UIColor::window_bkg);
s_disp->setTextSize(1);
// Oldest-first from the scroll position, newest at the bottom.
const int first = s_count - s_rows - s_scroll;
int y = 0;
for (int r = 0; r < s_rows; r++) {
const int idx = first + r;
const char* l = (idx >= 0) ? ringGet(idx) : nullptr;
if (l && *l) {
s_disp->setColor(colourFor(ringGetCol(idx)));
s_disp->setCursor(0, y);
s_disp->print(l);
}
y += s_line_h;
}
drawInputLine(s_blink_on);
#if CAP_TOUCH && !CAP_KEYBOARD
keypadDraw();
#endif
s_disp->endFrame();
s_dirty = false;
}
// Current recipient, set by `to`. A name rather than an index or a slot, for the
// same reason the send path matches by name: an index goes stale the moment the
// contact list changes underneath it.
char s_to[40] = {0};
void cmdContacts() {
char name[36], pk[12]; int type; uint32_t ago; double lat, lon; int32_t la6, lo6;
int shown = 0;
for (int i = 0; i < 200 && shown < 40; i++) {
if (!luaHostContactAt(i, name, sizeof name, &type, &ago, &lat, &lon, pk, sizeof pk, &la6, &lo6)) break;
static const char* kType[5] = { "?", "chat", "repeater", "room", "sensor" };
char line[kLineCap];
snprintf(line, sizeof line, "%-16s %-8s %s", name,
(type >= 1 && type <= 4) ? kType[type] : "?", pk);
consoleWriteLine(line);
shown++;
}
if (!shown) consoleWriteLine("(no contacts yet)");
}
void cmdChannels() {
static char names[8][32];
const int n = luaHostMeshChannelNames(names, 8);
for (int i = 0; i < n; i++) consoleWriteLine(names[i]);
if (!n) consoleWriteLine("(no channels)");
}
void drawInputLine(bool cursor_on) {
if (!s_disp) return;
#if CAP_TOUCH && !CAP_KEYBOARD
const int iy = s_kb_top - s_line_h;
#else
const int iy = s_disp->height() - s_line_h;
#endif
// Clear only this row. fillScreen would take the scrollback and keypad with it.
s_disp->setColor(UIColor::window_bkg);
s_disp->fillRect(0, iy, s_disp->width(), s_line_h);
s_disp->setTextSize(1);
s_disp->setColor(UIColor::title_txt);
s_disp->setCursor(0, iy);
s_disp->print(">");
s_disp->setColor(UIColor::primary_txt);
s_disp->setCursor(s_char_w * 2, iy);
// Show the tail of a long line so the caret stays visible while typing.
const int room = s_cols - 3;
const char* shown = s_input;
if (s_input_len > room) shown = s_input + (s_input_len - room);
s_disp->print(shown);
s_cur_x = s_char_w * 2 + s_disp->getTextWidth(shown);
s_cur_y = iy;
if (cursor_on) {
s_disp->setColor(UIColor::primary_txt);
s_disp->fillRect(s_cur_x, s_cur_y, s_char_w, s_line_h);
}
// Scrollback indicator: without it there is no way to tell you are not live.
if (s_scroll > 0) {
char tag[24];
snprintf(tag, sizeof tag, "-%d", s_scroll);
s_disp->setColor(UIColor::warning_txt);
s_disp->drawTextRightAlign(s_disp->width() - 2, iy, tag);
}
}
// Toggle just the cursor cell. Two fillRects, no text, no clear.
void drawCursorOnly(bool on) {
if (!s_disp) return;
s_disp->setColor(on ? UIColor::primary_txt : UIColor::window_bkg);
s_disp->fillRect(s_cur_x, s_cur_y, s_char_w, s_line_h);
}
// ---- boot banner + quick menu -----------------------------------------------
// A login banner in the server tradition: what this machine is, then what it can
// do, so the first screen answers "now what?" instead of leaving a bare prompt.
// Deliberately narrow: 26 columns fits the smallest panel we ship (the V4 at
// 240 px), so nobody sees a broken box.
const char* const kBanner[] = {
" _ _ _ ___ ___ ___ ",
"| | | | .'| | _|",
"|_____|__,|_|_|_| ",
" m e s h",
};
// The quick menu. Numbers because typing 1 is faster than typing 'contacts',
// and because a numbered list is how you discover what exists at all.
struct QuickItem { const char* key; const char* label; const char* cmd; };
const QuickItem kQuick[] = {
{ "1", "Unread", "unread" },
{ "2", "Contacts", "contacts" },
{ "3", "Channels", "chans" },
{ "4", "Discover", "discover" },
{ "5", "Signal", "stat" },
{ "6", "Battery", "batt" },
{ "7", "Wi-Fi", "wifi" },
{ "8", "Memory", "mem" },
{ "9", "Node", "status" },
};
const int kQuickN = (int)(sizeof(kQuick) / sizeof(kQuick[0]));
void cmdMenu() {
consoleWriteLineC(CC_HEAD, "quick launch");
for (int i = 0; i < kQuickN; i += 2) {
char line[kLineCap];
if (i + 1 < kQuickN)
snprintf(line, sizeof line, " %s %-10s %s %-10s",
kQuick[i].key, kQuick[i].label, kQuick[i + 1].key, kQuick[i + 1].label);
else
snprintf(line, sizeof line, " %s %-10s", kQuick[i].key, kQuick[i].label);
consoleWriteLine(line);
}
consoleWriteLineC(CC_DIM, "type a number, or 'help'");
}
// ---- command dispatch -------------------------------------------------------
void submit() {
if (s_input_len == 0) return;
char cmd[kInputCap];
snprintf(cmd, sizeof cmd, "%s", s_input);
consoleWriteLine(""); // blank line between commands, for legibility
char echo[kLineCap + 4];
snprintf(echo, sizeof echo, "> %s", cmd);
consoleWriteLineC(CC_ECHO, echo);
s_input[0] = '\0';
s_input_len = 0;
// A bare number is a quick-launch shortcut. Checked first so it cannot be
// shadowed by anything the node CLI happens to accept.
if (cmd[0] >= '1' && cmd[0] <= '9' && cmd[1] == '\0') {
for (int i = 0; i < kQuickN; i++) {
if (kQuick[i].key[0] != cmd[0]) continue;
snprintf(cmd, sizeof cmd, "%s", kQuick[i].cmd);
break;
}
}
if (!strcasecmp(cmd, "menu") || !strcasecmp(cmd, "apps")) { cmdMenu(); return; }
// Console-only commands first, then everything else to the node CLI. Keeping
// this list short is deliberate: anything CommonCLI already answers should go
// there rather than being reimplemented here.
if (!strcasecmp(cmd, "clear")) {
s_head = s_count = s_scroll = 0;
s_dirty = true;
return;
}
#if defined(ESP32)
// The way back to the graphical UI. One of three, per CONSOLE_MODE.md: this,
// a key held at boot, and clearing the pref over serial or the flasher.
if (!strcasecmp(cmd, "ui") || !strcasecmp(cmd, "exit")) {
touchPrefsSetConsoleMode(false);
consoleWriteLine("switching to the graphical UI, rebooting...");
render();
delay(600); // let the line land on the panel before the reset
ESP.restart();
return;
}
#endif
if (!strcasecmp(cmd, "help")) {
consoleWriteLine("console clear, help, mem, ui");
consoleWriteLine("mesh contacts, chans, unread");
consoleWriteLine(" chat <name> show a thread");
consoleWriteLine(" discover / discovered");
consoleWriteLine("radio stat, batt, wifi");
consoleWriteLine(" read <name> clear that thread's unread");
consoleWriteLine(" monitor on|off show arriving messages");
consoleWriteLine(" to <name> pick a contact or channel");
consoleWriteLine(" msg <text> send to it");
consoleWriteLine("node anything the CLI answers:");
consoleWriteLine(" advert, get name, set name <x>, time, ver");
return;
}
// Free memory. This is the number console mode is justified by, so it is a
// command rather than something you have to instrument a build to see:
// read it here, reboot into the UI, read it there.
if (!strcasecmp(cmd, "mem")) {
#if defined(ESP32)
const size_t dr_f = heap_caps_get_free_size(MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT);
const size_t dr_t = heap_caps_get_total_size(MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT);
const size_t ps_f = heap_caps_get_free_size(MALLOC_CAP_SPIRAM);
const size_t ps_t = heap_caps_get_total_size(MALLOC_CAP_SPIRAM);
const size_t ps_b = heap_caps_get_largest_free_block(MALLOC_CAP_SPIRAM);
consolePrintf("DRAM %u / %u KB free", (unsigned)(dr_f / 1024), (unsigned)(dr_t / 1024));
consolePrintf("PSRAM %u / %u KB free", (unsigned)(ps_f / 1024), (unsigned)(ps_t / 1024));
consolePrintf("PSRAM largest block %u KB", (unsigned)(ps_b / 1024));
#else
consoleWriteLine("not available on this build");
#endif
return;
}
// The monitor: on by default, and a boot-persistent pref rather than a
// session flag, because "show me what is arriving" is how you want the device
// to come up, not something to re-enable every reboot.
if (!strncasecmp(cmd, "monitor", 7)) {
const char* arg = cmd + 7;
while (*arg == ' ') arg++;
if (!strcasecmp(arg, "on")) { touchPrefsSetConsoleMonitor(true); consoleWriteLine("monitor on"); }
else if (!strcasecmp(arg, "off")) { touchPrefsSetConsoleMonitor(false); consoleWriteLine("monitor off"); }
else consolePrintf("monitor is %s (monitor on | monitor off)",
touchPrefsGetConsoleMonitor() ? "on" : "off");
return;
}
// What is waiting. Listing it does NOT clear it.
if (!strcasecmp(cmd, "unread")) {
char name[40]; int un = 0; bool chan = false; int shown = 0;
for (int i = 0; i < 64; i++) {
if (!consoleHostThreadAt(i, name, sizeof name, &un, &chan)) break;
if (un <= 0) continue;
consolePrintf("%s%-20s %d", chan ? "#" : " ", name, un);
shown++;
}
consolePrintf("%d unread in %d thread%s", consoleHostUnreadTotal(), shown, shown == 1 ? "" : "s");
return;
}
// Clearing is explicit. Seeing a message go past in the monitor is not
// reading it; this is.
if (!strncasecmp(cmd, "read ", 5)) {
const char* who = cmd + 5;
if (consoleHostMarkRead(who)) consolePrintf("marked %s read", who);
else consolePrintfC(CC_ERR, "no thread named '%s'", who);
return;
}
// Read a thread. Deliberately does NOT mark it read: use 'read <name>'.
if (!strncasecmp(cmd, "chat ", 5)) {
const char* who = cmd + 5;
char sender[40], text[176]; uint32_t ts; bool out;
int shown = 0;
// Collect newest-first, print oldest-first, so it reads like a conversation.
for (int i = 11; i >= 0; i--) {
if (!consoleHostHistoryAt(who, i, sender, sizeof sender, text, sizeof text, &ts, &out)) continue;
consolePrintfC(out ? CC_DIM : CC_TEXT, "%s%s: %s", out ? "> " : "", sender, text);
shown++;
}
if (!shown) consolePrintfC(CC_WARN, "nothing stored for '%s'", who);
else consoleWriteLineC(CC_DIM, "('read <name>' to clear unread)");
return;
}
// Radio + traffic, the Signal page's numbers.
if (!strcasecmp(cmd, "stat") || !strcasecmp(cmd, "signal")) {
float rssi, noise; uint32_t rxa, txa, rxp, rxe; int budget;
luaHostRadioStats(&rssi, &noise, &rxa, &txa, &rxp, &rxe, &budget);
consolePrintf("rssi %.0f noise %.0f margin %.0f dB", rssi, noise, rssi - noise);
consolePrintf("rx %lu pkts, %lu err", (unsigned long)rxp, (unsigned long)rxe);
consolePrintf("airtime rx %lus tx %lus", (unsigned long)rxa, (unsigned long)txa);
consolePrintfC(budget > 0 ? CC_OK : CC_WARN, "tx budget %d ms", budget);
return;
}
if (!strcasecmp(cmd, "batt") || !strcasecmp(cmd, "battery")) {
uint16_t mv = 0; int pct = -1; bool chg = false;
luaHostBattery(&mv, &pct, &chg);
consolePrintfC(pct >= 0 && pct < 20 ? CC_WARN : CC_TEXT,
"%d%% %u mV%s", pct, (unsigned)mv, chg ? " charging" : "");
return;
}
if (!strcasecmp(cmd, "wifi")) {
#if defined(ESP32)
consolePrintf("wifi %s", wifiConfigGetRadioEnabled() ? "on" : "off");
consoleWriteLineC(CC_DIM, "(use the CLI: 'set wifi.ssid', or the UI)");
#endif
return;
}
// The active probe. It transmits and makes every neighbour reply, so it says
// so rather than doing it silently.
if (!strcasecmp(cmd, "discover")) {
if (!luaHostMeshDiscover(0)) { consoleWriteLineC(CC_ERR, "probe failed (radio busy?)"); return; }
consoleWriteLineC(CC_OK, "probe sent - replies arrive over a few seconds");
consoleWriteLineC(CC_DIM, "'discovered' to list who answered");
return;
}
if (!strcasecmp(cmd, "discovered")) {
char pk[12], name[36]; int type, rssi, hops, heard; float snr, tsnr; uint32_t fa, la;
const int n = luaHostDiscoverCount();
for (int i = 0; i < n && i < 30; i++) {
if (!luaHostDiscoverAt(i, pk, sizeof pk, name, sizeof name, &type, &rssi, &snr,
&tsnr, &hops, &fa, &la, &heard)) break;
// Both link directions: theirs matters as much as ours, and only a probe
// reply can tell you how well they heard YOU.
consolePrintfC(hops == 0 ? CC_OK : CC_TEXT, "%-14s %5.1f/%-5.1f %s",
name[0] ? name : pk, snr, tsnr, hops == 0 ? "direct" : "relayed");
}
if (!n) consoleWriteLineC(CC_DIM, "nothing yet - run 'discover' first");
return;
}
if (!strcasecmp(cmd, "contacts")) { cmdContacts(); return; }
if (!strcasecmp(cmd, "chans") || !strcasecmp(cmd, "channels")) { cmdChannels(); return; }
if (!strncasecmp(cmd, "to ", 3)) {
snprintf(s_to, sizeof s_to, "%s", cmd + 3);
consolePrintf("sending to: %s", s_to);
return;
}
if (!strncasecmp(cmd, "msg ", 4)) {
if (!s_to[0]) { consoleWriteLineC(CC_WARN, "no recipient - use 'to <name>' first"); return; }
const char* text = cmd + 4;
// Try a channel first, then a contact. Channels and contacts share a name
// space here on purpose: the user typed a name, not a category.
if (luaHostMeshSendChannel(s_to, text)) { consolePrintfC(CC_OK, "sent to #%s", s_to); return; }
bool was_room = false;
if (luaHostMeshSendDM(s_to, text, &was_room)) {
consolePrintfC(CC_OK, "sent to %s%s", s_to, was_room ? " (room)" : "");
return;
}
consolePrintfC(CC_ERR, "no channel or contact named '%s'", s_to);
return;
}
the_mesh.runLocalCli(cmd);
}
} // namespace
// ---- public -----------------------------------------------------------------
void consoleBegin(DisplayDriver* d) {
s_disp = d;
if (!s_disp) return;
if (!s_ring) {
const size_t bytes = (size_t)kMaxLines * (kLineCap + 1);
#if defined(ESP32)
s_ring = (char*)heap_caps_malloc(bytes, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT);
#endif
if (!s_ring) s_ring = (char*)malloc(bytes);
if (!s_ring) return; // no scrollback, no console
memset(s_ring, 0, bytes);
#if defined(ESP32)
s_ring_col = (uint8_t*)heap_caps_malloc(kMaxLines, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT);
#endif
if (!s_ring_col) s_ring_col = (uint8_t*)malloc(kMaxLines);
if (s_ring_col) memset(s_ring_col, CC_TEXT, kMaxLines); // null is tolerated: all text
}
s_active = true;
measure();
s_dirty = true;
render();
}
// Printed once at boot by UITask::begin. Not inside consoleBegin, because the
// node name and version are not knowable until the mesh is up.
void consoleBanner(const char* node_name, const char* version) {
for (unsigned i = 0; i < sizeof(kBanner) / sizeof(kBanner[0]); i++)
consoleWriteLineC(CC_HEAD, kBanner[i]);
consoleWriteLine("");
if (node_name && *node_name) consolePrintfC(CC_OK, "node %s", node_name);
if (version && *version) consolePrintfC(CC_DIM, "build %s", version);
consoleWriteLine("");
cmdMenu();
consoleWriteLine("");
}
void consoleEnd() { s_active = false; }
bool consoleActive() { return s_active; }
void consoleWriteLine(const char* line) {
if (!s_ring) return;
if (!line) { ringPush("", 0); return; }
const int width = s_cols > 0 ? s_cols : 40;
// Split on embedded newlines FIRST. The node CLI hands its reply to the sink
// as one buffer containing '\n' (its help text is a dozen lines in a single
// string), and storing that as one ring entry meant print() rendered the
// breaks itself while our y-cursor still advanced by one row. Every later
// line then landed on top of the one before it, which is the overlap in the
// report, worsening down the screen as the error accumulated.
//
// Then wrap each piece to the panel width, rather than truncating: a reply
// that runs off the edge is the same as no reply on a screen this size. This
// also guarantees no stored line can be wider than the panel, so the text
// renderer never wraps one on its own and desynchronises the cursor again.
int pushed = 0;
const char* seg = line;
while (seg && pushed < 512) { // bound: a runaway reply cannot spin here
const char* nl = strchr(seg, '\n');
int seg_len = nl ? (int)(nl - seg) : (int)strlen(seg);
// Strip a trailing CR so CRLF output does not leave a stray glyph.
if (seg_len > 0 && seg[seg_len - 1] == '\r') seg_len--;
if (seg_len == 0) {
ringPush("", 0); pushed++;
} else {
for (int off = 0; off < seg_len && pushed < 512; off += width) {
int n = seg_len - off;
if (n > width) n = width;
ringPush(seg + off, n); pushed++;
}
}
if (!nl) break;
seg = nl + 1;
}
}
void consoleWriteLineC(uint8_t colour, const char* line) {
s_push_col = colour;
consoleWriteLine(line);
s_push_col = CC_TEXT;
}
void consolePrintfC(uint8_t colour, const char* fmt, ...) {
char buf[256];
va_list ap;
va_start(ap, fmt);
vsnprintf(buf, sizeof buf, fmt, ap);
va_end(ap);
consoleWriteLineC(colour, buf);
}
void consolePrintf(const char* fmt, ...) {
char buf[256];
va_list ap;
va_start(ap, fmt);
vsnprintf(buf, sizeof buf, fmt, ap);
va_end(ap);
consoleWriteLine(buf);
}
bool consoleKey(int c) {
if (!s_active) return false;
if (c == '\r' || c == '\n') { submit(); s_dirty = true; return true; }
if (c == '\b' || c == 127) {
if (s_input_len > 0) { s_input[--s_input_len] = '\0'; s_dirty_in = true; }
return true;
}
if (c < 32 || c > 126) return false;
if (s_input_len < kInputCap - 1) {
s_input[s_input_len++] = (char)c;
s_input[s_input_len] = '\0';
s_dirty_in = true; // only the input row repaints; no full-screen flash
}
return true;
}
void consoleLoop() {
if (!s_active || !s_disp) return;
#if CAP_TOUCH && !CAP_KEYBOARD
touchTick();
#endif
const uint32_t now = millis();
bool blink_flip = false;
if (now - s_blink_ms >= 530) {
s_blink_ms = now;
s_blink_on = !s_blink_on;
blink_flip = true;
}
// Cheapest repaint that covers what actually changed. An idle console paints
// one character cell every half second; typing repaints one row; only new
// output clears the screen.
if (s_dirty) { render(); s_dirty_in = false; }
else if (s_dirty_in) { drawInputLine(s_blink_on); s_dirty_in = false; }
else if (blink_flip) { drawCursorOnly(s_blink_on); }
}
#endif // CAP_CONSOLE
+58
View File
@@ -0,0 +1,58 @@
// SPDX-License-Identifier: GPL-3.0-or-later
#pragma once
// Console mode: a text front end with NO LVGL (CONSOLE_MODE.md).
//
// Draws straight to the panel through DisplayDriver and reads input from the
// board's own keyboard/touch drivers, so nothing here needs LVGL initialised.
// Commands go to MyMesh::runLocalCli() and its replies come back through
// MyMesh::setTerminalSink(), both of which already exist and are already what
// the LVGL Terminal app uses.
//
// Phase 1 (see CONSOLE_MODE.md): the front end itself. Booting into it and
// skipping LVGL entirely is Phase 2; this module is self-contained until then
// so it can be exercised without touching the graphical path.
#include "device_caps.h"
#include <stdint.h>
#include <stddef.h>
#if CAP_CONSOLE
class DisplayDriver;
// Take over the panel. Safe to call twice; a second call just re-renders.
void consoleBegin(DisplayDriver* d);
// Release it. Does not reboot or touch LVGL; the caller decides what happens next.
void consoleEnd();
bool consoleActive();
// Call every loop while active: polls touch, blinks the cursor, redraws when dirty.
void consoleLoop();
// Line colours. Kept as a small named set rather than raw values so the console
// reads as one thing: the meaning of a line picks the colour, not the caller's
// taste.
enum ConsoleColor : uint8_t {
CC_TEXT = 0, // normal output
CC_DIM, // secondary / hints
CC_ECHO, // the command you typed
CC_OK, // success
CC_WARN, // warnings, refusals
CC_ERR, // failures
CC_CHAN, // an incoming channel message
CC_DM, // an incoming direct message
CC_HEAD, // section headings, the banner
};
// Append one line of output. This is the MyMesh terminal-sink signature, so it
// can be handed to setTerminalSink() directly. Wraps long lines.
void consoleWriteLine(const char* line);
void consoleWriteLineC(uint8_t colour, const char* line);
void consolePrintf(const char* fmt, ...);
void consolePrintfC(uint8_t colour, const char* fmt, ...);
// The login banner: ASCII mark, who this node is, and the quick-launch menu.
void consoleBanner(const char* node_name, const char* version);
// Feed one character from a hardware keyboard. '\n' submits, '\b' deletes.
// Returns true if the console consumed it.
bool consoleKey(int c);
#endif
+40
View File
@@ -503,6 +503,43 @@ void listPaintRow(lv_obj_t* row, bool selected) {
lv_obj_set_style_bg_opa(row, LV_OPA_COVER, LV_PART_MAIN);
}
// wada.ui.text_w(text [, size]) -> rendered width in pixels.
//
// The gap this closes: an app can ask how TALL a line is (text_h) but had no way
// to ask how WIDE it is, so any app laying out its own rows had to guess whether
// a string would wrap. Guessing wrong puts the next row on top of it, which is
// exactly what happened to the SDK Test app's channel list. With this an app can
// compute its own line count instead of estimating from character counts.
int uiTextW(lua_State* L) {
size_t len = 0;
const char* txt = luaL_checklstring(L, 1, &len);
const lv_font_t* f = luaHostFontForSize((int)luaL_optinteger(L, 2, 14));
lua_pushinteger(L, (lua_Integer)lv_txt_get_width(txt, (uint32_t)len, f, 0, LV_TEXT_FLAG_NONE));
return 1;
}
// wada.ui.text_lines(text, width [, size]) -> how many lines it wraps to.
// The question apps actually have. Counts explicit newlines too, and measures
// rather than guessing, so a layout built on it cannot overlap.
int uiTextLines(lua_State* L) {
size_t len = 0;
const char* txt = luaL_checklstring(L, 1, &len);
const int w = (int)luaL_checkinteger(L, 2);
const lv_font_t* f = luaHostFontForSize((int)luaL_optinteger(L, 3, 14));
if (w <= 0) { lua_pushinteger(L, 1); return 1; }
int lines = 0;
const char* p = txt;
while (*p) {
uint32_t consumed = _lv_txt_get_next_line(p, f, 0, (lv_coord_t)w, nullptr, LV_TEXT_FLAG_NONE);
if (!consumed) break;
p += consumed;
lines++;
if (lines > 512) break; // pathological input: stop counting
}
lua_pushinteger(L, lines < 1 ? 1 : lines);
return 1;
}
int uiList(lua_State* L) {
if (!s_h || !s_h->body) return luaL_error(L, "no app body");
const int x = (int)luaL_checkinteger(L, 1), y = (int)luaL_checkinteger(L, 2);
@@ -699,6 +736,7 @@ int sysCaps(lua_State* L) {
lua_pushboolean(L, 1); lua_setfield(L, -2, "rx_identity");// rx_log pubkey/src/dst
lua_pushboolean(L, 1); lua_setfield(L, -2, "list"); // wada.ui.list
lua_pushboolean(L, 1); lua_setfield(L, -2, "packets"); // app.on_packet
lua_pushboolean(L, 1); lua_setfield(L, -2, "measure"); // ui.text_w / text_lines
lua_pushboolean(L, CAP_SENSORS); lua_setfield(L, -2, "sensors"); // wada.sys.env
lua_pushboolean(L, CAP_LUA_SDK_EXT); lua_setfield(L, -2, "map"); // wada.map.view
return 1;
@@ -1838,6 +1876,8 @@ void openWada(lua_State* L) {
lua_pushcfunction(L, uiChart); lua_setfield(L, -2, "chart");
lua_pushcfunction(L, uiScroll); lua_setfield(L, -2, "scroll");
lua_pushcfunction(L, uiTextH); lua_setfield(L, -2, "text_h");
lua_pushcfunction(L, uiTextW); lua_setfield(L, -2, "text_w");
lua_pushcfunction(L, uiTextLines); lua_setfield(L, -2, "text_lines");
lua_createtable(L, 0, 6); // wada.ui.colors (theme)
lua_pushinteger(L, 0x15B6A6); lua_setfield(L, -2, "accent");
lua_pushinteger(L, 0xE6E9ED); lua_setfield(L, -2, "text");
+400 -56
View File
@@ -9963,6 +9963,11 @@ static void doExportBackupFile(const char* fname); // write a backup (SD if a
// for a control pinned to the card's right edge (~56 for a switch); pass 0 for a
// full-width label. font==nullptr keeps the inherited default (g_font_14). The
// text is run through TR() here, so callers pass the bare English literal.
#if CAP_CONSOLE
// Defined with the other boot-mode toggles further down, next to Remote UI's,
// which is the same kind of switch. Declared here for Settings > General.
static void consoleModeToggleCb(lv_event_t* e);
#endif
static int settingsRowLabel(lv_obj_t* body, int y, int y_off, const char* text,
uint32_t color, const lv_font_t* font, int reserve_right) {
lv_obj_t* l = lv_label_create(body);
@@ -13301,6 +13306,42 @@ static void buildDeviceSettings(int sec) {
}
}
#if CAP_CONSOLE
if (sec == DSEC_GENERAL) { // --- Console mode (boot mode; reboots) ---
y += settingsRowLabel(body, y, 0, TR("Console mode"), COLOR_SUB, &g_font_12, 0) + 2;
{
lv_obj_t* crow = lv_obj_create(body);
lv_obj_remove_style_all(crow);
lv_obj_set_size(crow, lv_pct(100), SC(34));
lv_obj_set_pos(crow, 2, y);
lv_obj_set_style_bg_color(crow, lv_color_hex(COLOR_PANEL), LV_PART_MAIN);
lv_obj_set_style_bg_opa(crow, LV_OPA_COVER, LV_PART_MAIN);
lv_obj_set_style_radius(crow, 8, LV_PART_MAIN);
lv_obj_set_style_pad_left(crow, 10, LV_PART_MAIN);
lv_obj_set_style_pad_right(crow, 10, LV_PART_MAIN);
lv_obj_clear_flag(crow, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_t* cl = lv_label_create(crow);
lv_label_set_text(cl, TR("Text console (no UI)"));
useChainedFont(cl);
lv_obj_set_style_text_color(cl, lv_color_hex(COLOR_TEXT), LV_PART_MAIN);
lv_label_set_long_mode(cl, LV_LABEL_LONG_DOT);
lv_obj_set_width(cl, lv_pct(70));
lv_obj_align(cl, LV_ALIGN_LEFT_MID, 0, 0);
lv_obj_t* csw = lv_switch_create(crow);
lv_obj_set_size(csw, 44, 24);
lv_obj_align(csw, LV_ALIGN_RIGHT_MID, 0, 0);
if (touchPrefsGetConsoleMode()) lv_obj_add_state(csw, LV_STATE_CHECKED);
lv_obj_add_event_cb(csw, consoleModeToggleCb, LV_EVENT_VALUE_CHANGED, nullptr);
y += SC(42);
}
y += settingsRowLabel(body, y, 0,
TR("Boots into a text console with no graphical interface: type commands, read replies. "
"Frees the memory and processor time the interface uses. Type 'ui' in the console to "
"come back. Toggling reboots."),
COLOR_SUB, &g_font_12, 1) + 8;
}
#endif
if (sec == DSEC_GENERAL) { // --- Run setup + reboot ---
// Re-run the first-boot setup flow (name / region / Wi-Fi) on demand.
lv_obj_t* b_setup = lv_btn_create(body);
@@ -17124,6 +17165,21 @@ static void openContactActionSheet(uint32_t mesh_idx, bool is_repeater, const ch
#endif
}
mk_btn_full(LV_SYMBOL_TRASH " Delete", actionSheetDeleteCb, 0xB23A48);
// Scrollability is decided HERE, from the height the buttons actually reached,
// not from the grid_items tally above. That tally is hand-maintained and has
// to be updated every time a conditional button is added; the location-sharing
// row added for #266 is not in it, so a contact showing that button has one
// more row than the card was sized for. When the estimate is short the body is
// left unscrollable and the overflow is simply unreachable, which is the shape
// of #306. `y` is the truth, so use it and let the tally be wrong harmlessly.
{
const int body_h = card_h - 2 * padding - title_h;
if (y > body_h) {
lv_obj_set_scroll_dir(body, LV_DIR_VER);
lv_obj_add_flag(body, LV_OBJ_FLAG_SCROLLABLE);
}
}
}
static void contactSelectCb(lv_event_t* e) {
@@ -23253,6 +23309,40 @@ static void remoteToggleCb(lv_event_t* e) {
}
}
#if CAP_CONSOLE
// ---- console bridges: unread state ------------------------------------------
// The console shows messages as they arrive but must NOT mark them read; these
// let it report what is waiting and clear a thread deliberately.
int consoleHostUnreadTotal() { return g_lv.task ? g_lv.task->getUnreadTotal() : 0; }
int consoleHostThreadAt(int idx, char* name, size_t cap, int* unread, bool* is_channel) {
if (!g_lv.task) return 0;
return g_lv.task->consoleThreadAt(idx, name, cap, unread, is_channel);
}
bool consoleHostMarkRead(const char* name) {
return g_lv.task ? g_lv.task->consoleMarkThreadRead(name) : false;
}
int consoleHostHistoryAt(const char* thread, int back, char* sender, size_t sc,
char* text, size_t tc, uint32_t* ts, bool* outgoing) {
return g_lv.task ? g_lv.task->consoleHistoryAt(thread, back, sender, sc, text, tc, ts, outgoing) : 0;
}
#endif
#if CAP_CONSOLE
// Console mode is a boot mode like remote mode, so it is toggled the same way:
// set the pref, then reboot through rebootDevice() so chat history is flushed
// first rather than lost to the reset.
static void consoleModeToggleCb(lv_event_t* e) {
const bool on = lv_obj_has_state(lv_event_get_target(e), LV_STATE_CHECKED);
touchPrefsSetConsoleMode(on);
if (g_lv.task) {
g_lv.task->showAlert(on ? TR("Entering console mode - rebooting...")
: TR("Leaving console mode - rebooting..."), 1600);
g_lv.task->rebootDevice();
}
}
#endif
static void remoteLandscapeCb(lv_event_t* e) {
const bool on = lv_obj_has_state(lv_event_get_target(e), LV_STATE_CHECKED);
touchPrefsSetRemoteLandscape(on);
@@ -23442,6 +23532,46 @@ static void openRemotePage() {
lv_obj_add_event_cb(sw2, remoteToggleCb, LV_EVENT_VALUE_CHANGED, nullptr);
lv_obj_move_to_index(row, lv_obj_get_index(expl)); // toggle sits ABOVE its explanation
#if CAP_CONSOLE
// ---- 2b. Console mode (no LVGL at all; reboots) ----
lv_obj_t* ch = lv_label_create(s_remote_root);
lv_label_set_text(ch, TR("Console mode"));
lv_obj_set_style_text_font(ch, &g_font_14, LV_PART_MAIN);
lv_obj_set_style_text_color(ch, lv_color_hex(COLOR_ACCENT), LV_PART_MAIN);
lv_obj_set_width(ch, cw);
lv_obj_t* cexpl = lv_label_create(s_remote_root);
lv_label_set_text(cexpl, TR("Boots into a text console with no graphical interface: type commands, "
"read replies. Frees the memory and processor time the interface uses. "
"Type 'ui' in the console to come back. Toggling reboots the device."));
lv_obj_set_style_text_font(cexpl, &g_font_12, LV_PART_MAIN);
lv_obj_set_style_text_color(cexpl, lv_color_hex(COLOR_SUB), LV_PART_MAIN);
lv_label_set_long_mode(cexpl, LV_LABEL_LONG_WRAP);
lv_obj_set_width(cexpl, cw);
lv_obj_t* crow = lv_obj_create(s_remote_root);
lv_obj_remove_style_all(crow);
lv_obj_set_size(crow, cw, SC(42));
lv_obj_set_style_bg_color(crow, lv_color_hex(COLOR_PANEL), LV_PART_MAIN);
lv_obj_set_style_bg_opa(crow, LV_OPA_COVER, LV_PART_MAIN);
lv_obj_set_style_radius(crow, 8, LV_PART_MAIN);
lv_obj_set_style_pad_left(crow, 12, LV_PART_MAIN);
lv_obj_set_style_pad_right(crow, 12, LV_PART_MAIN);
lv_obj_clear_flag(crow, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_t* cll = lv_label_create(crow);
lv_label_set_text(cll, TR("Console mode"));
lv_obj_set_style_text_font(cll, &g_font_14, LV_PART_MAIN);
lv_obj_set_style_text_color(cll, lv_color_hex(COLOR_TEXT), LV_PART_MAIN);
lv_label_set_long_mode(cll, LV_LABEL_LONG_DOT);
lv_obj_set_width(cll, cw - 84);
lv_obj_align(cll, LV_ALIGN_LEFT_MID, 0, 0);
lv_obj_t* csw = lv_switch_create(crow);
lv_obj_align(csw, LV_ALIGN_RIGHT_MID, 0, 0);
if (touchPrefsGetConsoleMode()) lv_obj_add_state(csw, LV_STATE_CHECKED);
lv_obj_add_event_cb(csw, consoleModeToggleCb, LV_EVENT_VALUE_CHANGED, nullptr);
lv_obj_move_to_index(crow, lv_obj_get_index(cexpl));
#endif
lv_obj_t* lrow = lv_obj_create(s_remote_root);
lv_obj_remove_style_all(lrow);
lv_obj_set_size(lrow, cw, SC(42));
@@ -36757,7 +36887,10 @@ static void serviceLockscreen() {
// "Set as notification sound"), or "Built-in (default)" to reset to the chime.
static void soundDisplayName(const char* path, char* out, int cap) {
if (!out || cap <= 0) return;
if (!path || !path[0]) { snprintf(out, cap, "Built-in"); return; }
// TR(), not a bare literal: this is the label shown for the default chime, and
// it was the one path of two that never went through the translation table, so
// the same word appeared translated in the menu and English on the button.
if (!path || !path[0]) { snprintf(out, cap, "%s", TR("Built-in")); return; }
const bool sd = !strncmp(path, "sd:", 3);
const char* p = sd ? path + 3 : path;
const char* base = strrchr(p, '/'); base = base ? base + 1 : p;
@@ -38579,7 +38712,7 @@ static void refreshSettingsSectionSubtitles() {
#if defined(ESP32) && defined(MULTI_TRANSPORT_COMPANION)
lv_label_set_text(g_set_sec_sub[SEC_BLUETOOTH],
(g_lv.task->hasBleCapability() && bleRequestedOrEnabled())
? "Starting…" : "Off");
? TR("Starting…") : TR("Off"));
#else
lv_label_set_text(g_set_sec_sub[SEC_BLUETOOTH], TR("Inactive"));
#endif
@@ -42389,7 +42522,14 @@ static void updateGlobalStatusBar() {
// chat is still open underneath it, so suppress chat-mode chrome (the cog + back
// chevron + centred title) then — otherwise they sit over the page's "‹ title" and
// swallow its back tap.
const bool chat_open = (s_chat_title[0] != '\0') && !s_apppage_title;
// ...and a settings detail page too. It sets s_settings_open_cat rather than
// s_apppage_title, so the guard above missed it: opening Settings from inside
// a chat left the chat's back chevron AND its cog drawn over the settings
// page's own "‹ Title". Two back chevrons on the left of the tall bar, and the
// cog is clickable, so a tap near the wrong one went to channel settings or
// nowhere instead of going back (#308).
const bool chat_open = (s_chat_title[0] != '\0') && !s_apppage_title &&
(s_settings_open_cat < 0);
const bool inbox_overview = (getActiveTab() == CHAT_INBOX_TAB_INDEX) && !chat_open && (s_settings_open_cat < 0) && !s_apppage_title;
{
#if defined(TLORA_PAGER)
@@ -43213,16 +43353,21 @@ static void refreshStatusLabels() {
IPAddress ip = WiFi.localIP();
snprintf(st, sizeof st, LV_SYMBOL_WIFI " %d.%d.%d.%d%s",
ip[0], ip[1], ip[2], ip[3], ble_suffix);
} else if (WiFi.getMode() == WIFI_STA) {
// Gate on what the USER asked for, not on the driver mode. Reading only
// WiFi.getMode() meant that anything leaving the radio in STA made the home
// screen announce "Starting…" forever with Wi-Fi switched off, which is
// what #305 reported. The mode is still checked, so a genuinely idle STA is
// not described as connecting either.
} else if (wifiConfigGetRadioEnabled() && WiFi.getMode() == WIFI_STA) {
const char* hint;
switch (WiFi.status()) {
case WL_IDLE_STATUS: hint = "Starting…"; break;
case WL_NO_SSID_AVAIL: hint = "SSID not found"; break;
case WL_CONNECT_FAILED: hint = "Auth failed"; break;
case WL_CONNECTION_LOST: hint = "Link lost"; break;
case WL_DISCONNECTED: hint = "Connecting…"; break;
case WL_NO_SHIELD: hint = "Init…"; break;
default: hint = "Connecting…"; break;
case WL_IDLE_STATUS: hint = TR("Starting…"); break;
case WL_NO_SSID_AVAIL: hint = TR("SSID not found"); break;
case WL_CONNECT_FAILED: hint = TR("Auth failed"); break;
case WL_CONNECTION_LOST: hint = TR("Link lost"); break;
case WL_DISCONNECTED: hint = TR("Connecting…"); break;
case WL_NO_SHIELD: hint = TR("Init…"); break;
default: hint = TR("Connecting…"); break;
}
snprintf(st, sizeof st, LV_SYMBOL_WIFI " %s%s", hint, ble_suffix);
} else if (ble_on) {
@@ -43994,20 +44139,40 @@ static void openAccentPicker() {
lv_obj_set_style_text_color(pv, lv_color_hex(COLOR_TEXT), LV_PART_MAIN);
lv_obj_center(pv);
// Buttons size to their label instead of to 124/84 px. Those numbers were
// measured against "Save & restart" / "Reset"; Hungarian's
// "Mentés és újraindítás" is half again as long and ran off both edges of its
// button (#307). A wrapping row means an over-long pair drops onto two lines
// rather than clipping, and the picker is a scrollable flex column so the
// extra height costs nothing. max_width keeps a single very long label inside
// the card instead of pushing the row wider than the screen.
lv_obj_t* btnrow = lv_obj_create(s_accent_picker);
lv_obj_remove_style_all(btnrow);
lv_obj_set_size(btnrow, 220, 32);
lv_obj_t* save = lv_btn_create(btnrow);
lv_obj_set_size(save, 124, 30); lv_obj_align(save, LV_ALIGN_LEFT_MID, 0, 0);
styleButton(save);
lv_obj_add_event_cb(save, accentSaveCb, LV_EVENT_CLICKED, nullptr);
lv_obj_set_width(btnrow, sw - 24);
lv_obj_set_height(btnrow, LV_SIZE_CONTENT);
lv_obj_set_flex_flow(btnrow, LV_FLEX_FLOW_ROW_WRAP);
lv_obj_set_flex_align(btnrow, LV_FLEX_ALIGN_SPACE_BETWEEN,
LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
lv_obj_set_style_pad_column(btnrow, 8, LV_PART_MAIN);
lv_obj_set_style_pad_row(btnrow, 6, LV_PART_MAIN);
lv_obj_clear_flag(btnrow, LV_OBJ_FLAG_SCROLLABLE);
auto sized_btn = [&](lv_event_cb_t cb) {
lv_obj_t* b = lv_btn_create(btnrow);
lv_obj_set_size(b, LV_SIZE_CONTENT, 30);
lv_obj_set_style_max_width(b, LV_PCT(100), LV_PART_MAIN);
lv_obj_set_style_pad_hor(b, 12, LV_PART_MAIN);
styleButton(b);
lv_obj_add_event_cb(b, cb, LV_EVENT_CLICKED, nullptr);
return b;
};
lv_obj_t* save = sized_btn(accentSaveCb);
lv_obj_t* sl = lv_label_create(save); lv_label_set_text(sl, TR("Save & restart"));
lv_obj_set_style_text_font(sl, &g_font_12, LV_PART_MAIN); lv_obj_center(sl);
lv_obj_t* rst = lv_btn_create(btnrow);
lv_obj_set_size(rst, 84, 30); lv_obj_align(rst, LV_ALIGN_RIGHT_MID, 0, 0);
styleButton(rst);
lv_obj_add_event_cb(rst, accentResetCb, LV_EVENT_CLICKED, nullptr);
lv_obj_t* rl = lv_label_create(rst); lv_label_set_text(rl, TR("Reset")); lv_obj_center(rl);
useChainedFont(sl);
lv_obj_t* rst = sized_btn(accentResetCb);
lv_obj_t* rl = lv_label_create(rst); lv_label_set_text(rl, TR("Reset"));
lv_obj_set_style_text_font(rl, &g_font_12, LV_PART_MAIN); lv_obj_center(rl);
useChainedFont(rl);
accentSetSelection(touchPrefsGetAccentColor(), true);
@@ -46764,7 +46929,12 @@ void luaHostRadioStats2(uint32_t* rx_evt, uint32_t* rx_drop, uint32_t* tx_pkts,
// airtime_factor -> the duty ceiling the dispatcher enforces (see #161)
*duty_pct = p->airtime_factor > 0 ? (int)(100.0f / (1.0f + p->airtime_factor) + 0.5f) : 100;
}
#if CAP_LUA_SDK_EXT
// Widened for console mode: CAP_LUA_SDK_EXT is OFF on the V4, which is exactly
// the board console mode exists for, and the console needs the same send path.
// Reusing it rather than duplicating matters most for luaHostMeshSendChannel:
// it matches the channel BY NAME at transmit time, and a cached slot index is
// how messages went out encrypted to the wrong channel before.
#if CAP_LUA_SDK_EXT || CAP_CONSOLE
// ---- Lua app permissions: mesh send ----------------------------------------
// wada.mesh.send() is the first WRITE path a store app has into the mesh, and
// anything it sends goes out under the user's own node name -- to readers it is
@@ -46990,9 +47160,9 @@ bool luaHostMeshSendChannel(const char* chan_name, const char* text) {
}
return false; // no such channel on this device
}
#endif // CAP_LUA_SDK_EXT
#endif // CAP_LUA_SDK_EXT || CAP_CONSOLE
#if CAP_LUA_SDK_EXT
#if CAP_LUA_SDK_EXT || CAP_CONSOLE // console mode needs it on the V4 too
// wada.sys.battery(). Reads the SMOOTHED millivolts the status bar uses, not a
// raw ADC sample, so a Lua chart cannot show noise the rest of the UI hides.
void luaHostBattery(uint16_t* mv, int* pct, bool* charging) {
@@ -47026,9 +47196,9 @@ bool luaHostGps(double* lat, double* lon, int* sats, int* alt_m, uint32_t* fix_t
if (lon_e6) *lon_e6 = (int32_t)llround(lo * 1.0e6);
return true;
}
#endif // CAP_LUA_SDK_EXT
#endif // CAP_LUA_SDK_EXT || CAP_CONSOLE
#if CAP_LUA_SDK_EXT
#if CAP_LUA_SDK_EXT || CAP_CONSOLE // console mode needs it on the V4 too
// ---- Settings -> App permissions -------------------------------------------
// You could GRANT a permission but not review or take one back, which is only
// acceptable on a bench. "Which apps can transmit as me?" has to be answerable,
@@ -47187,7 +47357,7 @@ void luaHostRequestProbePerm(const char* app_id, const char* app_name) {
uint32_t luaHostMeshDiscover(int type_filter) {
return the_mesh.uiStartDiscoverScan((uint8_t)(type_filter & 0xFF));
}
#endif // CAP_LUA_SDK_EXT
#endif // CAP_LUA_SDK_EXT || CAP_CONSOLE
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) {
@@ -48632,8 +48802,67 @@ bool UITask::saveMsgsToStorage() {
// ============================================================
// Thread management
// ============================================================
#if CAP_CONSOLE
// Walk the thread table for the console. Read-only: seeing a thread listed with
// its unread count must not change that count.
int UITask::consoleThreadAt(int idx, char* name, size_t cap, int* unread, bool* is_channel) {
if (!_ui_threads || idx < 0) return 0;
int seen = 0;
for (int i = 0; i < MAX_UI_THREADS; ++i) {
if (!_ui_threads[i].used) continue;
if (seen++ != idx) continue;
snprintf(name, cap, "%s", _ui_threads[i].name);
if (unread) *unread = (int)_ui_threads[i].unread;
if (is_channel) *is_channel = _ui_threads[i].channel;
return 1;
}
return 0;
}
// Read back a thread's stored messages, NEWEST first. Read-only, like
// consoleThreadAt: showing history is not the same act as clearing unread, so
// the caller decides whether to also mark it read.
int UITask::consoleHistoryAt(const char* thread, int back, char* sender, size_t sc,
char* text, size_t tc, uint32_t* ts, bool* outgoing) {
if (!_ui_msgs || !thread || !*thread || back < 0) return 0;
int seen = 0;
for (int n = 0; n < _ui_msg_count; ++n) {
// walk backwards from the newest record in the ring
int i = (_ui_msg_head - 1 - n + _ui_msg_cap * 2) % _ui_msg_cap;
const UIMessage& m = _ui_msgs[i];
if (strncmp(m.thread, thread, MAX_THREAD_NAME) != 0) continue;
if (seen++ != back) continue;
snprintf(sender, sc, "%s", m.sender);
snprintf(text, tc, "%s", m.text);
if (ts) *ts = m.ts;
if (outgoing) *outgoing = m.outgoing;
return 1;
}
return 0;
}
// Clearing unread is an explicit act, which is the whole point: the monitor
// showing a message must not count as having read it.
bool UITask::consoleMarkThreadRead(const char* name) {
if (!_ui_threads || !name || !*name) return false;
for (int i = 0; i < MAX_UI_THREADS; ++i) {
if (!_ui_threads[i].used) continue;
if (strncmp(_ui_threads[i].name, name, MAX_THREAD_NAME) != 0) continue;
markThreadRead(i);
return true;
}
return false;
}
#endif
int UITask::findOrCreateThread(const char* name, bool channel) {
if (!name || !name[0]) return -1;
// _ui_threads is allocated further down begin(), which console mode returns
// before, so it is null there. An incoming message reached here and
// dereferenced it: the device panicked, and since it rebooted straight back
// into console mode it did so every time a message arrived. Guard rather than
// allocate: in console mode nothing reads the table.
if (!_ui_threads) return -1;
for (int i = 0; i < MAX_UI_THREADS; ++i) {
if (_ui_threads[i].used && _ui_threads[i].channel == channel &&
strncmp(_ui_threads[i].name, name, MAX_THREAD_NAME) == 0) return i;
@@ -48906,6 +49135,7 @@ int UITask::appendMessage(const char* thread, const char* sender, const char* te
uint16_t in_scope) {
int t_idx = findOrCreateThread(thread, channel);
if (t_idx < 0) return -1;
if (!_ui_msgs) return -1; // console mode: the ring is never allocated
UIMessage& m = _ui_msgs[_ui_msg_head];
// Full ring: this append overwrites the oldest record — tell the segment
// table so a fully-aged-out segment file gets unlinked.
@@ -49298,11 +49528,102 @@ bool UITask::sendComposerToActiveThread(const char* override_text) {
// ============================================================
// UITask::begin
// ============================================================
#if CAP_CONSOLE
#include "ConsoleUI.h"
// Console mode (CONSOLE_MODE.md). Decided once at begin() from a boot pref and
// never re-read, so nothing can flip the front end mid-session. When set, LVGL
// is never initialised and no draw buffer is allocated: that skip IS the saving,
// so it has to be a genuine one and not a hidden LVGL instance.
static bool s_console_mode = false;
bool uiConsoleModeActive() { return s_console_mode; }
// Route a UI alert to the console. Callers in main.cpp keep calling showAlert()
// and do not need to know which front end is running.
static void consoleAlert(const char* msg) {
if (s_console_mode && msg) consoleWriteLine(msg);
}
#else
bool uiConsoleModeActive() { return false; }
#endif
// The chat store: message ring + thread table, both PSRAM. Extracted from
// begin() because CONSOLE MODE needs it too. Console mode returns from begin()
// long before the LVGL setup, and skipping this left _ui_threads null, which an
// incoming message then dereferenced. It is also what makes unread counts work
// there: the console shows a message as it arrives, but the message is still
// appended and still marked unread, so opening the thread later is what clears
// it, not having seen it scroll past.
void UITask::allocMessageStore() {
#if defined(ESP32)
_ui_msg_cap = uiDataFsIsSdCard() ? MAX_UI_MESSAGES_SD : MAX_UI_MESSAGES;
uiDataEnsureDirs(); // segment dir exists before the loader scans / the first append
#endif
size_t msgs_bytes = sizeof(UIMessage) * (size_t)_ui_msg_cap;
const size_t threads_bytes = sizeof(UIThread) * MAX_UI_THREADS;
if (!_ui_msgs) {
_ui_msgs = (UIMessage*)heap_caps_malloc(msgs_bytes, MALLOC_CAP_SPIRAM);
if (!_ui_msgs && _ui_msg_cap > MAX_UI_MESSAGES) {
// PSRAM can't fit the deep ring (unexpected) — drop to the base size rather
// than strand 1.3 MB in internal RAM via the fallback below.
_ui_msg_cap = MAX_UI_MESSAGES;
msgs_bytes = sizeof(UIMessage) * (size_t)_ui_msg_cap;
_ui_msgs = (UIMessage*)heap_caps_malloc(msgs_bytes, MALLOC_CAP_SPIRAM);
}
if (!_ui_msgs) _ui_msgs = (UIMessage*)heap_caps_malloc(msgs_bytes, MALLOC_CAP_8BIT);
}
if (!_ui_threads) {
_ui_threads = (UIThread*)heap_caps_malloc(threads_bytes, MALLOC_CAP_SPIRAM);
if (!_ui_threads) _ui_threads = (UIThread*)heap_caps_malloc(threads_bytes, MALLOC_CAP_8BIT);
}
if (_ui_msgs) memset(_ui_msgs, 0, msgs_bytes);
if (_ui_threads) memset(_ui_threads, 0, threads_bytes);
for (int i = 0; i < MAX_UI_THREADS; ++i) {
_ui_threads[i].mesh_contact_idx = -1;
memset(_ui_threads[i].mesh_contact_pub, 0, sizeof(_ui_threads[i].mesh_contact_pub));
memset(_ui_threads[i].mesh_contact_key6, 0, sizeof(_ui_threads[i].mesh_contact_key6));
_ui_threads[i].mesh_channel_slot = -1;
}
}
void UITask::begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* node_prefs) {
_display = display;
_sensors = sensors;
_node_prefs = node_prefs;
#if CAP_CONSOLE && defined(ESP32)
s_console_mode = touchPrefsGetConsoleMode();
// SELF-HEAL. If the previous boot panicked while console mode was on, come up
// in the graphical UI and turn console mode off. Without this a crash on the
// console path is unrecoverable from the device: it panics, reboots into the
// console, panics again. The plan called for three ways out and this is the
// one that does not need the user to know anything.
if (s_console_mode && esp_reset_reason() == ESP_RST_PANIC) {
s_console_mode = false;
touchPrefsSetConsoleMode(false);
Serial.println("[BOOT] console mode disabled after a panic — starting the UI");
}
if (s_console_mode) {
// Nothing below this point runs: no lv_init(), no draw buffer, no widget
// tree. The console owns the panel from here.
// The chat store, exactly as the graphical path builds it. Console mode
// needs it for two reasons: an incoming message dereferences the thread
// table, and the monitor below relies on the NORMAL append path so a
// message stays unread until its thread is actually opened.
allocMessageStore();
Serial.println("[BOOT] console: begin"); Serial.flush();
consoleBegin(_display);
Serial.println("[BOOT] console: panel up"); Serial.flush();
consoleBanner(the_mesh.getNodePrefs() ? the_mesh.getNodePrefs()->node_name : nullptr,
FIRMWARE_VERSION);
consoleWriteLineC(CC_DIM, "'help' for everything, 'ui' for the graphical interface");
consoleWriteLine("");
Serial.println("[BOOT] console: text ok"); Serial.flush();
the_mesh.setTerminalSink(&consoleWriteLine);
Serial.println("[BOOT] console: sink set"); Serial.flush();
return;
}
#endif
#if defined(WADA_LUA_SPIKE)
luaSpikeRun(); // Phase 0 measurement pass (LUA_APPS.md) — spike builds only
#endif
@@ -49726,35 +50047,7 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* no
// (5000 msgs ≈ 1.3 MB PSRAM — the card comfortably holds the bigger file);
// internal-flash devices keep 500. Decided once, before the alloc; the loader
// linearizes a history file written under either capacity.
#if defined(ESP32)
_ui_msg_cap = uiDataFsIsSdCard() ? MAX_UI_MESSAGES_SD : MAX_UI_MESSAGES;
uiDataEnsureDirs(); // segment dir exists before the loader scans / the first append
#endif
size_t msgs_bytes = sizeof(UIMessage) * (size_t)_ui_msg_cap;
const size_t threads_bytes = sizeof(UIThread) * MAX_UI_THREADS;
if (!_ui_msgs) {
_ui_msgs = (UIMessage*)heap_caps_malloc(msgs_bytes, MALLOC_CAP_SPIRAM);
if (!_ui_msgs && _ui_msg_cap > MAX_UI_MESSAGES) {
// PSRAM can't fit the deep ring (unexpected) — drop to the base size rather
// than strand 1.3 MB in internal RAM via the fallback below.
_ui_msg_cap = MAX_UI_MESSAGES;
msgs_bytes = sizeof(UIMessage) * (size_t)_ui_msg_cap;
_ui_msgs = (UIMessage*)heap_caps_malloc(msgs_bytes, MALLOC_CAP_SPIRAM);
}
if (!_ui_msgs) _ui_msgs = (UIMessage*)heap_caps_malloc(msgs_bytes, MALLOC_CAP_8BIT);
}
if (!_ui_threads) {
_ui_threads = (UIThread*)heap_caps_malloc(threads_bytes, MALLOC_CAP_SPIRAM);
if (!_ui_threads) _ui_threads = (UIThread*)heap_caps_malloc(threads_bytes, MALLOC_CAP_8BIT);
}
if (_ui_msgs) memset(_ui_msgs, 0, msgs_bytes);
if (_ui_threads) memset(_ui_threads, 0, threads_bytes);
for (int i = 0; i < MAX_UI_THREADS; ++i) {
_ui_threads[i].mesh_contact_idx = -1;
memset(_ui_threads[i].mesh_contact_pub, 0, sizeof(_ui_threads[i].mesh_contact_pub));
memset(_ui_threads[i].mesh_contact_key6, 0, sizeof(_ui_threads[i].mesh_contact_key6));
_ui_threads[i].mesh_channel_slot = -1;
}
allocMessageStore();
// NOTE: a dev-era "interop safety" block used to live here that, on EVERY
// boot, force-reset manual_add_contacts=0 and OR'd AUTO_ADD_CHAT|REPEATER into
// autoadd_config, then savePrefs()'d. That silently clobbered the user's saved
@@ -50273,6 +50566,11 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* no
// ============================================================
void UITask::showAlert(const char* text, int duration_millis) {
if (!text) return;
#if CAP_CONSOLE
// main.cpp reports Wi-Fi and Bluetooth outcomes through here in ~20 places.
// They keep calling showAlert and do not need to know which front end is up.
if (s_console_mode) { consoleAlert(text); return; }
#endif
strncpy(_alert, text, sizeof(_alert) - 1);
_alert[sizeof(_alert) - 1] = '\0';
_alert_expiry = millis() + static_cast<unsigned long>(duration_millis);
@@ -51400,6 +51698,7 @@ void UITask::newMsgImpl(uint8_t path_len, const char* from_name, const char* tex
const char* sender_override) {
(void)path_len;
_msgcount = msgcount;
const bool have_sender_override = (sender_override && sender_override[0]);
bool channel = (g_last_event == UIEventType::channelMessage);
const char* thread = channel
@@ -51553,6 +51852,18 @@ void UITask::newMsgImpl(uint8_t path_len, const char* from_name, const char* tex
}
}
#endif
#if CAP_CONSOLE
// The monitor: show it as it arrives, without opening anything. Deliberately
// placed AFTER appendMessage, which was called with mark_unread = true, so the
// message is stored and still counts as unread. Watching it scroll past is not
// reading it; opening the thread is.
if (s_console_mode && touchPrefsGetConsoleMonitor()) {
char line[200];
if (channel) snprintf(line, sizeof line, "#%s %s: %s", thread, sender, body);
else snprintf(line, sizeof line, "%s: %s", sender, body);
consoleWriteLine(line);
}
#endif
#if defined(HAS_TOUCH_UI)
g_lv.dirty_threads = true;
g_lv.dirty_timeline = true;
@@ -52008,6 +52319,39 @@ static void sdHealthTick() {
void UITask::loop() {
unsigned long now = millis();
#if CAP_CONSOLE
if (s_console_mode) {
#if CAP_TOUCH
// Bring up the input hardware ourselves. The graphical path does this far
// below, behind `if (!g_lv.ready) return;` — and g_lv.ready is false here
// because LVGL is never initialised, so nothing ever started the poll task
// and the keyboard was never even begun. That is why keys did nothing.
//
// One call covers both: the background poll task owns the shared I2C bus
// and scans the touch panel AND (on the T-Deck) the keyboard, which is
// exactly why they must not be polled from two places at once.
static bool s_con_input_up = false;
if (!s_con_input_up) {
if (heltecV4CapTouchBegin()) {
heltecV4CapTouchStartBackgroundPoll(8);
s_con_input_up = true;
}
}
#endif
// The physical-keyboard drain lives further down this function, below this
// return, so console mode has to do its own. Same ring, filled by the task
// started above.
#if defined(HAS_TDECK_KEYBOARD)
for (int kbi = 0; kbi < 12; ++kbi) {
int key = tdeckKeyboardReadKey();
if (key <= 0) break;
consoleKey(key);
}
#endif
consoleLoop();
return; // the graphical loop is never entered
}
#endif
#if defined(ESP32)
// Snapshot copying is quick; all filesystem I/O runs on the core-0 worker.
touchPrefsTick(now);
+10
View File
@@ -193,6 +193,7 @@ private:
unsigned long _next_thread_seed;
UIMessage* _ui_msgs = nullptr; // ring of recent messages — PSRAM-allocated in begin()
UIThread* _ui_threads = nullptr; // thread table — PSRAM-allocated in begin()
void allocMessageStore(); // ring + thread table; also used by console mode
unsigned long ui_started_at, next_batt_chck;
int next_backlight_btn_check = 0;
#ifdef PIN_STATUS_LED
@@ -330,6 +331,12 @@ public:
int getUnreadTotal() const;
int getUnreadMentionCount() const; // # of threads with an unread @mention of me
void markThreadRead(int idx); // clear one thread's unread count (persisted)
// Console mode: list threads with their unread counts (read-only), and clear
// one deliberately. Kept separate so the monitor cannot mark anything read.
int consoleThreadAt(int idx, char* name, size_t cap, int* unread, bool* is_channel);
bool consoleMarkThreadRead(const char* name);
int consoleHistoryAt(const char* thread, int back, char* sender, size_t sc,
char* text, size_t tc, uint32_t* ts, bool* outgoing);
void markActiveThreadRead(); // clear the currently-open thread's unread (viewing == read)
void markAllThreadsRead(); // clear every thread's unread count
bool threadHasMention(int idx) const; // unread @mention of me in this thread
@@ -375,6 +382,8 @@ public:
// Active channel's mesh slot (-1 when the open thread isn't a channel). For the
// status-bar channel-settings gear (per-channel region scope).
int16_t activeChannelSlot() const {
// _ui_threads is null in console mode (allocated later in begin()).
if (!_ui_threads) return -1;
if (!_active_thread_is_channel || _active_thread_idx < 0 || _active_thread_idx >= MAX_UI_THREADS) return -1;
return _ui_threads[_active_thread_idx].mesh_channel_slot;
}
@@ -384,6 +393,7 @@ public:
* deleting a channel actually drops its mesh-table entry — otherwise
* refreshThreadsFromMesh() recreates the thread from the surviving channel. */
int16_t threadMeshChannelSlot(int idx) const {
if (!_ui_threads) return -1; // console mode: no table
if (idx < 0 || idx >= MAX_UI_THREADS || !_ui_threads[idx].used || !_ui_threads[idx].channel) return -1;
return _ui_threads[idx].mesh_channel_slot;
}
+12
View File
@@ -194,6 +194,18 @@
#define CAP_TRACKBALL 0
#endif
// Console mode (CONSOLE_MODE.md): a text front end drawn straight to the panel
// with no LVGL. Needs a DisplayDriver, which every board that has a screen has,
// so this is on wherever there is something to draw on. It does NOT imply the
// device boots into it; that is a user pref read at startup.
#ifndef CAP_CONSOLE
#if defined(DISPLAY_CLASS)
#define CAP_CONSOLE 1
#else
#define CAP_CONSOLE 0
#endif
#endif
#if defined(HAS_EXPANSION_KIT)
#define CAP_SENSORS 1
#else