1
0
Fork 0
mirror of https://github.com/maziggy/bambuddy.git synced 2026-05-09 00:08:34 +02:00

[PR #1185] [Feature] Add Macro and Command Terminal #1178

Open
opened 2026-05-07 00:16:28 +02:00 by BreizhHardware · 0 comments

📋 Pull Request Information

Original PR: https://github.com/maziggy/bambuddy/pull/1185
Author: @Keybored02
Created: 5/1/2026
Status: 🔄 Open

Base: devHead: feature/macro


📝 Commits (10+)

📊 Changes

44 files changed (+6277 additions, -8 deletions)

View changed files

backend/app/api/routes/macros.py (+319 -0)
📝 backend/app/api/routes/printers.py (+4 -4)
📝 backend/app/api/routes/webhook.py (+45 -0)
📝 backend/app/core/auth.py (+1 -0)
📝 backend/app/core/config.py (+1 -0)
📝 backend/app/core/database.py (+73 -0)
📝 backend/app/core/permissions.py (+17 -0)
📝 backend/app/main.py (+20 -0)
📝 backend/app/models/__init__.py (+6 -0)
📝 backend/app/models/api_key.py (+1 -0)
backend/app/models/macro.py (+68 -0)
backend/app/models/macro_var.py (+34 -0)
backend/app/schemas/macro.py (+109 -0)
📝 backend/app/services/archive.py (+41 -0)
backend/app/services/gcode_whitelist.py (+116 -0)
backend/app/services/macro_cfg_parser.py (+199 -0)
backend/app/services/macro_cfg_watcher.py (+161 -0)
backend/app/services/macro_files.py (+83 -0)
backend/app/services/macro_functions.py (+221 -0)
backend/app/services/macro_integrations/__init__.py (+3 -0)

...and 24 more files

📄 Description

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 .cfg files on disk.

Fixes #1139

Documentation

Companion docs PRs (delete lines that don't apply):

Pick one:

  • Docs PR(s) linked above
  • No docs update required — reason: ___

Type of Change

  • Bug fix (non-breaking change that fixes an issue)
  • New feature (non-breaking change that adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • Documentation update
  • Code refactoring
  • Performance improvement
  • Test addition or update

Changes Made

File-first storage model

Macros live in Klipper-style .cfg files (e.g. data/macros/my_macros.cfg) rather than as DB text blobs. A single .cfg file can hold multiple [macro name] blocks. The DB stores only metadata (name, trigger type, cron expression, linked printer) and a foreign key to the MacroCfgFile record — 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

  1. Jinja2 render — The script body is rendered inside a SandboxedEnvironment (no arbitrary Python execution). Context variables (printer, ams, queue, vars, assignments) are injected from live MQTT state and DB queries.
  2. Line dispatch — Each rendered line is classified as a G-code command, a system command, or a blank/comment and routed accordingly.
  3. G-code whitelist enforcement — Before forwarding any G-code to the printer via MQTT, 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_function decorator on plain async coroutines in macro_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 a FunctionResult(ok, message, value). The value field is used by context_var functions that inject data into the Jinja2 render namespace.


New files

Backend

File Role
backend/app/models/macro.py MacroCfgFile, Macro, MacroRun SQLAlchemy 2.0 models
backend/app/models/macro_var.py MacroVar — persistent key/value store for macro scripts
backend/app/schemas/macro.py Pydantic request/response schemas with field-level validation
backend/app/api/routes/macros.py REST API: CRUD for cfg files and macros, run/cancel, exec terminal, function catalogue
backend/app/services/macro_runner.py Jinja2 render + line dispatch + cron scheduler + exec_line terminal
backend/app/services/macro_functions.py @macro_function decorator, registry, FunctionContext, FunctionResult
backend/app/services/macro_files.py Disk I/O for .cfg files with cross-platform path-traversal guard
backend/app/services/macro_cfg_parser.py Parses [macro name] blocks from .cfg text; extracts config headers
backend/app/services/macro_cfg_watcher.py Syncs parsed macros to DB on file save/delete
backend/app/services/gcode_whitelist.py Allowlist of ~60 G-code prefixes + is_whitelisted() predicate
backend/app/services/macro_integrations/printer.py PRINTER_PAUSE/RESUME/STOP, AMS_DRYING, WAIT_FOR_TEMP
backend/app/services/macro_integrations/notify.py NOTIFY, WAIT
backend/app/services/macro_integrations/printer_extended.py CLEAR_HMS_ERRORS, PRINT_QUEUE_ADD
backend/app/services/macro_integrations/assignments.py ASSIGN_SPOOL, UNASSIGN_SPOOL, assignments context var
backend/app/services/macro_integrations/vars.py SET_VAR, DELETE_VAR, vars context var

Frontend

File Role
frontend/src/pages/MacrosPage.tsx Full macros UI: cfg file list, macro list, editor, run history, terminal
frontend/src/components/CfgFileEditor.tsx Editor for .cfg files with syntax hints panel

Detailed feature breakdown

.cfg file format (Klipper-style)

[macro preheat_bed]
description: Heat bed to 60C and wait
trigger: schedule
cron: 0 8 * * *
printer: My X1C

M140 S60
WAIT_FOR_TEMP --target=60 --tolerance=2
NOTIFY --message="Bed ready"

[macro log_filament]
trigger: manual
{% if vars.last_material != printer.ams[0].tray[0].material %}
SET_VAR --key=last_material --value="{{ printer.ams[0].tray[0].material }}"
NOTIFY --message="Filament changed to {{ vars.last_material }}"
{% endif %}

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 in MacroCfgFile.parse_error.

Jinja2 context variables

Variable Type Source
printer dict Live MQTT state from printer_manager
ams list[dict] AMS tray data from MQTT state
queue int Count of pending PrintQueueItem rows
vars dict All non-expired MacroVar rows (global + macro-scoped)
assignments list[dict] Current SpoolAssignment rows for the target printer

Context variables are populated by context_var functions in the registry — the same @macro_function mechanism, with context_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=False and the log line is prefixed with error: 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; calls send_drying_command()
  • WAIT_FOR_TEMP --target=T [--tolerance=D] [--max_wait=S] — polls nozzle temp every 2s; validates target 0-350C

Notifications / 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 > 0

Extended 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 queue

Spool assignments (ASSIGN/UNASSIGN allowed_in_embed=False):

  • ASSIGN_SPOOL --spool_id=N --ams=A --tray=T — upserts SpoolAssignment, captures fingerprint color/type from live MQTT state, broadcasts spool_assignment_changed via WebSocket
  • UNASSIGN_SPOOL --ams=A --tray=T — deletes assignment, broadcasts same event
  • assignments context var — injects current assignments as a list of dicts

Persistent variables (allowed_in_embed=True):

  • SET_VAR --key=K --value=V [--ttl=S] [--scope=global|macro] — upserts MacroVar; value is JSON-encoded (numbers, bools, lists all work); optional TTL for auto-expiry
  • DELETE_VAR --key=K [--scope=...] — removes the var
  • vars context var — merges global and macro-scoped vars; scoped vars shadow globals with the same key; expired rows are excluded at read time

Execution engine details

Log buffering (_LogBuffer): Instead of one DB write per log line (N+1 problem), log writes are batched in memory and flushed to MacroRun.log every 10 lines (or on explicit flush at end of run). This keeps SQLite happy during long macro runs.

G-code safety (_preflight()):

  • Rejects any command not in GCODE_WHITELIST
  • Rejects G0/G1 with XY coordinates — XY movement via gcode_line is unsafe on Bambu firmware because G91 is ignored for XY, making coordinates always absolute and risking toolhead crashes. Z-only moves are allowed.
  • Rejects homing/heating commands (G28, M104, M140, etc.) while printer is in RUNNING state

Concurrency: run_macro() uses asyncio.create_task() and registers the task in self._running_tasks[run_id]. cancel_run(run_id) calls task.cancel(). All DB I/O uses fresh async_session() per call — no shared sessions across async tasks.

Cron scheduler: start_scheduler() launches a background asyncio task that wakes every 60s. It uses croniter.match() to check each schedule macro against the current UTC time. A last_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 a Macro DB record, reads and renders the .cfg body, and executes it inline within the parent run log. A call_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 the run: macro_name prefix — this avoids the ambiguity of a macro named e.g. G28 shadowing the actual G-code token. Plain G-code lines go directly through _dispatch_gcode().

Path-traversal protection

macro_files._safe_path() uses Path.relative_to(macros_dir.resolve()) rather than a string prefix check. The string-prefix approach broke on Windows because Path.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 Permission enum and wired into DEFAULT_GROUPS:

Permission Administrators Operators Viewers
macros:read yes yes no
macros:create yes no no
macros:update yes no no
macros:delete yes no no
macros:run yes yes no

Operators 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.open with noopener) that had no auth token, forcing the user to log in again. Fixed by appending ?token=<current_token> to the popup URL — AuthContext already reads and applies this query parameter on load.


Test plan

  • Start backend — verify macro_cfg_files, macros, macro_runs, macro_vars tables created
  • GET /api/v1/macros/gcode-whitelist returns a sorted list of strings
  • GET /api/v1/macros/functions returns all registered system commands with args
  • Create a .cfg file via UI; verify file appears on disk at data/macros/*.cfg
  • Edit and save the file; verify DB macros synced to match the new block list
  • Run a manual macro with G28; verify MacroRun.status = success and log contains the dispatched line
  • Run a macro with an unlisted G-code (e.g. G999); verify it is blocked with an error: log line
  • Run AMS_DRYING --ams=0 --temp=65 --duration=4; verify send_drying_command() called in logs
  • Run WAIT --seconds=2; verify ~2s delay visible in run log
  • Run SET_VAR --key=x --value=42 then a macro using {{ vars.x }}; verify value injected
  • Run ASSIGN_SPOOL --spool_id=1 --ams=0 --tray=0; verify SpoolAssignment row and WebSocket event
  • Cancel an active run via POST /macros/runs/{id}/cancel; verify task cancelled and status updated
  • Create a schedule macro with cron * * * * *; wait 60s; verify auto-run created
  • Terminal: type run: macro_name to invoke a macro; type G28 directly to dispatch G-code
  • Navigate to /macros in frontend; create, edit, run a macro end-to-end; watch run history poll update
  • Open terminal from Printers page; verify session is inherited (no login prompt in popup)
  • Attempt path traversal: PUT cfg-file with relative_path ../../../etc/passwd; verify 400 rejected

Screenshots

image image image image image

Testing

  • I have tested this on my local machine
  • I have tested with my printer model: H2C

Checklist

  • My code follows the project's coding style
  • I have commented my code where necessary
  • My changes generate no new warnings
  • I have tested my changes thoroughly

Additional Notes


🔄 This issue represents a GitHub Pull Request. It cannot be merged through Gitea due to API limitations.

## 📋 Pull Request Information **Original PR:** https://github.com/maziggy/bambuddy/pull/1185 **Author:** [@Keybored02](https://github.com/Keybored02) **Created:** 5/1/2026 **Status:** 🔄 Open **Base:** `dev` ← **Head:** `feature/macro` --- ### 📝 Commits (10+) - [`d92cb90`](https://github.com/maziggy/bambuddy/commit/d92cb907aadffde3f473465204e25f38db97a294) Add Macros - [`66d9e82`](https://github.com/maziggy/bambuddy/commit/66d9e821dd1ecdd6d60886b49966023c2fcbaa27) Add Terminal view - [`372f55f`](https://github.com/maziggy/bambuddy/commit/372f55f566c6b00128001829f4054956524b8cb4) Improve multiline macro handling - [`142bb96`](https://github.com/maziggy/bambuddy/commit/142bb963d8ddc67b36d344ee402fb548bdb1cb55) Gcode reference creation - [`56d175d`](https://github.com/maziggy/bambuddy/commit/56d175dc888dd75a71b4ad356a5dbeb7496c17b7) Terminal placement rework - [`1176c72`](https://github.com/maziggy/bambuddy/commit/1176c725d735cd540f92c1dafa2df2ea7823127b) Gcode whitelist update - [`4aac7a5`](https://github.com/maziggy/bambuddy/commit/4aac7a58671e32a5424b5361031149f4094c47a9) Add .cfg file support - [`a8867e9`](https://github.com/maziggy/bambuddy/commit/a8867e952c02ad81a272716880184cbf964d0499) Macro editor updates - [`c571ca6`](https://github.com/maziggy/bambuddy/commit/c571ca68acc5aa19b5bfebf2a7522db2329cb12a) Add system functions and persistent vars - [`37f3424`](https://github.com/maziggy/bambuddy/commit/37f3424baac8dcb8885e0b67d821139597ea3ff2) Merge branch 'dev' into feature/macro ### 📊 Changes **44 files changed** (+6277 additions, -8 deletions) <details> <summary>View changed files</summary> ➕ `backend/app/api/routes/macros.py` (+319 -0) 📝 `backend/app/api/routes/printers.py` (+4 -4) 📝 `backend/app/api/routes/webhook.py` (+45 -0) 📝 `backend/app/core/auth.py` (+1 -0) 📝 `backend/app/core/config.py` (+1 -0) 📝 `backend/app/core/database.py` (+73 -0) 📝 `backend/app/core/permissions.py` (+17 -0) 📝 `backend/app/main.py` (+20 -0) 📝 `backend/app/models/__init__.py` (+6 -0) 📝 `backend/app/models/api_key.py` (+1 -0) ➕ `backend/app/models/macro.py` (+68 -0) ➕ `backend/app/models/macro_var.py` (+34 -0) ➕ `backend/app/schemas/macro.py` (+109 -0) 📝 `backend/app/services/archive.py` (+41 -0) ➕ `backend/app/services/gcode_whitelist.py` (+116 -0) ➕ `backend/app/services/macro_cfg_parser.py` (+199 -0) ➕ `backend/app/services/macro_cfg_watcher.py` (+161 -0) ➕ `backend/app/services/macro_files.py` (+83 -0) ➕ `backend/app/services/macro_functions.py` (+221 -0) ➕ `backend/app/services/macro_integrations/__init__.py` (+3 -0) _...and 24 more files_ </details> ### 📄 Description ## 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 `.cfg` files on disk. ## Related Issue <!-- Link to the issue this PR addresses (if applicable) --> Fixes #1139 ## Documentation <!-- If this PR changes user-visible behavior, config keys, ports, CLI flags, URLs, or installation steps, link matching PRs in the docs repos below. Internal refactors, bug fixes with no observable change, and test-only changes are exempt — just check the "not required" box and say why. See CONTRIBUTING.md → Documentation Requirements for the full rules. --> **Companion docs PRs** (delete lines that don't apply): - Wiki: maziggy/bambuddy-wiki#23 **Pick one**: - [x] Docs PR(s) linked above - [ ] No docs update required — reason: ___ ## Type of Change <!-- Mark the relevant option with an "x" --> - [ ] Bug fix (non-breaking change that fixes an issue) - [x] New feature (non-breaking change that adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to change) - [ ] Documentation update - [ ] Code refactoring - [ ] Performance improvement - [x] Test addition or update ## Changes Made ### File-first storage model Macros live in **Klipper-style `.cfg` files** (e.g. `data/macros/my_macros.cfg`) rather than as DB text blobs. A single `.cfg` file can hold multiple `[macro name]` blocks. The DB stores only metadata (name, trigger type, cron expression, linked printer) and a foreign key to the `MacroCfgFile` record — 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 1. **Jinja2 render** — The script body is rendered inside a `SandboxedEnvironment` (no arbitrary Python execution). Context variables (`printer`, `ams`, `queue`, `vars`, `assignments`) are injected from live MQTT state and DB queries. 2. **Line dispatch** — Each rendered line is classified as a G-code command, a system command, or a blank/comment and routed accordingly. 3. **G-code whitelist enforcement** — Before forwarding any G-code to the printer via MQTT, `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_function` decorator on plain async coroutines in `macro_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 a `FunctionResult(ok, message, value)`. The `value` field is used by *context_var* functions that inject data into the Jinja2 render namespace. --- ## New files ### Backend | File | Role | |---|---| | `backend/app/models/macro.py` | `MacroCfgFile`, `Macro`, `MacroRun` SQLAlchemy 2.0 models | | `backend/app/models/macro_var.py` | `MacroVar` — persistent key/value store for macro scripts | | `backend/app/schemas/macro.py` | Pydantic request/response schemas with field-level validation | | `backend/app/api/routes/macros.py` | REST API: CRUD for cfg files and macros, run/cancel, exec terminal, function catalogue | | `backend/app/services/macro_runner.py` | Jinja2 render + line dispatch + cron scheduler + exec_line terminal | | `backend/app/services/macro_functions.py` | `@macro_function` decorator, registry, `FunctionContext`, `FunctionResult` | | `backend/app/services/macro_files.py` | Disk I/O for `.cfg` files with cross-platform path-traversal guard | | `backend/app/services/macro_cfg_parser.py` | Parses `[macro name]` blocks from `.cfg` text; extracts config headers | | `backend/app/services/macro_cfg_watcher.py` | Syncs parsed macros to DB on file save/delete | | `backend/app/services/gcode_whitelist.py` | Allowlist of ~60 G-code prefixes + `is_whitelisted()` predicate | | `backend/app/services/macro_integrations/printer.py` | `PRINTER_PAUSE/RESUME/STOP`, `AMS_DRYING`, `WAIT_FOR_TEMP` | | `backend/app/services/macro_integrations/notify.py` | `NOTIFY`, `WAIT` | | `backend/app/services/macro_integrations/printer_extended.py` | `CLEAR_HMS_ERRORS`, `PRINT_QUEUE_ADD` | | `backend/app/services/macro_integrations/assignments.py` | `ASSIGN_SPOOL`, `UNASSIGN_SPOOL`, `assignments` context var | | `backend/app/services/macro_integrations/vars.py` | `SET_VAR`, `DELETE_VAR`, `vars` context var | ### Frontend | File | Role | |---|---| | `frontend/src/pages/MacrosPage.tsx` | Full macros UI: cfg file list, macro list, editor, run history, terminal | | `frontend/src/components/CfgFileEditor.tsx` | Editor for `.cfg` files with syntax hints panel | --- ## Detailed feature breakdown ### `.cfg` file format (Klipper-style) ```ini [macro preheat_bed] description: Heat bed to 60C and wait trigger: schedule cron: 0 8 * * * printer: My X1C M140 S60 WAIT_FOR_TEMP --target=60 --tolerance=2 NOTIFY --message="Bed ready" [macro log_filament] trigger: manual {% if vars.last_material != printer.ams[0].tray[0].material %} SET_VAR --key=last_material --value="{{ printer.ams[0].tray[0].material }}" NOTIFY --message="Filament changed to {{ vars.last_material }}" {% endif %} ``` 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 in `MacroCfgFile.parse_error`. ### Jinja2 context variables | Variable | Type | Source | |---|---|---| | `printer` | dict | Live MQTT state from `printer_manager` | | `ams` | list[dict] | AMS tray data from MQTT state | | `queue` | int | Count of pending `PrintQueueItem` rows | | `vars` | dict | All non-expired `MacroVar` rows (global + macro-scoped) | | `assignments` | list[dict] | Current `SpoolAssignment` rows for the target printer | Context variables are populated by *context_var* functions in the registry — the same `@macro_function` mechanism, with `context_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=False` and the log line is prefixed with `error:` 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; calls `send_drying_command()` - `WAIT_FOR_TEMP --target=T [--tolerance=D] [--max_wait=S]` — polls nozzle temp every 2s; validates target 0-350C **Notifications / 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 > 0 **Extended 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 queue **Spool assignments** (`ASSIGN/UNASSIGN allowed_in_embed=False`): - `ASSIGN_SPOOL --spool_id=N --ams=A --tray=T` — upserts `SpoolAssignment`, captures fingerprint color/type from live MQTT state, broadcasts `spool_assignment_changed` via WebSocket - `UNASSIGN_SPOOL --ams=A --tray=T` — deletes assignment, broadcasts same event - `assignments` context var — injects current assignments as a list of dicts **Persistent variables** (`allowed_in_embed=True`): - `SET_VAR --key=K --value=V [--ttl=S] [--scope=global|macro]` — upserts `MacroVar`; value is JSON-encoded (numbers, bools, lists all work); optional TTL for auto-expiry - `DELETE_VAR --key=K [--scope=...]` — removes the var - `vars` context var — merges global and macro-scoped vars; scoped vars shadow globals with the same key; expired rows are excluded at read time ### Execution engine details **Log buffering** (`_LogBuffer`): Instead of one DB write per log line (N+1 problem), log writes are batched in memory and flushed to `MacroRun.log` every 10 lines (or on explicit flush at end of run). This keeps SQLite happy during long macro runs. **G-code safety** (`_preflight()`): - Rejects any command not in `GCODE_WHITELIST` - Rejects `G0`/`G1` with XY coordinates — XY movement via `gcode_line` is unsafe on Bambu firmware because `G91` is ignored for XY, making coordinates always absolute and risking toolhead crashes. Z-only moves are allowed. - Rejects homing/heating commands (`G28`, `M104`, `M140`, etc.) while printer is in `RUNNING` state **Concurrency**: `run_macro()` uses `asyncio.create_task()` and registers the task in `self._running_tasks[run_id]`. `cancel_run(run_id)` calls `task.cancel()`. All DB I/O uses fresh `async_session()` per call — no shared sessions across async tasks. **Cron scheduler**: `start_scheduler()` launches a background asyncio task that wakes every 60s. It uses `croniter.match()` to check each schedule macro against the current UTC time. A `last_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 a `Macro` DB record, reads and renders the `.cfg` body, and executes it inline within the parent run log. A `call_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 the `run: macro_name` prefix — this avoids the ambiguity of a macro named e.g. `G28` shadowing the actual G-code token. Plain G-code lines go directly through `_dispatch_gcode()`. ### Path-traversal protection `macro_files._safe_path()` uses `Path.relative_to(macros_dir.resolve())` rather than a string prefix check. The string-prefix approach broke on Windows because `Path.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 `Permission` enum and wired into `DEFAULT_GROUPS`: | Permission | Administrators | Operators | Viewers | |---|---|---|---| | `macros:read` | yes | yes | no | | `macros:create` | yes | no | no | | `macros:update` | yes | no | no | | `macros:delete` | yes | no | no | | `macros:run` | yes | yes | no | Operators 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.open` with `noopener`) that had no auth token, forcing the user to log in again. Fixed by appending `?token=<current_token>` to the popup URL — `AuthContext` already reads and applies this query parameter on load. --- ## Test plan - [ ] Start backend — verify `macro_cfg_files`, `macros`, `macro_runs`, `macro_vars` tables created - [ ] `GET /api/v1/macros/gcode-whitelist` returns a sorted list of strings - [ ] `GET /api/v1/macros/functions` returns all registered system commands with args - [ ] Create a `.cfg` file via UI; verify file appears on disk at `data/macros/*.cfg` - [ ] Edit and save the file; verify DB macros synced to match the new block list - [ ] Run a manual macro with `G28`; verify `MacroRun.status = success` and log contains the dispatched line - [ ] Run a macro with an unlisted G-code (e.g. `G999`); verify it is blocked with an `error:` log line - [ ] Run `AMS_DRYING --ams=0 --temp=65 --duration=4`; verify `send_drying_command()` called in logs - [ ] Run `WAIT --seconds=2`; verify ~2s delay visible in run log - [ ] Run `SET_VAR --key=x --value=42` then a macro using `{{ vars.x }}`; verify value injected - [ ] Run `ASSIGN_SPOOL --spool_id=1 --ams=0 --tray=0`; verify `SpoolAssignment` row and WebSocket event - [ ] Cancel an active run via `POST /macros/runs/{id}/cancel`; verify task cancelled and status updated - [ ] Create a schedule macro with cron `* * * * *`; wait 60s; verify auto-run created - [ ] Terminal: type `run: macro_name` to invoke a macro; type `G28` directly to dispatch G-code - [ ] Navigate to `/macros` in frontend; create, edit, run a macro end-to-end; watch run history poll update - [ ] Open terminal from Printers page; verify session is inherited (no login prompt in popup) - [ ] Attempt path traversal: PUT cfg-file with relative_path `../../../etc/passwd`; verify 400 rejected ## Screenshots <img width="1002" height="740" alt="image" src="https://github.com/user-attachments/assets/9fa36745-abb2-45ed-a675-82d73efe27a5" /> <img width="1659" height="378" alt="image" src="https://github.com/user-attachments/assets/b88618cf-cc5a-4e78-ad32-f81c3de0e578" /> <img width="1606" height="119" alt="image" src="https://github.com/user-attachments/assets/3162d61c-ee0d-47d6-8186-ed02db751833" /> <img width="540" height="137" alt="image" src="https://github.com/user-attachments/assets/16e981cb-3825-4cb8-b2ab-405784e95ae4" /> <img width="1669" height="991" alt="image" src="https://github.com/user-attachments/assets/ab474771-5070-4874-a9e1-c3a6e5f750d8" /> ## Testing <!-- Describe how you tested your changes --> - [x] I have tested this on my local machine - [x] I have tested with my printer model: H2C ## Checklist - [x] My code follows the project's coding style - [x] I have commented my code where necessary - [x] My changes generate no new warnings - [x] I have tested my changes thoroughly ## Additional Notes <!-- Add any additional information that reviewers should know --> --- <sub>🔄 This issue represents a GitHub Pull Request. It cannot be merged through Gitea due to API limitations.</sub>
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
starred/bambuddy-maziggy-1#1178
No description provided.