""" REST API for S-57 ENC chart management. """ import asyncio import io import logging import shutil import tempfile from pathlib import Path import httpx from fastapi import APIRouter, HTTPException, UploadFile, File from fastapi.responses import JSONResponse from services.chart_manager import ( install_from_zip, install_from_enc, list_cells, delete_cell, get_all_features, get_all_depths, get_all_land, get_all_hazards, get_all_zones, CHARTS_DIR, set_meta, get_region, ) router = APIRouter(prefix="/charts", tags=["charts"]) log = logging.getLogger(__name__) # Florida/Miami NOAA ENC cells — name → description NOAA_FLORIDA_CELLS = { "US5FL17M": "Miami Harbor", "US5FL16M": "Biscayne Bay", "US4FL15M": "Florida Keys — East", "US4FL16M": "Florida Keys — West", "US5FL18M": "Port Everglades (Fort Lauderdale)", "US5FL19M": "Lake Worth / Palm Beach", "US4FL14M": "Florida Bay", "US5FL13M": "Card Sound / Barnes Sound", "US4FL13M": "Straits of Florida", "US5FL12M": "Hawk Channel East", "US3FL04M": "Florida Straits Overview", } NOAA_BASE = "https://charts.noaa.gov/ENCs/{cell}.zip" # Catálogo local NOAA: cell_id → (west, south, east, north, scale, description) # Fuente: NOAA Office of Coast Survey ENC catalog NOAA_CATALOG: list[dict] = [ # ── FLORIDA / MIAMI ─────────────────────────────────────────────────────── {"id":"US5FL17M","name":"Miami Harbor", "scale":10000, "w":-80.20,"s":25.73,"e":-80.10,"n":25.82}, {"id":"US5FL16M","name":"Biscayne Bay", "scale":40000, "w":-80.32,"s":25.42,"e":-80.08,"n":25.78}, {"id":"US5FL18M","name":"Port Everglades", "scale":10000, "w":-80.15,"s":26.05,"e":-80.06,"n":26.13}, {"id":"US5FL19M","name":"Lake Worth — Palm Beach", "scale":10000, "w":-80.10,"s":26.67,"e":-80.01,"n":26.76}, {"id":"US4FL15M","name":"Florida Keys — East", "scale":80000, "w":-80.60,"s":24.40,"e":-79.80,"n":25.50}, {"id":"US4FL16M","name":"Florida Keys — West", "scale":80000, "w":-82.00,"s":24.40,"e":-80.60,"n":25.20}, {"id":"US4FL14M","name":"Florida Bay", "scale":80000, "w":-81.50,"s":24.80,"e":-80.30,"n":25.70}, {"id":"US5FL13M","name":"Card Sound / Barnes Sound", "scale":20000, "w":-80.50,"s":25.10,"e":-80.25,"n":25.45}, {"id":"US4FL13M","name":"Straits of Florida", "scale":150000, "w":-81.00,"s":24.00,"e":-79.50,"n":26.00}, {"id":"US3FL04M","name":"Florida Straits Overview", "scale":600000, "w":-85.00,"s":22.00,"e":-78.00,"n":28.00}, {"id":"US5FL20M","name":"Miami Beach / Government Cut", "scale":5000, "w":-80.14,"s":25.75,"e":-80.10,"n":25.78}, {"id":"US5FL21M","name":"Fort Lauderdale — New River", "scale":10000, "w":-80.20,"s":26.07,"e":-80.09,"n":26.15}, {"id":"US5FL14M","name":"Key West Harbor", "scale":15000, "w":-81.86,"s":24.52,"e":-81.72,"n":24.60}, {"id":"US5FL15M","name":"Key West Approaches", "scale":40000, "w":-82.20,"s":24.30,"e":-81.50,"n":24.80}, {"id":"US4FL17M","name":"Tampa Bay", "scale":80000, "w":-82.80,"s":27.40,"e":-82.20,"n":28.10}, {"id":"US5FL01M","name":"Tampa Bay — Port Tampa", "scale":15000, "w":-82.60,"s":27.83,"e":-82.40,"n":27.95}, {"id":"US5FL04M","name":"Jacksonville", "scale":15000, "w":-81.70,"s":30.27,"e":-81.55,"n":30.42}, # ── NEW YORK / NEW JERSEY ──────────────────────────────────────────────── {"id":"US5NY01M","name":"New York Harbor", "scale":10000, "w":-74.15,"s":40.57,"e":-73.95,"n":40.72}, {"id":"US5NY02M","name":"Upper New York Bay", "scale":10000, "w":-74.10,"s":40.63,"e":-73.95,"n":40.75}, {"id":"US5NY13M","name":"New York — Lower Bay", "scale":20000, "w":-74.20,"s":40.48,"e":-73.90,"n":40.68}, {"id":"US4NY10M","name":"New York Approaches", "scale":80000, "w":-74.50,"s":40.20,"e":-73.50,"n":41.00}, {"id":"US5NY05M","name":"East River", "scale":10000, "w":-74.02,"s":40.65,"e":-73.90,"n":40.80}, {"id":"US5NY06M","name":"Hudson River — New York", "scale":10000, "w":-74.05,"s":40.68,"e":-73.95,"n":40.85}, {"id":"US5NJ01M","name":"Port Newark / Elizabeth", "scale":10000, "w":-74.18,"s":40.62,"e":-74.08,"n":40.72}, {"id":"US5NJ02M","name":"Newark Bay", "scale":10000, "w":-74.18,"s":40.64,"e":-74.05,"n":40.73}, {"id":"US3NY01M","name":"New York — Long Island Sound Overview","scale":400000,"w":-74.50,"s":40.00,"e":-71.50,"n":41.50}, # ── CHESAPEAKE / MID-ATLANTIC ──────────────────────────────────────────── {"id":"US5MD01M","name":"Baltimore Harbor", "scale":10000, "w":-76.65,"s":39.20,"e":-76.50,"n":39.32}, {"id":"US4MD01M","name":"Chesapeake Bay — Northern", "scale":80000, "w":-76.70,"s":38.80,"e":-75.80,"n":39.70}, {"id":"US4VA01M","name":"Chesapeake Bay — Southern", "scale":80000, "w":-76.70,"s":36.80,"e":-75.80,"n":38.00}, {"id":"US5VA01M","name":"Norfolk — Hampton Roads", "scale":15000, "w":-76.40,"s":36.82,"e":-76.20,"n":37.00}, {"id":"US5PA01M","name":"Philadelphia", "scale":10000, "w":-75.20,"s":39.88,"e":-75.06,"n":39.98}, # ── NEW ENGLAND ────────────────────────────────────────────────────────── {"id":"US5MA01M","name":"Boston Harbor", "scale":10000, "w":-71.10,"s":42.28,"e":-70.95,"n":42.40}, {"id":"US4MA01M","name":"Cape Cod — Approaches", "scale":80000, "w":-71.20,"s":41.40,"e":-69.80,"n":42.50}, {"id":"US5RI01M","name":"Providence — Narragansett Bay","scale":15000, "w":-71.48,"s":41.60,"e":-71.35,"n":41.85}, # ── GULF COAST ─────────────────────────────────────────────────────────── {"id":"US5LA01M","name":"New Orleans — Lower Mississippi","scale":10000,"w":-89.85,"s":29.85,"e":-89.70,"n":30.05}, {"id":"US5TX01M","name":"Houston Ship Channel", "scale":10000, "w":-95.10,"s":29.70,"e":-94.90,"n":29.85}, {"id":"US5TX02M","name":"Galveston Bay", "scale":10000, "w":-94.95,"s":29.25,"e":-94.65,"n":29.60}, {"id":"US4TX01M","name":"Texas — Gulf Coast", "scale":80000, "w":-97.50,"s":25.50,"e":-93.50,"n":30.00}, {"id":"US5MS01M","name":"Pascagoula", "scale":10000, "w":-88.65,"s":30.28,"e":-88.48,"n":30.38}, {"id":"US5AL01M","name":"Mobile Bay", "scale":20000, "w":-88.15,"s":30.50,"e":-87.90,"n":30.75}, # ── WEST COAST ─────────────────────────────────────────────────────────── {"id":"US5CA01M","name":"Los Angeles — Long Beach", "scale":15000, "w":-118.30,"s":33.70,"e":-118.10,"n":33.82}, {"id":"US5CA02M","name":"San Diego Bay", "scale":10000, "w":-117.22,"s":32.60,"e":-117.10,"n":32.75}, {"id":"US5CA03M","name":"San Francisco Bay", "scale":20000, "w":-122.55,"s":37.60,"e":-122.15,"n":37.90}, {"id":"US5CA04M","name":"San Francisco — Oakland", "scale":10000, "w":-122.42,"s":37.75,"e":-122.22,"n":37.86}, {"id":"US5OR01M","name":"Columbia River — Astoria", "scale":15000, "w":-124.00,"s":46.10,"e":-123.75,"n":46.30}, {"id":"US5WA01M","name":"Seattle — Elliott Bay", "scale":10000, "w":-122.45,"s":47.55,"e":-122.30,"n":47.66}, {"id":"US5WA02M","name":"Puget Sound — Tacoma", "scale":15000, "w":-122.55,"s":47.15,"e":-122.35,"n":47.35}, {"id":"US4WA01M","name":"Puget Sound Overview", "scale":80000, "w":-123.30,"s":46.80,"e":-122.00,"n":48.60}, # ── ALASKA ─────────────────────────────────────────────────────────────── {"id":"US5AK01M","name":"Anchorage", "scale":15000, "w":-150.10,"s":61.15,"e":-149.75,"n":61.32}, {"id":"US4AK01M","name":"Cook Inlet — Overview", "scale":80000, "w":-153.00,"s":59.00,"e":-148.00,"n":63.00}, # ── GREAT LAKES ────────────────────────────────────────────────────────── {"id":"US5MI01M","name":"Detroit River", "scale":10000, "w":-83.15,"s":42.22,"e":-82.95,"n":42.40}, {"id":"US5IL01M","name":"Chicago Harbor", "scale":10000, "w":-87.68,"s":41.82,"e":-87.55,"n":41.92}, {"id":"US5OH01M","name":"Cleveland Harbor", "scale":10000, "w":-81.75,"s":41.48,"e":-81.60,"n":41.56}, # ── CARIBBEAN / PUERTO RICO ────────────────────────────────────────────── {"id":"US5PR01M","name":"San Juan Harbor", "scale":10000, "w":-66.15,"s":18.43,"e":-66.05,"n":18.50}, {"id":"US4PR01M","name":"Puerto Rico — North Coast", "scale":80000, "w":-67.50,"s":17.80,"e":-65.00,"n":18.80}, {"id":"US5VI01M","name":"Charlotte Amalie — St. Thomas","scale":10000, "w":-64.97,"s":18.30,"e":-64.88,"n":18.37}, # ── HAWAII ─────────────────────────────────────────────────────────────── {"id":"US5HI01M","name":"Honolulu Harbor", "scale":10000, "w":-157.90,"s":21.27,"e":-157.83,"n":21.34}, {"id":"US4HI01M","name":"Oahu — Overview", "scale":80000, "w":-158.50,"s":21.00,"e":-157.50,"n":21.80}, ] @router.get("/cells") def get_cells(): return list_cells() @router.get("/florida-catalog") def florida_catalog(): installed = {c["id"] for c in list_cells()} return [ {"id": k, "description": v, "installed": k in installed} for k, v in NOAA_FLORIDA_CELLS.items() ] @router.get("/noaa-catalog") def noaa_catalog(): installed = {c["id"] for c in list_cells()} return [ {"id": c["id"], "description": c["name"], "scale": c["scale"], "installed": c["id"] in installed} for c in NOAA_CATALOG ] @router.get("/noaa-nearby") def noaa_nearby(west: float, south: float, east: float, north: float): """ Return NOAA ENC cells that intersect the given bbox. Checks both the hardcoded catalog AND installed cells' actual bounding boxes. """ cells_info = list_cells() installed = {c["id"] for c in cells_info} # Build actual-bbox map for installed cells actual_bbox = {c["id"]: c["bbox"] for c in cells_info if c["bbox"]} seen = set() matches = [] # 1. Installed cells with real bounding boxes for cell_id, bbox in actual_bbox.items(): w, s, e, n = bbox if e < west or w > east or n < south or s > north: continue # Look up catalog name if available name = next((c["name"] for c in NOAA_CATALOG if c["id"] == cell_id), cell_id) scale = next((c["scale"] for c in NOAA_CATALOG if c["id"] == cell_id), 0) matches.append({"id": cell_id, "name": name, "scale": scale, "installed": True}) seen.add(cell_id) # 2. Catalog cells not yet installed for cell in NOAA_CATALOG: if cell["id"] in seen: continue if cell["e"] < west or cell["w"] > east: continue if cell["n"] < south or cell["s"] > north: continue matches.append({ "id": cell["id"], "name": cell["name"], "scale": cell["scale"], "installed": cell["id"] in installed, }) matches.sort(key=lambda c: c["scale"]) return matches @router.post("/download-noaa/{cell_id}") async def download_noaa(cell_id: str): cell_id = cell_id.upper() url = NOAA_BASE.format(cell=cell_id) log.info("Downloading NOAA ENC %s from %s", cell_id, url) try: async with httpx.AsyncClient(timeout=120) as client: r = await client.get(url, follow_redirects=True) if r.status_code == 404: raise HTTPException(404, f"Cell {cell_id} not found on NOAA server") if r.status_code != 200: raise HTTPException(502, f"NOAA returned {r.status_code}") except httpx.TimeoutException: raise HTTPException(504, "Timeout downloading from NOAA") with tempfile.NamedTemporaryFile(suffix=".zip", delete=False) as tmp: tmp.write(r.content) tmp_path = Path(tmp.name) try: installed = await asyncio.get_event_loop().run_in_executor( None, install_from_zip, tmp_path) except Exception as e: raise HTTPException(500, str(e)) finally: tmp_path.unlink(missing_ok=True) return {"installed": installed} @router.post("/upload") async def upload_chart(file: UploadFile = File(...)): suffix = Path(file.filename).suffix.lower() if suffix not in (".zip", ".000"): raise HTTPException(400, "Only .zip or .000 files accepted") data = await file.read() with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as tmp: tmp.write(data) tmp_path = Path(tmp.name) try: if suffix == ".zip": installed = await asyncio.get_event_loop().run_in_executor( None, install_from_zip, tmp_path) else: orig_name = Path(file.filename).stem.upper() if file.filename else None cell_id = await asyncio.get_event_loop().run_in_executor( None, install_from_enc, tmp_path, orig_name) installed = [cell_id] except Exception as e: raise HTTPException(500, str(e)) finally: tmp_path.unlink(missing_ok=True) return {"installed": installed} @router.delete("/cells/{cell_id}") def remove_cell(cell_id: str): delete_cell(cell_id) return {"deleted": cell_id.upper()} @router.patch("/cells/{cell_id}/region") def set_cell_region(cell_id: str, region: str): region = (region or "").upper().strip() if region not in ("A", "B"): raise HTTPException(400, "region must be 'A' or 'B'") try: set_meta(cell_id, region=region) except FileNotFoundError: raise HTTPException(404, f"Cell {cell_id} not installed") return {"id": cell_id.upper(), "region": region} @router.get("/features") def chart_features(): return JSONResponse(get_all_features()) @router.get("/depths") def chart_depths(w: float | None = None, s: float | None = None, e: float | None = None, n: float | None = None): bbox = (w, s, e, n) if None not in (w, s, e, n) else None return JSONResponse(get_all_depths(bbox)) @router.get("/land") def chart_land(w: float | None = None, s: float | None = None, e: float | None = None, n: float | None = None): bbox = (w, s, e, n) if None not in (w, s, e, n) else None return JSONResponse(get_all_land(bbox)) @router.get("/hazards") def chart_hazards(): return JSONResponse(get_all_hazards()) @router.get("/zones") def chart_zones(w: float | None = None, s: float | None = None, e: float | None = None, n: float | None = None): bbox = (w, s, e, n) if None not in (w, s, e, n) else None return JSONResponse(get_all_zones(bbox)) @router.post("/rebuild-cache") async def rebuild_cache(): """Re-parse ALL installed ENC files and regenerate feature cache.""" from services.chart_manager import CHARTS_DIR, _build_cache import asyncio rebuilt = [] for cell_dir in CHARTS_DIR.iterdir(): if not cell_dir.is_dir(): continue enc_files = list(cell_dir.glob("*.000")) if not enc_files: continue await asyncio.get_event_loop().run_in_executor( None, _build_cache, cell_dir.name, enc_files[0]) rebuilt.append(cell_dir.name) return {"rebuilt": rebuilt} @router.post("/cells/{cell_id}/rebuild") async def rebuild_cell(cell_id: str): """Re-parse a single ENC cell and regenerate its feature cache.""" from services.chart_manager import CHARTS_DIR, _build_cache import asyncio cell_dir = CHARTS_DIR / cell_id if not cell_dir.is_dir(): from fastapi import HTTPException raise HTTPException(status_code=404, detail=f"Cell '{cell_id}' not found") enc_files = list(cell_dir.glob("*.000")) if not enc_files: from fastapi import HTTPException raise HTTPException(status_code=404, detail=f"No .000 file in cell '{cell_id}'") await asyncio.get_event_loop().run_in_executor( None, _build_cache, cell_id, enc_files[0]) return {"rebuilt": cell_id}