Commit 65702067 authored by vertighel's avatar vertighel
Browse files

loop: il toggle manda anche il binning; stop_looping() attende l'idle reale



1) Loop toggle → anche binning, non solo exptime:
   - loop-toggle.js: wireLoopToggle() accetta un getBinning opzionale,
     incluso nel body POST solo se valorizzato.
   - api/camera.py Loop.post(): legge "binning" dal payload e lo applica
     al device prima di avviare il loop.
   - Cablato sia nel pannello Guider (guider-binning-sel) sia nel widget
     Expose (binning-sel-imaging/spectro) — prima il binning del Guider
     veniva letto solo da Guider.start() (pulsante Guide), mai dal Loop.

2) "camera occupata" su Expose/Stop e Guide/Stop dopo un loop:
   looping=False è sempre stato un segnale "smetti di ritriggerare", mai
   un abort del frame in corso — chi lo usava (Guider.start(), start() su
   stx/atik/mako) procedeva subito dopo senza aspettare, quindi _start()/
   il setter binning si rifiutavano con "camera not idle" se il loop era
   ancora a metà frame.

   Nuovo metodo stop_looping() su stx.py/atik.py/mako.py: ferma il loop
   e ATTENDE davvero l'idle (bloccante, quindi va chiamato da un thread
   worker, non dall'event loop):
   - stx.py: abort() + attesa (nessun bug noto sull'abort STX)
   - atik.py: solo attesa, NESSUN abort — bug noto (la camera non riprende
     ad acquisire dopo un abort), lasciato come punto aperto a sé stante
   - mako.py: solo attesa (nessun abort() disponibile per Mako)

   start() (le tre classi), Guider.start() (via run_in_executor) e
   Loop.post() (via run_blocking) ora chiamano stop_looping() invece del
   solo looping=False, prima di procedere.

Co-Authored-By: default avatarClaude Sonnet 5 <noreply@anthropic.com>
parent 1aa802a4
Loading
Loading
Loading
Loading
+13 −1
Original line number Diff line number Diff line
@@ -8,6 +8,7 @@
from .baseresource import BaseResource, expects
from noctua.config import constants
from noctua.api.sequencer_instance import seq
from noctua.utils.logger import log


class FrameBinning(BaseResource):
@@ -231,10 +232,21 @@ class Loop(BaseResource):
    async def post(self):
        """Start the acquisition loop.

        Body: {"exptime": <float>}
        Body: {"exptime": <float>, "binning": <int>}
        """
        body     = await self.get_payload()
        exposure = float(body.get('exptime', 1.0))
        binning  = body.get('binning')

        stop_looping = getattr(self.dev, "stop_looping", None)
        if stop_looping and getattr(self.dev, "looping", False):
            await self.run_blocking(stop_looping)

        if binning is not None:
            try:
                self.dev.binning = [int(binning), int(binning)]
            except Exception as e:
                log.warning(f"Loop: could not set binning: {e}")

        self.dev.loop_exposure = exposure
        self.dev.looping = True
+18 −1
Original line number Diff line number Diff line
@@ -161,6 +161,23 @@ class Camera(BaseDevice):
            self._loop_thread = threading.Thread(target=self._run_loop, daemon=True)
            self._loop_thread.start()

    def stop_looping(self):
        """Stop the acquisition loop and wait for the in-flight frame to finish.

        ``looping = False`` only tells the background thread not to
        retrigger — a frame already in flight keeps running. Deliberately
        does NOT call abort(): a known bug leaves the camera unable to
        acquire again afterwards (open issue). Waits instead, bounded, for
        the loop's current exposure to complete naturally. Blocking — call
        from a worker thread, not the event loop.
        """
        if not self._looping:
            return
        self.looping = False
        deadline = time.time() + self.loop_exposure + 10.0
        while self.state != 0 and time.time() < deadline:
            time.sleep(0.1)

    def _run_loop(self):
        while self._looping:
            self._start(self.loop_exposure, 1)
@@ -207,7 +224,7 @@ class Camera(BaseDevice):
            UTC ISO-8601 string stored for later FITS header use.
        """
        if self._looping:
            self.looping = False
            self.stop_looping()
        self._start(duration, imagetype, datetime)

        
+18 −1
Original line number Diff line number Diff line
@@ -223,7 +223,7 @@ class Guider(Mako):
            Exposure time in seconds. If None, the current camera setting is used.
        """
        if self._looping:
            self.looping = False
            self.stop_looping()
        self._start(exptime)

    def _start(self, exptime=None):
@@ -265,6 +265,23 @@ class Guider(Mako):
            self._loop_thread = threading.Thread(target=self._run_loop, daemon=True)
            self._loop_thread.start()

    def stop_looping(self):
        """Stop the acquisition loop and wait for the in-flight frame to finish.

        ``looping = False`` only tells the background thread not to
        retrigger — a frame already in flight keeps running. No abort()
        exists for Mako, so this just waits (bounded) for the current
        frame acquisition to complete naturally — _run_loop sets state
        back to 0 as soon as it does. Blocking — call from a worker
        thread, not the event loop.
        """
        if not self._looping:
            return
        self.looping = False
        deadline = time.time() + self.loop_exposure + 10.0
        while self.state != 0 and time.time() < deadline:
            time.sleep(0.1)

    def _run_loop(self):
        try:
            self._set_exposure(self.loop_exposure)
+20 −1
Original line number Diff line number Diff line
@@ -199,6 +199,25 @@ class Camera(STX):
            self._loop_thread = threading.Thread(target=self._run_loop, daemon=True)
            self._loop_thread.start()

    def stop_looping(self):
        """Stop the acquisition loop and wait until the camera is actually idle.

        ``looping = False`` only tells the background thread not to
        retrigger — a frame already in flight keeps running. Abort it and
        wait so callers that need the camera available right away
        (``start()``, a binning change, ...) don't immediately fail with
        "camera not idle". Blocking — call from a worker thread, not the
        event loop.
        """
        if not self._looping:
            return
        self.looping = False
        if self.state != 0:
            self.abort()
        deadline = time.time() + self.loop_exposure + 2.0
        while self.state != 0 and time.time() < deadline:
            time.sleep(0.1)

    def _run_loop(self):
        while self._looping:
            self._start(self.loop_exposure, 1)
@@ -261,7 +280,7 @@ class Camera(STX):
            Defaults to the current UTC time.
        """
        if self._looping:
            self.looping = False
            self.stop_looping()
        self._start(duration, imagetype, datetime)


+4 −2
Original line number Diff line number Diff line
@@ -106,8 +106,10 @@ class Guider:

        cam = getattr(devices, self.camera, None)
        if cam is not None:
            if getattr(cam, "looping", False):
                cam.looping = False
            stop_looping = getattr(cam, "stop_looping", None)
            if stop_looping and getattr(cam, "looping", False):
                # Blocking (up to a few seconds) — keep off the event loop.
                await asyncio.get_running_loop().run_in_executor(None, stop_looping)
            if self.camera not in PASSIVE_CAMERAS:
                try:
                    cam.binning = [self.binning, self.binning]
Loading