Compare commits

...
7 Commits
Author SHA1 Message Date
Kaj SchittecatandClaude Opus 4.8 831959ea5c touch: beta_43 - web control panel becomes a full remote app + V4 map-tile fix
The browser control panel gains full Chats / Contacts / Discovered / Settings
tabs (search, sort, filter, per-contact actions, notifications + live refresh,
ACK/repeats, add-from-discovered, live radio settings, quick-command picker).

Heltec V4 map tiles: fixed the "only the top renders" bug on the 2 MB-PSRAM
board with a persistent tile-buffer pool + viewport culling (no more per-render
128 KB churn/fragmentation). Adds an opt-in "Tile debug overlay" (Map options).

Also: V4 physical-keyboard input in remote/VNC, clearer remote-access screen,
complete Terminal help on device + web, WADAMESH header + status icons.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 16:29:08 +02:00
Kaj SchittecatandClaude Opus 4.8 4df391a283 touch: beta_42 - REMOTE fits the Heltec V4 + keyboard-less exit
Remote mode showed a black screen on 2 MB-PSRAM boards (Heltec V4): the
mirror ring/shadow were too big to allocate. Size the ring to one frame
and drop the remote render to 400x240 so the whole footprint is smaller
than VNC's (which already worked). Add a touch-and-hold (3 s) exit for
keyboard-less touch boards and an Exit button on the web page.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 23:34:30 +02:00
Kaj SchittecatandClaude Opus 4.8 3f375f2fe4 touch: beta_41 - VNC + REMOTE web mirror (experimental)
Stream the live LVGL UI to a phone/desktop browser over the WS companion
server (VNC app) plus a headless REMOTE mode that renders the UI at a
browser-friendly resolution. Non-blocking per-client TX buffering, a
core-0 stream task guarded by a recursive mutex, an on-page Rotate
orientation toggle, and a small 480x288 render upscaled in the browser.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 22:32:25 +02:00
Kaj Schittecat 9da367d0bc chore: refresh Mesh America catalog for beta_40 2026-07-11 12:28:49 +02:00
Kaj SchittecatandClaude Opus 4.8 01a5238662 touch: beta_40 stable — Tanmatsu About version fix + comprehensive release notes
- Tanmatsu (ESP32-P4) now bakes FIRMWARE_VERSION="wadamesh <tag>" like the S3
  release path, and the app-store publish passes the exact beta tag into the IDF
  build — so the About page shows the real beta number instead of the MeshCore
  default "v1.16.0-touch".
- beta_40 stable release notes: rolls up beta_36 through beta_40.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 12:27:00 +02:00
Kaj SchittecatandClaude Opus 4.8 946e73bb0b touch: beta_40 - card-less contacts-save stall fix + long-text auto-fit
Fixes:
- Card-less devices (Heltec V4) froze for seconds: a full contacts rewrite to
  SPIFFS on every advert refresh triggered a multi-second GC in the mesh loop.
  Coalesce advert-driven saves (a contact-set change still saves promptly); SD
  boards unchanged. Flush on reboot/power-off so nothing is lost.
- Long translations (FR/DE/...) overflowed fixed-width labels: new
  uiFitLabelWidth() shrinks the font to fit one line instead of clipping/wrapping
  - applied to the message menu, confirm dialogs, and app-drawer tiles.
- Home signal graph: "tap for details" hint moved to bottom-right so it no longer
  overlaps the "Sig" chip in longer languages.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 18:31:26 +02:00
Kaj Schittecat 326d34aaec site: add BFGNeil community video (newest first) 2026-07-10 13:05:02 +02:00
23 changed files with 2903 additions and 104 deletions

No files matched your search

+8 -8
View File
@@ -17,19 +17,19 @@
"role": "gui",
"title": "Touch UI",
"version": {
"beta_35": {
"notes": "Heltec V4: multi-second freezes right after receiving messages are gone, including the screen refusing to wake from dark mode. Saving chat history to the internal flash could stall the device for up to 6 seconds (the V4 has no SD card, and internal flash pauses for housekeeping); that save now runs on a background task, so the screen and touch stay responsive no matter what storage is doing.\nTurning Bluetooth on while Wi-Fi is running no longer reboots the device when memory is tight: the switch now checks free memory first and tells you \"Not enough free memory for Bluetooth. Turn Wi-Fi off first.\" instead of crashing.\nThe same protection applies the other way around: turning Wi-Fi on with Bluetooth active used to claim \"Wi-Fi on\" while the radio silently never started. It now either really starts or tells you why it cannot.\nSettings could vanish for a single boot after an unlucky reboot (the device came up with a default name once, then recovered). Settings writes are now crash-safe: a reboot or power cut at any moment leaves at least one intact copy on storage, the loader self-heals from it, and a failed save now says \"Couldn't save the name to storage\" instead of pretending it worked.\nAbout 30 KB of the ESP32's scarce internal RAM has been freed by moving buffers to the external PSRAM and dropping unused USB components from the build. This is the headroom that makes Wi-Fi and Bluetooth run together comfortably again, especially on the Heltec V4.",
"beta_40": {
"notes": "Big translation pass: hundreds of previously-English strings across menus, settings, dialogs and the setup flow are now translated into all 11 non-English languages (Dutch, German, French, Spanish, Italian, Russian, Ukrainian, Bulgarian, Serbian, Greek, Brazilian Portuguese). Menu rows that carry an icon translate too. Switch language under Settings.\nChat scrolling is fully virtualized (contributed by scratchdiver, PR #130): a chat now scrolls through its ENTIRE stored history, up to 5000 messages on SD-card devices, smoothly in both bubble and compact mode, instead of only the most recent rows.\nLong-press any message for a Delete option that removes just that message; the message menu lays its buttons two per row so it fits on one screen.\nThe chat and channel settings sheet gains \"Delete history\": wipes the whole conversation after a confirmation, while the channel membership or contact stays.\nThe in-chat jump buttons are slim arrows tucked against the right edge: fully visible while you scroll, fading to half opacity a second after you stop.\nThinkNode M9 hardware support is complete (contributed by ded, PR #138): GPS, microSD, buzzer, backlight control, lock screen, long-press context menus, and a d-pad-driven setup wizard. It can be installed straight from the web flasher.\nNew board: RAK WisMesh Tap V2 (contributed by Ethac.chen, PR #136), with a 30+ fps display driver, also installable from the web flasher.\nThe Wi-Fi network screens (join a network, hidden network, saved-network details) are full-screen pages now instead of small popups, matching the other settings pages and scrolling properly with the keyboard open.\nThe lock screen shows an envelope with the number of unread messages under the clock, so you can check for news without unlocking (idea from issue #93).\nPasswords with special characters can be entered everywhere: long-press any settings field, including the Wi-Fi password, for a Cut / Copy / Paste / All / Sym menu. Sym opens a picker with the characters the on-screen keyboard lacks (issue #122). Double-tap selects a word.\nFor Launcher installs: a \"Save update bin to SD\" button on the About page downloads the latest firmware of your selected channel into a BINS folder on the SD card, ready to flash from the Launcher. The test-builds toggle is visible on Launcher installs too.\nNew region preset \"Australia (Mid)\": 915.075 MHz, 125 kHz, SF9, CR5 (requested in issue #121).\nYour GPS position is now the first row in the quick-reply picker, so you can send your current coordinates with one tap (contributed by Yazutsu, PR #125).\nTanmatsu: hold ALT and press a letter to open the accent picker; keep ALT held and press again to cycle, release to insert (requested by jochentj, issue #129). Double-tap the space bar to switch between your enabled keyboard languages, same as the T-Deck.\nWhile in a chat, the top bar shows the active keyboard language next to the clock when more than one layout is enabled.\nThe \"Store data on SD\" toggle is now actually honored after a reboot (fix contributed by scratchdiver, PR #123). On Launcher installs it silently reverted to internal storage every boot, which also kept the slow internal-flash writes. Turning it off works reliably now too.\nTurning on \"Store data on SD\" migrates your existing profile, settings and identity to the card FIRST, so upgrading no longer risks adopting an empty card and booting as a brand-new node. If you were already hit by that, Settings -> General -> \"Copy internal data to SD\" restores the profile from internal flash.\nCard-less devices (Heltec V4, and any board without an SD card) no longer freeze for several seconds at a time. A busy mesh re-writes the whole contacts file to internal flash on every advert refresh, and that flash garbage-collection stalled the whole device; contact saves are now coalesced so only real changes (a new or removed node) are written promptly.\nDeleting a message, or clearing a whole chat, now sticks: it survives a reboot or a hardware reset instead of coming back (reported by Leon P and MrWeavis). Deleting is also much faster - chat history is written compactly, only the messages you actually have, so a delete, a history wipe, or a reboot no longer pauses the screen while it saves.\nMessages no longer vanish when you reboot the device from the phone app (reported by Leon P): the app's reboot command now saves chat history first, like every on-device reboot path already did.\nChat history saves to the SD card are more robust: files are written atomically with a fallback save path, so a badly timed reboot can no longer truncate your message history (contributed by scratchdiver, PR #135).\nLonger translated labels (French, German, etc.) that used to clip or wrap now shrink to fit: the message action menu, confirmation dialogs, and the app-drawer tiles all keep their text on one line. The signal card's \"tap for details\" hint no longer overlaps in other languages.\Line truncated
"files": [
{
"type": "flash-wipe",
"name": "wadamesh-heltec-v4-tft-merged.bin",
"url": "https://firmware.wadamesh.com/releases/TOUCH/beta_35/wadamesh-heltec-v4-tft-merged.bin",
"url": "https://firmware.wadamesh.com/releases/TOUCH/beta_40/wadamesh-heltec-v4-tft-merged.bin",
"title": "Full install (bootloader + firmware)"
},
{
"type": "flash-update",
"name": "wadamesh-heltec-v4-tft.bin",
"url": "https://firmware.wadamesh.com/releases/TOUCH/beta_35/wadamesh-heltec-v4-tft.bin",
"url": "https://firmware.wadamesh.com/releases/TOUCH/beta_40/wadamesh-heltec-v4-tft.bin",
"title": "Update (app only)"
}
]
@@ -49,19 +49,19 @@
"role": "gui",
"title": "Touch UI",
"version": {
"beta_35": {
"notes": "Heltec V4: multi-second freezes right after receiving messages are gone, including the screen refusing to wake from dark mode. Saving chat history to the internal flash could stall the device for up to 6 seconds (the V4 has no SD card, and internal flash pauses for housekeeping); that save now runs on a background task, so the screen and touch stay responsive no matter what storage is doing.\nTurning Bluetooth on while Wi-Fi is running no longer reboots the device when memory is tight: the switch now checks free memory first and tells you \"Not enough free memory for Bluetooth. Turn Wi-Fi off first.\" instead of crashing.\nThe same protection applies the other way around: turning Wi-Fi on with Bluetooth active used to claim \"Wi-Fi on\" while the radio silently never started. It now either really starts or tells you why it cannot.\nSettings could vanish for a single boot after an unlucky reboot (the device came up with a default name once, then recovered). Settings writes are now crash-safe: a reboot or power cut at any moment leaves at least one intact copy on storage, the loader self-heals from it, and a failed save now says \"Couldn't save the name to storage\" instead of pretending it worked.\nAbout 30 KB of the ESP32's scarce internal RAM has been freed by moving buffers to the external PSRAM and dropping unused USB components from the build. This is the headroom that makes Wi-Fi and Bluetooth run together comfortably again, especially on the Heltec V4.",
"beta_40": {
"notes": "Big translation pass: hundreds of previously-English strings across menus, settings, dialogs and the setup flow are now translated into all 11 non-English languages (Dutch, German, French, Spanish, Italian, Russian, Ukrainian, Bulgarian, Serbian, Greek, Brazilian Portuguese). Menu rows that carry an icon translate too. Switch language under Settings.\nChat scrolling is fully virtualized (contributed by scratchdiver, PR #130): a chat now scrolls through its ENTIRE stored history, up to 5000 messages on SD-card devices, smoothly in both bubble and compact mode, instead of only the most recent rows.\nLong-press any message for a Delete option that removes just that message; the message menu lays its buttons two per row so it fits on one screen.\nThe chat and channel settings sheet gains \"Delete history\": wipes the whole conversation after a confirmation, while the channel membership or contact stays.\nThe in-chat jump buttons are slim arrows tucked against the right edge: fully visible while you scroll, fading to half opacity a second after you stop.\nThinkNode M9 hardware support is complete (contributed by ded, PR #138): GPS, microSD, buzzer, backlight control, lock screen, long-press context menus, and a d-pad-driven setup wizard. It can be installed straight from the web flasher.\nNew board: RAK WisMesh Tap V2 (contributed by Ethac.chen, PR #136), with a 30+ fps display driver, also installable from the web flasher.\nThe Wi-Fi network screens (join a network, hidden network, saved-network details) are full-screen pages now instead of small popups, matching the other settings pages and scrolling properly with the keyboard open.\nThe lock screen shows an envelope with the number of unread messages under the clock, so you can check for news without unlocking (idea from issue #93).\nPasswords with special characters can be entered everywhere: long-press any settings field, including the Wi-Fi password, for a Cut / Copy / Paste / All / Sym menu. Sym opens a picker with the characters the on-screen keyboard lacks (issue #122). Double-tap selects a word.\nFor Launcher installs: a \"Save update bin to SD\" button on the About page downloads the latest firmware of your selected channel into a BINS folder on the SD card, ready to flash from the Launcher. The test-builds toggle is visible on Launcher installs too.\nNew region preset \"Australia (Mid)\": 915.075 MHz, 125 kHz, SF9, CR5 (requested in issue #121).\nYour GPS position is now the first row in the quick-reply picker, so you can send your current coordinates with one tap (contributed by Yazutsu, PR #125).\nTanmatsu: hold ALT and press a letter to open the accent picker; keep ALT held and press again to cycle, release to insert (requested by jochentj, issue #129). Double-tap the space bar to switch between your enabled keyboard languages, same as the T-Deck.\nWhile in a chat, the top bar shows the active keyboard language next to the clock when more than one layout is enabled.\nThe \"Store data on SD\" toggle is now actually honored after a reboot (fix contributed by scratchdiver, PR #123). On Launcher installs it silently reverted to internal storage every boot, which also kept the slow internal-flash writes. Turning it off works reliably now too.\nTurning on \"Store data on SD\" migrates your existing profile, settings and identity to the card FIRST, so upgrading no longer risks adopting an empty card and booting as a brand-new node. If you were already hit by that, Settings -> General -> \"Copy internal data to SD\" restores the profile from internal flash.\nCard-less devices (Heltec V4, and any board without an SD card) no longer freeze for several seconds at a time. A busy mesh re-writes the whole contacts file to internal flash on every advert refresh, and that flash garbage-collection stalled the whole device; contact saves are now coalesced so only real changes (a new or removed node) are written promptly.\nDeleting a message, or clearing a whole chat, now sticks: it survives a reboot or a hardware reset instead of coming back (reported by Leon P and MrWeavis). Deleting is also much faster - chat history is written compactly, only the messages you actually have, so a delete, a history wipe, or a reboot no longer pauses the screen while it saves.\nMessages no longer vanish when you reboot the device from the phone app (reported by Leon P): the app's reboot command now saves chat history first, like every on-device reboot path already did.\nChat history saves to the SD card are more robust: files are written atomically with a fallback save path, so a badly timed reboot can no longer truncate your message history (contributed by scratchdiver, PR #135).\nLonger translated labels (French, German, etc.) that used to clip or wrap now shrink to fit: the message action menu, confirmation dialogs, and the app-drawer tiles all keep their text on one line. The signal card's \"tap for details\" hint no longer overlaps in other languages.\Line truncated
"files": [
{
"type": "flash-wipe",
"name": "wadamesh-tdeck-merged.bin",
"url": "https://firmware.wadamesh.com/releases/TOUCH/beta_35/wadamesh-tdeck-merged.bin",
"url": "https://firmware.wadamesh.com/releases/TOUCH/beta_40/wadamesh-tdeck-merged.bin",
"title": "Full install (bootloader + firmware)"
},
{
"type": "flash-update",
"name": "wadamesh-tdeck.bin",
"url": "https://firmware.wadamesh.com/releases/TOUCH/beta_35/wadamesh-tdeck.bin",
"url": "https://firmware.wadamesh.com/releases/TOUCH/beta_40/wadamesh-tdeck.bin",
"title": "Update (app only)"
}
]
+2
View File
@@ -557,6 +557,8 @@ We use no tracking, analytics, or advertising cookies. Our CDN and security prov
// ---- community videos — add an entry here to add a video ----
const VIDEOS = [
// newest first
{ id: 's-8pRsC0TYA', title: 'WadaMesh: The Feature That Makes T-Deck Plus Stand Out', by: 'BFGNeil' },
{ id: 'V19od8jbFRc', title: 'WadaMesh: The Best T-Deck Firmware So Far', by: 'KDHD Stuff' },
// { id: '<youtube-id>', title: '<title>', by: '<channel>' },
];
+32
View File
@@ -0,0 +1,32 @@
# beta_40 (STABLE) - the first stable since beta_35. Rolls up everything from beta_36 through beta_40: reliable SD storage, message delete + full-history scrolling, a big translation pass, two new boards, and a pile of storage/perf/stability fixes.
# One user-facing note per non-blank, non-# line; # lines are section comments.
# --- New ---
Big translation pass: hundreds of previously-English strings across menus, settings, dialogs and the setup flow are now translated into all 11 non-English languages (Dutch, German, French, Spanish, Italian, Russian, Ukrainian, Bulgarian, Serbian, Greek, Brazilian Portuguese). Menu rows that carry an icon translate too. Switch language under Settings.
Chat scrolling is fully virtualized (contributed by scratchdiver, PR #130): a chat now scrolls through its ENTIRE stored history, up to 5000 messages on SD-card devices, smoothly in both bubble and compact mode, instead of only the most recent rows.
Long-press any message for a Delete option that removes just that message; the message menu lays its buttons two per row so it fits on one screen.
The chat and channel settings sheet gains "Delete history": wipes the whole conversation after a confirmation, while the channel membership or contact stays.
The in-chat jump buttons are slim arrows tucked against the right edge: fully visible while you scroll, fading to half opacity a second after you stop.
ThinkNode M9 hardware support is complete (contributed by ded, PR #138): GPS, microSD, buzzer, backlight control, lock screen, long-press context menus, and a d-pad-driven setup wizard. It can be installed straight from the web flasher.
New board: RAK WisMesh Tap V2 (contributed by Ethac.chen, PR #136), with a 30+ fps display driver, also installable from the web flasher.
The Wi-Fi network screens (join a network, hidden network, saved-network details) are full-screen pages now instead of small popups, matching the other settings pages and scrolling properly with the keyboard open.
The lock screen shows an envelope with the number of unread messages under the clock, so you can check for news without unlocking (idea from issue #93).
Passwords with special characters can be entered everywhere: long-press any settings field, including the Wi-Fi password, for a Cut / Copy / Paste / All / Sym menu. Sym opens a picker with the characters the on-screen keyboard lacks (issue #122). Double-tap selects a word.
For Launcher installs: a "Save update bin to SD" button on the About page downloads the latest firmware of your selected channel into a BINS folder on the SD card, ready to flash from the Launcher. The test-builds toggle is visible on Launcher installs too.
New region preset "Australia (Mid)": 915.075 MHz, 125 kHz, SF9, CR5 (requested in issue #121).
Your GPS position is now the first row in the quick-reply picker, so you can send your current coordinates with one tap (contributed by Yazutsu, PR #125).
Tanmatsu: hold ALT and press a letter to open the accent picker; keep ALT held and press again to cycle, release to insert (requested by jochentj, issue #129). Double-tap the space bar to switch between your enabled keyboard languages, same as the T-Deck.
While in a chat, the top bar shows the active keyboard language next to the clock when more than one layout is enabled.
# --- Fixed ---
The "Store data on SD" toggle is now actually honored after a reboot (fix contributed by scratchdiver, PR #123). On Launcher installs it silently reverted to internal storage every boot, which also kept the slow internal-flash writes. Turning it off works reliably now too.
Turning on "Store data on SD" migrates your existing profile, settings and identity to the card FIRST, so upgrading no longer risks adopting an empty card and booting as a brand-new node. If you were already hit by that, Settings -> General -> "Copy internal data to SD" restores the profile from internal flash.
Card-less devices (Heltec V4, and any board without an SD card) no longer freeze for several seconds at a time. A busy mesh re-writes the whole contacts file to internal flash on every advert refresh, and that flash garbage-collection stalled the whole device; contact saves are now coalesced so only real changes (a new or removed node) are written promptly.
Deleting a message, or clearing a whole chat, now sticks: it survives a reboot or a hardware reset instead of coming back (reported by Leon P and MrWeavis). Deleting is also much faster - chat history is written compactly, only the messages you actually have, so a delete, a history wipe, or a reboot no longer pauses the screen while it saves.
Messages no longer vanish when you reboot the device from the phone app (reported by Leon P): the app's reboot command now saves chat history first, like every on-device reboot path already did.
Chat history saves to the SD card are more robust: files are written atomically with a fallback save path, so a badly timed reboot can no longer truncate your message history (contributed by scratchdiver, PR #135).
Longer translated labels (French, German, etc.) that used to clip or wrap now shrink to fit: the message action menu, confirmation dialogs, and the app-drawer tiles all keep their text on one line. The signal card's "tap for details" hint no longer overlaps in other languages.
Emoji picker: the food and object emoji that showed as empty boxes (umbrella, coffee, pizza, cake, rocket, camera, phone, laptop, calendar, antenna, e-mail, house, car, watch) now render properly.
Tanmatsu: profile name, settings and the node identity persist reliably across reboots (moved off the unreliable internal-flash partition onto the SD card, with existing data rescued on first boot). Accented letters render at full size at the Large and Huge UI scales. The About page shows the correct firmware version now.
T-Deck: if the touch controller glitches into a stuck "phantom finger" state (touch dead, trackball clicks eaten, screen never timing out), the driver detects it and recovers within seconds instead of needing a full power cycle. Touch initialisation at boot also retries for a few seconds instead of giving up after one attempt.
The keyboard-backlight "Off" preset now actually switches the backlight off and stays off (contributed by Yazutsu, PR #88).
+10
View File
@@ -0,0 +1,10 @@
# beta_41 (TEST) - experimental web control: view and drive the wadamesh UI from a phone or desktop browser over Wi-Fi. Two new apps, VNC and REMOTE.
# One user-facing note per non-blank, non-# line; # lines are section comments.
# --- New ---
New VNC app (in the app drawer, between Advertise and Signal): it mirrors the live device screen into a web browser over Wi-Fi and lets you drive it. Taps and typing in the browser control the device, and anything that changes on the device shows up in the browser. Turn it on, then open http://<device-ip>:8765/ from any browser on the same network. The app page itself is scrollable.
New REMOTE app: a headless mode that renders the whole UI at a larger, browser-friendly resolution and serves it over Wi-Fi, while the device's own screen drops to a placeholder showing the address to open. Meant for driving a device as a web client, and as groundwork for screenless hardware. Turning it on or off reboots the device. On the web page a Rotate button flips between landscape (desktop) and portrait (phone); on the device, double-tap the space bar to leave remote mode.
Both features are served by the device itself over plain Wi-Fi - no phone app, no account, no cloud, just a browser on the same network.
# --- Note ---
This is an experimental first cut, and an honest heads-up from Kaj: for me the remote UI still feels too slow, and I will most likely move toward a dedicated, lighter remote client instead of streaming the entire live UI. It is in this test build mainly because community feedback on it would be genuinely useful - if you try it, let us know how it runs for you and what you would actually want from a web or remote client.
+9
View File
@@ -0,0 +1,9 @@
# beta_42 (TEST) - fixes for the beta_41 REMOTE mode: it works on the Heltec V4 now, and keyboard-less boards can leave it.
# One user-facing note per non-blank, non-# line; # lines are section comments.
# --- Fixed ---
REMOTE mode now works on the Heltec V4 TFT (and other 2 MB-PSRAM boards) instead of showing a black screen in the browser. Its off-screen renderer needed more free memory than those boards had, so nothing was streamed; the buffers are sized to fit now, and the render is a bit smaller and faster as a result. VNC was never affected and already worked.
Keyboard-less touch boards (Heltec V4, RAK WisMesh Tap) can leave REMOTE mode again: touch and hold the device screen for about 3 seconds. Those boards have no SPACE key, which used to be the only way out on the device.
# --- New ---
The web page has an "Exit remote" button (with a confirmation) that leaves REMOTE mode from any browser, on any board.
+24
View File
@@ -0,0 +1,24 @@
# beta_43 (TEST) - the browser control panel grows into a full remote app (Chats, Contacts, Discovered, Settings), plus a Heltec V4 map-tile fix.
# One user-facing note per non-blank, non-# line; # lines are section comments.
# --- New: web control panel ---
The browser control panel is now a full remote app - you can run the device from your phone or laptop without ever picking up the T-Deck. Proper Chats, Contacts, Terminal and Settings tabs.
Contacts tab: search, sort and filter your contacts, see each one's distance, coordinates and last-heard age, and tap a contact for the same actions you get on the device (favourite, block, reset path, remove).
Discovered nodes show up in the browser too, each with a one-tap "Add" to save it as a contact - so you can build your whole contact list from the web app.
Chats tab: pop-up notifications (with an optional beep) when a message arrives, and the open chat refreshes on its own as replies come in - no manual reload.
Acknowledgements and repeats-heard now show for messages you send from the web app, the same as on the device.
A Settings page for the everyday options that make sense remotely, including live radio changes.
A quick-command button in the Terminal that drops common commands straight into the input box.
# --- New: device ---
Map options has a "Tile debug overlay" toggle (off by default) that shows a live read-out of the map-tile pipeline, for diagnosing tile problems.
# --- Fixed ---
Heltec V4: map tiles downloaded but only the top of the map rendered and the rest stayed black. The 2 MB-PSRAM V4 was running out of contiguous memory for the tile grid; tiles now reuse a small fixed pool and only load what is on screen, so the whole map fills in smoothly again.
Heltec V4 in remote/VNC mode: typing from a physical keyboard in the browser now works (it previously only worked on the T-Deck).
The remote-access screen on the device is clearer: the IP address is shown at the top, the explanation is shorter and more accurate, the toggles sit above it, and the address box only appears once remote access is on.
The Terminal "help" command now lists every command, on both the device and the web terminal (it was missing a lot).
# --- Changed ---
The web app header now shows the same signal, Wi-Fi, Bluetooth and battery icons as the device status bar, and the title reads WADAMESH.
The contact and chat sort/filter controls open a menu to pick an option instead of cycling through them on each tap.
+4
View File
@@ -54,6 +54,10 @@ echo "==> WadaMesh app-store publish: version $VERSION, revision $REVISION"
# ---- 1. build + verify the Tanmatsu app --------------------------------
if [ "$SKIP_BUILD" -eq 0 ]; then
# Bake the EXACT release tag into the P4 binary (About page + update check). We know
# it here; git describe inside build.sh would read a "-N-g<sha>" suffix (or the prior
# beta) whenever the beta_N tag isn't fetched locally.
[[ "$ARG" =~ ^beta_([0-9]+)$ ]] && export WADA_FW_TAG="$ARG"
echo "==> building Tanmatsu app (./build.sh build)..."
LOG="$(mktemp)"
( cd "$WADAMESH/tanmatsu" && ./build.sh build ) >"$LOG" 2>&1 || true # app_check_size exit!=0 is EXPECTED
+4 -4
View File
@@ -91,14 +91,14 @@ public:
* the only consumer that never sees app-originated sends — this lets it mirror one as a local
* outgoing bubble so it shows on-device too (issue #46). Default no-op for non-touch UIs. */
virtual void appSentMsgToContact(const uint8_t* to_pub, const char* to_name, const char* text,
uint32_t ack_hash) {
(void)to_pub; (void)to_name; (void)text; (void)ack_hash;
uint32_t ack_hash, uint32_t sent_fp = 0) {
(void)to_pub; (void)to_name; (void)text; (void)ack_hash; (void)sent_fp;
}
/** Mirror an app-originated CHANNEL message into the on-device UI — the channel-send path, like the
* DM path above, never shows companion-originated sends on screen otherwise. text is NUL-terminated
* by the caller. Default no-op for non-touch UIs. */
virtual void appSentMsgToChannel(const char* channel_name, const char* text) {
(void)channel_name; (void)text;
virtual void appSentMsgToChannel(const char* channel_name, const char* text, uint32_t sent_fp = 0) {
(void)channel_name; (void)text; (void)sent_fp;
}
virtual void notify(UIEventType t = UIEventType::none) = 0;
virtual void appendDiag(const char* message) { (void)message; }
+8
View File
@@ -76,6 +76,14 @@ public:
bool removeFile(FILESYSTEM* fs, const char* filename);
uint32_t getStorageUsedKb() const;
uint32_t getStorageTotalKb() const;
#if defined(ESP32)
// True while contacts/channels live on the internal flash FS (SPIFFS/LittleFS),
// whose garbage collection makes a full rewrite expensive — a multi-second GC
// pass that freezes the loop. False once routed to an SD card (useSdStorage sets
// _root; setSecondaryFS sets _fsExtra) — FAT has no such GC. Lets callers coalesce
// the advert-driven contacts save on card-less devices without changing SD boards.
bool contactsOnInternalFlash() const { return _root[0] == '\0' && _fsExtra == nullptr; }
#endif
private:
FILESYSTEM* _getContactsChannelsFS() const { if (_fsExtra) return _fsExtra; return _fs;};
+52 -24
View File
@@ -137,6 +137,11 @@ static int s_companion_ota_pinned_reply_target = -1;
#define DIRECT_SEND_PERHOP_FACTOR 6.0f
#define DIRECT_SEND_PERHOP_EXTRA_MILLIS 250
#define LAZY_CONTACTS_WRITE_DELAY 5000
// On card-less (internal-flash) devices, coalesce advert-refresh contacts saves to
// at most once per this window — a full rewrite can trigger a multi-second SPIFFS GC
// that freezes the loop, and re-adverts (last-heard/path refreshes) otherwise churn
// it constantly. A change in the contact SET still saves promptly (see MyMesh::loop).
#define CONTACTS_REFRESH_SAVE_INTERVAL 300000
// Our self-chosen room-session keep-alive interval (secs). Room servers zero the
// legacy suggested-interval field in LOGIN_OK, so the client picks. 128 is the
// value upstream itself recommended while the field was live (CLIENT_KEEP_ALIVE_SECS
@@ -237,27 +242,26 @@ static int wifiScanWatchdogSafe(uint32_t cap_ms) {
#endif
#endif
static const char* kMeshcomodHelpMsg =
"help\n"
"status\n"
"ota start\n"
"ota url <https://...bin>\n"
"ota netdiag\n"
"ota status\n"
"wifi scan\n"
"wifi use <n>\n"
"wifi set ssid <v>\n"
"wifi set pwd <v>\n"
"wifi status\n"
"wifi on\n"
"wifi off\n"
"wifi apply\n"
"wifi clear\n"
"tcp on\n"
"tcp off\n"
"tcp status\n"
"ble on\n"
"ble off\n"
"ble status";
"help / ? this command list\n"
"status device status\n"
"ver firmware version\n"
"clock RTC time (UTC)\n"
"get show radio params\n"
"advert send a flood advert\n"
"advert.zerohop send a 0-hop advert\n"
"set name <v> set node name\n"
"set freq <MHz> set frequency\n"
"set bw <kHz> set bandwidth\n"
"set sf <7-12> set spreading factor\n"
"set cr <5-8> set coding rate\n"
"set tx <dBm> set TX power\n"
"wifi status|on|off|scan\n"
"wifi use <n> | set ssid <v> | set pwd <v> | apply | clear\n"
"tcp status|on|off\n"
"ble status|on|off\n"
"ota status|start|netdiag | ota url <https://...bin>\n"
"reboot restart the device\n"
"bootloader / dfu reboot to download mode";
#define MESHCOMOD_CMD_CACHE_SIZE 6
struct MeshcomodCmdCacheEntry {
@@ -4015,6 +4019,11 @@ static bool save_filter(const ContactInfo& c) {
void MyMesh::saveContacts() {
_store->saveContacts(this, save_filter);
// Keep the advert-save coalescer (MyMesh::loop) in sync on EVERY save path —
// lazy flush, app command, reboot — so the next lazy check compares against the
// freshly-written contact set + resets the refresh window.
_last_saved_contacts_n = getNumContacts();
_next_contacts_refresh_save = futureMillis(CONTACTS_REFRESH_SAVE_INTERVAL);
}
void MyMesh::enterCLIRescue() {
@@ -4461,10 +4470,29 @@ void MyMesh::loop() {
checkSerialInterface();
}
// is there are pending dirty contacts write needed?
// Pending contacts write. On card-less devices the contacts file lives on internal
// flash (SPIFFS/LittleFS), where a full rewrite can trigger a multi-second GC pass
// that freezes the whole loop (About "Loop stalls" showed ~18 s on a V4). Adverts
// refresh existing contacts constantly, so persisting every refresh churns the
// flash. Save promptly when the contact SET changed (a new/removed node MUST
// survive a reboot), but coalesce pure refreshes (last-heard time, path, name/GPS
// of a known node — all self-healing, and a clean reboot flushes them via
// CMD_REBOOT) to CONTACTS_REFRESH_SAVE_INTERVAL. SD-routed boards (FAT, no GC) are
// unaffected — contactsOnInternalFlash() is false there, so they always save.
if (dirty_contacts_expiry && millisHasNowPassed(dirty_contacts_expiry)) {
saveContacts();
dirty_contacts_expiry = 0;
bool defer = false;
#if defined(ESP32)
if (getNumContacts() == _last_saved_contacts_n && _store->contactsOnInternalFlash()
&& !millisHasNowPassed(_next_contacts_refresh_save)) {
defer = true; // no add/remove + still inside the refresh window on GC-prone flash
}
#endif
if (defer) {
dirty_contacts_expiry = futureMillis(LAZY_CONTACTS_WRITE_DELAY); // re-check soon; don't rewrite yet
} else {
saveContacts(); // updates _last_saved_contacts_n + _next_contacts_refresh_save
dirty_contacts_expiry = 0;
}
}
#if defined(ENABLE_ADVERT_ON_BOOT) && ENABLE_ADVERT_ON_BOOT == 1
+11
View File
@@ -836,6 +836,10 @@ public:
* "Add to contacts" button — can persist; otherwise that contact is RAM-only
* and lost on reboot. */
bool uiPersistContacts() { saveContacts(); return true; }
// Flush a pending (possibly coalesced) contacts write before a deliberate
// shutdown/reboot, so the on-device power paths don't lose the last refresh
// window on card-less devices (see MyMesh::loop). No-op when nothing is pending.
void flushContactsIfDirty() { if (dirty_contacts_expiry) { saveContacts(); dirty_contacts_expiry = 0; } }
/** Remove a contact from a device-UI action and PERSIST it (mirrors the
* companion app's CMD_REMOVE_CONTACT). The base removeContact() only drops it
@@ -979,6 +983,13 @@ private:
uint8_t *sign_data;
uint32_t sign_data_len;
unsigned long dirty_contacts_expiry;
// Advert-driven contacts-save coalescer (see MyMesh::loop). On card-less devices a
// full contacts rewrite can trigger a multi-second SPIFFS GC that freezes the loop;
// constant advert refreshes would churn it. A change in the contact SET saves
// promptly; pure refreshes coalesce to CONTACTS_REFRESH_SAVE_INTERVAL. -1 forces
// the first save. Kept in sync by every MyMesh::saveContacts() call.
int _last_saved_contacts_n = -1;
uint32_t _next_contacts_refresh_save = 0;
TransportKey send_scope;
TransportKey _chan_scope_saved; // push/popChannelScope stash
@@ -1,6 +1,7 @@
#include "MultiTransportCompanionInterface.h"
#include <helpers/RepeaterTcpOtaEmit.h>
#include "WifiRuntimeStore.h" // persist BLE on/off (ble_en) across reboots
#include "WebMirror.h" // web UI mirror bridge (served over the WS server)
#include <string.h>
// Companion push code for the per-packet RX log (matches MyMesh.cpp). It is kept OFF
@@ -43,8 +44,27 @@ void MultiTransportCompanionInterface::startTcpServer(bool wifi_connected) {
}
}
// Web-mirror streaming runs on CORE 0 (parallel to the UI on core 1) so the browser's
// socket stays fed at full link speed even while core 1 is busy with a heavy LVGL render.
// The WS client array is guarded by WebSocketCompanionServer's recursive mutex, so this
// task and the main-loop companion/handshake code never corrupt each other. serviceMirror
// is non-blocking, so the mutex is held only briefly and cross-core contention is tiny.
static void wsMirrorStreamTask(void* arg) {
WebSocketCompanionServer* ws = static_cast<WebSocketCompanionServer*>(arg);
for (;;) {
ws->serviceMirror(g_web_mirror);
vTaskDelay(pdMS_TO_TICKS(g_web_mirror.clients() > 0 ? 2 : 25)); // fast when a browser is watching, idle otherwise
}
}
void MultiTransportCompanionInterface::tickWebSocketHandshake() {
if (_ws_started) _ws.tickHandshake();
if (_ws_started) {
_ws.tickHandshake(); // accept + WS handshake stay on core 1 (the main loop)
if (!_mirror_task) { // spawn the core-0 streamer once, on first tick after the WS server is up
xTaskCreatePinnedToCore(wsMirrorStreamTask, "ws_mirror", 4096, &_ws, 2,
reinterpret_cast<TaskHandle_t*>(&_mirror_task), 0);
}
}
}
void MultiTransportCompanionInterface::stopTcpServer() {
@@ -95,6 +95,7 @@ private:
uint16_t _ws_port;
bool _tcp_started;
bool _ws_started;
void* _mirror_task = nullptr; // TaskHandle_t for the core-0 web-mirror stream task (created once)
bool _tcp_enabled; // if false, startTcpServer() no-ops until enableTcp()
bool _isEnabled;
bool _broadcast; // if true, also send responses to all other clients
+69 -1
View File
@@ -38,7 +38,7 @@ static bool s_begun = false;
// short read (→ treat as absent → defaults); `ver` lets later builds add fields.
static const char* KEY_CFG = "cfg";
static const uint16_t TOUCH_CFG_MAGIC = 0x5743; // 'WC' (WadaCfg)
static const uint8_t TOUCH_CFG_VER = 33; // v2 sig_probe/poll; v3 tz_zone; v4 hide_node_name; v5 map_night/map_zoom; v6 map text/marker visibility; v7 app_grid_large; v8 ui_scale; v9 tb_keypad; v10 sleep_idle; v11 nav_keys; v12 map_zoom_buttons; v13 nav_dir_keys; v14 home_is_drawer; v15 kbd_nav default ON (one-time migrate); v16 nav_scroll_keys; v17 notify_new_contact; v18 kbd_nav OFF by default (reverses v15; T-Deck/V4 only, Tanmatsu stays on); v19 show_sensors_tab; v20 map_show_links; v21 map_style (0=OSM default, 1=OpenTopoMap); v22 tb_nav; v23 scope_direct (opt-in: scope direct/login floods to the region); v24 tb_nav default OFF (experimental); v25 fem_lna (Heltec V4.3 high-gain FEM LNA, opt-in); v26 msg_flash (flash keyboard backlight + wake screen on a new message, opt-in); v27 flood_adv_hrs + local_adv_min (periodic self-advert intervals, the standard MeshCore flood/local advert on a timer); v28 beta_updates (opt-in to test/beta firmware on the OTA update check + install); v29 ui_scale default -> Large/150% (Tanmatsu; bumps the old 100% default, leaves an explicit Large/Huge choice); v30 boot_advert (opt-in one-shot flood self-advert ~6s after boot, all boards, #76); v31 compact_chat (opt-in IRC-style dense chat rows instead of bubbles); v32 clock_floor (highest epoch handed out — monotonic send-timestamp floor across reboots, #89); v33 rx_queue (buffered LoRa receive: drain task + packet ring, experimental, default OFF)
static const uint8_t TOUCH_CFG_VER = 39; // v2 sig_probe/poll; v3 tz_zone; v4 hide_node_name; v5 map_night/map_zoom; v6 map text/marker visibility; v7 app_grid_large; v8 ui_scale; v9 tb_keypad; v10 sleep_idle; v11 nav_keys; v12 map_zoom_buttons; v13 nav_dir_keys; v14 home_is_drawer; v15 kbd_nav default ON (one-time migrate); v16 nav_scroll_keys; v17 notify_new_contact; v18 kbd_nav OFF by default (reverses v15; T-Deck/V4 only, Tanmatsu stays on); v19 show_sensors_tab; v20 map_show_links; v21 map_style (0=OSM default, 1=OpenTopoMap); v22 tb_nav; v23 scope_direct (opt-in: scope direct/login floods to the region); v24 tb_nav default OFF (experimental); v25 fem_lna (Heltec V4.3 high-gain FEM LNA, opt-in); v26 msg_flash (flash keyboard backlight + wake screen on a new message, opt-in); v27 flood_adv_hrs + local_adv_min (periodic self-advert intervals, the standard MeshCore flood/local advert on a timer); v28 beta_updates (opt-in to test/beta firmware on the OTA update check + install); v29 ui_scale default -> Large/150% (Tanmatsu; bumps the old 100% default, leaves an explicit Large/Huge choice); v30 boot_advert (opt-in one-shot flood self-advert ~6s after boot, all boards, #76); v31 compact_chat (opt-in IRC-style dense chat rows instead of bubbles); v32 clock_floor (highest epoch handed out — monotonic send-timestamp floor across reboots, #89); v33 rx_queue (buffered LoRa receive: drain task + packet ring, experimental, default OFF); v34 web_mirror (web control panel: mirror the live UI to a phone browser + inject taps, opt-in, default OFF); v35 remote_mode (render the UI off-screen at a web resolution instead of the panel; boot mode, default OFF); v36 remote_landscape (remote mode orientation: landscape 800x480 vs portrait 480x800); v37 remote_landscape now defaults ON (remote mode = landscape/desktop by default; one-time flip of existing installs, portrait stays a toggle); v38 web_terminal (web mesh CLI terminal served on the device IP; runtime toggle, mutually exclusive with VNC, default OFF)
// Defaults (kept identical to the historical per-key defaults).
static const uint16_t DEFAULT_SCREEN_TIMEOUT_S = 20;
@@ -106,6 +106,11 @@ struct __attribute__((packed)) TouchCfg {
uint8_t compact_chat; // IRC-style dense chat rows instead of bubbles (bool, 0=off) — v31 (trailing)
uint32_t clock_floor; // highest epoch this device handed out (ClockFloorRTC persistence) — v32 (trailing)
uint8_t rx_queue; // buffered LoRa receive: drain task + packet ring (bool, 0=off, experimental) — v33 (trailing)
uint8_t web_mirror; // web control panel: mirror the live UI to a phone browser + inject taps (bool, 0=off) — v34 (trailing)
uint8_t remote_mode; // render the UI off-screen at a web resolution instead of the panel (bool, 0=off) — v35 (trailing)
uint8_t remote_landscape; // remote mode orientation: 1=landscape 800x480 (desktop), 0=portrait 480x800 (phone) — v36 (trailing)
uint8_t web_terminal; // web mesh-CLI terminal served on the device IP (runtime; exclusive with VNC) — v38 (trailing)
uint8_t map_tile_debug; // show the map tile-pipeline diagnostic overlay (bool, 0=off) — v39 (trailing)
};
static TouchCfg s_cfg;
@@ -197,6 +202,11 @@ static void cfgSetDefaults(TouchCfg& c) {
c.show_sensors_tab = 1; // default: show the V4 Expansion-Kit Sensors tab + Home env widget
c.map_show_links = 1; // default: show self->contact link lines (PR #61)
c.map_style = 0; // default: OpenStreetMap (OpenTopoMap is opt-in)
c.web_mirror = 0; // OFF: web control panel is opt-in (remote control over the LAN)
c.remote_mode = 0; // OFF: render to the physical panel (remote mode is opt-in, reboots to apply)
c.remote_landscape = 1; // landscape 800x480 by default (remote mode = desktop/browser); portrait is a toggle
c.web_terminal = 0; // OFF: web mesh terminal is opt-in (runtime; mutually exclusive with VNC)
c.map_tile_debug = 0; // OFF: map tile-pipeline diagnostic overlay is opt-in (developer)
}
// Persist the whole blob using the same end()/begin(RW)/put/end()/begin(RO)
@@ -262,6 +272,12 @@ static void cfgLoadOrMigrate() {
if (s_cfg.ver < 31) s_cfg.compact_chat = 0; // new trailing field: compact chat rows off by default
if (s_cfg.ver < 32) s_cfg.clock_floor = 0; // new trailing field: no send-timestamp floor persisted yet (#89)
if (s_cfg.ver < 33) s_cfg.rx_queue = 1; // buffered LoRa receive ON for the test channel (opt-out toggle in Radio & Mesh)
if (s_cfg.ver < 34) s_cfg.web_mirror = 0; // new trailing field: web control panel off by default (opt-in remote control)
if (s_cfg.ver < 35) s_cfg.remote_mode = 0; // new trailing field: remote mode off by default (opt-in, reboots to apply)
if (s_cfg.ver < 36) s_cfg.remote_landscape = 0;
if (s_cfg.ver < 37) s_cfg.remote_landscape = 1; // remote mode = landscape/desktop by default (one-time flip; portrait stays a toggle)
if (s_cfg.ver < 38) s_cfg.web_terminal = 0; // new trailing field: web mesh terminal off by default (opt-in)
if (s_cfg.ver < 39) s_cfg.map_tile_debug = 0; // new trailing field: tile diagnostic overlay off by default
s_cfg.ver = TOUCH_CFG_VER;
s_cfg.magic = TOUCH_CFG_MAGIC;
cfgFlush(); // rewrite with new fields defaulted-in
@@ -731,6 +747,58 @@ bool touchPrefsSetAccentPopups(bool on) {
return cfgFlush();
}
// Web control panel: mirror the live UI to a phone browser (Settings > Wi-Fi).
bool touchPrefsGetWebMirror() {
if (!s_begun) touchPrefsBegin();
return s_cfg.web_mirror != 0;
}
bool touchPrefsSetWebMirror(bool on) {
if (!s_begun) touchPrefsBegin();
s_cfg.web_mirror = on ? 1 : 0;
return cfgFlush();
}
// Remote mode: render the UI off-screen at a web resolution (boot mode; REMOTE app).
bool touchPrefsGetRemoteMode() {
if (!s_begun) touchPrefsBegin();
return s_cfg.remote_mode != 0;
}
bool touchPrefsSetRemoteMode(bool on) {
if (!s_begun) touchPrefsBegin();
s_cfg.remote_mode = on ? 1 : 0;
return cfgFlush();
}
bool touchPrefsGetRemoteLandscape() {
if (!s_begun) touchPrefsBegin();
return s_cfg.remote_landscape != 0;
}
bool touchPrefsSetRemoteLandscape(bool on) {
if (!s_begun) touchPrefsBegin();
s_cfg.remote_landscape = on ? 1 : 0;
return cfgFlush();
}
bool touchPrefsGetWebTerminal() {
if (!s_begun) touchPrefsBegin();
return s_cfg.web_terminal != 0;
}
bool touchPrefsSetWebTerminal(bool on) {
if (!s_begun) touchPrefsBegin();
s_cfg.web_terminal = on ? 1 : 0;
return cfgFlush();
}
bool touchPrefsGetMapTileDebug() {
if (!s_begun) touchPrefsBegin();
return s_cfg.map_tile_debug != 0;
}
bool touchPrefsSetMapTileDebug(bool on) {
if (!s_begun) touchPrefsBegin();
s_cfg.map_tile_debug = on ? 1 : 0;
return cfgFlush();
}
// UI accent colour (buttons, active tab, keyboard, highlights) as 0xRRGGBB.
// Default = the WADAMESH brand teal (the logo dots). The picker clamps it dark
// enough that the off-white button text stays readable on any hue.
+20
View File
@@ -49,6 +49,24 @@ bool touchPrefsSetEnabledLayouts(uint16_t mask);
bool touchPrefsGetAccentPopups();
bool touchPrefsSetAccentPopups(bool on);
/** Web control panel: serve the live UI to a phone browser over the WebSocket
* server and inject taps back (remote control). Opt-in, default OFF. */
bool touchPrefsGetWebMirror();
bool touchPrefsSetWebMirror(bool on);
/** Remote mode: on boot, render the UI to an off-screen web resolution (480x800)
* instead of the physical panel, served to a browser (headless/remote use). It is
* a boot mode (reboot to apply). Opt-in, default OFF. */
bool touchPrefsGetRemoteMode();
bool touchPrefsSetRemoteMode(bool on);
/** Remote-mode orientation: landscape (800x480, for desktop) vs portrait (480x800, for
* phones). Applied at boot (reboots to change). Default portrait. */
bool touchPrefsGetRemoteLandscape();
bool touchPrefsSetRemoteLandscape(bool on);
bool touchPrefsGetWebTerminal();
bool touchPrefsSetWebTerminal(bool on);
/** UI language index (UiLang enum in i18n.h; 0 = English). Read at boot. */
uint8_t touchPrefsGetUiLang();
bool touchPrefsSetUiLang(uint8_t lang);
@@ -96,6 +114,8 @@ bool touchPrefsGetMapShowCoords();
bool touchPrefsSetMapShowCoords(bool on);
bool touchPrefsGetMapShowTileXYZ();
bool touchPrefsSetMapShowTileXYZ(bool on);
bool touchPrefsGetMapTileDebug();
bool touchPrefsSetMapTileDebug(bool on);
bool touchPrefsGetMapShowContacts();
bool touchPrefsSetMapShowContacts(bool on);
bool touchPrefsGetMapShowLinks();
+237
View File
@@ -0,0 +1,237 @@
#include "WebMirror.h"
#include <Arduino.h>
#include <esp_heap_caps.h>
#include <string.h>
WebMirror g_web_mirror;
#ifndef WEB_MIRROR_RING_BYTES
#define WEB_MIRROR_RING_BYTES (384 * 1024) // PSRAM; holds several banded frames
#endif
void WebMirror::begin(size_t ring_bytes) {
if (_ring) return;
if (ring_bytes == 0) ring_bytes = WEB_MIRROR_RING_BYTES;
_ring = (uint8_t*)heap_caps_malloc(ring_bytes, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT);
if (!_ring) _ring = (uint8_t*)malloc(ring_bytes); // last-ditch internal DRAM
_cap = _ring ? ring_bytes : 0;
_head = _tail = 0;
}
void WebMirror::setEnabled(bool en) {
_enabled = en;
if (en) {
_need_full = true; // next connect wants a full frame
} else {
_tail = _head; // drain the ring so nothing stale lingers
}
}
static inline size_t ringUsed(size_t head, size_t tail, size_t cap) {
return (head + cap - tail) % cap;
}
bool WebMirror::pushFrame(const uint8_t* hdr, size_t hdr_len, const uint8_t* body, size_t body_len) {
if (!_ring || _cap == 0) return false;
const size_t payload = hdr_len + body_len;
if (payload == 0 || payload > 0xFFFF) { _need_full = true; return false; }
const size_t total = 2 + payload; // 2-byte length prefix + payload
size_t head = _head;
const size_t tail = _tail; // written by the consumer; a stale read is conservative
const size_t freeb = _cap - ringUsed(head, tail, _cap) - 1;
if (freeb < total) { _need_full = true; return false; } // backed up -> drop, heal via full repaint
_ring[head] = (uint8_t)(payload & 0xFF); head = (head + 1) % _cap;
_ring[head] = (uint8_t)(payload >> 8); head = (head + 1) % _cap;
for (size_t i = 0; i < hdr_len; i++) { _ring[head] = hdr[i]; head = (head + 1) % _cap; }
{
size_t first = _cap - head;
if (first > body_len) first = body_len;
memcpy(_ring + head, body, first);
if (first < body_len) memcpy(_ring, body + first, body_len - first);
head = (head + body_len) % _cap;
}
__sync_synchronize(); // publish the payload before advancing head
_head = head;
return true;
}
size_t WebMirror::popFrame(uint8_t* dst, size_t max_len) {
if (!_ring || _cap == 0) return 0;
const size_t head = _head;
__sync_synchronize(); // read head before the payload it guards
size_t tail = _tail;
const size_t used = ringUsed(head, tail, _cap);
if (used < 2) return 0;
size_t l0 = _ring[tail]; tail = (tail + 1) % _cap;
size_t l1 = _ring[tail]; tail = (tail + 1) % _cap;
const size_t payload = l0 | (l1 << 8);
if (payload == 0 || payload + 2 > used || payload > max_len) {
_tail = head; // corrupt/oversized -> resync by dropping the ring
return 0;
}
size_t first = _cap - tail;
if (first > payload) first = payload;
memcpy(dst, _ring + tail, first);
if (first < payload) memcpy(dst + first, _ring, payload - first);
_tail = (tail + payload) % _cap;
return payload;
}
void WebMirror::pushPointer(int16_t x, int16_t y, uint8_t pressed) {
// Producer = network/stream core, consumer = UI core: publish the coords before the
// _pknown flag so the reader never sees a half-updated point.
_px = x; _py = y; _ppressed = pressed ? 1 : 0;
__sync_synchronize();
_pknown = 1;
}
bool WebMirror::readPointer(int16_t* x, int16_t* y, bool* pressed) const {
if (!_pknown) return false;
__sync_synchronize();
if (x) *x = _px;
if (y) *y = _py;
if (pressed) *pressed = (_ppressed != 0);
return true;
}
void WebMirror::pushKey(uint16_t cp) {
uint8_t n = (uint8_t)((_khead + 1) % KEY_RING);
if (n != _ktail) { _keys[_khead] = cp; __sync_synchronize(); _khead = n; } // publish key before advancing head; drop if full
}
bool WebMirror::popKey(uint16_t* cp) {
if (_khead == _ktail) return false;
__sync_synchronize(); // read head before the key slot it guards
if (cp) *cp = _keys[_ktail];
_ktail = (uint8_t)((_ktail + 1) % KEY_RING);
return true;
}
// ============================ web mesh terminal ============================
#ifndef WEB_TERM_REP_BYTES
#define WEB_TERM_REP_BYTES (16 * 1024) // PSRAM reply-text ring
#endif
#ifndef WEB_TERM_DATA_BYTES
#define WEB_TERM_DATA_BYTES (24 * 1024) // PSRAM framed-JSON ring (Contacts/Chats data)
#endif
void WebMirror::termBegin() {
if (_term_rep && _term_data) return;
if (!_term_rep) {
_term_rep = (uint8_t*)heap_caps_malloc(WEB_TERM_REP_BYTES, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT);
if (!_term_rep) _term_rep = (uint8_t*)malloc(WEB_TERM_REP_BYTES);
_tr_cap = _term_rep ? WEB_TERM_REP_BYTES : 0;
_tr_head = _tr_tail = 0;
}
if (!_term_data) {
_term_data = (uint8_t*)heap_caps_malloc(WEB_TERM_DATA_BYTES, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT);
if (!_term_data) _term_data = (uint8_t*)malloc(WEB_TERM_DATA_BYTES);
_td_cap = _term_data ? WEB_TERM_DATA_BYTES : 0;
_td_head = _td_tail = 0;
}
}
void WebMirror::setTerminalOn(bool on) {
if (on) termBegin();
_term_on = on;
if (!on) { _tr_tail = _tr_head; _tc_tail = _tc_head; _td_tail = _td_head; } // drain rings on disable
}
// command ring (browser -> device): [len_lo, len_hi, bytes...]
bool WebMirror::pushTermCmd(const char* s, size_t len) {
if (!s || len == 0 || len > 250) return false;
size_t head = _tc_head;
const size_t tail = _tc_tail;
const size_t used = (head + TERM_CMD_CAP - tail) % TERM_CMD_CAP;
const size_t freeb = TERM_CMD_CAP - used - 1;
if (freeb < len + 2) return false;
_term_cmd[head] = (uint8_t)(len & 0xFF); head = (head + 1) % TERM_CMD_CAP;
_term_cmd[head] = (uint8_t)(len >> 8); head = (head + 1) % TERM_CMD_CAP;
for (size_t i = 0; i < len; i++) { _term_cmd[head] = (uint8_t)s[i]; head = (head + 1) % TERM_CMD_CAP; }
__sync_synchronize();
_tc_head = head;
return true;
}
size_t WebMirror::popTermCmd(char* dst, size_t max) {
const size_t head = _tc_head;
__sync_synchronize();
size_t tail = _tc_tail;
const size_t used = (head + TERM_CMD_CAP - tail) % TERM_CMD_CAP;
if (used < 2) return 0;
size_t l0 = _term_cmd[tail]; tail = (tail + 1) % TERM_CMD_CAP;
size_t l1 = _term_cmd[tail]; tail = (tail + 1) % TERM_CMD_CAP;
const size_t len = l0 | (l1 << 8);
if (len == 0 || len + 2 > used || len + 1 > max) { _tc_tail = head; return 0; } // corrupt/too-big -> resync
for (size_t i = 0; i < len; i++) { dst[i] = (char)_term_cmd[tail]; tail = (tail + 1) % TERM_CMD_CAP; }
dst[len] = '\0';
_tc_tail = tail;
return len;
}
// reply ring (device -> browser): raw byte stream (browser just appends text)
void WebMirror::pushTermReply(const char* s) {
if (!s || !_term_rep || _tr_cap == 0) return;
size_t head = _tr_head;
const size_t tail = _tr_tail;
size_t used = (head + _tr_cap - tail) % _tr_cap;
size_t freeb = _tr_cap - used - 1;
for (const char* p = s; *p && freeb > 0; ++p) {
_term_rep[head] = (uint8_t)*p;
head = (head + 1) % _tr_cap;
freeb--;
}
__sync_synchronize();
_tr_head = head;
}
size_t WebMirror::popTermReply(uint8_t* dst, size_t max) {
if (!_term_rep || _tr_cap == 0) return 0;
const size_t head = _tr_head;
__sync_synchronize();
size_t tail = _tr_tail;
size_t n = 0;
while (tail != head && n < max) {
dst[n++] = _term_rep[tail];
tail = (tail + 1) % _tr_cap;
}
_tr_tail = tail;
return n;
}
// data ring (device -> browser): [len_lo, len_hi, json bytes...] framed messages
bool WebMirror::pushTermData(const char* json) {
if (!json || !_term_data || _td_cap == 0) return false;
size_t len = strlen(json);
if (len == 0 || len > 0xFFFF) return false;
size_t head = _td_head;
const size_t tail = _td_tail;
const size_t used = (head + _td_cap - tail) % _td_cap;
const size_t freeb = _td_cap - used - 1;
if (freeb < len + 2) return false; // ring backed up -> drop this message (browser can re-request)
_term_data[head] = (uint8_t)(len & 0xFF); head = (head + 1) % _td_cap;
_term_data[head] = (uint8_t)(len >> 8); head = (head + 1) % _td_cap;
for (size_t i = 0; i < len; i++) { _term_data[head] = (uint8_t)json[i]; head = (head + 1) % _td_cap; }
__sync_synchronize();
_td_head = head;
return true;
}
size_t WebMirror::popTermData(uint8_t* dst, size_t max) {
if (!_term_data || _td_cap == 0) return 0;
const size_t head = _td_head;
__sync_synchronize();
size_t tail = _td_tail;
const size_t used = (head + _td_cap - tail) % _td_cap;
if (used < 2) return 0;
size_t l0 = _term_data[tail]; tail = (tail + 1) % _td_cap;
size_t l1 = _term_data[tail]; tail = (tail + 1) % _td_cap;
const size_t len = l0 | (l1 << 8);
if (len == 0 || len + 2 > used || len > max) { _td_tail = head; return 0; } // corrupt/too-big -> resync
for (size_t i = 0; i < len; i++) { dst[i] = _term_data[tail]; tail = (tail + 1) % _td_cap; }
_td_tail = tail;
return len;
}
+141
View File
@@ -0,0 +1,141 @@
#pragma once
// ============================================================================
// Web UI mirror — bridge between the LVGL UI thread and the network loop thread.
//
// The device streams its live framebuffer (every LVGL flush) out to a phone
// browser over the existing WebSocket companion server, and the browser sends
// taps back. The browser renders the device's OWN UI verbatim, so any change to
// the firmware UI shows up on the web with zero web-side code.
//
// Two independent single-producer / single-consumer channels, so no locks:
// * display : LVGL flush thread PRODUCES rects -> network thread CONSUMES + sends
// * input : network thread PRODUCES pointers -> LVGL indev CONSUMES
//
// Header is lvgl-free (stdint only) so WebSocketCompanionServer can include it
// without pulling in LVGL.
// ============================================================================
#include <stdint.h>
#include <stddef.h>
class WebMirror {
public:
void begin(size_t ring_bytes = 0); // lazily allocate the PSRAM display ring (0 = default size)
void setEnabled(bool en); // user opt-in (Settings > Wi-Fi toggle)
bool enabled() const { return _enabled; }
void setScreenSize(int w, int h) { _scr_w = (uint16_t)w; _scr_h = (uint16_t)h; }
uint16_t screenW() const { return _scr_w; }
uint16_t screenH() const { return _scr_h; }
size_t ringBytes() const { return _cap; } // 0 = ring alloc failed (feature can't stream)
// True only when we should capture/stream: opted in, ring ready, >=1 client.
// The LVGL flush hook checks this first, so the feature costs one bool load
// when off.
bool active() const { return _enabled && _ring && _clients > 0; }
// ---- display: PRODUCER (LVGL flush thread) ----
// Push one framebuffer rect as header+body. Returns false if dropped (ring
// backed up); a drop arms a full-repaint so the mirror self-heals.
bool pushFrame(const uint8_t* hdr, size_t hdr_len, const uint8_t* body, size_t body_len);
// ---- display: CONSUMER (network thread) ----
size_t popFrame(uint8_t* dst, size_t max_len); // 0 if the ring is empty
bool empty() const { return _head == _tail; } // ring fully drained -> producer backpressure gate
// Full-screen repaint handshake (new client / after a drop). The UI thread
// consumes takeFullRepaint() and invalidates the screen.
void requestFullRepaint() { _need_full = true; }
bool takeFullRepaint() { if (!_need_full) return false; _need_full = false; return true; }
// Mirror client count, refreshed by the WS server each loop.
void noteClients(int n) { _clients = n; }
int clients() const { return _clients; }
// ---- pointer: PRODUCER (network thread) / CONSUMER (LVGL indev) ----
void pushPointer(int16_t x, int16_t y, uint8_t pressed);
bool readPointer(int16_t* x, int16_t* y, bool* pressed) const; // latest known state
// ---- keyboard: PRODUCER (network thread) / CONSUMER (LVGL/UI thread) ----
void pushKey(uint16_t cp); // codepoint, or a control code (0x08 backspace, 0x0D enter)
bool popKey(uint16_t* cp); // false if the queue is empty
// ---- text-field focus (device -> browser): show/hide the phone soft keyboard ----
void setKbFocused(bool f) { if (f != _kb_focused) { _kb_focused = f; _kb_dirty = true; } }
bool kbTake(bool* out) { if (!_kb_dirty) return false; _kb_dirty = false; if (out) *out = _kb_focused; return true; }
// ---- remote-mode flag (device -> browser, carried in the size meta): the browser
// shows the Rotate button only in remote mode (VNC mirrors a fixed panel). ----
void setRemote(bool r) { _remote = r; }
bool remote() const { return _remote; }
// ---- orientation request (browser -> device): the Rotate button asks for a new axis;
// the UI thread consumes it, saves the pref and reboots into that orientation. ----
void requestOrient(uint8_t v) { _orient_req = v; } // 1 = landscape, 2 = portrait, 0 = none
uint8_t takeOrient() { uint8_t v = _orient_req; _orient_req = 0; return v; }
// ---- exit-remote request (browser -> device): the Exit button leaves remote mode.
// Board-agnostic way out for keyboard-less boards (V4/RAK). ----
void requestExit() { _exit_req = true; }
bool takeExit() { bool v = _exit_req; _exit_req = false; return v; }
// ============ web mesh terminal (text CLI over WS; separate from the framebuffer) ============
// Lightweight text path: browser sends a command line, the device runs runLocalCli() and
// streams the CLI reply text back. No framebuffer, no reboot. Runtime toggle; the WS server
// serves the terminal page + a /term socket while on.
void termBegin(); // lazily alloc the reply ring (PSRAM); idempotent
void setTerminalOn(bool on); // runtime enable (Remote app "Remote terminal")
bool terminalOn() const { return _term_on; }
void noteTermClients(int n) { _term_clients = n; }
int termClients() const { return _term_clients; }
bool termActive() const { return _term_on && _term_rep && _term_clients > 0; }
// browser -> device: one command line. PRODUCER = WS server (net core), CONSUMER = UI/mesh loop.
bool pushTermCmd(const char* s, size_t len);
size_t popTermCmd(char* dst, size_t max); // one command (0 if none)
// device -> browser: CLI reply text stream. PRODUCER = mesh loop (CLI sink), CONSUMER = WS server.
void pushTermReply(const char* s); // append text (drops if the ring is full)
size_t popTermReply(uint8_t* dst, size_t max); // drain up to max bytes (0 if empty)
// device -> browser: framed JSON data messages (contacts / threads / msgs / live push for the
// Contacts + Chats tabs). PRODUCER = UI loop, CONSUMER = WS server. Length-prefixed, so each
// pops as one complete message the browser can JSON.parse.
bool pushTermData(const char* json); // one complete JSON message (dropped if it doesn't fit)
size_t popTermData(uint8_t* dst, size_t max); // one complete message (0 if none)
private:
uint8_t* _ring = nullptr;
size_t _cap = 0;
volatile size_t _head = 0; // producer writes, consumer reads
volatile size_t _tail = 0; // consumer writes, producer reads
volatile bool _enabled = false;
volatile bool _need_full = false;
volatile int _clients = 0;
uint16_t _scr_w = 0, _scr_h = 0;
volatile int16_t _px = 0, _py = 0;
volatile uint8_t _ppressed = 0;
volatile uint8_t _pknown = 0;
static const int KEY_RING = 32; // typed keys awaiting the UI thread (SPSC)
uint16_t _keys[KEY_RING];
volatile uint8_t _khead = 0, _ktail = 0;
volatile bool _kb_focused = false, _kb_dirty = false; // editable-field focus signal
volatile bool _remote = false; // remote mode -> browser shows the Rotate button
volatile uint8_t _orient_req = 0; // browser-requested orientation (1=landscape, 2=portrait, 0=none)
volatile bool _exit_req = false; // browser asked to leave remote mode
// ---- web terminal channels ----
volatile bool _term_on = false;
volatile int _term_clients = 0;
static const int TERM_CMD_CAP = 512; // command ring: length-prefixed lines (small; commands are short)
uint8_t _term_cmd[TERM_CMD_CAP];
volatile size_t _tc_head = 0, _tc_tail = 0;
uint8_t* _term_rep = nullptr; // reply ring: byte stream, PSRAM, lazily allocated
size_t _tr_cap = 0;
volatile size_t _tr_head = 0, _tr_tail = 0;
uint8_t* _term_data = nullptr; // data ring: length-prefixed JSON messages, PSRAM
size_t _td_cap = 0;
volatile size_t _td_head = 0, _td_tail = 0;
};
extern WebMirror g_web_mirror;
+710 -9
View File
@@ -3,6 +3,8 @@
#include <mbedtls/sha1.h>
#include <string.h>
#include <lwip/sockets.h>
#include <esp_heap_caps.h>
#include "WebMirror.h"
#ifndef WS_FRAME_DEBUG
#define WS_FRAME_DEBUG 0
@@ -10,17 +12,397 @@
#define TCP_WRITE_TIMEOUT_MS 120
#define WS_WEDGED_DROP_MS 10000
#define WS_MIRROR_WRITE_TIMEOUT_MS 250 // mirror bands are bigger than companion frames — a little more headroom before we treat the frame as unrecoverable
#define WS_MAGIC "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
#define WS_MIRROR_TXBUF 33000 // max popped mirror frame (band <= 32000 + header)
#define WS_MIRROR_TX_BUDGET (48 * 1024) // max mirror bytes pushed per serviceMirror() call
#define WS_MIRROR_CLIENT_TXBUF 16384 // per-client outgoing frame buffer (mirror bands <=4 KB; terminal JSON data frames can be larger)
// Browser opens http://device:8765 — short info page (plain WebSocket endpoint).
// Browser opens http://device:8765/ — the web UI mirror page. It opens a
// WebSocket to /mirror, paints framebuffer bands the device streams (RGB565 LE,
// LV_COLOR_16_SWAP=0), and forwards pointer taps back. Self-contained (no CDN):
// a strict LAN, plain-HTTP page. The MeshCore companion app still connects with a
// WebSocket upgrade to "/" and is routed to the companion protocol, not here.
static const char WS_HTTP_INFO_PAGE[] =
"HTTP/1.1 200 OK\r\n"
"Content-Type: text/html; charset=utf-8\r\n"
"Connection: close\r\n"
"\r\n"
"<!DOCTYPE html><html><head><meta charset=utf-8><title>meshcomod WebSocket</title></head><body>"
"<h1>meshcomod WebSocket</h1><p>Plain WebSocket endpoint. Use the meshcomod client with "
"<code>ws://</code> to this host and port.</p></body></html>";
"<!DOCTYPE html><html><head><meta charset=utf-8>\n"
"<meta name=viewport content='width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no'>\n"
"<link href='https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@700&display=swap' rel=stylesheet>\n"
"<title>wadamesh</title>\n"
"<style>\n"
"html,body{margin:0;height:100%;background:#0b0b0c;color:#e8e8ea;font-family:system-ui,-apple-system,sans-serif;overflow:hidden;-webkit-user-select:none;user-select:none;touch-action:none}\n"
"#w{display:flex;flex-direction:column;align-items:center;justify-content:flex-start;height:100%;gap:8px;padding-top:6px;box-sizing:border-box}\n"
"#c{background:#000;image-rendering:auto;max-width:100vw;box-shadow:0 0 26px #000a;border-radius:8px}\n"
"#s{font-size:13px;opacity:.7} #s.ok{color:#19d6c2} #s.err{color:#ff6b6b}\n"
"b{color:#19d6c2;letter-spacing:.5px}\n"
"#kb{margin-top:8px;padding:9px 18px;font-size:14px;background:#1c1c1f;color:#e8e8ea;border:1px solid #333;border-radius:8px}\n"
"#kb:active{background:#19d6c2;color:#000;border-color:#19d6c2}\n"
"#rot{margin-top:6px;padding:7px 15px;font-size:12px;background:#141416;color:#c8ccce;border:1px solid #2a2a2e;border-radius:8px;display:none}\n"
"#rot:active{background:#19d6c2;color:#000;border-color:#19d6c2}\n"
"#xit{margin-top:6px;padding:7px 15px;font-size:12px;background:#1e1416;color:#e0a6a6;border:1px solid #4a2a2e;border-radius:8px;display:none}\n"
"#xit:active{background:#ff6b6b;color:#000;border-color:#ff6b6b}\n"
"#k{position:fixed;top:0;left:0;width:1px;height:1px;opacity:0;border:0;padding:0;font-size:16px}\n"
"#hdr{position:fixed;top:14px;left:18px;display:none;align-items:center;gap:11px;z-index:5}\n"
"#hdr .wm{font-family:'JetBrains Mono','Courier New',monospace;font-weight:700;font-size:22px;letter-spacing:2px;color:#e8e8ea}\n"
"#hdr .wm .m{color:#15B6A6}\n"
// Desktop = a real mouse (hover + fine pointer), NOT window width — a half-width or
// display-scaled desktop window is still a desktop. Touch devices get the compact layout.
"@media(hover:hover) and (pointer:fine){#hdr{display:flex}#cap{display:none}}\n"
"</style></head><body>\n"
"<div id=hdr><svg width='40' height='27' viewBox='0 0 180 120' fill='none'><path d='M10 60 L50 108 L90 60 L130 108 L170 60' stroke='#dfe3e8' stroke-width='9' stroke-linecap='round' stroke-linejoin='round'/><path d='M10 60 L50 12 L90 60 L130 12 L170 60' stroke='#dfe3e8' stroke-width='9' stroke-linecap='round' stroke-linejoin='round'/><g fill='#15B6A6'><circle cx='10' cy='60' r='7'/><circle cx='90' cy='60' r='7'/><circle cx='170' cy='60' r='7'/><circle cx='50' cy='12' r='7'/><circle cx='130' cy='12' r='7'/><circle cx='50' cy='108' r='7'/><circle cx='130' cy='108' r='7'/></g></svg><span class=wm>WADA<span class=m>MESH</span></span></div>\n"
"<div id=w>\n"
"<div id=cap><b>wadamesh</b> &nbsp; live UI</div>\n"
"<canvas id=c width=320 height=240></canvas>\n"
"<div id=s>connecting...</div>\n"
"<button id=kb>&#9000; Keyboard</button>\n"
"<button id=rot>&#8635; Rotate</button>\n"
"<button id=xit>&#10005; Exit remote</button>\n"
"<textarea id=k autocomplete=off autocorrect=off autocapitalize=off spellcheck=false></textarea>\n"
"</div>\n"
"<script>\n"
"var C=document.getElementById('c'),X=C.getContext('2d'),S=document.getElementById('s');\n"
"var OT=document.createElement('canvas'),OX=OT.getContext('2d');\n"
"var DW=320,DH=240,down=false,last=0,ws;\n"
"var isTouch=('ontouchstart' in window)||navigator.maxTouchPoints>0,kbT=null;\n"
"var ROT=document.getElementById('rot'),XIT=document.getElementById('xit');\n"
"function st(t,k){S.textContent=t;S.className=k||''}\n"
"function conn(){\n"
" ws=new WebSocket((location.protocol=='https:'?'wss://':'ws://')+location.host+'/mirror');\n"
" ws.binaryType='arraybuffer';\n"
" ws.onopen=function(){st('connected','ok')};\n"
" ws.onclose=function(){st('disconnected - retrying','err');setTimeout(conn,1500)};\n"
" ws.onerror=function(){st('connection error','err')};\n"
" ws.onmessage=function(e){\n"
" var a=new Uint8Array(e.data);\n"
" if(a[0]==2){DW=a[1]|(a[2]<<8);DH=a[3]|(a[4]<<8);if(C.width!=DW)C.width=DW;if(C.height!=DH)C.height=DH;var rem=(a.length>5&&(a[5]&1))?'':'none';ROT.style.display=rem;XIT.style.display=rem;fit();return}\n"
" if(a[0]==3){clearTimeout(kbT);if(a[1])K.focus();else K.blur();return}\n"
" if(a[0]==1){\n"
" var fl=a[1],x=a[2]|(a[3]<<8),y=a[4]|(a[5]<<8),w=a[6]|(a[7]<<8),h=a[8]|(a[9]<<8);\n"
" var hf=fl&2,ow=hf?((w+1)>>1):w,oh=hf?((h+1)>>1):h;\n"
" var img=X.createImageData(ow,oh),d=img.data,L=d.length,p=10,o=0,v,r,g,b,c;\n"
" if(fl&1){\n"
" while(o<L){c=a[p++];v=a[p]|(a[p+1]<<8);p+=2;r=(v>>8)&0xf8;g=(v>>3)&0xfc;b=(v<<3)&0xf8;\n"
" while(c-->0){d[o++]=r;d[o++]=g;d[o++]=b;d[o++]=255}}\n"
" }else{\n"
" while(o<L){v=a[p]|(a[p+1]<<8);p+=2;d[o++]=(v>>8)&0xf8;d[o++]=(v>>3)&0xfc;d[o++]=(v<<3)&0xf8;d[o++]=255}\n"
" }\n"
" if(hf){OT.width=ow;OT.height=oh;OX.putImageData(img,0,0);X.imageSmoothingEnabled=true;X.drawImage(OT,0,0,ow,oh,x,y,w,h)}\n"
" else{X.putImageData(img,x,y)}\n"
" }\n"
" }\n"
"}\n"
"function xy(e){var r=C.getBoundingClientRect();\n"
" var x=(e.clientX-r.left)*DW/r.width,y=(e.clientY-r.top)*DH/r.height;\n"
" x=x<0?0:x>=DW?DW-1:x;y=y<0?0:y>=DH?DH-1:y;return[x|0,y|0]}\n"
"function snd(x,y,pr){if(!ws||ws.readyState!=1)return;ws.send(new Uint8Array([1,x&255,x>>8,y&255,y>>8,pr]))}\n"
"C.addEventListener('pointerdown',function(e){down=true;try{C.setPointerCapture(e.pointerId)}catch(x){}var p=xy(e);snd(p[0],p[1],1);e.preventDefault()});\n"
"C.addEventListener('pointermove',function(e){if(!down)return;var t=Date.now();if(t-last<33)return;last=t;var p=xy(e);snd(p[0],p[1],1)});\n"
"function up(e){if(!down)return;down=false;var p=xy(e);snd(p[0],p[1],0);\n"
" if(isTouch){K.value=' ';K.focus();try{K.setSelectionRange(1,1)}catch(x){}clearTimeout(kbT);kbT=setTimeout(function(){K.blur()},350)}}\n"
"C.addEventListener('pointerup',up);C.addEventListener('pointercancel',up);\n"
"var K=document.getElementById('k');\n"
"document.getElementById('kb').addEventListener('click',function(){K.value=' ';K.focus();try{K.setSelectionRange(1,1)}catch(x){}});\n"
"ROT.addEventListener('click',function(){if(!ws||ws.readyState!=1)return;st('rotating - reconnecting','');ws.send(new Uint8Array([4,DW<DH?1:0]))});\n"
"XIT.addEventListener('click',function(){if(!ws||ws.readyState!=1)return;if(!confirm('Leave remote mode? The device reboots to its normal screen.'))return;st('leaving remote - rebooting','');ws.send(new Uint8Array([5]))});\n"
"function skey(cp){if(!ws||ws.readyState!=1)return;ws.send(new Uint8Array([2,cp&255,(cp>>8)&255]))}\n"
"K.addEventListener('beforeinput',function(e){var t=e.inputType;\n"
" if(t=='insertText'&&e.data){for(var i=0;i<e.data.length;i++)skey(e.data.codePointAt(i));e.preventDefault()}\n"
" else if(t=='deleteContentBackward'){skey(8);e.preventDefault()}\n"
" else if(t=='insertLineBreak'||t=='insertParagraph'){skey(13);e.preventDefault()}\n"
" K.value=' ';try{K.setSelectionRange(1,1)}catch(x){}});\n"
"window.addEventListener('keydown',function(e){\n"
" if(document.activeElement===K)return;if(e.ctrlKey||e.metaKey||e.altKey)return;\n"
" if(e.key=='Backspace'){skey(8);e.preventDefault()}\n"
" else if(e.key=='Enter'){skey(13);e.preventDefault()}\n"
" else if(e.key.length==1){skey(e.key.codePointAt(0));e.preventDefault()}});\n"
"function fit(){var vv=window.visualViewport,vw=vv?vv.width:window.innerWidth,vh=vv?vv.height:window.innerHeight;\n"
" var desk=matchMedia('(hover:hover) and (pointer:fine)').matches,ch=desk?102:60,f=desk?0.72:0.99;\n"
" var aw=vw-8,ah=vh-ch;if(aw<60)aw=60;if(ah<60)ah=60;var s=Math.min(aw/DW,ah/DH)*f;\n"
" C.style.width=Math.max(1,Math.round(DW*s))+'px';C.style.height=Math.max(1,Math.round(DH*s))+'px'}\n"
"if(window.visualViewport)window.visualViewport.addEventListener('resize',fit);\n"
"window.addEventListener('resize',fit);fit();\n"
"conn();\n"
"</script></body></html>";
// Self-contained web mesh terminal (served at / while web_terminal is on). Talks the
// device's runLocalCli over a /term WebSocket: type a command, get the reply streamed
// back. Command history (up/down), tab-completion, clear, styled monospace output.
static const char WS_HTML_TERMINAL_PAGE[] =
"HTTP/1.1 200 OK\r\n"
"Content-Type: text/html; charset=utf-8\r\n"
"Connection: close\r\n"
"\r\n"
"<!DOCTYPE html><html><head><meta charset=utf-8>\n"
"<meta name=viewport content='width=device-width,initial-scale=1,maximum-scale=1'>\n"
"<title>wadamesh</title>\n"
"<style>\n"
"*{box-sizing:border-box}html,body{margin:0;height:100%;background:#0b0b0c;color:#d7dbdd;font-family:-apple-system,'Segoe UI',Roboto,sans-serif;font-size:14px;-webkit-text-size-adjust:100%}\n"
"#w{display:flex;flex-direction:column;height:100%}\n"
"#hd{padding:8px 10px;border-bottom:1px solid #1c1c1f;display:flex;align-items:center;gap:8px}#hdl{flex:1}\n"
"#hd b{color:#19d6c2;font-weight:700;letter-spacing:2px}#st{font-size:12px}\n"
"#tabs{display:flex;border-bottom:1px solid #1c1c1f}\n"
"#tabs button{flex:1;background:none;border:none;color:#7f868c;padding:11px 4px;font:inherit;font-size:13px;border-bottom:2px solid transparent;cursor:pointer}\n"
"#tabs button.on{color:#19d6c2;border-bottom-color:#19d6c2}\n"
".pane{flex:1;min-height:0;display:flex;flex-direction:column}\n"
".list{flex:1;overflow-y:auto}\n"
".row{display:flex;padding:11px 13px;border-bottom:1px solid #141416;cursor:pointer;gap:10px;align-items:center}\n"
".row:active{background:#141416}.rmain{flex:1;min-width:0}\n"
".rname{font-weight:600;color:#e8e8ea;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}\n"
".rsub{font-size:12px;color:#7f868c;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin-top:2px}\n"
".rtime{font-size:11px;color:#63696e;white-space:nowrap}\n"
".badge{background:#19d6c2;color:#04201d;border-radius:9px;padding:0 6px;font-size:11px;margin-left:6px;font-weight:700}\n"
".sec{padding:9px 13px 4px;font-size:11px;color:#63696e;letter-spacing:1px}\n"
".empty{padding:26px 13px;color:#63696e;text-align:center}\n"
"#cview{flex:1;min-height:0;flex-direction:column}\n"
"#cvh{display:flex;align-items:center;gap:6px;padding:8px 8px;border-bottom:1px solid #1c1c1f}\n"
"#cvback{background:none;border:none;color:#19d6c2;font-size:24px;line-height:1;cursor:pointer;padding:0 6px}\n"
"#cvname{font-weight:600}\n"
"#cvmsgs{flex:1;overflow-y:auto;padding:10px;display:flex;flex-direction:column;gap:6px}\n"
".b{max-width:80%;padding:6px 9px;border-radius:11px;word-break:break-word}\n"
".b.in{align-self:flex-start;background:#17181b}.b.out{align-self:flex-end;background:#123f3a}\n"
".bname{font-size:11px;color:#19d6c2;margin-bottom:2px}.btext{white-space:pre-wrap}\n"
".bmeta{font-size:10px;color:#8a9095;text-align:right;margin-top:2px}\n"
".cb{display:flex;border-top:1px solid #1c1c1f;padding:8px;gap:8px}\n"
".cb input{flex:1;background:#111214;color:#e8e8ea;border:1px solid #2a2a2e;border-radius:18px;padding:9px 14px;font:inherit;outline:none}\n"
".cb input:focus{border-color:#19d6c2}\n"
".cb button{background:#19d6c2;color:#04201d;border:none;border-radius:18px;padding:0 16px;font-weight:700;cursor:pointer}\n"
"#o{flex:1;overflow-y:auto;padding:10px 12px;white-space:pre-wrap;word-break:break-word;line-height:1.4;font-family:'JetBrains Mono',Menlo,monospace;font-size:13px}\n"
"#tbar{display:flex;border-top:1px solid #1c1c1f;padding:8px 10px;gap:8px;align-items:center}\n"
"#p{color:#19d6c2;font-weight:700}\n"
"#i{flex:1;background:#111214;color:#e8e8ea;border:1px solid #2a2a2e;border-radius:6px;padding:8px 10px;font-family:'JetBrains Mono',Menlo,monospace;font-size:13px;outline:none}\n"
"#i:focus{border-color:#19d6c2}\n"
".e{color:#7f868c}.c{color:#e8b84b}.ok{color:#19d6c2}.err{color:#ff6b6b}\n"
"#hdl{display:flex;align-items:center;gap:6px;min-width:0}#hnm{color:#9aa0a6;font-size:12px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}\n"
"#dot{font-size:11px;color:#63696e}#hdr{display:flex;align-items:center;gap:8px;font-size:12px;color:#9aa0a6;white-space:nowrap}\n"
"#hdr svg{vertical-align:middle;display:block}#hdr>span{display:inline-flex;align-items:center}.bpct{font-size:11px;margin-left:3px;color:#9aa0a6}\n"
".bars{letter-spacing:-1px;color:#19d6c2}.bars i{color:#3a3f44;font-style:normal}\n"
".bgrn{color:#19d6c2}.byel{color:#e8b84b}.bred{color:#ff6b6b}\n"
"#ctools{display:flex;gap:6px;padding:7px 8px;border-bottom:1px solid #1c1c1f}\n"
"#csearch{flex:1;background:#111214;color:#e8e8ea;border:1px solid #2a2a2e;border-radius:14px;padding:6px 12px;font:inherit;font-size:13px;outline:none;min-width:0}\n"
"#ctools button{background:#17181b;color:#cfd3d6;border:1px solid #2a2a2e;border-radius:14px;padding:0 10px;font:inherit;font-size:12px;white-space:nowrap;cursor:pointer}\n"
".tag{font-size:10px;padding:1px 5px;border-radius:6px;margin-left:6px;vertical-align:middle}.trep{background:#243b52;color:#7fc0ff}.troom{background:#3b2a52;color:#c69cff}.tfav{color:#e8b84b}\n"
"#scrim{position:fixed;inset:0;background:rgba(0,0,0,.45);z-index:19;display:none}#scrim.on{display:block}\n"
"#sheet{position:fixed;left:0;right:0;bottom:0;background:#151619;border-top:1px solid #2a2a2e;border-radius:14px 14px 0 0;transform:translateY(101%);transition:transform .15s;z-index:20;padding:6px 0 max(10px,env(safe-area-inset-bottom));max-height:74vh;overflow-y:auto}\n"
"#qcmd{background:#17181b;color:#19d6c2;border:1px solid #2a2a2e;border-radius:6px;padding:0 11px;font-size:16px;line-height:34px;cursor:pointer}\n"
"#sheet.on{transform:translateY(0)}#sheet h4{margin:8px 16px 4px;font-size:13px;color:#9aa0a6}\n"
".si{display:block;width:100%;text-align:left;background:none;border:none;color:#d7dbdd;padding:13px 16px;font:inherit;font-size:15px;cursor:pointer;border-top:1px solid #202225}.si:active{background:#1d1f22}.si.dng{color:#ff6b6b}\n"
"#modal{position:fixed;inset:0;display:none;align-items:center;justify-content:center;z-index:21;padding:20px}#modal.on{display:flex}\n"
"#mbox{background:#151619;border:1px solid #2a2a2e;border-radius:12px;max-width:340px;width:100%;padding:16px;max-height:82%;overflow:auto}#mbox h3{margin:0 0 10px;color:#19d6c2;font-size:15px;word-break:break-word}\n"
"#mbox .kv{display:flex;justify-content:space-between;gap:12px;padding:5px 0;font-size:13px;border-bottom:1px solid #202225}#mbox .kv b{color:#9aa0a6;font-weight:400}#mbox .kv span{text-align:right;word-break:break-word}\n"
"#mclose{margin-top:12px;width:100%;background:#19d6c2;color:#04201d;border:none;border-radius:8px;padding:10px;font-weight:700;cursor:pointer}\n"
"#gear{background:none;border:none;color:#9aa0a6;font-size:17px;cursor:pointer;padding:0 2px;line-height:1}\n"
".sset label{display:block;font-size:11px;color:#63696e;letter-spacing:1px;margin:12px 0 4px}\n"
".sset input[type=text],.sset input[type=number]{background:#111214;color:#e8e8ea;border:1px solid #2a2a2e;border-radius:6px;padding:7px 9px;font:inherit;outline:none;width:100%}\n"
".srow{display:flex;gap:8px;align-items:center;margin:4px 0}.srow input{flex:1;min-width:0}\n"
".sset .sbtn{background:#19d6c2;color:#04201d;border:none;border-radius:6px;padding:0 12px;font-weight:700;line-height:32px;cursor:pointer;white-space:nowrap}\n"
".sgrid{display:grid;grid-template-columns:1fr 1fr;gap:6px}.sgrid .f label{margin:2px 0}.sgrid .f{min-width:0}\n"
".stog{display:flex;justify-content:space-between;align-items:center;padding:9px 0;border-bottom:1px solid #202225}.stog b{font-weight:400;color:#d7dbdd}\n"
".sw{position:relative;width:40px;height:22px;background:#2a2a2e;border-radius:11px;cursor:pointer;transition:background .15s;flex:none}.sw.on{background:#19d6c2}.sw i{position:absolute;top:2px;left:2px;width:18px;height:18px;border-radius:50%;background:#e8e8ea;transition:left .15s}.sw.on i{left:20px}\n"
".srow2{display:flex;gap:8px;margin-top:14px}.srow2 button{flex:1;background:#17181b;color:#cfd3d6;border:1px solid #2a2a2e;border-radius:8px;padding:10px;font:inherit;cursor:pointer}.srow2 button.dng{color:#ff6b6b;border-color:#5a2a2a}\n"
"#toast{position:fixed;left:8px;right:8px;top:8px;background:#17342f;border:1px solid #19d6c2;border-radius:10px;padding:9px 12px;z-index:30;box-shadow:0 4px 16px rgba(0,0,0,.45);transform:translateY(-150%);transition:transform .2s;cursor:pointer}\n"
"#toast.on{transform:translateY(0)}#toast b{color:#19d6c2;display:block;font-size:13px;margin-bottom:1px}\n"
"#toast span{font-size:12px;color:#cfeee9;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;display:block}\n"
"</style></head><body>\n"
"<div id=w>\n"
"<div id=hd><div id=hdl><span id=dot>&bull;</span><b>WADAMESH</b><span id=hnm></span></div>\n"
" <div id=hdr><span id=hsig></span><span id=hwifi></span><span id=hble></span><span id=hbatt></span><span id=hclk></span></div><button id=gear title=Settings>&#9881;</button></div>\n"
"<div id=tabs><button data-t=chats class=on>Chats</button><button data-t=contacts>Contacts</button><button data-t=term>Terminal</button></div>\n"
"<div id=pchats class=pane>\n"
" <div id=tlist class=list></div>\n"
" <div id=cview style=display:none>\n"
" <div id=cvh><button id=cvback>&lsaquo;</button><span id=cvname></span></div>\n"
" <div id=cvmsgs></div>\n"
" <div class=cb><input id=cvi placeholder='Message' autocomplete=off><button id=cvsend>Send</button></div>\n"
" </div>\n"
"</div>\n"
"<div id=pcontacts class=pane style=display:none>\n"
" <div id=ctools><input id=csearch placeholder='Search' autocomplete=off autocapitalize=off spellcheck=false><button id=csort>Sort</button><button id=cfilt>All</button></div>\n"
" <div id=clist class=list></div>\n"
"</div>\n"
"<div id=pterm class=pane style=display:none>\n"
" <div id=o></div>\n"
" <div id=tbar><span id=p>&gt;</span><input id=i autocomplete=off autocorrect=off autocapitalize=off spellcheck=false placeholder='type a command - e.g. help'><button id=qcmd title='Commands'>&#9889;</button></div>\n"
"</div>\n"
"</div>\n"
"<div id=scrim></div>\n"
"<div id=sheet></div>\n"
"<div id=modal><div id=mbox></div></div>\n"
"<div id=toast></div>\n"
"<script>\n"
"var WS,dec=new TextDecoder(),curTab='chats',chat=null,threads=[],contacts=[],channels=[];\n"
"var csort=0,cfilter=0,csearchq='',self={la:0,lo:0},lpAt=0,useMi=0,discCount=0;\n"
"var SORTL=['Recent','A-Z','Distance'],FILTL=['All','Repeaters','Peers','Favorites','Located','Direct'];\n"
"function age(ts){if(!ts||ts<1000000000)return'';var s=Math.floor(Date.now()/1000)-ts;if(s<0)s=0;if(s<60)return'now';if(s<3600)return Math.floor(s/60)+'m';if(s<86400)return Math.floor(s/3600)+'h';if(s<604800)return Math.floor(s/86400)+'d';return Math.floor(s/604800)+'w'}\n"
"function fmtDist(km){if(km>=1e17)return'';if(useMi){var mi=km*0.621371;return (mi<10?mi.toFixed(1):Math.round(mi))+' mi'}return (km<10?km.toFixed(1):Math.round(km))+' km'}\n"
"function E(i){return document.getElementById(i)}\n"
"function each(l,f){Array.prototype.forEach.call(l,f)}\n"
"function esc(s){return (s==null?'':''+s).replace(/[&<>\"]/g,function(c){return({'&':'&amp;','<':'&lt;','>':'&gt;','\"':'&quot;'})[c]})}\n"
"function fmt(t){if(!t||t<1000000000)return'';var d=new Date(t*1000);return('0'+d.getHours()).slice(-2)+':'+('0'+d.getMinutes()).slice(-2)}\n"
"function fmtDate(t){return (t>1000000000)?new Date(t*1000).toLocaleString():'--'}\n"
"function stat(t,k){var el=E('dot');el.style.color=(k=='ok')?'#19d6c2':((k=='err')?'#ff6b6b':'#63696e');el.title=t}\n"
"function wsSend(s){if(WS&&WS.readyState==1){WS.send(s);return true}return false}\n"
"function cById(i){for(var k=0;k<contacts.length;k++)if(contacts[k].i==i)return contacts[k];return null}\n"
"function showTab(t){curTab=t;['chats','contacts','term'].forEach(function(x){E('p'+x).style.display=(x==t)?'':'none'});\n"
" each(document.querySelectorAll('#tabs button'),function(b){b.className=(b.getAttribute('data-t')==t)?'on':''});\n"
" if(t=='chats'){if(!chat){closeChat();wsSend('@t')}}else if(t=='contacts'){wsSend('@c')}else if(t=='term'){E('i').focus()}}\n"
"function svgSig(l){var b='';for(var i=0;i<4;i++){var h=3+i*3;b+='<rect x='+(i*4)+' y='+(12-h)+' width=3 height='+h+' rx=1 fill='+(i<l?'#19d6c2':'#3a3f44')+' />'}return '<svg width=15 height=12 viewBox=\"0 0 15 12\">'+b+'</svg>'}\n"
"function svgWifi(on){var c=on?'#19d6c2':'#3a3f44';return '<svg width=16 height=12 viewBox=\"0 0 16 12\" fill=none stroke='+c+' stroke-width=1.5 stroke-linecap=round><path d=\"M2.2 4.6a8 8 0 0 1 11.6 0\" /><path d=\"M4.6 7.1a4.6 4.6 0 0 1 6.8 0\" /><circle cx=8 cy=9.6 r=0.9 fill='+c+' stroke=none /></svg>'}\n"
"function svgBt(){return '<svg width=10 height=14 viewBox=\"0 0 10 14\" fill=none stroke=#7fb0ff stroke-width=1.3 stroke-linejoin=round stroke-linecap=round><path d=\"M2 4l6 6-3 2.5V1.5l3 2.5-6 6\" /></svg>'}\n"
"function svgBatt(p,chg){var col=p<=20?'#ff6b6b':(p<=50?'#e8b84b':'#19d6c2');var w=Math.max(1,Math.round(p/100*16));var bolt=chg?'<path d=\"M12 3l-3 4h2l-1 3 3-4h-2z\" fill=#e8b84b />':'';return '<svg width=27 height=13 viewBox=\"0 0 27 13\"><rect x=0.5 y=2 width=21 height=9 rx=2 fill=none stroke=#8a9095 /><rect x=22 y=4.5 width=2.2 height=4 rx=1 fill=#8a9095 /><rect x=2 y=3.5 width='+w+' height=6 rx=1 fill='+col+' />'+bolt+'</svg>'}\n"
"function renderStatus(d){E('hnm').textContent=d.nm||'';self.la=d.sla||0;self.lo=d.slo||0;useMi=d.mi?1:0;\n"
" var lvl=(d.snr<=-127)?0:(d.snr>=5?4:(d.snr>=0?3:(d.snr>=-7?2:1)));\n"
" E('hsig').innerHTML=svgSig(lvl);E('hwifi').innerHTML=d.wifi?svgWifi(1):'';E('hble').innerHTML=d.ble?svgBt():'';\n"
" E('hbatt').innerHTML=(d.batt<0)?'':(svgBatt(d.batt,d.chg)+'<span class=bpct>'+d.batt+'%</span>');\n"
" E('hclk').textContent=fmt(d.clk);\n"
" var cb=document.querySelector('#tabs [data-t=chats]');if(cb)cb.textContent=d.unr>0?('Chats ('+d.unr+')'):'Chats'}\n"
"function closeOv(){E('sheet').classList.remove('on');E('modal').classList.remove('on');E('scrim').classList.remove('on')}\n"
"function sheet(title,items){var h='<h4>'+esc(title)+'</h4>';items.forEach(function(it,i){h+='<button class=\"si '+(it.cls||'')+'\" data-i='+i+'>'+esc(it.label)+'</button>'});\n"
" E('sheet').innerHTML=h;each(E('sheet').querySelectorAll('.si'),function(b){b.onclick=function(){var it=items[+b.getAttribute('data-i')];closeOv();if(it.fn)it.fn()}});\n"
" E('sheet').classList.add('on');E('scrim').classList.add('on')}\n"
"function modal(title,html){E('mbox').innerHTML='<h3>'+esc(title)+'</h3>'+html+'<button id=mclose>Close</button>';E('modal').classList.add('on');E('scrim').classList.add('on');E('mclose').onclick=closeOv}\n"
"function copy(t){try{navigator.clipboard.writeText(t)}catch(_){var a=document.createElement('textarea');a.value=t;document.body.appendChild(a);a.select();try{document.execCommand('copy')}catch(e){}a.remove()}}\n"
"function longPress(el,fn){var tmr=null;function s(){tmr=setTimeout(function(){tmr=null;lpAt=Date.now();fn()},480)}function c(){if(tmr){clearTimeout(tmr);tmr=null}}\n"
" el.addEventListener('touchstart',s,{passive:true});el.addEventListener('touchmove',c,{passive:true});el.addEventListener('touchend',c);\n"
" el.addEventListener('mousedown',s);el.addEventListener('mouseup',c);el.addEventListener('mouseleave',c);el.addEventListener('contextmenu',function(e){e.preventDefault();lpAt=Date.now();fn()})}\n"
"function clicked(fn){return function(){if(Date.now()-lpAt<650)return;fn()}}\n"
"function contactSheet(c){sheet(c.n||'Contact',[\n"
" {label:'Message',fn:function(){if(c.t>=0)wsSend('@m '+c.t);else wsSend('@oc '+c.i)}},\n"
" {label:'Info',fn:function(){wsSend('@ic '+c.i)}},\n"
" {label:c.fav?'Unfavorite':'Favorite',fn:function(){wsSend('@fv '+c.i)}},\n"
" {label:'Reset path',fn:function(){wsSend('@pr '+c.i)}},\n"
" {label:c.ign?'Unblock':'Block',fn:function(){wsSend('@bk '+c.i)}},\n"
" {label:'Delete',cls:'dng',fn:function(){if(confirm('Delete '+(c.n||'contact')+'?'))wsSend('@xc '+c.i)}}])}\n"
"function chatSheet(t){sheet(t.n||'Chat',[\n"
" {label:'Mark as read',fn:function(){wsSend('@r '+t.i)}},\n"
" {label:'Clear history',fn:function(){if(confirm('Clear history?'))wsSend('@d '+t.i)}},\n"
" {label:'Delete chat',cls:'dng',fn:function(){if(confirm('Delete this chat?'))wsSend('@xt '+t.i)}}])}\n"
"function msgSheet(m){sheet('Message',[{label:'Copy',fn:function(){copy(m.x)}},{label:'Info',fn:function(){msgInfo(m)}}])}\n"
"function msgInfo(m){var pl=(m.pl==255)?'Direct':(m.pl+' hop'+(m.pl==1?'':'s'));\n"
" var h='<div class=kv><b>Direction</b><span>'+(m.o?'Sent':'Received')+'</span></div><div class=kv><b>Time</b><span>'+(fmt(m.ts)||'--')+'</span></div><div class=kv><b>Path</b><span>'+pl+'</span></div>';\n"
" if(!m.o)h+='<div class=kv><b>SNR</b><span>'+m.sn+' dB</span></div><div class=kv><b>RSSI</b><span>'+m.rs+' dBm</span></div>';\n"
" if(m.o)h+='<div class=kv><b>Delivery</b><span>'+(({0:'--',1:'Sent',2:'Delivered',3:'Failed'})[m.ds]||'--')+'</span></div>';\n"
" if(m.o&&m.rp>0)h+='<div class=kv><b>Repeats heard</b><span>'+m.rp+'</span></div>';\n"
" modal('Message info',h)}\n"
"function showContactInfo(d){var type=d.rp?'Repeater':(d.rm?'Room server':'Contact');var pl=(d.pl<0)?'Unknown':(d.pl==0?'Direct':(d.pl+' hop'+(d.pl==1?'':'s')));\n"
" var h='<div class=kv><b>Type</b><span>'+type+'</span></div><div class=kv><b>Path</b><span>'+pl+'</span></div><div class=kv><b>Favorite</b><span>'+(d.fav?'Yes':'No')+'</span></div><div class=kv><b>Blocked</b><span>'+(d.ign?'Yes':'No')+'</span></div>';\n"
" if(d.adv>1000000000)h+='<div class=kv><b>Last heard</b><span>'+fmtDate(d.adv)+'</span></div>';\n"
" if(d.la||d.lo){h+='<div class=kv><b>Location</b><span>'+(d.la/1e6).toFixed(5)+', '+(d.lo/1e6).toFixed(5)+'</span></div>';var ds=fmtDist(cdist(d));if(ds)h+='<div class=kv><b>Distance</b><span>'+ds+'</span></div>'}\n"
" h+='<div class=kv><b>ID</b><span>'+esc(d.k)+'</span></div>';modal(d.n||'Contact',h)}\n"
"var prevUnread={},toastTmr=null,AC=null;\n"
"function onThreads(nt){\n"
" nt.forEach(function(t){var pu=prevUnread[t.i];if(pu!=null&&t.u>pu&&!(chat&&chat.i==t.i))notify(t.i,(t.ch?'# ':'')+t.n,t.last||'New message')});\n"
" prevUnread={};nt.forEach(function(t){prevUnread[t.i]=t.u});threads=nt;renderThreads()}\n"
"function notify(ti,title,body){showToast(ti,title,body);\n"
" try{if('Notification' in window&&Notification.permission=='granted'&&document.hidden)new Notification(title,{body:body})}catch(e){}\n"
" try{beep()}catch(e){}}\n"
"function showToast(ti,title,body){var t=E('toast');t.innerHTML='<b></b><span></span>';t.querySelector('b').textContent=title;t.querySelector('span').textContent=body;\n"
" t.onclick=function(){t.classList.remove('on');wsSend('@m '+ti)};t.classList.add('on');\n"
" if(toastTmr)clearTimeout(toastTmr);toastTmr=setTimeout(function(){t.classList.remove('on')},4200)}\n"
"function beep(){AC=AC||new (window.AudioContext||window.webkitAudioContext)();var o=AC.createOscillator(),g=AC.createGain();o.connect(g);g.connect(AC.destination);o.frequency.value=880;g.gain.value=0.05;o.start();g.gain.exponentialRampToValueAtTime(0.0001,AC.currentTime+0.18);o.stop(AC.currentTime+0.2)}\n"
"document.addEventListener('click',function(){try{if('Notification' in window&&Notification.permission=='default')Notification.requestPermission()}catch(e){}},{once:true});\n"
"var QCMDS=[{h:'CHAT'},{l:'list - contacts',c:'list'},{l:'channels',c:'channels'},{l:'to <name>',c:'to '},{l:'send <text>',c:'send '},{l:'public <text>',c:'public '},{l:'exit',c:'exit'},\n"
" {h:'INFO'},{l:'help',c:'help'},{l:'ver - firmware version',c:'ver'},{l:'clock - RTC time',c:'clock'},{l:'status',c:'status'},{l:'get - radio params',c:'get'},\n"
" {h:'RADIO / MESH'},{l:'advert',c:'advert'},{l:'advert.zerohop',c:'advert.zerohop'},{l:'set name <v>',c:'set name '},{l:'set freq <MHz>',c:'set freq '},{l:'set bw <kHz>',c:'set bw '},{l:'set sf <7-12>',c:'set sf '},{l:'set cr <5-8>',c:'set cr '},{l:'set tx <dBm>',c:'set tx '},\n"
" {h:'CONNECTIVITY'},{l:'wifi status',c:'wifi status'},{l:'wifi on',c:'wifi on'},{l:'wifi off',c:'wifi off'},{l:'wifi scan',c:'wifi scan'},{l:'wifi apply',c:'wifi apply'},{l:'wifi clear',c:'wifi clear'},{l:'tcp status',c:'tcp status'},{l:'tcp on',c:'tcp on'},{l:'tcp off',c:'tcp off'},{l:'ble status',c:'ble status'},{l:'ble on',c:'ble on'},{l:'ble off',c:'ble off'},{l:'ota status',c:'ota status'},{l:'ota start',c:'ota start'},\n"
" {h:'SYSTEM'},{l:'reboot',c:'reboot'},{l:'bootloader - download mode',c:'bootloader'}];\n"
"function cmdPicker(){var h='<h4>Commands</h4>';QCMDS.forEach(function(x,i){if(x.h)h+='<div class=sec>'+esc(x.h)+'</div>';else h+='<button class=si data-i='+i+'>'+esc(x.l)+'</button>'});\n"
" E('sheet').innerHTML=h;each(E('sheet').querySelectorAll('.si'),function(b){b.onclick=function(){var x=QCMDS[+b.getAttribute('data-i')];closeOv();showTab('term');E('i').value=x.c;E('i').focus()}});\n"
" E('sheet').classList.add('on');E('scrim').classList.add('on')}\n"
"var hist=[],hi=0,CMDS=['help','?','status','ver','clock','get','advert','advert.zerohop','reboot','bootloader','set ','tcp ','ble ','ota ','wifi ','list','contacts','channels','to ','send ','public ','exit','leave','ok','cancel','clear'];\n"
"function tadd(t,cls){var s=document.createElement('span');if(cls)s.className=cls;s.textContent=t;E('o').appendChild(s);E('o').scrollTop=E('o').scrollHeight}\n"
"function tsend(){var v=E('i').value;E('i').value='';if(!v.trim())return;if(v.trim()=='clear'){E('o').innerHTML='';return}\n"
" tadd('\\n> '+v+'\\n','c');hist.push(v);hi=hist.length;if(!wsSend(v))tadd('(not connected)\\n','err')}\n"
"function onData(s){var d;try{d=JSON.parse(s)}catch(_){return}\n"
" if(d.t=='t'){onThreads(d.threads||[])}\n"
" else if(d.t=='c'){contacts=d.contacts||[];channels=d.channels||[];discCount=d.dc||0;renderContacts()}\n"
" else if(d.t=='m'){openChatView(d)}\n"
" else if(d.t=='st'){renderStatus(d)}\n"
" else if(d.t=='ci'){showContactInfo(d)}\n"
" else if(d.t=='dc'){showDiscovered(d.nodes||[])}\n"
" else if(d.t=='sg'){showSettings(d)}\n"
" else if(d.t=='rx'){wsSend('@t');if(chat&&chat.i>=0)wsSend('@m '+chat.i);if(curTab=='contacts')wsSend('@c')}}\n"
"function renderThreads(){var h='';if(!threads.length)h='<div class=empty>No chats yet.<br>Start one from Contacts.</div>';\n"
" threads.sort(function(a,b){return (b.ts||0)-(a.ts||0)});\n"
" threads.forEach(function(t){var bd=t.u>0?'<span class=badge>'+t.u+'</span>':'';\n"
" h+='<div class=row data-ti='+t.i+'><div class=rmain><div class=rname>'+(t.ch?'# ':'')+esc(t.n)+bd+'</div><div class=rsub>'+esc(t.last||'')+'</div></div><div class=rtime>'+fmt(t.ts)+'</div></div>'});\n"
" E('tlist').innerHTML=h;each(E('tlist').querySelectorAll('.row'),function(r){var ti=+r.getAttribute('data-ti');\n"
" r.onclick=clicked(function(){wsSend('@m '+ti)});var t=null;for(var k=0;k<threads.length;k++)if(threads[k].i==ti)t=threads[k];longPress(r,function(){if(t)chatSheet(t)})})}\n"
"function cdist(c){if(!(c.la||c.lo)||!(self.la||self.lo))return 1e18;var R=6371,rad=Math.PI/180,dLa=(c.la-self.la)/1e6*rad,dLo=(c.lo-self.lo)/1e6*rad,l1=self.la/1e6*rad,l2=c.la/1e6*rad;\n"
" var a=Math.sin(dLa/2)*Math.sin(dLa/2)+Math.cos(l1)*Math.cos(l2)*Math.sin(dLo/2)*Math.sin(dLo/2);return R*2*Math.atan2(Math.sqrt(a),Math.sqrt(1-a))}\n"
"function cVis(c){if(csearchq&&(c.n||'').toLowerCase().indexOf(csearchq)<0)return false;\n"
" if(cfilter==1)return c.rp;if(cfilter==2)return !c.rp;if(cfilter==3)return c.fav;if(cfilter==4)return !!(c.la||c.lo);if(cfilter==5)return c.dir;return true}\n"
"function renderContacts(){E('csort').textContent='Sort: '+SORTL[csort];E('cfilt').textContent=FILTL[cfilter];var h='';\n"
" if(cfilter==0&&!csearchq){h+='<div class=row id=discrow><div class=rmain><div class=rname>Discovered nodes'+(discCount?' <span class=badge>'+discCount+'</span>':'')+'</div><div class=rsub>heard nearby - tap to add</div></div><div class=rtime>\\u203a</div></div>'}\n"
" if(cfilter==0&&!csearchq&&channels.length){h+='<div class=sec>CHANNELS</div>';channels.forEach(function(c){h+='<div class=row data-h='+c.i+' data-t='+c.t+'><div class=rmain><div class=rname># '+esc(c.n)+'</div></div></div>'})}\n"
" var cs=contacts.filter(cVis);cs.sort(function(a,b){if(csort==1)return (a.n||'').localeCompare(b.n||'');if(csort==2)return cdist(a)-cdist(b);return (b.adv||0)-(a.adv||0)});\n"
" h+='<div class=sec>CONTACTS'+(cs.length?' ('+cs.length+')':'')+'</div>';if(!cs.length)h+='<div class=empty>No contacts.</div>';\n"
" cs.forEach(function(c){var tag=c.rp?'<span class=\"tag trep\">RPT</span>':(c.rm?'<span class=\"tag troom\">ROOM</span>':'');var fav=c.fav?'<span class=\"tag tfav\">\\u2605</span>':'';var blk=c.ign?'<span class=tag style=color:#ff6b6b>\\u2298</span>':'';\n"
" var sub=[fmtDist(cdist(c)),age(c.adv)].filter(Boolean).join(' \\u00b7 ');if(c.dir)sub=sub?(sub+' \\u00b7 direct'):'direct';\n"
" h+='<div class=row data-c='+c.i+' data-t='+c.t+'><div class=rmain><div class=rname>'+esc(c.n)+tag+fav+blk+'</div>'+(sub?'<div class=rsub>'+sub+'</div>':'')+'</div></div>'});\n"
" E('clist').innerHTML=h;var dr=E('discrow');if(dr)dr.onclick=function(){wsSend('@dc')};\n"
" each(E('clist').querySelectorAll('.row'),function(r){\n"
" if(r.hasAttribute('data-c')){var ci=+r.getAttribute('data-c');var open=function(){var c=cById(ci);if(c)contactSheet(c)};r.onclick=clicked(open);longPress(r,open)}\n"
" else if(r.hasAttribute('data-h')){r.onclick=clicked(function(){var t=+r.getAttribute('data-t');if(t>=0)wsSend('@m '+t);else wsSend('@oh '+r.getAttribute('data-h'))})}})}\n"
"function showDiscovered(nodes){if(!nodes.length){sheet('Discovered nodes',[{label:'No new nodes heard yet'}]);return}\n"
" var items=nodes.map(function(nd){var tag=nd.rp?' [RPT]':(nd.rm?' [ROOM]':'');var meta=[fmtDist(cdist(nd)),age(nd.adv)].filter(Boolean).join(' \\u00b7 ');\n"
" return {label:(nd.n||'(unnamed)')+tag+(meta?' ('+meta+')':''),fn:function(){wsSend('@ad '+nd.i);showToast(-1,'Added contact',nd.n||'')}}});\n"
" sheet('Discovered - tap to add',items)}\n"
"function showSettings(d){var g=E;\n"
" var h='<div class=sset><label>NODE NAME</label><div class=srow><input type=text id=sfname><button class=sbtn id=sbname>Save</button></div>';\n"
" h+='<label>RADIO (applies live)</label><div class=sgrid>';\n"
" h+='<div class=f><label>Freq MHz</label><input type=number step=0.001 id=sffreq></div><div class=f><label>BW kHz</label><input type=number id=sfbw></div>';\n"
" h+='<div class=f><label>SF</label><input type=number id=sfsf></div><div class=f><label>CR</label><input type=number id=sfcr></div>';\n"
" h+='<div class=f><label>TX dBm</label><input type=number id=sftx></div><div class=f><label>&nbsp;</label><button class=sbtn id=sbradio style=width:100%>Apply</button></div></div>';\n"
" h+='<label>CONNECTIVITY</label><div class=stog><b>Wi-Fi</b><div class=sw id=swifi><i></i></div></div>';\n"
" if(d.blecap)h+='<div class=stog><b>Bluetooth</b><div class=sw id=sble><i></i></div></div>';\n"
" h+='<div class=stog><b>TCP (phone app)</b><div class=sw id=stcp><i></i></div></div>';\n"
" h+='<div class=srow2><button id=sadvert>Send advert</button><button id=sreboot class=dng>Reboot</button></div></div>';\n"
" E('mbox').innerHTML='<h3>Settings</h3>'+h+'<button id=mclose>Close</button>';\n"
" g('sfname').value=d.name||'';g('sffreq').value=d.freq;g('sfbw').value=d.bw;g('sfsf').value=d.sf;g('sfcr').value=d.cr;g('sftx').value=d.tx;\n"
" function tg(id,on){var el=g(id);if(el){on?el.classList.add('on'):el.classList.remove('on')}}\n"
" tg('swifi',d.wifi);tg('sble',d.ble);tg('stcp',d.tcp);\n"
" g('sbname').onclick=function(){var v=g('sfname').value.trim();if(v){wsSend('set name '+v);showToast(-1,'Name saved',v)}};\n"
" g('sbradio').onclick=function(){wsSend('@sr '+(+g('sffreq').value)+' '+(+g('sfbw').value)+' '+(+g('sfsf').value)+' '+(+g('sfcr').value)+' '+(+g('sftx').value));showToast(-1,'Radio applied','')};\n"
" g('swifi').onclick=function(){var on=!g('swifi').classList.contains('on');tg('swifi',on);wsSend('wifi '+(on?'on':'off'))};\n"
" if(g('sble'))g('sble').onclick=function(){var on=!g('sble').classList.contains('on');tg('sble',on);wsSend('ble '+(on?'on':'off'))};\n"
" g('stcp').onclick=function(){var on=!g('stcp').classList.contains('on');tg('stcp',on);wsSend('tcp '+(on?'on':'off'))};\n"
" g('sadvert').onclick=function(){wsSend('advert');showToast(-1,'Advert sent','')};\n"
" g('sreboot').onclick=function(){if(confirm('Reboot the device now?'))wsSend('reboot')};\n"
" g('mclose').onclick=closeOv;E('modal').classList.add('on');E('scrim').classList.add('on')}\n"
"function openChatView(d){var box=E('cvmsgs');var wasBottom=(box.scrollHeight-box.scrollTop-box.clientHeight)<44;var same=chat&&d.i!=null&&d.i>=0&&chat.i==d.i;\n"
" chat={i:(d.i!=null?d.i:-1),ch:!!d.ch,n:d.n||'',c:(d.c!=null?d.c:null),h:(d.h!=null?d.h:null)};\n"
" showTab('chats');E('tlist').style.display='none';E('cview').style.display='flex';E('cvname').textContent=(chat.ch?'# ':'')+chat.n;\n"
" var arr=(d.msgs||[]).slice().reverse();var h='';arr.forEach(function(m,i){var cls=m.o?'b out':'b in';var meta=fmt(m.ts);\n"
" if(m.o){meta+=' '+(({1:'\\u2713',2:'\\u2713\\u2713',3:'\\u2717'})[m.ds]||'');if(m.rp>0)meta+=' \\u21bb'+m.rp}\n"
" var nm=(!m.o&&chat.ch)?'<div class=bname>'+esc(m.s)+'</div>':'';\n"
" h+='<div class=\"'+cls+'\" data-mi='+i+'>'+nm+'<div class=btext>'+esc(m.x)+'</div><div class=bmeta>'+meta+'</div></div>'});\n"
" E('cvmsgs').innerHTML=h;if(!same||wasBottom)E('cvmsgs').scrollTop=E('cvmsgs').scrollHeight;\n"
" each(E('cvmsgs').querySelectorAll('.b'),function(b){var m=arr[+b.getAttribute('data-mi')];longPress(b,function(){if(m)msgSheet(m)})})}\n"
"function closeChat(){chat=null;E('cview').style.display='none';E('tlist').style.display=''}\n"
"function chatSend(){var v=E('cvi').value.trim();if(!v||!chat)return;E('cvi').value='';\n"
" if(chat.i>=0)wsSend('@s '+chat.i+' '+v);else if(chat.c!=null)wsSend('@sc '+chat.c+' '+v);else if(chat.h!=null)wsSend('@sh '+chat.h+' '+v)}\n"
"function conn(){WS=new WebSocket((location.protocol=='https:'?'wss://':'ws://')+location.host+'/term');WS.binaryType='arraybuffer';\n"
" WS.onopen=function(){stat('online','ok');wsSend('@st');if(curTab=='chats')wsSend('@t')};\n"
" WS.onclose=function(){stat('reconnecting...','err');setTimeout(conn,1500)};WS.onerror=function(){stat('error','err')};\n"
" WS.onmessage=function(e){var b=new Uint8Array(e.data);if(!b.length)return;var ty=b[0],p=dec.decode(b.subarray(1));if(ty==0)tadd(p);else if(ty==1)onData(p)}}\n"
"each(document.querySelectorAll('#tabs button'),function(b){b.onclick=function(){showTab(b.getAttribute('data-t'))}});\n"
"E('scrim').onclick=closeOv;E('modal').onclick=function(e){if(e.target==E('modal'))closeOv()};E('qcmd').onclick=cmdPicker;E('gear').onclick=function(){wsSend('@sg')};\n"
"E('csort').onclick=function(){sheet('Sort by',SORTL.map(function(s,i){return {label:(i==csort?'\\u2713 ':'')+s,fn:function(){csort=i;renderContacts()}}}))};\n"
"E('cfilt').onclick=function(){sheet('Filter',FILTL.map(function(f,i){return {label:(i==cfilter?'\\u2713 ':'')+f,fn:function(){cfilter=i;renderContacts()}}}))};\n"
"E('csearch').addEventListener('input',function(){csearchq=E('csearch').value.trim().toLowerCase();renderContacts()});\n"
"E('cvback').onclick=function(){closeChat();wsSend('@t')};E('cvsend').onclick=chatSend;\n"
"E('cvi').addEventListener('keydown',function(e){if(e.key=='Enter'){chatSend();e.preventDefault()}});\n"
"E('i').addEventListener('keydown',function(e){if(e.key=='Enter'){tsend();e.preventDefault()}\n"
" else if(e.key=='ArrowUp'){if(hi>0){hi--;E('i').value=hist[hi];e.preventDefault()}}\n"
" else if(e.key=='ArrowDown'){if(hi<hist.length-1){hi++;E('i').value=hist[hi]}else{hi=hist.length;E('i').value=''}e.preventDefault()}\n"
" else if(e.key=='Tab'){var q=E('i').value.toLowerCase(),m=CMDS.filter(function(c){return c.indexOf(q)==0});\n"
" if(m.length==1)E('i').value=m[0];else if(m.length>1)tadd('\\n'+m.join(' ').trim()+'\\n','e');e.preventDefault()}});\n"
"tadd('wadamesh terminal - node config + messaging. help = all commands.\\n\\n','e');\n"
"conn();showTab('chats');\n"
"</script></body></html>";
#define COMP_STATE_IDLE 0
#define COMP_STATE_HDR_FOUND 1
@@ -82,8 +464,22 @@ static bool writeAllBytes(WiFiClient& client, const uint8_t* buf, size_t len, ui
return true;
}
// RAII lock for the _clients array. Recursive so nested same-thread calls
// (serviceMirror -> writeBinaryFrame -> drainClientTx -> disconnectClient, or
// tickHandshake -> acceptNewClients/pruneDisconnected) never self-deadlock.
namespace {
struct WsClientsLock {
SemaphoreHandle_t m;
explicit WsClientsLock(SemaphoreHandle_t mtx) : m(mtx) { if (m) xSemaphoreTakeRecursive(m, portMAX_DELAY); }
~WsClientsLock() { if (m) xSemaphoreGiveRecursive(m); }
WsClientsLock(const WsClientsLock&) = delete;
WsClientsLock& operator=(const WsClientsLock&) = delete;
};
}
WebSocketCompanionServer::WebSocketCompanionServer()
: _server(WiFiServer()), _port(0), _listening(false), _poll_start_idx(0) {
: _server(WiFiServer()), _port(0), _listening(false), _poll_start_idx(0), _mirror_txbuf(nullptr) {
_client_mtx = xSemaphoreCreateRecursiveMutex();
for (int i = 0; i < WS_COMPANION_MAX_CLIENTS; i++) {
_clients[i].in_use = false;
_clients[i].accept_ms = 0;
@@ -92,6 +488,12 @@ WebSocketCompanionServer::WebSocketCompanionServer()
_clients[i].ws_state = WS_STATE_HEADER_0;
_clients[i].comp_state = COMP_STATE_IDLE;
_clients[i].stall_ms = 0;
_clients[i].is_mirror = false;
_clients[i].is_term = false;
_clients[i].meta_sent = false;
_clients[i].tx_buf = nullptr;
_clients[i].tx_len = 0;
_clients[i].tx_sent = 0;
}
}
@@ -103,6 +505,7 @@ void WebSocketCompanionServer::begin(uint16_t port) {
}
void WebSocketCompanionServer::stop() {
WsClientsLock _lk(_client_mtx);
_listening = false;
_server.stop();
for (int i = 0; i < WS_COMPANION_MAX_CLIENTS; i++) {
@@ -163,6 +566,11 @@ void WebSocketCompanionServer::acceptNewClients() {
_clients[slot].ws_state = WS_STATE_HEADER_0;
_clients[slot].comp_state = COMP_STATE_IDLE;
_clients[slot].stall_ms = 0;
_clients[slot].is_mirror = false;
_clients[slot].is_term = false;
_clients[slot].meta_sent = false;
_clients[slot].tx_len = 0; // drop any stale pending frame from the previous occupant (tx_buf is reused)
_clients[slot].tx_sent = 0;
}
}
@@ -223,13 +631,22 @@ bool WebSocketCompanionServer::doHandshake(int idx) {
if (!writeAllBytes(*cl, (const uint8_t*)"\r\n\r\n", 4, TCP_WRITE_TIMEOUT_MS))
return false;
// Route GET /mirror to the web-UI mirror channel (display out + pointer
// in); every other WS upgrade (the companion app connects to "/") stays
// a companion peer on the shared protocol.
c->is_mirror = (strncmp(c->handshake_buf, "GET /mirror", 11) == 0);
c->is_term = (strncmp(c->handshake_buf, "GET /term", 9) == 0); // web mesh terminal socket
if (c->is_mirror || c->is_term) c->client.setNoDelay(true); // low latency: small frames, no Nagle coalescing
c->meta_sent = false;
c->handshake_done = true;
c->ws_state = WS_STATE_HEADER_0;
c->comp_state = COMP_STATE_IDLE;
return true;
}
}
(void)writeAllBytes(*cl, (const uint8_t*)WS_HTTP_INFO_PAGE, strlen(WS_HTTP_INFO_PAGE), TCP_WRITE_TIMEOUT_MS);
// Plain HTTP GET (no WS upgrade): serve the terminal page in terminal mode, else the mirror page.
const char* page = g_web_mirror.terminalOn() ? WS_HTML_TERMINAL_PAGE : WS_HTTP_INFO_PAGE;
(void)writeAllBytes(*cl, (const uint8_t*)page, strlen(page), TCP_WRITE_TIMEOUT_MS);
c->client.stop();
c->in_use = false;
return false;
@@ -244,11 +661,13 @@ bool WebSocketCompanionServer::doHandshake(int idx) {
}
void WebSocketCompanionServer::tickHandshake() {
WsClientsLock _lk(_client_mtx);
acceptNewClients();
pruneDisconnected();
}
size_t WebSocketCompanionServer::pollRecvFrame(uint8_t dest[], int* client_index_out) {
WsClientsLock _lk(_client_mtx);
acceptNewClients();
pruneDisconnected();
@@ -264,6 +683,9 @@ size_t WebSocketCompanionServer::pollRecvFrame(uint8_t dest[], int* client_index
doHandshake(idx);
continue;
}
// Mirror clients carry framebuffer/pointer traffic, not companion frames —
// serviceMirror() owns their socket. Never feed their bytes to the parser.
if (c->is_mirror || c->is_term) continue;
WiFiClient* cl = &c->client;
while (cl->available()) {
@@ -355,6 +777,7 @@ size_t WebSocketCompanionServer::pollRecvFrame(uint8_t dest[], int* client_index
}
size_t WebSocketCompanionServer::writeToClient(int client_index, const uint8_t src[], size_t len) {
WsClientsLock _lk(_client_mtx);
if (client_index < 0 || client_index >= WS_COMPANION_MAX_CLIENTS || len > MAX_FRAME_SIZE) return 0;
if (!_clients[client_index].in_use || !_clients[client_index].client.connected()) return 0;
@@ -395,11 +818,12 @@ size_t WebSocketCompanionServer::writeToClient(int client_index, const uint8_t s
}
size_t WebSocketCompanionServer::writeToAllClients(const uint8_t src[], size_t len) {
WsClientsLock _lk(_client_mtx);
if (len == 0 || len > MAX_FRAME_SIZE) return 0;
int connected = 0;
int sent = 0;
for (int i = 0; i < WS_COMPANION_MAX_CLIENTS; i++) {
bool ok = _clients[i].in_use && _clients[i].client.connected() && _clients[i].handshake_done;
bool ok = _clients[i].in_use && _clients[i].client.connected() && _clients[i].handshake_done && !_clients[i].is_mirror && !_clients[i].is_term;
if (ok) {
connected++;
if (writeToClient(i, src, len) == len) sent++;
@@ -409,24 +833,301 @@ size_t WebSocketCompanionServer::writeToAllClients(const uint8_t src[], size_t l
}
bool WebSocketCompanionServer::isClientConnected(int client_index) const {
WsClientsLock _lk(_client_mtx);
if (client_index < 0 || client_index >= WS_COMPANION_MAX_CLIENTS) return false;
const WSClientState* c = &_clients[client_index];
return c->in_use && c->client.connected() && c->handshake_done;
return c->in_use && c->client.connected() && c->handshake_done && !c->is_mirror && !c->is_term;
}
int WebSocketCompanionServer::connectedCount() const {
WsClientsLock _lk(_client_mtx);
int n = 0;
for (int i = 0; i < WS_COMPANION_MAX_CLIENTS; i++) {
if (_clients[i].in_use && _clients[i].client.connected() && _clients[i].handshake_done)
if (_clients[i].in_use && _clients[i].client.connected() && _clients[i].handshake_done && !_clients[i].is_mirror && !_clients[i].is_term)
n++;
}
return n;
}
void WebSocketCompanionServer::disconnectClient(int client_index) {
WsClientsLock _lk(_client_mtx);
if (client_index >= 0 && client_index < WS_COMPANION_MAX_CLIENTS && _clients[client_index].in_use) {
_clients[client_index].client.stop();
_clients[client_index].in_use = false;
_clients[client_index].stall_ms = 0;
_clients[client_index].tx_len = 0; // discard any half-sent frame; tx_buf stays for slot reuse
_clients[client_index].tx_sent = 0;
}
}
// ============================================================================
// Web UI mirror
// ============================================================================
int WebSocketCompanionServer::mirrorClientCount() const {
WsClientsLock _lk(_client_mtx);
int n = 0;
for (int i = 0; i < WS_COMPANION_MAX_CLIENTS; i++)
if (_clients[i].in_use && _clients[i].client.connected() &&
_clients[i].handshake_done && _clients[i].is_mirror) n++;
return n;
}
// Queue one binary WS frame (FIN+binary, len up to 65535 via the 126 extended header)
// into the client's tx_buf, then push what the socket accepts right now — all
// NON-BLOCKING. The frame is drained in full over later drainClientTx() calls, so the
// loop never spins on a write and a slow link never corrupts the stream or drops the
// client. Returns 0 (rejected) if a previous frame is still draining (caller waits) or
// the frame is too big to buffer. On success the whole frame is guaranteed atomic.
size_t WebSocketCompanionServer::writeBinaryFrame(int client_index, const uint8_t src[], size_t len) {
WsClientsLock _lk(_client_mtx);
if (client_index < 0 || client_index >= WS_COMPANION_MAX_CLIENTS || len == 0 || len > 0xFFFF) return 0;
WSClientState* c = &_clients[client_index];
if (!c->in_use || !c->client.connected()) return 0;
if (c->tx_len != 0) return 0; // a frame is still draining -> not ready
if (!c->tx_buf) {
c->tx_buf = (uint8_t*)heap_caps_malloc(WS_MIRROR_CLIENT_TXBUF, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT);
if (!c->tx_buf) c->tx_buf = (uint8_t*)malloc(WS_MIRROR_CLIENT_TXBUF);
if (!c->tx_buf) return 0;
}
const size_t hdr_len = (len < 126) ? 2 : 4;
if (hdr_len + len > WS_MIRROR_CLIENT_TXBUF) return 0; // never for our <=4 KB bands, but stay safe
uint8_t* p = c->tx_buf;
p[0] = 0x82; // FIN + binary
if (len < 126) { p[1] = (uint8_t)len; }
else { p[1] = 126; p[2] = (len >> 8) & 0xFF; p[3] = len & 0xFF; }
memcpy(p + hdr_len, src, len);
c->tx_len = (uint16_t)(hdr_len + len);
c->tx_sent = 0;
drainClientTx(client_index); // push whatever fits now (non-blocking)
return len;
}
// Push a mirror client's pending tx_buf bytes as far as the socket will take them right
// now, without ever blocking. Frame boundaries are byte-exact (tx_sent offset), so a
// partial push just resumes next call — the stream never desyncs. A client that can't
// take a single byte for WS_WEDGED_DROP_MS is genuinely dead and gets reaped.
void WebSocketCompanionServer::drainClientTx(int idx) {
WSClientState* c = &_clients[idx];
if (!c->in_use || c->tx_len == 0) return;
if (!c->client.connected()) { c->tx_len = c->tx_sent = 0; return; }
while (c->tx_sent < c->tx_len && socketWritableNow(c->client)) {
size_t n = c->client.write(c->tx_buf + c->tx_sent, c->tx_len - c->tx_sent);
if (n == 0) break;
c->tx_sent += (uint16_t)n;
}
if (c->tx_sent >= c->tx_len) { c->tx_len = 0; c->tx_sent = 0; c->stall_ms = 0; return; }
// Still bytes pending -> the socket is momentarily full. Just wait; only a peer that
// stays unwritable for the full drop window is treated as wedged.
if (c->stall_ms == 0) c->stall_ms = millis() | 1;
else if (millis() - c->stall_ms >= WS_WEDGED_DROP_MS) disconnectClient(idx);
}
// Read masked WebSocket binary frames from a mirror client and dispatch pointer
// events. Reuses this client's ws_* parse state (pollRecvFrame skips mirror
// clients, so it is exclusively ours). Payload: [0x01, x_lo,x_hi, y_lo,y_hi, pressed].
void WebSocketCompanionServer::drainMirrorInput(int idx, WebMirror& m) {
WSClientState* c = &_clients[idx];
WiFiClient* cl = &c->client;
int guard = 0;
while (cl->available() && guard++ < 1024) {
switch (c->ws_state) {
case WS_STATE_HEADER_0: {
uint8_t b = (uint8_t)cl->read();
c->ws_opcode = b & 0x0F;
c->ws_state = WS_STATE_HEADER_1;
break;
}
case WS_STATE_HEADER_1: {
uint8_t b = (uint8_t)cl->read();
uint8_t l7 = b & 0x7F;
c->ws_payload_read = 0;
c->comp_rx_len = 0;
if (l7 == 126 || l7 == 127) { c->ws_payload_len = l7; c->ws_state = WS_STATE_LEN_EXT; }
else { c->ws_payload_len = l7; c->ws_state = WS_STATE_MASK; }
break;
}
case WS_STATE_LEN_EXT: {
if (c->ws_payload_len == 126) {
if (cl->available() < 2) return;
uint8_t hi = (uint8_t)cl->read(), lo = (uint8_t)cl->read();
c->ws_payload_len = ((uint16_t)hi << 8) | lo;
} else {
if (cl->available() < 8) return;
c->ws_payload_len = 0;
for (int i = 0; i < 8; i++) c->ws_payload_len = (c->ws_payload_len << 8) | (uint8_t)cl->read();
}
c->ws_state = (c->ws_payload_len == 0) ? WS_STATE_HEADER_0 : WS_STATE_MASK;
break;
}
case WS_STATE_MASK: {
if (cl->available() < 4) return;
for (int i = 0; i < 4; i++) c->ws_mask[i] = (uint8_t)cl->read();
c->ws_state = (c->ws_payload_len == 0) ? WS_STATE_HEADER_0 : WS_STATE_PAYLOAD;
break;
}
default: { // WS_STATE_PAYLOAD
uint8_t b = (uint8_t)cl->read() ^ c->ws_mask[c->ws_payload_read % 4];
if (c->comp_rx_len < MAX_FRAME_SIZE) c->comp_rx_buf[c->comp_rx_len++] = b;
c->ws_payload_read++;
if (c->ws_payload_read >= c->ws_payload_len) {
if (c->ws_opcode == 0x02 && c->comp_rx_len >= 1) {
const uint8_t ty = c->comp_rx_buf[0];
if (ty == 0x01 && c->comp_rx_len >= 6) { // pointer: [0x01, x,y (LE16), pressed]
int16_t x = (int16_t)(c->comp_rx_buf[1] | (c->comp_rx_buf[2] << 8));
int16_t y = (int16_t)(c->comp_rx_buf[3] | (c->comp_rx_buf[4] << 8));
m.pushPointer(x, y, c->comp_rx_buf[5]);
} else if (ty == 0x02 && c->comp_rx_len >= 3) { // key: [0x02, codepoint LE16]
m.pushKey((uint16_t)(c->comp_rx_buf[1] | (c->comp_rx_buf[2] << 8)));
} else if (ty == 0x04 && c->comp_rx_len >= 2) { // orientation: [0x04, want_landscape]
m.requestOrient(c->comp_rx_buf[1] ? 1 : 2); // 1=landscape, 2=portrait; UI thread reboots into it
} else if (ty == 0x05) { // exit remote mode
m.requestExit();
}
} else if (c->ws_opcode == 0x08) { // client close
disconnectClient(idx);
return;
}
c->ws_state = WS_STATE_HEADER_0;
}
break;
}
}
}
}
// Parse a terminal client's masked WS frames; each complete text/binary frame is one
// command line -> m.pushTermCmd. Reuses this client's ws_* parse state (pollRecvFrame
// skips is_term clients, so it is exclusively ours).
void WebSocketCompanionServer::drainTermInput(int idx, WebMirror& m) {
WSClientState* c = &_clients[idx];
WiFiClient* cl = &c->client;
int guard = 0;
while (cl->available() && guard++ < 2048) {
switch (c->ws_state) {
case WS_STATE_HEADER_0: { uint8_t b = (uint8_t)cl->read(); c->ws_opcode = b & 0x0F; c->ws_state = WS_STATE_HEADER_1; break; }
case WS_STATE_HEADER_1: {
uint8_t b = (uint8_t)cl->read(); uint8_t l7 = b & 0x7F; c->ws_payload_read = 0; c->comp_rx_len = 0;
if (l7 == 126 || l7 == 127) { c->ws_payload_len = l7; c->ws_state = WS_STATE_LEN_EXT; }
else { c->ws_payload_len = l7; c->ws_state = WS_STATE_MASK; }
break;
}
case WS_STATE_LEN_EXT: {
if (c->ws_payload_len == 126) { if (cl->available() < 2) return; uint8_t hi = (uint8_t)cl->read(), lo = (uint8_t)cl->read(); c->ws_payload_len = ((uint16_t)hi << 8) | lo; }
else { if (cl->available() < 8) return; c->ws_payload_len = 0; for (int i = 0; i < 8; i++) c->ws_payload_len = (c->ws_payload_len << 8) | (uint8_t)cl->read(); }
c->ws_state = (c->ws_payload_len == 0) ? WS_STATE_HEADER_0 : WS_STATE_MASK; break;
}
case WS_STATE_MASK: {
if (cl->available() < 4) return;
for (int i = 0; i < 4; i++) c->ws_mask[i] = (uint8_t)cl->read();
c->ws_state = (c->ws_payload_len == 0) ? WS_STATE_HEADER_0 : WS_STATE_PAYLOAD; break;
}
default: { // WS_STATE_PAYLOAD
uint8_t b = (uint8_t)cl->read() ^ c->ws_mask[c->ws_payload_read % 4];
if (c->comp_rx_len < MAX_FRAME_SIZE) c->comp_rx_buf[c->comp_rx_len++] = b;
c->ws_payload_read++;
if (c->ws_payload_read >= c->ws_payload_len) {
if ((c->ws_opcode == 0x01 || c->ws_opcode == 0x02) && c->comp_rx_len > 0) { // text/binary = a command line
uint16_t n = c->comp_rx_len;
while (n > 0 && (c->comp_rx_buf[n - 1] == '\r' || c->comp_rx_buf[n - 1] == '\n')) n--;
if (n > 0) m.pushTermCmd((const char*)c->comp_rx_buf, n);
} else if (c->ws_opcode == 0x08) { disconnectClient(idx); return; }
c->ws_state = WS_STATE_HEADER_0;
}
break;
}
}
}
}
// Web mesh terminal: shuttle text both ways for /term clients (independent of the
// framebuffer mirror; a client is never both). Called from serviceMirror on the stream
// task with _client_mtx already held. writeBinaryFrame's per-client tx_buf keeps it
// non-blocking; the browser TextDecodes the reply bytes.
void WebSocketCompanionServer::serviceTerminalClients(WebMirror& m) {
int tc = 0;
bool all_idle = true;
for (int i = 0; i < WS_COMPANION_MAX_CLIENTS; i++) {
WSClientState* c = &_clients[i];
if (!c->in_use || !c->client.connected() || !c->handshake_done || !c->is_term) continue;
tc++;
drainClientTx(i);
if (!c->in_use) continue; // reaped mid-drain
drainTermInput(i, m);
if (c->tx_len != 0) all_idle = false;
}
m.noteTermClients(tc);
if (tc == 0 || !all_idle || !_mirror_txbuf) return; // wait until every client drained its previous chunk
// Each frame is [type][payload]: type 0x01 = a complete JSON data message (Contacts/Chats),
// 0x00 = a terminal text chunk. Prefer data (discrete + responsive) over the text stream.
_mirror_txbuf[0] = 0x01;
size_t n = m.popTermData(_mirror_txbuf + 1, WS_MIRROR_CLIENT_TXBUF - 8);
if (n == 0) { _mirror_txbuf[0] = 0x00; n = m.popTermReply(_mirror_txbuf + 1, 2048); }
if (n == 0) return;
for (int i = 0; i < WS_COMPANION_MAX_CLIENTS; i++) {
WSClientState* c = &_clients[i];
if (!c->in_use || !c->client.connected() || !c->handshake_done || !c->is_term) continue;
writeBinaryFrame(i, _mirror_txbuf, n + 1);
}
}
// Called every loop: refresh the client count for the producer gate, send each
// client its one-time screen-size meta, drain remote pointer input, and broadcast
// queued framebuffer bands. Strictly non-blocking (socketWritableNow-gated) so it
// never stalls the mesh loop (the beta_32 Wi-Fi-freeze discipline).
void WebSocketCompanionServer::serviceMirror(WebMirror& m) {
WsClientsLock _lk(_client_mtx); // held for the whole (quick, non-blocking) pass
if (!_mirror_txbuf) { // shared scratch — also used by serviceTerminalClients for data frames
_mirror_txbuf = (uint8_t*)heap_caps_malloc(WS_MIRROR_TXBUF, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT);
if (!_mirror_txbuf) _mirror_txbuf = (uint8_t*)malloc(WS_MIRROR_TXBUF);
}
serviceTerminalClients(m); // web mesh terminal + Contacts/Chats data (independent of the framebuffer)
const int mc = mirrorClientCount();
m.noteClients(mc);
if (mc == 0 || !_mirror_txbuf) return;
// Per-client: first push any bytes still pending from the previous frame (non-blocking),
// then queue the one-time size meta + keyboard-focus changes (only while idle, so on-wire
// frame order is preserved), then drain remote input.
bool kf = false;
const bool kb_changed = m.kbTake(&kf);
for (int i = 0; i < WS_COMPANION_MAX_CLIENTS; i++) {
WSClientState* c = &_clients[i];
if (!c->in_use || !c->client.connected() || !c->handshake_done || !c->is_mirror) continue;
drainClientTx(i);
if (!c->in_use) continue; // a wedged client may have just been reaped
if (c->tx_len == 0 && !c->meta_sent) {
uint8_t meta[6] = { 0x02,
(uint8_t)(m.screenW() & 0xFF), (uint8_t)(m.screenW() >> 8),
(uint8_t)(m.screenH() & 0xFF), (uint8_t)(m.screenH() >> 8),
(uint8_t)(m.remote() ? 1 : 0) }; // byte5 bit0 = remote mode -> browser shows the Rotate button
if (writeBinaryFrame(i, meta, 6) == 6) { c->meta_sent = true; m.requestFullRepaint(); }
}
if (kb_changed && c->tx_len == 0) { uint8_t km[2] = { 0x03, (uint8_t)(kf ? 1 : 0) }; writeBinaryFrame(i, km, 2); }
drainMirrorInput(i, m);
}
// Broadcast queued display bands: pop the next band only once EVERY mirror client has
// fully drained its previous frame (keeps all clients byte-synced + backpressures to the
// slowest peer). writeBinaryFrame buffers + pushes non-blocking, so a slow link just
// drains across later calls instead of blocking the loop or dropping the socket.
size_t budget = WS_MIRROR_TX_BUDGET;
while (budget > 0) {
bool any = false, all_idle = true;
for (int i = 0; i < WS_COMPANION_MAX_CLIENTS; i++) {
WSClientState* c = &_clients[i];
if (!c->in_use || !c->client.connected() || !c->handshake_done || !c->is_mirror) continue;
any = true;
if (c->tx_len != 0) { all_idle = false; break; }
}
if (!any || !all_idle) break;
size_t n = m.popFrame(_mirror_txbuf, WS_MIRROR_TXBUF);
if (n == 0) break;
for (int i = 0; i < WS_COMPANION_MAX_CLIENTS; i++) {
WSClientState* c = &_clients[i];
if (!c->in_use || !c->client.connected() || !c->handshake_done || !c->is_mirror) continue;
writeBinaryFrame(i, _mirror_txbuf, n);
}
budget = (n < budget) ? (budget - n) : 0;
}
}
+38 -1
View File
@@ -2,6 +2,10 @@
#include <helpers/BaseSerialInterface.h>
#include <WiFi.h>
#include <freertos/FreeRTOS.h>
#include <freertos/semphr.h>
class WebMirror; // web-UI mirror bridge (WebMirror.h); included in the .cpp
#ifndef WS_COMPANION_MAX_CLIENTS
#define WS_COMPANION_MAX_CLIENTS 2
@@ -36,6 +40,17 @@ struct WSClientState {
uint16_t comp_rx_len;
uint8_t comp_rx_buf[MAX_FRAME_SIZE];
uint32_t stall_ms; // start of a continuous write-failure run; 0 = healthy
bool is_mirror; // this client is a web-UI mirror (GET /mirror), not a companion peer
bool is_term; // this client is a web mesh terminal (GET /term)
bool meta_sent; // mirror: the one-time screen-size meta frame has been sent
// Mirror TX buffer: one WS frame (header + payload) queued for this client, drained
// NON-BLOCKING across loop iterations. The loop never spins on a socket write and a
// transient stall never drops the client — it just drains at the link's pace.
uint8_t* tx_buf; // lazily-allocated PSRAM frame buffer (null until first mirror use)
uint16_t tx_len; // bytes queued in tx_buf (0 = idle, ready for the next frame)
uint16_t tx_sent; // bytes already pushed to the socket
};
// WebSocket server for companion protocol. Same logical protocol as TCP; transport is RFC 6455 (plain WS).
@@ -58,9 +73,19 @@ public:
size_t writeToAllClients(const uint8_t src[], size_t len);
bool isClientConnected(int client_index) const;
int connectedCount() const;
int connectedCount() const; // companion clients only (mirror excluded)
void disconnectClient(int client_index);
// ---- Web UI mirror (see WebMirror) ----
int mirrorClientCount() const; // handshaken GET /mirror clients
/** Drain remote pointer input + push queued framebuffer frames to mirror
* clients. Non-blocking (socketWritableNow-gated); call each loop. */
void serviceMirror(WebMirror& m);
/** WebSocket binary frame with len up to 65535 (no MAX_FRAME_SIZE cap) — used
* for mirror display bands. Same freeze-safe write + wedged reaper as the
* companion path. */
size_t writeBinaryFrame(int client_index, const uint8_t src[], size_t len);
private:
WiFiServer _server;
mutable WSClientState _clients[WS_COMPANION_MAX_CLIENTS];
@@ -71,4 +96,16 @@ private:
void acceptNewClients();
void pruneDisconnected();
bool doHandshake(int idx);
uint8_t* _mirror_txbuf; // lazily-allocated PSRAM buffer for popped mirror frames
void drainMirrorInput(int idx, WebMirror& m);
void drainClientTx(int idx); // push a mirror client's pending tx_buf bytes, non-blocking
void serviceTerminalClients(WebMirror& m); // web mesh terminal: reply text out + command text in
void drainTermInput(int idx, WebMirror& m); // parse a term client's WS frames -> m.pushTermCmd
// The _clients array is now touched by TWO cores: the main loop (accept/handshake +
// companion RX/TX, core 1) and the dedicated mirror stream task (serviceMirror, core 0).
// A recursive mutex serialises every _clients access; held only for the quick,
// non-blocking work, so cross-core contention is negligible.
SemaphoreHandle_t _client_mtx;
};
+1491 -52
View File
File diff suppressed because it is too large. Load diff
+3 -3
View File
@@ -221,7 +221,6 @@ public:
void onTracePingResult(uint32_t tag, int8_t their_snr, int8_t our_snr,
uint8_t extra_hops, const int8_t* extra_snrs) override;
private:
int getThreadMessageIndexes(int thread_idx, int out_indexes[], int max_out, bool newest_first) const;
void setActiveThread(int idx, bool channel_mode);
void resetComposer();
void appendComposerChar(char c);
@@ -287,6 +286,7 @@ public:
bool getThreadInfo(int idx, bool& channel, uint16_t& unread, uint32_t& ts, char* name, size_t name_len) const;
int getActiveThreadMessageCount(int out_indexes[], int max_out, bool newest_first) const;
bool getMessageByIndex(int msg_idx, UIMessage& out) const;
int getThreadMessageIndexes(int thread_idx, int out_indexes[], int max_out, bool newest_first) const; // read any thread's message ring slots (no active-thread side effect)
bool deleteMessageBySlot(int msg_idx); // tombstone one ring slot (long-press Delete)
int clearThreadHistory(int thread_idx); // tombstone every message of a thread; returns count
const char* getComposerBuffer() const { return _compose_buf; }
@@ -505,8 +505,8 @@ public:
const char* text, int msgcount,
int8_t snr_q4, int8_t rssi) override;
void appSentMsgToContact(const uint8_t* to_pub, const char* to_name, const char* text,
uint32_t ack_hash) override;
void appSentMsgToChannel(const char* channel_name, const char* text) override;
uint32_t ack_hash, uint32_t sent_fp = 0) override;
void appSentMsgToChannel(const char* channel_name, const char* text, uint32_t sent_fp = 0) override;
void notify(UIEventType t = UIEventType::none) override;
void logRxFrame(float snr, float rssi, const uint8_t* raw, int len) override;
void discoveredContact(const ContactInfo& contact, bool is_new, uint8_t path_len) override;
+4 -1
View File
@@ -41,7 +41,10 @@ fi
# build on a fresh beta_N tag bakes exactly "beta_N" — no more hand-bumping the
# old hardcoded CMake define (which left every dev flash claiming the last beta).
# Passed as a CMake cache var: idf.py reconfigures automatically when it changes.
WADA_FW_TAG="$(git describe --tags --match 'beta_*' --always 2>/dev/null || echo dev)"
# A caller (the app-store publish) can pass the exact release tag via the env, since
# the beta_N git tag may not be fetched locally when gh created it remotely. Fall back
# to git describe for dev builds ("beta_28-14-gabc123" = 14 commits past the tag).
WADA_FW_TAG="${WADA_FW_TAG:-$(git describe --tags --match 'beta_*' --always 2>/dev/null || echo dev)}"
WADA_FW_DATE="$(date '+%-d %b %Y')"
exec idf.py -B build/tanmatsu \
+4
View File
@@ -68,5 +68,9 @@ if(NOT WADA_FW_DATE)
endif()
idf_build_set_property(COMPILE_OPTIONS "-DFIRMWARE_RELEASE_TAG=\"${WADA_FW_TAG}\"" APPEND)
idf_build_set_property(COMPILE_OPTIONS "-DFIRMWARE_BUILD_DATE=\"${WADA_FW_DATE}\"" APPEND)
# The About page shows FIRMWARE_VERSION (not the tag). The S3 release.sh sets it to
# "wadamesh <tag>"; without this the P4 build fell back to the MeshCore default
# ("v1.16.0-touch") and the Tanmatsu never showed its beta number. Keep them in sync.
idf_build_set_property(COMPILE_OPTIONS "-DFIRMWARE_VERSION=\"wadamesh ${WADA_FW_TAG}\"" APPEND)
# (the adcAttachPin compat shim is force-included per-component in meshcore + wadamesh_app —
# NOT globally, which would feed the C++ header to FreeRTOS's .S assembly files and break them.)