Files
meshcore-bot/modules/transmission_tracker.py
T
agessaman 96c4a01788 fix(commands,clients): correct scheduling, parsing and formatting defects
Remaining findings from the same triage pass. None are security
relevant; each produces a wrong user-visible result.

- greeter: treat rollout_started_at as UTC. It is written by SQLite
  CURRENT_TIMESTAMP, but datetime.timestamp() read the naive value as
  local time, shifting the backfill cutoff by the host's offset (7h on
  PT). Users who posted inside that window were never marked as already
  greeted and could be sent a welcome they should not have received.

- greeter: write greeted_at in SQLite's own format. The rollout backfill
  used isoformat() while every other path used CURRENT_TIMESTAMP. "T"
  sorts above a space, so ORDER BY greeted_at interleaved the two
  formats wrongly, corrupting duplicate cleanup and the web viewer's
  recently-greeted list.

- greeter: let an empty `channels =` disable the command. BaseCommand
  reads an empty value as disabled-on-channels, but the greeter
  collapsed "key absent" and "key present but empty" into one fallback
  and kept greeting via monitor_channels.

- greeter: stop comma-splitting greeting text. channel_greetings split
  entries on ",", so "Public:Welcome to the mesh, {sender}!" was stored
  as "Welcome to the mesh" with the placeholder silently dropped. A
  fragment now starts a new entry only when the text before its first
  colon looks like a channel name, which keeps commas, URLs and clock
  times attached to the greeting they belong to.

- wxsim_parser: match condition abbreviations longest-first. Substring
  matching in dict order let RAIN shadow CHNC. RAIN, so five conditions
  lost their "chance" qualifier (rain, snow, drizzle, t-storm, and
  FAIR-P.C.).

- wxsim_parser: re-anchor "now" on each parse. current_date and
  current_year were fixed at construction and wx_command builds one
  parser at startup, so after a few days forecast dates rolled back a
  year and staleness checks read permanently true.

- thesportsdb_client: hold the rate-limit read/sleep/write under a lock.
  Concurrent callers read the same last_request_time, slept the same
  interval and fired together, bursting past the 2.1s throttle. This
  exposes an unrelated request fan-out problem in fetch_league_scores;
  filed in TODO.md rather than fixed here.

- alert_command: stop duplicating the first incident. The tail treated
  any single-line buffer as "header only" and appended incidents[0],
  but messages after the first carry no header, so a final chunk holding
  one incident had incident 0 pasted onto it. The same confusion inside
  the loop also let a second incident be appended past the 130-character
  limit.

- feed: reject non-positive poll intervals. -1 is truthy and was stored,
  and the poller's `now - last_check >= interval` then treats the feed
  as permanently due and re-fetches the URL every cycle; 0 was ignored
  while still reporting success. The web viewer, which is the primary
  editor, had no validation at all, and a JSON null there raised a
  TypeError that aborted the poll cycle for every feed rather than one.
  feed_manager falls back to the default for rows written before this.

- transmission_tracker: age out confirmed transmissions that have
  repeats. Cleanup removed only repeat_count == 0, so repeated records
  accumulated for the lifetime of the process, which matters on a Pi
  Zero. Repeat counts are already persisted to packet_stream, so the
  longer 30-minute retention loses nothing.

- mesh_graph: weighted-merge avg_hop_position when promoting an edge.
  Promotion overwrote the average with the single new observation, so an
  edge averaging 2.0 over 4 observations became 9.0 instead of 3.4 and
  skewed subsequent path scoring.

- multitest_command: show the path that ends exactly at the display LCP.
  The suffix helpers are correct in isolation; the loss happens in the
  cluster formatters, where _shrink_display_lcp refuses to shrink a
  single-token LCP and the trunk then rendered as "96 ┐", meaning
  "everything continues past here" and dropping the bare 96 route while
  the header still counted it. Now uses the file's existing "├ common"
  marker. Empty suffixes are also no longer handed to the nested
  renderer, which mapped them onto the same [] as a route ending at the
  inner LCP and drew a row for a route that did not exist.

- sports_mappings: stop shadowing seven unique team nicknames. In one
  flat dict a repeated key silently drops the earlier team, so hawks
  resolved to the NBA Hawks rather than the Seahawks alias it was added
  as, blazers to Kamloops rather than Portland, and rockets to Kelowna
  rather than Houston. First definition now wins for hawks, giants,
  jets, rangers, kings, blazers and rockets; every shadowed team keeps
  its unambiguous full-name alias. The 55 city and abbreviation
  collisions (chicago, sf, la, ...) are genuinely ambiguous and stay
  last-wins, now pinned by a test so a new collision fails loudly
  instead of passing unnoticed.
2026-07-28 20:08:57 -07:00

386 lines
16 KiB
Python

#!/usr/bin/env python3
"""
Transmission tracker for monitoring message transmission success
Tracks transmitted message hashes and detects repeats from neighboring repeaters
"""
import threading
import time
from contextlib import closing
from dataclasses import dataclass, field
from typing import Any, Optional
@dataclass
class TransmissionRecord:
"""Record of a transmitted message"""
timestamp: float
content: str
target: str # Channel name or recipient ID
message_type: str # 'channel' or 'dm'
packet_hash: Optional[str] = None
repeat_count: int = 0
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
class TransmissionTracker:
"""Tracks transmitted messages and detects repeats from neighboring repeaters"""
def __init__(self, bot):
self.bot = bot
self.logger = bot.logger
# Store pending transmissions (by timestamp window)
# Key: approximate timestamp (rounded to nearest second)
# Value: List of TransmissionRecord
self.pending_transmissions: dict[int, list[TransmissionRecord]] = {}
# Store confirmed transmissions with hashes
# Key: packet_hash
# Value: TransmissionRecord
self.confirmed_transmissions: dict[str, TransmissionRecord] = {}
# Time window for matching transmissions (seconds)
self.match_window = 30 # Match RF data to transmissions within 30 seconds
# Cleanup old records after this time (seconds)
self.cleanup_after = 300 # 5 minutes
# Records that collected repeats are held longer, so a late repeat still
# lands on the same record — but they must expire too. Repeat counts are
# already persisted to packet_stream, so nothing is lost, and keeping
# them forever grew memory for the whole process lifetime.
self.repeat_cleanup_after = 1800 # 30 minutes
self._cleanup_interval = 60 # Run cleanup check every 60 seconds
self._last_cleanup_time = 0.0
# Lock protects record mutations (repeat_count, repeater_prefixes, etc.)
self._lock = threading.Lock()
# Track our bot's public key prefix (first 2 hex chars) for filtering
self.bot_prefix: Optional[str] = None
self._update_bot_prefix()
def _update_bot_prefix(self):
"""Update bot prefix from meshcore device info"""
if self.bot.meshcore and hasattr(self.bot.meshcore, 'device'):
try:
device_info = self.bot.meshcore.device
if hasattr(device_info, 'public_key'):
pubkey = device_info.public_key
if isinstance(pubkey, str) and len(pubkey) >= 2:
self.bot_prefix = pubkey[:self.bot.prefix_hex_chars].lower()
elif isinstance(pubkey, bytes) and len(pubkey) >= 1:
self.bot_prefix = f"{pubkey[0]:02x}".lower()
self.logger.debug(f"Bot prefix set to: {self.bot_prefix}")
except Exception as e:
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:
"""Record a transmission attempt.
Args:
content: Message content
target: Channel name or recipient ID
message_type: 'channel' or 'dm'
command_id: Optional command ID for correlation
Returns:
TransmissionRecord: The created record
"""
record = TransmissionRecord(
timestamp=time.time(),
content=content,
target=target,
message_type=message_type,
command_id=command_id
)
# Store in pending transmissions (by rounded timestamp)
timestamp_key = int(record.timestamp)
if timestamp_key not in self.pending_transmissions:
self.pending_transmissions[timestamp_key] = []
self.pending_transmissions[timestamp_key].append(record)
self.logger.debug(f"Recorded transmission: {message_type} to {target} at {record.timestamp}")
# Periodically clean up old records to prevent unbounded memory growth
self._maybe_cleanup()
return record
def match_packet_hash(self, packet_hash: str, rf_timestamp: float) -> Optional[TransmissionRecord]:
"""Match a received packet hash to a pending transmission.
Args:
packet_hash: Packet hash from received RF data
rf_timestamp: Timestamp when RF data was received
Returns:
TransmissionRecord if matched, None otherwise
"""
if not packet_hash or packet_hash == "0000000000000000":
return None
# Check if we already have this hash confirmed
if packet_hash in self.confirmed_transmissions:
return self.confirmed_transmissions[packet_hash]
# 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
for timestamp_key in range(search_start, search_end + 1):
if timestamp_key not in self.pending_transmissions:
continue
for record in self.pending_transmissions[timestamp_key]:
# Check if timestamp is within window
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:
record.packet_hash = packet_hash
# Move to confirmed transmissions
self.confirmed_transmissions[packet_hash] = record
self.logger.debug(f"Matched transmission hash {packet_hash} to {record.message_type} to {record.target}")
return record
return None
def record_repeat(self, packet_hash: str, repeater_prefix: Optional[str] = None) -> bool:
"""Record that we heard a repeat of one of our transmissions.
Args:
packet_hash: Packet hash of the repeated message
repeater_prefix: Repeater prefix (first 2 hex chars) that repeated it
Returns:
True if this was a match to one of our transmissions, False otherwise
"""
if not packet_hash or packet_hash == "0000000000000000":
return False
# Find the transmission record
record = self.confirmed_transmissions.get(packet_hash)
if not record:
# Try to match it
record = self.match_packet_hash(packet_hash, time.time())
if record:
with self._lock:
record.repeat_count += 1
if repeater_prefix:
record.repeater_prefixes.add(repeater_prefix)
# Track count per repeater
record.repeater_counts[repeater_prefix] = record.repeater_counts.get(repeater_prefix, 0) + 1
else:
# No prefix but still a repeat (heard by radio)
record.repeater_counts['_unknown'] = record.repeater_counts.get('_unknown', 0) + 1
repeat_count = record.repeat_count
unique_repeaters = len(record.repeater_prefixes)
prefixes = sorted(record.repeater_prefixes)
self.logger.info(f"📡 Recorded repeat for hash {packet_hash}: {repeat_count} repeats, {unique_repeaters} unique repeaters, prefixes: {prefixes}")
# 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)
return True
return False
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
# Get database path (use [Bot] db_path when [Web_Viewer] db_path is unset)
base_dir = self.bot.bot_root if hasattr(self.bot, 'bot_root') else '.'
if (self.bot.config.has_section('Web_Viewer') and self.bot.config.has_option('Web_Viewer', 'db_path')
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:
cursor = conn.cursor()
# Find the command entry by command_id
cursor.execute('''
SELECT id, data FROM packet_stream
WHERE type = 'command'
ORDER BY timestamp DESC
LIMIT 500
''')
rows = cursor.fetchall()
for row_id, data_json in rows:
try:
command_data = json.loads(data_json)
if command_data.get('command_id') == record.command_id:
# Update the command data with latest repeat info
command_data['repeat_count'] = record.repeat_count
command_data['repeater_prefixes'] = sorted(record.repeater_prefixes)
command_data['repeater_counts'] = record.repeater_counts.copy()
# Update the database entry
cursor.execute('''
UPDATE packet_stream
SET data = ?
WHERE id = ?
''', (json.dumps(command_data), row_id))
conn.commit()
self.logger.info(f"Updated command {record.command_id} in database: {record.repeat_count} repeats, prefixes: {sorted(record.repeater_prefixes)}")
# Emit update event via web viewer integration
# The web viewer polling will pick this up, but we can also try to trigger an immediate update
# by inserting a new entry with updated data (the polling will see it)
# Actually, updating the existing entry should work - the polling will see the updated timestamp
# But we need to update the timestamp so the polling picks it up
cursor.execute('''
UPDATE packet_stream
SET timestamp = ?
WHERE id = ?
''', (time.time(), row_id))
conn.commit()
break
except (json.JSONDecodeError, KeyError):
continue
except Exception as e:
self.logger.debug(f"Error updating command in database: {e}")
def get_repeat_info(self, command_id: Optional[str] = None,
packet_hash: Optional[str] = None) -> dict[str, Any]:
"""Get repeat information for a command or packet hash.
Args:
command_id: Command ID to look up
packet_hash: Packet hash to look up (alternative to command_id)
Returns:
Dict with repeat_count and repeater_prefixes
"""
record = None
if packet_hash:
record = self.confirmed_transmissions.get(packet_hash)
elif command_id:
# Search for record with matching command_id
for rec in self.confirmed_transmissions.values():
if rec.command_id == command_id:
record = rec
break
if record:
return {
'repeat_count': record.repeat_count,
'repeater_prefixes': sorted(record.repeater_prefixes),
'repeater_counts': record.repeater_counts.copy() # Include counts per repeater
}
return {'repeat_count': 0, 'repeater_prefixes': [], 'repeater_counts': {}}
def extract_repeater_prefixes_from_path(self, path: Optional[str],
path_nodes: Optional[list[str]] = None) -> list[str]:
"""Extract repeater prefix from the last hop in a message path.
The repeater that sent the packet is always the last hop in the path.
We only extract the prefix from that last hop, not from intermediate nodes.
Args:
path: Path string (e.g., "01,7e,55,86")
path_nodes: List of path nodes (alternative to path string)
Returns:
List containing the repeater prefix (2-character hex string) from the last hop,
or empty list if no valid prefix found
"""
# Try path_nodes first (more reliable)
if path_nodes and len(path_nodes) > 0:
# Get the last node in the path (the repeater that sent the packet)
last_node = path_nodes[-1]
if isinstance(last_node, str) and len(last_node) >= 2:
# Take first 2 characters as prefix
prefix = last_node[:self.bot.prefix_hex_chars].lower()
# Filter out our own prefix
if prefix != self.bot_prefix:
return [prefix]
# Fallback to parsing path string
elif path:
# Path format: "01,7e,55,86" or "01,7e,55,86 via ROUTE_TYPE_*"
path_part = path.split(" via ")[0] if " via " in path else path
# Remove any hop count info
if '(' in path_part:
path_part = path_part.split('(')[0].strip()
# Split by comma and get the last part (the repeater that sent the packet)
parts = [p.strip() for p in path_part.split(',') if p.strip()]
if parts:
last_part = parts[-1]
if len(last_part) >= 2:
prefix = last_part[:self.bot.prefix_hex_chars].lower()
# Filter out our own prefix
if prefix != self.bot_prefix:
return [prefix]
return [] # No valid prefix found
def _maybe_cleanup(self) -> None:
"""Run cleanup if enough time has passed since the last run."""
now = time.time()
if now - self._last_cleanup_time >= self._cleanup_interval:
self._last_cleanup_time = now
self.cleanup_old_records()
def cleanup_old_records(self):
"""Remove old transmission records that are beyond the cleanup window"""
current_time = time.time()
cutoff_time = current_time - self.cleanup_after
# Clean up pending transmissions
keys_to_remove = []
for timestamp_key, records in self.pending_transmissions.items():
# Remove records older than cutoff
filtered_records = [r for r in records if r.timestamp > cutoff_time]
if filtered_records:
self.pending_transmissions[timestamp_key] = filtered_records
else:
keys_to_remove.append(timestamp_key)
for key in keys_to_remove:
del self.pending_transmissions[key]
# Clean up confirmed transmissions. Repeated records get a longer grace
# period than plain ones, but both eventually age out.
repeat_cutoff_time = current_time - self.repeat_cleanup_after
hashes_to_remove = []
for packet_hash, record in self.confirmed_transmissions.items():
expiry = repeat_cutoff_time if record.repeat_count > 0 else cutoff_time
if record.timestamp < expiry:
hashes_to_remove.append(packet_hash)
for hash_val in hashes_to_remove:
del self.confirmed_transmissions[hash_val]
if keys_to_remove or hashes_to_remove:
self.logger.debug(f"Cleaned up {len(keys_to_remove)} pending transmission windows and {len(hashes_to_remove)} confirmed transmissions")