Compare commits
14 Commits
nightly-0.
...
nightly
| Author | SHA1 | Date | |
|---|---|---|---|
| 99bc1797c8 | |||
| e1b9480098 | |||
|
|
03089db6af | ||
|
|
cd4a8ce48e | ||
| bf3f043888 | |||
| b9594d4a22 | |||
|
|
bf94a8f563 | ||
| eda14db897 | |||
| dcf852db49 | |||
| d2c92c2deb | |||
| b541aafc74 | |||
| f2f4447809 | |||
| a3deb33b97 | |||
| 2e2061a269 |
11
CHANGELOG.md
11
CHANGELOG.md
@@ -3,6 +3,17 @@
|
||||
## [Unreleased]
|
||||
|
||||
### Fixed
|
||||
- **Slot kept showing/printing a stale filament type after a spool swap.** The
|
||||
per-slot profile override (config.ini `[filament_profiles]`) stores only
|
||||
vendor+name and was sticky: swapping the physical filament updated the AMS
|
||||
colour and type live, but the saved profile persisted, so a slot that held
|
||||
e.g. "KINGROON PETG Basic" kept showing/sending PETG in the panel and the
|
||||
OrcaSlicer lane hint even after yellow PLA was loaded — and survived restarts.
|
||||
The override is now applied only while its material *family* still matches the
|
||||
loaded AMS material (PLA / PLA+ / PLA SILK / PLA MATTE are one family, so
|
||||
within-family swaps never invalidate a valid profile). On a family change the
|
||||
slot falls back to the generic default; the override is not deleted, so
|
||||
reloading the original material reactivates it.
|
||||
- **Filament profiles not isolated between printers in a multi-printer bridge**
|
||||
(issue #74). The slot→profile mapping and `visible_vendors` were stored in a
|
||||
single global `[filament_profiles]` section, so configuring one printer
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
## Changes in this build
|
||||
|
||||
- Refactor: all logs, comments and API error strings translated to English across the codebase (no behavior change)
|
||||
- Refactor: the `print/start` payload was duplicated in three places and had drifted (the new resonance-compensation setting was missing from the upload+print path) — all print start paths now share one payload builder
|
||||
- Fix: a couple of silently swallowed errors (dashboard reprint filament-cache load, filament metadata backfill) now log a warning/debug message instead of failing silently
|
||||
|
||||
**A stable release is coming soon** with the fixes and features from the last several nightly builds. As usual it will be published as ready-to-run binaries (Linux amd64/arm64, Windows) alongside the Docker image.
|
||||
- Fix: dashboard reprint from the cache path crashed with an AttributeError (`self._db` did not exist) instead of applying the filament slot filter (PR #93, thanks @Pavulon87)
|
||||
- Feat: the dashboard now shows a banner with the reason and error code when a print is paused, with a direct link to the Anycubic error-code docs (PR #92, thanks @Pavulon87)
|
||||
- Fix: `/api/camera/stream` (the MJPEG live view used by the dashboard and every Moonraker-compatible client) opened a brand-new ffmpeg process and printer connection per HTTP client — two simultaneous viewers could already exhaust the printer's very limited camera connection slots and cause intermittent "stream unavailable"/429 failures. All consumers now share one ffmpeg process via the same fanout pattern already used for the H.264 stream (credit to @Pavulon87 for the fix and for finding two related race conditions along the way)
|
||||
|
||||
@@ -47,30 +47,39 @@ def _load_env_file(path: pathlib.Path):
|
||||
os.environ[key] = val
|
||||
|
||||
|
||||
# Single source of truth for env-var <-> config.ini mapping. _restart_bridge()
|
||||
# in kobrax_moonraker_bridge.py clears exactly these keys from os.environ
|
||||
# before restarting, so a value removed here or in the UI settings save can
|
||||
# never survive as a stale env var read by the new process. Add new settings
|
||||
# here ONLY - no second list to keep in sync.
|
||||
CONFIG_ENV_MAPPING = {
|
||||
"PRINTER_IP": (CONFIG_SECTION_CONNECTION, "printer_ip"),
|
||||
"MQTT_PORT": (CONFIG_SECTION_CONNECTION, "mqtt_port"),
|
||||
"MQTT_USERNAME": (CONFIG_SECTION_CONNECTION, "username"),
|
||||
"MQTT_PASSWORD": (CONFIG_SECTION_CONNECTION, "password"),
|
||||
"MODE_ID": (CONFIG_SECTION_CONNECTION, "mode_id"),
|
||||
"DEVICE_ID": (CONFIG_SECTION_CONNECTION, "device_id"),
|
||||
"DEFAULT_AMS_SLOT": (CONFIG_SECTION_PRINT, "default_ams_slot"),
|
||||
"AUTO_LEVELING": (CONFIG_SECTION_PRINT, "auto_leveling"),
|
||||
"VIBRATION_COMPENSATION": (CONFIG_SECTION_PRINT, "vibration_compensation"),
|
||||
"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"),
|
||||
"BRIDGE_PRINTER_NAME": (CONFIG_SECTION_BRIDGE, "printer_name"),
|
||||
"BRIDGE_HOST_IP": (CONFIG_SECTION_BRIDGE, "host_ip"),
|
||||
"POLL_INTERVAL": (CONFIG_SECTION_BRIDGE, "poll_interval"),
|
||||
"VERBOSE_HTTP_LOG": (CONFIG_SECTION_BRIDGE, "verbose_http_log"),
|
||||
"SPOOLMAN_SERVER": (CONFIG_SECTION_SPOOLMAN, "server"),
|
||||
"SPOOLMAN_SYNC_RATE": (CONFIG_SECTION_SPOOLMAN, "sync_rate"),
|
||||
}
|
||||
|
||||
|
||||
def _load_config_file(path: pathlib.Path):
|
||||
"""Loads config.ini and sets keys in os.environ (only if not already set)."""
|
||||
cfg = configparser.ConfigParser()
|
||||
cfg.read(path, encoding="utf-8")
|
||||
|
||||
mapping = {
|
||||
"PRINTER_IP": (CONFIG_SECTION_CONNECTION, "printer_ip"),
|
||||
"MQTT_PORT": (CONFIG_SECTION_CONNECTION, "mqtt_port"),
|
||||
"MQTT_USERNAME": (CONFIG_SECTION_CONNECTION, "username"),
|
||||
"MQTT_PASSWORD": (CONFIG_SECTION_CONNECTION, "password"),
|
||||
"MODE_ID": (CONFIG_SECTION_CONNECTION, "mode_id"),
|
||||
"DEVICE_ID": (CONFIG_SECTION_CONNECTION, "device_id"),
|
||||
"DEFAULT_AMS_SLOT": (CONFIG_SECTION_PRINT, "default_ams_slot"),
|
||||
"AUTO_LEVELING": (CONFIG_SECTION_PRINT, "auto_leveling"),
|
||||
"VIBRATION_COMPENSATION": (CONFIG_SECTION_PRINT, "vibration_compensation"),
|
||||
"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"),
|
||||
"BRIDGE_PRINTER_NAME": (CONFIG_SECTION_BRIDGE, "printer_name"),
|
||||
"BRIDGE_HOST_IP": (CONFIG_SECTION_BRIDGE, "host_ip"),
|
||||
"SPOOLMAN_SERVER": (CONFIG_SECTION_SPOOLMAN, "server"),
|
||||
"SPOOLMAN_SYNC_RATE": (CONFIG_SECTION_SPOOLMAN, "sync_rate"),
|
||||
}
|
||||
for env_key, (section, option) in mapping.items():
|
||||
for env_key, (section, option) in CONFIG_ENV_MAPPING.items():
|
||||
if env_key not in os.environ:
|
||||
try:
|
||||
val = cfg.get(section, option)
|
||||
@@ -448,3 +457,5 @@ PRINT_START_DIALOG = int(get("PRINT_START_DIALOG", get("FILE_READY_DIALOG", "1")
|
||||
SPOOLMAN_SERVER = get("SPOOLMAN_SERVER", "")
|
||||
SPOOLMAN_SYNC_RATE = int(get("SPOOLMAN_SYNC_RATE", "0"))
|
||||
BRIDGE_HOST_IP = get("BRIDGE_HOST_IP", "")
|
||||
POLL_INTERVAL = int(get("POLL_INTERVAL", "3"))
|
||||
VERBOSE_HTTP_LOG = int(get("VERBOSE_HTTP_LOG", "0"))
|
||||
|
||||
@@ -134,6 +134,14 @@ logging.basicConfig(level=logging.INFO,
|
||||
format="[%(asctime)s] %(levelname)-5s %(name)s: %(message)s",
|
||||
datefmt="%H:%M:%S")
|
||||
log = logging.getLogger("bridge")
|
||||
# aiohttp logs one INFO line per HTTP request (access log) — with 2s frontend
|
||||
# polling that drowns out the bridge's own logs by default. Toggleable at
|
||||
# runtime via the verbose_http_log setting (see handle_api_settings_post).
|
||||
logging.getLogger("aiohttp.access").setLevel(logging.WARNING)
|
||||
|
||||
|
||||
def _set_verbose_http_log(enabled: bool):
|
||||
logging.getLogger("aiohttp.access").setLevel(logging.INFO if enabled else logging.WARNING)
|
||||
|
||||
# Web UI: subdirectory under web/themes/<name>/index.html
|
||||
_UI_THEME_NAME_RE = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$")
|
||||
@@ -580,15 +588,23 @@ class GCodeStore:
|
||||
class CameraCache:
|
||||
"""Zentraler Kamera-Demuxer.
|
||||
|
||||
Keeps ONE ffmpeg process open that reads the FLV stream from the printer
|
||||
and produces two outputs in parallel:
|
||||
Keeps ONE ffmpeg process per output type open that reads the FLV stream
|
||||
from the printer and produces:
|
||||
- MJPEG @ 2fps -> last frame in RAM for /api/camera/snapshot
|
||||
- MPEG-TS (-c:v copy) -> fanout to all /api/camera/h264 subscribers
|
||||
- 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.
|
||||
"""
|
||||
@@ -602,40 +618,77 @@ 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"]
|
||||
if url.lower().startswith("rtsp://"):
|
||||
args += ["-probesize", "32", "-analyzeduration", "0", "-rtsp_transport", "tcp"]
|
||||
else:
|
||||
args += ["-probesize", "500000", "-analyzeduration", "500000"]
|
||||
# The printer's FLV source occasionally emits non-monotonic container
|
||||
# timestamps (PTS jumps of days) while the video data itself stays
|
||||
# valid. Without this flag ffmpeg's realtime pacing breaks on such a
|
||||
# jump and the stream stalls after ~15-30 min (Issue #90).
|
||||
args += ["-use_wallclock_as_timestamps", "1",
|
||||
"-probesize", "500000", "-analyzeduration", "500000"]
|
||||
return args
|
||||
|
||||
async def _run_jpeg_loop(self):
|
||||
@@ -782,6 +835,98 @@ 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:
|
||||
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
|
||||
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.
|
||||
@@ -880,6 +1025,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
|
||||
@@ -944,14 +1091,18 @@ class KobraXBridge:
|
||||
client.callbacks["light/report"] = self._on_light
|
||||
client.callbacks["skip/report"] = self._on_skip
|
||||
|
||||
# Reachability is rechecked periodically (not just once at boot) so the
|
||||
# UI status dot reflects the printer's/Spoolman's actual current state
|
||||
# instead of freezing on the boot-time result.
|
||||
self._spoolman_reachable: bool = False
|
||||
self._spoolman_last_health_check: float = 0.0
|
||||
if self._spoolman:
|
||||
threading.Thread(
|
||||
target=lambda: log.info(
|
||||
f"Spoolman: {'OK' if self._spoolman.health_check() else 'unreachable'} "
|
||||
f"at {self._spoolman.server_url}"
|
||||
),
|
||||
daemon=True, name="spoolman-health",
|
||||
).start()
|
||||
def _check():
|
||||
ok = self._spoolman.health_check()
|
||||
self._spoolman_reachable = ok
|
||||
self._spoolman_last_health_check = time.time()
|
||||
log.info(f"Spoolman: {'OK' if ok else 'unreachable'} at {self._spoolman.server_url}")
|
||||
threading.Thread(target=_check, daemon=True, name="spoolman-health").start()
|
||||
|
||||
# ── Spoolman helpers ──────────────────────────────────────────────────────
|
||||
|
||||
@@ -1042,6 +1193,7 @@ class KobraXBridge:
|
||||
"""GET /kx/spoolman/status"""
|
||||
return self._json_cors({
|
||||
"configured": bool(self._spoolman),
|
||||
"reachable": self._spoolman_reachable if self._spoolman else False,
|
||||
"server": self._spoolman.server_url if self._spoolman else "",
|
||||
"sync_rate": self._spoolman.sync_rate if self._spoolman else 0,
|
||||
"slot_spools": {str(k): v for k, v in self._spoolman_slot_spools.items()},
|
||||
@@ -1187,6 +1339,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:
|
||||
@@ -1904,6 +2067,55 @@ class KobraXBridge:
|
||||
return _ALIASES[m]
|
||||
return m
|
||||
|
||||
@staticmethod
|
||||
def _material_family(mat: str) -> str:
|
||||
"""Reduce a material to its base polymer family.
|
||||
|
||||
PLA / PLA+ / PLA SILK / PLA MATTE -> "PLA"; PETG / PETG+ -> "PETG"; etc.
|
||||
Used by the stale-profile guard: only a change of *family* (e.g. PETG ->
|
||||
PLA) invalidates a saved slot profile — a change within the family
|
||||
(PLA -> PLA SILK) must not discard an otherwise valid profile.
|
||||
"""
|
||||
if not mat:
|
||||
return ""
|
||||
m = KobraXBridge._normalize_material(mat)
|
||||
# Longer prefixes first so "PETG" is not swallowed by "PET".
|
||||
for fam in ("PETG", "PLA", "ABS", "ASA", "TPU", "PVA", "HIPS", "PA", "PC", "PET"):
|
||||
if m.startswith(fam):
|
||||
return fam
|
||||
return m
|
||||
|
||||
def _profile_material(self, profile: dict) -> str:
|
||||
"""Material type (e.g. "PETG") of a saved slot profile, resolved by
|
||||
(vendor, name) from the Orca filament library. Returns "" when the
|
||||
profile is not in the library — we do NOT guess in that case."""
|
||||
name = (profile or {}).get("name", "")
|
||||
if not name:
|
||||
return ""
|
||||
vendor = profile.get("vendor", "")
|
||||
for p in self._load_orca_filaments():
|
||||
if p.get("vendor") == vendor and p.get("name") == name:
|
||||
return p.get("type", "") or ""
|
||||
return ""
|
||||
|
||||
def _effective_slot_profile(self, global_idx: int, ams_material: str) -> dict:
|
||||
"""Saved slot-profile override — but only while its material *family*
|
||||
still matches the material currently loaded in the AMS.
|
||||
|
||||
Non-destructive suppression (Option A): when the family no longer matches
|
||||
(e.g. a PETG profile but PLA loaded) we return {} → the slot falls back to
|
||||
the generic default. The override stays in config.ini and reactivates as
|
||||
soon as the matching material is loaded again. When the profile's family
|
||||
is unknown we do NOT suppress (fail-safe)."""
|
||||
profile = self._filament_profiles.get(global_idx) or {}
|
||||
if not profile.get("name"):
|
||||
return {}
|
||||
prof_fam = self._material_family(self._profile_material(profile))
|
||||
ams_fam = self._material_family(ams_material)
|
||||
if prof_fam and ams_fam and prof_fam != ams_fam:
|
||||
return {}
|
||||
return profile
|
||||
|
||||
def _build_lane_data(self) -> dict:
|
||||
"""Builds BBL AMS JSON for OrcaSlicer DevFilaSystemParser::ParseV1_0.
|
||||
|
||||
@@ -1952,7 +2164,10 @@ class KobraXBridge:
|
||||
# 1. User-Wahl (config.ini [filament_profiles]) — exakte Kontrolle
|
||||
# 2. Generic fallback (_TRAY_INFO_IDX) per material type - no
|
||||
# vendor hint; OrcaSlicer then picks its own generic preset
|
||||
user_profile = self._filament_profiles.get(slot_index) or {}
|
||||
# Stale-profile guard: only apply the override while its material
|
||||
# family still matches the loaded filament (PETG profile + PLA
|
||||
# loaded -> dropped).
|
||||
user_profile = self._effective_slot_profile(slot_index, material)
|
||||
if user_profile.get("name"):
|
||||
vendor = user_profile.get("vendor", "")
|
||||
fila_name = user_profile.get("name", "")
|
||||
@@ -2132,7 +2347,9 @@ class KobraXBridge:
|
||||
# preset name -> 'Anycubic PLA' matches the printer-specific
|
||||
# preset; an empty string previously led to Generic PLA.
|
||||
if occupied:
|
||||
user_profile = self._filament_profiles.get(_global_index) or {}
|
||||
# Stale-profile guard (see _effective_slot_profile): only apply the
|
||||
# override while its material family still matches the loaded filament.
|
||||
user_profile = self._effective_slot_profile(_global_index, material)
|
||||
fila_name = user_profile.get("name") or self._default_filament_name(material)
|
||||
gate_filament_name.append(fila_name)
|
||||
else:
|
||||
@@ -2471,7 +2688,9 @@ class KobraXBridge:
|
||||
slots = []
|
||||
for i, s in enumerate(self._ams_slots):
|
||||
gidx = int(s.get("global_index", i))
|
||||
profile = self._filament_profiles.get(gidx) or {}
|
||||
# Stale-profile guard: only show the override while its material
|
||||
# family matches the loaded AMS material (else slot has no brand).
|
||||
profile = self._effective_slot_profile(gidx, s.get("type", ""))
|
||||
slots.append({
|
||||
"slot_index": gidx,
|
||||
"material": s.get("type", ""),
|
||||
@@ -3560,7 +3779,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:
|
||||
@@ -3690,21 +3909,33 @@ class KobraXBridge:
|
||||
# (only the bare HTML) -> without inlining neither
|
||||
# a single button works there (Issue #29). It is equally correct in a normal browser.
|
||||
base = os.path.join(_WEB_BASE, "web", "themes", self._ui_theme)
|
||||
try:
|
||||
css = pathlib.Path(os.path.join(base, "style.css")).read_text(encoding="utf-8")
|
||||
page = page.replace(
|
||||
'<link rel="stylesheet" href="/kx/ui/style.css">',
|
||||
"<style>\n" + css + "\n</style>")
|
||||
except OSError:
|
||||
pass
|
||||
try:
|
||||
js = pathlib.Path(os.path.join(base, "app.js")).read_text(encoding="utf-8")
|
||||
js = js.replace("'__VERSION__'", f"'{self._read_version()}'")
|
||||
page = page.replace(
|
||||
'<script src="/kx/ui/app.js"></script>',
|
||||
"<script>\n" + js + "\n</script>")
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
# Inline vendored lib CSS/JS too — the OrcaSlicer webview loads no
|
||||
# external <link>/<script src>, so GridStack (and its stylesheet) must
|
||||
# be embedded like style.css/app.js. Order matters: GridStack's <script>
|
||||
# sits in <head>, before app.js, so it is defined when app.js inits.
|
||||
def _inline_css(rel_path: str, link_tag: str):
|
||||
nonlocal page
|
||||
try:
|
||||
data = pathlib.Path(os.path.join(base, rel_path)).read_text(encoding="utf-8")
|
||||
page = page.replace(link_tag, "<style>\n" + data + "\n</style>")
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def _inline_js(rel_path: str, script_tag: str, version_sub: bool = False):
|
||||
nonlocal page
|
||||
try:
|
||||
data = pathlib.Path(os.path.join(base, rel_path)).read_text(encoding="utf-8")
|
||||
if version_sub:
|
||||
data = data.replace("'__VERSION__'", f"'{self._read_version()}'")
|
||||
page = page.replace(script_tag, "<script>\n" + data + "\n</script>")
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
_inline_css("lib/gridstack.min.css", '<link rel="stylesheet" href="/kx/ui/lib/gridstack.min.css">')
|
||||
_inline_js("lib/gridstack-all.min.js", '<script src="/kx/ui/lib/gridstack-all.min.js"></script>')
|
||||
_inline_css("style.css", '<link rel="stylesheet" href="/kx/ui/style.css">')
|
||||
_inline_js("app.js", '<script src="/kx/ui/app.js"></script>', version_sub=True)
|
||||
|
||||
return web.Response(text=page, content_type="text/html",
|
||||
headers={"Cache-Control": "no-store, no-cache, must-revalidate"})
|
||||
@@ -4052,10 +4283,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,
|
||||
@@ -4063,10 +4299,10 @@ class KobraXBridge:
|
||||
printer and is ~1 s faster)."""
|
||||
url = self._state.get("camera_url", "")
|
||||
if not url:
|
||||
return web.Response(status=503, text="Keine Kamera-URL bekannt")
|
||||
return web.Response(status=503, text="No camera URL known")
|
||||
self.camera_cache.set_url(url)
|
||||
await self.camera_cache.ensure_running()
|
||||
# Initialer Warmup: bis zu 5 s auf ersten Frame warten
|
||||
# Initial warmup: wait up to 5s for the first frame
|
||||
deadline = time.time() + 5.0
|
||||
while not self.camera_cache.latest_jpeg and time.time() < deadline:
|
||||
await asyncio.sleep(0.1)
|
||||
@@ -4082,43 +4318,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")
|
||||
return web.Response(status=503, text="No camera URL known")
|
||||
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={
|
||||
@@ -4127,46 +4346,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
|
||||
|
||||
@@ -4176,7 +4373,7 @@ class KobraXBridge:
|
||||
additional FLV connection to the printer (single-client limit)."""
|
||||
url = self._state.get("camera_url", "")
|
||||
if not url:
|
||||
return web.Response(status=503, text="Keine Kamera-URL bekannt")
|
||||
return web.Response(status=503, text="No camera URL known")
|
||||
self.camera_cache.set_url(url)
|
||||
await self.camera_cache.ensure_running()
|
||||
|
||||
@@ -4271,6 +4468,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):
|
||||
@@ -4411,6 +4610,7 @@ class KobraXBridge:
|
||||
"web_upload_warning": getattr(self._args, "web_upload_warning", 1),
|
||||
"print_start_dialog": getattr(self._args, "print_start_dialog", 1),
|
||||
"poll_interval": getattr(self._args, "poll_interval", 3),
|
||||
"verbose_http_log": getattr(self._args, "verbose_http_log", 0),
|
||||
"filament_profiles": {str(k): v for k, v in self._filament_profiles.items()},
|
||||
"visible_vendors": self._visible_vendors,
|
||||
"ace_dry_presets": self._ace_dry_presets,
|
||||
@@ -4455,6 +4655,10 @@ class KobraXBridge:
|
||||
cfg.set("bridge", "poll_interval", str(pi))
|
||||
elif not cfg.has_option("bridge", "poll_interval"):
|
||||
cfg.set("bridge", "poll_interval", "3")
|
||||
verbose_http_log = int(bool(data.get("verbose_http_log", getattr(self._args, "verbose_http_log", 0))))
|
||||
cfg.set("bridge", "verbose_http_log", str(verbose_http_log))
|
||||
_set_verbose_http_log(bool(verbose_http_log))
|
||||
self._args.verbose_http_log = verbose_http_log
|
||||
printer_name = str(data.get("printer_name", "")).strip()
|
||||
if printer_name:
|
||||
cfg.set("bridge", "printer_name", printer_name)
|
||||
@@ -4483,7 +4687,7 @@ class KobraXBridge:
|
||||
with open(config_path, "w", encoding="utf-8") as f:
|
||||
f.write("# KX-Bridge Konfigurationsdatei\n\n")
|
||||
cfg.write(f)
|
||||
log.info(f"Settings gespeichert in {config_path}")
|
||||
log.info(f"Settings saved to {config_path}")
|
||||
# Send the response, then restart
|
||||
response = web.json_response({"status": "restarting"})
|
||||
asyncio.get_event_loop().call_later(0.3, self._restart_bridge)
|
||||
@@ -4626,12 +4830,15 @@ class KobraXBridge:
|
||||
log.info("Restarting bridge...")
|
||||
# config_loader caches config.ini values in os.environ ("only if not set").
|
||||
# On restart, environ must be cleaned, otherwise the new process reads
|
||||
# the old values instead of the modified config.ini.
|
||||
for _k in ("PRINTER_IP", "MQTT_PORT", "MQTT_USERNAME", "MQTT_PASSWORD",
|
||||
"MODE_ID", "DEVICE_ID", "DEFAULT_AMS_SLOT", "AUTO_LEVELING",
|
||||
"CAMERA_ON_PRINT", "WEB_UPLOAD_WARNING", "PRINT_START_DIALOG",
|
||||
"FILE_READY_DIALOG", "BRIDGE_PRINTER_NAME",
|
||||
"SPOOLMAN_SERVER", "SPOOLMAN_SYNC_RATE"):
|
||||
# the old values instead of the modified config.ini. Keys are derived
|
||||
# from config_loader.CONFIG_ENV_MAPPING (single source of truth) so a
|
||||
# newly added setting can never be forgotten here again.
|
||||
try:
|
||||
import config_loader as _cl
|
||||
_restart_env_keys = set(_cl.CONFIG_ENV_MAPPING.keys()) | {"FILE_READY_DIALOG"}
|
||||
except Exception:
|
||||
_restart_env_keys = ()
|
||||
for _k in _restart_env_keys:
|
||||
os.environ.pop(_k, None)
|
||||
|
||||
in_docker = os.path.exists("/.dockerenv") or os.environ.get("KX_IN_DOCKER")
|
||||
@@ -5266,8 +5473,13 @@ class KobraXBridge:
|
||||
else:
|
||||
# No multiColorBox data — still attribute (no transitions to skip)
|
||||
self._spoolman_attribute_tick({})
|
||||
# Recheck Spoolman reachability periodically so the UI status
|
||||
# dot reflects the current state, not just the boot-time result.
|
||||
if self._spoolman and time.time() - self._spoolman_last_health_check >= 30.0:
|
||||
self._spoolman_reachable = self._spoolman.health_check()
|
||||
self._spoolman_last_health_check = time.time()
|
||||
except Exception as e:
|
||||
log.warning(f"Poll-Fehler: {e}")
|
||||
log.warning(f"Poll error: {e}")
|
||||
# Check whether the printer is really gone
|
||||
if not self._printer_reachable():
|
||||
log.info("Printer unreachable - switching to offline mode")
|
||||
@@ -5279,7 +5491,7 @@ class KobraXBridge:
|
||||
except Exception:
|
||||
pass
|
||||
_offline = True
|
||||
stop_event.wait(3.0)
|
||||
stop_event.wait(getattr(self._args, "poll_interval", 3))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -5435,6 +5647,7 @@ def _build_per_printer_args(base_args, p: dict):
|
||||
|
||||
|
||||
async def run_bridge(args):
|
||||
_set_verbose_http_log(bool(getattr(args, "verbose_http_log", 0)))
|
||||
printers = env_loader.list_printers()
|
||||
multi_mode = bool(printers)
|
||||
if not printers:
|
||||
@@ -5583,6 +5796,10 @@ def main():
|
||||
help="Spoolman URL (e.g. http://192.168.x.x:7912); leave empty to disable")
|
||||
parser.add_argument("--spoolman-sync-rate", type=int, default=env_loader.SPOOLMAN_SYNC_RATE,
|
||||
help="Mid-print filament sync interval in seconds (0 = only on print end)")
|
||||
parser.add_argument("--poll-interval", type=int, default=env_loader.POLL_INTERVAL,
|
||||
help="Printer poll interval in seconds")
|
||||
parser.add_argument("--verbose-http-log", type=int, default=env_loader.VERBOSE_HTTP_LOG,
|
||||
help="Log every HTTP request (aiohttp access log)")
|
||||
|
||||
parser.add_argument("--host", default="0.0.0.0",
|
||||
help="Bind address for the bridge server")
|
||||
|
||||
64
start.sh
64
start.sh
@@ -1,10 +1,12 @@
|
||||
#!/usr/bin/env bash
|
||||
# start.sh – KX-Bridge starten (baut Docker-Image automatisch wenn nötig)
|
||||
# start.sh – KX-Bridge starten (zieht das fertige Image aus der Registry)
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
IMAGE_BASE="gitea.it-drui.de/viewit/kx-bridge"
|
||||
|
||||
# .env anlegen falls nicht vorhanden
|
||||
if [[ ! -f .env ]]; then
|
||||
if [[ -f .env.example ]]; then
|
||||
@@ -30,38 +32,38 @@ if ! docker info > /dev/null 2>&1; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Prüfen ob Build nötig ist
|
||||
NEEDS_BUILD=0
|
||||
if ! docker image inspect kx-bridge:latest > /dev/null 2>&1; then
|
||||
echo "[start] Image nicht vorhanden – baue kx-bridge:latest ..."
|
||||
NEEDS_BUILD=1
|
||||
# Release-Kanal abfragen
|
||||
CHANNEL=""
|
||||
if [[ "${1:-}" == "stable" || "${1:-}" == "nightly" ]]; then
|
||||
CHANNEL="$1"
|
||||
else
|
||||
# Image-Erstellungszeit in Unix-Sekunden
|
||||
IMAGE_TS=$(docker inspect --format='{{.Created}}' kx-bridge:latest \
|
||||
| python3 -c "import sys,datetime; s=sys.stdin.read().strip(); \
|
||||
s=s[:26].rstrip('Z').replace('T',' '); \
|
||||
print(int(datetime.datetime.fromisoformat(s).replace(tzinfo=datetime.timezone.utc).timestamp()))" 2>/dev/null || echo 0)
|
||||
|
||||
for f in Dockerfile \
|
||||
bridge/kobrax_moonraker_bridge.py \
|
||||
bridge/kobrax_client.py \
|
||||
bridge/env_loader.py \
|
||||
bridge/requirements.txt \
|
||||
bridge/anycubic_slicer.crt \
|
||||
bridge/anycubic_slicer.key; do
|
||||
if [[ -f "$f" ]]; then
|
||||
FILE_TS=$(python3 -c "import os; print(int(os.path.getmtime('$f')))" 2>/dev/null || echo 0)
|
||||
if [[ $FILE_TS -gt $IMAGE_TS ]]; then
|
||||
echo "[start] '$f' ist neuer als das Image – baue neu ..."
|
||||
NEEDS_BUILD=1
|
||||
break
|
||||
fi
|
||||
fi
|
||||
done
|
||||
echo ""
|
||||
echo " Welchen Release-Kanal möchtest du starten?"
|
||||
echo " 1) stable (empfohlen)"
|
||||
echo " 2) nightly (getestete Vorabversion)"
|
||||
echo -n " Auswahl [1]: "
|
||||
read -r CHOICE
|
||||
case "$CHOICE" in
|
||||
2) CHANNEL="nightly" ;;
|
||||
*) CHANNEL="stable" ;;
|
||||
esac
|
||||
fi
|
||||
|
||||
if [[ $NEEDS_BUILD -eq 1 ]]; then
|
||||
docker build -t kx-bridge:latest .
|
||||
if [[ "$CHANNEL" == "nightly" ]]; then
|
||||
IMAGE_TAG="nightly"
|
||||
else
|
||||
IMAGE_TAG="latest"
|
||||
fi
|
||||
IMAGE="$IMAGE_BASE:$IMAGE_TAG"
|
||||
|
||||
echo "[start] Kanal: $CHANNEL → Image: $IMAGE"
|
||||
echo "[start] Ziehe aktuelles Image ..."
|
||||
docker pull "$IMAGE"
|
||||
|
||||
# docker-compose.yml auf den gewählten Kanal umschreiben (nur die image-Zeile)
|
||||
if [[ -f docker-compose.yml ]]; then
|
||||
sed -i.bak -E "s#^(\s*image:\s*).*#\1$IMAGE#" docker-compose.yml
|
||||
rm -f docker-compose.yml.bak
|
||||
fi
|
||||
|
||||
# Container starten
|
||||
@@ -70,7 +72,7 @@ docker-compose down 2>/dev/null || true
|
||||
docker-compose up -d
|
||||
|
||||
echo ""
|
||||
echo " ✓ KX-Bridge läuft"
|
||||
echo " ✓ KX-Bridge läuft ($CHANNEL)"
|
||||
echo " Web-UI : http://$(hostname -I | awk '{print $1}'):7125"
|
||||
echo " Logs : docker-compose logs -f"
|
||||
echo " Stop : docker-compose down"
|
||||
|
||||
@@ -2,12 +2,13 @@
|
||||
Shared fixtures für KX-Bridge Tests.
|
||||
Startet die Bridge in-process mit einem Mock-MQTT-Client (kein Drucker nötig).
|
||||
"""
|
||||
import sys, types, argparse, pytest, pytest_asyncio
|
||||
import sys, types, argparse, tempfile, pytest, pytest_asyncio
|
||||
from unittest.mock import MagicMock
|
||||
from aiohttp.test_utils import TestClient, TestServer
|
||||
|
||||
# ── Pfad ──────────────────────────────────────────────────────────────────────
|
||||
sys.path.insert(0, str(__import__("pathlib").Path(__file__).parent.parent / "bridge"))
|
||||
# Flat repo layout (no bridge/ subfolder anymore) — point at the repo root.
|
||||
sys.path.insert(0, str(__import__("pathlib").Path(__file__).parent.parent))
|
||||
|
||||
# ── env_loader mocken (keine .env nötig) ──────────────────────────────────────
|
||||
env_mod = types.ModuleType("env_loader")
|
||||
@@ -41,6 +42,7 @@ def make_args(**overrides):
|
||||
device_id = "",
|
||||
host = "127.0.0.1",
|
||||
port = 7125,
|
||||
data_dir = tempfile.mkdtemp(prefix="kxtest-"),
|
||||
)
|
||||
for k, v in overrides.items():
|
||||
setattr(args, k, v)
|
||||
|
||||
@@ -40,14 +40,14 @@ async def test_settings_get_returns_configured_values(client_configured):
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_settings_post_writes_env(client):
|
||||
"""POST /api/settings schreibt Werte in .env-Datei."""
|
||||
async def test_settings_post_writes_config_ini(client):
|
||||
"""POST /api/settings schreibt Werte in config.ini (Migration von .env, v0.9.x)."""
|
||||
c, bridge = client
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
env_path = pathlib.Path(tmpdir) / ".env"
|
||||
env_path.write_text("")
|
||||
bridge._find_env_path = lambda: env_path
|
||||
config_path = pathlib.Path(tmpdir) / "config.ini"
|
||||
bridge._find_config_path = lambda: config_path
|
||||
bridge._restart_bridge = lambda: None # POST triggers a restart — don't kill the test process
|
||||
|
||||
resp = await c.post("/api/settings", json={
|
||||
"printer_ip": "10.0.0.5",
|
||||
@@ -59,24 +59,28 @@ async def test_settings_post_writes_env(client):
|
||||
})
|
||||
assert resp.status == 200
|
||||
|
||||
content = env_path.read_text()
|
||||
assert "PRINTER_IP=10.0.0.5" in content
|
||||
assert "MQTT_USERNAME=userABCD" in content
|
||||
assert "DEVICE_ID=deadbeef01234567" in content
|
||||
content = config_path.read_text()
|
||||
assert "printer_ip = 10.0.0.5" in content
|
||||
assert "username = userABCD" in content
|
||||
assert "device_id = deadbeef01234567" in content
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_settings_post_preserves_existing_keys(client):
|
||||
"""POST darf unbekannte Keys in .env nicht löschen (z.B. GITEA_TOKEN)."""
|
||||
"""POST darf unbekannte Sections/Optionen in config.ini nicht löschen (z.B. Spoolman-Server)."""
|
||||
c, bridge = client
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
env_path = pathlib.Path(tmpdir) / ".env"
|
||||
env_path.write_text("GITEA_TOKEN=mytoken\nPRINTER_IP=old\n")
|
||||
bridge._find_env_path = lambda: env_path
|
||||
config_path = pathlib.Path(tmpdir) / "config.ini"
|
||||
config_path.write_text(
|
||||
"[spoolman]\nserver = http://192.168.1.50:7912\n\n"
|
||||
"[connection]\nprinter_ip = old\n"
|
||||
)
|
||||
bridge._find_config_path = lambda: config_path
|
||||
bridge._restart_bridge = lambda: None
|
||||
|
||||
await c.post("/api/settings", json={"printer_ip": "10.0.0.99"})
|
||||
|
||||
content = env_path.read_text()
|
||||
assert "GITEA_TOKEN=mytoken" in content
|
||||
assert "PRINTER_IP=10.0.0.99" in content
|
||||
content = config_path.read_text()
|
||||
assert "server = http://192.168.1.50:7912" in content
|
||||
assert "printer_ip = 10.0.0.99" in content
|
||||
|
||||
157
tests/test_slot_profile_material_guard.py
Normal file
157
tests/test_slot_profile_material_guard.py
Normal file
@@ -0,0 +1,157 @@
|
||||
"""Stale slot-profile guard: suppress a saved per-slot filament profile when the
|
||||
AMS now reports a *different material family* than the profile was assigned for.
|
||||
|
||||
Real-world bug (KX1): slot 1 held a PETG spool and got the profile
|
||||
"KINGROON PETG Basic". The user swapped in yellow PLA. The AMS updated the
|
||||
colour (live) but the saved profile stuck on PETG, so the panel + the slicer
|
||||
hint kept showing/using PETG. Restarting did not help — the override lives in
|
||||
config.ini.
|
||||
|
||||
Fix = non-destructive suppression (Option A): resolve the effective profile as
|
||||
"the saved override only if its material *family* matches the current AMS
|
||||
material; otherwise none (fall back to the generic default)". The override is
|
||||
never deleted, so putting the original material back reactivates it.
|
||||
|
||||
Comparison must be by *family*, never strict string equality — PLA / PLA+ /
|
||||
PLA SILK / PLA MATTE are the same family and must NOT invalidate each other
|
||||
(regression guard for the earlier over-strict material compare).
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import tempfile
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
# conftest.py (same dir) already put bridge/ on sys.path and mocked env_loader.
|
||||
from kobrax_moonraker_bridge import KobraXBridge
|
||||
|
||||
# Minimal in-memory stand-in for orca_filaments.json (id, name, vendor, type).
|
||||
LIBRARY = [
|
||||
{"vendor": "KINGROON", "name": "KINGROON PETG Basic", "type": "PETG", "id": "PB01"},
|
||||
{"vendor": "KINGROON", "name": "KINGROON PLA Basic", "type": "PLA", "id": "PL01"},
|
||||
{"vendor": "XTZL3D", "name": "XTZL3D Speedy Silk PLA+", "type": "PLA", "id": "PS01"},
|
||||
]
|
||||
|
||||
PETG_PROFILE = {"vendor": "KINGROON", "name": "KINGROON PETG Basic", "id": "PB01"}
|
||||
SILK_PROFILE = {"vendor": "XTZL3D", "name": "XTZL3D Speedy Silk PLA+", "id": "PS01"}
|
||||
UNKNOWN_PROFILE = {"vendor": "Foo", "name": "Foo Bar Filament", "id": "X99"}
|
||||
|
||||
|
||||
def _bridge():
|
||||
c = MagicMock()
|
||||
c.callbacks = {}
|
||||
c.connected = False
|
||||
args = argparse.Namespace(
|
||||
printer_ip="", mqtt_port=9883, username="", password="",
|
||||
mode_id="20030", device_id="", host="127.0.0.1", port=7125,
|
||||
data_dir=tempfile.mkdtemp(prefix="kxguard-"),
|
||||
)
|
||||
b = KobraXBridge(c, args=args)
|
||||
b._orca_filaments_cache = LIBRARY # _load_orca_filaments() returns this as-is
|
||||
return b
|
||||
|
||||
|
||||
# ── _material_family ──────────────────────────────────────────────────────────
|
||||
|
||||
def test_material_family_collapses_pla_variants():
|
||||
fam = KobraXBridge._material_family
|
||||
assert fam("PLA") == "PLA"
|
||||
assert fam("PLA+") == "PLA"
|
||||
assert fam("PLA SILK") == "PLA"
|
||||
assert fam("PLA MATTE") == "PLA"
|
||||
assert fam("Silk PLA") == "PLA" # alias-normalised before family reduction
|
||||
|
||||
|
||||
def test_material_family_collapses_petg_variants():
|
||||
fam = KobraXBridge._material_family
|
||||
assert fam("PETG") == "PETG"
|
||||
assert fam("PETG+") == "PETG"
|
||||
|
||||
|
||||
def test_material_family_distinguishes_pla_from_petg():
|
||||
fam = KobraXBridge._material_family
|
||||
assert fam("PLA") != fam("PETG")
|
||||
assert fam("PLA SILK") != fam("PETG")
|
||||
|
||||
|
||||
def test_material_family_empty_for_empty_input():
|
||||
assert KobraXBridge._material_family("") == ""
|
||||
assert KobraXBridge._material_family(None) == ""
|
||||
|
||||
|
||||
# ── _effective_slot_profile ───────────────────────────────────────────────────
|
||||
|
||||
def test_suppressed_when_family_changes_petg_profile_pla_loaded():
|
||||
"""The exact KX1 bug: PETG profile, AMS now reports PLA → suppress."""
|
||||
b = _bridge()
|
||||
b._filament_profiles = {0: dict(PETG_PROFILE)}
|
||||
assert b._effective_slot_profile(0, "PLA") == {}
|
||||
|
||||
|
||||
def test_kept_when_family_matches_petg_profile_petg_loaded():
|
||||
b = _bridge()
|
||||
b._filament_profiles = {0: dict(PETG_PROFILE)}
|
||||
assert b._effective_slot_profile(0, "PETG") == PETG_PROFILE
|
||||
|
||||
|
||||
def test_kept_for_pla_variant_no_false_positive():
|
||||
"""PLA+ profile with a PLA SILK spool loaded is the same family → keep."""
|
||||
b = _bridge()
|
||||
b._filament_profiles = {1: dict(SILK_PROFILE)}
|
||||
assert b._effective_slot_profile(1, "PLA SILK") == SILK_PROFILE
|
||||
|
||||
|
||||
def test_kept_when_profile_material_unknown_failsafe():
|
||||
"""If the profile is not in the library we cannot know its family → never
|
||||
suppress on uncertainty (fail-safe keeps the user's choice)."""
|
||||
b = _bridge()
|
||||
b._filament_profiles = {2: dict(UNKNOWN_PROFILE)}
|
||||
assert b._effective_slot_profile(2, "PLA") == UNKNOWN_PROFILE
|
||||
|
||||
|
||||
def test_empty_when_no_override():
|
||||
b = _bridge()
|
||||
b._filament_profiles = {}
|
||||
assert b._effective_slot_profile(3, "PLA") == {}
|
||||
|
||||
|
||||
# ── Integration: display endpoint (the visible panel) ─────────────────────────
|
||||
|
||||
async def test_display_endpoint_suppresses_stale_petg_when_pla_loaded():
|
||||
"""/kx/filament/slots must not show the stale PETG identity once PLA loads."""
|
||||
b = _bridge()
|
||||
b._ams_slots = [{"global_index": 0, "status": 5, "color": [255, 236, 61], "type": "PLA"}]
|
||||
b._filament_profiles = {0: dict(PETG_PROFILE)}
|
||||
row = json.loads((await b.handle_kx_filament_slots(MagicMock())).body)["result"][0]
|
||||
assert row["material"] == "PLA" # AMS truth, always
|
||||
assert row["filament_name"] == "" # stale PETG identity gone
|
||||
assert row["filament_vendor"] == ""
|
||||
|
||||
|
||||
async def test_display_endpoint_keeps_profile_when_family_matches():
|
||||
b = _bridge()
|
||||
b._ams_slots = [{"global_index": 0, "status": 5, "color": [10, 20, 30], "type": "PETG"}]
|
||||
b._filament_profiles = {0: dict(PETG_PROFILE)}
|
||||
row = json.loads((await b.handle_kx_filament_slots(MagicMock())).body)["result"][0]
|
||||
assert row["filament_name"] == "KINGROON PETG Basic"
|
||||
assert row["filament_vendor"] == "KINGROON"
|
||||
|
||||
|
||||
# ── Integration: print path (lane_data sent to OrcaSlicer) ────────────────────
|
||||
|
||||
async def test_lane_data_does_not_leak_stale_petg_identity():
|
||||
b = _bridge()
|
||||
b._ams_slots = [{"status": 5, "color": [255, 236, 61], "type": "PLA"}]
|
||||
b._filament_profiles = {0: dict(PETG_PROFILE)}
|
||||
tray = b._build_lane_data()["ams"][0]["tray"][0]
|
||||
assert tray["tray_type"] == "PLA"
|
||||
assert "PETG" not in tray["name"].upper()
|
||||
assert tray["vendor_name"] != "KINGROON"
|
||||
|
||||
|
||||
async def test_lane_data_keeps_profile_when_family_matches():
|
||||
b = _bridge()
|
||||
b._ams_slots = [{"status": 5, "color": [10, 20, 30], "type": "PETG"}]
|
||||
b._filament_profiles = {0: dict(PETG_PROFILE)}
|
||||
tray = b._build_lane_data()["ams"][0]["tray"][0]
|
||||
assert tray["name"] == "KINGROON PETG Basic"
|
||||
assert tray["vendor_name"] == "KINGROON"
|
||||
@@ -45,7 +45,7 @@ var ACE_DRY_PRESETS={
|
||||
};
|
||||
|
||||
// Spoolman state
|
||||
var _spoolmanStatus={configured:false,server:'',sync_rate:0,slot_spools:{}};
|
||||
var _spoolmanStatus={configured:false,reachable:false,server:'',sync_rate:0,slot_spools:{}};
|
||||
var _spoolmanSpools=[];
|
||||
var _slotSpoolMap={}; // {String(global_index): spoolman_spool_id} — last committed assignment
|
||||
|
||||
@@ -67,10 +67,12 @@ function _updateSpoolmanStatusDot(){
|
||||
var dot=document.getElementById('spoolman-status-dot');
|
||||
var lbl=document.getElementById('spoolman-status-lbl');
|
||||
if(!dot||!lbl)return;
|
||||
if(_spoolmanStatus.configured){
|
||||
if(!_spoolmanStatus.configured){
|
||||
dot.style.color='var(--txt2)';lbl.textContent='nicht konfiguriert';
|
||||
} else if(_spoolmanStatus.reachable){
|
||||
dot.style.color='var(--ok)';lbl.textContent=_spoolmanStatus.server||'verbunden';
|
||||
} else {
|
||||
dot.style.color='var(--txt2)';lbl.textContent='nicht konfiguriert';
|
||||
dot.style.color='var(--err)';lbl.textContent=(_spoolmanStatus.server||'')+' (nicht erreichbar)';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -406,6 +408,16 @@ function applyLang(){
|
||||
setText('lbl-set-lang',T.settings_cat_language||'Sprache');
|
||||
setText('lbl-set-theme',T.settings_cat_theme||'Hell / Dunkel umschalten');
|
||||
setText('lbl-poll-interval',T.settings_poll||'Poll-Intervall (Sekunden)');
|
||||
setText('lbl-verbose-http-log',T.settings_verbose_http_log||'Log every HTTP request (verbose)');
|
||||
var dashLbl=document.getElementById('dash-lbl-edit');
|
||||
if(dashLbl)dashLbl.textContent=_dashEditing?(T.dash_done||'Done'):(T.dash_edit||'Customize dashboard');
|
||||
setText('dash-lbl-reset',T.dash_reset||'Reset');
|
||||
setText('dash-lbl-save-preset',T.dash_save_preset||'Save as preset');
|
||||
var dashPresetStd=document.getElementById('dash-preset-standard');
|
||||
if(dashPresetStd)dashPresetStd.textContent=T.dash_preset_standard||'Standard';
|
||||
var dashPresetWide=document.getElementById('dash-preset-wide89');
|
||||
if(dashPresetWide)dashPresetWide.textContent=T.dash_preset_wide89||'Wide desktop';
|
||||
if(document.getElementById('dash-hidden-bar'))_dashRenderHiddenBar();
|
||||
setText('lbl-filament-mapping',T.settings_filament_mapping||'Filament-Profil-Mapping (pro Slot)');
|
||||
setText('lbl-filament-mapping-save',T.settings_filament_mapping_save||'Mapping speichern');
|
||||
setText('lbl-visible-vendors',T.settings_visible_vendors||'Sichtbare Hersteller (Profil-Dropdown)');
|
||||
@@ -734,6 +746,17 @@ function applyState(){
|
||||
// connection error banner – nur wenn überhaupt ein Drucker konfiguriert ist
|
||||
var banner=document.getElementById('conn-error-banner');
|
||||
if(banner){if(s.connection_error&&_printers.length>0){banner.textContent='⚠ '+tr('lbl_conn_error')+' '+s.connection_error;banner.style.display='block';}else{banner.style.display='none';}}
|
||||
var pauseBanner=document.getElementById('pause-msg-banner');
|
||||
if(pauseBanner){
|
||||
if(s.pause_msg && s.print_state==='paused'){
|
||||
var codePart = (s.error_code==0) ? ' ' :
|
||||
' [<a href="https://wiki.anycubic.com/en/error-codes/'+s.error_code+'-code" target="_blank">'+s.error_code+'</a>] ';
|
||||
pauseBanner.innerHTML='⏸ '+tr('lbl_pause_reason')+codePart+s.pause_msg;
|
||||
pauseBanner.style.display='block';
|
||||
}else{
|
||||
pauseBanner.style.display='none';
|
||||
}
|
||||
}
|
||||
var bannerVisible=false;
|
||||
var frb=document.getElementById('file-ready-banner');
|
||||
if(frb){
|
||||
@@ -1097,6 +1120,7 @@ function openSettings(){
|
||||
var sec=d.poll_interval||Math.round((parseInt(localStorage.getItem('pollInterval')||'2000'))/1000)||3;
|
||||
pi.value=sec;
|
||||
}
|
||||
var vhl=document.getElementById('s-verbose-http-log');if(vhl)vhl.checked=!!d.verbose_http_log;
|
||||
renderFilamentMapping(d.filament_profiles||{});
|
||||
renderSpoolmanSlotCard();
|
||||
// Spoolman
|
||||
@@ -1859,6 +1883,7 @@ function saveSettings(){
|
||||
print_start_dialog: parseInt((document.getElementById('s-file-ready-mode')||{}).value||'1',10),
|
||||
web_upload_warning:webUploadWarning,
|
||||
poll_interval: Math.min(60,Math.max(1,parseInt((document.getElementById('s-poll-interval')||{}).value,10)||3)),
|
||||
verbose_http_log: (document.getElementById('s-verbose-http-log')||{}).checked?1:0,
|
||||
spoolman_server: (document.getElementById('s-spoolman-url')||{}).value||'',
|
||||
spoolman_sync_rate: Math.max(0,parseInt((document.getElementById('s-spoolman-sync-rate')||{}).value||'30',10)),
|
||||
}).then(function(){
|
||||
@@ -1965,8 +1990,314 @@ var pollTimer;
|
||||
});
|
||||
}).catch(function(){});
|
||||
poll();pollTimer=setInterval(poll,ms);
|
||||
setInterval(_loadSpoolmanStatus,30000);
|
||||
// initDashGrid() is called at the very end of the file, after all the
|
||||
// DASH_* var declarations below have executed (they are hoisted but not yet
|
||||
// assigned at this point in the IIFE).
|
||||
})();
|
||||
|
||||
// ── Dashboard Free Grid (Issue #89) ────────────────────────────────────────
|
||||
// User-customizable dashboard powered by GridStack (v10, docs: gridstack.js).
|
||||
// Cards move + resize on a 12-column snap grid; layout persisted per browser
|
||||
// in localStorage. Built on documented APIs only:
|
||||
// - float:false (default) -> compact packing, no holes between cards
|
||||
// - staticGrid:true -> locked by default, setStatic(false) in edit mode
|
||||
// - grid.save(false)/load(layout,false) -> serialize/restore by gs-id
|
||||
// - draggable.cancel -> inputs/buttons/img excluded from drag start
|
||||
// - columnOpts.breakpoints -> 1-column below 700px GRID width (sidebar-aware)
|
||||
var DASH_STORAGE_KEY='dashLayout';
|
||||
var DASH_LAYOUT_VERSION=3; // v1/v2 = older editors; discard those states
|
||||
var DASH_CARD_KEYS=['camera','progress','temps','motion','speed','fan','ams'];
|
||||
// Layout arrays in GridStack save()/load() format. cellHeight=60px.
|
||||
var DASH_DEFAULT_LAYOUT=[
|
||||
{id:'camera', x:0,y:0, w:12,h:7},
|
||||
{id:'progress',x:0,y:7, w:12,h:6},
|
||||
{id:'temps', x:0,y:13,w:6, h:7},
|
||||
{id:'motion', x:6,y:13,w:6, h:7},
|
||||
{id:'speed', x:0,y:20,w:6, h:3},
|
||||
{id:'fan', x:6,y:20,w:6, h:3},
|
||||
{id:'ams', x:0,y:23,w:12,h:4},
|
||||
];
|
||||
// "Wide desktop" preset (Blaim, Issue #89): big camera left, settings center,
|
||||
// motion right.
|
||||
var DASH_PRESET_WIDE=[
|
||||
{id:'progress',x:0, y:0, w:12,h:4},
|
||||
{id:'camera', x:0, y:4, w:6, h:11},
|
||||
{id:'temps', x:6, y:4, w:3, h:7},
|
||||
{id:'speed', x:6, y:11,w:3, h:2},
|
||||
{id:'fan', x:6, y:13,w:3, h:2},
|
||||
{id:'motion', x:9, y:4, w:3, h:11},
|
||||
{id:'ams', x:0, y:15,w:12,h:4},
|
||||
];
|
||||
var _dashGrid=null;
|
||||
var _dashEditing=false;
|
||||
var _dashHidden=[]; // [{id,x,y,w,h}] cards currently hidden (position kept for re-show)
|
||||
|
||||
// GridStack requires .grid-stack-item > .grid-stack-item-content around each
|
||||
// card — wrap the existing .card elements in place (IDs/event handlers survive,
|
||||
// appendChild moves nodes without recreating them).
|
||||
function _dashWrapCards(){
|
||||
var grid=document.getElementById('dash-grid');
|
||||
if(!grid)return;
|
||||
DASH_CARD_KEYS.forEach(function(key){
|
||||
var card=grid.querySelector('[data-card="'+key+'"]');
|
||||
if(!card||card.parentNode.classList.contains('grid-stack-item-content'))return;
|
||||
var item=document.createElement('div');
|
||||
item.className='grid-stack-item';
|
||||
item.setAttribute('gs-id',key);
|
||||
var content=document.createElement('div');
|
||||
content.className='grid-stack-item-content';
|
||||
grid.insertBefore(item,card);
|
||||
item.appendChild(content);
|
||||
content.appendChild(card);
|
||||
});
|
||||
}
|
||||
|
||||
function _dashItem(key){
|
||||
return document.querySelector('#dash-grid .grid-stack-item[gs-id="'+key+'"]');
|
||||
}
|
||||
|
||||
function _dashLoadState(){
|
||||
try{
|
||||
var raw=localStorage.getItem(DASH_STORAGE_KEY);
|
||||
if(!raw)return null;
|
||||
var s=JSON.parse(raw);
|
||||
if(!s||s.version!==DASH_LAYOUT_VERSION||!Array.isArray(s.layout))return null;
|
||||
return s;
|
||||
}catch(e){return null;}
|
||||
}
|
||||
function _dashSaveState(){
|
||||
if(!_dashGrid)return;
|
||||
localStorage.setItem(DASH_STORAGE_KEY,JSON.stringify({
|
||||
version:DASH_LAYOUT_VERSION,
|
||||
layout:_dashGrid.save(false), // [{id,x,y,w,h},…] — visible widgets only
|
||||
hidden:_dashHidden.slice(),
|
||||
}));
|
||||
}
|
||||
|
||||
function initDashGrid(){
|
||||
var el=document.getElementById('dash-grid');
|
||||
if(!el||typeof GridStack==='undefined')return;
|
||||
_dashWrapCards();
|
||||
_dashGrid=GridStack.init({
|
||||
cellHeight:60,
|
||||
margin:8,
|
||||
staticGrid:true, // locked by default; setStatic(false) enables drag+resize
|
||||
columnOpts:{breakpoints:[{w:700,c:1}]}, // grid-width based (sidebar-aware)
|
||||
draggable:{cancel:'input,textarea,button,select,option,img,.slider'},
|
||||
},el);
|
||||
var state=_dashLoadState();
|
||||
if(state){
|
||||
_dashHidden=Array.isArray(state.hidden)?state.hidden:[];
|
||||
_dashGrid.load(state.layout,false); // update matching ids, no add/remove
|
||||
_dashHidden.forEach(function(n){
|
||||
var item=_dashItem(n.id);
|
||||
if(item){_dashGrid.removeWidget(item,false);item.style.display='none';}
|
||||
});
|
||||
}else{
|
||||
_dashGrid.load(DASH_DEFAULT_LAYOUT,false);
|
||||
}
|
||||
_dashGrid.on('change',function(){if(_dashEditing)_dashSaveState();});
|
||||
_dashRefreshPresetDropdown();
|
||||
}
|
||||
|
||||
function toggleDashEdit(){
|
||||
if(!_dashGrid)return;
|
||||
_dashEditing=!_dashEditing;
|
||||
_dashGrid.setStatic(!_dashEditing);
|
||||
document.getElementById('dash-grid').classList.toggle('editing',_dashEditing);
|
||||
document.getElementById('dash-lbl-edit').textContent=_dashEditing?(T.dash_done||'Done'):(T.dash_edit||'Customize dashboard');
|
||||
document.getElementById('dash-reset-btn').style.display=_dashEditing?'':'none';
|
||||
document.getElementById('dash-preset').style.display=_dashEditing?'':'none';
|
||||
document.getElementById('dash-preset-save-btn').style.display=_dashEditing?'':'none';
|
||||
_dashUpdatePresetDeleteBtn();
|
||||
if(_dashEditing)_dashInjectControls(); else _dashRemoveControls();
|
||||
_dashRenderHiddenBar();
|
||||
if(!_dashEditing)_dashSaveState();
|
||||
}
|
||||
|
||||
// ── Custom presets (user-saved layouts) ────────────────────────────────────
|
||||
var DASH_CUSTOM_PRESETS_KEY='dashCustomPresets';
|
||||
var DASH_BUILTIN_PRESET_IDS=['standard','wide89'];
|
||||
|
||||
function _dashLoadCustomPresets(){
|
||||
try{
|
||||
var raw=localStorage.getItem(DASH_CUSTOM_PRESETS_KEY);
|
||||
var obj=raw?JSON.parse(raw):{};
|
||||
return (obj&&typeof obj==='object')?obj:{};
|
||||
}catch(e){return {};}
|
||||
}
|
||||
function _dashSaveCustomPresets(presets){
|
||||
localStorage.setItem(DASH_CUSTOM_PRESETS_KEY,JSON.stringify(presets));
|
||||
}
|
||||
|
||||
function _dashRefreshPresetDropdown(){
|
||||
var sel=document.getElementById('dash-preset');
|
||||
if(!sel)return;
|
||||
var current=sel.value;
|
||||
var customs=_dashLoadCustomPresets();
|
||||
// Remove previously-rendered custom <option>s, keep the two built-ins.
|
||||
Array.prototype.slice.call(sel.querySelectorAll('option')).forEach(function(o){
|
||||
if(DASH_BUILTIN_PRESET_IDS.indexOf(o.value)===-1)sel.removeChild(o);
|
||||
});
|
||||
Object.keys(customs).sort().forEach(function(name){
|
||||
var opt=document.createElement('option');
|
||||
opt.value='custom:'+name;
|
||||
opt.textContent=name;
|
||||
sel.appendChild(opt);
|
||||
});
|
||||
if(Array.prototype.some.call(sel.options,function(o){return o.value===current;}))sel.value=current;
|
||||
_dashUpdatePresetDeleteBtn();
|
||||
}
|
||||
|
||||
function _dashUpdatePresetDeleteBtn(){
|
||||
var sel=document.getElementById('dash-preset');
|
||||
var delBtn=document.getElementById('dash-preset-delete-btn');
|
||||
if(!sel||!delBtn)return;
|
||||
var isCustom=sel.value.indexOf('custom:')===0;
|
||||
delBtn.style.display=(_dashEditing&&isCustom)?'':'none';
|
||||
}
|
||||
|
||||
function saveCurrentAsDashPreset(){
|
||||
if(!_dashGrid)return;
|
||||
var name=(prompt(T.dash_preset_name_prompt||'Preset name:')||'').trim();
|
||||
if(!name)return;
|
||||
var customs=_dashLoadCustomPresets();
|
||||
customs[name]=_dashGrid.save(false);
|
||||
_dashSaveCustomPresets(customs);
|
||||
_dashRefreshPresetDropdown();
|
||||
var sel=document.getElementById('dash-preset');
|
||||
if(sel)sel.value='custom:'+name;
|
||||
_dashUpdatePresetDeleteBtn();
|
||||
}
|
||||
|
||||
function deleteCurrentDashPreset(){
|
||||
var sel=document.getElementById('dash-preset');
|
||||
if(!sel||sel.value.indexOf('custom:')!==0)return;
|
||||
var name=sel.value.slice(7);
|
||||
if(!confirm((T.dash_preset_delete_confirm||'Delete preset "{name}"?').replace('{name}',name)))return;
|
||||
var customs=_dashLoadCustomPresets();
|
||||
delete customs[name];
|
||||
_dashSaveCustomPresets(customs);
|
||||
sel.value='standard';
|
||||
_dashRefreshPresetDropdown();
|
||||
resetDashLayout();
|
||||
}
|
||||
|
||||
function _dashInjectControls(){
|
||||
DASH_CARD_KEYS.forEach(function(key){
|
||||
if(_dashHidden.some(function(n){return n.id===key;}))return;
|
||||
var content=document.querySelector('#dash-grid .grid-stack-item[gs-id="'+key+'"] .grid-stack-item-content');
|
||||
if(!content||content.querySelector('.dash-card-controls'))return;
|
||||
var ctrl=document.createElement('div');
|
||||
ctrl.className='dash-card-controls';
|
||||
var hideBtn=document.createElement('button');
|
||||
hideBtn.className='dash-card-ctrl-btn';
|
||||
hideBtn.title=T.dash_hide||'Hide';
|
||||
hideBtn.textContent='👁';
|
||||
hideBtn.addEventListener('click',function(e){e.stopPropagation();dashHideCard(key);});
|
||||
ctrl.appendChild(hideBtn);
|
||||
content.appendChild(ctrl);
|
||||
});
|
||||
}
|
||||
function _dashRemoveControls(){
|
||||
var nodes=document.querySelectorAll('#dash-grid .dash-card-controls');
|
||||
Array.prototype.forEach.call(nodes,function(n){n.parentNode&&n.parentNode.removeChild(n);});
|
||||
}
|
||||
|
||||
function _dashCardLabel(key){
|
||||
var card=document.querySelector('[data-card="'+key+'"]');
|
||||
if(!card)return key;
|
||||
var t=card.querySelector('.card-title');
|
||||
return t?t.textContent.trim():key;
|
||||
}
|
||||
|
||||
function _dashRenderHiddenBar(){
|
||||
var bar=document.getElementById('dash-hidden-bar');
|
||||
if(!bar)return;
|
||||
bar.innerHTML='';
|
||||
if(!_dashEditing||!_dashHidden.length){bar.classList.remove('show');return;}
|
||||
bar.classList.add('show');
|
||||
var label=document.createElement('span');
|
||||
label.textContent=(T.dash_hidden_cards||'Hidden cards')+': ';
|
||||
bar.appendChild(label);
|
||||
_dashHidden.forEach(function(n){
|
||||
var chip=document.createElement('span');
|
||||
chip.className='dash-hidden-chip';
|
||||
chip.appendChild(document.createTextNode(_dashCardLabel(n.id)+' '));
|
||||
var btn=document.createElement('button');
|
||||
btn.textContent='👁';
|
||||
btn.title=T.dash_show||'Show';
|
||||
btn.addEventListener('click',function(){dashShowCard(n.id);});
|
||||
chip.appendChild(btn);
|
||||
bar.appendChild(chip);
|
||||
});
|
||||
}
|
||||
|
||||
function dashHideCard(key){
|
||||
if(_dashHidden.some(function(n){return n.id===key;}))return;
|
||||
var item=_dashItem(key);
|
||||
if(!item||!_dashGrid)return;
|
||||
// Remember position/size so re-show can restore it.
|
||||
var n=item.gridstackNode||{};
|
||||
_dashHidden.push({id:key,x:n.x,y:n.y,w:n.w,h:n.h});
|
||||
_dashGrid.removeWidget(item,false); // keep DOM element, drop the widget
|
||||
item.style.display='none';
|
||||
_dashSaveState();
|
||||
_dashRenderHiddenBar();
|
||||
}
|
||||
function dashShowCard(key){
|
||||
var entry=null;
|
||||
_dashHidden=_dashHidden.filter(function(n){if(n.id===key){entry=n;return false;}return true;});
|
||||
var item=_dashItem(key);
|
||||
if(item&&_dashGrid){
|
||||
item.style.display='';
|
||||
_dashGrid.makeWidget(item);
|
||||
if(entry&&entry.w)_dashGrid.update(item,{x:entry.x,y:entry.y,w:entry.w,h:entry.h});
|
||||
if(_dashEditing)_dashInjectControls();
|
||||
}
|
||||
_dashSaveState();
|
||||
_dashRenderHiddenBar();
|
||||
}
|
||||
|
||||
function _dashShowAllHidden(){
|
||||
_dashHidden.slice().forEach(function(n){dashShowCard(n.id);});
|
||||
_dashHidden=[];
|
||||
}
|
||||
|
||||
function resetDashLayout(){
|
||||
if(!_dashGrid)return;
|
||||
localStorage.removeItem(DASH_STORAGE_KEY);
|
||||
var sel=document.getElementById('dash-preset');if(sel)sel.value='standard';
|
||||
_dashShowAllHidden();
|
||||
_dashGrid.load(DASH_DEFAULT_LAYOUT,false);
|
||||
if(_dashEditing){_dashInjectControls();_dashSaveState();}
|
||||
_dashRenderHiddenBar();
|
||||
}
|
||||
function applyDashPreset(name){
|
||||
if(name==='standard'){resetDashLayout();return;}
|
||||
if(!_dashGrid)return;
|
||||
var layout=null;
|
||||
if(name==='wide89'){
|
||||
layout=DASH_PRESET_WIDE;
|
||||
}else if(name.indexOf('custom:')===0){
|
||||
var customs=_dashLoadCustomPresets();
|
||||
layout=customs[name.slice(7)];
|
||||
}
|
||||
if(!layout)return;
|
||||
var sel=document.getElementById('dash-preset');
|
||||
if(sel)sel.value=name; // keep the dropdown in sync even when called directly
|
||||
_dashShowAllHidden();
|
||||
_dashGrid.load(layout,false);
|
||||
_dashUpdatePresetDeleteBtn();
|
||||
if(_dashEditing)_dashInjectControls();
|
||||
_dashSaveState();
|
||||
_dashRenderHiddenBar();
|
||||
}
|
||||
|
||||
// Init now — all DASH_* declarations above have run, and app.js loads at
|
||||
// </body>, so the DOM is ready.
|
||||
initDashGrid();
|
||||
// ── Print actions ──
|
||||
function printAction(a){
|
||||
post('/printer/print/'+a,{}).then(function(){clog('Druck: '+a,'msg-ok');poll()})
|
||||
|
||||
@@ -6,10 +6,13 @@
|
||||
<title>KX-Bridge</title>
|
||||
<link rel="stylesheet" href="/kx/ui/style.css">
|
||||
<link rel="stylesheet" href="/kx/ui/lib/pickr-nano.min.css">
|
||||
<link rel="stylesheet" href="/kx/ui/lib/gridstack.min.css">
|
||||
<script src="/kx/ui/lib/pickr.min.js"></script>
|
||||
<script src="/kx/ui/lib/gridstack-all.min.js"></script>
|
||||
<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()"
|
||||
@@ -137,9 +140,19 @@
|
||||
<main>
|
||||
<!-- ═══ DASHBOARD ═══ -->
|
||||
<div class="panel active" id="panel-dashboard">
|
||||
<div class="grid">
|
||||
<div id="dash-toolbar" style="display:flex;justify-content:flex-end;align-items:center;gap:8px;margin-bottom:10px">
|
||||
<select id="dash-preset" onchange="applyDashPreset(this.value)" style="display:none;padding:4px 8px;font-size:12px;background:var(--raised);color:var(--txt);border:1px solid var(--border);border-radius:6px">
|
||||
<option value="standard" id="dash-preset-standard">Standard</option>
|
||||
<option value="wide89" id="dash-preset-wide89">Desktop breit</option>
|
||||
</select>
|
||||
<button id="dash-preset-delete-btn" onclick="deleteCurrentDashPreset()" style="display:none;padding:4px 10px;font-size:12px;background:var(--raised);color:var(--err);border:1px solid var(--border);border-radius:6px;cursor:pointer">🗑</button>
|
||||
<button id="dash-preset-save-btn" onclick="saveCurrentAsDashPreset()" style="display:none;padding:4px 10px;font-size:12px;background:var(--raised);color:var(--txt2);border:1px solid var(--border);border-radius:6px;cursor:pointer">💾 <span id="dash-lbl-save-preset">Als Preset speichern</span></button>
|
||||
<button id="dash-reset-btn" onclick="resetDashLayout()" style="display:none;padding:4px 10px;font-size:12px;background:var(--raised);color:var(--txt2);border:1px solid var(--border);border-radius:6px;cursor:pointer">↺ <span id="dash-lbl-reset">Zurücksetzen</span></button>
|
||||
<button id="dash-edit-btn" onclick="toggleDashEdit()" style="padding:4px 10px;font-size:12px;background:var(--raised);color:var(--txt2);border:1px solid var(--border);border-radius:6px;cursor:pointer">🖉 <span id="dash-lbl-edit">Dashboard anpassen</span></button>
|
||||
</div>
|
||||
<div class="grid-stack" id="dash-grid">
|
||||
<!-- Kamera -->
|
||||
<div class="card" style="grid-column:1/-1">
|
||||
<div class="card" id="card-camera" data-card="camera" gs-w="12" gs-h="7">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:10px">
|
||||
<div class="card-title" style="margin-bottom:0"><span>📷</span> <span id="d-card-cam">Kamera</span></div>
|
||||
<div style="display:flex;align-items:center;gap:10px">
|
||||
@@ -154,7 +167,7 @@
|
||||
<div class="cam-wrap" id="cam-wrap">
|
||||
<div class="cam-placeholder" id="cam-placeholder"><span id="cam-placeholder-txt">📷 Kamera nicht gestartet</span></div>
|
||||
<div class="cam-spinner" id="cam-spinner"></div>
|
||||
<img id="cam-img" style="display:none;width:100%;height:auto" alt="Kamera">
|
||||
<img id="cam-img" draggable="false" style="display:none;width:100%;height:auto" alt="Kamera">
|
||||
<div class="cam-overlay" id="cam-overlay" style="display:none">
|
||||
<div style="font-size:12px;color:#fff" id="cam-fname"></div>
|
||||
</div>
|
||||
@@ -164,7 +177,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Fortschritt -->
|
||||
<div class="card" style="grid-column:1/-1">
|
||||
<div class="card" id="card-progress" data-card="progress" gs-w="12" gs-h="6">
|
||||
<div class="card-title"><span>◉</span> <span id="d-card-progress">Fortschritt</span></div>
|
||||
<img id="d-thumbnail" src="" alt="" style="display:none;width:100%;max-height:160px;object-fit:contain;border-radius:8px;background:#111;margin-bottom:10px">
|
||||
<div class="pct-big"><span id="d-pct">0</span><small>%</small></div>
|
||||
@@ -210,7 +223,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Temperatursteuerung + Verlauf -->
|
||||
<div class="card" style="grid-column:1/-1">
|
||||
<div class="card" id="card-temps" data-card="temps" gs-w="6" gs-h="7">
|
||||
<div class="card-title"><span>⊙</span> <span id="d-card-temps">Temperaturen</span></div>
|
||||
<div class="temp-card-inner">
|
||||
<div class="temp-block">
|
||||
@@ -253,7 +266,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Achsensteuerung -->
|
||||
<div class="card">
|
||||
<div class="card" id="card-motion" data-card="motion" gs-w="6" gs-h="7">
|
||||
<div class="card-title"><span>✛</span> <span id="ptitle-motion-xy">Achsensteuerung</span></div>
|
||||
<div style="display:flex;gap:16px;align-items:flex-start;flex-wrap:wrap">
|
||||
<!-- XY -->
|
||||
@@ -301,7 +314,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Print Speed -->
|
||||
<div class="card">
|
||||
<div class="card" id="card-speed" data-card="speed" gs-w="6" gs-h="3">
|
||||
<div class="card-title"><span>🏎</span> <span id="d-card-speed">Druckgeschwindigkeit</span></div>
|
||||
<div style="display:flex;gap:8px;margin-top:4px">
|
||||
<button class="spd-btn" id="d-spd-1" onclick="setSpeed(1)">
|
||||
@@ -323,7 +336,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Lüfter -->
|
||||
<div class="card">
|
||||
<div class="card" id="card-fan" data-card="fan" gs-w="6" gs-h="3">
|
||||
<div class="card-title"><span>🌀</span> <span id="d-card-lightfan">Lüfter</span></div>
|
||||
<div class="slider-row">
|
||||
<input type="range" class="slider" min="0" max="100" value="0" id="d-fan" oninput="document.getElementById('d-fan-val').textContent=this.value" onchange="setFan()">
|
||||
@@ -338,18 +351,19 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="d-ace-dry-wrap" style="display:none">
|
||||
<div id="d-ace-dry-grid" style="display:contents"></div>
|
||||
</div>
|
||||
|
||||
<!-- AMS -->
|
||||
<div class="card" style="grid-column:1/-1" id="d-ams-card">
|
||||
<div class="card" id="d-ams-card" data-card="ams" gs-w="12" gs-h="4">
|
||||
<div class="card-title"><span>◫</span> <span id="d-card-ams">Filament</span></div>
|
||||
<div class="ams-slots" id="ams-slots">
|
||||
<div style="grid-column:1/-1;text-align:center;color:var(--txt2);padding:20px" id="ams-no-data">Keine AMS-Daten empfangen</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- ACE-Trocknung: außerhalb des GridStack-Grids, per Drucker-State eingeblendet -->
|
||||
<div id="d-ace-dry-wrap" class="grid" style="display:none;margin-top:16px">
|
||||
<div id="d-ace-dry-grid" style="display:contents"></div>
|
||||
</div>
|
||||
<div id="dash-hidden-bar"></div>
|
||||
</div>
|
||||
|
||||
<!-- ═══ CONSOLE ═══ -->
|
||||
@@ -554,6 +568,10 @@
|
||||
<input type="number" id="s-poll-interval" min="1" max="60" step="1" placeholder="3" oninput="onPollIntervalInput()">
|
||||
<small style="color:var(--txt2)" id="lbl-poll-hint">Wie oft die Bridge den Drucker-Status abfragt</small>
|
||||
</div>
|
||||
<div class="modal-field" style="flex-direction:row;align-items:center;gap:10px">
|
||||
<input type="checkbox" id="s-verbose-http-log" style="width:auto;margin:0">
|
||||
<label id="lbl-verbose-http-log" style="margin:0;cursor:pointer" for="s-verbose-http-log">Log every HTTP request (verbose)</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
9
web/themes/default/lib/gridstack-all.min.js
vendored
Normal file
9
web/themes/default/lib/gridstack-all.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
web/themes/default/lib/gridstack.min.css
vendored
Normal file
1
web/themes/default/lib/gridstack.min.css
vendored
Normal file
@@ -0,0 +1 @@
|
||||
.grid-stack{position:relative}.grid-stack-rtl{direction:ltr}.grid-stack-rtl>.grid-stack-item{direction:rtl}.grid-stack-placeholder>.placeholder-content{background-color:rgba(0,0,0,.1);margin:0;position:absolute;width:auto;z-index:0!important}.grid-stack>.grid-stack-item{position:absolute;padding:0}.grid-stack>.grid-stack-item>.grid-stack-item-content{margin:0;position:absolute;width:auto;overflow-x:hidden;overflow-y:auto}.grid-stack>.grid-stack-item.size-to-content:not(.size-to-content-max)>.grid-stack-item-content{overflow-y:hidden}.grid-stack-item>.ui-resizable-handle{position:absolute;font-size:.1px;display:block;-ms-touch-action:none;touch-action:none}.grid-stack-item.ui-resizable-autohide>.ui-resizable-handle,.grid-stack-item.ui-resizable-disabled>.ui-resizable-handle{display:none}.grid-stack-item>.ui-resizable-ne,.grid-stack-item>.ui-resizable-nw,.grid-stack-item>.ui-resizable-se,.grid-stack-item>.ui-resizable-sw{background-image:url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" fill="none" stroke="%23666" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" viewBox="0 0 20 20"><path d="m10 3 2 2H8l2-2v14l-2-2h4l-2 2"/></svg>');background-repeat:no-repeat;background-position:center}.grid-stack-item>.ui-resizable-ne{transform:translate(0,10px) rotate(45deg)}.grid-stack-item>.ui-resizable-sw{transform:rotate(45deg)}.grid-stack-item>.ui-resizable-nw{transform:translate(0,10px) rotate(-45deg)}.grid-stack-item>.ui-resizable-se{transform:rotate(-45deg)}.grid-stack-item>.ui-resizable-nw{cursor:nw-resize;width:20px;height:20px;top:0}.grid-stack-item>.ui-resizable-n{cursor:n-resize;height:10px;top:0;left:25px;right:25px}.grid-stack-item>.ui-resizable-ne{cursor:ne-resize;width:20px;height:20px;top:0}.grid-stack-item>.ui-resizable-e{cursor:e-resize;width:10px;top:15px;bottom:15px}.grid-stack-item>.ui-resizable-se{cursor:se-resize;width:20px;height:20px}.grid-stack-item>.ui-resizable-s{cursor:s-resize;height:10px;left:25px;bottom:0;right:25px}.grid-stack-item>.ui-resizable-sw{cursor:sw-resize;width:20px;height:20px}.grid-stack-item>.ui-resizable-w{cursor:w-resize;width:10px;top:15px;bottom:15px}.grid-stack-item.ui-draggable-dragging>.ui-resizable-handle{display:none!important}.grid-stack-item.ui-draggable-dragging{will-change:left,top;cursor:move}.grid-stack-item.ui-resizable-resizing{will-change:width,height}.ui-draggable-dragging,.ui-resizable-resizing{z-index:10000}.ui-draggable-dragging>.grid-stack-item-content,.ui-resizable-resizing>.grid-stack-item-content{box-shadow:1px 4px 6px rgba(0,0,0,.2);opacity:.8}.grid-stack-animate,.grid-stack-animate .grid-stack-item{transition:left .3s,top .3s,height .3s,width .3s}.grid-stack-animate .grid-stack-item.grid-stack-placeholder,.grid-stack-animate .grid-stack-item.ui-draggable-dragging,.grid-stack-animate .grid-stack-item.ui-resizable-resizing{transition:left 0s,top 0s,height 0s,width 0s}.grid-stack>.grid-stack-item[gs-y="0"]{top:0}.grid-stack>.grid-stack-item[gs-x="0"]{left:0}.gs-12>.grid-stack-item{width:8.333%}.gs-12>.grid-stack-item[gs-x="1"]{left:8.333%}.gs-12>.grid-stack-item[gs-w="2"]{width:16.667%}.gs-12>.grid-stack-item[gs-x="2"]{left:16.667%}.gs-12>.grid-stack-item[gs-w="3"]{width:25%}.gs-12>.grid-stack-item[gs-x="3"]{left:25%}.gs-12>.grid-stack-item[gs-w="4"]{width:33.333%}.gs-12>.grid-stack-item[gs-x="4"]{left:33.333%}.gs-12>.grid-stack-item[gs-w="5"]{width:41.667%}.gs-12>.grid-stack-item[gs-x="5"]{left:41.667%}.gs-12>.grid-stack-item[gs-w="6"]{width:50%}.gs-12>.grid-stack-item[gs-x="6"]{left:50%}.gs-12>.grid-stack-item[gs-w="7"]{width:58.333%}.gs-12>.grid-stack-item[gs-x="7"]{left:58.333%}.gs-12>.grid-stack-item[gs-w="8"]{width:66.667%}.gs-12>.grid-stack-item[gs-x="8"]{left:66.667%}.gs-12>.grid-stack-item[gs-w="9"]{width:75%}.gs-12>.grid-stack-item[gs-x="9"]{left:75%}.gs-12>.grid-stack-item[gs-w="10"]{width:83.333%}.gs-12>.grid-stack-item[gs-x="10"]{left:83.333%}.gs-12>.grid-stack-item[gs-w="11"]{width:91.667%}.gs-12>.grid-stack-item[gs-x="11"]{left:91.667%}.gs-12>.grid-stack-item[gs-w="12"]{width:100%}.gs-1>.grid-stack-item{width:100%}
|
||||
@@ -95,18 +95,63 @@ main{flex:1;overflow-y:auto;padding:20px}
|
||||
|
||||
/* ── CARD ── */
|
||||
.card{background:var(--card);border:1px solid var(--border);border-radius:12px;
|
||||
padding:18px;transition:box-shadow .15s,transform .15s}
|
||||
padding:18px;transition:box-shadow .15s,transform .15s;container-type:inline-size}
|
||||
.card:hover{box-shadow:0 4px 20px rgba(0,0,0,.3);transform:translateY(-1px)}
|
||||
.card-title{font-size:11px;text-transform:uppercase;letter-spacing:.1em;color:var(--txt2);
|
||||
margin-bottom:14px;display:flex;align-items:center;gap:8px}
|
||||
.card-title span{font-size:14px}
|
||||
|
||||
/* ── DASHBOARD FREE GRID (GridStack) ── */
|
||||
/* .grid-stack-item-content is already position:absolute + fills the cell
|
||||
(GridStack's own CSS). The card inside just needs to fill that box — it
|
||||
must NOT be position:absolute itself, or it can sit above/intercept
|
||||
GridStack's resize-handle hit area and drag listeners. */
|
||||
/* Doc pattern: the card fills its cell; content scrolls if the user resizes
|
||||
the cell smaller than the content needs. */
|
||||
.grid-stack-item-content>.card{width:100%;height:100%;margin:0;overflow:auto;box-sizing:border-box}
|
||||
/* .card:hover{transform:translateY(-1px)} creates a new containing block right
|
||||
as the user starts dragging, throwing off GridStack's position math. Kill
|
||||
the hover transform for dashboard cards specifically. */
|
||||
.grid-stack-item-content>.card:hover{transform:none}
|
||||
/* Edit mode: dashed outline + grab cursor on each item */
|
||||
#dash-grid.editing .grid-stack-item-content>.card{cursor:grab;
|
||||
outline:1px dashed var(--accent);outline-offset:-1px;user-select:none}
|
||||
/* GridStack placeholder styled to theme */
|
||||
.grid-stack>.grid-stack-placeholder>.placeholder-content{
|
||||
background:rgba(120,150,255,.12);border:1px dashed var(--accent);border-radius:12px}
|
||||
/* Resize handles only visible in edit mode */
|
||||
#dash-grid:not(.editing) .ui-resizable-handle{display:none!important}
|
||||
/* Per-card controls (hide button) */
|
||||
.dash-card-controls{display:none;position:absolute;top:8px;right:8px;gap:4px;z-index:6}
|
||||
#dash-grid.editing .dash-card-controls{display:flex}
|
||||
.dash-card-ctrl-btn{width:24px;height:24px;border-radius:6px;border:1px solid var(--border);
|
||||
background:var(--raised);color:var(--txt2);cursor:pointer;font-size:12px;
|
||||
display:flex;align-items:center;justify-content:center;line-height:1}
|
||||
.dash-card-ctrl-btn:hover{color:var(--accent);border-color:var(--accent)}
|
||||
#dash-hidden-bar{display:none;flex-wrap:wrap;gap:8px;margin-top:12px;padding:10px;
|
||||
border:1px dashed var(--border);border-radius:10px}
|
||||
#dash-hidden-bar.show{display:flex}
|
||||
.dash-hidden-chip{display:flex;align-items:center;gap:6px;padding:4px 10px;font-size:12px;
|
||||
background:var(--raised);border:1px solid var(--border);border-radius:20px;color:var(--txt2)}
|
||||
.dash-hidden-chip button{background:none;border:none;color:var(--accent);cursor:pointer;font-size:12px;padding:0}
|
||||
|
||||
@media(max-width:768px){
|
||||
#dash-toolbar{display:none}
|
||||
}
|
||||
|
||||
/* ── HERO ── */
|
||||
.hero{grid-column:1/-1;display:grid;grid-template-columns:1fr 320px;gap:16px}
|
||||
@media(max-width:900px){.hero{grid-template-columns:1fr}}
|
||||
.cam-wrap{background:#0a0a0e;border-radius:10px;overflow:hidden;
|
||||
min-height:180px;max-height:320px;display:flex;align-items:center;justify-content:center;position:relative}
|
||||
.cam-wrap img,.cam-wrap video{width:100%;max-height:320px;height:auto;display:block;object-fit:contain}
|
||||
.cam-wrap img,.cam-wrap video{width:100%;max-height:320px;height:auto;display:block;object-fit:contain;
|
||||
-webkit-user-drag:none;user-select:none}
|
||||
/* Inside the dashboard grid the camera card can be resized taller than the
|
||||
default 320px cap — flex column: title row keeps its height, cam view
|
||||
flexes to fill whatever cell height the user chose. */
|
||||
.grid-stack-item-content>#card-camera{display:flex;flex-direction:column}
|
||||
.grid-stack-item-content .cam-wrap{flex:1;min-height:0;max-height:none}
|
||||
.grid-stack-item-content .cam-wrap img,.grid-stack-item-content .cam-wrap video{max-height:100%;height:100%}
|
||||
.cam-placeholder{color:var(--txt2);font-size:13px;text-align:center;padding:20px}
|
||||
@keyframes spin{to{transform:rotate(360deg)}}
|
||||
.cam-spinner{width:40px;height:40px;border:3px solid rgba(255,255,255,.15);
|
||||
@@ -161,6 +206,12 @@ main{flex:1;overflow-y:auto;padding:20px}
|
||||
/* ── TEMPS ── */
|
||||
.temp-pair{display:grid;grid-template-columns:1fr 1fr;gap:12px}
|
||||
.temp-card-inner{display:grid;grid-template-columns:1fr 1fr;gap:12px}
|
||||
@container (max-width:340px){
|
||||
.temp-card-inner{grid-template-columns:1fr}
|
||||
.temp-val{font-size:24px}
|
||||
.temp-edit{flex-wrap:wrap}
|
||||
.temp-input{width:100%}
|
||||
}
|
||||
.temp-block{background:var(--raised);border-radius:10px;padding:14px;position:relative}
|
||||
.temp-label{font-size:11px;text-transform:uppercase;letter-spacing:.08em;color:var(--txt2);margin-bottom:6px}
|
||||
.temp-row{display:flex;align-items:baseline;gap:6px}
|
||||
|
||||
@@ -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)",
|
||||
@@ -246,6 +247,19 @@
|
||||
"settings_password": "MQTT-Passwort",
|
||||
"settings_poll": "Poll-Intervall (Sekunden)",
|
||||
"settings_poll_interval_hint": "Wie oft die Bridge den Druckerstatus abfragt",
|
||||
"settings_verbose_http_log": "Jede HTTP-Anfrage loggen (ausführlich)",
|
||||
"dash_edit": "Dashboard anpassen",
|
||||
"dash_done": "Fertig",
|
||||
"dash_reset": "Zurücksetzen",
|
||||
"dash_preset_standard": "Standard",
|
||||
"dash_preset_wide89": "Desktop breit",
|
||||
"dash_hidden_cards": "Ausgeblendete Karten",
|
||||
"dash_show": "Einblenden",
|
||||
"dash_hide": "Ausblenden",
|
||||
"dash_toggle_width": "Breite umschalten",
|
||||
"dash_save_preset": "Als Preset speichern",
|
||||
"dash_preset_name_prompt": "Preset-Name:",
|
||||
"dash_preset_delete_confirm": "Preset \"{name}\" löschen?",
|
||||
"settings_poll_interval_label": "Poll-Intervall (Sekunden)",
|
||||
"settings_print": "Druckeinstellungen",
|
||||
"settings_printer_ip": "Drucker-IP",
|
||||
|
||||
@@ -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)",
|
||||
@@ -246,6 +247,19 @@
|
||||
"settings_password": "MQTT Password",
|
||||
"settings_poll": "Poll Interval (seconds)",
|
||||
"settings_poll_interval_hint": "How often the bridge queries printer status",
|
||||
"settings_verbose_http_log": "Log every HTTP request (verbose)",
|
||||
"dash_edit": "Customize dashboard",
|
||||
"dash_done": "Done",
|
||||
"dash_reset": "Reset",
|
||||
"dash_preset_standard": "Standard",
|
||||
"dash_preset_wide89": "Wide desktop",
|
||||
"dash_hidden_cards": "Hidden cards",
|
||||
"dash_show": "Show",
|
||||
"dash_hide": "Hide",
|
||||
"dash_toggle_width": "Toggle width",
|
||||
"dash_save_preset": "Save as preset",
|
||||
"dash_preset_name_prompt": "Preset name:",
|
||||
"dash_preset_delete_confirm": "Delete preset \"{name}\"?",
|
||||
"settings_poll_interval_label": "Poll Interval (seconds)",
|
||||
"settings_print": "Print Settings",
|
||||
"settings_printer_ip": "Printer IP",
|
||||
|
||||
@@ -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)",
|
||||
@@ -246,6 +247,19 @@
|
||||
"settings_password": "Contraseña MQTT",
|
||||
"settings_poll": "Intervalo de sondeo (segundos)",
|
||||
"settings_poll_interval_hint": "Con qué frecuencia el bridge consulta el estado de la impresora",
|
||||
"settings_verbose_http_log": "Registrar cada solicitud HTTP (detallado)",
|
||||
"dash_edit": "Personalizar panel",
|
||||
"dash_done": "Listo",
|
||||
"dash_reset": "Restablecer",
|
||||
"dash_preset_standard": "Estándar",
|
||||
"dash_preset_wide89": "Escritorio ancho",
|
||||
"dash_hidden_cards": "Tarjetas ocultas",
|
||||
"dash_show": "Mostrar",
|
||||
"dash_hide": "Ocultar",
|
||||
"dash_toggle_width": "Cambiar ancho",
|
||||
"dash_save_preset": "Guardar como preset",
|
||||
"dash_preset_name_prompt": "Nombre del preset:",
|
||||
"dash_preset_delete_confirm": "¿Eliminar el preset \"{name}\"?",
|
||||
"settings_poll_interval_label": "Intervalo de sondeo (segundos)",
|
||||
"settings_print": "Ajustes de impresión",
|
||||
"settings_printer_ip": "IP de impresora",
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -121,6 +121,7 @@
|
||||
"lbl_feed": "进料",
|
||||
"lbl_layers": "层",
|
||||
"lbl_light": "💡 灯光",
|
||||
"lbl_pause_reason": "打印已暂停:",
|
||||
"lbl_remaining": "剩余时间:",
|
||||
"lbl_slicer_time": "切片预估:",
|
||||
"lbl_spoolman_sync_rate": "同步频率(秒,0=打印结束)",
|
||||
@@ -246,6 +247,19 @@
|
||||
"settings_password": "MQTT 密码",
|
||||
"settings_poll": "轮询间隔(秒)",
|
||||
"settings_poll_interval_hint": "Bridge 查询打印机状态的频率",
|
||||
"settings_verbose_http_log": "记录每个 HTTP 请求(详细模式)",
|
||||
"dash_edit": "自定义仪表盘",
|
||||
"dash_done": "完成",
|
||||
"dash_reset": "重置",
|
||||
"dash_preset_standard": "标准",
|
||||
"dash_preset_wide89": "宽屏桌面",
|
||||
"dash_hidden_cards": "隐藏的卡片",
|
||||
"dash_show": "显示",
|
||||
"dash_hide": "隐藏",
|
||||
"dash_toggle_width": "切换宽度",
|
||||
"dash_save_preset": "另存为预设",
|
||||
"dash_preset_name_prompt": "预设名称:",
|
||||
"dash_preset_delete_confirm": "删除预设 \"{name}\"?",
|
||||
"settings_poll_interval_label": "轮询间隔(秒)",
|
||||
"settings_print": "打印设置",
|
||||
"settings_printer_ip": "打印机 IP",
|
||||
|
||||
Reference in New Issue
Block a user