forked from viewit/KX-Bridge-Release
lots of fixes and features
This commit is contained in:
@@ -62,6 +62,7 @@ CONFIG_ENV_MAPPING = {
|
||||
"DEFAULT_AMS_SLOT": (CONFIG_SECTION_PRINT, "default_ams_slot"),
|
||||
"AUTO_LEVELING": (CONFIG_SECTION_PRINT, "auto_leveling"),
|
||||
"VIBRATION_COMPENSATION": (CONFIG_SECTION_PRINT, "vibration_compensation"),
|
||||
"FLOW_CALIBRATION": (CONFIG_SECTION_PRINT, "flow_calibration"),
|
||||
"CAMERA_ON_PRINT": (CONFIG_SECTION_PRINT, "camera_on_print"),
|
||||
"WEB_UPLOAD_WARNING": (CONFIG_SECTION_PRINT, "web_upload_warning"),
|
||||
"PRINT_START_DIALOG": (CONFIG_SECTION_PRINT, "print_start_dialog"),
|
||||
@@ -126,6 +127,7 @@ def migrate_env_to_config(env_path: pathlib.Path, config_path: pathlib.Path):
|
||||
"default_ams_slot": env_vals.get("DEFAULT_AMS_SLOT", "auto"),
|
||||
"auto_leveling": env_vals.get("AUTO_LEVELING", "1"),
|
||||
"vibration_compensation": env_vals.get("VIBRATION_COMPENSATION", "0"),
|
||||
"flow_calibration": env_vals.get("FLOW_CALIBRATION", "0"),
|
||||
"camera_on_print": env_vals.get("CAMERA_ON_PRINT", "0"),
|
||||
"web_upload_warning": env_vals.get("WEB_UPLOAD_WARNING", "1"),
|
||||
}
|
||||
@@ -451,6 +453,7 @@ DEVICE_ID = get("DEVICE_ID", "")
|
||||
DEFAULT_AMS_SLOT = get("DEFAULT_AMS_SLOT", "auto")
|
||||
AUTO_LEVELING = int(get("AUTO_LEVELING", "1"))
|
||||
VIBRATION_COMPENSATION = int(get("VIBRATION_COMPENSATION", "0"))
|
||||
FLOW_CALIBRATION = int(get("FLOW_CALIBRATION", "0"))
|
||||
CAMERA_ON_PRINT = int(get("CAMERA_ON_PRINT", "0"))
|
||||
WEB_UPLOAD_WARNING = int(get("WEB_UPLOAD_WARNING", "1"))
|
||||
PRINT_START_DIALOG = int(get("PRINT_START_DIALOG", get("FILE_READY_DIALOG", "1")))
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
services:
|
||||
kx-bridge:
|
||||
image: gitea.it-drui.de/viewit/kx-bridge:latest
|
||||
# image: gitea.it-drui.de/viewit/kx-bridge:latest
|
||||
# Selbst bauen statt das Registry-Image zu pullen?
|
||||
# Dann image-Zeile auskommentieren und folgende aktivieren:
|
||||
# build: .
|
||||
build: .
|
||||
volumes:
|
||||
- ./config:/app/config
|
||||
- ./data:/app/data
|
||||
- ./.env:/app/.env:ro
|
||||
ports:
|
||||
- "7125-7130:7125-7130"
|
||||
- "7145:7125"
|
||||
# environment:
|
||||
# - BRIDGE_HOST_IP=192.168.1.100 # LAN-IP des Docker-Hosts (für korrekte Log-Anzeige)
|
||||
restart: unless-stopped
|
||||
|
||||
@@ -49,6 +49,7 @@ DEVICE_ID = get("DEVICE_ID", "")
|
||||
DEFAULT_AMS_SLOT = get("DEFAULT_AMS_SLOT", "auto")
|
||||
AUTO_LEVELING = int(get("AUTO_LEVELING", "1"))
|
||||
VIBRATION_COMPENSATION = int(get("VIBRATION_COMPENSATION", "0"))
|
||||
FLOW_CALIBRATION = int(get("FLOW_CALIBRATION", "0"))
|
||||
CAMERA_ON_PRINT = int(get("CAMERA_ON_PRINT", "0"))
|
||||
WEB_UPLOAD_WARNING = int(get("WEB_UPLOAD_WARNING", "1"))
|
||||
PRINT_START_DIALOG = int(get("PRINT_START_DIALOG", get("FILE_READY_DIALOG", "1")))
|
||||
|
||||
@@ -593,10 +593,20 @@ class CameraCache:
|
||||
- MJPEG @ 2fps -> last frame in RAM for /api/camera/snapshot
|
||||
- MPEG-TS (-c:v copy) -> fanout to all /api/camera/h264 subscribers
|
||||
|
||||
A third, independent ffmpeg process produces:
|
||||
- MJPEG @ 15fps/640px -> fanout to all /api/camera/stream subscribers
|
||||
(the live-view used by the dashboard AND by every Moonraker-compatible
|
||||
client, since server.webcams.list advertises this same stream_url)
|
||||
|
||||
Damit:
|
||||
* Only ONE FLV connection to the printer (solves the single-client limit / 429)
|
||||
* Only ONE FLV connection to the printer per output type (solves the
|
||||
single-client limit / 429) - previously /api/camera/stream opened a
|
||||
brand-new, uncached ffmpeg + printer connection per HTTP client,
|
||||
which competed with the cached jpeg/h264 connections for the
|
||||
printer's very limited number of concurrent camera clients and
|
||||
caused intermittent "stream unavailable" failures.
|
||||
* Snapshots are instant (memory read, no ffmpeg spawn per request)
|
||||
* Multiple parallel H.264 consumers possible (plugin + web UI + ...)
|
||||
* Multiple parallel H.264/MJPEG consumers possible (plugin + web UI + ...)
|
||||
|
||||
Lazy start on the first consumer, auto-restart on ffmpeg crash.
|
||||
"""
|
||||
@@ -610,33 +620,65 @@ class CameraCache:
|
||||
self.latest_jpeg: bytes = b""
|
||||
self.latest_jpeg_ts: float = 0.0
|
||||
self.h264_subscribers: "set[asyncio.Queue[bytes]]" = set()
|
||||
self.mjpeg_subscribers: "set[asyncio.Queue[bytes]]" = set()
|
||||
self._proc_jpeg: "asyncio.subprocess.Process | None" = None
|
||||
self._proc_h264: "asyncio.subprocess.Process | None" = None
|
||||
self._proc_mjpeg: "asyncio.subprocess.Process | None" = None
|
||||
self._task_jpeg: "asyncio.Task | None" = None
|
||||
self._task_h264: "asyncio.Task | None" = None
|
||||
self._task_mjpeg: "asyncio.Task | None" = None
|
||||
self._lock = asyncio.Lock()
|
||||
self._fail_count_jpeg: int = 0
|
||||
self._fail_count_h264: int = 0
|
||||
self._fail_count_mjpeg: int = 0
|
||||
|
||||
def set_url(self, url: str):
|
||||
self._url = url
|
||||
|
||||
def reset(self):
|
||||
"""Reset backoff counters and kill running ffmpeg processes."""
|
||||
"""Reset backoff counters and forcefully tear down any running
|
||||
ffmpeg loops - including cancelling their background tasks.
|
||||
|
||||
Only killing the ffmpeg subprocess is not enough: the owning task
|
||||
might currently be sitting in `await asyncio.sleep(delay)` from a
|
||||
previous exponential backoff (up to 300s) after an earlier failure.
|
||||
Resetting the fail-count doesn't wake it up early, so a user
|
||||
clicking "reset" could see nothing happen for minutes. Cancelling
|
||||
the task guarantees an immediate, clean restart on the next
|
||||
ensure_running() call.
|
||||
"""
|
||||
self._fail_count_jpeg = 0
|
||||
self._fail_count_h264 = 0
|
||||
for proc in (self._proc_jpeg, self._proc_h264):
|
||||
self._fail_count_mjpeg = 0
|
||||
for task in (self._task_jpeg, self._task_h264, self._task_mjpeg):
|
||||
if task is not None and not task.done():
|
||||
task.cancel()
|
||||
for proc in (self._proc_jpeg, self._proc_h264, self._proc_mjpeg):
|
||||
if proc is not None:
|
||||
try:
|
||||
proc.kill()
|
||||
except Exception:
|
||||
pass
|
||||
self._task_jpeg = self._task_h264 = self._task_mjpeg = None
|
||||
self._proc_jpeg = self._proc_h264 = self._proc_mjpeg = None
|
||||
|
||||
async def ensure_running(self):
|
||||
if self._proc_jpeg is None or self._proc_jpeg.returncode is not None:
|
||||
# NOTE: we check the *task* state, not self._proc_* - the process
|
||||
# handle is only assigned later, inside the task body, once ffmpeg
|
||||
# has actually been spawned. Checking self._proc_* here left a race
|
||||
# window: two callers arriving before the newly-created task got a
|
||||
# chance to run would both see "no process yet" and each spawn a
|
||||
# duplicate ffmpeg + duplicate printer connection, silently
|
||||
# orphaning the older one (whichever task's coroutine runs last
|
||||
# overwrites the shared self._proc_* reference, so nobody keeps a
|
||||
# handle to kill the earlier orphaned process). Task creation is
|
||||
# synchronous, so checking self._task_* here is race-free.
|
||||
if self._task_jpeg is None or self._task_jpeg.done():
|
||||
self._task_jpeg = asyncio.create_task(self._run_jpeg_loop())
|
||||
if self._proc_h264 is None or self._proc_h264.returncode is not None:
|
||||
if self._task_h264 is None or self._task_h264.done():
|
||||
self._task_h264 = asyncio.create_task(self._run_h264_loop())
|
||||
if self._task_mjpeg is None or self._task_mjpeg.done():
|
||||
self._task_mjpeg = asyncio.create_task(self._run_mjpeg_loop())
|
||||
|
||||
def _input_args(self, url: str) -> list[str]:
|
||||
args = ["-fflags", "nobuffer", "-flags", "low_delay"]
|
||||
@@ -654,6 +696,7 @@ class CameraCache:
|
||||
await asyncio.sleep(2.0)
|
||||
continue
|
||||
try:
|
||||
log.info(f"CameraCache: ffmpeg-jpeg created for {url}")
|
||||
self._proc_jpeg = await asyncio.create_subprocess_exec(
|
||||
_find_ffmpeg(), "-loglevel", "warning",
|
||||
*self._input_args(url), "-i", url,
|
||||
@@ -729,6 +772,7 @@ class CameraCache:
|
||||
await asyncio.sleep(2.0)
|
||||
continue
|
||||
try:
|
||||
log.info(f"CameraCache: ffmpeg-h264 created for {url}")
|
||||
self._proc_h264 = await asyncio.create_subprocess_exec(
|
||||
_find_ffmpeg(), "-loglevel", "warning",
|
||||
*self._input_args(url), "-i", url,
|
||||
@@ -790,6 +834,100 @@ class CameraCache:
|
||||
self._fail_count_h264 = 0
|
||||
await asyncio.sleep(2.0)
|
||||
|
||||
async def _run_mjpeg_loop(self):
|
||||
"""Keeps an ffmpeg process alive that fans out MJPEG@15fps/640px
|
||||
(complete JPEG frames) to all /api/camera/stream subscribers."""
|
||||
while True:
|
||||
url = self._url
|
||||
if not url:
|
||||
await asyncio.sleep(2.0)
|
||||
continue
|
||||
try:
|
||||
log.info(f"CameraCache: ffmpeg-mjpeg created for {url}")
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
_find_ffmpeg(), "-loglevel", "warning",
|
||||
*self._input_args(url), "-i", url,
|
||||
"-vf", "fps=15,scale=640:-1",
|
||||
"-f", "image2pipe", "-vcodec", "mjpeg", "-q:v", "3",
|
||||
"-flush_packets", "1", "pipe:1",
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
self._proc_mjpeg = proc
|
||||
except Exception as e:
|
||||
log.warning(f"CameraCache: ffmpeg-mjpeg start failed: {e}")
|
||||
await asyncio.sleep(3.0)
|
||||
continue
|
||||
|
||||
buf = b""
|
||||
rc = None
|
||||
try:
|
||||
while True:
|
||||
chunk = await proc.stdout.read(self.TS_CHUNK)
|
||||
if not chunk:
|
||||
break
|
||||
buf += chunk
|
||||
# extract complete JPEG frames and fan them out whole
|
||||
# (so every subscriber gets clean multipart boundaries,
|
||||
# not arbitrary byte chunks like the h264/mpegts fanout)
|
||||
while True:
|
||||
start = buf.find(self.JPEG_SOI)
|
||||
if start == -1:
|
||||
buf = b""
|
||||
break
|
||||
end = buf.find(self.JPEG_EOI, start + 2)
|
||||
if end == -1:
|
||||
buf = buf[start:]
|
||||
break
|
||||
frame = buf[start:end + 2]
|
||||
buf = buf[end + 2:]
|
||||
for q in list(self.mjpeg_subscribers):
|
||||
if q.full():
|
||||
try:
|
||||
q.get_nowait()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
q.put_nowait(frame)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
log.debug(f"CameraCache: mjpeg-loop unterbrochen: {e}")
|
||||
finally:
|
||||
# NOTE: cleanup operates on the local `proc` reference, not
|
||||
# on self._proc_mjpeg. If this task got cancelled (e.g. by
|
||||
# reset()) a new task may already have started and assigned
|
||||
# its own process to self._proc_mjpeg by the time we reach
|
||||
# here - killing that shared attribute instead of our own
|
||||
# local proc would kill the WRONG (newer) process.
|
||||
try:
|
||||
proc.kill()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
await proc.wait()
|
||||
except Exception:
|
||||
pass
|
||||
rc = proc.returncode
|
||||
if rc:
|
||||
try:
|
||||
err = await proc.stderr.read(500)
|
||||
if err:
|
||||
log.warning(f"CameraCache: ffmpeg-mjpeg stderr: {err.decode(errors='replace').strip()}")
|
||||
except Exception:
|
||||
pass
|
||||
# Only clear the shared handle if it's still ours.
|
||||
if self._proc_mjpeg is proc:
|
||||
self._proc_mjpeg = None
|
||||
if rc:
|
||||
self._fail_count_mjpeg += 1
|
||||
delay = min(2.0 * (2 ** self._fail_count_mjpeg), 300.0)
|
||||
log.warning(f"CameraCache: ffmpeg-mjpeg exit {rc}, retry in {delay:.0f}s (Versuch {self._fail_count_mjpeg})")
|
||||
await asyncio.sleep(delay)
|
||||
else:
|
||||
self._fail_count_mjpeg = 0
|
||||
await asyncio.sleep(2.0)
|
||||
|
||||
|
||||
class SpoolmanClient:
|
||||
"""Thin synchronous HTTP client for Spoolman filament tracking.
|
||||
@@ -888,6 +1026,8 @@ class KobraXBridge:
|
||||
"filament_mode": "toolhead",
|
||||
"supplies_usage": 0,
|
||||
"ace_drying": {"status": 0, "target_temp": 0, "duration": 0, "remain_time": 0, "humidity": None, "current_temp": None},
|
||||
"error_code": 0,
|
||||
"pause_msg": "",
|
||||
}
|
||||
self._ams_slots: list[dict] = [] # flat global list; each entry has global_index + box_id
|
||||
self._ams_loaded_slot: int = -1 # global slot index of currently loaded slot
|
||||
@@ -1180,8 +1320,10 @@ class KobraXBridge:
|
||||
def _on_print(self, payload: dict):
|
||||
d = payload.get("data") or {}
|
||||
kobra_state = payload.get("state", "")
|
||||
self._state["print_state"] = KOBRA_TO_KLIPPER_STATE.get(kobra_state, "printing")
|
||||
|
||||
if kobra_state:
|
||||
self._state["print_state"] = KOBRA_TO_KLIPPER_STATE.get(kobra_state, "printing")
|
||||
if kobra_state != "" and kobra_state != "updated":
|
||||
self._state["kobra_state"] = kobra_state
|
||||
|
||||
# Automatically switch on the camera at print start (settings option).
|
||||
@@ -1200,6 +1342,17 @@ class KobraXBridge:
|
||||
elif kobra_state in ("free", "finished", "stoped", "canceled"):
|
||||
self._camera_autostarted = False
|
||||
self._camera_user_stopped = False # release for the next print
|
||||
|
||||
if kobra_state in ("pause", "paused"):
|
||||
pause_msg = payload.get("msg", "")
|
||||
if pause_msg:
|
||||
error_code = payload.get("code", 0)
|
||||
self._state["error_code"] = error_code
|
||||
self._state["pause_msg"] = pause_msg
|
||||
log.warning(f"Printer paused: [{error_code}] {pause_msg}")
|
||||
elif kobra_state in ("resuming", "resumed", "printing", "finished", "stoped", "canceled"):
|
||||
self._state["error_code"] = 0
|
||||
self._state["pause_msg"] = ""
|
||||
|
||||
# Job-History: Druckstart erkennen
|
||||
if kobra_state == "printing" and not self._current_job_id:
|
||||
@@ -1279,7 +1432,8 @@ class KobraXBridge:
|
||||
kobra_state = proj_state or d.get("state", "")
|
||||
if kobra_state:
|
||||
self._state["print_state"] = KOBRA_TO_KLIPPER_STATE.get(kobra_state, "standby")
|
||||
self._state["kobra_state"] = kobra_state
|
||||
if kobra_state != "" and kobra_state != "updated":
|
||||
self._state["kobra_state"] = kobra_state
|
||||
# Hide the upload banner after the print ends (Issue #29) - the state also
|
||||
# arrives via info/report (project.state) depending on the printer, not only print/report.
|
||||
if kobra_state in ("finished", "stoped", "canceled"):
|
||||
@@ -1591,7 +1745,41 @@ class KobraXBridge:
|
||||
return [int(color[0]), int(color[1]), int(color[2]), 255]
|
||||
return [255, 255, 255, 255]
|
||||
|
||||
def _build_auto_ams_box_mapping(
|
||||
@staticmethod
|
||||
def _hex_to_rgba(hex_color: str | None) -> list[int]:
|
||||
"""Convert a '#RRGGBB' (or 'RRGGBB') string from GCode metadata to [r,g,b,255]."""
|
||||
if not hex_color:
|
||||
return [255, 255, 255, 255]
|
||||
h = str(hex_color).strip().lstrip("#")
|
||||
if len(h) < 6:
|
||||
return [255, 255, 255, 255]
|
||||
try:
|
||||
return [int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16), 255]
|
||||
except ValueError:
|
||||
return [255, 255, 255, 255]
|
||||
|
||||
@staticmethod
|
||||
def _gcode_filament_by_index(gcode_filaments: list[dict] | None) -> dict[int, dict]:
|
||||
"""Index parsed GCode filament metadata (_extract_filament_info) by paint/slot index."""
|
||||
by_index: dict[int, dict] = {}
|
||||
if not gcode_filaments:
|
||||
return by_index
|
||||
for f in gcode_filaments:
|
||||
try:
|
||||
by_index[int(f.get("slot_index"))] = f
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
return by_index
|
||||
|
||||
def _color_distance(self, rgb1: list[int], rgb2: list[int]) -> float:
|
||||
"""Calculate RGB Euclidean distance."""
|
||||
return (
|
||||
(rgb1[0] - rgb2[0]) ** 2 +
|
||||
(rgb1[1] - rgb2[1]) ** 2 +
|
||||
(rgb1[2] - rgb2[2]) ** 2
|
||||
) ** 0.5
|
||||
|
||||
def _build_auto_ams_box_mapping_old(
|
||||
self,
|
||||
warn_on_empty_default: bool = False,
|
||||
loaded_slots: list[tuple[int, dict]] | None = None,
|
||||
@@ -1627,6 +1815,128 @@ class KobraXBridge:
|
||||
})
|
||||
return result
|
||||
|
||||
|
||||
def _build_auto_ams_box_mapping(
|
||||
self,
|
||||
warn_on_empty_default: bool = False,
|
||||
loaded_slots: list[tuple[int, dict]] | None = None,
|
||||
gcode_filaments: list[dict] | None = None,
|
||||
) -> list[dict]:
|
||||
"""Build print mapping by matching G-code filament colors
|
||||
with currently loaded AMS slots using RGB distance.
|
||||
"""
|
||||
|
||||
# IMPORTANT: loaded_slots is intentionally ignored whenever gcode_filaments
|
||||
# is provided. Color matching needs access to ALL currently loaded slots,
|
||||
# not just the ones whose index happens to coincide with the gcode's
|
||||
# paint_index - otherwise a black filament physically loaded in slot 3
|
||||
# could never be matched to gcode paint_index 1 just because slot 1
|
||||
# holds a different color. loaded_slots is only used as a fallback pool
|
||||
# when the caller has no gcode filament info to match colors against
|
||||
# (see _build_auto_ams_box_mapping_old for that plain, index-based case).
|
||||
loaded = loaded_slots if ( gcode_filaments is None or len(gcode_filaments) == 0 ) else None
|
||||
if loaded is None:
|
||||
loaded = self._select_loaded_slots_for_print( warn_on_empty_default=warn_on_empty_default )
|
||||
|
||||
if not loaded:
|
||||
return []
|
||||
|
||||
loaded_map = {gidx: s for gidx, s in loaded}
|
||||
log.debug(f"Loaded map: {loaded_map}")
|
||||
|
||||
gcode_by_index = self._gcode_filament_by_index(gcode_filaments)
|
||||
log.debug(f"GCode filaments by index: {gcode_by_index}")
|
||||
|
||||
# Slots available for assignment.
|
||||
available_slots = list(loaded_map.items())
|
||||
|
||||
result = []
|
||||
|
||||
max_idx = max(gcode_by_index.keys(), default=-1)
|
||||
|
||||
# Maximum RGB distance:
|
||||
# sqrt(255² + 255² + 255²) = 441
|
||||
# A threshold of 80–100 is reasonable for visually similar colors.
|
||||
MAX_COLOR_DISTANCE = 100
|
||||
|
||||
for paint_index in range(max_idx + 1):
|
||||
req = gcode_by_index.get(paint_index)
|
||||
|
||||
paint_color = (
|
||||
self._hex_to_rgba(req.get("color_hex"))
|
||||
if req else
|
||||
[255, 255, 255, 255]
|
||||
)
|
||||
|
||||
default_material = (
|
||||
req.get("material", "PLA")
|
||||
if req else
|
||||
"PLA"
|
||||
)
|
||||
|
||||
best_match = None
|
||||
best_distance = float("inf")
|
||||
|
||||
if req:
|
||||
requested_rgb = paint_color[:3]
|
||||
|
||||
for slot_idx, slot in available_slots:
|
||||
slot_color = slot.get("color")
|
||||
|
||||
if not slot_color or len(slot_color) < 3:
|
||||
continue
|
||||
|
||||
# Skip slots with a different material type.
|
||||
if slot.get("type") != default_material:
|
||||
continue
|
||||
|
||||
distance = self._color_distance(
|
||||
requested_rgb,
|
||||
slot_color[:3],
|
||||
)
|
||||
|
||||
if distance < best_distance:
|
||||
best_distance = distance
|
||||
best_match = (slot_idx, slot)
|
||||
|
||||
if best_match and best_distance <= MAX_COLOR_DISTANCE:
|
||||
slot_idx, slot = best_match
|
||||
|
||||
available_slots.remove(best_match)
|
||||
|
||||
log.debug(
|
||||
f"Matched paint index {paint_index}: "
|
||||
f"GCODE={paint_color[:3]} "
|
||||
f"AMS={slot.get('color')} "
|
||||
f"distance={best_distance:.2f} "
|
||||
f"slot={slot_idx}"
|
||||
)
|
||||
|
||||
result.append({
|
||||
"paint_index": paint_index,
|
||||
"ams_index": self._slot_to_print_ams_index(slot_idx),
|
||||
"paint_color": paint_color,
|
||||
"ams_color": self._slot_color_rgba(slot),
|
||||
"material_type": slot.get("type", default_material),
|
||||
})
|
||||
|
||||
else:
|
||||
log.debug(
|
||||
f"No AMS match for paint index {paint_index}: "
|
||||
f"color={paint_color[:3]} "
|
||||
f"best_distance={best_distance:.2f}"
|
||||
)
|
||||
|
||||
result.append({
|
||||
"paint_index": paint_index,
|
||||
"ams_index": self._slot_to_print_ams_index(paint_index),
|
||||
"paint_color": paint_color,
|
||||
"ams_color": [255, 255, 255, 255],
|
||||
"material_type": default_material,
|
||||
})
|
||||
|
||||
return result
|
||||
|
||||
def _build_assigned_ams_box_mapping(self, assignments: list) -> tuple[list[dict], int, int]:
|
||||
"""Build print mapping from UI filament assignments.
|
||||
|
||||
@@ -2958,7 +3268,7 @@ class KobraXBridge:
|
||||
return self._json_cors({"error": "no usable filament assignments for current filament mode"}, status=400)
|
||||
else:
|
||||
# No dialog -> all occupied slots as with a normal upload print
|
||||
ams_box_mapping = self._build_auto_ams_box_mapping()
|
||||
ams_box_mapping = self._build_auto_ams_box_mapping_old()
|
||||
|
||||
auto_leveling = int(body.get("auto_leveling", getattr(self._args, "auto_leveling", 1)))
|
||||
filename = gcode_file["filename"]
|
||||
@@ -3453,7 +3763,7 @@ class KobraXBridge:
|
||||
"task_settings": {
|
||||
"auto_leveling": auto_leveling,
|
||||
"vibration_compensation": getattr(self._args, "vibration_compensation", 0),
|
||||
"flow_calibration": 0,
|
||||
"flow_calibration": getattr(self._args, "flow_calibration", 0),
|
||||
"dry_mode": 0,
|
||||
"ai_settings": {"status": 0, "count": 0, "type": ai_type},
|
||||
"timelapse": {"status": 0, "count": 0, "type": timelapse_type},
|
||||
@@ -3496,7 +3806,12 @@ class KobraXBridge:
|
||||
# used slots; used-but-unloaded -> a warning may follow later.
|
||||
loaded = [(gidx, s) for (gidx, s) in loaded if gidx in used_paint_indices]
|
||||
|
||||
ams_box_mapping = self._build_auto_ams_box_mapping(loaded_slots=loaded)
|
||||
used_gcode_filaments = [
|
||||
f for f in (gcode_filaments or [])
|
||||
if used_paint_indices is not None and f.get("slot_index") in used_paint_indices
|
||||
]
|
||||
|
||||
ams_box_mapping = self._build_auto_ams_box_mapping(loaded_slots=loaded, gcode_filaments=used_gcode_filaments)
|
||||
log.debug(f"AMS slots: {len(loaded)} mapped (used paints: {used_paint_indices}) -> {[i for i, _ in loaded]}")
|
||||
payload = self._build_print_payload(
|
||||
filename, url, md5, filesize,
|
||||
@@ -3573,7 +3888,7 @@ class KobraXBridge:
|
||||
# filter applies and empty/shifted slots are not mapped incorrectly.
|
||||
gcode_filaments = None
|
||||
try:
|
||||
db_file = self._db.get_file_by_name(filename)
|
||||
db_file = self._store.get_file_by_name(filename)
|
||||
if db_file and db_file.get("gcode_filaments"):
|
||||
gcode_filaments = json.loads(db_file["gcode_filaments"])
|
||||
except Exception as e:
|
||||
@@ -4065,10 +4380,15 @@ class KobraXBridge:
|
||||
Useful after a 429 lock (Retry-After expired) or after a printer restart."""
|
||||
self.camera_cache.reset()
|
||||
url = self._state.get("camera_url", "")
|
||||
if url:
|
||||
self.camera_cache.set_url(url)
|
||||
await self.camera_cache.ensure_running()
|
||||
return web.json_response({"result": "ok"})
|
||||
if not url:
|
||||
log.warning("Camera reset requested but no camera_url is known yet (waiting for printer status)")
|
||||
return web.json_response({
|
||||
"result": "no_url",
|
||||
"message": "No camera URL known yet - wait for the next printer status update, or start a print/enable the camera first.",
|
||||
})
|
||||
self.camera_cache.set_url(url)
|
||||
await self.camera_cache.ensure_running()
|
||||
return web.json_response({"result": "ok", "url": url})
|
||||
|
||||
async def handle_api_camera_snapshot(self, request):
|
||||
"""Last JPEG frame from the CameraCache - instant from RAM,
|
||||
@@ -4095,43 +4415,26 @@ class KobraXBridge:
|
||||
return web.Response(body=jpeg, content_type="image/jpeg", headers=headers)
|
||||
|
||||
async def handle_camera_stream(self, request):
|
||||
"""MJPEG proxy: FLV → MJPEG via ffmpeg, served as multipart/x-mixed-replace."""
|
||||
"""MJPEG live view, served as multipart/x-mixed-replace.
|
||||
|
||||
Fed from the central CameraCache fanout (same pattern as
|
||||
handle_camera_h264) instead of spawning a dedicated ffmpeg process
|
||||
per HTTP client. The printer's camera server only tolerates a very
|
||||
limited number of concurrent connections (see CameraCache docstring)
|
||||
- previously every consumer of this endpoint (dashboard, OrcaSlicer,
|
||||
moonraker-obico, a second browser tab, ...) opened its own separate
|
||||
connection, so two simultaneous viewers could already exhaust the
|
||||
printer's connection limit and cause intermittent "stream
|
||||
unavailable" failures. Now all consumers share one connection.
|
||||
"""
|
||||
url = self._state.get("camera_url", "")
|
||||
if not url:
|
||||
return web.Response(status=503, text="Keine Kamera-URL bekannt")
|
||||
self.camera_cache.set_url(url)
|
||||
await self.camera_cache.ensure_running()
|
||||
|
||||
is_rtsp = url.lower().startswith("rtsp://")
|
||||
ffmpeg_input_args = [
|
||||
"-fflags", "nobuffer",
|
||||
"-flags", "low_delay",
|
||||
]
|
||||
if is_rtsp:
|
||||
ffmpeg_input_args += ["-probesize", "32", "-analyzeduration", "0", "-rtsp_transport", "tcp"]
|
||||
else:
|
||||
ffmpeg_input_args += ["-probesize", "1000000", "-analyzeduration", "1000000"]
|
||||
|
||||
# Start ffmpeg BEFORE the StreamResponse is opened
|
||||
# (so we can still send a normal HTTP response on error)
|
||||
try:
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
_find_ffmpeg(), "-loglevel", "quiet",
|
||||
*ffmpeg_input_args,
|
||||
"-i", url,
|
||||
"-vf", "fps=15,scale=640:-1",
|
||||
"-f", "image2pipe",
|
||||
"-vcodec", "mjpeg",
|
||||
"-q:v", "3",
|
||||
"-flush_packets", "1",
|
||||
"pipe:1",
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.DEVNULL,
|
||||
)
|
||||
except (FileNotFoundError, OSError) as e:
|
||||
log.warning("Camera: ffmpeg not found – camera stream unavailable")
|
||||
return web.Response(status=503, text="ffmpeg not found")
|
||||
except Exception as e:
|
||||
log.warning(f"Camera: could not start ffmpeg: {e}")
|
||||
return web.Response(status=503, text=str(e))
|
||||
q: asyncio.Queue[bytes] = asyncio.Queue(maxsize=8)
|
||||
self.camera_cache.mjpeg_subscribers.add(q)
|
||||
|
||||
boundary = "kobraxframe"
|
||||
resp = web.StreamResponse(headers={
|
||||
@@ -4140,46 +4443,24 @@ class KobraXBridge:
|
||||
"Connection": "keep-alive",
|
||||
})
|
||||
await resp.prepare(request)
|
||||
|
||||
buf = b""
|
||||
try:
|
||||
while True:
|
||||
chunk = await proc.stdout.read(65536)
|
||||
if not chunk:
|
||||
frame = await q.get()
|
||||
header = (
|
||||
f"--{boundary}\r\n"
|
||||
f"Content-Type: image/jpeg\r\n"
|
||||
f"Content-Length: {len(frame)}\r\n\r\n"
|
||||
).encode()
|
||||
try:
|
||||
await resp.write(header + frame + b"\r\n")
|
||||
except (ConnectionResetError, asyncio.CancelledError):
|
||||
break
|
||||
except Exception:
|
||||
break
|
||||
buf += chunk
|
||||
# Extract complete JPEG frames (SOI=FFD8, EOI=FFD9)
|
||||
while True:
|
||||
start = buf.find(b"\xff\xd8")
|
||||
if start == -1:
|
||||
buf = b""
|
||||
break
|
||||
end = buf.find(b"\xff\xd9", start + 2)
|
||||
if end == -1:
|
||||
buf = buf[start:]
|
||||
break
|
||||
frame = buf[start:end + 2]
|
||||
buf = buf[end + 2:]
|
||||
header = (
|
||||
f"--{boundary}\r\n"
|
||||
f"Content-Type: image/jpeg\r\n"
|
||||
f"Content-Length: {len(frame)}\r\n\r\n"
|
||||
).encode()
|
||||
try:
|
||||
await resp.write(header + frame + b"\r\n")
|
||||
except Exception:
|
||||
return resp
|
||||
except Exception as e:
|
||||
log.warning(f"Camera stream interrupted: {e}")
|
||||
finally:
|
||||
try:
|
||||
proc.kill()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
await proc.wait()
|
||||
except Exception:
|
||||
pass
|
||||
self.camera_cache.mjpeg_subscribers.discard(q)
|
||||
|
||||
return resp
|
||||
|
||||
@@ -4268,6 +4549,7 @@ class KobraXBridge:
|
||||
"print_speed_mode": s["print_speed_mode"],
|
||||
"auto_leveling": getattr(self._args, "auto_leveling", 1),
|
||||
"vibration_compensation": getattr(self._args, "vibration_compensation", 0),
|
||||
"flow_calibration": getattr(self._args, "flow_calibration", 0),
|
||||
"camera_on_print": getattr(self._args, "camera_on_print", 0),
|
||||
"web_upload_warning": getattr(self._args, "web_upload_warning", 1),
|
||||
"light_on": s["light_on"],
|
||||
@@ -4284,6 +4566,8 @@ class KobraXBridge:
|
||||
"file_ready": s["file_ready"],
|
||||
"print_start_dialog": s.get("print_start_dialog", getattr(self._args, "print_start_dialog", 1)),
|
||||
"version": self._read_version(),
|
||||
"pause_msg": s.get("pause_msg", ""),
|
||||
"error_code": s.get("error_code", 0),
|
||||
})
|
||||
|
||||
async def handle_moonraker_database(self, request):
|
||||
@@ -4420,6 +4704,7 @@ class KobraXBridge:
|
||||
"default_ams_slot": getattr(self._args, "default_ams_slot", "auto"),
|
||||
"auto_leveling": getattr(self._args, "auto_leveling", 1),
|
||||
"vibration_compensation": getattr(self._args, "vibration_compensation", 0),
|
||||
"flow_calibration": getattr(self._args, "flow_calibration", 0),
|
||||
"camera_on_print": getattr(self._args, "camera_on_print", 0),
|
||||
"web_upload_warning": getattr(self._args, "web_upload_warning", 1),
|
||||
"print_start_dialog": getattr(self._args, "print_start_dialog", 1),
|
||||
@@ -4458,6 +4743,7 @@ class KobraXBridge:
|
||||
cfg.set("print", "default_ams_slot", str(data.get("default_ams_slot", getattr(self._args, "default_ams_slot", "auto"))))
|
||||
cfg.set("print", "auto_leveling", str(data.get("auto_leveling", getattr(self._args, "auto_leveling", 1))))
|
||||
cfg.set("print", "vibration_compensation", str(int(bool(data.get("vibration_compensation", getattr(self._args, "vibration_compensation", 0))))))
|
||||
cfg.set("print", "flow_calibration", str(int(bool(data.get("flow_calibration", getattr(self._args, "flow_calibration", 0))))))
|
||||
cfg.set("print", "camera_on_print", str(int(bool(data.get("camera_on_print", getattr(self._args, "camera_on_print", 0))))))
|
||||
cfg.set("print", "web_upload_warning", str(int(bool(data.get("web_upload_warning", getattr(self._args, "web_upload_warning", 1))))))
|
||||
cfg.set("print", "print_start_dialog", str(int(bool(data.get("print_start_dialog", getattr(self._args, "print_start_dialog", 1))))))
|
||||
@@ -5602,6 +5888,7 @@ def main():
|
||||
parser.add_argument("--default-ams-slot",default=env_loader.DEFAULT_AMS_SLOT)
|
||||
parser.add_argument("--auto-leveling", type=int, default=env_loader.AUTO_LEVELING)
|
||||
parser.add_argument("--vibration-compensation", type=int, default=env_loader.VIBRATION_COMPENSATION)
|
||||
parser.add_argument("--flow-calibration", type=int, default=env_loader.FLOW_CALIBRATION)
|
||||
parser.add_argument("--camera-on-print", type=int, default=env_loader.CAMERA_ON_PRINT)
|
||||
parser.add_argument("--web-upload-warning", type=int, default=env_loader.WEB_UPLOAD_WARNING)
|
||||
parser.add_argument("--print-start-dialog", dest="print_start_dialog", type=int, default=env_loader.PRINT_START_DIALOG)
|
||||
|
||||
@@ -2915,29 +2915,85 @@ function openFilamentDialog(slots){
|
||||
var dr=ar[0]-br[0], dg=ar[1]-br[1], db=ar[2]-br[2];
|
||||
return (dr*dr + dg*dg + db*db);
|
||||
}
|
||||
var defaultSlotByPaint={};
|
||||
var usedDefaultSlot={};
|
||||
channels.forEach(function(gc,i){
|
||||
|
||||
// Globale, farboptimale Zuordnung Kanal -> Slot.
|
||||
// Anforderungen: 1) Material muss passen (harter Filter, wie bisher)
|
||||
// 2) Farbe so nah wie möglich
|
||||
// 3) Position spielt praktisch keine Rolle mehr
|
||||
// Ein rein gieriger Ansatz (Kanal für Kanal den "nächsten" Slot wählen)
|
||||
// kann bei knappen/mehrdeutigen Slots suboptimale Gesamtlösungen liefern
|
||||
// (z.B. Kanal A "verbraucht" den Slot, der eigentlich viel besser zu
|
||||
// Kanal B gepasst hätte). Da ein AMS in der Praxis nur wenige Slots hat
|
||||
// (üblich: bis zu 4), lohnt sich Brute-Force über alle möglichen
|
||||
// Zuordnungen mit Pruning - liefert garantiert das global beste Ergebnis,
|
||||
// ganz ohne einen vollen Hungarian-Algorithmus implementieren zu müssen.
|
||||
function _bestAssignment(slotsPerChannel){
|
||||
var n=slotsPerChannel.length;
|
||||
var assign=new Array(n).fill(-1);
|
||||
|
||||
// Sicherheitsnetz für unrealistisch viele Kanäle: gieriger Fallback
|
||||
// (kleinster Farbabstand zuerst bedient), um exponentielle Laufzeit zu
|
||||
// vermeiden. Greift in der Praxis nie, ein AMS hat max. wenige Slots.
|
||||
if(n>8){
|
||||
var used={};
|
||||
var order=slotsPerChannel.map(function(_,idx){return idx;}).sort(function(a,b){
|
||||
var da=slotsPerChannel[a].length?slotsPerChannel[a][0].dist:Infinity;
|
||||
var db=slotsPerChannel[b].length?slotsPerChannel[b][0].dist:Infinity;
|
||||
return da-db;
|
||||
});
|
||||
order.forEach(function(i){
|
||||
var cands=slotsPerChannel[i];
|
||||
if(!cands.length)return;
|
||||
var pick=cands.find(function(c){return !used[c.slot_index];})||cands[0];
|
||||
assign[i]=pick.slot_index;
|
||||
used[pick.slot_index]=true;
|
||||
});
|
||||
return assign;
|
||||
}
|
||||
|
||||
var bestCost=Infinity, bestAssign=assign.slice(), used={};
|
||||
(function backtrack(idx,cost){
|
||||
if(cost>=bestCost)return; // Pruning: kann eh nicht mehr besser werden
|
||||
if(idx===n){ bestCost=cost; bestAssign=assign.slice(); return; }
|
||||
var candidates=slotsPerChannel[idx];
|
||||
if(!candidates.length){
|
||||
assign[idx]=-1;
|
||||
backtrack(idx+1,cost);
|
||||
return;
|
||||
}
|
||||
var placedFree=false;
|
||||
for(var k=0;k<candidates.length;k++){
|
||||
var c=candidates[k];
|
||||
if(used[c.slot_index])continue;
|
||||
placedFree=true;
|
||||
used[c.slot_index]=true;
|
||||
assign[idx]=c.slot_index;
|
||||
backtrack(idx+1,cost+c.dist);
|
||||
used[c.slot_index]=false;
|
||||
}
|
||||
if(!placedFree){
|
||||
// keine freien kompatiblen Slots mehr -> Mehrfachbelegung des
|
||||
// farblich besten kompatiblen Slots zulassen (besser als leer)
|
||||
var best=candidates[0];
|
||||
assign[idx]=best.slot_index;
|
||||
backtrack(idx+1,cost+best.dist);
|
||||
}
|
||||
assign[idx]=-1;
|
||||
})(0,0);
|
||||
return bestAssign;
|
||||
}
|
||||
|
||||
var slotsPerChannel=channels.map(function(gc){
|
||||
var compatible=_amsSlots.filter(function(s){
|
||||
return _materialsCompatible(gc.material, s.material);
|
||||
});
|
||||
if(!compatible.length){
|
||||
defaultSlotByPaint[i]=-1;
|
||||
return;
|
||||
}
|
||||
|
||||
var ranked=compatible.slice().sort(function(a,b){
|
||||
var da=Math.abs((a.slot_index||0)-i), db=Math.abs((b.slot_index||0)-i);
|
||||
if(da!==db)return da-db;
|
||||
var ca=_colorDist(gc.color_hex, a.color_hex), cb=_colorDist(gc.color_hex, b.color_hex);
|
||||
if(ca!==cb)return ca-cb;
|
||||
return (a.slot_index||0)-(b.slot_index||0);
|
||||
});
|
||||
|
||||
var chosen=ranked.find(function(s){return !usedDefaultSlot[s.slot_index];}) || ranked[0];
|
||||
defaultSlotByPaint[i]=chosen?chosen.slot_index:-1;
|
||||
if(chosen) usedDefaultSlot[chosen.slot_index]=1;
|
||||
return compatible.map(function(s){
|
||||
return {slot_index:s.slot_index, dist:_colorDist(gc.color_hex, s.color_hex)};
|
||||
}).sort(function(a,b){ return a.dist-b.dist; });
|
||||
});
|
||||
var _assignResult=_bestAssignment(slotsPerChannel);
|
||||
var defaultSlotByPaint={};
|
||||
channels.forEach(function(gc,i){ defaultSlotByPaint[i]=_assignResult[i]; });
|
||||
|
||||
if(!_amsSlots.length){
|
||||
body.innerHTML='<p style="color:var(--txt2);font-size:13px;text-align:center;padding:16px 0">'+T.fd_no_slots_msg.replace('{br}','<br>')+'</p>';
|
||||
@@ -3391,4 +3447,4 @@ function loadPrinterTab(){
|
||||
}).catch(function(e){
|
||||
if(grid)grid.innerHTML='<div style="color:var(--err);font-size:13px;padding:20px">Fehler: '+e+'</div>';
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@
|
||||
<body>
|
||||
|
||||
<div id="conn-error-banner" style="display:none;background:#c0392b;color:#fff;padding:10px 18px;font-size:14px;text-align:center;position:sticky;top:0;z-index:999;"></div>
|
||||
<div id="pause-msg-banner" style="display:none;background:#b8860b;color:#fff;padding:10px 18px;font-size:14px;text-align:center;position:sticky;top:0;z-index:997;"></div>
|
||||
<div id="file-ready-banner" style="display:none;background:#1a6e3c;color:#fff;padding:10px 18px;font-size:14px;text-align:center;position:sticky;top:0;z-index:998;display:none;align-items:center;justify-content:center;gap:12px;flex-wrap:wrap">
|
||||
<span>📄 <span id="file-ready-name"></span></span>
|
||||
<button id="file-ready-btn" onclick="startReadyFile()"
|
||||
@@ -513,6 +514,10 @@
|
||||
<input type="checkbox" id="s-vibration-compensation" style="width:auto;margin:0">
|
||||
<label id="lbl-vibration-compensation" style="margin:0;cursor:pointer" for="s-vibration-compensation">Resonance compensation before print</label>
|
||||
</div>
|
||||
<div class="modal-field" style="flex-direction:row;align-items:center;gap:10px">
|
||||
<input type="checkbox" id="s-flow-calibration" style="width:auto;margin:0">
|
||||
<label id="lbl-flow-calibration" style="margin:0;cursor:pointer" for="s-flow-calibration">Flow calibration before print</label>
|
||||
</div>
|
||||
<div class="modal-field">
|
||||
<label id="lbl-file-ready-mode">Nach Upload: Druckstart-Verhalten</label>
|
||||
<select id="s-file-ready-mode">
|
||||
|
||||
@@ -121,6 +121,7 @@
|
||||
"lbl_feed": "Einziehen",
|
||||
"lbl_layers": "Layer",
|
||||
"lbl_light": "💡 Licht",
|
||||
"lbl_pause_reason": "Druck pausiert:",
|
||||
"lbl_remaining": "Restzeit:",
|
||||
"lbl_slicer_time": "Slicer-Schätzung:",
|
||||
"lbl_spoolman_sync_rate": "Sync-Rate (s, 0=Druckende)",
|
||||
@@ -235,6 +236,7 @@
|
||||
"settings_file_ready_banner": "Druckleiste",
|
||||
"settings_file_ready_dialog": "Druckdialog",
|
||||
"settings_file_ready_mode": "Nach Upload: Druckstart-Verhalten",
|
||||
"settings_flow_calibration": "Flow-Kalibrierung vor Druck",
|
||||
"settings_integrations": "Integrationen",
|
||||
"settings_language": "Sprache",
|
||||
"settings_mode_id": "Mode-ID",
|
||||
|
||||
@@ -121,6 +121,7 @@
|
||||
"lbl_feed": "Load",
|
||||
"lbl_layers": "Layer",
|
||||
"lbl_light": "💡 Light",
|
||||
"lbl_pause_reason": "Print paused:",
|
||||
"lbl_remaining": "Remaining:",
|
||||
"lbl_slicer_time": "Slicer estimate:",
|
||||
"lbl_spoolman_sync_rate": "Sync rate (s, 0=end of print)",
|
||||
@@ -235,6 +236,7 @@
|
||||
"settings_file_ready_banner": "Print bar",
|
||||
"settings_file_ready_dialog": "Print dialog",
|
||||
"settings_file_ready_mode": "After upload: Start print behavior",
|
||||
"settings_flow_calibration": "Flow calibration before print",
|
||||
"settings_integrations": "Integrations",
|
||||
"settings_language": "Language",
|
||||
"settings_mode_id": "Mode ID",
|
||||
|
||||
@@ -121,6 +121,7 @@
|
||||
"lbl_feed": "Cargar",
|
||||
"lbl_layers": "Capa",
|
||||
"lbl_light": "💡 Luz",
|
||||
"lbl_pause_reason": "Impresión pausada:",
|
||||
"lbl_remaining": "Restante:",
|
||||
"lbl_slicer_time": "Estimación del slicer:",
|
||||
"lbl_spoolman_sync_rate": "Tasa de sincronización (s, 0=fin impresión)",
|
||||
@@ -235,6 +236,7 @@
|
||||
"settings_file_ready_banner": "Barra de impresión",
|
||||
"settings_file_ready_dialog": "Diálogo de impresión",
|
||||
"settings_file_ready_mode": "Después de carga: Comportamiento de inicio de impresión",
|
||||
"settings_flow_calibration": "Calibración de flujo antes de imprimir",
|
||||
"settings_integrations": "Integraciones",
|
||||
"settings_language": "Idioma",
|
||||
"settings_mode_id": "ID de modo",
|
||||
|
||||
@@ -121,6 +121,7 @@
|
||||
"lbl_feed": "Charger",
|
||||
"lbl_layers": "Couche",
|
||||
"lbl_light": "💡 Lumière",
|
||||
"lbl_pause_reason": "Impression en pause :",
|
||||
"lbl_remaining": "Restant :",
|
||||
"lbl_slicer_time": "Estimation slicer :",
|
||||
"lbl_spoolman_sync_rate": "Taux de sync. (s, 0=fin impression)",
|
||||
@@ -234,6 +235,7 @@
|
||||
"settings_file_ready_banner": "Barre d'impression",
|
||||
"settings_file_ready_dialog": "Dialogue d'impression",
|
||||
"settings_file_ready_mode": "Après téléchargement : Comportement de démarrage d'impression",
|
||||
"settings_flow_calibration": "Calibration du flux avant impression",
|
||||
"settings_integrations": "Intégrations",
|
||||
"settings_language": "Langue",
|
||||
"settings_mode_id": "ID du mode",
|
||||
|
||||
@@ -121,6 +121,7 @@
|
||||
"lbl_feed": "Carica",
|
||||
"lbl_layers": "Layer",
|
||||
"lbl_light": "💡 Luce",
|
||||
"lbl_pause_reason": "Stampa in pausa:",
|
||||
"lbl_remaining": "Rimanente:",
|
||||
"lbl_slicer_time": "Stima slicer:",
|
||||
"lbl_spoolman_sync_rate": "Frequenza sync (s, 0=fine stampa)",
|
||||
@@ -234,6 +235,7 @@
|
||||
"settings_file_ready_banner": "Barra di stampa",
|
||||
"settings_file_ready_dialog": "Finestra di dialogo stampa",
|
||||
"settings_file_ready_mode": "Dopo il caricamento: Comportamento di avvio stampa",
|
||||
"settings_flow_calibration": "Calibrazione flusso prima della stampa",
|
||||
"settings_integrations": "Integrazioni",
|
||||
"settings_language": "Lingua",
|
||||
"settings_mode_id": "ID modalità",
|
||||
|
||||
@@ -121,6 +121,7 @@
|
||||
"lbl_feed": "进料",
|
||||
"lbl_layers": "层",
|
||||
"lbl_light": "💡 灯光",
|
||||
"lbl_pause_reason": "打印已暂停:",
|
||||
"lbl_remaining": "剩余时间:",
|
||||
"lbl_slicer_time": "切片预估:",
|
||||
"lbl_spoolman_sync_rate": "同步频率(秒,0=打印结束)",
|
||||
@@ -235,6 +236,7 @@
|
||||
"settings_file_ready_banner": "打印栏",
|
||||
"settings_file_ready_dialog": "打印对话框",
|
||||
"settings_file_ready_mode": "上传后:开始打印行为",
|
||||
"settings_flow_calibration": "打印前流量校准",
|
||||
"settings_integrations": "集成",
|
||||
"settings_language": "语言",
|
||||
"settings_mode_id": "模式 ID",
|
||||
|
||||
Reference in New Issue
Block a user