generated from Grigo/AndroidTemplate
added local api
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
.venv
|
||||
__pycache__
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.db
|
||||
.pytest_cache
|
||||
.git
|
||||
*.md
|
||||
@@ -0,0 +1,18 @@
|
||||
FROM python:3.12-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
LORATESTER_HOST=0.0.0.0 \
|
||||
LORATESTER_PORT=7634 \
|
||||
LORATESTER_DB=/data/loratester.db
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY . .
|
||||
|
||||
EXPOSE 7634
|
||||
|
||||
CMD ["uvicorn", "fastapi_app:app", "--host", "0.0.0.0", "--port", "7634"]
|
||||
+47
-8
@@ -23,14 +23,47 @@ python flask_app.py
|
||||
| `LORATESTER_DB` | `./loratester.db` |
|
||||
| `LORATESTER_TELEMETRY_LIMIT` | `5000` (записей истории на устройство) |
|
||||
| `LORATESTER_TRACK_POINTS_LIMIT` | `10000` (точек на один трек) |
|
||||
| `LORATESTER_ELEVATION_URL` | `http://192.168.1.109:8085/v1/elevation` |
|
||||
| `LORATESTER_ELEVATION_PROBE_TTL` | `60` (сек, кэш проверки доступности) |
|
||||
| `LORATESTER_ELEVATION_TIMEOUT` | `8` (сек, таймаут HTTP к сервису высот) |
|
||||
|
||||
## Docker Compose
|
||||
|
||||
```bash
|
||||
cd server
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
Проверка:
|
||||
|
||||
```bash
|
||||
curl http://127.0.0.1:7634/api/health | jq
|
||||
```
|
||||
|
||||
Ожидается `"elevation_ok": true` если локальный Open-Meteo доступен с хоста/контейнера.
|
||||
|
||||
Переопределить URL высот (`.env` рядом с `docker-compose.yml`):
|
||||
|
||||
```env
|
||||
LORATESTER_ELEVATION_URL=http://192.168.1.109:8085/v1/elevation
|
||||
```
|
||||
|
||||
БД хранится в volume `loratester-data` (`/data/loratester.db` внутри контейнера).
|
||||
|
||||
## Деплой (grigowashere.ru:7634)
|
||||
|
||||
```bash
|
||||
cd /srv/storage/disk2/services/LoraTester
|
||||
cd /srv/storage/disk2/services/LoraTester/server
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
Или без Docker:
|
||||
|
||||
```bash
|
||||
cd /srv/storage/disk2/services/LoraTester/server
|
||||
pip install -r requirements.txt
|
||||
# один путь БД для всех воркеров:
|
||||
export LORATESTER_DB=/srv/storage/disk2/services/LoraTester/loratester.db
|
||||
export LORATESTER_ELEVATION_URL=http://192.168.1.109:8085/v1/elevation
|
||||
uvicorn fastapi_app:app --host 0.0.0.0 --port 7634
|
||||
```
|
||||
|
||||
@@ -42,7 +75,7 @@ uvicorn fastapi_app:app --host 0.0.0.0 --port 7634
|
||||
curl http://127.0.0.1:7634/api/health
|
||||
```
|
||||
|
||||
Ожидается `"db_ok": true`, `"schema_version": 4`.
|
||||
Ожидается `"db_ok": true`, `"schema_version": 4`, `"elevation_ok": true`.
|
||||
|
||||
Если БД создана вручную и схема битая (`no such table: devices` / `no such column: t.meta`):
|
||||
|
||||
@@ -66,13 +99,19 @@ curl http://127.0.0.1:7634/api/health
|
||||
- `POST /api/tracks/{id}/points` — `{points: [{ts, lat, lon, altitude_gps?, rssi?, role?, meta?}]}`
|
||||
- `POST /api/tracks/{id}/finish`
|
||||
- `GET /api/tracks?device_id=`
|
||||
- `GET /api/tracks/{id}` — метаданные + точки (высота terrain через Open-Meteo)
|
||||
- `GET /api/tracks/{id}` — метаданные + точки (высота terrain через локальный Open-Meteo)
|
||||
|
||||
### Команды (очередь на устройство)
|
||||
|
||||
- `POST /api/commands` — `{from_device_id, to_device_id, kind, payload?}`
|
||||
`kind`: `at` (`payload.line`), `mode` (`payload.role`: TX/RX), `stats_push` (снимок meta/rssi/role)
|
||||
`from_device_id`: `web` или `android-xxxxxxxx`
|
||||
`kind`: `at` (`payload.line` — одна строка, или `payload.lines` — массив макроса), `mode` (`payload.role`: TX/RX), `stats_push` (снимок meta/rssi/role/sf/bw)
|
||||
`from_device_id`: `web` или `android-xxxxxxxx`
|
||||
Макрос обычно: `S` (стоп TX/RX), затем `AT+FQ=`, `AT+PW=`, `AT+SF=`, `AT+BW=`, `AT+CR=`, `AT+PL=`, `AT+TM=`, при необходимости `AT+TX` / `AT+RX`.
|
||||
|
||||
### Профиль высот (веб, треки)
|
||||
|
||||
- `POST /api/elevation/profile` — `{points: [{lat, lon}], step_m?: 10}` → срез рельефа (локальный Open-Meteo)
|
||||
- `GET /api/tracks/{id}/elevation-profile?step_m=10` — то же по сохранённому треку
|
||||
- `GET /api/commands/pending?device_id=` — Android, доставка + `delivered_at`
|
||||
- `GET /api/commands?to_device_id=&limit=` — история (веб)
|
||||
|
||||
@@ -87,7 +126,7 @@ curl http://127.0.0.1:7634/api/health
|
||||
|
||||
- `POST /api/chat` — `{device_id, text}`
|
||||
- `GET /api/chat?since=0`
|
||||
- `GET /api/health` — `{ok, db_ok, schema_version, database_path}`
|
||||
- `GET /api/health` — `{ok, db_ok, schema_version, database_path, elevation_ok, elevation_url, elevation_error}`
|
||||
|
||||
## FastAPI (прод)
|
||||
|
||||
@@ -107,6 +146,6 @@ python -m pytest tests/ -v
|
||||
|
||||
## Android
|
||||
|
||||
URL: `http://grigowashere.ru:7634`. На карте: **Начать/Остановить трекинг пути** — точки с GPS, статистикой приёма и высотой (Open-Meteo на сервере). Вкладка **Статистика** — история с сервера.
|
||||
URL: `http://grigowashere.ru:7634`. На карте: **Начать/Остановить трекинг пути** — точки с GPS, статистикой приёма и высотой (локальный Open-Meteo на сервере). Вкладка **Статистика** — история с сервера.
|
||||
|
||||
Telnet: `127.0.0.1:2727` — мост COM→telnet на устройстве.
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -9,3 +9,13 @@ HOST = os.environ.get("LORATESTER_HOST", "0.0.0.0")
|
||||
PORT = int(os.environ.get("LORATESTER_PORT", "7634"))
|
||||
TELEMETRY_LIMIT = int(os.environ.get("LORATESTER_TELEMETRY_LIMIT", "5000"))
|
||||
TRACK_POINTS_LIMIT = int(os.environ.get("LORATESTER_TRACK_POINTS_LIMIT", "10000"))
|
||||
ELEVATION_API_URL = os.environ.get(
|
||||
"LORATESTER_ELEVATION_URL",
|
||||
"http://192.168.1.109:8085/v1/elevation",
|
||||
).rstrip("/")
|
||||
ELEVATION_PROBE_TTL_SEC = float(
|
||||
os.environ.get("LORATESTER_ELEVATION_PROBE_TTL", "60")
|
||||
)
|
||||
ELEVATION_CONNECT_TIMEOUT = float(
|
||||
os.environ.get("LORATESTER_ELEVATION_TIMEOUT", "8")
|
||||
)
|
||||
|
||||
+288
-19
@@ -1,40 +1,309 @@
|
||||
"""Terrain elevation via Open-Meteo (cached per coordinate)."""
|
||||
"""Terrain elevation via self-hosted Open-Meteo-compatible API."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Optional
|
||||
import math
|
||||
import time
|
||||
from typing import Any, Optional
|
||||
|
||||
import httpx
|
||||
|
||||
from .config import (
|
||||
ELEVATION_API_URL,
|
||||
ELEVATION_CONNECT_TIMEOUT,
|
||||
ELEVATION_PROBE_TTL_SEC,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_BATCH_SIZE = 100
|
||||
_CACHE: dict[tuple[float, float], Optional[float]] = {}
|
||||
_TIMEOUT = 3.0
|
||||
_probe_checked_at = 0.0
|
||||
_probe_ok = False
|
||||
_probe_error: Optional[str] = None
|
||||
|
||||
|
||||
def _cache_key(lat: float, lon: float) -> tuple[float, float]:
|
||||
return (round(lat, 4), round(lon, 4))
|
||||
return (round(lat, 6), round(lon, 6))
|
||||
|
||||
|
||||
def fetch_elevation_m(lat: float, lon: float) -> Optional[float]:
|
||||
key = _cache_key(lat, lon)
|
||||
if key in _CACHE:
|
||||
return _CACHE[key]
|
||||
def haversine_m(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
|
||||
r = 6_371_000.0
|
||||
d_lat = math.radians(lat2 - lat1)
|
||||
d_lon = math.radians(lon2 - lon1)
|
||||
a = (
|
||||
math.sin(d_lat / 2) ** 2
|
||||
+ math.cos(math.radians(lat1))
|
||||
* math.cos(math.radians(lat2))
|
||||
* math.sin(d_lon / 2) ** 2
|
||||
)
|
||||
return r * 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a))
|
||||
|
||||
|
||||
def probe_elevation_api(force: bool = False) -> dict[str, Any]:
|
||||
"""Ping elevation service before batch requests (cached for TTL)."""
|
||||
global _probe_checked_at, _probe_ok, _probe_error
|
||||
|
||||
now = time.monotonic()
|
||||
if (
|
||||
not force
|
||||
and _probe_checked_at > 0
|
||||
and now - _probe_checked_at < ELEVATION_PROBE_TTL_SEC
|
||||
):
|
||||
return {
|
||||
"ok": _probe_ok,
|
||||
"url": ELEVATION_API_URL,
|
||||
"error": _probe_error,
|
||||
}
|
||||
|
||||
try:
|
||||
with httpx.Client(timeout=_TIMEOUT) as client:
|
||||
with httpx.Client(timeout=ELEVATION_CONNECT_TIMEOUT) as client:
|
||||
r = client.get(
|
||||
"https://api.open-meteo.com/v1/elevation",
|
||||
params={"latitude": lat, "longitude": lon},
|
||||
ELEVATION_API_URL,
|
||||
params={"latitude": "0.000000", "longitude": "0.000000"},
|
||||
)
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
elevations = data.get("elevation") or []
|
||||
if elevations:
|
||||
val = float(elevations[0])
|
||||
_CACHE[key] = val
|
||||
return val
|
||||
if "elevation" not in data:
|
||||
raise ValueError("response has no elevation field")
|
||||
_probe_checked_at = now
|
||||
_probe_ok = True
|
||||
_probe_error = None
|
||||
logger.info("elevation API ok: %s", ELEVATION_API_URL)
|
||||
except Exception as e:
|
||||
logger.warning("open-meteo elevation failed for %s,%s: %s", lat, lon, e)
|
||||
_CACHE[key] = None
|
||||
return None
|
||||
_probe_checked_at = now
|
||||
_probe_ok = False
|
||||
_probe_error = str(e)
|
||||
logger.warning("elevation API unreachable %s: %s", ELEVATION_API_URL, e)
|
||||
|
||||
return {
|
||||
"ok": _probe_ok,
|
||||
"url": ELEVATION_API_URL,
|
||||
"error": _probe_error,
|
||||
}
|
||||
|
||||
|
||||
def elevation_status(force: bool = False) -> dict[str, Any]:
|
||||
probe = probe_elevation_api(force=force)
|
||||
return {
|
||||
"elevation_ok": probe["ok"],
|
||||
"elevation_url": probe["url"],
|
||||
"elevation_error": probe["error"],
|
||||
}
|
||||
|
||||
|
||||
def _fetch_elevation_batch(
|
||||
batch_lat: list[float], batch_lon: list[float]
|
||||
) -> list[Optional[float]]:
|
||||
if not batch_lat:
|
||||
return []
|
||||
params = {
|
||||
"latitude": ",".join(f"{lat:.6f}" for lat in batch_lat),
|
||||
"longitude": ",".join(f"{lon:.6f}" for lon in batch_lon),
|
||||
}
|
||||
with httpx.Client(timeout=ELEVATION_CONNECT_TIMEOUT) as client:
|
||||
r = client.get(ELEVATION_API_URL, params=params)
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
elevations = data.get("elevation") or []
|
||||
out: list[Optional[float]] = []
|
||||
for j, elev in enumerate(elevations):
|
||||
if j >= len(batch_lat):
|
||||
break
|
||||
if elev is None:
|
||||
out.append(None)
|
||||
else:
|
||||
out.append(float(elev))
|
||||
while len(out) < len(batch_lat):
|
||||
out.append(None)
|
||||
return out
|
||||
|
||||
|
||||
def fetch_elevation_m(lat: float, lon: float) -> Optional[float]:
|
||||
vals = fetch_elevations_batch([lat], [lon])
|
||||
return vals[0] if vals else None
|
||||
|
||||
|
||||
def fetch_elevations_batch(
|
||||
lats: list[float], lons: list[float]
|
||||
) -> list[Optional[float]]:
|
||||
if not lats or len(lats) != len(lons):
|
||||
return []
|
||||
|
||||
probe = probe_elevation_api()
|
||||
if not probe["ok"]:
|
||||
logger.warning(
|
||||
"skip elevation fetch: API unreachable (%s)",
|
||||
probe.get("error"),
|
||||
)
|
||||
return [None] * len(lats)
|
||||
|
||||
out: list[Optional[float]] = [None] * len(lats)
|
||||
pending_idx: list[int] = []
|
||||
pending_lat: list[float] = []
|
||||
pending_lon: list[float] = []
|
||||
|
||||
for i, (lat, lon) in enumerate(zip(lats, lons)):
|
||||
key = _cache_key(lat, lon)
|
||||
if key in _CACHE:
|
||||
out[i] = _CACHE[key]
|
||||
else:
|
||||
pending_idx.append(i)
|
||||
pending_lat.append(float(lat))
|
||||
pending_lon.append(float(lon))
|
||||
|
||||
for start in range(0, len(pending_lat), _BATCH_SIZE):
|
||||
batch_i = pending_idx[start : start + _BATCH_SIZE]
|
||||
batch_lat = pending_lat[start : start + _BATCH_SIZE]
|
||||
batch_lon = pending_lon[start : start + _BATCH_SIZE]
|
||||
try:
|
||||
batch_vals = _fetch_elevation_batch(batch_lat, batch_lon)
|
||||
for j, val in enumerate(batch_vals):
|
||||
lat = batch_lat[j]
|
||||
lon = batch_lon[j]
|
||||
_CACHE[_cache_key(lat, lon)] = val
|
||||
out[batch_i[j]] = val
|
||||
logger.info(
|
||||
"elevation ok: %s points, sample=%s",
|
||||
len(batch_lat),
|
||||
batch_vals[0] if batch_vals else None,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"elevation batch failed (%s points): %s",
|
||||
len(batch_lat),
|
||||
e,
|
||||
)
|
||||
for j in range(len(batch_lat)):
|
||||
try:
|
||||
single = _fetch_elevation_batch(
|
||||
[batch_lat[j]], [batch_lon[j]]
|
||||
)
|
||||
val = single[0] if single else None
|
||||
except Exception as e2:
|
||||
logger.warning(
|
||||
"elevation single failed %.6f,%.6f: %s",
|
||||
batch_lat[j],
|
||||
batch_lon[j],
|
||||
e2,
|
||||
)
|
||||
val = None
|
||||
_CACHE[_cache_key(batch_lat[j], batch_lon[j])] = val
|
||||
out[batch_i[j]] = val
|
||||
return out
|
||||
|
||||
|
||||
def _interp_at_dist(
|
||||
cleaned: list[tuple[float, float]], cum: list[float], dist_m: float
|
||||
) -> tuple[float, float]:
|
||||
if dist_m <= 0:
|
||||
return cleaned[0]
|
||||
if dist_m >= cum[-1]:
|
||||
return cleaned[-1]
|
||||
for i in range(1, len(cum)):
|
||||
if dist_m <= cum[i]:
|
||||
seg = cum[i] - cum[i - 1]
|
||||
t = 0.0 if seg <= 0 else (dist_m - cum[i - 1]) / seg
|
||||
lat1, lon1 = cleaned[i - 1]
|
||||
lat2, lon2 = cleaned[i]
|
||||
return lat1 + (lat2 - lat1) * t, lon1 + (lon2 - lon1) * t
|
||||
return cleaned[-1]
|
||||
|
||||
|
||||
def resample_track_path(
|
||||
points: list[dict[str, Any]], step_m: float = 10.0
|
||||
) -> list[dict[str, float]]:
|
||||
"""Sample (lat, lon, dist_m) along polyline every ~step_m meters."""
|
||||
if not points or step_m <= 0:
|
||||
return []
|
||||
cleaned: list[tuple[float, float]] = []
|
||||
for p in points:
|
||||
lat = p.get("lat")
|
||||
lon = p.get("lon")
|
||||
if lat is None or lon is None:
|
||||
continue
|
||||
lat_f, lon_f = float(lat), float(lon)
|
||||
if not cleaned or haversine_m(cleaned[-1][0], cleaned[-1][1], lat_f, lon_f) > 0.5:
|
||||
cleaned.append((lat_f, lon_f))
|
||||
if not cleaned:
|
||||
return []
|
||||
if len(cleaned) == 1:
|
||||
return [{"lat": cleaned[0][0], "lon": cleaned[0][1], "dist_m": 0.0}]
|
||||
|
||||
cum = [0.0]
|
||||
for i in range(1, len(cleaned)):
|
||||
cum.append(
|
||||
cum[-1]
|
||||
+ haversine_m(
|
||||
cleaned[i - 1][0], cleaned[i - 1][1], cleaned[i][0], cleaned[i][1]
|
||||
)
|
||||
)
|
||||
total = cum[-1]
|
||||
samples: list[dict[str, float]] = []
|
||||
dist = 0.0
|
||||
while dist <= total + 1e-6:
|
||||
lat, lon = _interp_at_dist(cleaned, cum, dist)
|
||||
samples.append({"lat": lat, "lon": lon, "dist_m": round(dist, 1)})
|
||||
if dist >= total:
|
||||
break
|
||||
dist += step_m
|
||||
return samples
|
||||
|
||||
|
||||
def build_elevation_profile(
|
||||
points: list[dict[str, Any]], step_m: float = 10.0
|
||||
) -> dict[str, Any]:
|
||||
"""Resample track and fetch terrain elevations."""
|
||||
step_m = max(5.0, min(10.0, float(step_m)))
|
||||
samples = resample_track_path(points, step_m)
|
||||
if not samples:
|
||||
return {
|
||||
"step_m": step_m,
|
||||
"points": [],
|
||||
"total_m": 0.0,
|
||||
"api_source": "elevation",
|
||||
"api_error": "no samples",
|
||||
}
|
||||
|
||||
probe = probe_elevation_api()
|
||||
if not probe["ok"]:
|
||||
return {
|
||||
"step_m": step_m,
|
||||
"points": [],
|
||||
"total_m": 0.0,
|
||||
"api_source": "elevation",
|
||||
"api_error": f"elevation API unreachable: {probe['error']}",
|
||||
"elevation_url": ELEVATION_API_URL,
|
||||
}
|
||||
|
||||
lats = [s["lat"] for s in samples]
|
||||
lons = [s["lon"] for s in samples]
|
||||
elevations = fetch_elevations_batch(lats, lons)
|
||||
|
||||
profile: list[dict[str, Any]] = []
|
||||
elev_vals: list[float] = []
|
||||
for s, elev in zip(samples, elevations):
|
||||
item = {
|
||||
"dist_m": round(s["dist_m"], 1),
|
||||
"lat": round(s["lat"], 6),
|
||||
"lon": round(s["lon"], 6),
|
||||
"elevation_m": elev,
|
||||
}
|
||||
profile.append(item)
|
||||
if elev is not None:
|
||||
elev_vals.append(elev)
|
||||
|
||||
total_m = profile[-1]["dist_m"] if profile else 0.0
|
||||
result: dict[str, Any] = {
|
||||
"step_m": step_m,
|
||||
"total_m": total_m,
|
||||
"min_elevation_m": min(elev_vals) if elev_vals else None,
|
||||
"max_elevation_m": max(elev_vals) if elev_vals else None,
|
||||
"points": profile,
|
||||
"api_source": "elevation",
|
||||
"elevation_url": ELEVATION_API_URL,
|
||||
}
|
||||
if not elev_vals:
|
||||
result["api_error"] = "elevation API returned no values"
|
||||
return result
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
services:
|
||||
loratester:
|
||||
build: .
|
||||
container_name: loratester
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${LORATESTER_PORT:-7634}:7634"
|
||||
volumes:
|
||||
- loratester-data:/data
|
||||
environment:
|
||||
LORATESTER_DB: /data/loratester.db
|
||||
LORATESTER_PORT: "7634"
|
||||
LORATESTER_ELEVATION_URL: ${LORATESTER_ELEVATION_URL:-http://192.168.1.109:8085/v1/elevation}
|
||||
LORATESTER_ELEVATION_PROBE_TTL: ${LORATESTER_ELEVATION_PROBE_TTL:-60}
|
||||
LORATESTER_ELEVATION_TIMEOUT: ${LORATESTER_ELEVATION_TIMEOUT:-8}
|
||||
LORATESTER_TELEMETRY_LIMIT: ${LORATESTER_TELEMETRY_LIMIT:-5000}
|
||||
LORATESTER_TRACK_POINTS_LIMIT: ${LORATESTER_TRACK_POINTS_LIMIT:-10000}
|
||||
|
||||
volumes:
|
||||
loratester-data:
|
||||
+41
-1
@@ -298,10 +298,50 @@ def paired_tracks_cancel(body: PairedTrackCancelBody):
|
||||
raise HTTPException(400, detail=str(e)) from e
|
||||
|
||||
|
||||
class ElevationPoint(BaseModel):
|
||||
lat: float
|
||||
lon: float
|
||||
ts: Optional[float] = None
|
||||
|
||||
|
||||
class ElevationProfileBody(BaseModel):
|
||||
points: list[ElevationPoint] = Field(default_factory=list)
|
||||
step_m: float = 10.0
|
||||
|
||||
|
||||
@app.post("/api/elevation/profile")
|
||||
def elevation_profile(body: ElevationProfileBody):
|
||||
from core.elevation import build_elevation_profile
|
||||
|
||||
pts = [p.model_dump(exclude_none=True) for p in body.points]
|
||||
return build_elevation_profile(pts, body.step_m)
|
||||
|
||||
|
||||
@app.get("/api/tracks/{track_id}/elevation-profile")
|
||||
def track_elevation_profile(
|
||||
track_id: int,
|
||||
step_m: float = Query(10.0, ge=5.0, le=10.0),
|
||||
):
|
||||
from core.elevation import build_elevation_profile
|
||||
|
||||
try:
|
||||
track = storage.get_track(track_id)
|
||||
except ValueError as e:
|
||||
raise HTTPException(404, detail=str(e)) from e
|
||||
return build_elevation_profile(track.get("points") or [], step_m)
|
||||
|
||||
|
||||
@app.get("/api/health")
|
||||
def health():
|
||||
from core.elevation import elevation_status
|
||||
|
||||
status = storage.db_status()
|
||||
return {"ok": status["db_ok"], "ts": time.time(), **status}
|
||||
return {
|
||||
"ok": status["db_ok"],
|
||||
"ts": time.time(),
|
||||
**status,
|
||||
**elevation_status(),
|
||||
}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
+32
-1
@@ -237,10 +237,41 @@ def paired_tracks_cancel():
|
||||
return jsonify({"error": str(e)}), 400
|
||||
|
||||
|
||||
@app.post("/api/elevation/profile")
|
||||
def elevation_profile():
|
||||
from core.elevation import build_elevation_profile
|
||||
|
||||
body = request.get_json(force=True, silent=True) or {}
|
||||
points = body.get("points") or []
|
||||
step_m = body.get("step_m", 10)
|
||||
try:
|
||||
step = float(step_m)
|
||||
except (TypeError, ValueError):
|
||||
step = 10.0
|
||||
return jsonify(build_elevation_profile(points, step))
|
||||
|
||||
|
||||
@app.get("/api/tracks/<int:track_id>/elevation-profile")
|
||||
def track_elevation_profile(track_id: int):
|
||||
from core.elevation import build_elevation_profile
|
||||
|
||||
step_m = request.args.get("step_m", 10, type=float)
|
||||
try:
|
||||
track = storage.get_track(track_id)
|
||||
except ValueError as e:
|
||||
return jsonify({"error": str(e)}), 404
|
||||
points = track.get("points") or []
|
||||
return jsonify(build_elevation_profile(points, step_m or 10.0))
|
||||
|
||||
|
||||
@app.get("/api/health")
|
||||
def health():
|
||||
from core.elevation import elevation_status
|
||||
|
||||
status = storage.db_status()
|
||||
return jsonify({"ok": status["db_ok"], "ts": time.time(), **status})
|
||||
return jsonify(
|
||||
{"ok": status["db_ok"], "ts": time.time(), **status, **elevation_status()}
|
||||
)
|
||||
|
||||
|
||||
def _float_or_none(value):
|
||||
|
||||
+965
-100
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,157 @@
|
||||
/** Shared radio stats parsing/formatting (mirror of Android RadioSnapshot). */
|
||||
(function (global) {
|
||||
'use strict';
|
||||
|
||||
const KNOWN_LABELS = new Set([
|
||||
'send', 'receive', 'frequency', 'power', 'rssi', 'snr',
|
||||
'spreading factor', 'bandwidth', 'packet', 'packet number', 'payload',
|
||||
'on air', 'tx speed', 'rx speed', 'per'
|
||||
]);
|
||||
|
||||
function roleLabel(role) {
|
||||
if (role === 'TX') return 'Передатчик (TX)';
|
||||
if (role === 'RX') return 'Приёмник (RX)';
|
||||
return role || '—';
|
||||
}
|
||||
|
||||
function isKnownLabel(label) {
|
||||
const n = String(label || '').toLowerCase().trim();
|
||||
for (const k of KNOWN_LABELS) {
|
||||
if (n === k || n.includes(k)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function parseRadioSnapshot(meta, roleFallback, rssiFallback) {
|
||||
const snap = {
|
||||
role: roleFallback || null,
|
||||
frame: null,
|
||||
frequencyMhz: null,
|
||||
sf: null,
|
||||
bwKhz: null,
|
||||
powerDbm: null,
|
||||
rssiDbm: rssiFallback ?? null,
|
||||
snrDb: null,
|
||||
packet: null,
|
||||
payload: null,
|
||||
onAirMs: null,
|
||||
txPktPerS: null,
|
||||
rxPktPerS: null,
|
||||
perPercent: null,
|
||||
extraFields: {}
|
||||
};
|
||||
if (!meta) return snap;
|
||||
let o = meta;
|
||||
if (typeof meta === 'string') {
|
||||
try { o = JSON.parse(meta); } catch (e) { return snap; }
|
||||
}
|
||||
if (o.role) snap.role = o.role;
|
||||
if (o.frame) snap.frame = o.frame;
|
||||
if (o.rssi_dbm != null) snap.rssiDbm = Number(o.rssi_dbm);
|
||||
if (o.power_dbm != null) snap.powerDbm = Number(o.power_dbm);
|
||||
if (o.snr_db != null) snap.snrDb = Number(o.snr_db);
|
||||
if (o.frequency_hz != null) snap.frequencyMhz = Number(o.frequency_hz) / 1e6;
|
||||
if (o.spreading_factor != null) snap.sf = Number(o.spreading_factor);
|
||||
if (o.bandwidth_khz != null) snap.bwKhz = Number(o.bandwidth_khz);
|
||||
if (o.packet != null) snap.packet = Number(o.packet);
|
||||
if (o.payload) snap.payload = String(o.payload);
|
||||
if (o.on_air_ms != null) snap.onAirMs = Number(o.on_air_ms);
|
||||
if (o.tx_pkt_per_s != null) snap.txPktPerS = Number(o.tx_pkt_per_s);
|
||||
if (o.rx_pkt_per_s != null) snap.rxPktPerS = Number(o.rx_pkt_per_s);
|
||||
if (o.per_percent != null) snap.perPercent = Number(o.per_percent);
|
||||
if (o.fields && typeof o.fields === 'object') {
|
||||
for (const [k, v] of Object.entries(o.fields)) {
|
||||
if (!isKnownLabel(k)) snap.extraFields[k] = String(v);
|
||||
}
|
||||
}
|
||||
return snap;
|
||||
}
|
||||
|
||||
function diffSnapshots(a, b) {
|
||||
const changed = new Set();
|
||||
if (!a || !b) return changed;
|
||||
const keys = ['role', 'rssiDbm', 'snrDb', 'packet', 'payload', 'perPercent',
|
||||
'txPktPerS', 'rxPktPerS', 'frequencyMhz', 'sf', 'bwKhz', 'powerDbm'];
|
||||
const map = { role: 'role', rssiDbm: 'rssi', snrDb: 'snr', packet: 'packet',
|
||||
payload: 'payload', perPercent: 'per', txPktPerS: 'txSpeed', rxPktPerS: 'rxSpeed',
|
||||
frequencyMhz: 'frequency', sf: 'sf', bwKhz: 'bw', powerDbm: 'power' };
|
||||
for (const k of keys) {
|
||||
if (a[k] !== b[k] && !(a[k] == null && b[k] == null)) changed.add(map[k]);
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
const DYNAMIC_ROWS = [
|
||||
{ key: 'rssi', label: 'RSSI', fmt: s => s.rssiDbm != null ? `${s.rssiDbm} dBm` : '—' },
|
||||
{ key: 'snr', label: 'SNR', fmt: s => s.snrDb != null ? `${s.snrDb} dB` : '—' },
|
||||
{ key: 'packet', label: 'Пакет', fmt: s => s.packet != null ? String(s.packet) : '—' },
|
||||
{ key: 'payload', label: 'Payload', fmt: s => s.payload || '—' },
|
||||
{ key: 'per', label: 'PER', fmt: s => s.perPercent != null ? `${s.perPercent} %` : '—' },
|
||||
{ key: 'txSpeed', label: 'TX Speed', fmt: s => s.txPktPerS != null ? `${s.txPktPerS} pkt/s` : '—' },
|
||||
{ key: 'rxSpeed', label: 'RX Speed', fmt: s => s.rxPktPerS != null ? `${s.rxPktPerS} pkt/s` : '—' }
|
||||
];
|
||||
|
||||
const STATIC_ROWS = [
|
||||
{ key: 'role', label: 'Роль', fmt: s => roleLabel(s.role) },
|
||||
{ key: 'frequency', label: 'Частота', fmt: s => s.frequencyMhz != null ? `${s.frequencyMhz.toFixed(3)} MHz` : '—' },
|
||||
{ key: 'sf', label: 'SF', fmt: s => s.sf != null ? String(s.sf) : '—' },
|
||||
{ key: 'bw', label: 'BW', fmt: s => s.bwKhz != null ? `${s.bwKhz} kHz` : '—' },
|
||||
{ key: 'power', label: 'Мощность', fmt: s => s.powerDbm != null ? `${s.powerDbm} dBm` : '—' },
|
||||
{ key: 'onAir', label: 'On Air', fmt: s => s.onAirMs != null ? `${s.onAirMs} ms` : '—' }
|
||||
];
|
||||
|
||||
function escapeHtml(s) {
|
||||
if (s == null) return '';
|
||||
return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
|
||||
function renderCompareGrid(txSnap, rxSnap, txId, rxId, changedTx, changedRx) {
|
||||
let html = '<div class="radio-compare-grid">';
|
||||
html += `<div class="radio-compare-head"><span class="legend-tx">TX</span> ${escapeHtml(txId || '—')}`;
|
||||
html += `<span class="legend-rx">RX</span> ${escapeHtml(rxId || '—')}</div>`;
|
||||
for (const row of DYNAMIC_ROWS) {
|
||||
const txCls = changedTx && changedTx.has(row.key) ? ' changed' : '';
|
||||
const rxCls = changedRx && changedRx.has(row.key) ? ' changed' : '';
|
||||
html += `<div class="radio-row"><span class="radio-label">${row.label}</span>`;
|
||||
html += `<span class="radio-tx${txCls}">${escapeHtml(row.fmt(txSnap))}</span>`;
|
||||
html += `<span class="radio-rx${rxCls}">${escapeHtml(row.fmt(rxSnap))}</span></div>`;
|
||||
}
|
||||
html += '<details class="radio-static"><summary>Статика</summary>';
|
||||
for (const row of STATIC_ROWS) {
|
||||
const txCls = changedTx && changedTx.has(row.key) ? ' changed' : '';
|
||||
const rxCls = changedRx && changedRx.has(row.key) ? ' changed' : '';
|
||||
html += `<div class="radio-row"><span class="radio-label">${row.label}</span>`;
|
||||
html += `<span class="radio-tx${txCls}">${escapeHtml(row.fmt(txSnap))}</span>`;
|
||||
html += `<span class="radio-rx${rxCls}">${escapeHtml(row.fmt(rxSnap))}</span></div>`;
|
||||
}
|
||||
html += '</details></div>';
|
||||
return html;
|
||||
}
|
||||
|
||||
function formatRadioPanel(snap, changed) {
|
||||
if (!snap) return '—';
|
||||
const ch = changed || new Set();
|
||||
let html = '';
|
||||
for (const row of DYNAMIC_ROWS) {
|
||||
const cls = ch.has(row.key) ? ' class="changed"' : '';
|
||||
html += `<div${cls}><b>${row.label}:</b> ${escapeHtml(row.fmt(snap))}</div>`;
|
||||
}
|
||||
html += '<details><summary>Статика</summary>';
|
||||
for (const row of STATIC_ROWS) {
|
||||
const cls = ch.has(row.key) ? ' class="changed"' : '';
|
||||
html += `<div${cls}><b>${row.label}:</b> ${escapeHtml(row.fmt(snap))}</div>`;
|
||||
}
|
||||
html += '</details>';
|
||||
return html;
|
||||
}
|
||||
|
||||
global.RadioUI = {
|
||||
roleLabel,
|
||||
parseRadioSnapshot,
|
||||
diffSnapshots,
|
||||
renderCompareGrid,
|
||||
formatRadioPanel,
|
||||
DYNAMIC_ROWS,
|
||||
STATIC_ROWS
|
||||
};
|
||||
})(typeof window !== 'undefined' ? window : globalThis);
|
||||
Binary file not shown.
@@ -0,0 +1,64 @@
|
||||
import core.elevation as elev
|
||||
|
||||
|
||||
class _FakeResponse:
|
||||
def __init__(self, payload):
|
||||
self._payload = payload
|
||||
|
||||
def raise_for_status(self):
|
||||
return None
|
||||
|
||||
def json(self):
|
||||
return self._payload
|
||||
|
||||
|
||||
class _FakeClient:
|
||||
def __init__(self, **kwargs):
|
||||
self.kwargs = kwargs
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *args):
|
||||
return False
|
||||
|
||||
def get(self, url, params=None):
|
||||
return _FakeResponse({"elevation": [152.0]})
|
||||
|
||||
|
||||
def test_probe_elevation_api_ok(monkeypatch):
|
||||
monkeypatch.setattr(elev, "_probe_checked_at", 0.0)
|
||||
monkeypatch.setattr(elev.httpx, "Client", _FakeClient)
|
||||
|
||||
status = elev.probe_elevation_api(force=True)
|
||||
|
||||
assert status["ok"] is True
|
||||
assert status["error"] is None
|
||||
|
||||
|
||||
def test_fetch_skips_when_unreachable(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
elev,
|
||||
"probe_elevation_api",
|
||||
lambda force=False: {"ok": False, "url": elev.ELEVATION_API_URL, "error": "down"},
|
||||
)
|
||||
|
||||
vals = elev.fetch_elevations_batch([55.75], [37.62])
|
||||
|
||||
assert vals == [None]
|
||||
|
||||
|
||||
def test_build_profile_reports_unreachable(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
elev,
|
||||
"probe_elevation_api",
|
||||
lambda force=False: {"ok": False, "url": elev.ELEVATION_API_URL, "error": "down"},
|
||||
)
|
||||
|
||||
profile = elev.build_elevation_profile(
|
||||
[{"lat": 55.75, "lon": 37.62}, {"lat": 55.76, "lon": 37.63}],
|
||||
10,
|
||||
)
|
||||
|
||||
assert profile["points"] == []
|
||||
assert "unreachable" in profile["api_error"]
|
||||
Reference in New Issue
Block a user