diff --git a/.claude/launch.json b/.claude/launch.json deleted file mode 100644 index d8ad69b..0000000 --- a/.claude/launch.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "version": "0.0.1", - "configurations": [ - { - "name": "lh-preview", - "runtimeExecutable": "python", - "runtimeArgs": ["-m", "http.server", "7722", "--directory", "C:\\Temp"], - "port": 7722 - } - ] -} diff --git a/.claude/settings.local.json b/.claude/settings.local.json deleted file mode 100644 index 8ee592a..0000000 --- a/.claude/settings.local.json +++ /dev/null @@ -1,282 +0,0 @@ -{ - "permissions": { - "allow": [ - "Bash(curl -s http://localhost:5503/)", - "Bash(C:/Python314/python.exe -m py_compile services/chart_manager.py)", - "Bash(C:/Python314/python.exe -c \"from services import chart_manager; print\\('OK'\\)\")", - "Bash(C:/Python314/python.exe -c \"from main import app; print\\('OK'\\)\")", - "Bash(py --version)", - "Bash(where python *)", - "Bash(\"C:\\\\Python313\\\\python.exe\" -c \"import sys; print\\(sys.version\\)\")", - "Bash(\"C:/Python313/python.exe\" -m venv venv)", - "Bash(\"D:/Proyectos Software/AR ECDIS/webecdis/venv/Scripts/python.exe\" -m pip install --upgrade pip)", - "Bash(\"D:/Proyectos Software/AR ECDIS/webecdis/venv/Scripts/python.exe\" -m pip install PyQt5 PyQtWebEngine pynmea2 pyserial numpy shapely)", - "Bash(mkdir -p \"/d/Proyectos Software/AR ECDIS/webecdis/ui/lib\")", - "Bash(curl -sSL -o \"/d/Proyectos Software/AR ECDIS/webecdis/ui/lib/maplibre-gl.js\" \"https://unpkg.com/maplibre-gl@4.7.1/dist/maplibre-gl.js\")", - "Bash(curl -sSL -o \"/d/Proyectos Software/AR ECDIS/webecdis/ui/lib/maplibre-gl.css\" \"https://unpkg.com/maplibre-gl@4.7.1/dist/maplibre-gl.css\")", - "Bash(./venv/Scripts/python.exe -c ' *)", - "Bash(./venv/Scripts/python.exe -u -c ' *)", - "Read(//tmp/**)", - "Bash(\"./venv/Scripts/python.exe\" main.py)", - "Bash(\"D:/Proyectos Software/AR ECDIS/webecdis/venv/Scripts/python.exe\" -m pip install GDAL)", - "Bash(\"D:/Proyectos Software/AR ECDIS/webecdis/venv/Scripts/python.exe\" -m pip install fiona)", - "Bash(sort -k2)", - "Bash(\"D:\\\\Proyectos Software\\\\AR ECDIS\\\\webecdis\\\\venv\\\\Scripts\\\\pip.exe\" install pyais==4.4.0)", - "Bash(\"D:\\\\Proyectos Software\\\\AR ECDIS\\\\webecdis\\\\venv\\\\Scripts\\\\pip.exe\" install pyais==3.0.0)", - "Bash('venv\\\\Scripts\\\\python.exe' -c ' *)", - "Bash(xargs grep *)", - "Bash(curl -s -o /dev/null -w \"%{http_code}\" \"https://api.github.com/repos/jvde-github/AIS-catcher/releases/latest\")", - "Bash(curl -s \"https://api.github.com/repos/jvde-github/AIS-catcher/releases/latest\")", - "Bash(python3 -c \"import json,sys; r=json.load\\(sys.stdin\\); [print\\(a['name'], a['browser_download_url']\\) for a in r.get\\('assets',[]\\)]\")", - "Bash(python3 -c ' *)", - "PowerShell(python -c \"import serial.tools.list_ports; [print\\(p.device, p.description, p.vid, p.pid\\) for p in serial.tools.list_ports.comports\\(\\)]\")", - "Bash(node --check C:/AidsMonitoring/frontend/js/map.js)", - "Bash(python -c \"from services.ais_simulator import run_simulator, MIAMI_AIDS; print\\('OK', len\\(MIAMI_AIDS\\), 'aids'\\)\")", - "Bash(python -c \"from services.chart_manager import get_all_depths; r = get_all_depths\\(\\(-80.5, 25.6, -80.0, 25.9\\)\\); print\\('depths features:', len\\(r.get\\('features', []\\)\\)\\)\")", - "Bash(python -c ' *)", - "Bash(python -c \"from services.chart_manager import list_cells; import json; r = list_cells\\(\\); print\\(json.dumps\\(r[:2], indent=2, default=str\\)\\)\")", - "Bash(taskkill /F /IM python.exe)", - "Bash(python -m uvicorn backend.main:app --host 0.0.0.0 --port 5503 --reload)", - "Bash(curl -s http://localhost:5503/ais/status)", - "Bash(python -m uvicorn main:app --host 0.0.0.0 --port 5503 --reload)", - "Bash(curl -s http://localhost:5503/ais/stats)", - "Bash(curl -s http://localhost:5503/ais/raw)", - "Bash(python -m uvicorn main:app --host 0.0.0.0 --port 5503)", - "Bash(curl -s http://localhost:5503/debug)", - "Bash(python3 -c \"import json,sys; d=json.load\\(sys.stdin\\); print\\(list\\(d.keys\\(\\)\\)\\)\")", - "Bash(curl -sv http://localhost:5503/ais/stats)", - "Bash(curl -sv http://localhost:5503/ais/raw)", - "Bash(curl -sv http://localhost:5503/ais/launch -X POST)", - "PowerShell(netstat -ano)", - "Bash(curl -s http://localhost:8000/gps/status)", - "Bash(curl -sv http://localhost:8000/gps/status)", - "Bash(curl -s http://localhost:5503/gps/status)", - "Bash(curl -sv -m 3 http://localhost:8100/)", - "PowerShell(Get-Process -Name \"AIS-catcher\" -ErrorAction SilentlyContinue | Select Id, StartTime, @{N='RAM_MB';E={[math]::Round\\($_.WorkingSet/1MB,1\\)}}, @{N='CPU_s';E={[math]::Round\\($_.CPU,1\\)}})", - "Bash(curl -s -m 3 'http://localhost:8100/stat.json')", - "Bash(curl -s -m 3 'http://localhost:8100/api/stat')", - "Bash(curl -s -m 3 -o /dev/null -w \"HTTP %{http_code} | %{size_download} bytes\\\\n\" http://localhost:8100/)", - "Bash(curl -s -X POST http://localhost:5503/ais/stop)", - "Bash(curl -s -X POST http://localhost:5503/ais/launch)", - "Bash(curl -s http://localhost:8100/api/stat)", - "Bash(python -c \"import sys,json; d=json.load\\(sys.stdin\\); t=d['total']; print\\(f'count={t[\\\\\"count\\\\\"]} vessels={t[\\\\\"vessels\\\\\"]} ch={t[\\\\\"channel\\\\\"]} lvl_min={t[\\\\\"level_min\\\\\"]} lvl_max={t[\\\\\"level_max\\\\\"]} ppm={t[\\\\\"ppm\\\\\"]} received={d.get\\(\\\\\"received\\\\\"\\)} runtime={d.get\\(\\\\\"run_time\\\\\"\\)}s msg_rate={d.get\\(\\\\\"msg_rate\\\\\"\\)}'\\)\")", - "Bash(curl -s -m 3 -o /dev/null -w \"HTTP %{http_code} | %{size_download}\\\\n\" http://localhost:8100/api/stat)", - "Bash(curl -s -m 3 -o /dev/null -w \"HTTP %{http_code} | %{size_download}\\\\n\" 'http://localhost:8100/stat.json')", - "Bash(curl -s 'http://localhost:8100/stat.json')", - "Bash(curl -s -X POST 'http://localhost:5503/settings' -H 'Content-Type: application/json' -d '{\"ais_source\":\"SIMULATOR\"}')", - "Bash('D:/Proyectos Software/AR ECDIS/webecdis/venv/Scripts/python.exe' -c ' *)", - "Bash(\"D:/Proyectos Software/AR ECDIS/webecdis/venv/Scripts/pip.exe\" search iso8211)", - "Bash(\"D:/Proyectos Software/AR ECDIS/webecdis/venv/Scripts/pip.exe\" install --dry-run iso8211 pyiso8211 python-s57 ddr-writer)", - "WebSearch", - "WebFetch(domain:github.com)", - "Bash(\"D:\\\\Miniconda\\\\envs\\\\s57\\\\python.exe\" poc_wabasso.py)", - "Bash('D:\\\\Proyectos Software\\\\AR ECDIS\\\\webecdis\\\\venv\\\\Scripts\\\\python.exe' -c ' *)", - "Bash(\"D:\\\\Miniconda\\\\envs\\\\s57\\\\python.exe\" converter.py \"C:\\\\Users\\\\aerom\\\\Terreno Wabasso.qgz\" --output \"C:\\\\Users\\\\aerom\\\\Terreno_Wabasso.000\" --force --verbose)", - "Bash(\"D:\\\\Miniconda\\\\envs\\\\s57\\\\python.exe\" -c \"import geopandas; print\\('ok'\\)\")", - "Bash(\"D:\\\\Miniconda\\\\envs\\\\s57\\\\python.exe\" -c \"import geopandas\")", - "Bash(echo \"exit: $?\")", - "PowerShell(& \"D:\\\\Miniconda\\\\envs\\\\s57\\\\python.exe\" -c \"import geopandas; print\\(geopandas.__version__\\)\" 2>&1)", - "Bash(\"D:\\\\Miniconda\\\\envs\\\\s57\\\\python.exe\" -m PyInstaller QGISS57Converter.spec --noconfirm)", - "Bash(\"D:\\\\Miniconda\\\\Scripts\\\\activate.bat\" s57 *)", - "Bash(\"D:\\\\Miniconda\\\\envs\\\\s57\\\\Scripts\\\\pyinstaller.exe\" QGISS57Converter.spec --noconfirm)", - "PowerShell(\\(Get-Item \"D:\\\\Proyectos Software\\\\QGISS57Converter\\\\dist\\\\QGISS57Converter.exe\"\\).Length / 1MB | ForEach-Object { \"{0:N1} MB\" -f $_ })", - "WebFetch(domain:wwwcdn.imo.org)", - "WebFetch(domain:iho.int)", - "Bash(python main.py)", - "Bash(tasklist)", - "WebFetch(domain:www8.garmin.com)", - "WebFetch(domain:www.marineinsight.com)", - "WebFetch(domain:www.furunousa.com)", - "WebFetch(domain:www.imorules.com)", - "WebFetch(domain:www.bmemarine.com)", - "WebFetch(domain:www.starpath.com)", - "WebFetch(domain:www.manualslib.com)", - "WebFetch(domain:cioh.dimar.mil.co)", - "WebFetch(domain:www.ic-enc.org)", - "WebFetch(domain:www.ic-enc.com)", - "WebFetch(domain:sitmar.dimar.mil.co)", - "WebFetch(domain:www.dimar.mil.co)", - "WebFetch(domain:www.cioh.org.co)", - "WebFetch(domain:ihr.iho.int)", - "Bash(dir \"C:\\\\Users\\\\aerom\\\\Documents\\\\Cartas\" /s /b)", - "Read(//c/Users/aerom/Documents/Cartas/GARD0001/**)", - "Read(//c/Users/aerom/Documents/Cartas/GARD0002/**)", - "Read(//c/Users/aerom/Documents/Cartas/layers/**)", - "Bash(python -c \"import json; d=json.load\\(open\\('D:/Proyectos Software/QGISS57Converter/s57_objects.json','r',encoding='utf-8'\\)\\); print\\(d.get\\('LNDARE'\\) or list\\(d.items\\(\\)\\)[:3]\\)\")", - "WebFetch(domain:msi.nga.mil)", - "WebFetch(domain:www.postman.com)", - "Bash(curl -s -L -A \"Mozilla/5.0 \\(Windows NT 10.0; Win64; x64\\) AppleWebKit/537.36 \\(KHTML, like Gecko\\) Chrome/120.0.0.0 Safari/537.36\" \"https://msi.nga.mil/api/publications/ngalol/lights-buoys?volume=110&output=json&limit=2\")", - "Bash(curl -s -L --compressed -A \"Mozilla/5.0 \\(Windows NT 10.0; Win64; x64\\) AppleWebKit/537.36 \\(KHTML, like Gecko\\) Chrome/120.0.0.0 Safari/537.36\" -H \"Accept: application/json\" \"https://msi.nga.mil/api/publications/ngalol/lights-buoys?volume=110&output=json&limit=2\")", - "Bash(curl -s -L --compressed -A \"Mozilla/5.0\" -H \"Accept: application/json\" \"https://msi.nga.mil/api/publications/ngalol/lights-buoys?volume=110&includeRemovals=false&output=json&limit=2\")", - "Bash(curl -s -L --compressed -A \"Mozilla/5.0 \\(Windows NT 10.0; Win64; x64\\) AppleWebKit/537.36 \\(KHTML, like Gecko\\) Chrome/120.0.0.0 Safari/537.36\" -H \"Accept: application/json\" -H \"Referer: https://msi.nga.mil/\" \"https://msi.nga.mil/api/publications/ngalol/lights-buoys?volume=110&includeRemovals=false&output=json&limit=2\")", - "Bash(curl -s -L --compressed -A \"Mozilla/5.0 \\(Windows NT 10.0; Win64; x64\\) AppleWebKit/537.36 \\(KHTML, like Gecko\\) Chrome/120.0.0.0 Safari/537.36\" -H \"Accept: application/json\" -H \"Referer: https://msi.nga.mil/\" \"https://msi.nga.mil/api/swagger-ui.html\")", - "Bash(curl -s -L --compressed -A \"Mozilla/5.0 \\(Windows NT 10.0; Win64; x64\\) AppleWebKit/537.36 \\(KHTML, like Gecko\\) Chrome/120.0.0.0 Safari/537.36\" -H \"Accept: application/json\" -H \"Referer: https://msi.nga.mil/\" \"https://msi.nga.mil/api/publications/ngalol/lights-buoys?latitudeLeft=10&longitudeLeft=-75.5&latitudeRight=11.5&longitudeRight=-74&includeRemovals=false&output=json\")", - "Bash(python nga_fetch.py)", - "Bash(set PYTHONIOENCODING=utf-8)", - "Bash(python -X utf8 nga_fetch.py)", - "Bash(dir \"D:\\\\Proyectos Software\\\\QGISS57Converter\" /b)", - "Bash(pip install *)", - "Bash(python -X utf8 -c \"print\\('OK'\\)\")", - "WebFetch(domain:overpass-api.de)", - "Bash(curl -s \"https://overpass-api.de/api/interpreter\" --data \"[out:json][timeout:30];\\(node[\\\\\"seamark:type\\\\\"]\\(10.8,-75.1,11.3,-74.5\\);\\);out body;\")", - "Bash(python -c \"import sys,json; d=json.load\\(sys.stdin\\); [print\\(n['id'], n['lat'], n['lon'], n.get\\('tags',{}\\)\\) for n in d['elements']]\")", - "Bash(curl -s \"https://overpass-api.de/api/interpreter\" --data \"[out:json][timeout:60];\\(node[\\\\\"seamark:type\\\\\"~\\\\\"buoy|beacon|light\\\\\"]\\(10.5,-75.5,11.5,-74.0\\);way[\\\\\"seamark:type\\\\\"]\\(10.5,-75.5,11.5,-74.0\\);\\);out body;\")", - "Bash(curl -s \"https://cioh.dimar.mil.co/avisonav-api/api/novelty/getCurrentNoveltys\" -A \"Mozilla/5.0\" -H \"Accept: application/json\" -H \"Origin: https://cioh.dimar.mil.co\" -H \"Referer: https://cioh.dimar.mil.co/webnotice/noveltyMap\" --max-time 15)", - "Bash(curl -v \"https://cioh.dimar.mil.co/avisonav-api/api/novelty/getCurrentNoveltys\" -A \"Mozilla/5.0 \\(Windows NT 10.0; Win64; x64\\) AppleWebKit/537.36 \\(KHTML, like Gecko\\) Chrome/120.0.0.0 Safari/537.36\" -H \"Accept: application/json, text/plain, */*\" -H \"Origin: https://cioh.dimar.mil.co\" -H \"Referer: https://cioh.dimar.mil.co/webnotice/noveltyMap\" -H \"sec-fetch-site: same-origin\" -H \"sec-fetch-mode: cors\" --max-time 15)", - "Bash(python parse_lista_luces.py)", - "Bash(python -c \"import converter; print\\('OK'\\)\")", - "Bash(python -m pyinstaller --version)", - "Bash(dir \"C:\\\\Users\\\\aerom\\\\AppData\\\\Local\\\\Programs\\\\Python\")", - "Bash(\"C:\\\\Python314\\\\python.exe\" -m pyinstaller --version)", - "PowerShell(cmd /c \"dir C:\\\\Python314\\\\ 2>&1\")", - "Bash(wsl -e ls -la \"/mnt/c/AidsMonitoring/charts/TMPDJH0QRQ_/\")", - "Bash(wsl -e ls -la \"/mnt/c/AidsMonitoring/charts/BARRANQUILLA/\")", - "WebFetch(domain:www.iala.int)", - "WebFetch(domain:www.seasources.net)", - "WebFetch(domain:cdn.sealite.com)", - "WebFetch(domain:docs.iho.int)", - "WebFetch(domain:wiki.icaci.org)", - "WebFetch(domain:legacy.iho.int)", - "WebFetch(domain:www.charts.gc.ca)", - "WebFetch(domain:wiki.openstreetmap.org)", - "WebFetch(domain:repository.library.noaa.gov)", - "WebFetch(domain:nauticalcharts.noaa.gov)", - "WebFetch(domain:maritimesafetyinnovationlab.org)", - "WebFetch(domain:www.transport.wa.gov.au)", - "WebFetch(domain:www.merchantnavydecoded.com)", - "WebFetch(domain:www.captainsmode.com)", - "WebFetch(domain:www.amnautical.com)", - "WebFetch(domain:www.kartverket.no)", - "WebFetch(domain:seacomm.ru)", - "WebFetch(domain:studylib.net)", - "WebFetch(domain:journals.lib.unb.ca)", - "WebFetch(domain:www.hattelandtechnology.com)", - "WebFetch(domain:dev.luciad.com)", - "WebFetch(domain:www.admiralty.co.uk)", - "WebFetch(domain:assets.admiralty.co.uk)", - "WebFetch(domain:icaci.org)", - "WebFetch(domain:charts.gc.ca)", - "WebFetch(domain:opencpn.org)", - "WebFetch(domain:sailingissues.com)", - "WebFetch(domain:raw.githubusercontent.com)", - "WebFetch(domain:www.safe-skipper.com)", - "PowerShell(dir \"C:\\\\Users\\\\aerom\\\\\" -Name | Where-Object { $_ -notmatch \"^\\\\.\" })", - "Bash(python converter.py --help)", - "Bash(python build_barranquilla.py)", - "Bash(python check_s57.py dist/CO1CO01M/CO1CO01M.000)", - "Bash(curl -s http://localhost:5000/charts/cells)", - "Bash(echo \"EXIT: $?\")", - "Bash(curl -s http://localhost:5503/charts/cells)", - "Bash(curl -s -X POST http://localhost:5503/charts/rebuild)", - "Bash(curl -s http://localhost:5503/charts/rebuild/BARRANQUILLA)", - "Bash(curl -s -X POST \"http://localhost:5503/charts/cells/BARRANQUILLA/rebuild\")", - "Bash(curl -s \"http://localhost:5503/charts/cells\")", - "Bash(curl -s -o /tmp/features_test.json \"http://localhost:5503/charts/features\")", - "Bash(git add *)", - "Bash(git commit -m ' *)", - "Bash(python -c \"import sys,json; d=json.load\\(sys.stdin\\); print\\([c['id'] for c in d]\\)\")", - "Bash(curl -s -o /dev/null -w \"%{http_code}\" http://localhost:5503/charts/cells/BARRANQUILLA/features)", - "Bash(curl -s http://localhost:5503/charts/cells/BARRANQUILLA)", - "Bash(python -c \"import sys,json; d=json.load\\(sys.stdin\\); print\\('status:', d.get\\('status'\\), 'count:', d.get\\('feature_count'\\)\\)\")", - "Bash(curl -s http://localhost:5503/charts/features)", - "Bash(curl -s -X POST http://localhost:5503/charts/rebuild-cache)", - "Bash(python -c \"import sys,json; d=json.load\\(sys.stdin\\); cells=d.get\\('rebuilt',[]\\); print\\(f'Rebuilt {len\\(cells\\)} cells'\\)\")", - "Bash(node --check frontend/js/map.js)", - "Bash(grep -v \"^$\")", - "Bash(git checkout *)", - "mcp__Claude_in_Chrome__tabs_context_mcp", - "mcp__Claude_Preview__preview_start", - "Bash(Get-Content \"D:\\\\Proyectos Software\\\\AR ECDIS\\\\webecdis\\\\ui\\\\js\\\\colreg_ref.js\")", - "Bash(Measure-Object -Line)", - "Bash(Select-Object -ExpandProperty Lines)", - "WebFetch(domain:www.gov.uk)", - "WebFetch(domain:continuouswave.com)", - "WebFetch(domain:charts.noaa.gov)", - "WebFetch(domain:www.charts.noaa.gov)", - "Bash(curl -s \"https://gis.charttools.noaa.gov/arcgis/rest/services/MCS/ENCOnline/MapServer/exts/MaritimeChartService/MapServer/0/query?where=1%3D1&geometry=%7B%22xmin%22%3A-80.5%2C%22ymin%22%3A25.5%2C%22xmax%22%3A-80.0%2C%22ymax%22%3A25.9%7D&geometryType=esriGeometryEnvelope&spatialRel=esriSpatialRelIntersects&outFields=CELL_NAME%2CCELL_TITLE%2CSCALE%2CEDITION&f=json\")", - "Bash(curl -s \"https://gis.charttools.noaa.gov/arcgis/rest/services/MCS/ENCOnline/MapServer/0/query?where=1%3D1&geometry=%7B%22xmin%22%3A-80.5%2C%22ymin%22%3A25.5%2C%22xmax%22%3A-80.0%2C%22ymax%22%3A25.9%7D&geometryType=esriGeometryEnvelope&spatialRel=esriSpatialRelIntersects&outFields=CELL_NAME%2CCELL_TITLE%2CSCALE%2CEDITION&f=json\")", - "Bash(python3 -c \"import sys,json; d=json.load\\(sys.stdin\\); [print\\(f['attributes']\\) for f in d.get\\('features',[]\\)]\")", - "Bash(curl -s \"https://encdirect.noaa.gov/arcgis/rest/services/encdirect/enc_cells/MapServer/0/query?where=1%3D1&geometry=-80.5,25.5,-80.0,25.9&geometryType=esriGeometryEnvelope&spatialRel=esriSpatialRelIntersects&outFields=*&f=json\")", - "Bash(python3 -c \"import sys,json; d=json.load\\(sys.stdin\\); print\\(json.dumps\\(d, indent=2\\)\\)\")", - "Bash(curl -s \"https://gis.charttools.noaa.gov/arcgis/rest/services/MCS/ENCOnline/MapServer/0?f=json\")", - "Bash(python3 -c \"import sys,json; d=json.load\\(sys.stdin\\); print\\(d.get\\('name',''\\), d.get\\('description',''\\)\\)\")", - "Bash(curl -v \"https://gis.charttools.noaa.gov/arcgis/rest/services/MCS/ENCOnline/MapServer?f=json\")", - "Bash(curl -s \"https://gis.charttools.noaa.gov/arcgis/rest/services?f=json\")", - "Bash(python3 -c \"import sys,json; d=json.load\\(sys.stdin\\); [print\\(s['name'], s['type']\\) for s in d.get\\('services',[]\\)]\")", - "Bash(curl -s \"https://gis.charttools.noaa.gov/arcgis/rest/services/MCS/ENCOnline/MapServer/exts/MaritimeChartService/MapServer/0/query?where=1%3D1&geometry=-80.5%2C25.5%2C-80.0%2C25.9&geometryType=esriGeometryEnvelope&spatialRel=esriSpatialRelIntersects&outFields=*&f=json\")", - "Bash(python3 -c \"import sys,json; d=json.load\\(sys.stdin\\); print\\(json.dumps\\(d,indent=2\\)\\)\")", - "Bash(curl -s \"https://gis.charttools.noaa.gov/arcgis/rest/services/MCS/ENCOnline/MapServer/exts/MaritimeChartService?f=json\")", - "Bash(curl -s \"https://gis.charttools.noaa.gov/arcgis/rest/services?f=pjson\")", - "Bash(python3 -c \"import sys,json; d=json.load\\(sys.stdin\\); [print\\(s\\) for s in d]\")", - "Bash(curl -s \"https://gis.charttools.noaa.gov/arcgis/rest/services/MCS?f=pjson\")", - "Bash(curl -s \"https://gis.charttools.noaa.gov/arcgis/rest/services/MCS/NOAAChartDisplay/MapServer?f=pjson\")", - "Bash(curl -s \"https://charts.noaa.gov/ENCs/US3FL28M_19115.xml\")", - "Bash(curl -s \"https://charts.noaa.gov/ENCs/US4FL2AI_19115.xml\")", - "Bash(curl -s \"https://charts.noaa.gov/ENCs/ENCsIndv.shtml\")", - "Bash(curl -s \"https://charts.noaa.gov/ENCs/US4FL2BI_19115.xml\")", - "Bash(dir \"D:\\\\Proyectos Software\\\\AR ECDIS\\\\webecdis\\\\data\\\\charts\" /s /b)", - "Bash(dir \"D:\\\\Proyectos Software\\\\AR ECDIS\\\\webecdis\\\\backend\" /s /b *.py)", - "Bash(Select-String -Path \"D:\\\\Proyectos Software\\\\AR ECDIS\\\\webecdis\\\\ui\\\\lib\\\\maplibre-gl.js\" -Pattern \"\\\\\"version\\\\\"\")", - "Bash(Select-Object -First 3)", - "Bash(venv\\\\Scripts\\\\python.exe -c \"import weasyprint; print\\('weasyprint ok'\\)\")", - "Bash(venv\\\\Scripts\\\\python.exe -c \"import xhtml2pdf; print\\('xhtml2pdf ok'\\)\")", - "Bash(venv\\\\Scripts\\\\python.exe -c \"import pdfkit; print\\('pdfkit ok'\\)\")", - "Bash(Get-Content \"D:\\\\Proyectos Software\\\\AR ECDIS\\\\webecdis\\\\main.py\")", - "Bash(Select-String -Pattern \"crew_list|crew_add|passengers_list\")", - "Bash(Select-Object -First 5)", - "Bash(del /Q \"D:\\\\Proyectos Software\\\\AR ECDIS\\\\webecdis\\\\__pycache__\\\\main.cpython-*.pyc\")", - "Bash(cd /d \"D:\\\\Proyectos Software\\\\AR ECDIS\\\\webecdis\")", - "Bash(python -c \"import ast; ast.parse\\(open\\('backend/sensors/sensor_state.py',encoding='utf-8'\\).read\\(\\)\\); print\\('sensor_state OK'\\)\")", - "Bash(python -c \"import ast; ast.parse\\(open\\('backend/sensors/nmea0183_reader.py',encoding='utf-8'\\).read\\(\\)\\); print\\('nmea0183_reader OK'\\)\")", - "Bash(python -c \"import ast; ast.parse\\(open\\('backend/sensors/nmea_router.py',encoding='utf-8'\\).read\\(\\)\\); print\\('nmea_router OK'\\)\")", - "Bash(python -c \"import ast; ast.parse\\(open\\(r'D:\\\\Proyectos Software\\\\AR ECDIS\\\\webecdis\\\\backend\\\\sensors\\\\nmea0183_reader.py',encoding='utf-8'\\).read\\(\\)\\); print\\('nmea0183_reader OK'\\)\")", - "Bash(python -c \"import ast; ast.parse\\(open\\(r'D:\\\\Proyectos Software\\\\AR ECDIS\\\\webecdis\\\\backend\\\\sensors\\\\nmea_router.py',encoding='utf-8'\\).read\\(\\)\\); print\\('nmea_router OK'\\)\")", - "Bash(python -c \"import ast; ast.parse\\(open\\(r'D:\\\\Proyectos Software\\\\AR ECDIS\\\\webecdis\\\\main.py',encoding='utf-8'\\).read\\(\\)\\); print\\('main.py OK'\\)\")", - "Bash(node -e \"require\\('fs'\\).readFileSync\\(String.raw\\\\`D:\\\\Proyectos Software\\\\AR ECDIS\\\\webecdis\\\\ui\\\\js\\\\sim.js\\\\`, 'utf8'\\); console.log\\('sim.js OK'\\)\")", - "Bash(node --input-type=module)", - "Bash(node -e \"var src=require\\('fs'\\).readFileSync\\(String.raw\\\\`D:\\\\Proyectos Software\\\\AR ECDIS\\\\webecdis\\\\ui\\\\js\\\\app.js\\\\`,'utf8'\\); new Function\\(src\\); console.log\\('app.js OK'\\)\")", - "Bash(node -e \"var s=require\\('fs'\\).readFileSync\\(String.raw\\\\`D:\\\\Proyectos Software\\\\AR ECDIS\\\\webecdis\\\\ui\\\\js\\\\app.js\\\\`,'utf8'\\); new Function\\(s\\); console.log\\('app.js OK'\\)\")", - "Bash(python -c \"import ast; ast.parse\\(open\\(r'D:\\\\Proyectos Software\\\\AR ECDIS\\\\webecdis\\\\backend\\\\sensors\\\\sensor_state.py',encoding='utf-8'\\).read\\(\\)\\); print\\('sensor_state OK'\\)\")", - "Bash(node -e \"var s=require\\('fs'\\).readFileSync\\(String.raw\\\\`D:\\\\Proyectos Software\\\\AR ECDIS\\\\webecdis\\\\ui\\\\js\\\\crew.js\\\\`,'utf8'\\); new Function\\(s\\); console.log\\('crew.js OK'\\)\")", - "Bash(python -m py_compile \"D:\\\\Proyectos Software\\\\AR ECDIS\\\\webecdis\\\\main.py\")", - "Bash(python -c \"import ast; ast.parse\\(open\\('main.py', encoding='utf-8'\\).read\\(\\)\\); print\\('main.py OK'\\)\")", - "WebFetch(domain:ceehydrosystems.com)", - "Bash(python _regen_ctg.py)", - "Bash(findstr /n \"survey\\\\|Survey\\\\|lp-sv\\\\|lp-pane\" \"D:\\\\Proyectos Software\\\\AR ECDIS\\\\webecdis\\\\ui\\\\index.html\")", - "Bash(findstr /n \"survey\" \"D:\\\\Proyectos Software\\\\AR ECDIS\\\\webecdis\\\\ui\\\\index.html\")", - "Bash(python -c \"from backend.routers.charts import router; print\\('charts.py OK'\\)\")", - "Bash(python3 -c \"import pdfplumber; p=pdfplumber.open\\(r'C:\\\\Users\\\\aerom\\\\Downloads\\\\Notice_to_Mariners_Anual_2023.pdf'\\); print\\(p.pages[0].extract_text\\(\\)[:2000]\\)\")", - "Bash(python -c \"import pdfplumber; p=pdfplumber.open\\(r'C:\\\\Users\\\\aerom\\\\Downloads\\\\Notice_to_Mariners_Anual_2023.pdf'\\); print\\(p.pages[0].extract_text\\(\\)[:2000]\\)\")", - "Bash(dir \"D:\\\\Proyectos Software\\\\QGISS57Converter\\\\capas_ctg\")", - "Bash(dir \"C:\\\\AidsMonitoring\\\\charts\")", - "Bash(python -c \"import sys; sys.stdout.reconfigure\\(encoding='utf-8', errors='replace'\\); [print\\(l.rstrip\\(\\)\\) for l in sys.stdin]\")", - "Bash(python -c \"import sys; [print\\(f'{i+1:4d} {l}',end=''\\) for i,l in enumerate\\(sys.stdin\\)]\")", - "Bash(powershell -command \"\\(Get-Content 'C:\\\\AidsMonitoring\\\\frontend\\\\js\\\\menu.js'\\).Count\")", - "Bash(powershell -command \"\\(Get-Content 'C:\\\\AidsMonitoring\\\\frontend\\\\index.html'\\).Count\")", - "Bash(powershell -command \"Get-ChildItem 'C:\\\\AidsMonitoring\\\\charts' | Select-Object Name\")", - "Bash(powershell -command \"Get-ChildItem 'D:\\\\Proyectos Software\\\\AR ECDIS' | Select-Object Name\")", - "Bash(powershell -command \"Get-ChildItem 'D:\\\\Proyectos Software\\\\AR ECDIS\\\\webecdis\\\\data' | Select-Object Name\")", - "WebFetch(domain:www.puertocartagena.com)", - "WebFetch(domain:www.paracay.com)", - "WebFetch(domain:www.openstreetmap.org)", - "WebFetch(domain:cecoldodigital.dimar.mil.co)", - "Bash(where ogrinfo *)", - "Bash(C:\\\\Python313\\\\python.exe -c \"from osgeo import ogr; print\\('gdal OK'\\)\")", - "Bash(C:\\\\Users\\\\aerom\\\\AppData\\\\Local\\\\Python\\\\bin\\\\python.exe -c \"from osgeo import ogr; print\\('gdal OK'\\)\")", - "Bash(\"C:/Python313/python.exe\" -c \"from osgeo import ogr; print\\('gdal OK'\\)\")", - "Bash(\"C:/Users/aerom/AppData/Local/Python/bin/python.exe\" -c \"from osgeo import ogr; print\\('gdal OK'\\)\")", - "Read(//c/Program Files/**)", - "Read(//c/PROGRA~1/**)", - "Bash(python -c \"import geopandas; print\\('geopandas OK'\\)\")", - "Bash(grep -rn \"capas_ctg\\\\|BAHÍA_DE_CARTAGENA\" \"D:/Proyectos Software/AR ECDIS/webecdis/\" --include=\"*.py\" ! -path \"*/venv/*\")", - "Bash(python _patch_ecdis_cartagena.py)", - "Bash(python build_ecdis_manual.py capas_ctg \"BAHÍA_DE_CARTAGENA\")", - "Bash(python -c \"import main\")", - "Bash(python -c \"from models.user import User, Role; print\\(list\\(Role\\)\\)\")", - "Bash(taskkill /F /IM python.exe /T)", - "Bash(Start-Sleep -Milliseconds 500)", - "Bash(curl -s http://localhost:5503/health)", - "Bash(git -C \"C:/AidsMonitoring\" status)" - ] - } -} diff --git a/.gitignore b/.gitignore index 3ff4a30..795ab1a 100644 --- a/.gitignore +++ b/.gitignore @@ -17,13 +17,11 @@ build/ *.db *.sqlite -# ENC / chart binary exchange sets (large S-57 binaries) -Cartas/*/ENC_ROOT/**/*.000 -Cartas/*/ENC_ROOT/**/*.001 -Cartas/*/ENC_ROOT/**/*.002 -Cartas/*/ENC_ROOT/**/*.003 -Cartas/*/ENC_ROOT/**/*.004 -Cartas/*/ENC_ROOT/**/*.005 +# ENC / S-57 chart data — large binary + GeoJSON, rebuilt on demand. +# Keep only the single world-overview cell (US1GC09M) as base reference. +Cartas/ +charts/ +!charts/US1GC09M/ # Generated GeoJSON (rebuilt on demand) backend/cache/ @@ -34,3 +32,9 @@ node_modules/ # OS .DS_Store Thumbs.db + +# IDE / session files +.claude/ +*.log +logs/ +.nextcloudsync.log diff --git a/backend/__pycache__/database.cpython-314.pyc b/backend/__pycache__/database.cpython-314.pyc deleted file mode 100644 index f33221a..0000000 Binary files a/backend/__pycache__/database.cpython-314.pyc and /dev/null differ diff --git a/backend/__pycache__/main.cpython-314.pyc b/backend/__pycache__/main.cpython-314.pyc deleted file mode 100644 index b7c2f67..0000000 Binary files a/backend/__pycache__/main.cpython-314.pyc and /dev/null differ diff --git a/backend/aidsmonitoring.db b/backend/aidsmonitoring.db deleted file mode 100644 index 02e7d92..0000000 Binary files a/backend/aidsmonitoring.db and /dev/null differ diff --git a/backend/main.py b/backend/main.py index 191d838..36f12f1 100644 --- a/backend/main.py +++ b/backend/main.py @@ -6,6 +6,7 @@ from datetime import datetime from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Request from fastapi.staticfiles import StaticFiles from fastapi.middleware.cors import CORSMiddleware +from fastapi.middleware.gzip import GZipMiddleware from contextlib import asynccontextmanager import asyncio import json @@ -44,6 +45,7 @@ from services.alert_engine import evaluate_vessel, evaluate_aid_movement, aid_al from services.gps_reader import GPSReader from services.aton_decoder import decode_type21, decode_type8_aton, process_aton_message, aton_state from services import settings_store +from services.slave_relay import SlaveRelay import services.ais_catcher as _ais_catcher from services.ais_udp_reader import run_udp_listener import services.ais_udp_reader as _ais_udp @@ -56,7 +58,18 @@ models.lamp.Base.metadata.create_all(bind=engine) models.contact.Base.metadata.create_all(bind=engine) models.org.Base.metadata.create_all(bind=engine) # Additive migrations for columns added after first install -ensure_column("aids", "lamp_id", "TEXT") +ensure_column("aids", "lamp_id", "TEXT") +ensure_column("aids", "displacement_warn_m", "REAL") +ensure_column("aids", "displacement_alarm_m", "REAL") +ensure_column("aids", "signal_loss_min", "INTEGER") +ensure_column("aids", "din3_function", "TEXT") +ensure_column("aids", "din4_function", "TEXT") +# Link estable a feature S-57 (cell + feature_id estable cuando viene de carta) +ensure_column("aids", "source_chart", "TEXT") +ensure_column("aids", "cell_id", "TEXT") +ensure_column("aids", "chart_feature_id", "TEXT") +ensure_column("lamps", "warn_pct", "REAL DEFAULT 20.0") +ensure_column("lamps", "alarm_pct", "REAL DEFAULT 10.0") ensure_column("users", "prefs_json", "TEXT") ensure_column("users", "company_id", "TEXT") @@ -73,12 +86,22 @@ _aid_ownership_cache: dict[str, set] = {} # company_id → {aid_id, ...} _vessel_track_last: dict[str, dict] = {} # mmsi → {ts, lat, lon} _aton_track_last: dict[str, dict] = {} # mmsi → {ts, lat, lon} +# Signal-loss monitoring: last time each AIS AtoN was heard from +_aton_last_seen: dict[str, datetime] = {} # mmsi → datetime UTC +_signal_loss_state: dict[str, bool] = {} # mmsi → True if alert already sent + +# Digital input alert state: "{mmsi}_{din3|din4}" → True if currently triggered +_digital_alert_state: dict[str, bool] = {} + # Source of truth for runtime config — mutated via POST /settings. config = settings_store.SETTINGS # Background AIS reader task handle (lets us stop/restart on source change) _ais_task: asyncio.Task | None = None +# Slave relay — active when server_role == "SLAVE" +_slave_relay: SlaveRelay | None = None + # Last-known battery alert state per ATON to avoid re-emitting every msg. # Values: None | 'WARN' | 'ALARM' _battery_alert_state: dict[str, str | None] = {} @@ -200,6 +223,8 @@ async def broadcast(message: dict, owned_mmsi → filter vessel/AtoN traffic by MMSI (company users see only their own) owned_aid_id → filter aid-position updates by aid_id Admins (company_id=None) always receive everything. + + When server_role == "SLAVE", also forwards the event upstream to the master. """ data = json.dumps(message) dead = [] @@ -214,6 +239,10 @@ async def broadcast(message: dict, if c in connected_clients: connected_clients.remove(c) + # Forward upstream when acting as a slave + if _slave_relay is not None: + _slave_relay.send(message) + async def _persist_recording(db, alert: dict): """Save or close a RecordingEvent row when auto-recording triggers.""" from models.vessel import RecordingEvent @@ -253,7 +282,9 @@ async def process_message(msg: dict): alerts = evaluate_vessel(msg, aids_list, config) await broadcast(msg) # vessels = public traffic, no filter for alert in alerts: - await broadcast({"type": "alert", **alert}) + # Filter proximity alerts to the company that owns the aid involved + await broadcast({"type": "alert", **alert}, + owned_aid_id=alert.get("aid_id")) if alert["tipo"] in ("GRABACION_INICIADA", "GRABACION_FINALIZADA"): await _persist_recording(db, alert) @@ -279,10 +310,20 @@ async def process_message(msg: dict): if entry: mmsi = entry["mmsi"] aton_state[mmsi] = entry + _aton_last_seen[mmsi] = datetime.utcnow() # track for signal-loss + if _signal_loss_state.pop(mmsi, False): + # Signal restored — clear any active loss alert on clients + await broadcast({"type": "alert", + "tipo": "SENAL_RESTAURADA", + "mmsi": mmsi, + "timestamp": datetime.utcnow().isoformat()}, + owned_mmsi=mmsi) await broadcast({"type": "aton", **entry}, owned_mmsi=mmsi) # Auto-upsert Aid row on first sight (Type 21 carries name + position). - # The user must then assign a lamp via the right panel. + # When an existing Aid with matching MMSI is found, update its + # lat_actual + run drift evaluation using that aid's per-buoy + # thresholds so the alert engine fires correctly. if msg.get("msg_type") == 21 and entry.get("lat") is not None: aid = db.query(Aid).filter(Aid.mmsi == mmsi).first() if not aid: @@ -298,6 +339,7 @@ async def process_message(msg: dict): lat_nominal=entry["lat"], lon_nominal=entry["lon"], fuente_posicion="AIS", + source_chart="AIS", ) db.add(aid); db.commit() await broadcast({ @@ -311,6 +353,43 @@ async def process_message(msg: dict): "mensaje": "Nueva ayuda detectada — falta asignar lámpara", "timestamp": datetime.utcnow().isoformat(), }) + else: + # Existing aid configured by operator (or auto-created). + # Update its actual position, displacement, and broadcast + # an aid_position event so the map can render the AIS + # ghost marker. Drift alerts use this aid's per-buoy + # thresholds (or fall back to global config). + from services.alert_engine import haversine + aid.lat_actual = entry["lat"] + aid.lon_actual = entry["lon"] + aid.desplazamiento_m = haversine( + entry["lat"], entry["lon"], + aid.lat_nominal, aid.lon_nominal, + ) + aid.ultima_senal = datetime.utcnow() + # Drift evaluation — fires WARN/ALARM only on state changes + drift_alerts = evaluate_aid_movement( + aid.id, entry["lat"], entry["lon"], + aid.lat_nominal, aid.lon_nominal, config, + warn_m=aid.displacement_warn_m, + alarm_m=aid.displacement_alarm_m, + ) + # en_posicion = within swing_radius of nominal + aid.en_posicion = (aid.desplazamiento_m + <= (aid.radio_borneo_m or 10.0)) + db.commit() + await broadcast({ + "type": "aid_position", + "id": aid.id, + "lat_actual": entry["lat"], + "lon_actual": entry["lon"], + "desplazamiento_m": round(aid.desplazamiento_m, 1), + "en_posicion": aid.en_posicion, + "en_movimiento": False, + }, owned_aid_id=aid.id) + for alert in drift_alerts: + await broadcast({"type": "alert", **alert}, + owned_aid_id=alert.get("aid_id")) # Battery threshold check — per-aid lamp values if assigned, # otherwise the global defaults from settings_store. @@ -321,9 +400,11 @@ async def process_message(msg: dict): if aid and aid.lamp_id: lamp = db.query(Lamp).filter(Lamp.id == aid.lamp_id).first() if lamp: - rng = lamp.voltage_max - lamp.voltage_min - warn_v = lamp.voltage_min + rng * 0.20 - alarm_v = lamp.voltage_min + rng * 0.10 + rng = lamp.voltage_max - lamp.voltage_min + warn_pct = (lamp.warn_pct or 20.0) / 100.0 + alarm_pct = (lamp.alarm_pct or 10.0) / 100.0 + warn_v = lamp.voltage_min + rng * warn_pct + alarm_v = lamp.voltage_min + rng * alarm_pct threshold_source = f"lamp:{lamp.manufacturer} {lamp.model}" else: warn_v = config["battery_warn_v"] @@ -346,9 +427,54 @@ async def process_message(msg: dict): "umbral": alarm_v if new_state == "ALARM" else warn_v, "fuente_umbral": threshold_source, "timestamp": datetime.utcnow().isoformat(), - }) + }, owned_mmsi=mmsi) # only the company that owns this buoy _battery_alert_state[mmsi] = new_state + # ── Digital input alerts (water ingress / listing) ──────────── + if msg.get("msg_type") == 8: + aid = aid or db.query(Aid).filter(Aid.mmsi == mmsi).first() + if aid: + import uuid as _uuid2 + _DIN_ALERT_MAP = { + "WATER_INGRESS_WARN": ("ALERTA_AMARILLA", "INGRESO_AGUA", "⚠ Water ingress detected"), + "WATER_INGRESS_CRITICAL": ("ALERTA_ROJA", "HUNDIMIENTO", "🔴 Critical water ingress — sinking risk"), + "LISTING": ("ALERTA_ROJA", "ESCORA_CRITICA", "🔴 Critical list detected"), + } + for din_field, fn_attr in [("din3", "din3_function"), ("din4", "din4_function")]: + fn = getattr(aid, fn_attr, None) + if not fn or fn not in _DIN_ALERT_MAP: + continue + triggered = entry.get(din_field, False) + # Also check IEC standard water level bit for CRITICAL + if fn == "WATER_INGRESS_CRITICAL": + triggered = triggered or entry.get("water_level_high", False) + state_key = f"{mmsi}_{din_field}" + prev_state = _digital_alert_state.get(state_key) + if triggered and not prev_state: + tipo, subtipo, mensaje = _DIN_ALERT_MAP[fn] + await broadcast({ + "type": "alert", + "id": str(_uuid2.uuid4()), + "tipo": tipo, + "subtipo": subtipo, + "mmsi": mmsi, + "aid_id": aid.id, + "aid_nombre": aid.nombre, + "mensaje": mensaje, + "timestamp": datetime.utcnow().isoformat(), + }, owned_mmsi=mmsi) + elif not triggered and prev_state: + # Condition cleared + await broadcast({ + "type": "alert", + "tipo": "CONDICION_RESUELTA", + "subtipo": _DIN_ALERT_MAP[fn][1], + "mmsi": mmsi, + "aid_id": aid.id, + "timestamp": datetime.utcnow().isoformat(), + }, owned_mmsi=mmsi) + _digital_alert_state[state_key] = triggered + # ── Auto-persist AtonTrack (DVR) ───────────────────────────── at_lat = entry.get("lat") at_lon = entry.get("lon") @@ -374,7 +500,9 @@ async def process_message(msg: dict): if aid: alert_list = evaluate_aid_movement( aid_id, msg["lat_actual"], msg["lon_actual"], - aid.lat_nominal, aid.lon_nominal, config + aid.lat_nominal, aid.lon_nominal, config, + warn_m=aid.displacement_warn_m, # per-aid override (None → global) + alarm_m=aid.displacement_alarm_m, ) aid.lat_actual = msg["lat_actual"] aid.lon_actual = msg["lon_actual"] @@ -383,7 +511,9 @@ async def process_message(msg: dict): db.commit() await broadcast(msg) # aid positions = public, no filter for alert in alert_list: - await broadcast({"type": "alert", **alert}) + # Only the company that owns this aid receives movement/position alerts + await broadcast({"type": "alert", **alert}, + owned_aid_id=alert.get("aid_id")) finally: db.close() @@ -444,6 +574,62 @@ async def lifespan(app: FastAPI): global _ais_task _ais_task = await _start_ais_source() + # ── Pre-warm chart-cell coverage cache in background ─────────────────── + # Reading 691 cell files is a ~2.5 s one-time cost. Doing it at startup + # means the FIRST user request lands on a hot cache. Runs off the main + # event loop so the server is responsive immediately. + async def _warm_chart_cache(): + try: + from services import chart_manager as _cm + import os + t0 = asyncio.get_event_loop().time() + count = 0 + for cell_dir in _cm.CHARTS_DIR.iterdir(): + if not cell_dir.is_dir(): + continue + for fname in ("depths.geojson", "land.geojson", "zones.geojson", + "hazards.geojson", "features.geojson"): + cache = cell_dir / fname + if not cache.exists(): + continue + try: + mtime = cache.stat().st_mtime + except OSError: + continue + cov_key = f"{cache}|{mtime}" + if cov_key in _cm._cell_coverage_cache: + continue + try: + import json as _json + fc = _json.loads(cache.read_text()) + except Exception: + continue + cov = _cm._coverage_bbox(fc.get("features") or []) + if cov is not None: + _cm._cell_coverage_cache[cov_key] = cov + count += 1 + # Yield to event loop every 50 files so we don't block + if count % 50 == 0: + await asyncio.sleep(0) + dt = asyncio.get_event_loop().time() - t0 + print(f"[charts] Pre-warm: {count} files indexed in {dt:.1f}s " + f"({len(_cm._cell_coverage_cache)} cached bboxes)") + except Exception as e: + print(f"[charts] Pre-warm failed (non-fatal): {e}") + asyncio.create_task(_warm_chart_cache()) + + # ── Slave relay (start when role == SLAVE) ───────────────────────────── + global _slave_relay + role = config.get("server_role", "STANDALONE").upper() + master_url = config.get("master_url", "").strip() + slave_name = config.get("slave_name", "").strip() or config.get("station_name", "Slave") + if role == "SLAVE" and master_url: + _slave_relay = SlaveRelay(master_url=master_url, slave_name=slave_name) + await _slave_relay.start() + print(f"[cluster] Rol: SLAVE → maestro {master_url} (nombre='{slave_name}')") + elif role == "MASTER": + print(f"[cluster] Rol: MASTER — esperando conexiones de esclavos en /ws/slave") + # GPS — settings_store now holds the port (mirrored from .env on first run) gps_port = config.get("gps_port") or None gps_baud = config.get("gps_baud") or None @@ -451,12 +637,64 @@ async def lifespan(app: FastAPI): await gps.start() app.state.gps = gps + # ── Signal-loss monitor (runs every 60 s) ────────────────────────────── + async def _signal_loss_monitor(): + import uuid as _uuid + while True: + await asyncio.sleep(60) + now = datetime.utcnow() + db2 = SessionLocal() + try: + for mmsi, last_seen in list(_aton_last_seen.items()): + aid = db2.query(Aid).filter(Aid.mmsi == mmsi).first() + if not aid or not aid.signal_loss_min: + continue + elapsed_min = (now - last_seen).total_seconds() / 60 + if elapsed_min >= aid.signal_loss_min: + if not _signal_loss_state.get(mmsi): + _signal_loss_state[mmsi] = True + await broadcast({ + "type": "alert", + "id": str(_uuid.uuid4()), + "tipo": "ALERTA_ROJA", + "subtipo": "PERDIDA_SENAL", + "mmsi": mmsi, + "aid_id": aid.id, + "aid_nombre": aid.nombre, + "minutos_sin_senal": round(elapsed_min), + "umbral_min": aid.signal_loss_min, + "timestamp": now.isoformat(), + }, owned_mmsi=mmsi) + else: + _signal_loss_state.pop(mmsi, None) + finally: + db2.close() + + asyncio.create_task(_signal_loss_monitor()) + yield + # ── Shutdown: stop slave relay if running ────────────────────────────── + if _slave_relay is not None: + await _slave_relay.stop() + app = FastAPI(title="AidsMonitoring", lifespan=lifespan) -app.add_middleware(CORSMiddleware, allow_origins=["*"], - allow_methods=["*"], allow_headers=["*"]) +app.add_middleware( + CORSMiddleware, + allow_origins=[ + "http://localhost:8080", + "http://127.0.0.1:8080", + "http://localhost:5173", + "http://127.0.0.1:5173", + ], + allow_methods=["GET", "POST", "PUT", "PATCH", "DELETE"], + allow_headers=["Content-Type", "Authorization"], +) +# Compress GeoJSON / large JSON responses. minimum_size=1024 skips tiny ones +# (status pings, single-aid lookups) where the gzip overhead isn't worth it. +# Chart payloads (depths 350 MB, land 124 MB cross-cell total) compress 5–10×. +app.add_middleware(GZipMiddleware, minimum_size=1024) app.include_router(auth_router.router) app.include_router(aids.router) @@ -587,7 +825,8 @@ async def update_settings(payload: dict, # Split: system keys go to settings_store; everything stays in user prefs SYSTEM_KEYS = {"ais_source","ais_serial_port","ais_baud","ais_net_addr", "gps_port","gps_baud","smtp_host","smtp_port","smtp_user", - "smtp_password","smtp_from","smtp_from_name","smtp_use_tls"} + "smtp_password","smtp_from","smtp_from_name","smtp_use_tls", + "server_role","master_url","slave_name"} system_patch = {k: v for k, v in (payload or {}).items() if k in SYSTEM_KEYS} prev = settings_store.get_all() @@ -602,6 +841,22 @@ async def update_settings(payload: dict, _ais_task = await _start_ais_source() applied.append(f"ais_source → {new['ais_source']}") + # Cluster role / master URL change — restart slave relay if needed + if "server_role" in system_patch or "master_url" in system_patch or "slave_name" in system_patch: + global _slave_relay + if _slave_relay is not None: + await _slave_relay.stop() + _slave_relay = None + role = new.get("server_role", "STANDALONE").upper() + master_url = new.get("master_url", "").strip() + sname = new.get("slave_name", "").strip() or new.get("station_name", "Slave") + if role == "SLAVE" and master_url: + _slave_relay = SlaveRelay(master_url=master_url, slave_name=sname) + await _slave_relay.start() + applied.append(f"server_role → SLAVE, relay → {master_url}") + else: + applied.append(f"server_role → {role}") + # GPS port change if "gps_port" in system_patch or "gps_baud" in system_patch: gps: GPSReader = getattr(app.state, "gps", None) @@ -717,6 +972,79 @@ async def ais_stop(): return _ais_catcher.stop() +@app.websocket("/ws/slave") +async def slave_websocket_endpoint(ws: WebSocket): + """ + MASTER-mode endpoint. Slave servers connect here to stream their events. + The master rebroadcasts each event to all locally connected clients (after + tagging it with _slave origin), and merges vessel/aton state in memory. + """ + if config.get("server_role", "STANDALONE").upper() not in ("MASTER", "STANDALONE"): + await ws.close(code=1008, reason="This server is not a MASTER") + return + + await ws.accept() + slave_name = "unknown" + print(f"[cluster] Nuevo esclavo conectado desde {ws.client}") + + try: + while True: + raw = await ws.receive_text() + try: + msg = json.loads(raw) + except Exception: + continue + + msg_type = msg.get("type") + + # Hello handshake — log the slave name + if msg_type == "slave_hello": + slave_name = msg.get("slave_name", "unknown") + print(f"[cluster] Esclavo identificado: '{slave_name}'") + continue + + # Merge vessel state into master's in-memory snapshot + if msg_type == "vessel": + mmsi = msg.get("mmsi") + if mmsi: + vessels_state[mmsi] = msg + + elif msg_type == "aton": + mmsi = msg.get("mmsi") + if mmsi: + from services.aton_decoder import aton_state as _aton_state + _aton_state[mmsi] = msg + + elif msg_type == "aid_position": + aid_id = msg.get("id") + if aid_id and aid_id in aids_state: + aids_state[aid_id].update(msg) + + # Rebroadcast to all locally connected browser clients + # _slave tag identifies origin; admins see all; company clients + # still filtered by ownership as usual. + await broadcast(msg) + + except WebSocketDisconnect: + print(f"[cluster] Esclavo '{slave_name}' desconectado.") + except Exception as e: + print(f"[cluster] Error con esclavo '{slave_name}': {e}") + + +@app.get("/cluster/status") +async def cluster_status(current_user=Depends(get_current_user)): + """Return current cluster role and slave relay status.""" + role = config.get("server_role", "STANDALONE").upper() + info: dict = { + "role": role, + "slave_name": config.get("slave_name", ""), + "master_url": config.get("master_url", ""), + } + if role == "SLAVE" and _slave_relay is not None: + info["relay"] = _slave_relay.status() + return info + + @app.websocket("/ws") async def websocket_endpoint( ws: WebSocket, @@ -742,7 +1070,12 @@ async def websocket_endpoint( finally: _db.close() except Exception: - pass # invalid/expired token → treat as anonymous (admin-level view) + await ws.close(code=1008) + return + + if token is None: + await ws.close(code=1008) + return client = {"ws": ws, "company_id": company_id} connected_clients.append(client) @@ -776,8 +1109,11 @@ async def websocket_endpoint( })) # Re-emit any active aid alerts so new clients see current state + # Company users only receive alerts for aids they own from datetime import datetime as _dt for aid_id, state in aid_alert_state.items(): + if owned_aid_ids is not None and aid_id not in owned_aid_ids: + continue # this aid doesn't belong to the connecting client's company if state == 'RED': await ws.send_text(json.dumps({"type": "alert", "tipo": "ALERTA_ROJA", "subtipo": "AYUDA_EN_MOVIMIENTO", "aid_id": aid_id, diff --git a/backend/models/__pycache__/__init__.cpython-314.pyc b/backend/models/__pycache__/__init__.cpython-314.pyc deleted file mode 100644 index 8bbf29f..0000000 Binary files a/backend/models/__pycache__/__init__.cpython-314.pyc and /dev/null differ diff --git a/backend/models/__pycache__/aid.cpython-314.pyc b/backend/models/__pycache__/aid.cpython-314.pyc deleted file mode 100644 index 443b12a..0000000 Binary files a/backend/models/__pycache__/aid.cpython-314.pyc and /dev/null differ diff --git a/backend/models/__pycache__/contact.cpython-314.pyc b/backend/models/__pycache__/contact.cpython-314.pyc deleted file mode 100644 index 7cdb052..0000000 Binary files a/backend/models/__pycache__/contact.cpython-314.pyc and /dev/null differ diff --git a/backend/models/__pycache__/lamp.cpython-314.pyc b/backend/models/__pycache__/lamp.cpython-314.pyc deleted file mode 100644 index c7dd7df..0000000 Binary files a/backend/models/__pycache__/lamp.cpython-314.pyc and /dev/null differ diff --git a/backend/models/__pycache__/user.cpython-314.pyc b/backend/models/__pycache__/user.cpython-314.pyc deleted file mode 100644 index ab08b11..0000000 Binary files a/backend/models/__pycache__/user.cpython-314.pyc and /dev/null differ diff --git a/backend/models/__pycache__/vessel.cpython-314.pyc b/backend/models/__pycache__/vessel.cpython-314.pyc deleted file mode 100644 index cf8b831..0000000 Binary files a/backend/models/__pycache__/vessel.cpython-314.pyc and /dev/null differ diff --git a/backend/models/aid.py b/backend/models/aid.py index 3e3acc6..b8f5ba6 100644 --- a/backend/models/aid.py +++ b/backend/models/aid.py @@ -1,4 +1,4 @@ -from sqlalchemy import Column, String, Float, Boolean, DateTime, Enum, Text +from sqlalchemy import Column, String, Float, Boolean, DateTime, Enum, Text, Integer from sqlalchemy.sql import func from database import Base import enum @@ -36,13 +36,32 @@ class Aid(Base): # Posición oficial (fuente de verdad) lat_nominal = Column(Float, nullable=False) lon_nominal = Column(Float, nullable=False) - fuente_posicion = Column(String, default="MANUAL") # S57 | MANUAL + fuente_posicion = Column(String, default="MANUAL") # S57 | MANUAL | AIS + + # Link estable al feature S-57 (cuando el Aid fue creado desde una carta). + # cell_id + chart_feature_id permite reencontrar el feature aunque el ENC + # se reedite. Si no hay LNAM en el feature original, chart_feature_id es + # un fingerprint: tipo_obj + round(lat,6) + round(lon,6). + source_chart = Column(String, nullable=True) # 'S57' | 'MANUAL' | 'AIS' + cell_id = Column(String, nullable=True, index=True) + chart_feature_id = Column(String, nullable=True, index=True) # Solo flotantes - radio_borneo_m = Column(Float, default=10.0) + radio_borneo_m = Column(Float, default=10.0) + # Per-aid displacement alert thresholds (override global config when set). + # Different buoys have different anchor chain lengths / acceptable drift. + displacement_warn_m = Column(Float, nullable=True) # None → use global setting + displacement_alarm_m = Column(Float, nullable=True) # None → use global setting + # Minutes without AIS signal before PERDIDA_SENAL alert fires. + # None = no signal-loss monitoring for this aid. + signal_loss_min = Column(Integer, nullable=True) # Si tiene AIS mmsi = Column(String, unique=True, nullable=True) + # Digital input function mapping (what each IN means for THIS buoy) + # Values: NULL | 'WATER_INGRESS_WARN' | 'WATER_INGRESS_CRITICAL' | 'LISTING' + din3_function = Column(String, nullable=True) + din4_function = Column(String, nullable=True) # Posición actual (actualizada por AIS) lat_actual = Column(Float, nullable=True) diff --git a/backend/models/lamp.py b/backend/models/lamp.py index eb9c6a5..ebcfc79 100644 --- a/backend/models/lamp.py +++ b/backend/models/lamp.py @@ -18,6 +18,12 @@ class Lamp(Base): lamp_count = Column(Integer, default=1) voltage_min = Column(Float, nullable=False) # discharged threshold (V) voltage_max = Column(Float, nullable=False) # fully-charged nominal (V) + # Battery alert thresholds as % of usable voltage range. + # warn_pct=20 means: alert when remaining capacity ≤ 20% of (max−min). + # Defaults match the original hardcoded values (20% / 10%). + # Override per lamp model — e.g. Sabik recommends 30%/15%. + warn_pct = Column(Float, default=20.0) # % of range → warning + alarm_pct = Column(Float, default=10.0) # % of range → alarm notes = Column(Text, nullable=True) creado_en = Column(DateTime, server_default=func.now()) modificado_en = Column(DateTime, onupdate=func.now()) diff --git a/backend/requirements.txt b/backend/requirements.txt index ba9b56d..16c7af4 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -9,3 +9,6 @@ aiofiles==24.1.0 python-jose[cryptography]==3.3.0 passlib[bcrypt]==1.7.4 bcrypt==4.0.1 +geopandas==1.1.3 +httpx==0.28.1 +pandas==2.2.0 diff --git a/backend/routers/__pycache__/__init__.cpython-314.pyc b/backend/routers/__pycache__/__init__.cpython-314.pyc deleted file mode 100644 index c7e5019..0000000 Binary files a/backend/routers/__pycache__/__init__.cpython-314.pyc and /dev/null differ diff --git a/backend/routers/__pycache__/aids.cpython-314.pyc b/backend/routers/__pycache__/aids.cpython-314.pyc deleted file mode 100644 index 6091600..0000000 Binary files a/backend/routers/__pycache__/aids.cpython-314.pyc and /dev/null differ diff --git a/backend/routers/__pycache__/auth.cpython-314.pyc b/backend/routers/__pycache__/auth.cpython-314.pyc deleted file mode 100644 index fa7c9c9..0000000 Binary files a/backend/routers/__pycache__/auth.cpython-314.pyc and /dev/null differ diff --git a/backend/routers/__pycache__/charts.cpython-314.pyc b/backend/routers/__pycache__/charts.cpython-314.pyc deleted file mode 100644 index 820fd2f..0000000 Binary files a/backend/routers/__pycache__/charts.cpython-314.pyc and /dev/null differ diff --git a/backend/routers/__pycache__/contacts.cpython-314.pyc b/backend/routers/__pycache__/contacts.cpython-314.pyc deleted file mode 100644 index ebd0dbd..0000000 Binary files a/backend/routers/__pycache__/contacts.cpython-314.pyc and /dev/null differ diff --git a/backend/routers/__pycache__/equipment.cpython-314.pyc b/backend/routers/__pycache__/equipment.cpython-314.pyc deleted file mode 100644 index 157e122..0000000 Binary files a/backend/routers/__pycache__/equipment.cpython-314.pyc and /dev/null differ diff --git a/backend/routers/__pycache__/lamps.cpython-314.pyc b/backend/routers/__pycache__/lamps.cpython-314.pyc deleted file mode 100644 index ee94b19..0000000 Binary files a/backend/routers/__pycache__/lamps.cpython-314.pyc and /dev/null differ diff --git a/backend/routers/aids.py b/backend/routers/aids.py index cc4a04a..dd8a1ad 100644 --- a/backend/routers/aids.py +++ b/backend/routers/aids.py @@ -62,10 +62,37 @@ class AidUpdate(BaseModel): radio_borneo_m: Optional[float] = None observaciones: Optional[str] = None lamp_id: Optional[str] = None + # AIS link — operator-editable. Once set, AIS Type 21 with this MMSI + # will update this aid's lat_actual and trigger drift/battery alerts. + mmsi: Optional[str] = None + tipo_ais: Optional[str] = None + # Per-aid alarm thresholds (override global config when set) + displacement_warn_m: Optional[float] = None + displacement_alarm_m: Optional[float] = None + signal_loss_min: Optional[int] = None + din3_function: Optional[str] = None + din4_function: Optional[str] = None + # Chart link (set when aid was promoted from S-57 feature; usually + # untouched after creation) + source_chart: Optional[str] = None + cell_id: Optional[str] = None + chart_feature_id: Optional[str] = None motivo_cambio: str modificado_por: str +class AidFromChartFeature(BaseModel): + """Payload to create an Aid record anchored to a specific S-57 chart feature.""" + cell_id: str + chart_feature_id: str + lat_nominal: float + lon_nominal: float + nombre: str + tipo: str = "BOYA_ESPECIAL" # caller can refine from S-57 type + categoria: str = "FLOTANTE" + radio_borneo_m: float = 10.0 + + class LampAssign(BaseModel): lamp_id: Optional[str] = None # None → unassign @@ -76,6 +103,25 @@ def list_aids(db: Session = Depends(get_db)): return [_aid_dict(a, _lamp_for(a, db)) for a in aids] +# Static paths declared BEFORE /{aid_id} so FastAPI doesn't capture +# "by-chart-feature" as an aid_id. +@router.get("/by-chart-feature") +def aid_by_chart_feature( + cell_id: str = Query(...), + feature_id: str = Query(...), + db: Session = Depends(get_db), +): + """Return the Aid record linked to a specific S-57 chart feature, or 404.""" + aid = db.query(Aid).filter( + Aid.cell_id == cell_id, + Aid.chart_feature_id == feature_id, + Aid.activa == True, + ).first() + if not aid: + raise HTTPException(404, "No aid linked to this chart feature") + return _aid_dict(aid, _lamp_for(aid, db)) + + @router.get("/{aid_id}") def get_aid(aid_id: str, db: Session = Depends(get_db)): aid = db.query(Aid).filter(Aid.id == aid_id).first() @@ -111,9 +157,23 @@ def update_aid(aid_id: str, data: AidUpdate, db: Session = Depends(get_db), aid = db.query(Aid).filter(Aid.id == aid_id).first() if not aid: raise HTTPException(status_code=404, detail="Ayuda no encontrada") + # Empty-string MMSI → unassign. Reject if MMSI is already used by another aid. + if data.mmsi is not None: + mmsi_clean = data.mmsi.strip() or None + if mmsi_clean: + taken = db.query(Aid).filter( + Aid.mmsi == mmsi_clean, Aid.id != aid_id + ).first() + if taken: + raise HTTPException(409, + f"MMSI {mmsi_clean} ya está asignado a '{taken.nombre}'") + aid.mmsi = mmsi_clean for field in ["lat_nominal", "lon_nominal", "puerto_responsable", "empresa_responsable", "caracteristica_luz", "alcance_nm", - "radio_borneo_m", "observaciones", "lamp_id"]: + "radio_borneo_m", "observaciones", "lamp_id", + "tipo_ais", "displacement_warn_m", "displacement_alarm_m", + "signal_loss_min", "din3_function", "din4_function", + "source_chart", "cell_id", "chart_feature_id"]: val = getattr(data, field) if val is not None: setattr(aid, field, val) @@ -122,7 +182,46 @@ def update_aid(aid_id: str, data: AidUpdate, db: Session = Depends(get_db), aid.modificado_en = datetime.utcnow() db.commit() db.refresh(aid) - return aid + return _aid_dict(aid, _lamp_for(aid, db)) + + +@router.post("/from-chart-feature") +def create_aid_from_chart_feature( + data: AidFromChartFeature, + db: Session = Depends(get_db), + current_user: User = Depends(require_admin), +): + """Promote a S-57 chart feature into a monitored Aid record. + Idempotent: if an aid already exists for (cell_id, chart_feature_id), + returns it instead of creating a duplicate.""" + existing = db.query(Aid).filter( + Aid.cell_id == data.cell_id, + Aid.chart_feature_id == data.chart_feature_id, + Aid.activa == True, + ).first() + if existing: + return _aid_dict(existing, _lamp_for(existing, db)) + + aid = Aid( + id=str(uuid.uuid4()), + nombre=data.nombre, + categoria=data.categoria, + tipo=data.tipo, + tipo_ais="SIN_AIS", # operator can change later + lat_nominal=data.lat_nominal, + lon_nominal=data.lon_nominal, + fuente_posicion="S57", + radio_borneo_m=data.radio_borneo_m, + source_chart="S57", + cell_id=data.cell_id, + chart_feature_id=data.chart_feature_id, + modificado_por=current_user.nombre if current_user else None, + motivo_cambio="Promoted from S-57 chart feature", + ) + db.add(aid) + db.commit() + db.refresh(aid) + return _aid_dict(aid, _lamp_for(aid, db)) @router.get("/recordings", tags=["recordings"]) def list_recordings( diff --git a/backend/routers/charts.py b/backend/routers/charts.py index c2f1b59..68e4b4e 100644 --- a/backend/routers/charts.py +++ b/backend/routers/charts.py @@ -181,9 +181,14 @@ def noaa_nearby(west: float, south: float, east: float, north: float): return matches +import re as _re +_CELL_RE = _re.compile(r'^US[1-5][A-Z]{2}\d{2}M$') + @router.post("/download-noaa/{cell_id}") async def download_noaa(cell_id: str): cell_id = cell_id.upper() + if not _CELL_RE.match(cell_id): + raise HTTPException(400, f"Invalid cell_id format: {cell_id}") url = NOAA_BASE.format(cell=cell_id) log.info("Downloading NOAA ENC %s from %s", cell_id, url) try: @@ -333,35 +338,50 @@ def set_cell_region(cell_id: str, region: str): return {"id": cell_id.upper(), "region": region} +# Chart caches are rebuilt at server startup / explicit /rebuild-cache. +# Between rebuilds, GeoJSON content is stable, so we let the browser cache +# the response for an hour. ETag/304 would be safer but max-age is enough +# for typical operator sessions and avoids redownloading 350 MB of depths. +_CHART_CACHE_HEADERS = {"Cache-Control": "private, max-age=3600"} + + +def _bbox(w, s, e, n): + return (w, s, e, n) if None not in (w, s, e, n) else None + + @router.get("/features") -def chart_features(): - return JSONResponse(get_all_features()) +def chart_features(w: float | None = None, s: float | None = None, + e: float | None = None, n: float | None = None): + return JSONResponse(get_all_features(_bbox(w, s, e, n)), + headers=_CHART_CACHE_HEADERS) @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)) + return JSONResponse(get_all_depths(_bbox(w, s, e, n)), + headers=_CHART_CACHE_HEADERS) @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)) + return JSONResponse(get_all_land(_bbox(w, s, e, n)), + headers=_CHART_CACHE_HEADERS) @router.get("/hazards") -def chart_hazards(): - return JSONResponse(get_all_hazards()) +def chart_hazards(w: float | None = None, s: float | None = None, + e: float | None = None, n: float | None = None): + return JSONResponse(get_all_hazards(_bbox(w, s, e, n)), + headers=_CHART_CACHE_HEADERS) @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)) + return JSONResponse(get_all_zones(_bbox(w, s, e, n)), + headers=_CHART_CACHE_HEADERS) @router.post("/rebuild-cache") diff --git a/backend/routers/lamps.py b/backend/routers/lamps.py index 4fa3c34..0c973bb 100644 --- a/backend/routers/lamps.py +++ b/backend/routers/lamps.py @@ -22,19 +22,23 @@ class LampIn(BaseModel): lamp_count: int = 1 voltage_min: float = Field(..., gt=0) voltage_max: float = Field(..., gt=0) + warn_pct: float = Field(20.0, ge=1, le=50) # % of range → warning + alarm_pct: float = Field(10.0, ge=1, le=50) # % of range → alarm notes: Optional[str] = None -def compute_thresholds(vmin: float, vmax: float) -> dict: +def compute_thresholds(vmin: float, vmax: float, + warn_pct: float = 20.0, alarm_pct: float = 10.0) -> dict: rng = vmax - vmin return { - "warn_v": round(vmin + rng * 0.20, 3), - "alarm_v": round(vmin + rng * 0.10, 3), + "warn_v": round(vmin + rng * (warn_pct / 100), 3), + "alarm_v": round(vmin + rng * (alarm_pct / 100), 3), } def _lamp_dict(l: Lamp) -> dict: - th = compute_thresholds(l.voltage_min, l.voltage_max) + th = compute_thresholds(l.voltage_min, l.voltage_max, + l.warn_pct or 20.0, l.alarm_pct or 10.0) return { "id": l.id, "manufacturer": l.manufacturer, @@ -42,6 +46,8 @@ def _lamp_dict(l: Lamp) -> dict: "lamp_count": l.lamp_count, "voltage_min": l.voltage_min, "voltage_max": l.voltage_max, + "warn_pct": l.warn_pct or 20.0, + "alarm_pct": l.alarm_pct or 10.0, "notes": l.notes, "warn_v": th["warn_v"], "alarm_v": th["alarm_v"], diff --git a/backend/services/__pycache__/__init__.cpython-314.pyc b/backend/services/__pycache__/__init__.cpython-314.pyc deleted file mode 100644 index 9b5377d..0000000 Binary files a/backend/services/__pycache__/__init__.cpython-314.pyc and /dev/null differ diff --git a/backend/services/__pycache__/ais_catcher.cpython-314.pyc b/backend/services/__pycache__/ais_catcher.cpython-314.pyc deleted file mode 100644 index da28101..0000000 Binary files a/backend/services/__pycache__/ais_catcher.cpython-314.pyc and /dev/null differ diff --git a/backend/services/__pycache__/ais_decoder.cpython-314.pyc b/backend/services/__pycache__/ais_decoder.cpython-314.pyc deleted file mode 100644 index f91438b..0000000 Binary files a/backend/services/__pycache__/ais_decoder.cpython-314.pyc and /dev/null differ diff --git a/backend/services/__pycache__/ais_simulator.cpython-314.pyc b/backend/services/__pycache__/ais_simulator.cpython-314.pyc deleted file mode 100644 index 0081889..0000000 Binary files a/backend/services/__pycache__/ais_simulator.cpython-314.pyc and /dev/null differ diff --git a/backend/services/__pycache__/ais_udp_reader.cpython-314.pyc b/backend/services/__pycache__/ais_udp_reader.cpython-314.pyc deleted file mode 100644 index 25520f1..0000000 Binary files a/backend/services/__pycache__/ais_udp_reader.cpython-314.pyc and /dev/null differ diff --git a/backend/services/__pycache__/alert_engine.cpython-314.pyc b/backend/services/__pycache__/alert_engine.cpython-314.pyc deleted file mode 100644 index 8964157..0000000 Binary files a/backend/services/__pycache__/alert_engine.cpython-314.pyc and /dev/null differ diff --git a/backend/services/__pycache__/aton_decoder.cpython-314.pyc b/backend/services/__pycache__/aton_decoder.cpython-314.pyc deleted file mode 100644 index cb1697c..0000000 Binary files a/backend/services/__pycache__/aton_decoder.cpython-314.pyc and /dev/null differ diff --git a/backend/services/__pycache__/chart_manager.cpython-314.pyc b/backend/services/__pycache__/chart_manager.cpython-314.pyc deleted file mode 100644 index e432fd4..0000000 Binary files a/backend/services/__pycache__/chart_manager.cpython-314.pyc and /dev/null differ diff --git a/backend/services/__pycache__/gps_reader.cpython-314.pyc b/backend/services/__pycache__/gps_reader.cpython-314.pyc deleted file mode 100644 index 756570a..0000000 Binary files a/backend/services/__pycache__/gps_reader.cpython-314.pyc and /dev/null differ diff --git a/backend/services/__pycache__/notifier.cpython-314.pyc b/backend/services/__pycache__/notifier.cpython-314.pyc deleted file mode 100644 index 0b1b366..0000000 Binary files a/backend/services/__pycache__/notifier.cpython-314.pyc and /dev/null differ diff --git a/backend/services/__pycache__/settings_store.cpython-314.pyc b/backend/services/__pycache__/settings_store.cpython-314.pyc deleted file mode 100644 index 88af636..0000000 Binary files a/backend/services/__pycache__/settings_store.cpython-314.pyc and /dev/null differ diff --git a/backend/services/alert_engine.py b/backend/services/alert_engine.py index 56ebd40..f3b0657 100644 --- a/backend/services/alert_engine.py +++ b/backend/services/alert_engine.py @@ -119,10 +119,15 @@ def evaluate_vessel(vessel, aids, config): return alerts -def evaluate_aid_movement(aid_id, lat_actual, lon_actual, lat_nominal, lon_nominal, config=None): - config = config or {} - warn_m = config.get("displacement_warn_m", 10.0) - alarm_m = config.get("displacement_alarm_m", 15.0) +def evaluate_aid_movement(aid_id, lat_actual, lon_actual, lat_nominal, lon_nominal, + config=None, warn_m=None, alarm_m=None): + """ + warn_m / alarm_m: per-aid override from Aid.displacement_warn_m / alarm_m. + If None, falls back to global config values. + """ + config = config or {} + warn_m = warn_m if warn_m is not None else config.get("displacement_warn_m", 10.0) + alarm_m = alarm_m if alarm_m is not None else config.get("displacement_alarm_m", 15.0) desplazamiento = haversine(lat_actual, lon_actual, lat_nominal, lon_nominal) en_movimiento = detect_continuous_movement(aid_id, lat_actual, lon_actual) diff --git a/backend/services/aton_decoder.py b/backend/services/aton_decoder.py index 0ae5570..3ee2ae8 100644 --- a/backend/services/aton_decoder.py +++ b/backend/services/aton_decoder.py @@ -113,11 +113,22 @@ def decode_type8_aton(payload: str) -> dict | None: analog3 = _bits(payload, 81, 12) * 0.05 analog4 = _bits(payload, 93, 12) * 0.05 # Digital bits: racon, lamp, buoy code alarm, controller alarm, etc. - racon = bool(_bits(payload, 105, 2)) - light_ok = bool(_bits(payload, 107, 2)) - health = _bits(payload, 109, 2) # 0=ok,1=warn,2=alarm,3=no signal + racon = bool(_bits(payload, 105, 2)) + light_ok = bool(_bits(payload, 107, 2)) + health = _bits(payload, 109, 2) # 0=ok,1=warn,2=alarm,3=no signal battery_low = bool(_bits(payload, 111, 1)) + # IEC 62320-2 extended digital inputs (bits 112–119) + # bit 112 = buoy code / hull integrity → water ingress sensor (IN3) + # bit 113 = controller alarm → listing/tilt sensor (IN4) + # bit 114 = fog signal status + # bit 115 = EPIRB armed + # bit 116 = water level alarm → bilge high (critical) + # bits 117-119 = spare / manufacturer-defined + din3 = bool(_bits(payload, 112, 1)) # hull/water ingress + din4 = bool(_bits(payload, 113, 1)) # listing/tilt + water_level = bool(_bits(payload, 116, 1)) # bilge high level + return { "mmsi": str(mmsi), "msg_type": 8, @@ -132,6 +143,11 @@ def decode_type8_aton(payload: str) -> dict | None: "light_ok": light_ok, "health": health, "battery_low": battery_low, + # Digital inputs — meaning depends on din3_function / din4_function + # configured per-aid in the Aid model + "din3": din3, # IN3 state (True = triggered) + "din4": din4, # IN4 state (True = triggered) + "water_level_high": water_level, # bilge high level (IEC standard bit) "timestamp": datetime.utcnow().isoformat(), } except Exception: diff --git a/backend/services/chart_manager.py b/backend/services/chart_manager.py index 21bbb7a..1f4e45d 100644 --- a/backend/services/chart_manager.py +++ b/backend/services/chart_manager.py @@ -1143,12 +1143,79 @@ def list_cells() -> list[dict]: def delete_cell(cell_id: str): - cell_dir = CHARTS_DIR / cell_id.upper() + cell_dir = (CHARTS_DIR / cell_id.upper()).resolve() + if CHARTS_DIR.resolve() not in cell_dir.parents: + raise ValueError(f"Invalid cell_id: {cell_id}") if cell_dir.exists(): shutil.rmtree(cell_dir) -def get_all_features() -> dict: +# Per-(cell, file) coverage bbox cache. Populated lazily the first time a +# cell's GeoJSON file is read; subsequent bbox queries can skip the file +# entirely if its coverage doesn't intersect the query bbox. Keyed by the +# absolute path of the cache file. Invalidated implicitly on cache rebuild +# because rebuilt files get a fresh mtime, which we include in the key. +_cell_coverage_cache: dict[str, tuple[float, float, float, float]] = {} + + +def _coverage_bbox(features: list) -> tuple[float, float, float, float] | None: + """Return (min_lon, min_lat, max_lon, max_lat) covering all features. + Falls back to None when no coordinates are extractable.""" + min_lon = min_lat = float("inf") + max_lon = max_lat = float("-inf") + found = False + for f in features: + # Prefer the explicit per-feature bbox if the build wrote one + fb = (f.get("properties") or {}).get("bbox") + if fb and len(fb) == 4: + min_lon = min(min_lon, fb[0]); min_lat = min(min_lat, fb[1]) + max_lon = max(max_lon, fb[2]); max_lat = max(max_lat, fb[3]) + found = True + continue + geom = f.get("geometry") or {} + gt = geom.get("type") + coords = geom.get("coordinates") + if not coords: + continue + # Fast path for Point — by far the most common + if gt == "Point": + lon, lat = coords[0], coords[1] + min_lon = min(min_lon, lon); max_lon = max(max_lon, lon) + min_lat = min(min_lat, lat); max_lat = max(max_lat, lat) + found = True + else: + # Walk the nested coordinate arrays (LineString, Polygon, Multi*) + stack = [coords] + while stack: + x = stack.pop() + if isinstance(x, (list, tuple)) and len(x) >= 2 and not isinstance(x[0], (list, tuple)): + lon, lat = x[0], x[1] + if isinstance(lon, (int, float)) and isinstance(lat, (int, float)): + min_lon = min(min_lon, lon); max_lon = max(max_lon, lon) + min_lat = min(min_lat, lat); max_lat = max(max_lat, lat) + found = True + elif isinstance(x, (list, tuple)): + stack.extend(x) + return (min_lon, min_lat, max_lon, max_lat) if found else None + + +def _feature_in_bbox(feat: dict, w: float, s: float, e: float, n: float) -> bool: + """Spatial filter: return True iff the feature intersects (w,s,e,n). + Prefers a pre-computed properties.bbox, falls back to Point geometry.""" + fb = (feat.get("properties") or {}).get("bbox") + if fb and len(fb) == 4: + return not (fb[2] < w or fb[0] > e or fb[3] < s or fb[1] > n) + geom = feat.get("geometry") or {} + if geom.get("type") == "Point": + c = geom.get("coordinates") or [None, None] + if c[0] is None or c[1] is None: + return True # malformed — keep, don't lose it + return w <= c[0] <= e and s <= c[1] <= n + # No bbox + non-point geometry — keep it (better to render than to lose). + return True + + +def get_all_features(bbox: tuple[float, float, float, float] | None = None) -> dict: all_features = [] for cell_dir in CHARTS_DIR.iterdir(): cache = cell_dir / "features.geojson" @@ -1167,30 +1234,60 @@ def get_all_features() -> dict: # Backfill aid_type for old caches that pre-date the classifier. if "aid_type" not in p: p["aid_type"] = classify(p.get("layer", ""), p) - all_features.extend(fc["features"]) + if bbox is not None and not _feature_in_bbox(f, *bbox): + continue + all_features.append(f) return {"type": "FeatureCollection", "features": all_features} def _aggregate_cache(filename: str, bbox=None) -> dict: - """Generic aggregator: read from every installed cell.""" + """Generic aggregator: read from every installed cell. + Uses a per-file coverage-bbox cache to skip cells that don't intersect + the query bbox without reading their GeoJSON content.""" all_features = [] + w = s = e = n = None if bbox is not None: w, s, e, n = bbox for cell_dir in CHARTS_DIR.iterdir(): + if not cell_dir.is_dir(): + continue cache = cell_dir / filename if not cache.exists(): continue + + # Pre-skip via cached cell-coverage bbox. Key includes mtime so + # the entry self-invalidates when the file is rebuilt. + if bbox is not None: + try: + mtime = cache.stat().st_mtime + except OSError: + mtime = 0 + cov_key = f"{cache}|{mtime}" + cov = _cell_coverage_cache.get(cov_key) + if cov is not None: + if cov[2] < w or cov[0] > e or cov[3] < s or cov[1] > n: + continue # cell entirely outside query — skip file open + try: fc = json.loads(cache.read_text()) except Exception: continue - for f in (fc.get("features") or []): + + feats = fc.get("features") or [] + # Populate coverage cache on first read so subsequent bbox queries + # can short-circuit. + if bbox is not None and cov_key not in _cell_coverage_cache: + cov2 = _coverage_bbox(feats) + if cov2 is not None: + _cell_coverage_cache[cov_key] = cov2 + if cov2[2] < w or cov2[0] > e or cov2[3] < s or cov2[1] > n: + continue # just learned: cell outside query + + for f in feats: p = f.setdefault("properties", {}) p["cell"] = cell_dir.name - if bbox is not None: - fb = p.get("bbox") - if fb and (fb[2] < w or fb[0] > e or fb[3] < s or fb[1] > n): - continue + if bbox is not None and not _feature_in_bbox(f, w, s, e, n): + continue all_features.append(f) return {"type": "FeatureCollection", "features": all_features} @@ -1201,8 +1298,8 @@ def get_all_depths(bbox: tuple[float, float, float, float] | None = None) -> dic def get_all_land(bbox: tuple[float, float, float, float] | None = None) -> dict: return _aggregate_cache("land.geojson", bbox) -def get_all_hazards() -> dict: - return _aggregate_cache("hazards.geojson") +def get_all_hazards(bbox: tuple[float, float, float, float] | None = None) -> dict: + return _aggregate_cache("hazards.geojson", bbox) def get_all_zones(bbox: tuple[float, float, float, float] | None = None) -> dict: return _aggregate_cache("zones.geojson", bbox) diff --git a/backend/services/settings_store.py b/backend/services/settings_store.py index b8d8f5e..70f563e 100644 --- a/backend/services/settings_store.py +++ b/backend/services/settings_store.py @@ -53,6 +53,16 @@ DEFAULTS: dict = { "smtp_from": "", "smtp_from_name": "AidsMonitoring", "smtp_use_tls": True, + # ── Cluster / multi-server role ────────────────────────────────────────── + # STANDALONE: single server (default) + # MASTER : central aggregator — accepts connections from slave servers + # SLAVE : field server — forwards all events to the master + "server_role": os.getenv("SERVER_ROLE", "STANDALONE"), + # URL of the master's slave WebSocket endpoint (required when SLAVE) + # Example: "ws://10.0.0.1:8000/ws/slave" + "master_url": os.getenv("MASTER_URL", ""), + # Human-readable name for this slave (shown in master's status panel) + "slave_name": os.getenv("SLAVE_NAME", ""), } SETTINGS: dict = dict(DEFAULTS) diff --git a/backend/services/slave_relay.py b/backend/services/slave_relay.py new file mode 100644 index 0000000..ec67325 --- /dev/null +++ b/backend/services/slave_relay.py @@ -0,0 +1,177 @@ +""" +AidsMonitoring — Slave relay service +===================================== +When server_role == "SLAVE", this service maintains a persistent WebSocket +connection to the master server and forwards all AIS/ATON/aid_position/alert +events upstream. + +The relay is fully non-blocking: + - Events are enqueued from broadcast() via send() (never blocks) + - A single asyncio task drains the queue and writes to the master WS + - If the master is unreachable, a ring-buffer keeps the last max_queue + messages; older events are silently dropped + - On reconnect the slave sends a hello so the master can log it + +Usage (from main.py): + relay = SlaveRelay(master_url="ws://10.0.0.1:8000/ws/slave", + slave_name="Puerto Barranquilla") + await relay.start() + ... + relay.send({"type": "vessel", "mmsi": "012345678", ...}) + ... + await relay.stop() +""" +from __future__ import annotations + +import asyncio +import json +import logging +from collections import deque + +log = logging.getLogger("aids.slave_relay") + + +class SlaveRelay: + """Non-blocking WebSocket relay: slave → master.""" + + def __init__( + self, + master_url: str, + slave_name: str, + max_queue: int = 500, + reconnect_delay: float = 5.0, + ): + """ + Parameters + ---------- + master_url WebSocket URL of master's slave endpoint. + e.g. "ws://192.168.1.10:8000/ws/slave" + slave_name Human-readable identifier sent in hello message. + e.g. "Puerto Barranquilla" + max_queue Ring-buffer capacity. Oldest events dropped when full. + reconnect_delay Seconds to wait before reconnecting after a disconnect. + """ + self.master_url = master_url + self.slave_name = slave_name + self._max_queue = max_queue + self._reconnect_delay = reconnect_delay + + self._queue: deque = deque(maxlen=max_queue) + self._task: asyncio.Task | None = None + self._running = False + self._connected = False + self._connect_count = 0 # total successful connections (for logging) + + # ── Public API ────────────────────────────────────────────────────────── + + async def start(self): + """Start the background relay task.""" + if self._task and not self._task.done(): + return # already running + self._running = True + self._task = asyncio.create_task(self._run(), name="slave_relay") + log.info(f"[slave] Relay iniciado → {self.master_url} (nombre='{self.slave_name}')") + + async def stop(self): + """Cancel the relay task and wait for it to finish.""" + self._running = False + if self._task and not self._task.done(): + self._task.cancel() + try: + await self._task + except asyncio.CancelledError: + pass + self._task = None + self._connected = False + log.info("[slave] Relay detenido.") + + def send(self, msg: dict): + """ + Enqueue an event for forwarding. Non-blocking — safe to call from + broadcast() in the main event loop. Drops oldest if queue is full. + """ + if not self._running: + return + # Tag every message with slave origin so the master knows the source + tagged = {**msg, "_slave": self.slave_name} + self._queue.append(tagged) + + # ── Status ─────────────────────────────────────────────────────────────── + + @property + def connected(self) -> bool: + return self._connected + + @property + def queue_len(self) -> int: + return len(self._queue) + + def status(self) -> dict: + return { + "running": self._running, + "connected": self._connected, + "master_url": self.master_url, + "slave_name": self.slave_name, + "queue_len": len(self._queue), + "connect_count": self._connect_count, + } + + # ── Background task ────────────────────────────────────────────────────── + + async def _run(self): + import websockets + + while self._running: + try: + log.info(f"[slave] Conectando a maestro {self.master_url} …") + async with websockets.connect( + self.master_url, + ping_interval=20, + ping_timeout=10, + close_timeout=5, + open_timeout=10, + ) as ws: + self._connected = True + self._connect_count += 1 + log.info( + f"[slave] Conectado al maestro " + f"(conexión #{self._connect_count}, " + f"{len(self._queue)} msgs en cola)" + ) + + # ── Announce this slave ─────────────────────────────── + await ws.send(json.dumps({ + "type": "slave_hello", + "slave_name": self.slave_name, + "version": "1.0", + })) + + # ── Drain queue continuously ────────────────────────── + while self._running: + if self._queue: + msg = self._queue.popleft() + try: + await ws.send(json.dumps(msg)) + except Exception as send_err: + # Re-queue the failed message (prepend) + self._queue.appendleft(msg) + log.warning( + f"[slave] Error al enviar: {send_err}" + ) + break # force reconnect + else: + # Nothing to send — yield to event loop + await asyncio.sleep(0.02) # 20 ms idle + + except asyncio.CancelledError: + break + except Exception as conn_err: + log.warning( + f"[slave] Desconectado ({conn_err}). " + f"Reintentando en {self._reconnect_delay} s …" + ) + finally: + self._connected = False + + if self._running: + await asyncio.sleep(self._reconnect_delay) diff --git a/frontend/css/main.css b/frontend/css/main.css index 2a63b3a..22eb600 100644 --- a/frontend/css/main.css +++ b/frontend/css/main.css @@ -227,6 +227,26 @@ body { color: #fff; } +/* Small select controls in the toolbar (trail window, vector mode/time) */ +.tb-select { + background: var(--bg-base); + border: 1px solid var(--border); + color: var(--text-secondary); + border-radius: 2px; + font-size: 0.62rem; + font-family: var(--sans); + font-weight: 500; + letter-spacing: 0.5px; + padding: 2px 4px; + height: 24px; + cursor: pointer; + outline: none; + transition: border-color 0.15s; +} +.tb-select:hover { border-color: var(--border-light); color: var(--text-primary); } +.tb-select:focus { border-color: var(--accent); } +.tb-select option { background: var(--bg-panel2); color: var(--text-primary); } + #map { flex: 1; } #map-coords { @@ -413,7 +433,7 @@ body { } .field-label { - font-size: 0.54rem; + font-size: 0.68rem; letter-spacing: 1.5px; text-transform: uppercase; color: var(--text-muted); @@ -421,7 +441,7 @@ body { } .field-value { - font-size: 0.76rem; + font-size: 0.92rem; color: var(--text-primary); font-family: var(--mono); } @@ -430,19 +450,19 @@ body { background: var(--bg-base); border: 1px solid var(--border); border-radius: 3px; - padding: 4px 8px; + padding: 6px 10px; font-family: var(--mono); - font-size: 0.7rem; + font-size: 0.88rem; color: var(--cyan); - line-height: 1.35; - margin-bottom: 6px; + line-height: 1.4; + margin-bottom: 8px; } .coords-block .label { - font-size: 0.54rem; + font-size: 0.68rem; color: var(--text-muted); letter-spacing: 1.5px; text-transform: uppercase; - margin-bottom: 1px; + margin-bottom: 2px; font-family: var(--sans); } @@ -1460,3 +1480,27 @@ html.night .ol-zoom button { } .aton-ok { color: var(--green); } .aton-warn { color: var(--yellow); font-weight: 600; } + +/* ── Battery history chart ───────────────────────────────────────────────── */ +.batt-chart-hdr { + display: flex; align-items: center; + justify-content: space-between; margin-bottom: 6px; +} +.batt-range-btns { display: flex; gap: 4px; } +.batt-rb { + background: transparent; border: 1px solid var(--border); + color: var(--text-secondary); border-radius: 2px; + font-size: 0.6rem; font-family: var(--mono); + padding: 2px 6px; cursor: pointer; transition: all .15s; +} +.batt-rb:hover { border-color: var(--border-light); color: var(--text-primary); } +.batt-rb.active { background: var(--accent-dim); border-color: var(--accent); color: #fff; } +#batt-chart-wrap { margin-bottom: 8px; } +#batt-chart-svg { width: 100%; } +.batt-stats { + display: flex; flex-wrap: wrap; gap: 6px 12px; + font-size: 0.68rem; color: var(--text-muted); + margin-top: 4px; font-family: var(--mono); +} +.batt-stat { color: var(--text-secondary); } +.batt-stat-eta { color: var(--text-secondary); } diff --git a/frontend/index.html b/frontend/index.html index c471f0f..6c5aab8 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -4,6 +4,14 @@ AidsMonitoring — Maritime Traffic System + @@ -144,6 +152,29 @@
+
+ AIS + + +
+ + +
LAT -- LON --
@@ -272,6 +303,11 @@
Click DOWNLOAD to fetch directly from NOAA servers and install. No manual download needed. + + ↗ NOAA ENC Portal +
diff --git a/frontend/js/auth.js b/frontend/js/auth.js index 6d9f94d..1c162c0 100644 --- a/frontend/js/auth.js +++ b/frontend/js/auth.js @@ -171,14 +171,31 @@ const Modal = { btn.textContent = 'SAVING...'; status.textContent = ''; + // MMSI: empty string → unassign (sends "" so backend nulls the column). + // Non-empty → must be digits-only; backend enforces uniqueness across aids. + const mmsiVal = v('ef-mmsi'); + if (mmsiVal && !/^\d{6,9}$/.test(mmsiVal)) { + status.textContent = 'MMSI must be 6–9 digits, or blank to unassign.'; + status.className = 'save-status err'; + btn.disabled = false; btn.textContent = 'SAVE CHANGES'; + return; + } + const payload = { puerto_responsable: v('ef-puerto') || null, empresa_responsable: v('ef-empresa') || null, caracteristica_luz: v('ef-luz') || null, alcance_nm: flt('ef-alcance'), radio_borneo_m: flt('ef-borneo'), - observaciones: v('ef-obs') || null, - lamp_id: v('ef-lamp') || null, + displacement_warn_m: flt('ef-drift-warn'), + displacement_alarm_m: flt('ef-drift-alarm'), + signal_loss_min: flt('ef-signal-loss') ? parseInt(v('ef-signal-loss')) : null, + din3_function: v('ef-din3') || null, + din4_function: v('ef-din4') || null, + observaciones: v('ef-obs') || null, + lamp_id: v('ef-lamp') || null, + mmsi: mmsiVal, // "" unassigns; backend handles + tipo_ais: mmsiVal ? 'ATON_21' : 'SIN_AIS', modificado_por: Auth.session.nombre, motivo_cambio: motivo, }; @@ -273,6 +290,24 @@ function buildEditForm(p) { ` : ''; + // AIS link — when set, AIS Type 21 with this MMSI updates the buoy's + // lat_actual (ghost marker) and triggers drift/battery alerts using this + // aid's per-buoy thresholds. + const aisBlock = ` + +
+ MMSI of the AtoN transponder on this buoy. + Once set, the system listens for AIS Type 21 with this MMSI and shows + the AIS-reported position as a transparent ghost over the nominal marker. + Leave blank if the buoy has no AIS. +
+
+ + +
`; + return ` @@ -327,12 +362,56 @@ function buildEditForm(p) { value="${p.radio_borneo_m ?? 10}"> + +
+ Leave blank to use global settings. Override per buoy based on anchor chain length and operating area. +
+
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+
+ ${aisBlock} + ${nominalBlock} diff --git a/frontend/js/map.js b/frontend/js/map.js index d71e244..a655fa9 100644 --- a/frontend/js/map.js +++ b/frontend/js/map.js @@ -32,10 +32,18 @@ const seaMapLayer = new ol.layer.Tile({ const vesselsSource = new ol.source.Vector(); const aidsSource = new ol.source.Vector(); +// Ghost layer: AIS-reported actual position when it differs from nominal. +// Rendered translucent so the nominal marker stays the "well-marked" one. +const aisGhostSource = new ol.source.Vector(); window.vesselsSource = vesselsSource; const vesselsLayer = new ol.layer.Vector({ source: vesselsSource, zIndex: 10, declutter: true }); const aidsLayer = new ol.layer.Vector({ source: aidsSource, zIndex: 20, declutter: true }); +const aisGhostLayer = new ol.layer.Vector({ + source: aisGhostSource, + zIndex: 21, // sits just above the nominal aid so it's visible + declutter: false, +}); // ── S-57 ENC vector layer ───────────────────────────────────────────────── const encSource = new ol.source.Vector(); @@ -1755,9 +1763,10 @@ function encStyle(feature, resolution) { // Compute zoom from resolution (deterministic, no map object access needed). // OL WebMercator: resolution ≈ 156543 / 2^zoom → zoom = log2(156543/res) const _zr = resolution ? Math.log2(156543.03392 / Math.max(resolution, 0.001)) : 14; - // Scale: 0.14 at zoom 6, grows to 0.52 at zoom 14+. + // Scale: 0.168 at zoom 6, grows to 0.624 at zoom 14+. (~20 % bigger than + // the previous 0.14–0.52 range to improve readability in busy harbours.) // Smooth curve — symbols shrink as user zooms out instead of vanishing. - const iconScale = Math.max(0.14, Math.min(0.52, 0.14 + (_zr - 6) * 0.0475)); + const iconScale = Math.max(0.168, Math.min(0.624, 0.168 + (_zr - 6) * 0.057)); // Position dot — small circle rendered at the exact geographic coordinate. // Anchor is [0.5, 1.0] so the dot sits precisely at the buoy's lat/lon. @@ -1775,14 +1784,8 @@ function encStyle(feature, resolution) { scale: iconScale, anchor: [0.5, 1.0], anchorXUnits: 'fraction', anchorYUnits: 'fraction', }), - text: label ? new ol.style.Text({ - text: label, - offsetY: 8, - font: '600 9px "Inter", "Segoe UI", sans-serif', - fill: new ol.style.Fill({ color: '#0a1a2e' }), - stroke: new ol.style.Stroke({ color: 'rgba(255,255,255,0.92)', width: 2.5 }), - overflow: true, - }) : undefined, + // Persistent label removed — hover tooltip shows name + light + range. + // Avoids visual clutter at busy harbours like BAQ / CTG. }); return [iconStyle, dotStyle]; @@ -2251,7 +2254,7 @@ window.loadChartZones(); const map = new ol.Map({ target: 'map', - layers: [osmLayer, oceanRefLayer, depthLayer, landLayer, zoneLayer, seaMapLayer, encLayer, hazardLayer, soundLayer, vesselsLayer, aidsLayer], + layers: [osmLayer, oceanRefLayer, depthLayer, landLayer, zoneLayer, seaMapLayer, encLayer, hazardLayer, soundLayer, vesselsLayer, aidsLayer, aisGhostLayer], view: new ol.View({ center: ol.proj.fromLonLat([MAP_CENTER_LON, MAP_CENTER_LAT]), zoom: MAP_ZOOM, @@ -2262,6 +2265,50 @@ const map = new ol.Map({ // ── Estilos ──────────────────────────────────────────────────────────────── +// ── Geo helpers ─────────────────────────────────────────────────────────── + +// Compact nautical lat/lon e.g. 10°31.2'N 74°48.3'W +function _fmtLL(lat, lon) { + const ld = Math.floor(Math.abs(lat)), lm = (Math.abs(lat) - ld) * 60; + const od = Math.floor(Math.abs(lon)), om = (Math.abs(lon) - od) * 60; + return `${ld}°${lm.toFixed(1)}'${lat >= 0 ? 'N' : 'S'} ${od}°${om.toFixed(1)}'${lon >= 0 ? 'E' : 'W'}`; +} + +// CPA / TCPA of a moving vessel vs a stationary point (AtoN or own position). +// Returns { cpaNM, tcpaMin } or null if vessel is not moving or CPA is behind. +function _computeCPA(vesLat, vesLon, sogKt, cogDeg, refLat, refLon) { + if (!sogKt || sogKt < 0.1) return null; + const mPDLat = 111320; + const mPDLon = 111320 * Math.cos(vesLat * Math.PI / 180); + const dx = (vesLon - refLon) * mPDLon; // metres east + const dy = (vesLat - refLat) * mPDLat; // metres north + const spd = sogKt * 0.514444; // m/s + const h = cogDeg * Math.PI / 180; + const vx = spd * Math.sin(h); + const vy = spd * Math.cos(h); + const v2 = vx * vx + vy * vy; + const tcpaSec = -(dx * vx + dy * vy) / v2; + const cpax = dx + tcpaSec * vx; + const cpay = dy + tcpaSec * vy; + return { + cpaNM: Math.sqrt(cpax * cpax + cpay * cpay) / 1852, + tcpaMin: tcpaSec / 60, + }; +} + +// ── Estimated dimensions by AIS ship type — used when Type 5/24 hasn't arrived yet. +// Values are representative midpoints for each class (L×B in metres). +function _estimateDims(tipo) { + if (tipo >= 80 && tipo < 90) return { length: 180, beam: 28 }; // tanker + if (tipo >= 70 && tipo < 80) return { length: 150, beam: 22 }; // cargo + if (tipo >= 60 && tipo < 70) return { length: 120, beam: 20 }; // passenger + if (tipo >= 40 && tipo < 50) return { length: 40, beam: 10 }; // HSC + if (tipo === 52 || tipo === 21) return { length: 35, beam: 10 };// tug + if (tipo === 30) return { length: 25, beam: 8 }; // fishing + if (tipo === 36 || tipo === 37) return { length: 15, beam: 5 };// sailing + return { length: 60, beam: 12 }; // default +} + // Color escala por LOA (matching AR ECDIS) — la longitud es el dato útil // para evitar colisiones; el tipo AIS es categoría administrativa. function vesselColorByLength(L) { @@ -2320,7 +2367,40 @@ function vesselStyle(feature, resolution) { const geom = feature.getGeometry(); const isPoly = geom && geom.getType() === 'Polygon'; - const labelText = feature.get('nombre') || feature.get('mmsi'); + const name = feature.get('nombre') || String(feature.get('mmsi') || ''); + const sogV = feature.get('sog'); + const cogV = feature.get('cog'); + const latV = feature.get('lat'); + const lonV = feature.get('lon'); + const tipNom = feature.get('tipo_nombre') || shipTypeName(feature.get('tipo') || 0); + const cpaNM = feature.get('cpa_nm'); + const tcpaM = feature.get('tcpa_min'); + + // Multi-line label — richer content at closer zoom (res < 100 m/px ≈ zoom 12) + const detailed = resolution != null && resolution < 100; + let labelText = name; + const mmsiV = feature.get('mmsi'); + const banderaV = feature.get('bandera'); + const flagV = banderaV ? flagEmoji(banderaV) : ''; + if (detailed) { + // L1: nombre [MMSI] + const l1 = mmsiV ? `${name} [${mmsiV}]` : name; + // L2: bandera (si está disponible) + const l2 = flagV ? `${flagV} ${banderaV}` : ''; + // L3: tipo · SOG · COG + const l3 = `${tipNom} ${sogV != null ? sogV.toFixed(1)+'kn' : '--'} ${cogV != null ? Math.round(cogV)+'°' : '--'}`; + // L4: posición náutica + const l4 = (latV != null && lonV != null) ? _fmtLL(latV, lonV) : ''; + // L5: CPA/TCPA (solo si se acerca) + const l5 = (cpaNM != null && tcpaM != null && tcpaM > 0) + ? `CPA ${cpaNM.toFixed(2)}NM TCPA ${tcpaM.toFixed(0)}min` + : ''; + labelText = [l1, l2, l3, l4, l5].filter(Boolean).join('\n'); + } else { + labelText = (sogV > 0.3 && cogV != null) + ? `${name}\n${sogV.toFixed(1)}kn ${Math.round(cogV)}°` + : name; + } const styles = []; if (isPoly) { @@ -2488,15 +2568,17 @@ function aidStyle(feature) { let canvas; if (tipo === 'FARO') { + // S-52 lighthouse symbol: building body + star + magenta flash canvas = _aidIcon(`faro_${stat}`, () => { - const c = _drawLighthouse('#f9a825', 28); - _stampStatus(c, enPos, enMov); return c; + const base = _ialaLight(1); // white light (code 1) + _stampStatus(base, enPos, enMov); return base; }); } else if (tipo === 'FAROL') { + // Smaller lighthouse (farol de costa / sector light) canvas = _aidIcon(`farol_${stat}`, () => { - const c = _drawLighthouse('#f9a825', 22); - _stampStatus(c, enPos, enMov); return c; + const base = _ialaLight(1); + _stampStatus(base, enPos, enMov); return base; }); } else if (tipo === 'BOYA_LATERAL') { @@ -2557,18 +2639,29 @@ function aidStyle(feature) { canvas = _aidIcon(`aid_gen_${stat}`, () => _drawDiamond(fill, stroke)); } + // ── Aid label: nombre / tipo · pos actual / desplazamiento ───────────── + const tipoDisp = tipo.replace(/_/g, ' '); + const latA = feature.get('lat_actual') ?? feature.get('lat_nominal'); + const lonA = feature.get('lon_actual') ?? feature.get('lon_nominal'); + const dispM = feature.get('desplazamiento_m') || 0; + const posLine = (latA != null && lonA != null) ? `${tipoDisp} ${_fmtLL(latA, lonA)}` : tipoDisp; + const dispLine = dispM > 2 ? `Despl: ${dispM.toFixed(0)} m` : ''; + const aidLabel = [nombre, posLine, dispLine].filter(Boolean).join('\n'); + return new ol.style.Style({ image: new ol.style.Icon({ img: canvas, imgSize: [canvas.width, canvas.height], scale: 1, anchor: [0.5, 1.0], anchorXUnits: 'fraction', anchorYUnits: 'fraction', }), - text: new ol.style.Text({ - text: nombre, - offsetY: 8, - font: '500 9px Inter, sans-serif', - fill: new ol.style.Fill({ color: '#cbd5e1' }), - stroke: new ol.style.Stroke({ color: '#030810', width: 3 }), - }), + // Sin label persistente: el tooltip de hover ya muestra nombre + características. + // Solo se renderiza un indicador rojo si la boya tiene desplazamiento crítico. + text: dispM > 15 ? new ol.style.Text({ + text: `Despl ${dispM.toFixed(0)} m`, + offsetY: 14, + font: 'bold 9px Inter, sans-serif', + fill: new ol.style.Fill({ color: '#fca5a5' }), + stroke: new ol.style.Stroke({ color: 'rgba(3,8,16,0.55)', width: 2 }), + }) : undefined, }); } @@ -2578,19 +2671,17 @@ window.updateVessel = function(data) { let feature = vesselsSource.getFeatureById(data.mmsi); const headingForGeom = data.heading ?? data.cog ?? 0; - // Build silhouette polygon if we have dimensions, fall back to Point. - let geometry; - if (data.length && data.beam) { - const ringLL = _computeShipPolygon( - data.lat, data.lon, headingForGeom, - data.length, data.beam, - data.to_bow, data.to_stern, data.to_port, data.to_starboard - ); - const ring = ringLL.map(c => ol.proj.fromLonLat(c)); - geometry = new ol.geom.Polygon([ring]); - } else { - geometry = new ol.geom.Point(ol.proj.fromLonLat([data.lon, data.lat])); - } + // Always draw a silhouette polygon. Use reported dimensions (Type 5/24) when + // available; fall back to type-estimated dims so all targets show at real scale. + const _dims = (data.length && data.beam) + ? { length: data.length, beam: data.beam } + : _estimateDims(data.tipo || 0); + const ringLL = _computeShipPolygon( + data.lat, data.lon, headingForGeom, + _dims.length, _dims.beam, + data.to_bow, data.to_stern, data.to_port, data.to_starboard + ); + const geometry = new ol.geom.Polygon([ringLL.map(c => ol.proj.fromLonLat(c))]); if (!feature) { feature = new ol.Feature({ geometry }); @@ -2600,6 +2691,20 @@ window.updateVessel = function(data) { feature.setGeometry(geometry); } + // CPA/TCPA to nearest monitored AtoN ───────────────────────────────────── + let bestCPA = null, bestAtonName = null, bestCPAdist = Infinity; + aidsSource.forEachFeature(af => { + const aLat = af.get('lat_actual') ?? af.get('lat_nominal'); + const aLon = af.get('lon_actual') ?? af.get('lon_nominal'); + if (aLat == null || aLon == null) return; + const res = _computeCPA(data.lat, data.lon, data.sog || 0, data.cog || 0, aLat, aLon); + if (res && res.cpaNM < bestCPAdist) { + bestCPAdist = res.cpaNM; + bestCPA = res; + bestAtonName = af.get('nombre') || af.get('id') || ''; + } + }); + feature.setProperties({ featureType: 'vessel', mmsi: data.mmsi, nombre: data.nombre, @@ -2616,20 +2721,73 @@ window.updateVessel = function(data) { nav_status: data.nav_status, nav_status_name: data.nav_status_name, rot: data.rot, fix_type: data.fix_type, destino: data.destino, timestamp: data.timestamp, + // CPA/TCPA to nearest AtoN + cpa_nm: bestCPA ? +bestCPA.cpaNM.toFixed(2) : null, + tcpa_min: bestCPA ? +bestCPA.tcpaMin.toFixed(1) : null, + cpa_aton: bestAtonName, }); // Pass the function so OL re-evaluates per resolution (silhouette ↔ icon). feature.setStyle(vesselStyle); recordPosition(data.mmsi, data.lat, data.lon); + _updateVector(data.mmsi, data.lat, data.lon, data.sog || 0, data.cog || 0); const n = vesselsSource.getFeatures().length; document.getElementById('vessel-count').textContent = n; }; +// Sync the AIS-reported ghost marker for an aid. Shown only when the AIS +// position differs from nominal by more than ~5 m (avoids ghost shimmer for +// boats sitting on the chain swing centre). Style is the same icon as the +// nominal marker but drawn with reduced opacity. +function _syncAisGhost(data, nominalFeature) { + const ghostId = 'ghost_' + data.id; + const existing = aisGhostSource.getFeatureById(ghostId); + const la = data.lat_actual, lo = data.lon_actual; + // Hide ghost when no AIS fix yet, or when fix is essentially on nominal. + const HIDE_THRESHOLD_M = 5; + const disp = data.desplazamiento_m; + if (la == null || lo == null || (typeof disp === 'number' && disp < HIDE_THRESHOLD_M)) { + if (existing) aisGhostSource.removeFeature(existing); + return; + } + const coord = ol.proj.fromLonLat([lo, la]); + let f = existing; + if (!f) { + f = new ol.Feature({ geometry: new ol.geom.Point(coord) }); + f.setId(ghostId); + aisGhostSource.addFeature(f); + } else { + f.getGeometry().setCoordinates(coord); + } + // Copy aid props so aidStyle picks the right icon, then apply translucent + // version of that style. + f.setProperties({ ...data, featureType: 'aid', _ghost: true }); + const base = aidStyle(nominalFeature); + const img = base.getImage(); + const ghostStyle = new ol.style.Style({ + image: new ol.style.Icon({ + img: img.getImage(), + imgSize: [img.getImage().width, img.getImage().height], + scale: img.getScale(), + anchor: [0.5, 1.0], + opacity: 0.45, + }), + }); + f.setStyle(ghostStyle); +} + window.updateAid = function(data) { let feature = aidsSource.getFeatureById(data.id); - const lat = data.lat_actual ?? data.lat_nominal; - const lon = data.lon_actual ?? data.lon_nominal; - const coord = ol.proj.fromLonLat([lon, lat]); + // Main feature stays anchored at lat_nominal — "where the buoy should be" + // (well-marked, opaque). The AIS-reported position rides on a separate + // translucent ghost feature when it differs from nominal. + // aid_position WS broadcasts only carry actual lat/lon (no nominal), so + // fall back to the feature's existing nominal when nominal isn't in the + // payload. If nothing is known yet, bail — we'll redraw on next init/event. + const latNom = data.lat_nominal ?? feature?.get('lat_nominal'); + const lonNom = data.lon_nominal ?? feature?.get('lon_nominal'); + if (latNom == null || lonNom == null) return; + const coord = ol.proj.fromLonLat([lonNom, latNom]); if (!feature) { feature = new ol.Feature({ geometry: new ol.geom.Point(coord) }); @@ -2639,9 +2797,19 @@ window.updateAid = function(data) { feature.getGeometry().setCoordinates(coord); } - feature.setProperties({ featureType: 'aid', ...data }); + // Merge new fields without losing nominal coords from previous renders. + const merged = { ...feature.getProperties(), ...data, + lat_nominal: latNom, lon_nominal: lonNom, + featureType: 'aid' }; + feature.setProperties(merged); feature.setStyle(aidStyle(feature)); + // ── Ghost AIS marker ──────────────────────────────────────────────────── + // Show a transparent overlay at lat_actual when AIS reported a position + // that differs noticeably from nominal. Hidden when actual ≈ nominal so + // the operator sees a single opaque marker on stable buoys. + _syncAisGhost(merged, feature); + // Limpiar símbolo solo si volvió a posición Y no hay alerta activa registrada if (data.en_posicion === true && !data.en_movimiento) { const id = data.id; @@ -2774,8 +2942,7 @@ function showInfoPanel(p) { // ── Vessel info panel — full AIS data (Type 1/2/3 + 5 + 24) ─────────────── function renderVesselInfo(p) { const lat = p.lat, lon = p.lon; - const latStr = lat != null ? `${Math.abs(lat).toFixed(5)}° ${lat >= 0 ? 'N' : 'S'}` : '--'; - const lonStr = lon != null ? `${Math.abs(lon).toFixed(5)}° ${lon >= 0 ? 'E' : 'W'}` : '--'; + const posStr = (lat != null && lon != null) ? _fmtLL(lat, lon) : '--'; const flagTxt = p.bandera ? `${p.bandera} ${flagEmoji(p.bandera)}` : '--'; const navStatus = p.nav_status_name || (p.nav_status != null ? `Status ${p.nav_status}` : '--'); const navColor = navStatusColor(p.nav_status); @@ -2788,7 +2955,7 @@ function renderVesselInfo(p) {
POSITION
- ${latStr}   ${lonStr} + ${posStr}
@@ -2816,8 +2983,8 @@ function renderVesselInfo(p) {
SHIP TYPE
${p.tipo_nombre || shipTypeName(p.tipo)}
-
LOA
${p.length ? p.length + ' m' : '--'}
-
BEAM
${p.beam ? p.beam + ' m' : '--'}
+
LOA
${p.length ? p.length + ' m' : 'est.'}
+
BEAM
${p.beam ? p.beam + ' m' : 'est.'}
${p.to_bow != null ? `
@@ -2833,12 +3000,31 @@ function renderVesselInfo(p) {
FIX TYPE
${fixTxt}
+
+
CPA / TCPA
+ + ${(p.cpa_nm != null && p.tcpa_min != null) ? ` +
+
+
CPA
+
${p.cpa_nm.toFixed(2)} NM
+
+
+
TCPA
+
+ ${p.tcpa_min < 0 ? 'passed' : p.tcpa_min.toFixed(0) + ' min'} +
+
+
+ ${p.cpa_aton ? `
REF ATON
${p.cpa_aton}
` : ''} + ` : `
No AtoN in range or vessel not moving
`} +
VOYAGE
DESTINATION
${p.destino || '--'}
ETA
${p.eta || '--'}
-
LAST SIGNAL
${formatUTC(p.timestamp)}
+
LAST SIGNAL
${formatUTC(p.timestamp)}
${_ageStr(p.timestamp)}
+
+ Crea un registro editable enlazado a este feature de la carta. + Luego podrás asignar MMSI / lámpara / responsable. +
+ `; + } + + let existingAid = null; + try { + const r = await fetch(`/aids/by-chart-feature?cell_id=${encodeURIComponent(cellId)}&feature_id=${encodeURIComponent(featureId)}`); + if (r.ok) existingAid = await r.json(); + else if (r.status !== 404) throw new Error(`Lookup failed (${r.status})`); + } catch (e) { + console.warn('[enc] Aid lookup error:', e); + // Keep the SAVE button visible — user can still try to create. + return; + } + + if (existingAid) { + // An Aid already tracks this S-57 feature — replace the panel with the + // full Aid info so the operator sees MMSI / lamp / alerts / EDIT button. + if (window.renderAidInfo) { + window.renderAidInfo({ ...existingAid, featureType: 'aid' }, panel); + } + return; + } + + // Wire the button click handler (button HTML already rendered above). + document.getElementById('btn-promote-s57')?.addEventListener('click', async () => { + const btn = document.getElementById('btn-promote-s57'); + if (!window.Auth?.isAdmin?.()) { + window.Modal?.openLogin?.(null); + return; + } + btn.disabled = true; + btn.textContent = 'GUARDANDO...'; + try { + const r = await fetch('/aids/from-chart-feature', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${window.Auth.token()}`, + }, + body: JSON.stringify({ + cell_id: cellId, + chart_feature_id: featureId, + lat_nominal: lat, + lon_nominal: lon, + nombre: p.name || `${p.layer} ${featureId}`, + tipo: _s57LayerToAidTipo(p.layer, p.aid_type), + categoria: 'FLOTANTE', + radio_borneo_m: 10.0, + }), + }); + if (!r.ok) throw new Error((await r.json().catch(() => ({}))).detail || `HTTP ${r.status}`); + const newAid = await r.json(); + // Add the new feature to the aids layer immediately + window.updateAid?.(newAid); + // Re-render panel as a full Aid panel (with EDIT AID DATA button) + window.renderAidInfo?.({ ...newAid, featureType: 'aid' }, panel); + } catch (e) { + btn.disabled = false; + btn.textContent = 'GUARDAR COMO AYUDA MONITOREADA'; + alert('No se pudo crear la ayuda: ' + e.message); + } + }); } // ── Lamp assignment from the right panel ────────────────────────────────── @@ -3022,17 +3320,10 @@ function renderAidInfo(p, panel) { const aton = window.atonData?.[p.mmsi] || {}; const hasAton = Object.keys(aton).length > 0; - const atonBlock = (p.tipo_ais === 'ATON_21' || hasAton) ? ` -
-
ATON TRANSPONDER
- - ${p.caracteristica_luz ? ` -
-
LIGHT
${p.caracteristica_luz}
-
RANGE
${p.alcance_nm || '--'} NM
-
` : ''} - - ${hasAton ? ` + // ATON block: SIEMPRE se renderiza para que TODA ayuda pueda configurarse + // como referencia (luz, alcance, telemetría esperada). + // Si hay live data → se muestra; sino → "Waiting for live telemetry…". + const atonTelem = hasAton ? `
LIVE TELEMETRY ${aton.last_update ? aton.last_update.slice(11,19)+' UTC' : ''}
@@ -3085,8 +3376,19 @@ function renderAidInfo(p, panel) { ${aton.virtual ? '
VIRTUAL ATON
' : ''} -
` : '
Waiting for live telemetry…
'} - ` : ''; + ` : '
Waiting for live telemetry…
'; + + const atonBlock = ` +
+
ATON TRANSPONDER
+ +
+
LIGHT
${p.caracteristica_luz || '--'}
+
RANGE
${p.alcance_nm != null ? p.alcance_nm + ' NM' : '--'}
+
+ + ${atonTelem} + `; panel.innerHTML = `
AID TO NAVIGATION
@@ -3135,12 +3437,318 @@ function renderAidInfo(p, panel) { ${atonBlock} + ${p.mmsi ? ` +
+
+ BATTERY HISTORY +
+ + + +
+
+
+
+
+
` : ''} + `; document.getElementById('btn-edit-aid')?.addEventListener('click', () => { if (window.Modal) window.Modal.openEdit(p); }); + + // Load battery chart if buoy has AIS + if (p.mmsi) { + const lamp = (window._lampCache || []).find(l => l.id === p.lamp_id); + const warnV = lamp?.warn_v ?? null; + const almV = lamp?.alarm_v ?? null; + _loadBatteryChart(p.mmsi, warnV, almV, 1, + p.lat_nominal, p.lon_nominal, p.caracteristica_luz); + + document.querySelectorAll('.batt-rb').forEach(btn => { + btn.addEventListener('click', function() { + document.querySelectorAll('.batt-rb').forEach(b => b.classList.remove('active')); + this.classList.add('active'); + _loadBatteryChart(p.mmsi, warnV, almV, Number(this.dataset.range), + p.lat_nominal, p.lon_nominal, p.caracteristica_luz); + }); + }); + } +} + +// ── Sunrise/sunset (NOAA simplified, accurate ±2 min, no external dependency) +function _sunTimes(lat, lon, dateUTC) { + var D = Math.floor(dateUTC / 86400000); + var JD = D + 2440587.5; + var n = JD - 2451545.0; + var L = ((280.46 + 0.9856474 * n) % 360 + 360) % 360; + var g = ((357.528 + 0.9856003 * n) % 360 + 360) % 360; + var lam = L + 1.915 * Math.sin(g * Math.PI/180) + 0.020 * Math.sin(2*g*Math.PI/180); + var eps = 23.439 - 0.0000004 * n; + var sinDec = Math.sin(eps * Math.PI/180) * Math.sin(lam * Math.PI/180); + var dec = Math.asin(sinDec); + var latR = lat * Math.PI / 180; + var cosHA = (Math.cos((-0.833)*Math.PI/180) - Math.sin(latR)*sinDec) + / (Math.cos(latR) * Math.cos(dec)); + if (Math.abs(cosHA) > 1) return null; // polar day/night + var HA = Math.acos(cosHA) * 180 / Math.PI; + var B = 360/365 * (n - 81) * Math.PI/180; + var EoT = 9.87*Math.sin(2*B) - 7.53*Math.cos(B) - 1.5*Math.sin(B); // minutes + var noon = 720 - 4*lon - EoT; // solar noon in minutes from midnight UTC + var dayMs = D * 86400000; + return { + rise: new Date(dayMs + (noon - HA*4) * 60000), + set: new Date(dayMs + (noon + HA*4) * 60000), + }; +} + +// Build day/night band list for a time range (one entry per solar transition) +function _solarBands(lat, lon, tMin, tMax) { + var bands = []; + var d = new Date(tMin); d.setUTCHours(0,0,0,0); + while (d.getTime() <= tMax) { + var st = _sunTimes(lat, lon, d.getTime()); + if (st) { + bands.push({ t: Math.max(tMin, st.rise.getTime()), type: 'day' }); + bands.push({ t: Math.max(tMin, st.set.getTime()), type: 'night'}); + } + d.setUTCDate(d.getUTCDate() + 1); + } + // Determine opening phase + var firstSt = _sunTimes(lat, lon, tMin); + var openNight = firstSt ? (tMin < firstSt.rise.getTime() || tMin > firstSt.set.getTime()) : true; + bands.sort((a,b) => a.t - b.t); + return { bands, openNight }; +} + +// ── Flash character parser → power consumption index (base = Fl 5s = 1.0) +function _parseFlashChar(char) { + if (!char) return { label: '—', index: 1.0 }; + var c = char.trim().toUpperCase(); + if (/^F\b/.test(c) && !c.startsWith('FL')) + return { label: 'Fixed (continuous, max consumption)', index: 12.0 }; + if (/^OC|^OCC/.test(c)) return { label: 'Occulting (~75% on)', index: 5.0 }; + if (/^ISO/.test(c)) return { label: 'Isophase (50% on)', index: 3.0 }; + if (/^VQ/.test(c)) return { label: 'Very Quick ~120/min', index: 2.5 }; + if (/^IQ|^Q/.test(c)) return { label: 'Quick ~60/min', index: 1.5 }; + // Fl(N) Xs + var grp = c.match(/FL\((\d+)\)\s*(\d+(?:\.\d+)?)S/); + if (grp) { + var f = parseInt(grp[1]), p2 = parseFloat(grp[2]); + var rate = f / p2 * 60; + return { label: `Fl(${f}) ${p2}s — ${rate.toFixed(1)} fl/min`, + index: Math.max(0.3, Math.min(4.0, rate / 12)) }; + } + // Fl Xs + var fl = c.match(/FL(?:\(1\))?\s*(\d+(?:\.\d+)?)S/); + if (fl) { + var per = parseFloat(fl[1]); + var rate2 = 60 / per; + return { label: `Fl ${per}s — ${rate2.toFixed(1)} fl/min`, + index: Math.max(0.2, Math.min(2.0, rate2 / 12)) }; + } + return { label: char, index: 1.0 }; +} + +// ── Battery history chart ───────────────────────────────────────────────── +async function _loadBatteryChart(mmsi, warnV, almV, days, lat, lon, flashChar) { + var svgEl = document.getElementById('batt-chart-svg'); + var statsEl = document.getElementById('batt-chart-stats'); + if (!svgEl) return; + svgEl.innerHTML = '
Loading…
'; + if (statsEl) statsEl.innerHTML = ''; + + try { + var from = new Date(Date.now() - days * 86400000).toISOString(); + var r = await fetch(`${window.API}/tracks/atons/${mmsi}?from=${from}&limit=8000`, + { headers: Auth.session ? { Authorization: 'Bearer ' + Auth.session.token } : {} }); + if (!r.ok) throw new Error('API error'); + var rows = await r.json(); + + var pts = rows.filter(function(d){ return d.voltage_v != null; }) + .map(function(d){ return { t: new Date(d.ts).getTime(), v: d.voltage_v }; }); + + if (pts.length < 2) { + svgEl.innerHTML = '
Not enough data for this period.
'; + return; + } + + // Solar bands for day/night shading + var solar = (lat != null && lon != null) + ? _solarBands(lat, lon, pts[0].t, pts[pts.length-1].t) + : null; + + // Segment data into day/night for separate rate calculation + var chargeRates = [], dischargeRates = []; + if (solar) { + var bands = solar.bands; + var classify = function(ts) { + var type = solar.openNight ? 'night' : 'day'; + for (var i = 0; i < bands.length; i++) { + if (ts >= bands[i].t) type = bands[i].type; + else break; + } + return type; + }; + for (var i = 1; i < pts.length; i++) { + var prev = pts[i-1], cur = pts[i]; + var midT = (prev.t + cur.t) / 2; + var dV = cur.v - prev.v; + var dH = (cur.t - prev.t) / 3600000; + if (dH < 0.001) continue; + var rate = dV / dH; + if (classify(midT) === 'day') chargeRates.push(rate); + else dischargeRates.push(rate); + } + } + + var avgRate = function(arr) { + if (!arr.length) return null; + return arr.reduce(function(s,v){return s+v;},0) / arr.length; + }; + var chargeRate = avgRate(chargeRates); + var dischargeRate = avgRate(dischargeRates); + + svgEl.innerHTML = _battSvg(pts, warnV, almV, solar); + + // ── Stats panel ─────────────────────────────────────────────────────── + if (statsEl) { + var latest = pts[pts.length-1].v; + var flash = _parseFlashChar(flashChar); + + var fmtRate = function(v) { + if (v === null) return '—'; + return (v >= 0 ? '+' : '') + v.toFixed(3) + ' V/h'; + }; + + var etaHtml = ''; + if (almV != null && dischargeRate !== null && dischargeRate < 0) { + var nightsLeft = (latest - almV) / Math.abs(dischargeRate) / 12; // approx 12h night + if (nightsLeft > 0) { + var daysLeft = Math.floor(nightsLeft); + etaHtml = `Nights to alarm: ${daysLeft}`; + } + } + + var chargeHtml = chargeRate !== null + ? `☀ Charge: ${fmtRate(chargeRate)}` : ''; + var dischHtml = dischargeRate !== null + ? `☾ Discharge: ${fmtRate(dischargeRate)}` : ''; + + var flashHtml = ` + ⚡ Consumption index: ${flash.index.toFixed(1)}× + ${flash.label} + `; + + statsEl.innerHTML = ` +
+ Now: ${latest.toFixed(2)} V + ${chargeHtml} + ${dischHtml} + ${etaHtml} + ${flashHtml} +
`; + } + } catch(e) { + if (svgEl) svgEl.innerHTML = '
Error loading data.
'; + } +} + +function _battSvg(pts, warnV, almV, solar) { + var W = 280, H = 100, PAD = { t: 6, r: 28, b: 18, l: 34 }; + var pw = W - PAD.l - PAD.r, ph = H - PAD.t - PAD.b; + + var tMin = pts[0].t, tMax = pts[pts.length-1].t; + var vVals = pts.map(function(p){return p.v;}); + var vMin = Math.min.apply(null, vVals.concat(almV != null ? [almV] : [])) * 0.985; + var vMax = Math.max.apply(null, vVals.concat(warnV != null ? [warnV] : [])) * 1.008; + if (vMax <= vMin) vMax = vMin + 0.5; + + var tx = function(t) { return PAD.l + (t - tMin) / (tMax - tMin) * pw; }; + var ty = function(v) { return PAD.t + ph - (v - vMin) / (vMax - vMin) * ph; }; + + // ── Day/night background bands ────────────────────────────────────────── + var bands = ''; + if (solar) { + var events = [{ t: tMin, type: solar.openNight ? 'night' : 'day' }] + .concat(solar.bands) + .concat([{ t: tMax, type: '_end' }]); + for (var i = 0; i < events.length - 1; i++) { + var ev = events[i], next = events[i+1]; + var x1 = tx(Math.max(tMin, ev.t)), x2 = tx(Math.min(tMax, next.t)); + if (x2 <= x1) continue; + var fill = ev.type === 'day' ? 'rgba(255,220,80,0.06)' : 'rgba(10,20,60,0.45)'; + bands += ``; + } + } + + // ── Threshold lines ───────────────────────────────────────────────────── + var warnLine = '', almLine = ''; + if (warnV != null && warnV >= vMin && warnV <= vMax) { + var wy = ty(warnV).toFixed(1); + warnLine = ` + ${warnV.toFixed(2)}V`; + } + if (almV != null && almV >= vMin && almV <= vMax) { + var ay = ty(almV).toFixed(1); + almLine = ` + ${almV.toFixed(2)}V`; + } + + // ── Data line ─────────────────────────────────────────────────────────── + var latest = pts[pts.length-1].v; + var lineColor = almV != null && latest <= almV ? '#f44336' + : warnV != null && latest <= warnV ? '#ffb300' + : '#00bfff'; + var line = pts.map(function(p){ return tx(p.t).toFixed(1)+','+ty(p.v).toFixed(1); }).join(' '); + + // Current value dot + label + var curY = ty(latest).toFixed(1); + var curDot = ``; + + // ── Time axis labels ──────────────────────────────────────────────────── + var fmt = function(ts) { + var d = new Date(ts); + return (d.getDate()+'/'+(d.getMonth()+1)+' ' + +String(d.getUTCHours()).padStart(2,'0')+':'+String(d.getUTCMinutes()).padStart(2,'0')); + }; + var timeAxis = ` + ${fmt(tMin)} + ${fmt(tMax)}`; + + // ── Legend: ☀ / ☾ ─────────────────────────────────────────────────────── + var legend = solar ? ` + ☀ day + ☾ night` : ''; + + return ` + + + + + + ${bands} + + + + ${warnLine}${almLine} + + + + + ${curDot} + ${legend} + ${timeAxis} + `; } window.toggleAtonField = function(field, show) { @@ -3162,6 +3770,14 @@ function formatUTC(ts) { return new Date(ts).toUTCString().replace('GMT', 'UTC'); } +function _ageStr(ts) { + if (!ts) return ''; + const sec = Math.round((Date.now() - new Date(ts).getTime()) / 1000); + if (sec < 60) return `${sec}s ago`; + if (sec < 3600) return `${Math.round(sec / 60)} min ago`; + return `${Math.round(sec / 3600)} h ago`; +} + // ── Coordenadas en barra inferior ───────────────────────────────────────── map.on('pointermove', (evt) => { const coord = ol.proj.toLonLat(evt.coordinate); @@ -3529,6 +4145,115 @@ const trackSource = new ol.source.Vector(); const trackLayer = new ol.layer.Vector({ source: trackSource, zIndex: 5 }); map.addLayer(trackLayer); +// ── Auto-trail — estela en tiempo real para todos los targets AIS ────────── +// Dibuja automáticamente el rastro de cada barco conforme llegan posiciones. +const trailSource = new ol.source.Vector(); +const trailLayer = new ol.layer.Vector({ + source: trailSource, + zIndex: 6, + style: function(feature) { + var color = feature.get('color') || '#00bfff'; + return new ol.style.Style({ + stroke: new ol.style.Stroke({ color: color, width: 1.5, lineDash: [4, 4] }), + }); + }, +}); +map.addLayer(trailLayer); + +var _trailsVisible = true; +var _trailWindowMs = 6 * 60 * 1000; // default: last 6 minutes + +function _updateTrail(mmsi) { + var hist = vesselHistory.get(mmsi); + if (!hist) return; + var cutoff = (_trailWindowMs > 0) ? (Date.now() - _trailWindowMs) : 0; + var pts = hist.filter(function(p) { return p.ts >= cutoff; }); + var feat = trailSource.getFeatureById('trail_' + mmsi); + if (pts.length < 2) { + if (feat) trailSource.removeFeature(feat); + return; + } + var coords = pts.map(function(p) { return ol.proj.fromLonLat([p.lon, p.lat]); }); + if (!feat) { + var f = new ol.Feature({ geometry: new ol.geom.LineString(coords) }); + f.setId('trail_' + mmsi); + f.set('color', '#00bfff'); + trailSource.addFeature(f); + } else { + feat.getGeometry().setCoordinates(coords); + } +} + +window.setTrailsVisible = function(v) { _trailsVisible = v; trailLayer.setVisible(v); }; +window.setTrailWindow = function(ms) { + _trailWindowMs = ms; + // Redraw all trails under the new window + vesselHistory.forEach(function(hist, mmsi) { _updateTrail(mmsi); }); +}; + +// ── AIS Vectors (True / Relative) ───────────────────────────────────────── +const vectorSource = new ol.source.Vector(); +const vectorLayer = new ol.layer.Vector({ + source: vectorSource, + zIndex: 7, + visible: false, + style: new ol.style.Style({ + stroke: new ol.style.Stroke({ color: '#ffff00', width: 1.5 }), + }), +}); +map.addLayer(vectorLayer); + +var _vectorsVisible = false; +var _vectorMode = 'true'; // 'true' | 'relative' +var _vectorTimeMin = 6; // minutes ahead +var _ownShipCog = 0; +var _ownShipSog = 0; + +function _updateVector(mmsi, lat, lon, sog, cog) { + var feat = vectorSource.getFeatureById('vec_' + mmsi); + if (!_vectorsVisible || !(sog > 0.1)) { + if (feat) vectorSource.removeFeature(feat); + return; + } + var effSog = sog, effCog = cog; + if (_vectorMode === 'relative') { + var ownVx = _ownShipSog * Math.sin(_ownShipCog * Math.PI / 180); + var ownVy = _ownShipSog * Math.cos(_ownShipCog * Math.PI / 180); + var tgtVx = sog * Math.sin(cog * Math.PI / 180); + var tgtVy = sog * Math.cos(cog * Math.PI / 180); + var relVx = tgtVx - ownVx; + var relVy = tgtVy - ownVy; + effSog = Math.sqrt(relVx * relVx + relVy * relVy); + effCog = (Math.atan2(relVx, relVy) * 180 / Math.PI + 360) % 360; + } + var pts = projectPath(lat, lon, effSog, effCog, _vectorTimeMin); + var end = pts[pts.length - 1]; + var coords = [ol.proj.fromLonLat([lon, lat]), ol.proj.fromLonLat(end)]; + if (!feat) { + var f = new ol.Feature({ geometry: new ol.geom.LineString(coords) }); + f.setId('vec_' + mmsi); + vectorSource.addFeature(f); + } else { + feat.getGeometry().setCoordinates(coords); + } +} + +function _refreshAllVectors() { + vesselsSource.getFeatures().forEach(function(f) { + var d = f.getProperties(); + if (d.mmsi != null) _updateVector(d.mmsi, d.lat, d.lon, d.sog || 0, d.cog || 0); + }); +} + +window.setVectorsVisible = function(v) { + _vectorsVisible = v; + vectorLayer.setVisible(v); + if (!v) vectorSource.clear(); + else _refreshAllVectors(); +}; +window.setVectorMode = function(m) { _vectorMode = m; _refreshAllVectors(); }; +window.setVectorTime = function(t) { _vectorTimeMin = t; _refreshAllVectors(); }; + // ── Indicadores de warning/alarma sobre ayudas ──────────────────────────── const warnSource = new ol.source.Vector(); const warnLayer = new ol.layer.Vector({ source: warnSource, zIndex: 25 }); @@ -3613,6 +4338,9 @@ map.addLayer(new ol.layer.Vector({ window.updateOwnShip = function(fix) { ownShipSource.clear(); + // Capture COG/SOG for relative vector mode + _ownShipCog = fix.cog || 0; + _ownShipSog = fix.sog || 0; const f = new ol.Feature({ geometry: new ol.geom.Point(ol.proj.fromLonLat([fix.lon, fix.lat])), }); @@ -3627,6 +4355,7 @@ function recordPosition(mmsi, lat, lon) { if (!hist.length || now - hist[hist.length - 1].ts >= 30000) hist.push({ lat, lon, ts: now }); if (hist.length > 40) hist.shift(); + _updateTrail(mmsi); } function projectPath(lat, lon, sogKn, cogDeg, minutes = 20) { @@ -3959,3 +4688,22 @@ window._mapJump = function(lon, lat, zoom) { dvrLayer.setVisible(false); }; })(); + +// ── AIS toolbar controls ─────────────────────────────────────────────────── +document.getElementById('btn-trails').addEventListener('click', function(e) { + const on = e.target.classList.toggle('active'); + window.setTrailsVisible(on); +}); +document.getElementById('trail-window').addEventListener('change', function(e) { + window.setTrailWindow(Number(e.target.value)); +}); +document.getElementById('btn-vectors').addEventListener('click', function(e) { + const on = e.target.classList.toggle('active'); + window.setVectorsVisible(on); +}); +document.getElementById('vector-mode').addEventListener('change', function(e) { + window.setVectorMode(e.target.value); +}); +document.getElementById('vector-time').addEventListener('change', function(e) { + window.setVectorTime(Number(e.target.value)); +}); diff --git a/frontend/js/menu.js b/frontend/js/menu.js index 0c726fa..634d427 100644 --- a/frontend/js/menu.js +++ b/frontend/js/menu.js @@ -793,9 +793,11 @@ document.getElementById('btn-rec-search')?.addEventListener('click', async () => // ── MODAL LAMP CATALOG ──────────────────────────────────────────────────── window._lampCache = []; // exposed so the right panel can populate its dropdown -function _lampThresholds(vmin, vmax) { +function _lampThresholds(vmin, vmax, warn_pct, alarm_pct) { const rng = vmax - vmin; - return { warn: +(vmin + rng * 0.20).toFixed(3), alarm: +(vmin + rng * 0.10).toFixed(3) }; + const wp = ((warn_pct ?? 20) / 100); + const ap = ((alarm_pct ?? 10) / 100); + return { warn: +(vmin + rng * wp).toFixed(3), alarm: +(vmin + rng * ap).toFixed(3) }; } async function loadLamps() { @@ -844,20 +846,24 @@ window.editLamp = function(id) { - + + + `; // Live preview while editing vmin/vmax - ['lp-edit-vmin','lp-edit-vmax'].forEach(i => + ['lp-edit-vmin','lp-edit-vmax','lp-edit-wpct','lp-edit-apct'].forEach(i => document.getElementById(i).addEventListener('input', () => { const vmin = parseFloat(document.getElementById('lp-edit-vmin').value); const vmax = parseFloat(document.getElementById('lp-edit-vmax').value); + const wpct = parseFloat(document.getElementById('lp-edit-wpct').value); + const apct = parseFloat(document.getElementById('lp-edit-apct').value); const cell = document.getElementById('lp-edit-preview'); if (isNaN(vmin) || isNaN(vmax) || vmax <= vmin) { cell.textContent = '—'; return; } - const t = _lampThresholds(vmin, vmax); + const t = _lampThresholds(vmin, vmax, wpct, apct); cell.innerHTML = `${t.warn} V / ${t.alarm} V`; })); }; @@ -870,6 +876,8 @@ window.saveLamp = async function(id) { lamp_count: parseInt(document.getElementById('lp-edit-count').value) || 1, voltage_min: parseFloat(document.getElementById('lp-edit-vmin').value), voltage_max: parseFloat(document.getElementById('lp-edit-vmax').value), + warn_pct: parseFloat(document.getElementById('lp-edit-wpct').value) || 20.0, + alarm_pct: parseFloat(document.getElementById('lp-edit-apct').value) || 10.0, notes: document.getElementById('lp-edit-notes').value.trim() || null, }; if (!payload.manufacturer || !payload.model || isNaN(payload.voltage_min) || isNaN(payload.voltage_max)) {
CellDescriptionStatus
${l.warn_v} V / ${l.alarm_v} V${l.warn_v} V / ${l.alarm_v} V