next iteration, seems a first good and usable ver.
CI for csaxs_bec / test (push) Successful in 2m6s

This commit is contained in:
x12sa
2026-03-26 15:09:42 +01:00
parent 6f919ecfdf
commit 32e1ee374a
@@ -54,10 +54,9 @@ logger = bec_logger.logger
# ---------------------------------------------------------------------------
_LOCK_VAR_KEY = "webpage_generator_lock"
_LOCK_STALE_AFTER_S = 45
_LOCK_STALE_AFTER_S = 35 # just over 2 missed cycles (2×15s+5s margin)
_CYCLE_INTERVAL_S = 15
_TOMO_HEARTBEAT_STALE_S = 90
_IDLE_SHORT_WINDOW_S = 300 # 5 min → switch to idle_long + audio warning
VERBOSITY_SILENT = 0
VERBOSITY_NORMAL = 1
@@ -146,22 +145,31 @@ def _gvar(bec_client, key, fmt=None, suffix=""):
# Status derivation
# ---------------------------------------------------------------------------
def _derive_status(progress: dict, queue_has_active_scan: bool, idle_since) -> str:
def _derive_status(
progress: dict,
queue_has_active_scan: bool,
last_active_time,
had_activity: bool,
) -> str:
"""
Returns one of:
scanning -- tomo heartbeat fresh
running -- queue has active scan, outside tomo heartbeat window
idle_short -- idle < _IDLE_SHORT_WINDOW_S
idle_long -- idle >= _IDLE_SHORT_WINDOW_S (triggers audio warning)
unknown -- no information yet
scanning -- tomo heartbeat fresh (< _TOMO_HEARTBEAT_STALE_S)
running -- queue currently has an active scan
idle -- not scanning, last_active_time known
unknown -- no activity ever seen since generator started
'unknown' is ONLY returned before any scan activity has been observed.
Once activity has been seen the status goes directly:
scanning/running -> idle
never through 'unknown'.
"""
hb_age = _heartbeat_age_s(progress.get("heartbeat"))
if hb_age < _TOMO_HEARTBEAT_STALE_S:
return "scanning"
if queue_has_active_scan:
return "running"
if idle_since is not None:
return "idle_short" if (_epoch() - idle_since) < _IDLE_SHORT_WINDOW_S else "idle_long"
if last_active_time is not None or had_activity:
return "idle"
return "unknown"
@@ -190,29 +198,44 @@ class WebpageGeneratorBase:
self._thread = None
self._stop_event = threading.Event()
self._idle_since = None
self._last_queue_id = None # tracks queue history changes between cycles
self._owner_id = f"{socket.gethostname()}:{os.getpid()}"
self._last_active_time = None # epoch of last tomo/queue activity
self._had_activity = False # True once any activity has been observed
self._last_queue_id = None # tracks queue history changes between cycles
self._owner_id = f"{socket.gethostname()}:{os.getpid()}"
# ------------------------------------------------------------------
# Public API
# ------------------------------------------------------------------
def start(self) -> bool:
"""Start the generator thread if this session wins the singleton lock."""
def start(self) -> None:
"""Start the generator.
If this session already holds the thread, does nothing.
If another session holds a fresh lock, a background watcher thread
is launched that polls every 3 s and takes over as soon as the lock
goes stale — no blocking, no second call to start() needed.
Progress messages during the wait are printed at verbosity >= 2 only.
"""
if not _check_account_match(self._bec):
self._log(VERBOSITY_NORMAL,
"WebpageGenerator: BEC account does not match system user. "
"Not starting.", level="warning")
return False
return
if self._thread is not None and self._thread.is_alive():
self._log(VERBOSITY_NORMAL, "WebpageGenerator already running in this session.")
return True
return
if not self._acquire_lock():
return False
# Lock is fresh — _acquire_lock() has spawned a background watcher
# that will call _launch() automatically once the lock expires.
return
self._launch()
def _launch(self) -> None:
"""Actually start the worker thread and write static files. Called by
start() on the normal path, or by the lock-watcher thread after takeover."""
self._output_dir.mkdir(parents=True, exist_ok=True)
# Copy logo once at startup — HTML is also written once here,
@@ -228,7 +251,6 @@ class WebpageGeneratorBase:
self._log(VERBOSITY_NORMAL,
f"WebpageGenerator started owner={self._owner_id} "
f"output={self._output_dir} interval={self._cycle_interval}s")
return True
def stop(self) -> None:
"""Stop the generator thread and release the singleton lock."""
@@ -294,18 +316,49 @@ class WebpageGeneratorBase:
# ------------------------------------------------------------------
def _acquire_lock(self) -> bool:
lock = self._read_lock()
if lock:
age = _heartbeat_age_s(lock.get("heartbeat"))
if age < _LOCK_STALE_AFTER_S:
"""Try to acquire the singleton lock.
Returns True immediately if the lock is free or stale.
Returns False and launches a background watcher thread if the lock is
held by another session — the watcher will call _launch() automatically
once the lock expires (no user action needed).
"""
_POLL = 3 # seconds between retries in the watcher thread
lock = self._read_lock()
owner = lock.get("owner_id", "unknown") if lock else "none"
age = _heartbeat_age_s(lock.get("heartbeat")) if lock else float("inf")
if age >= _LOCK_STALE_AFTER_S:
if lock:
self._log(VERBOSITY_NORMAL,
f"WebpageGenerator already owned by '{lock.get('owner_id')}' "
f"({age:.0f}s ago). Not starting.")
return False
self._log(VERBOSITY_NORMAL,
f"Stale lock (owner: '{lock.get('owner_id')}', {age:.0f}s ago). Taking over.")
self._write_lock()
return True
f"Stale lock (owner: '{owner}', {age:.0f}s ago). Taking over.")
self._write_lock()
return True
# Lock is fresh — spin up a background watcher and return immediately.
self._log(VERBOSITY_NORMAL,
f"WebpageGenerator: lock held by '{owner}' ({age:.0f}s ago). "
f"Will take over automatically when it expires.")
def _watcher():
while True:
time.sleep(_POLL)
lk = self._read_lock()
age = _heartbeat_age_s(lk.get("heartbeat")) if lk else float("inf")
self._log(VERBOSITY_VERBOSE,
f" …waiting for lock, age {age:.0f}s / {_LOCK_STALE_AFTER_S}s")
if age >= _LOCK_STALE_AFTER_S:
break
owner2 = lk.get("owner_id", "unknown") if lk else "none"
self._log(VERBOSITY_VERBOSE,
f"Lock expired (was '{owner2}'). Taking over.")
self._write_lock()
self._launch()
t = threading.Thread(target=_watcher, name="WebpageGeneratorWatcher", daemon=True)
t.start()
return False
def _write_lock(self) -> None:
self._bec.set_global_var(_LOCK_VAR_KEY, {
@@ -384,12 +437,20 @@ class WebpageGeneratorBase:
self._last_queue_id = latest_queue_id
if tomo_active or queue_has_active_scan or history_changed:
self._idle_since = None
elif self._idle_since is None:
self._idle_since = _epoch()
# 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
exp_status = _derive_status(progress, queue_has_active_scan, self._idle_since)
idle_for_s = None if self._idle_since is None else (_epoch() - self._idle_since)
exp_status = _derive_status(
progress, queue_has_active_scan,
self._last_active_time, self._had_activity,
)
idle_for_s = (
None if self._last_active_time is None
else max(0.0, _epoch() - self._last_active_time)
)
# ── Reconstruction queue ──────────────────────────────────────
recon = self._collect_recon_data()
@@ -561,14 +622,19 @@ class FlomniWebpageGenerator(WebpageGeneratorBase):
stx = g.get_global_var("stitch_x")
sty = g.get_global_var("stitch_y")
def _fmt_int(v):
try:
return str(int(v))
except (TypeError, ValueError):
return "N/A"
settings = {
"Sample name": sample_name,
"FOV x / y": f"{_fmt2(fovx)} / {_fmt2(fovy)} \u00b5m",
"Step size": _gvar(g, "tomo_shellstep", ".2f", " \u00b5m"),
"Exposure time": _gvar(g, "tomo_countingtime", ".3f", " s"),
"Angle step": _gvar(g, "tomo_angle_stepsize", ".2f", "\u00b0"),
"Stitch x / y": f"{_fmt2(stx)} / {_fmt2(sty)} \u00b5m",
"Corridor size": str(g.get_global_var("corridor_size") or "N/A"),
"Stitch x / y": f"{_fmt_int(stx)} / {_fmt_int(sty)}",
}
return {
@@ -649,7 +715,6 @@ def _render_html(phone_numbers: list) -> str:
--c-scanning: #89dceb;
--c-running: #a6e3a1;
--c-idle-short: #f9e2af;
--c-idle-long: #fab387;
--c-error: #f38ba8;
--status-color: #6c7a9c;
--ring-blend: #4a5568;
@@ -691,9 +756,8 @@ def _render_html(phone_numbers: list) -> str:
/* ── Status colour cascades ── */
body.scanning {{ --status-color: var(--c-scanning); --ring-blend: #3a6b74; }}
body.running {{ --status-color: var(--c-running); --ring-blend: #3a6644; }}
body.idle_short {{ --status-color: var(--c-idle-short); --ring-blend: #7a6e44; }}
body.idle_long {{ --status-color: var(--c-idle-long); --ring-blend: #7a5a3a; }}
body.error {{ --status-color: var(--c-error); --ring-blend: #7a3a44; }}
body.idle {{ --status-color: var(--c-idle-short); --ring-blend: #7a6e44; }}
body.error {{ --status-color: var(--c-error); --ring-blend: #7a3a44; }}
body.unknown {{ --status-color: var(--text-dim); --ring-blend: #4a5568; }}
* {{ box-sizing: border-box; margin: 0; padding: 0; }}
@@ -893,13 +957,30 @@ def _render_html(phone_numbers: list) -> str:
display: flex; align-items: center;
justify-content: space-between; gap: 1rem; flex-wrap: wrap;
}}
.audio-info {{ display: flex; align-items: center; gap: 0.75rem; }}
.audio-dot {{
width: 8px; height: 8px; border-radius: 50%; background: var(--text-dim);
transition: background 0.3s; flex-shrink: 0;
.audio-info {{ display: flex; align-items: center; gap: 1rem; }}
.audio-leds {{ display: flex; gap: 0.9rem; align-items: center; }}
.led-group {{ display: flex; flex-direction: column; align-items: center; gap: 0.25rem; }}
.led-label {{
font-family: var(--mono); font-size: 0.55rem; letter-spacing: 0.08em;
text-transform: uppercase; color: var(--text-dim);
}}
.led {{
width: 9px; height: 9px; border-radius: 50%;
background: var(--surface2); border: 1px solid var(--border);
transition: background 0.3s, box-shadow 0.3s;
}}
.led.led-on {{ background: #89dceb; border-color: #89dceb;
box-shadow: 0 0 6px #89dceb; }}
.led.led-live {{ background: #a6e3a1; border-color: #a6e3a1;
box-shadow: 0 0 6px #a6e3a1; }}
.led.led-armed {{ background: #a6e3a1; border-color: #a6e3a1;
box-shadow: 0 0 6px #a6e3a1; }}
.led.led-warning {{ background: var(--c-idle-long); border-color: var(--c-idle-long);
box-shadow: 0 0 8px var(--c-idle-long);
animation: led-pulse 1s ease-in-out infinite; }}
@keyframes led-pulse {{
0%,100% {{ opacity: 1; }} 50% {{ opacity: 0.4; }}
}}
.audio-dot.active {{ background: var(--c-scanning); box-shadow: 0 0 6px var(--c-scanning); }}
.audio-dot.confirmed {{ background: var(--c-idle-short); }}
.audio-text {{ font-size: 0.85rem; color: var(--text-dim); }}
.audio-controls {{ display: flex; gap: 0.6rem; flex-wrap: wrap; }}
@@ -1040,11 +1121,25 @@ def _render_html(phone_numbers: list) -> str:
<!-- Audio -->
<div class="card audio-card">
<div class="audio-info">
<div class="audio-dot" id="audio-dot"></div>
<span class="audio-text" id="audio-text">Audio warnings: disabled</span>
<div class="audio-leds">
<div class="led-group">
<div class="led" id="led-system"></div>
<span class="led-label">System</span>
</div>
<div class="led-group">
<div class="led" id="led-watch"></div>
<span class="led-label">Watch</span>
</div>
<div class="led-group">
<div class="led" id="led-conn"></div>
<span class="led-label">Live</span>
</div>
</div>
<span class="audio-text" id="audio-text">Audio disabled</span>
</div>
<div class="audio-controls">
<button id="btn-confirm" class="confirm-btn" onclick="confirmWarning()">Confirm warning</button>
<button id="btn-confirm" class="confirm-btn" onclick="confirmWarning()">Confirm</button>
<button id="btn-confirm-stale" class="confirm-btn" onclick="confirmStale()">Confirm feed</button>
<button id="btn-toggle" onclick="toggleAudio()">Enable</button>
<button onclick="testSound()">Test sound</button>
</div>
@@ -1060,7 +1155,6 @@ def _render_html(phone_numbers: list) -> str:
<footer>
<span id="footer-gen">generator: -</span>
<span id="footer-queue">queue active: -</span>
<span id="footer-hb">tomo_heartbeat: -</span>
</footer>
@@ -1068,8 +1162,7 @@ def _render_html(phone_numbers: list) -> str:
<script>
const STATUS_JSON = 'status.json';
const POLL_MS = 15000;
const STALE_S = {_IDLE_SHORT_WINDOW_S};
const WARN_STATUSES = new Set(['idle_long', 'error', 'unknown']);
const STALE_S = 300; // outdated-banner threshold (seconds)
// ── Theme ─────────────────────────────────────────────────────────────────
function setTheme(t) {{
@@ -1086,8 +1179,26 @@ function setTheme(t) {{
}})();
// ── 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.
let audioCtx=null, audioEnabled=false, audioUnlocked=false;
let warningTimer=null, warningConfirmed=false, lastWarnStatus=null;
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; }} }}
@@ -1101,59 +1212,162 @@ function beep(freq,dur,vol){{
o.start(); o.stop(ctx.currentTime+dur);
}}catch(e){{console.warn('Audio:',e);}}
}}
function warningChime(){{ beep(660,0.3,0.4); setTimeout(()=>beep(440,0.4,0.4),350); }}
// 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;
ensureUnlocked();
audioEnabled=!audioEnabled;
localStorage.setItem('audioEnabled',audioEnabled);
updateAudioUI(); if(!audioEnabled) stopWarning();
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 confirmWarning(){{
warningConfirmed=true;
stopWarning();
// hide button, dim LED — audio stops but visual warning remains
warningActive=false;
document.getElementById('btn-confirm').style.display='none';
const dot=document.getElementById('audio-dot');
dot.classList.remove('active');
dot.classList.add('confirmed');
}}
function updateAudioUI(){{
const btn=document.getElementById('btn-toggle'),
dot=document.getElementById('audio-dot'),
txt=document.getElementById('audio-text');
if(audioEnabled){{
btn.textContent='Disable'; btn.classList.add('active');
if(!warningConfirmed) dot.classList.add('active');
txt.textContent='Audio warnings: enabled';
}}else{{
btn.textContent='Enable'; btn.classList.remove('active');
dot.classList.remove('active');
txt.textContent='Audio warnings: disabled';
}}
updateAudioUI();
}}
function startWarning(){{
// Start the audio timer only if not already running and not confirmed
if(!warningTimer && !warningConfirmed){{
if(audioEnabled) warningChime();
warningTimer=setInterval(()=>{{if(audioEnabled&&!warningConfirmed) warningChime();}},30000);
}}
// Show confirm button only if warning not yet acknowledged this episode
if(!warningConfirmed){{
document.getElementById('btn-confirm').style.display='inline-block';
if(warningActive) return;
warningActive=true;
if(audioEnabled) warningChime();
warningTimer=setInterval(()=>{{ if(audioEnabled) warningChime(); }},30000);
document.getElementById('btn-confirm').style.display='inline-block';
updateAudioUI();
}}
function stopWarning(){{
if(warningTimer){{clearInterval(warningTimer);warningTimer=null;}}
}}
// ── Live feed warning ────────────────────────────────────────────────────
function confirmStale(){{
stopStaleWarning();
staleActive=false;
staleConfirmed=true;
document.getElementById('btn-confirm-stale').style.display='none';
updateAudioUI();
}}
function startStaleWarning(){{
if(staleActive || staleConfirmed) return;
staleActive=true;
if(audioEnabled) staleChime();
staleTimer=setInterval(()=>{{ if(audioEnabled) staleChime(); }},30000);
document.getElementById('btn-confirm-stale').style.display='inline-block';
updateAudioUI();
}}
function stopStaleWarning(){{
if(staleTimer){{clearInterval(staleTimer);staleTimer=null;}}
}}
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;
document.getElementById('btn-confirm-stale').style.display='none';
updateAudioUI();
}}
}}
}}
// ── Combined UI update ────────────────────────────────────────────────────
function updateAudioUI(){{
const ledSys=document.getElementById('led-system'),
ledWatch=document.getElementById('led-watch'),
ledConn=document.getElementById('led-conn'),
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';
txt.textContent='Audio disabled \u2014 enable to receive warnings';
}}else if(warningActive && staleActive){{
ledWatch.className='led led-warning';
txt.textContent='Measurement stopped & live feed lost \u2014 confirm each';
}}else if(warningActive){{
ledWatch.className='led led-warning';
txt.textContent='Measurement stopped \u2014 confirm to silence';
}}else if(staleActive){{
ledWatch.className='led';
txt.textContent='Live feed lost \u2014 confirm to silence';
}}else if(audioArmed && scanRunning){{
ledWatch.className='led led-armed';
txt.textContent='Armed \u2014 will warn when measurement stops';
}}else{{
ledWatch.className='led';
txt.textContent='Enabled \u2014 waiting for measurement to start';
}}
}}
// ── Measurement status handler ────────────────────────────────────────────
function handleAudioForStatus(status, prevStatus){{
if(!audioEnabled) return;
const isScanning=(status==='scanning');
const wasScanning=(prevStatus==='scanning');
// Auto-arm when scan starts
if(isScanning && !audioArmed){{
audioArmed=true;
if(warningActive){{ stopWarning(); warningActive=false; document.getElementById('btn-confirm').style.display='none'; }}
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();
}}
}}
function stopWarning(){{ if(warningTimer){{clearInterval(warningTimer);warningTimer=null;}} }}
// ── Status rendering ──────────────────────────────────────────────────────
const LABELS={{scanning:'SCANNING',running:'RUNNING',idle_short:'IDLE',idle_long:'IDLE \u2014 CHECK',error:'STOPPED',unknown:'UNKNOWN'}};
const LABELS={{scanning:'SCANNING',running:'RUNNING',idle:'IDLE',error:'STOPPED',unknown:'UNKNOWN'}};
const DETAILS={{
scanning: d=>'Tomo scan in progress &middot; projection '+(d.progress.projection||0)+' of '+(d.progress.total_projections||0)+' &middot; '+(d.progress.tomo_type||''),
running: d=>'Queue active &middot; outside tomo heartbeat window',
idle_short:d=>'Idle for <strong>'+d.idle_for_human+'</strong>',
idle_long: d=>'Idle for <strong>'+d.idle_for_human+'</strong> &mdash; no tomo scan running',
idle: d=>'Idle for <strong>'+d.idle_for_human+'</strong>',
error: d=>'Queue stopped unexpectedly &middot; idle for <strong>'+(d.idle_for_human||'?')+'</strong>',
unknown: d=>'Status unknown &middot; waiting for first data&hellip;',
unknown: d=>'Waiting for first data\u2026',
}};
function setRing(id,circ,pct){{document.getElementById(id).style.strokeDashoffset=circ*(1-Math.min(Math.max(pct,0),1));}}
@@ -1184,14 +1398,6 @@ function renderInstrument(setup){{
function render(d){{
const s=d.experiment_status||'unknown',p=d.progress||{{}};
if(lastWarnStatus!==null&&WARN_STATUSES.has(lastWarnStatus)&&!WARN_STATUSES.has(s)){{
// Status recovered from warning — re-arm for next idle episode
warningConfirmed=false;
const dot=document.getElementById('audio-dot');
dot.classList.remove('confirmed');
if(audioEnabled) dot.classList.add('active');
}}
lastWarnStatus=s;
document.body.className=s;
document.getElementById('status-pill').textContent=LABELS[s]||s.toUpperCase();
document.getElementById('status-detail').innerHTML=(DETAILS[s]||(()=>s))(d);
@@ -1217,11 +1423,17 @@ function render(d){{
renderInstrument(d.setup);
document.getElementById('last-update').textContent='updated '+new Date(d.generated_at).toLocaleTimeString();
const ageS=(Date.now()/1000)-d.generated_at_epoch;
document.getElementById('outdated-banner').classList.toggle('visible',ageS>STALE_S);
const isStale=ageS>STALE_S;
document.getElementById('outdated-banner').classList.toggle('visible',isStale);
handleStale(isStale);
document.getElementById('footer-gen').textContent='generator: '+((d.generator||{{}}).owner_id||'-');
document.getElementById('footer-queue').textContent='queue active: '+(d.queue_has_active_scan||false);
document.getElementById('footer-hb').textContent='tomo_heartbeat: '+(p.tomo_heartbeat_age_s!=null?p.tomo_heartbeat_age_s+'s ago':'none');
WARN_STATUSES.has(s)?startWarning():(stopWarning(),document.getElementById('btn-confirm').style.display='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);
}}
async function poll(){{
@@ -1233,6 +1445,7 @@ async function poll(){{
console.warn('Fetch failed:',e);
document.getElementById('last-update').textContent='fetch failed - retrying...';
document.getElementById('outdated-banner').classList.add('visible');
handleStale(true);
}}
}}