From 699899f0523d0303f7b1c945fc6a08b7c963cad2 Mon Sep 17 00:00:00 2001 From: bbolinger <14969048+bbolinger@users.noreply.github.com> Date: Thu, 16 Jul 2026 02:44:22 +0000 Subject: [PATCH 1/5] Add filament temperature override engine + sourced range gate The slice side of the optional bed/nozzle temperature control: - u1_temps: per-material nozzle/bed envelopes sourced from Snapmaker's own U1 filament presets, published material guides, and the U1 hardware spec (hotend 300, bed 100). Bed has no minimum so a cool/cold plate or bed-off is never blocked; only the max is gated. - apply_filament_overrides: patches the flattened filament profile's temps, clamped to the material envelope, written across both layers and every bed plate variant so the filament stays self-consistent. - real_orca_slice gains a filament_overrides argument. Real-Orca e2e confirms an override lands in the sliced gcode; unit tests cover the clamp, the cold-plate bed-off case, and the no-op path. --- scripts/u1_slice_workflow.py | 73 ++++++++++++++++- scripts/u1_temps.py | 109 +++++++++++++++++++++++++ tests/test_filament_temp_override.py | 118 +++++++++++++++++++++++++++ tests/test_u1_temps.py | 79 ++++++++++++++++++ 4 files changed, 378 insertions(+), 1 deletion(-) create mode 100644 scripts/u1_temps.py create mode 100644 tests/test_filament_temp_override.py create mode 100644 tests/test_u1_temps.py diff --git a/scripts/u1_slice_workflow.py b/scripts/u1_slice_workflow.py index ecd5af8..dab6681 100755 --- a/scripts/u1_slice_workflow.py +++ b/scripts/u1_slice_workflow.py @@ -21,6 +21,8 @@ import os, sys, subprocess from pathlib import Path +import u1_temps # per-material temperature envelopes for the bed/nozzle override + def _bootstrap_env() -> dict: """os.environ copy without PYTHONPATH/PYTHONHOME (escape hatch: @@ -963,6 +965,70 @@ def apply_profile_overrides(process_path: Path, overrides: dict[str, str], return temp +# Filament (temperature) overrides. Unlike the process overrides above, these +# patch the FILAMENT profile — the U1 stores every temp as a single-element list +# (nozzle_temperature=['220']), so each override is written as [''] across +# the key's siblings to keep the filament self-consistent. +FILAMENT_OVERRIDE_KEYS = ('nozzle_temperature', 'hot_plate_temp') + +# When bed temp is overridden, every plate variant must move together — Orca +# stamps whichever the active bed_type selects, and _materialize_flat_filament +# already propagates hot_plate_temp into these, so leaving one stale would emit +# an inconsistent bed temp to the gcode metadata (bit the upload gate once). +_BED_SIBLINGS = ( + 'hot_plate_temp', 'hot_plate_temp_initial_layer', + 'textured_plate_temp', 'textured_plate_temp_initial_layer', + 'eng_plate_temp', 'eng_plate_temp_initial_layer', + 'cool_plate_temp', 'cool_plate_temp_initial_layer', +) +_NOZZLE_SIBLINGS = ('nozzle_temperature', 'nozzle_temperature_initial_layer') + + +def apply_filament_overrides(filament_path: Path, overrides: dict[str, Any], + out_dir: Path, material: str | None = None) -> Path: + """Materialize a temp filament profile with the operator's bed/nozzle temps. + + ``overrides`` maps a base key (``nozzle_temperature`` | ``hot_plate_temp``) + to a target temperature in C. Each value is CLAMPED to the material's sourced + envelope (u1_temps) — defense in depth, since the form only offers in-range + values but a text/API answer could carry anything — then written across the + key's siblings so both layers and every bed-plate variant stay consistent. + Bed temp of 0 (a cool/cold plate, or bed off) is a legitimate value and is + written through unchanged. Values are single-element string lists to match + the U1 filament schema. + + Returns ``filament_path`` unchanged when no honored override remains. + """ + clean: dict[str, int] = {} + for k, v in (overrides or {}).items(): + if k not in FILAMENT_OVERRIDE_KEYS or v in (None, '', 'default'): + continue + try: + iv = int(round(float(v))) + except (TypeError, ValueError): + continue + clean[k] = (u1_temps.clamp_nozzle(material, iv) + if k == 'nozzle_temperature' else u1_temps.clamp_bed(material, iv)) + if not clean: + return filament_path + data = _flatten_filament_profile(filament_path) + if 'nozzle_temperature' in clean: + for key in _NOZZLE_SIBLINGS: + data[key] = [str(clean['nozzle_temperature'])] + if 'hot_plate_temp' in clean: + for key in _BED_SIBLINGS: + data[key] = [str(clean['hot_plate_temp'])] + data.setdefault('_u1_workflow_notes', []).append( + 'filament temperature overrides applied per operator form answers' + + (f' (material {material})' if material else '') + ': ' + + ', '.join(f'{k}={v}C' for k, v in sorted(clean.items())) + ) + out_dir.mkdir(parents=True, exist_ok=True) + temp = out_dir / f'{Path(filament_path).stem}__temp.json' + temp.write_text(json.dumps(data, indent=2)) + return temp + + def last_used_per_tool(nozzle: str | None = None, history_path: Path | None = None) -> dict[str, str]: """Return a {tool_id: print_settings_id} map for the most recent print on each tool that matches the given nozzle. @@ -1329,7 +1395,7 @@ def machine_profile_for_orca(orca_bin: Path = DEFAULT_ORCA) -> Path: return c return ROOT/'profiles/machine/snapmaker_u1_0_4_nozzle.json' -def real_orca_slice(oriented_stl: Path, out_gcode: Path, tool: str, material: str, profile: str, orca_bin: Path = DEFAULT_ORCA, nozzle: str = '0.4', process_path_override: Path | None = None)->dict[str,Any]: +def real_orca_slice(oriented_stl: Path, out_gcode: Path, tool: str, material: str, profile: str, orca_bin: Path = DEFAULT_ORCA, nozzle: str = '0.4', process_path_override: Path | None = None, filament_overrides: dict[str, Any] | None = None)->dict[str,Any]: out_gcode.parent.mkdir(parents=True, exist_ok=True) machine=machine_profile_for_orca(orca_bin) # process_path_override lets the caller pass an already-resolved process @@ -1345,6 +1411,11 @@ def real_orca_slice(oriented_stl: Path, out_gcode: Path, tool: str, material: st # gate rejected. Caught live 2026-06-25 in round 5 of testing. filament_resolved = filament_path(material, nozzle=nozzle) filament = _materialize_flat_filament(filament_resolved, out_gcode.parent, orca_bin=orca_bin) + # Apply the operator's bed/nozzle temperature overrides (Track C) on top of + # the flattened filament, clamped to the material's sourced envelope. No-op + # when the form carried no temp change. + if filament_overrides: + filament = apply_filament_overrides(filament, filament_overrides, out_gcode.parent, material=material) cmd=[ str(orca_bin), '--load-settings', f'{machine};{process}', diff --git a/scripts/u1_temps.py b/scripts/u1_temps.py new file mode 100644 index 0000000..b975595 --- /dev/null +++ b/scripts/u1_temps.py @@ -0,0 +1,109 @@ +"""Per-material temperature envelopes for the optional bed/nozzle overrides. + +Every bound here is SOURCED, not guessed: + +- Nozzle ranges start from Snapmaker's OWN U1 filament presets' declared + ``nozzle_temperature_range_low``/``high`` (manufacturer data, read off the + box's Orca vendor tree), widened by the union with published material guides + so a value that either source blesses is allowed. TPU's preset carries no + range, so its bounds come from the guides. +- The U1 hardware caps the hotend at 300 C and the heated bed at 100 C + (Snapmaker U1 published spec). Nothing is ever offered or accepted above the + hotend cap; the bed cap keeps a little headroom (110) so ABS/ASA aren't + rejected against Snapmaker's own 110 C profiles, since the firmware caps the + real bed temp at its physical limit regardless. +- BED HAS NO PER-MATERIAL MINIMUM. A cool/cold plate (or simply running the bed + off) is a legitimate setup, and a low bed only risks adhesion, never the + hardware. Only the bed MAXIMUM is gated; bed-off (0) is always allowed. + +Sources: Snapmaker U1 spec (snapmaker.com/snapmaker-u1/specs); Snapmaker on-box +filament presets; sovol3d and filamentcheatsheet material temperature guides. +""" +from __future__ import annotations + +# U1 hardware limits (published spec). Hard backstops the gate never exceeds. +HOTEND_MAX_C = 300 +# Bed headroom: the U1 spec bed max is 100 C, but Snapmaker's own ABS/ASA U1 +# profiles request 110 C, so the gate allows up to 110 and lets the firmware +# cap the real temp. Keeps the gate from rejecting a stock profile value. +BED_MAX_C = 110 + +# (nozzle_min, nozzle_max, bed_max) per material. bed_min is always 0. +# Union of Snapmaker's on-box preset ranges and the published guides. +_RANGES: dict[str, tuple[int, int, int]] = { + "PLA": (190, 240, 70), + "PETG": (230, 270, 90), + "ABS": (230, 280, 110), + "ASA": (240, 280, 110), + "TPU": (200, 250, 60), + "PLA-CF": (200, 250, 70), + "PETG-CF": (235, 275, 90), +} +# An unrecognized filament still gets a conservative envelope bounded only by the +# hardware caps (and a 170 C melt floor) so it can be nudged but never fried. +_FALLBACK = (170, HOTEND_MAX_C, BED_MAX_C) + + +def _key(material: str | None) -> str: + return (material or "").strip().upper() + + +def nozzle_range(material: str | None) -> tuple[int, int]: + """(min, max) nozzle temperature in C for a material, hardware-capped.""" + lo, hi, _bed = _RANGES.get(_key(material), _FALLBACK) + return (max(0, lo), min(hi, HOTEND_MAX_C)) + + +def bed_range(material: str | None) -> tuple[int, int]: + """(min, max) bed temperature in C. min is ALWAYS 0 (cold plate / bed off).""" + _lo, _hi, bed = _RANGES.get(_key(material), _FALLBACK) + return (0, min(bed, BED_MAX_C)) + + +def _clamp(val: float, lo: int, hi: int) -> int: + return max(lo, min(int(round(float(val))), hi)) + + +def clamp_nozzle(material: str | None, val: float) -> int: + lo, hi = nozzle_range(material) + return _clamp(val, lo, hi) + + +def clamp_bed(material: str | None, val: float) -> int: + lo, hi = bed_range(material) + return _clamp(val, lo, hi) + + +def nozzle_in_range(material: str | None, val: float) -> bool: + lo, hi = nozzle_range(material) + return lo <= float(val) <= hi + + +def bed_in_range(material: str | None, val: float) -> bool: + lo, hi = bed_range(material) + return lo <= float(val) <= hi + + +def _steps(lo: int, hi: int, step: int) -> list[int]: + """Inclusive step list from the first multiple of ``step`` at or above lo, + always including hi so the top of the range is reachable.""" + start = ((lo + step - 1) // step) * step + out = list(range(start, hi + 1, step)) + if not out or out[-1] != hi: + out.append(hi) + return out + + +def offered_nozzle(material: str | None) -> list[int]: + """Curated absolute nozzle values to offer in the form (10 C steps).""" + lo, hi = nozzle_range(material) + return _steps(lo, hi, 10) + + +def offered_bed(material: str | None) -> list[int]: + """Curated absolute bed values to offer: 0 (off) plus 10 C steps up to the + material's max. A cool/cold plate user picks a low value or off here.""" + _lo, hi = bed_range(material) + vals = [0] + [v for v in _steps(10, hi, 10) if v > 0] + # de-dup + sort (steps may re-add hi) + return sorted(set(vals)) diff --git a/tests/test_filament_temp_override.py b/tests/test_filament_temp_override.py new file mode 100644 index 0000000..eeca52b --- /dev/null +++ b/tests/test_filament_temp_override.py @@ -0,0 +1,118 @@ +"""Bed/nozzle temperature overrides (Track C). + +The override patches the FILAMENT profile (not the process profile), clamps to +the material's sourced envelope, writes every sibling temp key as a single- +element list, and lands in the sliced gcode. Unit tests cover the patch/clamp/ +cold-plate logic; the e2e proves Orca actually stamps the overridden temps. +""" +from __future__ import annotations + +import json +from pathlib import Path + +import numpy as np +import pytest + +import u1_slice_workflow as wf +from u1_orient import DEFAULT_ORCA, write_binary_stl + + +def _write_filament(tmp_path: Path, **over) -> Path: + base = { + "nozzle_temperature": ["245"], "nozzle_temperature_initial_layer": ["250"], + "hot_plate_temp": ["70"], "hot_plate_temp_initial_layer": ["70"], + "textured_plate_temp": ["0"], "textured_plate_temp_initial_layer": ["0"], + "eng_plate_temp": ["0"], "cool_plate_temp": ["0"], + "filament_type": ["PETG"], + } + base.update(over) + p = tmp_path / "fil.json" + p.write_text(json.dumps(base)) + return p + + +# ---------- unit: patch / clamp / cold-plate / no-op ---------- + +def test_override_patches_both_layers_and_all_bed_plates(tmp_path): + src = _write_filament(tmp_path) + out = wf.apply_filament_overrides( + src, {"nozzle_temperature": 235, "hot_plate_temp": 60}, tmp_path, material="PETG") + d = json.loads(out.read_text()) + assert d["nozzle_temperature"] == ["235"] + assert d["nozzle_temperature_initial_layer"] == ["235"] + for k in ("hot_plate_temp", "hot_plate_temp_initial_layer", "textured_plate_temp", + "textured_plate_temp_initial_layer", "eng_plate_temp", "cool_plate_temp"): + assert d[k] == ["60"], k + assert out != src + + +def test_override_clamps_out_of_range_to_material_envelope(tmp_path): + src = _write_filament(tmp_path) + # PETG nozzle max is 270, bed max 90 (u1_temps) + hot = wf.apply_filament_overrides(src, {"nozzle_temperature": 999, "hot_plate_temp": 999}, + tmp_path, material="PETG") + d = json.loads(hot.read_text()) + assert d["nozzle_temperature"] == ["270"] + assert d["hot_plate_temp"] == ["90"] + # below-range nozzle clamps up to the material min + low = wf.apply_filament_overrides(src, {"nozzle_temperature": 100}, tmp_path, material="PETG") + assert json.loads(low.read_text())["nozzle_temperature"] == ["230"] + + +def test_bed_off_cold_plate_is_honored(tmp_path): + src = _write_filament(tmp_path) + out = wf.apply_filament_overrides(src, {"hot_plate_temp": 0}, tmp_path, material="PETG") + d = json.loads(out.read_text()) + assert d["hot_plate_temp"] == ["0"] # bed off / cool plate never clamped up + assert d["cool_plate_temp"] == ["0"] + + +def test_noop_returns_original(tmp_path): + src = _write_filament(tmp_path) + assert wf.apply_filament_overrides(src, {}, tmp_path, material="PETG") == src + assert wf.apply_filament_overrides(src, {"bogus": 5}, tmp_path, material="PETG") == src + assert wf.apply_filament_overrides( + src, {"nozzle_temperature": "default"}, tmp_path, material="PETG") == src + + +# ---------- e2e: the override survives the real slicer ---------- + +_PROFILE = "0_20_standard_snapmaker_u1_0_4_nozzle" + + +def _cube_tris(s: float) -> np.ndarray: + v = np.array([[0, 0, 0], [s, 0, 0], [s, s, 0], [0, s, 0], + [0, 0, s], [s, 0, s], [s, s, s], [0, s, s]], dtype=np.float32) + faces = [(0, 3, 2), (0, 2, 1), (4, 5, 6), (4, 6, 7), (0, 1, 5), (0, 5, 4), + (1, 2, 6), (1, 6, 5), (2, 3, 7), (2, 7, 6), (3, 0, 4), (3, 4, 7)] + return np.array([[v[a], v[b], v[c]] for a, b, c in faces], dtype=np.float32) + + +def _gcode_config(text: str) -> dict[str, str]: + out: dict[str, str] = {} + for line in text.splitlines(): + if line.startswith("; ") and " = " in line: + key, _, value = line[2:].partition(" = ") + out[key.strip()] = value.strip() + return out + + +@pytest.mark.skipif(not DEFAULT_ORCA.exists(), + reason="extracted Orca binary not present in this environment") +def test_temp_override_lands_in_sliced_gcode(tmp_path): + try: + wf.profile_path(_PROFILE) + wf.filament_path("PETG", nozzle="0.4") + except RuntimeError as exc: + pytest.skip(f"runtime profiles not fetched here ({exc})") + out_dir = tmp_path / "slice" + out_dir.mkdir() + stl = tmp_path / "cube.stl" + write_binary_stl(stl, _cube_tris(15.0), name="cube") + res = wf.real_orca_slice( + stl, out_dir / "cube.gcode", tool="T0", material="PETG", profile=_PROFILE, + filament_overrides={"nozzle_temperature": 235, "hot_plate_temp": 60}) + cfg = _gcode_config(Path(res["gcode"]).read_text(errors="replace")) + assert cfg, "no config block in sliced gcode" + assert cfg.get("nozzle_temperature") == "235", cfg.get("nozzle_temperature") + assert cfg.get("hot_plate_temp") == "60", cfg.get("hot_plate_temp") diff --git a/tests/test_u1_temps.py b/tests/test_u1_temps.py new file mode 100644 index 0000000..b84b939 --- /dev/null +++ b/tests/test_u1_temps.py @@ -0,0 +1,79 @@ +"""Temperature envelopes for the bed/nozzle overrides (Track C). + +Every material's bounds are sourced (Snapmaker on-box presets + published +guides + U1 hardware spec). These tests pin the sourced numbers and the two +load-bearing safety properties: nothing exceeds the U1 hardware caps, and the +bed has NO minimum (a cool/cold plate or bed-off must never be blocked). +""" +from __future__ import annotations + +import pytest + +import u1_temps as t + + +def test_nozzle_ranges_match_sourced_table(): + assert t.nozzle_range("PLA") == (190, 240) + assert t.nozzle_range("PETG") == (230, 270) + assert t.nozzle_range("ABS") == (230, 280) + assert t.nozzle_range("ASA") == (240, 280) + assert t.nozzle_range("TPU") == (200, 250) + assert t.nozzle_range("PLA-CF") == (200, 250) + assert t.nozzle_range("PETG-CF") == (235, 275) + + +def test_bed_ranges_have_no_minimum_and_sourced_max(): + # cold plate / bed-off: the MIN is always 0, for every material + for m in ("PLA", "PETG", "ABS", "ASA", "TPU", "PLA-CF", "PETG-CF"): + assert t.bed_range(m)[0] == 0, f"{m} bed must allow off (min 0)" + assert t.bed_range("PLA")[1] == 70 + assert t.bed_range("PETG")[1] == 90 + assert t.bed_range("ABS")[1] == 110 + assert t.bed_range("ASA")[1] == 110 + assert t.bed_range("TPU")[1] == 60 + + +def test_nothing_exceeds_u1_hardware_caps(): + for m in ("PLA", "PETG", "ABS", "ASA", "TPU", "PLA-CF", "PETG-CF", "MYSTERY"): + assert t.nozzle_range(m)[1] <= t.HOTEND_MAX_C == 300 + assert t.bed_range(m)[1] <= t.BED_MAX_C == 110 + assert all(v <= 300 for v in t.offered_nozzle(m)) + assert all(v <= 110 for v in t.offered_bed(m)) + + +def test_unknown_material_gets_safe_fallback(): + lo, hi = t.nozzle_range("SOME-EXOTIC-PA-CF") + assert lo == 170 and hi == 300 # melt floor .. hotend cap + assert t.bed_range("SOME-EXOTIC-PA-CF") == (0, 110) + + +def test_clamp_pins_to_the_envelope(): + assert t.clamp_nozzle("PLA", 999) == 240 # above PLA max + assert t.clamp_nozzle("PLA", 100) == 190 # below PLA min + assert t.clamp_nozzle("PLA", 210) == 210 # in range + assert t.clamp_bed("PLA", 999) == 70 # above PLA bed max + assert t.clamp_bed("PETG", 0) == 0 # bed off always ok + assert t.clamp_nozzle("ABS", 350) == 280 # never past a material max + # even a wild ABS request can't beat the 300 hotend cap via fallback path + assert t.clamp_nozzle("MYSTERY", 500) == 300 + + +def test_in_range_predicates(): + assert t.nozzle_in_range("PETG", 250) + assert not t.nozzle_in_range("PETG", 300) + assert t.bed_in_range("TPU", 0) # off + assert t.bed_in_range("TPU", 60) + assert not t.bed_in_range("TPU", 80) # above TPU bed max + + +def test_offered_values_are_in_range_and_include_off_for_bed(): + for m in ("PLA", "PETG", "ABS", "ASA", "TPU", "PLA-CF", "PETG-CF"): + nlo, nhi = t.nozzle_range(m) + noz = t.offered_nozzle(m) + assert noz and all(nlo <= v <= nhi for v in noz) + assert nhi in noz # top of range reachable + bed = t.offered_bed(m) + assert 0 in bed # bed-off offered (cold plate) + _blo, bhi = t.bed_range(m) + assert all(0 <= v <= bhi for v in bed) + assert bhi in bed From 7c0ff0d908f075144e92016fabf1734930f78cc7 Mon Sep 17 00:00:00 2001 From: bbolinger <14969048+bbolinger@users.noreply.github.com> Date: Thu, 16 Jul 2026 03:14:58 +0000 Subject: [PATCH 2/5] Wire the temperature controls through the form and workflow The form half of the bed/nozzle temperature override: - u1_form: a Temperature category with material-dynamic Nozzle and Bed controls. The schema carries each material's current temp and sourced range, so the renderer resolves the header and shows only in-range options. Parse validates the pick against the material range and maps it to filament_overrides; an out-of-range pick fails loudly. - u1_form_telegram: material-dynamic rendering (current temp in the header, out-of-range options hidden, bed-off offered for a cool or cold plate), plus a reset of any temp pick when the head changes. - u1_kit_workflow: resolves the per-material current temps and threads the filament override into the slice. - u1_arrange: applies the filament override before slicing. Tests cover the schema, parse validation, and the material-dynamic renderer. The readiness review already sweeps nozzle and bed temp, so an override shows there as a change automatically. --- adapters/telegram/u1_form_telegram.py | 58 +++++++++++++- scripts/u1_arrange.py | 6 ++ scripts/u1_form.py | 103 +++++++++++++++++++++++- scripts/u1_kit_workflow.py | 42 ++++++++++ tests/test_advanced_settings.py | 5 +- tests/test_temp_form.py | 109 ++++++++++++++++++++++++++ tests/test_u1_kit_workflow.py | 6 +- 7 files changed, 320 insertions(+), 9 deletions(-) create mode 100644 tests/test_temp_form.py diff --git a/adapters/telegram/u1_form_telegram.py b/adapters/telegram/u1_form_telegram.py index ad8bd59..a577db6 100644 --- a/adapters/telegram/u1_form_telegram.py +++ b/adapters/telegram/u1_form_telegram.py @@ -216,6 +216,26 @@ def _resolved_for_selected_profile(form: dict[str, Any]) -> dict[str, str]: return amap.get(str(pid)) or {} +def _selected_material(form: dict[str, Any]) -> str | None: + """The material the operator picked — from the head option (live tool map + carries each head's material) or the offline Material field. Drives the + temperature controls' current value + in-range gating.""" + fields = form["schema"]["fields"] + tool = next((f for f in fields if f["id"] == "tool"), None) + if tool is not None: + sel = form["selections"].get("tool") + if sel is not None: + mat = tool["options"][sel].get("material") + if mat: + return mat + matf = next((f for f in fields if f["id"] == "material"), None) + if matf is not None: + sel = form["selections"].get("material") + if sel is not None: + return _opt_id(matf["options"][sel]) + return None + + def _screen_of(form: dict[str, Any], fid: str) -> list[dict[str, Any]]: """The screen (list of fields) that renders together with ``fid``.""" for screen in _screens(form): @@ -335,18 +355,37 @@ def _field_control_rows(form: dict[str, Any], field: dict[str, Any], # repeat it (live 2026-07-15: "Infill 10% / Infill 15% / ..." doubled the # text and truncated wider labels at two-up). The default option is first # in every advanced field, so the header row lands before the values. + _range = None + if field.get("material_dynamic"): + # Temperature controls: resolve the CURRENT temp + the in-range window + # from the loaded material (not the process profile). Options outside + # the material's sourced envelope are hidden so a mis-tap can't offer + # a dangerous temp for that filament. + _mat = _selected_material(form) + _cur = ((form["schema"].get("temp_current_by_material") or {}).get(_mat) or {}).get(field["id"]) + if _cur is not None: + resolved_default = f"{_cur}°C" + _range = ((form["schema"].get("temp_range_by_material") or {}).get(_mat) or {}).get(field["id"]) alts: list[dict[str, str]] = [] for oi, opt in enumerate(field["options"]): + oid = _opt_id(opt) mark = "● " if sel == oi else "○ " - if _opt_id(opt) == "default": + if oid == "default": head = _opt_label(opt) if resolved_default: head = head.replace("profile default", f"keep profile ({resolved_default})") rows.append([{"text": f"{mark}{head}", "callback_data": f"s:{fi}:{oi}"}]) - else: - short = opt.get("short") or _opt_label(opt) - alts.append({"text": f"{mark}{short}", "callback_data": f"s:{fi}:{oi}"}) + continue + if _range is not None: + try: + _v = int(oid) + except (TypeError, ValueError): + _v = None + if _v is not None and not (_range[0] <= _v <= _range[1]): + continue # out of the material's range — hidden + short = opt.get("short") or _opt_label(opt) + alts.append({"text": f"{mark}{short}", "callback_data": f"s:{fi}:{oi}"}) for i in range(0, len(alts), 3): rows.append(alts[i:i + 3]) return rows @@ -696,6 +735,17 @@ def _apply_callback_inner(form: dict[str, Any], data: str) -> dict[str, Any]: return {"kind": "rerender", "warning": f"Stale button (option {oi} out of range for {_esc(repr(fid))})."} form["selections"][fid] = oi + # Changing the head/material re-bases the temperature controls (their + # valid range + current temp are per material), so a temp picked for the + # old filament must not silently survive — reset the material_dynamic + # fields to their profile default. + if fid in ("tool", "material"): + for f in fields: + if f.get("material_dynamic"): + di = next((i for i, o in enumerate(f["options"]) + if _opt_id(o) == "default"), None) + if di is not None: + form["selections"][f["id"]] = di # A single-select on its own screen advances on tap; one inside a # group is a radio — it only marks, the shared Next advances. if not field.get("group"): diff --git a/scripts/u1_arrange.py b/scripts/u1_arrange.py index 8995114..8132880 100644 --- a/scripts/u1_arrange.py +++ b/scripts/u1_arrange.py @@ -52,6 +52,7 @@ profile_path, filament_path, _materialize_flat_filament, + apply_filament_overrides, rewrite_gcode_for_tool, _tool_to_index, ) @@ -418,6 +419,7 @@ def arrange_slice( allow_rotations: bool = True, orca_bin: Path = DEFAULT_ORCA, process_path_override: Path | None = None, + filament_overrides: dict[str, Any] | None = None, runner: Callable[[list[str], Path], subprocess.CompletedProcess] | None = None, bed_mm: tuple[float, float] = _BED_MM, bed_margin_mm: float = _BED_MARGIN_MM, @@ -461,6 +463,10 @@ def arrange_slice( filament = _materialize_flat_filament( filament_path(material, nozzle=nozzle), out_dir, orca_bin=orca_bin ) + # Bed/nozzle temperature override (Track C), clamped to the material's + # envelope. No-op when the form carried no temp change. + if filament_overrides: + filament = apply_filament_overrides(filament, filament_overrides, out_dir, material=material) tool_idx = _tool_to_index(tool) def _clean_plate_gcodes(in_dir: Path) -> None: diff --git a/scripts/u1_form.py b/scripts/u1_form.py index b8bd8ef..a27d333 100644 --- a/scripts/u1_form.py +++ b/scripts/u1_form.py @@ -49,6 +49,8 @@ import re from typing import Any +import u1_temps # per-material temperature envelopes (bed/nozzle override gate) + REQUIRED_FIELDS = ("tool", "material", "profile") _ORIENT_AUTO = {"auto", "auto-orient", "autoorient"} @@ -155,6 +157,7 @@ ("strength", "\U0001f9f1 Strength & shells"), ("first_layer", "\U0001f321️ First layer & adhesion"), ("finish", "✨ Surface finish"), + ("temperature", "\U0001f525 Temperature"), ("supports", "\U0001fa9c Supports"), ) _ADVANCED_CATEGORY = { @@ -162,6 +165,7 @@ "one_wall_top": "strength", "infill": "strength", "infill_pattern": "strength", "brim": "first_layer", "raft": "first_layer", "fuzzy": "finish", + "nozzle_temp": "temperature", "bed_temp": "temperature", "support_style": "supports", } @@ -226,6 +230,36 @@ def resolve_advanced_from_profile(flat_process: dict[str, Any]) -> dict[str, str return out +# Temperature overrides (Track C). Filament nozzle + bed temps, offered as +# ABSOLUTE values in the "temperature" category. Unlike the process ADVANCED_ +# FIELDS (static options), the CURRENT value and the in-range options depend on +# the loaded MATERIAL, so the span here is the union across materials — u1_temps +# decides which apply per material and the renderer hides the rest — the +# "keep profile (X C)" header resolves per material at render time, and parse +# validates the pick against the material's range. base_key is the filament +# profile key the workflow maps the answer onto (see u1_slice_workflow +# .FILAMENT_OVERRIDE_KEYS). +_NOZZLE_SPAN = tuple(range(190, 281, 10)) # 190..280 covers every material +_BED_SPAN = (40, 50, 60, 70, 80, 90, 100, 110) # plus off (0) offered below + +TEMP_FIELDS = ( + ("nozzle_temp", "Nozzle temperature", "nozzle_temperature", + (("default", "Nozzle: profile default"),) + + tuple((str(v), f"Nozzle {v}°C") for v in _NOZZLE_SPAN)), + ("bed_temp", "Bed temperature", "hot_plate_temp", + (("default", "Bed: profile default"), ("0", "Bed: off")) + + tuple((str(v), f"Bed {v}°C") for v in _BED_SPAN)), +) +_TEMP_BY_ID = {fid: base_key for fid, _lbl, base_key, _opts in TEMP_FIELDS} + +# Bare labels for the temp buttons under the per-setting header (mirrors +# _ADVANCED_SHORT for the process controls). +_ADVANCED_SHORT.update({ + "nozzle_temp": {str(v): f"{v}°C" for v in _NOZZLE_SPAN}, + "bed_temp": {"0": "off", **{str(v): f"{v}°C" for v in _BED_SPAN}}, +}) + + # Quantity (v2.3): print N copies of a SINGLE-part job. The workflow sets # spec["offer_quantity"] only when the kit has one part — per-part quantities # on multi-part kits are out of scope (the operator picks parts instead). The @@ -559,6 +593,32 @@ def _finalize(values: dict[str, Any], spec: dict[str, Any], errors: list[str], if "material" not in values and values.get("tool") in tool_materials: values["material"] = tool_materials[values["tool"]] + # Temperature overrides (Track C): now that the material is resolved, validate + # the operator's absolute nozzle/bed picks against that material's sourced + # envelope and map them to filament_overrides {orca_key: int C}. The parsers + # stash the raw picks in "_temp_answers"; bed 0 (cool/cold plate, bed off) is + # valid; an out-of-range pick fails loudly rather than being silently clamped. + _temp_raw = values.pop("_temp_answers", None) or {} + _t_material = values.get("material") + for _tfid, _traw in _temp_raw.items(): + _tbase = _TEMP_BY_ID.get(_tfid) + if _tbase is None or _traw in (None, "", "default", "keep"): + continue + try: + _tiv = int(round(float(_traw))) + except (TypeError, ValueError): + errors.append(f"invalid {_tfid} value {_traw!r}") + continue + if _tbase == "nozzle_temperature": + _tok, (_tlo, _thi) = u1_temps.nozzle_in_range(_t_material, _tiv), u1_temps.nozzle_range(_t_material) + else: + _tok, (_tlo, _thi) = u1_temps.bed_in_range(_t_material, _tiv), u1_temps.bed_range(_t_material) + if not _tok: + errors.append(f"{_tfid} {_tiv}C is out of range for " + f"{_t_material or 'this material'} ({_tlo}-{_thi}C)") + else: + values.setdefault("filament_overrides", {})[_tbase] = _tiv + for f in REQUIRED_FIELDS: if f not in values: errors.append(f"missing required field: {f}") @@ -962,7 +1022,10 @@ def build_form_schema(spec: dict[str, Any], *, submit: dict[str, str] | None = N if heads: fields.append({"id": "tool", "type": "single_select", "label": "Print head", "group": _GROUP, "group_label": _GLABEL, - "options": [{"id": h["tool"], "label": _head_label(h)} for h in heads], + # carry each head's material so the temperature controls + # can resolve the loaded material's range/current temp + "options": [{"id": h["tool"], "label": _head_label(h), + "material": h.get("material")} for h in heads], "required": True}) else: tools = [str(t).upper() for t in spec.get("tools", [])] @@ -1038,6 +1101,22 @@ def build_form_schema(spec: dict[str, Any], *, submit: dict[str, str] | None = N "group_label": "Advanced settings", "category": _ADVANCED_CATEGORY.get(_fid, "finish"), }) + # Temperature controls (Track C). Advanced fields like the rest, but + # material_dynamic: the renderer resolves the current temp + shows only + # the loaded material's in-range options. base_key maps the answer to the + # filament-profile key the workflow overrides. + for _fid, _lbl, _base, _opts in TEMP_FIELDS: + _shorts = _ADVANCED_SHORT.get(_fid, {}) + fields.append({ + "id": _fid, "type": "single_select", "label": _lbl, + "options": [{"id": oid, "label": olbl, "short": _shorts.get(oid, olbl)} + for oid, olbl in _opts], + "default": "default", "required": False, + "advanced": True, "group": "advanced", + "group_label": "Advanced settings", + "category": "temperature", + "material_dynamic": True, "base_key": _base, + }) # v2.2 (kit refinement): NO action field. The form only collects the PLAN. # The single print/keep-staged decision happens AFTER slice + a FRESH bed # photo (you decide with the real bed in view) — not up front, before the @@ -1062,6 +1141,22 @@ def build_form_schema(spec: dict[str, Any], *, submit: dict[str, str] | None = N _resolved = spec.get("advanced_resolved") if _resolved: schema["advanced_resolved"] = {str(k): v for k, v in _resolved.items()} + # Per-material current filament temps (keyed by material) so the + # temperature controls can show "keep profile (X C)" and resolve the + # material's range for whichever head is picked. Shape: + # {material: {"nozzle_temp": , "bed_temp": }}. + _tcm = spec.get("temp_current_by_material") + if _tcm: + schema["temp_current_by_material"] = { + str(k): v for k, v in _tcm.items()} + # Per-material [min, max] so the renderer shows only in-range temp + # options for the loaded material (u1_temps, the sourced gate). Keeps + # the renderer dependency-free — it just filters to these bounds. + schema["temp_range_by_material"] = { + str(m): {"nozzle_temp": list(u1_temps.nozzle_range(m)), + "bed_temp": list(u1_temps.bed_range(m))} + for m in _tcm + } if submit: schema["submit"] = submit return schema @@ -1180,6 +1275,12 @@ def parse_answers_json(obj: dict[str, Any], spec: dict[str, Any]) -> dict[str, A errors.append(f"unknown {_fid} option {raw!r}") else: values.setdefault("overrides", {})[_orca_key] = mapped + # Temperature picks stash raw; _finalize validates them once the + # material is resolved (their valid range is per material). + _ta = {fid: obj.get(fid) for fid in _TEMP_BY_ID + if obj.get(fid) not in (None, "", "default")} + if _ta: + values["_temp_answers"] = _ta return _finalize(values, spec, errors) diff --git a/scripts/u1_kit_workflow.py b/scripts/u1_kit_workflow.py index 0d7211c..02658e2 100644 --- a/scripts/u1_kit_workflow.py +++ b/scripts/u1_kit_workflow.py @@ -2122,6 +2122,38 @@ def _build_form_spec(kit: dict[str, Any], nozzle: str, heads = u1_toolmap.load_head_options() except Exception: heads = [] + # Track C: current filament nozzle/bed temp per material, so the temperature + # controls can show "keep profile (X C)" for whichever head is loaded. Each + # material resolves from its filament profile (flattened); fully guarded. + _temp_by_material: dict[str, dict[str, int]] = {} + try: + import u1_slice_workflow as _sw + + def _first_int(_d, _k): + _v = _d.get(_k) + if isinstance(_v, list): + _v = _v[0] if _v else None + try: + return int(round(float(_v))) + except (TypeError, ValueError): + return None + + for _m in DEFAULT_MATERIALS: + try: + _flat = _sw._flatten_filament_profile(_sw.filament_path(_m, nozzle=nozzle)) + except Exception: + continue + _entry = {} + _n = _first_int(_flat, "nozzle_temperature") + _b = _first_int(_flat, "hot_plate_temp") + if _n is not None: + _entry["nozzle_temp"] = _n + if _b is not None: + _entry["bed_temp"] = _b + if _entry: + _temp_by_material[_m] = _entry + except Exception: + pass spec: dict[str, Any] = { "parts": parts, "tools": DEFAULT_TOOLS, @@ -2139,6 +2171,8 @@ def _build_form_spec(kit: dict[str, Any], nozzle: str, # Per-profile current advanced values for the tweak menu (empty on the # persisted/answer path, which renders no form). "advanced_resolved": _adv_resolved, + # Track C: current filament temps per material for the temperature controls. + "temp_current_by_material": _temp_by_material, } # v2.3: quantity (print N copies) — offered ONLY for single-part jobs. # Multi-part kits keep per-part quantities out of scope; the operator @@ -5465,6 +5499,13 @@ def _commit_kit_legacy(args, request_id, operator, out_dir, events_file, _audit(request_id, "advanced_overrides_applied", operator, **{k: str(v) for k, v in adv_overrides.items()}) + # Track C: bed/nozzle temperature overrides ride the filament rail (clamped + # to the material envelope inside apply_filament_overrides), applied at slice. + temp_overrides = values.get("filament_overrides") or {} + if temp_overrides: + _audit(request_id, "filament_temp_overrides_applied", operator, + material=material, **{k: str(v) for k, v in temp_overrides.items()}) + slice_out = out_dir / "slice" _emit(events_file, {"stage": "kit_slicing", "request_id": request_id, "parts": len(selected_paths), "auto_orient": auto_orient}, json_events) @@ -5474,6 +5515,7 @@ def _commit_kit_legacy(args, request_id, operator, out_dir, events_file, tool=tool, material=material, profile=profile_slug, nozzle=nozzle, auto_orient=auto_orient, allow_rotations=True, process_path_override=process, + filament_overrides=temp_overrides, ) except Exception as exc: ev = _classify_slice_failure( diff --git a/tests/test_advanced_settings.py b/tests/test_advanced_settings.py index 78a1ec2..f4c38bd 100644 --- a/tests/test_advanced_settings.py +++ b/tests/test_advanced_settings.py @@ -74,7 +74,7 @@ def test_schema_offers_advanced_fields_flagged(): adv = [f for f in schema["fields"] if f.get("advanced")] assert [f["id"] for f in adv] == ["infill", "infill_pattern", "walls", "brim", "fuzzy", "top_shell", "bottom_shell", "one_wall_top", "raft", - "support_style"] + "support_style", "nozzle_temp", "bed_temp"] assert all(f["group"] == "advanced" and f["default"] == "default" for f in adv) # not offered -> absent entirely schema2 = u1_form.build_form_schema(_spec(offer=False)) @@ -206,7 +206,8 @@ def test_advanced_buttons_self_describing_and_review_not_duplicated(): key = {"infill": "Infill", "infill_pattern": "Pattern", "walls": "Walls", "brim": "Brim", "fuzzy": "Fuzzy", "support_style": "Support", "top_shell": "Top", "bottom_shell": "Bottom", - "one_wall_top": "One wall", "raft": "Raft"}[f["id"]] + "one_wall_top": "One wall", "raft": "Raft", + "nozzle_temp": "Nozzle", "bed_temp": "Bed"}[f["id"]] assert all(key in tg._opt_label(o) for o in f["options"]), f["id"] # Review: exactly ONE advanced-related button (opens the tweak menu), and # NO per-advanced-field Edit rows. diff --git a/tests/test_temp_form.py b/tests/test_temp_form.py new file mode 100644 index 0000000..7944bd9 --- /dev/null +++ b/tests/test_temp_form.py @@ -0,0 +1,109 @@ +"""Temperature controls in the form (Track C): schema, parse validation, and the +material-dynamic renderer (current temp resolved from the loaded material, only +that material's in-range options shown, temp reset when the head changes).""" +from __future__ import annotations + +import sys +from pathlib import Path + +import u1_form + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "adapters" / "telegram")) +import u1_form_telegram as tg # noqa: E402 + + +def _spec(): + return { + "heads": [{"tool": "T0", "material": "PLA", "color": "red"}, + {"tool": "T1", "material": "PETG", "color": "black"}], + "tool_materials": {"T0": "PLA", "T1": "PETG"}, + "profiles": [{"idx": 1, "label": "0.20 Standard"}], + "supports": ["supports", "no-supports"], "actions": ["start", "upload-only"], + "offer_advanced": True, + "temp_current_by_material": {"PLA": {"nozzle_temp": 220, "bed_temp": 55}, + "PETG": {"nozzle_temp": 245, "bed_temp": 70}}, + } + + +# ---------- schema ---------- + +def test_schema_offers_temperature_fields_and_ranges(): + sch = u1_form.build_form_schema(_spec()) + temp = [f for f in sch["fields"] if f.get("category") == "temperature"] + assert [f["id"] for f in temp] == ["nozzle_temp", "bed_temp"] + assert all(f.get("material_dynamic") and f.get("advanced") for f in temp) + assert {"key": "temperature", "label": "\U0001f525 Temperature"} in sch["advanced_categories"] + assert sch["temp_range_by_material"]["PLA"]["nozzle_temp"] == [190, 240] + assert sch["temp_range_by_material"]["PLA"]["bed_temp"] == [0, 70] + + +# ---------- parse ---------- + +def test_parse_in_range_temp_becomes_filament_override(): + res = u1_form.parse_answers_json( + {"tool": "T0", "profile": 1, "nozzle_temp": "230", "bed_temp": "0"}, _spec()) + assert res["ok"], res["errors"] + assert res["values"]["filament_overrides"] == {"nozzle_temperature": 230, "hot_plate_temp": 0} + + +def test_parse_out_of_range_temp_fails_loudly(): + res = u1_form.parse_answers_json( + {"tool": "T0", "profile": 1, "nozzle_temp": "280"}, _spec()) # PLA max 240 + assert not res["ok"] + assert any("out of range for PLA" in e for e in res["errors"]) + + +def test_parse_keep_default_is_no_override(): + res = u1_form.parse_answers_json( + {"tool": "T0", "profile": 1, "nozzle_temp": "default"}, _spec()) + assert res["ok"] and "filament_overrides" not in res["values"] + + +# ---------- renderer (material-dynamic) ---------- + +def _pick_head(form, tool_id): + tfi = tg._field_index(form, "tool") + oi = next(i for i, o in enumerate(tg._field(form, "tool")["options"]) + if tg._opt_id(o) == tool_id) + tg.apply_callback(form, f"s:{tfi}:{oi}") + + +def test_temp_page_resolves_current_and_hides_out_of_range_for_material(): + form = tg.new_form(u1_form.build_form_schema(_spec())) + _pick_head(form, "T0") # PLA + form["current"] = "nozzle_temp" + kb = tg.render_screen(form)["keyboard"] + assert "keep profile (220°C)" in kb[0][0]["text"] # PLA current + vals = [b["text"] for r in kb for b in r if b["callback_data"].startswith("s:")] + assert any("190°C" in t for t in vals) and any("240°C" in t for t in vals) + assert not any("250°C" in t or "270°C" in t for t in vals) # above PLA range, hidden + # switch to PETG -> range + current follow + _pick_head(form, "T1") + form["current"] = "nozzle_temp" + kb2 = tg.render_screen(form)["keyboard"] + assert "keep profile (245°C)" in kb2[0][0]["text"] + vals2 = [b["text"] for r in kb2 for b in r if b["callback_data"].startswith("s:")] + assert any("270°C" in t for t in vals2) # in PETG range now + assert not any("190°C" in t for t in vals2) # below PETG range, hidden + + +def test_bed_control_offers_off_for_cold_plate(): + form = tg.new_form(u1_form.build_form_schema(_spec())) + _pick_head(form, "T0") + form["current"] = "bed_temp" + kb = tg.render_screen(form)["keyboard"] + assert any("off" in b["text"] for r in kb for b in r) # bed-off / cold plate + + +def test_changing_head_resets_a_stale_temp_pick(): + form = tg.new_form(u1_form.build_form_schema(_spec())) + _pick_head(form, "T0") # PLA + # pick PLA nozzle 190 (below PETG's 230 min) + nfi = tg._field_index(form, "nozzle_temp") + oi = next(i for i, o in enumerate(tg._field(form, "nozzle_temp")["options"]) + if tg._opt_id(o) == "190") + tg.apply_callback(form, f"s:{nfi}:{oi}") + assert tg._opt_id(tg._field(form, "nozzle_temp")["options"][form["selections"]["nozzle_temp"]]) == "190" + # switch head to PETG -> the stale 190 pick resets to default + _pick_head(form, "T1") + assert tg._opt_id(tg._field(form, "nozzle_temp")["options"][form["selections"]["nozzle_temp"]]) == "default" diff --git a/tests/test_u1_kit_workflow.py b/tests/test_u1_kit_workflow.py index fba3094..0876341 100644 --- a/tests/test_u1_kit_workflow.py +++ b/tests/test_u1_kit_workflow.py @@ -80,7 +80,8 @@ def fake_profiles(monkeypatch): def fake_slice_upload(monkeypatch): """Mock arrange-slice (writes plate files) + upload + profile resolution.""" def fake_arrange(paths, out_dir, *, tool, material, profile, nozzle, - auto_orient, allow_rotations, process_path_override=None): + auto_orient, allow_rotations, process_path_override=None, + filament_overrides=None): out_dir = Path(out_dir) out_dir.mkdir(parents=True, exist_ok=True) plates = [] @@ -424,7 +425,8 @@ def fake_upload(gcode, on_collision=None, material=None): def _fake_arrange(monkeypatch, n_plates=1): def fake_arrange(paths, out_dir, *, tool, material, profile, nozzle, - auto_orient, allow_rotations, process_path_override=None): + auto_orient, allow_rotations, process_path_override=None, + filament_overrides=None): out_dir = Path(out_dir) out_dir.mkdir(parents=True, exist_ok=True) plates = [] From 48bcf95af2f4cd9732480c130f2a633ca4a9ac19 Mon Sep 17 00:00:00 2001 From: bbolinger <14969048+bbolinger@users.noreply.github.com> Date: Thu, 16 Jul 2026 03:16:44 +0000 Subject: [PATCH 3/5] Prove the temp override on the kit arrange-slice path too The kit slices through arrange_slice, not real_orca_slice, so add an e2e that slices a cube through arrange_slice with a temp override and asserts the overridden nozzle/bed land in the plate gcode. --- tests/test_filament_temp_override.py | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/tests/test_filament_temp_override.py b/tests/test_filament_temp_override.py index eeca52b..3e660d3 100644 --- a/tests/test_filament_temp_override.py +++ b/tests/test_filament_temp_override.py @@ -116,3 +116,28 @@ def test_temp_override_lands_in_sliced_gcode(tmp_path): assert cfg, "no config block in sliced gcode" assert cfg.get("nozzle_temperature") == "235", cfg.get("nozzle_temperature") assert cfg.get("hot_plate_temp") == "60", cfg.get("hot_plate_temp") + + +@pytest.mark.skipif(not DEFAULT_ORCA.exists(), + reason="extracted Orca binary not present in this environment") +def test_temp_override_lands_via_kit_arrange_slice(tmp_path): + """The KIT path slices through u1_arrange.arrange_slice, not real_orca_slice, + so prove the override lands there too (this is the path the operator's kit + actually takes).""" + import u1_arrange + try: + wf.profile_path(_PROFILE) + wf.filament_path("PETG", nozzle="0.4") + except RuntimeError as exc: + pytest.skip(f"runtime profiles not fetched here ({exc})") + out_dir = tmp_path / "slice" + out_dir.mkdir() + stl = tmp_path / "cube.stl" + write_binary_stl(stl, _cube_tris(15.0), name="cube") + res = u1_arrange.arrange_slice( + [stl], out_dir, tool="T0", material="PETG", profile=_PROFILE, + filament_overrides={"nozzle_temperature": 235, "hot_plate_temp": 60}) + plate = Path(res["plates"][0]["gcode_path"]) + cfg = _gcode_config(plate.read_text(errors="replace")) + assert cfg.get("nozzle_temperature") == "235", cfg.get("nozzle_temperature") + assert cfg.get("hot_plate_temp") == "60", cfg.get("hot_plate_temp") From f65d9644c655fcf97a00e30a625bf838787ca988 Mon Sep 17 00:00:00 2001 From: bbolinger <14969048+bbolinger@users.noreply.github.com> Date: Thu, 16 Jul 2026 10:36:04 +0000 Subject: [PATCH 4/5] Make the temperature controls a +/- stepper for exact entry The step buttons only offered 10C increments. Replace them with a one-row stepper (-5, -, +, +5) so the operator dials any exact temperature. A full-width header shows the setting and the current target, and tapping it keeps the profile value. The dialed value is clamped to the material's sourced range and lives in form["temp"], separate from the option selections, so nothing indexes an option list with a temperature. Review, the changed count, and the answer payload read the stepper target; changing the head clears it. New T: callback prefix added to the gateway routing pattern. --- adapters/hermes/plugin/telegram_patch.py | 8 +- adapters/telegram/u1_form_telegram.py | 104 +++++++++++++++++------ tests/test_temp_form.py | 68 +++++++++------ 3 files changed, 123 insertions(+), 57 deletions(-) diff --git a/adapters/hermes/plugin/telegram_patch.py b/adapters/hermes/plugin/telegram_patch.py index 6e61e34..e9034eb 100644 --- a/adapters/hermes/plugin/telegram_patch.py +++ b/adapters/hermes/plugin/telegram_patch.py @@ -46,9 +46,11 @@ # a native Hermes callback (multi-char prefix + colon) can never match. # S:: is a submit-verb (sets the Action option, then submits). # g: is the Advanced-options menu (g:m menu, g:d done, g:r reset, -# g:c: open a category) — a SINGLE-char prefix, so it stays inside -# the form vocabulary and never shadows a native multi-char callback. -FORM_CB_PATTERN = r"^(?:[tsp]:\d+:\d+|S:\d+:\d+|[azne]:\d+|g:(?:[mdr]|c:[a-z_]+)|[SX])$" +# g:c: open a category); T:: is the temperature stepper +# (T:5:-5 / T:5:-1 / T:5:1 / T:5:5 step, T:5:k keep). Both SINGLE-char prefixes, +# so they stay inside the form vocabulary and never shadow a native multi-char +# callback. +FORM_CB_PATTERN = r"^(?:[tsp]:\d+:\d+|S:\d+:\d+|[azne]:\d+|g:(?:[mdr]|c:[a-z_]+)|T:\d+:(?:-?\d+|k)|[SX])$" # Grace-window cancel button on the countdown DM (u1c:). Handled # HERE — the gateway adapter layer — because a typed CANCEL that lands while diff --git a/adapters/telegram/u1_form_telegram.py b/adapters/telegram/u1_form_telegram.py index a577db6..45c2a75 100644 --- a/adapters/telegram/u1_form_telegram.py +++ b/adapters/telegram/u1_form_telegram.py @@ -193,8 +193,13 @@ def _adv_bucket_of(form: dict[str, Any], fid: str) -> str | None: def _adv_changed_count(form: dict[str, Any], fields: list[dict[str, Any]]) -> int: """How many of ``fields`` are set to something other than profile default.""" + temp = form.get("temp") or {} n = 0 for f in fields: + if f.get("material_dynamic"): # temperature stepper (form["temp"]) + if temp.get(f["id"]) is not None: + n += 1 + continue sel = form["selections"].get(f["id"]) if sel is not None and _opt_id(f["options"][sel]) != "default": n += 1 @@ -236,6 +241,18 @@ def _selected_material(form: dict[str, Any]) -> str | None: return None +def _temp_state(form: dict[str, Any], field: dict[str, Any]): + """(target, profile_temp, (lo, hi)) for a material_dynamic temp control. + target is the operator's dialed value, or None while keeping the profile. + The stepper state lives in form["temp"] (keyed by field id), separate from + the option-index selections so nothing indexes options with a temperature.""" + mat = _selected_material(form) + prof = ((form["schema"].get("temp_current_by_material") or {}).get(mat) or {}).get(field["id"]) + rng = ((form["schema"].get("temp_range_by_material") or {}).get(mat) or {}).get(field["id"]) + target = (form.get("temp") or {}).get(field["id"]) + return target, prof, rng + + def _screen_of(form: dict[str, Any], fid: str) -> list[dict[str, Any]]: """The screen (list of fields) that renders together with ``fid``.""" for screen in _screens(form): @@ -355,17 +372,27 @@ def _field_control_rows(form: dict[str, Any], field: dict[str, Any], # repeat it (live 2026-07-15: "Infill 10% / Infill 15% / ..." doubled the # text and truncated wider labels at two-up). The default option is first # in every advanced field, so the header row lands before the values. - _range = None if field.get("material_dynamic"): - # Temperature controls: resolve the CURRENT temp + the in-range window - # from the loaded material (not the process profile). Options outside - # the material's sourced envelope are hidden so a mis-tap can't offer - # a dangerous temp for that filament. - _mat = _selected_material(form) - _cur = ((form["schema"].get("temp_current_by_material") or {}).get(_mat) or {}).get(field["id"]) - if _cur is not None: - resolved_default = f"{_cur}°C" - _range = ((form["schema"].get("temp_range_by_material") or {}).get(_mat) or {}).get(field["id"]) + # Temperature: a +/- STEPPER, not option buttons, so the operator can + # dial ANY exact value in one row. A full-width header shows the + # setting and the current target (tap it to keep the profile); the row + # below is [-5][-][+][+5]. The dialed value is clamped to the + # material's sourced range. State lives in form["temp"] (see + # _temp_state), never in the option-index selections. + target, prof, rng = _temp_state(form, field) + name = field.get("label", field["id"]).replace(" temperature", "") + if target is None: + head = f"{name}: keep profile" + (f" ({prof}°C)" if prof is not None else "") + else: + head = f"{name}: {target}°C" + (f" · profile {prof}" if prof is not None else "") + rows.append([{"text": head, "callback_data": f"T:{fi}:k"}]) + rows.append([ + {"text": "−5", "callback_data": f"T:{fi}:-5"}, + {"text": "−", "callback_data": f"T:{fi}:-1"}, + {"text": "+", "callback_data": f"T:{fi}:1"}, + {"text": "+5", "callback_data": f"T:{fi}:5"}, + ]) + return rows alts: list[dict[str, str]] = [] for oi, opt in enumerate(field["options"]): oid = _opt_id(opt) @@ -377,13 +404,6 @@ def _field_control_rows(form: dict[str, Any], field: dict[str, Any], f"keep profile ({resolved_default})") rows.append([{"text": f"{mark}{head}", "callback_data": f"s:{fi}:{oi}"}]) continue - if _range is not None: - try: - _v = int(oid) - except (TypeError, ValueError): - _v = None - if _v is not None and not (_range[0] <= _v <= _range[1]): - continue # out of the material's range — hidden short = opt.get("short") or _opt_label(opt) alts.append({"text": f"{mark}{short}", "callback_data": f"s:{fi}:{oi}"}) for i in range(0, len(alts), 3): @@ -515,8 +535,15 @@ def _render_review(form: dict[str, Any]) -> dict[str, Any]: # jump (e:), so the group's Next returns straight here. adv = _advanced_fields(form) if adv: + temp = form.get("temp") or {} changed = [] for f in adv: + if f.get("material_dynamic"): # temperature stepper + t = temp.get(f["id"]) + if t is not None: + nm = f.get("label", f["id"]).replace(" temperature", "") + changed.append(f"{nm} {t}°C") + continue sel = form["selections"].get(f["id"]) if sel is not None and _opt_id(f["options"][sel]) != "default": # option labels are self-describing ("Infill 30%") — no prefix @@ -680,6 +707,7 @@ def _apply_callback_inner(form: dict[str, Any], data: str) -> dict[str, Any]: if _opt_id(o) == "default"), None) if di is not None: form["selections"][f["id"]] = di + form["temp"] = {} # also clear the temperature steppers return {"kind": "rerender"} if sub == "c": # open a category sub-page (g:c:) catkey = parts[2] if len(parts) > 2 else "" @@ -689,6 +717,27 @@ def _apply_callback_inner(form: dict[str, Any], data: str) -> dict[str, Any]: return {"kind": "rerender", "warning": f"Stale or invalid button ({_esc(data)})."} + if kind == "T": # temperature stepper (T::) + tfi = int(parts[1]) + tfield = fields[tfi] + tfid = tfield["id"] + sub = parts[2] if len(parts) > 2 else "" + temp = form.setdefault("temp", {}) + if sub == "k": # tap the header -> keep the profile temp + temp[tfid] = None + return {"kind": "rerender"} + target, prof, rng = _temp_state(form, tfield) + base = target if target is not None else ( + prof if prof is not None else (rng[0] if rng else 0)) + try: + base += int(sub) + except ValueError: + return {"kind": "rerender", "warning": f"Bad step ({_esc(sub)})."} + if rng: + base = max(rng[0], min(base, rng[1])) + temp[tfid] = base + return {"kind": "rerender"} + fi = int(parts[1]) field = fields[fi] fid = field["id"] @@ -736,16 +785,10 @@ def _apply_callback_inner(form: dict[str, Any], data: str) -> dict[str, Any]: "warning": f"Stale button (option {oi} out of range for {_esc(repr(fid))})."} form["selections"][fid] = oi # Changing the head/material re-bases the temperature controls (their - # valid range + current temp are per material), so a temp picked for the - # old filament must not silently survive — reset the material_dynamic - # fields to their profile default. + # valid range + current temp are per material), so a temp dialed for the + # old filament must not silently survive — clear the stepper state. if fid in ("tool", "material"): - for f in fields: - if f.get("material_dynamic"): - di = next((i for i, o in enumerate(f["options"]) - if _opt_id(o) == "default"), None) - if di is not None: - form["selections"][f["id"]] = di + form["temp"] = {} # A single-select on its own screen advances on tap; one inside a # group is a radio — it only marks, the shared Next advances. if not field.get("group"): @@ -763,8 +806,17 @@ def answer_json(form: dict[str, Any]) -> dict[str, Any]: omitted so the toolkit applies its defaults / flags required ones. """ out: dict[str, Any] = {} + temp = form.get("temp") or {} for field in form["schema"]["fields"]: fid = field["id"] + # Temperature steppers carry a dialed target in form["temp"], not an + # option index. Emit the absolute value when the operator moved it off + # the profile; otherwise it's a keep (no override). + if field.get("material_dynamic"): + t = temp.get(fid) + if t is not None: + out[fid] = str(t) + continue val = form["selections"][fid] opts = field["options"] if field["type"] == "multi_select": diff --git a/tests/test_temp_form.py b/tests/test_temp_form.py index 7944bd9..3969dbf 100644 --- a/tests/test_temp_form.py +++ b/tests/test_temp_form.py @@ -68,42 +68,54 @@ def _pick_head(form, tool_id): tg.apply_callback(form, f"s:{tfi}:{oi}") -def test_temp_page_resolves_current_and_hides_out_of_range_for_material(): +def test_temp_stepper_dials_exact_value_clamped_to_material(): form = tg.new_form(u1_form.build_form_schema(_spec())) - _pick_head(form, "T0") # PLA + _pick_head(form, "T0") # PLA: current 220, range 190-240 form["current"] = "nozzle_temp" + nfi = tg._field_index(form, "nozzle_temp") kb = tg.render_screen(form)["keyboard"] - assert "keep profile (220°C)" in kb[0][0]["text"] # PLA current - vals = [b["text"] for r in kb for b in r if b["callback_data"].startswith("s:")] - assert any("190°C" in t for t in vals) and any("240°C" in t for t in vals) - assert not any("250°C" in t or "270°C" in t for t in vals) # above PLA range, hidden - # switch to PETG -> range + current follow - _pick_head(form, "T1") - form["current"] = "nozzle_temp" - kb2 = tg.render_screen(form)["keyboard"] - assert "keep profile (245°C)" in kb2[0][0]["text"] - vals2 = [b["text"] for r in kb2 for b in r if b["callback_data"].startswith("s:")] - assert any("270°C" in t for t in vals2) # in PETG range now - assert not any("190°C" in t for t in vals2) # below PETG range, hidden + assert "keep profile (220" in kb[0][0]["text"] # header shows current + # the stepper is one row of exactly four steps: -5, -1, +1, +5 + assert [b["callback_data"] for b in kb[1]] == [ + f"T:{nfi}:-5", f"T:{nfi}:-1", f"T:{nfi}:1", f"T:{nfi}:5"] + # dial +5 +5 +1 -> 231 (an EXACT value the step buttons never offered) + tg.apply_callback(form, f"T:{nfi}:5") + tg.apply_callback(form, f"T:{nfi}:5") + tg.apply_callback(form, f"T:{nfi}:1") + assert form["temp"]["nozzle_temp"] == 231 + assert "231°C" in tg.render_screen(form)["keyboard"][0][0]["text"] + assert tg.answer_json(form)["nozzle_temp"] == "231" + # can't dial past the PLA max even with many taps + for _ in range(20): + tg.apply_callback(form, f"T:{nfi}:5") + assert form["temp"]["nozzle_temp"] == 240 + + +def test_temp_stepper_keep_resets_to_no_override(): + form = tg.new_form(u1_form.build_form_schema(_spec())) + _pick_head(form, "T0") + nfi = tg._field_index(form, "nozzle_temp") + tg.apply_callback(form, f"T:{nfi}:5") # 220 -> 225 + assert tg.answer_json(form).get("nozzle_temp") == "225" + tg.apply_callback(form, f"T:{nfi}:k") # tap header -> keep + assert "nozzle_temp" not in tg.answer_json(form) -def test_bed_control_offers_off_for_cold_plate(): +def test_bed_stepper_can_reach_off_for_cold_plate(): form = tg.new_form(u1_form.build_form_schema(_spec())) - _pick_head(form, "T0") - form["current"] = "bed_temp" - kb = tg.render_screen(form)["keyboard"] - assert any("off" in b["text"] for r in kb for b in r) # bed-off / cold plate + _pick_head(form, "T0") # PLA bed 55, range 0-70 + bfi = tg._field_index(form, "bed_temp") + for _ in range(20): + tg.apply_callback(form, f"T:{bfi}:-5") + assert form["temp"]["bed_temp"] == 0 # bed-off / cold plate reachable + assert tg.answer_json(form)["bed_temp"] == "0" -def test_changing_head_resets_a_stale_temp_pick(): +def test_changing_head_clears_the_temp_stepper(): form = tg.new_form(u1_form.build_form_schema(_spec())) _pick_head(form, "T0") # PLA - # pick PLA nozzle 190 (below PETG's 230 min) nfi = tg._field_index(form, "nozzle_temp") - oi = next(i for i, o in enumerate(tg._field(form, "nozzle_temp")["options"]) - if tg._opt_id(o) == "190") - tg.apply_callback(form, f"s:{nfi}:{oi}") - assert tg._opt_id(tg._field(form, "nozzle_temp")["options"][form["selections"]["nozzle_temp"]]) == "190" - # switch head to PETG -> the stale 190 pick resets to default - _pick_head(form, "T1") - assert tg._opt_id(tg._field(form, "nozzle_temp")["options"][form["selections"]["nozzle_temp"]]) == "default" + tg.apply_callback(form, f"T:{nfi}:5") + assert form.get("temp", {}).get("nozzle_temp") is not None + _pick_head(form, "T1") # PETG -> stepper cleared (its range/current changed) + assert not form.get("temp") From 40ff4119717f0b134123ac4a911dac0e12482e5c Mon Sep 17 00:00:00 2001 From: bbolinger <14969048+bbolinger@users.noreply.github.com> Date: Thu, 16 Jul 2026 11:24:52 +0000 Subject: [PATCH 5/5] Turn the numeric advanced controls into steppers too Infill, walls, and top/bottom shells were option grids; make them the same +/- stepper as temperature. Infill keeps the 5-jump (-5/-/+/+5); walls and shells use just -/+. Pattern, one-wall-on-top, and the on/off/named controls stay as option buttons. The stepper base comes from the profile's resolved value; the dialed value maps to the process override with its unit (infill -> "36%", walls -> "7"), clamped to sane bounds and range-checked at parse. A general _stepper_state resolves the base from the material (temps) or the profile (process), and form["steps"] holds every stepper's target. Changing the head clears only the temperature steppers; the numeric ones are absolute. Also swap the em-dash in the print-head label for a middot. --- adapters/telegram/u1_form_telegram.py | 136 +++++++++++++++----------- scripts/u1_form.py | 43 +++++++- tests/test_adapters.py | 2 +- tests/test_advanced_settings.py | 40 ++++---- tests/test_temp_form.py | 12 +-- 5 files changed, 148 insertions(+), 85 deletions(-) diff --git a/adapters/telegram/u1_form_telegram.py b/adapters/telegram/u1_form_telegram.py index 45c2a75..cb8b471 100644 --- a/adapters/telegram/u1_form_telegram.py +++ b/adapters/telegram/u1_form_telegram.py @@ -30,6 +30,7 @@ from __future__ import annotations import html +import re from typing import Any # Tunables (callers may monkey-patch for tests / different UX). @@ -193,11 +194,11 @@ def _adv_bucket_of(form: dict[str, Any], fid: str) -> str | None: def _adv_changed_count(form: dict[str, Any], fields: list[dict[str, Any]]) -> int: """How many of ``fields`` are set to something other than profile default.""" - temp = form.get("temp") or {} + steps = form.get("steps") or {} n = 0 for f in fields: - if f.get("material_dynamic"): # temperature stepper (form["temp"]) - if temp.get(f["id"]) is not None: + if f.get("stepper"): # dialed via form["steps"] + if steps.get(f["id"]) is not None: n += 1 continue sel = form["selections"].get(f["id"]) @@ -241,16 +242,34 @@ def _selected_material(form: dict[str, Any]) -> str | None: return None -def _temp_state(form: dict[str, Any], field: dict[str, Any]): - """(target, profile_temp, (lo, hi)) for a material_dynamic temp control. - target is the operator's dialed value, or None while keeping the profile. - The stepper state lives in form["temp"] (keyed by field id), separate from - the option-index selections so nothing indexes options with a temperature.""" - mat = _selected_material(form) - prof = ((form["schema"].get("temp_current_by_material") or {}).get(mat) or {}).get(field["id"]) - rng = ((form["schema"].get("temp_range_by_material") or {}).get(mat) or {}).get(field["id"]) - target = (form.get("temp") or {}).get(field["id"]) - return target, prof, rng +def _num(s: Any) -> int | None: + """First integer in a value string ('30%' -> 30, '4' -> 4), else None.""" + if s is None: + return None + m = re.search(r"-?\d+", str(s)) + return int(m.group()) if m else None + + +def _stepper_state(form: dict[str, Any], field: dict[str, Any]): + """(target, base, (lo, hi), steps, unit) for a stepper control. ``base`` is + the value the dial starts from — per-material for a temperature control, + per-profile-resolved for a process control (infill/walls/shells). ``target`` + is the dialed value, or None while keeping the profile. State lives in + form["steps"], separate from the option-index selections so nothing indexes + an option list with a temperature or a count.""" + cfg = field.get("stepper") or {} + steps = cfg.get("steps", (1,)) + unit = cfg.get("unit", "") + target = (form.get("steps") or {}).get(field["id"]) + if field.get("material_dynamic"): + mat = _selected_material(form) + base = ((form["schema"].get("temp_current_by_material") or {}).get(mat) or {}).get(field["id"]) + rng = ((form["schema"].get("temp_range_by_material") or {}).get(mat) or {}).get(field["id"]) + lo, hi = (rng[0], rng[1]) if rng else (cfg.get("min", 0), cfg.get("max", 300)) + else: + base = _num(_resolved_for_selected_profile(form).get(field["id"])) + lo, hi = cfg.get("min", 0), cfg.get("max", 999) + return target, base, (lo, hi), steps, unit def _screen_of(form: dict[str, Any], fid: str) -> list[dict[str, Any]]: @@ -372,26 +391,25 @@ def _field_control_rows(form: dict[str, Any], field: dict[str, Any], # repeat it (live 2026-07-15: "Infill 10% / Infill 15% / ..." doubled the # text and truncated wider labels at two-up). The default option is first # in every advanced field, so the header row lands before the values. - if field.get("material_dynamic"): - # Temperature: a +/- STEPPER, not option buttons, so the operator can - # dial ANY exact value in one row. A full-width header shows the - # setting and the current target (tap it to keep the profile); the row - # below is [-5][-][+][+5]. The dialed value is clamped to the - # material's sourced range. State lives in form["temp"] (see - # _temp_state), never in the option-index selections. - target, prof, rng = _temp_state(form, field) - name = field.get("label", field["id"]).replace(" temperature", "") + if field.get("stepper"): + # A numeric control (temperature, infill, walls, shells) renders as a + # +/- STEPPER so the operator dials ANY exact value in one row. A + # full-width header shows the setting and the current target (tap it + # to keep the profile); the row below is the steps ([5,1] -> -5/-/+/+5, + # [1] -> -/+), clamped to range. State lives in form["steps"]. + target, base, _rng, steps, unit = _stepper_state(form, field) + name = (field.get("label", field["id"]).replace(" temperature", "") + .replace(" density", "").replace(" loops", "").replace(" layers", "")) if target is None: - head = f"{name}: keep profile" + (f" ({prof}°C)" if prof is not None else "") + head = f"{name}: keep profile" + (f" ({base}{unit})" if base is not None else "") else: - head = f"{name}: {target}°C" + (f" · profile {prof}" if prof is not None else "") + head = f"{name}: {target}{unit}" + (f" · profile {base}{unit}" if base is not None else "") rows.append([{"text": head, "callback_data": f"T:{fi}:k"}]) - rows.append([ - {"text": "−5", "callback_data": f"T:{fi}:-5"}, - {"text": "−", "callback_data": f"T:{fi}:-1"}, - {"text": "+", "callback_data": f"T:{fi}:1"}, - {"text": "+5", "callback_data": f"T:{fi}:5"}, - ]) + minus = [{"text": (f"−{s}" if s > 1 else "−"), "callback_data": f"T:{fi}:-{s}"} + for s in sorted(steps, reverse=True)] + plus = [{"text": (f"+{s}" if s > 1 else "+"), "callback_data": f"T:{fi}:{s}"} + for s in sorted(steps)] + rows.append(minus + plus) return rows alts: list[dict[str, str]] = [] for oi, opt in enumerate(field["options"]): @@ -535,14 +553,16 @@ def _render_review(form: dict[str, Any]) -> dict[str, Any]: # jump (e:), so the group's Next returns straight here. adv = _advanced_fields(form) if adv: - temp = form.get("temp") or {} + steps = form.get("steps") or {} changed = [] for f in adv: - if f.get("material_dynamic"): # temperature stepper - t = temp.get(f["id"]) + if f.get("stepper"): # dialed value (form["steps"]) + t = steps.get(f["id"]) if t is not None: - nm = f.get("label", f["id"]).replace(" temperature", "") - changed.append(f"{nm} {t}°C") + nm = (f.get("label", f["id"]).replace(" temperature", "") + .replace(" density", "").replace(" loops", "").replace(" layers", "")) + unit = (f.get("stepper") or {}).get("unit", "") + changed.append(f"{nm} {t}{unit}") continue sel = form["selections"].get(f["id"]) if sel is not None and _opt_id(f["options"][sel]) != "default": @@ -707,7 +727,7 @@ def _apply_callback_inner(form: dict[str, Any], data: str) -> dict[str, Any]: if _opt_id(o) == "default"), None) if di is not None: form["selections"][f["id"]] = di - form["temp"] = {} # also clear the temperature steppers + form["steps"] = {} # also clear every stepper (temp + numeric) return {"kind": "rerender"} if sub == "c": # open a category sub-page (g:c:) catkey = parts[2] if len(parts) > 2 else "" @@ -717,25 +737,22 @@ def _apply_callback_inner(form: dict[str, Any], data: str) -> dict[str, Any]: return {"kind": "rerender", "warning": f"Stale or invalid button ({_esc(data)})."} - if kind == "T": # temperature stepper (T::) + if kind == "T": # numeric stepper (T::) tfi = int(parts[1]) tfield = fields[tfi] tfid = tfield["id"] sub = parts[2] if len(parts) > 2 else "" - temp = form.setdefault("temp", {}) - if sub == "k": # tap the header -> keep the profile temp - temp[tfid] = None + steps = form.setdefault("steps", {}) + if sub == "k": # tap the header -> keep the profile value + steps[tfid] = None return {"kind": "rerender"} - target, prof, rng = _temp_state(form, tfield) - base = target if target is not None else ( - prof if prof is not None else (rng[0] if rng else 0)) + target, base, (lo, hi), _steps, _unit = _stepper_state(form, tfield) + cur = target if target is not None else (base if base is not None else lo) try: - base += int(sub) + cur += int(sub) except ValueError: return {"kind": "rerender", "warning": f"Bad step ({_esc(sub)})."} - if rng: - base = max(rng[0], min(base, rng[1])) - temp[tfid] = base + steps[tfid] = max(lo, min(cur, hi)) return {"kind": "rerender"} fi = int(parts[1]) @@ -784,11 +801,16 @@ def _apply_callback_inner(form: dict[str, Any], data: str) -> dict[str, Any]: return {"kind": "rerender", "warning": f"Stale button (option {oi} out of range for {_esc(repr(fid))})."} form["selections"][fid] = oi - # Changing the head/material re-bases the temperature controls (their - # valid range + current temp are per material), so a temp dialed for the - # old filament must not silently survive — clear the stepper state. + # Changing the head/material re-bases the TEMPERATURE steppers (their + # valid range is per material), so a temp dialed for the old filament + # must not silently survive. The numeric process steppers (infill/walls/ + # shells) are absolute with fixed ranges, so they stay. if fid in ("tool", "material"): - form["temp"] = {} + _steps = form.get("steps") + if _steps: + for f in fields: + if f.get("material_dynamic"): + _steps.pop(f["id"], None) # A single-select on its own screen advances on tap; one inside a # group is a radio — it only marks, the shared Next advances. if not field.get("group"): @@ -806,14 +828,14 @@ def answer_json(form: dict[str, Any]) -> dict[str, Any]: omitted so the toolkit applies its defaults / flags required ones. """ out: dict[str, Any] = {} - temp = form.get("temp") or {} + steps = form.get("steps") or {} for field in form["schema"]["fields"]: fid = field["id"] - # Temperature steppers carry a dialed target in form["temp"], not an - # option index. Emit the absolute value when the operator moved it off - # the profile; otherwise it's a keep (no override). - if field.get("material_dynamic"): - t = temp.get(fid) + # Steppers carry a dialed target in form["steps"], not an option index. + # Emit the absolute value when the operator moved it off the profile; + # otherwise it's a keep (no override). + if field.get("stepper"): + t = steps.get(fid) if t is not None: out[fid] = str(t) continue diff --git a/scripts/u1_form.py b/scripts/u1_form.py index a27d333..ee8ce98 100644 --- a/scripts/u1_form.py +++ b/scripts/u1_form.py @@ -252,6 +252,21 @@ def resolve_advanced_from_profile(flat_process: dict[str, Any]) -> dict[str, str ) _TEMP_BY_ID = {fid: base_key for fid, _lbl, base_key, _opts in TEMP_FIELDS} +# Numeric advanced controls render as a +/- STEPPER (not an option grid) so the +# operator dials any exact value. steps = the step buttons offered ([5, 1] gives +# -5/-/+/+5; [1] gives -/+). unit is the display suffix; for a process override +# it's also the value suffix ("%" for infill, "" for counts). min/max bound the +# dial. Temperature fields override their bound per material via +# temp_range_by_material; the process fields use these fixed bounds. +_STEPPER = { + "nozzle_temp": {"steps": (5, 1), "unit": "°C", "min": 0, "max": 300}, + "bed_temp": {"steps": (5, 1), "unit": "°C", "min": 0, "max": 120}, + "infill": {"steps": (5, 1), "unit": "%", "min": 5, "max": 100}, + "walls": {"steps": (1,), "unit": "", "min": 1, "max": 8}, + "top_shell": {"steps": (1,), "unit": "", "min": 0, "max": 15}, + "bottom_shell": {"steps": (1,), "unit": "", "min": 0, "max": 15}, +} + # Bare labels for the temp buttons under the per-setting header (mirrors # _ADVANCED_SHORT for the process controls). _ADVANCED_SHORT.update({ @@ -697,11 +712,11 @@ def _match_profile_name(text: str, profiles: list, values: dict, def _head_label(head: dict[str, Any]) -> str: - """One button label for a print head: 'Head 2 (T1) — PETG ⚫ black'. + """One button label for a print head: 'Head 2 (T1) · PETG ⚫ black'. Channel is 0-based on the wire (T0..T3); operators count from 1.""" swatch = _COLOR_SWATCH.get(str(head.get("color", "")).lower(), "⬤") ch = head.get("channel", 0) - bits = f"Head {ch + 1} ({head.get('tool', f'T{ch}')}) — {head.get('material', '?')}" + bits = f"Head {ch + 1} ({head.get('tool', f'T{ch}')}) · {head.get('material', '?')}" color = head.get("color") if color and color != "unknown": bits += f" {swatch} {color}" @@ -1092,7 +1107,7 @@ def build_form_schema(spec: dict[str, Any], *, submit: dict[str, str] | None = N if spec.get("offer_advanced"): for _fid, _lbl, _opts, _orca_key, _mapping in ADVANCED_FIELDS: _shorts = _ADVANCED_SHORT.get(_fid, {}) - fields.append({ + _f = { "id": _fid, "type": "single_select", "label": _lbl, "options": [{"id": oid, "label": olbl, "short": _shorts.get(oid, olbl)} for oid, olbl in _opts], @@ -1100,7 +1115,12 @@ def build_form_schema(spec: dict[str, Any], *, submit: dict[str, str] | None = N "advanced": True, "group": "advanced", "group_label": "Advanced settings", "category": _ADVANCED_CATEGORY.get(_fid, "finish"), - }) + } + # Numeric controls (infill/walls/top/bottom) render as a stepper; the + # base value comes from the profile's resolved value (advanced_resolved). + if _fid in _STEPPER: + _f["stepper"] = _STEPPER[_fid] + fields.append(_f) # Temperature controls (Track C). Advanced fields like the rest, but # material_dynamic: the renderer resolves the current temp + shows only # the loaded material's in-range options. base_key maps the answer to the @@ -1116,6 +1136,7 @@ def build_form_schema(spec: dict[str, Any], *, submit: dict[str, str] | None = N "group_label": "Advanced settings", "category": "temperature", "material_dynamic": True, "base_key": _base, + "stepper": _STEPPER.get(_fid), }) # v2.2 (kit refinement): NO action field. The form only collects the PLAN. # The single print/keep-staged decision happens AFTER slice + a FRESH bed @@ -1270,6 +1291,20 @@ def parse_answers_json(obj: dict[str, Any], spec: dict[str, Any]) -> dict[str, A raw = obj.get(_fid) if raw in (None, "", "default"): continue + if _fid in _STEPPER: + # A numeric stepper carries a VALUE, not an option id. Clamp to + # the control's bounds and write it (with unit) to the override. + _cfg = _STEPPER[_fid] + try: + _n = int(round(float(raw))) + except (TypeError, ValueError): + errors.append(f"invalid {_fid} value {raw!r}") + continue + if not (_cfg["min"] <= _n <= _cfg["max"]): + errors.append(f"{_fid} {_n} out of range ({_cfg['min']}-{_cfg['max']})") + continue + values.setdefault("overrides", {})[_orca_key] = f"{_n}{_cfg['unit']}" + continue mapped = _mapping.get(str(raw)) if mapped is None: errors.append(f"unknown {_fid} option {raw!r}") diff --git a/tests/test_adapters.py b/tests/test_adapters.py index 70e2878..7d00064 100644 --- a/tests/test_adapters.py +++ b/tests/test_adapters.py @@ -1129,7 +1129,7 @@ def test_merged_head_drops_material_screen_and_labels_carry_color(): tool_field = schema["fields"][_fidx(schema, "tool")] assert tool_field["label"] == "Print head" labels = [o["label"] for o in tool_field["options"]] - assert labels[0] == "Head 1 (T0) — PETG ⚪ white" + assert labels[0] == "Head 1 (T0) · PETG ⚪ white" assert "🟠 orange" in labels[2] diff --git a/tests/test_advanced_settings.py b/tests/test_advanced_settings.py index f4c38bd..236289e 100644 --- a/tests/test_advanced_settings.py +++ b/tests/test_advanced_settings.py @@ -102,9 +102,14 @@ def test_json_answers_default_means_no_override(): def test_json_answers_unknown_advanced_option_fails_loudly(): + # a categorical control rejects an unknown option id... res = u1_form.parse_answers_json( - {"tool": "T0", "material": "PLA", "profile": 1, "infill": "37"}, _spec()) - assert not res["ok"] and any("infill" in e for e in res["errors"]) + {"tool": "T0", "material": "PLA", "profile": 1, "infill_pattern": "spirograph"}, _spec()) + assert not res["ok"] and any("infill_pattern" in e for e in res["errors"]) + # ...and a numeric stepper rejects an out-of-range value (infill max 100) + res2 = u1_form.parse_answers_json( + {"tool": "T0", "material": "PLA", "profile": 1, "infill": "999"}, _spec()) + assert not res2["ok"] and any("infill" in e for e in res2["errors"]) # ---------- text answers ---------- @@ -180,13 +185,16 @@ def test_renderer_advanced_reached_via_two_level_tweak_menu(): def test_renderer_answer_json_carries_advanced_ids(): schema = u1_form.build_form_schema(_spec()) form = tg.new_form(schema) - fi = tg._field_index(form, "walls") - three = next(i for i, o in enumerate(tg._field(form, "walls")["options"]) - if tg._opt_id(o) == "3") - tg.apply_callback(form, f"s:{fi}:{three}") + # walls is a stepper: dial it, and the answer carries the dialed value + wfi = tg._field_index(form, "walls") + tg.apply_callback(form, f"T:{wfi}:1") + tg.apply_callback(form, f"T:{wfi}:1") out = tg.answer_json(form) - assert out["walls"] == "3" - assert out["infill"] == "default" # untouched advanced fields submit their default + assert "walls" in out and out["walls"].isdigit() + # an untouched categorical control still submits its default; + # an untouched stepper submits nothing (keep the profile) + assert out.get("infill_pattern") == "default" + assert "infill" not in out @@ -244,15 +252,13 @@ def test_tweak_menu_conditional_supports_and_reset(): if tg._opt_id(o) == "supports") tg.apply_callback(form, f"s:{sfi}:{on}") assert "supports" in [k for k, _l, _f in tg._adv_categories(form)] - # set a tweak, confirm it counts, then Reset-all clears it + # dial a stepper tweak, confirm it counts, then Reset-all clears it wfi = tg._field_index(form, "walls") - three = next(i for i, o in enumerate(tg._field(form, "walls")["options"]) - if tg._opt_id(o) == "3") - tg.apply_callback(form, f"s:{wfi}:{three}") + tg.apply_callback(form, f"T:{wfi}:1") assert tg._adv_changed_count(form, tg._advanced_fields(form)) >= 1 tg.apply_callback(form, "g:r") assert tg._adv_changed_count(form, tg._advanced_fields(form)) == 0 - assert tg.answer_json(form)["walls"] == "default" + assert "walls" not in tg.answer_json(form) def test_every_renderer_callback_is_routable_by_the_gateway(): @@ -419,14 +425,14 @@ def test_advanced_category_uses_bare_values_under_a_header(): assert "Infill" not in b["text"], f"option repeats setting name: {b['text']}" -def test_tweak_menu_keep_profile_label_absent_without_resolved(): - # No advanced_resolved in the schema -> the generic "profile default" stands. +def test_stepper_header_without_resolved_shows_no_base(): + # No advanced_resolved in the schema -> the stepper header has no "(N)" base. schema = u1_form.build_form_schema(_spec()) form = tg.new_form(schema) form["current"] = "walls" txts = [b["text"] for row in tg.render_screen(form)["keyboard"] for b in row] - assert any("Walls: profile default" in t for t in txts) - assert not any("keep profile" in t for t in txts) + assert any(t.startswith("Wall: keep profile") for t in txts) # stepper header + assert not any("keep profile (" in t for t in txts) # no resolved value diff --git a/tests/test_temp_form.py b/tests/test_temp_form.py index 3969dbf..2da636e 100644 --- a/tests/test_temp_form.py +++ b/tests/test_temp_form.py @@ -82,13 +82,13 @@ def test_temp_stepper_dials_exact_value_clamped_to_material(): tg.apply_callback(form, f"T:{nfi}:5") tg.apply_callback(form, f"T:{nfi}:5") tg.apply_callback(form, f"T:{nfi}:1") - assert form["temp"]["nozzle_temp"] == 231 + assert form["steps"]["nozzle_temp"] == 231 assert "231°C" in tg.render_screen(form)["keyboard"][0][0]["text"] assert tg.answer_json(form)["nozzle_temp"] == "231" # can't dial past the PLA max even with many taps for _ in range(20): tg.apply_callback(form, f"T:{nfi}:5") - assert form["temp"]["nozzle_temp"] == 240 + assert form["steps"]["nozzle_temp"] == 240 def test_temp_stepper_keep_resets_to_no_override(): @@ -107,7 +107,7 @@ def test_bed_stepper_can_reach_off_for_cold_plate(): bfi = tg._field_index(form, "bed_temp") for _ in range(20): tg.apply_callback(form, f"T:{bfi}:-5") - assert form["temp"]["bed_temp"] == 0 # bed-off / cold plate reachable + assert form["steps"]["bed_temp"] == 0 # bed-off / cold plate reachable assert tg.answer_json(form)["bed_temp"] == "0" @@ -116,6 +116,6 @@ def test_changing_head_clears_the_temp_stepper(): _pick_head(form, "T0") # PLA nfi = tg._field_index(form, "nozzle_temp") tg.apply_callback(form, f"T:{nfi}:5") - assert form.get("temp", {}).get("nozzle_temp") is not None - _pick_head(form, "T1") # PETG -> stepper cleared (its range/current changed) - assert not form.get("temp") + assert form.get("steps", {}).get("nozzle_temp") is not None + _pick_head(form, "T1") # PETG -> temp stepper cleared (its range changed) + assert not form.get("steps")