feat: Add "Heard X repeats" echo tracking for sent messages

Track how many repeaters heard and forwarded sent channel messages,
similar to the Meshcore mobile app's "Heard X repeats" feature.

- Bridge: Detect GRP_TXT echoes from stdout, track unique paths
- Bridge: New /register_echo and /echo_counts API endpoints
- API: Register messages for echo tracking after send
- API: Merge echo counts into /api/messages response
- Frontend: Display green badge with broadcast icon next to Resend button
- CSS: Echo badge styling with dark mode support

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
MarekWo
2026-01-29 20:57:10 +01:00
parent 1ac76f107d
commit 07040dd6d0
4 changed files with 202 additions and 0 deletions
+35
View File
@@ -299,6 +299,30 @@ def get_messages():
channel_idx=channel_idx
)
# Fetch echo counts from bridge (for "Heard X repeats" feature)
if not archive_date: # Only for live messages, not archives
try:
bridge_url = config.MC_BRIDGE_URL.replace('/cli', '/echo_counts')
response = requests.get(bridge_url, timeout=2)
if response.ok:
echo_counts = response.json().get('echo_counts', [])
# Create lookup by timestamp + channel
echo_lookup = {(ec['timestamp'], ec['channel_idx']): ec['count']
for ec in echo_counts}
# Merge into messages
for msg in messages:
if msg.get('is_own'):
# Find matching echo count (within 5 second window)
msg['echo_count'] = 0
for (ts, ch), count in echo_lookup.items():
if msg.get('channel_idx') == ch and abs(msg['timestamp'] - ts) < 5:
msg['echo_count'] = count
break
except Exception as e:
logger.debug(f"Echo counts fetch failed (non-critical): {e}")
return jsonify({
'success': True,
'count': len(messages),
@@ -360,6 +384,17 @@ def send_message():
success, message = cli.send_message(text, reply_to=reply_to, channel_index=channel_idx)
if success:
# Register for echo tracking ("Heard X repeats" feature)
try:
bridge_url = config.MC_BRIDGE_URL.replace('/cli', '/register_echo')
requests.post(
bridge_url,
json={'channel_idx': channel_idx, 'timestamp': time.time()},
timeout=2
)
except Exception as e:
logger.debug(f"Echo registration failed (non-critical): {e}")
return jsonify({
'success': True,
'message': 'Message sent successfully',
+26
View File
@@ -1041,3 +1041,29 @@ main {
.scroll-to-bottom-btn:active {
transform: scale(0.95);
}
/* =============================================================================
Echo Badge - "Heard X repeats" indicator for sent messages
============================================================================= */
.echo-badge {
display: inline-flex;
align-items: center;
gap: 0.25rem;
font-size: 0.7rem;
color: #198754;
padding: 0.2rem 0.4rem;
margin-right: 0.25rem;
border-radius: 0.25rem;
background-color: rgba(25, 135, 84, 0.1);
}
.echo-badge i {
font-size: 0.65rem;
}
/* Dark mode support */
[data-bs-theme="dark"] .echo-badge {
color: #75b798;
background-color: rgba(117, 183, 152, 0.15);
}
+8
View File
@@ -712,6 +712,13 @@ function createMessageElement(msg) {
if (msg.is_own) {
// Own messages: right-aligned, no avatar
// Echo badge shows how many repeaters heard the message
const echoDisplay = msg.echo_count > 0
? `<span class="echo-badge" title="Heard by ${msg.echo_count} repeater(s)">
<i class="bi bi-broadcast"></i> ${msg.echo_count}
</span>`
: '';
wrapper.innerHTML = `
<div class="message-container">
<div class="message-footer own">
@@ -720,6 +727,7 @@ function createMessageElement(msg) {
<div class="message own">
<div class="message-content">${processMessageContent(msg.content)}</div>
<div class="message-actions justify-content-end">
${echoDisplay}
<button class="btn btn-outline-secondary btn-msg-action" onclick='resendMessage(${JSON.stringify(msg.content)})' title="Resend">
<i class="bi bi-arrow-repeat"></i>
</button>
+133
View File
@@ -146,6 +146,11 @@ class MeshCLISession:
# Shutdown flag
self.shutdown_flag = threading.Event()
# Echo tracking for "Heard X repeats" feature
self.pending_echo = None # {timestamp, channel_idx, pkt_payload}
self.echo_counts = {} # pkt_payload -> {paths: set(), timestamp: float, channel_idx: int}
self.echo_lock = threading.Lock()
# Start session
self._start_session()
@@ -308,6 +313,12 @@ class MeshCLISession:
self._log_advert(line)
continue
# Try to parse as GRP_TXT echo (for "Heard X repeats" feature)
echo_data = self._parse_grp_txt_echo(line)
if echo_data:
self._process_echo(echo_data[0], echo_data[1])
continue
# Otherwise, append to current CLI response
self._append_to_current_response(line)
@@ -471,6 +482,67 @@ class MeshCLISession:
except (json.JSONDecodeError, ValueError):
return False
def _parse_grp_txt_echo(self, line):
"""Parse GRP_TXT JSON echo, return (pkt_payload, path) or None."""
try:
data = json.loads(line)
if isinstance(data, dict) and data.get("payload_typename") == "GRP_TXT":
return (data.get('pkt_payload'), data.get('path', ''))
except (json.JSONDecodeError, ValueError):
pass
return None
def _process_echo(self, pkt_payload, path):
"""Process a GRP_TXT echo and track unique paths."""
if not pkt_payload:
return
with self.echo_lock:
current_time = time.time()
# If this pkt_payload is already tracked, add path
if pkt_payload in self.echo_counts:
self.echo_counts[pkt_payload]['paths'].add(path)
logger.debug(f"Echo: added path {path} to existing payload, total: {len(self.echo_counts[pkt_payload]['paths'])}")
return
# If we have a pending message waiting for correlation
if self.pending_echo and self.pending_echo.get('pkt_payload') is None:
# Check time window (60 seconds)
if current_time - self.pending_echo['timestamp'] < 60:
# Associate this pkt_payload with the pending message
self.pending_echo['pkt_payload'] = pkt_payload
self.echo_counts[pkt_payload] = {
'paths': {path},
'timestamp': self.pending_echo['timestamp'],
'channel_idx': self.pending_echo['channel_idx']
}
logger.info(f"Echo: correlated pkt_payload with sent message, first path: {path}")
def register_pending_echo(self, channel_idx, timestamp):
"""Register a sent message for echo tracking."""
with self.echo_lock:
self.pending_echo = {
'timestamp': timestamp,
'channel_idx': channel_idx,
'pkt_payload': None
}
# Cleanup old echo counts (> 1 hour)
cutoff = time.time() - 3600
self.echo_counts = {k: v for k, v in self.echo_counts.items()
if v['timestamp'] > cutoff}
logger.debug(f"Registered pending echo for channel {channel_idx}")
def get_echo_count(self, timestamp, channel_idx):
"""Get echo count for a message by timestamp and channel."""
with self.echo_lock:
for pkt_payload, data in self.echo_counts.items():
# Match within 5 second window
if (abs(data['timestamp'] - timestamp) < 5 and
data['channel_idx'] == channel_idx):
return len(data['paths'])
return 0
def _log_advert(self, json_line):
"""Log advert JSON to .jsonl file with timestamp"""
try:
@@ -1023,6 +1095,67 @@ def set_manual_add_contacts():
}), 500
# =============================================================================
# Echo tracking endpoints for "Heard X repeats" feature
# =============================================================================
@app.route('/register_echo', methods=['POST'])
def register_echo():
"""
Register a sent message for echo tracking.
Called after successfully sending a channel message to start
tracking repeater echoes for that message.
Request JSON:
{
"channel_idx": 0,
"timestamp": 1706500000.123
}
"""
if not meshcli_session:
return jsonify({'success': False, 'error': 'Not initialized'}), 503
data = request.get_json()
channel_idx = data.get('channel_idx', 0)
timestamp = data.get('timestamp', time.time())
meshcli_session.register_pending_echo(channel_idx, timestamp)
return jsonify({'success': True}), 200
@app.route('/echo_counts', methods=['GET'])
def get_echo_counts():
"""
Get all echo counts for recent messages.
Returns echo counts grouped by timestamp and channel, allowing
the caller to match with their sent messages.
Response JSON:
{
"success": true,
"echo_counts": [
{"timestamp": 1706500000.123, "channel_idx": 0, "count": 3},
...
]
}
"""
if not meshcli_session:
return jsonify({'success': False, 'error': 'Not initialized'}), 503
with meshcli_session.echo_lock:
result = []
for pkt_payload, data in meshcli_session.echo_counts.items():
result.append({
'timestamp': data['timestamp'],
'channel_idx': data['channel_idx'],
'count': len(data['paths'])
})
return jsonify({'success': True, 'echo_counts': result}), 200
# =============================================================================
# WebSocket handlers for console
# =============================================================================