lots of fixes and features

This commit is contained in:
Your Name
2026-07-13 22:01:55 +02:00
parent eda14db897
commit 4322b065c7
12 changed files with 471 additions and 107 deletions

View File

@@ -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")))

View File

@@ -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

View File

@@ -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")))

View File

@@ -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 80100 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)

View File

@@ -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>';
});
}
}

View File

@@ -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">

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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à",

View File

@@ -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",