Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
308be40b96 | ||
|
|
8a2825e7fd | ||
|
|
5f40653e58 | ||
|
|
d787d77365 | ||
|
|
831959ea5c | ||
|
|
4df391a283 |
No files matched your search
|
After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 7.9 KiB After Width: | Height: | Size: 9.5 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 8.2 KiB After Width: | Height: | Size: 9.0 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 9.5 KiB After Width: | Height: | Size: 9.5 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 4.5 KiB After Width: | Height: | Size: 4.5 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 96 KiB |
|
After Width: | Height: | Size: 62 KiB |
|
After Width: | Height: | Size: 83 KiB |
|
After Width: | Height: | Size: 93 KiB |
|
After Width: | Height: | Size: 104 KiB |
|
After Width: | Height: | Size: 105 KiB |
|
After Width: | Height: | Size: 81 KiB |
@@ -73,6 +73,11 @@
|
||||
.mini{background:#16191d; border-radius:12px; padding:7px; border:1px solid #e9ece7}
|
||||
.mini img{display:block; width:100%; height:auto; border-radius:6px}
|
||||
.mini .cap{margin-top:6px}
|
||||
.webgrid{display:grid; grid-template-columns:repeat(auto-fill,minmax(178px,1fr)); gap:18px; margin-top:22px}
|
||||
figure.phone{margin:0; background:#16191d; border-radius:20px; padding:8px; border:1px solid #e9ece7; box-shadow:0 18px 44px -26px rgba(21,24,30,.5)}
|
||||
figure.phone img{display:block; width:100%; height:auto; border-radius:13px}
|
||||
figure.phone figcaption{text-align:center; font-family:'JetBrains Mono',monospace; font-size:.66rem; color:var(--mut); margin-top:8px; letter-spacing:.04em}
|
||||
.subh{font-size:1.06rem; font-weight:700; color:var(--ink); margin:30px 0 2px}
|
||||
|
||||
/* Settings-page detail cards: real screenshot + what the page does + its controls */
|
||||
.sgrp{font-family:'JetBrains Mono',monospace; font-size:.7rem; letter-spacing:.14em; text-transform:uppercase; color:var(--mut); margin:30px 0 0; padding-top:4px}
|
||||
@@ -126,6 +131,7 @@
|
||||
<a href="#settings">Settings</a>
|
||||
<div class="grp">With your phone</div>
|
||||
<a href="#companion">Companion mode</a>
|
||||
<a href="#remote">Remote & web app</a>
|
||||
<div class="grp">Reference</div>
|
||||
<a href="#updates">Updates & test builds</a>
|
||||
<a href="#settings-pages">Settings pages</a>
|
||||
@@ -203,7 +209,7 @@
|
||||
<p>An OpenStreetMap / OpenTopoMap view with your position and your contacts plotted as markers, plus link lines from you to each node. Tiles are fetched over Wi-Fi through the wadamesh proxy and cached to flash (or microSD), so areas you've viewed work offline.</p>
|
||||
<p>It also has a line-of-sight analyzer: pick a contact and it uses SRTM elevation to show whether terrain blocks the path.</p>
|
||||
</div>
|
||||
<div class="shot"><div class="device"><img class="screen" src="docs-img/map.png" alt="Map"></div><div class="cap">Map</div></div>
|
||||
<div class="shot"><div class="device"><img class="screen" src="docs-img/map.png?v=2" alt="Map"></div><div class="cap">Map</div></div>
|
||||
</div>
|
||||
<div class="prose">
|
||||
<h3>Bring your own tiles (offline maps)</h3>
|
||||
@@ -266,6 +272,37 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="doc" id="remote">
|
||||
<h2><span class="ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 16.1A5 5 0 0 1 5.9 20M2 12.05A9 9 0 0 1 9.95 20M2 8V6a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2h-6"/><line x1="2" y1="20" x2="2.01" y2="20"/></svg></span> Remote & web app</h2>
|
||||
<div class="row">
|
||||
<div class="prose">
|
||||
<p>Over Wi-Fi, a WADAMESH device can put its screen — or a whole browser app — on your phone or laptop. There is nothing to install: the device serves a web page you open in any browser on the same network. Turn it on from <b>Home → Remote</b> (or the Remote screen in Settings), pick a mode, and open the address it shows.</p>
|
||||
<h3>Three ways to connect</h3>
|
||||
<ul>
|
||||
<li><b>Remote app</b> (recommended) — a full control panel, native to the browser: <b>Chats</b>, <b>Contacts</b>, a <b>Terminal</b> and <b>Settings</b>, with live message notifications. Run the device entirely from your phone.</li>
|
||||
<li><b>Screen mirror (VNC)</b> — streams the device's live screen to the browser and sends your taps and key presses back, so you see and drive exactly what is on the device, in real time.</li>
|
||||
<li><b>Remote UI</b> — renders the interface off-screen at desktop size (a wide landscape layout) and streams that instead of the small panel, for a roomier view on a big screen.</li>
|
||||
</ul>
|
||||
<p>Screen mirror and Remote UI both show the same interface you already know from the device — mirrored, or drawn larger. The <b>Remote app</b> is different: a browser-native panel built for a phone, shown below.</p>
|
||||
<h3>Turning it on</h3>
|
||||
<p>Connect the device to Wi-Fi first, then open <b>Home → Remote</b>. The screen shows the address to type into your browser (like <code>http://192.168.1.42</code>) and a switch for each mode. Only one streaming mode runs at a time, and the phone and the device must be on the same Wi-Fi network.</p>
|
||||
</div>
|
||||
<div class="shot"><div class="device"><img class="screen" src="docs-img/app_remote.png" alt="Remote access screen on the device"></div><div class="cap">Remote — turn it on, then open the address</div></div>
|
||||
</div>
|
||||
|
||||
<div class="subh">The remote app in a browser</div>
|
||||
<div class="prose" style="max-width:70ch"><p>The remote app is phone-first. It carries the same status icons as the device (signal, Wi-Fi, Bluetooth, battery), pops up a notification when a message arrives, and refreshes an open chat on its own as replies come in — so you rarely need to touch the device itself.</p></div>
|
||||
<div class="webgrid">
|
||||
<figure class="phone"><img src="docs-img/web_chats.png" alt="Chats tab in the web app"><figcaption>Chats — DMs, channels, unread badges</figcaption></figure>
|
||||
<figure class="phone"><img src="docs-img/web_chat.png" alt="A conversation in the web app"><figcaption>A conversation, with delivery ticks</figcaption></figure>
|
||||
<figure class="phone"><img src="docs-img/web_contacts.png" alt="Contacts tab in the web app"><figcaption>Contacts — search, sort, filter</figcaption></figure>
|
||||
<figure class="phone"><img src="docs-img/web_contact_sheet.png" alt="Contact actions in the web app"><figcaption>Tap a contact for actions</figcaption></figure>
|
||||
<figure class="phone"><img src="docs-img/web_discovered.png" alt="Discovered nodes in the web app"><figcaption>Discovered nodes — add with a tap</figcaption></figure>
|
||||
<figure class="phone"><img src="docs-img/web_settings.png" alt="Settings in the web app"><figcaption>Settings — including live radio</figcaption></figure>
|
||||
<figure class="phone"><img src="docs-img/web_terminal.png" alt="Terminal in the web app"><figcaption>Terminal — the MeshCore CLI</figcaption></figure>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="doc" id="updates">
|
||||
<h2><span class="ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M23 4v6h-6"/><path d="M1 20v-6h6"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/></svg></span> Updates & test builds</h2>
|
||||
<div class="row">
|
||||
@@ -286,7 +323,7 @@
|
||||
|
||||
<section class="doc" id="settings-pages">
|
||||
<h2><span class="ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="4" y1="6" x2="20" y2="6"/><line x1="4" y1="12" x2="20" y2="12"/><line x1="4" y1="18" x2="20" y2="18"/></svg></span> Settings pages</h2>
|
||||
<div class="prose" style="max-width:68ch"><p>WADAMESH splits its settings into eighteen pages. Here is what each one does and the controls you will find on it. Open any page from the Settings tab; a back chevron in the title bar returns you to the grid.</p></div>
|
||||
<div class="prose" style="max-width:68ch"><p>WADAMESH splits its settings into nineteen pages. Here is what each one does and the controls you will find on it. Open any page from the Settings tab; a back chevron in the title bar returns you to the grid.</p></div>
|
||||
|
||||
<div class="sgrp">Identity & radio</div>
|
||||
<div class="scards">
|
||||
@@ -357,6 +394,16 @@
|
||||
<li>Reads "no GPS module" on devices without one</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="scard">
|
||||
<div class="thumb"><img src="docs-img/set_mqtt_bridge.png" alt="MQTT bridge settings"></div>
|
||||
<h3>MQTT bridge <span class="tag">experimental</span></h3>
|
||||
<div class="lede">Forward received messages to your own MQTT broker.</div>
|
||||
<ul>
|
||||
<li>Publishes the <b>text, sender and time</b> of messages your node hears to an MQTT broker</li>
|
||||
<li>Broker <b>host, port and topic</b>, plus an <b>encryption key</b></li>
|
||||
<li>Stays off until you <b>accept the privacy note</b> — anyone who can read the broker sees the messages, so use one you control, never a public broker</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sgrp">Time & power</div>
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,15 @@
|
||||
# beta_44 (TEST) - an on-device text web browser, tappable links in chat, and a French-keyboard spacebar fix.
|
||||
# One user-facing note per non-blank, non-# line; # lines are section comments.
|
||||
|
||||
# --- New: on-device web browser ---
|
||||
New "Web" app: open a URL and read the page as plain text - articles, wikis, docs, plain sites - right on the device. No proxy, no tracking, no JavaScript or images; just the words. Links are tappable, and there are back, forward and refresh buttons.
|
||||
The Web app needs the memory only the 8 MB boards have (T-Deck, Tanmatsu), so it is hidden on the 2 MB Heltec V4 - that board can't complete an on-device HTTPS handshake.
|
||||
|
||||
# --- New: links in chat ---
|
||||
Links in a chat are now tappable. Tap one for a small menu: "Open in web" loads the page in the on-device browser, and "Create QR" shows a scannable QR code so you can open the link on your phone.
|
||||
Create QR works on every board, including the Heltec V4 - so even where the browser isn't available you can still bounce a link to your phone.
|
||||
|
||||
# --- Fixed ---
|
||||
French (AZERTY) on-screen keyboard: the spacebar is back in the normal letter view (along with the OK and keyboard-switch keys). You no longer have to switch to the symbols page just to type a space.
|
||||
Heltec V4: web page fetching and map tiles are more resilient when memory is tight.
|
||||
Home signal & traffic box: the "tap for details" cue is back at the top of the box.
|
||||
@@ -36,6 +36,7 @@ SINGLE = [
|
||||
(0x1F4F7, "1f4f7"), # camera
|
||||
(0x1F4F1, "1f4f1"), # mobile phone
|
||||
(0x1F4BB, "1f4bb"), # laptop
|
||||
(0x1F310, "1f310"), # globe with meridians (Web app icon)
|
||||
(0x1F4C5, "1f4c5"), # calendar
|
||||
(0x1F4E1, "1f4e1"), # satellite antenna
|
||||
(0x1F4E7, "1f4e7"), # e-mail
|
||||
|
||||
@@ -0,0 +1,320 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Representative demo data for the wadamesh web-control-panel doc harness.
|
||||
|
||||
Field names + shapes here MUST match the firmware's webPush* emitters in
|
||||
src/ui-touch/UITask.cpp. See serve.py for the @-command -> payload mapping.
|
||||
|
||||
Timestamps are seconds since the Unix epoch. Everything is expressed relative to
|
||||
`NOW` (filled in at serve time) so "last heard" ages stay believable regardless
|
||||
of when the screenshots are taken. Coordinates are *1e6 fixed-point integers
|
||||
(la/lo), matching c.gps_lat / c.gps_lon on the device.
|
||||
|
||||
The self node sits in Amsterdam; located contacts are scattered a few km around
|
||||
it so the Distance sort/filter produce nice values.
|
||||
"""
|
||||
|
||||
# Self node position (Amsterdam center) as 1e6 fixed-point, echoed in status.sla/slo.
|
||||
SELF_LAT = 52_372_800 # 52.3728
|
||||
SELF_LON = 4_893_400 # 4.8934
|
||||
|
||||
# Node identity / radio config shown in the Settings modal + header.
|
||||
NODE_NAME = "Kaj T-Deck"
|
||||
|
||||
RADIO = {
|
||||
"freq": 869.525, # MHz
|
||||
"bw": 250.0, # kHz
|
||||
"sf": 11,
|
||||
"cr": 5,
|
||||
"tx": 22, # dBm
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Contacts (webPushContacts: fields i,t,rp,rm,fav,ign,dir,la,lo,adv,n)
|
||||
# i = contact index (used by @m/@oc/@ic/@fv/@bk/@pr/@xc)
|
||||
# t = UI thread index if a chat exists, else -1
|
||||
# rp = 1 if repeater, rm = 1 if room server
|
||||
# fav = favourite, ign = blocked, dir = direct (out_path_len==0)
|
||||
# la/lo = gps *1e6 (0 if unknown), adv = last_advert_timestamp (epoch s)
|
||||
# n = name
|
||||
# `_dlat/_dlon` below are offsets in degrees from the self node; converted to the
|
||||
# absolute 1e6 la/lo at build time. Contacts with no GPS use la=lo=0.
|
||||
# ---------------------------------------------------------------------------
|
||||
_CONTACTS = [
|
||||
# name thread type fav ign dir age(s) dlat dlon
|
||||
dict(n="Sanne", t=0, kind="", fav=1, ign=0, dir=1, age=95, dlat=0.021, dlon=-0.015),
|
||||
dict(n="Diego", t=1, kind="", fav=0, ign=0, dir=0, age=1500, dlat=-0.034, dlon=0.052),
|
||||
dict(n="Marta", t=2, kind="", fav=0, ign=0, dir=1, age=320, dlat=0.0, dlon=0.0),
|
||||
dict(n="Hilltop Repeater", t=-1, kind="rp", fav=0, ign=0, dir=0, age=210, dlat=0.145, dlon=0.088),
|
||||
dict(n="Harbor Room", t=3, kind="rm", fav=0, ign=0, dir=0, age=640, dlat=-0.011, dlon=0.031),
|
||||
dict(n="Noah", t=-1, kind="", fav=0, ign=0, dir=0, age=18000, dlat=0.0, dlon=0.0),
|
||||
dict(n="Femke", t=-1, kind="", fav=0, ign=0, dir=1, age=86400*3, dlat=-0.062, dlon=-0.044),
|
||||
dict(n="Old Node", t=-1, kind="", fav=0, ign=1, dir=0, age=86400*9, dlat=0.0, dlon=0.0),
|
||||
]
|
||||
|
||||
# Group channels (webPushContacts channels[]: i=slot, t=thread, n=name).
|
||||
_CHANNELS = [
|
||||
dict(slot=0, t=-1, n="Public"),
|
||||
dict(slot=1, t=-1, n="Emergency"),
|
||||
]
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Messages per thread (webPushMessages: msgs[] fields o,ts,ds,pl,sn,rs,rp,s,x)
|
||||
# o = outgoing (1) / incoming (0)
|
||||
# ts = epoch s
|
||||
# ds = delivery state for outgoing: 0 none,1 sent,2 delivered,3 failed
|
||||
# pl = path length (hops); 255 shows as "Direct" in msg info
|
||||
# sn = SNR dB (incoming), rs = RSSI dBm (incoming)
|
||||
# rp = repeats heard (outgoing)
|
||||
# s = sender name (shown only in channel threads), x = text
|
||||
# Ordered oldest..newest here; serve.py emits newest-first (as the firmware does)
|
||||
# and the browser reverses again for display.
|
||||
# ---------------------------------------------------------------------------
|
||||
_MESSAGES = {
|
||||
# Sanne (direct peer) — thread 0
|
||||
0: [
|
||||
dict(o=0, ago=5400, x="Morning! You around for the hike this weekend?", pl=0, sn=8, rs=-64),
|
||||
dict(o=1, ago=5300, x="Yeah! Saturday works. Meshing the whole way up.", ds=2, pl=0, rp=1),
|
||||
dict(o=0, ago=5200, x="Perfect. I'll bring the spare T-Deck as a relay.", pl=0, sn=7, rs=-66),
|
||||
dict(o=1, ago=280, x="Just tested range from the ridge — 6 km line of sight.", ds=2, pl=0, rp=2),
|
||||
dict(o=0, ago=95, x="Nice, that covers the valley. See you at 8.", pl=0, sn=9, rs=-61),
|
||||
],
|
||||
# Diego (2-hop peer) — thread 1
|
||||
1: [
|
||||
dict(o=0, ago=2000, x="Did the firmware update land on your node?", pl=2, sn=2, rs=-98),
|
||||
dict(o=1, ago=1900, x="Flashing now. beta looks solid so far.", ds=2, pl=2, rp=3),
|
||||
dict(o=0, ago=1600, x="Great. Ping me once the repeater picks you up.", pl=2, sn=1, rs=-101),
|
||||
dict(o=1, ago=1500, x="Will do 👍", ds=1, pl=2, rp=0),
|
||||
],
|
||||
# Marta (direct) — thread 2
|
||||
2: [
|
||||
dict(o=1, ago=900, x="Map tiles are caching fine on the new build.", ds=2, pl=0, rp=1),
|
||||
dict(o=0, ago=700, x="Awesome — offline maps at the campsite then.", pl=0, sn=6, rs=-70),
|
||||
dict(o=1, ago=360, x="Exactly. Sending you my GPS pin now.", ds=3, pl=0, rp=0),
|
||||
dict(o=0, ago=320, x="Didn't come through, try again?", pl=0, sn=5, rs=-72),
|
||||
],
|
||||
# Harbor Room (room server channel) — thread 3, has unread
|
||||
3: [
|
||||
dict(o=0, ago=1200, s="Diego", x="Anyone near the north dock?", pl=1, sn=3, rs=-92),
|
||||
dict(o=0, ago=1100, s="Sanne", x="I'm 300 m out, what's up?", pl=1, sn=7, rs=-68),
|
||||
dict(o=1, ago=1000, s=NODE_NAME, x="Repeater on the crane looks healthy from here.", ds=2, pl=1, rp=2),
|
||||
dict(o=0, ago=700, s="Femke", x="Copy. Battery at 82% on the solar node.", pl=2, sn=1, rs=-104),
|
||||
dict(o=0, ago=640, s="Diego", x="Meeting at the boathouse in 10.", pl=1, sn=4, rs=-88),
|
||||
],
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Threads list (webPushThreads: threads[] fields i,ch,u,ts,n,last)
|
||||
# Derived from _MESSAGES so "last" + "ts" always match the open chat.
|
||||
# u = unread count (only a couple non-zero), ch=1 for channel/room threads.
|
||||
# ---------------------------------------------------------------------------
|
||||
_THREAD_META = {
|
||||
0: dict(ch=0, u=0, n="Sanne"),
|
||||
1: dict(ch=0, u=1, n="Diego"),
|
||||
2: dict(ch=0, u=0, n="Marta"),
|
||||
3: dict(ch=1, u=2, n="Harbor Room"),
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Discovered (heard-not-added) nodes (webPushDiscovered: nodes[] i,rp,rm,pl,adv,la,lo,n)
|
||||
# ---------------------------------------------------------------------------
|
||||
_DISCOVERED = [
|
||||
dict(n="Lars", kind="", pl=2, age=140, dlat=0.012, dlon=0.077),
|
||||
dict(n="Ridge Repeater", kind="rp", pl=1, age=380, dlat=0.191, dlon=-0.02),
|
||||
dict(n="Cafe Beacon", kind="", pl=3, age=900, dlat=-0.008, dlon=0.006),
|
||||
dict(n="Yuki", kind="", pl=2, age=2600, dlat=0.0, dlon=0.0),
|
||||
]
|
||||
|
||||
|
||||
def _abs_coord(base, delta_deg):
|
||||
"""Contact coord (1e6 fixed-point) from a degree offset; 0 stays 0 (unknown)."""
|
||||
if delta_deg == 0:
|
||||
return 0
|
||||
return int(round(base + delta_deg * 1_000_000))
|
||||
|
||||
|
||||
def contacts_payload(now):
|
||||
out = []
|
||||
for i, c in enumerate(_CONTACTS):
|
||||
la = _abs_coord(SELF_LAT, c["dlat"])
|
||||
lo = _abs_coord(SELF_LON, c["dlon"])
|
||||
out.append({
|
||||
"i": i, "t": c["t"],
|
||||
"rp": 1 if c["kind"] == "rp" else 0,
|
||||
"rm": 1 if c["kind"] == "rm" else 0,
|
||||
"fav": c["fav"], "ign": c["ign"], "dir": c["dir"],
|
||||
"la": la, "lo": lo,
|
||||
"adv": now - c["age"],
|
||||
"n": c["n"],
|
||||
})
|
||||
channels = [{"i": ch["slot"], "t": ch["t"], "n": ch["n"]} for ch in _CHANNELS]
|
||||
return {"t": "c", "dc": len(_DISCOVERED), "contacts": out, "channels": channels}
|
||||
|
||||
|
||||
def _thread_last(tidx):
|
||||
msgs = _MESSAGES.get(tidx, [])
|
||||
return msgs[-1] if msgs else None
|
||||
|
||||
|
||||
def threads_payload(now):
|
||||
out = []
|
||||
for tidx, meta in _THREAD_META.items():
|
||||
last = _thread_last(tidx)
|
||||
ts = now - last["ago"] if last else 0
|
||||
text = last["x"] if last else ""
|
||||
out.append({
|
||||
"i": tidx, "ch": meta["ch"], "u": meta["u"],
|
||||
"ts": ts, "n": meta["n"], "last": text,
|
||||
})
|
||||
return {"t": "t", "threads": out}
|
||||
|
||||
|
||||
def messages_payload(now, tidx):
|
||||
meta = _THREAD_META.get(tidx)
|
||||
msgs = _MESSAGES.get(tidx, [])
|
||||
ch = meta["ch"] if meta else 0
|
||||
name = meta["n"] if meta else "Chat"
|
||||
# firmware emits newest-first; the browser reverses for display
|
||||
arr = []
|
||||
for m in reversed(msgs):
|
||||
arr.append({
|
||||
"o": m["o"],
|
||||
"ts": now - m["ago"],
|
||||
"ds": m.get("ds", 0),
|
||||
"pl": m.get("pl", 0),
|
||||
"sn": m.get("sn", 0),
|
||||
"rs": m.get("rs", 0),
|
||||
"rp": m.get("rp", 0),
|
||||
"s": m.get("s", ""),
|
||||
"x": m["x"],
|
||||
})
|
||||
return {"t": "m", "i": tidx, "ch": ch, "n": name, "msgs": arr}
|
||||
|
||||
|
||||
def discovered_payload(now):
|
||||
out = []
|
||||
for i, d in enumerate(_DISCOVERED):
|
||||
out.append({
|
||||
"i": i,
|
||||
"rp": 1 if d["kind"] == "rp" else 0,
|
||||
"rm": 1 if d["kind"] == "rm" else 0,
|
||||
"pl": d["pl"],
|
||||
"adv": now - d["age"],
|
||||
"la": _abs_coord(SELF_LAT, d["dlat"]),
|
||||
"lo": _abs_coord(SELF_LON, d["dlon"]),
|
||||
"n": d["n"],
|
||||
})
|
||||
return {"t": "dc", "nodes": out}
|
||||
|
||||
|
||||
def contact_info_payload(now, cidx):
|
||||
"""webPushContactInfo: fields i,rp,rm,fav,ign,pl,adv,la,lo,k,n."""
|
||||
if cidx < 0 or cidx >= len(_CONTACTS):
|
||||
return {"t": "ci", "i": cidx, "rp": 0, "rm": 0, "fav": 0, "ign": 0,
|
||||
"pl": -1, "adv": 0, "la": 0, "lo": 0, "k": "000000000000", "n": "Contact"}
|
||||
c = _CONTACTS[cidx]
|
||||
la = _abs_coord(SELF_LAT, c["dlat"])
|
||||
lo = _abs_coord(SELF_LON, c["dlon"])
|
||||
# a stable pseudo pubkey-prefix (12 hex chars) so the Info card's ID looks real
|
||||
k = "%02x%02x%02x%02x%02x%02x" % tuple((0x2a + cidx * 7 + j * 31) & 0xff for j in range(6))
|
||||
return {
|
||||
"t": "ci", "i": cidx,
|
||||
"rp": 1 if c["kind"] == "rp" else 0,
|
||||
"rm": 1 if c["kind"] == "rm" else 0,
|
||||
"fav": c["fav"], "ign": c["ign"],
|
||||
"pl": 0 if c["dir"] else (2 if c["kind"] != "rp" else 3),
|
||||
"adv": now - c["age"],
|
||||
"la": la, "lo": lo, "k": k, "n": c["n"],
|
||||
}
|
||||
|
||||
|
||||
def status_payload(now):
|
||||
"""webPushStatus: batt,chg,wifi,rssi,ble,clk,snr,unr,mi,sla,slo,nm."""
|
||||
unread = sum(m["u"] for m in _THREAD_META.values())
|
||||
return {
|
||||
"t": "st",
|
||||
"batt": 78, "chg": 0,
|
||||
"wifi": 1, "rssi": -58,
|
||||
"ble": 1,
|
||||
"clk": now,
|
||||
"snr": 8, # good last-heard signal -> 4 bars
|
||||
"unr": unread,
|
||||
"mi": 0, # km, not miles
|
||||
"sla": SELF_LAT, "slo": SELF_LON,
|
||||
"nm": NODE_NAME,
|
||||
}
|
||||
|
||||
|
||||
def settings_payload(now):
|
||||
"""webPushSettings: freq,bw,sf,cr,tx,wifi,ble,blecap,tcp,name."""
|
||||
return {
|
||||
"t": "sg",
|
||||
"freq": RADIO["freq"], "bw": RADIO["bw"],
|
||||
"sf": RADIO["sf"], "cr": RADIO["cr"], "tx": RADIO["tx"],
|
||||
"wifi": 1, "ble": 1, "blecap": 1, "tcp": 1,
|
||||
"name": NODE_NAME,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Terminal (0x00 text) canned replies for the most common CLI commands, so the
|
||||
# Terminal tab shows believable output in screenshots. Anything not listed gets
|
||||
# a generic acknowledgement.
|
||||
# ---------------------------------------------------------------------------
|
||||
def terminal_reply(now, cmd):
|
||||
import datetime
|
||||
c = cmd.strip()
|
||||
cl = c.lower()
|
||||
clk = datetime.datetime.fromtimestamp(now).strftime("%Y-%m-%d %H:%M:%S")
|
||||
if cl in ("help", "?"):
|
||||
return (
|
||||
"commands:\n"
|
||||
" help ? this list\n"
|
||||
" ver firmware version\n"
|
||||
" clock RTC time\n"
|
||||
" status node status\n"
|
||||
" get radio params\n"
|
||||
" advert send an advert\n"
|
||||
" set <k> <v> name/freq/bw/sf/cr/tx\n"
|
||||
" wifi|ble|tcp ... connectivity\n"
|
||||
" list contacts channels\n"
|
||||
" to <name> / send <text> / public <text>\n"
|
||||
" reboot bootloader\n"
|
||||
)
|
||||
if cl == "ver":
|
||||
return "wadamesh " + FIRMWARE_VERSION + " (demo)\n"
|
||||
if cl == "clock":
|
||||
return clk + "\n"
|
||||
if cl == "status":
|
||||
return ("node: %s\nbattery: 78%% (discharging)\nwifi: connected -58 dBm\n"
|
||||
"ble: on tcp: on\nmesh: 4 bars contacts: %d\n"
|
||||
% (NODE_NAME, len(_CONTACTS)))
|
||||
if cl == "get":
|
||||
return ("freq: %.3f MHz\nbw: %.0f kHz\nsf: %d\ncr: %d\ntx: %d dBm\n"
|
||||
% (RADIO["freq"], RADIO["bw"], RADIO["sf"], RADIO["cr"], RADIO["tx"]))
|
||||
if cl == "list" or cl == "list contacts" or cl == "contacts":
|
||||
lines = ["contacts:"]
|
||||
for i, c2 in enumerate(_CONTACTS):
|
||||
tag = " [RPT]" if c2["kind"] == "rp" else (" [ROOM]" if c2["kind"] == "rm" else "")
|
||||
lines.append(" %2d %s%s" % (i, c2["n"], tag))
|
||||
return "\n".join(lines) + "\n"
|
||||
if cl == "channels":
|
||||
return "channels:\n" + "\n".join(" %d %s" % (c2["slot"], c2["n"]) for c2 in _CHANNELS) + "\n"
|
||||
if cl == "advert" or cl == "advert.zerohop":
|
||||
return "advert sent.\n"
|
||||
if cl.startswith("wifi"):
|
||||
return "wifi: connected ssid=meshnet -58 dBm 10.0.0.42\n"
|
||||
if cl.startswith("ble"):
|
||||
return "ble: advertising as \"" + NODE_NAME + "\"\n"
|
||||
if cl.startswith("tcp"):
|
||||
return "tcp: listening on :5000 (phone app)\n"
|
||||
if cl.startswith("set "):
|
||||
return "ok\n"
|
||||
if cl in ("reboot", "bootloader"):
|
||||
return "(demo) ignored: " + c + "\n"
|
||||
return "ok\n"
|
||||
|
||||
|
||||
# firmware version string surfaced by `ver` (kept generic; edit if you want an
|
||||
# exact tag in the screenshot).
|
||||
FIRMWARE_VERSION = "v1.x-touch"
|
||||
@@ -0,0 +1,126 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Extract the wadamesh web control-panel SPA from the firmware C source.
|
||||
|
||||
The page is the C string literal `WS_HTML_TERMINAL_PAGE[]` in
|
||||
src/helpers/esp32/WebSocketCompanionServer.cpp
|
||||
built from many adjacent "..." fragments. This re-joins the fragments,
|
||||
un-escapes the C escapes, strips the leading HTTP/1.1 response headers, and
|
||||
writes a standalone scripts/doc/web-demo/index.html.
|
||||
|
||||
Re-run whenever the page changes:
|
||||
python3 scripts/doc/web-demo/extract.py
|
||||
"""
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
REPO = os.path.abspath(os.path.join(HERE, "..", "..", ".."))
|
||||
SRC = os.path.join(REPO, "src", "helpers", "esp32", "WebSocketCompanionServer.cpp")
|
||||
OUT = os.path.join(HERE, "index.html")
|
||||
|
||||
DECL = "static const char WS_HTML_TERMINAL_PAGE[] ="
|
||||
|
||||
# C escape sequences that appear in the literal. Order matters: handle the
|
||||
# backslash-escapes we know about; leave any others (\uXXXX etc. don't occur here)
|
||||
# untouched. We decode via a single regex pass so a literal "\\n" (escaped
|
||||
# backslash then n) is NOT turned into a newline.
|
||||
_C_ESCAPES = {
|
||||
"\\n": "\n",
|
||||
"\\r": "\r",
|
||||
"\\t": "\t",
|
||||
'\\"': '"',
|
||||
"\\\\": "\\",
|
||||
"\\'": "'",
|
||||
"\\a": "\a",
|
||||
"\\b": "\b",
|
||||
"\\f": "\f",
|
||||
"\\v": "\v",
|
||||
"\\0": "\0",
|
||||
}
|
||||
_ESC_RE = re.compile(r"\\(?:[abfnrtv0'\"\\]|x[0-9A-Fa-f]{2})")
|
||||
|
||||
|
||||
def _unescape(s):
|
||||
def repl(m):
|
||||
tok = m.group(0)
|
||||
if tok.startswith("\\x"):
|
||||
return chr(int(tok[2:], 16))
|
||||
return _C_ESCAPES.get(tok, tok)
|
||||
return _ESC_RE.sub(repl, s)
|
||||
|
||||
|
||||
def _extract_string_fragments(block):
|
||||
"""Yield the decoded content of every "..." fragment in `block`, in order.
|
||||
|
||||
Parses char-by-char so escaped quotes (\") inside a fragment don't end it,
|
||||
and text between fragments (whitespace / newlines in the C source) is ignored.
|
||||
"""
|
||||
out = []
|
||||
i, n = 0, len(block)
|
||||
in_str = False
|
||||
frag = []
|
||||
while i < n:
|
||||
ch = block[i]
|
||||
if not in_str:
|
||||
if ch == '"':
|
||||
in_str = True
|
||||
frag = []
|
||||
elif ch == ";":
|
||||
break # end of the C statement
|
||||
i += 1
|
||||
continue
|
||||
# inside a string literal
|
||||
if ch == "\\" and i + 1 < n:
|
||||
frag.append(block[i:i + 2])
|
||||
i += 2
|
||||
continue
|
||||
if ch == '"':
|
||||
out.append(_unescape("".join(frag)))
|
||||
in_str = False
|
||||
i += 1
|
||||
continue
|
||||
frag.append(ch)
|
||||
i += 1
|
||||
return "".join(out)
|
||||
|
||||
|
||||
def extract(src_path=SRC):
|
||||
with open(src_path, "r", encoding="utf-8") as f:
|
||||
text = f.read()
|
||||
start = text.find(DECL)
|
||||
if start < 0:
|
||||
raise SystemExit("could not find %s in %s" % (DECL, src_path))
|
||||
start += len(DECL)
|
||||
# The statement ends at the first ';' that terminates the literal. Because
|
||||
# fragments can contain ';' we cannot naively split; instead let the fragment
|
||||
# parser stop at the first ';' found OUTSIDE a string.
|
||||
joined = _extract_string_fragments(text[start:])
|
||||
# Strip the HTTP response head: everything up to and including the blank line
|
||||
# (\r\n\r\n) that precedes the HTML body.
|
||||
marker = "\r\n\r\n"
|
||||
idx = joined.find(marker)
|
||||
if idx >= 0 and joined[:idx].startswith("HTTP/"):
|
||||
joined = joined[idx + len(marker):]
|
||||
else:
|
||||
# fall back: chop to the doctype if the header shape ever changes
|
||||
d = joined.find("<!DOCTYPE")
|
||||
if d > 0:
|
||||
joined = joined[d:]
|
||||
return joined
|
||||
|
||||
|
||||
def main():
|
||||
html = extract()
|
||||
with open(OUT, "w", encoding="utf-8") as f:
|
||||
f.write(html)
|
||||
print("wrote %s (%d bytes)" % (OUT, len(html)))
|
||||
# sanity: the reconstructed page must have the key anchors
|
||||
for needle in ("<!DOCTYPE html>", "WADAMESH", "/term", "id=w", "showTab"):
|
||||
if needle not in html:
|
||||
print(" WARNING: expected %r not found in output" % needle, file=sys.stderr)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,268 @@
|
||||
<!DOCTYPE html><html><head><meta charset=utf-8>
|
||||
<meta name=viewport content='width=device-width,initial-scale=1,maximum-scale=1'>
|
||||
<title>wadamesh</title>
|
||||
<style>
|
||||
*{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%}
|
||||
#w{display:flex;flex-direction:column;height:100%}
|
||||
#hd{padding:8px 10px;border-bottom:1px solid #1c1c1f;display:flex;align-items:center;gap:8px}#hdl{flex:1}
|
||||
#hd b{color:#19d6c2;font-weight:700;letter-spacing:2px}#st{font-size:12px}
|
||||
#tabs{display:flex;border-bottom:1px solid #1c1c1f}
|
||||
#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}
|
||||
#tabs button.on{color:#19d6c2;border-bottom-color:#19d6c2}
|
||||
.pane{flex:1;min-height:0;display:flex;flex-direction:column}
|
||||
.list{flex:1;overflow-y:auto}
|
||||
.row{display:flex;padding:11px 13px;border-bottom:1px solid #141416;cursor:pointer;gap:10px;align-items:center}
|
||||
.row:active{background:#141416}.rmain{flex:1;min-width:0}
|
||||
.rname{font-weight:600;color:#e8e8ea;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.rsub{font-size:12px;color:#7f868c;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin-top:2px}
|
||||
.rtime{font-size:11px;color:#63696e;white-space:nowrap}
|
||||
.badge{background:#19d6c2;color:#04201d;border-radius:9px;padding:0 6px;font-size:11px;margin-left:6px;font-weight:700}
|
||||
.sec{padding:9px 13px 4px;font-size:11px;color:#63696e;letter-spacing:1px}
|
||||
.empty{padding:26px 13px;color:#63696e;text-align:center}
|
||||
#cview{flex:1;min-height:0;flex-direction:column}
|
||||
#cvh{display:flex;align-items:center;gap:6px;padding:8px 8px;border-bottom:1px solid #1c1c1f}
|
||||
#cvback{background:none;border:none;color:#19d6c2;font-size:24px;line-height:1;cursor:pointer;padding:0 6px}
|
||||
#cvname{font-weight:600}
|
||||
#cvmsgs{flex:1;overflow-y:auto;padding:10px;display:flex;flex-direction:column;gap:6px}
|
||||
.b{max-width:80%;padding:6px 9px;border-radius:11px;word-break:break-word}
|
||||
.b.in{align-self:flex-start;background:#17181b}.b.out{align-self:flex-end;background:#123f3a}
|
||||
.bname{font-size:11px;color:#19d6c2;margin-bottom:2px}.btext{white-space:pre-wrap}
|
||||
.bmeta{font-size:10px;color:#8a9095;text-align:right;margin-top:2px}
|
||||
.cb{display:flex;border-top:1px solid #1c1c1f;padding:8px;gap:8px}
|
||||
.cb input{flex:1;background:#111214;color:#e8e8ea;border:1px solid #2a2a2e;border-radius:18px;padding:9px 14px;font:inherit;outline:none}
|
||||
.cb input:focus{border-color:#19d6c2}
|
||||
.cb button{background:#19d6c2;color:#04201d;border:none;border-radius:18px;padding:0 16px;font-weight:700;cursor:pointer}
|
||||
#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}
|
||||
#tbar{display:flex;border-top:1px solid #1c1c1f;padding:8px 10px;gap:8px;align-items:center}
|
||||
#p{color:#19d6c2;font-weight:700}
|
||||
#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}
|
||||
#i:focus{border-color:#19d6c2}
|
||||
.e{color:#7f868c}.c{color:#e8b84b}.ok{color:#19d6c2}.err{color:#ff6b6b}
|
||||
#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}
|
||||
#dot{font-size:11px;color:#63696e}#hdr{display:flex;align-items:center;gap:8px;font-size:12px;color:#9aa0a6;white-space:nowrap}
|
||||
#hdr svg{vertical-align:middle;display:block}#hdr>span{display:inline-flex;align-items:center}.bpct{font-size:11px;margin-left:3px;color:#9aa0a6}
|
||||
.bars{letter-spacing:-1px;color:#19d6c2}.bars i{color:#3a3f44;font-style:normal}
|
||||
.bgrn{color:#19d6c2}.byel{color:#e8b84b}.bred{color:#ff6b6b}
|
||||
#ctools{display:flex;gap:6px;padding:7px 8px;border-bottom:1px solid #1c1c1f}
|
||||
#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}
|
||||
#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}
|
||||
.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}
|
||||
#scrim{position:fixed;inset:0;background:rgba(0,0,0,.45);z-index:19;display:none}#scrim.on{display:block}
|
||||
#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}
|
||||
#qcmd{background:#17181b;color:#19d6c2;border:1px solid #2a2a2e;border-radius:6px;padding:0 11px;font-size:16px;line-height:34px;cursor:pointer}
|
||||
#sheet.on{transform:translateY(0)}#sheet h4{margin:8px 16px 4px;font-size:13px;color:#9aa0a6}
|
||||
.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}
|
||||
#modal{position:fixed;inset:0;display:none;align-items:center;justify-content:center;z-index:21;padding:20px}#modal.on{display:flex}
|
||||
#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}
|
||||
#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}
|
||||
#mclose{margin-top:12px;width:100%;background:#19d6c2;color:#04201d;border:none;border-radius:8px;padding:10px;font-weight:700;cursor:pointer}
|
||||
#gear{background:none;border:none;color:#9aa0a6;font-size:17px;cursor:pointer;padding:0 2px;line-height:1}
|
||||
.sset label{display:block;font-size:11px;color:#63696e;letter-spacing:1px;margin:12px 0 4px}
|
||||
.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%}
|
||||
.srow{display:flex;gap:8px;align-items:center;margin:4px 0}.srow input{flex:1;min-width:0}
|
||||
.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}
|
||||
.sgrid{display:grid;grid-template-columns:1fr 1fr;gap:6px}.sgrid .f label{margin:2px 0}.sgrid .f{min-width:0}
|
||||
.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}
|
||||
.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}
|
||||
.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}
|
||||
#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}
|
||||
#toast.on{transform:translateY(0)}#toast b{color:#19d6c2;display:block;font-size:13px;margin-bottom:1px}
|
||||
#toast span{font-size:12px;color:#cfeee9;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;display:block}
|
||||
</style></head><body>
|
||||
<div id=w>
|
||||
<div id=hd><div id=hdl><span id=dot>•</span><b>WADAMESH</b><span id=hnm></span></div>
|
||||
<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>⚙</button></div>
|
||||
<div id=tabs><button data-t=chats class=on>Chats</button><button data-t=contacts>Contacts</button><button data-t=term>Terminal</button></div>
|
||||
<div id=pchats class=pane>
|
||||
<div id=tlist class=list></div>
|
||||
<div id=cview style=display:none>
|
||||
<div id=cvh><button id=cvback>‹</button><span id=cvname></span></div>
|
||||
<div id=cvmsgs></div>
|
||||
<div class=cb><input id=cvi placeholder='Message' autocomplete=off><button id=cvsend>Send</button></div>
|
||||
</div>
|
||||
</div>
|
||||
<div id=pcontacts class=pane style=display:none>
|
||||
<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>
|
||||
<div id=clist class=list></div>
|
||||
</div>
|
||||
<div id=pterm class=pane style=display:none>
|
||||
<div id=o></div>
|
||||
<div id=tbar><span id=p>></span><input id=i autocomplete=off autocorrect=off autocapitalize=off spellcheck=false placeholder='type a command - e.g. help'><button id=qcmd title='Commands'>⚡</button></div>
|
||||
</div>
|
||||
</div>
|
||||
<div id=scrim></div>
|
||||
<div id=sheet></div>
|
||||
<div id=modal><div id=mbox></div></div>
|
||||
<div id=toast></div>
|
||||
<script>
|
||||
var WS,dec=new TextDecoder(),curTab='chats',chat=null,threads=[],contacts=[],channels=[];
|
||||
var csort=0,cfilter=0,csearchq='',self={la:0,lo:0},lpAt=0,useMi=0,discCount=0;
|
||||
var SORTL=['Recent','A-Z','Distance'],FILTL=['All','Repeaters','Peers','Favorites','Located','Direct'];
|
||||
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'}
|
||||
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'}
|
||||
function E(i){return document.getElementById(i)}
|
||||
function each(l,f){Array.prototype.forEach.call(l,f)}
|
||||
function esc(s){return (s==null?'':''+s).replace(/[&<>"]/g,function(c){return({'&':'&','<':'<','>':'>','"':'"'})[c]})}
|
||||
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)}
|
||||
function fmtDate(t){return (t>1000000000)?new Date(t*1000).toLocaleString():'--'}
|
||||
function stat(t,k){var el=E('dot');el.style.color=(k=='ok')?'#19d6c2':((k=='err')?'#ff6b6b':'#63696e');el.title=t}
|
||||
function wsSend(s){if(WS&&WS.readyState==1){WS.send(s);return true}return false}
|
||||
function cById(i){for(var k=0;k<contacts.length;k++)if(contacts[k].i==i)return contacts[k];return null}
|
||||
function showTab(t){curTab=t;['chats','contacts','term'].forEach(function(x){E('p'+x).style.display=(x==t)?'':'none'});
|
||||
each(document.querySelectorAll('#tabs button'),function(b){b.className=(b.getAttribute('data-t')==t)?'on':''});
|
||||
if(t=='chats'){if(!chat){closeChat();wsSend('@t')}}else if(t=='contacts'){wsSend('@c')}else if(t=='term'){E('i').focus()}}
|
||||
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>'}
|
||||
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>'}
|
||||
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>'}
|
||||
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>'}
|
||||
function renderStatus(d){E('hnm').textContent=d.nm||'';self.la=d.sla||0;self.lo=d.slo||0;useMi=d.mi?1:0;
|
||||
var lvl=(d.snr<=-127)?0:(d.snr>=5?4:(d.snr>=0?3:(d.snr>=-7?2:1)));
|
||||
E('hsig').innerHTML=svgSig(lvl);E('hwifi').innerHTML=d.wifi?svgWifi(1):'';E('hble').innerHTML=d.ble?svgBt():'';
|
||||
E('hbatt').innerHTML=(d.batt<0)?'':(svgBatt(d.batt,d.chg)+'<span class=bpct>'+d.batt+'%</span>');
|
||||
E('hclk').textContent=fmt(d.clk);
|
||||
var cb=document.querySelector('#tabs [data-t=chats]');if(cb)cb.textContent=d.unr>0?('Chats ('+d.unr+')'):'Chats'}
|
||||
function closeOv(){E('sheet').classList.remove('on');E('modal').classList.remove('on');E('scrim').classList.remove('on')}
|
||||
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>'});
|
||||
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()}});
|
||||
E('sheet').classList.add('on');E('scrim').classList.add('on')}
|
||||
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}
|
||||
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()}}
|
||||
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}}
|
||||
el.addEventListener('touchstart',s,{passive:true});el.addEventListener('touchmove',c,{passive:true});el.addEventListener('touchend',c);
|
||||
el.addEventListener('mousedown',s);el.addEventListener('mouseup',c);el.addEventListener('mouseleave',c);el.addEventListener('contextmenu',function(e){e.preventDefault();lpAt=Date.now();fn()})}
|
||||
function clicked(fn){return function(){if(Date.now()-lpAt<650)return;fn()}}
|
||||
function contactSheet(c){sheet(c.n||'Contact',[
|
||||
{label:'Message',fn:function(){if(c.t>=0)wsSend('@m '+c.t);else wsSend('@oc '+c.i)}},
|
||||
{label:'Info',fn:function(){wsSend('@ic '+c.i)}},
|
||||
{label:c.fav?'Unfavorite':'Favorite',fn:function(){wsSend('@fv '+c.i)}},
|
||||
{label:'Reset path',fn:function(){wsSend('@pr '+c.i)}},
|
||||
{label:c.ign?'Unblock':'Block',fn:function(){wsSend('@bk '+c.i)}},
|
||||
{label:'Delete',cls:'dng',fn:function(){if(confirm('Delete '+(c.n||'contact')+'?'))wsSend('@xc '+c.i)}}])}
|
||||
function chatSheet(t){sheet(t.n||'Chat',[
|
||||
{label:'Mark as read',fn:function(){wsSend('@r '+t.i)}},
|
||||
{label:'Clear history',fn:function(){if(confirm('Clear history?'))wsSend('@d '+t.i)}},
|
||||
{label:'Delete chat',cls:'dng',fn:function(){if(confirm('Delete this chat?'))wsSend('@xt '+t.i)}}])}
|
||||
function msgSheet(m){sheet('Message',[{label:'Copy',fn:function(){copy(m.x)}},{label:'Info',fn:function(){msgInfo(m)}}])}
|
||||
function msgInfo(m){var pl=(m.pl==255)?'Direct':(m.pl+' hop'+(m.pl==1?'':'s'));
|
||||
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>';
|
||||
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>';
|
||||
if(m.o)h+='<div class=kv><b>Delivery</b><span>'+(({0:'--',1:'Sent',2:'Delivered',3:'Failed'})[m.ds]||'--')+'</span></div>';
|
||||
if(m.o&&m.rp>0)h+='<div class=kv><b>Repeats heard</b><span>'+m.rp+'</span></div>';
|
||||
modal('Message info',h)}
|
||||
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')));
|
||||
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>';
|
||||
if(d.adv>1000000000)h+='<div class=kv><b>Last heard</b><span>'+fmtDate(d.adv)+'</span></div>';
|
||||
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>'}
|
||||
h+='<div class=kv><b>ID</b><span>'+esc(d.k)+'</span></div>';modal(d.n||'Contact',h)}
|
||||
var prevUnread={},toastTmr=null,AC=null;
|
||||
function onThreads(nt){
|
||||
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')});
|
||||
prevUnread={};nt.forEach(function(t){prevUnread[t.i]=t.u});threads=nt;renderThreads()}
|
||||
function notify(ti,title,body){showToast(ti,title,body);
|
||||
try{if('Notification' in window&&Notification.permission=='granted'&&document.hidden)new Notification(title,{body:body})}catch(e){}
|
||||
try{beep()}catch(e){}}
|
||||
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;
|
||||
t.onclick=function(){t.classList.remove('on');wsSend('@m '+ti)};t.classList.add('on');
|
||||
if(toastTmr)clearTimeout(toastTmr);toastTmr=setTimeout(function(){t.classList.remove('on')},4200)}
|
||||
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)}
|
||||
document.addEventListener('click',function(){try{if('Notification' in window&&Notification.permission=='default')Notification.requestPermission()}catch(e){}},{once:true});
|
||||
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'},
|
||||
{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'},
|
||||
{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 '},
|
||||
{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'},
|
||||
{h:'SYSTEM'},{l:'reboot',c:'reboot'},{l:'bootloader - download mode',c:'bootloader'}];
|
||||
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>'});
|
||||
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()}});
|
||||
E('sheet').classList.add('on');E('scrim').classList.add('on')}
|
||||
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'];
|
||||
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}
|
||||
function tsend(){var v=E('i').value;E('i').value='';if(!v.trim())return;if(v.trim()=='clear'){E('o').innerHTML='';return}
|
||||
tadd('\n> '+v+'\n','c');hist.push(v);hi=hist.length;if(!wsSend(v))tadd('(not connected)\n','err')}
|
||||
function onData(s){var d;try{d=JSON.parse(s)}catch(_){return}
|
||||
if(d.t=='t'){onThreads(d.threads||[])}
|
||||
else if(d.t=='c'){contacts=d.contacts||[];channels=d.channels||[];discCount=d.dc||0;renderContacts()}
|
||||
else if(d.t=='m'){openChatView(d)}
|
||||
else if(d.t=='st'){renderStatus(d)}
|
||||
else if(d.t=='ci'){showContactInfo(d)}
|
||||
else if(d.t=='dc'){showDiscovered(d.nodes||[])}
|
||||
else if(d.t=='sg'){showSettings(d)}
|
||||
else if(d.t=='rx'){wsSend('@t');if(chat&&chat.i>=0)wsSend('@m '+chat.i);if(curTab=='contacts')wsSend('@c')}}
|
||||
function renderThreads(){var h='';if(!threads.length)h='<div class=empty>No chats yet.<br>Start one from Contacts.</div>';
|
||||
threads.sort(function(a,b){return (b.ts||0)-(a.ts||0)});
|
||||
threads.forEach(function(t){var bd=t.u>0?'<span class=badge>'+t.u+'</span>':'';
|
||||
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>'});
|
||||
E('tlist').innerHTML=h;each(E('tlist').querySelectorAll('.row'),function(r){var ti=+r.getAttribute('data-ti');
|
||||
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)})})}
|
||||
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;
|
||||
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))}
|
||||
function cVis(c){if(csearchq&&(c.n||'').toLowerCase().indexOf(csearchq)<0)return false;
|
||||
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}
|
||||
function renderContacts(){E('csort').textContent='Sort: '+SORTL[csort];E('cfilt').textContent=FILTL[cfilter];var h='';
|
||||
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>'}
|
||||
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>'})}
|
||||
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)});
|
||||
h+='<div class=sec>CONTACTS'+(cs.length?' ('+cs.length+')':'')+'</div>';if(!cs.length)h+='<div class=empty>No contacts.</div>';
|
||||
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>':'';
|
||||
var sub=[fmtDist(cdist(c)),age(c.adv)].filter(Boolean).join(' \u00b7 ');if(c.dir)sub=sub?(sub+' \u00b7 direct'):'direct';
|
||||
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>'});
|
||||
E('clist').innerHTML=h;var dr=E('discrow');if(dr)dr.onclick=function(){wsSend('@dc')};
|
||||
each(E('clist').querySelectorAll('.row'),function(r){
|
||||
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)}
|
||||
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'))})}})}
|
||||
function showDiscovered(nodes){if(!nodes.length){sheet('Discovered nodes',[{label:'No new nodes heard yet'}]);return}
|
||||
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 ');
|
||||
return {label:(nd.n||'(unnamed)')+tag+(meta?' ('+meta+')':''),fn:function(){wsSend('@ad '+nd.i);showToast(-1,'Added contact',nd.n||'')}}});
|
||||
sheet('Discovered - tap to add',items)}
|
||||
function showSettings(d){var g=E;
|
||||
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>';
|
||||
h+='<label>RADIO (applies live)</label><div class=sgrid>';
|
||||
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>';
|
||||
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>';
|
||||
h+='<div class=f><label>TX dBm</label><input type=number id=sftx></div><div class=f><label> </label><button class=sbtn id=sbradio style=width:100%>Apply</button></div></div>';
|
||||
h+='<label>CONNECTIVITY</label><div class=stog><b>Wi-Fi</b><div class=sw id=swifi><i></i></div></div>';
|
||||
if(d.blecap)h+='<div class=stog><b>Bluetooth</b><div class=sw id=sble><i></i></div></div>';
|
||||
h+='<div class=stog><b>TCP (phone app)</b><div class=sw id=stcp><i></i></div></div>';
|
||||
h+='<div class=srow2><button id=sadvert>Send advert</button><button id=sreboot class=dng>Reboot</button></div></div>';
|
||||
E('mbox').innerHTML='<h3>Settings</h3>'+h+'<button id=mclose>Close</button>';
|
||||
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;
|
||||
function tg(id,on){var el=g(id);if(el){on?el.classList.add('on'):el.classList.remove('on')}}
|
||||
tg('swifi',d.wifi);tg('sble',d.ble);tg('stcp',d.tcp);
|
||||
g('sbname').onclick=function(){var v=g('sfname').value.trim();if(v){wsSend('set name '+v);showToast(-1,'Name saved',v)}};
|
||||
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','')};
|
||||
g('swifi').onclick=function(){var on=!g('swifi').classList.contains('on');tg('swifi',on);wsSend('wifi '+(on?'on':'off'))};
|
||||
if(g('sble'))g('sble').onclick=function(){var on=!g('sble').classList.contains('on');tg('sble',on);wsSend('ble '+(on?'on':'off'))};
|
||||
g('stcp').onclick=function(){var on=!g('stcp').classList.contains('on');tg('stcp',on);wsSend('tcp '+(on?'on':'off'))};
|
||||
g('sadvert').onclick=function(){wsSend('advert');showToast(-1,'Advert sent','')};
|
||||
g('sreboot').onclick=function(){if(confirm('Reboot the device now?'))wsSend('reboot')};
|
||||
g('mclose').onclick=closeOv;E('modal').classList.add('on');E('scrim').classList.add('on')}
|
||||
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;
|
||||
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)};
|
||||
showTab('chats');E('tlist').style.display='none';E('cview').style.display='flex';E('cvname').textContent=(chat.ch?'# ':'')+chat.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);
|
||||
if(m.o){meta+=' '+(({1:'\u2713',2:'\u2713\u2713',3:'\u2717'})[m.ds]||'');if(m.rp>0)meta+=' \u21bb'+m.rp}
|
||||
var nm=(!m.o&&chat.ch)?'<div class=bname>'+esc(m.s)+'</div>':'';
|
||||
h+='<div class="'+cls+'" data-mi='+i+'>'+nm+'<div class=btext>'+esc(m.x)+'</div><div class=bmeta>'+meta+'</div></div>'});
|
||||
E('cvmsgs').innerHTML=h;if(!same||wasBottom)E('cvmsgs').scrollTop=E('cvmsgs').scrollHeight;
|
||||
each(E('cvmsgs').querySelectorAll('.b'),function(b){var m=arr[+b.getAttribute('data-mi')];longPress(b,function(){if(m)msgSheet(m)})})}
|
||||
function closeChat(){chat=null;E('cview').style.display='none';E('tlist').style.display=''}
|
||||
function chatSend(){var v=E('cvi').value.trim();if(!v||!chat)return;E('cvi').value='';
|
||||
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)}
|
||||
function conn(){WS=new WebSocket((location.protocol=='https:'?'wss://':'ws://')+location.host+'/term');WS.binaryType='arraybuffer';
|
||||
WS.onopen=function(){stat('online','ok');wsSend('@st');if(curTab=='chats')wsSend('@t')};
|
||||
WS.onclose=function(){stat('reconnecting...','err');setTimeout(conn,1500)};WS.onerror=function(){stat('error','err')};
|
||||
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)}}
|
||||
each(document.querySelectorAll('#tabs button'),function(b){b.onclick=function(){showTab(b.getAttribute('data-t'))}});
|
||||
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')};
|
||||
E('csort').onclick=function(){sheet('Sort by',SORTL.map(function(s,i){return {label:(i==csort?'\u2713 ':'')+s,fn:function(){csort=i;renderContacts()}}}))};
|
||||
E('cfilt').onclick=function(){sheet('Filter',FILTL.map(function(f,i){return {label:(i==cfilter?'\u2713 ':'')+f,fn:function(){cfilter=i;renderContacts()}}}))};
|
||||
E('csearch').addEventListener('input',function(){csearchq=E('csearch').value.trim().toLowerCase();renderContacts()});
|
||||
E('cvback').onclick=function(){closeChat();wsSend('@t')};E('cvsend').onclick=chatSend;
|
||||
E('cvi').addEventListener('keydown',function(e){if(e.key=='Enter'){chatSend();e.preventDefault()}});
|
||||
E('i').addEventListener('keydown',function(e){if(e.key=='Enter'){tsend();e.preventDefault()}
|
||||
else if(e.key=='ArrowUp'){if(hi>0){hi--;E('i').value=hist[hi];e.preventDefault()}}
|
||||
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()}
|
||||
else if(e.key=='Tab'){var q=E('i').value.toLowerCase(),m=CMDS.filter(function(c){return c.indexOf(q)==0});
|
||||
if(m.length==1)E('i').value=m[0];else if(m.length>1)tadd('\n'+m.join(' ').trim()+'\n','e');e.preventDefault()}});
|
||||
tadd('wadamesh terminal - node config + messaging. help = all commands.\n\n','e');
|
||||
conn();showTab('chats');
|
||||
</script></body></html>
|
||||
@@ -0,0 +1,368 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Offline demo server for the wadamesh web control panel.
|
||||
|
||||
Serves the extracted index.html at GET / and speaks the device's /term
|
||||
WebSocket protocol with hard-coded demo data, so the real SPA renders exactly
|
||||
as it does on the device — just with clean documentation data. No firmware, no
|
||||
device, no third-party packages (WebSocket handshake + framing done by hand).
|
||||
|
||||
python3 scripts/doc/web-demo/serve.py [port] # default 8791
|
||||
|
||||
Then open http://127.0.0.1:<port>/ and screenshot. Runs in the foreground.
|
||||
|
||||
Protocol (mirrors WebSocketCompanionServer.cpp + handleWebDataCmd in UITask.cpp):
|
||||
* Browser opens ws://host/term, sends short text commands.
|
||||
* '@'-prefixed commands are the data API; the reply is ONE binary frame whose
|
||||
payload is 0x01 followed by JSON.
|
||||
* Any other command is a terminal CLI line; the reply is a binary frame whose
|
||||
payload is 0x00 followed by text.
|
||||
"""
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import socket
|
||||
import struct
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
sys.path.insert(0, HERE)
|
||||
import demo # noqa: E402 (local module)
|
||||
|
||||
INDEX = os.path.join(HERE, "index.html")
|
||||
WS_GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11" # RFC 6455 magic
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Command -> demo payload dispatch. Mirrors handleWebDataCmd() exactly.
|
||||
# Returns (kind, data): kind 'json' -> data is a dict (framed 0x01),
|
||||
# kind 'text' -> data is a str (framed 0x00),
|
||||
# kind None -> no reply (matches the firmware swallowing
|
||||
# mutation @-commands that only refresh).
|
||||
# --------------------------------------------------------------------------- #
|
||||
def dispatch(cmd):
|
||||
now = int(time.time())
|
||||
if not cmd:
|
||||
return (None, None)
|
||||
|
||||
# Terminal CLI line (not '@') -> text reply.
|
||||
if cmd[0] != "@":
|
||||
return ("text", demo.terminal_reply(now, cmd))
|
||||
|
||||
a = cmd[1:]
|
||||
|
||||
def is_word(prefix):
|
||||
# matches handleWebDataCmd's "a[0]==.. && (next==0 || ' ')" tests
|
||||
return a == prefix or a.startswith(prefix + " ")
|
||||
|
||||
def int_arg(after):
|
||||
# atoi of the tail starting at index `after`
|
||||
m = re.match(r"\s*(-?\d+)", a[after:])
|
||||
return int(m.group(1)) if m else 0
|
||||
|
||||
if is_word("c"):
|
||||
return ("json", demo.contacts_payload(now))
|
||||
if is_word("t"):
|
||||
return ("json", demo.threads_payload(now))
|
||||
if is_word("dc"):
|
||||
return ("json", demo.discovered_payload(now))
|
||||
if is_word("sg"):
|
||||
return ("json", demo.settings_payload(now))
|
||||
if is_word("st"):
|
||||
return ("json", demo.status_payload(now))
|
||||
if a.startswith("m "):
|
||||
return ("json", demo.messages_payload(now, int_arg(2)))
|
||||
if a.startswith("oc "):
|
||||
return ("json", demo.messages_payload(now, _thread_for_contact(int_arg(3))))
|
||||
if a.startswith("oh "):
|
||||
# open channel by slot: demo channels have no thread -> empty chat keyed to slot
|
||||
slot = int_arg(3)
|
||||
return ("json", _empty_channel_chat(slot))
|
||||
if a.startswith("ic "):
|
||||
return ("json", demo.contact_info_payload(now, int_arg(3)))
|
||||
if a.startswith("sr "):
|
||||
# apply radio -> echo settings back (firmware calls webPushSettings)
|
||||
return ("json", demo.settings_payload(now))
|
||||
# Send / mutate commands: the firmware pushes a refresh (messages/threads/
|
||||
# contacts). For a static demo we re-emit the relevant view so the UI stays
|
||||
# coherent if a screenshot script drives a send, but the canned data itself
|
||||
# doesn't change.
|
||||
if a.startswith("s "):
|
||||
return ("json", demo.messages_payload(now, int_arg(2)))
|
||||
if a.startswith("sc "):
|
||||
return ("json", demo.messages_payload(now, _thread_for_contact(int_arg(3))))
|
||||
if a.startswith("sh "):
|
||||
return (None, None)
|
||||
if a.startswith("ad "):
|
||||
return ("json", demo.discovered_payload(now)) # refresh discovered
|
||||
if a[:3] in ("fv ", "bk ", "pr ", "xc "):
|
||||
return ("json", demo.contacts_payload(now)) # refresh contacts
|
||||
if a.startswith("xt ") or a.startswith("d ") or a.startswith("r "):
|
||||
return ("json", demo.threads_payload(now)) # refresh threads
|
||||
# unknown '@' -> swallow (firmware returns true without replying)
|
||||
return (None, None)
|
||||
|
||||
|
||||
def _thread_for_contact(cidx):
|
||||
# map a contact index to its thread index using demo data (or -1)
|
||||
try:
|
||||
return demo._CONTACTS[cidx]["t"]
|
||||
except (IndexError, KeyError):
|
||||
return -1
|
||||
|
||||
|
||||
def _empty_channel_chat(slot):
|
||||
name = "Channel"
|
||||
for ch in demo._CHANNELS:
|
||||
if ch["slot"] == slot:
|
||||
name = ch["n"]
|
||||
break
|
||||
return {"t": "m", "i": -1, "h": slot, "ch": 1, "n": name, "msgs": []}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Minimal WebSocket framing (server -> client is never masked; client -> server
|
||||
# is always masked per RFC 6455).
|
||||
# --------------------------------------------------------------------------- #
|
||||
def ws_encode(payload_bytes, opcode=0x2):
|
||||
"""Build a single unmasked frame. opcode 0x2 = binary (what the SPA expects)."""
|
||||
fin_op = 0x80 | opcode
|
||||
n = len(payload_bytes)
|
||||
if n < 126:
|
||||
header = struct.pack("!BB", fin_op, n)
|
||||
elif n < 65536:
|
||||
header = struct.pack("!BBH", fin_op, 126, n)
|
||||
else:
|
||||
header = struct.pack("!BBQ", fin_op, 127, n)
|
||||
return header + payload_bytes
|
||||
|
||||
|
||||
def _recv_exact(sock, n):
|
||||
buf = b""
|
||||
while len(buf) < n:
|
||||
chunk = sock.recv(n - len(buf))
|
||||
if not chunk:
|
||||
return None
|
||||
buf += chunk
|
||||
return buf
|
||||
|
||||
|
||||
def ws_read_frame(sock):
|
||||
"""Read one client frame. Returns (opcode, payload_bytes) or (None, None) on close."""
|
||||
hdr = _recv_exact(sock, 2)
|
||||
if not hdr:
|
||||
return (None, None)
|
||||
b0, b1 = hdr[0], hdr[1]
|
||||
opcode = b0 & 0x0f
|
||||
masked = b1 & 0x80
|
||||
length = b1 & 0x7f
|
||||
if length == 126:
|
||||
ext = _recv_exact(sock, 2)
|
||||
if ext is None:
|
||||
return (None, None)
|
||||
length = struct.unpack("!H", ext)[0]
|
||||
elif length == 127:
|
||||
ext = _recv_exact(sock, 8)
|
||||
if ext is None:
|
||||
return (None, None)
|
||||
length = struct.unpack("!Q", ext)[0]
|
||||
mask = b"\x00\x00\x00\x00"
|
||||
if masked:
|
||||
mask = _recv_exact(sock, 4)
|
||||
if mask is None:
|
||||
return (None, None)
|
||||
payload = _recv_exact(sock, length) if length else b""
|
||||
if payload is None:
|
||||
return (None, None)
|
||||
if masked:
|
||||
payload = bytes(payload[i] ^ mask[i % 4] for i in range(len(payload)))
|
||||
return (opcode, payload)
|
||||
|
||||
|
||||
def frame_reply(kind, data):
|
||||
if kind == "json":
|
||||
return ws_encode(b"\x01" + json.dumps(data, ensure_ascii=False).encode("utf-8"))
|
||||
if kind == "text":
|
||||
return ws_encode(b"\x00" + data.encode("utf-8"))
|
||||
return None
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# HTTP / WebSocket handling on a raw socket.
|
||||
# --------------------------------------------------------------------------- #
|
||||
def read_http_request(sock):
|
||||
data = b""
|
||||
while b"\r\n\r\n" not in data:
|
||||
chunk = sock.recv(4096)
|
||||
if not chunk:
|
||||
return None, None, None
|
||||
data += chunk
|
||||
if len(data) > 65536:
|
||||
break
|
||||
head, _, _rest = data.partition(b"\r\n\r\n")
|
||||
lines = head.decode("latin-1").split("\r\n")
|
||||
if not lines:
|
||||
return None, None, None
|
||||
request_line = lines[0]
|
||||
parts = request_line.split(" ")
|
||||
method = parts[0] if parts else ""
|
||||
path = parts[1] if len(parts) > 1 else "/"
|
||||
headers = {}
|
||||
for ln in lines[1:]:
|
||||
if ":" in ln:
|
||||
k, v = ln.split(":", 1)
|
||||
headers[k.strip().lower()] = v.strip()
|
||||
return method, path, headers
|
||||
|
||||
|
||||
def serve_http_index(sock):
|
||||
with open(INDEX, "rb") as f:
|
||||
body = f.read()
|
||||
resp = (
|
||||
b"HTTP/1.1 200 OK\r\n"
|
||||
b"Content-Type: text/html; charset=utf-8\r\n"
|
||||
b"Content-Length: " + str(len(body)).encode() + b"\r\n"
|
||||
b"Connection: close\r\n"
|
||||
b"Cache-Control: no-store\r\n"
|
||||
b"\r\n" + body
|
||||
)
|
||||
sock.sendall(resp)
|
||||
|
||||
|
||||
def serve_http_404(sock):
|
||||
body = b"not found"
|
||||
sock.sendall(
|
||||
b"HTTP/1.1 404 Not Found\r\nContent-Length: " + str(len(body)).encode()
|
||||
+ b"\r\nConnection: close\r\n\r\n" + body
|
||||
)
|
||||
|
||||
|
||||
def do_ws_handshake(sock, headers):
|
||||
key = headers.get("sec-websocket-key")
|
||||
if not key:
|
||||
serve_http_404(sock)
|
||||
return False
|
||||
accept = base64.b64encode(hashlib.sha1((key + WS_GUID).encode()).digest()).decode()
|
||||
resp = (
|
||||
"HTTP/1.1 101 Switching Protocols\r\n"
|
||||
"Upgrade: websocket\r\n"
|
||||
"Connection: Upgrade\r\n"
|
||||
"Sec-WebSocket-Accept: " + accept + "\r\n"
|
||||
"\r\n"
|
||||
)
|
||||
sock.sendall(resp.encode())
|
||||
return True
|
||||
|
||||
|
||||
def handle_ws(sock):
|
||||
# Periodic status pushes, like the device's 3 s refresh, on a background thread.
|
||||
alive = {"v": True}
|
||||
lock = threading.Lock()
|
||||
|
||||
def push(frame):
|
||||
if frame is None:
|
||||
return
|
||||
with lock:
|
||||
try:
|
||||
sock.sendall(frame)
|
||||
except OSError:
|
||||
alive["v"] = False
|
||||
|
||||
def status_loop():
|
||||
while alive["v"]:
|
||||
time.sleep(3.0)
|
||||
if not alive["v"]:
|
||||
break
|
||||
push(frame_reply("json", demo.status_payload(int(time.time()))))
|
||||
|
||||
t = threading.Thread(target=status_loop, daemon=True)
|
||||
t.start()
|
||||
try:
|
||||
while alive["v"]:
|
||||
opcode, payload = ws_read_frame(sock)
|
||||
if opcode is None:
|
||||
break
|
||||
if opcode == 0x8: # close
|
||||
break
|
||||
if opcode == 0x9: # ping -> pong
|
||||
push(ws_encode(payload, opcode=0xA))
|
||||
continue
|
||||
if opcode == 0xA: # pong
|
||||
continue
|
||||
if opcode not in (0x1, 0x2):
|
||||
continue
|
||||
# strip trailing CR/LF like the firmware does
|
||||
text = payload.decode("utf-8", "replace").rstrip("\r\n")
|
||||
if not text:
|
||||
continue
|
||||
kind, data = dispatch(text)
|
||||
push(frame_reply(kind, data))
|
||||
finally:
|
||||
alive["v"] = False
|
||||
try:
|
||||
sock.close()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def handle_client(sock, addr):
|
||||
try:
|
||||
method, path, headers = read_http_request(sock)
|
||||
if method is None:
|
||||
sock.close()
|
||||
return
|
||||
upgrade = headers.get("upgrade", "").lower() == "websocket"
|
||||
# the SPA connects to /term; accept any path for the WS upgrade to be lenient
|
||||
if upgrade:
|
||||
if not do_ws_handshake(sock, headers):
|
||||
return
|
||||
handle_ws(sock)
|
||||
return
|
||||
if method == "GET" and path.split("?")[0] in ("/", "/index.html"):
|
||||
serve_http_index(sock)
|
||||
else:
|
||||
serve_http_404(sock)
|
||||
sock.close()
|
||||
except (OSError, UnicodeDecodeError):
|
||||
try:
|
||||
sock.close()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def main():
|
||||
port = 8791
|
||||
if len(sys.argv) > 1:
|
||||
try:
|
||||
port = int(sys.argv[1])
|
||||
except ValueError:
|
||||
print("usage: serve.py [port]", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
if not os.path.exists(INDEX):
|
||||
print("index.html missing — run: python3 %s/extract.py" % HERE, file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
srv.bind(("127.0.0.1", port))
|
||||
srv.listen(16)
|
||||
url = "http://127.0.0.1:%d/" % port
|
||||
print("wadamesh web-demo serving at %s" % url)
|
||||
print(" (Ctrl-C to stop)")
|
||||
try:
|
||||
while True:
|
||||
client, addr = srv.accept()
|
||||
client.settimeout(30)
|
||||
threading.Thread(target=handle_client, args=(client, addr), daemon=True).start()
|
||||
except KeyboardInterrupt:
|
||||
print("\nstopping.")
|
||||
finally:
|
||||
srv.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,79 @@
|
||||
// Automated screenshots of the wadamesh web control panel demo, to doc-shots/web_*.png.
|
||||
// Start the demo server first: python3 scripts/doc/web-demo/serve.py 8791
|
||||
// Then: NODE_PATH=<playwright node_modules> node scripts/doc/web-demo/shoot.js [port] [outdir]
|
||||
const { chromium } = require('playwright');
|
||||
|
||||
const PORT = process.argv[2] || '8791';
|
||||
const OUT = process.argv[3] || 'doc-shots';
|
||||
const URL = `http://127.0.0.1:${PORT}/`;
|
||||
const path = require('path');
|
||||
|
||||
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
|
||||
|
||||
(async () => {
|
||||
const browser = await chromium.launch();
|
||||
const page = await browser.newPage({
|
||||
viewport: { width: 390, height: 780 },
|
||||
deviceScaleFactor: 2, // crisp @2x for docs
|
||||
});
|
||||
const shot = async (name) => {
|
||||
const p = path.join(OUT, `web_${name}.png`);
|
||||
await page.screenshot({ path: p });
|
||||
console.log(' wrote', p);
|
||||
};
|
||||
|
||||
await page.goto(URL, { waitUntil: 'networkidle' });
|
||||
await page.waitForSelector('#tlist .row', { timeout: 8000 });
|
||||
await sleep(600);
|
||||
|
||||
// 1. Chats tab (default)
|
||||
await shot('chats');
|
||||
|
||||
// 2. An open chat (first thread) with bubbles + delivery meta
|
||||
await page.click('#tlist .row');
|
||||
await page.waitForSelector('#cview', { state: 'visible', timeout: 5000 });
|
||||
await page.waitForSelector('#cvmsgs .b', { timeout: 5000 });
|
||||
await sleep(500);
|
||||
await shot('chat');
|
||||
await page.click('#cvback'); await sleep(300);
|
||||
|
||||
// 3. Contacts tab
|
||||
await page.click('#tabs button[data-t=contacts]');
|
||||
await page.waitForSelector('#clist .row', { timeout: 5000 });
|
||||
await sleep(400);
|
||||
await shot('contacts');
|
||||
|
||||
// 4. Contact action sheet (tap a contact row)
|
||||
await page.click('#clist .row[data-c]');
|
||||
await page.waitForSelector('#sheet.on', { timeout: 5000 });
|
||||
await sleep(400);
|
||||
await shot('contact_sheet');
|
||||
await page.click('#scrim'); await sleep(400);
|
||||
|
||||
// 5. Discovered nodes sheet
|
||||
await page.click('#discrow');
|
||||
await page.waitForSelector('#sheet.on', { timeout: 5000 });
|
||||
await sleep(400);
|
||||
await shot('discovered');
|
||||
await page.click('#scrim'); await sleep(400);
|
||||
|
||||
// 6. Terminal tab with some output
|
||||
await page.click('#tabs button[data-t=term]');
|
||||
await page.waitForSelector('#i', { timeout: 5000 });
|
||||
for (const cmd of ['status', 'ver']) {
|
||||
await page.fill('#i', cmd);
|
||||
await page.press('#i', 'Enter');
|
||||
await sleep(500);
|
||||
}
|
||||
await sleep(400);
|
||||
await shot('terminal');
|
||||
|
||||
// 7. Settings modal (gear)
|
||||
await page.click('#gear');
|
||||
await page.waitForSelector('#modal.on', { timeout: 5000 });
|
||||
await sleep(400);
|
||||
await shot('settings');
|
||||
|
||||
await browser.close();
|
||||
console.log('done.');
|
||||
})().catch(e => { console.error('FAILED:', e); process.exit(1); });
|
||||
@@ -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; }
|
||||
|
||||
@@ -242,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 {
|
||||
|
||||
@@ -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 = 37; // 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)
|
||||
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;
|
||||
@@ -109,6 +109,8 @@ struct __attribute__((packed)) TouchCfg {
|
||||
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;
|
||||
@@ -203,6 +205,8 @@ static void cfgSetDefaults(TouchCfg& c) {
|
||||
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)
|
||||
@@ -272,6 +276,8 @@ static void cfgLoadOrMigrate() {
|
||||
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
|
||||
@@ -773,6 +779,26 @@ bool touchPrefsSetRemoteLandscape(bool on) {
|
||||
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.
|
||||
|
||||
@@ -64,6 +64,8 @@ bool touchPrefsSetRemoteMode(bool on);
|
||||
* 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();
|
||||
@@ -112,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();
|
||||
|
||||
@@ -109,3 +109,129 @@ bool WebMirror::popKey(uint16_t* cp) {
|
||||
_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;
|
||||
}
|
||||
@@ -26,6 +26,7 @@ public:
|
||||
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
|
||||
@@ -72,6 +73,36 @@ public:
|
||||
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;
|
||||
@@ -91,6 +122,20 @@ private:
|
||||
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;
|
||||
@@ -16,7 +16,7 @@
|
||||
#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 8192 // per-client outgoing frame buffer (one WS frame; bands <= ~4 KB)
|
||||
#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/ — the web UI mirror page. It opens a
|
||||
// WebSocket to /mirror, paints framebuffer bands the device streams (RGB565 LE,
|
||||
@@ -42,6 +42,8 @@ static const char WS_HTTP_INFO_PAGE[] =
|
||||
"#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"
|
||||
@@ -57,6 +59,7 @@ static const char WS_HTTP_INFO_PAGE[] =
|
||||
"<div id=s>connecting...</div>\n"
|
||||
"<button id=kb>⌨ Keyboard</button>\n"
|
||||
"<button id=rot>↻ Rotate</button>\n"
|
||||
"<button id=xit>✕ Exit remote</button>\n"
|
||||
"<textarea id=k autocomplete=off autocorrect=off autocapitalize=off spellcheck=false></textarea>\n"
|
||||
"</div>\n"
|
||||
"<script>\n"
|
||||
@@ -64,7 +67,7 @@ static const char WS_HTTP_INFO_PAGE[] =
|
||||
"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');\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"
|
||||
@@ -74,7 +77,7 @@ static const char WS_HTTP_INFO_PAGE[] =
|
||||
" 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;ROT.style.display=(a.length>5&&(a[5]&1))?'':'none';fit();return}\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"
|
||||
@@ -103,6 +106,7 @@ static const char WS_HTTP_INFO_PAGE[] =
|
||||
"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"
|
||||
@@ -123,6 +127,283 @@ static const char WS_HTTP_INFO_PAGE[] =
|
||||
"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>•</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>⚙</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>‹</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>></span><input id=i autocomplete=off autocorrect=off autocapitalize=off spellcheck=false placeholder='type a command - e.g. help'><button id=qcmd title='Commands'>⚡</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({'&':'&','<':'<','>':'>','\"':'"'})[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> </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
|
||||
#define COMP_STATE_LEN1_FOUND 2
|
||||
@@ -208,6 +489,7 @@ WebSocketCompanionServer::WebSocketCompanionServer()
|
||||
_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;
|
||||
@@ -285,6 +567,7 @@ void WebSocketCompanionServer::acceptNewClients() {
|
||||
_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;
|
||||
@@ -352,7 +635,8 @@ bool WebSocketCompanionServer::doHandshake(int idx) {
|
||||
// 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);
|
||||
if (c->is_mirror) c->client.setNoDelay(true); // low latency: many small display frames, no Nagle coalescing
|
||||
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;
|
||||
@@ -360,7 +644,9 @@ bool WebSocketCompanionServer::doHandshake(int idx) {
|
||||
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;
|
||||
@@ -399,7 +685,7 @@ size_t WebSocketCompanionServer::pollRecvFrame(uint8_t dest[], int* client_index
|
||||
}
|
||||
// Mirror clients carry framebuffer/pointer traffic, not companion frames —
|
||||
// serviceMirror() owns their socket. Never feed their bytes to the parser.
|
||||
if (c->is_mirror) continue;
|
||||
if (c->is_mirror || c->is_term) continue;
|
||||
|
||||
WiFiClient* cl = &c->client;
|
||||
while (cl->available()) {
|
||||
@@ -537,7 +823,7 @@ size_t WebSocketCompanionServer::writeToAllClients(const uint8_t src[], size_t l
|
||||
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 && !_clients[i].is_mirror;
|
||||
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++;
|
||||
@@ -550,14 +836,14 @@ 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 && !c->is_mirror;
|
||||
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 && !_clients[i].is_mirror)
|
||||
if (_clients[i].in_use && _clients[i].client.connected() && _clients[i].handshake_done && !_clients[i].is_mirror && !_clients[i].is_term)
|
||||
n++;
|
||||
}
|
||||
return n;
|
||||
@@ -695,6 +981,8 @@ void WebSocketCompanionServer::drainMirrorInput(int idx, WebMirror& m) {
|
||||
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);
|
||||
@@ -708,21 +996,95 @@ void WebSocketCompanionServer::drainMirrorInput(int idx, WebMirror& m) {
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
const int mc = mirrorClientCount();
|
||||
m.noteClients(mc);
|
||||
if (mc == 0) return;
|
||||
|
||||
if (!_mirror_txbuf) {
|
||||
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);
|
||||
if (!_mirror_txbuf) return;
|
||||
}
|
||||
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
|
||||
|
||||
@@ -42,6 +42,7 @@ struct WSClientState {
|
||||
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
|
||||
@@ -99,6 +100,8 @@ private:
|
||||
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).
|
||||
|
||||
@@ -227,28 +227,28 @@ static const lv_btnmatrix_ctrl_t kb_en_upper_ctrl[] = {
|
||||
static const char* const kb_fr_lower[] = {
|
||||
"1#", "a", "z", "e", "r", "t", "y", "u", "i", "o", "p", LV_SYMBOL_BACKSPACE, "\n",
|
||||
"ABC", "q", "s", "d", "f", "g", "h", "j", "k", "l", "m", LV_SYMBOL_NEW_LINE, "\n",
|
||||
"_", "-", "w", "x", "c", "v", "b", "n", ".", ",", ":", "", "\n",
|
||||
"_", "-", "w", "x", "c", "v", "b", "n", ".", ",", ":", "\n",
|
||||
LV_SYMBOL_KEYBOARD, LV_SYMBOL_LEFT, " ", LV_SYMBOL_RIGHT, LV_SYMBOL_OK, ""
|
||||
};
|
||||
|
||||
static const lv_btnmatrix_ctrl_t kb_fr_lower_ctrl[] = {
|
||||
LV_KEYBOARD_CTRL_BTN_FLAGS | 5, EN_KB_BTN(4), EN_KB_BTN(4), EN_KB_BTN(4), EN_KB_BTN(4), EN_KB_BTN(4), EN_KB_BTN(4), EN_KB_BTN(4), EN_KB_BTN(4), EN_KB_BTN(4), EN_KB_BTN(4), LV_BTNMATRIX_CTRL_CHECKED | 7,
|
||||
LV_KEYBOARD_CTRL_BTN_FLAGS | 6, EN_KB_BTN(3), EN_KB_BTN(3), EN_KB_BTN(3), EN_KB_BTN(3), EN_KB_BTN(3), EN_KB_BTN(3), EN_KB_BTN(3), EN_KB_BTN(3), EN_KB_BTN(3), EN_KB_BTN(3), LV_BTNMATRIX_CTRL_CHECKED | 7,
|
||||
LV_BTNMATRIX_CTRL_CHECKED | EN_KB_BTN(1), LV_BTNMATRIX_CTRL_CHECKED | EN_KB_BTN(1), EN_KB_BTN(1), EN_KB_BTN(1), EN_KB_BTN(1), EN_KB_BTN(1), EN_KB_BTN(1), EN_KB_BTN(1), LV_BTNMATRIX_CTRL_CHECKED | EN_KB_BTN(1), LV_BTNMATRIX_CTRL_CHECKED | EN_KB_BTN(1), LV_BTNMATRIX_CTRL_CHECKED | EN_KB_BTN(1), LV_BTNMATRIX_CTRL_HIDDEN,
|
||||
LV_BTNMATRIX_CTRL_CHECKED | EN_KB_BTN(1), LV_BTNMATRIX_CTRL_CHECKED | EN_KB_BTN(1), EN_KB_BTN(1), EN_KB_BTN(1), EN_KB_BTN(1), EN_KB_BTN(1), EN_KB_BTN(1), EN_KB_BTN(1), LV_BTNMATRIX_CTRL_CHECKED | EN_KB_BTN(1), LV_BTNMATRIX_CTRL_CHECKED | EN_KB_BTN(1), LV_BTNMATRIX_CTRL_CHECKED | EN_KB_BTN(1),
|
||||
LV_KEYBOARD_CTRL_BTN_FLAGS | 2, LV_BTNMATRIX_CTRL_CHECKED | 2, 6, LV_BTNMATRIX_CTRL_CHECKED | 2, LV_KEYBOARD_CTRL_BTN_FLAGS | 2
|
||||
};
|
||||
|
||||
static const char* const kb_fr_upper[] = {
|
||||
"1#", "A", "Z", "E", "R", "T", "Y", "U", "I", "O", "P", LV_SYMBOL_BACKSPACE, "\n",
|
||||
"abc", "Q", "S", "D", "F", "G", "H", "J", "K", "L", "M", LV_SYMBOL_NEW_LINE, "\n",
|
||||
"_", "-", "W", "X", "C", "V", "B", "N", ".", ",", ":", "", "\n",
|
||||
"_", "-", "W", "X", "C", "V", "B", "N", ".", ",", ":", "\n",
|
||||
LV_SYMBOL_KEYBOARD, LV_SYMBOL_LEFT, " ", LV_SYMBOL_RIGHT, LV_SYMBOL_OK, ""
|
||||
};
|
||||
|
||||
static const lv_btnmatrix_ctrl_t kb_fr_upper_ctrl[] = {
|
||||
LV_KEYBOARD_CTRL_BTN_FLAGS | 5, EN_KB_BTN(4), EN_KB_BTN(4), EN_KB_BTN(4), EN_KB_BTN(4), EN_KB_BTN(4), EN_KB_BTN(4), EN_KB_BTN(4), EN_KB_BTN(4), EN_KB_BTN(4), EN_KB_BTN(4), LV_BTNMATRIX_CTRL_CHECKED | 7,
|
||||
LV_KEYBOARD_CTRL_BTN_FLAGS | 6, EN_KB_BTN(3), EN_KB_BTN(3), EN_KB_BTN(3), EN_KB_BTN(3), EN_KB_BTN(3), EN_KB_BTN(3), EN_KB_BTN(3), EN_KB_BTN(3), EN_KB_BTN(3), EN_KB_BTN(3), LV_BTNMATRIX_CTRL_CHECKED | 7,
|
||||
LV_BTNMATRIX_CTRL_CHECKED | EN_KB_BTN(1), LV_BTNMATRIX_CTRL_CHECKED | EN_KB_BTN(1), EN_KB_BTN(1), EN_KB_BTN(1), EN_KB_BTN(1), EN_KB_BTN(1), EN_KB_BTN(1), EN_KB_BTN(1), LV_BTNMATRIX_CTRL_CHECKED | EN_KB_BTN(1), LV_BTNMATRIX_CTRL_CHECKED | EN_KB_BTN(1), LV_BTNMATRIX_CTRL_CHECKED | EN_KB_BTN(1), LV_BTNMATRIX_CTRL_HIDDEN,
|
||||
LV_BTNMATRIX_CTRL_CHECKED | EN_KB_BTN(1), LV_BTNMATRIX_CTRL_CHECKED | EN_KB_BTN(1), EN_KB_BTN(1), EN_KB_BTN(1), EN_KB_BTN(1), EN_KB_BTN(1), EN_KB_BTN(1), EN_KB_BTN(1), LV_BTNMATRIX_CTRL_CHECKED | EN_KB_BTN(1), LV_BTNMATRIX_CTRL_CHECKED | EN_KB_BTN(1), LV_BTNMATRIX_CTRL_CHECKED | EN_KB_BTN(1),
|
||||
LV_KEYBOARD_CTRL_BTN_FLAGS | 2, LV_BTNMATRIX_CTRL_CHECKED | 2, 6, LV_BTNMATRIX_CTRL_CHECKED | 2, LV_KEYBOARD_CTRL_BTN_FLAGS | 2
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -145,3 +145,15 @@
|
||||
#else
|
||||
#define CAP_COMPANION 0
|
||||
#endif
|
||||
|
||||
// ---- On-device web browser (the "Web" reader app) ---------------------------
|
||||
// The reader fetches pages over on-device HTTPS, and a TLS handshake needs ~30 KB of
|
||||
// free INTERNAL heap. Only the 8 MB-PSRAM boards (T-Deck, Tanmatsu, ThinkNode M9, RAK
|
||||
// Tap) have that headroom; the 2 MB Heltec V4 TFT can't complete the handshake
|
||||
// (fetch returns -1), so the Web app is gated out there. Tapping a link in chat still
|
||||
// offers "Create QR" everywhere — only "Open in web" is gated to these boards.
|
||||
#if defined(HELTEC_LORA_V4_TFT)
|
||||
#define CAP_WEB_BROWSER 0
|
||||
#else
|
||||
#define CAP_WEB_BROWSER 1
|
||||
#endif
|
||||
@@ -532,6 +532,8 @@ static const uint8_t e_1f4f1[] = {0,0,0,0,0,0,0,0,0,0,0,0,235,90,208,170,82,255,
|
||||
static const lv_img_dsc_t d_1f4f1 = { { LV_IMG_CF_TRUE_COLOR_ALPHA, 0, 0, 16, 16 }, sizeof(e_1f4f1), e_1f4f1 };
|
||||
static const uint8_t e_1f4bb[] = {0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,124,158,10,123,182,212,155,190,237,90,182,235,25,158,237,25,158,237,25,158,237,25,166,237,249,165,237,249,165,237,249,165,234,58,174,237,123,174,190,0,0,0,31,0,1,0,0,0,159,199,17,119,141,241,45,59,255,46,59,255,78,59,255,78,59,255,46,59,255,45,59,255,13,59,255,236,66,255,171,58,255,236,74,255,152,149,217,0,0,0,0,0,0,0,0,0,159,191,20,86,141,240,203,50,254,236,50,253,236,50,254,236,50,254,204,50,254,204,58,254,171,58,254,139,58,254,106,58,252,138,66,254,119,149,217,255,255,2,0,0,0,0,0,0,159,199,21,54,133,242,171,58,255,204,58,254,204,58,255,204,58,255,203,58,255,171,58,255,171,58,255,138,58,255,73,58,253,106,66,255,87,141,220,255,255,3,0,0,0,0,0,0,95,175,25,22,133,246,106,58,255,171,58,254,171,58,255,171,58,255,139,58,255,138,58,255,106,58,255,105,58,255,73,58,253,73,66,255,54,141,225,255,175,6,0,0,0,0,0,0,127,175,27,21,133,247,73,58,255,138,66,254,138,58,255,138,58,255,138,58,255,106,66,255,105,66,255,73,66,255,40,66,253,41,66,255,54,141,227,255,183,7,0,0,0,0,0,0,127,175,31,180,132,250,199,49,255,8,58,254,8,58,255,8,58,255,8,58,255,232,57,255,231,57,255,199,57,255,199,57,254,199,57,255,245,132,230,255,207,10,0,0,0,0,0,0,93,142,34,22,133,252,78,99,255,142,99,254,142,99,255,142,99,255,142,99,255,110,99,255,110,99,255,110,99,255,110,99,254,142,99,255,87,141,233,95,143,13,0,0,0,255,255,1,119,125,28,155,182,245,220,190,254,188,182,254,188,182,255,188,182,255,188,182,255,188,182,255,188,182,255,188,182,255,188,182,253,253,190,255,123,182,221,140,1,5,255,255,1,0,0,0,61,215,134,158,239,255,187,198,251,219,206,255,219,206,255,219,206,255,219,206,255,219,206,255,219,206,255,219,206,255,187,206,255,155,198,252,158,239,252,29,215,83,0,0,0,58,150,32,93,231,243,28,215,255,220,206,254,252,214,255,220,206,255,155,190,255,187,198,255,187,198,255,187,190,255,220,206,255,252,214,255,219,206,253,93,231,255,61,223,217,150,36,7,62,223,173,255,255,255,158,247,253,190,255,255,190,255,255,61,215,255,253,198,255,253,206,255,253,206,255,253,198,255,61,215,255,190,255,255,190,255,255,158,247,253,255,255,255,61,215,133,119,133,246,119,133,254,119,133,255,119,133,255,119,133,255,119,133,255,119,133,255,119,133,255,119,133,255,119,133,255,119,133,255,119,133,255,119,133,255,119,133,253,151,133,254,119,125,224};
|
||||
static const lv_img_dsc_t d_1f4bb = { { LV_IMG_CF_TRUE_COLOR_ALPHA, 0, 0, 16, 16 }, sizeof(e_1f4bb), e_1f4bb };
|
||||
static const uint8_t e_1f310[] = {0,0,0,255,7,1,0,0,0,0,0,0,190,5,58,223,5,151,159,5,232,127,5,249,127,5,248,159,5,234,223,5,163,191,5,75,0,0,0,0,0,0,255,3,2,0,0,0,255,7,1,0,0,0,189,5,14,191,5,164,191,5,251,63,6,255,223,12,161,95,5,158,126,5,176,223,12,160,63,6,255,191,5,255,191,5,188,190,5,28,0,0,0,255,3,2,255,7,2,189,5,14,158,5,200,127,5,209,126,5,244,223,12,112,0,0,0,190,5,111,190,5,138,0,0,0,255,12,106,126,5,244,95,13,199,159,5,215,190,5,25,255,3,2,0,0,0,158,5,172,31,6,255,158,5,199,191,5,255,158,5,171,158,5,180,158,5,219,158,5,226,158,5,180,158,5,170,191,5,255,158,5,191,223,5,255,158,5,186,0,0,0,158,5,73,158,5,239,31,13,169,191,5,255,31,13,172,255,12,179,255,12,178,95,5,216,95,5,224,255,12,178,255,12,179,31,13,174,223,5,255,31,13,171,159,5,234,158,5,74,158,5,193,158,5,120,158,5,47,158,5,227,0,0,0,0,0,0,0,0,0,158,5,118,158,5,145,0,0,0,0,0,0,0,0,0,158,5,230,158,5,42,159,5,114,158,5,189,158,5,233,190,5,21,190,5,106,159,5,189,0,0,0,0,0,0,0,0,0,158,5,121,158,5,147,0,0,0,0,0,0,0,0,0,158,5,192,190,5,106,31,6,17,158,5,227,158,5,241,159,5,206,158,5,230,158,5,239,126,5,203,158,5,205,126,5,202,158,5,229,158,5,233,126,5,195,158,5,197,158,5,194,158,5,237,158,5,227,159,5,197,158,5,240,158,5,240,255,12,157,95,5,194,126,5,230,223,12,151,255,12,153,191,12,151,95,5,202,95,5,213,223,12,152,255,12,160,223,12,159,126,5,229,94,5,208,255,12,166,158,5,237,158,5,226,62,6,26,190,5,64,158,5,207,0,0,0,0,0,0,0,0,0,158,5,117,158,5,144,0,0,0,0,0,0,0,0,0,158,5,198,190,5,92,254,5,27,158,5,219,158,5,166,158,5,146,29,5,14,158,5,234,127,5,19,0,0,0,0,0,0,158,5,124,158,5,149,0,0,0,0,0,0,191,5,7,158,5,236,126,5,41,190,5,144,158,5,159,95,5,42,158,5,240,191,5,197,191,5,255,158,5,215,126,5,218,127,5,219,158,5,236,158,5,240,126,5,219,127,5,219,158,5,210,223,5,255,191,5,203,158,5,239,63,5,41,0,0,0,94,5,138,223,5,255,63,5,161,126,5,252,223,12,134,191,20,131,95,5,196,127,5,207,191,20,133,159,20,126,126,5,250,94,5,178,159,5,255,31,13,151,0,0,0,95,5,3,31,80,3,63,13,182,191,5,208,190,5,230,222,5,137,0,0,0,190,5,109,158,5,134,0,0,0,222,5,102,190,5,237,191,5,208,31,13,198,191,58,9,95,5,3,255,7,1,255,7,1,31,96,5,255,12,156,127,5,251,95,6,255,158,5,176,190,5,167,190,5,170,190,5,151,63,6,255,127,5,255,255,12,171,223,34,14,0,0,0,255,7,1,0,0,0,31,0,1,0,0,0,0,0,0,95,28,58,255,20,156,63,13,232,63,13,251,63,13,249,63,13,237,255,12,165,95,28,69,0,0,0,0,0,0,255,7,1,0,0,0};
|
||||
static const lv_img_dsc_t d_1f310 = { { LV_IMG_CF_TRUE_COLOR_ALPHA, 0, 0, 16, 16 }, sizeof(e_1f310), e_1f310 };
|
||||
static const uint8_t e_1f4c5[] = {39,250,226,6,242,176,6,242,170,39,250,242,230,241,238,230,241,241,39,242,241,6,242,241,197,241,241,39,242,241,38,242,238,39,250,242,38,242,165,6,242,180,38,250,225,0,0,0,71,250,255,6,242,80,6,242,73,39,250,255,136,242,255,234,250,255,165,241,255,39,242,255,140,251,255,197,241,255,229,241,255,71,250,255,6,242,69,6,242,83,71,250,255,0,0,0,39,250,254,6,242,145,71,242,140,165,249,255,43,243,253,182,253,254,75,243,254,15,244,254,247,253,254,238,243,254,238,243,253,71,250,255,6,242,138,6,242,147,39,250,254,0,0,0,6,242,255,71,250,255,38,250,255,169,242,255,47,252,255,56,254,255,117,253,255,23,254,255,84,253,255,88,254,255,116,253,255,165,241,255,71,250,255,39,250,255,6,242,249,0,0,0,39,242,255,39,242,252,230,241,252,43,243,255,84,253,255,75,243,255,242,252,255,145,252,255,108,243,255,149,253,255,173,243,255,197,241,255,39,242,252,39,242,252,39,242,248,0,0,0,197,241,255,197,241,255,197,241,255,164,241,255,100,241,255,132,241,255,132,241,255,100,241,255,165,241,255,108,243,255,197,241,255,197,241,255,197,241,255,197,241,255,197,241,248,0,0,0,149,229,250,88,254,255,56,254,255,56,246,255,56,246,255,88,254,255,56,254,255,121,254,255,121,254,255,56,254,255,121,254,255,88,254,255,56,246,255,88,254,255,150,229,248,0,0,0,93,231,248,255,255,255,255,255,255,255,255,255,255,255,255,223,247,255,255,255,255,126,231,255,28,215,255,60,223,255,60,223,255,158,231,255,255,255,255,255,255,255,93,231,248,0,0,0,28,231,248,223,255,255,223,255,255,24,198,255,203,90,255,36,33,255,158,247,255,142,115,255,195,24,255,36,33,255,65,8,255,12,99,255,255,255,255,223,255,255,28,231,248,0,0,0,28,231,248,255,255,255,255,255,255,239,123,255,231,57,255,97,8,255,93,239,255,190,247,255,125,239,255,60,231,255,130,16,255,117,173,255,255,255,255,223,255,255,28,231,248,0,0,0,28,231,248,255,255,255,190,247,255,255,255,255,121,206,255,65,8,255,93,239,255,255,255,255,255,255,255,178,148,255,227,24,255,255,255,255,223,255,255,255,255,255,28,231,248,0,0,0,28,231,248,255,255,255,190,247,255,255,255,255,215,189,255,65,8,255,93,239,255,255,255,255,223,255,255,227,24,255,48,132,255,255,255,255,190,247,255,255,255,255,28,231,248,0,0,0,28,231,248,255,255,255,190,247,255,255,255,255,247,189,255,65,8,255,60,231,255,255,255,255,117,173,255,32,0,255,125,239,255,255,255,255,223,255,255,255,255,255,28,231,248,0,0,0,28,231,247,255,255,254,190,247,254,255,255,254,247,189,254,32,0,254,60,231,254,255,255,254,101,41,254,235,90,254,255,255,254,190,247,254,223,255,254,255,255,254,28,231,247,0,0,0,28,231,255,255,255,255,255,255,255,255,255,255,190,247,255,219,222,255,255,255,255,255,255,255,186,214,255,190,247,255,255,255,255,255,255,255,255,255,255,255,255,255,28,231,255,0,0,0,154,214,197,28,231,235,251,222,233,251,222,234,28,231,234,93,239,234,251,222,234,251,222,234,93,239,234,28,231,234,251,222,234,251,222,234,251,222,233,28,231,235,154,214,195,0,0,0};
|
||||
static const lv_img_dsc_t d_1f4c5 = { { LV_IMG_CF_TRUE_COLOR_ALPHA, 0, 0, 16, 16 }, sizeof(e_1f4c5), e_1f4c5 };
|
||||
static const uint8_t e_1f4e1[] = {123,158,139,187,198,249,155,198,248,122,190,206,25,158,123,117,68,29,0,0,0,0,0,0,247,125,4,255,123,2,0,0,0,249,141,107,152,133,56,0,0,0,255,127,2,0,0,0,188,182,240,92,239,255,28,231,255,92,239,255,61,231,255,155,198,234,249,157,132,52,44,17,0,0,0,0,0,0,249,141,77,217,141,253,119,133,53,0,0,0,255,127,2,0,0,0,123,158,226,60,231,255,251,222,254,251,222,253,28,231,252,92,239,255,158,247,255,90,182,225,55,117,107,120,133,90,245,100,206,54,117,114,0,0,0,85,85,3,0,0,0,0,0,0,217,133,181,29,199,254,28,231,252,28,231,255,28,231,255,219,214,254,122,198,251,155,190,255,94,223,255,22,117,186,177,43,103,26,166,57,85,85,3,85,85,3,0,0,0,0,0,0,55,125,105,91,150,255,252,214,253,187,214,255,122,190,255,122,190,255,187,206,255,28,231,253,154,198,251,90,174,255,155,190,217,84,76,52,0,0,0,247,125,4,0,0,0,0,0,0,55,133,23,119,133,237,91,150,254,154,190,254,219,222,255,28,231,255,28,231,255,28,231,255,216,165,255,60,231,251,191,239,255,58,166,200,243,59,18,0,0,0,239,123,2,0,0,0,0,0,0,119,133,146,250,141,255,123,158,251,28,223,255,27,231,255,28,223,255,252,222,255,28,231,255,28,231,255,187,198,251,255,255,255,187,190,195,41,1,7,255,255,3,0,0,0,255,255,1,152,133,27,152,133,243,152,133,253,58,150,254,28,207,255,28,231,255,28,231,255,28,223,255,28,231,255,122,190,255,190,247,250,255,255,255,58,166,149,0,0,0,0,0,0,85,173,3,0,0,0,120,133,102,250,141,255,120,133,252,249,133,255,220,190,255,28,231,255,27,231,255,28,231,255,90,190,255,125,239,255,255,255,252,158,231,255,120,133,69,0,0,0,0,0,0,95,173,3,0,0,0,152,133,145,249,125,255,22,109,253,217,141,252,155,166,255,28,215,255,28,231,255,90,190,255,93,239,255,223,255,253,255,255,254,187,190,191,0,0,0,0,0,0,255,127,2,255,7,1,155,142,40,123,142,255,22,101,254,213,92,255,54,117,255,217,125,255,123,166,255,122,190,252,223,255,252,255,255,251,255,255,255,220,198,245,0,0,0,0,0,0,95,173,3,255,127,2,123,150,72,62,167,255,54,101,239,111,35,133,242,59,248,177,51,254,18,52,255,181,84,255,184,141,255,155,182,255,123,182,252,184,141,137,0,0,0,0,0,0,121,158,5,0,0,0,91,142,79,62,167,255,87,109,248,242,67,116,18,60,243,83,68,254,148,92,79,14,11,69,79,27,90,145,35,78,15,19,41,0,0,0,0,0,0,255,255,1,0,0,0,57,142,27,188,150,167,253,166,254,152,117,253,176,35,255,144,35,254,115,76,254,217,149,136,255,255,4,0,0,0,0,0,0,0,0,0,255,255,1,0,0,0,255,7,1,123,150,57,221,158,250,30,159,255,188,158,255,26,134,255,148,92,254,22,109,255,87,125,255,26,142,255,217,133,245,26,142,46,247,59,4,247,125,4,0,0,0,0,0,0,31,0,1,156,150,34,221,158,145,156,150,205,156,150,239,156,150,254,123,150,255,58,150,253,58,142,238,91,150,200,156,150,137,124,150,26,255,7,1,255,255,1,0,0,0,0,0,0};
|
||||
@@ -588,6 +590,7 @@ static const EmojiGlyph kGlyphs[] = {
|
||||
{ 0x1F1F8u, &d_zero },
|
||||
{ 0x1F308u, &d_1f308 },
|
||||
{ 0x1F30Au, &d_1f30a },
|
||||
{ 0x1F310u, &d_1f310 },
|
||||
{ 0x1F319u, &d_1f319 },
|
||||
{ 0x1F31Bu, &d_1f31b },
|
||||
{ 0x1F31Eu, &d_1f31e },
|
||||
@@ -828,7 +831,7 @@ static const EmojiGlyph kGlyphs[] = {
|
||||
{ 0x1F9D0u, &d_1f9d0 },
|
||||
{ 0x1F9E1u, &d_1f9e1 },
|
||||
};
|
||||
const uint16_t kEmojiGlyphCount = 271;
|
||||
const uint16_t kEmojiGlyphCount = 272;
|
||||
|
||||
const lv_img_dsc_t* emojiGlyphLookup(uint32_t cp) {
|
||||
int lo = 0, hi = (int)(sizeof(kGlyphs)/sizeof(kGlyphs[0])) - 1;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// AUTO-GENERATED by scripts/build/gen-emoji-font.py — do not edit.
|
||||
// 271 Noto colour emoji baked to RGB565+alpha at 16px.
|
||||
// 272 Noto colour emoji baked to RGB565+alpha at 16px.
|
||||
#pragma once
|
||||
#include "lvgl.h"
|
||||
#ifdef __cplusplus
|
||||
|
||||