- 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>
204 lines
6.3 KiB
Python
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")
|