mirror of
https://github.com/MarekWo/mc-webui.git
synced 2026-07-30 21:43:19 +02:00
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:
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
# =============================================================================
|
||||
|
||||
Reference in New Issue
Block a user