storage.py 6.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195
  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 LineAlert:
  11. id: int
  12. device_id: str
  13. start_lat: float
  14. start_lon: float
  15. end_lat: float
  16. end_lon: float
  17. coord_type: str
  18. note: str
  19. created_at: int
  20. class NavDB:
  21. _default_instance: Optional["NavDB"] = None
  22. _default_lock = threading.Lock()
  23. def __init__(self, db_path: str):
  24. self.db_path = db_path
  25. os.makedirs(os.path.dirname(db_path), exist_ok=True)
  26. self._lock = threading.Lock()
  27. self._conn = sqlite3.connect(db_path, check_same_thread=False)
  28. self._conn.row_factory = sqlite3.Row
  29. self._init_schema()
  30. @classmethod
  31. def default(cls) -> "NavDB":
  32. if cls._default_instance is None:
  33. with cls._default_lock:
  34. if cls._default_instance is None:
  35. base = Path(__file__).resolve().parents[2]
  36. cls._default_instance = cls(str(base / "data" / "nav_copilot.sqlite3"))
  37. return cls._default_instance
  38. def _init_schema(self) -> None:
  39. with self._lock:
  40. cur = self._conn.cursor()
  41. # The old route/hazard model is no longer used. Drop it so the
  42. # SQLite file only contains app-managed line alerts.
  43. cur.execute("DROP TABLE IF EXISTS route_points")
  44. cur.execute("DROP TABLE IF EXISTS hazards")
  45. cur.execute("DROP TABLE IF EXISTS routes")
  46. cur.execute(
  47. """
  48. CREATE TABLE IF NOT EXISTS line_alerts (
  49. id INTEGER PRIMARY KEY AUTOINCREMENT,
  50. device_id TEXT NOT NULL,
  51. start_lat REAL NOT NULL,
  52. start_lon REAL NOT NULL,
  53. end_lat REAL NOT NULL,
  54. end_lon REAL NOT NULL,
  55. coord_type TEXT NOT NULL DEFAULT 'BD09',
  56. note TEXT NOT NULL,
  57. created_at INTEGER NOT NULL
  58. )
  59. """
  60. )
  61. self._ensure_column(
  62. cur,
  63. "line_alerts",
  64. "coord_type",
  65. "TEXT NOT NULL DEFAULT 'BD09'",
  66. )
  67. cur.execute(
  68. """
  69. CREATE INDEX IF NOT EXISTS idx_line_alerts_device_id
  70. ON line_alerts(device_id)
  71. """
  72. )
  73. self._conn.commit()
  74. def _ensure_column(
  75. self,
  76. cur: sqlite3.Cursor,
  77. table_name: str,
  78. column_name: str,
  79. column_ddl: str,
  80. ) -> None:
  81. columns = {
  82. str(row["name"])
  83. for row in cur.execute(f"PRAGMA table_info({table_name})").fetchall()
  84. }
  85. if column_name not in columns:
  86. cur.execute(
  87. f"ALTER TABLE {table_name} ADD COLUMN {column_name} {column_ddl}"
  88. )
  89. def count_line_alerts(self, device_id: str) -> int:
  90. with self._lock:
  91. row = self._conn.execute(
  92. "SELECT COUNT(1) AS total FROM line_alerts WHERE device_id=?",
  93. (device_id,),
  94. ).fetchone()
  95. return int(row["total"] or 0) if row else 0
  96. def get_line_alert(self, alert_id: int, device_id: Optional[str] = None) -> Optional[LineAlert]:
  97. query = """
  98. SELECT id, device_id, start_lat, start_lon, end_lat, end_lon, coord_type, note, created_at
  99. FROM line_alerts
  100. WHERE id=?
  101. """
  102. params: list[object] = [int(alert_id)]
  103. if device_id is not None:
  104. query += " AND device_id=?"
  105. params.append(device_id)
  106. with self._lock:
  107. row = self._conn.execute(query, tuple(params)).fetchone()
  108. return self._row_to_line_alert(row) if row else None
  109. def list_line_alerts(self, device_id: str) -> List[LineAlert]:
  110. with self._lock:
  111. rows = self._conn.execute(
  112. """
  113. SELECT id, device_id, start_lat, start_lon, end_lat, end_lon, coord_type, note, created_at
  114. FROM line_alerts
  115. WHERE device_id=?
  116. ORDER BY id DESC
  117. """,
  118. (device_id,),
  119. ).fetchall()
  120. return [self._row_to_line_alert(row) for row in rows]
  121. def create_line_alert(
  122. self,
  123. *,
  124. device_id: str,
  125. start_lat: float,
  126. start_lon: float,
  127. end_lat: float,
  128. end_lon: float,
  129. coord_type: str,
  130. note: str,
  131. ) -> int:
  132. now = int(time.time())
  133. with self._lock:
  134. cur = self._conn.execute(
  135. """
  136. INSERT INTO line_alerts(
  137. device_id, start_lat, start_lon, end_lat, end_lon, coord_type, note, created_at
  138. )
  139. VALUES(?, ?, ?, ?, ?, ?, ?, ?)
  140. """,
  141. (
  142. device_id,
  143. float(start_lat),
  144. float(start_lon),
  145. float(end_lat),
  146. float(end_lon),
  147. coord_type,
  148. note,
  149. now,
  150. ),
  151. )
  152. self._conn.commit()
  153. return int(cur.lastrowid)
  154. def delete_line_alert(self, alert_id: int, device_id: Optional[str] = None) -> bool:
  155. query = "DELETE FROM line_alerts WHERE id=?"
  156. params: list[object] = [int(alert_id)]
  157. if device_id is not None:
  158. query += " AND device_id=?"
  159. params.append(device_id)
  160. with self._lock:
  161. cur = self._conn.execute(query, tuple(params))
  162. self._conn.commit()
  163. return int(cur.rowcount or 0) > 0
  164. @staticmethod
  165. def _row_to_line_alert(row: sqlite3.Row) -> LineAlert:
  166. return LineAlert(
  167. id=int(row["id"]),
  168. device_id=str(row["device_id"]),
  169. start_lat=float(row["start_lat"]),
  170. start_lon=float(row["start_lon"]),
  171. end_lat=float(row["end_lat"]),
  172. end_lon=float(row["end_lon"]),
  173. coord_type=str(row["coord_type"] or "BD09"),
  174. note=str(row["note"] or ""),
  175. created_at=int(row["created_at"]),
  176. )