Commit 69f9bb4a authored by vertighel's avatar vertighel
Browse files

guider: fix ID Loop duplicato, switch Bootstrap 5, sync cross-widget



- Bug: guider_panel() ed expose_widget() generavano lo stesso id per il
  bottone Loop (entrambi "btn-{panel_id/camera_id}-loop-toggle" con lo
  stesso valore "imaging"/"spectro"), quindi getElementById prendeva
  sempre il primo (Expose) e il Loop del pannello Guider restava senza
  listener. Rinominato quello del Guider in "-guide-loop-toggle".
- Loop toggle: da <button> a switch Bootstrap 5 (form-check form-switch),
  semplificando loop-toggle.js (stato = el.checked, evento 'change'
  invece di click+classi manuali).
- loop-toggle.js: nuovo evento condiviso "noctua-loop-changed" così gli
  switch che osservano lo stesso cam_id restano sincronizzati tra loro
  (es. Guider panel su scicam1 ed Expose widget scicam1) e si
  aggiornano anche quando Expose/Guide fermano il loop indirettamente
  (priorità implementata nel commit precedente).
- stx.py: fix race in _run_loop — ready==1 + download() non garantivano
  che la camera avesse lasciato lo stato Exposing/Reading prima del
  prossimo _start(), causando "Camera not idle" durante un loop
  altrimenti regolare. Ora si attende state==0 dopo il download.

Co-Authored-By: default avatarClaude Sonnet 5 <noreply@anthropic.com>
parent ed9cc079
Loading
Loading
Loading
Loading
+6 −0
Original line number Diff line number Diff line
@@ -209,6 +209,12 @@ class Camera(STX):
                # loop that decides when to retrigger — otherwise the next
                # _start() can fire before the camera has left Reading state.
                self.download()
                # ready==1 + download() don't guarantee the camera has
                # actually left Exposing/Reading state yet (ready and state
                # are two independent HTTP polls) — wait for real idle
                # before retriggering, or _start() rejects the next frame.
                while self._looping and self.state != 0:
                    time.sleep(0.1)

    def _start(self, duration, imagetype, datetime=None):
        """Trigger an exposure unconditionally (no loop guard).
+19 −14
Original line number Diff line number Diff line
@@ -205,14 +205,16 @@
<!-- Start / Stop -->
<fieldset class="row mt-2">

  <div class="col-md-2">
  <button class="btn btn-sm btn-outline-secondary"           
          type="button"
          id="btn-{{ panel_id }}-loop-toggle"
  <div class="col-md-2 d-flex align-items-center">
    <div class="form-check form-switch mb-0">
      <input class="form-check-input" type="checkbox" role="switch"
             id="btn-{{ panel_id }}-guide-loop-toggle"
             {% if fixed %}
             data-fixed-camera="{{ fixed[0] }}"
             data-fixed-cam-id="{{ fixed[1] }}"
          {% endif %}>Loop</button>
             {% endif %}>
      <label class="form-check-label" for="btn-{{ panel_id }}-guide-loop-toggle">🗘</label>
    </div>
  </div>

  <div class="col-md">
@@ -416,10 +418,13 @@

<fieldset class="row mt-2">

  <div class="col-md-2">
    <button class="btn btn-sm btn-outline-secondary"           
            id="btn-{{ camera_id }}-loop-toggle" type="button" 
            data-fixed-cam-id="{{ camera_prefix }}">Loop</button>
  <div class="col-md-2 d-flex align-items-center">
    <div class="form-check form-switch mb-0">
      <input class="form-check-input" type="checkbox" role="switch"
             id="btn-{{ camera_id }}-loop-toggle"
             data-fixed-cam-id="{{ camera_prefix }}">
      <label class="form-check-label" for="btn-{{ camera_id }}-loop-toggle"></label>
    </div>
  </div>

  <div class="col-md">
+9 −5
Original line number Diff line number Diff line
// control.js — Control page: mode switching, stage movement, expose dispatch.

import { showToast, setInputState } from './ui.js';
import { wireLoopToggle } from './loop-toggle.js';
import { wireLoopToggle, notifyLoopMayHaveChanged } from './loop-toggle.js';

// Map station → { panel id suffix, FITS viewer combo, sequencer template, camera device, cam_id }
const MODES = {
@@ -137,13 +137,17 @@ document.addEventListener('DOMContentLoaded', () => {
        return params;
    }

    async function postExpose(template, camera, params) {
    async function postExpose(template, camera, camId, params) {
        try {
            await fetch('/api/sequencer/run', {
            const res = await fetch('/api/sequencer/run', {
                method:  'POST',
                headers: { 'Content-Type': 'application/json' },
                body:    JSON.stringify({ template, params: { camera, ...params } }),
            });
            // Expose takes priority over a running loop (see start() in
            // devices/{stx,atik,mako}.py) — resync every Loop switch
            // watching this camera, wherever it's shown.
            if (res.ok) notifyLoopMayHaveChanged(camId);
        } catch {
            showToast('Sequencer POST failed', 'danger');
        }
@@ -151,12 +155,12 @@ document.addEventListener('DOMContentLoaded', () => {

    document.getElementById('btn-imaging-expose')?.addEventListener('click', () => {
        const form = document.getElementById('form-imaging');
        if (form) postExpose(MODES.station1.template, MODES.station1.camera, readForm(form));
        if (form) postExpose(MODES.station1.template, MODES.station1.camera, MODES.station1.camId, readForm(form));
    });

    document.getElementById('btn-spectro-expose')?.addEventListener('click', () => {
        const form = document.getElementById('form-spectro');
        if (form) postExpose(MODES.station2.template, MODES.station2.camera, readForm(form));
        if (form) postExpose(MODES.station2.template, MODES.station2.camera, MODES.station2.camId, readForm(form));
    });

    // -----------------------------------------------------------------------
+10 −4
Original line number Diff line number Diff line
@@ -9,7 +9,7 @@ import { showToast } from './ui.js';
import { get as getViewer }    from './viewer/viewer-registry.js';
import { resolveValue }        from './ui-core.js';
import { computeTargetCoords } from './guider-target-coords.js';
import { wireLoopToggle }      from './loop-toggle.js';
import { wireLoopToggle, notifyLoopMayHaveChanged } from './loop-toggle.js';

// --- Constants ---

@@ -154,7 +154,7 @@ function collectParams(panelId) {
 * @param {HTMLButtonElement} btn
 * @param {object} params
 */
async function postGuider(btn, params) {
async function postGuider(btn, params, camId) {
    const orig    = btn.innerHTML;
    btn.disabled  = true;
    btn.innerHTML = '<span class="spinner-border spinner-border-sm" aria-hidden="true"></span>';
@@ -169,6 +169,10 @@ async function postGuider(btn, params) {
            const err = await res.json().catch(() => ({}));
            throw new Error(err.error || 'Server error');
        }
        // Guide takes priority over a running loop (see start() in
        // devices/{stx,atik,mako}.py) — resync every Loop switch
        // watching this camera, wherever it's shown.
        notifyLoopMayHaveChanged(camId);
    } catch (err) {
        showToast(`Guider: ${err.message}`, 'danger');
    } finally {
@@ -521,7 +525,7 @@ function initPanel(panelId) {
        targetY:          null,
    };

    const loopBtn  = document.getElementById(`btn-${panelId}-loop-toggle`);
    const loopBtn  = document.getElementById(`btn-${panelId}-guide-loop-toggle`);
    const loopCtrl = wireLoopToggle(
        loopBtn,
        () => getSelectedCamera(panelId).camId,
@@ -533,7 +537,9 @@ function initPanel(panelId) {
        loopCtrl?.refresh();
    });
    targetEl?.addEventListener('change', e => onTargetChange(panelId, e.target.value));
    guideBtn?.addEventListener('click',  () => postGuider(guideBtn, collectParams(panelId)));
    guideBtn?.addEventListener('click',  () => postGuider(
        guideBtn, collectParams(panelId), getSelectedCamera(panelId).camId,
    ));

    if (guideBtn) {
        document.addEventListener('noctua-telemetry', e => {
+51 −28
Original line number Diff line number Diff line
// loop-toggle.js
// Wires a button to toggle a camera's continuous acquisition loop
// (device.looping) via POST/DELETE /api/{cam_id}/loop. Shared by the
// guider panel and the expose widget, which both expose a Loop button
// next to their Start/Stop controls.
// Wires a Bootstrap switch (<input type="checkbox" role="switch">) to a
// camera's continuous acquisition loop (device.looping) via POST/DELETE
// /api/{cam_id}/loop. Shared by the guider panel and the expose widget,
// which both expose a Loop switch next to their Start/Stop controls.
//
// The same cam_id can be watched by more than one switch at once (e.g. the
// Guider panel's Camera selector currently pointing at scicam1, and the
// imaging Expose widget's own switch for scicam1). A DOM event keeps every
// switch watching a given cam_id in sync whenever any of them changes it —
// including indirectly, e.g. Expose/Guide forcing looping off server-side.

const LOOP_EVENT = 'noctua-loop-changed';

function broadcastLoopChange(camId) {
    if (!camId) return;
    document.dispatchEvent(new CustomEvent(LOOP_EVENT, { detail: { camId } }));
}

/**
 * @param {HTMLButtonElement} btn
 * Call after any action that may have changed a camera's loop state
 * server-side without going through a wireLoopToggle switch itself
 * (Expose start, Guide start both force looping off — see start()
 * priority in stx.py/atik.py/mako.py). Resyncs every switch watching
 * that cam_id without requiring a page reload.
 *
 * @param {string|null} camId
 */
export function notifyLoopMayHaveChanged(camId) {
    broadcastLoopChange(camId);
}

/**
 * @param {HTMLInputElement} el - checkbox input, its .checked IS the loop state.
 * @param {function(): (string|null)} getCamId - resolves the current cam_id
 *   (e.g. "teccam1"); may change over time (camera selector switched).
 * @param {function(): number} [getExptime] - exposure seconds to send on start.
 * @returns {{refresh: function(): Promise<void>}|null}
 */
export function wireLoopToggle(btn, getCamId, getExptime) {
    if (!btn) return null;

    let looping = false;

    const paint = () => {
        btn.classList.toggle('btn-warning',     looping);
        btn.classList.toggle('btn-outline-info', !looping);
    };
export function wireLoopToggle(el, getCamId, getExptime) {
    if (!el) return null;

    async function refresh() {
        const camId = getCamId();
        if (!camId) {
            looping = false;
            paint();
            el.checked = false;
            return;
        }
        try {
            const res  = await fetch(`/api/${camId}/loop`);
            const data = await res.json();
            looping = !!data.response;
            el.checked = !!data.response;
        } catch {
            looping = false;
            el.checked = false;
        }
        paint();
    }

    btn.addEventListener('click', async () => {
    el.addEventListener('change', async () => {
        const camId = getCamId();
        if (!camId) return;
        if (!camId) {
            el.checked = false;
            return;
        }

        const method = looping ? 'DELETE' : 'POST';
        const wantOn  = el.checked;
        const method  = wantOn ? 'POST' : 'DELETE';
        const options = { method };
        if (!looping) {
        if (wantOn) {
            options.headers = { 'Content-Type': 'application/json' };
            options.body    = JSON.stringify({ exptime: getExptime?.() ?? 1.0 });
        }
        try {
            const res = await fetch(`/api/${camId}/loop`, options);
            if (res.ok) {
                looping = !looping;
                paint();
            }
            if (!res.ok) el.checked = !wantOn;
            else broadcastLoopChange(camId);
        } catch {
            // leave state unchanged on network failure
            el.checked = !wantOn;
        }
    });

    document.addEventListener(LOOP_EVENT, e => {
        if (e.detail?.camId === getCamId()) refresh();
    });

    refresh();
    return { refresh };
}