feat(repeaters): My Repeaters panel (stage 1)

New full-screen panel (main menu / FAB) for repeater administration:
- repeaters table (saved per-pubkey admin password, login metadata);
  plaintext per observer_brokers precedent (single-user LAN app)
- REST /api/repeaters: list merged with device contact truth, add
  (device REP contacts only), set/clear password, remove, login
- device_manager: _repeater_lock serializes all repeater ops (companion
  firmware has a single pending-request slot); repeater_login now
  filters LOGIN_SUCCESS by pubkey_prefix and captures is_admin/
  permissions into an in-memory session store
- login timeout UX: wrong password and unreachable repeater are
  indistinguishable (firmware stays silent) - error message names both
- panel UI: add-picker, password modal (remembered password), per-
  repeater path editor reusing /api/contacts/<pk>/paths + Leaflet
  repeater map picker (adapted from the DM panel)
- meshcore pin bumped to >=2.3.7 (send_login_sync era API, fixed
  LOGIN_SUCCESS/LOGIN_FAILED parsing; container already ships 2.3.7)

Stage 2 (management panel with Status/Telemetry/Neighbors/CLI/
Settings/Actions tools) follows after user review.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
MarekWo
2026-07-17 22:55:44 +02:00
parent 87b30f63b3
commit ca2a6bacf9
13 changed files with 2004 additions and 79 deletions
+49
View File
@@ -715,6 +715,55 @@ class Database:
cursor = conn.execute("DELETE FROM observer_brokers WHERE id = ?", (broker_id,))
return cursor.rowcount > 0
# ================================================================
# My Repeaters (saved repeater logins for the admin panel)
# ================================================================
def get_repeaters(self) -> List[Dict]:
with self._connect() as conn:
rows = conn.execute(
"SELECT * FROM repeaters ORDER BY added_at"
).fetchall()
return [dict(r) for r in rows]
def get_repeater(self, public_key: str) -> Optional[Dict]:
with self._connect() as conn:
row = conn.execute(
"SELECT * FROM repeaters WHERE public_key = ?", (public_key,)
).fetchone()
return dict(row) if row else None
def add_repeater(self, public_key: str) -> bool:
"""Add a repeater entry. Returns False if it already exists."""
with self._connect() as conn:
cursor = conn.execute(
"INSERT OR IGNORE INTO repeaters (public_key) VALUES (?)",
(public_key,)
)
return cursor.rowcount > 0
def set_repeater_password(self, public_key: str, password: str) -> bool:
with self._connect() as conn:
cursor = conn.execute(
"UPDATE repeaters SET password = ? WHERE public_key = ?",
(password, public_key)
)
return cursor.rowcount > 0
def update_repeater_login(self, public_key: str, role: str) -> bool:
with self._connect() as conn:
cursor = conn.execute(
"UPDATE repeaters SET last_login_at = datetime('now'), "
"last_login_role = ? WHERE public_key = ?",
(role, public_key)
)
return cursor.rowcount > 0
def delete_repeater(self, public_key: str) -> bool:
with self._connect() as conn:
cursor = conn.execute("DELETE FROM repeaters WHERE public_key = ?", (public_key,))
return cursor.rowcount > 0
def set_channel_scope(self, channel_idx: int, region_id: Optional[int]) -> None:
"""Set or clear the region mapping for a channel.