mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-09-30 03:01:21 +02:00
chore(deps): dompurify 3.4.10 -> 3.4.11 (GHSA-cmwh-pvxp-8882, moderate)
This commit is contained in:
+1
-1
@@ -13,7 +13,7 @@ If you sponsor and your name isn't here within 48h, please write an email to mar
|
||||
|
||||
## Corporate Sponsors ($300/mo+)
|
||||
|
||||
*None yet — be the first. Your logo on the bambuddy.cool homepage and press.html, plus co-marketing.*
|
||||
- [@northpole3dprinting](https://github.com/northpole3dprinting)
|
||||
|
||||
## Sustaining Sponsors ($150/mo+)
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -23,6 +23,11 @@
|
||||
<a href="https://ko-fi.com/maziggy"><img src="https://img.shields.io/badge/Ko--fi-Support-ff5e5b?style=flat-square&logo=ko-fi&logoColor=white" alt="Ko-fi" target=_blank></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<sub><strong>Backed by</strong></sub><br>
|
||||
<a href="https://northpole3dprinting.com/"><img src="static/img/sponsors/northpole-3d-printing.jpg" alt="North Pole 3D Printing" height="60"></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://demo.bambuddy.cool"><strong>🎮 Try the Live Demo</strong></a> •
|
||||
<a href="#-features">Features</a> •
|
||||
|
||||
@@ -39,6 +39,7 @@ class VirtualPrinterCreate(BaseModel):
|
||||
target_printer_id: int | None = None
|
||||
auto_dispatch: bool = True
|
||||
queue_force_color_match: bool = False
|
||||
gcode_injection: bool = False
|
||||
bind_ip: str | None = None
|
||||
remote_interface_ip: str | None = None
|
||||
|
||||
@@ -52,6 +53,7 @@ class VirtualPrinterUpdate(BaseModel):
|
||||
target_printer_id: int | None = None
|
||||
auto_dispatch: bool | None = None
|
||||
queue_force_color_match: bool | None = None
|
||||
gcode_injection: bool | None = None
|
||||
bind_ip: str | None = None
|
||||
remote_interface_ip: str | None = None
|
||||
tailscale_disabled: bool | None = None
|
||||
@@ -107,6 +109,7 @@ async def _vp_to_dict(vp, db: AsyncSession, status: dict | None = None) -> dict:
|
||||
"target_printer_id": vp.target_printer_id,
|
||||
"auto_dispatch": vp.auto_dispatch,
|
||||
"queue_force_color_match": vp.queue_force_color_match,
|
||||
"gcode_injection": vp.gcode_injection,
|
||||
"bind_ip": vp.bind_ip,
|
||||
"remote_interface_ip": vp.remote_interface_ip,
|
||||
"tailscale_disabled": vp.tailscale_disabled,
|
||||
@@ -242,6 +245,7 @@ async def create_virtual_printer(
|
||||
target_printer_id=body.target_printer_id,
|
||||
auto_dispatch=body.auto_dispatch,
|
||||
queue_force_color_match=body.queue_force_color_match,
|
||||
gcode_injection=body.gcode_injection,
|
||||
bind_ip=body.bind_ip,
|
||||
remote_interface_ip=body.remote_interface_ip,
|
||||
serial_suffix=new_suffix,
|
||||
@@ -419,6 +423,8 @@ async def update_virtual_printer(
|
||||
vp.auto_dispatch = body.auto_dispatch
|
||||
if body.queue_force_color_match is not None:
|
||||
vp.queue_force_color_match = body.queue_force_color_match
|
||||
if body.gcode_injection is not None:
|
||||
vp.gcode_injection = body.gcode_injection
|
||||
if body.bind_ip is not None:
|
||||
vp.bind_ip = body.bind_ip
|
||||
if body.remote_interface_ip is not None:
|
||||
|
||||
@@ -959,6 +959,14 @@ async def run_migrations(conn):
|
||||
conn, "ALTER TABLE virtual_printers ADD COLUMN queue_force_color_match BOOLEAN DEFAULT FALSE"
|
||||
)
|
||||
|
||||
# Per-VP opt-in for auto-print G-code injection (#1516). Default false so
|
||||
# existing gcode_snippets users don't silently start injecting on VP/Studio
|
||||
# Send jobs after upgrading.
|
||||
if is_sqlite():
|
||||
await _safe_execute(conn, "ALTER TABLE virtual_printers ADD COLUMN gcode_injection BOOLEAN DEFAULT 0")
|
||||
else:
|
||||
await _safe_execute(conn, "ALTER TABLE virtual_printers ADD COLUMN gcode_injection BOOLEAN DEFAULT FALSE")
|
||||
|
||||
# Migration: Add target_parts_count column to projects for tracking total parts needed
|
||||
await _safe_execute(conn, "ALTER TABLE projects ADD COLUMN target_parts_count INTEGER")
|
||||
|
||||
|
||||
@@ -49,6 +49,11 @@ class VirtualPrinter(Base):
|
||||
) # queue mode: pin per-slot type+color from the 3MF onto the queue
|
||||
# item so the scheduler refuses to dispatch onto a printer with the wrong
|
||||
# filament loaded (#1188).
|
||||
gcode_injection: Mapped[bool] = mapped_column(
|
||||
Boolean, server_default="false"
|
||||
) # queue mode: opt this VP's Send/Print jobs into per-model G-code snippet
|
||||
# injection (#1516). Default off so existing gcode_snippets users don't
|
||||
# silently start injecting; no-op when no snippets exist for the model.
|
||||
model: Mapped[str | None] = mapped_column(String(50), nullable=True) # SSDP model code (server mode)
|
||||
access_code: Mapped[str | None] = mapped_column(String(8), nullable=True) # 8 chars (server mode)
|
||||
target_printer_id: Mapped[int | None] = mapped_column(
|
||||
|
||||
@@ -138,6 +138,7 @@ class VirtualPrinterInstance:
|
||||
target_printer_id: int | None = None,
|
||||
auto_dispatch: bool = True,
|
||||
queue_force_color_match: bool = False,
|
||||
gcode_injection: bool = False,
|
||||
bind_ip: str = "",
|
||||
remote_interface_ip: str = "",
|
||||
tailscale_disabled: bool = True,
|
||||
@@ -160,6 +161,7 @@ class VirtualPrinterInstance:
|
||||
self.target_printer_id = target_printer_id
|
||||
self.auto_dispatch = auto_dispatch
|
||||
self.queue_force_color_match = queue_force_color_match
|
||||
self.gcode_injection = gcode_injection
|
||||
self.bind_ip = bind_ip
|
||||
self.remote_interface_ip = remote_interface_ip
|
||||
self.tailscale_disabled = tailscale_disabled
|
||||
@@ -668,6 +670,11 @@ class VirtualPrinterInstance:
|
||||
vibration_cali=vibration_cali,
|
||||
layer_inspect=layer_inspect,
|
||||
timelapse=timelapse,
|
||||
# Per-VP opt-in for auto-print G-code injection (#1516).
|
||||
# Default off; when on, the scheduler still no-ops unless
|
||||
# gcode_snippets are configured for the target model, so it's
|
||||
# effectively "inject when enabled AND snippets exist".
|
||||
gcode_injection=self.gcode_injection,
|
||||
)
|
||||
db.add(queue_item)
|
||||
await db.flush() # populate queue_item.id before logging
|
||||
@@ -1226,6 +1233,7 @@ class VirtualPrinterManager:
|
||||
# instance silently keeps the old value until process
|
||||
# restart (#1552 follow-up family).
|
||||
or instance.queue_force_color_match != vp.queue_force_color_match
|
||||
or instance.gcode_injection != vp.gcode_injection
|
||||
or proxy_target_changed
|
||||
)
|
||||
|
||||
@@ -1279,6 +1287,7 @@ class VirtualPrinterManager:
|
||||
target_printer_id=vp.target_printer_id,
|
||||
auto_dispatch=vp.auto_dispatch,
|
||||
queue_force_color_match=vp.queue_force_color_match,
|
||||
gcode_injection=vp.gcode_injection,
|
||||
bind_ip=vp.bind_ip or "",
|
||||
remote_interface_ip=vp.remote_interface_ip or "",
|
||||
tailscale_disabled=vp.tailscale_disabled,
|
||||
|
||||
@@ -5,6 +5,7 @@ per-layer filament usage data from the embedded G-code. This enables
|
||||
accurate partial usage reporting for multi-material prints.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
@@ -546,6 +547,7 @@ _HEADER_PLACEHOLDER_ALIASES = {
|
||||
_HEADER_KEY_RE = re.compile(r"^;\s*([^:]+?)\s*:\s*(.+?)\s*$")
|
||||
_PLACEHOLDER_RE = re.compile(r"\{([a-zA-Z_][a-zA-Z0-9_]*)\}")
|
||||
_START_GCODE_END_MARKER = "; MACHINE_START_GCODE_END"
|
||||
_EXECUTABLE_BLOCK_END_MARKER = "; EXECUTABLE_BLOCK_END"
|
||||
|
||||
|
||||
def _parse_3mf_gcode_header(content: str) -> dict[str, str]:
|
||||
@@ -618,6 +620,29 @@ def _inject_start_at_marker(content: str, snippet: str) -> str:
|
||||
return content[:line_start] + snippet.rstrip("\n") + "\n" + content[line_start:]
|
||||
|
||||
|
||||
def _inject_end_before_marker(content: str, snippet: str) -> str:
|
||||
"""Insert snippet immediately before `; EXECUTABLE_BLOCK_END`.
|
||||
|
||||
The end snippet must run *inside* the executable block. Bambu firmware
|
||||
(verified on a P1S) does not execute G-code that sits after
|
||||
`; EXECUTABLE_BLOCK_END`, so appending to the file end silently drops the
|
||||
snippet — auto-eject / plate-clear moves never fire. Inserting before the
|
||||
marker places the snippet after the printer's own machine-end sequence but
|
||||
still within the executed block. Falls back to appending at the file end if
|
||||
the marker isn't present.
|
||||
"""
|
||||
marker_idx = content.find(_EXECUTABLE_BLOCK_END_MARKER)
|
||||
if marker_idx == -1:
|
||||
logger.warning(
|
||||
"G-code injection: '%s' not found, appending end snippet to file end",
|
||||
_EXECUTABLE_BLOCK_END_MARKER,
|
||||
)
|
||||
return content.rstrip("\n") + "\n" + snippet.rstrip("\n") + "\n"
|
||||
line_start = content.rfind("\n", 0, marker_idx)
|
||||
line_start = 0 if line_start == -1 else line_start + 1
|
||||
return content[:line_start] + snippet.rstrip("\n") + "\n" + content[line_start:]
|
||||
|
||||
|
||||
def inject_gcode_into_3mf(
|
||||
source_path: Path,
|
||||
plate_id: int,
|
||||
@@ -629,8 +654,12 @@ def inject_gcode_into_3mf(
|
||||
Snippets support `{placeholder}` substitution against values parsed from
|
||||
the 3MF G-code header block (e.g. `{max_layer_z}` → `16.00`). Start
|
||||
snippets are anchored to the `; MACHINE_START_GCODE_END` marker so they
|
||||
run after the printer's own startup (#422). End snippets are appended
|
||||
after the last line of the print.
|
||||
run after the printer's own startup (#422). End snippets are inserted just
|
||||
before `; EXECUTABLE_BLOCK_END` so they run inside the executable block —
|
||||
Bambu firmware (P1S) ignores g-code placed after that marker.
|
||||
|
||||
The plate's `.gcode.md5` sidecar is recomputed so firmware that validates
|
||||
it against the gcode (e.g. P1S) still accepts the modified file.
|
||||
|
||||
Args:
|
||||
source_path: Path to the original 3MF file.
|
||||
@@ -672,10 +701,25 @@ def inject_gcode_into_3mf(
|
||||
|
||||
if start_gcode:
|
||||
resolved = _substitute_placeholders(start_gcode, header)
|
||||
# Log the post-substitution snippet so the actually-injected G-code
|
||||
# (placeholders like {max_layer_z} already resolved) is visible at DEBUG.
|
||||
logger.debug("G-code injection [%s]: resolved START snippet:\n%s", target_gcode, resolved)
|
||||
gcode_content = _inject_start_at_marker(gcode_content, resolved)
|
||||
if end_gcode:
|
||||
resolved = _substitute_placeholders(end_gcode, header)
|
||||
gcode_content = gcode_content.rstrip("\n") + "\n" + resolved + "\n"
|
||||
logger.debug("G-code injection [%s]: resolved END snippet:\n%s", target_gcode, resolved)
|
||||
gcode_content = _inject_end_before_marker(gcode_content, resolved)
|
||||
|
||||
# The printer validates the plate gcode against an embedded
|
||||
# `<plate>.gcode.md5` sidecar (uppercase hex, no trailing newline).
|
||||
# Rewriting the gcode without refreshing this hash makes firmware
|
||||
# reject the file at load (P1S: HMS 0500-4003 "unable to parse"),
|
||||
# so recompute it from the exact bytes we're about to write.
|
||||
gcode_bytes = gcode_content.encode("utf-8")
|
||||
md5_name = target_gcode + ".md5"
|
||||
# Not a security hash — this reproduces Bambu's `.gcode.md5` sidecar
|
||||
# format, so flag it as non-security for the linters (ruff S324 / bandit B324).
|
||||
md5_value = hashlib.md5(gcode_bytes, usedforsecurity=False).hexdigest().upper().encode("ascii")
|
||||
|
||||
# Write modified 3MF to temp file
|
||||
with tempfile.NamedTemporaryFile(delete=False, suffix=".3mf") as tmp:
|
||||
@@ -685,7 +729,9 @@ def inject_gcode_into_3mf(
|
||||
for item in zf.namelist():
|
||||
info = zf.getinfo(item)
|
||||
if item == target_gcode:
|
||||
zf_write.writestr(info, gcode_content.encode("utf-8"))
|
||||
zf_write.writestr(info, gcode_bytes)
|
||||
elif item == md5_name:
|
||||
zf_write.writestr(info, md5_value)
|
||||
else:
|
||||
zf_write.writestr(info, zf.read(item))
|
||||
|
||||
|
||||
@@ -353,6 +353,53 @@ class TestVirtualPrinterAutoDispatchAPI:
|
||||
assert get_resp.json()["auto_dispatch"] is False
|
||||
|
||||
|
||||
class TestVirtualPrinterGcodeInjectionAPI:
|
||||
"""Integration tests for gcode_injection (#1516) on /api/v1/virtual-printers endpoints."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_create_virtual_printer_gcode_injection_default_off(self, async_client: AsyncClient):
|
||||
"""Verify creating a VP without gcode_injection defaults to false (opt-in)."""
|
||||
response = await async_client.post(
|
||||
"/api/v1/virtual-printers",
|
||||
json={
|
||||
"name": "TestDefaultInjection",
|
||||
"mode": "queue",
|
||||
"access_code": "12345678",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["gcode_injection"] is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_update_virtual_printer_gcode_injection(self, async_client: AsyncClient):
|
||||
"""Verify gcode_injection can be toggled via PUT and persists."""
|
||||
create_resp = await async_client.post(
|
||||
"/api/v1/virtual-printers",
|
||||
json={
|
||||
"name": "TestToggleInjection",
|
||||
"mode": "queue",
|
||||
"access_code": "12345678",
|
||||
},
|
||||
)
|
||||
assert create_resp.status_code == 200
|
||||
vp_id = create_resp.json()["id"]
|
||||
assert create_resp.json()["gcode_injection"] is False
|
||||
|
||||
update_resp = await async_client.put(
|
||||
f"/api/v1/virtual-printers/{vp_id}",
|
||||
json={"gcode_injection": True},
|
||||
)
|
||||
assert update_resp.status_code == 200
|
||||
assert update_resp.json()["gcode_injection"] is True
|
||||
|
||||
get_resp = await async_client.get(f"/api/v1/virtual-printers/{vp_id}")
|
||||
assert get_resp.status_code == 200
|
||||
assert get_resp.json()["gcode_injection"] is True
|
||||
|
||||
|
||||
class TestVirtualPrinterTailscaleToggleAPI:
|
||||
"""The Tailscale toggle is informational — toggling either way always succeeds.
|
||||
|
||||
|
||||
@@ -573,6 +573,109 @@ class TestVirtualPrinterInstance:
|
||||
queue_item = added_items[0]
|
||||
assert queue_item.manual_start is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_to_print_queue_gcode_injection_on(self, tmp_path):
|
||||
"""#1516: queue items opt into injection when the VP has gcode_injection=True."""
|
||||
from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
|
||||
|
||||
mock_db = AsyncMock()
|
||||
added_items = []
|
||||
mock_db.add = MagicMock(side_effect=added_items.append)
|
||||
mock_db.commit = AsyncMock()
|
||||
|
||||
mock_session_factory = MagicMock()
|
||||
mock_session_ctx = AsyncMock()
|
||||
mock_session_ctx.__aenter__ = AsyncMock(return_value=mock_db)
|
||||
mock_session_ctx.__aexit__ = AsyncMock(return_value=False)
|
||||
mock_session_factory.return_value = mock_session_ctx
|
||||
|
||||
inst = VirtualPrinterInstance(
|
||||
vp_id=13,
|
||||
name="InjectOn",
|
||||
mode="queue",
|
||||
model="C11",
|
||||
access_code="12345678",
|
||||
serial_suffix="391800013",
|
||||
gcode_injection=True,
|
||||
base_dir=tmp_path,
|
||||
session_factory=mock_session_factory,
|
||||
)
|
||||
|
||||
file_path = tmp_path / "test.3mf"
|
||||
file_path.write_bytes(b"fake3mf")
|
||||
|
||||
mock_archive = MagicMock()
|
||||
mock_archive.id = 1
|
||||
mock_archive.print_name = "test"
|
||||
|
||||
with (
|
||||
patch(
|
||||
"backend.app.api.routes.settings.get_setting",
|
||||
new_callable=AsyncMock,
|
||||
return_value=None,
|
||||
),
|
||||
patch(
|
||||
"backend.app.services.archive.ArchiveService.archive_print",
|
||||
new_callable=AsyncMock,
|
||||
return_value=mock_archive,
|
||||
),
|
||||
):
|
||||
await inst._add_to_print_queue(file_path, "192.168.1.100")
|
||||
|
||||
assert len(added_items) == 1
|
||||
assert added_items[0].gcode_injection is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_to_print_queue_gcode_injection_off_by_default(self, tmp_path):
|
||||
"""#1516: queue items do NOT inject when the VP leaves gcode_injection at its default."""
|
||||
from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
|
||||
|
||||
mock_db = AsyncMock()
|
||||
added_items = []
|
||||
mock_db.add = MagicMock(side_effect=added_items.append)
|
||||
mock_db.commit = AsyncMock()
|
||||
|
||||
mock_session_factory = MagicMock()
|
||||
mock_session_ctx = AsyncMock()
|
||||
mock_session_ctx.__aenter__ = AsyncMock(return_value=mock_db)
|
||||
mock_session_ctx.__aexit__ = AsyncMock(return_value=False)
|
||||
mock_session_factory.return_value = mock_session_ctx
|
||||
|
||||
inst = VirtualPrinterInstance(
|
||||
vp_id=14,
|
||||
name="InjectOff",
|
||||
mode="queue",
|
||||
model="C11",
|
||||
access_code="12345678",
|
||||
serial_suffix="391800014",
|
||||
base_dir=tmp_path,
|
||||
session_factory=mock_session_factory,
|
||||
)
|
||||
|
||||
file_path = tmp_path / "test.3mf"
|
||||
file_path.write_bytes(b"fake3mf")
|
||||
|
||||
mock_archive = MagicMock()
|
||||
mock_archive.id = 1
|
||||
mock_archive.print_name = "test"
|
||||
|
||||
with (
|
||||
patch(
|
||||
"backend.app.api.routes.settings.get_setting",
|
||||
new_callable=AsyncMock,
|
||||
return_value=None,
|
||||
),
|
||||
patch(
|
||||
"backend.app.services.archive.ArchiveService.archive_print",
|
||||
new_callable=AsyncMock,
|
||||
return_value=mock_archive,
|
||||
),
|
||||
):
|
||||
await inst._add_to_print_queue(file_path, "192.168.1.100")
|
||||
|
||||
assert len(added_items) == 1
|
||||
assert added_items[0].gcode_injection is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_to_print_queue_uses_workflow_defaults_from_settings(self, tmp_path):
|
||||
"""#1235: VP queue-mode constructed PrintQueueItem without specifying
|
||||
@@ -1650,6 +1753,7 @@ class TestVirtualPrinterManager:
|
||||
"auto_dispatch": True,
|
||||
"tailscale_disabled": True, # Opt-in default (#1070 UX fix)
|
||||
"queue_force_color_match": False, # default — must be explicit so MagicMock truthiness doesn't trip the change detector
|
||||
"gcode_injection": False, # same reason as above
|
||||
"position": 0,
|
||||
}
|
||||
defaults.update(overrides)
|
||||
@@ -1850,6 +1954,43 @@ class TestVirtualPrinterManager:
|
||||
|
||||
mock_remove.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sync_from_db_restarts_on_gcode_injection_toggle(self, manager, tmp_path):
|
||||
"""Toggling gcode_injection in the DB must restart the running instance.
|
||||
|
||||
Without this, the in-memory ``self.gcode_injection`` keeps its old value
|
||||
and ``_add_to_print_queue`` stamps the stale flag on every new queue
|
||||
item — so disabling injection in the UI silently has no effect until
|
||||
the process restarts.
|
||||
"""
|
||||
from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
|
||||
|
||||
inst = VirtualPrinterInstance(
|
||||
vp_id=1,
|
||||
name="TestVP",
|
||||
mode="archive",
|
||||
model="C11",
|
||||
access_code="12345678",
|
||||
serial_suffix="391800001",
|
||||
gcode_injection=True,
|
||||
base_dir=tmp_path,
|
||||
)
|
||||
inst.stop_server = AsyncMock()
|
||||
manager._instances[1] = inst
|
||||
|
||||
db_vp = self._make_db_vp(gcode_injection=False)
|
||||
self._setup_sync_mocks(manager, [db_vp], tmp_path)
|
||||
|
||||
with patch.object(manager, "remove_instance", new_callable=AsyncMock) as mock_remove:
|
||||
with patch("backend.app.services.virtual_printer.manager.VirtualPrinterInstance") as MockInst:
|
||||
mock_new = MagicMock()
|
||||
mock_new.start_server = AsyncMock()
|
||||
MockInst.return_value = mock_new
|
||||
|
||||
await manager.sync_from_db()
|
||||
|
||||
mock_remove.assert_called_once_with(1)
|
||||
|
||||
|
||||
class TestFTPSession:
|
||||
"""Tests for FTP session handling."""
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Unit tests for G-code injection into 3MF files (#422)."""
|
||||
|
||||
import hashlib
|
||||
import tempfile
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
@@ -234,6 +235,92 @@ M104 S0
|
||||
"""
|
||||
|
||||
|
||||
class TestMd5SidecarRecompute:
|
||||
"""The plate `.gcode.md5` sidecar must match the injected gcode (P1S rejects
|
||||
a stale hash with HMS 0500-4003)."""
|
||||
|
||||
def _make_3mf_with_md5(self, gcode: str, plate_id: int = 1) -> Path:
|
||||
"""A 3MF that carries a (deliberately wrong) md5 sidecar, like a real
|
||||
sliced .gcode.3mf does."""
|
||||
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)
|
||||
zf.writestr(f"Metadata/plate_{plate_id}.gcode.md5", "STALEHASHVALUE")
|
||||
zf.writestr("Metadata/slice_info.config", "<config></config>")
|
||||
return tmp_path
|
||||
|
||||
def test_md5_recomputed_to_match_injected_gcode(self):
|
||||
source = self._make_3mf_with_md5("G28\nM400\n")
|
||||
result = None
|
||||
try:
|
||||
result = inject_gcode_into_3mf(source, 1, None, "M104 S0")
|
||||
assert result is not None
|
||||
with zipfile.ZipFile(result, "r") as zf:
|
||||
gcode = zf.read("Metadata/plate_1.gcode")
|
||||
sidecar = zf.read("Metadata/plate_1.gcode.md5")
|
||||
expected = hashlib.md5(gcode, usedforsecurity=False).hexdigest().upper().encode("ascii")
|
||||
assert sidecar == expected
|
||||
assert sidecar != b"STALEHASHVALUE"
|
||||
finally:
|
||||
source.unlink(missing_ok=True)
|
||||
if result:
|
||||
result.unlink(missing_ok=True)
|
||||
|
||||
def test_sidecar_is_uppercase_hex_no_newline(self):
|
||||
"""Match Bambu's on-disk format exactly: uppercase, no trailing newline."""
|
||||
source = self._make_3mf_with_md5("G28\n")
|
||||
result = None
|
||||
try:
|
||||
result = inject_gcode_into_3mf(source, 1, "; START", None)
|
||||
assert result is not None
|
||||
with zipfile.ZipFile(result, "r") as zf:
|
||||
sidecar = zf.read("Metadata/plate_1.gcode.md5")
|
||||
assert sidecar == sidecar.upper()
|
||||
assert not sidecar.endswith(b"\n")
|
||||
assert len(sidecar) == 32
|
||||
finally:
|
||||
source.unlink(missing_ok=True)
|
||||
if result:
|
||||
result.unlink(missing_ok=True)
|
||||
|
||||
def test_no_md5_member_is_not_created(self):
|
||||
"""A 3MF without an md5 sidecar shouldn't gain one (firmware isn't
|
||||
validating it, and inventing a member could surprise older files)."""
|
||||
source = _make_test_3mf("G28\n") # no .md5 member
|
||||
result = None
|
||||
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/plate_1.gcode.md5" not in names
|
||||
finally:
|
||||
source.unlink(missing_ok=True)
|
||||
if result:
|
||||
result.unlink(missing_ok=True)
|
||||
|
||||
def test_other_member_compression_preserved(self):
|
||||
"""Non-target members keep their original compression (P1S preview
|
||||
parser chokes on re-DEFLATEd STORE'd PNGs)."""
|
||||
tmp_path = _make_temp_path()
|
||||
with zipfile.ZipFile(tmp_path, "w") as zf:
|
||||
zf.writestr(zipfile.ZipInfo("Metadata/plate_1.gcode"), "G28\n")
|
||||
# A STORE'd member (compress_type=0), like an embedded preview PNG.
|
||||
stored = zipfile.ZipInfo("Metadata/plate_1.png")
|
||||
stored.compress_type = zipfile.ZIP_STORED
|
||||
zf.writestr(stored, b"\x89PNG\r\n\x1a\n" + b"\x00" * 64)
|
||||
result = None
|
||||
try:
|
||||
result = inject_gcode_into_3mf(tmp_path, 1, None, "; END")
|
||||
assert result is not None
|
||||
with zipfile.ZipFile(result, "r") as zf:
|
||||
assert zf.getinfo("Metadata/plate_1.png").compress_type == zipfile.ZIP_STORED
|
||||
finally:
|
||||
tmp_path.unlink(missing_ok=True)
|
||||
if result:
|
||||
result.unlink(missing_ok=True)
|
||||
|
||||
|
||||
class TestStartAnchoredInjection:
|
||||
"""Tests for #422 follow-up: start g-code injected at MACHINE_START_GCODE_END."""
|
||||
|
||||
@@ -281,9 +368,10 @@ class TestStartAnchoredInjection:
|
||||
if result:
|
||||
result.unlink(missing_ok=True)
|
||||
|
||||
def test_end_still_appended_at_eof(self):
|
||||
"""End g-code keeps the existing append-to-EOF behaviour even with marker present."""
|
||||
source = _make_test_3mf(_BAMBU_GCODE_TEMPLATE)
|
||||
def test_end_falls_back_to_eof_without_block_marker(self):
|
||||
"""Files without ; EXECUTABLE_BLOCK_END (older / non-Bambu slicers) keep the
|
||||
append-to-EOF fallback for end snippets."""
|
||||
source = _make_test_3mf(_BAMBU_GCODE_TEMPLATE) # template has no EXECUTABLE_BLOCK_END
|
||||
try:
|
||||
result = inject_gcode_into_3mf(source, 1, None, "; SWAPMOD-END")
|
||||
assert result is not None
|
||||
@@ -292,8 +380,39 @@ class TestStartAnchoredInjection:
|
||||
gcode = zf.read("Metadata/plate_1.gcode").decode("utf-8")
|
||||
|
||||
assert gcode.endswith("; SWAPMOD-END\n")
|
||||
# Marker anchor is irrelevant for end snippets.
|
||||
assert gcode.index("; SWAPMOD-END") > gcode.index("; MACHINE_START_GCODE_END")
|
||||
finally:
|
||||
source.unlink(missing_ok=True)
|
||||
if result:
|
||||
result.unlink(missing_ok=True)
|
||||
|
||||
def test_end_lands_before_executable_block_end(self):
|
||||
"""With ; EXECUTABLE_BLOCK_END present, the end snippet sits INSIDE the
|
||||
executable block (just before the marker). Bambu firmware (P1S) does not
|
||||
run g-code placed after that marker, so appending to EOF would silently
|
||||
drop auto-eject / plate-clear moves."""
|
||||
gcode_src = (
|
||||
"; HEADER_BLOCK_START\n; max_z_height: 16.00\n; HEADER_BLOCK_END\n"
|
||||
"; MACHINE_START_GCODE_END\n"
|
||||
"G1 X10 Y10 Z0.2\n"
|
||||
"M104 S0 ; printer machine-end\n"
|
||||
"; EXECUTABLE_BLOCK_END\n"
|
||||
)
|
||||
source = _make_test_3mf(gcode_src)
|
||||
try:
|
||||
result = inject_gcode_into_3mf(source, 1, None, "; EJECT-SWEEP")
|
||||
assert result is not None
|
||||
|
||||
with zipfile.ZipFile(result, "r") as zf:
|
||||
gcode = zf.read("Metadata/plate_1.gcode").decode("utf-8")
|
||||
|
||||
snippet_idx = gcode.index("; EJECT-SWEEP")
|
||||
marker_idx = gcode.index("; EXECUTABLE_BLOCK_END")
|
||||
# Snippet is inside the block, before the end marker.
|
||||
assert snippet_idx < marker_idx
|
||||
# The printer's own machine-end still precedes our snippet.
|
||||
assert gcode.index("M104 S0 ; printer machine-end") < snippet_idx
|
||||
# Nothing executable remains after the marker.
|
||||
assert gcode[marker_idx:].strip() == "; EXECUTABLE_BLOCK_END"
|
||||
finally:
|
||||
source.unlink(missing_ok=True)
|
||||
if result:
|
||||
|
||||
Generated
+3
-3
@@ -4087,9 +4087,9 @@
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/dompurify": {
|
||||
"version": "3.4.10",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.10.tgz",
|
||||
"integrity": "sha512-0xzNv0e7oYC6yyuOGZIABPM4qtg3QxLFniDNPP4ZP90wR8Yq3zgwpRbrNiT4N3IKqDbbYFEJLV+JWEs19aZ//w==",
|
||||
"version": "3.4.11",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.11.tgz",
|
||||
"integrity": "sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw==",
|
||||
"optionalDependencies": {
|
||||
"@types/trusted-types": "^2.0.7"
|
||||
}
|
||||
|
||||
@@ -1142,6 +1142,102 @@ describe('PrintModal', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('reprint G-code injection dispatch (#422 / auto-eject)', () => {
|
||||
// Guards the fix: when "Inject auto-print G-code" is ticked on a reprint with
|
||||
// quantity > 1, ALL copies must go through the queue so every one is injected by
|
||||
// the scheduler. The first copy must NOT be dispatched immediately via the direct
|
||||
// reprint path — that path bypasses injection and would leave the first copy stuck
|
||||
// on the plate for auto-eject setups.
|
||||
const withSnippets = () =>
|
||||
http.get('/api/v1/settings/', () =>
|
||||
HttpResponse.json({ gcode_snippets: { X1C: { start_gcode: '', end_gcode: 'M400' } } }),
|
||||
);
|
||||
|
||||
it('injection ON queues all copies and dispatches none immediately', async () => {
|
||||
const reprintCalls: unknown[] = [];
|
||||
const queueCalls: Record<string, unknown>[] = [];
|
||||
server.use(
|
||||
withSnippets(),
|
||||
http.post('/api/v1/archives/:id/reprint', async ({ request }) => {
|
||||
reprintCalls.push(await request.json().catch(() => ({})));
|
||||
return HttpResponse.json({ status: 'dispatched' });
|
||||
}),
|
||||
http.post('/api/v1/queue/', async ({ request }) => {
|
||||
queueCalls.push((await request.json()) as Record<string, unknown>);
|
||||
return HttpResponse.json({ id: queueCalls.length, status: 'pending' });
|
||||
}),
|
||||
);
|
||||
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<PrintModal
|
||||
mode="reprint"
|
||||
archiveId={1}
|
||||
archiveName="Benchy"
|
||||
initialSelectedPrinterIds={[1]}
|
||||
onClose={mockOnClose}
|
||||
onSuccess={mockOnSuccess}
|
||||
/>
|
||||
);
|
||||
|
||||
// Quantity > 1 so the injection checkbox is offered
|
||||
const qty = (await screen.findByLabelText('Quantity')) as HTMLInputElement;
|
||||
await user.tripleClick(qty);
|
||||
await user.keyboard('3');
|
||||
expect(qty.value).toBe('3');
|
||||
|
||||
// Checkbox only renders once snippets are loaded AND quantity > 1
|
||||
await user.click(await screen.findByLabelText(/inject auto-print/i));
|
||||
|
||||
await user.click(document.querySelector('button[type="submit"]') as HTMLElement);
|
||||
|
||||
await waitFor(() => expect(queueCalls.length).toBe(1));
|
||||
// One queue item carrying all copies, and zero immediate reprint dispatches
|
||||
expect(queueCalls[0].quantity).toBe(3);
|
||||
expect(reprintCalls.length).toBe(0);
|
||||
});
|
||||
|
||||
it('injection OFF keeps the immediate first copy and queues the rest', async () => {
|
||||
const reprintCalls: unknown[] = [];
|
||||
const queueCalls: Record<string, unknown>[] = [];
|
||||
server.use(
|
||||
withSnippets(),
|
||||
http.post('/api/v1/archives/:id/reprint', async ({ request }) => {
|
||||
reprintCalls.push(await request.json().catch(() => ({})));
|
||||
return HttpResponse.json({ status: 'dispatched' });
|
||||
}),
|
||||
http.post('/api/v1/queue/', async ({ request }) => {
|
||||
queueCalls.push((await request.json()) as Record<string, unknown>);
|
||||
return HttpResponse.json({ id: queueCalls.length, status: 'pending' });
|
||||
}),
|
||||
);
|
||||
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<PrintModal
|
||||
mode="reprint"
|
||||
archiveId={1}
|
||||
archiveName="Benchy"
|
||||
initialSelectedPrinterIds={[1]}
|
||||
onClose={mockOnClose}
|
||||
onSuccess={mockOnSuccess}
|
||||
/>
|
||||
);
|
||||
|
||||
const qty = (await screen.findByLabelText('Quantity')) as HTMLInputElement;
|
||||
await user.tripleClick(qty);
|
||||
await user.keyboard('3');
|
||||
expect(qty.value).toBe('3');
|
||||
|
||||
// Leave injection unticked → first copy prints immediately, rest queue
|
||||
await user.click(document.querySelector('button[type="submit"]') as HTMLElement);
|
||||
|
||||
await waitFor(() => expect(reprintCalls.length).toBe(1));
|
||||
expect(queueCalls.length).toBe(1);
|
||||
expect(queueCalls[0].quantity).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('project_id forwarding', () => {
|
||||
beforeEach(() => {
|
||||
// Additional handlers needed for library file mode
|
||||
|
||||
@@ -6662,6 +6662,7 @@ export interface VirtualPrinterConfig {
|
||||
target_printer_id: number | null;
|
||||
auto_dispatch: boolean;
|
||||
queue_force_color_match: boolean;
|
||||
gcode_injection: boolean;
|
||||
tailscale_disabled: boolean;
|
||||
bind_ip: string | null;
|
||||
remote_interface_ip: string | null;
|
||||
@@ -6688,6 +6689,7 @@ export const multiVirtualPrinterApi = {
|
||||
target_printer_id?: number;
|
||||
auto_dispatch?: boolean;
|
||||
queue_force_color_match?: boolean;
|
||||
gcode_injection?: boolean;
|
||||
bind_ip?: string;
|
||||
remote_interface_ip?: string;
|
||||
}) =>
|
||||
@@ -6705,6 +6707,7 @@ export const multiVirtualPrinterApi = {
|
||||
target_printer_id?: number;
|
||||
auto_dispatch?: boolean;
|
||||
queue_force_color_match?: boolean;
|
||||
gcode_injection?: boolean;
|
||||
tailscale_disabled?: boolean;
|
||||
bind_ip?: string;
|
||||
remote_interface_ip?: string;
|
||||
|
||||
@@ -763,32 +763,43 @@ export function PrintModal({
|
||||
|
||||
try {
|
||||
if (mode === 'reprint' && !useStagger) {
|
||||
// Reprint mode - start print immediately (single plate only, multi-select not available)
|
||||
const printerMapping = getMappingForPrinter(printerId);
|
||||
if (isLibraryFile) {
|
||||
await api.printLibraryFile(libraryFileId!, printerId, {
|
||||
plate_id: selectedPlate ?? undefined,
|
||||
plate_name: selectedPlateName,
|
||||
ams_mapping: printerMapping,
|
||||
...printOptions,
|
||||
project_id: projectId,
|
||||
cleanup_library_after_dispatch: cleanupLibraryAfterDispatch,
|
||||
});
|
||||
} else {
|
||||
// project_id is intentionally omitted here: reprintArchive targets an existing
|
||||
// archive that already carries its own project association from the original print.
|
||||
await api.reprintArchive(archiveId!, printerId, {
|
||||
plate_id: selectedPlate ?? undefined,
|
||||
plate_name: selectedPlateName,
|
||||
ams_mapping: printerMapping,
|
||||
...printOptions,
|
||||
});
|
||||
}
|
||||
// Queue remaining copies if quantity > 1
|
||||
if (effectiveQuantity > 1) {
|
||||
if (scheduleOptions.gcodeInjection && effectiveQuantity > 1) {
|
||||
// Auto-print injection only happens in the scheduler path. A direct
|
||||
// immediate reprint bypasses it, so the first copy would print without
|
||||
// its end-snippet and stay stuck on the plate — defeating auto-eject and
|
||||
// blocking the injected copies queued behind it. Queue *all* copies so
|
||||
// every one is dispatched (and injected) by the scheduler.
|
||||
const queueData = getQueueData(printerId, plateId);
|
||||
queueData.quantity = effectiveQuantity - 1;
|
||||
queueData.quantity = effectiveQuantity;
|
||||
await addToQueueMutation.mutateAsync(queueData);
|
||||
} else {
|
||||
// Reprint mode - start print immediately (single plate only, multi-select not available)
|
||||
if (isLibraryFile) {
|
||||
await api.printLibraryFile(libraryFileId!, printerId, {
|
||||
plate_id: selectedPlate ?? undefined,
|
||||
plate_name: selectedPlateName,
|
||||
ams_mapping: printerMapping,
|
||||
...printOptions,
|
||||
project_id: projectId,
|
||||
cleanup_library_after_dispatch: cleanupLibraryAfterDispatch,
|
||||
});
|
||||
} else {
|
||||
// project_id is intentionally omitted here: reprintArchive targets an existing
|
||||
// archive that already carries its own project association from the original print.
|
||||
await api.reprintArchive(archiveId!, printerId, {
|
||||
plate_id: selectedPlate ?? undefined,
|
||||
plate_name: selectedPlateName,
|
||||
ams_mapping: printerMapping,
|
||||
...printOptions,
|
||||
});
|
||||
}
|
||||
// Queue remaining copies if quantity > 1
|
||||
if (effectiveQuantity > 1) {
|
||||
const queueData = getQueueData(printerId, plateId);
|
||||
queueData.quantity = effectiveQuantity - 1;
|
||||
await addToQueueMutation.mutateAsync(queueData);
|
||||
}
|
||||
}
|
||||
} else if (mode === 'edit-queue-item' && progressCounter === 1) {
|
||||
// Edit mode - update the original queue item for the first entry
|
||||
@@ -885,6 +896,21 @@ export function PrintModal({
|
||||
// Quantity only applies for single-printer or model-based assignment (not multi-printer)
|
||||
const effectiveQuantity = (assignmentMode === 'printer' && selectedPrinters.length > 1) ? 1 : quantity;
|
||||
|
||||
// Keep scheduleOptions.gcodeInjection in sync with the checkbox's render
|
||||
// condition. The checkbox only renders for reprint + snippets configured +
|
||||
// quantity > 1, so if the user ticks it at quantity 2 then drops back to 1
|
||||
// the box hides but the state stays true — and the immediate-reprint path
|
||||
// would then silently bypass injection.
|
||||
useEffect(() => {
|
||||
if (
|
||||
mode === 'reprint' &&
|
||||
scheduleOptions.gcodeInjection &&
|
||||
(effectiveQuantity <= 1 || !settings?.gcode_snippets)
|
||||
) {
|
||||
setScheduleOptions((opts) => ({ ...opts, gcodeInjection: false }));
|
||||
}
|
||||
}, [mode, effectiveQuantity, settings?.gcode_snippets, scheduleOptions.gcodeInjection]);
|
||||
|
||||
// Modal title and action button text based on mode
|
||||
const getModalConfig = () => {
|
||||
const printerCount = selectedPrinters.length;
|
||||
|
||||
@@ -55,6 +55,7 @@ export function VirtualPrinterCard({ printer, models }: VirtualPrinterCardProps)
|
||||
const [localModel, setLocalModel] = useState(printer.model || '');
|
||||
const [localAutoDispatch, setLocalAutoDispatch] = useState(printer.auto_dispatch ?? true);
|
||||
const [localQueueForceColorMatch, setLocalQueueForceColorMatch] = useState(printer.queue_force_color_match ?? false);
|
||||
const [localGcodeInjection, setLocalGcodeInjection] = useState(printer.gcode_injection ?? false);
|
||||
const [localTailscaleDisabled, setLocalTailscaleDisabled] = useState(printer.tailscale_disabled ?? true);
|
||||
const [showAccessCode, setShowAccessCode] = useState(false);
|
||||
const [pendingAction, setPendingAction] = useState<string | null>(null);
|
||||
@@ -100,6 +101,7 @@ export function VirtualPrinterCard({ printer, models }: VirtualPrinterCardProps)
|
||||
setLocalModel(printer.model || '');
|
||||
setLocalAutoDispatch(printer.auto_dispatch ?? true);
|
||||
setLocalQueueForceColorMatch(printer.queue_force_color_match ?? false);
|
||||
setLocalGcodeInjection(printer.gcode_injection ?? false);
|
||||
setLocalTailscaleDisabled(printer.tailscale_disabled ?? true);
|
||||
}
|
||||
}, [printer, pendingAction]);
|
||||
@@ -437,6 +439,36 @@ export function VirtualPrinterCard({ printer, models }: VirtualPrinterCardProps)
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* G-code injection toggle - only for queue mode (#1516) */}
|
||||
{localMode === 'queue' && (
|
||||
<div className="pt-2 border-t border-bambu-dark-tertiary">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="text-white text-sm font-medium">{t('virtualPrinter.gcodeInjection.title')}</div>
|
||||
<div className="text-[10px] text-bambu-gray">{t('virtualPrinter.gcodeInjection.description')}</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
const newVal = !localGcodeInjection;
|
||||
setLocalGcodeInjection(newVal);
|
||||
setPendingAction('gcodeInjection');
|
||||
updateMutation.mutate({ gcode_injection: newVal });
|
||||
}}
|
||||
disabled={pendingAction === 'gcodeInjection'}
|
||||
className={`relative w-10 h-5 rounded-full transition-colors flex-shrink-0 ${
|
||||
localGcodeInjection ? 'bg-bambu-green' : 'bg-bambu-dark-tertiary'
|
||||
} ${pendingAction === 'gcodeInjection' ? 'opacity-50' : ''}`}
|
||||
>
|
||||
<span
|
||||
className={`absolute top-0.5 left-0.5 w-4 h-4 bg-white rounded-full transition-transform ${
|
||||
localGcodeInjection ? 'translate-x-5' : ''
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tailscale toggle */}
|
||||
<div className="pt-2 border-t border-bambu-dark-tertiary">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
|
||||
@@ -4583,6 +4583,10 @@ export default {
|
||||
title: 'Farbabgleich erzwingen',
|
||||
description: 'Druckaufträge nur an Drucker senden, bei denen der genaue Filament-Typ und die genaue Farbe geladen sind. Standardmäßig deaktiviert — ohne diese Option verwendet die Warteschlange nur den Drucker-Modell-Abgleich und wählt möglicherweise einen Drucker mit der falschen Farbe.',
|
||||
},
|
||||
gcodeInjection: {
|
||||
title: 'G-code-Injektion',
|
||||
description: 'Wendet die in den Einstellungen pro Modell konfigurierten G-code-Snippets auf Jobs dieses VP an. Standardmäßig aus.',
|
||||
},
|
||||
tailscaleDisabled: {
|
||||
title: 'Tailscale-Integration',
|
||||
description: 'Aktivieren, um diesen VP als per Tailscale erreichbar zu markieren. Zeigt die Tailscale-Adresse des Hosts an, damit du weißt, welche IP du im Slicer eintragen musst. Der CA-Import bleibt unverändert — diese Option hat keinen Einfluss auf Zertifikate.',
|
||||
|
||||
@@ -4604,6 +4604,10 @@ export default {
|
||||
title: 'Force color match',
|
||||
description: 'Refuse to dispatch onto a printer that does not have the exact filament type and color loaded. Off by default — without this, the queue uses model-only matching and may pick a printer with the wrong color loaded.',
|
||||
},
|
||||
gcodeInjection: {
|
||||
title: 'G-code injection',
|
||||
description: 'Apply the per-model G-code snippets configured in Settings to jobs from this VP. Off by default.',
|
||||
},
|
||||
tailscaleDisabled: {
|
||||
title: 'Tailscale integration',
|
||||
description: 'Enable to mark this VP as exposed over Tailscale. Shows the host\'s Tailscale address so you know which IP to paste into the slicer. The CA-import step is unchanged — this toggle has no effect on certificates.',
|
||||
|
||||
@@ -4592,6 +4592,10 @@ export default {
|
||||
title: 'Forzar la coincidencia de color',
|
||||
description: 'Negarse a enviar a una impresora que no tiene cargados el tipo y el color exactos de filamento. Desactivado de forma predeterminada — sin esto, la cola usa la coincidencia solo por modelo y puede elegir una impresora con el color equivocado cargado.',
|
||||
},
|
||||
gcodeInjection: {
|
||||
title: 'Inyección de G-code',
|
||||
description: 'Aplica los fragmentos de G-code configurados por modelo en Ajustes a los trabajos de esta IV. Desactivado de forma predeterminada.',
|
||||
},
|
||||
tailscaleDisabled: {
|
||||
title: 'Integración con Tailscale',
|
||||
description: 'Actívelo para marcar esta IV como expuesta a través de Tailscale. Muestra la dirección de Tailscale del host para que sepa qué IP pegar en el laminador. El paso de importación de la CA no cambia — este interruptor no tiene ningún efecto sobre los certificados.',
|
||||
|
||||
@@ -4573,6 +4573,10 @@ export default {
|
||||
title: 'Forcer la correspondance des couleurs',
|
||||
description: 'Refuser l\'envoi vers une imprimante qui n\'a pas exactement le type de filament et la couleur chargés. Désactivé par défaut — sans cela, la file d\'attente utilise uniquement la correspondance par modèle et peut choisir une imprimante avec la mauvaise couleur.',
|
||||
},
|
||||
gcodeInjection: {
|
||||
title: 'Injection G-code',
|
||||
description: 'Applique les extraits de G-code configurés par modèle dans les Paramètres aux travaux de ce VP. Désactivé par défaut.',
|
||||
},
|
||||
tailscaleDisabled: {
|
||||
title: 'Intégration Tailscale',
|
||||
description: 'Activez pour marquer ce VP comme exposé via Tailscale. Affiche l\'adresse Tailscale de l\'hôte pour que vous sachiez quelle IP coller dans le slicer. L\'étape d\'import CA est inchangée — cette bascule n\'a aucun effet sur les certificats.',
|
||||
|
||||
@@ -4572,6 +4572,10 @@ export default {
|
||||
title: 'Forza corrispondenza colori',
|
||||
description: 'Rifiuta di inviare a una stampante che non ha esattamente il tipo di filamento e il colore caricato. Disattivato per impostazione predefinita — senza questo, la coda usa solo la corrispondenza per modello e potrebbe scegliere una stampante con il colore sbagliato.',
|
||||
},
|
||||
gcodeInjection: {
|
||||
title: 'Iniezione G-code',
|
||||
description: 'Applica gli snippet G-code configurati per modello nelle Impostazioni ai lavori di questo VP. Disattivato per impostazione predefinita.',
|
||||
},
|
||||
tailscaleDisabled: {
|
||||
title: 'Integrazione Tailscale',
|
||||
description: 'Abilita per contrassegnare questo VP come esposto tramite Tailscale. Mostra l\'indirizzo Tailscale dell\'host così sai quale IP incollare nello slicer. Il passo di importazione CA è invariato — questo toggle non ha effetto sui certificati.',
|
||||
|
||||
@@ -4584,6 +4584,10 @@ export default {
|
||||
title: '色の一致を強制',
|
||||
description: '正確なフィラメントタイプと色がロードされていないプリンターへの送信を拒否します。デフォルトはオフ — これがないと、キューはモデルのみのマッチングを使用し、間違った色がロードされたプリンターを選ぶ可能性があります。',
|
||||
},
|
||||
gcodeInjection: {
|
||||
title: 'G-codeインジェクション',
|
||||
description: '設定でモデルごとに構成したG-codeスニペットを、このVPのジョブに適用します。デフォルトはオフです。',
|
||||
},
|
||||
tailscaleDisabled: {
|
||||
title: 'Tailscale統合',
|
||||
description: 'このVPがTailscale経由で公開されていることをマークするには有効にしてください。スライサーに貼り付けるIPがわかるよう、ホストのTailscaleアドレスを表示します。CAインポート手順は変更されません — このトグルは証明書に影響しません。',
|
||||
|
||||
@@ -4324,6 +4324,10 @@ export default {
|
||||
title: '색상 일치 강제',
|
||||
description: '정확한 필라멘트 유형과 색상이 장착되지 않은 프린터에는 발송을 거부합니다. 기본적으로 꺼져 있음 — 이 옵션 없이는 대기열이 모델 전용 매칭을 사용하여 잘못된 색상이 장착된 프린터를 선택할 수 있습니다.'
|
||||
},
|
||||
gcodeInjection: {
|
||||
title: 'G-code 주입',
|
||||
description: '설정에서 모델별로 구성한 G-code 스니펫을 이 가상 프린터의 작업에 적용합니다. 기본값은 꺼짐입니다.'
|
||||
},
|
||||
tailscaleDisabled: {
|
||||
title: 'Tailscale 통합',
|
||||
description: '이 가상 프린터가 Tailscale을 통해 노출되도록 표시하려면 활성화하세요. 슬라이서에 붙여넣을 IP를 알 수 있도록 호스트의 Tailscale 주소를 표시합니다. CA 가져오기 단계는 변경되지 않음 — 이 토글은 인증서에 영향을 미치지 않습니다.'
|
||||
|
||||
@@ -4572,6 +4572,10 @@ export default {
|
||||
title: 'Forçar correspondência de cor',
|
||||
description: 'Recusa enviar para uma impressora que não tenha exatamente o tipo e cor de filamento carregados. Desativado por padrão — sem isto, a fila usa apenas correspondência por modelo e pode escolher uma impressora com a cor errada carregada.',
|
||||
},
|
||||
gcodeInjection: {
|
||||
title: 'Injeção de G-code',
|
||||
description: 'Aplica os trechos de G-code configurados por modelo nas Configurações aos trabalhos deste VP. Desativado por padrão.',
|
||||
},
|
||||
tailscaleDisabled: {
|
||||
title: 'Integração Tailscale',
|
||||
description: 'Ative para marcar este VP como exposto via Tailscale. Mostra o endereço Tailscale do host para você saber qual IP colar no fatiador. A etapa de importação CA é inalterada — este toggle não afeta certificados.',
|
||||
|
||||
@@ -4547,6 +4547,10 @@ export default {
|
||||
title: 'Renk eşleşmesini zorla',
|
||||
description: 'Tam olarak doğru filament türü ve rengi yüklü olmayan bir yazıcıya sevk etmeyi reddet. Varsayılan olarak kapalı — bu olmadan kuyruk yalnızca model eşleşmesi kullanır ve yanlış renk yüklü bir yazıcı seçebilir.',
|
||||
},
|
||||
gcodeInjection: {
|
||||
title: 'G-code enjeksiyonu',
|
||||
description: "Ayarlar'da model bazında yapılandırılan G-code parçacıklarını bu VP'nin işlerine uygular. Varsayılan olarak kapalı.",
|
||||
},
|
||||
tailscaleDisabled: {
|
||||
title: 'Tailscale entegrasyonu',
|
||||
description: "Bu VP'yi Tailscale üzerinden açıkta olarak işaretlemek için etkinleştirin. Dilimleyiciye hangi IP'yi yapıştıracağınızı bilmeniz için ana bilgisayarın Tailscale adresini gösterir. CA içe aktarma adımı değişmedi — bu anahtarın sertifikalar üzerinde etkisi yoktur.",
|
||||
|
||||
@@ -4572,6 +4572,10 @@ export default {
|
||||
title: '强制颜色匹配',
|
||||
description: '拒绝派发到没有完全相同耗材类型和颜色的打印机。默认关闭 — 不启用时,队列仅按型号匹配,可能选到颜色错误的打印机。',
|
||||
},
|
||||
gcodeInjection: {
|
||||
title: 'G-code 注入',
|
||||
description: '将“设置”中按型号配置的 G-code 片段应用到此 VP 的作业。默认关闭。',
|
||||
},
|
||||
tailscaleDisabled: {
|
||||
title: 'Tailscale 集成',
|
||||
description: '启用以将此 VP 标记为通过 Tailscale 公开。显示主机的 Tailscale 地址,以便您知道要粘贴到切片器中的 IP。CA 导入步骤保持不变 — 此开关对证书无影响。',
|
||||
|
||||
@@ -4572,6 +4572,10 @@ export default {
|
||||
title: '強制顏色匹配',
|
||||
description: '拒絕派發到沒有完全相同耗材類型和顏色的印表機。預設關閉 — 不啟用時,佇列僅按型號匹配,可能選到顏色錯誤的印表機。',
|
||||
},
|
||||
gcodeInjection: {
|
||||
title: 'G-code 注入',
|
||||
description: '將「設定」中依型號設定的 G-code 片段套用到此 VP 的作業。預設關閉。',
|
||||
},
|
||||
tailscaleDisabled: {
|
||||
title: 'Tailscale 整合',
|
||||
description: '啟用以將此 VP 標記為透過 Tailscale 公開。顯示主機的 Tailscale 位址,以便您知道要貼上到切片器中的 IP。CA 匯入步驟保持不變 — 此切換對憑證無影響。',
|
||||
|
||||
Reference in New Issue
Block a user