Commit 8255ad65 authored by vertighel's avatar vertighel
Browse files

Simplify Once (self.active=False, data-value payload); fix Mako connect lockup



Once now just flips self.active off in _loop() instead of the earlier
multi-attempt/return-value bookkeeping — no self.stop() call, since that
would await self._task from inside the task running it. The Once button
passes its payload fragment via data-value, like widget_standard's buttons,
instead of hardcoding it in the click handler.

mako.py: bound the GVSPAdjustPacketSize busy-wait with a timeout. Unbounded,
it could hang forever on a flaky link while holding the connect lock shared
by every Mako camera, locking out teccam2/teccam3 together (the "Mako
connection lockup" that used to need a process restart).

stream.py: offload FITS read + PNG/base64 encoding to a thread so a slow
preview frame can't stall the shared event loop (telemetry, other cameras'
broadcasts, websockets) while encoding.

Co-Authored-By: default avatarClaude Sonnet 5 <noreply@anthropic.com>
parent f094b786
Loading
Loading
Loading
Loading
Loading
+1 −0
Original line number Diff line number Diff line
@@ -26,6 +26,7 @@ class Guider(BaseResource):
        """Start the guider."""

        params = await self.get_payload()
        log.info(f"Guider: POST /guider/ params={params}")
        await self.dev.start(params or None)

        return self.make_response(self.dev.status)
+11 −3
Original line number Diff line number Diff line
@@ -87,10 +87,18 @@ class Mako(BaseDevice):
                        try:
                            stream = self._cam.get_streams()[0]
                            stream.GVSPAdjustPacketSize.run()
                            # Bounded: an unbounded busy-wait here has hung
                            # forever on a flaky link, holding _connect_lock
                            # and locking out every other Mako camera's
                            # first connection with it (the "Mako connection
                            # lockup" that used to need a process restart).
                            deadline = time.monotonic() + 3.0
                            while not stream.GVSPAdjustPacketSize.is_done():
                                pass
                        except Exception:
                            pass
                                if time.monotonic() > deadline:
                                    raise TimeoutError("GVSPAdjustPacketSize timed out")
                                time.sleep(0.01)
                        except Exception as e:
                            log.warning(f"Mako: packet size negotiation skipped: {e}")
                        self._set_throughput_limit(self._cam)
                    except Exception as e:
                        self._cam = None
+9 −39
Original line number Diff line number Diff line
@@ -31,8 +31,6 @@ class Guider:
    mirror with automatic offload to the telescope when the stroke limit is reached.
    """

    ONCE_MAX_ATTEMPTS = 3

    def __init__(self):

        self.active          = False
@@ -98,9 +96,8 @@ class Guider:

        if params:
            self.configure(params)
        # "once" is a start-time mode switch, not a persistent config field
        # (unlike configure()'s keys) — always resolved fresh so a prior
        # one-shot run can't silently leak into a later continuous Guide.
        # Resolved fresh on every start (not part of configure()'s persistent
        # keys) so a prior one-shot run can't leak into a later continuous Guide.
        self.once = bool(params.get("once", False)) if params else False
        if self._task and not self._task.done():
            return
@@ -137,48 +134,27 @@ class Guider:

    async def _loop(self):

        once_attempts = 0
        while self.active:
            try:
                corrected = await asyncio.get_running_loop().run_in_executor(None, self._step)
                await asyncio.get_running_loop().run_in_executor(None, self._step)
            except Exception as e:
                msg = f"step error: {e}"
                log.error(f"Guider: {msg}")
                self.error.append(msg)
                corrected = False

            if self.once:
                once_attempts += 1
                # A step can return False without a real failure (e.g. the
                # first "stick" step only locks the reference and applies
                # no correction yet) — keep trying for a bounded number of
                # attempts so "once" always means one *applied* correction,
                # not just one _step() call.
                if not corrected and once_attempts < self.ONCE_MAX_ATTEMPTS:
                    await asyncio.sleep(0)
                    continue
                if not corrected:
                    msg = f"once: no correction applied after {once_attempts} attempts"
                    log.warning(f"Guider: {msg}")
                    self.error.append(msg)
                self.active = False
                break
            # No fixed delay: _step()/_acquire() already block on the camera
            # producing a new frame, so the next correction starts as soon
            # as it is. The sleep(0) just yields control for cancellation.
            await asyncio.sleep(0)

    def _step(self):
        """Run one guiding cycle. Returns True iff a correction was applied."""

        cam = getattr(devices, self.camera, None)
        if cam is None:
            self.error.append(f"camera '{self.camera}' not found")
            return False
            return

        data, header = self._acquire(cam)
        if data is None:
            return False
            return
        ny, nx = data.shape

        cx = nx // 2 if self.box_center is None else int(self.box_center[0])
@@ -195,7 +171,7 @@ class Guider:
            msg = f"fit_star: {e}"
            log.error(f"Guider: {msg}")
            self.error.append(msg)
            return False
            return

        star_x = x0 + float(fitted.xc)
        star_y = y0 + float(fitted.yc)
@@ -208,7 +184,7 @@ class Guider:
            if self._stick_target is None:
                self._stick_target = [star_x, star_y]
                log.info(f"Guider: stick locked at ({star_x:.1f}, {star_y:.1f})")
                return False
                return
            tgt_x, tgt_y = self._stick_target
        else:
            tgt_x, tgt_y = float(self.target[0]), float(self.target[1])
@@ -231,24 +207,19 @@ class Guider:
                devices.tel.offset = updated
                self.last_offset   = updated
                log.info(f"Guider: New offset value: alt={updated[0]:.2f}\" az={updated[1]:.2f}\"")
                return True
            except Exception as e:
                msg = f"telescope offset failed: {e}"
                log.error(f"Guider: {msg}")
                self.error.append(msg)
                return False

        elif self.actuator == "ao_x":
            return self._step_ao_x(cam, header, nx, ny, dpx, dpy, tgt_x, tgt_y)

        return False
            self._step_ao_x(cam, header, nx, ny, dpx, dpy, tgt_x, tgt_y)

    def _step_ao_x(self, cam, header, nx, ny, dpx, dpy, tgt_x, tgt_y):
        """Apply one AO-X correction (with offload if needed). Returns True iff applied."""

        ao = getattr(devices, "ao", None)
        if ao is None or self._ao_calib_inv is None:
            return False
            return

        delta   = self._ao_calib_inv @ np.array([dpx, dpy])
        new_pos = [self.ao_pos[0] + delta[0], self.ao_pos[1] + delta[1]]
@@ -265,7 +236,6 @@ class Guider:
        self.ao_pos      = new_pos
        self.last_offset = new_pos
        log.info(f"Guider: AO-X → ({new_pos[0]:.1f}, {new_pos[1]:.1f})")
        return True

    def _offload_to_telescope(self, cam, header, nx, ny, tgt_x, tgt_y):

+1 −1
Original line number Diff line number Diff line
@@ -228,7 +228,7 @@
      <button class="btn btn-outline-secondary"
          id="btn-{{ panel_id }}-pick" type="button">Pick</button>
      <button class="btn btn-outline-primary" type="button"
          id="btn-{{ panel_id }}-once">Once</button>
          id="btn-{{ panel_id }}-once" data-value="true">Once</button>
      <button class="btn btn-primary flex-fill" type="button"
              id="btn-{{ panel_id }}-guide-start"
              {% if fixed %}
+7 −1
Original line number Diff line number Diff line
@@ -498,7 +498,13 @@ function initPanel(panelId) {
    cameraEl?.addEventListener('change', () => onTargetChange(panelId, targetEl?.value ?? 'center'));
    targetEl?.addEventListener('change', e => onTargetChange(panelId, e.target.value));
    guideBtn?.addEventListener('click',  () => postGuider(guideBtn, collectParams(panelId)));
    onceBtn?.addEventListener('click',   () => postGuider(onceBtn, { ...collectParams(panelId), once: true }));
    onceBtn?.addEventListener('click',   () => {
        // Mirrors widget_standard's data-value convention (see widgets.html):
        // the button declares its own payload fragment in the DOM instead of
        // the handler hardcoding it.
        const once = JSON.parse(onceBtn.dataset.value ?? 'true');
        postGuider(onceBtn, { ...collectParams(panelId), once });
    });

    if (guideBtn) {
        document.addEventListener('noctua-telemetry', e => {
Loading