atlus/backend/routers/plugins/asi_bridge.py
roberts f9743bb29a Initial commit — Atlus web desktop environment for SBCs
Full-stack implementation: FastAPI backend with PAM auth, WebSocket
stats/terminal, and vanilla JS frontend with tiling desktop shell.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 16:53:46 -05:00

201 lines
6.1 KiB
Python

"""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")