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 b6f22af..cb95548 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 @@ -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: