Initial import: WebAisMap
Closes TG-4 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,937 @@
|
||||
import os
|
||||
import time
|
||||
import socket
|
||||
import subprocess
|
||||
import json
|
||||
import threading
|
||||
import hashlib
|
||||
from urllib.request import urlopen, Request
|
||||
from urllib.parse import urlencode
|
||||
from urllib.error import URLError, HTTPError
|
||||
|
||||
from flask import Flask, jsonify, render_template, send_from_directory, send_file, request, Response
|
||||
|
||||
try:
|
||||
from flask_sock import Sock
|
||||
except ImportError:
|
||||
Sock = None
|
||||
|
||||
try:
|
||||
from websocket import create_connection as _ws_create_connection
|
||||
from websocket import WebSocketConnectionClosedException as _WsClosed
|
||||
except ImportError:
|
||||
_ws_create_connection = None
|
||||
_WsClosed = Exception
|
||||
|
||||
from state import get_cpu_usage_percent
|
||||
from network_manager import (
|
||||
load_network_config, save_network_config, get_current_network_info,
|
||||
switch_network_mode, network_config_lock, NETWORK_DEFAULTS,
|
||||
)
|
||||
from terminal import terminal_session
|
||||
from transponder import (
|
||||
load_transponder_config,
|
||||
normalize_transponder_config,
|
||||
save_transponder_config,
|
||||
merge_transponder_request,
|
||||
build_preview,
|
||||
send_transmission,
|
||||
build_slot_udp_payload,
|
||||
is_aistx_phy_available,
|
||||
parse_nrzi_hex,
|
||||
send_raw_nrzi_packet,
|
||||
tx_gpio_pulse,
|
||||
)
|
||||
|
||||
app = Flask(__name__)
|
||||
sock = Sock(app) if Sock else None
|
||||
|
||||
|
||||
# ==================== ais_hub upstream ====================
|
||||
|
||||
AIS_HUB_URL = os.environ.get("AIS_HUB_URL", "http://127.0.0.1:8081").rstrip("/")
|
||||
|
||||
|
||||
def _ais_hub_ws_url():
|
||||
base = AIS_HUB_URL
|
||||
if base.startswith("https://"):
|
||||
return "wss://" + base[len("https://"):] + "/ws"
|
||||
if base.startswith("http://"):
|
||||
return "ws://" + base[len("http://"):] + "/ws"
|
||||
return "ws://" + base + "/ws"
|
||||
|
||||
|
||||
# ==================== helpers ====================
|
||||
|
||||
def _append_client_log_line(line: str):
|
||||
"""Append a single line to client_errors.log (best-effort)."""
|
||||
try:
|
||||
path = os.path.join(os.path.dirname(__file__), "client_errors.log")
|
||||
with open(path, "a", encoding="utf-8") as f:
|
||||
f.write(line.rstrip("\n") + "\n")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# ==================== WebSocket terminal ====================
|
||||
if sock is not None and os.name == "posix":
|
||||
|
||||
@sock.route("/ws/terminal")
|
||||
def ws_terminal(ws):
|
||||
terminal_session(ws)
|
||||
|
||||
|
||||
# ==================== WebSocket proxy to ais_hub /ws ====================
|
||||
if sock is not None:
|
||||
|
||||
@sock.route("/ws")
|
||||
def ws_ais_hub_proxy(ws):
|
||||
"""Прозрачный WS-прокси к ais_hub /ws (localhost:8081)."""
|
||||
if _ws_create_connection is None:
|
||||
# Не закрываем WS сразу: иначе браузер будет переподключаться в цикле и шуметь в консоли.
|
||||
# Держим соединение открытым и периодически напоминаем про отсутствующую зависимость.
|
||||
while True:
|
||||
try:
|
||||
ws.send(json.dumps({"type": "error", "ts": time.time(),
|
||||
"data": {"error": "websocket-client not installed on server"}}))
|
||||
except Exception:
|
||||
return
|
||||
try:
|
||||
time.sleep(5)
|
||||
except Exception:
|
||||
return
|
||||
|
||||
upstream_url = _ais_hub_ws_url()
|
||||
# Если ais_hub временно недоступен, не рвём соединение с браузером:
|
||||
# держим WS открытым и периодически ретраим подключение к upstream.
|
||||
upstream = None
|
||||
backoff = 1.0
|
||||
while upstream is None:
|
||||
try:
|
||||
upstream = _ws_create_connection(upstream_url, timeout=5)
|
||||
break
|
||||
except Exception as e:
|
||||
try:
|
||||
ws.send(json.dumps({"type": "error", "ts": time.time(),
|
||||
"data": {"error": f"ais_hub unreachable: {e}"}}))
|
||||
except Exception:
|
||||
# Клиент ушёл — дальше ретраить нет смысла.
|
||||
return
|
||||
try:
|
||||
time.sleep(backoff)
|
||||
except Exception:
|
||||
return
|
||||
backoff = min(backoff * 2.0, 10.0)
|
||||
|
||||
stop = threading.Event()
|
||||
|
||||
def upstream_to_client():
|
||||
try:
|
||||
while not stop.is_set():
|
||||
try:
|
||||
msg = upstream.recv()
|
||||
except _WsClosed:
|
||||
break
|
||||
except Exception:
|
||||
break
|
||||
if msg is None or msg == "":
|
||||
break
|
||||
if isinstance(msg, bytes):
|
||||
try:
|
||||
msg = msg.decode("utf-8", errors="ignore")
|
||||
except Exception:
|
||||
continue
|
||||
try:
|
||||
ws.send(msg)
|
||||
except Exception:
|
||||
break
|
||||
finally:
|
||||
stop.set()
|
||||
|
||||
t = threading.Thread(target=upstream_to_client, daemon=True)
|
||||
t.start()
|
||||
|
||||
try:
|
||||
while not stop.is_set():
|
||||
try:
|
||||
# flask_sock/simple_websocket have slightly different receive() signatures
|
||||
# across versions; some don't support timeout=, and some raise on timeout.
|
||||
try:
|
||||
data = ws.receive(timeout=30)
|
||||
except TypeError:
|
||||
data = ws.receive()
|
||||
except Exception as e:
|
||||
# Don't tear down the whole proxy on a benign receive timeout.
|
||||
if "timeout" in str(e).lower():
|
||||
continue
|
||||
break
|
||||
if data is None:
|
||||
break
|
||||
try:
|
||||
if isinstance(data, bytes):
|
||||
upstream.send_binary(data)
|
||||
else:
|
||||
upstream.send(data)
|
||||
except Exception:
|
||||
break
|
||||
finally:
|
||||
stop.set()
|
||||
try:
|
||||
upstream.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# ==================== Pages ====================
|
||||
|
||||
@app.route("/")
|
||||
def index():
|
||||
return render_template("index.html")
|
||||
|
||||
|
||||
@app.route("/cert")
|
||||
def cert_install_page():
|
||||
return render_template("cert.html")
|
||||
|
||||
|
||||
# ==================== REST proxy to ais_hub /api/v1/* ====================
|
||||
|
||||
_HOP_HEADERS = {
|
||||
"connection", "keep-alive", "proxy-authenticate", "proxy-authorization",
|
||||
"te", "trailers", "transfer-encoding", "upgrade", "content-encoding",
|
||||
"content-length", "host",
|
||||
}
|
||||
|
||||
|
||||
@app.route("/api/v1/<path:rest>", methods=["GET", "POST", "PUT", "DELETE"])
|
||||
def proxy_ais_hub(rest):
|
||||
"""Прозрачный прокси REST /api/v1/* на ais_hub (127.0.0.1:8081)."""
|
||||
url = f"{AIS_HUB_URL}/api/v1/{rest}"
|
||||
qs = request.query_string.decode("utf-8") if request.query_string else ""
|
||||
if qs:
|
||||
url = f"{url}?{qs}"
|
||||
|
||||
method = request.method.upper()
|
||||
body = None
|
||||
if method in ("POST", "PUT"):
|
||||
body = request.get_data() or b""
|
||||
|
||||
headers = {}
|
||||
ct = request.headers.get("Content-Type")
|
||||
if ct:
|
||||
headers["Content-Type"] = ct
|
||||
|
||||
req = Request(url, data=body, method=method, headers=headers)
|
||||
timeout = 10.0 if method in ("POST", "PUT") else 5.0
|
||||
|
||||
try:
|
||||
resp = urlopen(req, timeout=timeout)
|
||||
payload = resp.read()
|
||||
status = getattr(resp, "status", 200) or 200
|
||||
out_headers = {}
|
||||
for k, v in resp.headers.items():
|
||||
if k.lower() in _HOP_HEADERS:
|
||||
continue
|
||||
out_headers[k] = v
|
||||
out_headers.setdefault("Cache-Control", "no-store")
|
||||
return Response(payload, status=status, headers=out_headers)
|
||||
except HTTPError as e:
|
||||
try:
|
||||
payload = e.read()
|
||||
except Exception:
|
||||
payload = b""
|
||||
out_headers = {}
|
||||
try:
|
||||
for k, v in e.headers.items():
|
||||
if k.lower() in _HOP_HEADERS:
|
||||
continue
|
||||
out_headers[k] = v
|
||||
except Exception:
|
||||
pass
|
||||
out_headers.setdefault("Cache-Control", "no-store")
|
||||
return Response(payload, status=e.code, headers=out_headers)
|
||||
except URLError as e:
|
||||
return jsonify({"error": f"ais_hub unreachable: {e.reason}"}), 503
|
||||
except Exception as e:
|
||||
return jsonify({"error": f"proxy error: {e}"}), 502
|
||||
|
||||
|
||||
# ==================== API ====================
|
||||
|
||||
@app.route("/api/terminal")
|
||||
def api_terminal():
|
||||
"""Доступность веб-консоли (PTY + WebSocket)."""
|
||||
return jsonify({"pty": os.name == "posix", "ws": sock is not None})
|
||||
|
||||
|
||||
@app.route("/api/version")
|
||||
def api_version():
|
||||
"""Версия/сборка для отладки кэша фронта и деплоя."""
|
||||
try:
|
||||
here = os.path.dirname(__file__)
|
||||
def _mtime(p):
|
||||
try:
|
||||
return int(os.path.getmtime(os.path.join(here, p)))
|
||||
except Exception:
|
||||
return None
|
||||
return jsonify({
|
||||
"server_time": int(time.time()),
|
||||
"routes_py_mtime": _mtime("routes.py"),
|
||||
"app_js_mtime": _mtime(os.path.join("static", "js", "app.js")),
|
||||
"index_html_mtime": _mtime(os.path.join("templates", "index.html")),
|
||||
})
|
||||
except Exception as e:
|
||||
return jsonify({"server_time": int(time.time()), "error": str(e)})
|
||||
|
||||
|
||||
@app.route("/api/sysinfo")
|
||||
def api_sysinfo():
|
||||
"""Локальная система: CPU %, температура, память, uptime. ais_hub этого не знает."""
|
||||
now = int(time.time())
|
||||
out = {"server_now": now}
|
||||
|
||||
try:
|
||||
with open("/proc/uptime", "r") as f:
|
||||
out["sys_uptime"] = int(float(f.read().split()[0]))
|
||||
except Exception:
|
||||
out["sys_uptime"] = None
|
||||
|
||||
try:
|
||||
with open("/sys/class/thermal/thermal_zone0/temp", "r") as f:
|
||||
out["cpu_temp"] = round(int(f.read().strip()) / 1000.0, 1)
|
||||
except Exception:
|
||||
out["cpu_temp"] = None
|
||||
|
||||
out["cpu_percent"] = get_cpu_usage_percent()
|
||||
|
||||
try:
|
||||
mi = {}
|
||||
with open("/proc/meminfo", "r") as f:
|
||||
for line in f:
|
||||
k, v = line.split(":", 1)
|
||||
mi[k.strip()] = int(v.strip().split()[0])
|
||||
total = mi.get("MemTotal", 0)
|
||||
avail = mi.get("MemAvailable", mi.get("MemFree", 0))
|
||||
out["mem_total_mb"] = round(total / 1024)
|
||||
out["mem_used_mb"] = round((total - avail) / 1024)
|
||||
out["mem_pct"] = round((total - avail) / total * 100, 1) if total else 0
|
||||
except Exception:
|
||||
out["mem_total_mb"] = None
|
||||
out["mem_used_mb"] = None
|
||||
out["mem_pct"] = None
|
||||
|
||||
return jsonify(out)
|
||||
|
||||
|
||||
@app.route("/api/client_log", methods=["POST"])
|
||||
def api_client_log():
|
||||
"""
|
||||
Приём логов/ошибок из браузера (front-end).
|
||||
Пишет в client_errors.log рядом с routes.py.
|
||||
"""
|
||||
try:
|
||||
data = request.get_json(force=True, silent=True) or {}
|
||||
except Exception:
|
||||
data = {}
|
||||
|
||||
level = str(data.get("level") or "info").lower()
|
||||
msg = str(data.get("msg") or "")
|
||||
ctx = data.get("ctx")
|
||||
ts = data.get("ts")
|
||||
url = data.get("url")
|
||||
ua = data.get("ua")
|
||||
|
||||
try:
|
||||
now = time.strftime("%Y-%m-%d %H:%M:%S")
|
||||
line = f"[{now}] level={level} msg={msg} url={url} ua={ua} ctx={ctx} ts={ts}"
|
||||
_append_client_log_line(line)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return jsonify({"ok": True})
|
||||
|
||||
|
||||
@app.route("/api/client_log_tail")
|
||||
def api_client_log_tail():
|
||||
"""Хвост client_errors.log для диагностики без SSH."""
|
||||
try:
|
||||
n = request.args.get("n", "200")
|
||||
try:
|
||||
n = int(n)
|
||||
except Exception:
|
||||
n = 200
|
||||
n = max(1, min(2000, n))
|
||||
|
||||
path = os.path.join(os.path.dirname(__file__), "client_errors.log")
|
||||
if not os.path.exists(path):
|
||||
return jsonify({"ok": True, "exists": False, "lines": []})
|
||||
|
||||
with open(path, "r", encoding="utf-8", errors="replace") as f:
|
||||
lines = f.readlines()
|
||||
tail = [ln.rstrip("\n") for ln in lines[-n:]]
|
||||
return jsonify({"ok": True, "exists": True, "lines": tail})
|
||||
except Exception as e:
|
||||
return jsonify({"ok": False, "error": str(e)}), 500
|
||||
|
||||
|
||||
@app.route("/api/transponder", methods=["GET"])
|
||||
def api_transponder_get():
|
||||
"""Настройки Class B. ownship тянется фронтом отдельно через /api/v1/ownship."""
|
||||
try:
|
||||
cfg = normalize_transponder_config(load_transponder_config())
|
||||
return jsonify(
|
||||
{
|
||||
"ok": True,
|
||||
"config": cfg,
|
||||
"aistx_phy_available": is_aistx_phy_available(),
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
return jsonify({"ok": False, "error": str(e)}), 500
|
||||
|
||||
|
||||
def _fetch_ownship_from_hub():
|
||||
"""Best-effort: последний GPS-fix из ais_hub. Возвращает dict (возможно пустой)."""
|
||||
try:
|
||||
resp = urlopen(f"{AIS_HUB_URL}/api/v1/ownship", timeout=2.0)
|
||||
raw = resp.read()
|
||||
own = json.loads(raw.decode("utf-8", errors="ignore"))
|
||||
if not isinstance(own, dict):
|
||||
return {}
|
||||
# Адаптируем имена полей к тем, что ожидает transponder (course/speed/...).
|
||||
return {
|
||||
"lat": own.get("lat"),
|
||||
"lon": own.get("lon"),
|
||||
"course": own.get("cog"),
|
||||
"speed": own.get("sog"),
|
||||
"heading": None,
|
||||
"timestamp": own.get("ts"),
|
||||
"satellites": own.get("sats"),
|
||||
"fix_quality": own.get("fix_quality"),
|
||||
}
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
@app.route("/api/transponder", methods=["POST"])
|
||||
def api_transponder_save():
|
||||
"""Сохранить настройки транспондера в transponder_config.json."""
|
||||
data = request.get_json(force=True) or {}
|
||||
base = load_transponder_config()
|
||||
merged = merge_transponder_request(base, data)
|
||||
saved = save_transponder_config(merged)
|
||||
return jsonify({"ok": True, "config": saved})
|
||||
|
||||
|
||||
@app.route("/api/transponder/preview", methods=["POST"])
|
||||
def api_transponder_preview():
|
||||
"""NRZI hex для типов 18/19/24 без отправки (отправка — 127.0.0.1:6010)."""
|
||||
data = request.get_json(force=True) or {}
|
||||
base = load_transponder_config()
|
||||
cfg = merge_transponder_request(base, data)
|
||||
own = _fetch_ownship_from_hub()
|
||||
try:
|
||||
prev = build_preview(cfg, own)
|
||||
except RuntimeError as e:
|
||||
return jsonify({"ok": False, "error": str(e)}), 503
|
||||
except Exception as e:
|
||||
return jsonify({"ok": False, "error": str(e)}), 400
|
||||
prev.pop("dictionaries", None)
|
||||
return jsonify({"ok": True, "preview": prev})
|
||||
|
||||
|
||||
@app.route("/api/transponder/send", methods=["POST"])
|
||||
def api_transponder_send():
|
||||
"""NRZI по UDP на 127.0.0.1:6010 с заголовком канал+слот (как тест слота)."""
|
||||
data = request.get_json(force=True) or {}
|
||||
which = data.get("which")
|
||||
if which not in ("18", "19", "24A", "24B", "broadcast"):
|
||||
return jsonify(
|
||||
{"ok": False, "error": "which must be 18, 19, 24A, 24B, broadcast"}
|
||||
), 400
|
||||
|
||||
cfg_body = {k: v for k, v in data.items() if k != "which"}
|
||||
base = load_transponder_config()
|
||||
cfg = merge_transponder_request(base, cfg_body)
|
||||
own = _fetch_ownship_from_hub()
|
||||
try:
|
||||
result = send_transmission(cfg, own, which)
|
||||
except RuntimeError as e:
|
||||
return jsonify({"ok": False, "error": str(e)}), 503
|
||||
except ValueError as e:
|
||||
return jsonify({"ok": False, "error": str(e)}), 400
|
||||
except Exception as e:
|
||||
return jsonify({"ok": False, "error": str(e)}), 500
|
||||
return jsonify({"ok": True, "result": result})
|
||||
|
||||
|
||||
@app.route("/api/transponder/gpio_pulse", methods=["POST"])
|
||||
def api_transponder_gpio_pulse():
|
||||
"""Один импульс TX (GPIO), без UDP. Настройки скрипта — из тела запроса или transponder_config."""
|
||||
data = request.get_json(force=True) or {}
|
||||
base = load_transponder_config()
|
||||
cfg = merge_transponder_request(base, data)
|
||||
pulse = tx_gpio_pulse(cfg, after_udp=False)
|
||||
if not pulse.get("ok"):
|
||||
err = pulse.get("error") or pulse.get("stderr") or "GPIO pulse failed"
|
||||
return jsonify({"ok": False, "error": err, "pulse": pulse}), 400
|
||||
return jsonify({"ok": True, "pulse": pulse})
|
||||
|
||||
|
||||
@app.route("/api/transponder/send_raw", methods=["POST"])
|
||||
def api_transponder_send_raw():
|
||||
"""Сырой NRZI (hex) + канал + слот → UDP 127.0.0.1:6010."""
|
||||
data = request.get_json(force=True) or {}
|
||||
hx = data.get("nrzi_hex") or data.get("hex")
|
||||
if not isinstance(hx, str) or not hx.strip():
|
||||
return jsonify({"ok": False, "error": "nrzi_hex (hex строка) обязателен"}), 400
|
||||
channel = data.get("channel", "A")
|
||||
try:
|
||||
slot = int(data.get("slot", 0))
|
||||
except (TypeError, ValueError):
|
||||
return jsonify({"ok": False, "error": "slot must be integer 0..2249"}), 400
|
||||
if channel not in ("A", "B"):
|
||||
return jsonify({"ok": False, "error": "channel must be A or B"}), 400
|
||||
if slot < 0 or slot > 2249:
|
||||
return jsonify({"ok": False, "error": "slot must be 0..2249"}), 400
|
||||
try:
|
||||
body = parse_nrzi_hex(hx)
|
||||
except ValueError as e:
|
||||
return jsonify({"ok": False, "error": str(e)}), 400
|
||||
if not body:
|
||||
return jsonify({"ok": False, "error": "пустой payload после hex"}), 400
|
||||
cfg_body = {
|
||||
k: v
|
||||
for k, v in data.items()
|
||||
if k not in ("nrzi_hex", "hex", "channel", "slot")
|
||||
}
|
||||
base = load_transponder_config()
|
||||
cfg = merge_transponder_request(base, cfg_body)
|
||||
try:
|
||||
result = send_raw_nrzi_packet(channel, slot, body, cfg=cfg)
|
||||
except ValueError as e:
|
||||
return jsonify({"ok": False, "error": str(e)}), 400
|
||||
except Exception as e:
|
||||
return jsonify({"ok": False, "error": str(e)}), 500
|
||||
return jsonify({"ok": True, "result": result})
|
||||
|
||||
|
||||
# Аппаратно проверенная NRZI-последовательность для «Тест слота».
|
||||
TEST_AIS_NRZI = bytes([
|
||||
102, 102, 102, 254, 149, 61, 224, 94, 245, 171, 174, 169,
|
||||
74, 84, 87, 105, 51, 82, 202, 166, 141, 99, 170, 170,
|
||||
170, 253, 236, 63, 170, 170, 170, 170,
|
||||
])
|
||||
|
||||
|
||||
@app.route("/api/send_test_slot", methods=["POST"])
|
||||
def api_send_test_slot():
|
||||
"""Отправляет тестовую датаграмму AIS NRZI в указанный слот/канал (UDP 127.0.0.1:6010)."""
|
||||
data = request.get_json(force=True)
|
||||
if not data:
|
||||
return jsonify({"ok": False, "error": "Empty payload"}), 400
|
||||
|
||||
channel = data.get("channel")
|
||||
slot = data.get("slot")
|
||||
|
||||
if channel not in ("A", "B"):
|
||||
return jsonify({"ok": False, "error": "channel must be 'A' or 'B'"}), 400
|
||||
if not isinstance(slot, int) or slot < 0 or slot > 2249:
|
||||
return jsonify({"ok": False, "error": "slot must be 0..2249"}), 400
|
||||
|
||||
dest = ("127.0.0.1", 6010)
|
||||
datagram = build_slot_udp_payload(channel, slot, TEST_AIS_NRZI)
|
||||
|
||||
try:
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
s.sendto(datagram, dest)
|
||||
s.close()
|
||||
return jsonify({"ok": True, "dest": f"{dest[0]}:{dest[1]}", "size": len(datagram)})
|
||||
except Exception as e:
|
||||
return jsonify({"ok": False, "error": str(e)}), 500
|
||||
|
||||
|
||||
# ==================== Network API ====================
|
||||
|
||||
@app.route("/api/network", methods=["GET"])
|
||||
def api_network_get():
|
||||
"""Returns current network configuration and live status."""
|
||||
with network_config_lock:
|
||||
cfg = load_network_config()
|
||||
live = get_current_network_info()
|
||||
return jsonify({"config": cfg, "live": live})
|
||||
|
||||
|
||||
@app.route("/api/network", methods=["POST"])
|
||||
def api_network_save():
|
||||
"""Saves network configuration (does NOT switch mode immediately)."""
|
||||
data = request.get_json(force=True)
|
||||
if not data:
|
||||
return jsonify({"ok": False, "error": "Empty payload"}), 400
|
||||
|
||||
with network_config_lock:
|
||||
cfg = load_network_config()
|
||||
for key in NETWORK_DEFAULTS:
|
||||
if key in data:
|
||||
cfg[key] = data[key]
|
||||
save_network_config(cfg)
|
||||
return jsonify({"ok": True, "config": cfg})
|
||||
|
||||
|
||||
@app.route("/api/network/switch", methods=["POST"])
|
||||
def api_network_switch():
|
||||
"""Switches network mode to AP or WiFi by running the shell script."""
|
||||
data = request.get_json(force=True)
|
||||
target = data.get("mode") if data else None
|
||||
if target not in ("ap", "wifi"):
|
||||
return jsonify({"ok": False, "error": "Provide 'mode': 'ap' or 'wifi'"}), 400
|
||||
|
||||
with network_config_lock:
|
||||
cfg = load_network_config()
|
||||
for key in NETWORK_DEFAULTS:
|
||||
if key in data and key != "mode":
|
||||
cfg[key] = data[key]
|
||||
save_network_config(cfg)
|
||||
|
||||
result = switch_network_mode(target)
|
||||
return jsonify(result)
|
||||
|
||||
|
||||
AIS_MINI_CONF = "/ais-mini.conf"
|
||||
AIS_MINI_SERVICE = "aisMini.service"
|
||||
|
||||
AIS_HUB_CONF = "/opt/aishub/config/config.yaml"
|
||||
AIS_HUB_SERVICE = "ais_hub.service"
|
||||
|
||||
|
||||
@app.route("/api/config", methods=["GET"])
|
||||
def api_config_get():
|
||||
"""Returns the content of the AIS-catcher Mini configuration file."""
|
||||
try:
|
||||
with open(AIS_MINI_CONF, "r") as f:
|
||||
text = f.read()
|
||||
return jsonify({"ok": True, "text": text})
|
||||
except FileNotFoundError:
|
||||
return jsonify({"ok": False, "error": f"File not found: {AIS_MINI_CONF}"}), 404
|
||||
except Exception as e:
|
||||
return jsonify({"ok": False, "error": str(e)}), 500
|
||||
|
||||
|
||||
@app.route("/api/config", methods=["POST"])
|
||||
def api_config_save():
|
||||
"""Saves the AIS-catcher Mini configuration file."""
|
||||
data = request.get_json(force=True)
|
||||
text = data.get("text") if data else None
|
||||
if text is None:
|
||||
return jsonify({"ok": False, "error": "No 'text' field"}), 400
|
||||
try:
|
||||
with open(AIS_MINI_CONF, "w") as f:
|
||||
f.write(text)
|
||||
return jsonify({"ok": True})
|
||||
except Exception as e:
|
||||
return jsonify({"ok": False, "error": str(e)}), 500
|
||||
|
||||
|
||||
@app.route("/api/service/restart", methods=["POST"])
|
||||
def api_service_restart():
|
||||
"""Restarts the aisMini.service via systemctl."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["systemctl", "restart", AIS_MINI_SERVICE],
|
||||
capture_output=True, text=True, timeout=30,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
return jsonify({"ok": True, "message": f"{AIS_MINI_SERVICE} restarted"})
|
||||
else:
|
||||
return jsonify({"ok": False, "error": result.stderr.strip() or f"Exit code {result.returncode}"}), 500
|
||||
except subprocess.TimeoutExpired:
|
||||
return jsonify({"ok": False, "error": "Restart timed out"}), 500
|
||||
except Exception as e:
|
||||
return jsonify({"ok": False, "error": str(e)}), 500
|
||||
|
||||
|
||||
@app.route("/api/service/status", methods=["GET"])
|
||||
def api_service_status():
|
||||
"""Returns the status of aisMini.service."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["systemctl", "is-active", AIS_MINI_SERVICE],
|
||||
capture_output=True, text=True, timeout=5,
|
||||
)
|
||||
state = result.stdout.strip()
|
||||
return jsonify({"ok": True, "state": state})
|
||||
except Exception as e:
|
||||
return jsonify({"ok": True, "state": "unknown", "error": str(e)})
|
||||
|
||||
|
||||
@app.route("/api/config/aishub", methods=["GET"])
|
||||
def api_aishub_config_get():
|
||||
"""Returns the content of the ais_hub configuration YAML."""
|
||||
try:
|
||||
with open(AIS_HUB_CONF, "r", encoding="utf-8", errors="replace") as f:
|
||||
text = f.read()
|
||||
return jsonify({"ok": True, "text": text})
|
||||
except FileNotFoundError:
|
||||
return jsonify({"ok": False, "error": f"File not found: {AIS_HUB_CONF}"}), 404
|
||||
except Exception as e:
|
||||
return jsonify({"ok": False, "error": str(e)}), 500
|
||||
|
||||
|
||||
@app.route("/api/config/aishub", methods=["POST"])
|
||||
def api_aishub_config_save():
|
||||
"""Saves the ais_hub configuration YAML."""
|
||||
data = request.get_json(force=True)
|
||||
text = data.get("text") if data else None
|
||||
if text is None:
|
||||
return jsonify({"ok": False, "error": "No 'text' field"}), 400
|
||||
try:
|
||||
with open(AIS_HUB_CONF, "w", encoding="utf-8") as f:
|
||||
f.write(text)
|
||||
return jsonify({"ok": True})
|
||||
except Exception as e:
|
||||
return jsonify({"ok": False, "error": str(e)}), 500
|
||||
|
||||
|
||||
@app.route("/api/service/aishub/status", methods=["GET"])
|
||||
def api_aishub_service_status():
|
||||
"""Returns the status of ais_hub.service."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["systemctl", "is-active", AIS_HUB_SERVICE],
|
||||
capture_output=True, text=True, timeout=5,
|
||||
)
|
||||
state = result.stdout.strip()
|
||||
return jsonify({"ok": True, "state": state})
|
||||
except Exception as e:
|
||||
return jsonify({"ok": True, "state": "unknown", "error": str(e)})
|
||||
|
||||
|
||||
@app.route("/api/service/aishub/restart", methods=["POST"])
|
||||
def api_aishub_service_restart():
|
||||
"""Restarts ais_hub.service via systemctl."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["systemctl", "restart", AIS_HUB_SERVICE],
|
||||
capture_output=True, text=True, timeout=30,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
return jsonify({"ok": True, "message": f"{AIS_HUB_SERVICE} restarted"})
|
||||
else:
|
||||
return jsonify({"ok": False, "error": result.stderr.strip() or f"Exit code {result.returncode}"}), 500
|
||||
except subprocess.TimeoutExpired:
|
||||
return jsonify({"ok": False, "error": "Restart timed out"}), 500
|
||||
except Exception as e:
|
||||
return jsonify({"ok": False, "error": str(e)}), 500
|
||||
|
||||
|
||||
@app.route("/api/network/scan", methods=["GET"])
|
||||
def api_network_scan():
|
||||
"""Scans for available WiFi networks (Linux/iw only)."""
|
||||
cfg = load_network_config()
|
||||
iface = cfg.get("iface", "wlan0")
|
||||
try:
|
||||
out = subprocess.check_output(
|
||||
["iw", "dev", iface, "scan", "ap-force"],
|
||||
timeout=15, stderr=subprocess.DEVNULL,
|
||||
).decode()
|
||||
networks = []
|
||||
current = {}
|
||||
for line in out.splitlines():
|
||||
line = line.strip()
|
||||
if line.startswith("BSS "):
|
||||
if current.get("ssid"):
|
||||
networks.append(current)
|
||||
current = {"bssid": line.split()[1].rstrip("("), "ssid": "", "signal": None}
|
||||
elif line.startswith("SSID:"):
|
||||
current["ssid"] = line.split(":", 1)[1].strip()
|
||||
elif line.startswith("signal:"):
|
||||
try:
|
||||
current["signal"] = float(line.split(":")[1].strip().split()[0])
|
||||
except (ValueError, IndexError):
|
||||
pass
|
||||
if current.get("ssid"):
|
||||
networks.append(current)
|
||||
seen = set()
|
||||
unique = []
|
||||
for n in networks:
|
||||
if n["ssid"] not in seen:
|
||||
seen.add(n["ssid"])
|
||||
unique.append(n)
|
||||
unique.sort(key=lambda x: x.get("signal") or -999, reverse=True)
|
||||
return jsonify({"ok": True, "networks": unique})
|
||||
except subprocess.TimeoutExpired:
|
||||
return jsonify({"ok": False, "error": "Scan timed out"})
|
||||
except Exception as e:
|
||||
return jsonify({"ok": False, "error": str(e), "networks": []})
|
||||
|
||||
|
||||
# ==================== Certificate downloads ====================
|
||||
|
||||
@app.route("/ca.pem")
|
||||
def download_ca():
|
||||
"""Отдаёт корневой CA-сертификат для установки на клиентские устройства."""
|
||||
ca_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "ca.pem")
|
||||
if not os.path.exists(ca_path):
|
||||
return "CA certificate not generated", 404
|
||||
return send_file(ca_path, mimetype="application/x-pem-file",
|
||||
as_attachment=True, download_name="AISMap_CA.pem")
|
||||
|
||||
|
||||
@app.route("/ca.crt")
|
||||
def download_ca_crt():
|
||||
"""Тот же CA, но с расширением .crt — Android открывает установщик автоматически."""
|
||||
ca_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "ca.pem")
|
||||
if not os.path.exists(ca_path):
|
||||
return "CA certificate not generated", 404
|
||||
return send_file(ca_path, mimetype="application/x-x509-ca-cert",
|
||||
as_attachment=True, download_name="AISMap_CA.crt")
|
||||
|
||||
|
||||
# ==================== Static files with caching ====================
|
||||
|
||||
IMMUTABLE_YEAR = "public, max-age=31536000, immutable"
|
||||
STATIC_MONTH = "public, max-age=2592000"
|
||||
SHORT_REVALIDATE = "public, max-age=60, must-revalidate"
|
||||
|
||||
|
||||
def _etag_for_path(path):
|
||||
try:
|
||||
st = os.stat(path)
|
||||
h = hashlib.md5(f"{st.st_mtime_ns}:{st.st_size}".encode("ascii")).hexdigest()
|
||||
return f'W/"{h}"'
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _serve_with_cache(directory, filename, cache_control):
|
||||
"""send_from_directory + заголовок Cache-Control + ETag/304."""
|
||||
abs_path = os.path.join(directory, filename)
|
||||
etag = _etag_for_path(abs_path)
|
||||
inm = request.headers.get("If-None-Match")
|
||||
if etag and inm and inm == etag:
|
||||
resp = Response(status=304)
|
||||
resp.headers["ETag"] = etag
|
||||
resp.headers["Cache-Control"] = cache_control
|
||||
return resp
|
||||
resp = send_from_directory(directory, filename)
|
||||
resp.headers["Cache-Control"] = cache_control
|
||||
if etag:
|
||||
resp.headers["ETag"] = etag
|
||||
return resp
|
||||
|
||||
|
||||
@app.route("/svg/<path:filename>")
|
||||
def serve_svg(filename):
|
||||
"""SVG-иконки — редко меняются, долгий кеш."""
|
||||
return _serve_with_cache("SVG", filename, STATIC_MONTH)
|
||||
|
||||
|
||||
@app.route("/static/leaflet/<path:filename>")
|
||||
def serve_leaflet(filename):
|
||||
"""Leaflet (версионирован по содержимому) — immutable."""
|
||||
return _serve_with_cache("static/leaflet", filename, IMMUTABLE_YEAR)
|
||||
|
||||
|
||||
@app.route("/static/xterm/<path:filename>")
|
||||
def serve_xterm(filename):
|
||||
"""xterm.js — immutable."""
|
||||
return _serve_with_cache("static/xterm", filename, IMMUTABLE_YEAR)
|
||||
|
||||
|
||||
@app.route("/static/js/<path:filename>")
|
||||
def serve_static_js(filename):
|
||||
"""app.js, модули — SWR: всегда проверяем обновление, но отдаём из кеша если не изменилось."""
|
||||
return _serve_with_cache("static/js", filename, SHORT_REVALIDATE)
|
||||
|
||||
|
||||
@app.route("/static/css/<path:filename>")
|
||||
def serve_static_css(filename):
|
||||
return _serve_with_cache("static/css", filename, SHORT_REVALIDATE)
|
||||
|
||||
|
||||
@app.route("/sw.js")
|
||||
def serve_sw():
|
||||
"""Service Worker должен быть в корне для scope '/'."""
|
||||
path = os.path.join(os.path.dirname(__file__), "static", "sw.js")
|
||||
if not os.path.exists(path):
|
||||
return "sw.js not found", 404
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
body = f.read()
|
||||
resp = Response(body, mimetype="application/javascript")
|
||||
resp.headers["Cache-Control"] = "no-store"
|
||||
resp.headers["Service-Worker-Allowed"] = "/"
|
||||
return resp
|
||||
|
||||
|
||||
VECTOR_TILE_URL = os.environ.get("AISMAP_VECTOR_TILE_URL", "http://127.0.0.1:8080").rstrip("/")
|
||||
VECTOR_TILE_SERVICE = os.environ.get("AISMAP_VECTOR_TILE_SERVICE", "planet_small_z14").strip().strip("/")
|
||||
|
||||
|
||||
@app.route("/vtiles/<int:z>/<int:x>/<int:y>.pbf")
|
||||
def proxy_vector_tile(z, x, y):
|
||||
"""Проксирует PBF-тайлы с локального векторного тайлсервера. immutable: тайл не меняется."""
|
||||
try:
|
||||
candidates = [
|
||||
f"{VECTOR_TILE_URL}/services/{VECTOR_TILE_SERVICE}/tiles/{z}/{x}/{y}.pbf",
|
||||
f"{VECTOR_TILE_URL}/services/{VECTOR_TILE_SERVICE}/{z}/{x}/{y}.pbf",
|
||||
f"{VECTOR_TILE_URL}/data/{VECTOR_TILE_SERVICE}/{z}/{x}/{y}.pbf",
|
||||
f"{VECTOR_TILE_URL}/{VECTOR_TILE_SERVICE}/{z}/{x}/{y}.pbf",
|
||||
]
|
||||
|
||||
last_err = None
|
||||
for url in candidates:
|
||||
try:
|
||||
resp = urlopen(url, timeout=5)
|
||||
data = resp.read()
|
||||
code = getattr(resp, "status", 200) or 200
|
||||
headers = {
|
||||
"Content-Type": resp.headers.get("Content-Type", "application/x-protobuf"),
|
||||
"Cache-Control": IMMUTABLE_YEAR,
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"X-AISMap-Upstream": url,
|
||||
}
|
||||
ce = resp.headers.get("Content-Encoding")
|
||||
if ce:
|
||||
headers["Content-Encoding"] = ce
|
||||
return data, code, headers
|
||||
except HTTPError as e:
|
||||
last_err = e
|
||||
if getattr(e, "code", None) != 404:
|
||||
raise
|
||||
except URLError as e:
|
||||
last_err = e
|
||||
break
|
||||
|
||||
if isinstance(last_err, HTTPError) and getattr(last_err, "code", None) == 404:
|
||||
return "Vector tile not found", 404
|
||||
return "Vector tile upstream unavailable", 503
|
||||
except (URLError, HTTPError):
|
||||
return "Vector tile upstream error", 503
|
||||
|
||||
|
||||
@app.route("/tiles/<int:z>/<int:x>/<int:y>.png")
|
||||
def serve_tile(z, x, y):
|
||||
"""Отдаёт растровые тайлы из static/tiles с вечным кешем (immutable)."""
|
||||
max_tile = 2 ** z
|
||||
if x < 0 or x >= max_tile or y < 0 or y >= max_tile:
|
||||
return "Invalid tile coordinates", 404
|
||||
|
||||
rel_dir = os.path.join("static", "tiles", str(z), str(x))
|
||||
tile_path = os.path.join(rel_dir, f"{y}.png")
|
||||
|
||||
if not os.path.exists(tile_path):
|
||||
return "Tile not found", 404
|
||||
|
||||
etag = _etag_for_path(tile_path)
|
||||
inm = request.headers.get("If-None-Match")
|
||||
if etag and inm and inm == etag:
|
||||
resp = Response(status=304)
|
||||
resp.headers["ETag"] = etag
|
||||
resp.headers["Cache-Control"] = IMMUTABLE_YEAR
|
||||
return resp
|
||||
|
||||
resp = send_from_directory(rel_dir, f"{y}.png")
|
||||
resp.headers["Cache-Control"] = IMMUTABLE_YEAR
|
||||
if etag:
|
||||
resp.headers["ETag"] = etag
|
||||
return resp
|
||||
Reference in New Issue
Block a user