From cbbec12d9b93a1882fc15442fdc8e032ef4bcfa1 Mon Sep 17 00:00:00 2001 From: x12sa Date: Fri, 27 Mar 2026 15:44:49 +0100 Subject: [PATCH] option to upload to a php interface --- .../plugins/flomni/flomni.py | 1 + .../flomni/flomni_webpage_generator.py | 366 +++++++++++++----- 2 files changed, 280 insertions(+), 87 deletions(-) diff --git a/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py b/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py index c1d7a4c..0f58094 100644 --- a/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py +++ b/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py @@ -1317,6 +1317,7 @@ class Flomni( self._webpage_gen = FlomniWebpageGenerator( bec_client=client, output_dir="~/data/raw/webpage/", + upload_url="http://s1090968537.online.de/upload.php", # optional ) self._webpage_gen.start() diff --git a/csaxs_bec/bec_ipython_client/plugins/flomni/flomni_webpage_generator.py b/csaxs_bec/bec_ipython_client/plugins/flomni/flomni_webpage_generator.py index 0df584b..2d8ff1e 100644 --- a/csaxs_bec/bec_ipython_client/plugins/flomni/flomni_webpage_generator.py +++ b/csaxs_bec/bec_ipython_client/plugins/flomni/flomni_webpage_generator.py @@ -3,10 +3,16 @@ flomni/webpage_generator.py ============================ Background thread that reads tomo progress from the BEC global variable store and writes status.json (every cycle) + status.html (once at startup) to a -staging directory. A separate upload process copies those files to the web host. +staging directory. An optional HttpUploader sends those files to a web host +after every cycle, running in a separate daemon thread so uploads never block +the generator cycle. Architecture ------------ +HttpUploader -- non-blocking HTTP uploader (fire-and-forget thread). + Tracks file mtimes; only uploads changed files. + Sends a cleanup request to the server when the + ptycho scan ID changes (removes old S*_*.png/jpg). WebpageGeneratorBase -- all common logic: queue, progress, idle detection, reconstruction queue, HTML, audio, phone numbers, outdated-page warning. Import this in subclasses. @@ -24,6 +30,7 @@ Integration (inside Flomni.__init__, after self._progress_proxy.reset()): self._webpage_gen = FlomniWebpageGenerator( bec_client=client, output_dir="~/data/raw/webpage/", + upload_url="http://omny.online/upload.php", # optional ) self._webpage_gen.start() @@ -191,6 +198,153 @@ def _derive_status( return "unknown" +# --------------------------------------------------------------------------- +# HTTP Uploader (non-blocking, fire-and-forget) +# --------------------------------------------------------------------------- + +class HttpUploader: + """ + Uploads files from a local directory to a remote server via HTTP POST. + + Uploads run in a daemon thread so they never block the generator cycle. + If an upload is already in progress when the next cycle fires, the new + upload request is dropped (logged at DEBUG level) rather than queuing up. + + File tracking: + - Tracks mtime of each file; only re-uploads files that have changed. + - upload_dir() -- uploads ALL eligible files (called once at start). + - upload_changed() -- uploads only files whose mtime has changed. + + Scan change cleanup: + - cleanup_ptycho_images() -- sends action=cleanup POST to the server, + asking it to delete all S*_*.png and S*_*.jpg files. + Called automatically by the generator when the ptycho scan ID changes. + + The server-side upload.php must: + - Accept POST with multipart file upload (field name 'file'). + - Accept POST with action=cleanup to delete ptycho image files. + - Enforce IP-based access control (129.129.122.x). + - Validate filename with regex to block path traversal. + """ + + _UPLOAD_SUFFIXES = {".html", ".json", ".jpg", ".png", ".pdf", ".txt"} + + def __init__(self, url: str, timeout: float = 20.0): + self._url = url + self._timeout = timeout + self._uploaded: dict[str, float] = {} # abs path -> mtime at last upload + self._lock = threading.Lock() + self._busy = False # True while an upload thread is running + + # ── Public API ────────────────────────────────────────────────────────── + + def upload_dir_async(self, directory: Path) -> None: + """Upload ALL eligible files in directory, in a background thread.""" + files = self._eligible_files(directory) + self._dispatch(self._upload_files, files, True) + + def upload_changed_async(self, directory: Path) -> None: + """Upload only changed files in directory, in a background thread.""" + files = self._changed_files(directory) + if files: + self._dispatch(self._upload_files, files, False) + + def cleanup_ptycho_images_async(self) -> None: + """Ask the server to delete all S*_*.png / S*_*.jpg files (background).""" + self._dispatch(self._do_cleanup) + + # ── Internal ──────────────────────────────────────────────────────────── + + def _dispatch(self, fn, *args) -> None: + """Run fn(*args) in a daemon thread. Drops the request if already busy.""" + with self._lock: + if self._busy: + logger.debug("HttpUploader: previous upload still running, skipping") + return + self._busy = True + + def _run(): + try: + fn(*args) + finally: + with self._lock: + self._busy = False + + t = threading.Thread(target=_run, name="HttpUploader", daemon=True) + t.start() + + def _eligible_files(self, directory: Path) -> list: + result = [] + for path in Path(directory).iterdir(): + if path.is_file() and path.suffix in self._UPLOAD_SUFFIXES: + result.append(path) + return result + + def _changed_files(self, directory: Path) -> list: + result = [] + for path in self._eligible_files(directory): + try: + mtime = path.stat().st_mtime + except OSError: + continue + if self._uploaded.get(str(path)) != mtime: + result.append(path) + return result + + def _upload_files(self, files: list, force: bool = False) -> None: + try: + import requests as _requests + except ImportError: + logger.warning("HttpUploader: 'requests' library not installed") + return + + for path in files: + try: + mtime = path.stat().st_mtime + except OSError: + continue + # Double-check mtime unless forced (upload_dir uses force=True) + if not force and self._uploaded.get(str(path)) == mtime: + continue + try: + with open(path, "rb") as f: + r = _requests.post( + self._url, + files={"file": (path.name, f)}, + timeout=self._timeout, + ) + if r.status_code == 200: + self._uploaded[str(path)] = mtime + logger.debug(f"HttpUploader: OK {path.name}") + else: + logger.warning( + f"HttpUploader: {path.name} -> HTTP {r.status_code}: " + f"{r.text[:120]}" + ) + except Exception as exc: + logger.warning(f"HttpUploader: {path.name} failed: {exc}") + + def _do_cleanup(self) -> None: + try: + import requests as _requests + except ImportError: + return + try: + r = _requests.post( + self._url, + data={"action": "cleanup"}, + timeout=self._timeout, + ) + logger.info(f"HttpUploader cleanup: {r.text[:120]}") + # Forget mtime records for ptycho files so they get re-uploaded + with self._lock: + to_remove = [k for k in self._uploaded if "/S" in k or "\\S" in k] + for k in to_remove: + self._uploaded.pop(k, None) + except Exception as exc: + logger.warning(f"HttpUploader cleanup failed: {exc}") + + # --------------------------------------------------------------------------- # Base generator # --------------------------------------------------------------------------- @@ -208,11 +362,13 @@ class WebpageGeneratorBase: output_dir: str = "~/data/raw/webpage/", cycle_interval: float = _CYCLE_INTERVAL_S, verbosity: int = VERBOSITY_NORMAL, + upload_url: str = None, ): self._bec = bec_client self._output_dir = Path(output_dir).expanduser().resolve() self._cycle_interval = cycle_interval self._verbosity = verbosity + self._uploader = HttpUploader(upload_url) if upload_url else None self._thread = None self._stop_event = threading.Event() @@ -262,6 +418,10 @@ class WebpageGeneratorBase: self._copy_logo() (self._output_dir / "status.html").write_text(_render_html(_PHONE_NUMBERS)) + # Upload static files (html + logo) once at startup + if self._uploader is not None: + self._uploader.upload_dir_async(self._output_dir) + self._stop_event.clear() self._thread = threading.Thread( target=self._run, name="WebpageGenerator", daemon=True @@ -269,7 +429,8 @@ class WebpageGeneratorBase: self._thread.start() self._log(VERBOSITY_NORMAL, f"WebpageGenerator started owner={self._owner_id} " - f"output={self._output_dir} interval={self._cycle_interval}s") + f"output={self._output_dir} interval={self._cycle_interval}s" + + (f" upload={self._uploader._url}" if self._uploader else " upload=disabled")) def stop(self) -> None: """Stop the generator thread and release the singleton lock.""" @@ -299,6 +460,7 @@ class WebpageGeneratorBase: f" Lock heartbeat : {lock.get('heartbeat', 'never')}\n" f" Output dir : {self._output_dir}\n" f" Cycle interval : {self._cycle_interval}s\n" + f" Upload URL : {self._uploader._url if self._uploader else 'disabled'}\n" f" Verbosity : {self._verbosity}\n" ) @@ -415,7 +577,7 @@ class WebpageGeneratorBase: self._stop_event.wait(max(0.0, self._cycle_interval - (_epoch() - t0))) def _cycle(self) -> None: - """One generator cycle: read state -> derive status -> write status.json.""" + """One generator cycle: read state -> derive status -> write files -> upload.""" # ── Progress ───────────────────────────────────────────────── progress = self._bec.get_global_var("tomo_progress") or {} @@ -456,9 +618,6 @@ class WebpageGeneratorBase: self._last_queue_id = latest_queue_id if tomo_active or queue_has_active_scan or history_changed: - # history_changed catches scans that started and finished between - # two polls: update last_active_time so the idle clock starts from - # now, not from before the scan ran. self._last_active_time = _epoch() self._had_activity = True @@ -474,7 +633,10 @@ class WebpageGeneratorBase: # ── Reconstruction queue ────────────────────────────────────── recon = self._collect_recon_data() - # ── Ptychography images ────────────────────────────────────── + # ── Ptychography images ─────────────────────────────────────── + # Snapshot scan id BEFORE collecting so we can detect changes + prev_ptycho_scan = self._last_ptycho_scan + ptycho = {} try: ptycho = self._collect_ptycho_images() @@ -522,11 +684,37 @@ class WebpageGeneratorBase: }, } - # ── Write status.json only (HTML is static, written once at start) ── + # ── Write status.json ───────────────────────────────────────── + # (HTML is static, written once at _launch()) (self._output_dir / "status.json").write_text( json.dumps(payload, indent=2, default=str) ) + # ── Upload ──────────────────────────────────────────────────── + # Uploads run in a background daemon thread — never blocks this cycle. + # If the server is unreachable the cycle continues normally; failures + # are logged as warnings. If an upload is still in progress from the + # previous cycle the new request is dropped (logged at DEBUG level). + if self._uploader is not None: + new_scan = ptycho.get("scan_id") + if new_scan and new_scan != prev_ptycho_scan: + # Scan changed: ask server to delete old S*_*.png/jpg first, + # then upload all current files (including new ptycho images). + self._log(VERBOSITY_VERBOSE, + f"Ptycho scan changed {prev_ptycho_scan} -> {new_scan}, " + f"triggering remote cleanup + full upload") + self._uploader.cleanup_ptycho_images_async() + # Small delay so cleanup runs before the file uploads start. + # Both are async, so we schedule the full upload on a short timer. + def _delayed_full_upload(): + time.sleep(2) + self._uploader.upload_dir_async(self._output_dir) + threading.Thread(target=_delayed_full_upload, + name="HttpUploaderDelayed", daemon=True).start() + else: + # Normal cycle: only upload files that have changed since last cycle. + self._uploader.upload_changed_async(self._output_dir) + # ── Console feedback ────────────────────────────────────────── self._log(VERBOSITY_VERBOSE, f"[{_now_iso()}] {exp_status:<12} active={queue_has_active_scan} " @@ -596,7 +784,6 @@ class WebpageGeneratorBase: return {} # Find latest subfolder by modification time. - # Typically named dset_NNNNN but any subfolder is accepted. candidates = sorted( [d for d in base.iterdir() if d.is_dir()], key=lambda d: d.stat().st_mtime, @@ -622,7 +809,6 @@ class WebpageGeneratorBase: matches = sorted(latest_dir.glob(pattern)) if matches: src = matches[-1] - # Extract scan id from filename prefix (e.g. S06666) stem = src.stem # e.g. S06666_400x400_b0_run_1_phase sid = stem.split("_")[0] if scan_id is None: @@ -664,7 +850,6 @@ class WebpageGeneratorBase: old_file.unlink(missing_ok=True) for old_file in self._output_dir.glob("S*_*.png"): if not old_file.name.startswith(scan_id + "_"): - # Only clean up ptycho role files, not other PNGs (e.g. logo) for role, _, _ in _ROLES: if f"_{role}." in old_file.name or f"_{role}_" in old_file.name: old_file.unlink(missing_ok=True) @@ -681,14 +866,12 @@ class WebpageGeneratorBase: thumb_path = self._output_dir / thumb_name full_path = self._output_dir / full_name try: - # Thumbnail via Pillow if _PIL_AVAILABLE: img = _PILImage.open(src) img.thumbnail((_PTYCHO_THUMB_PX, _PTYCHO_THUMB_PX)) img.save(thumb_path, "JPEG", quality=85) else: shutil.copy2(src, thumb_path) - # Full-size copy shutil.copy2(src, full_path) images.append({ "role": role, @@ -821,7 +1004,8 @@ def make_webpage_generator(bec_client, **kwargs): from csaxs_bec.bec_ipython_client.plugins.flomni.webpage_generator import ( make_webpage_generator, ) - gen = make_webpage_generator(bec, output_dir="~/data/raw/webpage/") + gen = make_webpage_generator(bec, output_dir="~/data/raw/webpage/", + upload_url="http://omny.online/upload.php") gen.start() """ try: @@ -1424,7 +1608,6 @@ function setTheme(t) {{ }})(); // ── Drag-and-drop card ordering ────────────────────────────────────────── -// Default order stored as card-id array; persisted in localStorage. const CARD_ORDER_KEY = 'cardOrder'; const DEFAULT_ORDER = ['audio','recon-queue','ptycho','instrument','contacts']; let _dragSrc = null; @@ -1445,7 +1628,6 @@ function applyOrder(order) {{ }} function initDrag() {{ - // Restore saved order (runs before first render so no visible reorder flash) const order = savedOrder(); if(order) applyOrder(order); @@ -1460,7 +1642,6 @@ function initDrag() {{ card.addEventListener('dragend', () => {{ card.classList.remove('dragging'); document.querySelectorAll('.drag-over').forEach(el => el.classList.remove('drag-over')); - // Save new order const ids = [...document.querySelectorAll('[data-card-id]')].map(el => el.dataset.cardId); localStorage.setItem(CARD_ORDER_KEY, JSON.stringify(ids)); }}); @@ -1486,64 +1667,90 @@ function initDrag() {{ }} initDrag(); + // ── Audio ───────────────────────────────────────────────────────────────── -// Two independent warning channels: -// -// Measurement warning (warningActive / warningTimer) -// Fires on: armed scan ends (scanning → not-scanning edge) -// Chime: two descending tones (660 → 440 Hz) -// Cleared by: Confirm button, or scan resuming -// LED: Watch (pulsing orange while active, green while scan running+armed) -// -// Live feed warning (staleActive / staleTimer) -// Fires on: status.json age > STALE_S (generator stopped / network issue) -// Chime: three rapid high-pitched beeps (different from measurement chime) -// Cleared by: fresh data arriving, or Confirm feed button -// LED: Live (green when alive, pulsing orange when feed lost) -// Only triggers if audioEnabled — otherwise banner is the only indicator. -// -// Both channels are independent: one can be confirmed while the other plays. +// iOS (all browsers on iPhone use WebKit) requires: +// 1. AudioContext created inside a user gesture. +// 2. A real (even silent) BufferSource started synchronously in the gesture. +// 3. ctx.resume() awaited before scheduling audible nodes. +// We combine all three: silent unlock buffer + resume promise + .then(beeps). -let audioCtx=null, audioEnabled=false, audioUnlocked=false; -let audioArmed=false, warningActive=false, warningTimer=null, lastStatus=null; -let staleActive=false, staleTimer=null, staleConfirmed=false; +let audioCtx = null, audioEnabled = false; +let audioArmed = false, warningActive = false, warningTimer = null, lastStatus = null; +let staleActive = false, staleTimer = null, staleConfirmed = false; -function getCtx(){{ if(!audioCtx) audioCtx=new(window.AudioContext||window.webkitAudioContext)(); return audioCtx; }} -function ensureUnlocked(){{ if(!audioUnlocked){{ getCtx().resume(); audioUnlocked=true; }} }} -function beep(freq,dur,vol){{ - try{{ - const ctx=getCtx(),o=ctx.createOscillator(),g=ctx.createGain(); +function getCtx() {{ + if (!audioCtx) audioCtx = new (window.AudioContext || window.webkitAudioContext)(); + return audioCtx; +}} + +function unlockAudio() {{ + // Must be called synchronously inside a user gesture handler. + // Plays a 1-sample silent buffer — the most reliable iOS unlock method. + const ctx = getCtx(); + const buf = ctx.createBuffer(1, 1, ctx.sampleRate); + const src = ctx.createBufferSource(); + src.buffer = buf; + src.connect(ctx.destination); + src.start(0); + // resume() is async; return the promise so callers can chain. + return ctx.state === 'suspended' ? ctx.resume() : Promise.resolve(); +}} + +function beep(freq, dur, vol) {{ + try {{ + const ctx = getCtx(); + const o = ctx.createOscillator(); + const g = ctx.createGain(); o.connect(g); g.connect(ctx.destination); - o.type='sine'; o.frequency.setValueAtTime(freq,ctx.currentTime); - g.gain.setValueAtTime(vol,ctx.currentTime); - g.gain.exponentialRampToValueAtTime(0.001,ctx.currentTime+dur); - o.start(); o.stop(ctx.currentTime+dur); - }}catch(e){{console.warn('Audio:',e);}} + o.type = 'sine'; + o.frequency.setValueAtTime(freq, ctx.currentTime); + g.gain.setValueAtTime(vol, ctx.currentTime); + g.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + dur); + o.start(); o.stop(ctx.currentTime + dur); + }} catch(e) {{ console.warn('Audio beep:', e); }} }} -// Measurement-stopped chime: two descending tones -function warningChime(){{ beep(660,0.3,0.4); setTimeout(()=>beep(440,0.5,0.4),350); }} -// Live-feed-lost chime: three rapid high beeps (clearly different) -function staleChime(){{ beep(1200,0.12,0.35); setTimeout(()=>beep(1200,0.12,0.35),180); setTimeout(()=>beep(1200,0.25,0.35),360); }} -function testSound(){{ ensureUnlocked(); beep(880,0.15,0.4); setTimeout(()=>beep(1100,0.15,0.4),180); setTimeout(()=>beep(880,0.3,0.4),360); }} -// ── Measurement warning ─────────────────────────────────────────────────── -function toggleAudio(){{ - ensureUnlocked(); - audioEnabled=!audioEnabled; - localStorage.setItem('audioEnabled',audioEnabled); - if(!audioEnabled){{ - stopWarning(); audioArmed=false; warningActive=false; - document.getElementById('btn-confirm').style.display='none'; - stopStaleWarning(); staleActive=false; staleConfirmed=false; - document.getElementById('btn-confirm-stale').style.display='none'; - }} else {{ - // Sync armed state immediately from current status — don't wait for next poll. - // If a scan is already running when audio is enabled, arm right away. - if(lastStatus==='scanning' && !audioArmed) audioArmed=true; - }} - updateAudioUI(); +function warningChime() {{ + beep(660, 0.3, 0.4); + setTimeout(() => beep(440, 0.5, 0.4), 350); }} +function staleChime() {{ + beep(1200, 0.12, 0.35); + setTimeout(() => beep(1200, 0.12, 0.35), 180); + setTimeout(() => beep(1200, 0.25, 0.35), 360); +}} + +function testSound() {{ + // Called directly from onclick — gesture is active here. + unlockAudio().then(() => {{ + beep(880, 0.15, 0.4); + setTimeout(() => beep(1100, 0.15, 0.4), 180); + setTimeout(() => beep(880, 0.3, 0.4), 360); + }}); +}} + +function toggleAudio() {{ + // Called directly from onclick — gesture is active here. + unlockAudio().then(() => {{ + audioEnabled = !audioEnabled; + localStorage.setItem('audioEnabled', audioEnabled); + if (!audioEnabled) {{ + stopWarning(); + audioArmed = false; warningActive = false; + document.getElementById('btn-confirm').style.display = 'none'; + stopStaleWarning(); + staleActive = false; staleConfirmed = false; + document.getElementById('btn-confirm-stale').style.display = 'none'; + }} else {{ + if (lastStatus === 'scanning' && !audioArmed) audioArmed = true; + }} + updateAudioUI(); + }}); +}} + + function confirmWarning(){{ stopWarning(); warningActive=false; @@ -1554,7 +1761,7 @@ function confirmWarning(){{ function startWarning(){{ if(warningActive) return; warningActive=true; - if(audioEnabled) warningChime(); + if(audioEnabled) warningChime(); // returns promise; chime plays after unlock warningTimer=setInterval(()=>{{ if(audioEnabled) warningChime(); }},30000); document.getElementById('btn-confirm').style.display='inline-block'; updateAudioUI(); @@ -1564,7 +1771,6 @@ function stopWarning(){{ if(warningTimer){{clearInterval(warningTimer);warningTimer=null;}} }} -// ── Live feed warning ──────────────────────────────────────────────────── function confirmStale(){{ stopStaleWarning(); staleActive=false; @@ -1576,7 +1782,7 @@ function confirmStale(){{ function startStaleWarning(){{ if(staleActive || staleConfirmed) return; staleActive=true; - if(audioEnabled) staleChime(); + if(audioEnabled) staleChime(); // returns promise; chime plays after unlock staleTimer=setInterval(()=>{{ if(audioEnabled) staleChime(); }},30000); document.getElementById('btn-confirm-stale').style.display='inline-block'; updateAudioUI(); @@ -1589,9 +1795,7 @@ function stopStaleWarning(){{ function handleStale(isStale){{ if(isStale){{ if(audioEnabled) startStaleWarning(); - // If audio not enabled, banner is the only indicator — no chime }}else{{ - // Fresh data arrived — auto-clear stale warning and re-arm for next outage if(staleActive || staleConfirmed){{ stopStaleWarning(); staleActive=false; staleConfirmed=false; @@ -1601,7 +1805,6 @@ function handleStale(isStale){{ }} }} -// ── Combined UI update ──────────────────────────────────────────────────── function updateAudioUI(){{ const ledSys=document.getElementById('led-system'), ledWatch=document.getElementById('led-watch'), @@ -1609,17 +1812,12 @@ function updateAudioUI(){{ btn=document.getElementById('btn-toggle'), txt=document.getElementById('audio-text'); - // System LED: on (cyan) when enabled ledSys.className='led'+(audioEnabled?' led-on':''); btn.textContent=audioEnabled?'Disable':'Enable'; btn.classList.toggle('active',audioEnabled); - // Live LED: green when feed is fresh, pulsing orange when stale ledConn.className='led'+(staleActive?' led-warning':' led-live'); - // Watch LED + status text. - // "armed" (green) only while a scan is actually running. - // After confirm or before first scan → off with "waiting" text. const scanRunning=(lastStatus==='scanning'); if(!audioEnabled){{ ledWatch.className='led'; @@ -1642,29 +1840,24 @@ function updateAudioUI(){{ }} }} -// ── Measurement status handler ──────────────────────────────────────────── function handleAudioForStatus(status, prevStatus){{ if(!audioEnabled) return; const isScanning=(status==='scanning'); const wasScanning=(prevStatus==='scanning'); - // Auto-arm when scan starts; also refresh UI when already armed and scan resumes if(isScanning && !audioArmed){{ audioArmed=true; if(warningActive){{ stopWarning(); warningActive=false; document.getElementById('btn-confirm').style.display='none'; }} updateAudioUI(); }} else if(isScanning && audioArmed){{ - // Scan resumed while already armed (e.g. after confirm) — refresh LED updateAudioUI(); }} - // Armed scan ended → trigger warning if(audioArmed && wasScanning && !isScanning){{ startWarning(); }} - // Scan resumed while warning active → cancel warning, stay armed if(isScanning && warningActive){{ stopWarning(); warningActive=false; document.getElementById('btn-confirm').style.display='none'; updateAudioUI(); @@ -1685,7 +1878,6 @@ function closeLightbox(){{ document.addEventListener('keydown', e=>{{ if(e.key==='Escape') closeLightbox(); }}); function thumbHTML(img){{ - // Use data-full attribute to avoid any filename-quoting issues in onclick return '
' + '' + img.role + '' + '
' + img.role + '
' @@ -1699,7 +1891,6 @@ function renderPtycho(ptycho){{ _ptychoScanId=null; return; }} - // Skip full re-render if scan unchanged — avoids image flicker on each poll if(ptycho.scan_id===_ptychoScanId) return; _ptychoScanId=ptycho.scan_id; @@ -1792,8 +1983,6 @@ function render(d){{ document.getElementById('footer-gen').textContent='generator: '+((d.generator||{{}}).owner_id||'-'); document.getElementById('footer-hb').textContent='tomo_heartbeat: '+(p.tomo_heartbeat_age_s!=null?p.tomo_heartbeat_age_s+'s ago':'none'); - // Audio state machine — pass previous status explicitly so wasScanning - // is correct, and lastStatus is already up-to-date when updateAudioUI runs. const prevStatus=lastStatus; lastStatus=s; handleAudioForStatus(s, prevStatus); @@ -1812,6 +2001,9 @@ async function poll(){{ }} }} +// Restore audio preference from localStorage. +// Do NOT call ensureUnlocked() here — iOS requires a user gesture first. +// The context will be unlocked when the user clicks Enable or Test sound. if(localStorage.getItem('audioEnabled')==='true'){{ audioEnabled=true; }} updateAudioUI(); setInterval(poll,POLL_MS);