diff --git a/NIGHTLY_CHANGELOG.md b/NIGHTLY_CHANGELOG.md index 973a812..221a2d2 100644 --- a/NIGHTLY_CHANGELOG.md +++ b/NIGHTLY_CHANGELOG.md @@ -1,8 +1,6 @@ ## Changes in this build -- Fix: the poll interval setting was saved to config.ini but never actually applied — the poll loop always used a hardcoded 3s wait -- Fix: Spoolman status showed a green "connected" dot even when the server was unreachable — reachability is now rechecked periodically and shown accurately (green/red) -- Fix: **settings could silently revert after saving** — the bridge restart that applies settings didn't clean up all the relevant environment variables, so the old value could win over the one just saved (affected poll interval, resonance compensation, Docker host IP, and the new HTTP log toggle below). Fixed at the root so this class of bug can't recur for future settings. -- Feat: new "Log every HTTP request (verbose)" toggle in Settings — off by default, since aiohttp's per-request access log was drowning out the bridge's own logs with the frontend's 2s polling - -**A stable release is coming soon** with the fixes and features from the last several nightly builds. As usual it will be published as ready-to-run binaries (Linux amd64/arm64, Windows) alongside the Docker image. +- Feat: the dashboard is now fully customizable — enter edit mode to freely drag, resize (via GridStack.js), hide, and rearrange every card on a 12-column snap grid +- Feat: two built-in layout presets ("Standard", "Wide desktop" per Issue #89's original proposal) plus the ability to save your own layout as a named custom preset, switch between them, and delete presets you no longer need +- Fix: camera stream would freeze after ~15-30 minutes — non-monotonic timestamps from the printer's FLV stream broke ffmpeg's realtime pacing; now handled with `-use_wallclock_as_timestamps` (Issue #90) +- Fix: the camera card's height was previously capped at 320px and its image intercepted drag events — camera can now be resized freely like any other dashboard card diff --git a/kobrax_moonraker_bridge.py b/kobrax_moonraker_bridge.py index 870e1c9..f2ca7ff 100644 --- a/kobrax_moonraker_bridge.py +++ b/kobrax_moonraker_bridge.py @@ -643,7 +643,12 @@ class CameraCache: if url.lower().startswith("rtsp://"): args += ["-probesize", "32", "-analyzeduration", "0", "-rtsp_transport", "tcp"] else: - args += ["-probesize", "500000", "-analyzeduration", "500000"] + # The printer's FLV source occasionally emits non-monotonic container + # timestamps (PTS jumps of days) while the video data itself stays + # valid. Without this flag ffmpeg's realtime pacing breaks on such a + # jump and the stream stalls after ~15-30 min (Issue #90). + args += ["-use_wallclock_as_timestamps", "1", + "-probesize", "500000", "-analyzeduration", "500000"] return args async def _run_jpeg_loop(self): @@ -3759,21 +3764,33 @@ class KobraXBridge: # (only the bare HTML) -> without inlining neither # a single button works there (Issue #29). It is equally correct in a normal browser. base = os.path.join(_WEB_BASE, "web", "themes", self._ui_theme) - try: - css = pathlib.Path(os.path.join(base, "style.css")).read_text(encoding="utf-8") - page = page.replace( - '', - "") - except OSError: - pass - try: - js = pathlib.Path(os.path.join(base, "app.js")).read_text(encoding="utf-8") - js = js.replace("'__VERSION__'", f"'{self._read_version()}'") - page = page.replace( - '', - "") - except OSError: - pass + + # Inline vendored lib CSS/JS too — the OrcaSlicer webview loads no + # external /") + except OSError: + pass + + _inline_css("lib/gridstack.min.css", '') + _inline_js("lib/gridstack-all.min.js", '') + _inline_css("style.css", '') + _inline_js("app.js", '', version_sub=True) return web.Response(text=page, content_type="text/html", headers={"Cache-Control": "no-store, no-cache, must-revalidate"}) @@ -4132,10 +4149,10 @@ class KobraXBridge: printer and is ~1 s faster).""" url = self._state.get("camera_url", "") if not url: - return web.Response(status=503, text="Keine Kamera-URL bekannt") + return web.Response(status=503, text="No camera URL known") self.camera_cache.set_url(url) await self.camera_cache.ensure_running() - # Initialer Warmup: bis zu 5 s auf ersten Frame warten + # Initial warmup: wait up to 5s for the first frame deadline = time.time() + 5.0 while not self.camera_cache.latest_jpeg and time.time() < deadline: await asyncio.sleep(0.1) @@ -4154,7 +4171,7 @@ class KobraXBridge: """MJPEG proxy: FLV → MJPEG via ffmpeg, served as multipart/x-mixed-replace.""" url = self._state.get("camera_url", "") if not url: - return web.Response(status=503, text="Keine Kamera-URL bekannt") + return web.Response(status=503, text="No camera URL known") is_rtsp = url.lower().startswith("rtsp://") ffmpeg_input_args = [ @@ -4164,7 +4181,9 @@ class KobraXBridge: if is_rtsp: ffmpeg_input_args += ["-probesize", "32", "-analyzeduration", "0", "-rtsp_transport", "tcp"] else: - ffmpeg_input_args += ["-probesize", "1000000", "-analyzeduration", "1000000"] + # See CameraCache._input_args - same non-monotonic-timestamp fix (Issue #90). + ffmpeg_input_args += ["-use_wallclock_as_timestamps", "1", + "-probesize", "1000000", "-analyzeduration", "1000000"] # Start ffmpeg BEFORE the StreamResponse is opened # (so we can still send a normal HTTP response on error) @@ -4245,7 +4264,7 @@ class KobraXBridge: additional FLV connection to the printer (single-client limit).""" url = self._state.get("camera_url", "") if not url: - return web.Response(status=503, text="Keine Kamera-URL bekannt") + return web.Response(status=503, text="No camera URL known") self.camera_cache.set_url(url) await self.camera_cache.ensure_running() diff --git a/tests/conftest.py b/tests/conftest.py index efa71fc..25bd6a8 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -2,12 +2,13 @@ Shared fixtures für KX-Bridge Tests. Startet die Bridge in-process mit einem Mock-MQTT-Client (kein Drucker nötig). """ -import sys, types, argparse, pytest, pytest_asyncio +import sys, types, argparse, tempfile, pytest, pytest_asyncio from unittest.mock import MagicMock from aiohttp.test_utils import TestClient, TestServer # ── Pfad ────────────────────────────────────────────────────────────────────── -sys.path.insert(0, str(__import__("pathlib").Path(__file__).parent.parent / "bridge")) +# Flat repo layout (no bridge/ subfolder anymore) — point at the repo root. +sys.path.insert(0, str(__import__("pathlib").Path(__file__).parent.parent)) # ── env_loader mocken (keine .env nötig) ────────────────────────────────────── env_mod = types.ModuleType("env_loader") @@ -41,6 +42,7 @@ def make_args(**overrides): device_id = "", host = "127.0.0.1", port = 7125, + data_dir = tempfile.mkdtemp(prefix="kxtest-"), ) for k, v in overrides.items(): setattr(args, k, v) diff --git a/tests/test_settings.py b/tests/test_settings.py index ef0b149..f0f16e8 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -40,14 +40,14 @@ async def test_settings_get_returns_configured_values(client_configured): @pytest.mark.asyncio -async def test_settings_post_writes_env(client): - """POST /api/settings schreibt Werte in .env-Datei.""" +async def test_settings_post_writes_config_ini(client): + """POST /api/settings schreibt Werte in config.ini (Migration von .env, v0.9.x).""" c, bridge = client with tempfile.TemporaryDirectory() as tmpdir: - env_path = pathlib.Path(tmpdir) / ".env" - env_path.write_text("") - bridge._find_env_path = lambda: env_path + config_path = pathlib.Path(tmpdir) / "config.ini" + bridge._find_config_path = lambda: config_path + bridge._restart_bridge = lambda: None # POST triggers a restart — don't kill the test process resp = await c.post("/api/settings", json={ "printer_ip": "10.0.0.5", @@ -59,24 +59,28 @@ async def test_settings_post_writes_env(client): }) assert resp.status == 200 - content = env_path.read_text() - assert "PRINTER_IP=10.0.0.5" in content - assert "MQTT_USERNAME=userABCD" in content - assert "DEVICE_ID=deadbeef01234567" in content + content = config_path.read_text() + assert "printer_ip = 10.0.0.5" in content + assert "username = userABCD" in content + assert "device_id = deadbeef01234567" in content @pytest.mark.asyncio async def test_settings_post_preserves_existing_keys(client): - """POST darf unbekannte Keys in .env nicht löschen (z.B. GITEA_TOKEN).""" + """POST darf unbekannte Sections/Optionen in config.ini nicht löschen (z.B. Spoolman-Server).""" c, bridge = client with tempfile.TemporaryDirectory() as tmpdir: - env_path = pathlib.Path(tmpdir) / ".env" - env_path.write_text("GITEA_TOKEN=mytoken\nPRINTER_IP=old\n") - bridge._find_env_path = lambda: env_path + config_path = pathlib.Path(tmpdir) / "config.ini" + config_path.write_text( + "[spoolman]\nserver = http://192.168.1.50:7912\n\n" + "[connection]\nprinter_ip = old\n" + ) + bridge._find_config_path = lambda: config_path + bridge._restart_bridge = lambda: None await c.post("/api/settings", json={"printer_ip": "10.0.0.99"}) - content = env_path.read_text() - assert "GITEA_TOKEN=mytoken" in content - assert "PRINTER_IP=10.0.0.99" in content + content = config_path.read_text() + assert "server = http://192.168.1.50:7912" in content + assert "printer_ip = 10.0.0.99" in content diff --git a/web/themes/default/app.js b/web/themes/default/app.js index 7d11be4..e127321 100644 --- a/web/themes/default/app.js +++ b/web/themes/default/app.js @@ -409,6 +409,15 @@ function applyLang(){ setText('lbl-set-theme',T.settings_cat_theme||'Hell / Dunkel umschalten'); setText('lbl-poll-interval',T.settings_poll||'Poll-Intervall (Sekunden)'); setText('lbl-verbose-http-log',T.settings_verbose_http_log||'Log every HTTP request (verbose)'); + var dashLbl=document.getElementById('dash-lbl-edit'); + if(dashLbl)dashLbl.textContent=_dashEditing?(T.dash_done||'Done'):(T.dash_edit||'Customize dashboard'); + setText('dash-lbl-reset',T.dash_reset||'Reset'); + setText('dash-lbl-save-preset',T.dash_save_preset||'Save as preset'); + var dashPresetStd=document.getElementById('dash-preset-standard'); + if(dashPresetStd)dashPresetStd.textContent=T.dash_preset_standard||'Standard'; + var dashPresetWide=document.getElementById('dash-preset-wide89'); + if(dashPresetWide)dashPresetWide.textContent=T.dash_preset_wide89||'Wide desktop'; + if(document.getElementById('dash-hidden-bar'))_dashRenderHiddenBar(); setText('lbl-filament-mapping',T.settings_filament_mapping||'Filament-Profil-Mapping (pro Slot)'); setText('lbl-filament-mapping-save',T.settings_filament_mapping_save||'Mapping speichern'); setText('lbl-visible-vendors',T.settings_visible_vendors||'Sichtbare Hersteller (Profil-Dropdown)'); @@ -1971,8 +1980,313 @@ var pollTimer; }).catch(function(){}); poll();pollTimer=setInterval(poll,ms); setInterval(_loadSpoolmanStatus,30000); + // initDashGrid() is called at the very end of the file, after all the + // DASH_* var declarations below have executed (they are hoisted but not yet + // assigned at this point in the IIFE). })(); +// ── Dashboard Free Grid (Issue #89) ──────────────────────────────────────── +// User-customizable dashboard powered by GridStack (v10, docs: gridstack.js). +// Cards move + resize on a 12-column snap grid; layout persisted per browser +// in localStorage. Built on documented APIs only: +// - float:false (default) -> compact packing, no holes between cards +// - staticGrid:true -> locked by default, setStatic(false) in edit mode +// - grid.save(false)/load(layout,false) -> serialize/restore by gs-id +// - draggable.cancel -> inputs/buttons/img excluded from drag start +// - columnOpts.breakpoints -> 1-column below 700px GRID width (sidebar-aware) +var DASH_STORAGE_KEY='dashLayout'; +var DASH_LAYOUT_VERSION=3; // v1/v2 = older editors; discard those states +var DASH_CARD_KEYS=['camera','progress','temps','motion','speed','fan','ams']; +// Layout arrays in GridStack save()/load() format. cellHeight=60px. +var DASH_DEFAULT_LAYOUT=[ + {id:'camera', x:0,y:0, w:12,h:7}, + {id:'progress',x:0,y:7, w:12,h:6}, + {id:'temps', x:0,y:13,w:6, h:7}, + {id:'motion', x:6,y:13,w:6, h:7}, + {id:'speed', x:0,y:20,w:6, h:3}, + {id:'fan', x:6,y:20,w:6, h:3}, + {id:'ams', x:0,y:23,w:12,h:4}, +]; +// "Wide desktop" preset (Blaim, Issue #89): big camera left, settings center, +// motion right. +var DASH_PRESET_WIDE=[ + {id:'progress',x:0, y:0, w:12,h:4}, + {id:'camera', x:0, y:4, w:6, h:11}, + {id:'temps', x:6, y:4, w:3, h:7}, + {id:'speed', x:6, y:11,w:3, h:2}, + {id:'fan', x:6, y:13,w:3, h:2}, + {id:'motion', x:9, y:4, w:3, h:11}, + {id:'ams', x:0, y:15,w:12,h:4}, +]; +var _dashGrid=null; +var _dashEditing=false; +var _dashHidden=[]; // [{id,x,y,w,h}] cards currently hidden (position kept for re-show) + +// GridStack requires .grid-stack-item > .grid-stack-item-content around each +// card — wrap the existing .card elements in place (IDs/event handlers survive, +// appendChild moves nodes without recreating them). +function _dashWrapCards(){ + var grid=document.getElementById('dash-grid'); + if(!grid)return; + DASH_CARD_KEYS.forEach(function(key){ + var card=grid.querySelector('[data-card="'+key+'"]'); + if(!card||card.parentNode.classList.contains('grid-stack-item-content'))return; + var item=document.createElement('div'); + item.className='grid-stack-item'; + item.setAttribute('gs-id',key); + var content=document.createElement('div'); + content.className='grid-stack-item-content'; + grid.insertBefore(item,card); + item.appendChild(content); + content.appendChild(card); + }); +} + +function _dashItem(key){ + return document.querySelector('#dash-grid .grid-stack-item[gs-id="'+key+'"]'); +} + +function _dashLoadState(){ + try{ + var raw=localStorage.getItem(DASH_STORAGE_KEY); + if(!raw)return null; + var s=JSON.parse(raw); + if(!s||s.version!==DASH_LAYOUT_VERSION||!Array.isArray(s.layout))return null; + return s; + }catch(e){return null;} +} +function _dashSaveState(){ + if(!_dashGrid)return; + localStorage.setItem(DASH_STORAGE_KEY,JSON.stringify({ + version:DASH_LAYOUT_VERSION, + layout:_dashGrid.save(false), // [{id,x,y,w,h},…] — visible widgets only + hidden:_dashHidden.slice(), + })); +} + +function initDashGrid(){ + var el=document.getElementById('dash-grid'); + if(!el||typeof GridStack==='undefined')return; + _dashWrapCards(); + _dashGrid=GridStack.init({ + cellHeight:60, + margin:8, + staticGrid:true, // locked by default; setStatic(false) enables drag+resize + columnOpts:{breakpoints:[{w:700,c:1}]}, // grid-width based (sidebar-aware) + draggable:{cancel:'input,textarea,button,select,option,img,.slider'}, + },el); + var state=_dashLoadState(); + if(state){ + _dashHidden=Array.isArray(state.hidden)?state.hidden:[]; + _dashGrid.load(state.layout,false); // update matching ids, no add/remove + _dashHidden.forEach(function(n){ + var item=_dashItem(n.id); + if(item){_dashGrid.removeWidget(item,false);item.style.display='none';} + }); + }else{ + _dashGrid.load(DASH_DEFAULT_LAYOUT,false); + } + _dashGrid.on('change',function(){if(_dashEditing)_dashSaveState();}); + _dashRefreshPresetDropdown(); +} + +function toggleDashEdit(){ + if(!_dashGrid)return; + _dashEditing=!_dashEditing; + _dashGrid.setStatic(!_dashEditing); + document.getElementById('dash-grid').classList.toggle('editing',_dashEditing); + document.getElementById('dash-lbl-edit').textContent=_dashEditing?(T.dash_done||'Done'):(T.dash_edit||'Customize dashboard'); + document.getElementById('dash-reset-btn').style.display=_dashEditing?'':'none'; + document.getElementById('dash-preset').style.display=_dashEditing?'':'none'; + document.getElementById('dash-preset-save-btn').style.display=_dashEditing?'':'none'; + _dashUpdatePresetDeleteBtn(); + if(_dashEditing)_dashInjectControls(); else _dashRemoveControls(); + _dashRenderHiddenBar(); + if(!_dashEditing)_dashSaveState(); +} + +// ── Custom presets (user-saved layouts) ──────────────────────────────────── +var DASH_CUSTOM_PRESETS_KEY='dashCustomPresets'; +var DASH_BUILTIN_PRESET_IDS=['standard','wide89']; + +function _dashLoadCustomPresets(){ + try{ + var raw=localStorage.getItem(DASH_CUSTOM_PRESETS_KEY); + var obj=raw?JSON.parse(raw):{}; + return (obj&&typeof obj==='object')?obj:{}; + }catch(e){return {};} +} +function _dashSaveCustomPresets(presets){ + localStorage.setItem(DASH_CUSTOM_PRESETS_KEY,JSON.stringify(presets)); +} + +function _dashRefreshPresetDropdown(){ + var sel=document.getElementById('dash-preset'); + if(!sel)return; + var current=sel.value; + var customs=_dashLoadCustomPresets(); + // Remove previously-rendered custom