feat: Enrich contacts cache with GPS coordinates and node type

- Extract lat/lon from advert payloads (struct unpack from binary)
- Store type_label and lat/lon in cache from device seed and adverts
- Show Map button for cache contacts with GPS coordinates
- Show colored type badge (CLI/REP/ROOM/SENS) for typed cache contacts
- Type filter now works for cache contacts with known type
- Change counter label from "known" to "cached"

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
MarekWo
2026-02-22 12:40:42 +01:00
parent a5e767e5bf
commit 247b11e1e9
2 changed files with 78 additions and 62 deletions

View File

@@ -5,11 +5,13 @@ Stores every node name ever seen (from device contacts and adverts),
so @mention autocomplete works even for removed contacts.
File format: JSONL ({device_name}.contacts_cache.jsonl)
Each line: {"public_key": "...", "name": "...", "first_seen": ts, "last_seen": ts, "source": "advert"|"device"}
Each line: {"public_key": "...", "name": "...", "first_seen": ts, "last_seen": ts,
"source": "advert"|"device", "lat": float, "lon": float, "type_label": "CLI"|"REP"|...}
"""
import json
import logging
import struct
import time
from pathlib import Path
from threading import Lock
@@ -91,7 +93,8 @@ def save_cache() -> bool:
return False
def upsert_contact(public_key: str, name: str, source: str = "advert") -> bool:
def upsert_contact(public_key: str, name: str, source: str = "advert",
lat: float = 0.0, lon: float = 0.0, type_label: str = "") -> bool:
"""Add or update a contact in the cache. Returns True if cache was modified."""
pk = public_key.lower()
now = int(time.time())
@@ -103,18 +106,34 @@ def upsert_contact(public_key: str, name: str, source: str = "advert") -> bool:
if name and name != existing.get('name'):
existing['name'] = name
changed = True
# Update lat/lon if new values are non-zero
if lat != 0.0 or lon != 0.0:
if lat != existing.get('lat') or lon != existing.get('lon'):
existing['lat'] = lat
existing['lon'] = lon
changed = True
# Update type_label if provided and not already set
if type_label and type_label != existing.get('type_label'):
existing['type_label'] = type_label
changed = True
existing['last_seen'] = now
return changed
else:
if not name:
return False
_cache[pk] = {
entry = {
'public_key': pk,
'name': name,
'first_seen': now,
'last_seen': now,
'source': source,
}
if lat != 0.0 or lon != 0.0:
entry['lat'] = lat
entry['lon'] = lon
if type_label:
entry['type_label'] = type_label
_cache[pk] = entry
return True
@@ -135,7 +154,7 @@ def get_all_names() -> list:
def parse_advert_payload(pkt_payload_hex: str):
"""
Parse advert pkt_payload to extract public_key and node_name.
Parse advert pkt_payload to extract public_key, node_name, and GPS coordinates.
Layout of pkt_payload (byte offsets):
[0:32] Public Key (32 bytes = 64 hex chars)
@@ -146,12 +165,12 @@ def parse_advert_payload(pkt_payload_hex: str):
If Name (bit 7): Node name (UTF-8, variable length)
Returns:
(public_key_hex, node_name) or (None, None) on failure
(public_key_hex, node_name, lat, lon) or (None, None, 0, 0) on failure
"""
try:
raw = bytes.fromhex(pkt_payload_hex)
if len(raw) < 101:
return None, None
return None, None, 0.0, 0.0
public_key = pkt_payload_hex[:64].lower()
app_flags = raw[100]
@@ -159,22 +178,26 @@ def parse_advert_payload(pkt_payload_hex: str):
has_location = bool(app_flags & 0x10) # bit 4
has_name = bool(app_flags & 0x80) # bit 7
if not has_name:
return public_key, None
lat, lon = 0.0, 0.0
name_offset = 101
if has_location:
name_offset += 8 # lat(4) + lon(4)
if len(raw) >= 109:
lat, lon = struct.unpack('<ff', raw[101:109])
name_offset += 8
if not has_name:
return public_key, None, lat, lon
if name_offset >= len(raw):
return public_key, None
return public_key, None, lat, lon
name_bytes = raw[name_offset:]
node_name = name_bytes.decode('utf-8', errors='replace').rstrip('\x00')
return public_key, node_name if node_name else None
return public_key, node_name if node_name else None, lat, lon
except Exception:
return None, None
return None, None, 0.0, 0.0
def scan_new_adverts() -> int:
@@ -201,9 +224,9 @@ def scan_new_adverts() -> int:
pkt_payload = advert.get('pkt_payload', '')
if not pkt_payload:
continue
pk, name = parse_advert_payload(pkt_payload)
pk, name, lat, lon = parse_advert_payload(pkt_payload)
if pk and name:
if upsert_contact(pk, name, source="advert"):
if upsert_contact(pk, name, source="advert", lat=lat, lon=lon):
updated += 1
except json.JSONDecodeError:
continue
@@ -218,18 +241,24 @@ def scan_new_adverts() -> int:
return updated
_TYPE_LABELS = {1: 'CLI', 2: 'REP', 3: 'ROOM', 4: 'SENS'}
def initialize_from_device(contacts_detailed: dict):
"""
Seed cache from /api/contacts/detailed response dict.
Called once at startup if cache file doesn't exist.
Args:
contacts_detailed: dict of {public_key: {adv_name, type, ...}} from meshcli
contacts_detailed: dict of {public_key: {adv_name, type, adv_lat, adv_lon, ...}} from meshcli
"""
added = 0
for pk, details in contacts_detailed.items():
name = details.get('adv_name', '')
if upsert_contact(pk, name, source="device"):
lat = details.get('adv_lat', 0.0) or 0.0
lon = details.get('adv_lon', 0.0) or 0.0
type_label = _TYPE_LABELS.get(details.get('type'), '')
if upsert_contact(pk, name, source="device", lat=lat, lon=lon, type_label=type_label):
added += 1
if added > 0:

View File

@@ -1651,7 +1651,9 @@ async function loadExistingContacts() {
name: c.name || 'Unknown',
public_key: c.public_key,
public_key_prefix: c.public_key_prefix || c.public_key.substring(0, 12),
type_label: '',
type_label: c.type_label || '',
adv_lat: c.lat || 0,
adv_lon: c.lon || 0,
last_seen: c.last_seen || 0,
on_device: false,
source: c.source || 'cache'
@@ -1693,7 +1695,7 @@ function updateCounter(count, limit, totalKnown) {
let text = `${count} / ${limit}`;
if (totalKnown && totalKnown > count) {
text += ` (${totalKnown} known)`;
text += ` (${totalKnown} cached)`;
}
counterEl.textContent = text;
counterEl.style.display = 'inline-block';
@@ -1917,41 +1919,28 @@ function createExistingContactCard(contact, index) {
nameDiv.appendChild(lockIndicator);
}
// Type badge (or "Cache only" badge for non-device contacts)
if (contact.on_device === false) {
const cacheBadge = document.createElement('span');
cacheBadge.className = 'badge type-badge bg-secondary';
cacheBadge.textContent = 'Cache';
cacheBadge.title = 'Not on device - known from adverts';
infoRow.appendChild(nameDiv);
infoRow.appendChild(cacheBadge);
} else {
const typeBadge = document.createElement('span');
typeBadge.className = 'badge type-badge';
// Type badge - use type_label if available, fall back to "Cache" for unknown type
const typeBadge = document.createElement('span');
typeBadge.className = 'badge type-badge';
if (contact.type_label) {
typeBadge.textContent = contact.type_label;
// Color-code by type
switch (contact.type_label) {
case 'CLI':
typeBadge.classList.add('bg-primary');
break;
case 'REP':
typeBadge.classList.add('bg-success');
break;
case 'ROOM':
typeBadge.classList.add('bg-info');
break;
case 'SENS':
typeBadge.classList.add('bg-warning');
break;
default:
typeBadge.classList.add('bg-secondary');
case 'CLI': typeBadge.classList.add('bg-primary'); break;
case 'REP': typeBadge.classList.add('bg-success'); break;
case 'ROOM': typeBadge.classList.add('bg-info'); break;
case 'SENS': typeBadge.classList.add('bg-warning'); break;
default: typeBadge.classList.add('bg-secondary');
}
infoRow.appendChild(nameDiv);
infoRow.appendChild(typeBadge);
} else {
typeBadge.textContent = 'Cache';
typeBadge.classList.add('bg-secondary');
typeBadge.title = 'Not on device - type unknown';
}
infoRow.appendChild(nameDiv);
infoRow.appendChild(typeBadge);
// Public key row (clickable to copy)
const keyDiv = document.createElement('div');
keyDiv.className = 'contact-key clickable-key';
@@ -1999,21 +1988,21 @@ function createExistingContactCard(contact, index) {
pathDiv.textContent = `Path: ${contact.path_or_mode}`;
}
// Action buttons (only for device contacts)
// Action buttons
const actionsDiv = document.createElement('div');
actionsDiv.className = 'd-flex gap-2 mt-2';
if (contact.on_device !== false) {
// Map button (only if GPS coordinates available)
if (contact.adv_lat && contact.adv_lon && (contact.adv_lat !== 0 || contact.adv_lon !== 0)) {
const mapBtn = document.createElement('button');
mapBtn.className = 'btn btn-sm btn-outline-primary';
mapBtn.innerHTML = '<i class="bi bi-geo-alt"></i> Map';
mapBtn.onclick = () => window.showContactOnMap(contact.name, contact.adv_lat, contact.adv_lon);
actionsDiv.appendChild(mapBtn);
}
// Map button - for ANY contact with GPS coordinates
if (contact.adv_lat && contact.adv_lon && (contact.adv_lat !== 0 || contact.adv_lon !== 0)) {
const mapBtn = document.createElement('button');
mapBtn.className = 'btn btn-sm btn-outline-primary';
mapBtn.innerHTML = '<i class="bi bi-geo-alt"></i> Map';
mapBtn.onclick = () => window.showContactOnMap(contact.name, contact.adv_lat, contact.adv_lon);
actionsDiv.appendChild(mapBtn);
}
// Protect button
// Protect & Delete buttons (only for device contacts)
if (contact.on_device !== false) {
const protectBtn = document.createElement('button');
protectBtn.className = isProtected ? 'btn btn-sm btn-warning' : 'btn btn-sm btn-outline-warning';
protectBtn.innerHTML = isProtected
@@ -2022,7 +2011,6 @@ function createExistingContactCard(contact, index) {
protectBtn.onclick = () => toggleContactProtection(contact.public_key, protectBtn);
actionsDiv.appendChild(protectBtn);
// Delete button (disabled if protected)
const deleteBtn = document.createElement('button');
deleteBtn.className = 'btn btn-sm btn-outline-danger';
deleteBtn.innerHTML = '<i class="bi bi-trash"></i> Delete';
@@ -2031,7 +2019,6 @@ function createExistingContactCard(contact, index) {
if (isProtected) {
deleteBtn.title = 'Cannot delete protected contact';
}
actionsDiv.appendChild(deleteBtn);
}