Skip to content

Commit 75fd95f

Browse files
committed
feat(snapmaker): U1 camera on the stock firmware (refreshed 1080p stills); printer side card scrolls as one with no visible bar
- No webcam declared by Moonraker (stock firmware) → wake the printer's own monitor (camera.start_monitor every 10 s over the existing socket) and show /server/files/camera/monitor.jpg at ~1 fps; Paxx keeps the WebRTC player; the loop runs only while a feed is on screen (12 s grace) - Printer side card: a single scroller (camera + body), scrollbar hidden — the body no longer scrolls on its own and the Snapmaker camera scrolls with the card - PROTOCOL.md §11.4: stock-firmware camera method, verified on a U1 2.0.0 (works outside LAN-only mode)
1 parent fb00284 commit 75fd95f

7 files changed

Lines changed: 212 additions & 24 deletions

File tree

‎WORKLOG.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,13 @@
1414
- Verified on real chips (TigerPOD, 2 readers): factory 0x12 + single 0x11 of the same product → stored 1/2 and 1/1, NOT paired, missing-twin banner on the 1/2; weight write 1000→950→1000 g touched only page 0x17, byte +39 and timestamp unchanged
1515

1616
## Changed
17+
- Snapmaker U1 camera now works on the stock firmware, not only on Paxx: when Moonraker declares no webcam, Studio wakes the printer's own monitor (`camera.start_monitor` every 10 s over the existing Moonraker socket) and shows its 1080p JPEG refreshed ~1 fps (Blob URLs, swapped only once downloaded); Paxx keeps the WebRTC player; the loop runs only while a feed is on screen (12 s grace) — `renderer/printers/snapmaker/widget_camera.js`, `renderer/printers/snapmaker/index.js`, `renderer/inventory.js`, `renderer/css/50-snapmaker.css`, `renderer/printers/snapmaker/PROTOCOL.md`
1718
- TigerScale photo is now the V3 hardware render (README, scale card, empty state) — contributed in PR #34; shipped copy trimmed + resized to 512² and palettised (1.38 MB → 78 KB), full-res master archived (committed c933d61) — `assets/img/TigerScale_V3.png`, `assets-src/img/TigerScale_V3.png`, `assets-src/README.md`, `renderer/IoT/tigerscale/index.js`, `README.md`
1819

1920
## Fixed
21+
- Printer side card: the whole card scrolls as one (camera included) with no visible scrollbar — the body had become a second scroller (generic `.panel-body` overflow), so the Snapmaker camera, kept outside it to survive re-renders, stayed pinned at the top — `renderer/css/40-printers.css`
2022
- Deleting products (bulk "Delete" in Favorites) was always refused with "permission denied" since the tier-gated attachments rule (v2.12.0): the `products` rule read `request.resource.data` in a single `allow write`, which errors on a delete — split into `allow create, update` (attachment cap kept) + `allow delete: if isOwner()` — backend `firestore.rules` (deployed + committed 4aa71b8)
2123
- Security: `js-yaml` 4.3.1 → 4.3.2 (CVE-2026-84375 / GHSA-2883-xcg3-v3hh, YAML merge-key DoS), transitive via `electron-updater` (parses `latest*.yml`) — `package-lock.json` (already committed in 22b3f3c; internal, not for release note / What's New)
22-
2324
- Toggling ★/❤ on a catalogue product no longer flickers the catalogue grid: a render that lands on the same result list patches the cards' product badges instead of rebuilding every card (which re-inserted the photos 2-3× per click and dropped the chunks scrolled into view); the product card keeps its photo node across re-renders — `renderer/inventory.js` (other views still to audit)
2425

2526
## Removed

‎renderer/css/40-printers.css‎

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2299,12 +2299,21 @@
22992299
overflow-y: auto;
23002300
display: flex;
23012301
flex-direction: column;
2302+
/* Scrolls without a visible bar — same as the material card (#panelBody). */
2303+
scrollbar-width: none;
23022304
}
2303-
#printerPanelBody {
2305+
.pp-scroll::-webkit-scrollbar { width: 0; height: 0; display: none; }
2306+
/* The body must NOT scroll on its own: the generic `.panel-body` rule
2307+
(70-detail-misc.css, loaded later) gives it `overflow-y: auto` + `flex: 1`,
2308+
which turned it into a second scroller — so the Snapmaker camera, which sits
2309+
above it in #ppPersistentCam, stayed pinned while the rest scrolled under it.
2310+
Doubled id selector to outrank that rule without !important. It still grows
2311+
to fill a short panel, but never shrinks below its content. */
2312+
#printerPanelBody#printerPanelBody {
23042313
padding: 0 0 24px;
23052314
display: flex; flex-direction: column;
2306-
flex: 1;
2307-
/* overflow handled by .pp-scroll */
2315+
flex: 1 0 auto;
2316+
overflow: visible;
23082317
}
23092318

23102319
/* Hero — printer photo (or fallback when camera is off). The

‎renderer/css/50-snapmaker.css‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -592,6 +592,10 @@
592592
border-radius: 0;
593593
}
594594

595+
/* Snapmaker on stock firmware — refreshed 1080p stills in an <img> sharing
596+
the iframe's box (.snap-camera-frame); cover so the 16:9 frame fills it. */
597+
.snap-camera-still { object-fit: cover; }
598+
595599
/* FlashForge — raw MJPEG stream served as <img>.
596600
object-fit:cover fills the 16:9 box and crops the stream edges if
597601
the camera aspect ratio differs. Self-contained: no dependency on

‎renderer/inventory.js‎

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -27133,27 +27133,31 @@ import { elgFanStep } from './printers/elegoo/widget_control.js';
2713327133
// WebRTC session continues uninterrupted across panel re-renders / opens.
2713427134
const _snapCamConn = (p.brand === "snapmaker") ? snapGetConn(snapKey(p)) : null;
2713527135
const _snapCamIp = (_snapCamConn?.status === "connected" && _snapCamConn?.ip) || null;
27136+
// IP + camera mode (WebRTC on Paxx, refreshed stills on stock firmware, see
27137+
// printers/snapmaker/widget_camera.js): the mode is learnt just after the
27138+
// socket opens, so it has to be able to rebuild the feed on its own.
27139+
const _snapCamSig = _snapCamIp ? `${_snapCamIp}|${_snapCamConn?.camMode || ""}` : null;
2713627140
const _persistEl = $("ppPersistentCam");
2713727141
if (_persistEl) {
2713827142
if (p.brand === "snapmaker") {
27139-
const _prevIp = _persistEl.dataset.snapIp || "";
27140-
if (_snapCamIp && _snapCamIp !== _prevIp) {
27141-
// New connection or IP changed — build a fresh iframe.
27142-
_persistEl.dataset.snapIp = _snapCamIp;
27143+
const _prevSig = _persistEl.dataset.snapCamSig || "";
27144+
if (_snapCamSig && _snapCamSig !== _prevSig) {
27145+
// New connection, IP or camera mode — build a fresh feed.
27146+
_persistEl.dataset.snapCamSig = _snapCamSig;
2714327147
_persistEl.innerHTML = renderSnapCamBanner(p);
27144-
} else if (!_snapCamIp && _prevIp) {
27148+
} else if (!_snapCamSig && _prevSig) {
2714527149
// Went offline — clear the camera.
27146-
delete _persistEl.dataset.snapIp;
27150+
delete _persistEl.dataset.snapCamSig;
2714727151
_persistEl.innerHTML = "";
2714827152
}
27149-
// Same IP → leave #ppPersistentCam entirely alone (WebRTC keeps running).
27153+
// Same IP + mode → leave #ppPersistentCam entirely alone (the feed keeps running).
2715027154
} else {
2715127155
// Different brand — clear any residual Snapmaker camera.
2715227156
_persistEl.innerHTML = "";
27153-
delete _persistEl.dataset.snapIp;
27157+
delete _persistEl.dataset.snapCamSig;
2715427158
}
2715527159
}
27156-
const _snapCamVisible = p.brand === "snapmaker" && !!_snapCamIp;
27160+
const _snapCamVisible = p.brand === "snapmaker" && !!_snapCamIp && _snapCamConn?.camMode !== "none";
2715727161

2715827162
// Camera banner (non-Snapmaker brands) — delegated to per-brand widget_camera.js.
2715927163
// Snapmaker is handled above via #ppPersistentCam, so it returns "" here.

‎renderer/printers/snapmaker/PROTOCOL.md‎

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -928,6 +928,39 @@ function createCameraView(parentWindow, ip) {
928928
}
929929
```
930930
931+
### 11.4 Stock firmware (no Paxx) — refreshed stills, no stream
932+
933+
The WebRTC page above exists only on the Paxx extended firmware. On the **stock
934+
firmware** (verified on a U1 running 2.0.0) nginx still proxies `/webcam/`, but
935+
nothing runs behind it (**502**), and `server.webcams.list` returns `[]`.
936+
937+
The camera is still reachable, through the printer's own service (unisrv):
938+
939+
1. **Wake it** over the Moonraker WebSocket (port 7125):
940+
```json
941+
{"jsonrpc":"2.0","id":9011,"method":"camera.start_monitor","params":{"domain":"lan","interval":0}}
942+
```
943+
→ `{"result":{"state":"success","url":"/files/camera/monitor.jpg"}}`
944+
2. **Read the frame**: `GET http://<IP>:7125/server/files/camera/monitor.jpg`
945+
— 1920×1080 JPEG, ~100 KB, refreshed about once a second.
946+
3. **Re-send the wake every ~10 s.** Without it the camera goes idle after a few
947+
seconds and the JPEG stops changing.
948+
949+
No SSH, no file installed, nothing else written on the printer — the same call
950+
the Snapmaker app and SimplyPrint use. It is ~1 fps, not video.
951+
952+
`"domain":"lan"` does NOT require the printer's LAN-only mode: confirmed on a U1
953+
left in its normal (cloud) mode — control over the IP and this camera both work.
954+
955+
**Detection used by Studio** (`camMode` in `index.js`): `server.webcams.list`
956+
non-empty (or an error) → WebRTC player; empty → stills. A `camera.start_monitor`
957+
error → no camera block. The still loop (`widget_camera.js`) runs only while a
958+
feed is on screen and stops 12 s after the last one leaves, so the wake-ups stop
959+
and the camera returns to idle on its own.
960+
961+
The `camera` file root also holds the print timelapses (`<job>_<time>.mp4` +
962+
`.jpg` cover), readable at `/server/files/camera/<name>`.
963+
931964
---
932965
933966
## 12. Commandes de contrôle

‎renderer/printers/snapmaker/index.js‎

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,14 @@ export function snapConnect(printer) {
175175
// null = unknown (no sensor found), true = loaded, false = not loaded.
176176
filamentLoaded: [null, null, null, null],
177177
},
178+
// Which camera this firmware offers (see snapWakeStockCamera):
179+
// null — not known yet (asked on every socket open)
180+
// "webrtc" — Moonraker declares a webcam (Paxx extended firmware): the
181+
// live WebRTC player page at /webcam/webrtc
182+
// "still" — stock firmware: no stream, but `camera.start_monitor` makes
183+
// unisrv refresh /server/files/camera/monitor.jpg (~1 fps)
184+
// "none" — neither answered: no camera block at all
185+
camMode: null,
178186
_filSensorNames: null, // sorted filament_switch_sensor names discovered via objects.list
179187
_ctrlStep: 10, // jog step size in mm, persisted per-conn
180188
};
@@ -250,6 +258,10 @@ function snapOpenSocket(conn) {
250258
// Discover all available Klipper objects so we can find filament sensors.
251259
// Response handled below (id === 9001).
252260
sendLogged({ jsonrpc: "2.0", id: 9001, method: "printer.objects.list", params: {} });
261+
// Which camera does this firmware offer? Paxx registers its stream as a
262+
// Moonraker webcam; the stock firmware registers none (verified on a U1 on
263+
// stock 2.0.0). Response handled below (id === 9010).
264+
sendLogged({ jsonrpc: "2.0", id: 9010, method: "server.webcams.list", params: {} });
253265

254266
// Still "connecting" at this point — the badge/grid only flip to online
255267
// when the first real frame arrives in the message handler below.
@@ -311,6 +323,19 @@ function snapOpenSocket(conn) {
311323
}
312324
}
313325

326+
// ── Camera mode (see `camMode` on the conn) ───────────────────────────
327+
// An error on the webcams query keeps the historical WebRTC player, so a
328+
// firmware that does not know the method behaves exactly as before.
329+
if (obj.id === 9010) {
330+
const mode = (obj.error || (obj.result?.webcams?.length ?? 0) > 0) ? "webrtc" : "still";
331+
if (conn.camMode !== mode) { conn.camMode = mode; snapNotifyChange(conn, /*statusChanged*/ true); }
332+
}
333+
// The stock wake call failed → this firmware has no monitor either.
334+
if (obj.id === 9011 && obj.error && conn.camMode === "still") {
335+
conn.camMode = "none";
336+
snapNotifyChange(conn, /*statusChanged*/ true);
337+
}
338+
314339
let status = null;
315340
if (obj.result && obj.result.status) status = obj.result.status;
316341
else if ((obj.method === "notify_status_update" || obj.method === "notify_status_changed")
@@ -655,6 +680,22 @@ export function snapSanitiseGcodeArg(s) {
655680
return String(s || "").trim().replace(/\s+/g, "-").replace(/["'`]/g, "");
656681
}
657682

683+
// Stock-firmware camera wake-up. The U1's own service (unisrv) only refreshes
684+
// /server/files/camera/monitor.jpg while someone asks for it, and lets the
685+
// camera go idle after a few seconds otherwise — the same call the Snapmaker app
686+
// and SimplyPrint send. Re-sent every ~10 s by the camera widget while a feed is
687+
// on screen; nothing else is written on the printer. Not logged: a frame every
688+
// 10 s would drown the request log.
689+
export function snapWakeStockCamera(conn) {
690+
if (!conn?.ws || conn.ws.readyState !== WebSocket.OPEN) return false;
691+
conn.ws.send(JSON.stringify({
692+
jsonrpc: "2.0", id: 9011,
693+
method: "camera.start_monitor",
694+
params: { domain: "lan", interval: 0 },
695+
}));
696+
return true;
697+
}
698+
658699
// Send a single g-code line via the printer's existing WebSocket.
659700
// Logs the outbound frame so it's visible in the request log too.
660701
export function snapSendGcode(conn, script) {

‎renderer/printers/snapmaker/widget_camera.js‎

Lines changed: 107 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -5,34 +5,130 @@
55
* All Snapmaker camera logic lives here — inventory.js calls this and
66
* never builds camera HTML inline.
77
*
8-
* Stream: Crowsnest WebRTC player page (port 80, path /webcam/webrtc).
9-
* The iframe handles the full WebRTC negotiation internally — no
10-
* RTCPeerConnection in the renderer.
8+
* Two feeds, picked per firmware (`conn.camMode`, see index.js):
9+
* • "webrtc" (Paxx extended firmware) — Crowsnest WebRTC player page (port 80,
10+
* path /webcam/webrtc). The iframe handles the full WebRTC negotiation
11+
* internally — no RTCPeerConnection in the renderer.
12+
* • "still" (stock firmware) — there is no stream, but the printer's own
13+
* service refreshes a 1080p JPEG (~1 fps) while it is woken up every few
14+
* seconds. An <img> is fed frame by frame; see the still-feed loop below.
1115
*/
1216
import { ctx } from '../context.js';
13-
import { snapGetConn, snapKey } from './index.js';
17+
import { snapGetConn, snapKey, snapWakeStockCamera } from './index.js';
18+
19+
const LOADING_OVERLAY = `
20+
<div class="pp-cam-loading-overlay">
21+
<span class="pp-cam-loading-dots">
22+
<span></span><span></span><span></span>
23+
</span>
24+
</div>`;
1425

1526
/**
1627
* Returns the full camera banner HTML for a Snapmaker printer,
17-
* or "" when the printer is offline / not yet connected.
28+
* or "" when the printer is offline / not yet connected / has no camera.
1829
*
1930
* @param {object} p — printer record from state.printers
2031
* @returns {string} — HTML string (safe to assign to innerHTML)
2132
*/
2233
export function renderSnapCamBanner(p) {
23-
const conn = snapGetConn(snapKey(p));
34+
const key = snapKey(p);
35+
const conn = snapGetConn(key);
2436
if (!conn || conn.status !== "connected" || !conn.ip) return "";
37+
if (conn.camMode === "none") return "";
38+
// Mode not known yet (the webcams query is in flight): hold the frame with
39+
// the loading dots rather than load a player that may not exist — the
40+
// answer triggers a re-render.
41+
if (!conn.camMode) return `<div class="pp-cam-full pp-cam-loading">${LOADING_OVERLAY}</div>`;
42+
if (conn.camMode === "still") {
43+
_stillEnsure(key);
44+
return `
45+
<div class="pp-cam-full pp-cam-loading">
46+
<img class="snap-camera-frame snap-camera-still" data-snap-cam="${ctx.esc(key)}" alt="" draggable="false">
47+
${LOADING_OVERLAY}
48+
</div>`;
49+
}
2550
return `
2651
<div class="pp-cam-full pp-cam-loading">
2752
<iframe class="snap-camera-frame" src="${ctx.esc(`http://${conn.ip}/webcam/webrtc`)}"
2853
sandbox="allow-scripts allow-same-origin"
2954
loading="lazy" referrerpolicy="no-referrer"
3055
allow="autoplay"
3156
onload="var h=this.closest('.pp-cam-loading');if(h){h.classList.remove('pp-cam-loading');h.querySelector('.pp-cam-loading-overlay')?.remove();}"></iframe>
32-
<div class="pp-cam-loading-overlay">
33-
<span class="pp-cam-loading-dots">
34-
<span></span><span></span><span></span>
35-
</span>
36-
</div>
57+
${LOADING_OVERLAY}
3758
</div>`;
3859
}
60+
61+
/* ── Still feed (stock firmware) ─────────────────────────────────────────────
62+
One loop per printer, shared by every surface showing it (side panel, camera
63+
wall, printer card): each tick finds the <img data-snap-cam="key"> currently
64+
in the document and hands them the same frame.
65+
66+
• Wake every WAKE_MS — the printer stops refreshing the JPEG once nobody asks.
67+
• A frame every FRAME_MS, fetched with no-store into a Blob URL: the image is
68+
swapped only once fully downloaded (no blank between frames), and the
69+
previous Blob URL is revoked, so an hour of watching does not pile up
70+
~100 KB frames in the HTTP cache.
71+
• Nobody on screen for IDLE_STOP_MS → the loop stops, and so do the wake-ups:
72+
the camera goes back to idle on its own. Same grace idea as cam_manager.js,
73+
so a view switch does not restart the feed from nothing. */
74+
const FRAME_MS = 1000;
75+
const WAKE_MS = 10000;
76+
const IDLE_STOP_MS = 12000;
77+
const _stills = new Map(); // key → { timer, lastWake, idleSince, busy, blobUrl }
78+
79+
function _stillImgs(key) {
80+
return document.querySelectorAll(`img.snap-camera-still[data-snap-cam="${CSS.escape(key)}"]`);
81+
}
82+
83+
function _stillEnsure(key) {
84+
if (_stills.has(key)) return;
85+
const s = { timer: null, lastWake: 0, idleSince: 0, busy: false, blobUrl: null };
86+
_stills.set(key, s);
87+
s.timer = setInterval(() => _stillTick(key, s), FRAME_MS);
88+
// First tick after the banner HTML has landed in the DOM.
89+
setTimeout(() => _stillTick(key, s), 0);
90+
}
91+
92+
function _stillStop(key) {
93+
const s = _stills.get(key);
94+
if (!s) return;
95+
clearInterval(s.timer);
96+
if (s.blobUrl) URL.revokeObjectURL(s.blobUrl);
97+
_stills.delete(key);
98+
}
99+
100+
async function _stillTick(key, s) {
101+
const conn = snapGetConn(key);
102+
if (!conn || conn.status !== "connected" || conn.camMode !== "still") { _stillStop(key); return; }
103+
const imgs = _stillImgs(key);
104+
const now = Date.now();
105+
if (!imgs.length) {
106+
if (!s.idleSince) s.idleSince = now;
107+
else if (now - s.idleSince > IDLE_STOP_MS) _stillStop(key);
108+
return;
109+
}
110+
s.idleSince = 0;
111+
if (now - s.lastWake >= WAKE_MS && snapWakeStockCamera(conn)) s.lastWake = now;
112+
if (s.busy) return; // a slow frame is still downloading — skip, never queue
113+
s.busy = true;
114+
try {
115+
const res = await fetch(`http://${conn.ip}:7125/server/files/camera/monitor.jpg`, { cache: "no-store" });
116+
if (!res.ok) return;
117+
const url = URL.createObjectURL(await res.blob());
118+
if (!_stills.has(key)) { URL.revokeObjectURL(url); return; } // stopped meanwhile
119+
const prev = s.blobUrl;
120+
s.blobUrl = url;
121+
_stillImgs(key).forEach(img => {
122+
img.src = url;
123+
const host = img.closest(".pp-cam-loading");
124+
if (host) { host.classList.remove("pp-cam-loading"); host.querySelector(".pp-cam-loading-overlay")?.remove(); }
125+
});
126+
// Revoke once the new frame is painted, not before (the old URL may still
127+
// be the one on screen for a few ms).
128+
if (prev) setTimeout(() => URL.revokeObjectURL(prev), 2000);
129+
} catch (_) {
130+
// Printer busy / network blip — the next tick simply tries again.
131+
} finally {
132+
s.busy = false;
133+
}
134+
}

0 commit comments

Comments
 (0)