Merge pull request 'feat: Spoolman filament tracking integration' (#65) from p2l/KX-Bridge-Release:feature/spoolman into master
This commit is contained in:
82
.gitea/ISSUE_TEMPLATE/bug_report.ym
Normal file
82
.gitea/ISSUE_TEMPLATE/bug_report.ym
Normal file
@@ -0,0 +1,82 @@
|
||||
name: "Bug Report"
|
||||
about: "Report a problem or error"
|
||||
title: "[BUG] "
|
||||
labels:
|
||||
- bug
|
||||
body:
|
||||
- type: dropdown
|
||||
id: os
|
||||
attributes:
|
||||
label: "Operating System"
|
||||
options:
|
||||
- "Windows 10"
|
||||
- "Windows 11"
|
||||
- "Ubuntu / Debian"
|
||||
- "Fedora / RHEL"
|
||||
- "Arch Linux"
|
||||
- "macOS"
|
||||
- "Other"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: install_type
|
||||
attributes:
|
||||
label: "Installation Type"
|
||||
options:
|
||||
- "Docker / Docker Compose"
|
||||
- "EXE / Binary (Windows)"
|
||||
- "Binary (Linux)"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: slicer
|
||||
attributes:
|
||||
label: "Slicer"
|
||||
options:
|
||||
- "OrcaSlicer (KX-Patch)"
|
||||
- "OrcaSlicer (Standard)"
|
||||
- "BambuStudio"
|
||||
- "PrusaSlicer"
|
||||
- "Other"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: slicer_version
|
||||
attributes:
|
||||
label: "Slicer Version"
|
||||
placeholder: "e.g. 2.4.0-dev-kx1"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: bridge_version
|
||||
attributes:
|
||||
label: "KX-Bridge Version"
|
||||
placeholder: "e.g. v0.9.20"
|
||||
description: "Found in the web interface or in the logs at startup."
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: "Problem Description"
|
||||
description: "What is happening? What did you expect to happen?"
|
||||
placeholder: |
|
||||
What happened:
|
||||
|
||||
What was expected:
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
label: "Logs / Error Message"
|
||||
description: "Relevant output from the KX-Bridge logs. Docker: `docker logs <container>`"
|
||||
render: text
|
||||
validations:
|
||||
required: false
|
||||
85
.gitea/ISSUE_TEMPLATE/bug_report.yml
Normal file
85
.gitea/ISSUE_TEMPLATE/bug_report.yml
Normal file
@@ -0,0 +1,85 @@
|
||||
name: Bug Report
|
||||
about: File a bug report
|
||||
title: "[Bug]: "
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Please fill out all required fields to help us resolve your issue faster.
|
||||
|
||||
- type: dropdown
|
||||
id: os
|
||||
attributes:
|
||||
label: Operating System
|
||||
options:
|
||||
- Windows 10
|
||||
- Windows 11
|
||||
- Ubuntu / Debian
|
||||
- Fedora / RHEL
|
||||
- Arch Linux
|
||||
- macOS
|
||||
- Other
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: install_type
|
||||
attributes:
|
||||
label: Installation Type
|
||||
options:
|
||||
- Docker / Docker Compose
|
||||
- EXE / Binary (Windows)
|
||||
- Binary (Linux)
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: slicer
|
||||
attributes:
|
||||
label: Slicer
|
||||
options:
|
||||
- OrcaSlicer (KX-Patch)
|
||||
- OrcaSlicer (Standard)
|
||||
- BambuStudio
|
||||
- PrusaSlicer
|
||||
- Other
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: slicer_version
|
||||
attributes:
|
||||
label: Slicer Version
|
||||
placeholder: "e.g. 2.4.0-dev-kx1"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: bridge_version
|
||||
attributes:
|
||||
label: KX-Bridge Version
|
||||
description: "Found in the web interface or in the logs at startup."
|
||||
placeholder: "e.g. v0.9.20"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: Problem Description
|
||||
description: What is happening? What did you expect to happen?
|
||||
placeholder: |
|
||||
What happened:
|
||||
|
||||
What was expected:
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
label: Logs / Error Message
|
||||
description: "Relevant output from the KX-Bridge logs. Docker: `docker logs <container>`"
|
||||
render: shell
|
||||
validations:
|
||||
required: false
|
||||
82
bug_report.yml
Normal file
82
bug_report.yml
Normal file
@@ -0,0 +1,82 @@
|
||||
name: "Bug Report"
|
||||
about: "Problem oder Fehler melden"
|
||||
title: "[BUG] "
|
||||
labels:
|
||||
- bug
|
||||
body:
|
||||
- type: dropdown
|
||||
id: os
|
||||
attributes:
|
||||
label: "Betriebssystem"
|
||||
options:
|
||||
- "Windows 10"
|
||||
- "Windows 11"
|
||||
- "Ubuntu / Debian"
|
||||
- "Fedora / RHEL"
|
||||
- "Arch Linux"
|
||||
- "macOS"
|
||||
- "Anderes"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: install_type
|
||||
attributes:
|
||||
label: "Installationsart"
|
||||
options:
|
||||
- "Docker / Docker Compose"
|
||||
- "EXE / Binary (Windows)"
|
||||
- "Binary (Linux)"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: slicer
|
||||
attributes:
|
||||
label: "Slicer"
|
||||
options:
|
||||
- "OrcaSlicer (KX-Patch)"
|
||||
- "OrcaSlicer (Standard)"
|
||||
- "BambuStudio"
|
||||
- "PrusaSlicer"
|
||||
- "Anderer"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: slicer_version
|
||||
attributes:
|
||||
label: "Slicer Version"
|
||||
placeholder: "z.B. 2.4.0-dev-kx1"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: bridge_version
|
||||
attributes:
|
||||
label: "KX-Bridge Version"
|
||||
placeholder: "z.B. v0.9.20"
|
||||
description: "Zu finden im Webinterface oder in den Logs beim Start."
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: "Problembeschreibung"
|
||||
description: "Was passiert? Was hast du erwartet?"
|
||||
placeholder: |
|
||||
Was ist passiert:
|
||||
|
||||
Was wurde erwartet:
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
label: "Logs / Fehlermeldung"
|
||||
description: "Relevante Ausgabe aus den KX-Bridge Logs. Docker: `docker logs <container>`"
|
||||
render: text
|
||||
validations:
|
||||
required: false
|
||||
@@ -34,3 +34,11 @@ auto_leveling = 1
|
||||
[bridge]
|
||||
# Poll-Intervall in Sekunden
|
||||
poll_interval = 3
|
||||
|
||||
[spoolman]
|
||||
# URL der Spoolman-Instanz (leer lassen um Spoolman zu deaktivieren)
|
||||
# server = http://192.168.x.x:7912
|
||||
|
||||
# Wie oft (Sekunden) der Filamentverbrauch während des Drucks gemeldet wird
|
||||
# (0 = nur beim Druckende)
|
||||
# sync_rate = 0
|
||||
|
||||
@@ -13,6 +13,7 @@ _BASE = pathlib.Path(sys.executable).parent if getattr(sys, "frozen", False) els
|
||||
CONFIG_SECTION_CONNECTION = "connection"
|
||||
CONFIG_SECTION_PRINT = "print"
|
||||
CONFIG_SECTION_BRIDGE = "bridge"
|
||||
CONFIG_SECTION_SPOOLMAN = "spoolman"
|
||||
|
||||
|
||||
def _find_config_file() -> pathlib.Path | None:
|
||||
@@ -63,6 +64,8 @@ def _load_config_file(path: pathlib.Path):
|
||||
"WEB_UPLOAD_WARNING": (CONFIG_SECTION_PRINT, "web_upload_warning"),
|
||||
"PRINT_START_DIALOG": (CONFIG_SECTION_PRINT, "print_start_dialog"),
|
||||
"BRIDGE_PRINTER_NAME": (CONFIG_SECTION_BRIDGE, "printer_name"),
|
||||
"SPOOLMAN_SERVER": (CONFIG_SECTION_SPOOLMAN, "server"),
|
||||
"SPOOLMAN_SYNC_RATE": (CONFIG_SECTION_SPOOLMAN, "sync_rate"),
|
||||
}
|
||||
for env_key, (section, option) in mapping.items():
|
||||
if env_key not in os.environ:
|
||||
@@ -317,3 +320,5 @@ 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"))
|
||||
PRINT_START_DIALOG = int(get("PRINT_START_DIALOG", get("FILE_READY_DIALOG", "1")))
|
||||
SPOOLMAN_SERVER = get("SPOOLMAN_SERVER", "")
|
||||
SPOOLMAN_SYNC_RATE = int(get("SPOOLMAN_SYNC_RATE", "0"))
|
||||
|
||||
@@ -733,6 +733,40 @@ class CameraCache:
|
||||
await asyncio.sleep(2.0)
|
||||
|
||||
|
||||
class SpoolmanClient:
|
||||
"""Thin synchronous HTTP client for Spoolman filament tracking.
|
||||
|
||||
Designed to be called from daemon threads (poll loop, _on_print callbacks).
|
||||
Uses requests (already in requirements) so no event-loop dependency.
|
||||
"""
|
||||
|
||||
def __init__(self, server_url: str, sync_rate: int = 0):
|
||||
self.server_url = server_url.rstrip("/")
|
||||
self.sync_rate = sync_rate
|
||||
|
||||
def _req(self, method: str, path: str, **kwargs):
|
||||
import requests
|
||||
r = requests.request(method, f"{self.server_url}{path}", timeout=5, **kwargs)
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
|
||||
def health_check(self) -> bool:
|
||||
try:
|
||||
self._req("GET", "/api/v1/health")
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def list_spools(self) -> list:
|
||||
return self._req("GET", "/api/v1/spool")
|
||||
|
||||
def use_filament(self, spool_id: int, use_length_mm: float) -> None:
|
||||
"""Report consumed filament length in mm. Spoolman converts to weight
|
||||
using the spool's filament profile density."""
|
||||
self._req("PUT", f"/api/v1/spool/{spool_id}/use",
|
||||
json={"use_length": round(use_length_mm, 2)})
|
||||
|
||||
|
||||
class KobraXBridge:
|
||||
def __init__(self, client: KobraXClient, args=None, store=None, printer_id: str = "1", all_bridges=None):
|
||||
self.client = client
|
||||
@@ -793,6 +827,7 @@ class KobraXBridge:
|
||||
"file_ready": "",
|
||||
"print_start_dialog": getattr(args, "print_start_dialog", 1),
|
||||
"filament_mode": "toolhead",
|
||||
"supplies_usage": 0,
|
||||
"ace_drying": {"status": 0, "target_temp": 0, "duration": 0, "remain_time": 0, "humidity": None, "current_temp": None},
|
||||
}
|
||||
self._ams_slots: list[dict] = [] # flat global list; each entry has global_index + box_id
|
||||
@@ -819,6 +854,18 @@ class KobraXBridge:
|
||||
self._pending_preprint_skip: list[str] = []
|
||||
self._pending_preprint_skip_deadline: float = 0.0
|
||||
|
||||
# Spoolman filament tracking
|
||||
_sm_url = (getattr(args, "spoolman_server", "") or "").strip()
|
||||
self._spoolman: SpoolmanClient | None = (
|
||||
SpoolmanClient(_sm_url, getattr(args, "spoolman_sync_rate", 0))
|
||||
if _sm_url else None
|
||||
)
|
||||
self._spoolman_slot_spools: dict[int, int] = {} # {ams_slot_idx: spoolman_spool_id}
|
||||
self._spoolman_slot_usage: dict[int, float] = {} # per-slot accumulated mm this print
|
||||
self._spoolman_slot_reported: dict[int, float] = {} # per-slot mm already sent to Spoolman
|
||||
self._spoolman_last_usage: float = 0.0 # supplies_usage at last attribution tick
|
||||
self._spoolman_last_sync: float = 0.0
|
||||
|
||||
# Theme-Name prüfen (keine Sonderzeichen oder Umlaute)
|
||||
raw_theme = (getattr(args, "ui_theme", None) or "default").strip()
|
||||
if not _UI_THEME_NAME_RE.match(raw_theme):
|
||||
@@ -837,6 +884,146 @@ class KobraXBridge:
|
||||
client.callbacks["light/report"] = self._on_light
|
||||
client.callbacks["skip/report"] = self._on_skip
|
||||
|
||||
if self._spoolman:
|
||||
threading.Thread(
|
||||
target=lambda: log.info(
|
||||
f"Spoolman: {'OK' if self._spoolman.health_check() else 'unreachable'} "
|
||||
f"at {self._spoolman.server_url}"
|
||||
),
|
||||
daemon=True, name="spoolman-health",
|
||||
).start()
|
||||
|
||||
# ── Spoolman helpers ──────────────────────────────────────────────────────
|
||||
|
||||
def _spoolman_filament_mm(self) -> float:
|
||||
"""Total filament_used_mm for the current print file from the GCode DB."""
|
||||
filename = self._state.get("filename", "")
|
||||
if not filename:
|
||||
return 0.0
|
||||
try:
|
||||
gf = self._store.get_file_by_name(filename)
|
||||
return float(gf.get("filament_used_mm") or 0.0) if gf else 0.0
|
||||
except Exception:
|
||||
return 0.0
|
||||
|
||||
def _spoolman_attribute_tick(self, activity_map: dict) -> None:
|
||||
"""Attribute the supplies_usage delta since last tick to the active slot.
|
||||
|
||||
Called every poll cycle after both print/report and multiColorBox/report
|
||||
have been processed, so _state["supplies_usage"] and _ams_loaded_slot
|
||||
are both current.
|
||||
|
||||
Skips attribution during loading/unloading transitions (tool changes +
|
||||
purges) to avoid charging the wrong spool for purge material."""
|
||||
if not self._spoolman or not self._spoolman_slot_spools:
|
||||
return
|
||||
if self._state.get("print_state") != "printing":
|
||||
return
|
||||
current = self._state.get("supplies_usage", 0)
|
||||
delta = current - self._spoolman_last_usage
|
||||
self._spoolman_last_usage = current
|
||||
if delta <= 0:
|
||||
return
|
||||
loaded = self._ams_loaded_slot
|
||||
if loaded < 0:
|
||||
return
|
||||
# Don't attribute during a loading/unloading transition
|
||||
if activity_map.get(loaded):
|
||||
return
|
||||
self._spoolman_slot_usage[loaded] = self._spoolman_slot_usage.get(loaded, 0.0) + delta
|
||||
|
||||
def _spoolman_unreported(self) -> dict[int, float]:
|
||||
"""Return {slot_idx: mm} of usage not yet reported to Spoolman.
|
||||
|
||||
Falls back to equal split of total supplies_usage when per-slot
|
||||
attribution data is absent (e.g. single-extruder with no AMS)."""
|
||||
total_used = self._state.get("supplies_usage", 0)
|
||||
if self._spoolman_slot_usage:
|
||||
return {
|
||||
slot: self._spoolman_slot_usage.get(slot, 0.0)
|
||||
- self._spoolman_slot_reported.get(slot, 0.0)
|
||||
for slot in self._spoolman_slot_spools
|
||||
}
|
||||
# Fallback: equal split
|
||||
n = len(self._spoolman_slot_spools)
|
||||
already = sum(self._spoolman_slot_reported.values())
|
||||
per = (total_used - already) / n if n else 0.0
|
||||
return {slot: per for slot in self._spoolman_slot_spools}
|
||||
|
||||
def _spoolman_report(self, unreported: dict[int, float], min_mm: float = 0.1) -> None:
|
||||
"""Fire-and-forget report of unreported mm to each mapped spool."""
|
||||
sm = self._spoolman
|
||||
for slot_idx, mm in unreported.items():
|
||||
if mm < min_mm:
|
||||
continue
|
||||
spool_id = self._spoolman_slot_spools.get(slot_idx)
|
||||
if not spool_id:
|
||||
continue
|
||||
self._spoolman_slot_reported[slot_idx] = (
|
||||
self._spoolman_slot_reported.get(slot_idx, 0.0) + mm
|
||||
)
|
||||
def _send(sid=spool_id, length=mm):
|
||||
try:
|
||||
sm.use_filament(sid, length)
|
||||
log.info(f"Spoolman: {length:.1f} mm → spool {sid}")
|
||||
except Exception as e:
|
||||
log.warning(f"Spoolman: report failed (spool {sid}): {e}")
|
||||
threading.Thread(target=_send, daemon=True, name="spoolman-report").start()
|
||||
|
||||
def _spoolman_notify_end(self):
|
||||
"""Report remaining filament on print end."""
|
||||
if not self._spoolman or not self._spoolman_slot_spools:
|
||||
return
|
||||
self._spoolman_report(self._spoolman_unreported())
|
||||
|
||||
def _spoolman_sync_midprint(self):
|
||||
"""Report incremental filament usage during a print (sync_rate interval)."""
|
||||
if not self._spoolman or not self._spoolman_slot_spools:
|
||||
return
|
||||
self._spoolman_report(self._spoolman_unreported(), min_mm=10.0)
|
||||
|
||||
# ── Spoolman API handlers ─────────────────────────────────────────────────
|
||||
|
||||
async def handle_kx_spoolman_status(self, request):
|
||||
"""GET /kx/spoolman/status"""
|
||||
return self._json_cors({
|
||||
"configured": bool(self._spoolman),
|
||||
"server": self._spoolman.server_url if self._spoolman else "",
|
||||
"sync_rate": self._spoolman.sync_rate if self._spoolman else 0,
|
||||
"slot_spools": {str(k): v for k, v in self._spoolman_slot_spools.items()},
|
||||
})
|
||||
|
||||
async def handle_kx_spoolman_spools(self, request):
|
||||
"""GET /kx/spoolman/spools — proxied from Spoolman."""
|
||||
if not self._spoolman:
|
||||
return self._json_cors({"error": "Spoolman not configured"}, status=503)
|
||||
try:
|
||||
spools = await asyncio.get_event_loop().run_in_executor(
|
||||
None, self._spoolman.list_spools
|
||||
)
|
||||
return self._json_cors({"spools": spools})
|
||||
except Exception as e:
|
||||
log.warning(f"Spoolman: list_spools failed: {e}")
|
||||
return self._json_cors({"error": str(e)}, status=502)
|
||||
|
||||
async def handle_kx_spoolman_set_active(self, request):
|
||||
"""POST /kx/spoolman/active-spool
|
||||
Body: {"slot_map": {"0": 42, "2": 17}} — AMS slot index → Spoolman spool ID.
|
||||
An empty slot_map clears all assignments."""
|
||||
try:
|
||||
data = await request.json()
|
||||
except Exception:
|
||||
return self._json_cors({"error": "invalid JSON"}, status=400)
|
||||
slot_map = data.get("slot_map") or {}
|
||||
self._spoolman_slot_spools = {
|
||||
int(k): int(v) for k, v in slot_map.items()
|
||||
if str(v).isdigit() and int(v) > 0
|
||||
}
|
||||
self._spoolman_slot_usage = {}
|
||||
self._spoolman_slot_reported = {}
|
||||
self._spoolman_last_usage = 0.0
|
||||
return self._json_cors({"slot_spools": {str(k): v for k, v in self._spoolman_slot_spools.items()}})
|
||||
|
||||
def _default_ace_dry_presets(self) -> dict[str, dict]:
|
||||
return {
|
||||
"pla": {"temp": 45, "duration_sec": 4 * 3600},
|
||||
@@ -951,15 +1138,21 @@ class KobraXBridge:
|
||||
printer_id=self._printer_id,
|
||||
)
|
||||
log.info(f"Job started: {self._current_job_id} for {filename}")
|
||||
self._spoolman_slot_usage = {}
|
||||
self._spoolman_slot_reported = {}
|
||||
self._spoolman_last_usage = 0.0
|
||||
self._spoolman_last_sync = 0.0
|
||||
|
||||
# Job-History: Druckende erkennen
|
||||
if kobra_state in ("finished",) and self._current_job_id:
|
||||
self._store.finish_job(self._current_job_id, status="completed")
|
||||
log.info(f"Job abgeschlossen: {self._current_job_id}")
|
||||
self._spoolman_notify_end()
|
||||
self._current_job_id = ""
|
||||
elif kobra_state in ("stoped", "canceled") and self._current_job_id:
|
||||
self._store.finish_job(self._current_job_id, status="cancelled")
|
||||
log.info(f"Job abgebrochen: {self._current_job_id}")
|
||||
self._spoolman_notify_end()
|
||||
self._current_job_id = ""
|
||||
|
||||
# Nach Druckende das Upload-Banner verschwinden lassen (Issue #29): der
|
||||
@@ -976,6 +1169,7 @@ class KobraXBridge:
|
||||
self._state["slicer_time"] = 0
|
||||
self._state["layer_height"] = 0.0
|
||||
self._state["first_layer_height"] = 0.0
|
||||
self._state["supplies_usage"] = 0
|
||||
self._thumbnail_b64 = ""
|
||||
self._state["filename"] = d.get("filename", self._state["filename"])
|
||||
if "progress" in d:
|
||||
@@ -990,6 +1184,8 @@ class KobraXBridge:
|
||||
self._state["total_layers"] = d["total_layers"]
|
||||
if "taskid" in d:
|
||||
self._state["taskid"] = str(d["taskid"])
|
||||
if "supplies_usage" in d:
|
||||
self._state["supplies_usage"] = int(d["supplies_usage"])
|
||||
settings = d.get("settings") or {}
|
||||
if "print_speed_mode" in settings:
|
||||
self._state["print_speed_mode"] = int(settings["print_speed_mode"])
|
||||
@@ -4804,6 +5000,14 @@ class KobraXBridge:
|
||||
print_r = self.client.publish("print", "query", timeout=3.0)
|
||||
if print_r:
|
||||
self._on_print(print_r)
|
||||
# Spoolman mid-print sync
|
||||
if (self._spoolman and self._spoolman.sync_rate > 0
|
||||
and self._spoolman_slot_spools
|
||||
and self._state.get("print_state") == "printing"):
|
||||
now = time.time()
|
||||
if now - self._spoolman_last_sync >= self._spoolman.sync_rate:
|
||||
self._spoolman_sync_midprint()
|
||||
self._spoolman_last_sync = now
|
||||
box = self.client.query_multicolor_box()
|
||||
if box:
|
||||
data = box.get("data") or {}
|
||||
@@ -4820,6 +5024,10 @@ class KobraXBridge:
|
||||
if global_slots:
|
||||
self._ams_slots = global_slots
|
||||
self._ams_loaded_slot = global_loaded
|
||||
self._spoolman_attribute_tick(activity_map)
|
||||
else:
|
||||
# No multiColorBox data — still attribute (no transitions to skip)
|
||||
self._spoolman_attribute_tick({})
|
||||
except Exception as e:
|
||||
log.warning(f"Poll-Fehler: {e}")
|
||||
# Prüfen ob Drucker wirklich weg ist
|
||||
@@ -4954,6 +5162,9 @@ def build_app(bridge: KobraXBridge) -> web.Application:
|
||||
r.add_post("/kx/skip", bridge.handle_kx_skip)
|
||||
r.add_post("/kx/skip/query", bridge.handle_kx_skip_query)
|
||||
r.add_get("/kx/skip/state", bridge.handle_kx_skip_state)
|
||||
r.add_get("/kx/spoolman/status", bridge.handle_kx_spoolman_status)
|
||||
r.add_get("/kx/spoolman/spools", bridge.handle_kx_spoolman_spools)
|
||||
r.add_post("/kx/spoolman/active-spool", bridge.handle_kx_spoolman_set_active)
|
||||
r.add_route("OPTIONS", "/kx/{path:.*}", bridge.handle_kx_options)
|
||||
|
||||
# Root + Printer-Routen (Single-Page, JS liest Pathname)
|
||||
@@ -5119,6 +5330,10 @@ def main():
|
||||
parser.add_argument("--web-upload-warning", type=int, default=env_loader.WEB_UPLOAD_WARNING)
|
||||
parser.add_argument("--print-start-dialog", dest="print_start_dialog", type=int, default=env_loader.PRINT_START_DIALOG)
|
||||
parser.add_argument("--file-ready-dialog", dest="print_start_dialog", type=int)
|
||||
parser.add_argument("--spoolman-server", default=env_loader.SPOOLMAN_SERVER,
|
||||
help="Spoolman URL (e.g. http://192.168.x.x:7912); leave empty to disable")
|
||||
parser.add_argument("--spoolman-sync-rate", type=int, default=env_loader.SPOOLMAN_SYNC_RATE,
|
||||
help="Mid-print filament sync interval in seconds (0 = only on print end)")
|
||||
|
||||
parser.add_argument("--host", default="0.0.0.0",
|
||||
help="Bind-Adresse für den Bridge-Server")
|
||||
|
||||
@@ -44,6 +44,66 @@ var ACE_DRY_PRESETS={
|
||||
custom_3:{name:'Custom 3',temp:45,duration_sec:4*3600}
|
||||
};
|
||||
|
||||
// Spoolman state
|
||||
var _spoolmanStatus={configured:false,server:'',sync_rate:0,slot_spools:{}};
|
||||
var _spoolmanSpools=[];
|
||||
var _slotSpoolMap={}; // {String(global_index): spoolman_spool_id} — last committed assignment
|
||||
|
||||
function _loadSpoolmanStatus(){
|
||||
fetch(_apiUrl('/kx/spoolman/status')).then(function(r){return r.json();}).then(function(d){
|
||||
_spoolmanStatus=d;
|
||||
_slotSpoolMap=d.slot_spools||{};
|
||||
}).catch(function(){});
|
||||
}
|
||||
|
||||
function _buildSpoolmanSection(){
|
||||
var sec=document.getElementById('fd-spoolman-section');
|
||||
var rows=document.getElementById('fd-spoolman-rows');
|
||||
var loading=document.getElementById('fd-spoolman-loading');
|
||||
if(!sec||!rows)return;
|
||||
if(!_spoolmanStatus.configured){sec.style.display='none';return;}
|
||||
sec.style.display='';
|
||||
rows.innerHTML='';
|
||||
if(loading)loading.style.display='';
|
||||
|
||||
// Collect the unique AMS slots the user has selected in the paint→slot rows
|
||||
var usedSlots={};
|
||||
document.querySelectorAll('#fd-slots select').forEach(function(sel){
|
||||
var idx=parseInt(sel.value);
|
||||
if(idx>=0){
|
||||
var slot=(_amsSlots||[]).find(function(s){return s.slot_index===idx;});
|
||||
if(slot&&!usedSlots[idx])usedSlots[idx]=slot;
|
||||
}
|
||||
});
|
||||
|
||||
fetch(_apiUrl('/kx/spoolman/spools')).then(function(r){return r.json();}).then(function(d){
|
||||
if(loading)loading.style.display='none';
|
||||
_spoolmanSpools=d.spools||[];
|
||||
var slotKeys=Object.keys(usedSlots).map(Number).sort(function(a,b){return a-b;});
|
||||
if(!slotKeys.length){rows.innerHTML='<span style="font-size:11px;color:var(--txt2)">–</span>';return;}
|
||||
rows.innerHTML=slotKeys.map(function(idx){
|
||||
var slot=usedSlots[idx];
|
||||
var col=(slot.color_hex||'#888');
|
||||
var currentSpool=_slotSpoolMap[String(idx)]||'';
|
||||
var opts='<option value="">–</option>'+_spoolmanSpools.map(function(sp){
|
||||
var rem=sp.remaining_weight!=null?' ('+sp.remaining_weight.toFixed(0)+'g)':'';
|
||||
var vendor=sp.filament&&sp.filament.vendor?sp.filament.vendor+' ':'';
|
||||
var name=sp.filament&&sp.filament.name?sp.filament.name:'Spool';
|
||||
return '<option value="'+sp.id+'"'+(sp.id==currentSpool?' selected':'')+'>'+
|
||||
escHtml('#'+sp.id+' '+vendor+name+rem)+'</option>';
|
||||
}).join('');
|
||||
return '<div style="display:flex;align-items:center;gap:8px;font-size:12px">'+
|
||||
'<span style="display:inline-block;width:14px;height:14px;border-radius:50%;background:'+col+';border:1px solid var(--border);flex-shrink:0"></span>'+
|
||||
'<span style="color:var(--txt2);min-width:46px">Slot '+(idx+1)+'</span>'+
|
||||
'<select data-spool-slot="'+idx+'" style="flex:1;padding:3px 6px;border-radius:6px;border:1px solid var(--border);background:var(--raised);color:var(--txt);font-size:11px">'+opts+'</select>'+
|
||||
'</div>';
|
||||
}).join('');
|
||||
}).catch(function(){
|
||||
if(loading)loading.style.display='none';
|
||||
if(rows)rows.innerHTML='<span style="font-size:11px;color:var(--err)">Spoolman unavailable</span>';
|
||||
});
|
||||
}
|
||||
|
||||
function _aceAutoRefillGet(aceId){return !!aceAutoRefillPrefs[String(aceId)];}
|
||||
function _aceAutoRefillSet(aceId,on){
|
||||
aceAutoRefillPrefs[String(aceId)]=!!on;
|
||||
@@ -482,6 +542,7 @@ function ensureAceDryCards(){
|
||||
fetch('/kx/printers').then(function(r){return r.json()}).then(function(d){
|
||||
if(!d.result||!d.result.length){showPanel('printers');loadPrinterTab();}
|
||||
}).catch(function(){});
|
||||
_loadSpoolmanStatus();
|
||||
});
|
||||
})();
|
||||
|
||||
@@ -889,11 +950,16 @@ function applyState(){
|
||||
var tt=(profile.name||'')+(profile.id?' ('+profile.id+')':'');
|
||||
vendorBadge='<div class="slot-label" style="font-size:9px;color:var(--accent);font-weight:600;margin-top:1px" title="'+tt+'">'+profile.vendor+'</div>';
|
||||
}
|
||||
var spoolId=_slotSpoolMap&&_slotSpoolMap[String(globalIdx)];
|
||||
var spoolBadge=(!empty&&spoolId)
|
||||
?'<div class="slot-label" style="font-size:9px;color:var(--ok);margin-top:1px" title="Spoolman spool #'+spoolId+'">🧵 #'+spoolId+'</div>'
|
||||
:'';
|
||||
html+='<div class="ams-slot'+(active?' active':'')+(loaded?' loaded':'')+(activity?' '+activity:'')+(empty?' empty':'')
|
||||
+'" style="--slot-color:'+col+';opacity:'+(empty?0.4:1)+';cursor:pointer" onclick="openSlotEdit('+i+')">'
|
||||
+'<div class="slot-circle" style="background:'+col+'"></div>'
|
||||
+'<div class="slot-material" title="'+(empty?'':genericType)+'">'+materialLabel+'</div>'
|
||||
+vendorBadge
|
||||
+spoolBadge
|
||||
+'<div class="slot-label">'+slotLabel+'</div>'
|
||||
+'<div class="slot-label" style="font-size:10px;color:var(--txt2)">'+pct+'</div>'
|
||||
+'<div style="font-size:9px;color:var(--txt2);margin-top:2px">✏</div>'
|
||||
@@ -2623,6 +2689,8 @@ function openFilamentDialog(slots){
|
||||
}).join('');
|
||||
}
|
||||
if(dlg)dlg.classList.add('open');
|
||||
// Spoolman spool picker — loaded async after dialog is visible
|
||||
setTimeout(_buildSpoolmanSection, 0);
|
||||
}
|
||||
|
||||
function closeFilamentDialog(){
|
||||
@@ -2675,6 +2743,22 @@ function confirmFilamentPrint(){
|
||||
var excludedObjects=_printObjects.filter(function(o){return o.skip;}).map(function(o){return o.name;});
|
||||
var fdAlEl=document.getElementById('fd-auto-leveling');
|
||||
var fdAutoLeveling=fdAlEl?( fdAlEl.checked?1:0):(S.auto_leveling===undefined?1:S.auto_leveling?1:0);
|
||||
|
||||
// Spoolman: collect slot→spool assignments and submit to backend
|
||||
if(_spoolmanStatus.configured){
|
||||
var newSlotMap={};
|
||||
document.querySelectorAll('[data-spool-slot]').forEach(function(sel){
|
||||
var spoolId=parseInt(sel.value);
|
||||
if(!Number.isNaN(spoolId)&&spoolId>0)newSlotMap[sel.dataset.spoolSlot]=spoolId;
|
||||
});
|
||||
fetch(_apiUrl('/kx/spoolman/active-spool'),{
|
||||
method:'POST',headers:{'Content-Type':'application/json'},
|
||||
body:JSON.stringify({slot_map:newSlotMap})
|
||||
}).then(function(r){return r.json();}).then(function(d){
|
||||
_slotSpoolMap=d.slot_spools||{};
|
||||
}).catch(function(){});
|
||||
}
|
||||
|
||||
closeFilamentDialog();
|
||||
if(_filamentDialogMode==='banner'){
|
||||
// Banner-Modus: /kx/print bevorzugen wenn _storeFileId bekannt (gleicher Pfad wie File-Browser).
|
||||
|
||||
@@ -646,6 +646,13 @@
|
||||
<label for="fd-auto-leveling" style="margin:0;cursor:pointer;font-size:13px" id="fd-lbl-auto-leveling">Auto-Leveling</label>
|
||||
</div>
|
||||
</div>
|
||||
<div id="fd-spoolman-section" style="display:none;margin-bottom:16px;border-top:1px solid var(--border);padding-top:12px">
|
||||
<p style="font-size:12px;color:var(--txt2);margin-bottom:8px;display:flex;align-items:center;gap:6px">
|
||||
<span id="fd-spoolman-lbl">🧵 Spoolman</span>
|
||||
<span id="fd-spoolman-loading" style="display:none;font-size:10px">…</span>
|
||||
</p>
|
||||
<div id="fd-spoolman-rows" style="display:flex;flex-direction:column;gap:6px"></div>
|
||||
</div>
|
||||
<div style="display:flex;gap:8px;justify-content:flex-end">
|
||||
<button id="fd-cancel" onclick="closeFilamentDialog()" style="padding:8px 16px;background:var(--raised);border:1px solid var(--border);border-radius:8px;color:var(--txt);cursor:pointer">Abbrechen</button>
|
||||
<button id="fd-print" onclick="confirmFilamentPrint()" style="padding:8px 18px;background:var(--accent);color:#fff;border:none;border-radius:8px;cursor:pointer;font-weight:600">▶ Drucken</button>
|
||||
|
||||
Reference in New Issue
Block a user