Skip to content

FEAT: Control tab under Print Hub (webcam, print panel, console) - #188

Merged
decay71 merged 16 commits into
mainfrom
feat/control-tab
Oct 24, 2026
Merged

decay71 merged 16 commits into
mainfrom
feat/control-tab

Conversation

@stefanodangelo

@stefanodangelo stefanodangelo commented Oct 21, 2026 •

Copy link
Copy Markdown
Collaborator

Adds Control, the first tab of the Print Hub group from #184. Print Hub was hidden until a feature gave it a child, so this is the PR that makes it appear.

Nothing here is new functionality. The camera, the console and the print controls already exist in the printer's Fluidd/Moonraker interface. Control only routes them into the multiACE page, so everything is handy from one place. It replaces nothing in Fluidd.

What it adds

  • Webcam. GET /api/webcam/info, /stream (MJPEG) and /snapshot, plus POST /api/webcam/webrtc-signal for WebRTC cameras such as camera-streamer. The camera is looked up through Moonraker, and our own Fluidd iframe entry is skipped. The browser never sees the camera's address: media goes browser-to-camera, and only the SDP handshake goes through us, because camera-streamer sends no CORS headers. The lookup retries on its own while no camera is found, and a connection that comes up without delivering video fails visibly and retries.
  • Print panel. Speed, flow and fan sliders, nozzle and bed targets, Z-offset steps, Pause / Resume / Cancel (Cancel asks first). Values are read back from the printer every 2 s while the tab is open.
  • Chamber light. A switch for led cavity_led, shown only when the printer has that LED. While a print is running or paused with defect detection enabled, the light stays on: stock turns it on itself because the AI camera check needs it, so the server refuses dimming or switching it off then, and the switch is disabled with the reason.
  • Chamber temperature and enclosure. The chamber temperature comes from the cavity sensor and reads without an enclosure. With an enclosure attached (power_detected) the tab also shows the active mode and its target temperature, two sliders for the exhaust and inner fan, and "Fans off". See below.
  • Console. Read-only, incremental via since_id, using the Moonraker websocket the backend already listens to, so there is no extra polling. History is loaded once from Moonraker's gcode store.
  • Restart Firmware / Restart Printer. POST /api/restart with one of two fixed behaviours. See below.

Safety. POST /api/print-control takes a fixed verb and a number, never a G-code string. Values are clamped on the server:

  • speed 25–300 %, flow 75–125 %, fan, light and the enclosure fans 0–100 %;
  • heaters are capped by Klipper's own max_temp (read from its config) and nothing tighter: stock loads and unloads above a material's printing range, so a per-material cap would stop someone heating up to clear a clog. A target below a printing range is not raised, and 0 means off;
  • Z offset in steps of at most 0.05 mm, with a ±0.5 mm total per print that resets when a print starts;
  • the status poll is homing-gated like _query_state_gated, and a command that times out on its way back is reported as queued, with the matching control kept disabled, so a swap that holds Klipper's queue cannot make a second press send it twice.

Unknown verbs return 400 and send nothing. The nozzle follows the active extruder. When Moonraker rejects a command, the panel shows its error message, not the traceback that comes with it.

Restarts are refused while a print is running or paused (read fresh from Moonraker) and under MULTIACE_MANAGED, where the buttons are hidden. A downed Klipper (503) does not block a firmware restart, which is when it is needed; an unreachable Moonraker does. firmware_restart is what the config save already sends.

Restart Printer asks Moonraker (/machine/reboot) first. That runs sudo systemctl reboot, and the Snapmaker U1 has neither sudo nor systemd (busybox init), so it always fails there. If it fails, and only when the backend runs as root on the same machine as Moonraker, the backend runs reboot itself. A backend on a dev box talking to a remote printer, one that is not root, or one on Windows never takes that path, and the firmware restart never does. reboot is looked up in /usr/sbin as well, because the service's own PATH does not include it.

Enclosure controls use the stock commands: SET_PURIFIER FAN=exhaust|inner SPEED=0..1 and SET_PURIFIER_MODE MODE=0 EXHAUST_FAN_DELAY_OFF=0 INNER_FAN_DELAY_OFF=0 for "Fans off". The server refuses the fan speeds unless an enclosure is attached, no print is running and no mode is running, because stock drives the fans itself in modes 1–3 and sets the mode from the sliced file during a print. "Fans off" stays available in a mode as the way out. The modes themselves (cool, preheat, hot chamber) are not driven from here.

Navigation. One navItems entry with group: "printhub", control added to _validTabs, and ui.tabs.control plus ui.control.* and ui.restart.* in en, de and zh. The top bar and .subnav rules are untouched. Panel mode is unaffected.

Not included. A console input box, the enclosure modes, and Retry controls. Polling and the camera connection only run while the Control tab is showing.

Mock mode. Fixtures: mock_webcam.json (shows "camera unavailable"), mock_console.json, mock_print_control.json. The stream, snapshot and signalling endpoints and the restarts refuse or skip Moonraker in mock mode, and ?mock= still only works while mock mode is on.

Testing

  • pytest tests/unit: 242 passed, 226 of them new (camera lookup, stream proxy, WebRTC relay, console buffer, every clamp and guard, restarts including the local reboot fallback, enclosure, light and defect detection).
  • Tried on a real Snapmaker U1: live 1920×1080 WebRTC video in dark, light and classic, the page does not widen at 390 px, the light switch, the console and the readbacks. Restart Printer fails on it through Moonraker as described above; with the fallback, the new code run on the printer rebooted it and it came back with Klipper ready and multiACE intact.
  • Not exercised on hardware: the enclosure controls (no enclosure), and the heater, fan, babystep and firmware-restart commands, which are covered by unit tests and mock mode.

Screenshots. See the comments.

@stefanodangelo

Copy link
Copy Markdown
Collaborator Author

Here are the screenshots:
control-dark
control-phone

@stefanodangelo

Copy link
Copy Markdown
Collaborator Author

If you like it, then I can also make the console send commands rather than be just read-only. I use it on my printer and it's pretty handy to have.
Part of that, I also have 2 buttons that send macros to the console to restart the firmware and the printer. Eventually we could potentially think of adding other buttons too.

@decay71 decay71 left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, three points

  1. Nozzle clamp: _heater_limits intersects the material ranges of every head with filament_detected, not just the active one (filament_detected is also true for an unloaded head with a spool at the ACE gate). PLA on one head and PETG on another gives 210-230 on both; PLA and PC inverts the range (240/230), so any nozzle target on the PLA head lands at 240. Please use only the toolhead whose idx matches the active extruder.

  2. Homing gate: the 2 s status poll (_print_status) calls Moonraker directly. Please check _homing_active() first, like _query_state_gated does, and skip the poll or serve the last value while the flag is fresh. Polling during bed mesh/homing is what that gate prevents.

  3. Swaps: /printer/gcode/script only returns once the command has run, and a multiACE swap holds the queue for 60-150 s. The 30 s timeout then reports 502 although the command is still queued and runs after the swap, so a second press sends it twice. Please treat a timeout as "queued" in the UI, or raise the timeout above a swap.

One question on top: would you like to add controls for the Snapmaker enclosure as well? Stock drives it through the purifier object (exhaust and inner fan, with its own attach detection), and the cavity temperature sensor gives the matching readout. A toggle for the chamber light (led cavity_led, part of the printer itself) would fit next to it and also fix your dark camera picture. Same pattern as the rest: fixed verbs, values clamped on the server.

@stefanodangelo

Copy link
Copy Markdown
Collaborator Author

Thanks for the review, I'll make those three changes and add the chamber light toggle.
However, I don't have the enclosure, so I have no way to test the purifier fan controls or the cavity temperature readout.

- Nozzle clamp: use only the material of the head that owns the active
  extruder. Other heads' spools no longer narrow or invert the range.
- Homing gate: the 2 s status poll now serves the last value while ace.py's
  homing flag is fresh, like _query_state_gated. Klipper's config limits are
  cached and not queried during homing either.
- Swaps: a read timeout on /printer/gcode/script is reported as queued, not
  as a 502. The UI keeps the same control disabled meanwhile so a second
  press cannot send it twice. A connect timeout is still a failure.
- Chamber light: a fixed "light" verb (0-100 %, SET_LED LED=cavity_led),
  read back from the printer, shown as a toggle only when the printer has
  the LED.
@decay71

decay71 commented Oct 22, 2026

Copy link
Copy Markdown
Owner

Thank you, i will test that here!

A bulb icon and "Light", with a toggle switch whose knob carries the ON /
OFF text, instead of a button next to a status word. The switch has fixed
dimensions so the phone rule that raises buttons to 44px leaves it alone,
and it is a real role="switch" with aria-checked.
…rol tab

Read-only: the cavity temperature sensor (part of the printer, it reads
without an enclosure) and, when the purifier object reports an attached
enclosure (power_detected), the exhaust and inner fan speeds.

One control: "Fans off" sends SET_PURIFIER_MODE MODE=0, the command stock
itself sends at the end of a print (PRINT_END, CANCEL_PRINT), so its meaning
is known. There is no verb that starts the fans or sets a speed: no command
for that could be found on the printer (help, config, logs, web UI), and it
is not guessed. The verb is refused with 409 on a printer without the
purifier object.

Also, found while checking this on a real printer:
- The console history was only fetched when the buffer was empty, so any
  live line arriving first meant it never loaded. It is now fetched once,
  and lines newer than the request are kept after it.
- A WebRTC connection that comes up but delivers no video now fails
  visibly and retries, instead of staying silent.
POST /api/restart takes one of two fixed behaviours (firmware ->
/printer/firmware_restart, printer -> /machine/reboot). It is refused while a
print is running or paused, read fresh from Moonraker rather than from the
homing cache, and refused under MULTIACE_MANAGED, where the platform owns
restarts. A downed Klipper (503) does not block a firmware restart, which is
when it is needed; an unreachable Moonraker does, rather than guessing. A
connection that drops mid-request counts as sent.

In the UI both buttons ask for confirmation, are disabled with an explanation
while a print runs, are not rendered on a managed install, and the poll errors
that follow a restart are held back until the printer answers again.

Also: the webcam lookup failed once at first load with an empty error message
and then stayed on "Camera unavailable" until Reload was pressed. The error now
names the exception, and the tab looks again every ~10 s while no camera is
found.
Found under load: the console seed test recorded its "early" line in the same
clock tick as the request start, and the homing-gate test wrote a flag whose
mtime could read slightly ahead of time.time() on Windows, so it counted as
stale. Neither was a logic error in the code under test.
@stefanodangelo

Copy link
Copy Markdown
Collaborator Author

Pushed the fixes.

Here are the details:

  • Nozzle clamp: with PLA on head 0 and PC on head 1, a target of 200 now stays 200 instead of landing on 240.
  • Homing gate: besides the 2 s poll, I also cached the Klipper config query (5 min) and skip it during homing, because it hit Moonraker on every nozzle or bed call.
  • Swaps: a connect timeout is still reported as a failure, since nothing was queued then. Only a read timeout counts as "queued". While something is queued, the matching control (Pause, Resume, Cancel, Z steps) stays disabled for 3 minutes.
  • Chamber light: it's led cavity_led, driven with SET_LED LED=cavity_led WHITE=<0..1>. I tested it on my printer, and the camera picture is no longer black.
  • Enclosure: I don't have the enclosure, so I could only verify what the printer itself reports. I added the chamber temperature and, only when power_detected is true, the exhaust and inner fan speeds. The one control is "Fans off", which sends SET_PURIFIER_MODE MODE=0, the command stock sends in PRINT_END and CANCEL_PRINT. I couldn't find a command for turning the fans on or setting a speed, so I didn't guess one. If you tell me the stock command and its mode values, I'll add it, but someone with the enclosure would have to test it.
  • Restart buttons: I also added Restart Firmware and Restart Printer, as in the plan. They're refused while a print is running or paused, and hidden under MULTIACE_MANAGED. A downed Klipper doesn't block a firmware restart, since that's when it's needed. I know index.html has comments saying a restart from the UI "worked only sometimes" and that Fluidd can do it. If you'd rather not have them here, they're one commit and easy to drop.

Other fixes found while testing on the printer:

  • The console history only loaded when the buffer was empty, so it never loaded once any live line had arrived.
  • A WebRTC connection that came up but delivered no video now shows an error and retries.
  • The camera lookup retries on its own if it fails once at load.
control-phone

Here are the screenshots from my printer:
control-dark

@decay71 decay71 left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, this reads well and the safety design (fixed verbs, server-side clamps, print-state gate on restarts) is exactly right. Three things:

  1. Heater clamp raises low targets. A nozzle target below the material range is lifted to the floor: PLA 150 becomes 170, PC 200 becomes 240. Someone setting a standby temperature gets a hotter nozzle than they typed. Please cap only the top (max_temp and the material maximum) and let values below the material floor through, the same way 0 already passes as "off".

  2. MATERIAL_TEMP_LIMITS only knows base names. PLA-CF, PETG-CF, PETG-HF and similar find no entry and fall back to max_temp alone. That is safe, just inconsistent. Either match on the base material before the first "-" or leave it as is and say so in the comment.

  3. The babystep allowance resets only when the filename changes. Printing the same file again keeps the used total from the previous job. Resetting it on print start would fit better than keying on the filename.

The restart buttons can stay: firmware_restart is what our own config save sends, the warning in index.html is about the bare Klipper restart, which you do not use.

On your enclosure question, this is what stock purifier.py (2.0.0) offers, take whatever you think fits:

  • SET_PURIFIER FAN=exhaust|inner SPEED=<0..1> sets one fan directly. SPEED=0 starts the delayed turn-off (default 180 s) rather than stopping at once. Speed > 0 is refused without an attached enclosure.
  • SET_PURIFIER_MODE MODE=0..3: 0 idle, 1 cool chamber (exhaust, FAN_SPEED default 0.6, DESIRE_TEMP default 42, ALARM_TEMP), 2 preheat chamber (inner fan, waits for DESIRE_TEMP), 3 hot chamber (inner fan, exhaust off). get_status reports the active mode and desired_chamber_temp, so the panel could show what is running.
  • Your current "Fans off" (MODE=0) stops the fans only after that delay. MODE=0 EXHAUST_FAN_DELAY_OFF=0 INNER_FAN_DELAY_OFF=0 stops them now.

Things worth keeping in mind if you go further: in modes 1 to 3 stock drives the fans itself (dynamically in cool mode), so a direct speed control would compete with it; and during a print the mode usually comes from the sliced file, which is also what a power-loss resume restores. Direct fan control in idle and mode buttons outside a print would be one way, but it is your call.

I will test the enclosure readout, the fan controls and the chamber light here after the merge.

…t start

- Cap only the top of the nozzle target (max_temp and the material maximum).
  A target below the material's printing range is a standby temperature set
  on purpose; it used to be lifted to the floor (PLA 150 became 170, PC 200
  became 240). 0 still passes as off.
- Match the material ceiling on the base name before the first "-", so
  PLA-CF, PETG-CF and PETG-HF get their base material's ceiling instead of
  only max_temp. A name with no entry still gets max_temp alone.
- The Z babystep allowance now resets when a print starts, from the
  websocket's print-state change (pause and resume is the same print), and no
  longer depends on the filename, so printing the same file again starts
  over.
Using what stock purifier.py (2.0.0) offers:

- Fans off now stops both fans at once (MODE=0 with EXHAUST_FAN_DELAY_OFF=0
  and INNER_FAN_DELAY_OFF=0). Plain MODE=0 only starts stock's delayed
  turn-off (180 s by default).
- Two sliders set the exhaust and inner fan directly (SET_PURIFIER FAN=...
  SPEED=0..1, clamped as a percentage). The server refuses it unless an
  enclosure is attached, no print is running (the mode usually comes from the
  sliced file) and stock is not running a mode (it drives the fans itself, and
  in cool mode dynamically, so a direct speed would compete). The sliders
  are disabled with the reason. Fans off stays available in a mode: it is the
  way out.
- The active mode and its target temperature are read back and shown.

The modes themselves (cool, preheat, hot chamber) are not driven from here:
they take extra parameters and stock sets them during a print.

None of this could be run against an enclosure; it is covered by unit tests
and mock mode.
@stefanodangelo

Copy link
Copy Markdown
Collaborator Author

Pushed two more commits.

The first one targets your 3 points. The nozzle target is now only capped at the top, so lower targets pass through like 0 does. The material ceiling matches on the base name, so PLA-CF and PETG-HF get their base material's ceiling. The babystep allowance resets when a print starts, and pause then resume is still the same print.

The second and last one instead features the following:

  • "Fans off" now stops both fans at once (MODE=0 with both delays at 0).
  • Two sliders set the exhaust and inner fan directly with SET_PURIFIER. The server refuses them unless an enclosure is attached, no print is running and no mode is running. The sliders are disabled and say why, and "Fans off" stays available as the way out of a mode.
  • The tab shows the active mode and its target temperature.

I left the modes themselves (cool, preheat, hot chamber) out, since they take extra parameters and stock sets them during a print. I couldn't run any of the enclosure part without an enclosure, so it's covered by unit tests and mock mode only. I'd appreciate your test on yours.

I also updated the PR description to reflect the new entries. Mock-mode screenshots of the enclosure sliders are below. I couldn't take screenshots from the real frontend because I have no enclosure.

In Idle:
enclosure-fan-sliders-mock-idle
In Cool mode:
enclosure-locked-in-cool-mode-mock
During a print:
enclosure-locked-while-printing-mock

@decay71 decay71 left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, the three fixes are in and the enclosure commands match stock purifier.py. Three notes:

  1. Light off during a print: stock turns cavity_led on itself at DEFECT_DETECTION_START because the AI camera check needs it. Switching it off from the tab during a print with defect detection enabled leaves the camera dark, so the detection can misfire or go blind. Please refuse (or at least warn) light-off while printing or paused with defect detection on.
  2. One more on the material table from my last review: the ceilings are stricter than stock. PLA stops at 230 here, while the Snapmaker filament database gives PLA a load temperature of 250 and stock loads and unloads at that, so heating up to clear a clog is not possible from the tab. Please cap at max_temp only, or use more generous ceilings.
  3. Restart Printer fails on my U1: /machine/reboot answers "500 Error running shell command: 'sudo systemctl reboot'". Did you try the button on your printer?

The per-material ceilings were stricter than stock: PLA stopped at 230 while
stock loads and unloads PLA at 250, so heating up to clear a clog was not
possible from the tab. The heater limits now come from Klipper's own config
(min_temp and max_temp) alone. A target below a printing range is still not
raised, and 0 is still off.

This removes the material table and the base-name matching added in the
previous round, which only existed to feed it.
…runs

Stock turns cavity_led on itself at DEFECT_DETECTION_START because the AI
camera check needs it. Switching it off from the tab during a print with
defect detection enabled would leave the camera dark, so the check could
misfire or go blind.

The server now refuses dimming or switching off the light (409) while a print
is running or paused and defect detection is enabled (its master switch plus
at least one of its checks). Keeping it fully on is still allowed, and outside
a print, without detection, or without the object, nothing changes. The state
reports a "detection" flag, and the switch is disabled with the reason.
Moonraker's JSON error carries a full Python traceback (about 2 KB). A failed
Restart Printer put all of it into the panel. The restart and print-control
endpoints now pass on only error.message, and fall back to the raw body when
the response is not Moonraker's JSON.

Found by pressing Restart Printer on a real printer, where it fails with
"Error running shell command: 'sudo systemctl reboot'".
Moonraker's /machine/reboot runs `sudo systemctl reboot`. The Snapmaker U1
has neither sudo nor systemd (busybox init), so Restart Printer always failed
there with "Error running shell command: 'sudo systemctl reboot'".

Moonraker stays the first choice, so printers where it works are unchanged.
Only if it fails does the backend run reboot itself, and only when it runs as
root on the same machine as Moonraker, so a backend on a dev box talking to a
remote printer (or on Windows) can never reboot the wrong machine. The print,
managed-install and mock-mode guards still come first, the firmware restart
never takes this path, and reboot is looked up in /usr/sbin as well, because
the service's own PATH (.local/bin:/usr/bin:/bin) does not include it.

Verified on a real Snapmaker U1 (idle, root, no sudo/systemd): the fallback
ran reboot, the printer went down and came back with Klipper ready and
multiACE intact (uptime reset from 20742 s to 48 s). The first attempt on the
printer did not reboot, which is how the PATH problem above was found. The
fallback is also unit-tested, including that case.
@stefanodangelo

Copy link
Copy Markdown
Collaborator Author

Pushed four more commits for your three notes.

Light during a print (22e9137): the server now refuses dimming or switching off the chamber light while a print is running or paused and defect detection is enabled (the defect_detection master switch plus at least one of its checks). Keeping it fully on is still allowed, and without detection, outside a print, or without that object nothing changes. The tab reports a detection flag, and the switch is disabled with the reason.

Ceilings (b71af85): the nozzle is now capped at Klipper's max_temp only. I removed the material table, and with it the base-name matching from last round, which only existed to feed it.

Restart Printer (7e1d0f2): you were right, and I hadn't tried it on a printer before. I did now, and it fails on mine with the same sudo systemctl reboot error. The U1 ships without sudo and without systemctl (busybox init), so Moonraker's /machine/reboot can never work there.

It now tries Moonraker first, so printers where that works are unchanged. Only if it fails, and only when the backend runs as root on the same machine as Moonraker, it runs reboot itself. A backend on a dev box talking to a remote printer, or one that isn't root, never takes that path. The print, managed-install and mock-mode guards still apply first, and the firmware restart never takes it. reboot lives in /usr/sbin, which the service's own PATH doesn't include, so it is looked up there as well. I only found that by running it on the printer.

I ran the new code on my U1 from a scratch copy, without touching the installed service. The printer went down and came back with Klipper ready and multiACE intact (uptime reset from 20742 s to 48 s). I haven't been able to try it on yours, so it relies on your web service running as root, which is the default.

9f174d4: a failed command used to put Moonraker's whole traceback into the panel. It now shows only Moonraker's error message.

@decay71 decay71 left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Merged, thanks for the careful work and for testing it on your U1. I will run the enclosure controls and the remaining commands on my printer and report back here if anything turns up.

@decay71
decay71 merged commit 37b01cc into main Oct 24, 2026
7 checks passed
@decay71
decay71 deleted the feat/control-tab branch October 24, 2026 10:11
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants