diff --git a/repeater/companion/__init__.py b/repeater/companion/__init__.py index f64af20..f104252 100644 --- a/repeater/companion/__init__.py +++ b/repeater/companion/__init__.py @@ -3,6 +3,7 @@ Exposes the MeshCore companion frame protocol over TCP for standard clients. """ +from .bridge import RepeaterCompanionBridge from .constants import ( CMD_APP_START, CMD_GET_CONTACTS, @@ -17,6 +18,7 @@ from .frame_server import CompanionFrameServer __all__ = [ "CompanionFrameServer", + "RepeaterCompanionBridge", "CMD_APP_START", "CMD_GET_CONTACTS", "CMD_SEND_TXT_MSG", diff --git a/repeater/companion/bridge.py b/repeater/companion/bridge.py new file mode 100644 index 0000000..f24ddb1 --- /dev/null +++ b/repeater/companion/bridge.py @@ -0,0 +1,92 @@ +""" +Repeater CompanionBridge with SQLite-backed preference persistence. + +Persists full NodePrefs as a JSON blob so companion settings (including +auto-add config) survive repeater restarts. Merge-on-load supports +schema evolution when NodePrefs gains or loses fields. +""" + +from __future__ import annotations + +import dataclasses +import logging +from typing import Any, Callable, Optional + +from pymc_core.companion import CompanionBridge + +logger = logging.getLogger("RepeaterCompanionBridge") + + +class RepeaterCompanionBridge(CompanionBridge): + """CompanionBridge that persists and loads prefs (full NodePrefs) via SQLite JSON blob.""" + + def __init__( + self, + identity, + packet_injector: Callable[..., Any], + node_name: str = "pyMC", + adv_type: int = 1, + max_contacts: int = 1000, + max_channels: int = 40, + offline_queue_size: int = 512, + radio_config: Optional[dict] = None, + authenticate_callback: Optional[Callable[..., tuple[bool, int]]] = None, + initial_contacts: Optional[Any] = None, + *, + sqlite_handler=None, + companion_hash: str = "", + ) -> None: + self._sqlite_handler = sqlite_handler + self._companion_hash = companion_hash + super().__init__( + identity=identity, + packet_injector=packet_injector, + node_name=node_name, + adv_type=adv_type, + max_contacts=max_contacts, + max_channels=max_channels, + offline_queue_size=offline_queue_size, + radio_config=radio_config, + authenticate_callback=authenticate_callback, + initial_contacts=initial_contacts, + ) + + def _save_prefs(self) -> None: + """Persist full NodePrefs as JSON to SQLite.""" + if not self._sqlite_handler or not self._companion_hash: + return + try: + prefs_dict = dataclasses.asdict(self.prefs) + self._sqlite_handler.companion_save_prefs(self._companion_hash, prefs_dict) + except Exception as e: + logger.warning("Failed to persist companion prefs: %s", e) + + def _load_prefs(self) -> None: + """Load prefs from SQLite JSON and merge into self.prefs (only known keys).""" + if not self._sqlite_handler or not self._companion_hash: + return + try: + stored = self._sqlite_handler.companion_load_prefs(self._companion_hash) + if not stored or not isinstance(stored, dict): + return + for key, value in stored.items(): + if not hasattr(self.prefs, key): + continue + current = getattr(self.prefs, key) + try: + if value is None: + continue + if isinstance(current, bool): + setattr(self.prefs, key, bool(value)) + elif isinstance(current, int): + setattr(self.prefs, key, int(value)) + elif isinstance(current, float): + setattr(self.prefs, key, float(value)) + elif isinstance(current, str): + setattr(self.prefs, key, str(value)) + else: + setattr(self.prefs, key, value) + except (TypeError, ValueError) as e: + logger.debug("Skip prefs key %r: %s", key, e) + except Exception as e: + logger.warning("Failed to load companion prefs: %s", e) diff --git a/repeater/data_acquisition/sqlite_handler.py b/repeater/data_acquisition/sqlite_handler.py index f8b1d24..4701252 100644 --- a/repeater/data_acquisition/sqlite_handler.py +++ b/repeater/data_acquisition/sqlite_handler.py @@ -423,6 +423,33 @@ class SQLiteHandler: ) logger.info(f"Migration '{migration_name}' applied successfully") + # Migration 7: Add companion_prefs table (JSON blob for full NodePrefs persistence) + migration_name = "add_companion_prefs" + existing = conn.execute( + "SELECT migration_name FROM migrations WHERE migration_name = ?", + (migration_name,), + ).fetchone() + + if not existing: + cursor = conn.execute( + "SELECT name FROM sqlite_master WHERE type='table' AND name='companion_prefs'" + ) + if not cursor.fetchone(): + conn.execute( + """ + CREATE TABLE companion_prefs ( + companion_hash TEXT PRIMARY KEY, + prefs_json TEXT NOT NULL + ) + """ + ) + logger.info("Created companion_prefs table") + conn.execute( + "INSERT INTO migrations (migration_name, applied_at) VALUES (?, ?)", + (migration_name, time.time()), + ) + logger.info(f"Migration '{migration_name}' applied successfully") + conn.commit() except Exception as e: @@ -1786,6 +1813,41 @@ class SQLiteHandler: logger.error(f"Failed to upsert companion contact: {e}") return False + def companion_load_prefs(self, companion_hash: str) -> Optional[Dict]: + """Load persisted prefs for a companion. Returns parsed JSON dict or None if no row.""" + try: + with sqlite3.connect(self.sqlite_path) as conn: + cursor = conn.execute( + "SELECT prefs_json FROM companion_prefs WHERE companion_hash = ?", + (companion_hash,), + ) + row = cursor.fetchone() + if row is None: + return None + return json.loads(row[0]) + except Exception as e: + logger.error(f"Failed to load companion prefs: {e}") + return None + + def companion_save_prefs(self, companion_hash: str, prefs: Dict) -> bool: + """Persist prefs for a companion as JSON. Upserts by companion_hash.""" + try: + prefs_json = json.dumps(prefs) + with sqlite3.connect(self.sqlite_path) as conn: + conn.execute( + """ + INSERT INTO companion_prefs (companion_hash, prefs_json) + VALUES (?, ?) + ON CONFLICT(companion_hash) DO UPDATE SET prefs_json = excluded.prefs_json + """, + (companion_hash, prefs_json), + ) + conn.commit() + return True + except Exception as e: + logger.error(f"Failed to save companion prefs: {e}") + return False + def companion_load_channels(self, companion_hash: str) -> List[Dict]: """Load channels for a companion from storage.""" try: diff --git a/repeater/main.py b/repeater/main.py index 5db5ce8..7cacf8f 100644 --- a/repeater/main.py +++ b/repeater/main.py @@ -356,10 +356,9 @@ class RepeaterDaemon: async def _load_companion_identities(self) -> None: """Load companion identities from config and create CompanionBridge + frame server for each.""" from pymc_core import LocalIdentity - from pymc_core.companion import CompanionBridge from pymc_core.companion.models import Channel, Contact - from repeater.companion import CompanionFrameServer + from repeater.companion import CompanionFrameServer, RepeaterCompanionBridge companions_config = self.config.get("identities", {}).get("companions") or [] if not companions_config: @@ -412,11 +411,13 @@ class RepeaterDaemon: tcp_port = settings.get("tcp_port", 5000) bind_address = settings.get("bind_address", "0.0.0.0") - bridge = CompanionBridge( + bridge = RepeaterCompanionBridge( identity=identity, packet_injector=self.router.inject_packet, node_name=node_name, radio_config=radio_config, + sqlite_handler=sqlite_handler, + companion_hash=companion_hash_str, ) # Load contacts from SQLite