option to upload to a php interface
All checks were successful
CI for csaxs_bec / test (push) Successful in 1m57s
All checks were successful
CI for csaxs_bec / test (push) Successful in 1m57s
This commit is contained in:
@@ -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()
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user