atlus/backend/routers/plugins/asi_bridge.py
roberts 342dc0f0cf Fix robustness issues across backend and frontend
- Add shutil.which guard to _run() in settings, asi_bridge routers
- Catch RuntimeError on WebSocket disconnect in services, asi_bridge
- Make file listing resilient to individual entry errors
- Fix keyboard double-fire on touch devices (touchstart + click)
- Update install.sh with correct Gitea repo URL
- Add six to requirements.txt (python-pam dependency)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 17:41:43 -05:00

204 lines
6.3 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]:
import shutil
if not shutil.which(cmd[0]):
return 1, "", f"{cmd[0]}: command not found"
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, RuntimeError):
pass
except Exception:
log.exception("ASI Bridge WS error")