diff --git a/NIGHTLY_CHANGELOG.md b/NIGHTLY_CHANGELOG.md index f1eff73..87cefde 100644 --- a/NIGHTLY_CHANGELOG.md +++ b/NIGHTLY_CHANGELOG.md @@ -1,2 +1,3 @@ ## Changes in this build +- Feat: `server/files/metadata` now uses the printer's own `buried/report` analytics event (fires once per print start, regardless of slicer) as a fallback for `size`/`estimated_time`/`layer_count` — fixes broken `size: 1`/`estimated_time: null` placeholders for files not in the bridge's own GCode store, e.g. prints started directly from Anycubic Slicer Next (Issue #102, thanks @fmontagna). Also surfaces the printer's storage usage (`storage_total_mb`/`storage_used_mb`) in `/api/state`. diff --git a/kobrax_moonraker_bridge.py b/kobrax_moonraker_bridge.py index db32088..5792063 100644 --- a/kobrax_moonraker_bridge.py +++ b/kobrax_moonraker_bridge.py @@ -1042,6 +1042,8 @@ class KobraXBridge: "ace_drying": {"status": 0, "target_temp": 0, "duration": 0, "remain_time": 0, "humidity": None, "current_temp": None}, "error_code": 0, "pause_msg": "", + "storage_total_mb": 0, + "storage_used_mb": 0, } self._ams_slots: list[dict] = [] # flat global list; each entry has global_index + box_id self._ams_loaded_slot: int = -1 # global slot index of currently loaded slot @@ -1062,6 +1064,12 @@ class KobraXBridge: # base64 PNG string, "" if the file has no embedded thumbnail). # In-memory only - not persisted, cleared on restart. self._printer_thumbnail_cache: dict[str, str] = {} + # Last buried/report payload (printer's own analytics event, fired once + # per print start regardless of slicer - see reference_buried_report_trigger + # memory). Carries gcode_size/estimate_duration/total_layers that are + # otherwise unavailable for files not uploaded through the bridge itself + # (Issue #102). Single entry only - just the most recent print. + self._buried_cache: dict | None = None self._store = store if store is not None else GCodeStore(args.data_dir) self._serve_dir_path: str = self._store._gcode_dir self._current_job_id: str = "" @@ -1113,6 +1121,7 @@ class KobraXBridge: client.callbacks["print/report"] = self._on_print client.callbacks["info/report"] = self._on_info client.callbacks["file/report"] = self._on_file + client.callbacks["buried/report"] = self._on_buried client.callbacks["multiColorBox/report"] = self._on_multicolor_box client.callbacks["light/report"] = self._on_light client.callbacks["skip/report"] = self._on_skip @@ -1594,6 +1603,30 @@ class KobraXBridge: if self._file_action_waiters.get(action) is waiter: del self._file_action_waiters[action] + def _on_buried(self, payload: dict): + """buried/report - the printer's own analytics event, fired once per + print start (verified live against a real Kobra X: fires identically + for prints started via Anycubic Slicer Next and via OrcaSlicer/the + bridge). Carries gcode_size/estimate_duration/total_layers, which + _build_file_metadata() falls back to for files not in our own + GCodeStore (Issue #102), plus printer storage usage.""" + d = payload.get("data") or {} + task_name = d.get("task_name") or "" + if not task_name: + return + self._buried_cache = { + "task_name": task_name, + "gcode_size": int(d.get("gcode_size") or 0), + "estimate_duration": int(d.get("estimate_duration") or 0), + "total_layers": int(d.get("total_layers") or 0), + } + self._state["storage_total_mb"] = int(d.get("storage_total") or 0) + self._state["storage_used_mb"] = int(d.get("storage_used") or 0) + log.info( + f"buried/report: {task_name} size={d.get('gcode_size')} " + f"est={d.get('estimate_duration')}s layers={d.get('total_layers')}" + ) + def _on_file(self, payload: dict): # Deliver to any pending listLocal/deleteBatch waiter first (see # _wait_for_file_action) - these actions carry no file_details/ @@ -3577,6 +3610,18 @@ class KobraXBridge: size_bytes = int(gf.get("size_bytes") or 0) except Exception: pass + # Third fallback: the printer's own buried/report analytics event + # (fires once per print start regardless of slicer), for files that + # are neither the currently-tracked job nor in our own GCodeStore - + # e.g. printed directly via Anycubic Slicer Next (Issue #102). + buried = self._buried_cache + if buried and buried.get("task_name") == filename: + if not total_layers: + total_layers = buried.get("total_layers") or total_layers + if not est_time: + est_time = buried.get("estimate_duration") or est_time + if not size_bytes: + size_bytes = buried.get("gcode_size") or size_bytes if not layer_h: layer_h = self._layer_height_from_filename(filename) if layer_h and not first_h: @@ -4739,6 +4784,8 @@ class KobraXBridge: "version": self._read_version(), "pause_msg": s.get("pause_msg", ""), "error_code": s.get("error_code", 0), + "storage_total_mb": s.get("storage_total_mb", 0), + "storage_used_mb": s.get("storage_used_mb", 0), }) async def handle_moonraker_database(self, request): diff --git a/tests/test_buried_report.py b/tests/test_buried_report.py new file mode 100644 index 0000000..67d4a6c --- /dev/null +++ b/tests/test_buried_report.py @@ -0,0 +1,111 @@ +""" +Tests für buried/report — das druckerseitige Analytics-Event, das einmal pro +Druckstart feuert (verifiziert live gegen einen echten Kobra X, sowohl für +Anycubic Slicer Next als auch für OrcaSlicer/die Bridge selbst). Liefert +gcode_size/estimate_duration/total_layers, die server/files/metadata für +Dateien außerhalb des eigenen GCodeStore sonst nicht hat (Issue #102). +""" +import pytest + + +BURIED_PAYLOAD = { + "type": "buried", + "action": "PrintStart", + "code": 200, + "state": "done", + "data": { + "task_name": "Smileys_simple_plate(01)_PLA_0.2_16m39s.gcode", + "gcode_size": 644437, + "estimate_duration": 1264, + "total_layers": 8, + "storage_total": 6481, + "storage_used": 898, + "slicer": "OrcaSlicer", + }, +} + + +def test_on_buried_populates_cache(client): + _, bridge = client + bridge._on_buried(BURIED_PAYLOAD) + assert bridge._buried_cache == { + "task_name": "Smileys_simple_plate(01)_PLA_0.2_16m39s.gcode", + "gcode_size": 644437, + "estimate_duration": 1264, + "total_layers": 8, + } + + +def test_on_buried_populates_storage_state(client): + _, bridge = client + bridge._on_buried(BURIED_PAYLOAD) + assert bridge._state["storage_total_mb"] == 6481 + assert bridge._state["storage_used_mb"] == 898 + + +def test_on_buried_ignores_payload_without_task_name(client): + _, bridge = client + bridge._buried_cache = None + bridge._on_buried({"type": "buried", "data": {"gcode_size": 123}}) + assert bridge._buried_cache is None + + +def test_build_file_metadata_uses_buried_fallback_for_unknown_file(client): + """A file not in the GCodeStore and not the currently-tracked job should + still get real size/estimated_time/layer_count from the buried cache.""" + _, bridge = client + bridge._on_buried(BURIED_PAYLOAD) + meta = bridge._build_file_metadata("Smileys_simple_plate(01)_PLA_0.2_16m39s.gcode") + assert meta["size"] == 644437 + assert meta["estimated_time"] == 1264 + assert meta["layer_count"] == 8 + + +def test_build_file_metadata_ignores_buried_cache_for_different_file(client): + """The buried cache must only apply when task_name matches the queried + filename - otherwise it would leak the last print's data into an + unrelated query, the exact bug Issue #102 already fixed for live state.""" + _, bridge = client + bridge._on_buried(BURIED_PAYLOAD) + meta = bridge._build_file_metadata("some_other_file.gcode") + assert meta["size"] == 1 # unchanged fallback, not leaked from buried cache + assert meta["estimated_time"] is None + assert meta["layer_count"] is None + + +def test_build_file_metadata_prefers_gcodestore_over_buried(client): + """GCodeStore data (from the bridge's own upload) must win over the + buried cache when both are available for the same filename.""" + _, bridge = client + bridge._on_buried(BURIED_PAYLOAD) + with bridge._store._lock: + bridge._store._conn.execute( + "INSERT INTO gcode_files (id, filename, path, size_bytes, uploaded_at, layer_count, est_print_time_sec) " + "VALUES (?,?,?,?,?,?,?)", + ("f1", "Smileys_simple_plate(01)_PLA_0.2_16m39s.gcode", "/tmp/f1", 999999, "2026-01-01T00:00:00Z", 42, 5000), + ) + bridge._store._conn.commit() + meta = bridge._build_file_metadata("Smileys_simple_plate(01)_PLA_0.2_16m39s.gcode") + assert meta["size"] == 999999 + assert meta["estimated_time"] == 5000 + assert meta["layer_count"] == 42 + + +@pytest.mark.asyncio +async def test_api_state_reports_storage_after_buried_report(client): + c, bridge = client + bridge._on_buried(BURIED_PAYLOAD) + resp = await c.get("/api/state") + assert resp.status == 200 + data = await resp.json() + assert data["storage_total_mb"] == 6481 + assert data["storage_used_mb"] == 898 + + +@pytest.mark.asyncio +async def test_api_state_storage_defaults_to_zero(client): + c, _ = client + resp = await c.get("/api/state") + data = await resp.json() + assert data["storage_total_mb"] == 0 + assert data["storage_used_mb"] == 0