Commit d47c4c6d authored by vertighel's avatar vertighel
Browse files

Fase 1 devices: vero get/put per atik.py, decorator...


Fase 1 devices: vero get/put per atik.py, decorator ctypes_errors/vmbpy_errors, _RECOVERABLE collegato a noctua-app

- atik.py: get(method, *args)/put(method, *args) chiamano davvero la
  funzione Artemis nominata (getattr(self._lib, method)(h, *args))
  invece di fare un getattr/setattr generico su self. Tutti i punti che
  chiamavano self._lib.ArtemisXxx(...) direttamente ora passano da
  get/put; resta diretto solo il bootstrap in _check_connection()
  (stesso precedente di mako.py/stl.py). Il lock RLock che serializza
  le chiamate SDK (aggiunto su main durante la caccia al bug hardware,
  che questo branch non aveva ancora) ora vive dentro get()/put(),
  unico punto di accesso, invece di essere sparso per ogni property.

- check.py: due nuovi decorator, ctypes_errors (atik.py, stl.py) e
  vmbpy_errors (mako.py, sostituisce il try/except inline e copre
  anche get() che prima non aveva nessuna gestione errori). Nessuno
  dei due resetta self.error a inizio chiamata, a differenza di
  request_errors/telnet_errors: get/put qui sono la primitiva di
  basso livello chiamata molte volte per operazione (es. una volta per
  riga nel loop di readout di stl.py), e sia atik.py che stl.py che
  mako.py già gestiscono self.error da soli, azzerandolo solo dopo una
  riconnessione riuscita — un reset per-chiamata avrebbe cancellato in
  silenzio gli errori intermedi.

- app.py: noctua-app chiama check.set_recoverable(True) all'avvio,
  come deciso in precedenza — unico entry point che sopravvive a
  un'eccezione non gestita invece di uscire.

- python.md, PLAN.md: aggiornati per riflettere quanto implementato.

Co-Authored-By: default avatarClaude Sonnet 5 <noreply@anthropic.com>
parent 67170649
Loading
Loading
Loading
Loading
+28 −20
Original line number Diff line number Diff line
@@ -103,14 +103,18 @@ own `get()`/`put()`, the way `stx.py` (CGI HTTP), `mako.py` (VmbPy
feature), and `stl.py` (`SBIGUnivDrvCommand`) already do — not call the
underlying SDK/protocol directly from other methods.

**Known violation** (audit, 2026-07): `atik.py`'s `get(key)`/`put(key,
value)` (atik.py:129-135) is a generic Python `getattr`/`setattr`
proxy, not hardware I/O — every real SDK call in the file
(`_start`/`download`/`set_window`/binning/cooler/temperature, see
atik.py:204-215, 261-324, 417-480, 532-561) bypasses it and calls
`self._lib.Artemis...` directly. Fix during the devices phase: give
atik.py a real get/put wrapping the Artemis SDK, and route the
bypassing methods through it.
**Fixed** (devices phase): `atik.py`'s `get(key)`/`put(key, value)` used
to be a generic Python `getattr`/`setattr` proxy, not hardware I/O.
`get(method, *args)`/`put(method, *args)` now call the named Artemis SDK
function directly (`getattr(self._lib, method)(h, *args)`), and every
method in the file routes through them instead of calling
`self._lib.Artemis...` itself — the only exception is `_check_connection()`'s
own bootstrap (establishing the handle can't go through get/put, which
need a handle to already exist; same precedent as mako.py's/stl.py's own
connection setup). The lock serializing SDK access (added earlier while
chasing the atik.py hardware bugs on `main`, see PLAN.md) now lives
inside get()/put() themselves, the single choke point, instead of being
sprinkled across every property.

## Error handling — `utils/check.py` decorators

@@ -121,18 +125,22 @@ consistent: `stx.py`/`astelco.py`/`alpaca.py`/`netio.py`/`ipcam.py`/
`@check.telnet_errors` (`ascom_errors`/`meteo_errors`/`socket_errors`
are defined but currently unused anywhere).

**Known violations** (audit, 2026-07): `atik.py` (no error handling at
all around its bare `get`/`put` proxy calls — the SDK calls that
bypass it have no protection either), `mako.py` (`get()`,
mako.py:101-104, has *no* error handling; `put()`, mako.py:106-114,
catches generic `Exception` inline), `stl.py` (uses a fundamentally
different, non-exception paradigm — `get`/`put` return a `PAR_ERROR`
code, every call site checks it manually; this is a deliberate,
documented choice per the module docstring, not a bug — a decorator
here would need to match that shape, not force exceptions onto it).
Fix: design new decorators for the ctypes/SDK family (one for
Atik/STL-style ctypes calls, one for VmbPy) instead of reusing the
existing HTTP/telnet-specific ones, which don't fit.
**Fixed** (devices phase): two new decorators added — `check.ctypes_errors`
(`atik.py`, `stl.py`) and `check.vmbpy_errors` (`mako.py`), replacing
`mako.py put()`'s inline `try/except` and covering `atik.py`/`mako.py
get()`, which had no error handling at all before. Both catch
Python-level failures around the call (bad function/feature name,
connection lost, driver/OS failure) — neither interprets a SDK's own
numeric return/error code (still each call site's job: `stl.py`'s
`PAR_ERROR` meaning isn't uniform across every wrapped command, same for
Atik's return values, so a decorator can't generically judge
success/failure from them). Unlike the HTTP/telnet decorators, **neither
resets `this.error` at the start of the call** — get()/put() here are
the low-level primitive, called many times per higher-level operation
(e.g. once per row in `stl.py`'s readout loop), and both devices already
manage `self.error` themselves, clearing it only after a successful
reconnect. Resetting it per get()/put() call would wipe out an error
appended by an earlier call in the same operation.

**Decided**: several `check.py` fallbacks do `raise SystemExit(e)` on a
fully unhandled exception — this kills the entire `noctua-app` process
+10 −5
Original line number Diff line number Diff line
@@ -132,13 +132,18 @@ binned frames). None of the below are closed.

### 1. Devices

- [ ] Give `atik.py` a real `get`/`put` wrapping the Artemis SDK (see
- [x] Give `atik.py` a real `get`/`put` wrapping the Artemis SDK (see
      `dev/conventions/python.md`), route the bypassing methods
      through it.
- [ ] Design ctypes-family and VmbPy-family error decorators for
      through it. Also folds in the SDK-serializing lock (moved inside
      get()/put(), the single choke point) that was added separately
      while chasing the atik.py hardware bugs on `main` — this branch's
      atik.py had none of that yet, see the "Bugs found during hardware
      testing on main" section above.
- [x] Design ctypes-family and VmbPy-family error decorators for
      `utils/check.py`, apply to `atik.py`/`mako.py`/`stl.py`'s
      get/put. Decide the `SystemExit`-on-unhandled-error question
      first (see python.md).
      get/put. `SystemExit`-on-unhandled-error question decided and
      implemented (`check._RECOVERABLE`, only `noctua.app:run()` opts
      in) — see python.md.
- [ ] `netio.py`/`siemens.py`/`domotics.py` don't inherit
      `BaseDevice` unlike every other device — decide if that's
      intentional or should be fixed.
+7 −0
Original line number Diff line number Diff line
@@ -11,6 +11,7 @@ from noctua.api import api_blueprint
from noctua.api.baseresource import register_error_handlers
from noctua.web import web_blueprint
from noctua.api.fits_image import viewer_blueprint
from noctua.utils import check


app = Quart(__name__)
@@ -27,6 +28,12 @@ def run():
    Server run using uvicorn.
    """

    # Only the long-lived noctua-app process should survive an
    # unhandled device exception instead of exiting — see
    # dev/conventions/python.md. ipython, noctua-sequencer and
    # noctua-guider never call this, so they keep failing loudly.
    check.set_recoverable(True)

    try:
        uvicorn.run(app, host="0.0.0.0", port=5533)
    except KeyboardInterrupt as e:
+104 −76
Original line number Diff line number Diff line
@@ -29,6 +29,7 @@ from astropy.io import fits
# Custom modules
from .basedevice import BaseDevice
from ..config.constants import camera_frame
from ..utils import check
from ..utils.image import make_png
from ..utils.logger import log

@@ -74,6 +75,13 @@ class Camera(BaseDevice):
        self._loop_thread = None
        self.loop_exposure = 1.0

        # Serializes all Artemis SDK calls, made through get()/put()
        # below: the SDK isn't thread-safe for a single handle, and this
        # camera reads most properties live from it (no cached last-frame
        # like mako.py/stl.py), so the background loop thread and request
        # threads can otherwise call into it concurrently.
        self._lock = threading.RLock()

        try:
            self._lib = ctypes.CDLL("/usr/lib/libatikcameras.so")
            self._lib.ArtemisConnect.restype = ctypes.c_void_p
@@ -86,8 +94,15 @@ class Camera(BaseDevice):

            
    def _check_connection(self):
        """Internal method to manage the persistent camera handle."""
        """Internal method to manage the persistent camera handle.

        Bootstrap only — establishing the handle in the first place
        can't go through get()/put() below (they need a handle to
        already exist), so it stays a direct SDK call, same precedent as
        mako.py's/stl.py's own connection setup.
        """

        with self._lock:
            if self._handle is None and self._lib:
                count = self._lib.ArtemisDeviceCount()
                log.debug(f"SDK reported {count} devices.")
@@ -123,28 +138,41 @@ class Camera(BaseDevice):
            except:
                pass

    # --- Generic Get/Put for compatibility ---
    # --- Get/Put: the Artemis SDK's own hardware I/O entry point ---

    def get(self, key):
        """Generic getter wrapper."""
        return getattr(self, key)
    @check.ctypes_errors
    def get(self, method, *args):
        """Call a read-only Artemis SDK function by name (e.g.
        ``'ArtemisTemperatureSensorInfo'``), passing the connected
        handle as the first argument automatically."""
        h = self._check_connection()
        if not h:
            return None
        with self._lock:
            return getattr(self._lib, method)(h, *args)

    def put(self, key, value):
        """Generic setter wrapper."""
        setattr(self, key, value)
    @check.ctypes_errors
    def put(self, method, *args):
        """Call a state-changing Artemis SDK function by name, passing
        the connected handle as the first argument automatically."""
        h = self._check_connection()
        if not h:
            return None
        with self._lock:
            return getattr(self._lib, method)(h, *args)

    @property
    def connection(self):
        if not self._lib: return False
        h = self._check_connection()
        return bool(self._lib.ArtemisIsConnected(h)) if h else False
        return bool(self.get('ArtemisIsConnected')) if h else False

    # --- Exposure Methods ---

    def abort(self):
        """Abort the current exposure."""
        h = self._check_connection()
        if h: self._lib.ArtemisAbortExposure(h)
        if h: self.put('ArtemisAbortExposure')

    @property
    def looping(self):
@@ -210,8 +238,8 @@ class Camera(BaseDevice):
        self._last_imagetype = imagetype
        self._last_datetime = datetime
        is_dark = True if imagetype in [0, 2, "Dark", "Bias"] else False
        self._lib.ArtemisSetDarkMode(h, is_dark)
        self._lib.ArtemisStartExposure(h, ctypes.c_float(duration))
        self.put('ArtemisSetDarkMode', is_dark)
        self.put('ArtemisStartExposure', ctypes.c_float(duration))

    def start(self, duration, imagetype, datetime=None):
        """Start a single exposure.
@@ -257,16 +285,16 @@ class Camera(BaseDevice):
        if not h: return

        log.debug(f"Getting original data")
        while not self._lib.ArtemisImageReady(h):
        while not self.get('ArtemisImageReady'):
            time.sleep(0.1)

        x, y, w, h_img, bx, by = [ctypes.c_int() for _ in range(6)]
        self._lib.ArtemisGetImageData(h, ctypes.byref(x), ctypes.byref(y),
        self.get('ArtemisGetImageData', ctypes.byref(x), ctypes.byref(y),
                 ctypes.byref(w), ctypes.byref(h_img),
                 ctypes.byref(bx), ctypes.byref(by))
        log.debug(f"Got original data")

        buf_ptr = self._lib.ArtemisImageBuffer(h)
        buf_ptr = self.get('ArtemisImageBuffer')
        if not buf_ptr:
            return

@@ -284,13 +312,13 @@ class Camera(BaseDevice):

        hdr['INSTRUME'] = (self._props.Description.decode(), "Camera model")

        exptime = self._lib.ArtemisLastExposureDuration(h)
        exptime = self.get('ArtemisLastExposureDuration')
        if exptime == 0.0 and self._last_exptime is not None:
            exptime = self._last_exptime
        hdr['EXPTIME'] = (float(exptime), "[s] Exposure duration")

        start_time_bytes = self._lib.ArtemisLastStartTime(h)
        start_ms = self._lib.ArtemisLastStartTimeMilliseconds(h)
        start_time_bytes = self.get('ArtemisLastStartTime')
        start_ms = self.get('ArtemisLastStartTimeMilliseconds')
        if start_time_bytes:
            hdr['DATE-OBS'] = (f"{start_time_bytes.decode()}.{start_ms:03d}",
                               "(UTC) Date the exposure was started")
@@ -299,11 +327,11 @@ class Camera(BaseDevice):
                               "(UTC) Date the exposure was started")

        ccd_temp = ctypes.c_int()
        self._lib.ArtemisTemperatureSensorInfo(h, 1, ctypes.byref(ccd_temp))
        self.get('ArtemisTemperatureSensorInfo', 1, ctypes.byref(ccd_temp))
        hdr['CCD-TEMP'] = (ccd_temp.value / 100.0, "[C] CCD temperature")

        flags, level, minl, maxl, setp = [ctypes.c_int() for _ in range(5)]
        self._lib.ArtemisCoolingInfo(h, ctypes.byref(flags), ctypes.byref(level),
        self.get('ArtemisCoolingInfo', ctypes.byref(flags), ctypes.byref(level),
                 ctypes.byref(minl), ctypes.byref(maxl),
                 ctypes.byref(setp))
        hdr['SET-TEMP'] = (setp.value / 100.0, "[C] CCD setpoint temperature")
@@ -332,13 +360,13 @@ class Camera(BaseDevice):
        Returns None if no image is ready in the SDK buffer.
        """
        h = self._check_connection()
        if not h or not self._lib.ArtemisImageReady(h):
        if not h or not self.get('ArtemisImageReady'):
            return None
        x, y, w, h_img, bx, by = [ctypes.c_int() for _ in range(6)]
        self._lib.ArtemisGetImageData(h, ctypes.byref(x), ctypes.byref(y),
        self.get('ArtemisGetImageData', ctypes.byref(x), ctypes.byref(y),
                 ctypes.byref(w), ctypes.byref(h_img),
                 ctypes.byref(bx), ctypes.byref(by))
        buf_ptr = self._lib.ArtemisImageBuffer(h)
        buf_ptr = self.get('ArtemisImageBuffer')
        if not buf_ptr:
            return None
        size = w.value * h_img.value
@@ -415,7 +443,7 @@ class Camera(BaseDevice):
        """
        h = self._check_connection()
        if h:
            self._lib.ArtemisSubframe(h, start_x, start_y, width, height)
            self.put('ArtemisSubframe', start_x, start_y, width, height)
            self._subframe = [start_x, start_y, width, height]

    def full_frame(self):
@@ -429,7 +457,7 @@ class Camera(BaseDevice):
        h = self._check_connection()
        nx, ny = self._props.nPixelsX, self._props.nPixelsY
        if h:
            self._lib.ArtemisSubframe(h, 0, 0, nx, ny)
            self.put('ArtemisSubframe', 0, 0, nx, ny)
            self._subframe = [0, 0, nx, ny]
        return [nx, ny]

@@ -451,7 +479,7 @@ class Camera(BaseDevice):
            x0, y0, x1, y1 = coords
        w, hh = x1 - x0, y1 - y0
        if h:
            self._lib.ArtemisSubframe(h, x0, y0, w, hh)
            self.put('ArtemisSubframe', x0, y0, w, hh)
            self._subframe = [x0, y0, w, hh]
        return [w, hh]

@@ -474,7 +502,7 @@ class Camera(BaseDevice):
            x0, y0, x1, y1 = coords
        w, hh = x1 - x0, y1 - y0
        if h:
            self._lib.ArtemisSubframe(h, x0, y0, w, hh)
            self.put('ArtemisSubframe', x0, y0, w, hh)
            self._subframe = [x0, y0, w, hh]
        return [w, hh]

@@ -500,7 +528,7 @@ class Camera(BaseDevice):
        (0=Idle, 1=Waiting, 2=Exposing, 3=Readout, 4=Downloading, 5=Error,
        6=Flushing)."""
        h = self._check_connection()
        raw = self._lib.ArtemisCameraState(h) if h else -1
        raw = self.get('ArtemisCameraState') if h else -1
        return self._STATE_MAP.get(raw, 5)

    @property
@@ -509,7 +537,7 @@ class Camera(BaseDevice):
        h = self._check_connection()
        if not h: return None
        temp = ctypes.c_int()
        self._lib.ArtemisTemperatureSensorInfo(h, 1, ctypes.byref(temp))
        self.get('ArtemisTemperatureSensorInfo', 1, ctypes.byref(temp))
        return temp.value / 100.0

    @temperature.setter
@@ -522,7 +550,7 @@ class Camera(BaseDevice):
        h = self._check_connection()
        if not h: return False
        flags = ctypes.c_int()
        self._lib.ArtemisCoolingInfo(h, ctypes.byref(flags), ctypes.byref(ctypes.c_int()),
        self.get('ArtemisCoolingInfo', ctypes.byref(flags), ctypes.byref(ctypes.c_int()),
                 ctypes.byref(ctypes.c_int()), ctypes.byref(ctypes.c_int()),
                 ctypes.byref(ctypes.c_int()))
        return bool(flags.value & 64)
@@ -533,22 +561,22 @@ class Camera(BaseDevice):
        if not h: return
        if b:
            setp = self._setpoint if self._setpoint is not None else -10.0
            self._lib.ArtemisSetCooling(h, int(setp * 100))
            self.put('ArtemisSetCooling', int(setp * 100))
        else:
            self._lib.ArtemisCoolerWarmUp(h)
            self.put('ArtemisCoolerWarmUp')

    @property
    def binning(self):
        """list of int : Current [X, Y] binning factors."""
        h = self._check_connection()
        bx, by = ctypes.c_int(), ctypes.c_int()
        self._lib.ArtemisGetBin(h, ctypes.byref(bx), ctypes.byref(by))
        self.get('ArtemisGetBin', ctypes.byref(bx), ctypes.byref(by))
        return [bx.value, by.value]

    @binning.setter
    def binning(self, b):
        h = self._check_connection()
        if h: self._lib.ArtemisBin(h, b[0], b[1])
        if h: self.put('ArtemisBin', b[0], b[1])

    @property
    def filter(self):
@@ -582,7 +610,7 @@ class Camera(BaseDevice):
        """int : 1 if an image is ready in the SDK buffer, 0 otherwise."""
        h = self._check_connection()
        if not h: return 0
        return 1 if self._lib.ArtemisImageReady(h) else 0
        return 1 if self.get('ArtemisImageReady') else 0

    @property
    def setpoint(self):
@@ -595,7 +623,7 @@ class Camera(BaseDevice):
        h = self._check_connection()
        if not h: return None
        flags, level, minl, maxl, setp = [ctypes.c_int() for _ in range(5)]
        self._lib.ArtemisCoolingInfo(h, ctypes.byref(flags), ctypes.byref(level),
        self.get('ArtemisCoolingInfo', ctypes.byref(flags), ctypes.byref(level),
                 ctypes.byref(minl), ctypes.byref(maxl),
                 ctypes.byref(setp))
        return round((level.value / 255.0) * 100) if maxl.value > 0 else 0
@@ -649,13 +677,13 @@ class Camera(BaseDevice):
        # Get Cooling Information from SDK
        # ArtemisCoolingInfo returns: flags, level, minlvl, maxlvl, setpoint
        flags, level, minl, maxl, setp = [ctypes.c_int() for _ in range(5)]
        self._lib.ArtemisCoolingInfo(h, ctypes.byref(flags), ctypes.byref(level), 
        self.get('ArtemisCoolingInfo', ctypes.byref(flags), ctypes.byref(level),
                 ctypes.byref(minl), ctypes.byref(maxl),
                 ctypes.byref(setp))

        # Get Binning
        bx, by = ctypes.c_int(), ctypes.c_int()
        self._lib.ArtemisGetBin(h, ctypes.byref(bx), ctypes.byref(by))
        self.get('ArtemisGetBin', ctypes.byref(bx), ctypes.byref(by))

        # Calculate fan/cooler power percentage (level is usually 0-255)
        # We scale it to 0-100 to match Noctua standards.
+6 −7
Original line number Diff line number Diff line
@@ -19,6 +19,7 @@ from vmbpy import FrameStatus, VmbSystem

# Custom modules
from .basedevice import BaseDevice
from ..utils import check
from ..utils.image import make_png
from ..utils.logger import log

@@ -98,20 +99,18 @@ class Mako(BaseDevice):
                        raise ConnectionError(msg)
        return self._cam

    @check.vmbpy_errors
    def get(self, feature_name):
        """Read a VmbPy camera feature by name."""
        cam = self._check_connection()
        return cam.get_feature_by_name(feature_name).get()

    @check.vmbpy_errors
    def put(self, feature_name, value):
        """Write a VmbPy camera feature by name. Returns True on success."""
        try:
        cam = self._check_connection()
        cam.get_feature_by_name(feature_name).set(value)
        return True
        except Exception as e:
            log.warning(f"Mako {feature_name}: {e}")
            return False

    def __del__(self):
        if self._cam:
Loading