feat(repeaters): Neighbors tool with map view (stage 5)

List view: all zero-hop neighbours (fetch_all_neighbours paginates the
firmware's ~14-entry pages), rows show resolved contact name (or
[pubkey prefix] for unknown repeaters), heard-ago and SNR; count in
the toolbar. Names/positions are enriched server-side by prefix-
matching device contacts with the DB contact cache as fallback.

Map view (List/Map toggle, shown only when something is mappable):
Leaflet with the managed repeater as a red marker, positioned
neighbours in green, dashed connection lines labeled with permanent
SNR tooltips, and a footnote counting neighbours without a known
position. Toggle hidden entirely when no coordinates exist.

Verified live on PL-KRA Wegrzce 2: 37/37 neighbours fetched, 20
mappable, SNR labels rendered.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
MarekWo
2026-07-18 13:23:43 +02:00
parent 995cb49f4c
commit 2b6d82a11c
3 changed files with 332 additions and 0 deletions
+66
View File
@@ -5889,6 +5889,72 @@ def repeater_telemetry(public_key):
return jsonify({'success': False, 'error': str(e)}), 500
@api_bp.route('/repeaters/<public_key>/neighbours', methods=['GET'])
def repeater_neighbours(public_key):
"""Zero-hop neighbours of a repeater, enriched with contact names/coords.
Neighbour entries carry only a 4-byte pubkey prefix; names and GPS
positions are resolved by prefix-matching device contacts (with the
DB contact cache as fallback), so unknown repeaters stay prefix-only.
"""
dm = _get_dm()
if not dm:
return jsonify({'success': False, 'error': 'Device not connected'}), 503
pk = _normalize_repeater_key(public_key)
if not pk:
return jsonify({'success': False, 'error': 'Invalid public_key'}), 400
login_error = _require_repeater_login(dm, pk)
if login_error:
return login_error
try:
result = dm.repeater_req_neighbours(pk)
if not result.get('success'):
return jsonify({'success': False,
'error': result.get('error', 'No neighbours response')}), _repeater_result_status(result)
data = result['data'] or {}
db = _get_db()
entries = []
for n in data.get('neighbours', []):
prefix = (n.get('pubkey') or '').lower()
entry = {
'pubkey_prefix': prefix,
'secs_ago': n.get('secs_ago'),
'snr': n.get('snr'),
'name': '',
'lat': None,
'lon': None,
}
contact = dm.mc.get_contact_by_key_prefix(prefix) if dm.mc else None
if contact:
entry['name'] = contact.get('adv_name', '') or ''
lat = contact.get('adv_lat')
lon = contact.get('adv_lon')
if lat and lon and (lat != 0 or lon != 0):
entry['lat'] = lat
entry['lon'] = lon
elif db:
db_contact = db.get_contact_by_prefix(prefix)
if db_contact:
entry['name'] = db_contact.get('name', '') or ''
lat = db_contact.get('adv_lat')
lon = db_contact.get('adv_lon')
if lat and lon and (lat != 0 or lon != 0):
entry['lat'] = lat
entry['lon'] = lon
entries.append(entry)
return jsonify({
'success': True,
'total': data.get('neighbours_count', len(entries)),
'fetched': data.get('results_count', len(entries)),
'entries': entries,
}), 200
except Exception as e:
logger.error(f"Error getting repeater neighbours: {e}")
return jsonify({'success': False, 'error': str(e)}), 500
@api_bp.route('/repeaters/<public_key>/clock', methods=['GET'])
def repeater_clock(public_key):
"""Current clock of a repeater (unix seconds + formatted)."""
+242
View File
@@ -237,6 +237,10 @@ function openToolPane(tool) {
renderTelemetryPane(body);
return;
}
if (tool.key === 'neighbors') {
renderNeighborsPane(body);
return;
}
body.innerHTML = `
<div class="text-center text-muted py-4">
<i class="bi ${tool.icon}" style="font-size: 2rem;"></i>
@@ -569,6 +573,244 @@ function renderTelemetryCards(container, lpp) {
container.innerHTML = html;
}
// ================================================================
// Neighbors tool
// ================================================================
let _neighborsData = null; // last successful response
let _neighborsView = 'list'; // 'list' | 'map'
let _nbMap = null;
let _nbMapLayers = null;
function renderNeighborsPane(body) {
_neighborsView = 'list';
_nbMap = null; // pane body was rebuilt; force map re-init
body.innerHTML = `
<div class="d-flex align-items-center gap-2 mb-2 flex-wrap">
<span class="text-muted small flex-grow-1" id="neighborsCount"></span>
<div class="btn-group btn-group-sm" role="group" id="neighborsViewToggle" style="display: none;">
<button type="button" class="btn btn-outline-secondary active" id="nbListBtn">
<i class="bi bi-list-ul"></i> List
</button>
<button type="button" class="btn btn-outline-secondary" id="nbMapBtn">
<i class="bi bi-map"></i> Map
</button>
</div>
<button type="button" class="btn btn-sm btn-outline-secondary" id="neighborsRefreshBtn" title="Refresh neighbours">
<i class="bi bi-arrow-clockwise"></i> Refresh
</button>
</div>
<div id="neighborsContainer"></div>
<div id="neighborsMapWrap" style="display: none;">
<div id="nbLeafletMap" style="height: 420px; width: 100%;" class="rounded border"></div>
<div class="small text-muted mt-1" id="nbMapNote"></div>
</div>
`;
body.querySelector('#neighborsRefreshBtn').addEventListener('click', loadNeighbors);
body.querySelector('#nbListBtn').addEventListener('click', () => setNeighborsView('list'));
body.querySelector('#nbMapBtn').addEventListener('click', () => setNeighborsView('map'));
loadNeighbors();
}
async function loadNeighbors() {
const container = document.getElementById('neighborsContainer');
const btn = document.getElementById('neighborsRefreshBtn');
if (!container) return;
if (btn) btn.disabled = true;
setNeighborsView('list');
container.innerHTML = `
<div class="text-center text-muted py-4">
<div class="spinner-border text-primary" role="status"></div>
<p class="mt-2 mb-0">Requesting neighbours from the repeater<br>
<span class="small">Long lists are fetched in pages and can take a while.</span></p>
</div>
`;
let data = null;
try {
const resp = await fetch(`/api/repeaters/${encodeURIComponent(_pubkey)}/neighbours`);
data = await resp.json();
} catch (e) {
data = { success: false, error: 'Request failed' };
}
if (btn) btn.disabled = false;
if (!data || !data.success) {
const countEl = document.getElementById('neighborsCount');
if (countEl) countEl.textContent = '';
container.innerHTML = `
<div class="text-center py-4">
<i class="bi bi-exclamation-triangle text-warning" style="font-size: 2rem;"></i>
<p class="mt-2 mb-2">${esc((data && data.error) || 'Failed to get neighbours')}</p>
<button type="button" class="btn btn-sm btn-primary" id="neighborsRetryBtn">
<i class="bi bi-arrow-clockwise"></i> Try again
</button>
</div>
`;
const retry = document.getElementById('neighborsRetryBtn');
if (retry) retry.addEventListener('click', loadNeighbors);
return;
}
_neighborsData = data;
renderNeighborsList();
}
function neighborLabel(n) {
return n.name || `[${n.pubkey_prefix}]`;
}
function renderNeighborsList() {
const container = document.getElementById('neighborsContainer');
const countEl = document.getElementById('neighborsCount');
const toggle = document.getElementById('neighborsViewToggle');
const data = _neighborsData;
if (!container || !data) return;
const entries = data.entries || [];
if (countEl) {
let label = `${data.total} neighbor${data.total === 1 ? '' : 's'}`;
if (data.fetched < data.total) label += ` (showing ${data.fetched})`;
countEl.textContent = label;
}
// Map view is offered when the repeater or any neighbour has a position
const mappable = entries.some(n => n.lat != null && n.lon != null)
|| (_repeater && _repeater.adv_lat && _repeater.adv_lon);
if (toggle) toggle.style.display = mappable ? '' : 'none';
if (!entries.length) {
container.innerHTML = '<div class="text-muted text-center py-4">No neighbours reported yet.<br><small>Neighbours are learned from zero-hop repeater adverts.</small></div>';
return;
}
const rows = entries.map(n => `
<tr>
<td class="text-truncate" style="max-width: 220px;">
${n.name ? esc(n.name) : `<span class="font-monospace text-muted">[${esc(n.pubkey_prefix)}]</span>`}
${n.lat != null ? '<i class="bi bi-geo-alt text-muted small ms-1" title="Position known"></i>' : ''}
</td>
<td class="text-muted text-nowrap">${fmtHeardAgo(n.secs_ago)}</td>
<td class="text-end text-nowrap fw-medium">${n.snr != null ? n.snr + ' dB' : '—'}</td>
</tr>
`).join('');
container.innerHTML = `
<table class="table table-sm align-middle mb-0">
<thead><tr class="small text-muted">
<th class="fw-normal">Repeater</th>
<th class="fw-normal">Heard</th>
<th class="fw-normal text-end">SNR</th>
</tr></thead>
<tbody>${rows}</tbody>
</table>
`;
}
function fmtHeardAgo(secs) {
if (secs == null) return '—';
if (secs < 60) return `${secs}s ago`;
if (secs < 3600) return `${Math.floor(secs / 60)}m ${secs % 60}s ago`;
if (secs < 86400) return `${Math.floor(secs / 3600)}h ${Math.floor((secs % 3600) / 60)}m ago`;
return `${Math.floor(secs / 86400)}d ${Math.floor((secs % 86400) / 3600)}h ago`;
}
function setNeighborsView(view) {
_neighborsView = view;
const listWrap = document.getElementById('neighborsContainer');
const mapWrap = document.getElementById('neighborsMapWrap');
const listBtn = document.getElementById('nbListBtn');
const mapBtn = document.getElementById('nbMapBtn');
if (!listWrap || !mapWrap) return;
const isMap = view === 'map';
listWrap.style.display = isMap ? 'none' : '';
mapWrap.style.display = isMap ? '' : 'none';
if (listBtn) listBtn.classList.toggle('active', !isMap);
if (mapBtn) mapBtn.classList.toggle('active', isMap);
if (isMap) renderNeighborsMap();
}
function renderNeighborsMap() {
const data = _neighborsData;
if (!data) return;
if (!_nbMap) {
_nbMap = L.map('nbLeafletMap').setView([52.0, 19.0], 6);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '&copy; <a href="https://openstreetmap.org/copyright">OpenStreetMap</a>'
}).addTo(_nbMap);
_nbMapLayers = L.layerGroup().addTo(_nbMap);
}
_nbMapLayers.clearLayers();
const hasCenter = _repeater && _repeater.adv_lat && _repeater.adv_lon;
const center = hasCenter ? [_repeater.adv_lat, _repeater.adv_lon] : null;
const bounds = [];
if (hasCenter) {
const centerMarker = L.circleMarker(center, {
radius: 11,
fillColor: '#dc3545',
color: '#fff',
weight: 2,
opacity: 1,
fillOpacity: 0.9
}).addTo(_nbMapLayers);
centerMarker.bindPopup(`<b>${esc(_repeater.name || 'This repeater')}</b><br>managed repeater`);
bounds.push(center);
}
let placed = 0;
let skipped = 0;
(data.entries || []).forEach(n => {
if (n.lat == null || n.lon == null) { skipped++; return; }
const pos = [n.lat, n.lon];
const marker = L.circleMarker(pos, {
radius: 9,
fillColor: '#198754',
color: '#fff',
weight: 2,
opacity: 1,
fillOpacity: 0.85
}).addTo(_nbMapLayers);
marker.bindPopup(
`<b>${esc(neighborLabel(n))}</b><br>` +
`SNR: ${n.snr != null ? n.snr + ' dB' : '—'}<br>` +
`Heard: ${fmtHeardAgo(n.secs_ago)}`
);
if (hasCenter) {
const line = L.polyline([center, pos], {
color: '#6c757d',
weight: 2,
dashArray: '6 6',
opacity: 0.8
}).addTo(_nbMapLayers);
line.bindTooltip(`${n.snr != null ? n.snr + ' dB' : '?'}`, {
permanent: true,
direction: 'center',
className: 'nb-snr-tooltip'
});
}
bounds.push(pos);
placed++;
});
const note = document.getElementById('nbMapNote');
if (note) {
const parts = [`${placed} neighbor${placed === 1 ? '' : 's'} with known position`];
if (skipped) parts.push(`${skipped} without position not shown`);
if (!hasCenter) parts.push('managed repeater has no position — connection lines unavailable');
note.textContent = parts.join(' · ');
}
// Leaflet needs a size recalc after the container becomes visible
setTimeout(() => {
_nbMap.invalidateSize();
if (bounds.length > 0) {
_nbMap.fitBounds(bounds, { padding: [30, 30] });
}
}, 50);
}
async function loadClock() {
const el = document.getElementById('statusClock');
if (!el) return;
+24
View File
@@ -24,6 +24,10 @@
<link href="{{ url_for('static', filename='vendor/bootstrap/css/bootstrap.min.css') }}" rel="stylesheet">
<!-- Bootstrap Icons (local) -->
<link rel="stylesheet" href="{{ url_for('static', filename='vendor/bootstrap-icons/bootstrap-icons.css') }}">
<!-- Leaflet CSS (neighbors map view) -->
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY="
crossorigin="" />
<!-- Custom CSS -->
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
<!-- Theme CSS (light/dark mode) -->
@@ -167,6 +171,22 @@
padding: 1rem;
}
/* SNR labels on neighbor map connection lines */
.nb-snr-tooltip {
background: rgba(13, 110, 253, 0.92);
color: #fff;
border: none;
border-radius: 0.5rem;
padding: 1px 6px;
font-size: 0.72rem;
font-weight: 600;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
}
.nb-snr-tooltip::before {
display: none;
}
/* Centered state screens */
.state-screen {
text-align: center;
@@ -301,6 +321,10 @@
<!-- Bootstrap JS Bundle (local) -->
<script src="{{ url_for('static', filename='vendor/bootstrap/js/bootstrap.bundle.min.js') }}"></script>
<!-- Leaflet JS (neighbors map view) -->
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"
integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo="
crossorigin=""></script>
<!-- Repeater Management JS -->
<script src="{{ url_for('static', filename='js/repeater-manage.js') }}"></script>
</body>