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 <noreply@anthropic.com>
This commit is contained in:
@@ -1,11 +0,0 @@
|
||||
{
|
||||
"version": "0.0.1",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "lh-preview",
|
||||
"runtimeExecutable": "python",
|
||||
"runtimeArgs": ["-m", "http.server", "7722", "--directory", "C:\\Temp"],
|
||||
"port": 7722
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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)"
|
||||
]
|
||||
}
|
||||
}
|
||||
+11
-7
@@ -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
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
+347
-11
@@ -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
|
||||
@@ -57,6 +59,17 @@ 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", "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.
|
||||
@@ -322,8 +401,10 @@ async def process_message(msg: dict):
|
||||
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
|
||||
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,
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+21
-2
@@ -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)
|
||||
# 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)
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+101
-2
@@ -62,10 +62,37 @@ class AidUpdate(BaseModel):
|
||||
radio_borneo_m: Optional[float] = None
|
||||
observaciones: Optional[str] = None
|
||||
lamp_id: Optional[str] = None
|
||||
# AIS link — operator-editable. Once set, AIS Type 21 with this MMSI
|
||||
# will update this aid's lat_actual and trigger drift/battery alerts.
|
||||
mmsi: Optional[str] = None
|
||||
tipo_ais: Optional[str] = None
|
||||
# Per-aid alarm thresholds (override global config when set)
|
||||
displacement_warn_m: Optional[float] = None
|
||||
displacement_alarm_m: Optional[float] = None
|
||||
signal_loss_min: Optional[int] = None
|
||||
din3_function: Optional[str] = None
|
||||
din4_function: Optional[str] = None
|
||||
# Chart link (set when aid was promoted from S-57 feature; usually
|
||||
# untouched after creation)
|
||||
source_chart: Optional[str] = None
|
||||
cell_id: Optional[str] = None
|
||||
chart_feature_id: Optional[str] = None
|
||||
motivo_cambio: str
|
||||
modificado_por: str
|
||||
|
||||
|
||||
class AidFromChartFeature(BaseModel):
|
||||
"""Payload to create an Aid record anchored to a specific S-57 chart feature."""
|
||||
cell_id: str
|
||||
chart_feature_id: str
|
||||
lat_nominal: float
|
||||
lon_nominal: float
|
||||
nombre: str
|
||||
tipo: str = "BOYA_ESPECIAL" # caller can refine from S-57 type
|
||||
categoria: str = "FLOTANTE"
|
||||
radio_borneo_m: float = 10.0
|
||||
|
||||
|
||||
class LampAssign(BaseModel):
|
||||
lamp_id: Optional[str] = None # None → unassign
|
||||
|
||||
@@ -76,6 +103,25 @@ def list_aids(db: Session = Depends(get_db)):
|
||||
return [_aid_dict(a, _lamp_for(a, db)) for a in aids]
|
||||
|
||||
|
||||
# Static paths declared BEFORE /{aid_id} so FastAPI doesn't capture
|
||||
# "by-chart-feature" as an aid_id.
|
||||
@router.get("/by-chart-feature")
|
||||
def aid_by_chart_feature(
|
||||
cell_id: str = Query(...),
|
||||
feature_id: str = Query(...),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Return the Aid record linked to a specific S-57 chart feature, or 404."""
|
||||
aid = db.query(Aid).filter(
|
||||
Aid.cell_id == cell_id,
|
||||
Aid.chart_feature_id == feature_id,
|
||||
Aid.activa == True,
|
||||
).first()
|
||||
if not aid:
|
||||
raise HTTPException(404, "No aid linked to this chart feature")
|
||||
return _aid_dict(aid, _lamp_for(aid, db))
|
||||
|
||||
|
||||
@router.get("/{aid_id}")
|
||||
def get_aid(aid_id: str, db: Session = Depends(get_db)):
|
||||
aid = db.query(Aid).filter(Aid.id == aid_id).first()
|
||||
@@ -111,9 +157,23 @@ def update_aid(aid_id: str, data: AidUpdate, db: Session = Depends(get_db),
|
||||
aid = db.query(Aid).filter(Aid.id == aid_id).first()
|
||||
if not aid:
|
||||
raise HTTPException(status_code=404, detail="Ayuda no encontrada")
|
||||
# Empty-string MMSI → unassign. Reject if MMSI is already used by another aid.
|
||||
if data.mmsi is not None:
|
||||
mmsi_clean = data.mmsi.strip() or None
|
||||
if mmsi_clean:
|
||||
taken = db.query(Aid).filter(
|
||||
Aid.mmsi == mmsi_clean, Aid.id != aid_id
|
||||
).first()
|
||||
if taken:
|
||||
raise HTTPException(409,
|
||||
f"MMSI {mmsi_clean} ya está asignado a '{taken.nombre}'")
|
||||
aid.mmsi = mmsi_clean
|
||||
for field in ["lat_nominal", "lon_nominal", "puerto_responsable",
|
||||
"empresa_responsable", "caracteristica_luz", "alcance_nm",
|
||||
"radio_borneo_m", "observaciones", "lamp_id"]:
|
||||
"radio_borneo_m", "observaciones", "lamp_id",
|
||||
"tipo_ais", "displacement_warn_m", "displacement_alarm_m",
|
||||
"signal_loss_min", "din3_function", "din4_function",
|
||||
"source_chart", "cell_id", "chart_feature_id"]:
|
||||
val = getattr(data, field)
|
||||
if val is not None:
|
||||
setattr(aid, field, val)
|
||||
@@ -122,7 +182,46 @@ def update_aid(aid_id: str, data: AidUpdate, db: Session = Depends(get_db),
|
||||
aid.modificado_en = datetime.utcnow()
|
||||
db.commit()
|
||||
db.refresh(aid)
|
||||
return aid
|
||||
return _aid_dict(aid, _lamp_for(aid, db))
|
||||
|
||||
|
||||
@router.post("/from-chart-feature")
|
||||
def create_aid_from_chart_feature(
|
||||
data: AidFromChartFeature,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_admin),
|
||||
):
|
||||
"""Promote a S-57 chart feature into a monitored Aid record.
|
||||
Idempotent: if an aid already exists for (cell_id, chart_feature_id),
|
||||
returns it instead of creating a duplicate."""
|
||||
existing = db.query(Aid).filter(
|
||||
Aid.cell_id == data.cell_id,
|
||||
Aid.chart_feature_id == data.chart_feature_id,
|
||||
Aid.activa == True,
|
||||
).first()
|
||||
if existing:
|
||||
return _aid_dict(existing, _lamp_for(existing, db))
|
||||
|
||||
aid = Aid(
|
||||
id=str(uuid.uuid4()),
|
||||
nombre=data.nombre,
|
||||
categoria=data.categoria,
|
||||
tipo=data.tipo,
|
||||
tipo_ais="SIN_AIS", # operator can change later
|
||||
lat_nominal=data.lat_nominal,
|
||||
lon_nominal=data.lon_nominal,
|
||||
fuente_posicion="S57",
|
||||
radio_borneo_m=data.radio_borneo_m,
|
||||
source_chart="S57",
|
||||
cell_id=data.cell_id,
|
||||
chart_feature_id=data.chart_feature_id,
|
||||
modificado_por=current_user.nombre if current_user else None,
|
||||
motivo_cambio="Promoted from S-57 chart feature",
|
||||
)
|
||||
db.add(aid)
|
||||
db.commit()
|
||||
db.refresh(aid)
|
||||
return _aid_dict(aid, _lamp_for(aid, db))
|
||||
|
||||
@router.get("/recordings", tags=["recordings"])
|
||||
def list_recordings(
|
||||
|
||||
+30
-10
@@ -181,9 +181,14 @@ def noaa_nearby(west: float, south: float, east: float, north: float):
|
||||
return matches
|
||||
|
||||
|
||||
import re as _re
|
||||
_CELL_RE = _re.compile(r'^US[1-5][A-Z]{2}\d{2}M$')
|
||||
|
||||
@router.post("/download-noaa/{cell_id}")
|
||||
async def download_noaa(cell_id: str):
|
||||
cell_id = cell_id.upper()
|
||||
if not _CELL_RE.match(cell_id):
|
||||
raise HTTPException(400, f"Invalid cell_id format: {cell_id}")
|
||||
url = NOAA_BASE.format(cell=cell_id)
|
||||
log.info("Downloading NOAA ENC %s from %s", cell_id, url)
|
||||
try:
|
||||
@@ -333,35 +338,50 @@ def set_cell_region(cell_id: str, region: str):
|
||||
return {"id": cell_id.upper(), "region": region}
|
||||
|
||||
|
||||
# Chart caches are rebuilt at server startup / explicit /rebuild-cache.
|
||||
# Between rebuilds, GeoJSON content is stable, so we let the browser cache
|
||||
# the response for an hour. ETag/304 would be safer but max-age is enough
|
||||
# for typical operator sessions and avoids redownloading 350 MB of depths.
|
||||
_CHART_CACHE_HEADERS = {"Cache-Control": "private, max-age=3600"}
|
||||
|
||||
|
||||
def _bbox(w, s, e, n):
|
||||
return (w, s, e, n) if None not in (w, s, e, n) else None
|
||||
|
||||
|
||||
@router.get("/features")
|
||||
def chart_features():
|
||||
return JSONResponse(get_all_features())
|
||||
def chart_features(w: float | None = None, s: float | None = None,
|
||||
e: float | None = None, n: float | None = None):
|
||||
return JSONResponse(get_all_features(_bbox(w, s, e, n)),
|
||||
headers=_CHART_CACHE_HEADERS)
|
||||
|
||||
|
||||
@router.get("/depths")
|
||||
def chart_depths(w: float | None = None, s: float | None = None,
|
||||
e: float | None = None, n: float | None = None):
|
||||
bbox = (w, s, e, n) if None not in (w, s, e, n) else None
|
||||
return JSONResponse(get_all_depths(bbox))
|
||||
return JSONResponse(get_all_depths(_bbox(w, s, e, n)),
|
||||
headers=_CHART_CACHE_HEADERS)
|
||||
|
||||
|
||||
@router.get("/land")
|
||||
def chart_land(w: float | None = None, s: float | None = None,
|
||||
e: float | None = None, n: float | None = None):
|
||||
bbox = (w, s, e, n) if None not in (w, s, e, n) else None
|
||||
return JSONResponse(get_all_land(bbox))
|
||||
return JSONResponse(get_all_land(_bbox(w, s, e, n)),
|
||||
headers=_CHART_CACHE_HEADERS)
|
||||
|
||||
|
||||
@router.get("/hazards")
|
||||
def chart_hazards():
|
||||
return JSONResponse(get_all_hazards())
|
||||
def chart_hazards(w: float | None = None, s: float | None = None,
|
||||
e: float | None = None, n: float | None = None):
|
||||
return JSONResponse(get_all_hazards(_bbox(w, s, e, n)),
|
||||
headers=_CHART_CACHE_HEADERS)
|
||||
|
||||
|
||||
@router.get("/zones")
|
||||
def chart_zones(w: float | None = None, s: float | None = None,
|
||||
e: float | None = None, n: float | None = None):
|
||||
bbox = (w, s, e, n) if None not in (w, s, e, n) else None
|
||||
return JSONResponse(get_all_zones(bbox))
|
||||
return JSONResponse(get_all_zones(_bbox(w, s, e, n)),
|
||||
headers=_CHART_CACHE_HEADERS)
|
||||
|
||||
|
||||
@router.post("/rebuild-cache")
|
||||
|
||||
@@ -22,19 +22,23 @@ class LampIn(BaseModel):
|
||||
lamp_count: int = 1
|
||||
voltage_min: float = Field(..., gt=0)
|
||||
voltage_max: float = Field(..., gt=0)
|
||||
warn_pct: float = Field(20.0, ge=1, le=50) # % of range → warning
|
||||
alarm_pct: float = Field(10.0, ge=1, le=50) # % of range → alarm
|
||||
notes: Optional[str] = None
|
||||
|
||||
|
||||
def compute_thresholds(vmin: float, vmax: float) -> dict:
|
||||
def compute_thresholds(vmin: float, vmax: float,
|
||||
warn_pct: float = 20.0, alarm_pct: float = 10.0) -> dict:
|
||||
rng = vmax - vmin
|
||||
return {
|
||||
"warn_v": round(vmin + rng * 0.20, 3),
|
||||
"alarm_v": round(vmin + rng * 0.10, 3),
|
||||
"warn_v": round(vmin + rng * (warn_pct / 100), 3),
|
||||
"alarm_v": round(vmin + rng * (alarm_pct / 100), 3),
|
||||
}
|
||||
|
||||
|
||||
def _lamp_dict(l: Lamp) -> dict:
|
||||
th = compute_thresholds(l.voltage_min, l.voltage_max)
|
||||
th = compute_thresholds(l.voltage_min, l.voltage_max,
|
||||
l.warn_pct or 20.0, l.alarm_pct or 10.0)
|
||||
return {
|
||||
"id": l.id,
|
||||
"manufacturer": l.manufacturer,
|
||||
@@ -42,6 +46,8 @@ def _lamp_dict(l: Lamp) -> dict:
|
||||
"lamp_count": l.lamp_count,
|
||||
"voltage_min": l.voltage_min,
|
||||
"voltage_max": l.voltage_max,
|
||||
"warn_pct": l.warn_pct or 20.0,
|
||||
"alarm_pct": l.alarm_pct or 10.0,
|
||||
"notes": l.notes,
|
||||
"warn_v": th["warn_v"],
|
||||
"alarm_v": th["alarm_v"],
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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):
|
||||
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 = config.get("displacement_warn_m", 10.0)
|
||||
alarm_m = config.get("displacement_alarm_m", 15.0)
|
||||
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)
|
||||
|
||||
|
||||
@@ -118,6 +118,17 @@ def decode_type8_aton(payload: str) -> dict | None:
|
||||
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:
|
||||
|
||||
@@ -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,29 +1234,59 @@ 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 <filename> from every installed cell."""
|
||||
"""Generic aggregator: read <filename> 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):
|
||||
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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
+52
-8
@@ -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); }
|
||||
|
||||
@@ -4,6 +4,14 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>AidsMonitoring — Maritime Traffic System</title>
|
||||
<script>
|
||||
// Auto-scale UI to physical monitor resolution — same breakpoints as AR ECDIS.
|
||||
(function () {
|
||||
const w = window.screen.width;
|
||||
const z = w < 1366 ? 0.80 : w < 1600 ? 0.90 : w < 1920 ? 1.00 : 1.10;
|
||||
if (z !== 1.00) document.documentElement.style.zoom = z;
|
||||
})();
|
||||
</script>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/ol@v9.2.4/ol.css">
|
||||
<link rel="stylesheet" href="css/main.css">
|
||||
</head>
|
||||
@@ -144,6 +152,29 @@
|
||||
<button class="tb-btn active" id="toggle-lang">EN/ES</button>
|
||||
<div class="toolbar-sep"></div>
|
||||
<button class="tb-btn" id="btn-sdr" title="Launch AIS-catcher (RTL-SDR receiver)">SDR</button>
|
||||
<div class="toolbar-sep"></div>
|
||||
<span class="toolbar-label">AIS</span>
|
||||
<button class="tb-btn active" id="btn-trails" title="Show vessel past tracks (breadcrumb trail)">TRAILS</button>
|
||||
<select class="tb-select" id="trail-window" title="Trail history window">
|
||||
<option value="60000">1 min</option>
|
||||
<option value="120000">2 min</option>
|
||||
<option value="360000" selected>6 min</option>
|
||||
<option value="720000">12 min</option>
|
||||
<option value="1800000">30 min</option>
|
||||
<option value="0">ALL</option>
|
||||
</select>
|
||||
<div class="toolbar-sep"></div>
|
||||
<button class="tb-btn" id="btn-vectors" title="Show COG/SOG vectors">VECT</button>
|
||||
<select class="tb-select" id="vector-mode" title="Vector mode: True (COG absolute) or Relative (minus own ship)">
|
||||
<option value="true">TRUE</option>
|
||||
<option value="relative">RELAT</option>
|
||||
</select>
|
||||
<select class="tb-select" id="vector-time" title="Vector time ahead (minutes)">
|
||||
<option value="3">3 min</option>
|
||||
<option value="6" selected>6 min</option>
|
||||
<option value="12">12 min</option>
|
||||
<option value="20">20 min</option>
|
||||
</select>
|
||||
</div>
|
||||
<div id="map"></div>
|
||||
<div id="map-coords" class="mono">LAT -- LON --</div>
|
||||
@@ -272,6 +303,11 @@
|
||||
<div id="tab-catalog" class="ctab-panel">
|
||||
<div style="font-size:0.7rem;color:var(--text-muted);margin-bottom:10px">
|
||||
Click DOWNLOAD to fetch directly from NOAA servers and install. No manual download needed.
|
||||
<a href="https://charts.coast.noaa.gov/ENCs/AllENCs.zip" target="_blank"
|
||||
style="color:var(--accent);text-decoration:none;margin-left:8px"
|
||||
title="Browse all NOAA ENCs on the NOAA Chart Portal">
|
||||
↗ NOAA ENC Portal
|
||||
</a>
|
||||
</div>
|
||||
<table class="chart-table" id="noaa-catalog-table">
|
||||
<thead><tr><th>Cell</th><th>Description</th><th>Status</th><th></th></tr></thead>
|
||||
|
||||
@@ -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'),
|
||||
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) {
|
||||
</div>
|
||||
</div>` : '';
|
||||
|
||||
// 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 = `
|
||||
<div class="modal-section-label">AIS LINK</div>
|
||||
<div style="font-size:0.68rem;color:var(--text-muted);margin-bottom:6px">
|
||||
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.
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<label class="form-label">MMSI</label>
|
||||
<input class="form-input" id="ef-mmsi" type="text" inputmode="numeric"
|
||||
pattern="\\d{6,9}" maxlength="9"
|
||||
value="${p.mmsi || ''}" placeholder="e.g. 993001002">
|
||||
</div>`;
|
||||
|
||||
return `
|
||||
<div class="modal-section-label">GENERAL</div>
|
||||
|
||||
@@ -327,12 +362,56 @@ function buildEditForm(p) {
|
||||
value="${p.radio_borneo_m ?? 10}">
|
||||
</div>
|
||||
|
||||
<div class="modal-section-label">ALERT THRESHOLDS</div>
|
||||
<div style="font-size:0.68rem;color:var(--text-muted);margin-bottom:6px">
|
||||
Leave blank to use global settings. Override per buoy based on anchor chain length and operating area.
|
||||
</div>
|
||||
<div class="field-row-modal">
|
||||
<div class="form-field">
|
||||
<label class="form-label">Drift warn (m)</label>
|
||||
<input class="form-input" id="ef-drift-warn" type="number" step="1" min="1"
|
||||
value="${p.displacement_warn_m ?? ''}" placeholder="global">
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<label class="form-label">Drift alarm (m)</label>
|
||||
<input class="form-input" id="ef-drift-alarm" type="number" step="1" min="1"
|
||||
value="${p.displacement_alarm_m ?? ''}" placeholder="global">
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<label class="form-label">Signal loss (min)</label>
|
||||
<input class="form-input" id="ef-signal-loss" type="number" step="1" min="1"
|
||||
value="${p.signal_loss_min ?? ''}" placeholder="off">
|
||||
</div>
|
||||
</div>
|
||||
<div class="field-row-modal">
|
||||
<div class="form-field">
|
||||
<label class="form-label">Digital IN3 function</label>
|
||||
<select class="form-input-select" id="ef-din3">
|
||||
<option value="">— Not connected —</option>
|
||||
<option value="WATER_INGRESS_WARN" ${p.din3_function==='WATER_INGRESS_WARN' ?'selected':''}>Water ingress (warning)</option>
|
||||
<option value="WATER_INGRESS_CRITICAL" ${p.din3_function==='WATER_INGRESS_CRITICAL' ?'selected':''}>Water ingress (critical)</option>
|
||||
<option value="LISTING" ${p.din3_function==='LISTING' ?'selected':''}>Listing / tilt sensor</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<label class="form-label">Digital IN4 function</label>
|
||||
<select class="form-input-select" id="ef-din4">
|
||||
<option value="">— Not connected —</option>
|
||||
<option value="WATER_INGRESS_WARN" ${p.din4_function==='WATER_INGRESS_WARN' ?'selected':''}>Water ingress (warning)</option>
|
||||
<option value="WATER_INGRESS_CRITICAL" ${p.din4_function==='WATER_INGRESS_CRITICAL' ?'selected':''}>Water ingress (critical)</option>
|
||||
<option value="LISTING" ${p.din4_function==='LISTING' ?'selected':''}>Listing / tilt sensor</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-field">
|
||||
<label class="form-label">Observations</label>
|
||||
<textarea class="form-textarea" id="ef-obs"
|
||||
style="height:64px">${p.observaciones || ''}</textarea>
|
||||
</div>
|
||||
|
||||
${aisBlock}
|
||||
|
||||
${nominalBlock}
|
||||
|
||||
<div class="modal-section-label">AUDIT</div>
|
||||
|
||||
+806
-58
File diff suppressed because it is too large
Load Diff
+13
-5
@@ -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) {
|
||||
<td><input class="form-input" id="lp-edit-count" type="number" value="${l.lamp_count}" style="width:60px"></td>
|
||||
<td><input class="form-input" id="lp-edit-vmin" type="number" step="0.1" value="${l.voltage_min}" style="width:70px"></td>
|
||||
<td><input class="form-input" id="lp-edit-vmax" type="number" step="0.1" value="${l.voltage_max}" style="width:70px"></td>
|
||||
<td colspan="2" style="font-size:0.7rem;color:var(--text-muted)" id="lp-edit-preview">${l.warn_v} V / ${l.alarm_v} V</td>
|
||||
<td><input class="form-input" id="lp-edit-wpct" type="number" step="1" min="1" max="50" value="${l.warn_pct ?? 20}" style="width:55px" title="Warn %"></td>
|
||||
<td><input class="form-input" id="lp-edit-apct" type="number" step="1" min="1" max="50" value="${l.alarm_pct ?? 10}" style="width:55px" title="Alarm %"></td>
|
||||
<td style="font-size:0.7rem" id="lp-edit-preview">${l.warn_v} V / ${l.alarm_v} V</td>
|
||||
<td><input class="form-input" id="lp-edit-notes" value="${l.notes || ''}"></td>
|
||||
<td style="display:flex;gap:4px">
|
||||
<button class="chart-row-btn" onclick="saveLamp('${id}')">SAVE</button>
|
||||
<button class="chart-row-btn danger" onclick="renderLampsTable()">CANCEL</button>
|
||||
</td>`;
|
||||
// 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 = `<span style="color:var(--yellow)">${t.warn} V</span> / <span style="color:var(--red)">${t.alarm} V</span>`;
|
||||
}));
|
||||
};
|
||||
@@ -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)) {
|
||||
|
||||
Reference in New Issue
Block a user