embpilot 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
embpilot/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """EmbPilot — Embedded Debugging MCP Server."""
2
+
3
+ __version__ = "0.1.0"
embpilot/__main__.py ADDED
@@ -0,0 +1,7 @@
1
+ from __future__ import annotations
2
+
3
+ from embpilot.cli import main
4
+
5
+
6
+ if __name__ == "__main__":
7
+ main()
embpilot/cli.py ADDED
@@ -0,0 +1,87 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import sys
5
+ from collections.abc import Sequence
6
+
7
+ from embpilot import __version__
8
+
9
+
10
+ def build_parser() -> argparse.ArgumentParser:
11
+ parser = argparse.ArgumentParser(
12
+ prog="embpilot",
13
+ description="EmbPilot — Embedded Debugging MCP Server",
14
+ )
15
+ parser.add_argument(
16
+ "--version",
17
+ action="version",
18
+ version=f"%(prog)s {__version__}",
19
+ )
20
+ parser.add_argument(
21
+ "--data-dir",
22
+ default=None,
23
+ help="Root data directory for all database files "
24
+ "(default: XDG-compliant platform path)",
25
+ )
26
+ parser.add_argument(
27
+ "--main-db-path",
28
+ default=None,
29
+ help="Central database file path (default: <data-dir>/embpilot_main.db)",
30
+ )
31
+ parser.add_argument(
32
+ "--session-data-dir",
33
+ default=None,
34
+ help="Directory for per-session database files "
35
+ "(default: <data-dir>/sessions)",
36
+ )
37
+ parser.add_argument(
38
+ "--lancedb-path",
39
+ default=None,
40
+ help="LanceDB vector store directory (default: <data-dir>/lancedb)",
41
+ )
42
+ parser.add_argument(
43
+ "--retention-days",
44
+ type=int,
45
+ default=None,
46
+ help="Auto-delete session files older than N days (default: 30)",
47
+ )
48
+ parser.add_argument(
49
+ "--retention-max-gb",
50
+ type=int,
51
+ default=None,
52
+ help="Cap total session storage at N GB (default: 5)",
53
+ )
54
+ parser.add_argument(
55
+ "--framing-timeout-ms",
56
+ type=int,
57
+ default=None,
58
+ help="Frame assembly timeout in milliseconds (default: 50)",
59
+ )
60
+ parser.add_argument(
61
+ "--log-level",
62
+ default=None,
63
+ choices=["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"],
64
+ help="Logging level (default: INFO)",
65
+ )
66
+ subparsers = parser.add_subparsers(dest="command")
67
+ subparsers.add_parser(
68
+ "doctor",
69
+ help="Run environment diagnostics and exit",
70
+ )
71
+ return parser
72
+
73
+
74
+ def main(argv: Sequence[str] | None = None) -> None:
75
+ parser = build_parser()
76
+ args = parser.parse_args(argv)
77
+
78
+ if args.command == "doctor":
79
+ from embpilot.doctor import run_doctor
80
+
81
+ sys.exit(run_doctor())
82
+
83
+ from embpilot.config import EmbPilotConfig
84
+ from embpilot.mcp_app import run_stdio_mcp_server
85
+
86
+ config = EmbPilotConfig.from_args(args)
87
+ run_stdio_mcp_server(config)
embpilot/config.py ADDED
@@ -0,0 +1,105 @@
1
+ """
2
+ Configuration management for EmbPilot.
3
+ Loads config from CLI args / environment variables and computes XDG-compliant paths.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import argparse
9
+ import os
10
+ import sys
11
+ from dataclasses import dataclass, field
12
+ from pathlib import Path
13
+ from typing import Optional
14
+
15
+
16
+ def _default_data_dir() -> Path:
17
+ """Return the platform-appropriate XDG data directory for EmbPilot.
18
+
19
+ Order of precedence:
20
+ 1. $EMBPILOT_DATA_DIR environment variable
21
+ 2. XDG platform default
22
+ """
23
+ env = os.environ.get("EMBPILOT_DATA_DIR")
24
+ if env:
25
+ return Path(env)
26
+
27
+ if sys.platform == "win32":
28
+ base = os.environ.get("APPDATA", os.path.expanduser("~\\AppData\\Roaming"))
29
+ return Path(base) / "embpilot"
30
+ elif sys.platform == "darwin":
31
+ return Path.home() / "Library" / "Application Support" / "embpilot"
32
+ else:
33
+ xdg = os.environ.get("XDG_DATA_HOME")
34
+ base = Path(xdg) if xdg else Path.home() / ".local" / "share"
35
+ return base / "embpilot"
36
+
37
+
38
+ @dataclass
39
+ class EmbPilotConfig:
40
+ """Central configuration for EmbPilot — dual-track database paths.
41
+
42
+ All paths are resolved at construction time; directories are created lazily
43
+ by the components that use them.
44
+ """
45
+
46
+ # ── Data paths ──────────────────────────────────────────────────
47
+ data_dir: Path = field(default_factory=_default_data_dir)
48
+ main_db_path: Optional[Path] = None # default: <data_dir>/embpilot_main.db
49
+ session_data_dir: Optional[Path] = None # default: <data_dir>/sessions/
50
+ lancedb_path: Optional[Path] = None # default: <data_dir>/lancedb
51
+
52
+ # ── Retention (auto-cleanup) ────────────────────────────────────
53
+ retention_days: int = 30 # delete session files older than N days
54
+ retention_max_gb: int = 5 # cap total session directory size (GB)
55
+
56
+ # ── Framing ─────────────────────────────────────────────────────
57
+ framing_timeout_ms: int = 50
58
+
59
+ # ── Logging ─────────────────────────────────────────────────────
60
+ log_level: str = "INFO"
61
+
62
+ # ── Connection defaults ─────────────────────────────────────────
63
+ serial_baudrate_default: int = 115200
64
+ serial_timeout_default: float = 5.0
65
+ network_timeout_default: float = 10.0
66
+
67
+ def __post_init__(self) -> None:
68
+ if self.main_db_path is None:
69
+ self.main_db_path = self.data_dir / "embpilot_main.db"
70
+ if self.session_data_dir is None:
71
+ self.session_data_dir = self.data_dir / "sessions"
72
+ if self.lancedb_path is None:
73
+ self.lancedb_path = self.data_dir / "lancedb"
74
+
75
+ @classmethod
76
+ def from_args(cls, args: argparse.Namespace) -> "EmbPilotConfig":
77
+ """Build a config from parsed CLI arguments."""
78
+ kwargs: dict = {}
79
+
80
+ if args.data_dir is not None:
81
+ kwargs["data_dir"] = Path(args.data_dir)
82
+ if args.main_db_path is not None:
83
+ kwargs["main_db_path"] = Path(args.main_db_path)
84
+ if args.session_data_dir is not None:
85
+ kwargs["session_data_dir"] = Path(args.session_data_dir)
86
+ if args.lancedb_path is not None:
87
+ kwargs["lancedb_path"] = Path(args.lancedb_path)
88
+ if args.framing_timeout_ms is not None:
89
+ kwargs["framing_timeout_ms"] = args.framing_timeout_ms
90
+ if args.log_level is not None:
91
+ kwargs["log_level"] = args.log_level
92
+ if args.retention_days is not None:
93
+ kwargs["retention_days"] = args.retention_days
94
+ if args.retention_max_gb is not None:
95
+ kwargs["retention_max_gb"] = args.retention_max_gb
96
+
97
+ return cls(**kwargs)
98
+
99
+ def ensure_data_dirs(self) -> None:
100
+ """Create data directories if they do not exist."""
101
+ self.data_dir.mkdir(parents=True, exist_ok=True)
102
+ if self.session_data_dir:
103
+ self.session_data_dir.mkdir(parents=True, exist_ok=True)
104
+ if self.lancedb_path:
105
+ self.lancedb_path.mkdir(parents=True, exist_ok=True)
File without changes
@@ -0,0 +1,342 @@
1
+ """
2
+ Database layer — dual-track SQLite with WAL mode.
3
+
4
+ MainDatabase (embpilot_main.db)
5
+ Persistent, low-frequency: sessions index + operation_history.
6
+
7
+ SessionDatabase (session_<ts>_<device>.db)
8
+ Per-connection, high-frequency: device_logs with batch ingest.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import logging
15
+ import time
16
+ from datetime import datetime, timezone
17
+ from pathlib import Path
18
+ from typing import Any, Optional
19
+
20
+ import aiosqlite
21
+
22
+ from embpilot.runtime.models import LogLine
23
+
24
+ logger = logging.getLogger(__name__)
25
+
26
+ # ── DDL resources ────────────────────────────────────────────────────────────
27
+
28
+ _schema_main: str = (Path(__file__).parent / "schema_main.sql").read_text("utf-8")
29
+ _schema_session: str = (Path(__file__).parent / "schema_session.sql").read_text("utf-8")
30
+
31
+
32
+ # ── Shared helpers ───────────────────────────────────────────────────────────
33
+
34
+ def _now_iso() -> str:
35
+ return datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S.") + \
36
+ f"{datetime.now(timezone.utc).microsecond // 1000:03d}"
37
+
38
+
39
+ # ── MainDatabase ─────────────────────────────────────────────────────────────
40
+
41
+ class MainDatabase:
42
+ """Central persistent database — session registry + operation history.
43
+
44
+ Opens ``embpilot_main.db`` at startup and keeps it open for the
45
+ lifetime of the MCP server.
46
+ """
47
+
48
+ def __init__(self, db_path: Path) -> None:
49
+ self._db_path = db_path
50
+ self._conn: Optional[aiosqlite.Connection] = None
51
+
52
+ async def open(self) -> None:
53
+ self._conn = await aiosqlite.connect(str(self._db_path))
54
+ self._conn.row_factory = aiosqlite.Row
55
+ await self._conn.execute("PRAGMA journal_mode=WAL")
56
+ await self._conn.execute("PRAGMA synchronous=NORMAL")
57
+ await self._conn.executescript(_schema_main)
58
+ await self._conn.commit()
59
+ logger.info("Main database opened at %s", self._db_path)
60
+
61
+ async def close(self) -> None:
62
+ if self._conn:
63
+ await self._conn.close()
64
+ self._conn = None
65
+
66
+ # ── Sessions ─────────────────────────────────────────────────────
67
+
68
+ async def register_session(
69
+ self,
70
+ session_id: str,
71
+ device_name: str,
72
+ interface: str,
73
+ db_path: str,
74
+ ) -> None:
75
+ """Insert a new session record (status='active')."""
76
+ if self._conn is None:
77
+ return
78
+ await self._conn.execute(
79
+ "INSERT INTO sessions (session_id, device_name, interface, started_at, db_path, status) "
80
+ "VALUES (?, ?, ?, ?, ?, 'active')",
81
+ (session_id, device_name, interface, _now_iso(), db_path),
82
+ )
83
+ await self._conn.commit()
84
+
85
+ async def end_session(self, session_id: str) -> None:
86
+ """Mark a session as closed and update its file size."""
87
+ if self._conn is None:
88
+ return
89
+ now = _now_iso()
90
+ # Get db_path and check file size
91
+ cursor = await self._conn.execute(
92
+ "SELECT db_path FROM sessions WHERE session_id = ?", (session_id,)
93
+ )
94
+ row = await cursor.fetchone()
95
+ file_size = 0
96
+ if row:
97
+ p = Path(row["db_path"])
98
+ if p.exists():
99
+ file_size = p.stat().st_size
100
+
101
+ await self._conn.execute(
102
+ "UPDATE sessions SET ended_at=?, status='closed', file_size=? "
103
+ "WHERE session_id=?",
104
+ (now, file_size, session_id),
105
+ )
106
+ await self._conn.commit()
107
+
108
+ async def list_sessions(self) -> list[dict[str, Any]]:
109
+ """Return all session records, newest first."""
110
+ if self._conn is None:
111
+ return []
112
+ cursor = await self._conn.execute(
113
+ "SELECT session_id, device_name, interface, started_at, ended_at, "
114
+ " db_path, file_size, status "
115
+ "FROM sessions ORDER BY id DESC"
116
+ )
117
+ rows = await cursor.fetchall()
118
+ return [dict(r) for r in rows]
119
+
120
+ async def delete_session(self, session_id: str) -> None:
121
+ """Physically delete the session db file and remove the index entry."""
122
+ if self._conn is None:
123
+ return
124
+ cursor = await self._conn.execute(
125
+ "SELECT db_path FROM sessions WHERE session_id = ?", (session_id,)
126
+ )
127
+ row = await cursor.fetchone()
128
+ if row:
129
+ p = Path(row["db_path"])
130
+ if p.exists():
131
+ p.unlink()
132
+ logger.info("Deleted session file %s", p)
133
+ await self._conn.execute(
134
+ "DELETE FROM sessions WHERE session_id = ?", (session_id,)
135
+ )
136
+ await self._conn.commit()
137
+
138
+ async def get_session_db_path(self, session_id: str) -> Optional[str]:
139
+ """Return the db_path for a session, or None if not found."""
140
+ if self._conn is None:
141
+ return None
142
+ cursor = await self._conn.execute(
143
+ "SELECT db_path FROM sessions WHERE session_id = ?", (session_id,)
144
+ )
145
+ row = await cursor.fetchone()
146
+ return row["db_path"] if row else None
147
+
148
+ # ── Operation history ────────────────────────────────────────────
149
+
150
+ async def insert_operation(
151
+ self,
152
+ actor: str,
153
+ action_type: str,
154
+ detail: dict[str, Any],
155
+ session_id: Optional[str] = None,
156
+ ) -> None:
157
+ if self._conn is None:
158
+ return
159
+ await self._conn.execute(
160
+ "INSERT INTO operation_history (timestamp, session_id, actor, action_type, detail) "
161
+ "VALUES (?, ?, ?, ?, ?)",
162
+ (_now_iso(), session_id, actor, action_type, json.dumps(detail, ensure_ascii=False)),
163
+ )
164
+ await self._conn.commit()
165
+
166
+ # ── Cleanup ──────────────────────────────────────────────────────
167
+
168
+ async def cleanup_expired_sessions(
169
+ self,
170
+ max_days: int = 30,
171
+ max_gb: int = 5,
172
+ ) -> None:
173
+ """Auto-delete sessions exceeding retention thresholds.
174
+
175
+ Called once at server startup.
176
+ """
177
+ if self._conn is None:
178
+ return
179
+ deleted = 0
180
+
181
+ # 1. Remove sessions older than max_days
182
+ cursor = await self._conn.execute(
183
+ "SELECT session_id, db_path FROM sessions "
184
+ "WHERE started_at < datetime('now', ?)",
185
+ (f"-{max_days} days",),
186
+ )
187
+ old_sessions = await cursor.fetchall()
188
+ for row in old_sessions:
189
+ p = Path(row["db_path"])
190
+ if p.exists():
191
+ p.unlink()
192
+ await self._conn.execute(
193
+ "UPDATE sessions SET status='cleaned' WHERE session_id=?",
194
+ (row["session_id"],),
195
+ )
196
+ deleted += 1
197
+
198
+ # 2. If total size exceeds max_gb, remove oldest until under limit
199
+ total_size = 0
200
+ cursor = await self._conn.execute(
201
+ "SELECT session_id, db_path, file_size FROM sessions "
202
+ "WHERE status IN ('active','closed') ORDER BY started_at ASC"
203
+ )
204
+ remaining = await cursor.fetchall()
205
+ for row in remaining:
206
+ total_size += row["file_size"]
207
+
208
+ max_bytes = max_gb * 1024**3
209
+ if total_size > max_bytes:
210
+ for row in remaining:
211
+ if total_size <= max_bytes:
212
+ break
213
+ p = Path(row["db_path"])
214
+ if p.exists():
215
+ total_size -= row["file_size"]
216
+ p.unlink()
217
+ await self._conn.execute(
218
+ "UPDATE sessions SET status='cleaned' WHERE session_id=?",
219
+ (row["session_id"],),
220
+ )
221
+ deleted += 1
222
+
223
+ if deleted > 0:
224
+ await self._conn.commit()
225
+ logger.info("Cleaned up %d old/excess session(s)", deleted)
226
+
227
+
228
+ # ── SessionDatabase ──────────────────────────────────────────────────────────
229
+
230
+ class SessionDatabase:
231
+ """Per-session database — high-frequency device log storage.
232
+
233
+ Each session gets its own ``session_<ts>_<device>.db`` file under the
234
+ configured session data directory.
235
+ """
236
+
237
+ def __init__(self, db_path: Path) -> None:
238
+ self._db_path = db_path
239
+ self._conn: Optional[aiosqlite.Connection] = None
240
+
241
+ async def open(self) -> None:
242
+ self._conn = await aiosqlite.connect(str(self._db_path))
243
+ self._conn.row_factory = aiosqlite.Row
244
+ await self._conn.execute("PRAGMA journal_mode=WAL")
245
+ await self._conn.execute("PRAGMA synchronous=NORMAL")
246
+ await self._conn.execute("PRAGMA busy_timeout=5000")
247
+ await self._conn.executescript(_schema_session)
248
+ await self._conn.commit()
249
+ logger.info("Session database opened at %s", self._db_path)
250
+
251
+ async def close(self) -> None:
252
+ if self._conn:
253
+ # Flush any remaining WAL
254
+ await self._conn.execute("PRAGMA wal_checkpoint(TRUNCATE)")
255
+ await self._conn.commit()
256
+ await self._conn.close()
257
+ self._conn = None
258
+ logger.info("Session database closed at %s", self._db_path)
259
+
260
+ async def bulk_insert_logs(
261
+ self, lines: list[LogLine], source: str = "serial"
262
+ ) -> None:
263
+ """Insert a batch of log lines."""
264
+ if not lines or self._conn is None:
265
+ return
266
+ rows = [
267
+ (line.timestamp.isoformat(" "), source, line.text)
268
+ for line in lines
269
+ ]
270
+ await self._conn.executemany(
271
+ "INSERT INTO device_logs (timestamp, source, text) VALUES (?, ?, ?)",
272
+ rows,
273
+ )
274
+ await self._conn.commit()
275
+
276
+ async def search_logs(
277
+ self,
278
+ keyword: str,
279
+ time_window_seconds: Optional[int] = None,
280
+ limit: int = 50,
281
+ offset: int = 0,
282
+ ) -> list[dict[str, Any]]:
283
+ """Search device_logs by keyword and optional time window."""
284
+ if self._conn is None:
285
+ return []
286
+ query = "SELECT timestamp, source, text FROM device_logs WHERE text LIKE ?"
287
+ params: list[Any] = [f"%{keyword}%"]
288
+
289
+ if time_window_seconds is not None:
290
+ query += " AND timestamp >= datetime('now', ?)"
291
+ params.append(f"-{time_window_seconds} seconds")
292
+
293
+ query += " ORDER BY id DESC LIMIT ? OFFSET ?"
294
+ params.extend([limit, offset])
295
+
296
+ cursor = await self._conn.execute(query, params)
297
+ rows = await cursor.fetchall()
298
+ return [dict(r) for r in rows]
299
+
300
+ async def fetch_logs(
301
+ self, limit: int = 5000, offset: int = 0
302
+ ) -> list[dict[str, Any]]:
303
+ """Return device logs in insertion order (for export)."""
304
+ if self._conn is None:
305
+ return []
306
+ cursor = await self._conn.execute(
307
+ "SELECT timestamp, source, text FROM device_logs "
308
+ "ORDER BY id ASC LIMIT ? OFFSET ?",
309
+ (limit, offset),
310
+ )
311
+ rows = await cursor.fetchall()
312
+ return [dict(r) for r in rows]
313
+
314
+ async def get_analytics(self, limit: int = 20) -> list[dict[str, Any]]:
315
+ """Aggregate common error-like patterns."""
316
+ if self._conn is None:
317
+ return []
318
+ cursor = await self._conn.execute(
319
+ """
320
+ SELECT text, COUNT(*) as cnt
321
+ FROM device_logs
322
+ WHERE text LIKE '%error%'
323
+ OR text LIKE '%fail%'
324
+ OR text LIKE '%panic%'
325
+ OR text LIKE '%hardfault%'
326
+ OR text LIKE '%fault%'
327
+ GROUP BY text
328
+ ORDER BY cnt DESC
329
+ LIMIT ?
330
+ """,
331
+ (limit,),
332
+ )
333
+ rows = await cursor.fetchall()
334
+ return [dict(r) for r in rows]
335
+
336
+ async def get_log_count(self) -> int:
337
+ """Return total log rows (for size estimation)."""
338
+ if self._conn is None:
339
+ return 0
340
+ cursor = await self._conn.execute("SELECT COUNT(*) as cnt FROM device_logs")
341
+ row = await cursor.fetchone()
342
+ return row["cnt"] if row else 0