Files
MeshTerm/meshterm/persistence/db.py
T
Jean-Pierre MartineauandClaude Opus 4.8 a3398bebf5 UI punch-list: dashboard feed, chart axes, chat counter, popups
Two rounds of interactive-polish fixes across the dashboard, Time Machine,
chat, and the menu popups.

Dashboard
- Feed name lane names what a row actually is: a channel message's parsed
  sender, or a relayed packet's payload class (channel text / trace / …),
  instead of a bare "?" or a redundant "ch N". The class is persisted so
  history-seeded rows carry it too (observations.payload_typename, schema v10).
- PgUp/PgDn walk the feed selection so the highlight travels with the view.

Packet viewer
- Takes a live source and re-locates the viewed packet by identity, so packets
  arriving after it opens become reachable; the n/total title stays current.
- Decrypts overheard channel text straight from stored history (crypto fields
  persisted: chan_hash/cipher_mac/crypted, schema v9).

Time Machine
- Day charts label their bars with column ticks: each date sits under its own
  bar with a ┬ notch, shortens (month only on the first tick and on rollover),
  and thins to fit. axis_chart grows an optional ticks path for columnar charts.
- Node history charts render through the shared axis_chart widget; the mesh
  Rhythm is 96 fifteen-minute slices at 4x width, "now/newest" hints dropped.

Chat
- The byte counter pins to the right edge of the input's last line, so it stays
  bottom-right when the compose wraps instead of trailing the cursor.

Menu popups
- The per-node Rules popover gets an explicit Close row.
- The Send advert popup drops the emoji from its floating title (a terminal that
  paints it narrow left the content-sized dialog's border short, bleeding the
  backdrop through); the menu row keeps its icon.

625 tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 22:47:36 -04:00

213 lines
9.4 KiB
Python

"""SQLite database bootstrap and schema.
The schema is intentionally generic: a central ``runs`` table records every tool
execution (with arguments and a JSON summary), and measurement tables reference it so
new tools can persist data without schema churn.
"""
from __future__ import annotations
import sqlite3
from pathlib import Path
SCHEMA_VERSION = 10
_SCHEMA = """
CREATE TABLE IF NOT EXISTS schema_meta (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
-- Small key/value store for UI state that should survive across sessions (e.g. the last
-- map viewport), independent of any tool run. New keys need no schema change.
CREATE TABLE IF NOT EXISTS app_state (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
-- One row per tool execution; the spine that measurements hang off of.
CREATE TABLE IF NOT EXISTS runs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
tool TEXT NOT NULL,
profile TEXT,
args_json TEXT NOT NULL DEFAULT '{}',
status TEXT NOT NULL DEFAULT 'running', -- running | ok | error
summary_json TEXT,
started_at TEXT NOT NULL,
finished_at TEXT
);
-- Aggregated outcome of a single path trace, linked to its run.
CREATE TABLE IF NOT EXISTS traces (
id INTEGER PRIMARY KEY AUTOINCREMENT,
run_id INTEGER NOT NULL REFERENCES runs(id) ON DELETE CASCADE,
target TEXT NOT NULL,
success INTEGER NOT NULL,
hop_count INTEGER NOT NULL DEFAULT 0,
min_snr REAL,
round_trip_ms REAL,
tx_power INTEGER,
created_at TEXT NOT NULL
);
-- Per-hop SNR for a trace.
CREATE TABLE IF NOT EXISTS trace_hops (
id INTEGER PRIMARY KEY AUTOINCREMENT,
trace_id INTEGER NOT NULL REFERENCES traces(id) ON DELETE CASCADE,
hop_index INTEGER NOT NULL,
node TEXT,
snr REAL NOT NULL
);
-- One robust sample per (tx_power) tried during TX-power optimization.
CREATE TABLE IF NOT EXISTS tx_samples (
id INTEGER PRIMARY KEY AUTOINCREMENT,
run_id INTEGER NOT NULL REFERENCES runs(id) ON DELETE CASCADE,
target TEXT NOT NULL,
tx_power INTEGER NOT NULL,
median_min_snr REAL,
success_rate REAL,
samples INTEGER NOT NULL,
created_at TEXT NOT NULL
);
-- A candidate path evaluated during path optimization.
CREATE TABLE IF NOT EXISTS path_candidates (
id INTEGER PRIMARY KEY AUTOINCREMENT,
run_id INTEGER NOT NULL REFERENCES runs(id) ON DELETE CASCADE,
target TEXT NOT NULL,
path_json TEXT NOT NULL,
bottleneck_snr REAL,
success_rate REAL,
median_rtt_ms REAL,
created_at TEXT NOT NULL
);
-- One entry of a remote repeater's neighbour table, fetched over the mesh (v8). Each row
-- is a link the repeater reported hearing directly, with SNR measured at the repeater;
-- refetching appends a new snapshot and readers take the latest row per pair.
CREATE TABLE IF NOT EXISTS neighbour_reports (
id INTEGER PRIMARY KEY AUTOINCREMENT,
run_id INTEGER NOT NULL REFERENCES runs(id) ON DELETE CASCADE,
repeater TEXT NOT NULL, -- canonical id of the repeater that was asked
neighbour TEXT NOT NULL, -- reported neighbour's key-prefix hex, as replied
snr REAL, -- dB, measured at the repeater
heard_at TEXT, -- when the repeater last heard the neighbour
fetched_at TEXT NOT NULL
);
-- One packet overheard while passively monitoring the mesh (advert/telemetry/...).
CREATE TABLE IF NOT EXISTS observations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
run_id INTEGER NOT NULL REFERENCES runs(id) ON DELETE CASCADE,
node TEXT,
name TEXT,
kind TEXT NOT NULL DEFAULT 'advert',
node_type INTEGER, -- advert type (repeater/chat/room/...), when carried
snr REAL,
rssi REAL,
lat REAL,
lon REAL,
path TEXT, -- 'packet' rows: comma-separated relay-hop hex hashes
observed_at TEXT NOT NULL,
chan_hash TEXT, -- overheard GRP_TXT frames: channel-hash fingerprint (hex)
cipher_mac TEXT, -- …its 2-byte MAC (hex)
crypted TEXT, -- …its ciphertext (hex), so a stored channel text still decrypts
payload_typename TEXT -- 'packet' rows: the frame's payload class (GRP_TXT/TRACE/…)
);
-- One chat message, sent or received, on a channel or with a contact. Unlike the other
-- measurement tables these are not tied to a single tool run (they arrive unsolicited via
-- the event hub), so run_id is nullable and detaches rather than cascades.
CREATE TABLE IF NOT EXISTS messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
run_id INTEGER REFERENCES runs(id) ON DELETE SET NULL,
outbound INTEGER NOT NULL DEFAULT 0,
is_channel INTEGER NOT NULL DEFAULT 0,
channel_id TEXT, -- a channel's slot-independent identity (its history key)
channel_idx INTEGER, -- the slot it went out on / arrived on (reference only)
peer TEXT,
peer_name TEXT,
text TEXT NOT NULL,
snr REAL,
acked INTEGER,
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_traces_run ON traces(run_id);
CREATE INDEX IF NOT EXISTS idx_trace_hops_trace ON trace_hops(trace_id);
CREATE INDEX IF NOT EXISTS idx_tx_samples_run ON tx_samples(run_id);
CREATE INDEX IF NOT EXISTS idx_neighbour_reports_pair ON neighbour_reports(repeater, neighbour);
CREATE INDEX IF NOT EXISTS idx_observations_run ON observations(run_id);
CREATE INDEX IF NOT EXISTS idx_observations_node ON observations(node);
CREATE INDEX IF NOT EXISTS idx_messages_peer ON messages(peer);
"""
def connect(db_path: Path) -> sqlite3.Connection:
"""Open (creating if needed) the SQLite database and ensure the schema exists.
Args:
db_path: Filesystem location of the database. Parent directories are created.
Returns:
An open connection with ``Row`` factory and foreign keys enabled.
"""
db_path.parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(db_path)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA foreign_keys = ON;")
conn.executescript(_SCHEMA)
_migrate(conn)
conn.execute(
"INSERT OR REPLACE INTO schema_meta(key, value) VALUES ('version', ?)",
(str(SCHEMA_VERSION),),
)
conn.commit()
return conn
def _migrate(conn: sqlite3.Connection) -> None:
"""Bring an existing database's tables up to the current schema.
``executescript(_SCHEMA)`` only *creates* missing tables (``IF NOT EXISTS``); it cannot
add a column to a table an older version already created. Column additions are applied
here, guarded so the migration is idempotent and safe to run on every open. Indexes on
added columns are created here too (not in ``_SCHEMA``), so ``executescript`` never
references a column an older database hasn't grown yet.
"""
message_cols = {row["name"] for row in conn.execute("PRAGMA table_info(messages)")}
if "channel_id" not in message_cols:
# v3 -> v4: channel history moved from being keyed by slot index to a
# slot-independent channel identity. Existing rows are backfilled lazily at runtime
# (see Repository.backfill_channel_ids) once the device's channels can be read.
conn.execute("ALTER TABLE messages ADD COLUMN channel_id TEXT")
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_messages_channel_id ON messages(channel_id)"
)
observation_cols = {row["name"] for row in conn.execute("PRAGMA table_info(observations)")}
if "node_type" not in observation_cols:
# v4 -> v5: observations gained the transmitting node's advert type, so the map can
# tell repeaters from leaf nodes. Older rows simply carry NULL (type unknown).
conn.execute("ALTER TABLE observations ADD COLUMN node_type INTEGER")
if "path" not in observation_cols:
# v6 -> v7: observations gained the relay path of RX-logged packets, the passive
# evidence the mesh topology graph is built from. Older rows carry NULL (no path).
conn.execute("ALTER TABLE observations ADD COLUMN path TEXT")
if "chan_hash" not in observation_cols:
# v8 -> v9: overheard channel-text (GRP_TXT) frames keep the fields the packet
# viewer needs to decrypt them — the channel-hash fingerprint, the 2-byte MAC, and
# the ciphertext — so a channel we hold the key for stays readable even when the feed
# is seeded from stored history (a live frame carries them in its raw payload; a
# replayed one had nowhere to keep them). Older rows carry NULL (never decryptable).
conn.execute("ALTER TABLE observations ADD COLUMN chan_hash TEXT")
conn.execute("ALTER TABLE observations ADD COLUMN cipher_mac TEXT")
conn.execute("ALTER TABLE observations ADD COLUMN crypted TEXT")
if "payload_typename" not in observation_cols:
# v9 -> v10: 'packet' RX-log rows keep their payload class (GRP_TXT, TRACE, PATH,
# ACK, …), the one identifying thing a relayed flood carries when it names no
# origin node — so the dashboard feed can read "channel text" / "trace" instead of
# a bare "?" even when seeded from history. Older rows carry NULL (class unknown).
conn.execute("ALTER TABLE observations ADD COLUMN payload_typename TEXT")