Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b39577ad4d | ||
|
|
cfe46b4cad | ||
|
|
250e89f18a | ||
| 031e34d8ea | |||
|
|
fc89dfffa5 |
@@ -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"))
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
@@ -3085,13 +3268,10 @@ class KobraXBridge:
|
||||
loop = asyncio.get_event_loop()
|
||||
|
||||
def _send():
|
||||
return self.client.publish("multiColorBox", "setDry", payload, timeout=5)
|
||||
|
||||
resp = await loop.run_in_executor(None, _send)
|
||||
if resp is None:
|
||||
return web.json_response({"error": "No response from printer"}, status=504)
|
||||
if int(resp.get("code", 200)) != 200:
|
||||
return web.json_response({"error": f"Printer rejected command: {resp}"}, status=502)
|
||||
return self.client.publish("multiColorBox", "setDry", payload, timeout=0)
|
||||
# Fire-and-forget: setDry ACK arrives via multiColorBox/report callback.
|
||||
# Waiting for a response on that busy push topic causes false "code:0" rejections.
|
||||
await loop.run_in_executor(None, _send)
|
||||
|
||||
self._state["ace_drying"] = ui_state
|
||||
self._state_dirty = True
|
||||
@@ -3506,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:
|
||||
@@ -3529,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,
|
||||
})
|
||||
|
||||
@@ -3559,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()
|
||||
@@ -4443,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)
|
||||
@@ -4605,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")
|
||||
|
||||
@@ -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 • '+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){
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "可用",
|
||||
|
||||
Reference in New Issue
Block a user