mirror of
https://github.com/pyMC-dev/pyMC_Repeater.git
synced 2026-10-31 04:01:59 +01:00
284 lines
11 KiB
Python
284 lines
11 KiB
Python
"""
|
|
Repeater-specific CompanionFrameServer with SQLite persistence.
|
|
|
|
Thin subclass of :class:`openhop_core.companion.frame_server.CompanionFrameServer`
|
|
that adds SQLite-backed message, contact, and channel persistence via a
|
|
``sqlite_handler`` dependency.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import logging
|
|
import shutil
|
|
from typing import Callable, Optional
|
|
|
|
from openhop_core.companion.constants import RESP_CODE_NO_MORE_MESSAGES
|
|
from openhop_core.companion.frame_server import CompanionFrameServer as _BaseFrameServer
|
|
from openhop_core.companion.models import QueuedMessage
|
|
|
|
logger = logging.getLogger("CompanionFrameServer")
|
|
|
|
|
|
class CompanionFrameServer(_BaseFrameServer):
|
|
"""Adds SQLite persistence for messages, contacts, and channels.
|
|
|
|
Constructor signature is intentionally kept compatible with the
|
|
previous monolithic implementation so ``main.py`` call-sites need
|
|
zero changes.
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
bridge,
|
|
companion_hash: str,
|
|
port: int = 5000,
|
|
bind_address: str = "0.0.0.0", # nosec B104 - intentional default for LAN reachability
|
|
client_idle_timeout_sec: Optional[int] = 8 * 60 * 60, # 8 hours
|
|
sqlite_handler=None,
|
|
local_hash: Optional[int] = None,
|
|
stats_getter=None,
|
|
control_handler=None,
|
|
batt_getter: Callable[[], int] | None = None,
|
|
storage_dir: str | None = None,
|
|
):
|
|
super().__init__(
|
|
bridge=bridge,
|
|
companion_hash=companion_hash,
|
|
port=port,
|
|
bind_address=bind_address,
|
|
client_idle_timeout_sec=client_idle_timeout_sec,
|
|
device_model="openHop-Repeater-Companion",
|
|
device_version=None, # use FIRMWARE_VER_CODE from openhop-core
|
|
build_date="13 Feb 2026",
|
|
local_hash=local_hash,
|
|
stats_getter=stats_getter,
|
|
control_handler=control_handler,
|
|
)
|
|
self.sqlite_handler = sqlite_handler
|
|
self.batt_getter = batt_getter
|
|
self.storage_dir = storage_dir
|
|
|
|
def _get_batt_and_storage(self) -> tuple[int, int, int]:
|
|
"""Report battery millivolts and storage usage to companion clients.
|
|
|
|
The base-class hook returns zeros, so companion apps show no battery and
|
|
no storage for a Python repeater. Battery comes from whichever sensor
|
|
plug-in publishes one (an attached UPS HAT, or the battery of an
|
|
openHop modem the repeater drives); storage is the filesystem holding
|
|
the repeater's data directory.
|
|
"""
|
|
millivolts = 0
|
|
if self.batt_getter is not None:
|
|
try:
|
|
millivolts = int(self.batt_getter() or 0)
|
|
except Exception: # pragma: no cover - never break the companion link
|
|
logger.debug("battery lookup failed", exc_info=True)
|
|
millivolts = 0
|
|
if millivolts < 0 or millivolts > 0xFFFF:
|
|
millivolts = 0
|
|
|
|
used_kb = total_kb = 0
|
|
if self.storage_dir:
|
|
try:
|
|
usage = shutil.disk_usage(self.storage_dir)
|
|
total_kb = min(usage.total // 1024, 0xFFFFFFFF)
|
|
used_kb = min((usage.total - usage.free) // 1024, 0xFFFFFFFF)
|
|
except OSError:
|
|
logger.debug("storage lookup failed for %s", self.storage_dir, exc_info=True)
|
|
return millivolts, used_kb, total_kb
|
|
|
|
async def start(self) -> None:
|
|
"""Start persistence before accepting companion client connections."""
|
|
if self.sqlite_handler:
|
|
self.bridge.on_message_event(self._on_message_event)
|
|
self.bridge.on_channel_message_event(self._on_channel_message_event)
|
|
self.bridge.on_channel_data_event(self._on_channel_data_event)
|
|
await super().start()
|
|
|
|
# -----------------------------------------------------------------
|
|
# Persistence hook overrides
|
|
# -----------------------------------------------------------------
|
|
|
|
async def _persist_companion_message(self, msg_dict: dict, queue_entry=None) -> None:
|
|
"""Persist message to SQLite and remove it from the bridge queue.
|
|
|
|
The bridge's ``offline_queue_size`` (``message_queue.max_size``) doubles
|
|
as the SQLite retention limit: 0 disables offline storage entirely, so the
|
|
message is dropped instead of persisted.
|
|
|
|
``queue_entry`` is the exact in-memory entry this message came from. The
|
|
persisted entry is removed by identity (``message_queue.remove``) rather
|
|
than by ``pop_last``: pushes happen synchronously in sibling receive
|
|
tasks, so during the awaited ``to_thread`` another task can append a newer
|
|
entry and ``pop_last`` would remove that one instead — duplicating this
|
|
message and losing the newer one.
|
|
"""
|
|
if not self.sqlite_handler:
|
|
return
|
|
# Older cores predate the public max_size property.
|
|
retention = getattr(
|
|
self.bridge.message_queue,
|
|
"max_size",
|
|
getattr(self.bridge.message_queue, "_max_size", None),
|
|
)
|
|
if retention == 0:
|
|
self._remove_queue_entry(queue_entry)
|
|
return
|
|
persisted = await asyncio.to_thread(
|
|
self.sqlite_handler.companion_push_message,
|
|
self.companion_hash,
|
|
msg_dict,
|
|
retention,
|
|
)
|
|
if persisted:
|
|
self._remove_queue_entry(queue_entry)
|
|
else:
|
|
logger.debug(
|
|
"Companion %s: retaining message in memory after SQLite queue rejection",
|
|
self.companion_hash,
|
|
)
|
|
|
|
def _remove_queue_entry(self, queue_entry) -> None:
|
|
"""Remove exactly the persisted entry from the bridge queue by identity.
|
|
|
|
Falling back to ``pop_last`` when ``queue_entry`` is None would reopen the
|
|
interleaving race (it could evict a newer, unpersisted entry), so an event
|
|
from an older core that carries no entry is left in memory: a possible
|
|
duplicate is preferable to losing a message.
|
|
"""
|
|
if queue_entry is None:
|
|
logger.debug(
|
|
"Companion %s: no queue entry on persisted message; leaving in memory",
|
|
self.companion_hash,
|
|
)
|
|
return
|
|
self.bridge.message_queue.remove(queue_entry)
|
|
|
|
def _sync_next_from_persistence(self) -> Optional[QueuedMessage]:
|
|
"""Retrieve next message from SQLite when bridge queue is empty."""
|
|
if not self.sqlite_handler:
|
|
return None
|
|
msg_dict = self.sqlite_handler.companion_pop_message(self.companion_hash)
|
|
if not msg_dict:
|
|
return None
|
|
sender_prefix = msg_dict.get("sender_prefix", b"")
|
|
if isinstance(sender_prefix, str):
|
|
sender_prefix = bytes.fromhex(sender_prefix) if sender_prefix else b""
|
|
return QueuedMessage(
|
|
sender_key=msg_dict.get("sender_key", b""),
|
|
txt_type=msg_dict.get("txt_type", 0),
|
|
timestamp=msg_dict.get("timestamp", 0),
|
|
text=msg_dict.get("text", ""),
|
|
is_channel=bool(msg_dict.get("is_channel", False)),
|
|
channel_idx=msg_dict.get("channel_idx", 0),
|
|
path_len=msg_dict.get("path_len", 0),
|
|
snr=float(msg_dict.get("snr") or 0.0),
|
|
rssi=int(msg_dict.get("rssi") or 0),
|
|
channel_data_type=int(msg_dict.get("channel_data_type") or 0),
|
|
channel_data_payload=bytes(msg_dict.get("channel_data_payload") or b""),
|
|
sender_prefix=sender_prefix,
|
|
)
|
|
|
|
# -----------------------------------------------------------------
|
|
# Non-blocking command overrides (keep event loop responsive)
|
|
# -----------------------------------------------------------------
|
|
|
|
async def _cmd_sync_next_message(self, data: bytes) -> None:
|
|
"""Sync next message; run persistence read in thread so SQLite does not block."""
|
|
msg = self.bridge.sync_next_message()
|
|
if msg is None:
|
|
msg = await asyncio.to_thread(self._sync_next_from_persistence)
|
|
if msg is None:
|
|
self._write_frame(bytes([RESP_CODE_NO_MORE_MESSAGES]))
|
|
return
|
|
self._write_frame(self._build_message_frame(msg))
|
|
|
|
@staticmethod
|
|
def _contact_to_dict(c) -> dict:
|
|
"""Convert a Contact object to a persistence dict."""
|
|
pk = c.public_key if isinstance(c.public_key, bytes) else bytes.fromhex(c.public_key)
|
|
raw_advert = getattr(c, "last_advert_packet", None)
|
|
if isinstance(raw_advert, bytearray):
|
|
raw_advert = bytes(raw_advert)
|
|
elif isinstance(raw_advert, str):
|
|
try:
|
|
raw_advert = bytes.fromhex(raw_advert)
|
|
except ValueError:
|
|
raw_advert = None
|
|
elif not isinstance(raw_advert, bytes):
|
|
raw_advert = None
|
|
return {
|
|
"pubkey": pk,
|
|
"name": c.name,
|
|
"adv_type": c.adv_type,
|
|
"flags": c.flags,
|
|
"out_path_len": c.out_path_len,
|
|
"out_path": (
|
|
c.out_path
|
|
if isinstance(c.out_path, bytes)
|
|
else (bytes.fromhex(c.out_path) if c.out_path else b"")
|
|
),
|
|
"last_advert_timestamp": c.last_advert_timestamp,
|
|
"last_advert_packet": raw_advert,
|
|
"lastmod": c.lastmod,
|
|
"gps_lat": c.gps_lat,
|
|
"gps_lon": c.gps_lon,
|
|
"sync_since": c.sync_since,
|
|
}
|
|
|
|
async def _persist_contact(self, contact) -> None:
|
|
"""Upsert a single contact to SQLite (non-blocking)."""
|
|
if not self.sqlite_handler:
|
|
return
|
|
contact_dict = self._contact_to_dict(contact)
|
|
await asyncio.to_thread(
|
|
self.sqlite_handler.companion_upsert_contact,
|
|
self.companion_hash,
|
|
contact_dict,
|
|
)
|
|
|
|
async def _save_contacts(self) -> None:
|
|
"""Persist all contacts to SQLite (non-blocking)."""
|
|
if not self.sqlite_handler:
|
|
return
|
|
contacts = self.bridge.get_contacts()
|
|
dicts = [self._contact_to_dict(c) for c in contacts]
|
|
await asyncio.to_thread(
|
|
self.sqlite_handler.companion_save_contacts,
|
|
self.companion_hash,
|
|
dicts,
|
|
)
|
|
|
|
async def _save_channels(self) -> None:
|
|
"""Persist channels to SQLite (non-blocking)."""
|
|
if not self.sqlite_handler:
|
|
return
|
|
channels = []
|
|
max_ch = getattr(getattr(self.bridge, "channels", None), "max_channels", 40)
|
|
for idx in range(max_ch):
|
|
ch = self.bridge.get_channel(idx)
|
|
if ch is not None:
|
|
channels.append(
|
|
{
|
|
"channel_idx": idx,
|
|
"name": ch.name,
|
|
"secret": ch.secret,
|
|
}
|
|
)
|
|
await asyncio.to_thread(
|
|
self.sqlite_handler.companion_save_channels,
|
|
self.companion_hash,
|
|
channels,
|
|
)
|
|
|
|
async def stop(self) -> None:
|
|
"""Persist contacts and channels before stopping (so they survive daemon restart)."""
|
|
if self.sqlite_handler:
|
|
try:
|
|
await self._save_contacts()
|
|
await self._save_channels()
|
|
except Exception as e:
|
|
logger.warning("Failed to persist contacts/channels on stop: %s", e)
|
|
await super().stop()
|