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:
2026-07-03 12:45:43 -04:00
parent 3e04c4113f
commit cfd94f905a
47 changed files with 1847 additions and 427 deletions
-11
View File
@@ -1,11 +0,0 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "lh-preview",
"runtimeExecutable": "python",
"runtimeArgs": ["-m", "http.server", "7722", "--directory", "C:\\Temp"],
"port": 7722
}
]
}
-282
View File
@@ -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
View File
@@ -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
View File
@@ -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 510×.
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
View File
@@ -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)
+6
View File
@@ -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 (maxmin).
# 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())
+3
View File
@@ -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.
+101 -2
View File
@@ -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
View File
@@ -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")
+10 -4
View File
@@ -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"],
+8 -3
View File
@@ -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)
+16
View File
@@ -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 112119)
# 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:
+107 -10
View File
@@ -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)
+10
View File
@@ -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)
+177
View File
@@ -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
View File
@@ -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); }
+36
View File
@@ -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">
&#8599; 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>
+79
View File
@@ -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 69 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
View File
File diff suppressed because it is too large Load Diff
+13 -5
View File
@@ -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)) {