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