next iteration, seems a first good and usable ver.
CI for csaxs_bec / test (push) Successful in 2m6s
CI for csaxs_bec / test (push) Successful in 2m6s
This commit is contained in:
@@ -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 · projection '+(d.progress.projection||0)+' of '+(d.progress.total_projections||0)+' · '+(d.progress.tomo_type||''),
|
||||
running: d=>'Queue active · 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> — no tomo scan running',
|
||||
idle: d=>'Idle for <strong>'+d.idle_for_human+'</strong>',
|
||||
error: d=>'Queue stopped unexpectedly · idle for <strong>'+(d.idle_for_human||'?')+'</strong>',
|
||||
unknown: d=>'Status unknown · waiting for first data…',
|
||||
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);
|
||||
}}
|
||||
}}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user