diff --git a/CONSOLE_MODE.md b/CONSOLE_MODE.md new file mode 100644 index 0000000..d8ed5fb --- /dev/null +++ b/CONSOLE_MODE.md @@ -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 ` +sets a recipient): + +- `msg`, `to`, `reply`, unread counts, reading a thread's scrollback +- `contacts`, `chans`, `discover` with the results table +- `apps`, `run ` + +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? diff --git a/deploy/apps/apps.json b/deploy/apps/apps.json index 25ff683..aa63dc6 100644 --- a/deploy/apps/apps.json +++ b/deploy/apps/apps.json @@ -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)." }, { diff --git a/deploy/apps/nearby/1.0/nearby.lua b/deploy/apps/nearby/1.0/nearby.lua index 6606560..a86d64a 100644 --- a/deploy/apps/nearby/1.0/nearby.lua +++ b/deploy/apps/nearby/1.0/nearby.lua @@ -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) diff --git a/deploy/apps/sdktest/1.4/sdktest.json b/deploy/apps/sdktest/1.4/sdktest.json new file mode 100644 index 0000000..73f8e19 --- /dev/null +++ b/deploy/apps/sdktest/1.4/sdktest.json @@ -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)."} diff --git a/deploy/apps/sdktest/1.4/sdktest.lua b/deploy/apps/sdktest/1.4/sdktest.lua new file mode 100644 index 0000000..4a3c7ea --- /dev/null +++ b/deploy/apps/sdktest/1.4/sdktest.lua @@ -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 diff --git a/deploy/apps/sdktest/1.5/sdktest.json b/deploy/apps/sdktest/1.5/sdktest.json new file mode 100644 index 0000000..96e21ea --- /dev/null +++ b/deploy/apps/sdktest/1.5/sdktest.json @@ -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)."} diff --git a/deploy/apps/sdktest/1.5/sdktest.lua b/deploy/apps/sdktest/1.5/sdktest.lua new file mode 100644 index 0000000..96f08f8 --- /dev/null +++ b/deploy/apps/sdktest/1.5/sdktest.lua @@ -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 diff --git a/deploy/site/sdk.html b/deploy/site/sdk.html index 2a4d79b..1befff9 100644 --- a/deploy/site/sdk.html +++ b/deploy/site/sdk.html @@ -231,6 +231,8 @@ return app CallReturns / does wada.ui.label(text, x, y, size, color)A text line placed at x, y. size is a class, not a pixel height: 12, 14 or 16. color takes a wada.ui.colors value. wada.ui.button(text, x, y, w, h, fn)A tappable button at x, y sized w × h; fn is called with no arguments. + wada.ui.text_w(text [, size])Rendered width of text in pixels, for the given size class. + wada.ui.text_lines(text, width [, size])How many lines text wraps to inside width. Counts explicit newlines too. Use this when you lay out your own rows. label:width() turns wrapping on, so a line longer than the screen becomes two while a fixed y step still advances by one, and the next row is drawn on top of it. This is the single most common way an app's layout goes wrong; it happened to the SDK Test app's channel list. wada.ui.list(x, y, w, h)A scrollable list of selectable rows — the "pick one of N" widget. Methods: add(text [, fn]) returning the row index, set(i, text), color(i, c), select(i) (highlights and scrolls it into view), selected(), count(), clear(), pos(x, y). Rows are real buttons, so keyboard and trackball navigation walks them on touchless boards without you doing anything. wada.ui.input(title, initial, cb)A modal text field. cb(text) on OK, cb(nil) on cancel or on the dialog being closed any other way — exactly one call, always, so an app that disabled itself while waiting always gets to re-enable. Uses the firmware's own dialog and keyboard, so it behaves identically on a touchscreen, on the Tanmatsu's physical keyboard and on a trackball board. One prompt at a time. wada.ui.canvas(w, h)A drawing surface. See the canvas methods below. @@ -445,7 +447,7 @@ end wada.sys.epoch()Unix time in seconds, or nil when the clock has not been set yet (no GPS fix and no NTP). Always handle the nil. wada.sys.datetime(){year, month, day, hour, min, sec, wday} in local time. wday is 0 for Sunday. wada.sys.beep()Short beep on boards with a buzzer; silent elsewhere, and silent when the user has sound off. - wada.sys.caps(){sdk_ext, keyboard, touch, sd, discover, input, rx_identity, list, packets, sensors, map}. Check sdk_ext before using anything marked ext 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. + wada.sys.caps(){sdk_ext, keyboard, touch, sd, discover, input, rx_identity, list, packets, sensors, map, measure}. Check sdk_ext before using anything marked ext 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. wada.sys.battery() ext{mv, pct, charging}. wada.sys.env() sensors{temp_c, humidity, pressure_hpa, alt_m}, or nil. A field is present only when the hardware actually reported it, so you can tell "no humidity sensor" from "0% humidity". Gated on caps().sensors — does this board have the sensor rail — and not on sdk_ext, which is a memory gate. Those are different questions: the plain Heltec V4 has the Expansion Kit but not the extended SDK, and most boards with the extended SDK have no sensors at all. wada.sys.gps() ext{lat, lon, lat_e6, lon_e6, alt_m, sats, time}, or nil with no fix — which is the normal indoor case, so handle it. lat_e6/lon_e6 are exact micro-degrees; lat/lon are single-precision floats, so log the integers. time is satellite time, absent until the receiver has decoded the date. diff --git a/src/helpers/esp32/TouchPrefsSchema.h b/src/helpers/esp32/TouchPrefsSchema.h index dbd309b..7877840 100644 --- a/src/helpers/esp32/TouchPrefsSchema.h +++ b/src/helpers/esp32/TouchPrefsSchema.h @@ -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 /lang/.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 diff --git a/src/helpers/esp32/TouchPrefsStore.cpp b/src/helpers/esp32/TouchPrefsStore.cpp index 2f635d6..65a875a 100644 --- a/src/helpers/esp32/TouchPrefsStore.cpp +++ b/src/helpers/esp32/TouchPrefsStore.cpp @@ -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; diff --git a/src/helpers/esp32/TouchPrefsStore.h b/src/helpers/esp32/TouchPrefsStore.h index a1ec985..b528898 100644 --- a/src/helpers/esp32/TouchPrefsStore.h +++ b/src/helpers/esp32/TouchPrefsStore.h @@ -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); diff --git a/src/ui-touch/ConsoleUI.cpp b/src/ui-touch/ConsoleUI.cpp new file mode 100644 index 0000000..d33ef5d --- /dev/null +++ b/src/ui-touch/ConsoleUI.cpp @@ -0,0 +1,748 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#include "ConsoleUI.h" + +#if CAP_CONSOLE + +#include +#include +#include +#include +#if defined(ESP32) + #include +#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 show a thread"); + consoleWriteLine(" discover / discovered"); + consoleWriteLine("radio stat, batt, wifi"); + consoleWriteLine(" read clear that thread's unread"); + consoleWriteLine(" monitor on|off show arriving messages"); + consoleWriteLine(" to pick a contact or channel"); + consoleWriteLine(" msg send to it"); + consoleWriteLine("node anything the CLI answers:"); + consoleWriteLine(" advert, get name, set name , 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 '. + 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 ' 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 ' 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 diff --git a/src/ui-touch/ConsoleUI.h b/src/ui-touch/ConsoleUI.h new file mode 100644 index 0000000..ef51e6b --- /dev/null +++ b/src/ui-touch/ConsoleUI.h @@ -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 +#include + +#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 diff --git a/src/ui-touch/LuaAppHost.cpp b/src/ui-touch/LuaAppHost.cpp index 1d88095..f125073 100644 --- a/src/ui-touch/LuaAppHost.cpp +++ b/src/ui-touch/LuaAppHost.cpp @@ -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"); diff --git a/src/ui-touch/UITask.cpp b/src/ui-touch/UITask.cpp index a6e40f0..ddf606c 100644 --- a/src/ui-touch/UITask.cpp +++ b/src/ui-touch/UITask.cpp @@ -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(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); diff --git a/src/ui-touch/UITask.h b/src/ui-touch/UITask.h index ed14248..3a631fb 100644 --- a/src/ui-touch/UITask.h +++ b/src/ui-touch/UITask.h @@ -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; } diff --git a/src/ui-touch/device_caps.h b/src/ui-touch/device_caps.h index a4a6290..39b9273 100644 --- a/src/ui-touch/device_caps.h +++ b/src/ui-touch/device_caps.h @@ -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