option to upload to a php interface
All checks were successful
CI for csaxs_bec / test (push) Successful in 1m57s

This commit is contained in:
x12sa
2026-03-27 15:44:49 +01:00
parent 8f4a9f025e
commit cbbec12d9b
2 changed files with 280 additions and 87 deletions

View File

@@ -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()

View File

@@ -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 '<div class="ptycho-thumb-wrap" data-full="' + img.full + '" onclick="openLightbox(this.dataset.full)">'
+ '<img src="' + img.thumb + '" loading="lazy" alt="' + img.role + '">'
+ '<div class="ptycho-role-label">' + img.role + '</div>'
@@ -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);