From cfd94f905ad45d233372c41b98731ecab318ac2d Mon Sep 17 00:00:00 2001 From: aerom Date: Fri, 3 Jul 2026 12:45:43 -0400 Subject: [PATCH] security: CORS hardening, path traversal fix, WebSocket auth + cleanup - Restrict CORS to localhost origins (was allow_origins=[*]) - Require valid JWT on WebSocket /ws (anonymous no longer gets admin view) - Fix path traversal in delete_cell(): resolve() + parent check - Validate cell_id format in /charts/download-noaa/{cell_id} - Exclude charts/ and Cartas/ from git (keep US1GC09M world overview) - Add NOAA ENC Portal external link in charts catalog tab - Untrack __pycache__/, .db, .claude/ session files Co-Authored-By: Claude Sonnet 4.6 --- .claude/launch.json | 11 - .claude/settings.local.json | 282 ------ .gitignore | 18 +- backend/__pycache__/database.cpython-314.pyc | Bin 2565 -> 0 bytes backend/__pycache__/main.cpython-314.pyc | Bin 46752 -> 0 bytes backend/aidsmonitoring.db | Bin 147456 -> 0 bytes backend/main.py | 362 +++++++- .../__pycache__/__init__.cpython-314.pyc | Bin 143 -> 0 bytes .../models/__pycache__/aid.cpython-314.pyc | Bin 3126 -> 0 bytes .../__pycache__/contact.cpython-314.pyc | Bin 2613 -> 0 bytes .../models/__pycache__/lamp.cpython-314.pyc | Bin 1489 -> 0 bytes .../models/__pycache__/user.cpython-314.pyc | Bin 1559 -> 0 bytes .../models/__pycache__/vessel.cpython-314.pyc | Bin 3134 -> 0 bytes backend/models/aid.py | 25 +- backend/models/lamp.py | 6 + backend/requirements.txt | 3 + .../__pycache__/__init__.cpython-314.pyc | Bin 144 -> 0 bytes .../routers/__pycache__/aids.cpython-314.pyc | Bin 12582 -> 0 bytes .../routers/__pycache__/auth.cpython-314.pyc | Bin 13786 -> 0 bytes .../__pycache__/charts.cpython-314.pyc | Bin 32505 -> 0 bytes .../__pycache__/contacts.cpython-314.pyc | Bin 12448 -> 0 bytes .../__pycache__/equipment.cpython-314.pyc | Bin 10211 -> 0 bytes .../routers/__pycache__/lamps.cpython-314.pyc | Bin 6979 -> 0 bytes backend/routers/aids.py | 103 ++- backend/routers/charts.py | 40 +- backend/routers/lamps.py | 14 +- .../__pycache__/__init__.cpython-314.pyc | Bin 145 -> 0 bytes .../__pycache__/ais_catcher.cpython-314.pyc | Bin 6854 -> 0 bytes .../__pycache__/ais_decoder.cpython-314.pyc | Bin 13378 -> 0 bytes .../__pycache__/ais_simulator.cpython-314.pyc | Bin 13048 -> 0 bytes .../ais_udp_reader.cpython-314.pyc | Bin 9609 -> 0 bytes .../__pycache__/alert_engine.cpython-314.pyc | Bin 8652 -> 0 bytes .../__pycache__/aton_decoder.cpython-314.pyc | Bin 10019 -> 0 bytes .../__pycache__/chart_manager.cpython-314.pyc | Bin 71335 -> 0 bytes .../__pycache__/gps_reader.cpython-314.pyc | Bin 18751 -> 0 bytes .../__pycache__/notifier.cpython-314.pyc | Bin 3675 -> 0 bytes .../settings_store.cpython-314.pyc | Bin 4521 -> 0 bytes backend/services/alert_engine.py | 13 +- backend/services/aton_decoder.py | 22 +- backend/services/chart_manager.py | 119 ++- backend/services/settings_store.py | 10 + backend/services/slave_relay.py | 177 ++++ frontend/css/main.css | 60 +- frontend/index.html | 36 + frontend/js/auth.js | 83 +- frontend/js/map.js | 872 ++++++++++++++++-- frontend/js/menu.js | 18 +- 47 files changed, 1847 insertions(+), 427 deletions(-) delete mode 100644 .claude/launch.json delete mode 100644 .claude/settings.local.json delete mode 100644 backend/__pycache__/database.cpython-314.pyc delete mode 100644 backend/__pycache__/main.cpython-314.pyc delete mode 100644 backend/aidsmonitoring.db delete mode 100644 backend/models/__pycache__/__init__.cpython-314.pyc delete mode 100644 backend/models/__pycache__/aid.cpython-314.pyc delete mode 100644 backend/models/__pycache__/contact.cpython-314.pyc delete mode 100644 backend/models/__pycache__/lamp.cpython-314.pyc delete mode 100644 backend/models/__pycache__/user.cpython-314.pyc delete mode 100644 backend/models/__pycache__/vessel.cpython-314.pyc delete mode 100644 backend/routers/__pycache__/__init__.cpython-314.pyc delete mode 100644 backend/routers/__pycache__/aids.cpython-314.pyc delete mode 100644 backend/routers/__pycache__/auth.cpython-314.pyc delete mode 100644 backend/routers/__pycache__/charts.cpython-314.pyc delete mode 100644 backend/routers/__pycache__/contacts.cpython-314.pyc delete mode 100644 backend/routers/__pycache__/equipment.cpython-314.pyc delete mode 100644 backend/routers/__pycache__/lamps.cpython-314.pyc delete mode 100644 backend/services/__pycache__/__init__.cpython-314.pyc delete mode 100644 backend/services/__pycache__/ais_catcher.cpython-314.pyc delete mode 100644 backend/services/__pycache__/ais_decoder.cpython-314.pyc delete mode 100644 backend/services/__pycache__/ais_simulator.cpython-314.pyc delete mode 100644 backend/services/__pycache__/ais_udp_reader.cpython-314.pyc delete mode 100644 backend/services/__pycache__/alert_engine.cpython-314.pyc delete mode 100644 backend/services/__pycache__/aton_decoder.cpython-314.pyc delete mode 100644 backend/services/__pycache__/chart_manager.cpython-314.pyc delete mode 100644 backend/services/__pycache__/gps_reader.cpython-314.pyc delete mode 100644 backend/services/__pycache__/notifier.cpython-314.pyc delete mode 100644 backend/services/__pycache__/settings_store.cpython-314.pyc create mode 100644 backend/services/slave_relay.py 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 f33221a8ee6c46d3709f06e95682df34b40e0338..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2565 zcmah~T}&L;6~6Pcvpc&3%TI{24u)$(jJGjM;yQJREhkHWfLQQm3^AVR?Nqj3_S6%M1>c(EzkZf`EO?(q@#Uo6`q{oXvAItl*JA;)Cl zBvqxYG9?$*4tbX>lNc}c_4VcYR8#lLj%^ahHSL9*KJPbci-xwOcxu^D$Rc>8 z55slNrLxEZhL+^EA9WXd)Z)6N+NfvlTz7C}l^lSd^C(YB8oluXwn7TlBV zfVxdlJOcNmDJmhZIbR`$rzkE>r(Noe|IDi|&Ez50@$X}unOC(X!`5eXm8kP@d~SKw zO@nPf8i4U)Kz@Zj<`XV;K9K()HmCvA1^?a4K#rhJh;Jr}7o7wd9n0uxG>ukJ6)IsG z73(OvY>b|M+J!)WsVSefWFcL3oBX{vrUR-(BEAXW)g2FnSjC)YmS+W49v{ zbcdjf=#SclX`W3~YG9VvbvzfQ!5k(Io(nQ#4x1%x60AFhXZH|nte76j;q$K-0OEQ* zwPavb*G-C`$+EdXvDC5gm(|~4w_e$46pjN6c(aK1jE2Tv@+zeQ>+$g}cVx3-|KdhtJm9U#_KJ`6xYlKRsGYk6q;- zq?&K@wM_3F{^s+W`5WDv-FNu=nckgL?*pl6qp)7M{=`k`_SEgy?w;8a?yYQH`mpuG z*mlQ6Epu*1DpH%V^|80dAB!k?@V=P&_RCD~;~4PZ#kQFFr*{ba`+aWUSnPx5=7Fx{ z2S-i;|EI1PrN>eO{bIJ&&4P_f0l;tEyVx9nj7zKCrTxGc!p`basPkFJ^ZB61@;`G- z+mGv}OM4XUVkI-^ToiSoC%Xx{!;dJ*P!e>ScFNHk3_4ZVWdf*9oC!PMS@$$(pEA+GKCUug7ml*QJ~C`xkCqcwf1t+YJ;Z9oyNFT6(l9eIY*in=9Kt9)=4%F|i|_s|tTfKYg=uFSa$jlOC%| ZU-JwjUVZs%1k>Mz7}NT=3D7d|?>nR*KJWkl diff --git a/backend/__pycache__/main.cpython-314.pyc b/backend/__pycache__/main.cpython-314.pyc deleted file mode 100644 index b7c2f67ace756db5a219dded175cf3e46b36706e..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 46752 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 diff --git a/backend/aidsmonitoring.db b/backend/aidsmonitoring.db deleted file mode 100644 index 02e7d92e541bc6df91189342abba6bd834489da3..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 147456 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 LKmXs&*uwt@rYwzT 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 8bbf29ffbb0370538356f193d1b4fb1d8fd94cbc..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 143 zcmdPq>P{wCAAftgHh(Vb_lhJP_LlF~@{~08COUKzN#xXOc z*f&2fvn0PLGcP?RDKR-aH7_M5H$Npcr#L1)J`*SvAFo$Xd5gmaC|H`4YFESx)C@Ad Q7{vI*%*e=C#0+Es0R4F&BLDyZ diff --git a/backend/models/__pycache__/aid.cpython-314.pyc b/backend/models/__pycache__/aid.cpython-314.pyc deleted file mode 100644 index 443b12a69579184057d9aade41fbc94ea02528c7..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 3126 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 diff --git a/backend/models/__pycache__/user.cpython-314.pyc b/backend/models/__pycache__/user.cpython-314.pyc deleted file mode 100644 index ab08b114f53026ac8be7489c90a63c50a2065b9a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1559 zcmZuxTWcFf6rNqpu69=!Cz9`l8dEn|Eu_UU1d3CLEd|Ng!i?oNE5lkE$J^{ho}Epq zmcl-S;>Y$W`3dfuX&?GW1a+o_Qu@@lf}PU0c4k)>7c#JC&gGjkXTCW*9~TQ25M$}> zKhD1y0RE81Xy`-b>P#pxcnpxz0Cj~ab(N|049nCZgLRE*b)D%;GOjkVb%Pm+o@tnM zi&=>d8#$Jn0m}esa{%e>d^@+7>^s^jo!4PHvqFN+3art#)iAE1b~6ZEV*A{v+KjXuk8oJ?Bag!t`I1!>4v!Dnb93rtHxAqVAYUp8gx6$NUI_0-^ z8ntE%rxrZeuc046vtn`sW8d}&#@xc#3%Zdj^*qL>k?js6Qy3pRG-R&h6FCSeP6yje9RDa{BoxrwB6cM{z*o0EJACML!`;AlSRe^HJhD4aiQhdG z`w}-ij6y>1_WCq0;E9m;9nK2zEx0T%#oL$V#kh7^UXFJz%L^~6bZ!LvA9dp{SrjDR zpe~Y3oC65p-%?Y)(>!kcm#tPf3Ojx(3j6(Y+d^K{~y5k3p$#;2M^c{Ya%qFss{ z9tg&{CV3BAq7QRD>UcKorYUo{%o?J?D@nZQlvJp6&Os)1{hRuG<~vJPW972x>}C#t)uz zvqOp94KVR}UKHhv<>%EOSAY6Q=5D+$FT7e6yO!7D9jULJPhYS>dE>=)TC*&-GR3X%)V1Bt z3Gw<9eiN2bcCzwR$@z;Dm&rsC*5&zw6rqt)ltxQ3l}j-iE5&KNq)=rP z+oLOLNuwI~<&{J!Nt2_}fHWrQu^~y1ZzShr5tF7kssO5XplOb3fF?T73`diIraI89 zo<1QJGrwcNqQaB~->p_Wrre;!@wQof$@NXjv_+HJYfi;x>Sf<|ZPR1wibw5jn-n7~ zwpI1YSY9>*yDXL_Y-{hwBe?esiAHpZMsF%q)+02w5g(H_6g{fT??uG`s)Gfx?nkuh1ZwoSbu2V0gep<7H{osNLT2vuEH5; zO!?Ff0^4QkE-!<%2N9OqAx_04d&X^h52~71bzO7QwZT^{d&{i4^ntWm9ATPacxJ^m z43;#Eif>h2^wWlMr)s**mL9{{a!5d3$Fn^jnlpw$F`8&6NGlQp!(x?6;4sDWE1Se7 z8i%N}<1=;B^emg0ID0@H&nI}sOm=}Kvg>pXI+4DL-<5px)oeP1P2#<$w4*Qgeo-7Z7I>R*|2rT@^FqD)yJi zA#Vz z9rWH`5$nPwg~-=Z@gJb2gM1UOkneRB@1&kkaV12gCLKeVQ1N7l3~d!}E2mI#We#o@ z{HP#hFT`q-w+~2^Ymz9d+hw1iAYH)|VZG$2Ztx=t)<_oO2Wsl_mZM?E$8WqPgDoh2b+lGr&XrWpL&$RrmWI2bE zpg|@D78oSUsgS02`JIvZ0%!Cvu~zqO{v0x z`^$nAIBkd(ElK(bS~^%UCs?6JX?#P0`(4##_=~kV|iFZdu+d+0Le*$ZR#USZ28h53~m1?aw7m|WZ2u?w>^#e!w;I%PYUVkr~i9BCDK z7dEz3jFLVmS=@n}^b4q+g&O$~4T{#AB#NnqVfkga6vJ92=*Ni|7ArD?5ogh07Pn=J zC3bz6n%lOqD~Ol&x3-L0=0tL zsrtDeV~zYpxJ>b?dpP&x{G;=aFC4&?_Yo)-U?+i?`Dq~FYyNm6e^&IFs4q71Gy7Ks z2XXS%Vh>9OcU;pg-?A%v?Kg|qAeU);RftDRw%v0ITp{3^Wx}fm-l_y&?c$}tb(rTD z-)2~)nf8HrQC%X-Fai}PmCggOGaky0S*QFv-e_ugFnl+^)GTz!y*6x 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 c7e501960e93a9459e5bb658cc40cae8214d7d76..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 144 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 Onxk05hR<2mF#Ug$HttOT diff --git a/backend/routers/__pycache__/auth.cpython-314.pyc b/backend/routers/__pycache__/auth.cpython-314.pyc deleted file mode 100644 index fa7c9c9668d2bcab053a3655580fa43c2c4f35cd..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 13786 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 diff --git a/backend/routers/__pycache__/charts.cpython-314.pyc b/backend/routers/__pycache__/charts.cpython-314.pyc deleted file mode 100644 index 820fd2f02e544c173802d07ea339adc83a57a87e..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 32505 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 diff --git a/backend/routers/__pycache__/contacts.cpython-314.pyc b/backend/routers/__pycache__/contacts.cpython-314.pyc deleted file mode 100644 index ebd0dbdf952b5b24ed919d5fafaff64edb8a157f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 12448 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 diff --git a/backend/routers/__pycache__/equipment.cpython-314.pyc b/backend/routers/__pycache__/equipment.cpython-314.pyc deleted file mode 100644 index 157e122d31ff278954c6f497d82c876ede737837..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 10211 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 K{R4mHiuM1lh#f2d diff --git a/backend/routers/__pycache__/lamps.cpython-314.pyc b/backend/routers/__pycache__/lamps.cpython-314.pyc deleted file mode 100644 index ee94b19743657d1f7d4ce7cc1705635519ac1a04..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6979 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 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 9b5377d4d46c26d3c6b1fd55c92c936f406e66ce..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 145 zcmdPq>P{wCAAftgHh(Vb_lhJP_LlF~@{~08COV8OV#xXOc z*f&2fvn0PLGcP?RDKR-aH7_NmIJKxOGdZ<5CO$qBC>bBGS5SG2!zMRBr8Fniu80+= T9b`c* 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 bQQrG)@b-PM8J->YV-x`UgD$ugWXt~_;Iwo& 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 f91438b039926b7dba69b5595a1d5e5378421bc9..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 13378 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 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 25520f1d6d8c5e302964742336244ac15580e43b..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 9609 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 lwS2`5th~k(@ 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 Vg>Zh+dV(OF|4XU538(bx_W$I8zrFwf 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 cb1697cb863f8977985bedb99e50f953f5071803..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 10019 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 XJSRS{gZGu^CpmC1eLKskw&{NZ#NF;? 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 e432fd4218ed24f56f2897a96adf4767223a9e23..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 71335 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 ID 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 W+6g20D}t&Cs-UC>8bD6uE&m^7*bF}a 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 756570a0e88dc5fa1ce5c2272213ac8515077151..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 18751 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 Caqc(( 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 88af63663e759c0ca23679490e914ad2f940c023..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4521 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 Vny+g8T>R^5r~^*~fIWtH{{?%g6|(>U 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 @@
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