Compare commits

...
1 Commits
Author SHA1 Message Date
Kaj SchittecatandClaude Opus 4.8 4df391a283 touch: beta_42 - REMOTE fits the Heltec V4 + keyboard-less exit
Remote mode showed a black screen on 2 MB-PSRAM boards (Heltec V4): the
mirror ring/shadow were too big to allocate. Size the ring to one frame
and drop the remote render to 400x240 so the whole footprint is smaller
than VNC's (which already worked). Add a touch-and-hold (3 s) exit for
keyboard-less touch boards and an Exit button on the web page.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 23:34:30 +02:00
4 changed files with 82 additions and 12 deletions

No files matched your search

+9
View File
@@ -0,0 +1,9 @@
# beta_42 (TEST) - fixes for the beta_41 REMOTE mode: it works on the Heltec V4 now, and keyboard-less boards can leave it.
# One user-facing note per non-blank, non-# line; # lines are section comments.
# --- Fixed ---
REMOTE mode now works on the Heltec V4 TFT (and other 2 MB-PSRAM boards) instead of showing a black screen in the browser. Its off-screen renderer needed more free memory than those boards had, so nothing was streamed; the buffers are sized to fit now, and the render is a bit smaller and faster as a result. VNC was never affected and already worked.
Keyboard-less touch boards (Heltec V4, RAK WisMesh Tap) can leave REMOTE mode again: touch and hold the device screen for about 3 seconds. Those boards have no SPACE key, which used to be the only way out on the device.
# --- New ---
The web page has an "Exit remote" button (with a confirmation) that leaves REMOTE mode from any browser, on any board.
+7
View File
@@ -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,11 @@ 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; }
private:
uint8_t* _ring = nullptr;
size_t _cap = 0;
@@ -91,6 +97,7 @@ 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
};
extern WebMirror g_web_mirror;
@@ -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>&#9000; Keyboard</button>\n"
"<button id=rot>&#8635; Rotate</button>\n"
"<button id=xit>&#10005; Exit remote</button>\n"
"<textarea id=k autocomplete=off autocorrect=off autocapitalize=off spellcheck=false></textarea>\n"
"</div>\n"
"<script>\n"
@@ -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"
@@ -695,6 +699,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);
+58 -10
View File
@@ -2162,7 +2162,15 @@ static void drawRemotePlaceholder(bool exit_armed = false) {
display.drawTextCentered(W / 2, y, "Connecting to Wi-Fi...");
}
display.setColor(exit_armed ? DisplayDriver::GREEN : DisplayDriver::LIGHT);
display.drawTextCentered(W / 2, H - 16, exit_armed ? "Press SPACE again to exit" : "Press SPACE twice to exit");
#if CAP_KEYBOARD
display.drawTextCentered(W / 2, H - 30, exit_armed ? "Press SPACE again to exit" : "Press SPACE twice to exit");
#elif CAP_TOUCH
display.drawTextCentered(W / 2, H - 30, exit_armed ? "Keep holding to exit..." : "Touch and hold to exit");
#else
display.drawTextCentered(W / 2, H - 30, "Open the web page to exit");
#endif
display.setColor(DisplayDriver::LIGHT);
display.drawTextCentered(W / 2, H - 14, "or tap Exit in the browser");
display.endFrame();
s_remote_ph_ip = up ? (uint32_t)WiFi.localIP() : 0;
}
@@ -2180,6 +2188,29 @@ static void remotePhysicalKey(int key) {
drawRemotePlaceholder(true);
}
}
#if CAP_TOUCH
// Keyboard-less touch boards (Heltec V4, RAK Tap) have no SPACE key to exit remote mode,
// so a 3 s touch-and-hold on the physical panel leaves it. The touch poll runs even in
// remote mode (the LVGL indev is skipped but the driver still polls), so the live-touch
// state is available here. Called each loop from the remote block.
static uint32_t s_remote_touch_start = 0;
static void remoteTouchTick() {
uint16_t tx, ty;
const bool pressed = heltecV4CapTouchGetLive(&tx, &ty);
const uint32_t nowm = millis();
if (pressed) {
if (!s_remote_touch_start) { s_remote_touch_start = nowm; drawRemotePlaceholder(true); } // "Keep holding..."
else if (nowm - s_remote_touch_start >= 3000) { // held long enough -> leave
touchPrefsSetRemoteMode(false);
if (g_lv.task) g_lv.task->rebootDevice();
}
} else if (s_remote_touch_start) {
s_remote_touch_start = 0;
drawRemotePlaceholder(false); // lifted before 3 s -> revert the hint
}
}
#endif
#endif
#if !defined(HAS_TANMATSU)
@@ -38277,8 +38308,12 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* no
// scales with pixel count, so fewer pixels = proportionally faster — ~44% less than
// 640x384. The browser bilinear-upscales the canvas (near-free on the GPU) at the cost of
// some softness. Orientation toggles live via the Rotate button ([0x04] + s_remote_landscape).
g_lv.disp_drv.hor_res = s_remote_mode ? (s_remote_landscape ? 480 : 288) : (ui_landscape ? 320 : 240);
g_lv.disp_drv.ver_res = s_remote_mode ? (s_remote_landscape ? 288 : 480) : (ui_landscape ? 240 : 320);
// 400x240 (was 480x288): keeps the whole mirror footprint (~514 KB) BELOW the VNC
// footprint that already allocates on the 2 MB-PSRAM Heltec V4 (~616 KB), so remote is
// guaranteed to fit anywhere VNC does instead of black-screening on a failed alloc. Also
// faster (fewer pixels). Upscaled in the browser.
g_lv.disp_drv.hor_res = s_remote_mode ? (s_remote_landscape ? 400 : 240) : (ui_landscape ? 320 : 240);
g_lv.disp_drv.ver_res = s_remote_mode ? (s_remote_landscape ? 240 : 400) : (ui_landscape ? 240 : 320);
#endif
g_lv.disp_drv.flush_cb = lvglFlush;
g_lv.disp_drv.draw_buf = &g_lv.draw_buf;
@@ -38361,15 +38396,22 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* no
g_lv.indev_drv.disp = lv_disp_get_default();
if (!lv_indev_drv_register(&g_lv.indev_drv)) pushDiagLine("LVGL indev failed");
}
// Web UI mirror: stream this display + accept a phone browser's taps as a
// second pointer indev (opt-in via the VNC/REMOTE apps; see WebMirror / the WS
// server). Remote mode always streams + wants a bigger ring for the 480x800 frame.
g_web_mirror.begin(s_remote_mode ? (size_t)(1024 * 1024) : (size_t)0);
g_web_mirror.setScreenSize(lv_disp_get_hor_res(NULL), lv_disp_get_ver_res(NULL));
g_web_mirror.setEnabled(s_remote_mode ? true : touchPrefsGetWebMirror());
g_web_mirror.setRemote(s_remote_mode); // browser shows the Rotate button only in remote mode
// Web UI mirror: stream this display + accept a phone browser's taps as a second
// pointer indev (opt-in via the VNC/REMOTE apps; see WebMirror / the WS server).
// Size the remote ring to just above ONE frame (w*h*2 + 48 KB) instead of a fixed
// 1 MB: the old 1 MB ring failed to allocate on the 2 MB-PSRAM Heltec V4 (VNC's
// smaller default ring fit), leaving active()==false and a black browser. The ring
// only ever holds one dirty region at a time, so frame-sized is also functionally
// enough. begin() shrink-retries if even this is tight, so remote streams as long
// as one frame fits in PSRAM.
s_web_fb_w = lv_disp_get_hor_res(NULL); // shadow-buffer dimensions for the mirror
s_web_fb_h = lv_disp_get_ver_res(NULL);
const size_t rmt_frame = (size_t)s_web_fb_w * s_web_fb_h * 2;
g_web_mirror.begin(s_remote_mode ? (rmt_frame + 48u * 1024u) : (size_t)0);
g_web_mirror.setScreenSize(s_web_fb_w, s_web_fb_h);
g_web_mirror.setEnabled(s_remote_mode ? true : touchPrefsGetWebMirror());
g_web_mirror.setRemote(s_remote_mode); // browser shows the Rotate button only in remote mode
if (s_remote_mode) webMirrorEnsureBufs(); // pre-allocate the mirror shadow/band buffers up front
lv_indev_drv_init(&s_web_indev_drv);
s_web_indev_drv.type = LV_INDEV_TYPE_POINTER;
s_web_indev_drv.read_cb = webPointerRead;
@@ -39647,6 +39689,12 @@ void UITask::loop() {
}
}
if (s_remote_exit_armed && (now - s_remote_exit_armed) >= 3000) { s_remote_exit_armed = 0; drawRemotePlaceholder(false); }
// Exit button on the web page -> leave remote mode (board-agnostic; the main way out
// for keyboard-less boards like the Heltec V4).
if (g_web_mirror.takeExit()) { touchPrefsSetRemoteMode(false); if (g_lv.task) g_lv.task->rebootDevice(); }
#if CAP_TOUCH
remoteTouchTick(); // 3 s touch-and-hold on the panel also leaves remote (no SPACE key)
#endif
uint32_t rip = (WiFi.status() == WL_CONNECTED) ? (uint32_t)WiFi.localIP() : 0;
if (rip != s_remote_ph_ip) drawRemotePlaceholder();
}