generated from Grigo/AndroidTemplate
offline track
This commit is contained in:
+2
-1
@@ -22,7 +22,7 @@ python flask_app.py
|
||||
| `LORATESTER_PORT` | `7634` |
|
||||
| `LORATESTER_DB` | `./loratester.db` |
|
||||
| `LORATESTER_TELEMETRY_LIMIT` | `5000` (записей истории на устройство) |
|
||||
| `LORATESTER_TRACK_POINTS_LIMIT` | `10000` (точек на один трек) |
|
||||
| `LORATESTER_TRACK_POINTS_LIMIT` | `500000` (точек на один трек) |
|
||||
| `LORATESTER_ELEVATION_URL` | `http://192.168.1.109:8085/v1/elevation` |
|
||||
| `LORATESTER_ELEVATION_PROBE_TTL` | `60` (сек, кэш проверки доступности) |
|
||||
| `LORATESTER_ELEVATION_TIMEOUT` | `8` (сек, таймаут HTTP к сервису высот) |
|
||||
@@ -99,6 +99,7 @@ curl http://127.0.0.1:7634/api/health
|
||||
|
||||
### Треки (запись с Android)
|
||||
|
||||
- `POST /api/tracks/sync` — `{device_id, track_id?, started_at?, points[], finish?}` — офлайн-догрузка точек и завершение трека
|
||||
- `POST /api/tracks/start` — `{device_id}` → `{track_id}`
|
||||
- `POST /api/tracks/{id}/points` — `{points: [{ts, lat, lon, altitude_gps?, rssi?, role?, meta?}]}`
|
||||
- `POST /api/tracks/{id}/finish`
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -8,7 +8,7 @@ DATABASE_PATH = os.environ.get(
|
||||
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"))
|
||||
TRACK_POINTS_LIMIT = int(os.environ.get("LORATESTER_TRACK_POINTS_LIMIT", "500000"))
|
||||
ELEVATION_OPENTOPO_URL = os.environ.get(
|
||||
"LORATESTER_ELEVATION_OPENTOPO_URL",
|
||||
"http://grigowashere.ru:5300/v1/srtm30",
|
||||
|
||||
@@ -353,6 +353,79 @@ def finish_track(track_id: int) -> dict[str, Any]:
|
||||
return {"ok": True, "track_id": track_id, "ended_at": ts, "point_count": count}
|
||||
|
||||
|
||||
def sync_track(
|
||||
device_id: str,
|
||||
points: list[dict[str, Any]],
|
||||
track_id: Optional[int] = None,
|
||||
started_at: Optional[float] = None,
|
||||
finish: bool = False,
|
||||
label: Optional[str] = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Upload buffered points after offline recording; optionally create track and finish."""
|
||||
if not is_valid_device_id(device_id):
|
||||
raise ValueError(f"invalid device_id '{device_id}'")
|
||||
points = points or []
|
||||
|
||||
if track_id is not None:
|
||||
with _db() as conn:
|
||||
track = conn.execute(
|
||||
"SELECT id, device_id, ended_at FROM tracks WHERE id = ?",
|
||||
(track_id,),
|
||||
).fetchone()
|
||||
if not track:
|
||||
raise ValueError(f"track {track_id} not found")
|
||||
if track["device_id"] != device_id:
|
||||
raise ValueError("device_id does not match track owner")
|
||||
if track["ended_at"] is not None:
|
||||
raise ValueError(f"track {track_id} already finished")
|
||||
else:
|
||||
if not points and not finish:
|
||||
raise ValueError("points required when creating a new track")
|
||||
ts = float(started_at) if started_at is not None else time.time()
|
||||
with _db() as conn:
|
||||
cur = conn.execute(
|
||||
"""
|
||||
INSERT INTO tracks (device_id, started_at, label)
|
||||
VALUES (?, ?, ?)
|
||||
""",
|
||||
(device_id, ts, label),
|
||||
)
|
||||
track_id = int(cur.lastrowid)
|
||||
|
||||
added = 0
|
||||
batch_size = 100
|
||||
for i in range(0, len(points), batch_size):
|
||||
chunk = points[i : i + batch_size]
|
||||
if not chunk:
|
||||
continue
|
||||
result = add_track_points(track_id, chunk)
|
||||
added += int(result.get("added") or 0)
|
||||
|
||||
finished = False
|
||||
ended_at = None
|
||||
point_count = added
|
||||
if finish:
|
||||
fin = finish_track(track_id)
|
||||
finished = True
|
||||
ended_at = fin.get("ended_at")
|
||||
point_count = int(fin.get("point_count") or 0)
|
||||
else:
|
||||
with _db() as conn:
|
||||
point_count = conn.execute(
|
||||
"SELECT COUNT(*) FROM track_points WHERE track_id = ?",
|
||||
(track_id,),
|
||||
).fetchone()[0]
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"track_id": track_id,
|
||||
"added": added,
|
||||
"point_count": point_count,
|
||||
"finished": finished,
|
||||
"ended_at": ended_at,
|
||||
}
|
||||
|
||||
|
||||
def list_tracks(device_id: Optional[str] = None, limit: int = 50) -> list[dict[str, Any]]:
|
||||
limit = min(max(1, limit), 200)
|
||||
with _db() as conn:
|
||||
|
||||
@@ -16,7 +16,7 @@ services:
|
||||
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}
|
||||
LORATESTER_TRACK_POINTS_LIMIT: ${LORATESTER_TRACK_POINTS_LIMIT:-500000}
|
||||
|
||||
volumes:
|
||||
loratester-data:
|
||||
|
||||
+30
-1
@@ -72,6 +72,15 @@ class TrackPointsBody(BaseModel):
|
||||
points: list[TrackPoint] = Field(default_factory=list)
|
||||
|
||||
|
||||
class TrackSyncBody(BaseModel):
|
||||
device_id: str
|
||||
track_id: Optional[int] = None
|
||||
started_at: Optional[float] = None
|
||||
points: list[TrackPoint] = Field(default_factory=list)
|
||||
finish: bool = False
|
||||
label: Optional[str] = None
|
||||
|
||||
|
||||
class CommandBody(BaseModel):
|
||||
from_device_id: str
|
||||
to_device_id: str
|
||||
@@ -193,6 +202,26 @@ def tracks_finish(
|
||||
raise HTTPException(400, detail=str(e)) from e
|
||||
|
||||
|
||||
@app.post("/api/tracks/sync")
|
||||
def tracks_sync(
|
||||
body: TrackSyncBody,
|
||||
x_lora_client: Optional[str] = Header(None, alias=ANDROID_CLIENT_HEADER),
|
||||
):
|
||||
_require_android(x_lora_client)
|
||||
try:
|
||||
points = [p.model_dump(exclude_none=True) for p in body.points]
|
||||
return storage.sync_track(
|
||||
body.device_id,
|
||||
points,
|
||||
track_id=body.track_id,
|
||||
started_at=body.started_at,
|
||||
finish=body.finish,
|
||||
label=body.label,
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(400, detail=str(e)) from e
|
||||
|
||||
|
||||
@app.get("/api/tracks")
|
||||
def tracks_list(
|
||||
device_id: Optional[str] = None,
|
||||
@@ -379,7 +408,7 @@ def health():
|
||||
return {
|
||||
"ok": status["db_ok"],
|
||||
"ts": time.time(),
|
||||
"api_build": "2026-06-16i",
|
||||
"api_build": "2026-06-19a",
|
||||
**status,
|
||||
**elevation_status(),
|
||||
}
|
||||
|
||||
Binary file not shown.
@@ -45,6 +45,50 @@ def test_old_telemetry_without_meta_gets_migrated(temp_db):
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_sync_track_offline_upload(temp_db, monkeypatch):
|
||||
storage.init_db()
|
||||
monkeypatch.setattr(storage, "fetch_elevation_m", lambda lat, lon: 100.0)
|
||||
|
||||
start = storage.start_track("android-12345678")
|
||||
tid = start["track_id"]
|
||||
|
||||
result = storage.sync_track(
|
||||
"android-12345678",
|
||||
[
|
||||
{"ts": 1.0, "lat": 55.75, "lon": 37.62, "role": "TX"},
|
||||
{"ts": 2.0, "lat": 55.751, "lon": 37.621, "role": "TX"},
|
||||
],
|
||||
track_id=tid,
|
||||
finish=True,
|
||||
)
|
||||
assert result["added"] == 2
|
||||
assert result["finished"] is True
|
||||
assert result["point_count"] == 2
|
||||
|
||||
track = storage.get_track(tid)
|
||||
assert len(track["points"]) == 2
|
||||
assert track["ended_at"] is not None
|
||||
|
||||
|
||||
def test_sync_track_create_offline(temp_db, monkeypatch):
|
||||
storage.init_db()
|
||||
monkeypatch.setattr(storage, "fetch_elevation_m", lambda lat, lon: 50.0)
|
||||
|
||||
result = storage.sync_track(
|
||||
"android-abcdef01",
|
||||
[
|
||||
{"ts": 10.0, "lat": 59.93, "lon": 30.33, "role": "RX"},
|
||||
],
|
||||
track_id=None,
|
||||
started_at=10.0,
|
||||
finish=True,
|
||||
)
|
||||
assert result["track_id"] > 0
|
||||
assert result["point_count"] == 1
|
||||
track = storage.get_track(result["track_id"])
|
||||
assert track["started_at"] == 10.0
|
||||
|
||||
|
||||
def test_tracks_crud(temp_db):
|
||||
storage.init_db()
|
||||
start = storage.start_track("android-12345678")
|
||||
|
||||
Reference in New Issue
Block a user