Compare commits

...

3 Commits

Author SHA1 Message Date
Phil Merricks
b39577ad4d feat: 2-column dashboard layout toggle + local timelapse
2-column layout: settings toggle switches dashboard between 1-col
(current behaviour) and 2-col (Camera|Progress, Temps|AMS side by
side, motion cards in 2×2). Preference stored in localStorage only
(per-browser, not printer config). Mobile always stays 1-col via CSS.

Local timelapse: new TimelapseSpool class captures JPEG frames from
the camera stream during a print at a configurable interval (default
4s) and encodes them to MP4 with ffmpeg on print end. Stored in
data/timelapses/, indexed in SQLite. New /kx/timelapses API + browser
tab in the file store panel with download and delete. Also wires the
undocumented printer-native timelapse MQTT field as an experimental
opt-in setting.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 22:20:35 +01:00
Phil Merricks
cfe46b4cad feat: add vibration_compensation and flow_calibration print settings
Expose resonance compensation and flow calibration as configurable
toggles in the settings UI, config.ini [print] section, and CLI args.
Both default to 0 (off). Previously hardcoded to 0 in all three
print-start MQTT payload paths.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 12:21:29 +01:00
Phil Merricks
250e89f18a fix: slot assignment dialog now matches "PLA Silk", "Matte PLA" etc. to PLA slots
_normalizeMaterialKey now scans all space-separated words in a slot label and
returns the first known base material type found. This handles both "modifier
first" ("Matte PLA") and "modifier last" ("PLA Silk", "PLA Matte") patterns,
which arise when users label slots with full product-style names while OrcaSlicer
writes only the base type (PLA, PETG, …) in GCode comments.

Dash-suffix composites ("PLA-CF", "PETG-CF") contain no space and are unchanged,
preserving correct incompatibility with their base types.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 12:05:11 +01:00
9 changed files with 587 additions and 40 deletions

View File

@@ -57,10 +57,15 @@ def _load_config_file(path: pathlib.Path):
"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"),
"CAMERA_ON_PRINT": (CONFIG_SECTION_PRINT, "camera_on_print"),
"WEB_UPLOAD_WARNING": (CONFIG_SECTION_PRINT, "web_upload_warning"),
"DEFAULT_AMS_SLOT": (CONFIG_SECTION_PRINT, "default_ams_slot"),
"AUTO_LEVELING": (CONFIG_SECTION_PRINT, "auto_leveling"),
"VIBRATION_COMPENSATION": (CONFIG_SECTION_PRINT, "vibration_compensation"),
"FLOW_CALIBRATION": (CONFIG_SECTION_PRINT, "flow_calibration"),
"CAMERA_ON_PRINT": (CONFIG_SECTION_PRINT, "camera_on_print"),
"WEB_UPLOAD_WARNING": (CONFIG_SECTION_PRINT, "web_upload_warning"),
"TIMELAPSE_LOCAL": (CONFIG_SECTION_PRINT, "timelapse_local"),
"TIMELAPSE_INTERVAL_SEC": (CONFIG_SECTION_PRINT, "timelapse_interval_sec"),
"TIMELAPSE_PRINTER": (CONFIG_SECTION_PRINT, "timelapse_printer"),
"BRIDGE_PRINTER_NAME": (CONFIG_SECTION_BRIDGE, "printer_name"),
}
for env_key, (section, option) in mapping.items():
@@ -95,10 +100,15 @@ def migrate_env_to_config(env_path: pathlib.Path, config_path: pathlib.Path):
"device_id": env_vals.get("DEVICE_ID", ""),
}
cfg[CONFIG_SECTION_PRINT] = {
"default_ams_slot": env_vals.get("DEFAULT_AMS_SLOT", "auto"),
"auto_leveling": env_vals.get("AUTO_LEVELING", "1"),
"camera_on_print": env_vals.get("CAMERA_ON_PRINT", "0"),
"web_upload_warning": env_vals.get("WEB_UPLOAD_WARNING", "1"),
"default_ams_slot": env_vals.get("DEFAULT_AMS_SLOT", "auto"),
"auto_leveling": env_vals.get("AUTO_LEVELING", "1"),
"vibration_compensation": env_vals.get("VIBRATION_COMPENSATION", "0"),
"flow_calibration": env_vals.get("FLOW_CALIBRATION", "0"),
"camera_on_print": env_vals.get("CAMERA_ON_PRINT", "0"),
"web_upload_warning": env_vals.get("WEB_UPLOAD_WARNING", "1"),
"timelapse_local": env_vals.get("TIMELAPSE_LOCAL", "0"),
"timelapse_interval_sec": env_vals.get("TIMELAPSE_INTERVAL_SEC", "4"),
"timelapse_printer": env_vals.get("TIMELAPSE_PRINTER", "0"),
}
cfg[CONFIG_SECTION_BRIDGE] = {
"poll_interval": "3",
@@ -256,6 +266,11 @@ PASSWORD = get("MQTT_PASSWORD", "")
MODE_ID = get("MODE_ID", "")
DEVICE_ID = get("DEVICE_ID", "")
DEFAULT_AMS_SLOT = get("DEFAULT_AMS_SLOT", "auto")
AUTO_LEVELING = int(get("AUTO_LEVELING","1"))
CAMERA_ON_PRINT = int(get("CAMERA_ON_PRINT","0"))
WEB_UPLOAD_WARNING = int(get("WEB_UPLOAD_WARNING", "1"))
AUTO_LEVELING = int(get("AUTO_LEVELING", "1"))
VIBRATION_COMPENSATION = int(get("VIBRATION_COMPENSATION", "0"))
FLOW_CALIBRATION = int(get("FLOW_CALIBRATION", "0"))
CAMERA_ON_PRINT = int(get("CAMERA_ON_PRINT", "0"))
WEB_UPLOAD_WARNING = int(get("WEB_UPLOAD_WARNING", "1"))
TIMELAPSE_LOCAL = int(get("TIMELAPSE_LOCAL", "0"))
TIMELAPSE_INTERVAL_SEC = int(get("TIMELAPSE_INTERVAL_SEC", "4"))
TIMELAPSE_PRINTER = int(get("TIMELAPSE_PRINTER", "0"))

View File

@@ -407,6 +407,18 @@ class GCodeStore:
filament_assignments TEXT,
abort_reason TEXT
);
CREATE TABLE IF NOT EXISTS timelapses (
id TEXT PRIMARY KEY,
job_id TEXT NOT NULL,
printer_id TEXT NOT NULL,
created_at TEXT NOT NULL,
filename TEXT NOT NULL,
path TEXT NOT NULL,
frame_count INTEGER DEFAULT 0,
fps INTEGER DEFAULT 24,
status TEXT NOT NULL,
duration_sec INTEGER DEFAULT 0
);
""")
# Migration: Spalte gcode_filaments nachrüsten falls DB älter
try:
@@ -570,6 +582,56 @@ class GCodeStore:
).fetchall()
return [dict(r) for r in rows]
def create_timelapse(self, job_id: str, printer_id: str, path: str, fps: int = 24) -> str:
tid = str(uuid.uuid4())
now = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
filename = os.path.basename(path)
with self._lock:
self._conn.execute(
"""INSERT INTO timelapses (id, job_id, printer_id, created_at, filename, path, fps, status, frame_count, duration_sec)
VALUES (?,?,?,?,?,?,?,'recording',0,0)""",
(tid, job_id, printer_id, now, filename, path, fps),
)
self._conn.commit()
return tid
def update_timelapse(self, tid: str, status: str, frame_count: int = 0, duration_sec: int = 0):
with self._lock:
self._conn.execute(
"UPDATE timelapses SET status=?, frame_count=?, duration_sec=? WHERE id=?",
(status, frame_count, duration_sec, tid),
)
self._conn.commit()
def list_timelapses(self) -> list:
with self._lock:
rows = self._conn.execute(
"SELECT * FROM timelapses ORDER BY created_at DESC"
).fetchall()
return [dict(r) for r in rows]
def get_timelapse(self, tid: str) -> "dict | None":
with self._lock:
row = self._conn.execute(
"SELECT * FROM timelapses WHERE id=?", (tid,)
).fetchone()
return dict(row) if row else None
def delete_timelapse(self, tid: str) -> bool:
rec = self.get_timelapse(tid)
if not rec:
return False
path = rec.get("path", "")
if path and os.path.isfile(path):
try:
os.remove(path)
except Exception:
pass
with self._lock:
self._conn.execute("DELETE FROM timelapses WHERE id=?", (tid,))
self._conn.commit()
return True
class CameraCache:
"""Zentraler Kamera-Demuxer.
@@ -732,6 +794,96 @@ class CameraCache:
await asyncio.sleep(2.0)
class TimelapseSpool:
"""Erfasst JPEG-Frames von CameraCache während eines Drucks und kodiert sie
nach Druckende zu einer MP4-Datei mit ffmpeg."""
def __init__(self, camera: CameraCache, timelapse_dir: str):
self._camera = camera
self._dir = timelapse_dir
self._task: "asyncio.Task | None" = None
self._frame_dir: str = ""
self._frame_count: int = 0
self._last_ts: float = 0.0
async def start(self, job_id: str, interval_sec: float = 4.0):
safe_id = job_id.replace("/", "_").replace("\\", "_")
self._frame_dir = os.path.join(self._dir, safe_id, "frames")
os.makedirs(self._frame_dir, exist_ok=True)
self._frame_count = 0
self._last_ts = 0.0
self._task = asyncio.create_task(self._capture_loop(interval_sec))
log.info(f"Timelapse-Aufnahme gestartet: {self._frame_dir} @ {interval_sec}s Intervall")
async def stop(self, output_fps: int = 24) -> "str | None":
if self._task:
self._task.cancel()
try:
await self._task
except asyncio.CancelledError:
pass
self._task = None
if self._frame_count == 0:
log.warning("Timelapse: keine Frames aufgenommen")
return None
job_dir = os.path.dirname(self._frame_dir)
output_path = job_dir.rstrip("/\\") + ".mp4"
ok = await self._encode(output_path, output_fps)
import shutil
try:
shutil.rmtree(job_dir)
except Exception as e:
log.warning(f"Timelapse: Frames-Verzeichnis löschen fehlgeschlagen: {e}")
return output_path if ok else None
async def _capture_loop(self, interval_sec: float):
while True:
try:
ts = self._camera.latest_jpeg_ts
frame = self._camera.latest_jpeg
if ts > self._last_ts and frame:
path = os.path.join(self._frame_dir, f"frame_{self._frame_count:05d}.jpg")
with open(path, "wb") as f:
f.write(frame)
self._frame_count += 1
self._last_ts = ts
except Exception as e:
log.warning(f"Timelapse capture-Fehler: {e}")
await asyncio.sleep(interval_sec)
async def _encode(self, output_path: str, fps: int) -> bool:
try:
ffmpeg = _find_ffmpeg()
pattern = os.path.join(self._frame_dir, "frame_%05d.jpg")
cmd = [
ffmpeg, "-y", "-loglevel", "error",
"-framerate", str(fps),
"-i", pattern,
"-c:v", "libx264", "-pix_fmt", "yuv420p",
"-movflags", "+faststart",
output_path,
]
proc = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
_, stderr = await proc.communicate()
if proc.returncode != 0:
log.error(f"Timelapse encode fehlgeschlagen (rc={proc.returncode}): "
f"{stderr.decode(errors='replace')[:300]}")
return False
log.info(f"Timelapse kodiert: {output_path} ({self._frame_count} Frames @ {fps}fps)")
return True
except Exception as e:
log.error(f"Timelapse encode Ausnahme: {e}")
return False
class KobraXBridge:
def __init__(self, client: KobraXClient, args=None, store=None, printer_id: str = "1", all_bridges=None):
self.client = client
@@ -796,9 +948,15 @@ class KobraXBridge:
self._last_uploaded_file: str = ""
self._store = store if store is not None else GCodeStore(args.data_dir)
self._serve_dir_path: str = self._store._gcode_dir
self._timelapse_dir: str = os.path.join(args.data_dir, "timelapses")
os.makedirs(self._timelapse_dir, exist_ok=True)
self._current_job_id: str = ""
self._camera_autostarted: bool = False
self._timelapse_spool: "TimelapseSpool | None" = None
self._current_timelapse_id: str = ""
self._timelapse_autostarted: bool = False
self.camera_cache: CameraCache = CameraCache()
self._loop: asyncio.AbstractEventLoop = asyncio.get_event_loop()
self._thumbnail_b64: str = ""
self._ace_dry_presets: dict[str, dict] = self._load_ace_dry_presets_config()
@@ -924,6 +1082,18 @@ class KobraXBridge:
elif kobra_state in ("free", "finished", "stoped", "canceled"):
self._camera_autostarted = False
# Timelapse: Druckstart
if kobra_state == "printing" and not self._timelapse_autostarted:
if getattr(self._args, "timelapse_local", 0):
self._timelapse_autostarted = True
asyncio.run_coroutine_threadsafe(self._start_timelapse(), self._loop)
elif kobra_state in ("finished", "stoped", "canceled"):
self._timelapse_autostarted = False
if self._timelapse_spool:
asyncio.run_coroutine_threadsafe(
self._stop_timelapse(success=(kobra_state == "finished")), self._loop
)
# Job-History: Druckstart erkennen
if kobra_state == "printing" and not self._current_job_id:
filename = d.get("filename", self._state.get("filename", ""))
@@ -1010,6 +1180,13 @@ class KobraXBridge:
log.warning(f"Kamera-Autostart fehlgeschlagen: {e}")
elif kobra_state in ("free", "finished", "stoped", "canceled"):
self._camera_autostarted = False
# Timelapse-Autostart auch hier (Guard verhindert Doppel-Start mit _on_print).
if kobra_state == "printing" and not self._timelapse_autostarted:
if getattr(self._args, "timelapse_local", 0):
self._timelapse_autostarted = True
asyncio.run_coroutine_threadsafe(self._start_timelapse(), self._loop)
elif kobra_state in ("free", "finished", "stoped", "canceled"):
self._timelapse_autostarted = False
if project:
if "filename" in project:
self._state["filename"] = project["filename"]
@@ -2197,7 +2374,9 @@ class KobraXBridge:
ams_box_mapping = self._build_auto_ams_box_mapping()
use_ams = len(ams_box_mapping) > 0
auto_leveling = getattr(self._args, "auto_leveling", 1)
auto_leveling = getattr(self._args, "auto_leveling", 1)
vibration_compensation = getattr(self._args, "vibration_compensation", 0)
flow_calibration = getattr(self._args, "flow_calibration", 0)
filename = gcode_file["filename"]
file_path = gcode_file["path"]
@@ -2219,11 +2398,11 @@ class KobraXBridge:
},
"task_settings": {
"auto_leveling": auto_leveling,
"vibration_compensation": 0,
"flow_calibration": 0,
"vibration_compensation": vibration_compensation,
"flow_calibration": flow_calibration,
"dry_mode": 0,
"ai_settings": {"status": 0, "count": 0, "type": 1},
"timelapse": {"status": 0, "count": 0, "type": 64},
"timelapse": {"status": getattr(self._args, "timelapse_printer", 0), "count": 0, "type": 64},
"drying_settings": {"status": 0, "target_temp": 0, "duration": 0, "remain_time": 0},
"model_objects_skip_parts": excluded_objects,
},
@@ -2619,7 +2798,9 @@ class KobraXBridge:
use_ams = len(loaded) > 0
ams_box_mapping = self._build_auto_ams_box_mapping(loaded_slots=loaded)
log.debug(f"AMS-Slots: {len(loaded)} gemappt (genutzte Paints: {used_paint_indices}) → {[i for i, _ in loaded]}")
auto_leveling = getattr(self._args, "auto_leveling", 1)
auto_leveling = getattr(self._args, "auto_leveling", 1)
vibration_compensation = getattr(self._args, "vibration_compensation", 0)
flow_calibration = getattr(self._args, "flow_calibration", 0)
payload = {
"taskid": "-1",
"url": url,
@@ -2635,11 +2816,11 @@ class KobraXBridge:
},
"task_settings": {
"auto_leveling": auto_leveling,
"vibration_compensation": 0,
"flow_calibration": 0,
"vibration_compensation": vibration_compensation,
"flow_calibration": flow_calibration,
"dry_mode": 0,
"ai_settings": {"status": 0, "count": 0, "type": 1},
"timelapse": {"status": 0, "count": 0, "type": 64},
"timelapse": {"status": getattr(self._args, "timelapse_printer", 0), "count": 0, "type": 64},
"drying_settings": {"status": 0, "target_temp": 0, "duration": 0, "remain_time": 0},
"model_objects_skip_parts": [],
},
@@ -2707,7 +2888,9 @@ class KobraXBridge:
ams_box_mapping = self._build_auto_ams_box_mapping()
use_ams = len(ams_box_mapping) > 0
auto_leveling = getattr(self._args, "auto_leveling", 1)
auto_leveling = getattr(self._args, "auto_leveling", 1)
vibration_compensation = getattr(self._args, "vibration_compensation", 0)
flow_calibration = getattr(self._args, "flow_calibration", 0)
url = self._state.get("last_upload_url", "")
filesize = self._state.get("last_upload_size", 0)
md5 = self._state.get("last_upload_md5", "")
@@ -2727,11 +2910,11 @@ class KobraXBridge:
},
"task_settings": {
"auto_leveling": auto_leveling,
"vibration_compensation": 0,
"flow_calibration": 0,
"vibration_compensation": vibration_compensation,
"flow_calibration": flow_calibration,
"dry_mode": 0,
"ai_settings": {"status": 0, "count": 0, "type": 0},
"timelapse": {"status": 0, "count": 0, "type": 0},
"timelapse": {"status": getattr(self._args, "timelapse_printer", 0), "count": 0, "type": 0},
"drying_settings": {"status": 0, "target_temp": 0, "duration": 0, "remain_time": 0},
"model_objects_skip_parts": excluded_objects,
},
@@ -3503,6 +3686,97 @@ class KobraXBridge:
self._ams_loaded_slot = global_loaded
return self._ams_slots
# ─── Timelapse ───────────────────────────────────────────────────────────
async def _start_timelapse(self):
camera_url = self._state.get("camera_url", "")
if not camera_url:
log.info("Timelapse: kein Kamera-URL bekannt — übersprungen")
return
await self.camera_cache.ensure_running()
interval = float(getattr(self._args, "timelapse_interval_sec", 4))
spool = TimelapseSpool(self.camera_cache, self._timelapse_dir)
job_id = self._current_job_id or str(uuid.uuid4())
safe_id = job_id.replace("/", "_").replace("\\", "_")
mp4_path = os.path.join(self._timelapse_dir, safe_id + ".mp4")
tid = self._store.create_timelapse(
job_id=job_id,
printer_id=self._printer_id,
path=mp4_path,
fps=24,
)
self._timelapse_spool = spool
self._current_timelapse_id = tid
await spool.start(job_id, interval_sec=interval)
log.info(f"Timelapse ID={tid} für Job {job_id} gestartet")
async def _stop_timelapse(self, success: bool = True):
spool = self._timelapse_spool
if not spool:
return
self._timelapse_spool = None
tid = self._current_timelapse_id
self._current_timelapse_id = ""
frame_count = spool._frame_count
self._store.update_timelapse(tid, "processing", frame_count=frame_count)
mp4_path = await spool.stop(output_fps=24)
if mp4_path:
interval = float(getattr(self._args, "timelapse_interval_sec", 4))
duration_sec = int(frame_count * interval)
self._store.update_timelapse(tid, "completed", frame_count=frame_count,
duration_sec=duration_sec)
log.info(f"Timelapse {tid} abgeschlossen: {mp4_path}")
else:
self._store.update_timelapse(tid, "failed", frame_count=frame_count)
log.warning(f"Timelapse {tid} fehlgeschlagen")
async def handle_kx_timelapses(self, request):
rows = self._store.list_timelapses()
# Laufende Aufnahme in der DB-Ansicht sichtbar machen
if self._current_timelapse_id:
spool = self._timelapse_spool
for r in rows:
if r["id"] == self._current_timelapse_id and spool:
r["frame_count"] = spool._frame_count
return self._json_cors(rows)
async def handle_kx_timelapse_download(self, request):
tid = request.match_info["tid"]
rec = self._store.get_timelapse(tid)
if not rec or not os.path.isfile(rec.get("path", "")):
return web.Response(status=404)
filename = rec.get("filename") or os.path.basename(rec["path"])
return web.FileResponse(
rec["path"],
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
async def handle_kx_timelapse_thumb(self, request):
tid = request.match_info["tid"]
rec = self._store.get_timelapse(tid)
if not rec:
return web.Response(status=404)
job_id = rec.get("job_id", "")
safe_id = job_id.replace("/", "_").replace("\\", "_")
frame_dir = os.path.join(self._timelapse_dir, safe_id, "frames")
try:
if os.path.isdir(frame_dir):
frames = sorted(f for f in os.listdir(frame_dir) if f.endswith(".jpg"))
if frames:
with open(os.path.join(frame_dir, frames[0]), "rb") as f:
data = f.read()
return web.Response(body=data, content_type="image/jpeg")
except Exception:
pass
return web.Response(status=404)
async def handle_kx_timelapse_delete(self, request):
tid = request.match_info["tid"]
ok = self._store.delete_timelapse(tid)
if not ok:
return self._json_cors({"error": "not found"}, status=404)
return self._json_cors({"status": "deleted"})
# ─── Settings ────────────────────────────────────────────────────────────
def _find_config_path(self) -> pathlib.Path:
@@ -3526,10 +3800,15 @@ class KobraXBridge:
"password": self._args.password,
"mode_id": self._args.mode_id,
"device_id": self._args.device_id,
"default_ams_slot": getattr(self._args, "default_ams_slot", "auto"),
"auto_leveling": getattr(self._args, "auto_leveling", 1),
"camera_on_print": getattr(self._args, "camera_on_print", 0),
"web_upload_warning": getattr(self._args, "web_upload_warning", 1),
"default_ams_slot": getattr(self._args, "default_ams_slot", "auto"),
"auto_leveling": getattr(self._args, "auto_leveling", 1),
"vibration_compensation": getattr(self._args, "vibration_compensation", 0),
"flow_calibration": getattr(self._args, "flow_calibration", 0),
"camera_on_print": getattr(self._args, "camera_on_print", 0),
"web_upload_warning": getattr(self._args, "web_upload_warning", 1),
"timelapse_local": getattr(self._args, "timelapse_local", 0),
"timelapse_interval_sec": getattr(self._args, "timelapse_interval_sec", 4),
"timelapse_printer": getattr(self._args, "timelapse_printer", 0),
"ace_dry_presets": self._ace_dry_presets,
})
@@ -3556,10 +3835,15 @@ class KobraXBridge:
cfg.set("connection", "password", str(data.get("password", self._args.password or "")))
cfg.set("connection", "mode_id", str(data.get("mode_id", self._args.mode_id or "")))
cfg.set("connection", "device_id", str(data.get("device_id", self._args.device_id or "")))
cfg.set("print", "default_ams_slot", str(data.get("default_ams_slot", getattr(self._args, "default_ams_slot", "auto"))))
cfg.set("print", "auto_leveling", str(data.get("auto_leveling", getattr(self._args, "auto_leveling", 1))))
cfg.set("print", "camera_on_print", str(int(bool(data.get("camera_on_print", getattr(self._args, "camera_on_print", 0))))))
cfg.set("print", "web_upload_warning", str(int(bool(data.get("web_upload_warning", getattr(self._args, "web_upload_warning", 1))))))
cfg.set("print", "default_ams_slot", str(data.get("default_ams_slot", getattr(self._args, "default_ams_slot", "auto"))))
cfg.set("print", "auto_leveling", str(data.get("auto_leveling", getattr(self._args, "auto_leveling", 1))))
cfg.set("print", "vibration_compensation", str(int(bool(data.get("vibration_compensation", getattr(self._args, "vibration_compensation", 0))))))
cfg.set("print", "flow_calibration", str(int(bool(data.get("flow_calibration", getattr(self._args, "flow_calibration", 0))))))
cfg.set("print", "camera_on_print", str(int(bool(data.get("camera_on_print", getattr(self._args, "camera_on_print", 0))))))
cfg.set("print", "web_upload_warning", str(int(bool(data.get("web_upload_warning", getattr(self._args, "web_upload_warning", 1))))))
cfg.set("print", "timelapse_local", str(int(bool(data.get("timelapse_local", getattr(self._args, "timelapse_local", 0))))))
cfg.set("print", "timelapse_interval_sec", str(max(1, int(data.get("timelapse_interval_sec", getattr(self._args, "timelapse_interval_sec", 4))))))
cfg.set("print", "timelapse_printer", str(int(bool(data.get("timelapse_printer", getattr(self._args, "timelapse_printer", 0))))))
if not cfg.has_option("bridge", "poll_interval"):
cfg.set("bridge", "poll_interval", "3")
printer_name = str(data.get("printer_name", "")).strip()
@@ -4440,6 +4724,10 @@ def build_app(bridge: KobraXBridge) -> web.Application:
r.add_get("/kx/filament/profiles", bridge.handle_kx_filament_profiles)
r.add_post("/kx/filament/slots/{idx}/profile", bridge.handle_kx_filament_slot_profile)
r.add_get("/kx/history", bridge.handle_kx_history)
r.add_get("/kx/timelapses", bridge.handle_kx_timelapses)
r.add_get("/kx/timelapse/{tid}/download", bridge.handle_kx_timelapse_download)
r.add_get("/kx/timelapse/{tid}/thumb", bridge.handle_kx_timelapse_thumb)
r.add_delete("/kx/timelapse/{tid}", bridge.handle_kx_timelapse_delete)
r.add_get("/kx/ui/{name:.*}", bridge.handle_kx_ui_asset)
r.add_get("/kx/files/{id}/objects", bridge.handle_kx_file_objects)
r.add_post("/kx/skip", bridge.handle_kx_skip)
@@ -4602,9 +4890,14 @@ def main():
parser.add_argument("--mode-id", default=env_loader.MODE_ID)
parser.add_argument("--device-id", default=env_loader.DEVICE_ID)
parser.add_argument("--default-ams-slot",default=env_loader.DEFAULT_AMS_SLOT)
parser.add_argument("--auto-leveling", type=int, default=env_loader.AUTO_LEVELING)
parser.add_argument("--camera-on-print", type=int, default=env_loader.CAMERA_ON_PRINT)
parser.add_argument("--web-upload-warning", type=int, default=env_loader.WEB_UPLOAD_WARNING)
parser.add_argument("--auto-leveling", type=int, default=env_loader.AUTO_LEVELING)
parser.add_argument("--vibration-compensation", type=int, default=env_loader.VIBRATION_COMPENSATION)
parser.add_argument("--flow-calibration", type=int, default=env_loader.FLOW_CALIBRATION)
parser.add_argument("--camera-on-print", type=int, default=env_loader.CAMERA_ON_PRINT)
parser.add_argument("--web-upload-warning", type=int, default=env_loader.WEB_UPLOAD_WARNING)
parser.add_argument("--timelapse-local", type=int, default=env_loader.TIMELAPSE_LOCAL)
parser.add_argument("--timelapse-interval-sec", type=int, default=env_loader.TIMELAPSE_INTERVAL_SEC)
parser.add_argument("--timelapse-printer", type=int, default=env_loader.TIMELAPSE_PRINTER)
parser.add_argument("--host", default="0.0.0.0",
help="Bind-Adresse für den Bridge-Server")

View File

@@ -323,8 +323,18 @@ function applyLang(){
setText('lbl-default-slot',T.settings_default_slot);
setText('opt-slot-auto',T.settings_slot_auto);
setText('lbl-auto-leveling',T.settings_auto_leveling);
setText('lbl-vibration-compensation',T.settings_vibration_compensation);
setText('lbl-flow-calibration',T.settings_flow_calibration);
setText('lbl-camera-on-print',T.settings_camera_on_print);
setText('lbl-web-upload-warning',T.settings_web_upload_warning);
setText('lbl-timelapse-local',T.settings_timelapse_local);
setText('lbl-timelapse-interval',T.settings_timelapse_interval);
setText('lbl-timelapse-printer',T.settings_timelapse_printer);
setText('lbl-layout',T.settings_layout);
setText('opt-layout-1col',T.settings_layout_1col);
setText('opt-layout-2col',T.settings_layout_2col);
setText('store-tab-files-label',T.store_tab_files||'Dateien');
setText('store-tab-tl-label',T.timelapse_tab);
setText('lbl-update-check',T.update_check);
setText('lbl-update-apply',T.update_apply);
@@ -890,8 +900,14 @@ function openSettings(){
document.getElementById('s-mode-id').value=d.mode_id||'';
document.getElementById('s-default-slot').value=d.default_ams_slot||'auto';
document.getElementById('s-auto-leveling').checked=(d.auto_leveling===undefined?true:!!d.auto_leveling);
var vc=document.getElementById('s-vibration-compensation');if(vc)vc.checked=!!d.vibration_compensation;
var fc=document.getElementById('s-flow-calibration');if(fc)fc.checked=!!d.flow_calibration;
var cop=document.getElementById('s-camera-on-print');if(cop)cop.checked=!!d.camera_on_print;
var wuw=document.getElementById('s-web-upload-warning');if(wuw)wuw.checked=(d.web_upload_warning===undefined?true:!!d.web_upload_warning);
var tlLocal=document.getElementById('s-timelapse-local');if(tlLocal){tlLocal.checked=!!d.timelapse_local;_toggleTlInterval();}
var tlInt=document.getElementById('s-timelapse-interval');if(tlInt)tlInt.value=d.timelapse_interval_sec||4;
var tlPrinter=document.getElementById('s-timelapse-printer');if(tlPrinter)tlPrinter.checked=!!d.timelapse_printer;
var lay=document.getElementById('s-layout');if(lay)lay.value=localStorage.getItem('layout')||'1col';
});
var v=localStorage.getItem('pollInterval')||'2000';
document.querySelectorAll('.poll-btn').forEach(function(b){b.classList.remove('active')});
@@ -908,10 +924,90 @@ function closeSettings(){
document.getElementById('settings-modal').classList.remove('open');
}
// ── Layout ──
function applyLayout(mode){
var panel=document.getElementById('panel-dashboard');
if(!panel)return;
var fullWidth=['d-cam-card','d-progress-card','d-temps-card','d-ams-card'];
if(mode==='2col'){
panel.classList.add('layout-2col');
fullWidth.forEach(function(id){var el=document.getElementById(id);if(el)el.style.gridColumn='auto';});
}else{
panel.classList.remove('layout-2col');
fullWidth.forEach(function(id){var el=document.getElementById(id);if(el)el.style.gridColumn='1/-1';});
}
localStorage.setItem('layout',mode);
}
function _toggleTlInterval(){
var cb=document.getElementById('s-timelapse-local');
var row=document.getElementById('timelapse-interval-row');
if(row)row.style.display=(cb&&cb.checked)?'':'none';
}
// ── Store tabs ──
var _currentStoreTab='files';
var _timelapseData=[];
function switchStoreTab(tab){
_currentStoreTab=tab;
var filesSection=document.getElementById('store-files-section');
var tlSection=document.getElementById('store-timelapses-section');
var tabFiles=document.getElementById('store-tab-files');
var tabTl=document.getElementById('store-tab-timelapses');
if(filesSection)filesSection.style.display=tab==='files'?'':'none';
if(tlSection)tlSection.style.display=tab==='timelapses'?'':'none';
if(tabFiles){tabFiles.classList.toggle('active',tab==='files');}
if(tabTl){tabTl.classList.toggle('active',tab==='timelapses');}
if(tab==='timelapses')loadTimelapses();
}
function refreshStoreTab(){
if(_currentStoreTab==='timelapses')loadTimelapses();else loadStore();
}
function loadTimelapses(){
fetch(_apiUrl('/kx/timelapses')).then(function(r){return r.json();}).then(function(d){
_timelapseData=Array.isArray(d)?d:[];
renderTimelapses();
}).catch(function(e){clog('Timelapse-Fehler: '+e,'msg-err');});
}
function renderTimelapses(){
var list=document.getElementById('timelapse-list');
if(!list)return;
if(!_timelapseData.length){
list.innerHTML='<div class="tl-row"><span style="color:var(--txt2);padding:20px">'+(T.timelapse_empty||'Keine Timelapses vorhanden')+'</span></div>';
return;
}
list.innerHTML=_timelapseData.map(function(t){
var statusCls=t.status==='completed'?'tl-status-ok':t.status==='failed'?'tl-status-err':'tl-status-busy';
var statusTxt=t.status==='recording'?(T.timelapse_recording||'Aufnahme…'):t.status==='processing'?(T.timelapse_processing||'Kodierung…'):t.status;
var dur=t.duration_sec>0?Math.floor(t.duration_sec/60)+'m ':' ';
var dlBtn=t.status==='completed'?'<a href="'+_apiUrl('/kx/timelapse/'+t.id+'/download')+'" style="flex-shrink:0;padding:6px 12px;background:var(--accent);color:#000;border-radius:6px;font-size:12px;font-weight:600;text-decoration:none" download>⬇ Download</a>':'';
return '<div class="tl-row">'+
'<div class="tl-icon">🎬</div>'+
'<div class="tl-info">'+
'<div class="tl-name" title="'+t.filename+'">'+t.filename+'</div>'+
'<div class="tl-meta '+statusCls+'">'+statusTxt+'</div>'+
'<div class="tl-meta">'+t.frame_count+' frames &bull; '+dur+t.created_at.slice(0,10)+'</div>'+
'</div>'+
dlBtn+
'<button onclick="deleteTimelapse(\''+t.id+'\')" style="flex-shrink:0;padding:6px 10px;background:var(--raised);border:1px solid var(--border);border-radius:6px;color:var(--err);cursor:pointer;font-size:12px">✕</button>'+
'</div>';
}).join('');
}
function deleteTimelapse(id){
if(!confirm(T.confirm_delete_timelapse||'Dieses Timelapse löschen?'))return;
fetch(_apiUrl('/kx/timelapse/'+id),{method:'DELETE'}).then(function(){loadTimelapses();});
}
// ── AMS Slot Edit ──
var _slotEditIndex=-1;
var _slotEditLoaded=false;
var _MAT_PRESETS=['PLA','PETG','ABS','ASA','TPU','PA','PC','HIPS'];
var _BASE_MATERIAL_TYPES=['PLA','PETG','ABS','ASA','TPU','TPE','PA','PC','HIPS','PEI','PEEK'];
function updateSlotEditFeedButton(){
var btn=document.getElementById('btn-slot-edit-feed');
if(!btn)return;
@@ -1145,10 +1241,16 @@ function saveSettings(){
device_id: document.getElementById('s-device-id').value,
mode_id: document.getElementById('s-mode-id').value,
default_ams_slot: document.getElementById('s-default-slot').value,
auto_leveling: document.getElementById('s-auto-leveling').checked?1:0,
camera_on_print: (document.getElementById('s-camera-on-print')||{}).checked?1:0,
auto_leveling: document.getElementById('s-auto-leveling').checked?1:0,
vibration_compensation: (document.getElementById('s-vibration-compensation')||{}).checked?1:0,
flow_calibration: (document.getElementById('s-flow-calibration')||{}).checked?1:0,
camera_on_print: (document.getElementById('s-camera-on-print')||{}).checked?1:0,
web_upload_warning:webUploadWarning,
timelapse_local: (document.getElementById('s-timelapse-local')||{}).checked?1:0,
timelapse_interval_sec: parseInt((document.getElementById('s-timelapse-interval')||{value:'4'}).value)||4,
timelapse_printer: (document.getElementById('s-timelapse-printer')||{}).checked?1:0,
}).then(function(){
applyLayout((document.getElementById('s-layout')||{value:'1col'}).value);
btn.textContent=T.update_restarting;
setTimeout(function(){
btn.disabled=false;
@@ -1227,6 +1329,7 @@ var pollTimer;
});
}).catch(function(){});
poll();pollTimer=setInterval(poll,ms);
applyLayout(localStorage.getItem('layout')||'1col');
})();
// ── Print actions ──
@@ -1984,6 +2087,20 @@ function _normalizeMaterialKey(material){
var key=(material||'').toUpperCase().replace(/[^A-Z0-9+]/g,'');
// Orca often uses PLA for PLA+, while AMS may report PLA+.
if(key==='PLA+'||key==='PLAPLUS') return 'PLA';
// Handle modifier+base patterns in either order: "Matte PLA", "Silk PETG",
// "PLA Silk", "PLA Matte". OrcaSlicer always writes the base type in GCode
// (filament_type = PLA), but users label slots with the full product-style name.
// Scan each space-separated word; return the first one that is a known base material.
// Dash-suffix variants ("PLA-CF", "PETG-CF") contain no space and fall through
// unchanged, preserving correct incompatibility with their base types.
var trimmed=(material||'').trim();
if(trimmed.indexOf(' ')>=0){
var words=trimmed.toUpperCase().split(/\s+/);
for(var i=0;i<words.length;i++){
var w=words[i].replace(/[^A-Z0-9+]/g,'');
if(_BASE_MATERIAL_TYPES.indexOf(w)>=0) return w;
}
}
return key;
}
function _materialsCompatible(a,b){

View File

@@ -102,6 +102,14 @@
<input type="checkbox" id="s-auto-leveling" style="width:auto;margin:0">
<label id="lbl-auto-leveling" style="margin:0;cursor:pointer" for="s-auto-leveling">Auto-Leveling vor Druck</label>
</div>
<div class="modal-field" style="flex-direction:row;align-items:center;gap:10px">
<input type="checkbox" id="s-vibration-compensation" style="width:auto;margin:0">
<label id="lbl-vibration-compensation" style="margin:0;cursor:pointer" for="s-vibration-compensation">Resonance Compensation</label>
</div>
<div class="modal-field" style="flex-direction:row;align-items:center;gap:10px">
<input type="checkbox" id="s-flow-calibration" style="width:auto;margin:0">
<label id="lbl-flow-calibration" style="margin:0;cursor:pointer" for="s-flow-calibration">Flow Calibration</label>
</div>
<div class="modal-field" style="flex-direction:row;align-items:center;gap:10px">
<input type="checkbox" id="s-camera-on-print" style="width:auto;margin:0">
<label id="lbl-camera-on-print" style="margin:0;cursor:pointer" for="s-camera-on-print">Kamera bei Druckstart einschalten</label>
@@ -110,6 +118,29 @@
<input type="checkbox" id="s-web-upload-warning" style="width:auto;margin:0">
<label id="lbl-web-upload-warning" style="margin:0;cursor:pointer" for="s-web-upload-warning">Warnung bei Web-Upload-Druck anzeigen</label>
</div>
<div class="modal-field" style="flex-direction:row;align-items:center;gap:10px">
<input type="checkbox" id="s-timelapse-local" style="width:auto;margin:0" onchange="_toggleTlInterval()">
<label id="lbl-timelapse-local" style="margin:0;cursor:pointer" for="s-timelapse-local">Lokales Timelapse während Druck</label>
</div>
<div class="modal-field" id="timelapse-interval-row" style="display:none">
<label id="lbl-timelapse-interval" style="font-size:12px;color:var(--txt2)">Aufnahme-Intervall (Sekunden)</label>
<input type="number" id="s-timelapse-interval" min="1" max="60" value="4" style="width:80px">
</div>
<div class="modal-field" style="flex-direction:row;align-items:center;gap:10px">
<input type="checkbox" id="s-timelapse-printer" style="width:auto;margin:0">
<label id="lbl-timelapse-printer" style="margin:0;cursor:pointer" for="s-timelapse-printer">Drucker-Timelapse aktivieren (experimentell)</label>
</div>
</div>
<div>
<div class="modal-section" id="modal-sec-layout">Layout</div>
<div class="modal-field" style="flex-direction:row;align-items:center;gap:10px;flex-wrap:wrap">
<label id="lbl-layout" style="margin:0;font-size:12px;color:var(--txt2)">Dashboard-Layout</label>
<select id="s-layout" style="width:auto;margin:0">
<option value="1col" id="opt-layout-1col">1 Spalte</option>
<option value="2col" id="opt-layout-2col">2 Spalten</option>
</select>
</div>
</div>
<div>
@@ -193,7 +224,7 @@
<div class="panel active" id="panel-dashboard">
<div class="grid">
<!-- Kamera -->
<div class="card" style="grid-column:1/-1">
<div class="card" id="d-cam-card" style="grid-column:1/-1">
<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">
@@ -217,7 +248,7 @@
</div>
<!-- Fortschritt -->
<div class="card" style="grid-column:1/-1">
<div class="card" id="d-progress-card" style="grid-column:1/-1">
<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>
@@ -251,7 +282,7 @@
</div>
<!-- Temperatursteuerung + Verlauf -->
<div class="card" style="grid-column:1/-1">
<div class="card" id="d-temps-card" style="grid-column:1/-1">
<div class="card-title"><span></span> <span id="d-card-temps">Temperaturen</span></div>
<div class="temp-card-inner">
<div class="temp-block">
@@ -401,8 +432,15 @@
<div class="card">
<div class="card-title" style="display:flex;justify-content:space-between;align-items:center;margin-bottom:10px">
<span id="store-panel-title">🗂 Datei-Browser</span>
<button id="store-refresh-btn" onclick="loadStore()" style="font-size:12px;padding:4px 12px;background:var(--raised);border:1px solid var(--border);border-radius:6px;color:var(--txt2);cursor:pointer">↻ Aktualisieren</button>
<div style="display:flex;gap:6px;align-items:center">
<button id="store-tab-files" class="store-tab-btn active" onclick="switchStoreTab('files')">📁 <span id="store-tab-files-label">Dateien</span></button>
<button id="store-tab-timelapses" class="store-tab-btn" onclick="switchStoreTab('timelapses')">🎬 <span id="store-tab-tl-label">Timelapses</span></button>
<button id="store-refresh-btn" onclick="refreshStoreTab()" style="font-size:12px;padding:4px 12px;background:var(--raised);border:1px solid var(--border);border-radius:6px;color:var(--txt2);cursor:pointer"></button>
</div>
</div>
<!-- Dateien-Bereich -->
<div id="store-files-section">
<div style="display:flex;gap:8px;margin-bottom:12px;flex-wrap:wrap">
<input id="store-search" type="text" placeholder="🔍 Suche…" oninput="renderStore()"
style="flex:1;min-width:140px;padding:6px 10px;background:var(--raised);border:1px solid var(--border);border-radius:6px;color:var(--txt);font-size:13px">
@@ -433,6 +471,12 @@
<div id="store-empty" style="display:none;color:var(--txt2);text-align:center;padding:40px 0;font-size:14px">
</div>
<div id="store-grid" style="display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:14px"></div>
</div><!-- /store-files-section -->
<!-- Timelapse-Bereich -->
<div id="store-timelapses-section" style="display:none">
<div id="timelapse-list"><div style="text-align:center;padding:40px;color:var(--txt2)" id="timelapse-empty-msg">Keine Timelapses vorhanden</div></div>
</div>
</div>
</div>

View File

@@ -313,3 +313,25 @@ nav.bottom-nav{display:none;position:fixed;bottom:0;left:0;right:0;
.modal-box{padding:16px;border-radius:10px}
.poll-btns{gap:6px}
}
/* ── 2-Column Dashboard Layout ── */
#panel-dashboard.layout-2col .grid{grid-template-columns:1fr 1fr}
@media(max-width:768px){
#panel-dashboard.layout-2col .grid{grid-template-columns:1fr}
}
/* ── Store tabs ── */
.store-tab-btn{padding:6px 16px;border-radius:6px;border:1px solid var(--border);
background:var(--raised);color:var(--txt2);cursor:pointer;font-size:13px;font-weight:600;transition:.12s}
.store-tab-btn.active{background:var(--accent);border-color:var(--accent);color:#000}
/* ── Timelapse list ── */
.tl-row{display:flex;align-items:center;gap:12px;padding:10px;
border:1px solid var(--border);border-radius:8px;margin-bottom:8px}
.tl-icon{font-size:28px;color:var(--txt2);flex-shrink:0}
.tl-info{flex:1;min-width:0}
.tl-name{font-size:13px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.tl-meta{font-size:11px;color:var(--txt2);margin-top:2px}
.tl-status-ok{color:var(--ok)}
.tl-status-err{color:var(--err)}
.tl-status-busy{color:var(--warn)}

View File

@@ -140,8 +140,22 @@
"settings_default_slot": "Standard-Slot (Einfarbdruck)",
"settings_slot_auto": "Auto (alle belegten Slots)",
"settings_auto_leveling": "Auto-Leveling vor Druck",
"settings_vibration_compensation": "Resonanzkompensation vor Druck",
"settings_flow_calibration": "Flow-Kalibrierung vor Druck",
"settings_camera_on_print": "Kamera bei Druckstart einschalten",
"settings_web_upload_warning": "Warnung bei Web-Upload-Druck anzeigen",
"settings_timelapse_local": "Lokales Timelapse während Druck",
"settings_timelapse_interval": "Aufnahme-Intervall (Sekunden)",
"settings_timelapse_printer": "Drucker-Timelapse aktivieren (experimentell)",
"settings_layout": "Dashboard-Layout",
"settings_layout_1col": "1 Spalte",
"settings_layout_2col": "2 Spalten",
"store_tab_files": "Dateien",
"timelapse_tab": "Timelapses",
"timelapse_empty": "Noch keine Timelapses vorhanden",
"timelapse_recording": "Aufnahme läuft…",
"timelapse_processing": "Kodierung läuft…",
"confirm_delete_timelapse": "Dieses Timelapse löschen?",
"update_check": "Auf Updates prüfen",
"update_checking": "Prüfe...",
"update_available": "verfügbar",

View File

@@ -140,8 +140,22 @@
"settings_default_slot": "Default Slot (single color)",
"settings_slot_auto": "Auto (all loaded slots)",
"settings_auto_leveling": "Auto-Leveling before print",
"settings_vibration_compensation": "Resonance Compensation before print",
"settings_flow_calibration": "Flow Calibration before print",
"settings_camera_on_print": "Turn camera on at print start",
"settings_web_upload_warning": "Show warning when printing web uploads",
"settings_timelapse_local": "Local timelapse during print",
"settings_timelapse_interval": "Capture interval (seconds)",
"settings_timelapse_printer": "Enable printer timelapse (experimental)",
"settings_layout": "Dashboard layout",
"settings_layout_1col": "1 column",
"settings_layout_2col": "2 columns",
"store_tab_files": "Files",
"timelapse_tab": "Timelapses",
"timelapse_empty": "No timelapses yet",
"timelapse_recording": "Recording…",
"timelapse_processing": "Encoding…",
"confirm_delete_timelapse": "Delete this timelapse?",
"update_check": "Check for Updates",
"update_checking": "Checking...",
"update_available": "available",

View File

@@ -140,8 +140,22 @@
"settings_default_slot": "Ranura predeterminada (un color)",
"settings_slot_auto": "Auto (todos los slots cargados)",
"settings_auto_leveling": "Autonivelado antes de imprimir",
"settings_vibration_compensation": "Compensación de resonancia antes de imprimir",
"settings_flow_calibration": "Calibración de flujo antes de imprimir",
"settings_camera_on_print": "Encender cámara al iniciar impresión",
"settings_web_upload_warning": "Mostrar advertencia al imprimir subidas web",
"settings_timelapse_local": "Timelapse local durante la impresión",
"settings_timelapse_interval": "Intervalo de captura (segundos)",
"settings_timelapse_printer": "Activar timelapse de impresora (experimental)",
"settings_layout": "Diseño del panel",
"settings_layout_1col": "1 columna",
"settings_layout_2col": "2 columnas",
"store_tab_files": "Archivos",
"timelapse_tab": "Timelapses",
"timelapse_empty": "Aún no hay timelapses",
"timelapse_recording": "Grabando…",
"timelapse_processing": "Codificando…",
"confirm_delete_timelapse": "¿Eliminar este timelapse?",
"update_check": "Buscar actualizaciones",
"update_checking": "Comprobando...",
"update_available": "disponible",

View File

@@ -140,8 +140,22 @@
"settings_default_slot": "默认槽位 (单色)",
"settings_slot_auto": "自动 (所有已装载槽位)",
"settings_auto_leveling": "打印前自动调平",
"settings_vibration_compensation": "打印前共振补偿",
"settings_flow_calibration": "打印前流量校准",
"settings_camera_on_print": "打印开始时开启相机",
"settings_web_upload_warning": "打印网页上传文件时显示警告",
"settings_timelapse_local": "打印期间本地延时摄影",
"settings_timelapse_interval": "拍摄间隔(秒)",
"settings_timelapse_printer": "启用打印机延时摄影(实验性)",
"settings_layout": "仪表板布局",
"settings_layout_1col": "单列",
"settings_layout_2col": "双列",
"store_tab_files": "文件",
"timelapse_tab": "延时视频",
"timelapse_empty": "暂无延时视频",
"timelapse_recording": "录制中…",
"timelapse_processing": "编码中…",
"confirm_delete_timelapse": "删除此延时视频?",
"update_check": "检查更新",
"update_checking": "检查中...",
"update_available": "可用",