build: sources for v0.9.17
This commit is contained in:
@@ -1,5 +1,63 @@
|
||||
# Changelog
|
||||
|
||||
## [0.9.17] – 2026-05-30
|
||||
|
||||
### Neu
|
||||
- **🧪 Obico-Anbindung (experimentell):** Die Bridge spielt jetzt einen
|
||||
Moonraker, der vom [moonraker-obico](https://github.com/TheSpaghettiDetective/moonraker-obico)
|
||||
Plugin akzeptiert wird. Damit funktionieren Time-Lapse, Layer-aligned
|
||||
First-Layer-Scan und WebRTC-Live-Stream gegen einen (selbst gehosteten oder
|
||||
Cloud-) Obico-Server. **Hinweis:** Das KI-Modell zur Spaghetti-Erkennung
|
||||
ist auf seitliche Kamera-Winkel (Ender/Voron) trainiert — beim Kobra X
|
||||
mit Top-Down-Kamera erkennt es derzeit keine Druckfehler. Das ist deshalb
|
||||
noch nicht produktiv nutzbar, aber Stream/Time-Lapse/Telemetrie laufen.
|
||||
- **Mehrsprachiges UI (PR #37 von @gangoke):** Inline-Translations sind raus,
|
||||
stattdessen wechselbares Sprach-Dropdown mit Globe-Icon. Auto-Auswahl nach
|
||||
Browser-Locale, manuelle Wahl wird im LocalStorage gemerkt. Sprachen: 🇩🇪 🇬🇧
|
||||
🇪🇸 🇨🇳 (ES + ZH-CN sind KI-übersetzt und noch nicht von Muttersprachlern
|
||||
geprüft).
|
||||
- **OrcaSlicer-Filament-Profil pro AMS-Slot:** Im Slot-Bearbeiten-Dialog kannst
|
||||
du jetzt ein konkretes OrcaSlicer-Profil (z.B. „PolyTerra PLA — Polymaker")
|
||||
pro Slot wählen — die Bridge sendet diese Information beim AMS-Sync mit,
|
||||
statt nur „Generic PLA". Die Profil-Liste wird aus dem OrcaSlicer-Source
|
||||
generiert (~1000 Profile, 43 Hersteller). Damit OrcaSlicer den Hint
|
||||
vollständig respektiert, wird ein passender Patch im OrcaSlicer-KX-Build
|
||||
folgen.
|
||||
- **H.264-Direkt-Stream:** Neuer Endpunkt `/api/camera/h264` liefert den
|
||||
Drucker-Kamera-Stream ohne Re-Encoding als MPEG-TS — Latenz drastisch
|
||||
reduziert, Bridge-CPU bei Obico-Stream von ~13 % auf ~3 %.
|
||||
|
||||
### Fixes
|
||||
- **Temperatur-Setzen über Bridge-UI / Obico löste Drucker-Systemfehler aus:**
|
||||
Per Live-MQTT-Sniff vom Anycubic Slicer Next korrigiert — der Befehl
|
||||
`tempature/set` braucht ein `type`-Feld (0=Nozzle, 1=Bett, 2=beide) und
|
||||
muss über das `web/printer/…`-Topic, nicht `slicer/printer/…`. Nozzle/Bett
|
||||
über die Bridge heizen jetzt sauber.
|
||||
- **Große GCode-Uploads (>50 MB) brachen mit Timeout ab:** Der
|
||||
Connect-Timeout vom Socket lief auch während des `sendall()` — bei ~200 MB
|
||||
über LAN brauchte das Schieben mehr als die 30 s und wurde fälschlich als
|
||||
Connect-Timeout abgebrochen. Jetzt sind Connect-, Send- und Read-Phase
|
||||
separat getimeoutet.
|
||||
- **Kamera-Snapshot war langsam und konnte sich mit dem Live-Stream blockieren:**
|
||||
Die Bridge hält nun einen zentralen Kamera-Cache (ein einziger ffmpeg-Prozess
|
||||
zieht vom Drucker, alle Konsumenten teilen sich den Stream). Snapshots
|
||||
kommen in ~1.3 ms aus dem RAM statt nach 1-2 s per neuer ffmpeg-Instanz.
|
||||
Behebt außerdem das Single-Client-Limit am Drucker (HTTP 429 bei parallelen
|
||||
Zugriffen).
|
||||
- **Sprachwechsel aktualisierte den GCode-Browser nicht:** Die in die
|
||||
File-Karten eingebackenen Texte („Drucken", „Schätzung", „Download") blieben
|
||||
in der alten Sprache. Beim Sprachwechsel werden die Karten jetzt neu
|
||||
gerendert.
|
||||
- **GCode Web-Upload + Download + Verify-Dialog (PR #32 von @gangoke):**
|
||||
Dateien können direkt im Browser hoch/runtergeladen werden, mit
|
||||
Warn-Dialog wenn ein nicht durch OrcaSlicer hochgeladener GCode gestartet
|
||||
wird.
|
||||
|
||||
### CI/Build
|
||||
- Multi-Arch Docker-Image (amd64 + arm64) per Gitea-Actions automatisiert.
|
||||
- Release-Build über lokalen CodeBuilder für alle drei Targets
|
||||
(linux-amd64, linux-arm64, windows.exe).
|
||||
|
||||
## [0.9.16] – 2026-05-22
|
||||
|
||||
### Neu
|
||||
|
||||
56
CHANGELOG.md
56
CHANGELOG.md
@@ -1,5 +1,61 @@
|
||||
# Changelog
|
||||
|
||||
## [0.9.17] – 2026-05-30
|
||||
|
||||
### New
|
||||
- **🧪 Obico integration (experimental):** The bridge now exposes a
|
||||
Moonraker-compatible surface that the
|
||||
[moonraker-obico](https://github.com/TheSpaghettiDetective/moonraker-obico)
|
||||
plugin accepts. Time-lapses, layer-aligned first-layer scan and WebRTC
|
||||
live streaming work against a (self-hosted or cloud) Obico server.
|
||||
**Note:** the spaghetti-detection ML model is trained on side-view
|
||||
cameras (Ender/Voron); with the Kobra X's top-down camera it currently
|
||||
detects no print failures. Stream / time-lapse / telemetry work — the
|
||||
AI side is not production-ready yet.
|
||||
- **Multi-language UI (PR #37 by @gangoke):** Inline translations have
|
||||
moved into JSON files; a globe-icon dropdown lets you switch language.
|
||||
Browser locale is auto-detected; manual choice persists in
|
||||
LocalStorage. Languages: 🇩🇪 🇬🇧 🇪🇸 🇨🇳 (ES + ZH-CN are AI-translated
|
||||
and not verified by native speakers yet).
|
||||
- **OrcaSlicer filament profile per AMS slot:** The slot-edit dialog now
|
||||
lets you pick a concrete OrcaSlicer profile (e.g. "PolyTerra PLA —
|
||||
Polymaker") per slot; the bridge sends it along on AMS sync instead
|
||||
of just "Generic PLA". Profile list is generated from the OrcaSlicer
|
||||
source (~1000 profiles, 43 vendors). A matching patch in
|
||||
OrcaSlicer-KX is on the way so OrcaSlicer fully honours the hint.
|
||||
- **H.264 direct stream:** New `/api/camera/h264` endpoint serves the
|
||||
printer camera stream as MPEG-TS without re-encoding — dramatically
|
||||
reduces latency, bridge CPU during Obico streaming drops from ~13 %
|
||||
to ~3 %.
|
||||
|
||||
### Fixes
|
||||
- **Setting temperature via bridge UI / Obico caused a printer system
|
||||
error:** Fixed via live MQTT capture from Anycubic Slicer Next — the
|
||||
`tempature/set` command needs a `type` field (0=nozzle, 1=bed,
|
||||
2=both) and must go over the `web/printer/…` topic, not
|
||||
`slicer/printer/…`. Nozzle/bed heating from the bridge now works.
|
||||
- **Large GCode uploads (>50 MB) timed out:** The socket connect timeout
|
||||
was active during `sendall()` too — pushing ~200 MB over LAN took
|
||||
more than 30 s and was falsely aborted. Connect / send / read phases
|
||||
are now timed out separately.
|
||||
- **Camera snapshots were slow and could collide with the live stream:**
|
||||
The bridge now keeps a central camera cache (one ffmpeg pulls from
|
||||
the printer, all consumers share it). Snapshots return in ~1.3 ms
|
||||
from RAM instead of 1–2 s per spawned ffmpeg. Also resolves the
|
||||
single-client limit on the printer (HTTP 429 on parallel access).
|
||||
- **Language switch did not refresh the GCode browser:** Strings baked
|
||||
into the file cards ("Print", "Estimate", "Download") stayed in the
|
||||
previous language. Cards are now re-rendered on language switch.
|
||||
- **GCode web upload + download + verify dialog (PR #32 by @gangoke):**
|
||||
Files can be uploaded / downloaded directly in the browser, with a
|
||||
warning dialog when starting a GCode that was not uploaded via
|
||||
OrcaSlicer.
|
||||
|
||||
### CI/Build
|
||||
- Multi-arch Docker image (amd64 + arm64) automated via Gitea Actions.
|
||||
- Release builds for all three targets (linux-amd64, linux-arm64,
|
||||
windows.exe) via the local CodeBuilder.
|
||||
|
||||
## [0.9.16] – 2026-05-22
|
||||
|
||||
### New
|
||||
|
||||
@@ -7,6 +7,9 @@ RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY kobrax_moonraker_bridge.py .
|
||||
COPY web/ ./web/
|
||||
# Statische Daten (orca_filaments.json etc.) liegen in /app/static/, NICHT in
|
||||
# /app/data/ — letzteres wird vom User als Volume gemountet (Runtime-State).
|
||||
COPY data/ ./static/
|
||||
COPY config_loader.py .
|
||||
COPY env_loader.py .
|
||||
COPY kobrax_client.py .
|
||||
|
||||
@@ -166,6 +166,77 @@ def list_printers() -> list[dict]:
|
||||
return printers
|
||||
|
||||
|
||||
def list_filament_profiles() -> dict[int, dict]:
|
||||
"""Liest die [filament_profiles]-Sektion aus config.ini.
|
||||
|
||||
Format pro AMS-Slot (slot_N_id + slot_N_vendor):
|
||||
[filament_profiles]
|
||||
slot_0_id = OGFL01
|
||||
slot_0_vendor = Polymaker
|
||||
slot_1_id = OGFG23
|
||||
slot_1_vendor = Polymaker
|
||||
|
||||
Gibt einen Dict {slot_index: {"id": ..., "vendor": ...}} zurück.
|
||||
Leere/fehlende Slots werden NICHT aufgenommen — das Default-Mapping
|
||||
(per filament_type) in der Bridge bleibt dann aktiv.
|
||||
"""
|
||||
path = _find_config_file()
|
||||
if not path:
|
||||
return {}
|
||||
cfg = configparser.ConfigParser()
|
||||
cfg.read(path, encoding="utf-8")
|
||||
if not cfg.has_section("filament_profiles"):
|
||||
return {}
|
||||
result: dict[int, dict] = {}
|
||||
for key, value in cfg.items("filament_profiles"):
|
||||
# Erwartet: slot_<idx>_id oder slot_<idx>_vendor
|
||||
if not key.startswith("slot_"):
|
||||
continue
|
||||
parts = key.split("_", 2)
|
||||
if len(parts) < 3:
|
||||
continue
|
||||
try:
|
||||
slot_idx = int(parts[1])
|
||||
except ValueError:
|
||||
continue
|
||||
field = parts[2]
|
||||
if field not in ("id", "vendor"):
|
||||
continue
|
||||
if not value.strip():
|
||||
continue
|
||||
result.setdefault(slot_idx, {})[field] = value.strip()
|
||||
# Leere Einträge (nur vendor ohne id oder umgekehrt) trotzdem behalten —
|
||||
# der Aufrufer prüft selbst was er nutzt.
|
||||
return result
|
||||
|
||||
|
||||
def save_filament_profiles(profiles: dict[int, dict]) -> bool:
|
||||
"""Schreibt die übergebenen Slot-Profile in die [filament_profiles]-
|
||||
Sektion der config.ini. Existierende Einträge werden komplett ersetzt.
|
||||
|
||||
profiles: {slot_index: {"id": "OGFL01", "vendor": "Polymaker"}}
|
||||
"""
|
||||
path = _find_config_file()
|
||||
if not path:
|
||||
return False
|
||||
cfg = configparser.ConfigParser()
|
||||
cfg.read(path, encoding="utf-8")
|
||||
# Sektion neu aufbauen — entfernt damit auch alte/verwaiste Slots
|
||||
if cfg.has_section("filament_profiles"):
|
||||
cfg.remove_section("filament_profiles")
|
||||
if profiles:
|
||||
cfg["filament_profiles"] = {}
|
||||
for slot_idx in sorted(profiles.keys()):
|
||||
entry = profiles[slot_idx] or {}
|
||||
if entry.get("id"):
|
||||
cfg["filament_profiles"][f"slot_{slot_idx}_id"] = entry["id"]
|
||||
if entry.get("vendor"):
|
||||
cfg["filament_profiles"][f"slot_{slot_idx}_vendor"] = entry["vendor"]
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
cfg.write(f)
|
||||
return True
|
||||
|
||||
|
||||
def get(key: str, default: str = "") -> str:
|
||||
return os.environ.get(key, default)
|
||||
|
||||
|
||||
@@ -545,9 +545,15 @@ class KobraXClient:
|
||||
f"Connection: close\r\n\r\n"
|
||||
).encode()
|
||||
|
||||
sock = socket.create_connection((self.host, 18910), timeout=30)
|
||||
# Connect-Timeout kurz (LAN). Während sendall() darf der Socket so
|
||||
# lange brauchen wie nötig — bei großen Dateien (>100 MB) und
|
||||
# langsamerem WLAN am Drucker dauert das Schieben sonst >30 s und
|
||||
# würde den Connect-Timeout fälschlich auslösen. Read-Timeout danach
|
||||
# generös (Drucker verarbeitet die Datei bevor er antwortet).
|
||||
sock = socket.create_connection((self.host, 18910), timeout=10)
|
||||
sock.settimeout(None) # blocking während Send
|
||||
sock.sendall(headers + body)
|
||||
sock.settimeout(120) # große GCode-Dateien brauchen Zeit bis der Drucker antwortet
|
||||
sock.settimeout(180)
|
||||
response = b""
|
||||
try:
|
||||
while True:
|
||||
|
||||
@@ -534,6 +534,155 @@ class GCodeStore:
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
class CameraCache:
|
||||
"""Zentraler Kamera-Demuxer.
|
||||
|
||||
Hält EINEN ffmpeg-Prozess offen, der den FLV-Stream vom Drucker liest
|
||||
und parallel zwei Outputs erzeugt:
|
||||
- MJPEG @ 2fps → letzter Frame im RAM für /api/camera/snapshot
|
||||
- MPEG-TS (-c:v copy) → Fanout an alle /api/camera/h264-Subscriber
|
||||
|
||||
Damit:
|
||||
* Nur EINE FLV-Verbindung zum Drucker (löst Single-Client-Limit / 429)
|
||||
* Snapshot ist instant (Speicher-Read, kein ffmpeg-Spawn pro Request)
|
||||
* Mehrere parallele H.264-Konsumenten möglich (Plugin + Web-UI + …)
|
||||
|
||||
Lazy-Start beim ersten Konsumenten, Auto-Restart bei ffmpeg-Crash.
|
||||
"""
|
||||
|
||||
JPEG_SOI = b"\xff\xd8"
|
||||
JPEG_EOI = b"\xff\xd9"
|
||||
TS_CHUNK = 65536
|
||||
|
||||
def __init__(self):
|
||||
self._url: str = ""
|
||||
self.latest_jpeg: bytes = b""
|
||||
self.latest_jpeg_ts: float = 0.0
|
||||
self.h264_subscribers: "set[asyncio.Queue[bytes]]" = set()
|
||||
self._proc_jpeg: "asyncio.subprocess.Process | None" = None
|
||||
self._proc_h264: "asyncio.subprocess.Process | None" = None
|
||||
self._task_jpeg: "asyncio.Task | None" = None
|
||||
self._task_h264: "asyncio.Task | None" = None
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
def set_url(self, url: str):
|
||||
self._url = url
|
||||
|
||||
async def ensure_running(self):
|
||||
if self._proc_jpeg is None or self._proc_jpeg.returncode is not None:
|
||||
self._task_jpeg = asyncio.create_task(self._run_jpeg_loop())
|
||||
if self._proc_h264 is None or self._proc_h264.returncode is not None:
|
||||
self._task_h264 = asyncio.create_task(self._run_h264_loop())
|
||||
|
||||
def _input_args(self, url: str) -> list[str]:
|
||||
args = ["-fflags", "nobuffer", "-flags", "low_delay"]
|
||||
if url.lower().startswith("rtsp://"):
|
||||
args += ["-probesize", "32", "-analyzeduration", "0", "-rtsp_transport", "tcp"]
|
||||
else:
|
||||
args += ["-probesize", "500000", "-analyzeduration", "500000"]
|
||||
return args
|
||||
|
||||
async def _run_jpeg_loop(self):
|
||||
"""Hält einen ffmpeg-Prozess am Leben der MJPEG@2fps in den Cache schreibt."""
|
||||
while True:
|
||||
url = self._url
|
||||
if not url:
|
||||
await asyncio.sleep(2.0)
|
||||
continue
|
||||
try:
|
||||
self._proc_jpeg = await asyncio.create_subprocess_exec(
|
||||
_find_ffmpeg(), "-loglevel", "quiet",
|
||||
*self._input_args(url), "-i", url,
|
||||
"-vf", "fps=2",
|
||||
"-f", "image2pipe", "-vcodec", "mjpeg", "-q:v", "3",
|
||||
"-flush_packets", "1", "pipe:1",
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.DEVNULL,
|
||||
)
|
||||
except Exception as e:
|
||||
log.warning(f"CameraCache: ffmpeg-jpeg start fehlgeschlagen: {e}")
|
||||
await asyncio.sleep(3.0)
|
||||
continue
|
||||
|
||||
buf = b""
|
||||
try:
|
||||
while True:
|
||||
chunk = await self._proc_jpeg.stdout.read(self.TS_CHUNK)
|
||||
if not chunk:
|
||||
break
|
||||
buf += chunk
|
||||
# extract complete JPEG frames
|
||||
while True:
|
||||
start = buf.find(self.JPEG_SOI)
|
||||
if start == -1:
|
||||
buf = b""
|
||||
break
|
||||
end = buf.find(self.JPEG_EOI, start + 2)
|
||||
if end == -1:
|
||||
buf = buf[start:]
|
||||
break
|
||||
self.latest_jpeg = buf[start:end + 2]
|
||||
self.latest_jpeg_ts = time.time()
|
||||
buf = buf[end + 2:]
|
||||
except Exception as e:
|
||||
log.debug(f"CameraCache: jpeg-loop unterbrochen: {e}")
|
||||
finally:
|
||||
try:
|
||||
self._proc_jpeg.kill()
|
||||
except Exception:
|
||||
pass
|
||||
self._proc_jpeg = None
|
||||
await asyncio.sleep(2.0) # restart delay
|
||||
|
||||
async def _run_h264_loop(self):
|
||||
"""Hält einen ffmpeg-Prozess am Leben der MPEG-TS an alle Subscriber fanoutet."""
|
||||
while True:
|
||||
url = self._url
|
||||
if not url:
|
||||
await asyncio.sleep(2.0)
|
||||
continue
|
||||
try:
|
||||
self._proc_h264 = await asyncio.create_subprocess_exec(
|
||||
_find_ffmpeg(), "-loglevel", "quiet",
|
||||
*self._input_args(url), "-i", url,
|
||||
"-c:v", "copy", "-an",
|
||||
"-f", "mpegts", "pipe:1",
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.DEVNULL,
|
||||
)
|
||||
except Exception as e:
|
||||
log.warning(f"CameraCache: ffmpeg-h264 start fehlgeschlagen: {e}")
|
||||
await asyncio.sleep(3.0)
|
||||
continue
|
||||
|
||||
try:
|
||||
while True:
|
||||
chunk = await self._proc_h264.stdout.read(self.TS_CHUNK)
|
||||
if not chunk:
|
||||
break
|
||||
# Fanout: nicht-blockierend pro Subscriber, langsame Clients
|
||||
# bekommen ihren ältesten Chunk verworfen (Queue voll → drop).
|
||||
for q in list(self.h264_subscribers):
|
||||
if q.full():
|
||||
try:
|
||||
q.get_nowait()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
q.put_nowait(chunk)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
log.debug(f"CameraCache: h264-loop unterbrochen: {e}")
|
||||
finally:
|
||||
try:
|
||||
self._proc_h264.kill()
|
||||
except Exception:
|
||||
pass
|
||||
self._proc_h264 = None
|
||||
await asyncio.sleep(2.0)
|
||||
|
||||
|
||||
class KobraXBridge:
|
||||
def __init__(self, client: KobraXClient, args=None, store=None, printer_id: str = "1", all_bridges=None):
|
||||
self.client = client
|
||||
@@ -541,6 +690,18 @@ class KobraXBridge:
|
||||
self._printer_id = printer_id
|
||||
self._all_bridges = all_bridges if all_bridges is not None else {}
|
||||
self.ws_clients: set[web.WebSocketResponse] = set()
|
||||
# In-Memory KV-Store für Moonraker /server/database/item (moonraker-obico,
|
||||
# mainsail-presets etc.). Nicht persistent — überlebt keinen Restart.
|
||||
self._moonraker_kv_store: dict[str, dict] = {}
|
||||
# Slot→Orca-Filament-Profile-Mapping (aus config.ini [filament_profiles]).
|
||||
# Format: {slot_idx: {"id": "OGFL01", "vendor": "Polymaker"}}.
|
||||
# Wird in _build_lane_data verwendet, damit OrcaSlicer die konkrete
|
||||
# Marke ("PolyTerra PLA — Polymaker") statt nur "Generic PLA" anzeigt.
|
||||
try:
|
||||
import config_loader as _cl
|
||||
self._filament_profiles: dict[int, dict] = _cl.list_filament_profiles()
|
||||
except Exception:
|
||||
self._filament_profiles = {}
|
||||
self._last_state: dict = {}
|
||||
self._state = {
|
||||
"nozzle_temp": 0.0,
|
||||
@@ -582,6 +743,7 @@ class KobraXBridge:
|
||||
self._serve_dir_path: str = self._store._gcode_dir
|
||||
self._current_job_id: str = ""
|
||||
self._camera_autostarted: bool = False
|
||||
self.camera_cache: CameraCache = CameraCache()
|
||||
|
||||
self._thumbnail_b64: str = ""
|
||||
self._ace_dry_presets: dict[str, dict] = self._load_ace_dry_presets_config()
|
||||
@@ -815,6 +977,7 @@ class KobraXBridge:
|
||||
self._state["upload_url"] = urls["fileUploadurl"]
|
||||
if urls.get("rtspUrl"):
|
||||
self._state["camera_url"] = urls["rtspUrl"]
|
||||
self.camera_cache.set_url(urls["rtspUrl"])
|
||||
fan = d.get("fan_speed_pct")
|
||||
if fan is not None:
|
||||
self._state["fan_speed"] = int(fan)
|
||||
@@ -1321,13 +1484,22 @@ class KobraXBridge:
|
||||
else:
|
||||
color_hex = "FFFFFFFF"
|
||||
material = slot.get("type", "PLA").upper()
|
||||
tray_info_idx = self._TRAY_INFO_IDX.get(material, "OGFL99")
|
||||
# User-Override aus config.ini [filament_profiles].slot_N_id
|
||||
# bekommt Vorrang vor dem Default-Mapping nach material-Type.
|
||||
# Vendor wird mitgesendet (tray_sub_brands + filament_vendor),
|
||||
# damit ein gepatchter OrcaSlicer den Match nach Marke + Type +
|
||||
# Farbe machen kann (analog SnapmakerPrinterAgent).
|
||||
user_profile = self._filament_profiles.get(slot_index) or {}
|
||||
tray_info_idx = user_profile.get("id") or self._TRAY_INFO_IDX.get(material, "OGFL99")
|
||||
vendor = user_profile.get("vendor", "")
|
||||
tray_array.append({
|
||||
"id": str(slot_id),
|
||||
"tag_uid": "0000000000000000",
|
||||
"tray_info_idx": tray_info_idx,
|
||||
"tray_type": material,
|
||||
"tray_color": color_hex,
|
||||
"tray_sub_brands": vendor,
|
||||
"filament_vendor": vendor, # OrcaSlicer-Patch-Ready (Snapmaker-Stil)
|
||||
})
|
||||
else:
|
||||
tray_array.append({
|
||||
@@ -1445,6 +1617,9 @@ class KobraXBridge:
|
||||
"progress": s["progress"],
|
||||
"is_active": s["print_state"] == "printing",
|
||||
"file_path": s["filename"],
|
||||
# file_position approximiert: fraction × est_total_size.
|
||||
# Genauer Wert kommt aus dem Drucker nicht, Obico nutzt es nur als Anzeige.
|
||||
"file_position": int(s["progress"] * 1_000_000) if s["progress"] else 0,
|
||||
},
|
||||
"toolhead": {
|
||||
"position": [0, 0, 0, 0],
|
||||
@@ -1453,6 +1628,60 @@ class KobraXBridge:
|
||||
"estimated_print_time": s["print_duration"],
|
||||
},
|
||||
"mmu": self._build_mmu_object(),
|
||||
# ── Moonraker-Kompatibilität für moonraker-obico ──
|
||||
"heaters": {
|
||||
"available_heaters": ["extruder", "heater_bed"],
|
||||
"available_sensors": [],
|
||||
"available_monitors": [],
|
||||
},
|
||||
"webhooks": {
|
||||
"state": "ready",
|
||||
"state_message": "Printer is ready",
|
||||
},
|
||||
# speed_factor: 1=silent(0.5) / 2=standard(1.0) / 3=high(1.3) / 4=ultra(1.5)
|
||||
"gcode_move": {
|
||||
"speed_factor": {1: 0.5, 2: 1.0, 3: 1.3, 4: 1.5}.get(int(s.get("print_speed_mode") or 2), 1.0),
|
||||
"extrude_factor": 1.0,
|
||||
"speed": 0,
|
||||
"gcode_position": [0, 0, 0, 0],
|
||||
"absolute_coordinates": True,
|
||||
"absolute_extrude": True,
|
||||
"homing_origin": [0, 0, 0, 0],
|
||||
"position": [0, 0, 0, 0],
|
||||
},
|
||||
"fan": {
|
||||
"speed": (int(s.get("fan_speed") or 0)) / 100.0,
|
||||
"rpm": None,
|
||||
},
|
||||
# history (object): Obico abonniert es als Objekt; das eigentliche
|
||||
# /server/history/list-Endpoint liefert die echte Liste separat.
|
||||
"history": {
|
||||
"job_totals": {
|
||||
"total_jobs": 0,
|
||||
"total_time": 0,
|
||||
"total_print_time": 0,
|
||||
"total_filament_used": 0.0,
|
||||
"longest_job": 0,
|
||||
"longest_print": 0,
|
||||
},
|
||||
"current_job": None,
|
||||
},
|
||||
# Pseudo-Klipper-Macros für moonraker-obico:
|
||||
# - _OBICO_LAYER_CHANGE meldet die aktuelle Layer-Nr. Obico nutzt das
|
||||
# für "first layer scan"-Trigger und layer-aligned Time-Lapse-Frames.
|
||||
# Wir bedienen das aus dem MQTT-Stream (s["curr_layer"]).
|
||||
# - TIMELAPSE_TAKE_FRAME signalisiert, dass die aktuelle Pause vom
|
||||
# Time-Lapse stammt (sonst würde Obico die Pause als User-Pause
|
||||
# interpretieren). Wir setzen is_paused=False, weil unsere Pausen
|
||||
# nie Time-Lapse-Pausen sind.
|
||||
"gcode_macro _OBICO_LAYER_CHANGE": {
|
||||
"current_layer": int(s.get("curr_layer") or 0),
|
||||
"first_layer_scanning": False,
|
||||
"first_layer_scan_enabled": False,
|
||||
},
|
||||
"gcode_macro TIMELAPSE_TAKE_FRAME": {
|
||||
"is_paused": False,
|
||||
},
|
||||
}
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
@@ -1543,15 +1772,102 @@ class KobraXBridge:
|
||||
slots = []
|
||||
for i, s in enumerate(self._ams_slots):
|
||||
gidx = int(s.get("global_index", i))
|
||||
profile = self._filament_profiles.get(gidx) or {}
|
||||
slots.append({
|
||||
"slot_index": gidx,
|
||||
"material": s.get("type", ""),
|
||||
"color_hex": "#{:02X}{:02X}{:02X}".format(*s.get("color", [0,0,0])[:3]),
|
||||
"status": "loaded" if s.get("status") == 5 else "empty",
|
||||
"nozzle_temp": 0,
|
||||
# Aktueller User-Override aus config.ini [filament_profiles]
|
||||
"filament_id": profile.get("id", ""),
|
||||
"filament_vendor": profile.get("vendor", ""),
|
||||
})
|
||||
return self._json_cors({"result": slots})
|
||||
|
||||
async def handle_kx_filament_profiles(self, request):
|
||||
"""Liefert die statische Liste der OrcaSlicer-Filament-Profile
|
||||
(aus bridge/data/orca_filaments.json — vom Generator-Script
|
||||
tools/gen_orca_filament_list.py erzeugt).
|
||||
|
||||
Optional Filter via ?type=PLA / ?vendor=Polymaker.
|
||||
Frontend nutzt das für die Slot-Profile-Dropdown.
|
||||
"""
|
||||
type_filter = request.rel_url.query.get("type", "").upper().strip()
|
||||
vendor_filter = request.rel_url.query.get("vendor", "").strip()
|
||||
data_path = self._find_orca_filaments_json()
|
||||
if not data_path or not os.path.isfile(data_path):
|
||||
return self._json_cors({"result": []})
|
||||
try:
|
||||
with open(data_path, encoding="utf-8") as f:
|
||||
profiles = json.load(f)
|
||||
except Exception as e:
|
||||
log.warning(f"orca_filaments.json read error: {e}")
|
||||
return self._json_cors({"result": []})
|
||||
if type_filter:
|
||||
profiles = [p for p in profiles if p.get("type", "").upper() == type_filter]
|
||||
if vendor_filter:
|
||||
profiles = [p for p in profiles if p.get("vendor", "") == vendor_filter]
|
||||
return self._json_cors({"result": profiles})
|
||||
|
||||
def _find_orca_filaments_json(self) -> str | None:
|
||||
"""Findet die statische JSON-Datei. Liegt analog zu web/ unter _WEB_BASE/data/
|
||||
— in allen 3 Deployment-Modi:
|
||||
• Dev: bridge/data/orca_filaments.json
|
||||
• Docker: /app/data/orca_filaments.json (statisch im Image, NICHT das
|
||||
Volume-data/ das Runtime-State enthält — siehe Dockerfile)
|
||||
• Onefile: sys._MEIPASS/data/orca_filaments.json
|
||||
Wenn das Volume-mounted /app/data/ den static-data überdeckt, liegt eine
|
||||
Kopie auch unter _WEB_BASE/data/ (= /app/ im Docker = derselbe Pfad).
|
||||
Bei Konflikt: zweite Suche unter ../bridge/data/ als Fallback für Dev-Setups."""
|
||||
candidates = [
|
||||
# Docker: COPY bridge/data → /app/static/ (data/ ist Volume → überdeckt)
|
||||
os.path.join(_WEB_BASE, "static", "orca_filaments.json"),
|
||||
os.path.join(_WEB_BASE, "data", "orca_filaments.json"),
|
||||
]
|
||||
here = os.path.dirname(os.path.abspath(__file__))
|
||||
candidates.append(os.path.join(here, "data", "orca_filaments.json"))
|
||||
candidates.append(os.path.join(here, "..", "bridge", "data", "orca_filaments.json"))
|
||||
for c in candidates:
|
||||
if os.path.isfile(c):
|
||||
return c
|
||||
return None
|
||||
|
||||
async def handle_kx_filament_slot_profile(self, request):
|
||||
"""POST /kx/filament/slots/<idx>/profile — speichert oder löscht
|
||||
ein User-Override-Mapping für einen einzelnen AMS-Slot.
|
||||
|
||||
Body: {"id": "OGFL01", "vendor": "Polymaker"}
|
||||
{"id": ""} → Mapping entfernen → Default-Fallback aktiv
|
||||
"""
|
||||
try:
|
||||
slot_idx = int(request.match_info.get("idx", "-1"))
|
||||
except ValueError:
|
||||
return self._json_cors({"error": "bad slot index"}, status=400)
|
||||
if slot_idx < 0:
|
||||
return self._json_cors({"error": "bad slot index"}, status=400)
|
||||
try:
|
||||
data = await request.json()
|
||||
except Exception:
|
||||
data = {}
|
||||
new_id = (data.get("id") or "").strip()
|
||||
new_vendor = (data.get("vendor") or "").strip()
|
||||
if new_id:
|
||||
self._filament_profiles[slot_idx] = {"id": new_id, "vendor": new_vendor}
|
||||
else:
|
||||
self._filament_profiles.pop(slot_idx, None)
|
||||
# Persistieren in config.ini
|
||||
try:
|
||||
import config_loader as _cl
|
||||
_cl.save_filament_profiles(self._filament_profiles)
|
||||
except Exception as e:
|
||||
log.warning(f"save_filament_profiles failed: {e}")
|
||||
return self._json_cors({"error": str(e)}, status=500)
|
||||
return self._json_cors({"result": "ok",
|
||||
"slot_index": slot_idx,
|
||||
"id": new_id,
|
||||
"vendor": new_vendor})
|
||||
|
||||
async def handle_kx_history(self, request):
|
||||
limit = int(request.rel_url.query.get("limit", 50))
|
||||
offset = int(request.rel_url.query.get("offset", 0))
|
||||
@@ -1848,6 +2164,92 @@ class KobraXBridge:
|
||||
})
|
||||
return web.json_response({"result": files})
|
||||
|
||||
# ── Moonraker-Stubs für moonraker-obico ──────────────────────────────────
|
||||
async def handle_access_api_key(self, request):
|
||||
"""Moonraker /access/api_key — wir haben keine Auth, geben einen Dummy zurück.
|
||||
moonraker-obico stellt sonst eine WARNING ins Log."""
|
||||
return web.json_response({"result": "kx-bridge-no-auth-required"})
|
||||
|
||||
async def handle_machine_update_status(self, request):
|
||||
"""Moonraker /machine/update/status — Obico zeigt installierte Plugins damit."""
|
||||
return web.json_response({
|
||||
"result": {
|
||||
"busy": False,
|
||||
"github_rate_limit": 60,
|
||||
"github_requests_remaining": 60,
|
||||
"github_limit_reset_time": time.time() + 3600,
|
||||
"version_info": {},
|
||||
}
|
||||
})
|
||||
|
||||
async def handle_history_list(self, request):
|
||||
"""Moonraker /server/history/list — Job-Historie aus dem GCodeStore.
|
||||
|
||||
moonraker-obico nutzt nur das letzte Element (limit=1, order=desc)."""
|
||||
try:
|
||||
limit = int(request.rel_url.query.get("limit", "50"))
|
||||
except ValueError:
|
||||
limit = 50
|
||||
try:
|
||||
jobs = self._store.list_jobs(limit=limit) or []
|
||||
except Exception:
|
||||
jobs = []
|
||||
# Mapping auf Moonraker-Schema. Moonraker liefert start_time als Unix-
|
||||
# Timestamp (float), nicht ISO-String — moonraker-obico parsed das mit
|
||||
# int(start_time) und crasht sonst.
|
||||
def _to_unix_ts(iso: str | None) -> float:
|
||||
if not iso:
|
||||
return 0.0
|
||||
try:
|
||||
from datetime import datetime
|
||||
# Format aus GCodeStore: "2026-05-27T21:22:25Z"
|
||||
dt = datetime.strptime(iso, "%Y-%m-%dT%H:%M:%SZ")
|
||||
return dt.replace(tzinfo=__import__("datetime").timezone.utc).timestamp()
|
||||
except Exception:
|
||||
return 0.0
|
||||
result_jobs = []
|
||||
for j in jobs:
|
||||
start_ts = _to_unix_ts(j.get("started_at"))
|
||||
dur = j.get("duration_sec") or 0
|
||||
result_jobs.append({
|
||||
"job_id": j.get("id"),
|
||||
"exists": True,
|
||||
"end_time": (start_ts + dur) if start_ts and dur else None,
|
||||
"filament_used": 0.0,
|
||||
"filename": j.get("filename", ""),
|
||||
"metadata": {},
|
||||
"print_duration": dur,
|
||||
"status": j.get("status") or "completed",
|
||||
"start_time": start_ts,
|
||||
"total_duration": dur,
|
||||
})
|
||||
return web.json_response({"result": {"count": len(result_jobs), "jobs": result_jobs}})
|
||||
|
||||
async def handle_webcams_list(self, request):
|
||||
"""Moonraker /server/webcams/list — Obico holt die Webcam-URLs hier."""
|
||||
return web.json_response({
|
||||
"result": {
|
||||
"webcams": [
|
||||
{
|
||||
"name": "KX-Bridge",
|
||||
"location": "printer",
|
||||
"service": "mjpegstreamer",
|
||||
"enabled": True,
|
||||
"icon": "mdiWebcam",
|
||||
"target_fps": 5,
|
||||
"target_fps_idle": 2,
|
||||
"stream_url": "/api/camera/stream",
|
||||
"snapshot_url": "/api/camera/snapshot",
|
||||
"flip_horizontal": False,
|
||||
"flip_vertical": False,
|
||||
"rotation": 0,
|
||||
"aspect_ratio": "16:9",
|
||||
"extra_data": {},
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
async def handle_file_upload(self, request):
|
||||
log.info(f"Upload-Request: {request.method} {request.path_qs} CT={request.headers.get('Content-Type','')[:60]}")
|
||||
ct = request.headers.get("Content-Type", "")
|
||||
@@ -2514,13 +2916,23 @@ class KobraXBridge:
|
||||
{"taskid": taskid, "settings": {"target_hotbed_temp": b}},
|
||||
))
|
||||
else:
|
||||
# Idle: standard tempature/set with both values
|
||||
n = int(float(nozzle)) if nozzle is not None else int(self._state["nozzle_target"])
|
||||
b = int(float(bed)) if bed is not None else int(self._state["bed_target"])
|
||||
await loop.run_in_executor(None, lambda: self.client.publish(
|
||||
# Idle: tempature/set über `web/printer`-Topic mit `type`-Feld.
|
||||
# Live-Sniff 2026-05-29 vom Anycubic Slicer Next bestätigt:
|
||||
# topic = web/printer/.../tempature
|
||||
# data = {"type": 0|1|2, "target_hotbed_temp": B, "target_nozzle_temp": N}
|
||||
# type-Werte (aus Workbench-Vue): 0=Nozzle, 1=Bed, 2=beide.
|
||||
# Ohne `type` ODER auf `slicer/printer`-Topic → Systemfehler am Drucker.
|
||||
if nozzle is not None and bed is not None:
|
||||
t, n, b = 2, int(float(nozzle)), int(float(bed))
|
||||
elif nozzle is not None:
|
||||
t, n, b = 0, int(float(nozzle)), 0
|
||||
elif bed is not None:
|
||||
t, n, b = 1, 0, int(float(bed))
|
||||
else:
|
||||
return web.json_response({"result": "ok"})
|
||||
await loop.run_in_executor(None, lambda: self.client.publish_web(
|
||||
"tempature", "set",
|
||||
{"target_nozzle_temp": n, "target_hotbed_temp": b},
|
||||
timeout=0
|
||||
{"type": t, "target_nozzle_temp": n, "target_hotbed_temp": b},
|
||||
))
|
||||
return web.json_response({"result": "ok"})
|
||||
|
||||
@@ -2545,34 +2957,28 @@ class KobraXBridge:
|
||||
return web.json_response({"result": "ok"})
|
||||
|
||||
async def handle_api_camera_snapshot(self, request):
|
||||
"""Einzelner JPEG-Frame aus dem Kamera-Stream – für Obico und andere Snapshot-Clients."""
|
||||
"""Letzter JPEG-Frame aus dem CameraCache — instant aus dem RAM,
|
||||
keine eigene ffmpeg-Instanz mehr (verhindert Single-Client-429 am
|
||||
Drucker und ist ~1 s schneller)."""
|
||||
url = self._state.get("camera_url", "")
|
||||
if not url:
|
||||
return web.Response(status=503, text="Keine Kamera-URL bekannt")
|
||||
is_rtsp = url.lower().startswith("rtsp://")
|
||||
input_args = ["-fflags", "nobuffer", "-flags", "low_delay"]
|
||||
if is_rtsp:
|
||||
input_args += ["-probesize", "32", "-analyzeduration", "0", "-rtsp_transport", "tcp"]
|
||||
else:
|
||||
input_args += ["-probesize", "1000000", "-analyzeduration", "1000000"]
|
||||
try:
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
_find_ffmpeg(), "-loglevel", "quiet",
|
||||
*input_args, "-i", url,
|
||||
"-frames:v", "1", "-f", "mjpeg", "-q:v", "3",
|
||||
"pipe:1",
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.DEVNULL,
|
||||
)
|
||||
jpeg, _ = await asyncio.wait_for(proc.communicate(), timeout=20)
|
||||
except asyncio.TimeoutError:
|
||||
return web.Response(status=504, text="Snapshot-Timeout")
|
||||
except Exception as e:
|
||||
return web.Response(status=503, text=str(e))
|
||||
self.camera_cache.set_url(url)
|
||||
await self.camera_cache.ensure_running()
|
||||
# Initialer Warmup: bis zu 5 s auf ersten Frame warten
|
||||
deadline = time.time() + 5.0
|
||||
while not self.camera_cache.latest_jpeg and time.time() < deadline:
|
||||
await asyncio.sleep(0.1)
|
||||
jpeg = self.camera_cache.latest_jpeg
|
||||
if not jpeg:
|
||||
return web.Response(status=503, text="Kein Frame empfangen")
|
||||
return web.Response(body=jpeg, content_type="image/jpeg",
|
||||
headers={"Cache-Control": "no-cache"})
|
||||
return web.Response(status=503, text="Noch kein Frame im Cache")
|
||||
# Wenn der letzte Frame älter als 10 s ist → der Cache-ffmpeg läuft
|
||||
# vermutlich nicht mehr stabil, trotzdem ausliefern aber Stale-Header.
|
||||
age = time.time() - self.camera_cache.latest_jpeg_ts
|
||||
headers = {"Cache-Control": "no-cache"}
|
||||
if age > 10:
|
||||
headers["X-Frame-Age"] = f"{age:.1f}"
|
||||
return web.Response(body=jpeg, content_type="image/jpeg", headers=headers)
|
||||
|
||||
async def handle_camera_stream(self, request):
|
||||
"""MJPEG proxy: FLV → MJPEG via ffmpeg, served as multipart/x-mixed-replace."""
|
||||
@@ -2659,6 +3065,38 @@ class KobraXBridge:
|
||||
|
||||
return resp
|
||||
|
||||
async def handle_camera_h264(self, request):
|
||||
"""H.264-Passthrough als MPEG-TS, gespeist aus dem zentralen
|
||||
CameraCache-Fanout. Erlaubt mehrere parallele Konsumenten ohne
|
||||
zusätzliche FLV-Verbindung zum Drucker (Single-Client-Limit)."""
|
||||
url = self._state.get("camera_url", "")
|
||||
if not url:
|
||||
return web.Response(status=503, text="Keine Kamera-URL bekannt")
|
||||
self.camera_cache.set_url(url)
|
||||
await self.camera_cache.ensure_running()
|
||||
|
||||
q: asyncio.Queue[bytes] = asyncio.Queue(maxsize=64)
|
||||
self.camera_cache.h264_subscribers.add(q)
|
||||
|
||||
resp = web.StreamResponse(headers={
|
||||
"Content-Type": "video/mp2t",
|
||||
"Cache-Control": "no-cache",
|
||||
"Connection": "keep-alive",
|
||||
})
|
||||
await resp.prepare(request)
|
||||
try:
|
||||
while True:
|
||||
chunk = await q.get()
|
||||
try:
|
||||
await resp.write(chunk)
|
||||
except (ConnectionResetError, asyncio.CancelledError):
|
||||
break
|
||||
except Exception as e:
|
||||
log.warning(f"H.264-Stream unterbrochen: {e}")
|
||||
finally:
|
||||
self.camera_cache.h264_subscribers.discard(q)
|
||||
return resp
|
||||
|
||||
async def handle_serve_file(self, request):
|
||||
"""Liefert hochgeladene G-Code-Dateien vom Temp-Verzeichnis (für Drucker-Download)."""
|
||||
filename = os.path.basename(request.match_info.get("filename", ""))
|
||||
@@ -2747,14 +3185,71 @@ class KobraXBridge:
|
||||
"result": {"namespace": namespace, "key": key, "value": None}
|
||||
})
|
||||
|
||||
# mainsail/presets: Obico fragt nach Temperatur-Presets. Schema wird in
|
||||
# find_all_thermal_presets als data['value']['presets'].values() ausgewertet,
|
||||
# also brauchen wir mindestens {presets: {}} damit kein Crash.
|
||||
if namespace == "mainsail":
|
||||
if key == "presets":
|
||||
return web.json_response({
|
||||
"result": {"namespace": "mainsail", "key": "presets",
|
||||
"value": {"presets": {}}}
|
||||
})
|
||||
return web.json_response({
|
||||
"result": {"namespace": "mainsail", "key": key, "value": {}}
|
||||
})
|
||||
|
||||
# obico-namespace: in-memory KV-Store für Plugin-Settings (key=printer_id etc.)
|
||||
if namespace == "obico":
|
||||
store = self._moonraker_kv_store.setdefault("obico", {})
|
||||
if key and key in store:
|
||||
return web.json_response({
|
||||
"result": {"namespace": "obico", "key": key, "value": store[key]}
|
||||
})
|
||||
return web.json_response({
|
||||
"result": {"namespace": "obico", "key": key, "value": store if not key else None}
|
||||
})
|
||||
|
||||
return web.json_response(
|
||||
{"error": {"code": 404, "message": f"Namespace '{namespace}' not found"}},
|
||||
status=404
|
||||
)
|
||||
|
||||
async def handle_moonraker_database_post(self, request):
|
||||
"""POST /server/database/item — KV-Store-Write (von moonraker-obico verwendet).
|
||||
moonraker-obico sendet namespace/key/value als form-urlencoded POST-params."""
|
||||
# Versuche JSON, fallback auf form-data, fallback auf Query-Params
|
||||
namespace = ""
|
||||
key = ""
|
||||
value = None
|
||||
try:
|
||||
data = await request.json()
|
||||
if isinstance(data, dict):
|
||||
namespace = data.get("namespace", "")
|
||||
key = data.get("key", "")
|
||||
value = data.get("value")
|
||||
except Exception:
|
||||
try:
|
||||
form = await request.post()
|
||||
namespace = form.get("namespace", "") or ""
|
||||
key = form.get("key", "") or ""
|
||||
value = form.get("value")
|
||||
except Exception:
|
||||
pass
|
||||
if not namespace:
|
||||
namespace = request.rel_url.query.get("namespace", "")
|
||||
if not key:
|
||||
key = request.rel_url.query.get("key", "")
|
||||
if namespace and key:
|
||||
store = self._moonraker_kv_store.setdefault(namespace, {})
|
||||
store[key] = value
|
||||
return web.json_response({
|
||||
"result": {"namespace": namespace, "key": key, "value": value}
|
||||
})
|
||||
return web.json_response({"error": {"code": 400, "message": "namespace + key required"}}, status=400)
|
||||
|
||||
async def handle_database_list(self, request):
|
||||
"""OrcaSlicer prüft welche Namespaces vorhanden sind um MMU-Typ zu erkennen."""
|
||||
return web.json_response({"result": {"namespaces": ["lane_data"]}})
|
||||
return web.json_response({"result": {"namespaces": ["lane_data", "mainsail", "obico"]}})
|
||||
|
||||
def _get_ams_slots_fresh(self):
|
||||
"""Frische Slot-Daten per getInfo holen, Fallback auf gecachte."""
|
||||
@@ -3212,6 +3707,130 @@ class KobraXBridge:
|
||||
])
|
||||
return web.Response(body=ico, content_type="image/x-icon")
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Klipper G-Code-Script Emulation für moonraker-obico
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
async def _exec_gcode_script(self, script: str) -> str:
|
||||
"""Mappt eine Klipper- oder Marlin-G-Code-Zeile auf einen MQTT-Befehl
|
||||
an den Kobra X. Unterstützt:
|
||||
- PAUSE / M25, RESUME / M24, CANCEL_PRINT / M0/M1/M524/ABORT
|
||||
- M104 S<temp> → Nozzle-Temperatur
|
||||
- M140 S<temp> → Bett-Temperatur
|
||||
- SET_HEATER_TEMPERATURE HEATER=extruder TARGET=200 (Klipper)
|
||||
- SET_HEATER_TEMPERATURE HEATER=heater_bed TARGET=60 (Klipper)
|
||||
Unbekannte Scripts werden mit 'ok' quittiert (Obico schickt z.B. G28
|
||||
zum Home, das ignoriert die Bridge stillschweigend)."""
|
||||
if not script:
|
||||
return "ok"
|
||||
s = script.strip().upper()
|
||||
loop = asyncio.get_event_loop()
|
||||
|
||||
def _parse_marlin_temp(line: str) -> int | None:
|
||||
"""Aus 'M104 S200' oder 'M140 S60' den Temperatur-Wert ziehen."""
|
||||
try:
|
||||
return int(line.split("S", 1)[1].split()[0])
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def _parse_klipper_set_heater(line: str) -> tuple[str | None, int | None]:
|
||||
"""Aus 'SET_HEATER_TEMPERATURE HEATER=extruder TARGET=143' die
|
||||
Heater-ID + Target rausziehen. Heater ist 'extruder' oder
|
||||
'heater_bed', Target ist int. Liefert (None,None) bei Fehler."""
|
||||
heater = None
|
||||
target = None
|
||||
for part in line.split():
|
||||
if part.startswith("HEATER="):
|
||||
heater = part.split("=", 1)[1].strip().lower()
|
||||
elif part.startswith("TARGET="):
|
||||
try:
|
||||
target = int(float(part.split("=", 1)[1]))
|
||||
except Exception:
|
||||
pass
|
||||
return heater, target
|
||||
|
||||
async def _set_temps(nozzle: int | None, bed: int | None):
|
||||
"""Setzt Nozzle/Bed-Temperatur über den richtigen MQTT-Pfad —
|
||||
druckend: print/update mit taskid, idle: tempature/set mit beiden."""
|
||||
is_printing = self._state.get("print_state") in ("printing", "paused")
|
||||
if is_printing:
|
||||
taskid = self._state.get("taskid", "")
|
||||
if nozzle is not None:
|
||||
await loop.run_in_executor(None, lambda: self.client.publish_web(
|
||||
"print", "update",
|
||||
{"taskid": taskid, "settings": {"target_nozzle_temp": int(nozzle)}},
|
||||
))
|
||||
if bed is not None:
|
||||
await loop.run_in_executor(None, lambda: self.client.publish_web(
|
||||
"print", "update",
|
||||
{"taskid": taskid, "settings": {"target_hotbed_temp": int(bed)}},
|
||||
))
|
||||
else:
|
||||
# Idle: tempature/set via web/printer-Topic mit type-Feld
|
||||
# (Live-Sniff 2026-05-29). type: 0=Nozzle, 1=Bed, 2=beide.
|
||||
if nozzle is not None and bed is not None:
|
||||
t, n, b = 2, int(nozzle), int(bed)
|
||||
elif nozzle is not None:
|
||||
t, n, b = 0, int(nozzle), 0
|
||||
elif bed is not None:
|
||||
t, n, b = 1, 0, int(bed)
|
||||
else:
|
||||
return
|
||||
await loop.run_in_executor(None, lambda: self.client.publish_web(
|
||||
"tempature", "set",
|
||||
{"type": t, "target_nozzle_temp": n, "target_hotbed_temp": b},
|
||||
))
|
||||
|
||||
try:
|
||||
if s in ("PAUSE", "M25"):
|
||||
await loop.run_in_executor(None, self.client.pause_print)
|
||||
elif s in ("RESUME", "M24"):
|
||||
await loop.run_in_executor(None, self.client.resume_print)
|
||||
elif s in ("CANCEL_PRINT", "M0", "M1", "M524", "ABORT"):
|
||||
await loop.run_in_executor(None, self.client.stop_print)
|
||||
elif s.startswith("M104 "):
|
||||
t = _parse_marlin_temp(s)
|
||||
if t is not None:
|
||||
log.info(f"gcode.script: Nozzle-Target {t}°C (M104)")
|
||||
await _set_temps(t, None)
|
||||
elif s.startswith("M140 "):
|
||||
t = _parse_marlin_temp(s)
|
||||
if t is not None:
|
||||
log.info(f"gcode.script: Bed-Target {t}°C (M140)")
|
||||
await _set_temps(None, t)
|
||||
elif s.startswith("SET_HEATER_TEMPERATURE"):
|
||||
heater, target = _parse_klipper_set_heater(s)
|
||||
if target is not None and heater:
|
||||
if heater == "extruder":
|
||||
log.info(f"gcode.script: Nozzle-Target {target}°C (Klipper)")
|
||||
await _set_temps(target, None)
|
||||
elif heater in ("heater_bed", "bed"):
|
||||
log.info(f"gcode.script: Bed-Target {target}°C (Klipper)")
|
||||
await _set_temps(None, target)
|
||||
else:
|
||||
log.debug(f"gcode.script: unbekannter Heater '{heater}' ignoriert")
|
||||
else:
|
||||
# Unbekanntes Script: stillschweigend OK quittieren.
|
||||
log.debug(f"gcode.script ignored: {s[:60]}")
|
||||
except Exception as e:
|
||||
log.warning(f"gcode.script {s[:30]}: {e}")
|
||||
return "ok"
|
||||
|
||||
async def handle_printer_gcode_script(self, request):
|
||||
"""HTTP POST /printer/gcode/script — Klipper-G-Code-Wrapper (siehe _exec_gcode_script)."""
|
||||
script = ""
|
||||
if request.method == "POST":
|
||||
try:
|
||||
body = await request.json()
|
||||
if isinstance(body, dict):
|
||||
script = body.get("script", "") or ""
|
||||
except Exception:
|
||||
pass
|
||||
if not script:
|
||||
script = request.rel_url.query.get("script", "")
|
||||
result = await self._exec_gcode_script(script)
|
||||
return web.json_response({"result": result})
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# WebSocket handler
|
||||
# -------------------------------------------------------------------------
|
||||
@@ -3324,6 +3943,67 @@ class KobraXBridge:
|
||||
result = {"system_info": {"cpu_info": {"cpu_desc": "Kobra X Bridge"}}}
|
||||
elif method == "server.files.list":
|
||||
result = []
|
||||
# ── moonraker-obico passthru-Targets ──
|
||||
elif method == "printer.gcode.script":
|
||||
script = (params.get("script") or "").strip().upper() if isinstance(params, dict) else ""
|
||||
result = await self._exec_gcode_script(script)
|
||||
elif method in ("server.connection.identify",):
|
||||
# Obico identifiziert sich beim Connect. Connection-ID egal.
|
||||
result = {"connection_id": 1}
|
||||
elif method == "connection.register_remote_method":
|
||||
# Obico registriert obico_remote_event-Callback. Wir akzeptieren leer.
|
||||
result = "ok"
|
||||
elif method == "server.webcams.list":
|
||||
# WS-Variante des HTTP-Endpoints
|
||||
result = {"webcams": [{
|
||||
"name": "KX-Bridge", "location": "printer", "service": "mjpegstreamer",
|
||||
"enabled": True, "stream_url": "/api/camera/stream",
|
||||
"snapshot_url": "/api/camera/snapshot",
|
||||
"flip_horizontal": False, "flip_vertical": False, "rotation": 0,
|
||||
"target_fps": 5, "aspect_ratio": "16:9",
|
||||
}]}
|
||||
elif method == "server.history.list":
|
||||
# Reuse HTTP-Handler-Logik (Moonraker-Schema mit Unix-TS).
|
||||
try:
|
||||
jobs = self._store.list_jobs(limit=50) or []
|
||||
except Exception:
|
||||
jobs = []
|
||||
from datetime import datetime, timezone as _tz
|
||||
def _ts(iso):
|
||||
try:
|
||||
return datetime.strptime(iso, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=_tz.utc).timestamp()
|
||||
except Exception:
|
||||
return 0.0
|
||||
result = {"count": len(jobs), "jobs": [
|
||||
{"job_id": j.get("id"), "exists": True, "filename": j.get("filename",""),
|
||||
"status": j.get("status") or "completed",
|
||||
"print_duration": j.get("duration_sec") or 0,
|
||||
"total_duration": j.get("duration_sec") or 0,
|
||||
"start_time": _ts(j.get("started_at")),
|
||||
"end_time": (_ts(j.get("started_at")) + (j.get("duration_sec") or 0)) if j.get("started_at") and j.get("duration_sec") else None,
|
||||
"filament_used": 0.0, "metadata": {}}
|
||||
for j in jobs
|
||||
]}
|
||||
elif method == "machine.update.status":
|
||||
result = {"busy": False, "version_info": {}}
|
||||
elif method == "server.files.metadata":
|
||||
# Obico fragt nach Metadaten zu einer Datei (filename in params)
|
||||
fname = (params or {}).get("filename") if isinstance(params, dict) else None
|
||||
meta = {}
|
||||
if fname:
|
||||
try:
|
||||
rec = self._store.get_file_by_filename(fname) if hasattr(self._store, "get_file_by_filename") else None
|
||||
except Exception:
|
||||
rec = None
|
||||
if rec:
|
||||
meta = {
|
||||
"filename": rec.get("filename"),
|
||||
"size": rec.get("size_bytes") or 0,
|
||||
"modified": time.time(),
|
||||
"estimated_time": rec.get("est_print_time_sec") or 0,
|
||||
"thumbnails": [],
|
||||
}
|
||||
result = meta
|
||||
else:
|
||||
log.debug(f"Unbekannte RPC-Methode: {method}")
|
||||
result = {}
|
||||
@@ -3469,6 +4149,13 @@ def build_app(bridge: KobraXBridge) -> web.Application:
|
||||
r.add_post("/printer/print/resume", bridge.handle_print_resume)
|
||||
r.add_post("/printer/print/cancel", bridge.handle_print_cancel)
|
||||
|
||||
# Moonraker-Stubs für moonraker-obico
|
||||
r.add_get("/access/api_key", bridge.handle_access_api_key)
|
||||
r.add_get("/machine/update/status", bridge.handle_machine_update_status)
|
||||
r.add_get("/server/history/list", bridge.handle_history_list)
|
||||
r.add_get("/server/webcams/list", bridge.handle_webcams_list)
|
||||
r.add_post("/printer/gcode/script", bridge.handle_printer_gcode_script)
|
||||
|
||||
# OctoPrint compatibility (OrcaSlicer probes this + uploads here)
|
||||
r.add_get("/api/version", bridge.handle_octoprint_version)
|
||||
r.add_post("/api/files/local", bridge.handle_file_upload)
|
||||
@@ -3476,6 +4163,7 @@ def build_app(bridge: KobraXBridge) -> web.Application:
|
||||
|
||||
# Moonraker database (OrcaSlicer AMS-Sync)
|
||||
r.add_get("/server/database/item", bridge.handle_moonraker_database)
|
||||
r.add_post("/server/database/item", bridge.handle_moonraker_database_post)
|
||||
r.add_get("/server/database/list", bridge.handle_database_list)
|
||||
|
||||
# New API endpoints
|
||||
@@ -3493,6 +4181,7 @@ def build_app(bridge: KobraXBridge) -> web.Application:
|
||||
r.add_post("/api/temperature", bridge.handle_api_temperature)
|
||||
r.add_get("/api/camera", bridge.handle_api_camera)
|
||||
r.add_get("/api/camera/stream", bridge.handle_camera_stream)
|
||||
r.add_get("/api/camera/h264", bridge.handle_camera_h264)
|
||||
r.add_get("/api/camera/snapshot", bridge.handle_api_camera_snapshot)
|
||||
r.add_post("/api/camera/start", bridge.handle_api_camera_start)
|
||||
r.add_post("/api/camera/stop", bridge.handle_api_camera_stop)
|
||||
@@ -3515,6 +4204,8 @@ def build_app(bridge: KobraXBridge) -> web.Application:
|
||||
r.add_get("/kx/files/{file_id}/download", bridge.handle_kx_file_download)
|
||||
r.add_post("/kx/files/{file_id}/verify", bridge.handle_kx_file_verify)
|
||||
r.add_get("/kx/filament/slots", bridge.handle_kx_filament_slots)
|
||||
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/ui/{name:.*}", bridge.handle_kx_ui_asset)
|
||||
r.add_get("/kx/files/{id}/objects", bridge.handle_kx_file_objects)
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
# ein → zur Laufzeit über sys._MEIPASS lesbar (_WEB_BASE in der Bridge).
|
||||
from PyInstaller.utils.hooks import collect_all
|
||||
|
||||
datas = [("web", "web")]
|
||||
datas = [("web", "web"), ("data", "static")] # bridge/data/ → static/ im _MEIPASS
|
||||
binaries = []
|
||||
hiddenimports = []
|
||||
|
||||
|
||||
@@ -361,6 +361,10 @@ function applyLang(){
|
||||
// Slot-Edit-Dialog
|
||||
setText('lbl-slot-color',T.slot_edit_color);
|
||||
setText('lbl-slot-material',T.slot_edit_material);
|
||||
setText('lbl-slot-profile',T.slot_edit_profile);
|
||||
setText('slot-profile-hint',T.slot_edit_profile_hint);
|
||||
var defOpt=document.getElementById('slot-profile-default-opt');
|
||||
if(defOpt) defOpt.textContent=T.slot_edit_profile_default;
|
||||
setText('btn-slot-edit-save',T.slot_edit_save);
|
||||
updateSlotEditFeedButton();
|
||||
var mi=document.getElementById('slot-edit-mat');if(mi)mi.setAttribute('placeholder',T.slot_edit_custom);
|
||||
@@ -370,7 +374,11 @@ function applyLang(){
|
||||
setText('file-ready-btn',T.file_ready_btn);
|
||||
setText('file-slots-btn',T.file_slots_btn);
|
||||
setText('file-cancel-btn',T.file_cancel_btn);
|
||||
setText('file-cancel-btn',T.file_cancel_btn);
|
||||
// GCode-Browser-Karten: Texte sind via innerHTML eingebacken,
|
||||
// bei Sprachwechsel komplett neu rendern.
|
||||
if(typeof renderStore==='function' && typeof storeFiles!=='undefined'){
|
||||
try{ renderStore(); }catch(e){}
|
||||
}
|
||||
}
|
||||
function setText(id,txt){var el=document.getElementById(id);if(el)el.textContent=txt;}
|
||||
|
||||
@@ -904,6 +912,42 @@ function updateSlotEditFeedButton(){
|
||||
btn.style.display='';
|
||||
btn.textContent=_slotEditLoaded?tr('slot_edit_unload'):tr('slot_edit_load');
|
||||
}
|
||||
var _orcaFilamentCache=null; // [{id,name,vendor,type,color}, …]
|
||||
function _loadOrcaFilaments(cb){
|
||||
if(_orcaFilamentCache){ cb(_orcaFilamentCache); return; }
|
||||
fetch(_apiUrl('/kx/filament/profiles')).then(function(r){return r.json();}).then(function(d){
|
||||
_orcaFilamentCache=d.result||[];
|
||||
cb(_orcaFilamentCache);
|
||||
}).catch(function(){ cb([]); });
|
||||
}
|
||||
function _fillSlotProfileDropdown(material, currentId){
|
||||
var sel=document.getElementById('slot-edit-profile');
|
||||
if(!sel) return;
|
||||
_loadOrcaFilaments(function(profiles){
|
||||
// Type-Filter: nur Profile vom passenden material zeigen (z.B. PLA → alle PLA-Varianten)
|
||||
var matU=(material||'').toUpperCase().trim();
|
||||
var matched=profiles.filter(function(p){
|
||||
var pt=(p.type||'').toUpperCase();
|
||||
// PLA-CF, PLA-SILK etc. zählen auch zu PLA
|
||||
return matU==='' || pt===matU || pt.startsWith(matU+'-') || pt.startsWith(matU+' ');
|
||||
});
|
||||
sel.innerHTML='<option value="">'+tr('slot_edit_profile_default')+'</option>';
|
||||
// Gruppieren nach Vendor
|
||||
var byVendor={};
|
||||
matched.forEach(function(p){ (byVendor[p.vendor]=byVendor[p.vendor]||[]).push(p); });
|
||||
Object.keys(byVendor).sort().forEach(function(v){
|
||||
var g=document.createElement('optgroup'); g.label=v;
|
||||
byVendor[v].forEach(function(p){
|
||||
var o=document.createElement('option');
|
||||
o.value=p.id; o.dataset.vendor=p.vendor;
|
||||
o.textContent=p.name;
|
||||
if(p.id===currentId) o.selected=true;
|
||||
g.appendChild(o);
|
||||
});
|
||||
sel.appendChild(g);
|
||||
});
|
||||
});
|
||||
}
|
||||
function openSlotEdit(i){
|
||||
var slot=(window._amsSlots||[])[i]||{};
|
||||
var globalIdx=slot.global_index!=null?slot.global_index:(slot.index!=null?slot.index:i);
|
||||
@@ -923,6 +967,16 @@ function openSlotEdit(i){
|
||||
+'style="padding:4px 10px;border-radius:6px;border:1px solid var(--border);cursor:pointer;font-size:12px;'
|
||||
+(m===mat?'background:var(--accent);color:#fff':'background:var(--raised);color:var(--txt2)')+'">'+m+'</button>';
|
||||
}).join('');
|
||||
// OrcaSlicer-Profil-Dropdown: aktuellen User-Override für diesen Slot
|
||||
// aus /kx/filament/slots holen (enthält filament_id+filament_vendor).
|
||||
// Mit dem material-Filter (PLA→PLA*) wird die Liste auf passende Profile reduziert.
|
||||
fetch(_apiUrl('/kx/filament/slots')).then(function(r){return r.json();}).then(function(d){
|
||||
var arr=d.result||[];
|
||||
var entry=arr.find(function(x){return x.slot_index===globalIdx;})||{};
|
||||
window._slotProfileMap=window._slotProfileMap||{};
|
||||
window._slotProfileMap[globalIdx]={id:entry.filament_id||'',vendor:entry.filament_vendor||''};
|
||||
_fillSlotProfileDropdown(mat, entry.filament_id||'');
|
||||
}).catch(function(){ _fillSlotProfileDropdown(mat,''); });
|
||||
updateSlotEditFeedButton();
|
||||
document.getElementById('slot-edit-modal').classList.add('open');
|
||||
}
|
||||
@@ -967,6 +1021,9 @@ function cancelReadyFile(){
|
||||
function selectMatPreset(m){
|
||||
document.getElementById('slot-edit-mat').value=m;
|
||||
highlightMatBtn(m);
|
||||
// Filament-Profile-Dropdown an neues Material anpassen
|
||||
// (vorherige Selektion zurücksetzen — andere Material-Profile passen nicht)
|
||||
_fillSlotProfileDropdown(m, '');
|
||||
}
|
||||
function highlightMatBtn(val){
|
||||
document.querySelectorAll('.mat-preset-btn').forEach(function(b){
|
||||
@@ -974,6 +1031,8 @@ function highlightMatBtn(val){
|
||||
b.style.background=on?'var(--accent)':'var(--raised)';
|
||||
b.style.color=on?'#fff':'var(--txt2)';
|
||||
});
|
||||
// Auch bei manueller Eingabe ins Material-Textfeld: Dropdown refreshen.
|
||||
if(val) _fillSlotProfileDropdown(val, '');
|
||||
}
|
||||
function hexToRgb(hex){
|
||||
var r=parseInt(hex.slice(1,3),16),g=parseInt(hex.slice(3,5),16),b=parseInt(hex.slice(5,7),16);
|
||||
@@ -983,11 +1042,30 @@ function saveSlotEdit(){
|
||||
var hex=document.getElementById('slot-edit-color').value;
|
||||
var mat=document.getElementById('slot-edit-mat').value.trim().toUpperCase()||'PLA';
|
||||
var color=hexToRgb(hex);
|
||||
post('/api/ams/set_slot',{index:_slotEditIndex,type:mat,color:color})
|
||||
var slotIdx=_slotEditIndex;
|
||||
// OrcaSlicer-Profil-Override: parallel persistieren (Profile bleiben auch
|
||||
// erhalten wenn der User nur Farbe/Material ändert)
|
||||
var profSel=document.getElementById('slot-edit-profile');
|
||||
var newProfId=profSel?profSel.value:'';
|
||||
var newProfVendor='';
|
||||
if(profSel && profSel.selectedOptions && profSel.selectedOptions[0]){
|
||||
newProfVendor=profSel.selectedOptions[0].dataset.vendor||'';
|
||||
}
|
||||
fetch(_apiUrl('/kx/filament/slots/'+slotIdx+'/profile'),{
|
||||
method:'POST',
|
||||
headers:{'Content-Type':'application/json'},
|
||||
body:JSON.stringify({id:newProfId,vendor:newProfVendor})
|
||||
}).then(function(r){return r.json();}).then(function(){
|
||||
window._slotProfileMap=window._slotProfileMap||{};
|
||||
if(newProfId){ window._slotProfileMap[slotIdx]={id:newProfId,vendor:newProfVendor}; }
|
||||
else delete window._slotProfileMap[slotIdx];
|
||||
}).catch(function(e){clog('Profil-Speichern fehlgeschlagen: '+e,'msg-err');});
|
||||
post('/api/ams/set_slot',{index:slotIdx,type:mat,color:color})
|
||||
.then(function(r){return r.json();})
|
||||
.then(function(r){
|
||||
closeSlotEdit();
|
||||
clog(tr('slot_edit_ok')+' '+(_slotEditIndex+1)+': '+mat+' '+hex,'msg-ok');
|
||||
var profSuffix=newProfId?(' ['+newProfId+']'):'';
|
||||
clog(tr('slot_edit_ok')+' '+(slotIdx+1)+': '+mat+' '+hex+profSuffix,'msg-ok');
|
||||
})
|
||||
.catch(function(e){clog('Fehler: '+e,'msg-err');});
|
||||
}
|
||||
|
||||
@@ -162,6 +162,15 @@
|
||||
oninput="highlightMatBtn(this.value)"
|
||||
style="margin-top:8px;width:100%;padding:6px 10px;background:var(--raised);border:1px solid var(--border);border-radius:6px;color:var(--txt);font-size:13px;box-sizing:border-box">
|
||||
</div>
|
||||
<!-- Orca-Filament-Profil-Override (für AMS-Sync) -->
|
||||
<div style="margin-bottom:20px">
|
||||
<div style="font-size:11px;color:var(--txt2);margin-bottom:6px" id="lbl-slot-profile"></div>
|
||||
<select id="slot-edit-profile"
|
||||
style="width:100%;padding:6px 10px;background:var(--raised);border:1px solid var(--border);border-radius:6px;color:var(--txt);font-size:13px;box-sizing:border-box">
|
||||
<option value="" id="slot-profile-default-opt"></option>
|
||||
</select>
|
||||
<div style="font-size:11px;color:var(--txt2);margin-top:4px" id="slot-profile-hint"></div>
|
||||
</div>
|
||||
<button class="btn" id="btn-slot-edit-feed" style="width:100%;margin-bottom:8px" onclick="slotEditFeed()"></button>
|
||||
<button class="modal-save" id="btn-slot-edit-save" onclick="saveSlotEdit()"></button>
|
||||
</div>
|
||||
|
||||
@@ -161,6 +161,9 @@
|
||||
"slot_edit_save": "💾 Speichern",
|
||||
"slot_edit_custom": "z.B. PLA, PETG, ABS…",
|
||||
"slot_edit_ok": "AMS Slot",
|
||||
"slot_edit_profile": "OrcaSlicer-Profil",
|
||||
"slot_edit_profile_hint": "Sendet beim OrcaSlicer-Sync die konkrete Marke statt nur „Generic\"",
|
||||
"slot_edit_profile_default": "— Generic (Default) —",
|
||||
"log_dir_all": "Alle",
|
||||
"log_lvl_label": "Level:",
|
||||
"file_ready_btn": "▶ Druck starten",
|
||||
|
||||
@@ -161,6 +161,9 @@
|
||||
"slot_edit_save": "💾 Save",
|
||||
"slot_edit_custom": "e.g. PLA, PETG, ABS…",
|
||||
"slot_edit_ok": "AMS Slot",
|
||||
"slot_edit_profile": "OrcaSlicer profile",
|
||||
"slot_edit_profile_hint": "Sent on OrcaSlicer sync as the specific brand instead of just \"Generic\"",
|
||||
"slot_edit_profile_default": "— Generic (default) —",
|
||||
"log_dir_all": "All",
|
||||
"log_lvl_label": "Level:",
|
||||
"file_ready_btn": "▶ Start Print",
|
||||
|
||||
@@ -161,6 +161,9 @@
|
||||
"slot_edit_save": "💾 Guardar",
|
||||
"slot_edit_custom": "p. ej. PLA, PETG, ABS…",
|
||||
"slot_edit_ok": "Ranura AMS",
|
||||
"slot_edit_profile": "Perfil de OrcaSlicer",
|
||||
"slot_edit_profile_hint": "Envía al sincronizar con OrcaSlicer la marca concreta en lugar de solo \"Generic\"",
|
||||
"slot_edit_profile_default": "— Genérico (Predeterminado) —",
|
||||
"log_dir_all": "Todos",
|
||||
"log_lvl_label": "Level:",
|
||||
"file_ready_btn": "▶ Iniciar impresion",
|
||||
|
||||
@@ -161,6 +161,9 @@
|
||||
"slot_edit_save": "💾 保存",
|
||||
"slot_edit_custom": "例如 PLA, PETG, ABS…",
|
||||
"slot_edit_ok": "AMS 槽位",
|
||||
"slot_edit_profile": "OrcaSlicer 配置",
|
||||
"slot_edit_profile_hint": "在 OrcaSlicer 同步时发送具体品牌,而不仅仅是“Generic”",
|
||||
"slot_edit_profile_default": "— 通用 (默认) —",
|
||||
"log_dir_all": "全部",
|
||||
"log_lvl_label": "级别:",
|
||||
"file_ready_btn": "▶ 开始打印",
|
||||
|
||||
Reference in New Issue
Block a user