[Feature] Add Macro and Command Terminal#1185
Conversation
…o system functionality
|
What needs to be done:
|
|
Open tasks should be done before merging. |
|
Beside testing, the other tasks are way outside of the scope of this PR and would need broad community support. They're not requisites for this, but rather ways in which the feature can move forward. |
- C1: webhook endpoint now passes run_id to run_macro(); previously it
created a MacroRun row and then called run_macro() without it, causing
the runner to create a second row and leaving the first stuck as 'pending'
- C2: webhook endpoint now rejects macros not configured with
trigger_type='webhook'; previously any API key could fire manual/schedule
macros via the webhook URL
- C3: removed broken {{ run_macro("name") }} Jinja2 callable that used
asyncio.ensure_future() inside a synchronous render, making sub-macros
run concurrently with no ordering or error propagation; replaced with a
new MACRO --name=<name> imperative system command that executes
synchronously and propagates errors correctly
- C4: _run_sub_macro now receives and honours allow_printer_commands; previously
a macro invoked in gcode_embed mode could call sub-macros that bypassed
the printer command restriction entirely
- C5: SandboxedEnvironment now uses StrictUndefined; previously undefined
variables (e.g. typos in {{ priinter.nozzle_temp }}) silently rendered
as empty strings, masking bugs in macro scripts
…into feature/macro
|
Critical fixes — macro execution engine
|
…failures, data integrity - M2/H5: _LogBuffer.flush and cancel_run now append log via a single SQL UPDATE SET log = COALESCE(log,'') || :blob, eliminating the read-modify-write race where concurrent writers (runner flush vs. cancel API) could silently lose log lines under last-writer-wins ORM semantics - M3: removed duplicate [GCODE] log lines in run_macro dispatch loop; the loop was logging each line before batching and _send_gcode logged it again after MQTT publish — every G-code appeared twice in run logs - M5: _get_queue_count now logs a warning on DB failure instead of silently returning 0, making macros that branch on `queue` diagnosable - M7: discover() no longer swallows import errors from macro_integrations/ modules; a bad import now raises at startup rather than leaving commands silently unavailable until a macro fails with "Unknown system command" - M8: create_cfg_file cleanup on DB failure now catches all exceptions, not only IntegrityError; any DB error during sync_file previously left an orphaned .cfg file on disk - L2: start_scheduler is now idempotent — calling it twice no longer creates two scheduler tasks; stop_scheduler now nulls the task reference - L3: added UNIQUE(key, macro_id) constraint to macro_vars (model + idempotent DB migration); concurrent SET_VAR of the same key/scope could previously produce duplicate rows with unpredictable read results - L7: parse_error in cfg_watcher no longer prefixes "; " when there is no prior file-level error, fixing "'; Name conflict:...'" leading-semicolon display in the UI
|
Medium/low fixes — macro system reliability and data integrity
|
…idation, session hygiene
- L1: _parse_flags now stores bare --flag (no value) as an empty string
instead of silently dropping it; callers can now detect flag presence vs.
absence. The guard "not tokens[i+1].startswith('--')" previously dropped
any positional value whose text started with '--' (e.g. a quoted argument
passed through shlex); bare flags at end-of-line were also silently lost.
- L5: cron expressions are now validated by croniter.is_valid() in
macro_cfg_parser at parse time. A bad expression (e.g. "every minute")
sets parse_error on the MacroCfgFile immediately when the file is saved,
instead of only surfacing as a warning log at the first scheduler tick
60 seconds later.
- L8: POST /{macro_id}/run no longer issues a redundant SELECT after commit
to re-fetch the MacroRun it just created; it now calls db.refresh(run)
on the already-loaded ORM object instead.
|
Low-severity fixes — flag parsing, cron validation, session hygiene
|
14 pure-unit cases covering: - empty and whitespace-only files - single macro with no config, all config keys, multiple macros - duplicate name detection and error propagation - unknown trigger type defaulting to "manual" - valid and invalid cron expression validation (P8/P9 — new behavior from the cron-validation-at-parse-time fix) - get_macro_body found/not-found/errored-entry paths - internal blank line preservation in body - case-insensitive config key parsing - serialize→parse roundtrip
21 pure-unit cases (no DB, no async) covering: - create() returns a relative .cfg path and writes content - slug collision counter: same name → unique paths each time - write/read roundtrip via the public service API - read() raises FileNotFoundError for missing files - delete() removes the file; missing file and traversal attempts are silent - _safe_path() rejects directory traversal with ValueError, accepts valid names - _slug() strips specials, collapses separators, strips leading/trailing underscores, falls back to "macro" on empty/all-special input - list_cfg_files() returns only .cfg entries; empty dir returns []
Replaces the stale test_macro_runner.py (which called removed methods _dispatch_line/_append_log and referenced the old Jinja2 file model) with 30 cases covering the current execution engine: Whitelist / preflight: - Pass/block cases, XY movement rejection, unsafe-while-running guard, disconnected printer, unknown G-code _parse_flags: - --key=value form, --key value form, bare --flag stored as "" - mixed flags, empty input, positional args ignored _LogBuffer: - flush() issues UPDATE with COALESCE (not read-modify-write) - batch threshold: no flush before N lines, flush on Nth write exec_line: - G28 dispatches to MQTT, M600 blocked with [PREFLIGHT], NOTIFY works without printer, comment lines are no-ops run_macro end-to-end: - success sets status="success" and finished_at - StrictUndefined template error sets status="error" - embed mode (allow_printer_commands=False) skips G-code and allowed_in_embed=False system commands - cancel sets status="error" with [CANCELLED] log line Sub-macros: - MACRO --name=b runs macro B's body inline - Cycle detection (A→A) logs error and does not recurse - Missing sub-macro logs [WARN] and run still succeeds Scheduler - start_scheduler() twice leaves exactly one task - Cron tick fires run_macro for a macro with matching expression All integration tests use real in-memory SQLite via the db_session fixture; async_session is patched to the test session factory.
Covers cfg-file CRUD (create/get/content/save/delete, duplicate-409), macro read endpoints (list/get/not-found), run lifecycle (pending run, get run, list runs, cancel active, cancel-finished-409), utility endpoints (gcode-whitelist, function catalogue), exec terminal (gcode line, run: syntax, run: not-found), invalid-cron parse_error surface, and cfg-file list. Uses async_client + in-memory SQLite. asyncio.create_task patched in all run-triggering tests so background execution does not race with assertions.
Covers: webhook-trigger macro accepted (200 + run_id + "accepted"), wrong trigger_type rejected with 400, macro not found returns 404, missing API key returns 401/403, and run_id propagation (no orphan MacroRun — the route commits the run first, then passes its id to run_macro() via create_task so the runner never creates a second row). Also adds the missing can_run_macros column to the APIKey ORM model (it existed only as a DB migration, so test databases built via create_all lacked the column — getattr fallback always returned False).
Fixes applied to source code:
- macro_cfg_parser: treat ImportError from missing croniter as "no
validation" instead of "invalid cron" — cron expression is stored
as-is; active validation only runs when croniter is present
- macros route: pass stem=data.name to sync_file() so the MacroCfgFile
DB row always gets the user-supplied name, not the collision-counter
filename stem; this makes the IntegrityError fire on duplicate names
- webhook route: move `import asyncio` to module level so
`asyncio.create_task` is patchable as an attribute in tests
- models/api_key: add can_run_macros column to ORM model (existed only
as a DB migration, so test databases built via create_all lacked it)
Fixes applied to tests:
- conftest: patch macro_cfg_watcher.async_session and
macro_runner.async_session so route integration tests share the
in-memory test DB (previously sync_file wrote to a different session)
- runner/routes tests: replace asyncio.create_task patches with
macro_runner.run_macro AsyncMock — patching asyncio.create_task
globally broke anyio's internal task scheduling under pytest-asyncio
- runner tests: fix printer_manager patch path from
macro_runner.printer_manager (no module-level attr, lazy import) to
backend.app.services.printer_manager.printer_manager
- runner tests: fix NOTIFY test to mock async_session + DB result
rather than a non-existent _send_notification helper
- cron-dependent tests: guard with pytest.importorskip("croniter") /
skipif so they skip cleanly when croniter is absent rather than fail
Test Coverage Report —
|
| Module | Coverage | Notes |
|---|---|---|
macro_files.py |
100% | All file I/O paths exercised |
macro_cfg_parser.py |
99% | 1 unreachable line (post-ImportError guard) |
macro_functions.py |
85% | Uncovered: error paths in the decorator registry and unused arg-spec helpers |
macro_runner.py |
74% | See below |
macros.py (routes) |
79% | See below |
macro_cfg_watcher.py |
36% | See below |
webhook.py |
42% | Macro webhook is covered; the 58% miss is pre-existing non-macro routes (queue, printer control) |
Notable gaps
macro_runner.py (74%) — uncovered lines are:
- The cron scheduler loop (
lines 370–406) — tested via direct tick invocation; the fullasyncio.sleeploop body is not exercised _run_sub_macroedge cases:allow_printer_commands=Falsepropagation into sub-macro G-code batching- G-code batch flush and HMS error detection after send (
lines 507–526) _build_contextJinja2 context construction paths (printer state enrichment)
macros.py routes (79%) — uncovered lines are:
GET /macros/{id}/varsandPUT /macros/{id}/vars(macro variable endpoints, lines 102–148)execterminalrun:error path for already-running macros- Some cancel-run edge paths
macro_cfg_watcher.py (36%) — the watcher's filesystem watch loop (start_watching / stop_watching, lines 37–55) and cross-file collision handling are not covered. These are integration-heavy: they require a real filesystem event or the full startup lifecycle. The sync path that route tests exercise is covered; the background watcher daemon is not.
What is covered well
- All cfg-file CRUD operations end-to-end (create, read, update, delete, duplicate rejection)
- Full macro execution pipeline: template rendering, G-code dispatch, system commands, embed-mode blocking, cycle detection, cancel
- All API status codes: 200, 404, 409, 400, 401/403
- Parser edge cases: comments, duplicate names, cron validation, case-insensitive config, serialize roundtrip
- Webhook auth enforcement and orphan-run prevention (run_id propagation)
|
Need anything else for this before a review? |
Description
This PR introduces a complete macro scripting system for Bambuddy, enabling users to automate printer actions — filament drying, print control, notifications, spool assignments, and more — through Jinja2 template scripts stored as plain
.cfgfiles on disk.Related Issue
Fixes #1139
Documentation
Companion docs PRs (delete lines that don't apply):
Pick one:
Type of Change
Changes Made
File-first storage model
Macros live in Klipper-style
.cfgfiles (e.g.data/macros/my_macros.cfg) rather than as DB text blobs. A single.cfgfile can hold multiple[macro name]blocks. The DB stores only metadata (name, trigger type, cron expression, linked printer) and a foreign key to theMacroCfgFilerecord — the actual script body is always read from disk at render time.This means macros can be version-controlled, edited externally with any text editor, and shared between Bambuddy instances by copying files.
Three-layer execution pipeline
SandboxedEnvironment(no arbitrary Python execution). Context variables (printer,ams,queue,vars,assignments) are injected from live MQTT state and DB queries.is_whitelisted()checks the first token against a curated allowlist. Unknown tokens are rejected with a log warning, never silently dropped.Registry-based system commands
System commands (
AMS_DRYING,NOTIFY,WAIT,SET_VAR, etc.) are registered via a@macro_functiondecorator on plain async coroutines inmacro_integrations/*.py. The registry auto-discovers all files in that package at startup — adding a new command requires only a new decorated function, with zero changes to any other file.Each function receives a typed
FunctionContext(flags dict, printer_id, run_id, log callable) and returns aFunctionResult(ok, message, value). Thevaluefield is used by context_var functions that inject data into the Jinja2 render namespace.New files
Backend
backend/app/models/macro.pyMacroCfgFile,Macro,MacroRunSQLAlchemy 2.0 modelsbackend/app/models/macro_var.pyMacroVar— persistent key/value store for macro scriptsbackend/app/schemas/macro.pybackend/app/api/routes/macros.pybackend/app/services/macro_runner.pybackend/app/services/macro_functions.py@macro_functiondecorator, registry,FunctionContext,FunctionResultbackend/app/services/macro_files.py.cfgfiles with cross-platform path-traversal guardbackend/app/services/macro_cfg_parser.py[macro name]blocks from.cfgtext; extracts config headersbackend/app/services/macro_cfg_watcher.pybackend/app/services/gcode_whitelist.pyis_whitelisted()predicatebackend/app/services/macro_integrations/printer.pyPRINTER_PAUSE/RESUME/STOP,AMS_DRYING,WAIT_FOR_TEMPbackend/app/services/macro_integrations/notify.pyNOTIFY,WAITbackend/app/services/macro_integrations/printer_extended.pyCLEAR_HMS_ERRORS,PRINT_QUEUE_ADDbackend/app/services/macro_integrations/assignments.pyASSIGN_SPOOL,UNASSIGN_SPOOL,assignmentscontext varbackend/app/services/macro_integrations/vars.pySET_VAR,DELETE_VAR,varscontext varFrontend
frontend/src/pages/MacrosPage.tsxfrontend/src/components/CfgFileEditor.tsx.cfgfiles with syntax hints panelDetailed feature breakdown
.cfgfile format (Klipper-style)Config headers (
description:,trigger:,cron:,printer:) are parsed before the script body. Everything after the first non-header line is the Jinja2 body. Duplicate block names are caught at parse time and surfaced inMacroCfgFile.parse_error.Jinja2 context variables
printerprinter_manageramsqueuePrintQueueItemrowsvarsMacroVarrows (global + macro-scoped)assignmentsSpoolAssignmentrows for the target printerContext variables are populated by context_var functions in the registry — the same
@macro_functionmechanism, withcontext_var="vars"instead of a command name. They run eagerly before each Jinja2 render.System commands
All commands return structured
FunctionResult(ok, message). On failure,ok=Falseand the log line is prefixed witherror:so the UI can highlight it in red.Printer control (
allowed_in_embed=False— cannot run from G-code embeds):PRINTER_PAUSE / RESUME / STOP— wraps the printer client pause/resume/stop methods; distinguishes "not connected" from "command rejected by firmware"AMS_DRYING --ams=N --temp=T --duration=H— validates ams 0-3, temp 20-90C, duration 1-12h; callssend_drying_command()WAIT_FOR_TEMP --target=T [--tolerance=D] [--max_wait=S]— polls nozzle temp every 2s; validates target 0-350CNotifications / timing (
allowed_in_embed=True):NOTIFY --message="..."— dispatches through the configured notification providers; distinguishes "no providers configured" (ok, no-op) from "dispatch error"WAIT --seconds=N— async sleep; validates N > 0Extended printer (
allowed_in_embed=False):CLEAR_HMS_ERRORS— sends HMS clear command; reports "no errors to clear" vs "cleared N errors"PRINT_QUEUE_ADD --file_id=N [--plate=N]— validates file_id > 0, plate >= 1; adds item to print queueSpool assignments (
ASSIGN/UNASSIGN allowed_in_embed=False):ASSIGN_SPOOL --spool_id=N --ams=A --tray=T— upsertsSpoolAssignment, captures fingerprint color/type from live MQTT state, broadcastsspool_assignment_changedvia WebSocketUNASSIGN_SPOOL --ams=A --tray=T— deletes assignment, broadcasts same eventassignmentscontext var — injects current assignments as a list of dictsPersistent variables (
allowed_in_embed=True):SET_VAR --key=K --value=V [--ttl=S] [--scope=global|macro]— upsertsMacroVar; value is JSON-encoded (numbers, bools, lists all work); optional TTL for auto-expiryDELETE_VAR --key=K [--scope=...]— removes the varvarscontext var — merges global and macro-scoped vars; scoped vars shadow globals with the same key; expired rows are excluded at read timeExecution engine details
Log buffering (
_LogBuffer): Instead of one DB write per log line (N+1 problem), log writes are batched in memory and flushed toMacroRun.logevery 10 lines (or on explicit flush at end of run). This keeps SQLite happy during long macro runs.G-code safety (
_preflight()):GCODE_WHITELISTG0/G1with XY coordinates — XY movement viagcode_lineis unsafe on Bambu firmware becauseG91is ignored for XY, making coordinates always absolute and risking toolhead crashes. Z-only moves are allowed.G28,M104,M140, etc.) while printer is inRUNNINGstateConcurrency:
run_macro()usesasyncio.create_task()and registers the task inself._running_tasks[run_id].cancel_run(run_id)callstask.cancel(). All DB I/O uses freshasync_session()per call — no shared sessions across async tasks.Cron scheduler:
start_scheduler()launches a background asyncio task that wakes every 60s. It usescroniter.match()to check each schedule macro against the current UTC time. Alast_fired: dict[int, datetime]guard prevents double-fires within the same minute window.Sub-macro calls: The Jinja2 context includes a
run_macro("name")callable. It resolves the name to aMacroDB record, reads and renders the.cfgbody, and executes it inline within the parent run log. Acall_stack: set[str]is threaded through recursive calls; a repeated name raises an error to prevent infinite recursion.Terminal exec (
POST /macros/exec): Single-line execution for the in-app terminal. Invokes a full macro by name using therun: macro_nameprefix — this avoids the ambiguity of a macro named e.g.G28shadowing the actual G-code token. Plain G-code lines go directly through_dispatch_gcode().Path-traversal protection
macro_files._safe_path()usesPath.relative_to(macros_dir.resolve())rather than a string prefix check. The string-prefix approach broke on Windows becausePath.resolve()returns backslash-separated paths while the old code appended a forward-slash sentinel, causing all valid filenames to be incorrectly rejected with a 500 error.Permissions
Five new permissions added to the
Permissionenum and wired intoDEFAULT_GROUPS:macros:readmacros:createmacros:updatemacros:deletemacros:runOperators can read the macro list and trigger runs but cannot create or modify macros.
Terminal session inheritance fix
Opening the G-code terminal from the Printers page previously created an isolated browsing context (
window.openwithnoopener) that had no auth token, forcing the user to log in again. Fixed by appending?token=<current_token>to the popup URL —AuthContextalready reads and applies this query parameter on load.Test plan
macro_cfg_files,macros,macro_runs,macro_varstables createdGET /api/v1/macros/gcode-whitelistreturns a sorted list of stringsGET /api/v1/macros/functionsreturns all registered system commands with args.cfgfile via UI; verify file appears on disk atdata/macros/*.cfgG28; verifyMacroRun.status = successand log contains the dispatched lineG999); verify it is blocked with anerror:log lineAMS_DRYING --ams=0 --temp=65 --duration=4; verifysend_drying_command()called in logsWAIT --seconds=2; verify ~2s delay visible in run logSET_VAR --key=x --value=42then a macro using{{ vars.x }}; verify value injectedASSIGN_SPOOL --spool_id=1 --ams=0 --tray=0; verifySpoolAssignmentrow and WebSocket eventPOST /macros/runs/{id}/cancel; verify task cancelled and status updated* * * * *; wait 60s; verify auto-run createdrun: macro_nameto invoke a macro; typeG28directly to dispatch G-code/macrosin frontend; create, edit, run a macro end-to-end; watch run history poll update../../../etc/passwd; verify 400 rejectedScreenshots
Testing
Checklist
Additional Notes