feat(moonraker): use buried/report as size/duration/layer fallback (Issue #102)
server/files/metadata returned broken placeholders (size: 1, estimated_time: null) for any file that wasn't uploaded through the bridge's own GCode store - e.g. prints started directly from Anycubic Slicer Next. Verified live against a real Kobra X (see memory reference_buried_report_trigger.md) that the printer sends a previously-unused MQTT topic, buried/report, exactly once per print start - fires identically whether the print was started via Anycubic Slicer Next or via OrcaSlicer/the bridge itself. It carries gcode_size, estimate_duration, and total_layers: precisely the fields the metadata endpoint was missing. Add _on_buried() (registered alongside the existing file/report callback) that caches the single most recent buried/report payload. _build_file_metadata() now tries this cache - matched by task_name - as a third fallback, between the existing GCodeStore lookup and the final size:1 hardcoded placeholder. Ordering is deliberate: live tracked-job state and the file's own GCodeStore row (if the file was uploaded through the bridge) still take priority; buried/report only fills the gap for files the bridge has no other record of. Also surfaces the printer's own storage usage (storage_total_mb/ storage_used_mb from the same payload) in /api/state, previously not exposed anywhere in the bridge. Verified end-to-end against the real printer: after a print start, server/files/metadata for that file returned real size (9573908), estimated_time (3203s), and layer_count (497) instead of the placeholders, and /api/state reported real storage_total_mb/ storage_used_mb.
This commit is contained in:
@@ -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`.
|
||||
|
||||
@@ -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):
|
||||
|
||||
111
tests/test_buried_report.py
Normal file
111
tests/test_buried_report.py
Normal file
@@ -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
|
||||
Reference in New Issue
Block a user