Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion multiace/i18n/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@
"mode_1": "Kammer kühlen",
"mode_2": "Kammer vorheizen",
"mode_3": "Heiße Kammer",
"enc_locked_print": "Nicht während eines Drucks: der Gehäusemodus kommt aus der Slicer-Datei.",
"enc_hint_print": "Ein laufender Modus kann den Abluftwert während des Drucks wieder überschreiben.",
"enc_locked_mode": "Das Gehäuse läuft in einem Modus und steuert seine Lüfter selbst. Zuerst Lüfter aus.",
"restart_blocked": "Gesperrt, solange ein Druck läuft",
"light": "Licht",
Expand Down
2 changes: 1 addition & 1 deletion multiace/i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@
"mode_1": "Cooling chamber",
"mode_2": "Preheating chamber",
"mode_3": "Hot chamber",
"enc_locked_print": "Not during a print: the enclosure mode comes from the sliced file.",
"enc_hint_print": "A running mode may override the exhaust value while the print runs.",
"enc_locked_mode": "The enclosure is running a mode and drives its fans itself. Use Fans off first.",
"restart_blocked": "Blocked while a print is running",
"light": "Light",
Expand Down
2 changes: 1 addition & 1 deletion multiace/i18n/zh.json
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@
"mode_1": "腔体降温",
"mode_2": "腔体预热",
"mode_3": "高温腔体",
"enc_locked_print": "打印期间不可用:机箱模式由切片文件决定。",
"enc_hint_print": "模式运行期间,打印过程中排气风扇的数值可能会被其覆盖。",
"enc_locked_mode": "机箱正在运行某个模式并自行控制风扇。请先点击“关闭风扇”。",
"restart_blocked": "打印进行中,已禁用",
"light": "灯光",
Expand Down
16 changes: 8 additions & 8 deletions multiace/web/backend/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -2652,12 +2652,13 @@ async def _require_object(name: str, what: str) -> dict:
raise HTTPException(409, f"this printer has no {what}")
return status

async def _require_enclosure_idle() -> None:
async def _require_enclosure_drivable() -> None:
"""409 unless the enclosure fans may be driven directly: an enclosure is
attached, no print is running, and stock is not running a mode. In modes
1 to 3 stock drives the fans itself (dynamically in cool mode), so a
direct speed would compete with it, and during a print the mode usually
comes from the sliced file."""
attached and, outside a print, stock is not running a mode. During a
print the fans are allowed whatever the mode (stock itself accepts
SET_PURIFIER at any time); a running mode may override the exhaust value
again, which the UI says. Outside a print, modes 1 to 3 drive the fans
themselves (dynamically in cool mode), so a direct speed would compete."""
try:
status = await _print_status()
except httpx.HTTPError as e:
Expand All @@ -2668,8 +2669,7 @@ async def _require_enclosure_idle() -> None:
if not purifier.get("power_detected"):
raise HTTPException(409, "no enclosure is attached")
if (status.get("print_stats") or {}).get("state") in ("printing", "paused"):
raise HTTPException(409, "not during a print: the enclosure mode comes "
"from the sliced file")
return
if purifier.get("mode") != 0:
raise HTTPException(409, "the enclosure is running a mode and drives "
"its fans itself; stop it first")
Expand Down Expand Up @@ -2712,7 +2712,7 @@ async def print_control(request: Request, req: PrintControl) -> dict:
# One enclosure fan directly (stock SET_PURIFIER). SPEED=0 starts
# stock's delayed turn-off rather than stopping at once.
if not mocked:
await _require_enclosure_idle()
await _require_enclosure_drivable()
script = f"SET_PURIFIER FAN={verb} SPEED={applied / 100:.2f}"
else:
if not mocked:
Expand Down
30 changes: 22 additions & 8 deletions multiace/web/frontend/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -7497,9 +7497,10 @@ createApp({
resume: printCtl.state === "paused",
cancel: printCtl.state === "printing" || printCtl.state === "paused",
}));
// The enclosure fans may be driven directly only when stock is not: not
// during a print (the mode comes from the sliced file) and not while a
// mode runs. The server enforces both; this only explains it.
// Outside a print the fans may be driven directly only while no mode
// runs; the server enforces it, this only explains it. During a print
// they are always allowed (stock accepts it too); a running mode may
// override the exhaust value, which is only a hint.
// Stock's defect detection watches the print with the camera, which needs
// the chamber light: while it is on during a print the light stays on.
// The server refuses it too; this only disables the switch and explains.
Expand All @@ -7508,12 +7509,15 @@ createApp({
&& (printCtl.state === "printing" || printCtl.state === "paused"));
const enclosureLock = computed(() => {
const e = printCtl.enclosure;
if (printCtl.state === "printing" || printCtl.state === "paused") {
return t("ui.control.enc_locked_print");
}
if (printCtl.state === "printing" || printCtl.state === "paused") return "";
if (e && e.mode) return t("ui.control.enc_locked_mode");
return "";
});
const enclosureHint = computed(() => {
const e = printCtl.enclosure;
return (printCtl.state === "printing" || printCtl.state === "paused")
&& e && e.mode ? t("ui.control.enc_hint_print") : "";
});
const enclosureModeText = computed(() => {
const e = printCtl.enclosure;
if (!e || e.mode === null || e.mode === undefined) return "";
Expand Down Expand Up @@ -7609,14 +7613,23 @@ createApp({
function printCtlReset(verb) {
printCtlRelease(verb, verb === "fan" ? 0 : 100);
}
// The target boxes show the current target, so a spinner arrow steps from
// the real value instead of from an empty box. While the user is in a
// box the typed value wins over the readback; leaving the box (blur)
// goes back to showing the target.
const printCtlTempDraft = reactive({nozzle: null, bed: null});
function printCtlTempShown(verb) {
if (printCtlTempDraft[verb] !== null) return printCtlTempDraft[verb];
const target = printCtl[verb].target;
return target === null ? "" : Math.round(target);
}
// An empty box is "nothing typed", not 0: sending 0 would switch the
// heater off.
function printCtlHeat(verb, ev) {
const raw = String(ev.target.value).trim();
if (raw === "") return;
const v = Number(raw);
if (Number.isFinite(v)) sendPrintControl(verb, v);
ev.target.value = "";
}
// Restart Firmware / Restart Printer. Refused by the server while a
// print runs and under a managed install; the buttons mirror that. After
Expand Down Expand Up @@ -7887,7 +7900,8 @@ createApp({
webcam, webcamSrc, webcamVideo, reloadWebcam,
printCtl, printCtlLimits, printCtlDraft, printCtlBusy, printCtlError, printCtlCan,
printCtlNotice, printCtlQueued, printCtlRestart, printCtlRestartBlocked,
enclosureLock, enclosureModeText, lightLocked,
printCtlTempDraft, printCtlTempShown,
enclosureLock, enclosureHint, enclosureModeText, lightLocked,
printCtlSlide, printCtlRelease, printCtlReset, printCtlHeat, printCtlCancel,
sendPrintControl,
consoleLines, consoleFollow, consoleEl, loadConsole, consoleTime,
Expand Down
14 changes: 11 additions & 3 deletions multiace/web/frontend/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -1429,6 +1429,9 @@ <h3>{{ t('ui.control.print') }}</h3>
<input type="number" class="pc-temp-in"
min="0" :max="printCtlLimits.nozzle.max"
:placeholder="t('ui.control.target')"
:value="printCtlTempShown('nozzle')"
@input="printCtlTempDraft.nozzle = $event.target.value"
@blur="printCtlTempDraft.nozzle = null"
@change="printCtlHeat('nozzle', $event)">
</div>
<div class="pc-row pc-temps">
Expand All @@ -1438,6 +1441,9 @@ <h3>{{ t('ui.control.print') }}</h3>
<input type="number" class="pc-temp-in"
min="0" :max="printCtlLimits.bed.max"
:placeholder="t('ui.control.target')"
:value="printCtlTempShown('bed')"
@input="printCtlTempDraft.bed = $event.target.value"
@blur="printCtlTempDraft.bed = null"
@change="printCtlHeat('bed', $event)">
</div>

Expand All @@ -1463,9 +1469,10 @@ <h3>{{ t('ui.control.print') }}</h3>
<p v-if="lightLocked" class="muted small">{{ t('ui.control.light_locked') }}</p>

<!-- Only with an enclosure attached (the printer's own attach detection).
Fans can be set directly only when stock is not driving them: the
server refuses it during a print and while a mode runs, and the
sliders say why. "Fans off" returns to idle and stops both at once. -->
Outside a print the fans can be set directly only while no mode
runs (the sliders say why). During a print they always can; a
running mode may override the exhaust value, which the hint
says. "Fans off" returns to idle and stops both at once. -->
<div class="pc-enc" v-if="printCtl.enclosure && printCtl.enclosure.attached">
<div class="pc-row pc-enclosure">
<span>{{ t('ui.control.enclosure_fans') }}</span>
Expand All @@ -1483,6 +1490,7 @@ <h3>{{ t('ui.control.print') }}</h3>
<b>{{ Math.round(printCtl.enclosure[f]) }} %</b>
</label>
<p v-if="enclosureLock" class="muted small">{{ enclosureLock }}</p>
<p v-else-if="enclosureHint" class="muted small">{{ enclosureHint }}</p>
</div>

<!-- Z offset is BUTTONS, never a slider: a slider makes it easy to
Expand Down
20 changes: 17 additions & 3 deletions tests/unit/test_print_control.py
Original file line number Diff line number Diff line change
Expand Up @@ -416,7 +416,8 @@ def test_there_is_no_way_to_pick_a_mode_or_name_a_fan(self, client, printer):


class TestEnclosureFans:
"""Direct fan speed: only with an enclosure attached, idle, and no print."""
"""Direct fan speed: only with an enclosure attached; outside a print also
only while no mode runs."""
IDLE = {"purifier": PURIFIER_OBJ, "print_stats": {"state": "standby"}}

@pytest.mark.parametrize("fan,value,script,applied", [
Expand All @@ -440,8 +441,6 @@ def test_a_value_is_required(self, client, printer):
({"print_stats": {"state": "standby"}}, "no enclosure"),
({"purifier": {**PURIFIER_OBJ, "power_detected": False},
"print_stats": {"state": "standby"}}, "not attached"),
({"purifier": PURIFIER_OBJ, "print_stats": {"state": "printing"}}, "printing"),
({"purifier": PURIFIER_OBJ, "print_stats": {"state": "paused"}}, "paused"),
({"purifier": {**PURIFIER_OBJ, "mode": 1}, "print_stats": {"state": "standby"}}, "cool"),
({"purifier": {**PURIFIER_OBJ, "mode": 2}, "print_stats": {"state": "standby"}}, "preheat"),
({"purifier": {**PURIFIER_OBJ, "mode": 3}, "print_stats": {"state": "standby"}}, "hot"),
Expand All @@ -454,6 +453,21 @@ def test_refused_and_nothing_sent(self, client, printer, status, why, fan):
assert post(client, fan, 50).status_code == 409, why
assert printer.sent == []

@pytest.mark.parametrize("state", ["printing", "paused"])
@pytest.mark.parametrize("mode", [0, 1, 3])
@pytest.mark.parametrize("fan", ["exhaust", "inner"])
def test_allowed_during_a_print_whatever_the_mode(
self, client, printer, state, mode, fan):
printer.status = {"purifier": {**PURIFIER_OBJ, "mode": mode},
"print_stats": {"state": state}}
assert post(client, fan, 50).status_code == 200
assert printer.sent == [f"SET_PURIFIER FAN={fan} SPEED=0.50"]

def test_a_missing_enclosure_is_still_refused_during_a_print(self, client, printer):
printer.status = {"print_stats": {"state": "printing"}}
assert post(client, "exhaust", 50).status_code == 409
assert printer.sent == []

def test_an_unreachable_moonraker_blocks_it(self, main, client, printer, monkeypatch):
async def boom(path):
raise httpx.ConnectError("down")
Expand Down
Loading