Commit 26bd8150 authored by vertighel's avatar vertighel
Browse files

Viewer: teccam loop + viewer.ini paths + download() defaults



- viewer.ini: consistent paths (fits/scicam.fits, fits/teccamN.fits);
  add device= key to teccam sections for loop device resolution
- devices.ini + devices/__init__.py: set _viewer_key on instances
  so download() defaults to the correct viewer.ini path
- constants.py: add viewer_fits_path() helper
- stx/atik/mako download(): default filepath via viewer_fits_path()
- stx Camera: add matrix property (in-memory FITS fetch, no disk write)
- fits_image.py: teccam acquisition loop endpoint (POST/GET /loop);
  _run_loop broadcasts frames via streamer._broadcast_preview() without
  touching the filesystem — STX triggers exposure + polls ready,
  Mako grabs current frame + sleeps
- viewer.html + web/__init__.py: loop controls (exp input, Start/Stop)
  shown for cameras with a device= entry in viewer.ini

Co-Authored-By: default avatarClaude Sonnet 4.6 <noreply@anthropic.com>
parent c7eb4d66
Loading
Loading
Loading
Loading
Loading
+85 −0
Original line number Diff line number Diff line
@@ -37,6 +37,7 @@ The key must match the URL segment ``<station>/<camera>``.
"""

# System modules
import asyncio
import configparser
from pathlib import Path

@@ -49,6 +50,7 @@ from quart import Blueprint, request, Response, jsonify, send_file
from noctua.config.constants import DATA_FOLDER
from noctua.utils.image import array_to_png
from noctua.utils.color_tables import viridis
from noctua.devices import stx as _stx

viewer_blueprint = Blueprint('viewer', __name__)

@@ -163,6 +165,50 @@ def _auto_range(data, lo=0.5, hi=99.5):
    return float(np.percentile(data, lo)), float(np.percentile(data, hi))


# ---------------------------------------------------------------------------
# Teccam acquisition loop
# ---------------------------------------------------------------------------

_loops: dict[str, asyncio.Task] = {}


def _get_device(station, camera):
    """Return the device instance for a station/camera pair, or None."""
    dev_name = _cfg.get(f'{station}/{camera}', 'device', fallback=None)
    if not dev_name:
        return None
    from noctua import devices as _devices
    return getattr(_devices, dev_name, None)


async def _run_loop(device, exposure: float, station: str, camera: str):
    """Continuous acquisition loop: captures frames and broadcasts PNG via WebSocket."""
    from noctua.web import streamer  # lazy — avoids circular import at module load
    ev = asyncio.get_running_loop()
    is_stx = isinstance(device, _stx.Camera)
    try:
        while True:
            if is_stx:
                # STX Guider: trigger exposure, poll ready, then grab matrix
                await ev.run_in_executor(None, device.start, exposure, 1)
                deadline = ev.time() + exposure + 30.0
                while ev.time() < deadline:
                    ready = await ev.run_in_executor(None, lambda: device.ready)
                    if ready == 1:
                        break
                    await asyncio.sleep(0.3)

            data = await ev.run_in_executor(None, lambda: device.matrix)
            if data is not None:
                await streamer._broadcast_preview(station, camera, data)

            if not is_stx:
                # Mako: broadcast first frame immediately, then wait before next
                await asyncio.sleep(exposure)
    except asyncio.CancelledError:
        pass


# ---------------------------------------------------------------------------
# Routes
# ---------------------------------------------------------------------------
@@ -371,3 +417,42 @@ async def download_fits(station, camera):
        as_attachment=True,
        attachment_filename=path.name,
    )


@viewer_blueprint.route('/viewer/<station>/<camera>/loop', methods=['GET'])
async def camera_loop_status(station, camera):
    """Return whether a continuous acquisition loop is running."""
    key = f'{station}/{camera}'
    task = _loops.get(key)
    return jsonify({'running': task is not None and not task.done()})


@viewer_blueprint.route('/viewer/<station>/<camera>/loop', methods=['POST'])
async def camera_loop(station, camera):
    """Start or stop a continuous acquisition loop for a teccam."""
    key = f'{station}/{camera}'
    body = await request.get_json(silent=True) or {}
    action = body.get('action')

    if action == 'start':
        device = _get_device(station, camera)
        if device is None:
            return jsonify({'error': 'no device configured'}), 404

        existing = _loops.get(key)
        if existing and not existing.done():
            existing.cancel()

        exposure = float(body.get('exposure', 1.0))
        _loops[key] = asyncio.get_running_loop().create_task(
            _run_loop(device, exposure, station, camera)
        )
        return jsonify({'status': 'started', 'exposure': exposure})

    if action == 'stop':
        task = _loops.pop(key, None)
        if task and not task.done():
            task.cancel()
        return jsonify({'status': 'stopped'})

    return jsonify({'error': 'unknown action'}), 400
+17 −0
Original line number Diff line number Diff line
@@ -5,9 +5,26 @@
Constants used in the whole project
'''

import configparser
from pathlib import Path

_DATA_DIR = Path(__file__).parents[2] / "data"
_VIEWER_INI = Path(__file__).parent / "viewer.ini"


def viewer_fits_path(viewer_key):
    """Return the absolute FITS path for a viewer key from viewer.ini.

    Falls back to temp_fits if the key is absent or not configured.
    """
    if not viewer_key:
        return temp_fits
    cfg = configparser.ConfigParser()
    cfg.read(_VIEWER_INI)
    raw = cfg.get(viewer_key, 'path', fallback=None)
    if raw is None:
        return temp_fits
    return str(_DATA_DIR / raw)

# Telescope

+5 −0
Original line number Diff line number Diff line
@@ -30,16 +30,19 @@ depends-on = cam
module = stx
class = Guider
node = STX
viewer-key = station1/teccam

[teccam2]
module = mako
class = Guider
node = MAKO111
viewer-key = station2/teccam

[teccam3]
module = mako
class = Guider
node = MAKO115
viewer-key = station3/teccam

######################################

@@ -49,12 +52,14 @@ class = Camera
node = STX
depends-on = pdu_cam
ping-using = all
viewer-key = station1/scicam

[cam2]
module = atik
class = Camera
node = DUMMY
depends-on = pdu_cam2
viewer-key = station2/scicam

######################################

+9 −7
Original line number Diff line number Diff line
@@ -2,23 +2,25 @@
#
# Map station/camera identifiers to FITS file paths.
# The key must match the URL pattern: /api/viewer/<station>/<camera>/...
#
# A typical observatory with three stations and two cameras each:
# Paths are relative to the project data/ directory.

[station1/scicam]
path = fits/temp.fits
path = fits/scicam.fits

[station1/teccam]
path = temp2.fits
path = fits/teccam1.fits
device = teccam

[station2/scicam]
path = temp2.fits
path = fits/scicam2.fits

[station2/teccam]
path = temp2.fits
path = fits/teccam2.fits
device = teccam2

[station3/scicam]
path = /data/station3/latest_sci.fits

[station3/teccam]
path = /data/station3/latest_tec.fits
path = fits/teccam3.fits
device = teccam3
+4 −0
Original line number Diff line number Diff line
@@ -63,6 +63,10 @@ def dynamic_import(this, dev):
    # Set the instance as an attribute of the current object
    setattr(this, instance_name, instance)

    viewer_key = devs.get(dev, 'viewer-key', fallback=None)
    if viewer_key:
        instance._viewer_key = viewer_key

# Adding a mock device
class MockDevice:
    def __init__(self):
Loading