storage.py 11 KB

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