mirror of
https://github.com/pyMC-dev/pyMC_Repeater.git
synced 2026-07-28 12:33:23 +02:00
250 lines
8.3 KiB
Python
250 lines
8.3 KiB
Python
"""SQLite round-trip of the queued-message signal and channel-data fields.
|
|
|
|
Companion offline-queue messages are persisted as structured fields and the
|
|
response frame is rebuilt on replay (SYNC_NEXT_MESSAGE). The parity contract is
|
|
that the frame rebuilt from a SQLite round-tripped message is byte-identical to
|
|
the frame built from the original in-memory ``QueuedMessage`` — so snr, rssi,
|
|
channel_data_type and channel_data_payload must all survive persistence.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import sqlite3
|
|
|
|
import pytest
|
|
from openhop_core.companion.models import QueuedMessage
|
|
from openhop_core.protocol.constants import TXT_TYPE_PLAIN, TXT_TYPE_SIGNED_PLAIN
|
|
|
|
from repeater.companion.frame_server import CompanionFrameServer
|
|
from repeater.data_acquisition.sqlite_handler import SQLiteHandler
|
|
|
|
_HASH = "0x01"
|
|
|
|
|
|
def _handler(tmp_path):
|
|
return SQLiteHandler(tmp_path)
|
|
|
|
|
|
def _frame_server(handler, app_target_ver=3):
|
|
"""A CompanionFrameServer wired to ``handler`` for frame build + replay.
|
|
|
|
``_build_message_frame`` only reads ``_app_target_ver``; V3 is used so the
|
|
SNR byte is emitted in the direct and channel-text frames (the pre-V3
|
|
variants omit it).
|
|
"""
|
|
fs = CompanionFrameServer.__new__(CompanionFrameServer)
|
|
fs._app_target_ver = app_target_ver
|
|
fs.sqlite_handler = handler
|
|
fs.companion_hash = _HASH
|
|
return fs
|
|
|
|
|
|
def _qm_from_dict(d: dict) -> QueuedMessage:
|
|
"""Build the original in-memory QueuedMessage the bridge would enqueue."""
|
|
prefix = d.get("sender_prefix") or b""
|
|
return QueuedMessage(
|
|
sender_key=d.get("sender_key", b""),
|
|
txt_type=d.get("txt_type", 0),
|
|
timestamp=d.get("timestamp", 0),
|
|
text=d.get("text", ""),
|
|
is_channel=bool(d.get("is_channel", False)),
|
|
channel_idx=d.get("channel_idx", 0),
|
|
path_len=d.get("path_len", 0),
|
|
snr=float(d.get("snr") or 0.0),
|
|
rssi=int(d.get("rssi") or 0),
|
|
channel_data_type=int(d.get("channel_data_type") or 0),
|
|
channel_data_payload=bytes(d.get("channel_data_payload") or b""),
|
|
sender_prefix=bytes(prefix),
|
|
)
|
|
|
|
|
|
# Nonzero snr (negative fractional exercises the int8 wrap) and nonzero rssi.
|
|
_DIRECT_PLAIN = {
|
|
"sender_key": b"\x11" * 32,
|
|
"text": "hello world",
|
|
"timestamp": 1000,
|
|
"txt_type": TXT_TYPE_PLAIN,
|
|
"is_channel": False,
|
|
"channel_idx": 0,
|
|
"path_len": 0xFF,
|
|
"packet_hash": "direct-plain",
|
|
"snr": -6.25,
|
|
"rssi": -80,
|
|
"sender_prefix": b"",
|
|
}
|
|
_DIRECT_SIGNED = {
|
|
"sender_key": b"\x22" * 32,
|
|
"text": "signed room post",
|
|
"timestamp": 2000,
|
|
"txt_type": TXT_TYPE_SIGNED_PLAIN,
|
|
"is_channel": False,
|
|
"channel_idx": 0,
|
|
"path_len": 3,
|
|
"packet_hash": "direct-signed",
|
|
"snr": 7.5,
|
|
"rssi": -60,
|
|
"sender_prefix": b"\xaa\xbb\xcc\xdd",
|
|
}
|
|
_CHANNEL_TEXT = {
|
|
"sender_key": b"",
|
|
"text": "channel chatter",
|
|
"timestamp": 3000,
|
|
"txt_type": 0,
|
|
"is_channel": True,
|
|
"channel_idx": 2,
|
|
"path_len": 5,
|
|
"packet_hash": "channel-text",
|
|
"snr": -3.75,
|
|
"rssi": -90,
|
|
}
|
|
_CHANNEL_DATA = {
|
|
"sender_key": b"",
|
|
"text": "",
|
|
"timestamp": 0,
|
|
"txt_type": 0,
|
|
"is_channel": True,
|
|
"channel_idx": 1,
|
|
"path_len": 4,
|
|
"packet_hash": "channel-data",
|
|
"snr": -6.25,
|
|
"rssi": -70,
|
|
"channel_data_type": 0x0102,
|
|
"channel_data_payload": b"\x01\x02\x03\xff\x10",
|
|
}
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"msg_dict",
|
|
[_DIRECT_PLAIN, _DIRECT_SIGNED, _CHANNEL_TEXT, _CHANNEL_DATA],
|
|
ids=["direct_plain", "direct_signed", "channel_text", "channel_data"],
|
|
)
|
|
def test_frame_round_trip_is_byte_identical(tmp_path, msg_dict):
|
|
h = _handler(tmp_path)
|
|
fs = _frame_server(h)
|
|
|
|
original_frame = fs._build_message_frame(_qm_from_dict(msg_dict))
|
|
|
|
assert h.companion_push_message(_HASH, dict(msg_dict))
|
|
rebuilt = fs._sync_next_from_persistence()
|
|
assert rebuilt is not None
|
|
rebuilt_frame = fs._build_message_frame(rebuilt)
|
|
|
|
assert rebuilt_frame == original_frame
|
|
|
|
|
|
def test_channel_data_survives_as_binary_frame_not_empty_text(tmp_path):
|
|
# Regression: a binary channel-data message must not collapse into an empty
|
|
# channel-text frame after the SQLite round-trip.
|
|
h = _handler(tmp_path)
|
|
fs = _frame_server(h)
|
|
|
|
assert h.companion_push_message(_HASH, dict(_CHANNEL_DATA))
|
|
rebuilt = fs._sync_next_from_persistence()
|
|
|
|
assert rebuilt.channel_data_type == 0x0102
|
|
assert rebuilt.channel_data_payload == b"\x01\x02\x03\xff\x10"
|
|
assert rebuilt.snr == pytest.approx(-6.25)
|
|
assert rebuilt.rssi == -70
|
|
|
|
|
|
def test_restart_durability_reconstructs_all_fields(tmp_path):
|
|
h = _handler(tmp_path)
|
|
assert h.companion_push_message(_HASH, dict(_DIRECT_SIGNED))
|
|
assert h.companion_push_message(_HASH, dict(_CHANNEL_DATA))
|
|
|
|
# A daemon restart: drop the handler and reopen the same database file.
|
|
del h
|
|
h2 = _handler(tmp_path)
|
|
|
|
signed = h2.companion_pop_message(_HASH)
|
|
assert signed["sender_prefix"] == b"\xaa\xbb\xcc\xdd"
|
|
assert signed["snr"] == pytest.approx(7.5)
|
|
assert signed["rssi"] == -60
|
|
assert signed["channel_data_type"] == 0
|
|
assert signed["channel_data_payload"] == b""
|
|
|
|
data = h2.companion_pop_message(_HASH)
|
|
assert data["channel_data_type"] == 0x0102
|
|
assert data["channel_data_payload"] == b"\x01\x02\x03\xff\x10"
|
|
assert data["snr"] == pytest.approx(-6.25)
|
|
assert data["rssi"] == -70
|
|
|
|
|
|
def test_load_messages_returns_new_fields_with_types(tmp_path):
|
|
h = _handler(tmp_path)
|
|
assert h.companion_push_message(_HASH, dict(_CHANNEL_DATA))
|
|
|
|
msgs = h.companion_load_messages(_HASH)
|
|
assert len(msgs) == 1
|
|
msg = msgs[0]
|
|
assert isinstance(msg["snr"], float)
|
|
assert isinstance(msg["rssi"], int)
|
|
assert isinstance(msg["channel_data_type"], int)
|
|
assert isinstance(msg["channel_data_payload"], bytes)
|
|
assert msg["snr"] == pytest.approx(-6.25)
|
|
assert msg["rssi"] == -70
|
|
assert msg["channel_data_type"] == 0x0102
|
|
assert msg["channel_data_payload"] == b"\x01\x02\x03\xff\x10"
|
|
|
|
|
|
def test_legacy_schema_migrates_and_defaults(tmp_path):
|
|
# Rewind companion_messages to the pre-change schema (sender_prefix present,
|
|
# but no snr/rssi/channel_data_type/channel_data_payload) and drop the
|
|
# migration marker, then re-run migrations against a pre-existing row.
|
|
h = _handler(tmp_path)
|
|
conn = sqlite3.connect(str(h.sqlite_path))
|
|
conn.execute(
|
|
"DELETE FROM migrations "
|
|
"WHERE migration_name = 'add_signal_and_channel_data_to_companion_messages'"
|
|
)
|
|
conn.execute("ALTER TABLE companion_messages RENAME TO companion_messages_old")
|
|
conn.execute(
|
|
"""
|
|
CREATE TABLE companion_messages (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
companion_hash TEXT NOT NULL,
|
|
sender_key BLOB NOT NULL,
|
|
txt_type INTEGER NOT NULL DEFAULT 0,
|
|
timestamp INTEGER NOT NULL DEFAULT 0,
|
|
text TEXT NOT NULL,
|
|
is_channel INTEGER NOT NULL DEFAULT 0,
|
|
channel_idx INTEGER NOT NULL DEFAULT 0,
|
|
path_len INTEGER NOT NULL DEFAULT 0,
|
|
sender_prefix TEXT NOT NULL DEFAULT '',
|
|
packet_hash TEXT,
|
|
created_at REAL NOT NULL
|
|
)
|
|
"""
|
|
)
|
|
conn.execute("DROP TABLE companion_messages_old")
|
|
conn.execute(
|
|
"INSERT INTO companion_messages "
|
|
"(companion_hash, sender_key, text, created_at) VALUES ('0x01', X'01', 'old', 1.0)"
|
|
)
|
|
conn.commit()
|
|
conn.close()
|
|
|
|
h2 = _handler(tmp_path) # re-runs migrations
|
|
|
|
# (a) the four columns are added.
|
|
conn = sqlite3.connect(str(h2.sqlite_path))
|
|
columns = {row[1] for row in conn.execute("PRAGMA table_info(companion_messages)")}
|
|
conn.close()
|
|
assert {"snr", "rssi", "channel_data_type", "channel_data_payload"} <= columns
|
|
|
|
# (b) the old row pops with defaults and no exception.
|
|
old = h2.companion_pop_message(_HASH)
|
|
assert old["text"] == "old"
|
|
assert old["snr"] == 0.0
|
|
assert old["rssi"] == 0
|
|
assert old["channel_data_type"] == 0
|
|
assert old["channel_data_payload"] == b""
|
|
|
|
# (c) new pushes round-trip fully through the migrated columns.
|
|
assert h2.companion_push_message(_HASH, dict(_CHANNEL_DATA))
|
|
data = h2.companion_pop_message(_HASH)
|
|
assert data["channel_data_type"] == 0x0102
|
|
assert data["channel_data_payload"] == b"\x01\x02\x03\xff\x10"
|
|
assert data["snr"] == pytest.approx(-6.25)
|
|
assert data["rssi"] == -70
|