"""ASI Bridge plugin — CIFS mount watcher + FITS file scanner.""" import asyncio import logging import os import time from pathlib import Path from typing import Optional from fastapi import APIRouter, Depends, HTTPException, WebSocket, WebSocketDisconnect from pydantic import BaseModel from backend.auth import get_current_user, ws_authenticate from backend.config import load_config, save_config router = APIRouter(prefix="/api/plugins/asi-bridge", tags=["asi-bridge"]) log = logging.getLogger("atlus.asi_bridge") class MountConfig(BaseModel): cifs_share: Optional[str] = None mount_point: Optional[str] = None cifs_user: Optional[str] = None cifs_pass: Optional[str] = None async def _run(cmd: list[str]) -> tuple[int, str, str]: proc = await asyncio.create_subprocess_exec( *cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, ) stdout, stderr = await proc.communicate() return proc.returncode, stdout.decode(), stderr.decode() def _is_mounted(mount_point: str) -> bool: """Check if a path is a mount point.""" try: return os.path.ismount(mount_point) except OSError: return False def _scan_fits(mount_point: str) -> list[dict]: """Scan for FITS files in the mount point.""" fits_files = [] mp = Path(mount_point) if not mp.is_dir(): return fits_files for root, dirs, files in os.walk(str(mp)): for fname in files: if fname.lower().endswith((".fit", ".fits", ".fts")): fp = Path(root) / fname try: st = fp.stat() fits_files.append({ "name": fname, "path": str(fp), "size": st.st_size, "modified": st.st_mtime, "relative": str(fp.relative_to(mp)), }) except OSError: continue fits_files.sort(key=lambda x: x["modified"], reverse=True) return fits_files @router.get("/status") async def bridge_status(_user: str = Depends(get_current_user)): """Get current ASI Bridge status.""" cfg = load_config().get("asi_bridge", {}) mount_point = cfg.get("mount_point", "/mnt/asiair") mounted = _is_mounted(mount_point) fits_count = 0 total_size = 0 latest_file = None if mounted: fits = _scan_fits(mount_point) fits_count = len(fits) total_size = sum(f["size"] for f in fits) if fits: latest_file = fits[0] return { "mounted": mounted, "mount_point": mount_point, "cifs_share": cfg.get("cifs_share", ""), "fits_count": fits_count, "total_size": total_size, "latest_file": latest_file, } @router.get("/files") async def list_fits(_user: str = Depends(get_current_user)): """List all FITS files on the mount.""" cfg = load_config().get("asi_bridge", {}) mount_point = cfg.get("mount_point", "/mnt/asiair") if not _is_mounted(mount_point): raise HTTPException(503, "ASI Air share not mounted") return _scan_fits(mount_point) @router.post("/mount") async def mount_share(_user: str = Depends(get_current_user)): """Mount the ASI Air CIFS share.""" cfg = load_config().get("asi_bridge", {}) mount_point = cfg.get("mount_point", "/mnt/asiair") share = cfg.get("cifs_share", "") user = cfg.get("cifs_user", "anonymous") password = cfg.get("cifs_pass", "") if not share: raise HTTPException(400, "CIFS share not configured") Path(mount_point).mkdir(parents=True, exist_ok=True) creds = f"username={user}" if password: creds += f",password={password}" rc, out, err = await _run([ "mount", "-t", "cifs", share, mount_point, "-o", f"{creds},iocharset=utf8,vers=3.0", ]) if rc != 0: raise HTTPException(500, f"Mount failed: {err}") return {"mounted": True, "mount_point": mount_point} @router.post("/unmount") async def unmount_share(_user: str = Depends(get_current_user)): cfg = load_config().get("asi_bridge", {}) mount_point = cfg.get("mount_point", "/mnt/asiair") rc, out, err = await _run(["umount", mount_point]) if rc != 0: raise HTTPException(500, f"Unmount failed: {err}") return {"mounted": False, "mount_point": mount_point} @router.get("/config") async def get_bridge_config(_user: str = Depends(get_current_user)): cfg = load_config() return cfg.get("asi_bridge", {}) @router.put("/config") async def update_bridge_config(update: MountConfig, _user: str = Depends(get_current_user)): cfg = load_config() bridge = cfg.get("asi_bridge", {}) for field, value in update.model_dump(exclude_none=True).items(): bridge[field] = value cfg["asi_bridge"] = bridge save_config(cfg) return bridge @router.websocket("/ws") async def bridge_ws(websocket: WebSocket): """Stream mount status and new FITS file events.""" username = await ws_authenticate(websocket) await websocket.accept() cfg = load_config().get("asi_bridge", {}) mount_point = cfg.get("mount_point", "/mnt/asiair") seen_files: set[str] = set() last_mounted = None try: while True: mounted = _is_mounted(mount_point) if mounted != last_mounted: await websocket.send_json({ "type": "mount_status", "mounted": mounted, }) last_mounted = mounted if mounted: fits = _scan_fits(mount_point) current_paths = {f["path"] for f in fits} new_files = current_paths - seen_files if new_files and seen_files: for f in fits: if f["path"] in new_files: await websocket.send_json({ "type": "new_file", "file": f, }) seen_files = current_paths await asyncio.sleep(5) except WebSocketDisconnect: pass except Exception: log.exception("ASI Bridge WS error")