storage.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396
  1. from __future__ import annotations
  2. import os
  3. import sqlite3
  4. import threading
  5. import time
  6. from dataclasses import dataclass
  7. from pathlib import Path
  8. from typing import List, Optional
  9. @dataclass(frozen=True)
  10. class RouteInfo:
  11. id: int
  12. name: str
  13. mode: str
  14. created_at: int
  15. finished_at: Optional[int]
  16. points: int
  17. hazards: int
  18. active: bool
  19. monitoring: bool
  20. @dataclass(frozen=True)
  21. class RoutePoint:
  22. seq: int
  23. t: int
  24. lat: float
  25. lon: float
  26. speed_mps: float
  27. cum_dist_m: float
  28. counter_value: int = 0
  29. counter_absolute: int = 0
  30. @dataclass(frozen=True)
  31. class Hazard:
  32. id: int
  33. route_id: int
  34. seq: int
  35. t: int
  36. lat: float
  37. lon: float
  38. cum_dist_m: float
  39. type: str
  40. note: str
  41. counter_value: int = 0
  42. counter_absolute: int = 0
  43. class NavDB:
  44. def __init__(self, db_path: str):
  45. self.db_path = db_path
  46. os.makedirs(os.path.dirname(db_path), exist_ok=True)
  47. self._lock = threading.Lock()
  48. self._conn = sqlite3.connect(db_path, check_same_thread=False)
  49. self._conn.row_factory = sqlite3.Row
  50. self._init_schema()
  51. @classmethod
  52. def default(cls) -> "NavDB":
  53. base = Path(__file__).resolve().parents[2]
  54. return cls(str(base / "data" / "nav_copilot.sqlite3"))
  55. def _init_schema(self) -> None:
  56. with self._lock:
  57. cur = self._conn.cursor()
  58. cur.execute(
  59. """
  60. CREATE TABLE IF NOT EXISTS routes (
  61. id INTEGER PRIMARY KEY AUTOINCREMENT,
  62. name TEXT NOT NULL,
  63. mode TEXT NOT NULL DEFAULT 'gps',
  64. created_at INTEGER NOT NULL,
  65. finished_at INTEGER,
  66. active INTEGER NOT NULL DEFAULT 0,
  67. monitoring INTEGER NOT NULL DEFAULT 0
  68. )
  69. """
  70. )
  71. cur.execute(
  72. """
  73. CREATE TABLE IF NOT EXISTS route_points (
  74. route_id INTEGER NOT NULL,
  75. seq INTEGER NOT NULL,
  76. t INTEGER NOT NULL,
  77. lat REAL NOT NULL,
  78. lon REAL NOT NULL,
  79. speed_mps REAL NOT NULL,
  80. cum_dist_m REAL NOT NULL,
  81. counter_value INTEGER NOT NULL DEFAULT 0,
  82. counter_absolute INTEGER NOT NULL DEFAULT 0,
  83. PRIMARY KEY(route_id, seq)
  84. )
  85. """
  86. )
  87. cur.execute(
  88. """
  89. CREATE TABLE IF NOT EXISTS hazards (
  90. id INTEGER PRIMARY KEY AUTOINCREMENT,
  91. route_id INTEGER NOT NULL,
  92. seq INTEGER NOT NULL,
  93. t INTEGER NOT NULL,
  94. lat REAL NOT NULL,
  95. lon REAL NOT NULL,
  96. cum_dist_m REAL NOT NULL,
  97. type TEXT NOT NULL,
  98. note TEXT NOT NULL DEFAULT '',
  99. counter_value INTEGER NOT NULL DEFAULT 0,
  100. counter_absolute INTEGER NOT NULL DEFAULT 0
  101. )
  102. """
  103. )
  104. self._ensure_column(cur, "routes", "mode", "TEXT NOT NULL DEFAULT 'gps'")
  105. self._ensure_column(
  106. cur,
  107. "routes",
  108. "monitoring",
  109. "INTEGER NOT NULL DEFAULT 0",
  110. )
  111. self._ensure_column(
  112. cur,
  113. "route_points",
  114. "counter_value",
  115. "INTEGER NOT NULL DEFAULT 0",
  116. )
  117. self._ensure_column(
  118. cur,
  119. "route_points",
  120. "counter_absolute",
  121. "INTEGER NOT NULL DEFAULT 0",
  122. )
  123. self._ensure_column(
  124. cur,
  125. "hazards",
  126. "counter_value",
  127. "INTEGER NOT NULL DEFAULT 0",
  128. )
  129. self._ensure_column(
  130. cur,
  131. "hazards",
  132. "counter_absolute",
  133. "INTEGER NOT NULL DEFAULT 0",
  134. )
  135. self._conn.commit()
  136. def _ensure_column(
  137. self,
  138. cur: sqlite3.Cursor,
  139. table_name: str,
  140. column_name: str,
  141. column_ddl: str,
  142. ) -> None:
  143. columns = {
  144. str(row["name"])
  145. for row in cur.execute(f"PRAGMA table_info({table_name})").fetchall()
  146. }
  147. if column_name not in columns:
  148. cur.execute(
  149. f"ALTER TABLE {table_name} ADD COLUMN {column_name} {column_ddl}"
  150. )
  151. def create_route(self, name: str, mode: str = "gps") -> int:
  152. now = int(time.time())
  153. route_mode = (mode or "gps").strip() or "gps"
  154. with self._lock:
  155. self._conn.execute("UPDATE routes SET active=0, monitoring=0")
  156. cur = self._conn.execute(
  157. "INSERT INTO routes(name, mode, created_at, active, monitoring) VALUES(?, ?, ?, 1, 0)",
  158. (name, route_mode, now),
  159. )
  160. self._conn.commit()
  161. return int(cur.lastrowid)
  162. def finish_route(self, route_id: int) -> None:
  163. now = int(time.time())
  164. with self._lock:
  165. self._conn.execute(
  166. "UPDATE routes SET finished_at=? WHERE id=?",
  167. (now, route_id),
  168. )
  169. self._conn.commit()
  170. def set_active_route(self, route_id: int) -> None:
  171. with self._lock:
  172. self._conn.execute("UPDATE routes SET active=0")
  173. self._conn.execute("UPDATE routes SET active=1 WHERE id=?", (route_id,))
  174. self._conn.commit()
  175. def get_active_route_id(self) -> Optional[int]:
  176. with self._lock:
  177. row = self._conn.execute(
  178. "SELECT id FROM routes WHERE active=1 ORDER BY id DESC LIMIT 1"
  179. ).fetchone()
  180. return int(row["id"]) if row else None
  181. def set_monitoring_route(self, route_id: Optional[int]) -> None:
  182. with self._lock:
  183. self._conn.execute("UPDATE routes SET monitoring=0")
  184. if route_id is not None:
  185. self._conn.execute(
  186. "UPDATE routes SET monitoring=1 WHERE id=?",
  187. (int(route_id),),
  188. )
  189. self._conn.commit()
  190. def get_monitoring_route_id(self) -> Optional[int]:
  191. with self._lock:
  192. row = self._conn.execute(
  193. "SELECT id FROM routes WHERE monitoring=1 ORDER BY id DESC LIMIT 1"
  194. ).fetchone()
  195. return int(row["id"]) if row else None
  196. # Backward-compatible aliases for older call sites that used sanitized names.
  197. def setmonitoringroute(self, route_id: Optional[int]) -> None:
  198. self.set_monitoring_route(route_id)
  199. def getmonitoringrouteid(self) -> Optional[int]:
  200. return self.get_monitoring_route_id()
  201. def get_route_mode(self, route_id: int) -> str:
  202. with self._lock:
  203. row = self._conn.execute(
  204. "SELECT mode FROM routes WHERE id=?",
  205. (route_id,),
  206. ).fetchone()
  207. if not row:
  208. return "gps"
  209. return str(row["mode"] or "gps")
  210. def update_route_mode(self, route_id: int, mode: str) -> None:
  211. route_mode = (mode or "gps").strip() or "gps"
  212. with self._lock:
  213. self._conn.execute(
  214. "UPDATE routes SET mode=? WHERE id=?",
  215. (route_mode, route_id),
  216. )
  217. self._conn.commit()
  218. def add_point(
  219. self,
  220. *,
  221. route_id: int,
  222. seq: int,
  223. t: int,
  224. lat: float,
  225. lon: float,
  226. speed_mps: float,
  227. cum_dist_m: float,
  228. counter_value: int = 0,
  229. counter_absolute: int = 0,
  230. ) -> None:
  231. with self._lock:
  232. self._conn.execute(
  233. """
  234. INSERT OR REPLACE INTO route_points(
  235. route_id, seq, t, lat, lon, speed_mps, cum_dist_m, counter_value, counter_absolute
  236. )
  237. VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?)
  238. """,
  239. (
  240. route_id,
  241. seq,
  242. t,
  243. lat,
  244. lon,
  245. speed_mps,
  246. cum_dist_m,
  247. int(counter_value or 0),
  248. int(counter_absolute or 0),
  249. ),
  250. )
  251. self._conn.commit()
  252. def add_hazard(
  253. self,
  254. *,
  255. route_id: int,
  256. seq: int,
  257. t: int,
  258. lat: float,
  259. lon: float,
  260. cum_dist_m: float,
  261. hazard_type: str,
  262. note: str = "",
  263. counter_value: int = 0,
  264. counter_absolute: int = 0,
  265. ) -> int:
  266. with self._lock:
  267. cur = self._conn.execute(
  268. """
  269. INSERT INTO hazards(
  270. route_id, seq, t, lat, lon, cum_dist_m, type, note, counter_value, counter_absolute
  271. )
  272. VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
  273. """,
  274. (
  275. route_id,
  276. seq,
  277. t,
  278. lat,
  279. lon,
  280. cum_dist_m,
  281. hazard_type,
  282. note or "",
  283. int(counter_value or 0),
  284. int(counter_absolute or 0),
  285. ),
  286. )
  287. self._conn.commit()
  288. return int(cur.lastrowid)
  289. def list_routes(self, limit: int = 20) -> List[RouteInfo]:
  290. with self._lock:
  291. rows = self._conn.execute(
  292. """
  293. SELECT
  294. r.id, r.name, r.mode, r.created_at, r.finished_at, r.active, r.monitoring,
  295. (SELECT COUNT(1) FROM route_points p WHERE p.route_id=r.id) AS points,
  296. (SELECT COUNT(1) FROM hazards h WHERE h.route_id=r.id) AS hazards
  297. FROM routes r
  298. ORDER BY r.id DESC
  299. LIMIT ?
  300. """,
  301. (int(limit),),
  302. ).fetchall()
  303. return [
  304. RouteInfo(
  305. id=int(row["id"]),
  306. name=str(row["name"]),
  307. mode=str(row["mode"] or "gps"),
  308. created_at=int(row["created_at"]),
  309. finished_at=int(row["finished_at"]) if row["finished_at"] else None,
  310. points=int(row["points"]),
  311. hazards=int(row["hazards"]),
  312. active=bool(int(row["active"])),
  313. monitoring=bool(int(row["monitoring"] or 0)),
  314. )
  315. for row in rows
  316. ]
  317. def load_route_points(self, route_id: int) -> List[RoutePoint]:
  318. with self._lock:
  319. rows = self._conn.execute(
  320. """
  321. SELECT seq, t, lat, lon, speed_mps, cum_dist_m, counter_value, counter_absolute
  322. FROM route_points
  323. WHERE route_id=?
  324. ORDER BY seq ASC
  325. """,
  326. (route_id,),
  327. ).fetchall()
  328. return [
  329. RoutePoint(
  330. seq=int(row["seq"]),
  331. t=int(row["t"]),
  332. lat=float(row["lat"]),
  333. lon=float(row["lon"]),
  334. speed_mps=float(row["speed_mps"]),
  335. cum_dist_m=float(row["cum_dist_m"]),
  336. counter_value=int(row["counter_value"] or 0),
  337. counter_absolute=int(row["counter_absolute"] or 0),
  338. )
  339. for row in rows
  340. ]
  341. def load_hazards(self, route_id: int) -> List[Hazard]:
  342. with self._lock:
  343. rows = self._conn.execute(
  344. """
  345. SELECT
  346. id, route_id, seq, t, lat, lon, cum_dist_m, type, note, counter_value, counter_absolute
  347. FROM hazards
  348. WHERE route_id=?
  349. ORDER BY cum_dist_m ASC, id ASC
  350. """,
  351. (route_id,),
  352. ).fetchall()
  353. return [
  354. Hazard(
  355. id=int(row["id"]),
  356. route_id=int(row["route_id"]),
  357. seq=int(row["seq"]),
  358. t=int(row["t"]),
  359. lat=float(row["lat"]),
  360. lon=float(row["lon"]),
  361. cum_dist_m=float(row["cum_dist_m"]),
  362. type=str(row["type"]),
  363. note=str(row["note"] or ""),
  364. counter_value=int(row["counter_value"] or 0),
  365. counter_absolute=int(row["counter_absolute"] or 0),
  366. )
  367. for row in rows
  368. ]