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
8 changes: 5 additions & 3 deletions adapters/hermes/plugin/telegram_patch.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,9 +46,11 @@
# a native Hermes callback (multi-char prefix + colon) can never match.
# S:<f>:<o> is a submit-verb (sets the Action option, then submits).
# g:<sub> is the Advanced-options menu (g:m menu, g:d done, g:r reset,
# g:c:<category> 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:<category> open a category); T:<field>:<step|k> 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:<request_id>). Handled
# HERE — the gateway adapter layer — because a typed CANCEL that lands while
Expand Down
132 changes: 128 additions & 4 deletions adapters/telegram/u1_form_telegram.py
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -193,8 +194,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."""
steps = form.get("steps") or {}
n = 0
for f in fields:
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"])
if sel is not None and _opt_id(f["options"][sel]) != "default":
n += 1
Expand All @@ -216,6 +222,56 @@ 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 _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]]:
"""The screen (list of fields) that renders together with ``fid``."""
for screen in _screens(form):
Expand Down Expand Up @@ -335,18 +391,39 @@ 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("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" ({base}{unit})" if base is not None else "")
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"}])
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"]):
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
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
Expand Down Expand Up @@ -476,8 +553,17 @@ def _render_review(form: dict[str, Any]) -> dict[str, Any]:
# jump (e:<fi>), so the group's Next returns straight here.
adv = _advanced_fields(form)
if adv:
steps = form.get("steps") or {}
changed = []
for f in adv:
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", "")
.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":
# option labels are self-describing ("Infill 30%") — no prefix
Expand Down Expand Up @@ -641,6 +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["steps"] = {} # also clear every stepper (temp + numeric)
return {"kind": "rerender"}
if sub == "c": # open a category sub-page (g:c:<catkey>)
catkey = parts[2] if len(parts) > 2 else ""
Expand All @@ -650,6 +737,24 @@ 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": # numeric stepper (T:<fi>:<delta|k>)
tfi = int(parts[1])
tfield = fields[tfi]
tfid = tfield["id"]
sub = parts[2] if len(parts) > 2 else ""
steps = form.setdefault("steps", {})
if sub == "k": # tap the header -> keep the profile value
steps[tfid] = None
return {"kind": "rerender"}
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:
cur += int(sub)
except ValueError:
return {"kind": "rerender", "warning": f"Bad step ({_esc(sub)})."}
steps[tfid] = max(lo, min(cur, hi))
return {"kind": "rerender"}

fi = int(parts[1])
field = fields[fi]
fid = field["id"]
Expand Down Expand Up @@ -696,6 +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 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"):
_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"):
Expand All @@ -713,8 +828,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] = {}
steps = form.get("steps") or {}
for field in form["schema"]["fields"]:
fid = field["id"]
# 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
val = form["selections"][fid]
opts = field["options"]
if field["type"] == "multi_select":
Expand Down
6 changes: 6 additions & 0 deletions scripts/u1_arrange.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@
profile_path,
filament_path,
_materialize_flat_filament,
apply_filament_overrides,
rewrite_gcode_for_tool,
_tool_to_index,
)
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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:
Expand Down
Loading
Loading