From 323ebedeae67c76d317d8aab64cb9ff72b190b53 Mon Sep 17 00:00:00 2001 From: agessaman Date: Fri, 2 Oct 2026 16:51:15 -0700 Subject: [PATCH] fix(transmission_tracker): match a sent message only to its own packet match_packet_hash gave a pending transmission the hash of the first packet heard within its 30-second window, whatever the packet was. On a busy mesh that was usually someone else's message or advert, so its repeats were counted as ours (the web viewer's repeat counts, DARC MoWaS's ACK check, graph_trace_helper's "our trace" test) and our real repeats were not. A transmission now records what identifies its packet (the channel index for a channel message, the recipient's key for a DM, the tag for a trace) and match_packet_hash claims it only for a packet carrying the same: a GRP_TXT that decrypts on that channel to the same text, a TXT_MSG addressed to the recipient's hash and, when the radio's key is known, from ours, or a TRACE with the same tag. The RX handler passes that evidence through _transmission_evidence, reusing the authenticated channel decode. Without evidence, match_packet_hash only looks up hashes already matched, so record_repeat and graph_trace_helper no longer claim pending transmissions. Repeat-count writes go to a single worker thread instead of running sqlite on the event loop, and _update_command_in_database no longer inserts into sys.path on every repeat. bot_prefix still reads meshcore.device and stays None; fixing it turns on graph_trace_helper's trace updates, which have never run, so it is a separate change. --- CHANGELOG.md | 1 + modules/command_manager.py | 6 +- modules/message_handler.py | 33 ++++++-- modules/trace_runner.py | 1 + modules/transmission_tracker.py | 127 +++++++++++++++++++++++++---- tests/test_message_handler.py | 19 +++++ tests/test_transmission_tracker.py | 95 +++++++++++++++++---- 7 files changed, 243 insertions(+), 39 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d98caf3..db74efa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,6 +38,7 @@ semantic versioning. ### Fixed +- Repeat counts for the bot's own messages (the web viewer's command log, the DARC MoWaS retry check) count the bot's own packets. A sent message was matched to the first packet the radio heard within 30 seconds, whatever it was, so on a busy mesh it usually took someone else's packet: its repeats were counted as the bot's, and the bot's real repeats were not. A channel message is now matched only by a packet that decrypts on the same channel to the same text, a DM by a direct message addressed to the recipient from the bot, and a trace by its tag. The repeat counts are written to the database off the event loop, and each repeat no longer adds an entry to Python's import path. - `gwx` (and `wx` with `weather_provider = openmeteo`) labels the current conditions by whether the sun is up at the location, using Open-Meteo's `is_day`. Before 06:00 the reply said "Tonight" next to today's high and low and a "Tomorrow" that was really the day after, so at 5:58 am it read like last night's forecast. Between midnight and sunrise it now says "Overnight", as NWS does; "Tonight" runs from sunset to midnight; and a dark 06:30 in October or a light 18:30 in June is no longer mislabeled. Clear skies at night show 🌙 instead of ☀️, in hourly forecasts too (each hour by its own `is_day`), and tomorrow's part names its weather ("🌫️Foggy", not just "🌫️") when it fits. - `wx hourly`, `gwx tomorrow`, `wx 5d`, `wx alerts` and other options given without a location now apply to the location the command falls back to (a custom default source, the sender's position, `default_city`, the bot's position); they showed the usage text, and `wx alerts` looked up a place called "alerts". A custom source named like an option (`custom.wxsim.hourly`) still answers as that source. `wx alerts` stops when its first message is refused instead of sending the rest of the list. With `[Weather]` units and no `[Wx_Command]` override, `wx` also no longer logs a notice to move the unit settings into `[Wx_Command]` on every reply. - `wx tomorrow` (NOAA) fits its reply into one message. It ignored the length budget, so a day with long condition names ("Chance Showers And Thunderstorms" for both periods) went out as two messages. It now leaves out wind, the night period's first, and then the night period itself when that is what it takes. `gwx tomorrow` ignored its budget too; it now leaves out the precipitation amount, gusts, wind, the precipitation chance and then the description, in that order. diff --git a/modules/command_manager.py b/modules/command_manager.py index 77c15d0..d4f660c 100644 --- a/modules/command_manager.py +++ b/modules/command_manager.py @@ -1244,7 +1244,8 @@ class CommandManager: content=content, target=contact_name, message_type='dm', - command_id=command_id + command_id=command_id, + recipient_key=contact.get('public_key') if isinstance(contact, dict) else None, ) except Exception as e: self.logger.debug(f"Error recording transmission for repeat tracking: {e}") @@ -1491,7 +1492,8 @@ class CommandManager: content=content, target=channel, message_type='channel', - command_id=command_id + command_id=command_id, + channel_idx=channel_num, ) except Exception as e: self.logger.debug(f"Error recording transmission for repeat tracking: {e}") diff --git a/modules/message_handler.py b/modules/message_handler.py index e6eeb81..82034bf 100644 --- a/modules/message_handler.py +++ b/modules/message_handler.py @@ -526,9 +526,13 @@ class MessageHandler: return result def _decode_authenticated_channel_identity( - self, packet_info: dict[str, Any] | None + self, packet_info: dict[str, Any] | None, *, include_text: bool = False ) -> dict[str, Any] | None: - """Authenticate a decoded GRP_TXT packet and derive its CHAN identity.""" + """Authenticate a decoded GRP_TXT packet and derive its CHAN identity. + + ``include_text`` adds the decrypted text without its sender prefix as + ``channel_text``. + """ if not packet_info or packet_info.get("payload_type") != self._grp_txt_payload_type_int(): return None payload_hex = packet_info.get("payload_hex") @@ -559,13 +563,28 @@ class MessageHandler: ) if identity is None: return None - return { + result = { "channel_message_id": identity, "channel_idx": channel_idx, "channel_attempt": int(decrypted["flags"]) & 0x03, } + if include_text: + result["channel_text"] = decrypted["text"] + return result return None + def _transmission_evidence(self, packet_info: dict[str, Any]) -> dict[str, Any]: + """What a received packet says about itself, for TransmissionTracker to recognize our own.""" + evidence: dict[str, Any] = { + "payload_type": packet_info.get("payload_type"), + "payload_hex": packet_info.get("payload_hex") or "", + } + channel = self._decode_authenticated_channel_identity(packet_info, include_text=True) + if channel: + evidence["channel_idx"] = channel["channel_idx"] + evidence["channel_text"] = channel["channel_text"] + return evidence + def _cache_authenticated_channel_rf_data( self, rf_data: dict[str, Any], @@ -1355,7 +1374,9 @@ class MessageHandler: ) # Try to match this packet hash to a transmission - record = self.bot.transmission_tracker.match_packet_hash(packet_hash, current_time) + record = self.bot.transmission_tracker.match_packet_hash( + packet_hash, current_time, self._transmission_evidence(decoded_packet) + ) if record: # This is one of our transmissions - check for repeats @@ -1385,7 +1406,9 @@ class MessageHandler: # Still count as a repeat (heard by our radio) self.bot.transmission_tracker.record_repeat(packet_hash, None) else: - record = self.bot.transmission_tracker.match_packet_hash(packet_hash, current_time) + record = self.bot.transmission_tracker.match_packet_hash( + packet_hash, current_time, self._transmission_evidence(decoded_packet) + ) if record: self.logger.debug( "📡 TRACE packet matched our transmission; skipping repeater prefix " diff --git a/modules/trace_runner.py b/modules/trace_runner.py index 3433065..6185171 100644 --- a/modules/trace_runner.py +++ b/modules/trace_runner.py @@ -52,6 +52,7 @@ async def _run_trace_attempt( target="", message_type="trace", command_id=str(tag), + trace_tag=tag, ) except Exception as e: bot.logger.debug(f"Trace runner: failed to record transmission: {e}") diff --git a/modules/transmission_tracker.py b/modules/transmission_tracker.py index 925162c..d326f7c 100644 --- a/modules/transmission_tracker.py +++ b/modules/transmission_tracker.py @@ -4,12 +4,24 @@ Transmission tracker for monitoring message transmission success Tracks transmitted message hashes and detects repeats from neighboring repeaters """ +import asyncio +import json +import sqlite3 import threading import time +from concurrent.futures import ThreadPoolExecutor from contextlib import closing from dataclasses import dataclass, field +from pathlib import Path from typing import Any, Optional +from .utils import resolve_path + +# MeshCore payload types a transmission can be recognized by +_PAYLOAD_TXT_MSG = 0x02 +_PAYLOAD_GRP_TXT = 0x05 +_PAYLOAD_TRACE = 0x09 + @dataclass class TransmissionRecord: @@ -23,6 +35,12 @@ class TransmissionRecord: repeater_prefixes: set[str] = field(default_factory=set) repeater_counts: dict[str, int] = field(default_factory=dict) # Count per repeater prefix command_id: Optional[str] = None # For correlating with command data + # What identifies this transmission's packet when it is heard again: the + # channel index for a channel message, the recipient's key for a DM, the + # tag for a trace. A record without one is never matched to a packet. + channel_idx: Optional[int] = None + recipient_key: Optional[str] = None + trace_tag: Optional[int] = None class TransmissionTracker: @@ -58,6 +76,9 @@ class TransmissionTracker: # Lock protects record mutations (repeat_count, repeater_prefixes, etc.) self._lock = threading.Lock() + # One worker keeps repeat-count writes in order and off the event loop + self._db_executor: Optional[ThreadPoolExecutor] = None + # Track our bot's public key prefix (first 2 hex chars) for filtering self.bot_prefix: Optional[str] = None self._update_bot_prefix() @@ -78,14 +99,20 @@ class TransmissionTracker: self.logger.debug(f"Could not determine bot prefix: {e}") def record_transmission(self, content: str, target: str, message_type: str, - command_id: Optional[str] = None) -> TransmissionRecord: + command_id: Optional[str] = None, *, + channel_idx: Optional[int] = None, + recipient_key: Optional[str] = None, + trace_tag: Optional[int] = None) -> TransmissionRecord: """Record a transmission attempt. Args: content: Message content target: Channel name or recipient ID - message_type: 'channel' or 'dm' + message_type: 'channel', 'dm' or 'trace' command_id: Optional command ID for correlation + channel_idx: Channel index a channel message was sent on + recipient_key: Recipient public key (hex) of a DM + trace_tag: Tag of a trace Returns: TransmissionRecord: The created record @@ -95,7 +122,10 @@ class TransmissionTracker: content=content, target=target, message_type=message_type, - command_id=command_id + command_id=command_id, + channel_idx=channel_idx, + recipient_key=recipient_key, + trace_tag=trace_tag, ) # Store in pending transmissions (by rounded timestamp) @@ -111,12 +141,20 @@ class TransmissionTracker: return record - def match_packet_hash(self, packet_hash: str, rf_timestamp: float) -> Optional[TransmissionRecord]: + def match_packet_hash(self, packet_hash: str, rf_timestamp: float, + packet: Optional[dict[str, Any]] = None) -> Optional[TransmissionRecord]: """Match a received packet hash to a pending transmission. + A pending transmission is claimed only by a packet that carries its + identity (see ``_packet_is_ours``); without ``packet`` only hashes that + were already matched are found. + Args: packet_hash: Packet hash from received RF data rf_timestamp: Timestamp when RF data was received + packet: What the packet says about itself: ``payload_type`` and + ``payload_hex``, plus ``channel_idx`` and ``channel_text`` for a + channel message that decrypted with one of our channel keys Returns: TransmissionRecord if matched, None otherwise @@ -128,6 +166,9 @@ class TransmissionTracker: if packet_hash in self.confirmed_transmissions: return self.confirmed_transmissions[packet_hash] + if packet is None: + return None + # Search in pending transmissions within the match window search_start = int(rf_timestamp - self.match_window) search_end = int(rf_timestamp + 1) # Include current second @@ -141,7 +182,7 @@ class TransmissionTracker: time_diff = abs(rf_timestamp - record.timestamp) if time_diff <= self.match_window: # This is a potential match - store the hash - if record.packet_hash is None: + if record.packet_hash is None and self._packet_is_ours(record, packet): record.packet_hash = packet_hash # Move to confirmed transmissions self.confirmed_transmissions[packet_hash] = record @@ -150,6 +191,48 @@ class TransmissionTracker: return None + def _own_key_byte(self) -> Optional[str]: + """First byte (hex) of the radio's public key, or None before it is known.""" + self_info = getattr(self.bot.meshcore, "self_info", None) if self.bot.meshcore else None + pubkey = self_info.get("public_key") if isinstance(self_info, dict) else None + if isinstance(pubkey, str) and len(pubkey) >= 2: + return pubkey[:2].lower() + return None + + def _packet_is_ours(self, record: TransmissionRecord, packet: Optional[dict[str, Any]]) -> bool: + """Whether ``packet`` is this transmission, judged by what both carry. + + A channel message must decrypt on the same channel to the same text; a DM + must be a TXT_MSG addressed to the recipient's hash, from ours when known; + a trace must carry the same tag. + """ + if not packet: + return False + payload_type = packet.get("payload_type") + payload_hex = packet.get("payload_hex") or "" + if record.message_type == "channel": + return ( + payload_type == _PAYLOAD_GRP_TXT + and record.channel_idx is not None + and packet.get("channel_idx") == record.channel_idx + and packet.get("channel_text") == record.content + ) + if record.message_type == "dm": + if payload_type != _PAYLOAD_TXT_MSG or not record.recipient_key or len(payload_hex) < 4: + return False + if payload_hex[:2].lower() != record.recipient_key[:2].lower(): + return False + own = self._own_key_byte() + return own is None or payload_hex[2:4].lower() == own + if record.message_type == "trace": + if payload_type != _PAYLOAD_TRACE or record.trace_tag is None or len(payload_hex) < 8: + return False + try: + return int.from_bytes(bytes.fromhex(payload_hex[:8]), "little") == record.trace_tag + except ValueError: + return False + return False + def record_repeat(self, packet_hash: str, repeater_prefix: Optional[str] = None) -> bool: """Record that we heard a repeat of one of our transmissions. @@ -188,23 +271,38 @@ class TransmissionTracker: # Update the database entry if we have a command_id if record.command_id and hasattr(self.bot, 'web_viewer_integration'): - self._update_command_in_database(record) + self._schedule_command_update(record) return True return False + def _schedule_command_update(self, record: TransmissionRecord) -> None: + """Write the record's repeat counts to the database, off the event loop when one is running.""" + with self._lock: + snapshot = TransmissionRecord( + timestamp=record.timestamp, + content=record.content, + target=record.target, + message_type=record.message_type, + packet_hash=record.packet_hash, + repeat_count=record.repeat_count, + repeater_prefixes=set(record.repeater_prefixes), + repeater_counts=dict(record.repeater_counts), + command_id=record.command_id, + ) + try: + asyncio.get_running_loop() + except RuntimeError: + self._update_command_in_database(snapshot) + return + if self._db_executor is None: + self._db_executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix="tx-tracker-db") + self._db_executor.submit(self._update_command_in_database, snapshot) + def _update_command_in_database(self, record: TransmissionRecord): """Update command entry in database with latest repeat information""" try: - import json - import os - import sqlite3 - import sys - # Add parent directory to path for imports - sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..')) - from modules.utils import resolve_path - if not record.command_id: return @@ -214,7 +312,6 @@ class TransmissionTracker: and self.bot.config.get('Web_Viewer', 'db_path', fallback='').strip()): db_path = resolve_path(self.bot.config.get('Web_Viewer', 'db_path').strip(), base_dir) else: - from pathlib import Path db_path = str(Path(self.bot.db_manager.db_path).resolve()) with closing(sqlite3.connect(str(db_path), timeout=30.0)) as conn: diff --git a/tests/test_message_handler.py b/tests/test_message_handler.py index 2976f17..588368e 100644 --- a/tests/test_message_handler.py +++ b/tests/test_message_handler.py @@ -19,6 +19,7 @@ from modules.message_handler import ( rf_data_is_correlated, ) from modules.models import MeshMessage +from modules.transmission_tracker import TransmissionTracker from tests.conftest import mock_message as make_message @@ -1921,6 +1922,24 @@ class TestAuthenticatedChannelCorrelation: assert handler._decode_authenticated_channel_identity(packet_info) is None + def test_transmission_evidence_carries_the_decrypted_text(self, handler): + self._setup( + handler, + {1: {"channel_idx": 1, "channel_secret": self.SECRET_1}}, + ) + packet_hex, _group_payload = _make_group_text_packet( + self.SECRET_1, self.SENDER_TIMESTAMP, self.TEXT + ) + packet_info = handler.decode_meshcore_packet(packet_hex) + evidence = handler._transmission_evidence(packet_info) + assert evidence["payload_type"] == 5 + assert evidence["channel_idx"] == 1 + assert evidence["channel_text"] == self.TEXT.split(": ", 1)[1] + record = TransmissionTracker(handler.bot).record_transmission( + self.TEXT.split(": ", 1)[1], "general", "channel", channel_idx=1 + ) + assert record.content == evidence["channel_text"] + @pytest.mark.asyncio async def test_channel_index_disambiguates_same_timestamp_and_text(self, handler): self._setup( diff --git a/tests/test_transmission_tracker.py b/tests/test_transmission_tracker.py index ae34bd9..e9136b3 100644 --- a/tests/test_transmission_tracker.py +++ b/tests/test_transmission_tracker.py @@ -11,6 +11,11 @@ import pytest from modules.transmission_tracker import TransmissionRecord, TransmissionTracker +def _chan(text, idx=0): + """A received GRP_TXT that decrypted on channel ``idx`` to ``text``.""" + return {"payload_type": 5, "payload_hex": "", "channel_idx": idx, "channel_text": text} + + @pytest.fixture def mock_bot(mock_logger): """Minimal bot mock for TransmissionTracker.""" @@ -67,7 +72,7 @@ class TestRecordTransmission: assert rec.message_type == "channel" def test_stores_in_pending(self, tracker): - rec = tracker.record_transmission("msg", "ch", "channel") + rec = tracker.record_transmission("msg", "ch", "channel", channel_idx=0) key = int(rec.timestamp) assert key in tracker.pending_transmissions assert rec in tracker.pending_transmissions[key] @@ -93,24 +98,24 @@ class TestMatchPacketHash: assert tracker.match_packet_hash("0000000000000000", time.time()) is None def test_matches_pending_transmission(self, tracker): - rec = tracker.record_transmission("msg", "general", "channel") - result = tracker.match_packet_hash("deadbeef", rec.timestamp + 1) + rec = tracker.record_transmission("msg", "general", "channel", channel_idx=0) + result = tracker.match_packet_hash("deadbeef", rec.timestamp + 1, _chan("msg")) assert result is not None assert result.packet_hash == "deadbeef" def test_already_confirmed_returned_immediately(self, tracker): - rec = tracker.record_transmission("msg", "ch", "channel") + rec = tracker.record_transmission("msg", "ch", "channel", channel_idx=0) # First match confirms it - tracker.match_packet_hash("abc123", rec.timestamp) + tracker.match_packet_hash("abc123", rec.timestamp, _chan("msg")) # Second call returns same confirmed record result2 = tracker.match_packet_hash("abc123", time.time()) assert result2 is not None assert result2.packet_hash == "abc123" def test_no_match_outside_window(self, tracker): - rec = tracker.record_transmission("msg", "ch", "channel") + rec = tracker.record_transmission("msg", "ch", "channel", channel_idx=0) # RF timestamp far in the future - result = tracker.match_packet_hash("deadbeef", rec.timestamp + 9999) + result = tracker.match_packet_hash("deadbeef", rec.timestamp + 9999, _chan("msg")) assert result is None @@ -122,9 +127,9 @@ class TestRecordRepeat: assert tracker.record_repeat("0000000000000000") is False def test_repeat_increments_count(self, tracker): - rec = tracker.record_transmission("msg", "ch", "channel") + rec = tracker.record_transmission("msg", "ch", "channel", channel_idx=0) # First confirm the hash - tracker.match_packet_hash("hash01", rec.timestamp) + tracker.match_packet_hash("hash01", rec.timestamp, _chan("msg")) # Now record a repeat result = tracker.record_repeat("hash01", repeater_prefix="7e") assert result is True @@ -132,16 +137,16 @@ class TestRecordRepeat: assert "7e" in rec.repeater_prefixes def test_repeat_without_prefix(self, tracker): - rec = tracker.record_transmission("msg", "ch", "channel") - tracker.match_packet_hash("hash02", rec.timestamp) + rec = tracker.record_transmission("msg", "ch", "channel", channel_idx=0) + tracker.match_packet_hash("hash02", rec.timestamp, _chan("msg")) result = tracker.record_repeat("hash02") assert result is True assert rec.repeat_count == 1 assert rec.repeater_counts.get("_unknown") == 1 def test_multiple_repeats_same_repeater(self, tracker): - rec = tracker.record_transmission("msg", "ch", "channel") - tracker.match_packet_hash("hash03", rec.timestamp) + rec = tracker.record_transmission("msg", "ch", "channel", channel_idx=0) + tracker.match_packet_hash("hash03", rec.timestamp, _chan("msg")) tracker.record_repeat("hash03", repeater_prefix="01") tracker.record_repeat("hash03", repeater_prefix="01") assert rec.repeat_count == 2 @@ -162,16 +167,16 @@ class TestGetRepeatInfo: assert info["repeater_counts"] == {} def test_lookup_by_packet_hash(self, tracker): - rec = tracker.record_transmission("msg", "ch", "channel") - tracker.match_packet_hash("hashXX", rec.timestamp) + rec = tracker.record_transmission("msg", "ch", "channel", channel_idx=0) + tracker.match_packet_hash("hashXX", rec.timestamp, _chan("msg")) tracker.record_repeat("hashXX", repeater_prefix="7e") info = tracker.get_repeat_info(packet_hash="hashXX") assert info["repeat_count"] == 1 assert "7e" in info["repeater_prefixes"] def test_lookup_by_command_id(self, tracker): - rec = tracker.record_transmission("msg", "ch", "channel", command_id="cmd-99") - tracker.match_packet_hash("hashYY", rec.timestamp) + rec = tracker.record_transmission("msg", "ch", "channel", command_id="cmd-99", channel_idx=0) + tracker.match_packet_hash("hashYY", rec.timestamp, _chan("msg")) tracker.record_repeat("hashYY", repeater_prefix="ab") info = tracker.get_repeat_info(command_id="cmd-99") assert info["repeat_count"] == 1 @@ -571,3 +576,59 @@ class TestMaybeCleanup: # Recording a new transmission should trigger cleanup tracker.record_transmission("new msg", "general", "channel") assert old_key not in tracker.pending_transmissions + + +class TestOnlyOurOwnPacketsMatch: + """A pending transmission is claimed only by a packet carrying its identity.""" + + def test_an_unrelated_packet_does_not_claim_a_channel_send(self, tracker): + rec = tracker.record_transmission("hello", "general", "channel", channel_idx=0) + assert tracker.match_packet_hash("aaaa", rec.timestamp + 1, {"payload_type": 4, "payload_hex": "c1" * 40}) is None + assert tracker.match_packet_hash("bbbb", rec.timestamp + 1, _chan("someone else's text")) is None + assert tracker.match_packet_hash("cccc", rec.timestamp + 1, _chan("hello", idx=1)) is None + assert rec.packet_hash is None + assert tracker.match_packet_hash("dddd", rec.timestamp + 2, _chan("hello")) is rec + assert tracker.record_repeat("dddd", "7e") is True + + def test_a_hash_without_packet_evidence_is_only_looked_up(self, tracker): + rec = tracker.record_transmission("hello", "general", "channel", channel_idx=0) + assert tracker.match_packet_hash("eeee", rec.timestamp + 1) is None + assert tracker.record_repeat("eeee", "7e") is False + assert rec.repeat_count == 0 + + def test_a_dm_is_matched_by_recipient_and_sender_hash(self, mock_bot): + mock_bot.meshcore = Mock(self_info={"public_key": "ab" + "00" * 31}) + tracker = TransmissionTracker(mock_bot) + rec = tracker.record_transmission("hi", "Alice", "dm", recipient_key="c4" + "11" * 31) + other_recipient = {"payload_type": 2, "payload_hex": "d5ab" + "00" * 20} + other_sender = {"payload_type": 2, "payload_hex": "c4ee" + "00" * 20} + ours = {"payload_type": 2, "payload_hex": "c4ab" + "00" * 20} + assert tracker.match_packet_hash("1111", rec.timestamp, other_recipient) is None + assert tracker.match_packet_hash("2222", rec.timestamp, other_sender) is None + assert tracker.match_packet_hash("3333", rec.timestamp, ours) is rec + + def test_a_trace_is_matched_by_its_tag(self, tracker): + tag = 0x12345678 + rec = tracker.record_transmission("trace", "", "trace", command_id=str(tag), trace_tag=tag) + other = {"payload_type": 9, "payload_hex": (0x0BADF00D).to_bytes(4, "little").hex() + "00" * 5} + ours = {"payload_type": 9, "payload_hex": tag.to_bytes(4, "little").hex() + "00" * 5} + assert tracker.match_packet_hash("4444", rec.timestamp, other) is None + assert tracker.match_packet_hash("5555", rec.timestamp, ours) is rec + + def test_a_record_without_identity_is_never_claimed(self, tracker): + rec = tracker.record_transmission("msg", "ch", "channel") + assert tracker.match_packet_hash("6666", rec.timestamp, _chan("msg")) is None + + +class TestRepeatCountWrites: + async def test_repeat_count_is_written_off_the_event_loop(self, tracker, monkeypatch): + import threading + + threads = [] + monkeypatch.setattr(tracker, "_update_command_in_database", lambda rec: threads.append(threading.current_thread().name)) + tracker.bot.web_viewer_integration = Mock() + rec = tracker.record_transmission("msg", "ch", "channel", command_id="cmd-1", channel_idx=0) + tracker.match_packet_hash("7777", rec.timestamp, _chan("msg")) + assert tracker.record_repeat("7777", "7e") is True + tracker._db_executor.shutdown(wait=True) + assert threads and threads[0] != threading.current_thread().name