Feature: Seed companion contacts from repeater adverts #58

Closed
opened 2026-10-27 09:30:39 +01:00 by adam · 9 comments
Owner

Problem

When a new companion identity is created, its contact store starts empty. The repeater already knows about dozens (or hundreds) of mesh nodes via its adverts table, but the companion has to rediscover them all from scratch.

Proposed solution

A one-time "seed" operation that imports the repeater's known adverts into a companion's companion_contacts table.

1. New method on sqlite_handler

Reuses the existing companion_upsert_contact() pattern (ON CONFLICT upsert, dict-based, BLOB pubkeys):

def companion_import_repeater_contacts(self, companion_hash: str) -> int:
    """Import repeater adverts into a companion's contact store (one-time seed)."""
    try:
        with sqlite3.connect(self.sqlite_path) as conn:
            conn.row_factory = sqlite3.Row
            rows = conn.execute(
                "SELECT pubkey, node_name, contact_type, latitude, longitude, last_seen "
                "FROM adverts WHERE pubkey IS NOT NULL"
            ).fetchall()

        type_map = {"companion": 1, "repeater": 2, "room_server": 3}
        count = 0
        for row in rows:
            contact = {
                "pubkey": bytes.fromhex(row["pubkey"]),
                "name": row["node_name"] or "",
                "adv_type": type_map.get(row["contact_type"], 0),
                "gps_lat": row["latitude"] or 0.0,
                "gps_lon": row["longitude"] or 0.0,
                "last_advert_timestamp": int(row["last_seen"] or 0),
                "lastmod": int(row["last_seen"] or 0),
            }
            if self.companion_upsert_contact(companion_hash, contact):
                count += 1
        return count
    except Exception as e:
        logger.error(f"Failed to import repeater contacts: {e}")
        return 0

2. New endpoint on CompanionAPIEndpoints

POST /api/companion/import_repeater_contacts
Body: { "companion_name": "MyCompanion" }
Response: { "success": true, "data": { "imported": 47 } }

After the SQL import, call bridge.contacts.load_from_dicts() to hot-reload into memory.

Why this approach

  • Reuses companion_upsert_contact() — handles TEXT→BLOB pubkey conversion, string→int adv_type mapping, and ON CONFLICT dedup
  • Follows the existing dict-based pattern used by companion_save_contacts and companion_load_contacts
  • Idempotent — safe to call multiple times (upsert skips or updates existing contacts)
  • No new tables or schema changes

Usage

Intended as a one-time seed when setting up a new companion identity, triggered from the dashboard UI.

## Problem When a new companion identity is created, its contact store starts empty. The repeater already knows about dozens (or hundreds) of mesh nodes via its `adverts` table, but the companion has to rediscover them all from scratch. ## Proposed solution A one-time "seed" operation that imports the repeater's known adverts into a companion's `companion_contacts` table. ### 1. New method on `sqlite_handler` Reuses the existing `companion_upsert_contact()` pattern (ON CONFLICT upsert, dict-based, BLOB pubkeys): ```python def companion_import_repeater_contacts(self, companion_hash: str) -> int: """Import repeater adverts into a companion's contact store (one-time seed).""" try: with sqlite3.connect(self.sqlite_path) as conn: conn.row_factory = sqlite3.Row rows = conn.execute( "SELECT pubkey, node_name, contact_type, latitude, longitude, last_seen " "FROM adverts WHERE pubkey IS NOT NULL" ).fetchall() type_map = {"companion": 1, "repeater": 2, "room_server": 3} count = 0 for row in rows: contact = { "pubkey": bytes.fromhex(row["pubkey"]), "name": row["node_name"] or "", "adv_type": type_map.get(row["contact_type"], 0), "gps_lat": row["latitude"] or 0.0, "gps_lon": row["longitude"] or 0.0, "last_advert_timestamp": int(row["last_seen"] or 0), "lastmod": int(row["last_seen"] or 0), } if self.companion_upsert_contact(companion_hash, contact): count += 1 return count except Exception as e: logger.error(f"Failed to import repeater contacts: {e}") return 0 ``` ### 2. New endpoint on `CompanionAPIEndpoints` ``` POST /api/companion/import_repeater_contacts Body: { "companion_name": "MyCompanion" } Response: { "success": true, "data": { "imported": 47 } } ``` After the SQL import, call `bridge.contacts.load_from_dicts()` to hot-reload into memory. ### Why this approach - Reuses `companion_upsert_contact()` — handles TEXT→BLOB pubkey conversion, string→int `adv_type` mapping, and ON CONFLICT dedup - Follows the existing dict-based pattern used by `companion_save_contacts` and `companion_load_contacts` - Idempotent — safe to call multiple times (upsert skips or updates existing contacts) - No new tables or schema changes ### Usage Intended as a one-time seed when setting up a new companion identity, triggered from the dashboard UI. <!-- github-sync-id:4062800682 -->
adam closed this issue 2026-10-27 09:30:39 +01:00
Author
Owner

@agessaman maybe we have this as a options when creating the companion, (sometimes 500+ contacts might not be that useful) ;-)

@agessaman maybe we have this as a options when creating the companion, (sometimes 500+ contacts might not be that useful) ;-) <!-- github-comment-id:4046749128 -->
Author
Owner

100%, I think it would be awesome for legacy pyMC users as an option... for new users, not so helpful.

with a a feature like this, users may even choose to seed one ID with all prior contacts from their Repeater and/or create a pure ID with none.

100%, I think it would be awesome for legacy pyMC users as an option... for new users, not so helpful. with a a feature like this, users may even choose to seed one ID with all prior contacts from their Repeater and/or create a pure ID with none. <!-- github-comment-id:4047696004 -->
Author
Owner

@rightup Agreed. I like it. I'd like to propose allowing users to have some additional control over what is imported. At a minimum, to select the types of nodes to be imported and the recency of the advert.

Update to add: As I starting thinking about implementation a bit more, I actually think we need:

  • Import maximum (only give me 500 entries)
  • Import type(s) (only give me repeaters + companions, or only room servers, etc.)
  • Import recency (adverted in the last x days)
@rightup Agreed. I like it. I'd like to propose allowing users to have some additional control over what is imported. At a minimum, to select the types of nodes to be imported and the recency of the advert. Update to add: As I starting thinking about implementation a bit more, I actually think we need: - Import maximum (only give me 500 entries) - Import type(s) (only give me repeaters + companions, or only room servers, etc.) - Import recency (adverted in the last x days) <!-- github-comment-id:4048398038 -->
Author
Owner
Image

Okay, I have it working. Going to test a bit more and then I'll send it over.

<img width="537" height="479" alt="Image" src="https://github.com/user-attachments/assets/35f42932-23b3-41f4-96e6-175b6be5f695" /> Okay, I have it working. Going to test a bit more and then I'll send it over. <!-- github-comment-id:4048816172 -->
Author
Owner

what's the companion max? @agessaman

"where we're going, there are no caps" ⚡️🚙🔥🔥🔥

what's the companion max? @agessaman "where we're going, there are no caps" ⚡️🚙🔥🔥🔥 <!-- github-comment-id:4048823575 -->
Author
Owner

Currently 1000, but that's really arbitrary. I didn't want to grow it until it choked the app.

Currently 1000, but that's really arbitrary. I didn't want to grow it until it choked the app. <!-- github-comment-id:4048834533 -->
Author
Owner

would 2500 be a bridge too far? Also arbitrary — without pushing the limits I'm not sure what the frontend (or back) can reasonably handle. So far lazy loading and careful management of dom elements has worked well enough

would 2500 be a bridge too far? Also arbitrary — without pushing the limits I'm not sure what the frontend (or back) can reasonably handle. So far lazy loading and careful management of dom elements has worked well enough <!-- github-comment-id:4048849698 -->
Author
Owner

We can try it. It is set by a constant in pyMC_core, but I can make a small modification to expose it through an optional config option in pyMC_repeater.

We can try it. It is set by a constant in pyMC_core, but I can make a small modification to expose it through an optional config option in pyMC_repeater. <!-- github-comment-id:4049094724 -->
Author
Owner

merged

merged <!-- github-comment-id:4054144443 -->
Sign in to join this conversation.
No labels
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: adam/pyMC_Repeater#58