Browse Source

Add auto-print G-code injection for queue items (#422)

  Per-model start/end G-code snippets configurable in Settings (Workflow
  tab). Queue items get "Inject G-code" toggle — scheduler injects
  snippets into a temp 3MF copy before FTP upload. Supports Farmloop,
  SwapMod, AutoClear, Printflow 3D and similar bed-clearing systems.
  Original files are never modified.
maziggy 1 month ago
parent
commit
f006472f79

+ 1 - 0
CHANGELOG.md

@@ -7,6 +7,7 @@ All notable changes to Bambuddy will be documented in this file.
 ### New Features
 - **Optional PostgreSQL Database Support** — Bambuddy can now use an external PostgreSQL database instead of the built-in SQLite. Set the `DATABASE_URL` environment variable (e.g., `postgresql+asyncpg://user:pass@host:5432/bambuddy`) to connect to Postgres. SQLite remains the default when no `DATABASE_URL` is set. All features work with both backends including full-text archive search (FTS5 on SQLite, tsvector+GIN on PostgreSQL), backup/restore (file copy vs pg_dump/pg_restore), health diagnostics, and cross-database restore (import a SQLite backup into PostgreSQL with automatic type conversion and FK handling).
 - **Shortest Job First Queue Scheduling** ([#879](https://github.com/maziggy/bambuddy/issues/879)) — New SJF toggle badge on the queue page header. When enabled, the scheduler starts shorter print jobs before longer ones instead of FIFO order. A starvation guard ensures long jobs that get skipped once are protected from being skipped again — they move to the front of the queue on the next cycle. The queue display automatically reorders to show the scheduler's actual execution order. Print duration is cached on queue items at creation time from the 3MF metadata.
+- **Auto-Print G-code Injection** ([#422](https://github.com/maziggy/bambuddy/issues/422)) — Configure custom start and end G-code snippets per printer model in Settings (Workflow tab) for bed-clearing systems like Farmloop, SwapMod, AutoClear, and Printflow 3D. When adding a print to the queue, enable "Inject G-code" to have the scheduler inject the configured snippets into the 3MF before uploading to the printer. The original file is never modified — injection creates a temporary copy for upload only.
 - **External Folder Subfolder Preservation** ([#890](https://github.com/maziggy/bambuddy/issues/890)) — Scanning an external folder now mirrors the real directory structure into the file manager folder tree instead of flattening all files into the root. Subdirectories are created as child LibraryFolders with correct parent/child hierarchy, and files are assigned to their matching subfolder. Hidden directories are skipped when "Show hidden files" is disabled. Subfolders that are deleted from disk are automatically cleaned up on the next scan. Created subfolders inherit the parent's read-only and show-hidden settings.
 
 ### Improved

+ 1 - 0
README.md

@@ -120,6 +120,7 @@ Perfect for remote print farms, traveling makers, or accessing your home printer
 - Shortest Job First scheduling (SJF toggle on queue page — scheduler picks shorter prints first, with starvation guard)
 - Queue Only mode (stage without auto-start)
 - Clear plate confirmation between queued prints (can be disabled in settings for farm workflows)
+- Auto-print G-code injection (per-model start/end snippets for Farmloop, SwapMod, AutoClear, Printflow 3D — toggle per queue item)
 - Smart plug integration (Tasmota, Home Assistant, MQTT, REST/Webhook)
 - REST smart plugs: Control any device with an HTTP API (openHAB, ioBroker, FHEM, Node-RED) with separate power/energy URLs and unit multipliers
 - MQTT smart plugs: Subscribe to Zigbee2MQTT, Shelly, or any MQTT topic for energy monitoring

+ 3 - 0
backend/app/api/routes/print_queue.py

@@ -216,6 +216,8 @@ def _enrich_response(item: PrintQueueItem) -> PrintQueueItemResponse:
         "batch_name": item.batch.name if item.batch else None,
         # SJF scheduling
         "been_jumped": item.been_jumped,
+        # Auto-print G-code injection
+        "gcode_injection": item.gcode_injection,
     }
     response = PrintQueueItemResponse(**item_dict)
     if item.archive:
@@ -505,6 +507,7 @@ async def add_to_queue(
             layer_inspect=data.layer_inspect,
             timelapse=data.timelapse,
             use_ams=data.use_ams,
+            gcode_injection=data.gcode_injection,
             position=max_pos + 1 + i,
             status="pending",
             created_by_id=current_user.id if current_user else None,

+ 3 - 0
backend/app/core/database.py

@@ -1288,6 +1288,9 @@ async def run_migrations(conn):
     await _safe_execute(conn, "ALTER TABLE print_queue ADD COLUMN print_time_seconds INTEGER")
     await _safe_execute(conn, "ALTER TABLE print_queue ADD COLUMN been_jumped BOOLEAN DEFAULT FALSE NOT NULL")
 
+    # Migration: Auto-print G-code injection (#422)
+    await _safe_execute(conn, "ALTER TABLE print_queue ADD COLUMN gcode_injection BOOLEAN DEFAULT FALSE NOT NULL")
+
     # Migration: Add backup_spools and backup_archives columns to github_backup_config
     await _safe_execute(conn, "ALTER TABLE github_backup_config ADD COLUMN backup_spools BOOLEAN DEFAULT 0")
     await _safe_execute(conn, "ALTER TABLE github_backup_config ADD COLUMN backup_archives BOOLEAN DEFAULT 0")

+ 3 - 0
backend/app/models/print_queue.py

@@ -62,6 +62,9 @@ class PrintQueueItem(Base):
     print_time_seconds: Mapped[int | None] = mapped_column(Integer, nullable=True)  # Cached from archive/library
     been_jumped: Mapped[bool] = mapped_column(Boolean, default=False)  # Starvation guard for SJF
 
+    # Auto-print G-code injection (#422)
+    gcode_injection: Mapped[bool] = mapped_column(Boolean, default=False)
+
     # Print options
     bed_levelling: Mapped[bool] = mapped_column(Boolean, default=True)
     flow_cali: Mapped[bool] = mapped_column(Boolean, default=False)

+ 9 - 0
backend/app/schemas/print_queue.py

@@ -40,6 +40,8 @@ class PrintQueueItemCreate(BaseModel):
     layer_inspect: bool = False
     timelapse: bool = False
     use_ams: bool = True
+    # Auto-print G-code injection
+    gcode_injection: bool = False
     # Batch: create multiple copies (creates a batch if > 1)
     quantity: int = 1
 
@@ -63,6 +65,8 @@ class PrintQueueItemUpdate(BaseModel):
     layer_inspect: bool | None = None
     timelapse: bool | None = None
     use_ams: bool | None = None
+    # Auto-print G-code injection
+    gcode_injection: bool | None = None
 
 
 class PrintQueueItemResponse(BaseModel):
@@ -120,6 +124,9 @@ class PrintQueueItemResponse(BaseModel):
     # Shortest-job-first scheduling
     been_jumped: bool = False
 
+    # Auto-print G-code injection
+    gcode_injection: bool = False
+
     class Config:
         from_attributes = True
 
@@ -150,6 +157,8 @@ class PrintQueueBulkUpdate(BaseModel):
     layer_inspect: bool | None = None
     timelapse: bool | None = None
     use_ams: bool | None = None
+    # Auto-print G-code injection
+    gcode_injection: bool | None = None
 
 
 class PrintQueueBulkUpdateResponse(BaseModel):

+ 20 - 0
backend/app/schemas/settings.py

@@ -84,6 +84,12 @@ class AppSettings(BaseModel):
         description="JSON blob of drying presets per filament type (empty = use built-in defaults)",
     )
 
+    # Auto-print G-code injection (#422)
+    gcode_snippets: str = Field(
+        default="",
+        description="JSON: per-model G-code injection snippets {model: {start_gcode, end_gcode}}",
+    )
+
     # Print modal settings
     per_printer_mapping_expanded: bool = Field(
         default=False, description="Expand custom filament mapping by default in print modal"
@@ -303,8 +309,22 @@ class AppSettingsUpdate(BaseModel):
     stagger_interval_minutes: int | None = Field(default=None, ge=1, le=60)
     require_plate_clear: bool | None = None
     queue_shortest_first: bool | None = None
+    gcode_snippets: str | None = None
     default_sidebar_order: str | None = None
 
+    @field_validator("gcode_snippets")
+    @classmethod
+    def validate_gcode_snippets(cls, v: str | None) -> str | None:
+        if v is None or v == "":
+            return v
+        try:
+            parsed = json.loads(v)
+        except json.JSONDecodeError:
+            raise ValueError("gcode_snippets must be valid JSON or empty")
+        if not isinstance(parsed, dict):
+            raise ValueError("gcode_snippets must be a JSON object keyed by printer model")
+        return v
+
     @field_validator("default_sidebar_order")
     @classmethod
     def validate_default_sidebar_order(cls, v: str | None) -> str | None:

+ 36 - 0
backend/app/services/print_scheduler.py

@@ -1120,6 +1120,12 @@ class PrintScheduler:
             )
         return idle
 
+    async def _get_setting(self, db: AsyncSession, key: str) -> str | None:
+        """Read a setting value from the database."""
+        result = await db.execute(select(Settings).where(Settings.key == key))
+        setting = result.scalar_one_or_none()
+        return setting.value if setting else None
+
     async def _get_bool_setting(self, db: AsyncSession, key: str, default: bool = False) -> bool:
         """Read a boolean setting from the database."""
         result = await db.execute(select(Settings).where(Settings.key == key))
@@ -1644,6 +1650,32 @@ class PrintScheduler:
             await self._power_off_if_needed(db, item)
             return
 
+        # G-code injection for auto-print systems (#422)
+        injected_path = None
+        if item.gcode_injection:
+            try:
+                snippets_raw = await self._get_setting(db, "gcode_snippets")
+                if snippets_raw:
+                    snippets = json.loads(snippets_raw)
+                    model_snippets = snippets.get(printer.model, {})
+                    start_gc = (model_snippets.get("start_gcode") or "").strip()
+                    end_gc = (model_snippets.get("end_gcode") or "").strip()
+                    if start_gc or end_gc:
+                        from backend.app.utils.threemf_tools import inject_gcode_into_3mf
+
+                        injected_path = inject_gcode_into_3mf(
+                            file_path, item.plate_id or 1, start_gc or None, end_gc or None
+                        )
+                        if injected_path:
+                            file_path = injected_path
+                            logger.info("Queue item %s: G-code injected for model %s", item.id, printer.model)
+                        else:
+                            logger.warning(
+                                "Queue item %s: G-code injection returned no result, using original", item.id
+                            )
+            except Exception as e:
+                logger.warning("Queue item %s: G-code injection failed, using original: %s", item.id, e)
+
         # Upload file to printer via FTP
         # Use a clean filename to avoid issues with double extensions like .gcode.3mf
         base_name = filename
@@ -1708,6 +1740,10 @@ class PrintScheduler:
             uploaded = False
             logger.error("Queue item %s: FTP error: %s (type: %s)", item.id, e, type(e).__name__)
 
+        # Clean up injected temp file after upload attempt
+        if injected_path and injected_path.exists():
+            injected_path.unlink(missing_ok=True)
+
         if not uploaded:
             error_msg = (
                 "Failed to upload file to printer. Check if SD card is inserted and properly formatted (FAT32/exFAT). "

+ 71 - 0
backend/app/utils/threemf_tools.py

@@ -440,3 +440,74 @@ def extract_filament_usage_from_3mf(file_path: Path, plate_id: int | None = None
         pass  # Return whatever usage data was collected before the error
 
     return filament_usage
+
+
+def inject_gcode_into_3mf(
+    source_path: Path,
+    plate_id: int,
+    start_gcode: str | None,
+    end_gcode: str | None,
+):
+    """Create a temp copy of a 3MF with G-code injected at start/end.
+
+    Args:
+        source_path: Path to the original 3MF file.
+        plate_id: Plate number (1-indexed) to inject into.
+        start_gcode: G-code to prepend, or None.
+        end_gcode: G-code to append, or None.
+
+    Returns:
+        Path to temp file with injected G-code, or None if injection failed.
+        Caller is responsible for cleaning up the temp file.
+    """
+    import tempfile
+
+    if not start_gcode and not end_gcode:
+        return None
+
+    try:
+        # Find the target gcode file inside the 3MF
+        with zipfile.ZipFile(source_path, "r") as zf:
+            all_gcode = [f for f in zf.namelist() if f.endswith(".gcode")]
+            if not all_gcode:
+                return None
+
+            # Try plate-specific gcode file first
+            target_gcode = None
+            plate_pattern = f"plate_{plate_id}.gcode"
+            for f in all_gcode:
+                if f.endswith(plate_pattern):
+                    target_gcode = f
+                    break
+
+            # Fall back to first gcode file
+            if target_gcode is None:
+                target_gcode = all_gcode[0]
+
+            # Read and modify gcode content
+            gcode_content = zf.read(target_gcode).decode("utf-8", errors="ignore")
+
+            if start_gcode:
+                gcode_content = start_gcode + "\n" + gcode_content
+            if end_gcode:
+                gcode_content = gcode_content.rstrip("\n") + "\n" + end_gcode + "\n"
+
+            # Write modified 3MF to temp file
+            with tempfile.NamedTemporaryFile(delete=False, suffix=".3mf") as tmp:
+                tmp_path = Path(tmp.name)
+
+            with zipfile.ZipFile(tmp_path, "w", zipfile.ZIP_DEFLATED) as zf_write:
+                for item in zf.namelist():
+                    info = zf.getinfo(item)
+                    if item == target_gcode:
+                        zf_write.writestr(info, gcode_content.encode("utf-8"))
+                    else:
+                        zf_write.writestr(info, zf.read(item))
+
+        return tmp_path
+
+    except Exception:
+        # Clean up temp file on error
+        if "tmp_path" in locals() and tmp_path.exists():
+            tmp_path.unlink(missing_ok=True)
+        return None

+ 207 - 0
backend/tests/unit/test_gcode_injection.py

@@ -0,0 +1,207 @@
+"""Unit tests for G-code injection into 3MF files (#422)."""
+
+import tempfile
+import zipfile
+from pathlib import Path
+
+import pytest
+
+from backend.app.utils.threemf_tools import inject_gcode_into_3mf
+
+
+def _make_temp_path(suffix=".3mf") -> Path:
+    """Create a temp file path without leaving it open (avoids SIM115)."""
+    fd, name = tempfile.mkstemp(suffix=suffix)
+    import os
+
+    os.close(fd)
+    return Path(name)
+
+
+def _make_test_3mf(gcode_content: str = "G28\nG1 X0 Y0\nM400\n", plate_id: int = 1) -> Path:
+    """Create a minimal 3MF file with embedded G-code for testing."""
+    tmp_path = _make_temp_path()
+
+    with zipfile.ZipFile(tmp_path, "w", zipfile.ZIP_DEFLATED) as zf:
+        zf.writestr(f"Metadata/plate_{plate_id}.gcode", gcode_content)
+        zf.writestr("Metadata/slice_info.config", "<config></config>")
+        zf.writestr("3D/3dmodel.model", "<model></model>")
+
+    return tmp_path
+
+
+class TestInjectGcodeInto3mf:
+    """Tests for inject_gcode_into_3mf()."""
+
+    def test_inject_start_gcode(self):
+        """Start G-code is prepended before the original content."""
+        source = _make_test_3mf("G28\nM400\n")
+        try:
+            result = inject_gcode_into_3mf(source, 1, "M117 Start\nG92 E0", None)
+            assert result is not None
+
+            with zipfile.ZipFile(result, "r") as zf:
+                gcode = zf.read("Metadata/plate_1.gcode").decode("utf-8")
+
+            assert gcode.startswith("M117 Start\nG92 E0\n")
+            assert "G28\nM400\n" in gcode
+        finally:
+            source.unlink(missing_ok=True)
+            if result:
+                result.unlink(missing_ok=True)
+
+    def test_inject_end_gcode(self):
+        """End G-code is appended after the original content."""
+        source = _make_test_3mf("G28\nM400")
+        try:
+            result = inject_gcode_into_3mf(source, 1, None, "M104 S0\nG28 X")
+            assert result is not None
+
+            with zipfile.ZipFile(result, "r") as zf:
+                gcode = zf.read("Metadata/plate_1.gcode").decode("utf-8")
+
+            assert gcode.endswith("M104 S0\nG28 X\n")
+            assert gcode.startswith("G28\nM400")
+        finally:
+            source.unlink(missing_ok=True)
+            if result:
+                result.unlink(missing_ok=True)
+
+    def test_inject_both_start_and_end(self):
+        """Both start and end G-code are injected."""
+        source = _make_test_3mf("G28\n")
+        try:
+            result = inject_gcode_into_3mf(source, 1, "; START", "; END")
+            assert result is not None
+
+            with zipfile.ZipFile(result, "r") as zf:
+                gcode = zf.read("Metadata/plate_1.gcode").decode("utf-8")
+
+            assert gcode.startswith("; START\n")
+            assert gcode.endswith("; END\n")
+            assert "G28" in gcode
+        finally:
+            source.unlink(missing_ok=True)
+            if result:
+                result.unlink(missing_ok=True)
+
+    def test_no_injection_returns_none(self):
+        """Returns None when both start and end are None."""
+        source = _make_test_3mf()
+        try:
+            result = inject_gcode_into_3mf(source, 1, None, None)
+            assert result is None
+        finally:
+            source.unlink(missing_ok=True)
+
+    def test_empty_strings_returns_none(self):
+        """Returns None when both start and end are empty strings."""
+        source = _make_test_3mf()
+        try:
+            result = inject_gcode_into_3mf(source, 1, "", "")
+            assert result is None
+        finally:
+            source.unlink(missing_ok=True)
+
+    def test_plate_id_selection(self):
+        """Injects into the correct plate's G-code file."""
+        source = _make_temp_path()
+
+        with zipfile.ZipFile(source, "w", zipfile.ZIP_DEFLATED) as zf:
+            zf.writestr("Metadata/plate_1.gcode", "PLATE1\n")
+            zf.writestr("Metadata/plate_2.gcode", "PLATE2\n")
+
+        try:
+            result = inject_gcode_into_3mf(source, 2, "; INJECTED", None)
+            assert result is not None
+
+            with zipfile.ZipFile(result, "r") as zf:
+                plate1 = zf.read("Metadata/plate_1.gcode").decode("utf-8")
+                plate2 = zf.read("Metadata/plate_2.gcode").decode("utf-8")
+
+            # Only plate 2 should be modified
+            assert plate1 == "PLATE1\n"
+            assert plate2.startswith("; INJECTED\n")
+        finally:
+            source.unlink(missing_ok=True)
+            if result:
+                result.unlink(missing_ok=True)
+
+    def test_preserves_other_files(self):
+        """Non-gcode files in the 3MF are preserved unchanged."""
+        source = _make_test_3mf()
+        try:
+            result = inject_gcode_into_3mf(source, 1, "; START", None)
+            assert result is not None
+
+            with zipfile.ZipFile(result, "r") as zf:
+                names = zf.namelist()
+                assert "Metadata/slice_info.config" in names
+                assert "3D/3dmodel.model" in names
+                config = zf.read("Metadata/slice_info.config").decode("utf-8")
+                assert config == "<config></config>"
+        finally:
+            source.unlink(missing_ok=True)
+            if result:
+                result.unlink(missing_ok=True)
+
+    def test_no_gcode_file_returns_none(self):
+        """Returns None when the 3MF has no gcode files."""
+        source = _make_temp_path()
+
+        with zipfile.ZipFile(source, "w", zipfile.ZIP_DEFLATED) as zf:
+            zf.writestr("3D/3dmodel.model", "<model></model>")
+
+        try:
+            result = inject_gcode_into_3mf(source, 1, "; START", None)
+            assert result is None
+        finally:
+            source.unlink(missing_ok=True)
+
+    def test_invalid_file_returns_none(self):
+        """Returns None for a non-ZIP file."""
+        source = _make_temp_path()
+        source.write_bytes(b"not a zip file")
+
+        try:
+            result = inject_gcode_into_3mf(source, 1, "; START", None)
+            assert result is None
+        finally:
+            source.unlink(missing_ok=True)
+
+    def test_fallback_to_first_gcode(self):
+        """Falls back to first gcode file when plate-specific not found."""
+        source = _make_temp_path()
+
+        with zipfile.ZipFile(source, "w", zipfile.ZIP_DEFLATED) as zf:
+            zf.writestr("Metadata/plate_1.gcode", "ORIGINAL\n")
+
+        try:
+            # Request plate 5 which doesn't exist — should fall back to plate_1
+            result = inject_gcode_into_3mf(source, 5, "; INJECTED", None)
+            assert result is not None
+
+            with zipfile.ZipFile(result, "r") as zf:
+                gcode = zf.read("Metadata/plate_1.gcode").decode("utf-8")
+
+            assert gcode.startswith("; INJECTED\n")
+        finally:
+            source.unlink(missing_ok=True)
+            if result:
+                result.unlink(missing_ok=True)
+
+    def test_original_file_unchanged(self):
+        """The source 3MF is never modified."""
+        source = _make_test_3mf("ORIGINAL\n")
+        try:
+            result = inject_gcode_into_3mf(source, 1, "; START", "; END")
+            assert result is not None
+
+            # Verify original is untouched
+            with zipfile.ZipFile(source, "r") as zf:
+                original = zf.read("Metadata/plate_1.gcode").decode("utf-8")
+            assert original == "ORIGINAL\n"
+        finally:
+            source.unlink(missing_ok=True)
+            if result:
+                result.unlink(missing_ok=True)

+ 1 - 0
frontend/src/__tests__/components/PrintModal.test.tsx

@@ -30,6 +30,7 @@ const createMockQueueItem = (overrides: Partial<PrintQueueItem> = {}): PrintQueu
   scheduled_time: null,
   require_previous_success: false,
   auto_off_after: false,
+  gcode_injection: false,
   manual_start: false,
   ams_mapping: null,
   plate_id: null,

+ 27 - 0
frontend/src/__tests__/pages/QueuePage.test.tsx

@@ -415,4 +415,31 @@ describe('QueuePage', () => {
       });
     });
   });
+
+  describe('gcode injection badge', () => {
+    it('shows G-code badge when gcode_injection is true', async () => {
+      const itemsWithGcode = mockQueueItems.map((item, i) =>
+        i === 0 ? { ...item, gcode_injection: true } : item
+      );
+      server.use(
+        http.get('/api/v1/queue/', () => HttpResponse.json(itemsWithGcode)),
+      );
+
+      render(<QueuePage />);
+
+      await waitFor(() => {
+        expect(screen.getByText('G-code')).toBeInTheDocument();
+      });
+    });
+
+    it('does not show G-code badge when gcode_injection is false', async () => {
+      render(<QueuePage />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Test Print 1')).toBeInTheDocument();
+      });
+
+      expect(screen.queryByText('G-code')).not.toBeInTheDocument();
+    });
+  });
 });

+ 9 - 0
frontend/src/api/client.ts

@@ -831,6 +831,7 @@ export interface AppSettings {
   queue_drying_block: boolean;  // Block queue until drying completes
   ambient_drying_enabled: boolean;  // Auto-dry idle printers based on humidity regardless of queue
   drying_presets: string;  // JSON blob of drying presets per filament type
+  gcode_snippets: string;  // JSON: per-model G-code injection snippets
   // Print modal settings
   per_printer_mapping_expanded: boolean;  // Whether custom mapping is expanded by default in print modal
   // Date/time format settings
@@ -1384,6 +1385,8 @@ export interface PrintQueueItem {
   batch_name?: string | null;
   // Shortest-job-first scheduling
   been_jumped?: boolean;
+  // Auto-print G-code injection
+  gcode_injection?: boolean;
 }
 
 export interface PrintBatch {
@@ -1423,6 +1426,8 @@ export interface PrintQueueItemCreate {
   layer_inspect?: boolean;
   timelapse?: boolean;
   use_ams?: boolean;
+  // Auto-print G-code injection
+  gcode_injection?: boolean;
   // Batch: create multiple copies (creates a batch if > 1)
   quantity?: number;
 }
@@ -1446,6 +1451,8 @@ export interface PrintQueueItemUpdate {
   layer_inspect?: boolean;
   timelapse?: boolean;
   use_ams?: boolean;
+  // Auto-print G-code injection
+  gcode_injection?: boolean;
 }
 
 export interface PrintQueueBulkUpdate {
@@ -1462,6 +1469,8 @@ export interface PrintQueueBulkUpdate {
   layer_inspect?: boolean;
   timelapse?: boolean;
   use_ams?: boolean;
+  // Auto-print G-code injection
+  gcode_injection?: boolean;
 }
 
 export interface PrintQueueBulkUpdateResponse {

+ 19 - 1
frontend/src/components/PrintModal/ScheduleOptions.tsx

@@ -1,6 +1,6 @@
 import { useState, useEffect, useRef } from 'react';
 import { useTranslation } from 'react-i18next';
-import { Calendar, Clock, Hand, Power, Layers } from 'lucide-react';
+import { Calendar, Clock, Hand, Power, Layers, Code } from 'lucide-react';
 import type { ScheduleOptionsProps, ScheduleType } from './types';
 import {
   formatDateInput,
@@ -27,6 +27,7 @@ export function ScheduleOptionsPanel({
   canControlPrinter = true,
   showStagger = false,
   printerCount = 0,
+  hasGcodeSnippets = false,
 }: ScheduleOptionsProps) {
   const { t } = useTranslation();
   const [dateValue, setDateValue] = useState('');
@@ -251,6 +252,23 @@ export function ScheduleOptionsPanel({
         </label>
       </div>
 
+      {/* G-code injection */}
+      {hasGcodeSnippets && (
+        <div className="flex items-center gap-2">
+          <input
+            type="checkbox"
+            id="gcodeInjection"
+            checked={options.gcodeInjection}
+            onChange={(e) => onChange({ ...options, gcodeInjection: e.target.checked })}
+            className="rounded border-bambu-dark-tertiary bg-bambu-dark text-bambu-green focus:ring-bambu-green"
+          />
+          <label htmlFor="gcodeInjection" className="text-sm flex items-center gap-1 text-bambu-gray">
+            <Code className="w-3.5 h-3.5" />
+            {t('printModal.gcodeInjection', 'Inject auto-print G-code')}
+          </label>
+        </div>
+      )}
+
       {/* Stagger start */}
       {showStagger && options.scheduleType !== 'manual' && (
         <div className="space-y-3">

+ 23 - 1
frontend/src/components/PrintModal/index.tsx

@@ -1,5 +1,5 @@
 import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
-import { AlertCircle, AlertTriangle, Calendar, Layers, Loader2, Pencil, Printer, X } from 'lucide-react';
+import { AlertCircle, AlertTriangle, Calendar, Code, Layers, Loader2, Pencil, Printer, X } from 'lucide-react';
 import { useEffect, useMemo, useRef, useState } from 'react';
 import { useTranslation } from 'react-i18next';
 import type { PrintQueueItemCreate, PrintQueueItemUpdate, SpoolAssignment } from '../../api/client';
@@ -124,6 +124,7 @@ export function PrintModal({
         scheduledTime,
         requirePreviousSuccess: queueItem.require_previous_success,
         autoOffAfter: queueItem.auto_off_after,
+        gcodeInjection: queueItem.gcode_injection ?? false,
         staggerEnabled: false,
         staggerGroupSize: DEFAULT_SCHEDULE_OPTIONS.staggerGroupSize,
         staggerIntervalMinutes: DEFAULT_SCHEDULE_OPTIONS.staggerIntervalMinutes,
@@ -645,6 +646,7 @@ export function PrintModal({
       library_file_id: isLibraryFile ? libraryFileId : undefined,
       require_previous_success: scheduleOptions.requirePreviousSuccess,
       auto_off_after: scheduleOptions.autoOffAfter,
+      gcode_injection: scheduleOptions.gcodeInjection,
       manual_start: scheduleOptions.scheduleType === 'manual',
       ams_mapping: printerId ? getMappingForPrinter(printerId) : undefined,
       plate_id: plateOverride !== undefined ? plateOverride : selectedPlate,
@@ -678,6 +680,7 @@ export function PrintModal({
               filament_overrides: filamentOverridesArray || null,
               require_previous_success: scheduleOptions.requirePreviousSuccess,
               auto_off_after: scheduleOptions.autoOffAfter,
+              gcode_injection: scheduleOptions.gcodeInjection,
               manual_start: scheduleOptions.scheduleType === 'manual',
               ams_mapping: undefined,
               plate_id: plateId,
@@ -755,6 +758,7 @@ export function PrintModal({
                 target_location: null,
                 require_previous_success: scheduleOptions.requirePreviousSuccess,
                 auto_off_after: scheduleOptions.autoOffAfter,
+                gcode_injection: scheduleOptions.gcodeInjection,
                 manual_start: scheduleOptions.scheduleType === 'manual',
                 ams_mapping: printerMapping,
                 plate_id: plateId,
@@ -1130,9 +1134,27 @@ export function PrintModal({
                 canControlPrinter={hasPermission('printers:control')}
                 showStagger={mode === 'add-to-queue' && assignmentMode === 'printer' && selectedPrinters.length > 1}
                 printerCount={selectedPrinters.length}
+                hasGcodeSnippets={!!settings?.gcode_snippets}
               />
             )}
 
+            {/* G-code injection for reprint mode (only shown when quantity > 1 — applies to queued copies) */}
+            {mode === 'reprint' && !!settings?.gcode_snippets && effectiveQuantity > 1 && (
+              <div className="flex items-center gap-2">
+                <input
+                  type="checkbox"
+                  id="gcodeInjectionReprint"
+                  checked={scheduleOptions.gcodeInjection}
+                  onChange={(e) => setScheduleOptions({ ...scheduleOptions, gcodeInjection: e.target.checked })}
+                  className="rounded border-bambu-dark-tertiary bg-bambu-dark text-bambu-green focus:ring-bambu-green"
+                />
+                <label htmlFor="gcodeInjectionReprint" className="text-sm flex items-center gap-1 text-bambu-gray">
+                  <Code className="w-3.5 h-3.5" />
+                  {t('printModal.gcodeInjection', 'Inject auto-print G-code')}
+                </label>
+              </div>
+            )}
+
             {/* Error message */}
             {updateQueueMutation.isError && (
               <div className="mb-4 p-3 bg-red-500/20 border border-red-500/50 rounded-lg text-sm text-red-400">

+ 4 - 0
frontend/src/components/PrintModal/types.ts

@@ -69,6 +69,7 @@ export interface ScheduleOptions {
   scheduledTime: string;
   requirePreviousSuccess: boolean;
   autoOffAfter: boolean;
+  gcodeInjection: boolean;
   staggerEnabled: boolean;
   staggerGroupSize: number;
   staggerIntervalMinutes: number;
@@ -82,6 +83,7 @@ export const DEFAULT_SCHEDULE_OPTIONS: ScheduleOptions = {
   scheduledTime: '',
   requirePreviousSuccess: false,
   autoOffAfter: false,
+  gcodeInjection: false,
   staggerEnabled: false,
   staggerGroupSize: 2,
   staggerIntervalMinutes: 5,
@@ -214,4 +216,6 @@ export interface ScheduleOptionsProps {
   showStagger?: boolean;
   /** Number of selected printers (for stagger preview) */
   printerCount?: number;
+  /** Whether G-code snippets are configured in settings */
+  hasGcodeSnippets?: boolean;
 }

+ 9 - 0
frontend/src/i18n/locales/de.ts

@@ -919,6 +919,7 @@ export default {
       staged: 'Bereitgestellt',
       requiresPrevious: 'Erfordert vorherigen Erfolg',
       autoPowerOff: 'Automatisch ausschalten',
+      gcodeInjection: 'G-code',
     },
     // Empty state
     empty: {
@@ -1612,6 +1613,13 @@ export default {
     plateClear: 'Druckplatte-Bestätigung',
     requirePlateClear: 'Druckplatte-Bestätigung erforderlich',
     requirePlateClearDescription: 'Wenn aktiviert, wartet der Scheduler auf eine Druckplatte-Bestätigung pro Drucker, bevor geplante Drucke auf Druckern mit abgeschlossenen Aufträgen gestartet werden. Deaktivieren Sie dies für Farm-Workflows, bei denen die Platten physisch überprüft werden.',
+    gcodeInjection: 'G-code Injection',
+    gcodeInjectionDescription: 'Konfigurieren Sie benutzerdefinierten G-code, der am Anfang und/oder Ende von Drucken für Auto-Print-Systeme wie Farmloop, SwapMod, AutoClear und Printflow 3D eingefügt wird. Snippets werden pro Druckermodell konfiguriert und angewendet, wenn "G-code einfügen" bei einem Warteschlangen-Element aktiviert ist.',
+    gcodeInjectionNoPrinters: 'Keine Drucker gefunden. Fügen Sie Drucker hinzu, um G-code-Snippets zu konfigurieren.',
+    gcodeStartLabel: 'Start G-code',
+    gcodeEndLabel: 'End G-code',
+    gcodeStartPlaceholder: 'G-code, der vor dem Druckstart eingefügt wird...',
+    gcodeEndPlaceholder: 'G-code, der nach dem Druckende angefügt wird...',
     staggerGroupSize: 'Group size',
     staggerGroupSizeHelp: 'Printers to start simultaneously per group',
     staggerInterval: 'Interval (minutes)',
@@ -3218,6 +3226,7 @@ export default {
     staggerLastGroup: 'last group: {{count}}',
     staggerTotal: 'total: {{minutes}} min',
     staggerToPrinters: 'Gestaffelt an {{count}} Drucker senden',
+    gcodeInjection: 'Auto-Print G-code einfügen',
   },
 
   // Backup

+ 9 - 0
frontend/src/i18n/locales/en.ts

@@ -919,6 +919,7 @@ export default {
       staged: 'Staged',
       requiresPrevious: 'Requires previous success',
       autoPowerOff: 'Auto power off',
+      gcodeInjection: 'G-code',
     },
     // Empty state
     empty: {
@@ -1613,6 +1614,13 @@ export default {
     plateClear: 'Plate-Clear Confirmation',
     requirePlateClear: 'Require plate-clear confirmation',
     requirePlateClearDescription: 'When enabled, the scheduler waits for per-printer plate-clear confirmation before starting queued prints on printers with finished jobs. Disable for farm workflows where plates are verified physically.',
+    gcodeInjection: 'G-code Injection',
+    gcodeInjectionDescription: 'Configure custom G-code to inject at the start and/or end of prints for auto-print systems like Farmloop, SwapMod, AutoClear, and Printflow 3D. Snippets are configured per printer model and applied when "Inject G-code" is enabled on a queue item.',
+    gcodeInjectionNoPrinters: 'No printers found. Add printers to configure G-code snippets.',
+    gcodeStartLabel: 'Start G-code',
+    gcodeEndLabel: 'End G-code',
+    gcodeStartPlaceholder: 'G-code prepended before the print starts...',
+    gcodeEndPlaceholder: 'G-code appended after the print ends...',
     staggerGroupSize: 'Group size',
     staggerGroupSizeHelp: 'Printers to start simultaneously per group',
     staggerInterval: 'Interval (minutes)',
@@ -3223,6 +3231,7 @@ export default {
     staggerLastGroup: 'last group: {{count}}',
     staggerTotal: 'total: {{minutes}} min',
     staggerToPrinters: 'Stagger to {{count}} printers',
+    gcodeInjection: 'Inject auto-print G-code',
   },
 
   // Backup

+ 9 - 0
frontend/src/i18n/locales/fr.ts

@@ -919,6 +919,7 @@ export default {
       staged: 'Préparé',
       requiresPrevious: 'Nécessite succès précédent',
       autoPowerOff: 'Extinction auto',
+      gcodeInjection: 'G-code',
     },
     // Empty state
     empty: {
@@ -1612,6 +1613,13 @@ export default {
     plateClear: 'Confirmation de plateau libre',
     requirePlateClear: 'Exiger la confirmation de plateau libre',
     requirePlateClearDescription: 'Lorsque activé, le planificateur attend la confirmation de plateau libre par imprimante avant de lancer les impressions en file d\'attente sur les imprimantes ayant terminé. Désactivez pour les workflows de ferme où les plateaux sont vérifiés physiquement.',
+    gcodeInjection: 'Injection de G-code',
+    gcodeInjectionDescription: 'Configurez du G-code personnalisé à injecter au début et/ou à la fin des impressions pour les systèmes d\'auto-impression comme Farmloop, SwapMod, AutoClear et Printflow 3D. Les snippets sont configurés par modèle d\'imprimante et appliqués lorsque « Injecter le G-code » est activé sur un élément de file d\'attente.',
+    gcodeInjectionNoPrinters: 'Aucune imprimante trouvée. Ajoutez des imprimantes pour configurer les snippets G-code.',
+    gcodeStartLabel: 'G-code de début',
+    gcodeEndLabel: 'G-code de fin',
+    gcodeStartPlaceholder: 'G-code ajouté avant le début de l\'impression...',
+    gcodeEndPlaceholder: 'G-code ajouté après la fin de l\'impression...',
     staggerGroupSize: 'Group size',
     staggerGroupSizeHelp: 'Printers to start simultaneously per group',
     staggerInterval: 'Interval (minutes)',
@@ -3209,6 +3217,7 @@ export default {
     staggerLastGroup: 'last group: {{count}}',
     staggerTotal: 'total: {{minutes}} min',
     staggerToPrinters: 'Échelonner sur {{count}} imprimantes',
+    gcodeInjection: 'Injecter le G-code auto-impression',
   },
 
   // Backup

+ 9 - 0
frontend/src/i18n/locales/it.ts

@@ -919,6 +919,7 @@ export default {
       staged: 'In staging',
       requiresPrevious: 'Richiede successo precedente',
       autoPowerOff: 'Spegnimento automatico',
+      gcodeInjection: 'G-code',
     },
     // Empty state
     empty: {
@@ -1612,6 +1613,13 @@ export default {
     plateClear: 'Conferma piatto libero',
     requirePlateClear: 'Richiedi conferma piatto libero',
     requirePlateClearDescription: 'Quando abilitato, lo scheduler attende la conferma per stampante che il piatto è libero prima di avviare le stampe in coda su stampanti con lavori completati. Disabilitare per flussi di lavoro in farm dove i piatti vengono verificati fisicamente.',
+    gcodeInjection: 'Iniezione G-code',
+    gcodeInjectionDescription: 'Configura G-code personalizzato da iniettare all\'inizio e/o alla fine delle stampe per sistemi di stampa automatica come Farmloop, SwapMod, AutoClear e Printflow 3D. Gli snippet sono configurati per modello di stampante e applicati quando "Inietta G-code" è abilitato su un elemento della coda.',
+    gcodeInjectionNoPrinters: 'Nessuna stampante trovata. Aggiungi stampanti per configurare gli snippet G-code.',
+    gcodeStartLabel: 'G-code iniziale',
+    gcodeEndLabel: 'G-code finale',
+    gcodeStartPlaceholder: 'G-code inserito prima dell\'inizio della stampa...',
+    gcodeEndPlaceholder: 'G-code aggiunto dopo la fine della stampa...',
     staggerGroupSize: 'Group size',
     staggerGroupSizeHelp: 'Printers to start simultaneously per group',
     staggerInterval: 'Interval (minutes)',
@@ -3208,6 +3216,7 @@ export default {
     staggerLastGroup: 'last group: {{count}}',
     staggerTotal: 'total: {{minutes}} min',
     staggerToPrinters: 'Scagliona a {{count}} stampanti',
+    gcodeInjection: 'Inietta G-code auto-stampa',
   },
 
   // Backup

+ 9 - 0
frontend/src/i18n/locales/ja.ts

@@ -918,6 +918,7 @@ export default {
       staged: 'ステージ済み',
       requiresPrevious: '前の成功が必要',
       autoPowerOff: '自動電源オフ',
+      gcodeInjection: 'G-code',
     },
     // Empty state
     empty: {
@@ -1611,6 +1612,13 @@ export default {
     plateClear: 'プレートクリア確認',
     requirePlateClear: 'プレートクリア確認を必須にする',
     requirePlateClearDescription: '有効にすると、スケジューラーは完了したプリンターでキューの印刷を開始する前に、プリンターごとのプレートクリア確認を待ちます。プレートを物理的に確認するファームワークフローでは無効にしてください。',
+    gcodeInjection: 'G-codeインジェクション',
+    gcodeInjectionDescription: 'Farmloop、SwapMod、AutoClear、Printflow 3Dなどの自動印刷システム用に、印刷の開始と終了時にカスタムG-codeを挿入します。スニペットはプリンターモデルごとに設定し、キューアイテム��「G-codeを挿入」を有効にすると適用されます。',
+    gcodeInjectionNoPrinters: 'プリンターが見つかりません。G-codeスニペットを設定するにはプリンターを追加してください。',
+    gcodeStartLabel: '開始G-code',
+    gcodeEndLabel: '終了G-code',
+    gcodeStartPlaceholder: '印刷開始前に挿入されるG-code...',
+    gcodeEndPlaceholder: '印刷終了後に追加されるG-code...',
     staggerGroupSize: 'Group size',
     staggerGroupSizeHelp: 'Printers to start simultaneously per group',
     staggerInterval: 'Interval (minutes)',
@@ -3221,6 +3229,7 @@ export default {
     staggerLastGroup: 'last group: {{count}}',
     staggerTotal: 'total: {{minutes}} min',
     staggerToPrinters: '{{count}}台のプリンターに段階的に送信',
+    gcodeInjection: '自動印刷G-codeを挿入',
   },
 
   // Backup

+ 9 - 0
frontend/src/i18n/locales/pt-BR.ts

@@ -919,6 +919,7 @@ export default {
       staged: 'Preparado (início manual)',
       requiresPrevious: 'Requer sucesso anterior',
       autoPowerOff: 'Desligamento automático',
+      gcodeInjection: 'G-code',
     },
     // Empty state
     empty: {
@@ -1612,6 +1613,13 @@ export default {
     plateClear: 'Confirmação de placa livre',
     requirePlateClear: 'Exigir confirmação de placa livre',
     requirePlateClearDescription: 'Quando ativado, o agendador aguarda a confirmação de placa livre por impressora antes de iniciar impressões na fila em impressoras com trabalhos concluídos. Desative para fluxos de trabalho de fazenda onde as placas são verificadas fisicamente.',
+    gcodeInjection: 'Injeção de G-code',
+    gcodeInjectionDescription: 'Configure G-code personalizado para injetar no início e/ou no final das impressões para sistemas de impressão automática como Farmloop, SwapMod, AutoClear e Printflow 3D. Os snippets são configurados por modelo de impressora e aplicados quando "Injetar G-code" está ativado em um item da fila.',
+    gcodeInjectionNoPrinters: 'Nenhuma impressora encontrada. Adicione impressoras para configurar snippets de G-code.',
+    gcodeStartLabel: 'G-code inicial',
+    gcodeEndLabel: 'G-code final',
+    gcodeStartPlaceholder: 'G-code inserido antes do início da impressão...',
+    gcodeEndPlaceholder: 'G-code adicionado após o término da impressão...',
     staggerGroupSize: 'Group size',
     staggerGroupSizeHelp: 'Printers to start simultaneously per group',
     staggerInterval: 'Interval (minutes)',
@@ -3208,6 +3216,7 @@ export default {
     staggerLastGroup: 'last group: {{count}}',
     staggerTotal: 'total: {{minutes}} min',
     staggerToPrinters: 'Escalonar para {{count}} impressoras',
+    gcodeInjection: 'Injetar G-code de auto-impressão',
   },
 
   // Backup

+ 9 - 0
frontend/src/i18n/locales/zh-CN.ts

@@ -919,6 +919,7 @@ export default {
       staged: '已暂存',
       requiresPrevious: '需要前一个成功',
       autoPowerOff: '自动关机',
+      gcodeInjection: 'G-code',
     },
     // Empty state
     empty: {
@@ -1612,6 +1613,13 @@ export default {
     plateClear: '热床清空确认',
     requirePlateClear: '需要热床清空确认',
     requirePlateClearDescription: '启用后,调度器会在已完成打印的打印机上启动排队打印之前,等待每台打印机的热床清空确认。对于物理验证热床的农场工作流,请禁用此选项。',
+    gcodeInjection: 'G-code注入',
+    gcodeInjectionDescription: '为Farmloop、SwapMod、AutoClear和Printflow 3D等自动打印系统配置自定义G-code,在打印开始和/或结束时注入。代码片段按打印机型号配置,在队列项目上启用"注入G-code"时应用。',
+    gcodeInjectionNoPrinters: '未找到打印机。添加打印机以配置G-code代码片段。',
+    gcodeStartLabel: '开始G-code',
+    gcodeEndLabel: '结束G-code',
+    gcodeStartPlaceholder: '在打印开始前插入的G-code...',
+    gcodeEndPlaceholder: '在打印结束后追加的G-code...',
     staggerGroupSize: 'Group size',
     staggerGroupSizeHelp: 'Printers to start simultaneously per group',
     staggerInterval: 'Interval (minutes)',
@@ -3208,6 +3216,7 @@ export default {
     staggerLastGroup: 'last group: {{count}}',
     staggerTotal: 'total: {{minutes}} min',
     staggerToPrinters: '分批发送到 {{count}} 台打印机',
+    gcodeInjection: '注入自动打印G-code',
   },
 
   // Backup

+ 7 - 0
frontend/src/pages/QueuePage.tsx

@@ -52,6 +52,7 @@ import {
   ChevronRight,
   List,
   GanttChart,
+  Code,
 } from 'lucide-react';
 import { api } from '../api/client';
 import { type TimeFormat, formatETA, formatDuration, formatRelativeTime, parseUTCDate } from '../utils/date';
@@ -547,6 +548,12 @@ function SortableQueueItem({
                 {t('queue.badges.autoPowerOff')}
               </span>
             )}
+            {item.gcode_injection && (
+              <span className="text-[10px] sm:text-xs px-1.5 sm:px-2 py-0.5 bg-emerald-500/10 text-emerald-400 rounded-full border border-emerald-500/20 flex items-center gap-1">
+                <Code className="w-2.5 h-2.5 sm:w-3 sm:h-3" />
+                {t('queue.badges.gcodeInjection')}
+              </span>
+            )}
           </div>
 
           {/* Progress bar for printing items - TODO: integrate with WebSocket */}

+ 92 - 1
frontend/src/pages/SettingsPage.tsx

@@ -1,5 +1,5 @@
 import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
-import { Loader2, Plus, Plug, AlertTriangle, RotateCcw, Bell, Download, RefreshCw, ExternalLink, Globe, Droplets, Thermometer, FileText, Edit2, Send, CheckCircle, XCircle, History, Trash2, Zap, TrendingUp, Calendar, DollarSign, Power, PowerOff, Key, Copy, Database, X, Shield, Printer, Cylinder, Wifi, Home, Video, Users, Lock, Unlock, ChevronDown, Save, Mail, Flame, Layers, ListOrdered } from 'lucide-react';
+import { Loader2, Plus, Plug, AlertTriangle, RotateCcw, Bell, Download, RefreshCw, ExternalLink, Globe, Droplets, Thermometer, FileText, Edit2, Send, CheckCircle, XCircle, History, Trash2, Zap, TrendingUp, Calendar, DollarSign, Power, PowerOff, Key, Copy, Database, X, Shield, Printer, Cylinder, Wifi, Home, Video, Users, Lock, Unlock, ChevronDown, Save, Mail, Flame, Layers, ListOrdered, Code } from 'lucide-react';
 import { useTranslation } from 'react-i18next';
 import { useNavigate, useSearchParams } from 'react-router-dom';
 import { api } from '../api/client';
@@ -662,6 +662,7 @@ export function SettingsPage() {
 
   // Ref for debounce timeout
   const saveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
+  const pendingGcodeSnippetsRef = useRef<string | null>(null);
   const isSavingRef = useRef(false);
   const isInitialLoadRef = useRef(true);
 
@@ -3471,6 +3472,96 @@ export function SettingsPage() {
             </CardContent>
           </Card>
 
+          {/* G-code Injection (#422) */}
+          <Card>
+            <CardHeader>
+              <h3 className="text-base font-semibold text-white flex items-center gap-2">
+                <Code className="w-4 h-4 text-bambu-green" />
+                {t('settings.gcodeInjection', 'G-code Injection')}
+              </h3>
+            </CardHeader>
+            <CardContent className="space-y-4">
+              <p className="text-xs text-bambu-gray">
+                {t('settings.gcodeInjectionDescription', 'Configure custom G-code to inject at the start and/or end of prints for auto-print systems like Farmloop, SwapMod, AutoClear, and Printflow 3D. Snippets are configured per printer model and applied when "Inject G-code" is enabled on a queue item.')}
+              </p>
+              {(() => {
+                const gcodeSnippets: Record<string, { start_gcode: string; end_gcode: string }> = (() => {
+                  try {
+                    return localSettings.gcode_snippets ? JSON.parse(localSettings.gcode_snippets) : {};
+                  } catch {
+                    return {};
+                  }
+                })();
+                const printerModels = [...new Set((printers || []).filter((p) => p.model).map((p) => p.model as string))].sort();
+
+                const updateSnippet = (model: string, field: 'start_gcode' | 'end_gcode', value: string) => {
+                  const updated = { ...gcodeSnippets };
+                  if (!updated[model]) {
+                    updated[model] = { start_gcode: '', end_gcode: '' };
+                  }
+                  updated[model][field] = value;
+                  // Remove model entry if both fields are empty
+                  if (!updated[model].start_gcode && !updated[model].end_gcode) {
+                    delete updated[model];
+                  }
+                  const newValue = Object.keys(updated).length > 0 ? JSON.stringify(updated) : '';
+                  // Update local state for immediate UI feedback, save on blur
+                  setLocalSettings(prev => prev ? { ...prev, gcode_snippets: newValue } : null);
+                  pendingGcodeSnippetsRef.current = newValue;
+                };
+
+                const saveGcodeSnippets = () => {
+                  if (pendingGcodeSnippetsRef.current !== null) {
+                    updateMutation.mutate({ gcode_snippets: pendingGcodeSnippetsRef.current });
+                    pendingGcodeSnippetsRef.current = null;
+                  }
+                };
+
+                if (printerModels.length === 0) {
+                  return (
+                    <p className="text-sm text-bambu-gray italic">
+                      {t('settings.gcodeInjectionNoPrinters', 'No printers found. Add printers to configure G-code snippets.')}
+                    </p>
+                  );
+                }
+
+                return printerModels.map((model) => {
+                  const snippet = gcodeSnippets[model] || { start_gcode: '', end_gcode: '' };
+                  return (
+                    <div key={model} className="space-y-2">
+                      <h4 className="text-sm font-medium text-white">{model}</h4>
+                      <div>
+                        <label className="block text-xs text-bambu-gray mb-1">
+                          {t('settings.gcodeStartLabel', 'Start G-code')}
+                        </label>
+                        <textarea
+                          value={snippet.start_gcode}
+                          onChange={(e) => updateSnippet(model, 'start_gcode', e.target.value)}
+                          onBlur={saveGcodeSnippets}
+                          placeholder={t('settings.gcodeStartPlaceholder', 'G-code prepended before the print starts...')}
+                          rows={3}
+                          className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-xs font-mono focus:outline-none focus:border-bambu-green resize-y"
+                        />
+                      </div>
+                      <div>
+                        <label className="block text-xs text-bambu-gray mb-1">
+                          {t('settings.gcodeEndLabel', 'End G-code')}
+                        </label>
+                        <textarea
+                          value={snippet.end_gcode}
+                          onChange={(e) => updateSnippet(model, 'end_gcode', e.target.value)}
+                          onBlur={saveGcodeSnippets}
+                          placeholder={t('settings.gcodeEndPlaceholder', 'G-code appended after the print ends...')}
+                          rows={3}
+                          className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-xs font-mono focus:outline-none focus:border-bambu-green resize-y"
+                        />
+                      </div>
+                    </div>
+                  );
+                });
+              })()}
+            </CardContent>
+          </Card>
 
           </div>
           {/* Right Column */}

File diff suppressed because it is too large
+ 0 - 0
static/assets/index-BkHf43_D.css


File diff suppressed because it is too large
+ 0 - 0
static/assets/index-Zgcm6AwS.css


File diff suppressed because it is too large
+ 0 - 0
static/assets/index-q3PKuEzO.js


+ 2 - 2
static/index.html

@@ -23,8 +23,8 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-6NBxaU55.js"></script>
-    <link rel="stylesheet" crossorigin href="/assets/index-BkHf43_D.css">
+    <script type="module" crossorigin src="/assets/index-q3PKuEzO.js"></script>
+    <link rel="stylesheet" crossorigin href="/assets/index-Zgcm6AwS.css">
   </head>
   <body>
     <div id="root"></div>

Some files were not shown because too many files changed in this diff